diff --git a/assets/kimi.png b/assets/kimi.png new file mode 100644 index 0000000..2839282 Binary files /dev/null and b/assets/kimi.png differ diff --git a/cli/proxima-cli.cjs b/cli/proxima-cli.cjs index 1934cf0..9e673e4 100644 --- a/cli/proxima-cli.cjs +++ b/cli/proxima-cli.cjs @@ -119,7 +119,7 @@ function startSpinner(text) { const FALLBACK_INFO = { mode: 'session', - providers: ['chatgpt', 'claude', 'gemini', 'perplexity'], + providers: ['chatgpt', 'claude', 'gemini', 'perplexity', 'kimi'], models: {}, firstProvider: 'claude' }; diff --git a/electron/api/byok/keys.cjs b/electron/api/byok/keys.cjs index 3e61b3f..50071e7 100644 --- a/electron/api/byok/keys.cjs +++ b/electron/api/byok/keys.cjs @@ -11,7 +11,7 @@ const ENC_VERSION = 1; let _storeCache = null; const KNOWN_PROVIDERS = Object.freeze([ - 'chatgpt', 'claude', 'gemini', 'perplexity', + 'chatgpt', 'claude', 'gemini', 'perplexity', 'kimi', 'deepseek', 'groq', 'xai', 'openrouter', 'together', 'fireworks', 'mistral', 'nvidia', ]); diff --git a/electron/api/byok/model-fetcher.cjs b/electron/api/byok/model-fetcher.cjs index e28e93f..09244ed 100644 --- a/electron/api/byok/model-fetcher.cjs +++ b/electron/api/byok/model-fetcher.cjs @@ -25,6 +25,10 @@ const MODELS_CONFIG = Object.freeze({ 'sonar-deep-research', 'r1-1776', ], }, + kimi: { + url: 'https://api.moonshot.cn/v1/models', + auth: 'bearer', + }, deepseek: { url: 'https://api.deepseek.com/v1/models', auth: 'bearer', diff --git a/electron/api/byok/models.cjs b/electron/api/byok/models.cjs index 73461b2..3e4f481 100644 --- a/electron/api/byok/models.cjs +++ b/electron/api/byok/models.cjs @@ -8,6 +8,7 @@ const FALLBACK_MODELS = Object.freeze({ claude: ['claude-sonnet-5', 'claude-3-5-sonnet-latest', 'claude-3-haiku-20240307'], gemini: ['gemini-3.5-flash', 'gemini-3.1-pro', 'gemini-2.0-flash-lite'], perplexity: ['sonar-pro', 'sonar'], + kimi: ['moonshot-v1-32k', 'moonshot-v1-8k'], deepseek: ['deepseek-chat'], groq: ['llama-3.3-70b-versatile', 'llama-3.1-8b-instant'], xai: ['grok-2-1212', 'grok-beta'], @@ -23,6 +24,7 @@ const DEFAULT_MODELS = Object.freeze({ claude: FALLBACK_MODELS.claude[0], gemini: FALLBACK_MODELS.gemini[0], perplexity: FALLBACK_MODELS.perplexity[0], + kimi: FALLBACK_MODELS.kimi[0], deepseek: FALLBACK_MODELS.deepseek[0], groq: FALLBACK_MODELS.groq[0], xai: FALLBACK_MODELS.xai[0], @@ -44,6 +46,7 @@ const API_ENDPOINTS = Object.freeze({ claude: 'https://api.anthropic.com/v1/messages', google: 'https://generativelanguage.googleapis.com/v1beta/models', perplexity: 'https://api.perplexity.ai/chat/completions', + kimi: 'https://api.moonshot.cn/v1/chat/completions', deepseek: 'https://api.deepseek.com/v1/chat/completions', groq: 'https://api.groq.com/openai/v1/chat/completions', xai: 'https://api.x.ai/v1/chat/completions', @@ -61,6 +64,7 @@ const MAX_TOKENS = Object.freeze({ claude: 4096, gemini: 8192, perplexity: 4096, + kimi: 8192, deepseek: 4096, groq: 4096, xai: 4096, diff --git a/electron/api/byok/router.cjs b/electron/api/byok/router.cjs index 2883a35..4655d1d 100644 --- a/electron/api/byok/router.cjs +++ b/electron/api/byok/router.cjs @@ -16,6 +16,7 @@ const CALLERS = Object.freeze({ claude: anthropic, gemini: google, perplexity: perplexity, + kimi: compatible, deepseek: compatible, groq: compatible, xai: compatible, diff --git a/electron/api/pages/widget.cjs b/electron/api/pages/widget.cjs index e44a472..23d6f9d 100644 --- a/electron/api/pages/widget.cjs +++ b/electron/api/pages/widget.cjs @@ -74,7 +74,7 @@ function getChatJS() { } catch (e) { console.warn('[Widget] Could not fetch models, using defaults:', e.message); _gatewayMode = 'session'; - _enabledProviders = ['chatgpt', 'claude', 'gemini', 'perplexity']; + _enabledProviders = ['chatgpt', 'claude', 'gemini', 'perplexity', 'kimi']; } populateDropdown(); populateBattle(); diff --git a/electron/api/rest-api.cjs b/electron/api/rest-api.cjs index 94c7b8d..3b429d6 100644 --- a/electron/api/rest-api.cjs +++ b/electron/api/rest-api.cjs @@ -29,6 +29,7 @@ const MODEL_ALIASES = { 'gemini': 'gemini', 'gemini-pro': 'gemini', 'gemini-2': 'gemini', 'gemini-2.5': 'gemini', 'google': 'gemini', 'bard': 'gemini', 'perplexity': 'perplexity', 'pplx': 'perplexity', 'sonar': 'perplexity', + 'kimi': 'kimi', 'moonshot': 'kimi', 'moonshotai': 'kimi', 'auto': 'auto', 'all': 'all' }; @@ -342,7 +343,7 @@ function pickBestProvider(preferred) { return enabled.includes(base) ? base : null; } - const priorityList = ['claude', 'chatgpt', 'gemini', 'perplexity', 'deepseek', 'groq', 'xai', 'openrouter', 'together', 'fireworks', 'mistral', 'nvidia']; + const priorityList = ['claude', 'chatgpt', 'gemini', 'perplexity', 'kimi', 'deepseek', 'groq', 'xai', 'openrouter', 'together', 'fireworks', 'mistral', 'nvidia']; const priority = priorityList.filter(p => enabled.includes(p)); if (priority.length === 0) { return enabled.length > 0 ? enabled[0] : null; @@ -566,7 +567,7 @@ function startRestAPI() { } const url = new URL(req.url, `http://localhost:${REST_PORT}`); - const publicPaths = ['/', '/docs', '/cli', '/ws', '/websocket', '/openapi.json', '/v1/openapi.json', '/api-key', '/v1/byok/models', '/v1/models', '/v1/stats', '/v1/functions']; + const publicPaths = ['/', '/docs', '/cli', '/ws', '/websocket', '/openapi.json', '/v1/openapi.json', '/api-key', '/v1/byok/models', '/v1/models', '/v1/stats', '/v1/functions', '/v1/kimi/session']; if (!publicPaths.includes(url.pathname) && !validateApiKey(req)) { return sendError(res, 401, 'Invalid or missing API key', 'authentication_error'); } @@ -574,7 +575,7 @@ function startRestAPI() { const body = req.method === 'POST' ? await parseBody(req) : {}; const providerKey = (req.headers['x-provider-key'] || '').trim(); if (providerKey) body._byokKey = providerKey; - await handleRoute(req.method, url.pathname, body, res); + await handleRoute(req.method, url.pathname, body, res, req.headers, url.searchParams); } catch (err) { console.error('[API] Error:', err.message); if (!res.headersSent) sendError(res, err.statusCode || 500, err.message); } }); diff --git a/electron/api/routes.cjs b/electron/api/routes.cjs index 5fe239c..674f297 100644 --- a/electron/api/routes.cjs +++ b/electron/api/routes.cjs @@ -15,9 +15,11 @@ function createRouteHandler(deps) { byok, } = deps; - return async function handleRoute(method, pathname, body, res) { + return async function handleRoute(method, pathname, body, res, headers, searchParams) { const handleMCPRequest = getHandler(); - const conversationId = (body && (body.conversationId || body.conversation_id || body.sessionId || body.session_id)) || null; + const conversationId = (body && (body.conversationId || body.conversation_id || body.sessionId || body.session_id)) + || (headers && (headers['x-proxima-conversation'] || headers['X-Proxima-Conversation'])) + || null; if (method === 'POST' && pathname === `${API_PREFIX}/chat/completions`) { const fn = (body.function || '').toLowerCase().trim(); @@ -253,10 +255,17 @@ function createRouteHandler(deps) { else sendJSON(res, 200, formatChatResponse(result, provider)); } } else { - const toolPrompt = buildToolCallingPrompt(body); - if (!toolPrompt) return sendError(res, 400, 'No message provided'); + // Kimi's engine needs the raw messages array (it reads the + // system prompt to detect a NEW chat and only forwards the + // latest user turn on follow-ups). Flattening to a string + // via buildToolCallingPrompt would lose the system prompt + // and make the engine reuse a stale/too-long chat_id. + const inputForEngine = provider === 'kimi' + ? (Array.isArray(body.messages) ? body.messages : message) + : buildToolCallingPrompt(body); + if (!inputForEngine) return sendError(res, 400, 'No message provided'); if (TOOL_DEBUG) console.log('[TOOL-CALL/Legacy] Provider:', provider, '| Tools:', body.tools.length); - const result = await queryProvider(provider, toolPrompt, filePath, gemini, null, conversationId, body._byokKey, null, resolved.byokModelId); + const result = await queryProvider(provider, inputForEngine, filePath, gemini, null, conversationId, body._byokKey, null, resolved.byokModelId); if (TOOL_DEBUG) console.log('[TOOL-CALL/Legacy] Response text (first 300):', JSON.stringify(result.text?.slice(0, 300))); const parsed = parseToolCallResponse(result.text); if (TOOL_DEBUG) console.log('[TOOL-CALL/Legacy] Parsed:', parsed.isToolCall, '| toolCalls:', parsed.toolCalls?.length || 0); @@ -283,7 +292,12 @@ function createRouteHandler(deps) { if (resolved.mode === 'single') { const provider = resolved.providers[0]; const isByokActive = !!(body._byokKey || (byok.keys.isEnabled() ? byok.keys.getKey(provider) : null)); - const inputMessage = isByokActive ? (body.messages || message) : message; + // Kimi's engine needs the full messages array so it can send the + // system prompt + context once at chat start and only the new + // user turn afterwards (parent_id chain carries the rest). + const inputMessage = isByokActive + ? (body.messages || message) + : (provider === 'kimi' && Array.isArray(body.messages) ? body.messages : message); let onChunk = null; let streamedAny = false; @@ -373,7 +387,7 @@ function createRouteHandler(deps) { aliases: Object.entries(MODEL_ALIASES).filter(([_, v]) => v === p).map(([k]) => k).filter(k => k !== p), }); } - ['chatgpt', 'claude', 'gemini', 'perplexity'].filter(p => !enabled.includes(p)).forEach(p => + ['chatgpt', 'claude', 'gemini', 'perplexity', 'kimi'].filter(p => !enabled.includes(p)).forEach(p => models.push({ id: p, object: 'model', owned_by: 'proxima', status: 'disabled' }) ); } @@ -395,7 +409,7 @@ function createRouteHandler(deps) { function: null, name: 'chat', description: 'Default chat completion. Omit the "function" field to chat with a model.', - fields: { model: 'auto|claude|chatgpt|gemini|perplexity|all (or array of models)', message: 'string (or use messages[])', stream: 'boolean (optional)', filePath: 'local file path for multimodal/Gemini routing (optional)' } + fields: { model: 'auto|claude|chatgpt|gemini|perplexity|kimi|all (or array of models)', message: 'string (or use messages[])', stream: 'boolean (optional)', filePath: 'local file path for multimodal/Gemini routing (optional)' } }, { function: 'search', @@ -472,7 +486,7 @@ function createRouteHandler(deps) { if (method === 'POST' && pathname === `${API_PREFIX}/conversations/new`) { const requested = (body && (body.provider || body.model)) || null; if (!requested) { - return sendError(res, 400, 'A "provider" (or "model") is required: chatgpt, claude, gemini, or perplexity.'); + return sendError(res, 400, 'A "provider" (or "model") is required: chatgpt, claude, gemini, perplexity, or kimi.'); } const target = pickBestProvider(requested); if (!target) { @@ -482,6 +496,20 @@ function createRouteHandler(deps) { catch (e) { return sendError(res, 500, e.message); } } + if (method === 'GET' && pathname === `${API_PREFIX}/kimi/session`) { + // Session-status endpoint for the opencode plugin. The plugin polls this + // before each request; when resetCount changes, it knows Kimi rolled to + // a NEW chat_id and must re-send the system prompt + handoff summary. + const cid = (searchParams && (searchParams.get('conversationId') || searchParams.get('conversation_id') || searchParams.get('sessionId'))) + || (headers && headers['x-proxima-conversation']) + || null; + try { + const r = await handleMCPRequest({ action: 'getKimiSessionStatus', provider: 'kimi', data: { conversationId: cid } }); + if (!r || r.success === false) return sendJSON(res, 200, { success: false, resetCount: 0, chatId: null, parentId: null, error: (r && r.error) || 'unavailable' }); + return sendJSON(res, 200, { success: true, resetCount: r.resetCount || 0, chatId: r.chatId || null, parentId: r.parentId || null }); + } catch (e) { return sendJSON(res, 200, { success: false, resetCount: 0, chatId: null, parentId: null, error: e.message }); } + } + if (method === 'GET' && (pathname === `${API_PREFIX}/openapi.json` || pathname === '/openapi.json')) { try { return sendJSON(res, 200, JSON.parse(require('fs').readFileSync(require('path').join(__dirname, '..', '..', 'docs', 'openapi.json'), 'utf8'))); } catch (e) { return sendError(res, 500, 'OpenAPI spec not found'); } diff --git a/electron/api/ws-server.cjs b/electron/api/ws-server.cjs index cf0156e..4b0fb9a 100644 --- a/electron/api/ws-server.cjs +++ b/electron/api/ws-server.cjs @@ -154,7 +154,7 @@ function closeWebSocket() { } } -async function queryProvider(provider, message, filePath = null, engine = null, conversationId = null, modelId = null) { +async function queryProvider(provider, message, filePath = null, engine = null, conversationId = null, modelId = null, think = true) { const byokKey = byok.keys.isEnabled() ? byok.keys.getKey(provider) : null; if (byokKey) { const messages = [{ role: 'user', content: message }]; @@ -167,7 +167,7 @@ async function queryProvider(provider, message, filePath = null, engine = null, const sendResult = await handleMCPRequest({ action: 'sendMessage', provider, - data: { message, filePath, engine, conversationId } + data: { message, filePath, engine, conversationId, think } }); if (!sendResult.success) { @@ -199,7 +199,7 @@ function pickBestProvider(preferred) { if (found) return found; return null; } - const priorityList = ['claude', 'chatgpt', 'gemini', 'perplexity', 'deepseek', 'groq', 'xai', 'openrouter', 'together', 'fireworks', 'mistral', 'nvidia']; + const priorityList = ['claude', 'chatgpt', 'gemini', 'perplexity', 'kimi', 'deepseek', 'groq', 'xai', 'openrouter', 'together', 'fireworks', 'mistral', 'nvidia']; const priority = priorityList.find(p => enabled.includes(p)); if (priority) return priority; return enabled.length > 0 ? enabled[0] : null; @@ -265,9 +265,10 @@ async function handleWSMessage(ws, clientId, msg) { sendJSON(ws, { type: 'status', id: requestId, status: 'processing', model: provider, timestamp: new Date().toISOString() }); const conversationId = msg.conversationId || msg.conversation_id || msg.sessionId || msg.session_id || null; + const think = msg.think !== false; const startTime = Date.now(); try { - const content = await queryProvider(provider, message, filePath, engine, conversationId, byokModelId); + const content = await queryProvider(provider, message, filePath, engine, conversationId, byokModelId, think); const responseTimeMs = Date.now() - startTime; sendJSON(ws, { @@ -546,7 +547,7 @@ async function handleWSMessage(ws, clientId, msg) { case 'reset': { const requested = msg.model || null; if (!requested) { - sendJSON(ws, { type: 'error', id: requestId, error: 'A "model" (provider) is required to reset: chatgpt, claude, gemini, or perplexity.', timestamp: new Date().toISOString() }); + sendJSON(ws, { type: 'error', id: requestId, error: 'A "model" (provider) is required to reset: chatgpt, claude, gemini, perplexity, or kimi.', timestamp: new Date().toISOString() }); break; } const target = pickBestProvider(requested); diff --git a/electron/browser-manager.cjs b/electron/browser-manager.cjs index ec228c6..4ff59c9 100644 --- a/electron/browser-manager.cjs +++ b/electron/browser-manager.cjs @@ -32,6 +32,11 @@ class BrowserManager { url: 'https://gemini.google.com/app', partition: 'persist:gemini', color: '#4285f4' + }, + kimi: { + url: 'https://www.kimi.com/', + partition: 'persist:kimi', + color: '#6b4fbb' } }; @@ -398,7 +403,7 @@ class BrowserManager { }; view.webContents.on('will-navigate', (event, url) => { - if (isAuthUrl(url)) { + if (isAuthUrl(url) && provider !== 'kimi') { console.log(`[${provider}] Intercepting navigation to auth URL:`, url.substring(0, 80)); event.preventDefault(); this.openAuthPopup(provider, url); @@ -406,7 +411,7 @@ class BrowserManager { }); view.webContents.on('will-redirect', (event, url) => { - if (isAuthUrl(url)) { + if (isAuthUrl(url) && provider !== 'kimi') { console.log(`[${provider}] Intercepting redirect to auth URL:`, url.substring(0, 80)); event.preventDefault(); this.openAuthPopup(provider, url); @@ -434,6 +439,26 @@ class BrowserManager { view.webContents.setWindowOpenHandler(({ url, frameName, features }) => { console.log(`[${provider}] Popup requested:`, url.substring(0, 80)); + // Kimi's login is an in-page modal; its Google OAuth must stay in the + // same Chromium session (not be routed through the Firefox-UA auth + // window), otherwise the OAuth callback breaks. Allow all popups for + // Kimi with the normal Chromium UA/session. + if (provider === 'kimi') { + return { + action: 'allow', + overrideBrowserWindowOptions: { + width: 600, + height: 700, + webPreferences: { + session: ses, + sandbox: true, + contextIsolation: true, + nodeIntegration: false, + } + } + }; + } + const lowerUrl = url.toLowerCase(); const isAuthPopup = lowerUrl.includes('accounts.google.com') || @@ -608,7 +633,8 @@ class BrowserManager { perplexity: 'perplexity.ai', chatgpt: 'chatgpt.com', claude: 'claude.ai', - gemini: 'gemini.google.com' + gemini: 'gemini.google.com', + kimi: 'kimi.com' }; const domain = providerDomains[provider]; @@ -801,6 +827,38 @@ class BrowserManager { return hasInput && !hasSignIn; })() `); + case 'kimi': + // kimi.com renders a chat editor even when logged out, so DOM + // checks are unreliable. Kimi's web session is backed by a JWT + // in localStorage plus an HttpOnly cookie. Login = a valid + // access token is present in storage AND the subscription + // endpoint answers 2xx when called with that token. + return await webContents.executeJavaScript(` + (async function() { + function isJwt(v) { + return typeof v === 'string' && /^eyJ[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+$/.test(v.trim()); + } + var token = null; + try { + var at = localStorage.getItem('access_token'); + if (isJwt(at)) token = at.trim(); + } catch (e) { } + try { + var deviceId = localStorage.getItem('proxima_kimi_device_id') || '7' + String(Math.floor(Math.random() * 9e17)).padStart(18, '0'); + var sessionId = localStorage.getItem('proxima_kimi_session_id') || '17' + String(Math.floor(Math.random() * 9e17)).padStart(17, '0'); + var headers = { 'Content-Type': 'application/json', 'Connect-Protocol-Version': '1', 'Origin': 'https://www.kimi.com', 'X-Msh-Platform': 'web', 'X-Msh-Device-Id': deviceId, 'X-Msh-Session-Id': sessionId }; + if (token) headers['Authorization'] = 'Bearer ' + token; + var res = await fetch('/apiv2/kimi.gateway.order.v1.SubscriptionService/GetSubscription', { + method: 'POST', credentials: 'include', headers: headers, body: '{}' + }); + if (res.status === 401 || res.status === 403) return false; + if (res.ok) return true; + return false; + } catch (e) { + return !!token; + } + })() + `); default: return false; } diff --git a/electron/index-v2.html b/electron/index-v2.html index afc2123..c18724a 100644 --- a/electron/index-v2.html +++ b/electron/index-v2.html @@ -154,6 +154,10 @@ background: linear-gradient(135deg, #4285f4, #8ab4f8); } + .tab-icon.kimi { + background: linear-gradient(135deg, #6b4fbb, #a78bfa); + } + .tab-icon.settings { background: transparent; border: none; @@ -1328,6 +1332,11 @@ Gemini
+
+ Kimi + Kimi +
+
Active Providers
-
0 / 4
+
0 / 5
@@ -1668,6 +1677,28 @@

Account: Session + + +
+
+
+ + Kimi +
+
+
+
+
Status: Checking...
+
Latency: --ms
+
Model: Auto
+
Account: Session
+
+
@@ -2456,7 +2487,7 @@

Platform Stats:
- 4 Providers • MCP Enabled • REST Server + 5 Providers • MCP Enabled • REST Server
@@ -2570,7 +2601,8 @@