diff --git a/cli.js b/cli.js index 3c0df825..02477634 100644 --- a/cli.js +++ b/cli.js @@ -148,7 +148,7 @@ const { deleteCodexSkills } = require('./cli/skills'); const { cmdImportSkills: cmdImportSkillsFromUrl } = require('./cli/import-skills-url'); -const { cmdToolUpdate } = require('./cli/update'); +const { cmdToolUpdate, fetchLatestVersion } = require('./cli/update'); const { getFileStatSafe, isBootstrapLikeText, @@ -291,7 +291,11 @@ const DEFAULT_BUILTIN_CLAUDE_PROXY_SETTINGS = Object.freeze({ host: '127.0.0.1', port: 8328, provider: '', + upstreamProviderName: '', + upstreamBaseUrl: '', + upstreamApiKey: '', authSource: 'provider', + targetApi: 'responses', timeoutMs: 30000 }); const CLI_INSTALL_TARGETS = Object.freeze([ @@ -5740,7 +5744,9 @@ const { HTTPS_KEEP_ALIVE_AGENT, readConfigOrVirtualDefault, resolveBuiltinProxyProviderName, - resolveAuthTokenFromCurrentProfile + resolveAuthTokenFromCurrentProfile, + OPENAI_BRIDGE_SETTINGS_FILE, + resolveOpenaiBridgeUpstream }); function applyBuiltinProxyProvider(params = {}) { @@ -8082,15 +8088,17 @@ function buildClaudeSharePayload(config = {}) { const apiKey = typeof config.apiKey === 'string' ? config.apiKey : ''; const baseUrl = typeof config.baseUrl === 'string' ? config.baseUrl : ''; const model = typeof config.model === 'string' ? config.model : ''; + const targetApi = normalizeClaudeTargetApi(config.targetApi); if (!baseUrl) return { error: 'Claude Base URL 未设置' }; - if (!apiKey) return { error: 'Claude API 密钥未设置' }; + if (!apiKey && targetApi !== 'ollama') return { error: 'Claude API 密钥未设置' }; return { payload: { baseUrl: baseUrl.trim(), apiKey: apiKey.trim(), - model: (model && model.trim()) || DEFAULT_CLAUDE_MODEL + model: (model && model.trim()) || DEFAULT_CLAUDE_MODEL, + targetApi } }; } @@ -9404,19 +9412,93 @@ function maskKey(key) { return key.substring(0, 4) + '...' + key.substring(key.length - 4); } +function normalizeClaudeTargetApi(value) { + const raw = typeof value === 'string' ? value.trim().toLowerCase() : ''; + if (raw === 'chat_completions' || raw === 'chat-completions' || raw === 'chat/completions') { + return 'chat_completions'; + } + if (raw === 'ollama') { + return 'ollama'; + } + return 'responses'; +} + +function resetBuiltinClaudeProxySavedSettingsToResponses() { + const proxySettingsResult = readJsonObjectFromFile(BUILTIN_CLAUDE_PROXY_SETTINGS_FILE, DEFAULT_BUILTIN_CLAUDE_PROXY_SETTINGS); + const proxySettings = proxySettingsResult.ok && proxySettingsResult.data && typeof proxySettingsResult.data === 'object' && !Array.isArray(proxySettingsResult.data) + ? proxySettingsResult.data + : DEFAULT_BUILTIN_CLAUDE_PROXY_SETTINGS; + writeJsonAtomic(BUILTIN_CLAUDE_PROXY_SETTINGS_FILE, { + ...DEFAULT_BUILTIN_CLAUDE_PROXY_SETTINGS, + ...proxySettings, + enabled: false, + targetApi: 'responses' + }); +} + // 应用到 Claude Code settings.json(跨平台) -function applyToClaudeSettings(config = {}) { - assertToolConfigWriteAllowed('claude'); +async function applyToClaudeSettings(config = {}) { + let proxyStarted = false; try { + assertToolConfigWriteAllowed('claude'); const apiKey = (config.apiKey || '').trim(); - if (!apiKey) { + const targetApi = normalizeClaudeTargetApi(config.targetApi); + if (!apiKey && targetApi !== 'ollama') { return { success: false, mode: 'settings-file', error: '请先输入 API Key' }; } - const baseUrl = (config.baseUrl || 'https://open.bigmodel.cn/api/anthropic').trim(); + const configuredBaseUrl = typeof config.baseUrl === 'string' ? config.baseUrl.trim() : ''; + const baseUrl = (configuredBaseUrl || (targetApi === 'ollama' ? 'http://127.0.0.1:11434' : 'https://open.bigmodel.cn/api/anthropic')).trim(); const model = (config.model || DEFAULT_CLAUDE_MODEL).trim(); + let settingsBaseUrl = baseUrl; + let settingsApiKey = apiKey; + let proxyResult = null; + + if (targetApi === 'chat_completions' || targetApi === 'ollama') { + const upstreamProviderName = typeof config.name === 'string' ? config.name.trim() : ''; + if (targetApi === 'chat_completions' && !configuredBaseUrl && !upstreamProviderName) { + return { + success: false, + mode: 'claude-proxy', + error: 'chat_completions 模式需要显式的上游 Base URL 或可解析的 provider 名称' + }; + } + await stopBuiltinClaudeProxyRuntime(); + const proxyToken = crypto.randomBytes(24).toString('hex'); + proxyResult = await startBuiltinClaudeProxyRuntime({ + enabled: true, + host: DEFAULT_BUILTIN_CLAUDE_PROXY_SETTINGS.host, + provider: upstreamProviderName, + authSource: 'provider', + targetApi, + timeoutMs: DEFAULT_BUILTIN_CLAUDE_PROXY_SETTINGS.timeoutMs, + upstreamProviderName, + ...(configuredBaseUrl ? { upstreamBaseUrl: configuredBaseUrl } : {}), + upstreamApiKey: apiKey + }); + if (!proxyResult || proxyResult.error || proxyResult.success === false || !proxyResult.listenUrl) { + await stopBuiltinClaudeProxyRuntime(); + resetBuiltinClaudeProxySavedSettingsToResponses(); + return { + success: false, + mode: 'claude-proxy', + error: (proxyResult && proxyResult.error) || '启动 Claude 兼容代理失败' + }; + } + proxyStarted = true; + settingsBaseUrl = proxyResult.listenUrl; + settingsApiKey = proxyToken; + } else { + await stopBuiltinClaudeProxyRuntime(); + resetBuiltinClaudeProxySavedSettingsToResponses(); + } + const readResult = readJsonObjectFromFile(CLAUDE_SETTINGS_FILE, {}); if (!readResult.ok) { + if (proxyStarted) { + await stopBuiltinClaudeProxyRuntime(); + resetBuiltinClaudeProxySavedSettingsToResponses(); + } return { success: false, mode: 'settings-file', error: readResult.error }; } @@ -9427,8 +9509,8 @@ function applyToClaudeSettings(config = {}) { const nextEnv = { ...currentEnv, - ANTHROPIC_API_KEY: apiKey, - ANTHROPIC_BASE_URL: baseUrl, + ANTHROPIC_API_KEY: settingsApiKey, + ANTHROPIC_BASE_URL: settingsBaseUrl, ANTHROPIC_MODEL: model }; delete nextEnv.ANTHROPIC_AUTH_TOKEN; @@ -9445,7 +9527,8 @@ function applyToClaudeSettings(config = {}) { const result = { success: true, - mode: 'settings-file', + mode: targetApi === 'responses' ? 'settings-file' : 'claude-proxy', + targetApi, targetPath: CLAUDE_SETTINGS_FILE, updatedKeys: [ 'env.ANTHROPIC_API_KEY', @@ -9453,11 +9536,23 @@ function applyToClaudeSettings(config = {}) { 'env.ANTHROPIC_MODEL' ] }; + if (proxyResult) { + result.proxy = { + running: true, + listenUrl: proxyResult.listenUrl, + upstreamProvider: proxyResult.upstreamProvider || '', + mode: proxyResult.mode || (targetApi === 'ollama' ? 'anthropic-to-ollama' : 'anthropic-to-chat-completions') + }; + } if (backupPath) { result.backupPath = backupPath; } return result; } catch (e) { + if (proxyStarted) { + try { await stopBuiltinClaudeProxyRuntime(); } catch (_) {} + try { resetBuiltinClaudeProxySavedSettingsToResponses(); } catch (_) {} + } return { success: false, mode: 'settings-file', @@ -9570,6 +9665,40 @@ async function restoreCodexDir(payload) { } // CLI: 一行写入 Claude Code 配置 +function parseClaudeCommandArgs(argv = []) { + const positionals = []; + let targetApi = 'responses'; + for (let i = 0; i < argv.length; i += 1) { + const token = String(argv[i] ?? ''); + if (token === '--target-api' || token === '--targetApi') { + const nextValue = String(argv[i + 1] ?? ''); + if (!nextValue || nextValue.startsWith('--')) { + throw new Error('错误: --target-api 需要一个值(responses、chat_completions 或 ollama)'); + } + targetApi = normalizeClaudeTargetApi(nextValue); + i += 1; + continue; + } + positionals.push(token); + } + + const baseUrl = positionals[0]; + if (targetApi === 'ollama' && positionals.length === 2) { + return { + baseUrl, + apiKey: '', + model: positionals[1], + targetApi + }; + } + return { + baseUrl, + apiKey: positionals[1], + model: positionals[2], + targetApi + }; +} + async function cmdClaude(args = []) { const argv = Array.isArray(args) ? args : []; // 无参数 → 代理启动 @@ -9577,7 +9706,7 @@ async function cmdClaude(args = []) { return runProxyCommand('Claude', 'claude', [], '', { autoFlag: '--dangerously-skip-permissions' }); } // 有参数 → 配置写入 - const [baseUrl, apiKey, model] = argv; + const { baseUrl, apiKey, model, targetApi } = parseClaudeCommandArgs(argv); const normalizedBaseUrl = typeof baseUrl === 'string' ? baseUrl.trim() : ''; const normalizedKey = typeof apiKey === 'string' ? apiKey.trim() : ''; const normalizedModel = typeof model === 'string' && model.trim() @@ -9586,19 +9715,21 @@ async function cmdClaude(args = []) { const silent = false; - if (!normalizedBaseUrl || !normalizedKey) { + if (!normalizedBaseUrl || (!normalizedKey && targetApi !== 'ollama')) { if (!silent) { - console.error('用法: codexmate claude [模型]'); + console.error('用法: codexmate claude [模型] [--target-api responses|chat_completions|ollama]'); console.log('\n示例:'); console.log(' codexmate claude https://open.bigmodel.cn/api/anthropic sk-ant-xxx glm-4.7'); + console.log(" codexmate claude http://127.0.0.1:11434 '' llama3.1:8b --target-api ollama"); } - throw new Error('BaseURL 和 API 密钥必填'); + throw new Error(targetApi === 'ollama' ? 'BaseURL 必填' : 'BaseURL 和 API 密钥必填'); } - const result = applyToClaudeSettings({ + const result = await applyToClaudeSettings({ baseUrl: normalizedBaseUrl, apiKey: normalizedKey, - model: normalizedModel + model: normalizedModel, + targetApi }); if (!result || result.success === false) { @@ -11105,6 +11236,27 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser case 'install-status': result = buildInstallStatusReport(); break; + case 'version-status': { + const currentVersion = (() => { + try { + const pkg = require('./package.json'); + return pkg && pkg.version ? pkg.version : ''; + } catch (_) { + return ''; + } + })(); + try { + const latestVersion = await fetchLatestVersion({ timeoutMs: 2000 }); + result = { currentVersion, latestVersion }; + } catch (e) { + result = { + currentVersion, + latestVersion: '', + error: e && e.message ? e.message : '获取最新版本失败' + }; + } + break; + } case 'list': result = buildMcpProviderListPayload(); break; @@ -11297,7 +11449,7 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser result = applyClaudeSettingsRaw(params || {}); break; case 'apply-claude-config': - result = applyToClaudeSettings(params.config); + result = await applyToClaudeSettings(params.config); if (result && !result.error) { const cfgName = (params && params.config && typeof params.config.name === 'string') ? params.config.name : ''; const cfgFrom = (params && typeof params.previousName === 'string') ? params.previousName : ''; @@ -15894,9 +16046,20 @@ function createMcpTools(options = {}) { properties: { apiKey: { type: 'string' }, baseUrl: { type: 'string' }, - model: { type: 'string' } + model: { type: 'string' }, + name: { type: 'string' }, + targetApi: { type: 'string' } }, - required: ['apiKey'], + allOf: [{ + if: { + not: { + type: 'object', + properties: { targetApi: { type: 'string', pattern: '^[\\s]*[oO][lL][lL][aA][mM][aA][\\s]*$' } }, + required: ['targetApi'] + } + }, + then: { required: ['apiKey'] } + }], additionalProperties: false }, handler: async (args = {}) => applyToClaudeSettings(args || {}) @@ -16352,7 +16515,7 @@ function printMainHelp() { console.log(' codexmate add <名称> [密钥] [--bridge ]'); console.log(' codexmate delete <名称> 删除提供商'); console.log(' codexmate claude 等同于 claude --dangerously-skip-permissions'); - console.log(' codexmate claude [模型] 写入 Claude Code 配置'); + console.log(' codexmate claude [模型] [--target-api responses|chat_completions|ollama] 写入 Claude Code 配置'); console.log(' codexmate auth 认证管理'); console.log(' codexmate add-model <模型> 添加模型'); console.log(' codexmate delete-model <模型> 删除模型'); diff --git a/cli/claude-proxy.js b/cli/claude-proxy.js index 0317b0b1..38bad69a 100644 --- a/cli/claude-proxy.js +++ b/cli/claude-proxy.js @@ -84,6 +84,40 @@ function stringifyAnthropicToolResultContent(content) { return safeJsonStringify(content); } +function buildOpenAIImageUrlFromAnthropicSource(source) { + if (!source || typeof source !== 'object') return ''; + if (source.type === 'base64' && typeof source.data === 'string' && source.data.trim()) { + const mediaType = typeof source.media_type === 'string' && source.media_type.trim() + ? source.media_type.trim() + : 'image/png'; + return `data:${mediaType};base64,${source.data.trim()}`; + } + if (source.type === 'url' && typeof source.url === 'string' && source.url.trim()) { + return source.url.trim(); + } + return ''; +} + +function collectAnthropicImageBase64(source) { + if (!source || typeof source !== 'object') return ''; + if (source.type === 'base64' && typeof source.data === 'string' && source.data.trim()) { + return source.data.trim(); + } + const url = buildOpenAIImageUrlFromAnthropicSource(source); + const match = url ? url.match(/^data:[^;,]+;base64,(.+)$/i) : null; + return match && match[1] ? match[1] : ''; +} + +function isDroppableAnthropicBridgeBlock(block) { + const type = block && typeof block.type === 'string' ? block.type : ''; + return type === 'thinking' || type === 'document'; +} + +function isDroppableAnthropicOllamaBlock(block) { + const type = block && typeof block.type === 'string' ? block.type : ''; + return type === 'thinking' || type === 'document' || type === 'video'; +} + function appendAnthropicMessageToResponsesInput(target, message) { if (!message || typeof message !== 'object') return; const roleRaw = typeof message.role === 'string' ? message.role.trim().toLowerCase() : ''; @@ -103,6 +137,13 @@ function appendAnthropicMessageToResponsesInput(target, message) { buffered.push({ type: textType, text: block.text }); continue; } + if (block.type === 'image' && role === 'user') { + const imageUrl = buildOpenAIImageUrlFromAnthropicSource(block.source); + if (imageUrl) { + buffered.push({ type: 'input_image', image_url: imageUrl }); + } + continue; + } if (block.type === 'tool_use' && typeof block.name === 'string' && block.name.trim()) { flushBuffered(); target.push({ @@ -124,6 +165,9 @@ function appendAnthropicMessageToResponsesInput(target, message) { }); continue; } + if (isDroppableAnthropicBridgeBlock(block)) { + continue; + } buffered.push({ type: textType, text: `[unsupported anthropic block: ${typeof block.type === 'string' ? block.type : 'unknown'}]` @@ -133,6 +177,23 @@ function appendAnthropicMessageToResponsesInput(target, message) { flushBuffered(); } +function mapAnthropicToolChoiceToChat(toolChoice) { + if (!toolChoice) return undefined; + if (typeof toolChoice === 'string') { + if (toolChoice === 'auto' || toolChoice === 'none') return toolChoice; + if (toolChoice === 'any') return 'required'; + return undefined; + } + if (!toolChoice || typeof toolChoice !== 'object') return undefined; + const type = typeof toolChoice.type === 'string' ? toolChoice.type.trim().toLowerCase() : ''; + if (type === 'auto' || type === 'none') return type; + if (type === 'any') return 'required'; + if (type === 'tool' && typeof toolChoice.name === 'string' && toolChoice.name.trim()) { + return { type: 'function', function: { name: toolChoice.name.trim() } }; + } + return undefined; +} + function mapAnthropicToolChoiceToResponses(toolChoice) { if (!toolChoice) return undefined; if (typeof toolChoice === 'string') { @@ -218,6 +279,277 @@ function buildBuiltinClaudeResponsesRequest(payload = {}) { return requestBody; } +function appendAnthropicMessageToChatMessages(target, message) { + if (!message || typeof message !== 'object') return; + const roleRaw = typeof message.role === 'string' ? message.role.trim().toLowerCase() : ''; + const role = roleRaw === 'assistant' ? 'assistant' : 'user'; + let textParts = []; + let contentParts = []; + const toolCalls = []; + + const flushTextPartsToContentParts = () => { + const content = textParts.join('\n\n').trim(); + textParts = []; + if (content) { + contentParts.push({ type: 'text', text: content }); + } + }; + + const buildContent = () => { + if (contentParts.length) { + flushTextPartsToContentParts(); + if (contentParts.length === 1 && contentParts[0].type === 'text') { + return contentParts[0].text; + } + return contentParts; + } + return textParts.join('\n\n').trim(); + }; + + const flushText = () => { + const content = buildContent(); + textParts = []; + contentParts = []; + if (!content || (Array.isArray(content) && !content.length)) return; + target.push({ role, content }); + }; + + for (const block of normalizeAnthropicContentBlocks(message.content)) { + if (!block || typeof block !== 'object') continue; + if (block.type === 'text' && typeof block.text === 'string' && block.text) { + textParts.push(block.text); + continue; + } + if (block.type === 'image' && role === 'user') { + flushTextPartsToContentParts(); + const imageUrl = buildOpenAIImageUrlFromAnthropicSource(block.source); + if (imageUrl) { + contentParts.push({ type: 'image_url', image_url: { url: imageUrl } }); + } + continue; + } + if (isDroppableAnthropicBridgeBlock(block)) { + continue; + } + if (block.type === 'tool_use' && role === 'assistant' && typeof block.name === 'string' && block.name.trim()) { + toolCalls.push({ + id: typeof block.id === 'string' && block.id.trim() + ? block.id.trim() + : `call_${crypto.randomBytes(8).toString('hex')}`, + type: 'function', + function: { + name: block.name.trim(), + arguments: safeJsonStringify(block.input && typeof block.input === 'object' ? block.input : {}) + } + }); + continue; + } + if (block.type === 'tool_result' && typeof block.tool_use_id === 'string' && block.tool_use_id.trim()) { + flushText(); + target.push({ + role: 'tool', + tool_call_id: block.tool_use_id.trim(), + content: stringifyAnthropicToolResultContent(block.content) + }); + continue; + } + textParts.push(`[unsupported anthropic block: ${typeof block.type === 'string' ? block.type : 'unknown'}]`); + } + + if (role === 'assistant' && toolCalls.length) { + const content = buildContent(); + target.push({ + role: 'assistant', + content: content || null, + tool_calls: toolCalls + }); + return; + } + flushText(); +} + +function buildBuiltinClaudeChatCompletionsRequest(payload = {}) { + const model = typeof payload.model === 'string' ? payload.model.trim() : ''; + if (!model) { + throw new Error('Anthropic messages 请求缺少 model'); + } + const messages = Array.isArray(payload.messages) ? payload.messages : []; + if (!messages.length) { + throw new Error('Anthropic messages 请求缺少 messages'); + } + + const requestBody = { model, messages: [] }; + const systemText = collectAnthropicTextContent(payload.system); + if (systemText) { + requestBody.messages.push({ role: 'system', content: systemText }); + } + for (const message of messages) { + appendAnthropicMessageToChatMessages(requestBody.messages, message); + } + + const maxTokens = parseInt(String(payload.max_tokens), 10); + if (Number.isFinite(maxTokens) && maxTokens > 0) { + requestBody.max_tokens = maxTokens; + } + if (Number.isFinite(payload.temperature)) { + requestBody.temperature = Number(payload.temperature); + } + if (Number.isFinite(payload.top_p)) { + requestBody.top_p = Number(payload.top_p); + } + if (Array.isArray(payload.stop_sequences) && payload.stop_sequences.length) { + const stop = payload.stop_sequences.filter((item) => typeof item === 'string' && item.trim()); + if (stop.length) requestBody.stop = stop; + } + if (Array.isArray(payload.tools) && payload.tools.length) { + requestBody.tools = payload.tools + .map((tool) => { + if (!tool || typeof tool !== 'object') return null; + const name = typeof tool.name === 'string' ? tool.name.trim() : ''; + if (!name) return null; + return { + type: 'function', + function: { + name, + description: typeof tool.description === 'string' ? tool.description : '', + parameters: isPlainObject(tool.input_schema) ? tool.input_schema : { type: 'object', properties: {} } + } + }; + }) + .filter(Boolean); + if (!requestBody.tools.length) delete requestBody.tools; + } + const toolChoice = mapAnthropicToolChoiceToChat(payload.tool_choice); + if (toolChoice !== undefined) { + requestBody.tool_choice = toolChoice; + } + requestBody.stream = false; + return requestBody; +} + + + +function appendAnthropicMessageToOllamaMessages(target, message) { + if (!message || typeof message !== 'object') return; + const roleRaw = typeof message.role === 'string' ? message.role.trim().toLowerCase() : ''; + const role = roleRaw === 'assistant' ? 'assistant' : 'user'; + let textParts = []; + let images = []; + const toolCalls = []; + + const flushText = () => { + const content = textParts.join('\n\n').trim(); + textParts = []; + if (!content && !images.length) return; + const msg = { role, content }; + if (images.length) msg.images = images; + target.push(msg); + images = []; + }; + + for (const block of normalizeAnthropicContentBlocks(message.content)) { + if (!block || typeof block !== 'object') continue; + if (block.type === 'text' && typeof block.text === 'string' && block.text) { + textParts.push(block.text); + continue; + } + if (block.type === 'image' && role === 'user') { + const image = collectAnthropicImageBase64(block.source); + if (image) images.push(image); + continue; + } + if (isDroppableAnthropicOllamaBlock(block)) { + continue; + } + if (block.type === 'tool_use' && role === 'assistant' && typeof block.name === 'string' && block.name.trim()) { + toolCalls.push({ + function: { + name: block.name.trim(), + arguments: block.input && typeof block.input === 'object' ? block.input : {} + } + }); + continue; + } + if (block.type === 'tool_result' && typeof block.tool_use_id === 'string' && block.tool_use_id.trim()) { + flushText(); + target.push({ + role: 'tool', + content: stringifyAnthropicToolResultContent(block.content), + tool_call_id: block.tool_use_id.trim() + }); + continue; + } + textParts.push(`[unsupported anthropic block: ${typeof block.type === 'string' ? block.type : 'unknown'}]`); + } + + if (role === 'assistant' && toolCalls.length) { + const content = textParts.join('\n\n').trim(); + const msg = { role: 'assistant', content, tool_calls: toolCalls }; + target.push(msg); + return; + } + flushText(); +} + +function buildBuiltinClaudeOllamaChatRequest(payload = {}) { + const model = typeof payload.model === 'string' ? payload.model.trim() : ''; + if (!model) { + throw new Error('Anthropic messages 请求缺少 model'); + } + const messages = Array.isArray(payload.messages) ? payload.messages : []; + if (!messages.length) { + throw new Error('Anthropic messages 请求缺少 messages'); + } + + const requestBody = { model, messages: [], stream: false }; + const systemText = collectAnthropicTextContent(payload.system); + if (systemText) { + requestBody.messages.push({ role: 'system', content: systemText }); + } + for (const message of messages) { + appendAnthropicMessageToOllamaMessages(requestBody.messages, message); + } + + const options = {}; + const maxTokens = parseInt(String(payload.max_tokens), 10); + if (Number.isFinite(maxTokens) && maxTokens > 0) options.num_predict = maxTokens; + if (Number.isFinite(payload.temperature)) options.temperature = Number(payload.temperature); + if (Number.isFinite(payload.top_p)) options.top_p = Number(payload.top_p); + if (Array.isArray(payload.stop_sequences) && payload.stop_sequences.length) { + const stop = payload.stop_sequences.filter((item) => typeof item === 'string' && item.trim()); + if (stop.length) options.stop = stop; + } + if (Object.keys(options).length) requestBody.options = options; + + if (isPlainObject(payload.thinking)) { + const thinkingType = typeof payload.thinking.type === 'string' + ? payload.thinking.type.trim().toLowerCase() + : ''; + if (thinkingType === 'enabled') requestBody.think = true; + if (thinkingType === 'disabled') requestBody.think = false; + } + + if (Array.isArray(payload.tools) && payload.tools.length) { + requestBody.tools = payload.tools + .map((tool) => { + if (!tool || typeof tool !== 'object') return null; + const name = typeof tool.name === 'string' ? tool.name.trim() : ''; + if (!name) return null; + return { + type: 'function', + function: { + name, + description: typeof tool.description === 'string' ? tool.description : '', + parameters: isPlainObject(tool.input_schema) ? tool.input_schema : { type: 'object', properties: {} } + } + }; + }) + .filter(Boolean); + if (!requestBody.tools.length) delete requestBody.tools; + } + return requestBody; +} + function parseJsonObjectLoose(value) { if (value && typeof value === 'object' && !Array.isArray(value)) { return value; @@ -296,6 +628,133 @@ function buildAnthropicStopReasonFromResponses(payload, content) { return 'end_turn'; } +function buildAnthropicUsageFromChatCompletion(payload) { + const usage = payload && payload.usage && typeof payload.usage === 'object' ? payload.usage : {}; + return { + input_tokens: readResponsesUsageValue(usage.prompt_tokens), + output_tokens: readResponsesUsageValue(usage.completion_tokens) + }; +} + +function normalizeChatMessageContentText(content) { + if (typeof content === 'string') return content; + if (Array.isArray(content)) { + return content.map((item) => { + if (typeof item === 'string') return item; + if (item && typeof item === 'object' && typeof item.text === 'string') return item.text; + return ''; + }).filter(Boolean).join('\n\n'); + } + return ''; +} + +function buildAnthropicStopReasonFromChatChoice(choice, content) { + if (Array.isArray(content) && content.some((item) => item && item.type === 'tool_use')) { + return 'tool_use'; + } + const finishReason = choice && typeof choice.finish_reason === 'string' ? choice.finish_reason : ''; + if (finishReason === 'length') return 'max_tokens'; + if (finishReason === 'tool_calls' || finishReason === 'function_call') return 'tool_use'; + return 'end_turn'; +} + +function buildAnthropicMessageFromChatCompletion(payload, requestPayload = {}) { + const choices = Array.isArray(payload && payload.choices) ? payload.choices : []; + const choice = choices.find((item) => item && item.message) || choices[0] || {}; + const chatMessage = choice && choice.message && typeof choice.message === 'object' ? choice.message : {}; + const content = []; + const text = normalizeChatMessageContentText(chatMessage.content); + if (text) { + content.push({ type: 'text', text }); + } + const toolCalls = Array.isArray(chatMessage.tool_calls) ? chatMessage.tool_calls : []; + for (const call of toolCalls) { + if (!call || typeof call !== 'object') continue; + const fn = call.function && typeof call.function === 'object' ? call.function : {}; + const name = typeof fn.name === 'string' ? fn.name : ''; + if (!name) continue; + content.push({ + type: 'tool_use', + id: typeof call.id === 'string' && call.id.trim() + ? call.id.trim() + : `toolu_${crypto.randomBytes(8).toString('hex')}`, + name, + input: parseJsonObjectLoose(fn.arguments) + }); + } + if (!content.length) { + const fallbackText = extractModelResponseText(payload); + if (fallbackText) content.push({ type: 'text', text: fallbackText }); + } + const usage = buildAnthropicUsageFromChatCompletion(payload); + return { + id: typeof payload.id === 'string' && payload.id.trim() + ? payload.id.trim() + : `msg_${crypto.randomBytes(8).toString('hex')}`, + type: 'message', + role: 'assistant', + model: typeof payload.model === 'string' && payload.model.trim() + ? payload.model.trim() + : (typeof requestPayload.model === 'string' ? requestPayload.model : ''), + content, + stop_reason: buildAnthropicStopReasonFromChatChoice(choice, content), + stop_sequence: null, + usage + }; +} + + +function buildAnthropicMessageFromOllamaChat(payload, requestPayload = {}) { + const ollamaMessage = payload && payload.message && typeof payload.message === 'object' ? payload.message : {}; + const content = []; + if (typeof ollamaMessage.thinking === 'string' && ollamaMessage.thinking) { + content.push({ type: 'thinking', thinking: ollamaMessage.thinking }); + } + if (typeof ollamaMessage.content === 'string' && ollamaMessage.content) { + content.push({ type: 'text', text: ollamaMessage.content }); + } + const toolCalls = Array.isArray(ollamaMessage.tool_calls) ? ollamaMessage.tool_calls : []; + for (const call of toolCalls) { + if (!call || typeof call !== 'object') continue; + const fn = call.function && typeof call.function === 'object' ? call.function : {}; + const name = typeof fn.name === 'string' ? fn.name : ''; + if (!name) continue; + content.push({ + type: 'tool_use', + id: typeof call.id === 'string' && call.id.trim() + ? call.id.trim() + : `toolu_${crypto.randomBytes(8).toString('hex')}`, + name, + input: parseJsonObjectLoose(fn.arguments) + }); + } + if (!content.length) { + const fallbackText = extractModelResponseText(payload); + if (fallbackText) content.push({ type: 'text', text: fallbackText }); + } + const usage = { + input_tokens: readResponsesUsageValue(payload && payload.prompt_eval_count), + output_tokens: readResponsesUsageValue(payload && payload.eval_count) + }; + const doneReason = payload && typeof payload.done_reason === 'string' ? payload.done_reason : ''; + return { + id: typeof payload.id === 'string' && payload.id.trim() + ? payload.id.trim() + : `msg_${crypto.randomBytes(8).toString('hex')}`, + type: 'message', + role: 'assistant', + model: typeof payload.model === 'string' && payload.model.trim() + ? payload.model.trim() + : (typeof requestPayload.model === 'string' ? requestPayload.model : ''), + content, + stop_reason: Array.isArray(content) && content.some((item) => item && item.type === 'tool_use') + ? 'tool_use' + : (doneReason === 'length' || doneReason === 'max_tokens' ? 'max_tokens' : 'end_turn'), + stop_sequence: null, + usage + }; +} + function buildAnthropicMessageFromResponses(payload, requestPayload = {}) { const content = collectAnthropicContentFromResponsesOutput(payload); const usage = buildAnthropicUsageFromResponses(payload); @@ -360,6 +819,28 @@ function buildAnthropicStreamEvents(message) { events.push({ event: 'content_block_stop', data: { type: 'content_block_stop', index } }); return; } + if (block.type === 'thinking') { + events.push({ + event: 'content_block_start', + data: { + type: 'content_block_start', + index, + content_block: { type: 'thinking', thinking: '' } + } + }); + if (typeof block.thinking === 'string' && block.thinking) { + events.push({ + event: 'content_block_delta', + data: { + type: 'content_block_delta', + index, + delta: { type: 'thinking_delta', thinking: block.thinking } + } + }); + } + events.push({ event: 'content_block_stop', data: { type: 'content_block_stop', index } }); + return; + } if (block.type === 'tool_use') { events.push({ event: 'content_block_start', @@ -423,6 +904,15 @@ function buildAnthropicModelsPayload(upstreamPayload) { }; } +function joinBuiltinClaudeProxyUpstreamUrl(baseUrl, pathSuffix) { + const suffix = typeof pathSuffix === 'string' ? pathSuffix.replace(/^\/+/, '') : ''; + if (suffix === 'api/tags' || suffix === 'api/chat') { + const normalized = normalizeBaseUrl(baseUrl); + return normalized ? `${normalized}/${suffix}` : ''; + } + return joinApiUrl(baseUrl, suffix); +} + function createBuiltinClaudeProxyRuntimeController(deps = {}) { const { BUILTIN_CLAUDE_PROXY_SETTINGS_FILE, @@ -433,7 +923,9 @@ function createBuiltinClaudeProxyRuntimeController(deps = {}) { HTTPS_KEEP_ALIVE_AGENT, readConfigOrVirtualDefault, resolveBuiltinProxyProviderName, - resolveAuthTokenFromCurrentProfile + resolveAuthTokenFromCurrentProfile, + OPENAI_BRIDGE_SETTINGS_FILE, + resolveOpenaiBridgeUpstream } = deps; if (!BUILTIN_CLAUDE_PROXY_SETTINGS_FILE) { @@ -462,18 +954,32 @@ function createBuiltinClaudeProxyRuntimeController(deps = {}) { const host = typeof merged.host === 'string' ? merged.host.trim() : ''; const port = parseInt(String(merged.port), 10); const provider = typeof merged.provider === 'string' ? merged.provider.trim() : ''; + const upstreamProviderName = typeof merged.upstreamProviderName === 'string' ? merged.upstreamProviderName.trim() : ''; + const upstreamBaseUrl = typeof merged.upstreamBaseUrl === 'string' ? merged.upstreamBaseUrl.trim() : ''; + const upstreamApiKey = typeof merged.upstreamApiKey === 'string' ? merged.upstreamApiKey.trim() : ''; const authSourceRaw = typeof merged.authSource === 'string' ? merged.authSource.trim().toLowerCase() : ''; + const targetApiRaw = typeof merged.targetApi === 'string' ? merged.targetApi.trim().toLowerCase() : ''; const timeoutMs = parseInt(String(merged.timeoutMs), 10); const authSource = authSourceRaw === 'profile' || authSourceRaw === 'none' || authSourceRaw === 'request' ? authSourceRaw : 'provider'; + let targetApi = 'responses'; + if (targetApiRaw === 'chat_completions' || targetApiRaw === 'chat-completions' || targetApiRaw === 'chat/completions') { + targetApi = 'chat_completions'; + } else if (targetApiRaw === 'ollama') { + targetApi = 'ollama'; + } return { enabled: merged.enabled !== false, host: host || DEFAULT_BUILTIN_CLAUDE_PROXY_SETTINGS.host, port: Number.isFinite(port) && port > 0 && port <= 65535 ? port : DEFAULT_BUILTIN_CLAUDE_PROXY_SETTINGS.port, provider, + upstreamProviderName, + upstreamBaseUrl, + upstreamApiKey, authSource, + targetApi, timeoutMs: Number.isFinite(timeoutMs) && timeoutMs >= 1000 ? timeoutMs : DEFAULT_BUILTIN_CLAUDE_PROXY_SETTINGS.timeoutMs @@ -507,6 +1013,17 @@ function createBuiltinClaudeProxyRuntimeController(deps = {}) { ...merged, provider: finalProvider }; + const payloadObject = isPlainObject(payload) ? payload : {}; + const payloadHasDirectUpstream = Object.prototype.hasOwnProperty.call(payloadObject, 'upstreamBaseUrl'); + const payloadSelectsProvider = Object.prototype.hasOwnProperty.call(payloadObject, 'provider') + || Object.prototype.hasOwnProperty.call(payloadObject, 'upstreamProviderName'); + const payloadSelectsResponses = Object.prototype.hasOwnProperty.call(payloadObject, 'targetApi') + && normalized.targetApi === 'responses'; + if (!payloadHasDirectUpstream && (payloadSelectsProvider || payloadSelectsResponses)) { + normalized.upstreamProviderName = ''; + normalized.upstreamBaseUrl = ''; + normalized.upstreamApiKey = ''; + } if (!options.skipWrite) { writeJsonAtomic(BUILTIN_CLAUDE_PROXY_SETTINGS_FILE, normalized); @@ -539,9 +1056,36 @@ function createBuiltinClaudeProxyRuntimeController(deps = {}) { return { error: `上游 provider 不存在: ${providerName}` }; } - const wireApi = normalizeWireApi(provider.wire_api); - if (wireApi !== 'responses') { - return { error: `Claude 兼容代理仅支持上游 responses provider: ${providerName}` }; + const targetApi = settings.targetApi === 'chat_completions' + ? 'chat_completions' + : (settings.targetApi === 'ollama' ? 'ollama' : 'responses'); + if (targetApi === 'responses') { + const wireApi = normalizeWireApi(provider.wire_api); + if (wireApi !== 'responses') { + return { error: `Claude 兼容代理仅支持上游 responses provider: ${providerName}` }; + } + } + + if (targetApi === 'chat_completions' + && provider.codexmate_bridge === 'openai' + && typeof resolveOpenaiBridgeUpstream === 'function' + && OPENAI_BRIDGE_SETTINGS_FILE) { + const bridgeUpstream = resolveOpenaiBridgeUpstream(OPENAI_BRIDGE_SETTINGS_FILE, providerName); + if (!bridgeUpstream || bridgeUpstream.error) { + return { error: bridgeUpstream && bridgeUpstream.error ? bridgeUpstream.error : `OpenAI bridge 配置未找到: ${providerName}` }; + } + const bridgeBaseUrl = typeof bridgeUpstream.baseUrl === 'string' ? bridgeUpstream.baseUrl.trim() : ''; + if (!bridgeBaseUrl || !isValidHttpUrl(bridgeBaseUrl)) { + return { error: `OpenAI 转换上游 base_url 无效: ${providerName}` }; + } + const bridgeToken = typeof bridgeUpstream.apiKey === 'string' ? bridgeUpstream.apiKey.trim() : ''; + return { + providerName, + baseUrl: normalizeBaseUrl(bridgeBaseUrl), + authHeader: bridgeToken ? (/^bearer\s+/i.test(bridgeToken) ? bridgeToken : `Bearer ${bridgeToken}`) : '', + extraHeaders: isPlainObject(bridgeUpstream.headers) ? bridgeUpstream.headers : {}, + targetApi + }; } const baseUrl = typeof provider.base_url === 'string' ? provider.base_url.trim() : ''; @@ -567,7 +1111,43 @@ function createBuiltinClaudeProxyRuntimeController(deps = {}) { return { providerName, baseUrl: normalizeBaseUrl(baseUrl), - authHeader + authHeader, + extraHeaders: {}, + targetApi + }; + } + + function resolveBuiltinClaudeProxyDirectUpstream(settings, payload = {}) { + const targetApi = settings.targetApi === 'chat_completions' + ? 'chat_completions' + : (settings.targetApi === 'ollama' ? 'ollama' : 'responses'); + const baseUrl = typeof payload.upstreamBaseUrl === 'string' && payload.upstreamBaseUrl.trim() + ? payload.upstreamBaseUrl.trim() + : (typeof settings.upstreamBaseUrl === 'string' ? settings.upstreamBaseUrl.trim() : ''); + if (!baseUrl) { + return null; + } + if (!isValidHttpUrl(baseUrl)) { + return { error: 'Claude 兼容代理上游 base_url 无效' }; + } + const token = typeof payload.upstreamApiKey === 'string' && payload.upstreamApiKey.trim() + ? payload.upstreamApiKey.trim() + : (typeof settings.upstreamApiKey === 'string' ? settings.upstreamApiKey.trim() : ''); + let authHeader = ''; + if (token) { + authHeader = /^bearer\s+/i.test(token) ? token : `Bearer ${token}`; + } + const providerName = typeof payload.upstreamProviderName === 'string' && payload.upstreamProviderName.trim() + ? payload.upstreamProviderName.trim() + : (typeof settings.upstreamProviderName === 'string' && settings.upstreamProviderName.trim() + ? settings.upstreamProviderName.trim() + : 'claude-config'); + return { + providerName, + baseUrl: normalizeBaseUrl(baseUrl), + authHeader, + extraHeaders: {}, + targetApi }; } @@ -656,7 +1236,7 @@ function createBuiltinClaudeProxyRuntimeController(deps = {}) { function requestBuiltinClaudeProxyUpstream(upstream, requestOptions = {}) { const pathSuffix = typeof requestOptions.pathSuffix === 'string' ? requestOptions.pathSuffix : ''; - const targetBase = joinApiUrl(upstream.baseUrl, pathSuffix); + const targetBase = joinBuiltinClaudeProxyUpstreamUrl(upstream.baseUrl, pathSuffix); if (!targetBase) { return Promise.reject(new Error('failed to build upstream URL')); } @@ -770,7 +1350,7 @@ function createBuiltinClaudeProxyRuntimeController(deps = {}) { ok: true, upstreamProvider: upstream.providerName, upstreamBaseUrl: upstream.baseUrl, - mode: 'anthropic-to-responses' + mode: upstream.targetApi === 'chat_completions' ? 'anthropic-to-chat-completions' : (upstream.targetApi === 'ollama' ? 'anthropic-to-ollama' : 'anthropic-to-responses') }); res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', @@ -794,8 +1374,9 @@ function createBuiltinClaudeProxyRuntimeController(deps = {}) { } const upstreamResponse = await requestBuiltinClaudeProxyUpstream(upstream, { method: 'GET', - pathSuffix: 'models', + pathSuffix: upstream.targetApi === 'ollama' ? 'api/tags' : 'models', authHeader: authResult.authHeader, + headers: upstream.extraHeaders, timeoutMs: settings.timeoutMs }); if (upstreamResponse.statusCode < 200 || upstreamResponse.statusCode >= 300) { @@ -828,12 +1409,20 @@ function createBuiltinClaudeProxyRuntimeController(deps = {}) { } const payload = await readJsonRequestBody(req); - const upstreamRequestBody = buildBuiltinClaudeResponsesRequest(payload); + const activeTargetApi = upstream.targetApi === 'ollama' || settings.targetApi === 'ollama' + ? 'ollama' + : (upstream.targetApi === 'chat_completions' || settings.targetApi === 'chat_completions' ? 'chat_completions' : 'responses'); + const upstreamRequestBody = activeTargetApi === 'ollama' + ? buildBuiltinClaudeOllamaChatRequest(payload) + : (activeTargetApi === 'chat_completions' + ? buildBuiltinClaudeChatCompletionsRequest(payload) + : buildBuiltinClaudeResponsesRequest(payload)); const upstreamResponse = await requestBuiltinClaudeProxyUpstream(upstream, { method: 'POST', - pathSuffix: 'responses', + pathSuffix: activeTargetApi === 'ollama' ? 'api/chat' : (activeTargetApi === 'chat_completions' ? 'chat/completions' : 'responses'), body: upstreamRequestBody, authHeader: authResult.authHeader, + headers: upstream.extraHeaders, timeoutMs: settings.timeoutMs }); @@ -847,7 +1436,11 @@ function createBuiltinClaudeProxyRuntimeController(deps = {}) { return; } - const anthropicMessage = buildAnthropicMessageFromResponses(upstreamResponse.payload || {}, payload); + const anthropicMessage = activeTargetApi === 'ollama' + ? buildAnthropicMessageFromOllamaChat(upstreamResponse.payload || {}, payload) + : (activeTargetApi === 'chat_completions' + ? buildAnthropicMessageFromChatCompletion(upstreamResponse.payload || {}, payload) + : buildAnthropicMessageFromResponses(upstreamResponse.payload || {}, payload)); if (payload.stream === true) { writeAnthropicStreamEvents(res, anthropicMessage); return; @@ -934,7 +1527,7 @@ function createBuiltinClaudeProxyRuntimeController(deps = {}) { return { error: saveResult.error }; } const settings = saveResult.settings; - const upstream = resolveBuiltinClaudeProxyUpstream(settings); + const upstream = resolveBuiltinClaudeProxyDirectUpstream(settings, payload) || resolveBuiltinClaudeProxyUpstream(settings); if (upstream.error) { return { error: upstream.error }; } @@ -946,7 +1539,7 @@ function createBuiltinClaudeProxyRuntimeController(deps = {}) { running: true, listenUrl: runtime.listenUrl, upstreamProvider: upstream.providerName, - mode: 'anthropic-to-responses', + mode: upstream.targetApi === 'chat_completions' ? 'anthropic-to-chat-completions' : (upstream.targetApi === 'ollama' ? 'anthropic-to-ollama' : 'anthropic-to-responses'), settings }; } catch (e) { @@ -995,7 +1588,7 @@ function createBuiltinClaudeProxyRuntimeController(deps = {}) { listenUrl: runtime.listenUrl, upstreamProvider: runtime.upstream.providerName, upstreamBaseUrl: runtime.upstream.baseUrl, - mode: 'anthropic-to-responses' + mode: runtime.upstream.targetApi === 'chat_completions' ? 'anthropic-to-chat-completions' : (runtime.upstream.targetApi === 'ollama' ? 'anthropic-to-ollama' : 'anthropic-to-responses') } : null }; @@ -1016,7 +1609,11 @@ function createBuiltinClaudeProxyRuntimeController(deps = {}) { module.exports = { createBuiltinClaudeProxyRuntimeController, buildBuiltinClaudeResponsesRequest, + buildBuiltinClaudeChatCompletionsRequest, + buildBuiltinClaudeOllamaChatRequest, buildAnthropicMessageFromResponses, + buildAnthropicMessageFromChatCompletion, + buildAnthropicMessageFromOllamaChat, buildAnthropicStreamEvents, buildAnthropicModelsPayload }; diff --git a/cli/update.js b/cli/update.js index 498b24ad..97197d5c 100644 --- a/cli/update.js +++ b/cli/update.js @@ -64,22 +64,41 @@ async function cmdToolUpdate(args = []) { } } -async function fetchLatestVersion() { +async function fetchLatestVersion(options = {}) { return new Promise((resolve, reject) => { + const timeoutMs = Number.isFinite(Number(options.timeoutMs)) + ? Math.max(0, Number(options.timeoutMs)) + : 5000; const url = 'https://registry.npmjs.org/codexmate/latest'; - https.get(url, (res) => { + let settled = false; + const finish = (fn, value) => { + if (settled) return; + settled = true; + fn(value); + }; + const req = https.get(url, (res) => { let data = ''; res.on('data', (chunk) => { data += chunk; }); res.on('end', () => { try { + if (res.statusCode && (res.statusCode < 200 || res.statusCode >= 300)) { + finish(reject, new Error(`NPM registry returned ${res.statusCode}`)); + return; + } const json = JSON.parse(data); - resolve(json.version || ''); + finish(resolve, json.version || ''); } catch (e) { - reject(new Error('解析 NPM 响应失败')); + finish(reject, new Error('解析 NPM 响应失败')); } }); - }).on('error', (err) => { - reject(err); + }); + if (timeoutMs > 0) { + req.setTimeout(timeoutMs, () => { + req.destroy(new Error('获取 NPM 最新版本超时')); + }); + } + req.on('error', (err) => { + finish(reject, err); }); }); } @@ -167,5 +186,6 @@ function updateViaStandalone(version) { } module.exports = { - cmdToolUpdate + cmdToolUpdate, + fetchLatestVersion }; diff --git a/tests/e2e/test-claude-proxy.js b/tests/e2e/test-claude-proxy.js index 6ff3aafd..98fb67f4 100644 --- a/tests/e2e/test-claude-proxy.js +++ b/tests/e2e/test-claude-proxy.js @@ -1,3 +1,5 @@ +const fs = require('fs'); +const path = require('path'); const http = require('http'); const { assert, closeServer } = require('./helpers'); @@ -98,6 +100,49 @@ function startClaudeProxyUpstreamServer() { return; } + if (req.method === 'POST' && requestPath === '/v1/chat/completions') { + if (parsedBody && parsedBody.model === 'error-model') { + const payload = JSON.stringify({ error: { message: 'chat upstream failed' } }); + res.writeHead(502, { + 'Content-Type': 'application/json; charset=utf-8', + 'Content-Length': Buffer.byteLength(payload, 'utf-8') + }); + res.end(payload, 'utf-8'); + return; + } + const isToolResponse = parsedBody + && Array.isArray(parsedBody.tools) + && parsedBody.tools.length > 0; + const payload = JSON.stringify({ + id: 'chatcmpl_e2e_1', + model: parsedBody && parsedBody.model ? parsedBody.model : 'unknown-model', + choices: [{ + finish_reason: isToolResponse ? 'tool_calls' : 'stop', + message: isToolResponse + ? { + role: 'assistant', + content: 'chat tool ready', + tool_calls: [{ + id: 'call_lookup', + type: 'function', + function: { name: 'lookup', arguments: '{"city":"tokyo"}' } + }] + } + : { role: 'assistant', content: 'chat proxy ok' } + }], + usage: { + prompt_tokens: 19, + completion_tokens: 8 + } + }); + res.writeHead(200, { + 'Content-Type': 'application/json; charset=utf-8', + 'Content-Length': Buffer.byteLength(payload, 'utf-8') + }); + res.end(payload, 'utf-8'); + return; + } + const notFound = JSON.stringify({ error: { message: 'not found' } }); res.writeHead(404, { 'Content-Type': 'application/json; charset=utf-8', @@ -115,7 +160,7 @@ function startClaudeProxyUpstreamServer() { } module.exports = async function testClaudeProxy(ctx) { - const { api } = ctx; + const { api, tmpHome } = ctx; const upstream = await startClaudeProxyUpstreamServer(); const proxyPort = 19000 + Math.floor(Math.random() * 1000); try { @@ -214,6 +259,152 @@ module.exports = async function testClaudeProxy(ctx) { const stopResult = await api('claude-proxy-stop'); assert(stopResult.success === true, 'claude-proxy-stop failed'); + + const chatStartResult = await api('claude-proxy-start', { + host: '127.0.0.1', + port: proxyPort, + provider: 'claude-proxy-e2e', + authSource: 'provider', + targetApi: 'chat_completions', + timeoutMs: 5000 + }); + assert(chatStartResult.success === true, 'claude-proxy-start chat_completions failed'); + assert(chatStartResult.mode === 'anthropic-to-chat-completions', 'claude-proxy-start chat mode mismatch'); + + const chatModelsResponse = await requestRaw(proxyPort, '/v1/models', { + headers: { + 'x-api-key': 'sk-anthropic-client', + 'anthropic-version': '2023-06-01' + } + }); + assert(chatModelsResponse.statusCode === 200, 'claude proxy chat /v1/models should succeed'); + const chatModelsPayload = JSON.parse(chatModelsResponse.body); + assert(Array.isArray(chatModelsPayload.data) && chatModelsPayload.data[0].id === 'gpt-4.1', 'claude proxy chat /v1/models model list mismatch'); + + const chatMessageResponse = await requestRaw(proxyPort, '/v1/messages', { + headers: { + 'x-api-key': 'sk-anthropic-client', + 'anthropic-version': '2023-06-01' + }, + body: { + model: 'DeepSeek-V4-pro', + max_tokens: 128, + system: 'system prompt', + messages: [ + { role: 'user', content: 'hello chat proxy' } + ] + } + }); + assert(chatMessageResponse.statusCode === 200, 'claude proxy chat /v1/messages should succeed'); + const chatMessagePayload = JSON.parse(chatMessageResponse.body); + assert(chatMessagePayload.content[0].text === 'chat proxy ok', 'claude proxy chat message text mismatch'); + assert(chatMessagePayload.usage.input_tokens === 19, 'claude proxy chat usage input mismatch'); + assert(chatMessagePayload.usage.output_tokens === 8, 'claude proxy chat usage output mismatch'); + + const chatStreamResponse = await requestRaw(proxyPort, '/v1/messages', { + headers: { + 'x-api-key': 'sk-anthropic-client', + 'anthropic-version': '2023-06-01' + }, + body: { + model: 'DeepSeek-V4-pro', + max_tokens: 128, + stream: true, + messages: [{ role: 'user', content: 'call chat tool please' }], + tools: [{ name: 'lookup', description: 'Lookup city', input_schema: { type: 'object', properties: { city: { type: 'string' } } } }], + tool_choice: { type: 'tool', name: 'lookup' } + } + }); + assert(chatStreamResponse.statusCode === 200, 'claude proxy chat streamed /v1/messages should succeed'); + assert(String(chatStreamResponse.headers['content-type'] || '').includes('text/event-stream'), 'claude proxy chat stream should return SSE content type'); + assert(chatStreamResponse.body.includes('chat tool ready'), 'claude proxy chat stream should include assistant text delta'); + assert(chatStreamResponse.body.includes('input_json_delta'), 'claude proxy chat stream should include tool json delta'); + + const chatErrorResponse = await requestRaw(proxyPort, '/v1/messages', { + headers: { + 'x-api-key': 'sk-anthropic-client', + 'anthropic-version': '2023-06-01' + }, + body: { + model: 'error-model', + max_tokens: 32, + messages: [{ role: 'user', content: 'trigger upstream error' }] + } + }); + assert(chatErrorResponse.statusCode === 502, 'claude proxy chat should preserve upstream error status'); + const chatErrorPayload = JSON.parse(chatErrorResponse.body); + assert(chatErrorPayload.error && chatErrorPayload.error.message === 'chat upstream failed', 'claude proxy chat should map upstream error message'); + + const upstreamChatMessages = upstream.requests.filter((item) => item.path === '/v1/chat/completions'); + assert(upstreamChatMessages.length >= 2, 'claude proxy should hit upstream /v1/chat/completions'); + assert(upstreamChatMessages[0].headers.authorization === 'Bearer sk-claude-upstream', 'claude proxy chat should use provider auth for upstream'); + assert(upstreamChatMessages[0].body.messages[0].role === 'system', 'claude proxy chat should map system prompt to system message'); + assert(upstreamChatMessages[0].body.max_tokens === 128, 'claude proxy chat should map max_tokens to max_tokens'); + assert(upstreamChatMessages[0].body.stream === false, 'claude proxy chat should synthesize Anthropic streaming locally'); + assert(upstreamChatMessages[1].body.tool_choice.function.name === 'lookup', 'claude proxy chat should map tool_choice'); + + const chatStopResult = await api('claude-proxy-stop'); + assert(chatStopResult.success === true, 'claude-proxy-stop chat failed'); + + const addBridgeProvider = await api('add-provider', { + name: 'claude-proxy-openai-bridge-e2e', + url: upstreamUrl, + key: 'sk-bridge-upstream', + model: 'gpt-4.1', + useTransform: true + }); + assert(addBridgeProvider.success === true, 'add-provider(claude-proxy-openai-bridge-e2e) failed'); + + const bridgeSettingsPath = path.join(tmpHome, '.codex', 'codexmate-openai-bridge.json'); + const savedBridgeSettings = fs.readFileSync(bridgeSettingsPath, 'utf-8'); + try { + fs.writeFileSync(bridgeSettingsPath, JSON.stringify({ providers: {} }, null, 2), 'utf-8'); + const missingBridgeStartResult = await api('claude-proxy-start', { + host: '127.0.0.1', + port: proxyPort, + provider: 'claude-proxy-openai-bridge-e2e', + authSource: 'provider', + targetApi: 'chat_completions', + timeoutMs: 5000 + }); + assert(missingBridgeStartResult.error && missingBridgeStartResult.error.includes('OpenAI 转换未配置'), 'claude proxy should return an explicit error when OpenAI bridge upstream is missing'); + const missingBridgeStatus = await api('claude-proxy-status'); + assert(missingBridgeStatus.running === false, 'failed OpenAI bridge resolution must not start Claude proxy runtime'); + } finally { + fs.writeFileSync(bridgeSettingsPath, savedBridgeSettings, 'utf-8'); + } + + const bridgeStartResult = await api('claude-proxy-start', { + host: '127.0.0.1', + port: proxyPort, + provider: 'claude-proxy-openai-bridge-e2e', + authSource: 'provider', + targetApi: 'chat_completions', + timeoutMs: 5000 + }); + assert(bridgeStartResult.success === true, 'claude-proxy-start chat_completions bridge failed'); + assert(bridgeStartResult.mode === 'anthropic-to-chat-completions', 'claude proxy bridge chat mode mismatch'); + + const bridgeMessageResponse = await requestRaw(proxyPort, '/v1/messages', { + headers: { + 'x-api-key': 'sk-anthropic-client', + 'anthropic-version': '2023-06-01' + }, + body: { + model: 'DeepSeek-V4-pro', + max_tokens: 64, + messages: [{ role: 'user', content: 'hello bridge chat proxy' }] + } + }); + assert(bridgeMessageResponse.statusCode === 200, 'claude proxy bridge chat /v1/messages should succeed'); + const bridgeMessagePayload = JSON.parse(bridgeMessageResponse.body); + assert(bridgeMessagePayload.content[0].text === 'chat proxy ok', 'claude proxy bridge chat text mismatch'); + + const upstreamBridgeChatMessages = upstream.requests.filter((item) => item.path === '/v1/chat/completions' && item.headers.authorization === 'Bearer sk-bridge-upstream'); + assert(upstreamBridgeChatMessages.length >= 1, 'claude proxy bridge chat should resolve direct OpenAI bridge upstream'); + + const bridgeStopResult = await api('claude-proxy-stop'); + assert(bridgeStopResult.success === true, 'claude-proxy-stop bridge chat failed'); } finally { try { await api('claude-proxy-stop'); @@ -221,6 +412,9 @@ module.exports = async function testClaudeProxy(ctx) { try { await api('delete-provider', { name: 'claude-proxy-e2e' }); } catch (_) {} + try { + await api('delete-provider', { name: 'claude-proxy-openai-bridge-e2e', allowManaged: true }); + } catch (_) {} await closeServer(upstream.server); } }; diff --git a/tests/e2e/test-claude.js b/tests/e2e/test-claude.js index 7d6339d4..8328252f 100644 --- a/tests/e2e/test-claude.js +++ b/tests/e2e/test-claude.js @@ -1,7 +1,9 @@ +const fs = require('fs'); +const path = require('path'); const { assert } = require('./helpers'); module.exports = async function testClaude(ctx) { - const { api, mockProviderUrl, claudeModel } = ctx; + const { api, mockProviderUrl, claudeModel, tmpHome } = ctx; // ========== Get Claude Settings Tests ========== const claudeSettingsInfo = await api('get-claude-settings'); @@ -43,6 +45,15 @@ module.exports = async function testClaude(ctx) { assert(claudeShareDefaultModel.payload, 'export-claude-share(default model) missing payload'); assert(claudeShareDefaultModel.payload.model === 'glm-4.7', 'export-claude-share should use default model'); + const claudeShareOllamaNoKey = await api('export-claude-share', { + config: { baseUrl: 'http://127.0.0.1:11434', apiKey: '', model: 'llama3.1:8b', targetApi: 'ollama' } + }); + assert(claudeShareOllamaNoKey.payload, 'export-claude-share(ollama no key) missing payload'); + assert(claudeShareOllamaNoKey.payload.baseUrl === 'http://127.0.0.1:11434', 'export-claude-share(ollama) baseUrl mismatch'); + assert(claudeShareOllamaNoKey.payload.apiKey === '', 'export-claude-share(ollama) should preserve empty api key'); + assert(claudeShareOllamaNoKey.payload.model === 'llama3.1:8b', 'export-claude-share(ollama) model mismatch'); + assert(claudeShareOllamaNoKey.payload.targetApi === 'ollama', 'export-claude-share(ollama) target api mismatch'); + // ========== Apply Claude Config Tests ========== const permissionsBefore = await api('get-tool-config-permissions'); assert(permissionsBefore.permissions && permissionsBefore.permissions.claude === false, 'claude write permission should default to disabled'); @@ -73,9 +84,68 @@ module.exports = async function testClaude(ctx) { assert(claudeSettingsAfter.baseUrl === mockProviderUrl, 'get-claude-settings baseUrl not updated'); assert(claudeSettingsAfter.model === 'new-model', 'get-claude-settings model not updated'); + const applyClaudeChatCompletions = await api('apply-claude-config', { + config: { name: 'claude-chat-direct', baseUrl: mockProviderUrl, apiKey: 'sk-new', model: 'new-model', targetApi: 'chat_completions' } + }); + assert(applyClaudeChatCompletions.success === true, 'apply-claude-config chat_completions failed'); + assert(applyClaudeChatCompletions.mode === 'claude-proxy', 'apply-claude-config chat_completions should use claude proxy mode'); + assert(applyClaudeChatCompletions.proxy && applyClaudeChatCompletions.proxy.mode === 'anthropic-to-chat-completions', 'apply-claude-config chat_completions proxy mode mismatch'); + + const claudeChatSettings = await api('get-claude-settings'); + assert(/^[a-f0-9]{48}$/.test(claudeChatSettings.apiKey), 'chat_completions should point Claude Code at a random local proxy token'); + assert(claudeChatSettings.apiKey !== 'sk-new', 'chat_completions should not write the upstream API key into Claude Code settings'); + assert(/http:\/\/127\.0\.0\.1:\d+$/.test(claudeChatSettings.baseUrl), 'chat_completions should point Claude Code at local proxy base url'); + assert(claudeChatSettings.model === 'new-model', 'chat_completions should preserve Claude model'); + + const claudeProxyStatus = await api('claude-proxy-status'); + assert(claudeProxyStatus.running === true, 'chat_completions apply should start Claude proxy'); + assert(claudeProxyStatus.settings && claudeProxyStatus.settings.host === '127.0.0.1', 'chat_completions apply should bind Claude proxy to loopback'); + assert(claudeProxyStatus.runtime && claudeProxyStatus.runtime.mode === 'anthropic-to-chat-completions', 'Claude proxy runtime mode mismatch after chat_completions apply'); + assert(claudeProxyStatus.runtime.upstreamProvider === 'claude-chat-direct', 'Claude proxy should use the applied Claude config as direct upstream'); + assert(claudeProxyStatus.runtime.upstreamBaseUrl === mockProviderUrl, 'Claude proxy direct upstream base url mismatch'); + + const applyClaudeOllama = await api('apply-claude-config', { + config: { name: 'local-ollama', baseUrl: mockProviderUrl, apiKey: '', model: 'llama3.1:8b', targetApi: 'ollama' } + }); + assert(applyClaudeOllama.success === true, 'apply-claude-config ollama without api key failed'); + assert(applyClaudeOllama.mode === 'claude-proxy', 'apply-claude-config ollama should use claude proxy mode'); + assert(applyClaudeOllama.targetApi === 'ollama', 'apply-claude-config ollama target api mismatch'); + assert(applyClaudeOllama.proxy && applyClaudeOllama.proxy.mode === 'anthropic-to-ollama', 'apply-claude-config ollama proxy mode mismatch'); + + const claudeOllamaSettings = await api('get-claude-settings'); + assert(/^[a-f0-9]{48}$/.test(claudeOllamaSettings.apiKey), 'ollama should point Claude Code at a random local proxy token even when upstream key is empty'); + assert(/http:\/\/127\.0\.0\.1:\d+$/.test(claudeOllamaSettings.baseUrl), 'ollama should point Claude Code at local proxy base url'); + assert(claudeOllamaSettings.model === 'llama3.1:8b', 'ollama should preserve Claude model'); + + const claudeOllamaProxyStatus = await api('claude-proxy-status'); + assert(claudeOllamaProxyStatus.running === true, 'ollama apply should start Claude proxy'); + assert(claudeOllamaProxyStatus.settings && claudeOllamaProxyStatus.settings.targetApi === 'ollama', 'ollama apply should persist saved Claude proxy targetApi'); + assert(claudeOllamaProxyStatus.runtime && claudeOllamaProxyStatus.runtime.mode === 'anthropic-to-ollama', 'Claude proxy runtime mode mismatch after ollama apply'); + assert(claudeOllamaProxyStatus.runtime.upstreamProvider === 'local-ollama', 'Ollama proxy should use the applied Claude config as direct upstream'); + assert(claudeOllamaProxyStatus.runtime.upstreamBaseUrl === mockProviderUrl, 'Ollama proxy direct upstream base url mismatch'); + // ========== Restore Original Settings ========== const restoreClaude = await api('apply-claude-config', { config: { baseUrl: mockProviderUrl, apiKey: 'sk-claude', model: claudeModel } }); assert(restoreClaude.success === true, 'restore-claude-config failed'); + const claudeProxyStatusAfterRestore = await api('claude-proxy-status'); + assert(claudeProxyStatusAfterRestore.running === false, 'responses apply should stop Claude proxy runtime'); + assert(claudeProxyStatusAfterRestore.settings && claudeProxyStatusAfterRestore.settings.targetApi === 'responses', 'responses apply should reset saved Claude proxy targetApi'); + + // ========== Chat Completions Apply Rollback Tests ========== + const claudeSettingsPath = path.join(tmpHome, '.claude', 'settings.json'); + const validClaudeSettings = fs.readFileSync(claudeSettingsPath, 'utf-8'); + try { + fs.writeFileSync(claudeSettingsPath, '{ invalid json', 'utf-8'); + const failedChatApply = await api('apply-claude-config', { + config: { name: 'claude-chat-direct', baseUrl: mockProviderUrl, apiKey: 'sk-new', model: 'new-model', targetApi: 'chat_completions' } + }); + assert(failedChatApply.success === false || failedChatApply.error, 'apply-claude-config should fail when Claude settings cannot be read'); + const claudeProxyStatusAfterFailedApply = await api('claude-proxy-status'); + assert(claudeProxyStatusAfterFailedApply.running === false, 'failed chat_completions apply should roll back the Claude proxy runtime'); + assert(claudeProxyStatusAfterFailedApply.settings && claudeProxyStatusAfterFailedApply.settings.targetApi === 'responses', 'failed chat_completions apply should reset saved Claude proxy targetApi'); + } finally { + fs.writeFileSync(claudeSettingsPath, validClaudeSettings, 'utf-8'); + } }; diff --git a/tests/unit/claude-proxy-adapter.test.mjs b/tests/unit/claude-proxy-adapter.test.mjs index 4eef5aea..97af667a 100644 --- a/tests/unit/claude-proxy-adapter.test.mjs +++ b/tests/unit/claude-proxy-adapter.test.mjs @@ -1,12 +1,23 @@ import assert from 'assert'; +import http from 'http'; +import net from 'net'; +import os from 'os'; +import path from 'path'; import { createRequire } from 'module'; +import { Agent as HttpAgent } from 'http'; +import { Agent as HttpsAgent } from 'https'; const require = createRequire(import.meta.url); const { buildBuiltinClaudeResponsesRequest, + buildBuiltinClaudeChatCompletionsRequest, + buildBuiltinClaudeOllamaChatRequest, buildAnthropicMessageFromResponses, + buildAnthropicMessageFromChatCompletion, + buildAnthropicMessageFromOllamaChat, buildAnthropicStreamEvents, - buildAnthropicModelsPayload + buildAnthropicModelsPayload, + createBuiltinClaudeProxyRuntimeController } = require('../../cli/claude-proxy'); test('buildBuiltinClaudeResponsesRequest maps anthropic messages/tools into responses payload', () => { @@ -62,6 +73,169 @@ test('buildBuiltinClaudeResponsesRequest maps anthropic messages/tools into resp ]); }); +test('buildBuiltinClaudeResponsesRequest preserves images and drops incompatible bridge-only blocks', () => { + const payload = buildBuiltinClaudeResponsesRequest({ + model: 'gpt-4.1', + messages: [{ + role: 'user', + content: [ + { type: 'text', text: 'describe this' }, + { type: 'thinking', thinking: 'hidden chain' }, + { type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'aW1n' } }, + { type: 'document', source: { type: 'text', data: 'unsupported doc' } } + ] + }] + }); + + assert.deepStrictEqual(payload.input, [{ + role: 'user', + content: [ + { type: 'input_text', text: 'describe this' }, + { type: 'input_image', image_url: 'data:image/png;base64,aW1n' } + ] + }]); +}); + +test('buildBuiltinClaudeChatCompletionsRequest maps anthropic messages/tools into chat completions payload', () => { + const payload = buildBuiltinClaudeChatCompletionsRequest({ + model: 'DeepSeek-V4-pro', + max_tokens: 128, + system: [{ type: 'text', text: 'system prompt' }], + messages: [ + { role: 'user', content: [{ type: 'text', text: 'hello' }] }, + { role: 'assistant', content: [{ type: 'tool_use', id: 'toolu_1', name: 'lookup', input: { q: 'hi' } }] }, + { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'toolu_1', content: 'tool ok' }] } + ], + tools: [{ name: 'lookup', description: 'Lookup', input_schema: { type: 'object', properties: { q: { type: 'string' } } } }], + tool_choice: { type: 'tool', name: 'lookup' }, + stop_sequences: ['END'] + }); + + assert.strictEqual(payload.model, 'DeepSeek-V4-pro'); + assert.strictEqual(payload.max_tokens, 128); + assert.strictEqual(payload.stream, false); + assert.deepStrictEqual(payload.stop, ['END']); + assert.deepStrictEqual(payload.tool_choice, { type: 'function', function: { name: 'lookup' } }); + assert.deepStrictEqual(payload.tools, [{ + type: 'function', + function: { + name: 'lookup', + description: 'Lookup', + parameters: { type: 'object', properties: { q: { type: 'string' } } } + } + }]); + assert.deepStrictEqual(payload.messages, [ + { role: 'system', content: 'system prompt' }, + { role: 'user', content: 'hello' }, + { + role: 'assistant', + content: null, + tool_calls: [{ id: 'toolu_1', type: 'function', function: { name: 'lookup', arguments: '{"q":"hi"}' } }] + }, + { role: 'tool', tool_call_id: 'toolu_1', content: 'tool ok' } + ]); +}); + + +test('buildBuiltinClaudeChatCompletionsRequest preserves multimodal user content for OpenAI-compatible upstreams', () => { + const payload = buildBuiltinClaudeChatCompletionsRequest({ + model: 'gpt-4o-mini', + max_tokens: 64, + messages: [{ + role: 'user', + content: [ + { type: 'text', text: 'describe this' }, + { type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'aW1n' } } + ] + }] + }); + + assert.deepStrictEqual(payload.messages, [{ + role: 'user', + content: [ + { type: 'text', text: 'describe this' }, + { type: 'image_url', image_url: { url: 'data:image/png;base64,aW1n' } } + ] + }]); +}); + +test('buildBuiltinClaudeChatCompletionsRequest drops incompatible bridge-only blocks instead of sending them as text', () => { + const payload = buildBuiltinClaudeChatCompletionsRequest({ + model: 'gpt-4o-mini', + messages: [{ + role: 'user', + content: [ + { type: 'text', text: 'visible' }, + { type: 'thinking', thinking: 'hidden chain' }, + { type: 'document', source: { type: 'text', data: 'unsupported doc' } } + ] + }] + }); + + assert.deepStrictEqual(payload.messages, [{ role: 'user', content: 'visible' }]); +}); + +test('buildBuiltinClaudeOllamaChatRequest maps anthropic messages/tools into Ollama /api/chat payload', () => { + const payload = buildBuiltinClaudeOllamaChatRequest({ + model: 'qwen2.5-coder:7b', + max_tokens: 80, + temperature: 0.2, + top_p: 0.9, + system: [{ type: 'text', text: 'system prompt' }], + messages: [ + { + role: 'user', + content: [ + { type: 'text', text: 'hello' }, + { type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'aW1n' } } + ] + }, + { role: 'assistant', content: [{ type: 'tool_use', id: 'toolu_1', name: 'lookup', input: { q: 'hi' } }] }, + { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'toolu_1', content: 'tool ok' }] } + ], + tools: [{ name: 'lookup', description: 'Lookup', input_schema: { type: 'object', properties: { q: { type: 'string' } } } }], + stop_sequences: ['END'], + thinking: { type: 'disabled' } + }); + + assert.strictEqual(payload.model, 'qwen2.5-coder:7b'); + assert.strictEqual(payload.stream, false); + assert.strictEqual(payload.think, false); + assert.deepStrictEqual(payload.options, { num_predict: 80, temperature: 0.2, top_p: 0.9, stop: ['END'] }); + assert.deepStrictEqual(payload.messages, [ + { role: 'system', content: 'system prompt' }, + { role: 'user', content: 'hello', images: ['aW1n'] }, + { role: 'assistant', content: '', tool_calls: [{ function: { name: 'lookup', arguments: { q: 'hi' } } }] }, + { role: 'tool', content: 'tool ok', tool_call_id: 'toolu_1' } + ]); + assert.deepStrictEqual(payload.tools, [{ + type: 'function', + function: { + name: 'lookup', + description: 'Lookup', + parameters: { type: 'object', properties: { q: { type: 'string' } } } + } + }]); +}); + +test('buildBuiltinClaudeOllamaChatRequest drops incompatible bridge-only blocks and keeps base64 images', () => { + const payload = buildBuiltinClaudeOllamaChatRequest({ + model: 'qwen2.5-coder:7b', + messages: [{ + role: 'user', + content: [ + { type: 'text', text: 'describe this' }, + { type: 'thinking', thinking: 'hidden chain' }, + { type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'aW1n' } }, + { type: 'video', source: { type: 'url', url: 'https://example.com/demo.mp4' } }, + { type: 'document', source: { type: 'text', data: 'unsupported doc' } } + ] + }] + }); + + assert.deepStrictEqual(payload.messages, [{ role: 'user', content: 'describe this', images: ['aW1n'] }]); +}); + test('buildAnthropicMessageFromResponses maps responses output into anthropic message', () => { const message = buildAnthropicMessageFromResponses({ id: 'resp_123', @@ -100,6 +274,60 @@ test('buildAnthropicMessageFromResponses maps responses output into anthropic me ]); }); +test('buildAnthropicMessageFromChatCompletion maps chat completion output into anthropic message', () => { + const message = buildAnthropicMessageFromChatCompletion({ + id: 'chatcmpl_123', + model: 'DeepSeek-V4-pro', + choices: [{ + finish_reason: 'tool_calls', + message: { + role: 'assistant', + content: 'proxy ok', + tool_calls: [{ + id: 'call_9', + type: 'function', + function: { name: 'lookup', arguments: '{"city":"tokyo"}' } + }] + } + }], + usage: { prompt_tokens: 11, completion_tokens: 5 } + }, { model: 'fallback' }); + + assert.strictEqual(message.id, 'chatcmpl_123'); + assert.strictEqual(message.model, 'DeepSeek-V4-pro'); + assert.strictEqual(message.stop_reason, 'tool_use'); + assert.deepStrictEqual(message.usage, { input_tokens: 11, output_tokens: 5 }); + assert.deepStrictEqual(message.content, [ + { type: 'text', text: 'proxy ok' }, + { type: 'tool_use', id: 'call_9', name: 'lookup', input: { city: 'tokyo' } } + ]); +}); + + +test('buildAnthropicMessageFromOllamaChat maps Ollama /api/chat output into anthropic message', () => { + const message = buildAnthropicMessageFromOllamaChat({ + model: 'qwen2.5-coder:7b', + message: { + role: 'assistant', + thinking: 'checking the tool result', + content: 'proxy ok', + tool_calls: [{ function: { name: 'lookup', arguments: { city: 'tokyo' } } }] + }, + prompt_eval_count: 9, + eval_count: 4 + }, { model: 'fallback' }); + + assert.strictEqual(message.model, 'qwen2.5-coder:7b'); + assert.strictEqual(message.stop_reason, 'tool_use'); + assert.deepStrictEqual(message.usage, { input_tokens: 9, output_tokens: 4 }); + assert.deepStrictEqual(message.content, [ + { type: 'thinking', thinking: 'checking the tool result' }, + { type: 'text', text: 'proxy ok' }, + { type: 'tool_use', id: message.content[2].id, name: 'lookup', input: { city: 'tokyo' } } + ]); + assert(message.content[2].id.startsWith('toolu_')); +}); + test('buildAnthropicStreamEvents emits anthropic-style SSE events', () => { const events = buildAnthropicStreamEvents({ id: 'msg_1', @@ -107,6 +335,7 @@ test('buildAnthropicStreamEvents emits anthropic-style SSE events', () => { role: 'assistant', model: 'gpt-4.1', content: [ + { type: 'thinking', thinking: 'brief hidden reasoning' }, { type: 'text', text: 'hello stream' }, { type: 'tool_use', id: 'toolu_stream', name: 'lookup', input: { city: 'tokyo' } } ], @@ -126,13 +355,17 @@ test('buildAnthropicStreamEvents emits anthropic-style SSE events', () => { 'content_block_start', 'content_block_delta', 'content_block_stop', + 'content_block_start', + 'content_block_delta', + 'content_block_stop', 'message_delta', 'message_stop' ]); - assert.strictEqual(events[2].data.delta.text, 'hello stream'); - assert.strictEqual(events[5].data.delta.partial_json, '{"city":"tokyo"}'); - assert.strictEqual(events[7].data.delta.stop_reason, 'tool_use'); - assert.strictEqual(events[7].data.usage.output_tokens, 4); + assert.strictEqual(events[2].data.delta.thinking, 'brief hidden reasoning'); + assert.strictEqual(events[5].data.delta.text, 'hello stream'); + assert.strictEqual(events[8].data.delta.partial_json, '{"city":"tokyo"}'); + assert.strictEqual(events[10].data.delta.stop_reason, 'tool_use'); + assert.strictEqual(events[10].data.usage.output_tokens, 4); }); test('buildAnthropicModelsPayload reshapes upstream models list', () => { @@ -158,3 +391,298 @@ test('buildAnthropicModelsPayload reshapes upstream models list', () => { } ]); }); + +function listenForTest(server, host = '127.0.0.1', port = 0) { + return new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(port, host, () => { + server.removeListener('error', reject); + resolve(server.address()); + }); + }); +} + +function closeServerForTest(server) { + return new Promise((resolve) => server.close(() => resolve())); +} + +function findFreePortForTest() { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + const port = server.address().port; + server.close(() => resolve(port)); + }); + }); +} + +test('builtin Claude proxy sends Ollama traffic to /api paths without injecting /v1', async () => { + const upstreamRequests = []; + const upstream = http.createServer((req, res) => { + const chunks = []; + req.on('data', (chunk) => chunks.push(chunk)); + req.on('end', () => { + upstreamRequests.push({ method: req.method, url: req.url, body: Buffer.concat(chunks).toString('utf8') }); + res.setHeader('content-type', 'application/json; charset=utf-8'); + if (req.method === 'GET' && req.url === '/api/tags') { + res.end(JSON.stringify({ models: [{ name: 'qwen2.5-coder:7b' }] })); + return; + } + if (req.method === 'POST' && req.url === '/api/chat') { + res.end(JSON.stringify({ + model: 'qwen2.5-coder:7b', + message: { role: 'assistant', thinking: 'short thought', content: 'proxy ok' }, + done: true, + done_reason: 'stop', + prompt_eval_count: 3, + eval_count: 2 + })); + return; + } + res.statusCode = 404; + res.end(JSON.stringify({ error: `unexpected ${req.method} ${req.url}` })); + }); + }); + + const upstreamAddress = await listenForTest(upstream); + const proxyPort = await findFreePortForTest(); + const settingsFile = path.join(os.tmpdir(), `codexmate-claude-proxy-test-${Date.now()}-${Math.random().toString(16).slice(2)}.json`); + const controller = createBuiltinClaudeProxyRuntimeController({ + BUILTIN_CLAUDE_PROXY_SETTINGS_FILE: settingsFile, + DEFAULT_BUILTIN_CLAUDE_PROXY_SETTINGS: { + enabled: true, + host: '127.0.0.1', + port: proxyPort, + provider: '', + authSource: 'none', + targetApi: 'ollama', + timeoutMs: 30000 + }, + BUILTIN_PROXY_PROVIDER_NAME: 'codexmate-builtin-proxy', + MAX_API_BODY_SIZE: 1024 * 1024, + HTTP_KEEP_ALIVE_AGENT: new HttpAgent({ keepAlive: false }), + HTTPS_KEEP_ALIVE_AGENT: new HttpsAgent({ keepAlive: false }), + readConfigOrVirtualDefault: () => ({ config: { model_providers: {}, model_provider: '' } }), + resolveBuiltinProxyProviderName: () => '', + resolveAuthTokenFromCurrentProfile: () => '', + OPENAI_BRIDGE_SETTINGS_FILE: '', + resolveOpenaiBridgeUpstream: () => null + }); + + try { + const start = await controller.startBuiltinClaudeProxyRuntime({ + host: '127.0.0.1', + port: proxyPort, + authSource: 'none', + targetApi: 'ollama', + upstreamBaseUrl: `http://127.0.0.1:${upstreamAddress.port}`, + upstreamProviderName: 'ollama-test' + }); + assert.strictEqual(start.success, true, JSON.stringify(start)); + + const modelsRes = await fetch(`${start.listenUrl}/v1/models`); + assert.strictEqual(modelsRes.status, 200); + const models = await modelsRes.json(); + assert.deepStrictEqual(models.data.map((item) => item.id), ['qwen2.5-coder:7b']); + + const messageRes = await fetch(`${start.listenUrl}/v1/messages`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + model: 'qwen2.5-coder:7b', + thinking: { type: 'disabled' }, + messages: [{ + role: 'user', + content: [ + { type: 'text', text: 'hello' }, + { type: 'video', source: { type: 'url', url: 'https://example.com/demo.mp4' } } + ] + }] + }) + }); + assert.strictEqual(messageRes.status, 200); + const message = await messageRes.json(); + assert.deepStrictEqual(message.content, [ + { type: 'thinking', thinking: 'short thought' }, + { type: 'text', text: 'proxy ok' } + ]); + + assert.deepStrictEqual(upstreamRequests.map((item) => `${item.method} ${item.url}`), [ + 'GET /api/tags', + 'POST /api/chat' + ]); + const chatBody = JSON.parse(upstreamRequests[1].body); + assert.strictEqual(chatBody.think, false); + assert.deepStrictEqual(chatBody.messages, [{ role: 'user', content: 'hello' }]); + } finally { + await controller.stopBuiltinClaudeProxyRuntime(); + await closeServerForTest(upstream); + } +}); + +test('builtin Claude proxy maps Ollama upstream errors into Anthropic errors', async () => { + const upstream = http.createServer((req, res) => { + req.resume(); + res.statusCode = 429; + res.setHeader('content-type', 'application/json; charset=utf-8'); + res.end(JSON.stringify({ + StatusCode: 429, + Status: '429 Too Many Requests', + error: 'weekly usage limit reached' + })); + }); + + const upstreamAddress = await listenForTest(upstream); + const proxyPort = await findFreePortForTest(); + const settingsFile = path.join(os.tmpdir(), `codexmate-claude-proxy-error-${Date.now()}-${Math.random().toString(16).slice(2)}.json`); + const controller = createBuiltinClaudeProxyRuntimeController({ + BUILTIN_CLAUDE_PROXY_SETTINGS_FILE: settingsFile, + DEFAULT_BUILTIN_CLAUDE_PROXY_SETTINGS: { + enabled: true, + host: '127.0.0.1', + port: proxyPort, + provider: '', + authSource: 'none', + targetApi: 'ollama', + timeoutMs: 30000 + }, + BUILTIN_PROXY_PROVIDER_NAME: 'codexmate-builtin-proxy', + MAX_API_BODY_SIZE: 1024 * 1024, + HTTP_KEEP_ALIVE_AGENT: new HttpAgent({ keepAlive: false }), + HTTPS_KEEP_ALIVE_AGENT: new HttpsAgent({ keepAlive: false }), + readConfigOrVirtualDefault: () => ({ config: { model_providers: {}, model_provider: '' } }), + resolveBuiltinProxyProviderName: () => '', + resolveAuthTokenFromCurrentProfile: () => '', + OPENAI_BRIDGE_SETTINGS_FILE: '', + resolveOpenaiBridgeUpstream: () => null + }); + + try { + const start = await controller.startBuiltinClaudeProxyRuntime({ + host: '127.0.0.1', + port: proxyPort, + authSource: 'none', + targetApi: 'ollama', + upstreamBaseUrl: `http://127.0.0.1:${upstreamAddress.port}`, + upstreamProviderName: 'ollama-error-test' + }); + assert.strictEqual(start.success, true, JSON.stringify(start)); + + const messageRes = await fetch(`${start.listenUrl}/v1/messages`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + model: 'gemma4:31b-cloud', + messages: [{ role: 'user', content: 'hello' }] + }) + }); + assert.strictEqual(messageRes.status, 429); + const errorPayload = await messageRes.json(); + assert.deepStrictEqual(errorPayload, { + type: 'error', + error: { type: 'api_error', message: 'weekly usage limit reached' } + }); + } finally { + await controller.stopBuiltinClaudeProxyRuntime(); + await closeServerForTest(upstream); + try { require('fs').rmSync(settingsFile, { force: true }); } catch (_) {} + } +}); + +test('builtin Claude proxy can restart Ollama direct upstream from saved share import settings', async () => { + const upstreamRequests = []; + const upstream = http.createServer((req, res) => { + const chunks = []; + req.on('data', (chunk) => chunks.push(chunk)); + req.on('end', () => { + upstreamRequests.push({ method: req.method, url: req.url, body: Buffer.concat(chunks).toString('utf8') }); + res.setHeader('content-type', 'application/json; charset=utf-8'); + if (req.method === 'POST' && req.url === '/api/chat') { + res.end(JSON.stringify({ + model: 'qwen2.5-coder:7b', + message: { role: 'assistant', content: 'restored ollama ok' }, + done: true, + done_reason: 'stop' + })); + return; + } + res.statusCode = 404; + res.end(JSON.stringify({ error: `unexpected ${req.method} ${req.url}` })); + }); + }); + + const upstreamAddress = await listenForTest(upstream); + const proxyPort = await findFreePortForTest(); + const settingsFile = path.join(os.tmpdir(), `codexmate-claude-proxy-restart-${Date.now()}-${Math.random().toString(16).slice(2)}.json`); + const baseOptions = { + BUILTIN_CLAUDE_PROXY_SETTINGS_FILE: settingsFile, + DEFAULT_BUILTIN_CLAUDE_PROXY_SETTINGS: { + enabled: true, + host: '127.0.0.1', + port: proxyPort, + provider: '', + upstreamProviderName: '', + upstreamBaseUrl: '', + upstreamApiKey: '', + authSource: 'none', + targetApi: 'ollama', + timeoutMs: 30000 + }, + BUILTIN_PROXY_PROVIDER_NAME: 'codexmate-builtin-proxy', + MAX_API_BODY_SIZE: 1024 * 1024, + HTTP_KEEP_ALIVE_AGENT: new HttpAgent({ keepAlive: false }), + HTTPS_KEEP_ALIVE_AGENT: new HttpsAgent({ keepAlive: false }), + readConfigOrVirtualDefault: () => ({ config: { model_providers: {}, model_provider: '' } }), + resolveBuiltinProxyProviderName: () => '', + resolveAuthTokenFromCurrentProfile: () => '', + OPENAI_BRIDGE_SETTINGS_FILE: '', + resolveOpenaiBridgeUpstream: () => null + }; + + const firstController = createBuiltinClaudeProxyRuntimeController(baseOptions); + let secondController = null; + try { + const firstStart = await firstController.startBuiltinClaudeProxyRuntime({ + host: '127.0.0.1', + port: proxyPort, + authSource: 'none', + targetApi: 'ollama', + upstreamBaseUrl: `http://127.0.0.1:${upstreamAddress.port}`, + upstreamProviderName: 'ollama-share-import' + }); + assert.strictEqual(firstStart.success, true, JSON.stringify(firstStart)); + await firstController.stopBuiltinClaudeProxyRuntime(); + + const saved = JSON.parse(require('fs').readFileSync(settingsFile, 'utf-8')); + assert.strictEqual(saved.targetApi, 'ollama'); + assert.strictEqual(saved.upstreamBaseUrl, `http://127.0.0.1:${upstreamAddress.port}`); + assert.strictEqual(saved.upstreamProviderName, 'ollama-share-import'); + + secondController = createBuiltinClaudeProxyRuntimeController(baseOptions); + const restoredStart = await secondController.startBuiltinClaudeProxyRuntime({}); + assert.strictEqual(restoredStart.success, true, JSON.stringify(restoredStart)); + assert.strictEqual(restoredStart.upstreamProvider, 'ollama-share-import'); + assert.strictEqual(restoredStart.mode, 'anthropic-to-ollama'); + + const messageRes = await fetch(`${restoredStart.listenUrl}/v1/messages`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + model: 'qwen2.5-coder:7b', + messages: [{ role: 'user', content: [{ type: 'text', text: 'hello' }] }] + }) + }); + assert.strictEqual(messageRes.status, 200); + const message = await messageRes.json(); + assert.deepStrictEqual(message.content, [{ type: 'text', text: 'restored ollama ok' }]); + assert.deepStrictEqual(upstreamRequests.map((item) => `${item.method} ${item.url}`), ['POST /api/chat']); + } finally { + await firstController.stopBuiltinClaudeProxyRuntime(); + if (secondController) { + await secondController.stopBuiltinClaudeProxyRuntime(); + } + await closeServerForTest(upstream); + try { require('fs').rmSync(settingsFile, { force: true }); } catch (_) {} + } +}); diff --git a/tests/unit/claude-settings-sync.test.mjs b/tests/unit/claude-settings-sync.test.mjs index e74de2f0..1528af77 100644 --- a/tests/unit/claude-settings-sync.test.mjs +++ b/tests/unit/claude-settings-sync.test.mjs @@ -12,6 +12,7 @@ const { createI18nMethods } = await import( const appSource = readBundledWebUiScript(); const claudeConfigModuleSource = readProjectFile('web-ui/modules/app.methods.claude-config.mjs'); +const cliSource = readProjectFile('cli.js'); function escapeRegExp(value) { return String(value || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); @@ -331,6 +332,40 @@ ${extractMethodAsFunction(appSource, 'canSubmitClaudeConfig')}`; assert.strictEqual(canSubmitClaudeConfig.call(context, 'edit'), true); }); +test('Claude validation allows Ollama target without api key', () => { + const support = claudeValidationSupportSource(); + const fieldErrorSource = `${support} +${extractMethodAsFunction(appSource, 'claudeConfigFieldError')}`; + const canSubmitSource = `${support} +${extractMethodAsFunction(appSource, 'canSubmitClaudeConfig')}`; + const claudeConfigFieldError = instantiateFunction(fieldErrorSource, 'claudeConfigFieldError'); + const canSubmitClaudeConfig = instantiateFunction(canSubmitSource, 'canSubmitClaudeConfig'); + const context = { + newClaudeConfig: { + name: 'Local Ollama', + apiKey: '', + externalCredentialType: '', + baseUrl: 'http://127.0.0.1:11434', + model: 'deepseek-v4-pro:cloud', + targetApi: 'ollama' + }, + editingConfig: { + name: 'Edit Ollama', + apiKey: '', + externalCredentialType: '', + baseUrl: 'http://127.0.0.1:11434', + model: 'deepseek-v4-pro:cloud', + targetApi: 'ollama' + }, + claudeConfigs: {} + }; + + assert.strictEqual(claudeConfigFieldError.call(context, 'add', 'apiKey'), ''); + assert.strictEqual(canSubmitClaudeConfig.call(context, 'add'), true); + assert.strictEqual(claudeConfigFieldError.call(context, 'edit', 'apiKey'), ''); + assert.strictEqual(canSubmitClaudeConfig.call(context, 'edit'), true); +}); + test('openEditConfigModal carries external credential metadata into edit validation state', () => { const source = extractMethodAsFunction(appSource, 'openEditConfigModal'); const openEditConfigModal = instantiateFunction(source, 'openEditConfigModal'); @@ -355,7 +390,8 @@ test('openEditConfigModal carries external credential metadata into edit validat apiKey: '', externalCredentialType: 'auth-token', baseUrl: 'https://api.anthropic.com', - model: 'claude-opus-4-6' + model: 'claude-opus-4-6', + targetApi: 'responses' }); assert.strictEqual(context.showEditClaudeConfigKey, false); assert.strictEqual(context.showEditConfigModal, true); @@ -608,8 +644,10 @@ test('saveAndApplyConfig writes the edited Claude model through apply api', asyn params: { config: { apiKey: 'sk-test', + externalCredentialType: '', baseUrl: 'https://api.example.com/anthropic', model: 'claude-model-from-edit', + targetApi: 'responses', name: 'UI Claude Use' } } @@ -671,6 +709,74 @@ test('saveAndApplyConfig saves external credential config without api key', asyn assert.deepStrictEqual(messages, [{ msg: '已保存(未填写 API Key)', type: 'info' }]); }); +test('saveAndApplyConfig applies ollama config without api key through proxy', async () => { + const source = extractClaudeMethodAsFunction(appSource, 'saveAndApplyConfig'); + const applyCalls = []; + const saveAndApplyConfig = instantiateFunction(source, 'saveAndApplyConfig', { + api: async (action, params) => { + applyCalls.push({ action, params }); + return { success: true, mode: 'claude-proxy', targetApi: 'ollama' }; + } + }); + + const messages = []; + let saveCount = 0; + let closed = false; + let refreshCount = 0; + const i18nMethods = createI18nMethods(); + const context = { + ...i18nMethods, + lang: 'zh', + editingConfig: { + name: 'Local Ollama', + apiKey: '', + externalCredentialType: '', + baseUrl: 'http://127.0.0.1:11434/', + model: 'deepseek-v4-pro:cloud', + targetApi: 'ollama' + }, + claudeConfigs: { + 'Local Ollama': { + apiKey: '', + baseUrl: 'https://old.example.com/anthropic', + model: 'old-model', + targetApi: 'responses' + } + }, + currentClaudeConfig: 'Local Ollama', + _lastAppliedClaudeKey: '', + mergeClaudeConfig(existing, updates) { + return { ...existing, ...updates }; + }, + saveClaudeConfigs() { saveCount += 1; }, + closeEditConfigModal() { closed = true; }, + refreshClaudeModelContext() { refreshCount += 1; }, + showMessage(msg, type) { messages.push({ msg, type }); } + }; + + await saveAndApplyConfig.call(context); + + assert.strictEqual(context.claudeConfigs['Local Ollama'].baseUrl, 'http://127.0.0.1:11434'); + assert.strictEqual(context.claudeConfigs['Local Ollama'].targetApi, 'ollama'); + assert.strictEqual(saveCount, 1); + assert.strictEqual(closed, true); + assert.strictEqual(refreshCount, 1); + assert.deepStrictEqual(applyCalls, [{ + action: 'apply-claude-config', + params: { + config: { + apiKey: '', + externalCredentialType: '', + baseUrl: 'http://127.0.0.1:11434', + model: 'deepseek-v4-pro:cloud', + targetApi: 'ollama', + name: 'Local Ollama' + } + } + }]); + assert.deepStrictEqual(messages, [{ msg: 'Claude 配置已生效', type: 'success' }]); +}); + test('applyClaudeConfig reports informative message for external credential only config', async () => { const source = extractMethodAsFunction(appSource, 'applyClaudeConfig'); const applyClaudeConfig = instantiateFunction(source, 'applyClaudeConfig', { @@ -711,6 +817,60 @@ test('applyClaudeConfig reports informative message for external credential only assert.deepStrictEqual(result, messages[0]); }); +test('applyClaudeConfig applies ollama config without api key', async () => { + const source = extractMethodAsFunction(appSource, 'applyClaudeConfig'); + const applyCalls = []; + const applyClaudeConfig = instantiateFunction(source, 'applyClaudeConfig', { + api: async (action, params) => { + applyCalls.push({ action, params }); + return { success: true, mode: 'claude-proxy', targetApi: 'ollama' }; + } + }); + + const messages = []; + let refreshCount = 0; + const i18nMethods = createI18nMethods(); + const context = { + ...i18nMethods, + lang: 'zh', + claudeConfigs: { + ollama: { + apiKey: '', + baseUrl: 'http://127.0.0.1:11434', + model: 'deepseek-v4-pro:cloud', + targetApi: 'ollama' + } + }, + currentClaudeConfig: '', + _lastAppliedClaudeKey: '', + refreshClaudeModelContext: () => { + refreshCount += 1; + }, + showMessage: (msg, type) => { + messages.push({ msg, type }); + return { msg, type }; + } + }; + + await applyClaudeConfig.call(context, 'ollama'); + + assert.strictEqual(context.currentClaudeConfig, 'ollama'); + assert.strictEqual(refreshCount, 1); + assert.deepStrictEqual(applyCalls, [{ + action: 'apply-claude-config', + params: { + config: { + apiKey: '', + baseUrl: 'http://127.0.0.1:11434', + model: 'deepseek-v4-pro:cloud', + targetApi: 'ollama', + name: 'ollama' + } + } + }]); + assert.deepStrictEqual(messages, [{ msg: '配置已应用', type: 'success' }]); +}); + test('onClaudeModelChange applies external credential config without api key', () => { const source = extractMethodAsFunction(appSource, 'onClaudeModelChange'); const onClaudeModelChange = instantiateFunction(source, 'onClaudeModelChange'); @@ -819,7 +979,8 @@ test('mergeClaudeConfig preserves externalCredentialType across edits without ap model: typeof config.model === 'string' ? config.model.trim() : '', authToken: typeof config.authToken === 'string' ? config.authToken.trim() : '', useKey: typeof config.useKey === 'string' ? config.useKey.trim() : '', - externalCredentialType: typeof config.externalCredentialType === 'string' ? config.externalCredentialType.trim() : '' + externalCredentialType: typeof config.externalCredentialType === 'string' ? config.externalCredentialType.trim() : '', + targetApi: typeof config.targetApi === 'string' ? config.targetApi.trim() : 'responses' }) }; @@ -839,7 +1000,8 @@ test('mergeClaudeConfig preserves externalCredentialType across edits without ap baseUrl: 'https://api.anthropic.com/', model: 'claude-3-7-sonnet', hasKey: true, - externalCredentialType: 'auth-token' + externalCredentialType: 'auth-token', + targetApi: 'responses' }); }); @@ -1149,3 +1311,27 @@ test('loadClaudeModels skips remote fetch for external-credential config without assert.strictEqual(context.claudeModelsHasCurrent, true); assert.deepStrictEqual(messages, []); }); + +test('applyToClaudeSettings does not proxy chat completions through default Anthropic URL', () => { + const startIndex = cliSource.indexOf('async function applyToClaudeSettings'); + assert.notStrictEqual(startIndex, -1); + const endIndex = cliSource.indexOf('async function cmdClaude', startIndex); + assert.notStrictEqual(endIndex, -1); + const source = cliSource.slice(startIndex, endIndex); + assert.match(source, /const configuredBaseUrl = typeof config\.baseUrl === 'string' \? config\.baseUrl\.trim\(\) : '';/); + assert.match(source, /targetApi === 'chat_completions' && !configuredBaseUrl && !upstreamProviderName/); + assert.match(source, /chat_completions 模式需要显式的上游 Base URL 或可解析的 provider 名称/); + assert.match(source, /\.\.\.\(configuredBaseUrl \? \{ upstreamBaseUrl: configuredBaseUrl \} : \{\}\)/); +}); + +test('MCP Claude config schema allows Ollama without API key only for ollama target', () => { + const toolIndex = cliSource.indexOf("name: 'codexmate.claude.config.apply'"); + assert.notStrictEqual(toolIndex, -1); + const schemaEnd = cliSource.indexOf('handler: async (args = {}) => applyToClaudeSettings(args || {})', toolIndex); + assert.notStrictEqual(schemaEnd, -1); + const schemaSource = cliSource.slice(toolIndex, schemaEnd); + assert.match(schemaSource, /allOf:\s*\[\{/); + assert.match(schemaSource, /properties:\s*\{ targetApi:\s*\{ type: 'string', pattern: '\^\[\\\\s\]\*\[oO\]\[lL\]\[lL\]\[aA\]\[mM\]\[aA\]\[\\\\s\]\*\$' \} \}/); + assert.match(schemaSource, /then:\s*\{ required:\s*\['apiKey'\] \}/); + assert.doesNotMatch(schemaSource, /required:\s*\['apiKey'\],\s*additionalProperties/); +}); diff --git a/tests/unit/config-tabs-ui.test.mjs b/tests/unit/config-tabs-ui.test.mjs index 7062d039..4313f921 100644 --- a/tests/unit/config-tabs-ui.test.mjs +++ b/tests/unit/config-tabs-ui.test.mjs @@ -135,6 +135,9 @@ test('config template keeps expected config tabs in top and side navigation', () assert.doesNotMatch(sideGhostTab, /@keydown/); assert.ok(html.indexOf('id="side-tab-trash"') < html.indexOf('id="side-tab-new"'), 'ghost side tab should remain after trash tab to reserve end scroll space'); assert.match(html, /
Codex Mate v\{\{ appVersion \}\}<\/span><\/div>/); + assert.match(html, /v-if="isAppUpdateAvailable\(\)"[\s\S]*class="side-update-notice"[\s\S]*@click="openAppUpdateDocs"/); + assert.match(html, /\{\{\s*appUpdateNoticeText\(\)\s*\}\}<\/span>/); + assert.match(html, /\{\{\s*appUpdateNoticeMeta\(\)\s*\}\}<\/span>/); assert.doesNotMatch(html, /class="brand-block" tabindex="0"/); assert.doesNotMatch(html, /appVersion && brandHovered/); assert.doesNotMatch(html, /brandHovered = true/); @@ -142,6 +145,8 @@ test('config template keeps expected config tabs in top and side navigation', () assert.match(styles, /\.side-item-ghost\s*\{[\s\S]*opacity:\s*0;[\s\S]*pointer-events:\s*none;[\s\S]*user-select:\s*none;/); assert.match(styles, /\.brand-kicker\s*\{[\s\S]*font-size:\s*15px;/); assert.match(styles, /\.brand-version\s*\{[\s\S]*font-size:\s*13px;/); + assert.match(styles, /\.side-update-notice\s*\{[\s\S]*margin-top:\s*12px;[\s\S]*background:\s*rgba\(255, 255, 255, 0\.52\);/); + assert.match(styles, /\.side-update-meta\s*\{[\s\S]*text-overflow:\s*ellipsis;/); } assert.match(html, /id="side-tab-market"/); assert.match(html, /id="tab-market"/); @@ -282,6 +287,22 @@ test('config template keeps expected config tabs in top and side navigation', () html, /:class="\['card', \{ active: currentClaudeConfig === name \}\]"[\s\S]*@click="applyClaudeConfig\(name\)"[\s\S]*@keydown\.enter\.self\.prevent="applyClaudeConfig\(name\)"[\s\S]*@keydown\.space\.self\.prevent="applyClaudeConfig\(name\)"[\s\S]*tabindex="0"[\s\S]*role="button"[\s\S]*:aria-current="currentClaudeConfig === name \? 'true' : null"/ ); + assert.match( + html, + /
\{\{\s*name\.charAt\(0\)\.toUpperCase\(\)\s*\}\}<\/span><\/div>/ + ); + assert.match( + html, + /
+
diff --git a/web-ui/partials/index/modals-basic.html b/web-ui/partials/index/modals-basic.html index 7221bc54..382fa4fe 100644 --- a/web-ui/partials/index/modals-basic.html +++ b/web-ui/partials/index/modals-basic.html @@ -165,6 +165,15 @@
{{ claudeConfigFieldError('add', 'model') }}
+
+ + +
{{ t('claude.targetApi.hint') }}
+
@@ -204,6 +213,15 @@
{{ claudeConfigFieldError('edit', 'model') }}
+
+ + +
{{ t('claude.targetApi.hint') }}
+
@@ -260,4 +278,3 @@
- diff --git a/web-ui/partials/index/panel-config-claude.html b/web-ui/partials/index/panel-config-claude.html index 5f1ebc07..8f8a617c 100644 --- a/web-ui/partials/index/panel-config-claude.html +++ b/web-ui/partials/index/panel-config-claude.html @@ -34,6 +34,7 @@