From 4b24338b17108fbbb6deb6b7ce5e748d876648c8 Mon Sep 17 00:00:00 2001 From: awsl233777 Date: Sun, 24 May 2026 12:46:58 +0000 Subject: [PATCH 1/4] feat: add claude chat completions proxy mode --- cli.js | 5 +- cli/claude-proxy.js | 273 +++++++++++++++++- tests/e2e/test-claude-proxy.js | 172 +++++++++++ tests/unit/claude-proxy-adapter.test.mjs | 71 +++++ tests/unit/claude-settings-sync.test.mjs | 6 +- tests/unit/web-ui-logic.test.mjs | 13 +- web-ui/app.js | 10 +- web-ui/logic.claude.mjs | 7 +- web-ui/modules/app.methods.claude-config.mjs | 15 +- web-ui/modules/app.methods.startup-claude.mjs | 3 +- web-ui/partials/index/modals-basic.html | 17 +- .../partials/index/panel-config-claude.html | 1 + web-ui/res/web-ui-render.precompiled.js | 34 ++- 13 files changed, 598 insertions(+), 29 deletions(-) diff --git a/cli.js b/cli.js index 531b8dd6..c13ab7a2 100644 --- a/cli.js +++ b/cli.js @@ -290,6 +290,7 @@ const DEFAULT_BUILTIN_CLAUDE_PROXY_SETTINGS = Object.freeze({ port: 8328, provider: '', authSource: 'provider', + targetApi: 'responses', timeoutMs: 30000 }); const CLI_INSTALL_TARGETS = Object.freeze([ @@ -5488,7 +5489,9 @@ const { HTTPS_KEEP_ALIVE_AGENT, readConfigOrVirtualDefault, resolveBuiltinProxyProviderName, - resolveAuthTokenFromCurrentProfile + resolveAuthTokenFromCurrentProfile, + OPENAI_BRIDGE_SETTINGS_FILE, + resolveOpenaiBridgeUpstream }); function applyBuiltinProxyProvider(params = {}) { diff --git a/cli/claude-proxy.js b/cli/claude-proxy.js index 0317b0b1..c5be3f5f 100644 --- a/cli/claude-proxy.js +++ b/cli/claude-proxy.js @@ -133,6 +133,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 +235,122 @@ 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 = []; + const toolCalls = []; + + const flushText = () => { + const content = textParts.join('\n\n').trim(); + if (!content) return; + target.push({ role, content }); + textParts = []; + }; + + 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 === '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 = textParts.join('\n\n').trim(); + 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 parseJsonObjectLoose(value) { if (value && typeof value === 'object' && !Array.isArray(value)) { return value; @@ -296,6 +429,81 @@ 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 buildAnthropicMessageFromResponses(payload, requestPayload = {}) { const content = collectAnthropicContentFromResponsesOutput(payload); const usage = buildAnthropicUsageFromResponses(payload); @@ -433,7 +641,9 @@ function createBuiltinClaudeProxyRuntimeController(deps = {}) { HTTPS_KEEP_ALIVE_AGENT, readConfigOrVirtualDefault, resolveBuiltinProxyProviderName, - resolveAuthTokenFromCurrentProfile + resolveAuthTokenFromCurrentProfile, + OPENAI_BRIDGE_SETTINGS_FILE, + resolveOpenaiBridgeUpstream } = deps; if (!BUILTIN_CLAUDE_PROXY_SETTINGS_FILE) { @@ -463,10 +673,14 @@ function createBuiltinClaudeProxyRuntimeController(deps = {}) { const port = parseInt(String(merged.port), 10); const provider = typeof merged.provider === 'string' ? merged.provider.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'; + const targetApi = targetApiRaw === 'chat_completions' || targetApiRaw === 'chat-completions' || targetApiRaw === 'chat/completions' + ? 'chat_completions' + : 'responses'; return { enabled: merged.enabled !== false, @@ -474,6 +688,7 @@ function createBuiltinClaudeProxyRuntimeController(deps = {}) { port: Number.isFinite(port) && port > 0 && port <= 65535 ? port : DEFAULT_BUILTIN_CLAUDE_PROXY_SETTINGS.port, provider, authSource, + targetApi, timeoutMs: Number.isFinite(timeoutMs) && timeoutMs >= 1000 ? timeoutMs : DEFAULT_BUILTIN_CLAUDE_PROXY_SETTINGS.timeoutMs @@ -539,9 +754,34 @@ 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' : '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.error }; + } + 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 +807,9 @@ function createBuiltinClaudeProxyRuntimeController(deps = {}) { return { providerName, baseUrl: normalizeBaseUrl(baseUrl), - authHeader + authHeader, + extraHeaders: {}, + targetApi }; } @@ -770,7 +1012,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' : 'anthropic-to-responses' }); res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', @@ -796,6 +1038,7 @@ function createBuiltinClaudeProxyRuntimeController(deps = {}) { method: 'GET', pathSuffix: 'models', authHeader: authResult.authHeader, + headers: upstream.extraHeaders, timeoutMs: settings.timeoutMs }); if (upstreamResponse.statusCode < 200 || upstreamResponse.statusCode >= 300) { @@ -828,12 +1071,16 @@ function createBuiltinClaudeProxyRuntimeController(deps = {}) { } const payload = await readJsonRequestBody(req); - const upstreamRequestBody = buildBuiltinClaudeResponsesRequest(payload); + const isChatCompletionsMode = upstream.targetApi === 'chat_completions' || settings.targetApi === 'chat_completions'; + const upstreamRequestBody = isChatCompletionsMode + ? buildBuiltinClaudeChatCompletionsRequest(payload) + : buildBuiltinClaudeResponsesRequest(payload); const upstreamResponse = await requestBuiltinClaudeProxyUpstream(upstream, { method: 'POST', - pathSuffix: 'responses', + pathSuffix: isChatCompletionsMode ? 'chat/completions' : 'responses', body: upstreamRequestBody, authHeader: authResult.authHeader, + headers: upstream.extraHeaders, timeoutMs: settings.timeoutMs }); @@ -847,7 +1094,9 @@ function createBuiltinClaudeProxyRuntimeController(deps = {}) { return; } - const anthropicMessage = buildAnthropicMessageFromResponses(upstreamResponse.payload || {}, payload); + const anthropicMessage = isChatCompletionsMode + ? buildAnthropicMessageFromChatCompletion(upstreamResponse.payload || {}, payload) + : buildAnthropicMessageFromResponses(upstreamResponse.payload || {}, payload); if (payload.stream === true) { writeAnthropicStreamEvents(res, anthropicMessage); return; @@ -946,7 +1195,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' : 'anthropic-to-responses', settings }; } catch (e) { @@ -995,7 +1244,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' : 'anthropic-to-responses' } : null }; @@ -1016,7 +1265,9 @@ function createBuiltinClaudeProxyRuntimeController(deps = {}) { module.exports = { createBuiltinClaudeProxyRuntimeController, buildBuiltinClaudeResponsesRequest, + buildBuiltinClaudeChatCompletionsRequest, buildAnthropicMessageFromResponses, + buildAnthropicMessageFromChatCompletion, buildAnthropicStreamEvents, buildAnthropicModelsPayload }; diff --git a/tests/e2e/test-claude-proxy.js b/tests/e2e/test-claude-proxy.js index 7ca08463..30de9216 100644 --- a/tests/e2e/test-claude-proxy.js +++ b/tests/e2e/test-claude-proxy.js @@ -98,6 +98,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', @@ -213,6 +256,132 @@ 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', + useTransform: true + }); + assert(addBridgeProvider.success === true, 'add-provider(claude-proxy-openai-bridge-e2e) failed'); + + 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'); @@ -220,6 +389,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/unit/claude-proxy-adapter.test.mjs b/tests/unit/claude-proxy-adapter.test.mjs index 4eef5aea..0abd1e9f 100644 --- a/tests/unit/claude-proxy-adapter.test.mjs +++ b/tests/unit/claude-proxy-adapter.test.mjs @@ -4,7 +4,9 @@ import { createRequire } from 'module'; const require = createRequire(import.meta.url); const { buildBuiltinClaudeResponsesRequest, + buildBuiltinClaudeChatCompletionsRequest, buildAnthropicMessageFromResponses, + buildAnthropicMessageFromChatCompletion, buildAnthropicStreamEvents, buildAnthropicModelsPayload } = require('../../cli/claude-proxy'); @@ -62,6 +64,46 @@ test('buildBuiltinClaudeResponsesRequest maps anthropic messages/tools into resp ]); }); +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('buildAnthropicMessageFromResponses maps responses output into anthropic message', () => { const message = buildAnthropicMessageFromResponses({ id: 'resp_123', @@ -100,6 +142,35 @@ 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('buildAnthropicStreamEvents emits anthropic-style SSE events', () => { const events = buildAnthropicStreamEvents({ id: 'msg_1', diff --git a/tests/unit/claude-settings-sync.test.mjs b/tests/unit/claude-settings-sync.test.mjs index 4693ab2f..f37ce8b5 100644 --- a/tests/unit/claude-settings-sync.test.mjs +++ b/tests/unit/claude-settings-sync.test.mjs @@ -487,7 +487,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' }) }; @@ -507,7 +508,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' }); }); diff --git a/tests/unit/web-ui-logic.test.mjs b/tests/unit/web-ui-logic.test.mjs index 711a3d69..94a3cdd6 100644 --- a/tests/unit/web-ui-logic.test.mjs +++ b/tests/unit/web-ui-logic.test.mjs @@ -51,7 +51,7 @@ test('normalizeClaudeValue trims strings and ignores non-string', () => { test('normalizeClaudeConfig trims all fields', () => { const cfg = normalizeClaudeConfig({ apiKey: ' key ', baseUrl: ' url ', model: ' model ', authToken: ' token ', useKey: ' yes ', externalCredentialType: ' auth-token ' }); - assert.deepStrictEqual(cfg, { apiKey: 'key', baseUrl: 'url', model: 'model', authToken: 'token', useKey: 'yes', externalCredentialType: 'auth-token' }); + assert.deepStrictEqual(cfg, { apiKey: 'key', baseUrl: 'url', model: 'model', authToken: 'token', useKey: 'yes', externalCredentialType: 'auth-token', targetApi: 'responses' }); }); test('normalizeClaudeConfig infers external credential type from authToken and useKey', () => { @@ -63,7 +63,8 @@ test('normalizeClaudeConfig infers external credential type from authToken and u model: '', authToken: 'token', useKey: '', - externalCredentialType: 'auth-token' + externalCredentialType: 'auth-token', + targetApi: 'responses' } ); assert.deepStrictEqual( @@ -74,11 +75,17 @@ test('normalizeClaudeConfig infers external credential type from authToken and u model: '', authToken: '', useKey: '1', - externalCredentialType: 'claude-code-use-key' + externalCredentialType: 'claude-code-use-key', + targetApi: 'responses' } ); }); +test('normalizeClaudeConfig accepts chat completions target api aliases', () => { + assert.strictEqual(normalizeClaudeConfig({ targetApi: 'chat/completions' }).targetApi, 'chat_completions'); + assert.strictEqual(normalizeClaudeConfig({ targetApi: 'chat-completions' }).targetApi, 'chat_completions'); +}); + test('normalizeClaudeSettingsEnv trims settings env', () => { const env = { ANTHROPIC_API_KEY: ' key ', diff --git a/web-ui/app.js b/web-ui/app.js index bb24d331..e0c4a1c5 100644 --- a/web-ui/app.js +++ b/web-ui/app.js @@ -275,12 +275,13 @@ document.addEventListener('DOMContentLoaded', () => { currentClaudeConfig: '', currentClaudeModel: '', claudeCustomModelDraft: '', - editingConfig: { name: '', apiKey: '', baseUrl: '', model: '' }, + editingConfig: { name: '', apiKey: '', baseUrl: '', model: '', targetApi: 'responses' }, claudeConfigs: { '智谱GLM': { apiKey: '', baseUrl: 'https://open.bigmodel.cn/api/anthropic', model: 'glm-4.7', + targetApi: 'responses', hasKey: false } }, @@ -288,7 +289,8 @@ document.addEventListener('DOMContentLoaded', () => { name: '', apiKey: '', baseUrl: '', - model: '' + model: '', + targetApi: 'responses' }, currentOpenclawConfig: '', openclawConfigs: { @@ -534,6 +536,10 @@ document.addEventListener('DOMContentLoaded', () => { config.apiKey = ''; config.hasKey = false; } + const targetApiRaw = typeof config.targetApi === 'string' ? config.targetApi.trim().toLowerCase() : ''; + config.targetApi = targetApiRaw === 'chat_completions' || targetApiRaw === 'chat-completions' || targetApiRaw === 'chat/completions' + ? 'chat_completions' + : 'responses'; } localStorage.setItem('claudeConfigs', JSON.stringify(this.claudeConfigs)); } catch (e) { diff --git a/web-ui/logic.claude.mjs b/web-ui/logic.claude.mjs index 254157ac..e007d2bd 100644 --- a/web-ui/logic.claude.mjs +++ b/web-ui/logic.claude.mjs @@ -69,13 +69,18 @@ export function normalizeClaudeConfig(config) { const useKey = normalizeClaudeValue(safe.useKey); const externalCredentialType = normalizeClaudeValue(safe.externalCredentialType) || (apiKey ? '' : (authToken ? 'auth-token' : (useKey ? 'claude-code-use-key' : ''))); + const targetApiRaw = normalizeClaudeValue(safe.targetApi).toLowerCase(); + const targetApi = targetApiRaw === 'chat_completions' || targetApiRaw === 'chat-completions' || targetApiRaw === 'chat/completions' + ? 'chat_completions' + : 'responses'; return { apiKey, baseUrl: normalizeClaudeValue(safe.baseUrl), model: normalizeClaudeValue(safe.model), authToken, useKey, - externalCredentialType + externalCredentialType, + targetApi }; } diff --git a/web-ui/modules/app.methods.claude-config.mjs b/web-ui/modules/app.methods.claude-config.mjs index b95fc87b..fbe2ff3b 100644 --- a/web-ui/modules/app.methods.claude-config.mjs +++ b/web-ui/modules/app.methods.claude-config.mjs @@ -52,7 +52,8 @@ export function createClaudeConfigMethods(options = {}) { name: '', apiKey: config.apiKey || '', baseUrl: config.baseUrl || '', - model: config.model || '' + model: config.model || '', + targetApi: config.targetApi || 'responses' }; this.showClaudeConfigModal = true; }, @@ -63,7 +64,8 @@ export function createClaudeConfigMethods(options = {}) { name: name, apiKey: config.apiKey || '', baseUrl: config.baseUrl || '', - model: config.model || '' + model: config.model || '', + targetApi: config.targetApi || 'responses' }; this.showEditClaudeConfigKey = false; this.showEditConfigModal = true; @@ -83,7 +85,7 @@ export function createClaudeConfigMethods(options = {}) { closeEditConfigModal() { this.showEditConfigModal = false; this.showEditClaudeConfigKey = false; - this.editingConfig = { name: '', apiKey: '', baseUrl: '', model: '' }; + this.editingConfig = { name: '', apiKey: '', baseUrl: '', model: '', targetApi: 'responses' }; }, toggleEditClaudeConfigKey() { @@ -105,7 +107,7 @@ export function createClaudeConfigMethods(options = {}) { return; } - const _claudeKey = `${name}|${config.apiKey || ""}|${config.baseUrl || ""}|${config.model || ""}`; + const _claudeKey = `${name}|${config.apiKey || ""}|${config.baseUrl || ""}|${config.model || ""}|${config.targetApi || "responses"}`; try { const res = await api('apply-claude-config', { config }); if (res.error || res.success === false) { @@ -181,7 +183,7 @@ export function createClaudeConfigMethods(options = {}) { return this.showMessage('请先配置 API Key', 'error'); } - const _claudeKey2 = `${name}|${config.apiKey || ""}|${config.baseUrl || ""}|${config.model || ""}`; + const _claudeKey2 = `${name}|${config.apiKey || ""}|${config.baseUrl || ""}|${config.model || ""}|${config.targetApi || "responses"}`; try { const res = await api('apply-claude-config', { config }); if (res.error || res.success === false) { @@ -203,7 +205,8 @@ export function createClaudeConfigMethods(options = {}) { name: '', apiKey: '', baseUrl: '', - model: '' + model: '', + targetApi: 'responses' }; }, diff --git a/web-ui/modules/app.methods.startup-claude.mjs b/web-ui/modules/app.methods.startup-claude.mjs index 12f1795d..b0ad8026 100644 --- a/web-ui/modules/app.methods.startup-claude.mjs +++ b/web-ui/modules/app.methods.startup-claude.mjs @@ -247,7 +247,8 @@ export function createStartupClaudeMethods(options = {}) { baseUrl: next.baseUrl, model: next.model || previous.model || 'glm-4.7', hasKey: !!(next.apiKey || externalCredentialType), - externalCredentialType + externalCredentialType, + targetApi: next.targetApi || previous.targetApi || 'responses' }; }, diff --git a/web-ui/partials/index/modals-basic.html b/web-ui/partials/index/modals-basic.html index dabcf6d4..9231c133 100644 --- a/web-ui/partials/index/modals-basic.html +++ b/web-ui/partials/index/modals-basic.html @@ -133,6 +133,14 @@ +
+ + +
选择 Chat Completions 时由 Claude 兼容代理内建转换,不修改 Codex provider 的 wire_api。
+
@@ -164,6 +172,14 @@
+
+ + +
选择 Chat Completions 时由 Claude 兼容代理内建转换,不修改 Codex provider 的 wire_api。
+
@@ -220,4 +236,3 @@
- diff --git a/web-ui/partials/index/panel-config-claude.html b/web-ui/partials/index/panel-config-claude.html index 7505c97e..3e284cf7 100644 --- a/web-ui/partials/index/panel-config-claude.html +++ b/web-ui/partials/index/panel-config-claude.html @@ -129,6 +129,7 @@
{{ name }}
{{ config.model || t('claude.model.unset') }}
+
OpenAI Chat Completions
{{ config.baseUrl }}
diff --git a/web-ui/res/web-ui-render.precompiled.js b/web-ui/res/web-ui-render.precompiled.js index ce66e6fa..b06dede1 100644 --- a/web-ui/res/web-ui-render.precompiled.js +++ b/web-ui/res/web-ui-render.precompiled.js @@ -1887,9 +1887,15 @@ return function render(_ctx, _cache) { _createElementVNode("div", { class: "card-content" }, [ _createElementVNode("div", { class: "card-title" }, _toDisplayString(name), 1 /* TEXT */), _createElementVNode("div", { class: "card-subtitle card-subtitle-model" }, _toDisplayString(config.model || _ctx.t('claude.model.unset')), 1 /* TEXT */), - (config.baseUrl) + (config.targetApi === 'chat_completions') ? (_openBlock(), _createElementBlock("div", { key: 0, + class: "card-subtitle" + }, "OpenAI Chat Completions")) + : _createCommentVNode("v-if", true), + (config.baseUrl) + ? (_openBlock(), _createElementBlock("div", { + key: 1, class: "card-subtitle card-subtitle-url" }, _toDisplayString(config.baseUrl), 1 /* TEXT */)) : _createCommentVNode("v-if", true) @@ -5551,6 +5557,19 @@ return function render(_ctx, _cache) { [_vModelText, _ctx.newClaudeConfig.baseUrl] ]) ]), + _createElementVNode("div", { class: "form-group" }, [ + _createElementVNode("label", { class: "form-label" }, "目标 API"), + _withDirectives(_createElementVNode("select", { + "onUpdate:modelValue": $event => ((_ctx.newClaudeConfig.targetApi) = $event), + class: "form-input" + }, [ + _createElementVNode("option", { value: "responses" }, "Anthropic / OpenAI Responses"), + _createElementVNode("option", { value: "chat_completions" }, "OpenAI Chat Completions (/v1/chat/completions)") + ], 8 /* PROPS */, ["onUpdate:modelValue"]), [ + [_vModelSelect, _ctx.newClaudeConfig.targetApi] + ]), + _createElementVNode("div", { class: "form-hint" }, "选择 Chat Completions 时由 Claude 兼容代理内建转换,不修改 Codex provider 的 wire_api。") + ]), _createElementVNode("div", { class: "btn-group" }, [ _createElementVNode("button", { class: "btn btn-cancel", @@ -5652,6 +5671,19 @@ return function render(_ctx, _cache) { [_vModelText, _ctx.editingConfig.baseUrl] ]) ]), + _createElementVNode("div", { class: "form-group" }, [ + _createElementVNode("label", { class: "form-label" }, "目标 API"), + _withDirectives(_createElementVNode("select", { + "onUpdate:modelValue": $event => ((_ctx.editingConfig.targetApi) = $event), + class: "form-input" + }, [ + _createElementVNode("option", { value: "responses" }, "Anthropic / OpenAI Responses"), + _createElementVNode("option", { value: "chat_completions" }, "OpenAI Chat Completions (/v1/chat/completions)") + ], 8 /* PROPS */, ["onUpdate:modelValue"]), [ + [_vModelSelect, _ctx.editingConfig.targetApi] + ]), + _createElementVNode("div", { class: "form-hint" }, "选择 Chat Completions 时由 Claude 兼容代理内建转换,不修改 Codex provider 的 wire_api。") + ]), _createElementVNode("div", { class: "btn-group" }, [ _createElementVNode("button", { class: "btn btn-cancel", From 9c40b1a664fa8d171b5348ae0e29e89685cc1dca Mon Sep 17 00:00:00 2001 From: awsl233777 Date: Sun, 24 May 2026 16:27:28 +0000 Subject: [PATCH 2/4] fix: apply Claude chat completions via local proxy --- cli.js | 72 +++++++++++++++++-- cli/claude-proxy.js | 28 +++++++- tests/e2e/test-claude.js | 20 ++++++ tests/unit/web-ui-logic.test.mjs | 1 + web-ui/modules/app.methods.claude-config.mjs | 4 +- web-ui/modules/i18n.dict.mjs | 15 ++++ web-ui/partials/index/modals-basic.html | 16 ++--- .../partials/index/panel-config-claude.html | 2 +- web-ui/res/web-ui-render.precompiled.js | 18 ++--- 9 files changed, 148 insertions(+), 28 deletions(-) diff --git a/cli.js b/cli.js index c13ab7a2..d8c7040b 100644 --- a/cli.js +++ b/cli.js @@ -9151,8 +9151,15 @@ function maskKey(key) { return key.substring(0, 4) + '...' + key.substring(key.length - 4); } +function normalizeClaudeTargetApi(value) { + const raw = typeof value === 'string' ? value.trim().toLowerCase() : ''; + return raw === 'chat_completions' || raw === 'chat-completions' || raw === 'chat/completions' + ? 'chat_completions' + : 'responses'; +} + // 应用到 Claude Code settings.json(跨平台) -function applyToClaudeSettings(config = {}) { +async function applyToClaudeSettings(config = {}) { try { const apiKey = (config.apiKey || '').trim(); if (!apiKey) { @@ -9161,6 +9168,46 @@ function applyToClaudeSettings(config = {}) { const baseUrl = (config.baseUrl || 'https://open.bigmodel.cn/api/anthropic').trim(); const model = (config.model || DEFAULT_CLAUDE_MODEL).trim(); + const targetApi = normalizeClaudeTargetApi(config.targetApi); + let settingsBaseUrl = baseUrl; + let settingsApiKey = apiKey; + let proxyResult = null; + + if (targetApi === 'chat_completions') { + await stopBuiltinClaudeProxyRuntime(); + proxyResult = await startBuiltinClaudeProxyRuntime({ + enabled: true, + provider: typeof config.name === 'string' ? config.name.trim() : '', + authSource: 'provider', + targetApi, + timeoutMs: DEFAULT_BUILTIN_CLAUDE_PROXY_SETTINGS.timeoutMs, + upstreamProviderName: typeof config.name === 'string' ? config.name.trim() : '', + upstreamBaseUrl: baseUrl, + upstreamApiKey: apiKey + }); + if (!proxyResult || proxyResult.error || proxyResult.success === false || !proxyResult.listenUrl) { + return { + success: false, + mode: 'claude-proxy', + error: (proxyResult && proxyResult.error) || '启动 Claude 兼容代理失败' + }; + } + settingsBaseUrl = proxyResult.listenUrl; + settingsApiKey = 'codexmate'; + } else { + await stopBuiltinClaudeProxyRuntime(); + 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' + }); + } + const readResult = readJsonObjectFromFile(CLAUDE_SETTINGS_FILE, {}); if (!readResult.ok) { return { success: false, mode: 'settings-file', error: readResult.error }; @@ -9173,8 +9220,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; @@ -9191,7 +9238,8 @@ function applyToClaudeSettings(config = {}) { const result = { success: true, - mode: 'settings-file', + mode: targetApi === 'chat_completions' ? 'claude-proxy' : 'settings-file', + targetApi, targetPath: CLAUDE_SETTINGS_FILE, updatedKeys: [ 'env.ANTHROPIC_API_KEY', @@ -9199,6 +9247,14 @@ function applyToClaudeSettings(config = {}) { 'env.ANTHROPIC_MODEL' ] }; + if (proxyResult) { + result.proxy = { + running: true, + listenUrl: proxyResult.listenUrl, + upstreamProvider: proxyResult.upstreamProvider || '', + mode: proxyResult.mode || 'anthropic-to-chat-completions' + }; + } if (backupPath) { result.backupPath = backupPath; } @@ -9340,7 +9396,7 @@ async function cmdClaude(args = []) { throw new Error('BaseURL 和 API 密钥必填'); } - const result = applyToClaudeSettings({ + const result = await applyToClaudeSettings({ baseUrl: normalizedBaseUrl, apiKey: normalizedKey, model: normalizedModel @@ -10949,7 +11005,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 : ''; @@ -15497,7 +15553,9 @@ function createMcpTools(options = {}) { properties: { apiKey: { type: 'string' }, baseUrl: { type: 'string' }, - model: { type: 'string' } + model: { type: 'string' }, + name: { type: 'string' }, + targetApi: { type: 'string' } }, required: ['apiKey'], additionalProperties: false diff --git a/cli/claude-proxy.js b/cli/claude-proxy.js index c5be3f5f..d15ed035 100644 --- a/cli/claude-proxy.js +++ b/cli/claude-proxy.js @@ -813,6 +813,32 @@ function createBuiltinClaudeProxyRuntimeController(deps = {}) { }; } + function resolveBuiltinClaudeProxyDirectUpstream(settings, payload = {}) { + const targetApi = settings.targetApi === 'chat_completions' ? 'chat_completions' : 'responses'; + const baseUrl = typeof payload.upstreamBaseUrl === 'string' ? payload.upstreamBaseUrl.trim() : ''; + if (!baseUrl) { + return null; + } + if (!isValidHttpUrl(baseUrl)) { + return { error: 'Claude 兼容代理上游 base_url 无效' }; + } + const token = typeof payload.upstreamApiKey === 'string' ? payload.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() + : 'claude-config'; + return { + providerName, + baseUrl: normalizeBaseUrl(baseUrl), + authHeader, + extraHeaders: {}, + targetApi + }; + } + function buildBuiltinClaudeProxyRequestAuthHeader(req, settings, upstream) { if (settings && settings.authSource === 'request') { const apiKey = typeof req.headers['x-api-key'] === 'string' @@ -1183,7 +1209,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 }; } diff --git a/tests/e2e/test-claude.js b/tests/e2e/test-claude.js index d2d522e4..9886fa04 100644 --- a/tests/e2e/test-claude.js +++ b/tests/e2e/test-claude.js @@ -64,9 +64,29 @@ 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(claudeChatSettings.apiKey === 'codexmate', 'chat_completions should point Claude Code at local proxy token'); + 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.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'); + // ========== 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'); }; diff --git a/tests/unit/web-ui-logic.test.mjs b/tests/unit/web-ui-logic.test.mjs index 94a3cdd6..02572485 100644 --- a/tests/unit/web-ui-logic.test.mjs +++ b/tests/unit/web-ui-logic.test.mjs @@ -82,6 +82,7 @@ test('normalizeClaudeConfig infers external credential type from authToken and u }); test('normalizeClaudeConfig accepts chat completions target api aliases', () => { + assert.strictEqual(normalizeClaudeConfig({ targetApi: 'chat_completions' }).targetApi, 'chat_completions'); assert.strictEqual(normalizeClaudeConfig({ targetApi: 'chat/completions' }).targetApi, 'chat_completions'); assert.strictEqual(normalizeClaudeConfig({ targetApi: 'chat-completions' }).targetApi, 'chat_completions'); }); diff --git a/web-ui/modules/app.methods.claude-config.mjs b/web-ui/modules/app.methods.claude-config.mjs index fbe2ff3b..be1d954c 100644 --- a/web-ui/modules/app.methods.claude-config.mjs +++ b/web-ui/modules/app.methods.claude-config.mjs @@ -109,7 +109,7 @@ export function createClaudeConfigMethods(options = {}) { const _claudeKey = `${name}|${config.apiKey || ""}|${config.baseUrl || ""}|${config.model || ""}|${config.targetApi || "responses"}`; try { - const res = await api('apply-claude-config', { config }); + const res = await api('apply-claude-config', { config: { ...config, name } }); if (res.error || res.success === false) { this.showMessage(res.error || '应用配置失败', 'error'); } else { @@ -185,7 +185,7 @@ export function createClaudeConfigMethods(options = {}) { const _claudeKey2 = `${name}|${config.apiKey || ""}|${config.baseUrl || ""}|${config.model || ""}|${config.targetApi || "responses"}`; try { - const res = await api('apply-claude-config', { config }); + const res = await api('apply-claude-config', { config: { ...config, name } }); if (res.error || res.success === false) { this.showMessage(res.error || '应用配置失败', 'error'); } else { diff --git a/web-ui/modules/i18n.dict.mjs b/web-ui/modules/i18n.dict.mjs index 861c19f9..de2ef81a 100644 --- a/web-ui/modules/i18n.dict.mjs +++ b/web-ui/modules/i18n.dict.mjs @@ -1048,6 +1048,11 @@ const DICT = Object.freeze({ 'claude.localBridge.noProviders': '暂无可用提供商,请先添加直连提供商', 'claude.localBridge.disabled': '未启用', 'claude.localBridge.enabled': '已启用', + 'claude.targetApi.label': '目标 API', + 'claude.targetApi.responses': 'Anthropic / OpenAI Responses', + 'claude.targetApi.chatCompletions': 'OpenAI Chat Completions (/v1/chat/completions)', + 'claude.targetApi.chatCompletionsBadge': 'OpenAI Chat Completions', + 'claude.targetApi.hint': '选择 Chat Completions 时由 Claude 兼容代理内建转换,不修改 Codex provider 的 wire_api。', // OpenClaw config panel 'openclaw.applyHint': '写入 ~/.openclaw/openclaw.json,支持 JSON5。', @@ -2104,6 +2109,11 @@ const DICT = Object.freeze({ 'claude.localBridge.noProviders': '利用可能なプロバイダがありません。まずプロバイダを追加してください。', 'claude.localBridge.disabled': '無効', 'claude.localBridge.enabled': '有効', + 'claude.targetApi.label': 'ターゲット API', + 'claude.targetApi.responses': 'Anthropic / OpenAI Responses', + 'claude.targetApi.chatCompletions': 'OpenAI Chat Completions (/v1/chat/completions)', + 'claude.targetApi.chatCompletionsBadge': 'OpenAI Chat Completions', + 'claude.targetApi.hint': 'Chat Completions を選ぶと Claude 互換プロキシが内蔵変換します。Codex provider の wire_api は変更しません。', // OpenClaw config panel 'openclaw.applyHint': '~/.openclaw/openclaw.json に書き込みます。JSON5 対応。', @@ -3170,6 +3180,11 @@ const DICT = Object.freeze({ 'claude.localBridge.noProviders': 'No providers available. Add a provider first.', 'claude.localBridge.disabled': 'Disabled', 'claude.localBridge.enabled': 'Enabled', + 'claude.targetApi.label': 'Target API', + 'claude.targetApi.responses': 'Anthropic / OpenAI Responses', + 'claude.targetApi.chatCompletions': 'OpenAI Chat Completions (/v1/chat/completions)', + 'claude.targetApi.chatCompletionsBadge': 'OpenAI Chat Completions', + 'claude.targetApi.hint': 'When Chat Completions is selected, the Claude-compatible proxy performs the built-in conversion without changing the Codex provider wire_api.', // OpenClaw config panel 'openclaw.applyHint': 'Writes to ~/.openclaw/openclaw.json (JSON5 supported).', diff --git a/web-ui/partials/index/modals-basic.html b/web-ui/partials/index/modals-basic.html index 9231c133..982bd4c1 100644 --- a/web-ui/partials/index/modals-basic.html +++ b/web-ui/partials/index/modals-basic.html @@ -134,12 +134,12 @@
- + -
选择 Chat Completions 时由 Claude 兼容代理内建转换,不修改 Codex provider 的 wire_api。
+
{{ t('claude.targetApi.hint') }}
@@ -173,12 +173,12 @@
- + -
选择 Chat Completions 时由 Claude 兼容代理内建转换,不修改 Codex provider 的 wire_api。
+
{{ t('claude.targetApi.hint') }}
diff --git a/web-ui/partials/index/panel-config-claude.html b/web-ui/partials/index/panel-config-claude.html index 3e284cf7..b04f7a34 100644 --- a/web-ui/partials/index/panel-config-claude.html +++ b/web-ui/partials/index/panel-config-claude.html @@ -129,7 +129,7 @@
{{ name }}
{{ config.model || t('claude.model.unset') }}
-
OpenAI Chat Completions
+
{{ t('claude.targetApi.chatCompletionsBadge') }}
{{ config.baseUrl }}
diff --git a/web-ui/res/web-ui-render.precompiled.js b/web-ui/res/web-ui-render.precompiled.js index b06dede1..0de40f7f 100644 --- a/web-ui/res/web-ui-render.precompiled.js +++ b/web-ui/res/web-ui-render.precompiled.js @@ -1891,7 +1891,7 @@ return function render(_ctx, _cache) { ? (_openBlock(), _createElementBlock("div", { key: 0, class: "card-subtitle" - }, "OpenAI Chat Completions")) + }, _toDisplayString(_ctx.t('claude.targetApi.chatCompletionsBadge')), 1 /* TEXT */)) : _createCommentVNode("v-if", true), (config.baseUrl) ? (_openBlock(), _createElementBlock("div", { @@ -5558,17 +5558,17 @@ return function render(_ctx, _cache) { ]) ]), _createElementVNode("div", { class: "form-group" }, [ - _createElementVNode("label", { class: "form-label" }, "目标 API"), + _createElementVNode("label", { class: "form-label" }, _toDisplayString(_ctx.t('claude.targetApi.label')), 1 /* TEXT */), _withDirectives(_createElementVNode("select", { "onUpdate:modelValue": $event => ((_ctx.newClaudeConfig.targetApi) = $event), class: "form-input" }, [ - _createElementVNode("option", { value: "responses" }, "Anthropic / OpenAI Responses"), - _createElementVNode("option", { value: "chat_completions" }, "OpenAI Chat Completions (/v1/chat/completions)") + _createElementVNode("option", { value: "responses" }, _toDisplayString(_ctx.t('claude.targetApi.responses')), 1 /* TEXT */), + _createElementVNode("option", { value: "chat_completions" }, _toDisplayString(_ctx.t('claude.targetApi.chatCompletions')), 1 /* TEXT */) ], 8 /* PROPS */, ["onUpdate:modelValue"]), [ [_vModelSelect, _ctx.newClaudeConfig.targetApi] ]), - _createElementVNode("div", { class: "form-hint" }, "选择 Chat Completions 时由 Claude 兼容代理内建转换,不修改 Codex provider 的 wire_api。") + _createElementVNode("div", { class: "form-hint" }, _toDisplayString(_ctx.t('claude.targetApi.hint')), 1 /* TEXT */) ]), _createElementVNode("div", { class: "btn-group" }, [ _createElementVNode("button", { @@ -5672,17 +5672,17 @@ return function render(_ctx, _cache) { ]) ]), _createElementVNode("div", { class: "form-group" }, [ - _createElementVNode("label", { class: "form-label" }, "目标 API"), + _createElementVNode("label", { class: "form-label" }, _toDisplayString(_ctx.t('claude.targetApi.label')), 1 /* TEXT */), _withDirectives(_createElementVNode("select", { "onUpdate:modelValue": $event => ((_ctx.editingConfig.targetApi) = $event), class: "form-input" }, [ - _createElementVNode("option", { value: "responses" }, "Anthropic / OpenAI Responses"), - _createElementVNode("option", { value: "chat_completions" }, "OpenAI Chat Completions (/v1/chat/completions)") + _createElementVNode("option", { value: "responses" }, _toDisplayString(_ctx.t('claude.targetApi.responses')), 1 /* TEXT */), + _createElementVNode("option", { value: "chat_completions" }, _toDisplayString(_ctx.t('claude.targetApi.chatCompletions')), 1 /* TEXT */) ], 8 /* PROPS */, ["onUpdate:modelValue"]), [ [_vModelSelect, _ctx.editingConfig.targetApi] ]), - _createElementVNode("div", { class: "form-hint" }, "选择 Chat Completions 时由 Claude 兼容代理内建转换,不修改 Codex provider 的 wire_api。") + _createElementVNode("div", { class: "form-hint" }, _toDisplayString(_ctx.t('claude.targetApi.hint')), 1 /* TEXT */) ]), _createElementVNode("div", { class: "btn-group" }, [ _createElementVNode("button", { From 3501aca442c46e3a9b6942f3d4ed09c8fc1e11d2 Mon Sep 17 00:00:00 2001 From: awsl233777 Date: Sun, 24 May 2026 16:50:53 +0000 Subject: [PATCH 3/4] fix: harden Claude proxy apply flow --- cli.js | 40 +++++++++++++++++++++++++++++----------- cli/claude-proxy.js | 4 ++-- tests/e2e/test-claude.js | 3 ++- 3 files changed, 33 insertions(+), 14 deletions(-) diff --git a/cli.js b/cli.js index d8c7040b..effc0af7 100644 --- a/cli.js +++ b/cli.js @@ -9158,8 +9158,22 @@ function normalizeClaudeTargetApi(value) { : '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(跨平台) async function applyToClaudeSettings(config = {}) { + let proxyStarted = false; try { const apiKey = (config.apiKey || '').trim(); if (!apiKey) { @@ -9175,8 +9189,10 @@ async function applyToClaudeSettings(config = {}) { if (targetApi === 'chat_completions') { await stopBuiltinClaudeProxyRuntime(); + const proxyToken = crypto.randomBytes(24).toString('hex'); proxyResult = await startBuiltinClaudeProxyRuntime({ enabled: true, + host: DEFAULT_BUILTIN_CLAUDE_PROXY_SETTINGS.host, provider: typeof config.name === 'string' ? config.name.trim() : '', authSource: 'provider', targetApi, @@ -9186,30 +9202,28 @@ async function applyToClaudeSettings(config = {}) { 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 = 'codexmate'; + settingsApiKey = proxyToken; } else { await stopBuiltinClaudeProxyRuntime(); - 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' - }); + resetBuiltinClaudeProxySavedSettingsToResponses(); } const readResult = readJsonObjectFromFile(CLAUDE_SETTINGS_FILE, {}); if (!readResult.ok) { + if (proxyStarted) { + await stopBuiltinClaudeProxyRuntime(); + resetBuiltinClaudeProxySavedSettingsToResponses(); + } return { success: false, mode: 'settings-file', error: readResult.error }; } @@ -9260,6 +9274,10 @@ async function applyToClaudeSettings(config = {}) { } return result; } catch (e) { + if (proxyStarted) { + try { await stopBuiltinClaudeProxyRuntime(); } catch (_) {} + try { resetBuiltinClaudeProxySavedSettingsToResponses(); } catch (_) {} + } return { success: false, mode: 'settings-file', diff --git a/cli/claude-proxy.js b/cli/claude-proxy.js index d15ed035..f0a91962 100644 --- a/cli/claude-proxy.js +++ b/cli/claude-proxy.js @@ -767,8 +767,8 @@ function createBuiltinClaudeProxyRuntimeController(deps = {}) { && typeof resolveOpenaiBridgeUpstream === 'function' && OPENAI_BRIDGE_SETTINGS_FILE) { const bridgeUpstream = resolveOpenaiBridgeUpstream(OPENAI_BRIDGE_SETTINGS_FILE, providerName); - if (bridgeUpstream && bridgeUpstream.error) { - return { error: bridgeUpstream.error }; + 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)) { diff --git a/tests/e2e/test-claude.js b/tests/e2e/test-claude.js index 9886fa04..d4f0c95f 100644 --- a/tests/e2e/test-claude.js +++ b/tests/e2e/test-claude.js @@ -72,7 +72,8 @@ module.exports = async function testClaude(ctx) { 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(claudeChatSettings.apiKey === 'codexmate', 'chat_completions should point Claude Code at local proxy token'); + 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'); From 7b4946716c6ec3c6e88d4baab2e678cb30878a11 Mon Sep 17 00:00:00 2001 From: awsl233777 Date: Mon, 25 May 2026 02:03:53 +0000 Subject: [PATCH 4/4] test: cover Claude chat completions edge cases --- tests/e2e/test-claude-proxy.js | 20 +++++++++++++++++++- tests/e2e/test-claude.js | 19 ++++++++++++++++++- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/tests/e2e/test-claude-proxy.js b/tests/e2e/test-claude-proxy.js index 30de9216..efcc2d2d 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'); @@ -158,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 { @@ -351,6 +353,22 @@ module.exports = async function testClaudeProxy(ctx) { }); 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'); + 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'); + fs.writeFileSync(bridgeSettingsPath, savedBridgeSettings, 'utf-8'); + const bridgeStartResult = await api('claude-proxy-start', { host: '127.0.0.1', port: proxyPort, diff --git a/tests/e2e/test-claude.js b/tests/e2e/test-claude.js index d4f0c95f..2a17d99e 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'); @@ -79,6 +81,7 @@ module.exports = async function testClaude(ctx) { 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'); @@ -90,4 +93,18 @@ module.exports = async function testClaude(ctx) { 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'); + 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'); + fs.writeFileSync(claudeSettingsPath, validClaudeSettings, 'utf-8'); };