diff --git a/media/chat.js b/media/chat.js index 1fb8d3f..9a6460c 100644 --- a/media/chat.js +++ b/media/chat.js @@ -20,24 +20,82 @@ var modelBtn = document.getElementById("modelBtn"); var modelDrop = document.getElementById("modelDrop"); var _modelOpen = false; - var MODELS = [ - { value: "deepseek-v4-pro", name: "deepseek-v4-pro", desc: "DeepSeek V4 Pro — 旗舰推理模型" }, - { value: "deepseek-v4-flash", name: "deepseek-v4-flash", desc: "DeepSeek V4 Flash — 快速轻量模型" }, - { value: "deepseek-reasoner", name: "deepseek-reasoner", desc: "DeepSeek Reasoner — 深度思维链模型" }, - ]; + var PROVIDER_MODELS = { + deepseek: [ + { value: "deepseek-v4-pro", name: "deepseek-v4-pro", desc: "Flagship — best quality" }, + { value: "deepseek-v4-flash", name: "deepseek-v4-flash", desc: "Fast & cheap" }, + { value: "deepseek-reasoner", name: "deepseek-reasoner", desc: "Deep chain-of-thought" }, + ], + openai: [ + { value: "gpt-4o", name: "gpt-4o", desc: "Flagship multimodal" }, + { value: "gpt-4o-mini", name: "gpt-4o-mini", desc: "Fast & affordable" }, + { value: "gpt-4.1", name: "gpt-4.1", desc: "Latest GPT-4.1" }, + { value: "gpt-4.1-mini", name: "gpt-4.1-mini", desc: "Fast GPT-4.1" }, + { value: "o3", name: "o3", desc: "Advanced reasoning" }, + ], + groq: [ + { value: "llama-3.3-70b-versatile", name: "llama-3.3-70b", desc: "Fast Llama 3.3 70B" }, + { value: "llama-3.1-8b-instant", name: "llama-3.1-8b", desc: "Ultra-fast 8B" }, + { value: "gemma2-9b-it", name: "gemma2-9b", desc: "Google Gemma 2 9B" }, + { value: "mixtral-8x7b-32768", name: "mixtral-8x7b", desc: "Mixtral 8x7B 32K ctx" }, + ], + ollama: [], + gemini: [ + { value: "gemini-2.0-flash", name: "gemini-2.0-flash", desc: "Fast & capable" }, + { value: "gemini-2.5-flash", name: "gemini-2.5-flash", desc: "Latest Gemini Flash" }, + { value: "gemini-1.5-pro", name: "gemini-1.5-pro", desc: "2M token context" }, + ], + custom: [], + }; + var MODELS = PROVIDER_MODELS.deepseek; + var _currentProvider = 'deepseek'; + function setModelUI(model){ - var md = MODELS.find(function(x){ return x.value === model; }) || MODELS[0]; + var md = MODELS.find(function(x){ return x.value === model; }); + if (!md) md = { value: model, name: model, desc: '' }; if (modelPicker) modelPicker.dataset.model = md.value; - if (modelBtn) modelBtn.innerHTML = md.name + " \u25BE"; + if (modelBtn) { + modelBtn.textContent = ''; + modelBtn.appendChild(document.createTextNode(String(md.name))); + modelBtn.appendChild(document.createTextNode(' ')); + var chev = document.createElement('span'); + chev.className = 'mode-chev'; + chev.textContent = '\u25BE'; + modelBtn.appendChild(chev); + } if (modelDrop){ var opts = modelDrop.querySelectorAll(".mo"); for (var i=0;i" + + "Press Enter to confirm"; + modelDrop.innerHTML = h; + modelDrop.style.display = 'block'; + _modelOpen = true; + var inp = document.getElementById('ollama-model-input'); + if (inp) { + inp.focus(); inp.select(); + inp.addEventListener('keydown', function(e){ + if (e.key === 'Enter') { + var val = inp.value.trim(); + if (val) { setModelUI(val); vscode.postMessage({ type: 'setModel', model: val }); } + closeModelDrop(); + } else if (e.key === 'Escape') { closeModelDrop(); } + }); + } + return; + } + h = "Switch Model"; for (var i=0;i" + @@ -53,6 +111,53 @@ modelDrop.style.display = "none"; _modelOpen = false; } + function switchToProvider(provider){ + _currentProvider = provider; + MODELS = PROVIDER_MODELS[provider] || []; + if (MODELS.length) { + var defaultModel = MODELS[0].value; + setModelUI(defaultModel); + } else { + if (modelPicker) modelPicker.dataset.model = ''; + if (modelBtn) modelBtn.innerHTML = "custom model ▾"; + } + if (stgDsKey) { + if (provider === 'ollama') { + stgDsKey.placeholder = 'No API key required'; + stgDsKey.style.opacity = '0.4'; + stgDsKey.disabled = true; + } else { + stgDsKey.disabled = false; + stgDsKey.style.opacity = ''; + stgDsKey.placeholder = _stgDsKeySet ? '(configured)' : 'sk-...'; + } + } + if (stgDsLink) { + var linkInfo = PROVIDER_KEY_LINKS[provider]; + if (linkInfo) { stgDsLink.textContent = linkInfo.label; stgDsLink.style.display = ''; } + else { stgDsLink.style.display = 'none'; } + } + if (stgBaseReset) { + var resetUrl = PROVIDER_URLS[provider] || ''; + stgBaseReset.onclick = function(){ if (stgBaseUrl) { stgBaseUrl.value = resetUrl; _stgUpdateDirtyBar(); } }; + } + } + /* ── Interaction Mode toggle (Agent ↔ Plan) ── */ + var iModePicker = document.getElementById("iModePicker"); + var iModeBtn = document.getElementById("iModeBtn"); + var _curIMode = "agent"; // tracks current interaction mode for ft-mode display + var INTERACTION_MODES = [ + { value: "agent", icon: "tools", name: "Agent", desc: "自主调用工具完成任务(文件读写、Shell、搜索等)" }, + { value: "plan", icon: "list-ordered", name: "Plan", desc: "只探索代码库、生成可审阅的计划,不执行任何写操作" }, + ]; + function setIModeUI(mode){ + var md = INTERACTION_MODES.find(function(x){ return x.value === mode; }) || INTERACTION_MODES[0]; + _curIMode = md.value; + if (iModePicker) iModePicker.dataset.im = mode; + if (iModeBtn) iModeBtn.innerHTML = " " + md.name; + } + + /* ── Approval Mode picker (Manual / Auto-Edit / Autopilot / Read-Only) ── */ var modePicker = document.getElementById("modePicker"); var modeBtn = document.getElementById("modeBtn"); var modeDrop = document.getElementById("modeDrop"); @@ -1693,6 +1798,12 @@ vscode.postMessage({type:"setModel", model: model}); closeModelDrop(); }); + iModeBtn && iModeBtn.addEventListener("click", function(e){ + e.stopPropagation(); + var next = _curIMode === "agent" ? "plan" : "agent"; + setIModeUI(next); + vscode.postMessage({type:"setInteractionMode", mode: next}); + }); modeBtn && modeBtn.addEventListener("click", function(e){ e.stopPropagation(); _modeOpen ? closeModeDrop() : openModeDrop(); @@ -1712,8 +1823,26 @@ var _stgOpen = false; var _stgDsKeySet = false, _stgTvKeySet = false; var _stgOrigBaseUrl = ''; + var _stgOrigProvider = 'deepseek'; + var PROVIDER_KEY_LINKS = { + deepseek: { url: 'https://platform.deepseek.com/api_keys', label: '↗ platform.deepseek.com/api_keys' }, + openai: { url: 'https://platform.openai.com/api-keys', label: '↗ platform.openai.com/api-keys' }, + groq: { url: 'https://console.groq.com/keys', label: '↗ console.groq.com/keys' }, + ollama: null, + gemini: { url: 'https://aistudio.google.com/app/apikey', label: '↗ aistudio.google.com/app/apikey' }, + custom: null, + }; + var PROVIDER_URLS = { + deepseek: 'https://api.deepseek.com', + openai: 'https://api.openai.com/v1', + groq: 'https://api.groq.com/openai/v1', + ollama: 'http://localhost:11434/v1', + gemini: 'https://generativelanguage.googleapis.com/v1beta/openai/', + custom: '', + }; var _stgTestTimers = {}; + var stgProvider = document.getElementById('s-provider'); var stgOverlay = document.getElementById('settings-overlay'); var stgDsKey = document.getElementById('s-ds-key'); var stgDsEye = document.getElementById('s-ds-key-eye'); @@ -1736,6 +1865,7 @@ function _stgIsDirty(){ return (stgDsKey && stgDsKey.value !== '') || (stgTvKey && stgTvKey.value !== '') || + (stgProvider && stgProvider.value !== _stgOrigProvider) || (stgBaseUrl && stgBaseUrl.value !== _stgOrigBaseUrl); } function _stgUpdateDirtyBar(){ @@ -1760,7 +1890,9 @@ if (stgDsKey) stgDsKey.value = ''; if (stgTvKey) stgTvKey.value = ''; if (stgBaseUrl) stgBaseUrl.value = ''; + if (stgProvider) stgProvider.value = 'deepseek'; _stgOrigBaseUrl = ''; + _stgOrigProvider = 'deepseek'; _stgDsKeySet = false; _stgTvKeySet = false; if (stgDirtyBar) stgDirtyBar.style.display = 'none'; _stgSetResult(stgDsResult, 'ds', '', ''); @@ -1791,12 +1923,20 @@ stgDiscard && stgDiscard.addEventListener('click', function(){ if (stgDsKey) stgDsKey.value = ''; if (stgTvKey) stgTvKey.value = ''; + if (stgProvider) stgProvider.value = _stgOrigProvider; if (stgBaseUrl) stgBaseUrl.value = _stgOrigBaseUrl; closeSettingsModal(true); }); stgDsKey && stgDsKey.addEventListener('input', _stgUpdateDirtyBar); stgTvKey && stgTvKey.addEventListener('input', _stgUpdateDirtyBar); stgBaseUrl && stgBaseUrl.addEventListener('input', _stgUpdateDirtyBar); + stgProvider && stgProvider.addEventListener('change', function(){ + var prov = stgProvider.value; + var preset = PROVIDER_URLS[prov]; + if (preset !== undefined && stgBaseUrl) stgBaseUrl.value = preset; + switchToProvider(prov); + _stgUpdateDirtyBar(); + }); stgDsEye && stgDsEye.addEventListener('click', function(){ if (!stgDsKey) return; stgDsKey.type = stgDsKey.type === 'password' ? 'text' : 'password'; @@ -1810,7 +1950,8 @@ }); stgDsLink && stgDsLink.addEventListener('click', function(e){ e.preventDefault(); - vscode.postMessage({ type: 'openExternal', url: 'https://platform.deepseek.com/api_keys' }); + var linkInfo = PROVIDER_KEY_LINKS[_stgOrigProvider]; + if (linkInfo) vscode.postMessage({ type: 'openExternal', url: linkInfo.url }); }); stgTvLink && stgTvLink.addEventListener('click', function(e){ e.preventDefault(); @@ -1821,7 +1962,8 @@ var url = stgBaseUrl ? stgBaseUrl.value.trim() : ''; _stgSetResult(stgDsResult, 'ds', 'pending', '⟳ Testing...'); if (stgDsTest) stgDsTest.disabled = true; - vscode.postMessage({ type: 'testApiKey', which: 'ds', key: key || null, baseUrl: url || null }); + var prov = stgProvider ? stgProvider.value : 'deepseek'; + vscode.postMessage({ type: 'testApiKey', which: 'ds', key: key || null, baseUrl: url || null, provider: prov }); }); stgTvTest && stgTvTest.addEventListener('click', function(){ var key = stgTvKey ? stgTvKey.value.trim() : ''; @@ -1830,14 +1972,20 @@ vscode.postMessage({ type: 'testApiKey', which: 'tv', key: key || null }); }); stgSaveBtn && stgSaveBtn.addEventListener('click', function(){ - var dsKey = stgDsKey ? stgDsKey.value.trim() : null; - var tvKey = stgTvKey ? stgTvKey.value.trim() : null; - var baseUrl = stgBaseUrl ? stgBaseUrl.value.trim() : null; + var dsKey = stgDsKey ? stgDsKey.value.trim() : null; + var tvKey = stgTvKey ? stgTvKey.value.trim() : null; + var provider = stgProvider ? stgProvider.value : _stgOrigProvider; + var rawUrl = stgBaseUrl ? stgBaseUrl.value.trim() : ''; + // Don't persist preset URLs — save empty so the backend always resolves + // the correct URL from the provider preset, avoiding stale URL overrides. + var presetUrl = PROVIDER_URLS[provider] || ''; + var baseUrl = (rawUrl && rawUrl !== presetUrl) ? rawUrl : ''; vscode.postMessage({ - type: 'saveApiSettings', - dsKey: dsKey || null, - tvKey: tvKey || null, - baseUrl: baseUrl !== null ? baseUrl : _stgOrigBaseUrl, + type: 'saveApiSettings', + dsKey: dsKey || null, + tvKey: tvKey || null, + provider: provider, + baseUrl: baseUrl, }); closeSettingsModal(true); }); @@ -1854,7 +2002,10 @@ if (m.type === "settingsLoaded"){ _stgDsKeySet = !!m.dsKeySet; _stgTvKeySet = !!m.tvKeySet; - _stgOrigBaseUrl = m.baseUrl || 'https://api.deepseek.com'; + _stgOrigProvider = m.provider || 'deepseek'; + _stgOrigBaseUrl = m.baseUrl || PROVIDER_URLS[_stgOrigProvider] || 'https://api.deepseek.com'; + if (stgProvider) stgProvider.value = _stgOrigProvider; + switchToProvider(_stgOrigProvider); if (stgDsKey) { stgDsKey.value = ''; stgDsKey.placeholder = _stgDsKeySet ? (m.dsKeyHint || '(configured)') : 'sk-...'; } if (stgTvKey) { stgTvKey.value = ''; stgTvKey.placeholder = _stgTvKeySet ? (m.tvKeyHint || '(configured)') : 'tvly-...'; } if (stgBaseUrl) stgBaseUrl.value = _stgOrigBaseUrl; @@ -2193,9 +2344,16 @@ if (!m.running) sb.textContent = "⚠ 后端服务器未启动 — 发送时将自动启动"; dot.className = "dot" + (m.running ? "" : " err"); } else if (m.type === "modelInfo"){ + if (m.provider && m.provider !== _currentProvider) { switchToProvider(m.provider); } if (m.model){ setModelUI(m.model); - ftMode.textContent = "agent · " + m.model; + ftMode.textContent = _curIMode + " · " + m.model; + } + if (m.interactionMode){ + setIModeUI(m.interactionMode); + // refresh footer with updated interaction mode + var _curModel = (modelPicker && modelPicker.dataset.model) || "deepseek-v4-pro"; + ftMode.textContent = m.interactionMode + " · " + _curModel; } if (m.approvalMode){ setModeUI(m.approvalMode); } } else if (m.type === "balanceUpdate"){ diff --git a/package-lock.json b/package-lock.json index 904f8df..c3bf192 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,15 @@ { "name": "deep-copilot", - "version": "0.33.5", + "version": "0.35.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "deep-copilot", - "version": "0.33.5", + "version": "0.35.0", + "dependencies": { + "openai": "^6.38.0" + }, "devDependencies": { "@types/node": "^20.0.0", "@types/vscode": "^1.95.0", @@ -3676,6 +3679,27 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/openai": { + "version": "6.38.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.38.0.tgz", + "integrity": "sha512-AoMplt2UalrpgUDMh3L09QWjNRlgJPipclQvA6sYAaeF6nHNBMgmikAZGmcYLn8on4d9sQY9Q8bOLfrBS7Lc8g==", + "license": "Apache-2.0", + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", diff --git a/package.json b/package.json index d9bfe05..aa7016e 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "deep-copilot", "displayName": "Deep Copilot", "description": "Deep Copilot — AI coding agent embedded in VS Code. Powered by DeepSeek V4, with file editing, terminal, search, and plan/todos. Standalone, no backend required.", - "version": "0.33.5", + "version": "0.35.0", "publisher": "ZhouChaunge", "icon": "imgs/logo.png", "repository": { @@ -142,10 +142,24 @@ "default": 8787, "description": "API server port (unused in standalone mode)." }, + "deepseekAgent.provider": { + "type": "string", + "default": "deepseek", + "enum": ["deepseek", "openai", "groq", "ollama", "gemini", "custom"], + "enumDescriptions": [ + "DeepSeek — api.deepseek.com", + "OpenAI — api.openai.com/v1", + "Groq — api.groq.com/openai/v1 (fast Llama / Mixtral)", + "Ollama — localhost:11434/v1 (local, no API key required)", + "Google Gemini — generativelanguage.googleapis.com (OpenAI-compat endpoint)", + "Custom — use the Base URL set in the API settings panel" + ], + "description": "AI provider. Selecting a preset auto-fills the Base URL; use Custom to enter your own." + }, "deepseekAgent.apiBaseUrl": { "type": "string", "default": "", - "description": "DeepSeek API Base URL. Leave empty to use https://api.deepseek.com. China users: https://api.deepseeki.com" + "description": "Override the provider Base URL. Leave empty to use the preset for the selected provider." }, "deepseekAgent.defaultModel": { "type": "string", @@ -247,15 +261,13 @@ "default": "agent", "enum": [ "agent", - "plan", - "ask" + "plan" ], "enumDescriptions": [ "Agent — model can autonomously call tools (file reads, edits, shell, etc.). Recommended for coding tasks.", - "Plan — model can only use read-only tools to explore and produce a plan. All write/shell tools are blocked. Switch to Agent mode to execute the plan.", - "Ask — pure conversational mode. Tools are NEVER exposed to the model. Use for Q&A or when you don't want any workspace exploration." + "Plan — model can only use read-only tools to explore and produce a plan. All write/shell tools are blocked. Switch to Agent mode to execute the plan." ], - "description": "Interaction mode (analogous to GitHub Copilot's Ask / Plan / Agent). Agent mode also runs a per-turn intent classifier that auto-disables tools on greetings." + "description": "Interaction mode. Agent: full tool access. Plan: read-only exploration + plan generation, no writes or shell commands." }, "deepCopilot.inlineCompletion.enable": { "type": "boolean", @@ -328,5 +340,8 @@ "esbuild": "^0.28.0", "eslint": "^8.57.0", "katex": "^0.16.45" + }, + "dependencies": { + "openai": "^6.38.0" } } diff --git a/src/api/adapter.js b/src/api/adapter.js new file mode 100644 index 0000000..5f3861e --- /dev/null +++ b/src/api/adapter.js @@ -0,0 +1,63 @@ +'use strict'; + +const { streamChat: streamChatBase, fetchBalance } = require('./openai-client'); + +// Preset baseURLs and default models per provider. +// All use the OpenAI-compatible client — only the endpoint changes. +const PROVIDER_PRESETS = { + // streamOpts parallelTools + deepseek: { baseUrl: 'https://api.deepseek.com', defaultModel: 'deepseek-v4-pro', streamOptions: true, parallelTools: true }, + openai: { baseUrl: 'https://api.openai.com/v1', defaultModel: 'gpt-4o', streamOptions: true, parallelTools: true }, + groq: { baseUrl: 'https://api.groq.com/openai/v1', defaultModel: 'llama-3.3-70b-versatile', streamOptions: false, parallelTools: false }, + ollama: { baseUrl: 'http://localhost:11434/v1', defaultModel: 'llama3.2', noApiKey: true, streamOptions: false, parallelTools: false }, + gemini: { baseUrl: 'https://generativelanguage.googleapis.com/v1beta/openai/', defaultModel: 'gemini-2.0-flash', streamOptions: false, parallelTools: false }, + custom: { streamOptions: false, parallelTools: false }, +}; + +function shouldUseOverrideModel(provider, overrideModel) { + if (!overrideModel) { + return false; + } + + if (provider === 'deepseek') { + return true; + } + + return overrideModel !== PROVIDER_PRESETS.deepseek.defaultModel; +} + +function resolveProviderConfig(provider, overrideBaseUrl, overrideModel) { + const preset = PROVIDER_PRESETS[provider] || PROVIDER_PRESETS.custom; + const model = shouldUseOverrideModel(provider, overrideModel) + ? overrideModel + : (preset.defaultModel || 'deepseek-chat'); + + return { + baseUrl: overrideBaseUrl || preset.baseUrl || 'https://api.deepseek.com', + model, + noApiKey: !!preset.noApiKey, + streamOptions: preset.streamOptions !== false, + parallelTools: preset.parallelTools !== false, + }; +} + +// Reads provider from params, +// resolves the correct baseUrl/model/flags, then delegates to the OpenAI client. +function streamChat({ provider, apiKey, baseUrl, model, ...rest }, callbacks, abortSignal) { + const resolved = resolveProviderConfig(provider, baseUrl, model); + const effectiveApiKey = resolved.noApiKey ? 'ollama' : (apiKey || ''); + return streamChatBase( + { + ...rest, + apiKey: effectiveApiKey, + baseUrl: resolved.baseUrl, + model: resolved.model, + streamOptions: resolved.streamOptions, + parallelTools: resolved.parallelTools, + }, + callbacks, + abortSignal, + ); +} + +module.exports = { streamChat, fetchBalance, PROVIDER_PRESETS, resolveProviderConfig }; diff --git a/src/api/deepseek.js b/src/api/deepseek.js deleted file mode 100644 index ebb2a12..0000000 --- a/src/api/deepseek.js +++ /dev/null @@ -1,309 +0,0 @@ -// Stream a chat completion from DeepSeek (OpenAI-compatible). -'use strict'; - -const https = require('https'); -const http = require('http'); - -const { Logger } = require('../logger'); -const { TOOL_DEFS } = require('../tools/schema'); - -/** - * @returns Promise<{ toolCalls: Array<{id, name, args}>, usage: object }> - */ -function streamDeepSeek({ apiKey, baseUrl, messages, model, noTools, toolChoice, tools, httpAgent }, callbacks, abortSignal) { - return new Promise((resolve, reject) => { - const base = (baseUrl || 'https://api.deepseek.com').replace(/\/$/, ''); - const urlObj = new URL('/chat/completions', base); - const isHttps = urlObj.protocol === 'https:'; - - const reqPayload = { - model: model || 'deepseek-chat', - messages, - stream: true, - max_tokens: 32768, - }; - if (!noTools) { - reqPayload.tools = tools || TOOL_DEFS; - // Hard API-level switch: 'none' means the model CANNOT emit tool - // calls this turn. 'auto' is default. Used by the conversational - // intent classifier to physically gate exploration on greetings. - reqPayload.tool_choice = toolChoice || 'auto'; - // Allow the model to emit multiple tool calls in one turn. - // Read-only tools will be executed in parallel; mutating tools - // are still serialized in the agent loop (provider.js). - reqPayload.parallel_tool_calls = true; - } - const body = JSON.stringify(reqPayload); - const bodyBytes = Buffer.byteLength(body); - - const reqOpts = { - hostname: urlObj.hostname, - port: urlObj.port || (isHttps ? 443 : 80), - path: urlObj.pathname + (urlObj.search || ''), - method: 'POST', - headers: { - 'Authorization': `Bearer ${apiKey}`, - 'Content-Type': 'application/json', - 'Accept': 'text/event-stream', - 'Content-Length': bodyBytes, - }, - // Reuse an existing keep-alive HTTPS agent when provided (e.g. from - // SubAgentRunner) to avoid a full TLS handshake on every API call. - ...(httpAgent ? { agent: httpAgent } : {}), - }; - - const mod = isHttps ? https : http; - let buf = ''; - const toolCalls = {}; - let usage = null; - let settled = false; - const startedAt = Date.now(); - let firstByteAt = 0; - let chunkCount = 0; - - function settle(val) { - if (!settled) { - settled = true; - if (abortSignal && _onAbort) { - try { abortSignal.removeEventListener('abort', _onAbort); } catch {} - } - Logger.info('STREAM_DONE', { - elapsed_ms: Date.now() - startedAt, - ttfb_ms: firstByteAt ? firstByteAt - startedAt : null, - chunks: chunkCount, - tool_calls: (val.toolCalls || []).length, - }); - resolve(val); - } - } - function fail(err) { - if (settled) return; - settled = true; - if (abortSignal && _onAbort) { - try { abortSignal.removeEventListener('abort', _onAbort); } catch {} - } - reject(err); - } - let _onAbort = null; - - Logger.info('HTTP_REQUEST', { url: urlObj.href, model, msg_count: messages.length, body_bytes: bodyBytes }); - - const req = mod.request(reqOpts, (res) => { - if (res.statusCode !== 200) { - let errBody = ''; - res.on('data', c => { errBody += c; }); - res.on('end', () => { - Logger.info('HTTP_ERROR', { status: res.statusCode, body: errBody.slice(0, 1500) }); - const err = new Error(`DeepSeek API ${res.statusCode}: ${errBody.slice(0, 500)}`); - err.statusCode = res.statusCode; - err.body = errBody; - reject(err); - }); - return; - } - res.setEncoding('utf8'); - res.on('data', chunk => { - if (!firstByteAt) firstByteAt = Date.now(); - chunkCount++; - buf += chunk; - let idx; - while ((idx = buf.indexOf('\n')) !== -1) { - const line = buf.slice(0, idx).trim(); - buf = buf.slice(idx + 1); - if (!line.startsWith('data: ')) continue; - const data = line.slice(6).trim(); - if (data === '[DONE]') { settle({ toolCalls: Object.values(toolCalls), usage }); return; } - let obj; - try { obj = JSON.parse(data); } catch { continue; } - if (obj.usage) usage = obj.usage; - const choice = obj.choices?.[0]; - if (!choice) continue; - const delta = choice.delta || {}; - if (delta.content) callbacks.onDelta?.(delta.content); - if (delta.reasoning_content) callbacks.onThinking?.(delta.reasoning_content); - if (delta.tool_calls) { - for (const tc of delta.tool_calls) { - const i = tc.index ?? 0; - if (!toolCalls[i]) toolCalls[i] = { id: '', name: '', args: '' }; - if (tc.id) toolCalls[i].id = tc.id; - if (tc.function?.name) toolCalls[i].name = tc.function.name; - if (tc.function?.arguments) { - toolCalls[i].args += tc.function.arguments; - callbacks.onToolArgsDelta?.({ - index: i, - id: toolCalls[i].id, - name: toolCalls[i].name, - deltaArgs: tc.function.arguments, - accArgs: toolCalls[i].args, - }); - } - } - } - if (choice.finish_reason === 'stop') { settle({ toolCalls: [], usage }); return; } - } - }); - res.on('end', () => settle({ toolCalls: Object.values(toolCalls), usage })); - res.on('error', fail); - }); - - if (abortSignal) { - _onAbort = () => { try { req.destroy(); } catch {} fail(new Error('aborted')); }; - if (abortSignal.aborted) { - process.nextTick(_onAbort); - } else { - abortSignal.addEventListener('abort', _onAbort, { once: true }); - } - } - - req.on('error', fail); - req.write(body); - req.end(); - }); -} - -/** - * Query account balance from DeepSeek /user/balance. - * Returns null silently for non-deepseek.com base URLs (3rd-party compatible APIs). - * @returns {Promise<{available: boolean, balance_cny: number, balance_usd: number, topped_up_cny: number, granted_cny: number}|null>} - */ -function fetchBalance({ apiKey, baseUrl }) { - return new Promise((resolve) => { - const base = (baseUrl || 'https://api.deepseek.com').replace(/\/$/, ''); - // Only query official DeepSeek endpoint; 3rd-party APIs may not support this route. - if (!base.includes('deepseek.com')) { resolve(null); return; } - let urlObj; - try { urlObj = new URL('/user/balance', base); } catch { resolve(null); return; } - const isHttps = urlObj.protocol === 'https:'; - const reqOpts = { - hostname: urlObj.hostname, - port: urlObj.port || (isHttps ? 443 : 80), - path: urlObj.pathname, - method: 'GET', - headers: { - 'Authorization': `Bearer ${apiKey}`, - 'Accept': 'application/json', - }, - timeout: 8000, - }; - const mod = isHttps ? https : http; - const req = mod.request(reqOpts, (res) => { - let raw = ''; - res.on('data', (c) => { raw += c; }); - res.on('end', () => { - try { - const data = JSON.parse(raw); - if (!data || typeof data.is_available === 'undefined') { resolve(null); return; } - const infos = Array.isArray(data.balance_infos) ? data.balance_infos : []; - const cnyInfo = infos.find(i => i.currency === 'CNY') || {}; - const usdInfo = infos.find(i => i.currency === 'USD') || {}; - resolve({ - available: !!data.is_available, - balance_cny: parseFloat(cnyInfo.total_balance || '0'), - topped_up_cny: parseFloat(cnyInfo.topped_up_balance || '0'), - granted_cny: parseFloat(cnyInfo.granted_balance || '0'), - balance_usd: parseFloat(usdInfo.total_balance || '0'), - }); - } catch { resolve(null); } - }); - res.on('error', () => resolve(null)); - }); - req.on('error', () => resolve(null)); - req.on('timeout', () => { req.destroy(); resolve(null); }); - req.end(); - }); -} - -// ─── FIM completion (Issue #60) ──────────────────────────────────────────── -// Non-streaming fill-in-the-middle completion against DeepSeek's beta endpoint. -// Powers the InlineCompletionItemProvider (src/completion/provider.js). -// -// API: POST {baseUrl}/beta/completions -// Schema: OpenAI legacy /v1/completions with `prompt` (prefix) + `suffix`. -// -// Returns the completion text, or null on any failure (silent — inline -// completion must never throw user-visible errors). -function fimComplete({ apiKey, baseUrl, model, prefix, suffix, maxTokens, temperature }, abortSignal) { - return new Promise((resolve) => { - const base = (baseUrl || 'https://api.deepseek.com').replace(/\/$/, ''); - - // Parse the URL first so we can do hostname-based whitelisting. - // FIM is only documented for the official DeepSeek API; third-party - // proxies may not implement /beta/completions. We MUST check the - // parsed hostname rather than a substring of the raw string — see - // CodeQL js/incomplete-url-substring-sanitization: a substring check - // would falsely accept hosts like `deepseek.com.attacker.com`, - // `evil.com/?x=deepseek.com`, etc., leaking the API key. - let urlObj; - try { urlObj = new URL('/beta/completions', base); } catch { resolve(null); return; } - const host = (urlObj.hostname || '').toLowerCase(); - if (host !== 'deepseek.com' && host !== 'api.deepseek.com' && !host.endsWith('.deepseek.com')) { - resolve(null); - return; - } - const isHttps = urlObj.protocol === 'https:'; - - const body = JSON.stringify({ - model: model || 'deepseek-chat', - prompt: prefix || '', - suffix: suffix || '', - max_tokens: maxTokens || 64, - temperature: temperature == null ? 0.2 : temperature, - stream: false, - }); - - const reqOpts = { - hostname: urlObj.hostname, - port: urlObj.port || (isHttps ? 443 : 80), - path: urlObj.pathname, - method: 'POST', - headers: { - 'Authorization': `Bearer ${apiKey}`, - 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'Content-Length': Buffer.byteLength(body), - }, - timeout: 15000, - }; - - const mod = isHttps ? https : http; - const req = mod.request(reqOpts, (res) => { - if (res.statusCode !== 200) { - let errBody = ''; - res.on('data', c => { errBody += c; }); - res.on('end', () => { - // Sanitize untrusted upstream body before logging - // (CodeQL js/http-to-file-access): strip CR/LF/control - // chars so a malicious proxy cannot inject log lines, - // and cap length so logs cannot be ballooned. - const safeBody = String(errBody) - .slice(0, 300) - .replace(/[\r\n\x00-\x1f\x7f]+/g, ' '); - Logger.info('FIM_HTTP_ERROR', { status: res.statusCode, body: safeBody }); - resolve(null); - }); - return; - } - let raw = ''; - res.on('data', c => { raw += c; }); - res.on('end', () => { - try { - const data = JSON.parse(raw); - const text = (data && data.choices && data.choices[0] && data.choices[0].text) || ''; - resolve(text); - } catch { resolve(null); } - }); - res.on('error', () => resolve(null)); - }); - req.on('error', () => resolve(null)); - req.on('timeout', () => { try { req.destroy(); } catch {}; resolve(null); }); - if (abortSignal) { - const onAbort = () => { try { req.destroy(); } catch {}; resolve(null); }; - if (abortSignal.aborted) { onAbort(); return; } - abortSignal.addEventListener('abort', onAbort, { once: true }); - } - req.write(body); - req.end(); - }); -} - -module.exports = { streamDeepSeek, fetchBalance, fimComplete }; diff --git a/src/api/openai-client.js b/src/api/openai-client.js new file mode 100644 index 0000000..b8000e9 --- /dev/null +++ b/src/api/openai-client.js @@ -0,0 +1,183 @@ +// Stream a chat completion via the OpenAI-compatible client. +// Works with DeepSeek, OpenAI, Groq, Together, OpenRouter, and any other +// OpenAI-compatible provider — swap baseUrl + model and it just works. +'use strict'; + +const { OpenAI } = require('openai'); +const https = require('https'); +const http = require('http'); + +const { Logger } = require('../logger'); +const { TOOL_DEFS } = require('../tools/schema'); + +/** + * @returns Promise<{ toolCalls: Array<{id, name, args}>, usage: object }> + */ +async function streamChat({ apiKey, baseUrl, messages, model, noTools, toolChoice, tools, httpAgent, streamOptions, parallelTools }, callbacks, abortSignal) { + const client = new OpenAI({ + apiKey, + baseURL: (baseUrl || 'https://api.deepseek.com').replace(/\/$/, ''), + ...(httpAgent ? { httpAgent } : {}), + }); + + const reqPayload = { + model: model || 'deepseek-chat', + messages, + stream: true, + max_tokens: 32768, + }; + if (streamOptions !== false) reqPayload.stream_options = { include_usage: true }; + if (!noTools) { + reqPayload.tools = tools || TOOL_DEFS; + reqPayload.tool_choice = toolChoice || 'auto'; + if (parallelTools !== false) reqPayload.parallel_tool_calls = true; + } + + const startedAt = Date.now(); + let firstByteAt = 0; + let chunkCount = 0; + const toolCalls = {}; + let usage = null; + + Logger.info('HTTP_REQUEST', { url: client.baseURL, model, msg_count: messages.length }); + + function normalizeError(err) { + if (err.name === 'AbortError' || err.message === 'aborted') return new Error('aborted'); + if (err.status) { + const detail = err.error + ? (typeof err.error === 'object' ? JSON.stringify(err.error) : String(err.error)) + : ''; + Logger.info('HTTP_ERROR', { status: err.status, body: detail || err.message }); + const apiErr = new Error(`API ${err.status}: ${err.message}${detail ? '\n\n' + detail : ''}`); + apiErr.statusCode = err.status; + apiErr.body = detail || err.message; + return apiErr; + } + return err; + } + + // Acquire the stream — auto-retry without tools if the provider rejects tool use. + let stream; + try { + stream = await client.chat.completions.create(reqPayload, { signal: abortSignal }); + } catch (err) { + const isToolErr = err.status === 404 && /tool/i.test(err.message + JSON.stringify(err.error || '')); + if (isToolErr && reqPayload.tools) { + Logger.info('TOOL_USE_UNSUPPORTED', { status: err.status, retrying: true }); + delete reqPayload.tools; + delete reqPayload.tool_choice; + delete reqPayload.parallel_tool_calls; + try { + stream = await client.chat.completions.create(reqPayload, { signal: abortSignal }); + } catch (retryErr) { + throw normalizeError(retryErr); + } + } else { + throw normalizeError(err); + } + } + + try { + for await (const chunk of stream) { + if (!firstByteAt) firstByteAt = Date.now(); + chunkCount++; + + if (chunk.usage) usage = chunk.usage; + + const choice = chunk.choices?.[0]; + if (!choice) continue; + + const delta = choice.delta || {}; + + if (delta.content) callbacks.onDelta?.(delta.content); + if (delta.reasoning_content) callbacks.onThinking?.(delta.reasoning_content); + + if (delta.tool_calls) { + for (const tc of delta.tool_calls) { + const i = tc.index ?? 0; + if (!toolCalls[i]) toolCalls[i] = { id: '', name: '', args: '' }; + if (tc.id) toolCalls[i].id = tc.id; + if (tc.function?.name) toolCalls[i].name = tc.function.name; + if (tc.function?.arguments) { + toolCalls[i].args += tc.function.arguments; + callbacks.onToolArgsDelta?.({ + index: i, + id: toolCalls[i].id, + name: toolCalls[i].name, + deltaArgs: tc.function.arguments, + accArgs: toolCalls[i].args, + }); + } + } + } + + } + } catch (err) { + throw normalizeError(err); + } + + Logger.info('STREAM_DONE', { + elapsed_ms: Date.now() - startedAt, + ttfb_ms: firstByteAt ? firstByteAt - startedAt : null, + chunks: chunkCount, + tool_calls: Object.values(toolCalls).length, + }); + + return { toolCalls: Object.values(toolCalls), usage }; +} + +/** + * Query account balance from DeepSeek /user/balance. + * Returns null silently unless an explicit official DeepSeek base URL is configured. + * @returns {Promise<{available: boolean, balance_cny: number, balance_usd: number, topped_up_cny: number, granted_cny: number}|null>} + */ +function fetchBalance({ apiKey, baseUrl }) { + return new Promise((resolve) => { + const base = typeof baseUrl === 'string' ? baseUrl.trim().replace(/\/$/, '') : ''; + if (!base) { resolve(null); return; } + let urlObj; + try { urlObj = new URL('/user/balance', base); } catch { resolve(null); return; } + const hostname = (urlObj.hostname || '').toLowerCase(); + // Only query official DeepSeek endpoints; 3rd-party APIs may not support this route. + if (!(hostname === 'deepseek.com' || hostname.endsWith('.deepseek.com'))) { resolve(null); return; } + const isHttps = urlObj.protocol === 'https:'; + const reqOpts = { + hostname: urlObj.hostname, + port: urlObj.port || (isHttps ? 443 : 80), + path: urlObj.pathname, + method: 'GET', + headers: { + 'Authorization': `Bearer ${apiKey}`, + 'Accept': 'application/json', + }, + timeout: 8000, + }; + const mod = isHttps ? https : http; + const req = mod.request(reqOpts, (res) => { + let raw = ''; + res.on('data', (c) => { raw += c; }); + res.on('end', () => { + try { + const data = JSON.parse(raw); + if (!data || typeof data.is_available === 'undefined') { resolve(null); return; } + const infos = Array.isArray(data.balance_infos) ? data.balance_infos : []; + const cnyInfo = infos.find(i => i.currency === 'CNY') || {}; + const usdInfo = infos.find(i => i.currency === 'USD') || {}; + resolve({ + available: !!data.is_available, + balance_cny: parseFloat(cnyInfo.total_balance || '0'), + topped_up_cny: parseFloat(cnyInfo.topped_up_balance || '0'), + granted_cny: parseFloat(cnyInfo.granted_balance || '0'), + balance_usd: parseFloat(usdInfo.total_balance || '0'), + }); + } catch { resolve(null); } + }); + res.on('error', () => resolve(null)); + }); + req.on('error', () => resolve(null)); + req.on('timeout', () => { req.destroy(); resolve(null); }); + req.end(); + }); +} + +module.exports = { streamChat, fetchBalance }; diff --git a/src/chat/agent-loop.js b/src/chat/agent-loop.js index bf14e0c..ea0c7b8 100644 --- a/src/chat/agent-loop.js +++ b/src/chat/agent-loop.js @@ -11,7 +11,7 @@ const { Logger } = require('../logger'); const { friendlyError } = require('../errors'); const { computeCost } = require('../pricing'); const { buildSystemPrompt }= require('../prompts/system'); -const { streamDeepSeek } = require('../api/deepseek'); +const { streamChat, PROVIDER_PRESETS } = require('../api/adapter'); const { getToolDefs } = require('../tools/schema'); const { mcpManager } = require('../mcp'); const { isZh } = require('../utils/i18n'); @@ -101,8 +101,12 @@ class AgentLoop { const existingActive = this._getRun(this._store.sessionId); if (existingActive && existingActive.busy) return; + const cfg = vscode.workspace.getConfiguration('deepseekAgent'); + const provider = cfg.get('provider') || 'deepseek'; + const apiKey = await this._context.secrets.get('deepseekAgent.apiKey'); - if (!apiKey) { + const needsKey = !PROVIDER_PRESETS[provider]?.noApiKey; + if (needsKey && !apiKey) { this._post({ type: 'error', text: '请先设置 API Key — 点击工具栏 🔑 按钮' }); return; } @@ -114,10 +118,8 @@ class AgentLoop { run = this._newRun(sid, seed); } run.busy = true; - - const cfg = vscode.workspace.getConfiguration('deepseekAgent'); - const model = cfg.get('defaultModel') || 'deepseek-v4-pro'; - const baseUrl = (cfg.get('apiBaseUrl') || '').trim() || 'https://api.deepseek.com'; + const model = cfg.get('defaultModel') || 'deepseek-v4-pro'; + const baseUrl = (cfg.get('apiBaseUrl') || '').trim(); const mode = cfg.get('approvalMode') || 'manual'; // Build attachment block (active editor context) @@ -350,8 +352,8 @@ class AgentLoop { postProgress('waiting_first_token'); let _gotFirstToken = false; - const { toolCalls, usage } = await streamDeepSeek( - { apiKey, baseUrl, messages: finalMsgs, model, noTools: askMode, tools: allTools }, + const { toolCalls, usage } = await streamChat( + { provider, apiKey, baseUrl, messages: finalMsgs, model, noTools: askMode, tools: allTools }, { onDelta: (delta) => { if (!_gotFirstToken) { _gotFirstToken = true; postProgress('streaming'); } @@ -563,8 +565,8 @@ class AgentLoop { { role: 'user', content: '\nYou have reached the tool-call iteration limit without producing a user-facing answer. Stop calling tools. Write a concise plain-text reply that: (1) summarises what you tried, (2) states what you found or could not find, (3) suggests a concrete next step the user can take.\n' }, ]; let tail = ''; - await streamDeepSeek( - { apiKey, baseUrl, messages: finalMsgs, model, noTools: true }, + await streamChat( + { provider, apiKey, baseUrl, messages: finalMsgs, model, noTools: true }, { onDelta: t => { tail += t; run.reply.asst += t; this._postToRun(run, { type: 'replyDelta', text: t }); }, onThinking: t => { run.reply.thoughts += t; this._postToRun(run, { type: 'thinkingDelta', text: t }); }, diff --git a/src/chat/provider.js b/src/chat/provider.js index 7bfd82b..8e8797b 100644 --- a/src/chat/provider.js +++ b/src/chat/provider.js @@ -25,7 +25,7 @@ const { mcpManager } = require('../mcp'); const { SessionStore } = require('./session-store'); const { ToolExecutor } = require('./tool-executor'); const { AgentLoop } = require('./agent-loop'); -const { fetchBalance } = require('../api/deepseek'); +const { fetchBalance, resolveProviderConfig } = require('../api/adapter'); const { resolveContextRef } = require('./context-refs'); class ChatViewProvider { @@ -145,7 +145,13 @@ class ChatViewProvider { switch (msg.type) { case 'ready': { const cfg = vscode.workspace.getConfiguration('deepseekAgent'); - this._post({ type: 'modelInfo', model: cfg.get('defaultModel') || 'deepseek-v4-pro', approvalMode: cfg.get('approvalMode') || 'manual' }); + this._post({ + type: 'modelInfo', + model: cfg.get('defaultModel') || 'deepseek-v4-pro', + approvalMode: cfg.get('approvalMode') || 'manual', + provider: cfg.get('provider') || 'deepseek', + interactionMode: cfg.get('interactionMode') || 'agent', + }); this._store.postList(); if (!this._store.sessionId) this._post({ type: 'sessionLoaded', id: null, messages: [] }); this._refreshBalance(false); @@ -169,6 +175,12 @@ class ChatViewProvider { case 'sessionPin': this._store.pin(msg.id); break; case 'sessionUnread': this._store.unread(msg.id); break; case 'sessionArchive': this._store.archive(msg.id); break; + case 'setInteractionMode': { + const cfg = vscode.workspace.getConfiguration('deepseekAgent'); + cfg.update('interactionMode', msg.mode, vscode.ConfigurationTarget.Global) + .then(() => this._post({ type: 'modelInfo', interactionMode: msg.mode })); + break; + } case 'setMode': { const cfg = vscode.workspace.getConfiguration('deepseekAgent'); cfg.update('approvalMode', msg.mode, vscode.ConfigurationTarget.Global) @@ -185,7 +197,8 @@ class ChatViewProvider { const cfg = vscode.workspace.getConfiguration('deepseekAgent'); const dsKey = await this._context.secrets.get('deepseekAgent.apiKey') || ''; const tvKey = await this._context.secrets.get('deepseekAgent.tavilyKey') || ''; - const baseUrl = cfg.get('apiBaseUrl') || 'https://api.deepseek.com'; + const baseUrl = cfg.get('apiBaseUrl') || ''; + const provider = cfg.get('provider') || 'deepseek'; const maskKey = (k) => k ? (k.slice(0, 6) + '...' + k.slice(-4)) : ''; this._post({ type: 'settingsLoaded', @@ -194,6 +207,7 @@ class ChatViewProvider { tvKeySet: !!tvKey, tvKeyHint: maskKey(tvKey), baseUrl: baseUrl, + provider: provider, }); break; } @@ -203,19 +217,18 @@ class ChatViewProvider { if (which === 'ds') { const testKey = msg.key || (await this._context.secrets.get('deepseekAgent.apiKey') || ''); const cfg = vscode.workspace.getConfiguration('deepseekAgent'); - const baseUrl = (msg.baseUrl !== null && msg.baseUrl !== undefined && msg.baseUrl !== '') - ? msg.baseUrl - : (cfg.get('apiBaseUrl') || 'https://api.deepseek.com'); - if (!testKey) { + const provider = msg.provider || cfg.get('provider') || 'deepseek'; + const resolved = resolveProviderConfig(provider, msg.baseUrl || cfg.get('apiBaseUrl') || '', ''); + if (!testKey && !resolved.noApiKey) { this._post({ type: 'testApiKeyResult', which, ok: false, error: 'No API key set' }); break; } try { const https = require('https'); const http = require('http'); - const base = (baseUrl || 'https://api.deepseek.com').replace(/\/$/, ''); + const base = resolved.baseUrl.replace(/\/$/, ''); const urlObj = new URL('/chat/completions', base); - const body = JSON.stringify({ model: 'deepseek-chat', messages: [{ role: 'user', content: 'hi' }], max_tokens: 1 }); + const body = JSON.stringify({ model: resolved.model, messages: [{ role: 'user', content: 'hi' }], max_tokens: 1 }); const isHttps = urlObj.protocol === 'https:'; const result = await new Promise((resolve) => { const req = (isHttps ? https : http).request({ @@ -295,9 +308,11 @@ class ChatViewProvider { if (msg.tvKey) { await this._context.secrets.store('deepseekAgent.tavilyKey', msg.tvKey); } + if (msg.provider) { + await cfg.update('provider', msg.provider, vscode.ConfigurationTarget.Global); + } if (typeof msg.baseUrl === 'string') { - const normalized = msg.baseUrl.trim().replace(/\/$/, '') || 'https://api.deepseek.com'; - await cfg.update('apiBaseUrl', normalized, vscode.ConfigurationTarget.Global); + await cfg.update('apiBaseUrl', msg.baseUrl.trim().replace(/\/$/, ''), vscode.ConfigurationTarget.Global); } this._refreshBalance(true); break; @@ -751,11 +766,12 @@ class ChatViewProvider { async _refreshBalance(force) { const now = Date.now(); if (!force && now - this._balanceLastAt < 30_000) return; - const cfg = vscode.workspace.getConfiguration('deepseekAgent'); - const apiKey = await this._context.secrets.get('deepseekAgent.apiKey') || ''; - const baseUrl = cfg.get('baseUrl') || ''; + const cfg = vscode.workspace.getConfiguration('deepseekAgent'); + const provider = cfg.get('provider') || 'deepseek'; + const apiKey = await this._context.secrets.get('deepseekAgent.apiKey') || ''; + const resolved = resolveProviderConfig(provider, cfg.get('apiBaseUrl') || '', ''); if (!apiKey) { this._post({ type: 'balanceUpdate', unsupported: true }); return; } - const result = await fetchBalance({ apiKey, baseUrl }); + const result = await fetchBalance({ apiKey, baseUrl: resolved.baseUrl }); if (result === null) { this._post({ type: 'balanceUpdate', unsupported: true }); return; } this._balanceLastAt = Date.now(); this._post({ type: 'balanceUpdate', ...result }); diff --git a/src/chat/sub-agent.js b/src/chat/sub-agent.js index 08fbeba..333dace 100644 --- a/src/chat/sub-agent.js +++ b/src/chat/sub-agent.js @@ -15,7 +15,7 @@ const https = require('https'); const vscode = require('vscode'); const { Logger } = require('../logger'); -const { streamDeepSeek } = require('../api/deepseek'); +const { streamChat, PROVIDER_PRESETS } = require('../api/adapter'); const { getToolDefs } = require('../tools/schema'); const { mcpManager } = require('../mcp'); const { autoCompactIfNeeded } = require('./compact'); @@ -100,16 +100,31 @@ class SubAgentRunner { return '[spawn_agent] Error: `prompt` argument is required and must be non-empty.'; } + const cfg = vscode.workspace.getConfiguration('deepseekAgent'); + const provider = cfg.get('provider') || 'deepseek'; + const apiKey = await this._context.secrets.get('deepseekAgent.apiKey'); - if (!apiKey) return '[spawn_agent] Error: no API key configured.'; + if (!PROVIDER_PRESETS[provider]?.noApiKey && !apiKey) { + return '[spawn_agent] Error: no API key configured.'; + } - const cfg = vscode.workspace.getConfiguration('deepseekAgent'); - // Sub-agents default to a fast/cheap model (flash) so that multiple - // concurrent sub-agents don't saturate the rate-limit quota of the - // parent's (possibly expensive) model. Users can override via the - // deepseekAgent.subAgentModel setting. - const model = cfg.get('subAgentModel') || cfg.get('defaultModel') || 'deepseek-v4-flash'; - const baseUrl = (cfg.get('apiBaseUrl') || '').trim() || 'https://api.deepseek.com'; + // Sub-agents default to a fast/cheap model (flash) for DeepSeek. + // For other providers, fall back to the user's selected model. + // Users can always override via the deepseekAgent.subAgentModel setting. + const subAgentModelConfig = cfg.inspect('subAgentModel'); + const hasExplicitSubAgentModel = !!( + subAgentModelConfig && ( + subAgentModelConfig.workspaceFolderValue || + subAgentModelConfig.workspaceValue || + subAgentModelConfig.globalValue + ) + ); + const configuredSubAgentModel = cfg.get('subAgentModel'); + const defaultModel = cfg.get('defaultModel'); + const model = provider === 'deepseek' + ? (configuredSubAgentModel || defaultModel || 'deepseek-v4-flash') + : ((hasExplicitSubAgentModel ? configuredSubAgentModel : '') || defaultModel || ''); + const baseUrl = (cfg.get('apiBaseUrl') || '').trim(); // ── Keep-alive HTTPS agent ───────────────────────────────────────── // Re-use the same TLS connection for every API call in this sub-agent's @@ -182,7 +197,7 @@ class SubAgentRunner { for (let attempt = 0; attempt <= MAX_NET_RETRIES; attempt++) { if (childAbort.signal.aborted) throw new Error('aborted'); try { - return await streamDeepSeek( + return await streamChat( { ...params, httpAgent: keepAliveAgent }, callbacks, childAbort.signal, @@ -222,7 +237,7 @@ class SubAgentRunner { let assistantText = ''; let reasoningText = ''; // must be passed back to DeepSeek in thinking mode const { toolCalls } = await streamWithRetry( - { apiKey, baseUrl, messages: apiMessages, model, noTools: false, tools: childTools }, + { provider, apiKey, baseUrl, messages: apiMessages, model, noTools: false, tools: childTools }, { onDelta: d => { assistantText += d; }, onThinking: d => { reasoningText += d; }, // keep — API requires passback diff --git a/src/pricing.js b/src/pricing.js index b2e7096..707a97a 100644 --- a/src/pricing.js +++ b/src/pricing.js @@ -11,10 +11,14 @@ function getModelPricing(model) { if (model === 'deepseek-v4-flash' || model === 'deepseek-reasoner' || model === 'deepseek-chat') { return { input: 1.0, cache_hit: 0.02, output: 2.0 }; } - if (Date.now() < V4_PRO_DISCOUNT_END) { - return { input: 3.0, cache_hit: 0.025, output: 6.0, discount: '2.5折' }; + if (typeof model === 'string' && model.startsWith('deepseek-')) { + if (Date.now() < V4_PRO_DISCOUNT_END) { + return { input: 3.0, cache_hit: 0.025, output: 6.0, discount: '2.5折' }; + } + return { input: 12.0, cache_hit: 0.1, output: 24.0 }; } - return { input: 12.0, cache_hit: 0.1, output: 24.0 }; + // Unknown / non-DeepSeek model — no pricing data available. + return null; } function computeCost(model, usage) { @@ -26,6 +30,20 @@ function computeCost(model, usage) { const cacheMiss = (usage.prompt_cache_miss_tokens != null) ? usage.prompt_cache_miss_tokens : Math.max(prompt - cacheHit, 0); + // No pricing table for this model — report token counts but zero cost. + if (!p) { + return { + cost_cny: 0, + breakdown: { + cache_hit_tokens: cacheHit, + cache_miss_tokens: cacheMiss, + completion_tokens: completion, + prompt_tokens: prompt, + total_tokens: usage.total_tokens || (prompt + completion), + pricing: null, + }, + }; + } const cost = (cacheHit / 1e6) * p.cache_hit + (cacheMiss / 1e6) * p.input + diff --git a/src/utils/i18n.js b/src/utils/i18n.js index 40508b9..9d4b50d 100644 --- a/src/utils/i18n.js +++ b/src/utils/i18n.js @@ -60,16 +60,16 @@ const EN = { sessionUntitled: 'Untitled', errTitle: 'Request failed', errTitle401: 'Invalid or expired API Key', - errTip401: 'Click the 🔑 button to re-enter your DeepSeek API key. Make sure the key has not expired or been disabled.', + errTip401: 'Click the 🔑 button to re-enter your API key. Make sure the key has not expired or been disabled.', errTitle402: 'Insufficient account balance', - errTip402: 'Please top up your DeepSeek account and try again.', + errTip402: 'Please top up your account and try again.', errTitle429: 'Rate limit exceeded', - errTip429: 'You have hit the DeepSeek rate limit. Wait a few seconds and click Retry.', + errTip429: 'You have hit the provider rate limit. Wait a few seconds and click Retry.', errTitle400: 'Bad request', errTip400: 'The context may be too long or the message format may be invalid. Try clearing the session (Ctrl+K) and retrying.', - errTitle5xx: 'DeepSeek service error', + errTitle5xx: 'Service error', errNetwork: 'Network connection failed', - errTipNetwork: 'Cannot reach the DeepSeek API. Check your network, proxy, or firewall settings.', + errTipNetwork: 'Cannot reach the API. Check your network, proxy, or firewall settings.', errAborted: 'Generation stopped', errTipAborted: 'Generation was interrupted by the user.', wvWelcomeSub: 'Open, fair, and accessible AI productivity for all', @@ -88,6 +88,7 @@ const EN = { wvCacheTitle: 'Prompt cache hit rate (higher = cheaper)', wvSwitchModel: 'Switch model', wvApprovalMode: 'Approval Mode', + wvInteractionMode: 'Interaction Mode', wvBalanceTitle: 'Account balance (click to refresh)', wvBalanceInit: '💰 Checking...', @@ -147,16 +148,16 @@ const ZH = { sessionUntitled: '未命名', errTitle: '请求失败', errTitle401: 'API Key 无效或已过期', - errTip401: '请打开右上角 🔑 重新设置 DeepSeek API Key,确认密钥未过期且未被禁用。', + errTip401: '请打开右上角 🔑 重新设置 API Key,确认密钥未过期且未被禁用。', errTitle402: '账户余额不足', - errTip402: '请前往 DeepSeek 控制台充值后再试。', + errTip402: '请前往服务商控制台充值后再试。', errTitle429: '请求过于频繁(限流)', - errTip429: '已触发 DeepSeek 限流。请稍候几秒再点击「重试」。', + errTip429: '已触发限流。请稍候几秒再点击「重试」。', errTitle400: '请求参数错误', errTip400: '可能是上下文过长或消息格式异常。可尝试清空会话(Ctrl+K)后重试。', - errTitle5xx: 'DeepSeek 服务异常', + errTitle5xx: '服务异常', errNetwork: '网络连接失败', - errTipNetwork: '无法连接 DeepSeek API。请检查网络/代理/防火墙设置。', + errTipNetwork: '无法连接 API。请检查网络/代理/防火墙设置。', errAborted: '已停止生成', errTipAborted: '生成被用户中断。', wvWelcomeSub: '让高质量 AI 生产力开放、公平、普惠', @@ -175,6 +176,7 @@ const ZH = { wvCacheTitle: 'prompt 缓存命中率(越高越省钱)', wvSwitchModel: '切换模型', wvApprovalMode: '批准策略 (Approval Mode)', + wvInteractionMode: '交互模式', wvBalanceTitle: '账户余额(点击刷新)', wvBalanceInit: '💰 查询中…', diff --git a/src/webview/html.js b/src/webview/html.js index 7f53681..009ae94 100644 --- a/src/webview/html.js +++ b/src/webview/html.js @@ -31,6 +31,7 @@ function buildWebviewHtml(webview, extensionUri) { cacheTitle: t('wvCacheTitle'), switchModel: t('wvSwitchModel'), approvalMode: t('wvApprovalMode'), + interactionMode: t('wvInteractionMode'), balanceTitle: t('wvBalanceTitle'), balanceInit: t('wvBalanceInit'), }; @@ -106,6 +107,9 @@ function buildWebviewHtml(webview, extensionUri) { + + Agent + ⚡ v4-pro ▾ @@ -141,7 +145,19 @@ function buildWebviewHtml(webview, extensionUri) { - DeepSeek AI + AI Provider + + Provider + + DeepSeek + OpenAI + Groq + Ollama (local) + Gemini + Custom + + Auto-fills Base URL · swap model in VS Code settings + API Key *