From 4b24338b17108fbbb6deb6b7ce5e748d876648c8 Mon Sep 17 00:00:00 2001 From: awsl233777 Date: Sun, 24 May 2026 12:46:58 +0000 Subject: [PATCH 01/22] 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 02/22] 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 03/22] 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 04/22] 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'); }; From 059aad248b4be1a82d09497e6903ea3878bd0651 Mon Sep 17 00:00:00 2001 From: awsl233777 Date: Sun, 31 May 2026 16:11:56 +0000 Subject: [PATCH 05/22] chore: minimize PR diff noise --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index ddf9a500..3581375f 100644 --- a/.gitignore +++ b/.gitignore @@ -56,3 +56,4 @@ codex-switcher.exe log.txt tmp/ .gitnexus/ + From 5a5af3076c6f50ef4afa6d5f1f58b51efdd1dddc Mon Sep 17 00:00:00 2001 From: awsl233777 Date: Mon, 1 Jun 2026 16:36:01 +0000 Subject: [PATCH 06/22] fix: align claude proxy targets and config UI --- cli.js | 22 +- cli/claude-proxy.js | 292 ++++++++++++++++-- tests/unit/claude-proxy-adapter.test.mjs | 145 +++++++++ tests/unit/web-ui-logic.test.mjs | 14 + web-ui/app.js | 10 +- web-ui/logic.claude.mjs | 9 +- web-ui/logic.runtime.mjs | 7 - web-ui/modules/app.methods.claude-config.mjs | 9 +- .../modules/app.methods.session-timeline.mjs | 1 - web-ui/modules/i18n/locales/en.mjs | 4 +- web-ui/modules/i18n/locales/ja.mjs | 4 +- web-ui/modules/i18n/locales/vi.mjs | 4 +- web-ui/modules/i18n/locales/zh.mjs | 4 +- web-ui/partials/index/modals-basic.html | 2 + .../partials/index/panel-config-claude.html | 1 + web-ui/res/web-ui-render.precompiled.js | 15 +- web-ui/styles/controls-forms.css | 10 +- 17 files changed, 494 insertions(+), 59 deletions(-) diff --git a/cli.js b/cli.js index ffb96b7a..55c362bf 100644 --- a/cli.js +++ b/cli.js @@ -9324,9 +9324,13 @@ function maskKey(key) { function normalizeClaudeTargetApi(value) { const raw = typeof value === 'string' ? value.trim().toLowerCase() : ''; - return raw === 'chat_completions' || raw === 'chat-completions' || raw === 'chat/completions' - ? 'chat_completions' - : 'responses'; + if (raw === 'chat_completions' || raw === 'chat-completions' || raw === 'chat/completions') { + return 'chat_completions'; + } + if (raw === 'ollama') { + return 'ollama'; + } + return 'responses'; } function resetBuiltinClaudeProxySavedSettingsToResponses() { @@ -9348,18 +9352,18 @@ async function applyToClaudeSettings(config = {}) { 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 baseUrl = (config.baseUrl || (targetApi === 'ollama' ? 'http://127.0.0.1:11434' : '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') { + if (targetApi === 'chat_completions' || targetApi === 'ollama') { await stopBuiltinClaudeProxyRuntime(); const proxyToken = crypto.randomBytes(24).toString('hex'); proxyResult = await startBuiltinClaudeProxyRuntime({ @@ -9424,7 +9428,7 @@ async function applyToClaudeSettings(config = {}) { const result = { success: true, - mode: targetApi === 'chat_completions' ? 'claude-proxy' : 'settings-file', + mode: targetApi === 'responses' ? 'settings-file' : 'claude-proxy', targetApi, targetPath: CLAUDE_SETTINGS_FILE, updatedKeys: [ @@ -9438,7 +9442,7 @@ async function applyToClaudeSettings(config = {}) { running: true, listenUrl: proxyResult.listenUrl, upstreamProvider: proxyResult.upstreamProvider || '', - mode: proxyResult.mode || 'anthropic-to-chat-completions' + mode: proxyResult.mode || (targetApi === 'ollama' ? 'anthropic-to-ollama' : 'anthropic-to-chat-completions') }; } if (backupPath) { diff --git a/cli/claude-proxy.js b/cli/claude-proxy.js index f0a91962..5a31a63a 100644 --- a/cli/claude-proxy.js +++ b/cli/claude-proxy.js @@ -84,6 +84,35 @@ 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 appendAnthropicMessageToResponsesInput(target, message) { if (!message || typeof message !== 'object') return; const roleRaw = typeof message.role === 'string' ? message.role.trim().toLowerCase() : ''; @@ -103,6 +132,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 +160,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'}]` @@ -240,13 +279,34 @@ function appendAnthropicMessageToChatMessages(target, message) { const roleRaw = typeof message.role === 'string' ? message.role.trim().toLowerCase() : ''; const role = roleRaw === 'assistant' ? 'assistant' : 'user'; let textParts = []; + let contentParts = []; const toolCalls = []; - const flushText = () => { + const flushTextPartsToContentParts = () => { const content = textParts.join('\n\n').trim(); - if (!content) return; - target.push({ role, content }); 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)) { @@ -255,6 +315,17 @@ function appendAnthropicMessageToChatMessages(target, message) { 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() @@ -281,7 +352,7 @@ function appendAnthropicMessageToChatMessages(target, message) { } if (role === 'assistant' && toolCalls.length) { - const content = textParts.join('\n\n').trim(); + const content = buildContent(); target.push({ role: 'assistant', content: content || null, @@ -351,6 +422,121 @@ function buildBuiltinClaudeChatCompletionsRequest(payload = {}) { 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 (isDroppableAnthropicBridgeBlock(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 (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; @@ -504,6 +690,55 @@ function buildAnthropicMessageFromChatCompletion(payload, requestPayload = {}) { }; } + +function buildAnthropicMessageFromOllamaChat(payload, requestPayload = {}) { + const ollamaMessage = payload && payload.message && typeof payload.message === 'object' ? payload.message : {}; + const content = []; + 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' ? 'max_tokens' : 'end_turn'), + stop_sequence: null, + usage + }; +} + function buildAnthropicMessageFromResponses(payload, requestPayload = {}) { const content = collectAnthropicContentFromResponsesOutput(payload); const usage = buildAnthropicUsageFromResponses(payload); @@ -678,9 +913,12 @@ function createBuiltinClaudeProxyRuntimeController(deps = {}) { const authSource = authSourceRaw === 'profile' || authSourceRaw === 'none' || authSourceRaw === 'request' ? authSourceRaw : 'provider'; - const targetApi = targetApiRaw === 'chat_completions' || targetApiRaw === 'chat-completions' || targetApiRaw === 'chat/completions' - ? 'chat_completions' - : 'responses'; + 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, @@ -754,7 +992,9 @@ function createBuiltinClaudeProxyRuntimeController(deps = {}) { return { error: `上游 provider 不存在: ${providerName}` }; } - const targetApi = settings.targetApi === 'chat_completions' ? 'chat_completions' : 'responses'; + 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') { @@ -814,7 +1054,9 @@ function createBuiltinClaudeProxyRuntimeController(deps = {}) { } function resolveBuiltinClaudeProxyDirectUpstream(settings, payload = {}) { - const targetApi = settings.targetApi === 'chat_completions' ? 'chat_completions' : 'responses'; + const targetApi = settings.targetApi === 'chat_completions' + ? 'chat_completions' + : (settings.targetApi === 'ollama' ? 'ollama' : 'responses'); const baseUrl = typeof payload.upstreamBaseUrl === 'string' ? payload.upstreamBaseUrl.trim() : ''; if (!baseUrl) { return null; @@ -1038,7 +1280,7 @@ function createBuiltinClaudeProxyRuntimeController(deps = {}) { ok: true, upstreamProvider: upstream.providerName, upstreamBaseUrl: upstream.baseUrl, - mode: upstream.targetApi === 'chat_completions' ? 'anthropic-to-chat-completions' : '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', @@ -1062,7 +1304,7 @@ 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 @@ -1097,13 +1339,17 @@ function createBuiltinClaudeProxyRuntimeController(deps = {}) { } const payload = await readJsonRequestBody(req); - const isChatCompletionsMode = upstream.targetApi === 'chat_completions' || settings.targetApi === 'chat_completions'; - const upstreamRequestBody = isChatCompletionsMode - ? buildBuiltinClaudeChatCompletionsRequest(payload) - : 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: isChatCompletionsMode ? 'chat/completions' : 'responses', + pathSuffix: activeTargetApi === 'ollama' ? 'api/chat' : (activeTargetApi === 'chat_completions' ? 'chat/completions' : 'responses'), body: upstreamRequestBody, authHeader: authResult.authHeader, headers: upstream.extraHeaders, @@ -1120,9 +1366,11 @@ function createBuiltinClaudeProxyRuntimeController(deps = {}) { return; } - const anthropicMessage = isChatCompletionsMode - ? buildAnthropicMessageFromChatCompletion(upstreamResponse.payload || {}, payload) - : 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; @@ -1221,7 +1469,7 @@ function createBuiltinClaudeProxyRuntimeController(deps = {}) { running: true, listenUrl: runtime.listenUrl, upstreamProvider: upstream.providerName, - mode: upstream.targetApi === 'chat_completions' ? 'anthropic-to-chat-completions' : 'anthropic-to-responses', + mode: upstream.targetApi === 'chat_completions' ? 'anthropic-to-chat-completions' : (upstream.targetApi === 'ollama' ? 'anthropic-to-ollama' : 'anthropic-to-responses'), settings }; } catch (e) { @@ -1270,7 +1518,7 @@ function createBuiltinClaudeProxyRuntimeController(deps = {}) { listenUrl: runtime.listenUrl, upstreamProvider: runtime.upstream.providerName, upstreamBaseUrl: runtime.upstream.baseUrl, - mode: runtime.upstream.targetApi === 'chat_completions' ? 'anthropic-to-chat-completions' : 'anthropic-to-responses' + mode: runtime.upstream.targetApi === 'chat_completions' ? 'anthropic-to-chat-completions' : (runtime.upstream.targetApi === 'ollama' ? 'anthropic-to-ollama' : 'anthropic-to-responses') } : null }; @@ -1292,8 +1540,10 @@ module.exports = { createBuiltinClaudeProxyRuntimeController, buildBuiltinClaudeResponsesRequest, buildBuiltinClaudeChatCompletionsRequest, + buildBuiltinClaudeOllamaChatRequest, buildAnthropicMessageFromResponses, buildAnthropicMessageFromChatCompletion, + buildAnthropicMessageFromOllamaChat, buildAnthropicStreamEvents, buildAnthropicModelsPayload }; diff --git a/tests/unit/claude-proxy-adapter.test.mjs b/tests/unit/claude-proxy-adapter.test.mjs index 0abd1e9f..ac0b70de 100644 --- a/tests/unit/claude-proxy-adapter.test.mjs +++ b/tests/unit/claude-proxy-adapter.test.mjs @@ -5,8 +5,10 @@ const require = createRequire(import.meta.url); const { buildBuiltinClaudeResponsesRequest, buildBuiltinClaudeChatCompletionsRequest, + buildBuiltinClaudeOllamaChatRequest, buildAnthropicMessageFromResponses, buildAnthropicMessageFromChatCompletion, + buildAnthropicMessageFromOllamaChat, buildAnthropicStreamEvents, buildAnthropicModelsPayload } = require('../../cli/claude-proxy'); @@ -64,6 +66,29 @@ 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', @@ -104,6 +129,103 @@ test('buildBuiltinClaudeChatCompletionsRequest maps anthropic messages/tools int ]); }); + +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'] + }); + + assert.strictEqual(payload.model, 'qwen2.5-coder:7b'); + assert.strictEqual(payload.stream, 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: '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', @@ -171,6 +293,29 @@ test('buildAnthropicMessageFromChatCompletion maps chat completion output into a ]); }); + +test('buildAnthropicMessageFromOllamaChat maps Ollama /api/chat output into anthropic message', () => { + const message = buildAnthropicMessageFromOllamaChat({ + model: 'qwen2.5-coder:7b', + message: { + role: 'assistant', + 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: 'text', text: 'proxy ok' }, + { type: 'tool_use', id: message.content[1].id, name: 'lookup', input: { city: 'tokyo' } } + ]); + assert(message.content[1].id.startsWith('toolu_')); +}); + test('buildAnthropicStreamEvents emits anthropic-style SSE events', () => { const events = buildAnthropicStreamEvents({ id: 'msg_1', diff --git a/tests/unit/web-ui-logic.test.mjs b/tests/unit/web-ui-logic.test.mjs index 8572b023..91c925ad 100644 --- a/tests/unit/web-ui-logic.test.mjs +++ b/tests/unit/web-ui-logic.test.mjs @@ -510,6 +510,20 @@ test('shouldForceCompactLayoutMode keeps desktop layout for narrow non-touch win assert.strictEqual(enabled, false); }); + +test('shouldForceCompactLayoutMode does not let mobile UA alone diverge from desktop Web UI layout', () => { + const enabled = shouldForceCompactLayoutMode({ + viewportWidth: 390, + screenWidth: 390, + screenHeight: 844, + maxTouchPoints: 0, + userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) Mobile', + coarsePointer: false, + noHover: false + }); + assert.strictEqual(enabled, false); +}); + test('shouldForceCompactLayoutMode enables compact mode for mobile UA on narrow viewport', () => { const enabled = shouldForceCompactLayoutMode({ viewportWidth: 390, diff --git a/web-ui/app.js b/web-ui/app.js index 00842f1c..c116ae8b 100644 --- a/web-ui/app.js +++ b/web-ui/app.js @@ -547,9 +547,13 @@ document.addEventListener('DOMContentLoaded', () => { 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'; + if (targetApiRaw === 'chat_completions' || targetApiRaw === 'chat-completions' || targetApiRaw === 'chat/completions') { + config.targetApi = 'chat_completions'; + } else if (targetApiRaw === 'ollama') { + config.targetApi = 'ollama'; + } else { + config.targetApi = '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 e007d2bd..cab26bc6 100644 --- a/web-ui/logic.claude.mjs +++ b/web-ui/logic.claude.mjs @@ -70,9 +70,12 @@ export function normalizeClaudeConfig(config) { 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'; + let targetApi = 'responses'; + if (targetApiRaw === 'chat_completions' || targetApiRaw === 'chat-completions' || targetApiRaw === 'chat/completions') { + targetApi = 'chat_completions'; + } else if (targetApiRaw === 'ollama') { + targetApi = 'ollama'; + } return { apiKey, baseUrl: normalizeClaudeValue(safe.baseUrl), diff --git a/web-ui/logic.runtime.mjs b/web-ui/logic.runtime.mjs index 619e48e9..1409a445 100644 --- a/web-ui/logic.runtime.mjs +++ b/web-ui/logic.runtime.mjs @@ -101,10 +101,6 @@ export function shouldForceCompactLayoutMode(options = {}) { const screenHeight = Number(options.screenHeight || 0); const shortEdge = Number(options.shortEdge || (screenWidth > 0 && screenHeight > 0 ? Math.min(screenWidth, screenHeight) : 0)); const maxTouchPoints = Number(options.maxTouchPoints || 0); - const userAgent = typeof options.userAgent === 'string' ? options.userAgent : ''; - const isMobileUa = typeof options.isMobileUa === 'boolean' - ? options.isMobileUa - : /(Android|iPhone|iPad|iPod|Mobile)/i.test(userAgent); const coarsePointer = !!options.coarsePointer; const noHover = !!options.noHover; const isSmallPhysicalScreen = shortEdge > 0 && shortEdge <= 920; @@ -115,9 +111,6 @@ export function shouldForceCompactLayoutMode(options = {}) { if (!isNarrowViewport) { return false; } - if (isMobileUa) { - return true; - } return pointerSuggestsTouchOnly && maxTouchPoints > 0; } diff --git a/web-ui/modules/app.methods.claude-config.mjs b/web-ui/modules/app.methods.claude-config.mjs index 30ac4462..7bfbf2f9 100644 --- a/web-ui/modules/app.methods.claude-config.mjs +++ b/web-ui/modules/app.methods.claude-config.mjs @@ -23,6 +23,10 @@ function getClaudeConfigValidationForContext(vm, mode = 'add') { const externalCredentialType = normalizeClaudeText(draft && draft.externalCredentialType); const baseUrl = normalizeClaudeBaseUrl(draft && draft.baseUrl); const model = normalizeClaudeText(draft && draft.model); + const targetApiRaw = normalizeClaudeText(draft && draft.targetApi).toLowerCase(); + const targetApi = targetApiRaw === 'chat_completions' || targetApiRaw === 'chat-completions' || targetApiRaw === 'chat/completions' + ? 'chat_completions' + : (targetApiRaw === 'ollama' ? 'ollama' : 'responses'); const errors = { name: '', apiKey: '', @@ -36,7 +40,7 @@ function getClaudeConfigValidationForContext(vm, mode = 'add') { errors.name = '名称已存在'; } - if (!apiKey && !externalCredentialType) { + if (!apiKey && !externalCredentialType && targetApi !== 'ollama') { errors.apiKey = 'API Key 必填'; } @@ -57,6 +61,7 @@ function getClaudeConfigValidationForContext(vm, mode = 'add') { externalCredentialType, baseUrl, model, + targetApi, errors, ok: !errors.name && !errors.apiKey && !errors.baseUrl && !errors.model }; @@ -88,7 +93,7 @@ export function createClaudeConfigMethods(options = {}) { this.claudeConfigs[name] = this.mergeClaudeConfig(existing, { model }); this.saveClaudeConfigs(); this.updateClaudeModelsCurrent(); - if (!this.claudeConfigs[name].apiKey && !this.claudeConfigs[name].externalCredentialType) { + if (!this.claudeConfigs[name].apiKey && !this.claudeConfigs[name].externalCredentialType && this.claudeConfigs[name].targetApi !== 'ollama') { this.showMessage('请先配置 API Key', 'error'); return; } diff --git a/web-ui/modules/app.methods.session-timeline.mjs b/web-ui/modules/app.methods.session-timeline.mjs index 15e66d21..96913df6 100644 --- a/web-ui/modules/app.methods.session-timeline.mjs +++ b/web-ui/modules/app.methods.session-timeline.mjs @@ -354,7 +354,6 @@ export function createSessionTimelineMethods() { shortEdge, maxTouchPoints: touchPoints, userAgent, - isMobileUa, coarsePointer, noHover }); diff --git a/web-ui/modules/i18n/locales/en.mjs b/web-ui/modules/i18n/locales/en.mjs index b2dc3993..f6d2c0c6 100644 --- a/web-ui/modules/i18n/locales/en.mjs +++ b/web-ui/modules/i18n/locales/en.mjs @@ -1094,8 +1094,10 @@ const en = Object.freeze({ 'claude.targetApi.label': 'Target API', 'claude.targetApi.responses': 'Anthropic / OpenAI Responses', 'claude.targetApi.chatCompletions': 'OpenAI Chat Completions (/v1/chat/completions)', + 'claude.targetApi.ollama': 'Ollama Chat (/api/chat)', '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.', + 'claude.targetApi.ollamaBadge': 'Ollama', + 'claude.targetApi.hint': 'When Chat Completions or Ollama is selected, the Claude-compatible proxy performs the built-in conversion without changing the Codex provider wire_api; Ollama does not require an API key.', 'claude.health.title': 'Config health check', 'claude.health.run': 'Run check', 'claude.health.running': 'Checking...', diff --git a/web-ui/modules/i18n/locales/ja.mjs b/web-ui/modules/i18n/locales/ja.mjs index 2dc968c7..25015b7d 100644 --- a/web-ui/modules/i18n/locales/ja.mjs +++ b/web-ui/modules/i18n/locales/ja.mjs @@ -1086,8 +1086,10 @@ const ja = Object.freeze({ 'claude.targetApi.label': 'ターゲット API', 'claude.targetApi.responses': 'Anthropic / OpenAI Responses', 'claude.targetApi.chatCompletions': 'OpenAI Chat Completions (/v1/chat/completions)', + 'claude.targetApi.ollama': 'Ollama Chat (/api/chat)', 'claude.targetApi.chatCompletionsBadge': 'OpenAI Chat Completions', - 'claude.targetApi.hint': 'Chat Completions を選ぶと Claude 互換プロキシが内蔵変換します。Codex provider の wire_api は変更しません。', + 'claude.targetApi.ollamaBadge': 'Ollama', + 'claude.targetApi.hint': 'Chat Completions または Ollama を選ぶと Claude 互換プロキシが内蔵変換します。Codex provider の wire_api は変更しません。Ollama では API Key は不要です。', 'claude.health.title': '設定ヘルスチェック', 'claude.health.run': 'チェック実行', 'claude.health.running': 'チェック中...', diff --git a/web-ui/modules/i18n/locales/vi.mjs b/web-ui/modules/i18n/locales/vi.mjs index dcf116be..ceb36f1f 100644 --- a/web-ui/modules/i18n/locales/vi.mjs +++ b/web-ui/modules/i18n/locales/vi.mjs @@ -239,8 +239,10 @@ const vi = Object.freeze({ 'claude.targetApi.label': 'API đích', 'claude.targetApi.responses': 'Anthropic / OpenAI Responses', 'claude.targetApi.chatCompletions': 'OpenAI Chat Completions (/v1/chat/completions)', + 'claude.targetApi.ollama': 'Ollama Chat (/api/chat)', 'claude.targetApi.chatCompletionsBadge': 'OpenAI Chat Completions', - 'claude.targetApi.hint': 'Khi chọn Chat Completions, proxy tương thích Claude sẽ chuyển đổi nội bộ mà không thay đổi wire_api của Codex provider.' + 'claude.targetApi.ollamaBadge': 'Ollama', + 'claude.targetApi.hint': 'Khi chọn Chat Completions hoặc Ollama, proxy tương thích Claude sẽ chuyển đổi nội bộ mà không thay đổi wire_api của Codex provider; Ollama không cần API key.' }); diff --git a/web-ui/modules/i18n/locales/zh.mjs b/web-ui/modules/i18n/locales/zh.mjs index bfc6013f..d98d9775 100644 --- a/web-ui/modules/i18n/locales/zh.mjs +++ b/web-ui/modules/i18n/locales/zh.mjs @@ -1097,8 +1097,10 @@ const zh = Object.freeze({ 'claude.targetApi.label': '目标 API', 'claude.targetApi.responses': 'Anthropic / OpenAI Responses', 'claude.targetApi.chatCompletions': 'OpenAI Chat Completions (/v1/chat/completions)', + 'claude.targetApi.ollama': 'Ollama Chat (/api/chat)', 'claude.targetApi.chatCompletionsBadge': 'OpenAI Chat Completions', - 'claude.targetApi.hint': '选择 Chat Completions 时由 Claude 兼容代理内建转换,不修改 Codex provider 的 wire_api。', + 'claude.targetApi.ollamaBadge': 'Ollama', + 'claude.targetApi.hint': '选择 Chat Completions 或 Ollama 时由 Claude 兼容代理内建转换,不修改 Codex provider 的 wire_api;Ollama 可不填 API Key。', 'claude.health.title': '配置健康检查', 'claude.health.run': '运行检查', 'claude.health.running': '检查中...', diff --git a/web-ui/partials/index/modals-basic.html b/web-ui/partials/index/modals-basic.html index 5c07f1aa..382fa4fe 100644 --- a/web-ui/partials/index/modals-basic.html +++ b/web-ui/partials/index/modals-basic.html @@ -170,6 +170,7 @@
{{ t('claude.targetApi.hint') }}
@@ -217,6 +218,7 @@
{{ 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 e5083afe..1a11b3a8 100644 --- a/web-ui/partials/index/panel-config-claude.html +++ b/web-ui/partials/index/panel-config-claude.html @@ -150,6 +150,7 @@
{{ name }}
{{ config.model || t('claude.model.unset') }}
{{ t('claude.targetApi.chatCompletionsBadge') }}
+
{{ t('claude.targetApi.ollamaBadge') }}
{{ config.baseUrl }}
diff --git a/web-ui/res/web-ui-render.precompiled.js b/web-ui/res/web-ui-render.precompiled.js index ffb029be..b337394b 100644 --- a/web-ui/res/web-ui-render.precompiled.js +++ b/web-ui/res/web-ui-render.precompiled.js @@ -1914,10 +1914,15 @@ return function render(_ctx, _cache) { key: 0, class: "card-subtitle" }, _toDisplayString(_ctx.t('claude.targetApi.chatCompletionsBadge')), 1 /* TEXT */)) - : _createCommentVNode("v-if", true), + : (config.targetApi === 'ollama') + ? (_openBlock(), _createElementBlock("div", { + key: 1, + class: "card-subtitle" + }, _toDisplayString(_ctx.t('claude.targetApi.ollamaBadge')), 1 /* TEXT */)) + : _createCommentVNode("v-if", true), (config.baseUrl) ? (_openBlock(), _createElementBlock("div", { - key: 1, + key: 2, class: "card-subtitle card-subtitle-url" }, _toDisplayString(config.baseUrl), 1 /* TEXT */)) : _createCommentVNode("v-if", true) @@ -5799,7 +5804,8 @@ return function render(_ctx, _cache) { class: "form-input" }, [ _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 */) + _createElementVNode("option", { value: "chat_completions" }, _toDisplayString(_ctx.t('claude.targetApi.chatCompletions')), 1 /* TEXT */), + _createElementVNode("option", { value: "ollama" }, _toDisplayString(_ctx.t('claude.targetApi.ollama')), 1 /* TEXT */) ], 8 /* PROPS */, ["onUpdate:modelValue"]), [ [_vModelSelect, _ctx.newClaudeConfig.targetApi] ]), @@ -5951,7 +5957,8 @@ return function render(_ctx, _cache) { class: "form-input" }, [ _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 */) + _createElementVNode("option", { value: "chat_completions" }, _toDisplayString(_ctx.t('claude.targetApi.chatCompletions')), 1 /* TEXT */), + _createElementVNode("option", { value: "ollama" }, _toDisplayString(_ctx.t('claude.targetApi.ollama')), 1 /* TEXT */) ], 8 /* PROPS */, ["onUpdate:modelValue"]), [ [_vModelSelect, _ctx.editingConfig.targetApi] ]), diff --git a/web-ui/styles/controls-forms.css b/web-ui/styles/controls-forms.css index 5cf828a2..6e50747f 100644 --- a/web-ui/styles/controls-forms.css +++ b/web-ui/styles/controls-forms.css @@ -483,16 +483,16 @@ } .tool-config-write-overlay { - position: absolute; + position: fixed; inset: 0; - z-index: 20; + z-index: 90; display: flex; align-items: center; justify-content: center; box-sizing: border-box; - min-height: 360px; - padding: 16px; - border-radius: 18px; + min-height: 100dvh; + padding: max(16px, env(safe-area-inset-top)) max(16px, env(safe-area-inset-right)) max(16px, env(safe-area-inset-bottom)) max(16px, env(safe-area-inset-left)); + border-radius: 0; background: rgba(247, 240, 233, 0.78); background: color-mix(in srgb, var(--color-bg) 68%, transparent); backdrop-filter: blur(2px); From 881a1793e1b4ae5bcb39767bf82ba3e0b10c56a6 Mon Sep 17 00:00:00 2001 From: awsl233777 Date: Mon, 1 Jun 2026 16:42:47 +0000 Subject: [PATCH 07/22] fix: route claude ollama proxy to native api paths --- cli/claude-proxy.js | 11 +- tests/unit/claude-proxy-adapter.test.mjs | 125 ++++++++++++++++++++++- 2 files changed, 134 insertions(+), 2 deletions(-) diff --git a/cli/claude-proxy.js b/cli/claude-proxy.js index 5a31a63a..c7871c9d 100644 --- a/cli/claude-proxy.js +++ b/cli/claude-proxy.js @@ -866,6 +866,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, @@ -1166,7 +1175,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')); } diff --git a/tests/unit/claude-proxy-adapter.test.mjs b/tests/unit/claude-proxy-adapter.test.mjs index ac0b70de..280b467c 100644 --- a/tests/unit/claude-proxy-adapter.test.mjs +++ b/tests/unit/claude-proxy-adapter.test.mjs @@ -1,5 +1,11 @@ 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 { @@ -10,7 +16,8 @@ const { buildAnthropicMessageFromChatCompletion, buildAnthropicMessageFromOllamaChat, buildAnthropicStreamEvents, - buildAnthropicModelsPayload + buildAnthropicModelsPayload, + createBuiltinClaudeProxyRuntimeController } = require('../../cli/claude-proxy'); test('buildBuiltinClaudeResponsesRequest maps anthropic messages/tools into responses payload', () => { @@ -374,3 +381,119 @@ 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', 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', + 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: 'proxy ok' }]); + + assert.deepStrictEqual(upstreamRequests.map((item) => `${item.method} ${item.url}`), [ + 'GET /api/tags', + 'POST /api/chat' + ]); + } finally { + await controller.stopBuiltinClaudeProxyRuntime(); + await closeServerForTest(upstream); + } +}); From 93b2dfda64937e8eeff0564cce7f4eacfc308458 Mon Sep 17 00:00:00 2001 From: awsl233777 Date: Mon, 1 Jun 2026 16:51:26 +0000 Subject: [PATCH 08/22] fix: align claude mcp schema with ollama mode --- cli.js | 28 +++++++++++++++++++----- tests/unit/claude-settings-sync.test.mjs | 25 +++++++++++++++++++++ 2 files changed, 48 insertions(+), 5 deletions(-) diff --git a/cli.js b/cli.js index 55c362bf..ad5377fe 100644 --- a/cli.js +++ b/cli.js @@ -9357,24 +9357,33 @@ async function applyToClaudeSettings(config = {}) { return { success: false, mode: 'settings-file', error: '请先输入 API Key' }; } - const baseUrl = (config.baseUrl || (targetApi === 'ollama' ? 'http://127.0.0.1:11434' : '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: typeof config.name === 'string' ? config.name.trim() : '', + provider: upstreamProviderName, authSource: 'provider', targetApi, timeoutMs: DEFAULT_BUILTIN_CLAUDE_PROXY_SETTINGS.timeoutMs, - upstreamProviderName: typeof config.name === 'string' ? config.name.trim() : '', - upstreamBaseUrl: baseUrl, + upstreamProviderName, + ...(configuredBaseUrl ? { upstreamBaseUrl: configuredBaseUrl } : {}), upstreamApiKey: apiKey }); if (!proxyResult || proxyResult.error || proxyResult.success === false || !proxyResult.listenUrl) { @@ -15825,7 +15834,16 @@ function createMcpTools(options = {}) { name: { type: 'string' }, targetApi: { type: 'string' } }, - required: ['apiKey'], + allOf: [{ + if: { + not: { + type: 'object', + properties: { targetApi: { const: 'ollama' } }, + required: ['targetApi'] + } + }, + then: { required: ['apiKey'] } + }], additionalProperties: false }, handler: async (args = {}) => applyToClaudeSettings(args || {}) diff --git a/tests/unit/claude-settings-sync.test.mjs b/tests/unit/claude-settings-sync.test.mjs index 61ae6c13..d3c411a7 100644 --- a/tests/unit/claude-settings-sync.test.mjs +++ b/tests/unit/claude-settings-sync.test.mjs @@ -3,6 +3,7 @@ import { readBundledWebUiScript, readProjectFile } from './helpers/web-ui-source 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, '\\$&'); @@ -1116,3 +1117,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*\{ const: 'ollama' \} \}/); + assert.match(schemaSource, /then:\s*\{ required:\s*\['apiKey'\] \}/); + assert.doesNotMatch(schemaSource, /required:\s*\['apiKey'\],\s*additionalProperties/); +}); From 89f84bc03689e6a9784cffd3ecf547f96310f9d9 Mon Sep 17 00:00:00 2001 From: awsl233777 Date: Mon, 1 Jun 2026 17:45:51 +0000 Subject: [PATCH 09/22] fix: apply ollama claude config without api key --- tests/unit/claude-settings-sync.test.mjs | 118 +++++++++++++++++++ web-ui/modules/app.methods.claude-config.mjs | 9 +- 2 files changed, 125 insertions(+), 2 deletions(-) diff --git a/tests/unit/claude-settings-sync.test.mjs b/tests/unit/claude-settings-sync.test.mjs index d3c411a7..425392a5 100644 --- a/tests/unit/claude-settings-sync.test.mjs +++ b/tests/unit/claude-settings-sync.test.mjs @@ -586,8 +586,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' } } @@ -646,6 +648,71 @@ 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 context = { + 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', { @@ -683,6 +750,57 @@ 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 context = { + 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'); diff --git a/web-ui/modules/app.methods.claude-config.mjs b/web-ui/modules/app.methods.claude-config.mjs index 7bfbf2f9..17c26ded 100644 --- a/web-ui/modules/app.methods.claude-config.mjs +++ b/web-ui/modules/app.methods.claude-config.mjs @@ -165,8 +165,10 @@ export function createClaudeConfigMethods(options = {}) { } const name = validation.name; this.editingConfig.apiKey = validation.apiKey; + this.editingConfig.externalCredentialType = validation.externalCredentialType; this.editingConfig.baseUrl = validation.baseUrl; this.editingConfig.model = validation.model; + this.editingConfig.targetApi = validation.targetApi; this.claudeConfigs[name] = this.mergeClaudeConfig(this.claudeConfigs[name], this.editingConfig); this.saveClaudeConfigs(); this.showMessage('操作成功', 'success'); @@ -193,13 +195,15 @@ export function createClaudeConfigMethods(options = {}) { } const name = validation.name; this.editingConfig.apiKey = validation.apiKey; + this.editingConfig.externalCredentialType = validation.externalCredentialType; this.editingConfig.baseUrl = validation.baseUrl; this.editingConfig.model = validation.model; + this.editingConfig.targetApi = validation.targetApi; this.claudeConfigs[name] = this.mergeClaudeConfig(this.claudeConfigs[name], this.editingConfig); this.saveClaudeConfigs(); const config = this.claudeConfigs[name]; - if (!config.apiKey) { + if (!config.apiKey && config.targetApi !== 'ollama') { this.showMessage('已保存(未填写 API Key)', 'info'); this.closeEditConfigModal(); if (name === this.currentClaudeConfig) { @@ -237,6 +241,7 @@ export function createClaudeConfigMethods(options = {}) { this.newClaudeConfig.externalCredentialType = validation.externalCredentialType; this.newClaudeConfig.baseUrl = validation.baseUrl; this.newClaudeConfig.model = validation.model; + this.newClaudeConfig.targetApi = validation.targetApi; const name = validation.name; const duplicateName = this.findDuplicateClaudeConfigName(this.newClaudeConfig); if (duplicateName) { @@ -280,7 +285,7 @@ export function createClaudeConfigMethods(options = {}) { this.refreshClaudeModelContext(); const config = this.claudeConfigs[name]; - if (!config.apiKey) { + if (!config.apiKey && config.targetApi !== 'ollama') { if (config.externalCredentialType) { return this.showMessage('使用外部认证,无需 API Key', 'info'); } From 9c7e5cced9e5ce5deeb3dfb2f882e8aeba9a71d8 Mon Sep 17 00:00:00 2001 From: awsl233777 Date: Mon, 1 Jun 2026 18:09:56 +0000 Subject: [PATCH 10/22] fix: disable claude ollama target option --- tests/unit/config-tabs-ui.test.mjs | 8 +++++++ web-ui/modules/i18n/locales/en.mjs | 1 + web-ui/modules/i18n/locales/ja.mjs | 1 + web-ui/modules/i18n/locales/vi.mjs | 1 + web-ui/modules/i18n/locales/zh.mjs | 1 + web-ui/partials/index/modals-basic.html | 4 ++-- .../partials/index/panel-config-claude.html | 2 +- web-ui/res/web-ui-render.precompiled.js | 21 ++++++++++++++++--- 8 files changed, 33 insertions(+), 6 deletions(-) diff --git a/tests/unit/config-tabs-ui.test.mjs b/tests/unit/config-tabs-ui.test.mjs index 7062d039..08f2bcbb 100644 --- a/tests/unit/config-tabs-ui.test.mjs +++ b/tests/unit/config-tabs-ui.test.mjs @@ -282,6 +282,14 @@ 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, + /
@@ -218,7 +218,7 @@
{{ 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 1a11b3a8..4d299b44 100644 --- a/web-ui/partials/index/panel-config-claude.html +++ b/web-ui/partials/index/panel-config-claude.html @@ -145,7 +145,7 @@
-
{{ name.charAt(0).toUpperCase() }}
+
{{ name.charAt(0).toUpperCase() }}
{{ name }}
{{ config.model || t('claude.model.unset') }}
diff --git a/web-ui/res/web-ui-render.precompiled.js b/web-ui/res/web-ui-render.precompiled.js index b337394b..3a358d2e 100644 --- a/web-ui/res/web-ui-render.precompiled.js +++ b/web-ui/res/web-ui-render.precompiled.js @@ -1905,7 +1905,16 @@ return function render(_ctx, _cache) { "aria-current": _ctx.currentClaudeConfig === name ? 'true' : null }, [ _createElementVNode("div", { class: "card-leading" }, [ - _createElementVNode("div", { class: "card-icon" }, _toDisplayString(name.charAt(0).toUpperCase()), 1 /* TEXT */), + _createElementVNode("div", { class: "card-icon" }, [ + _createTextVNode(_toDisplayString(name.charAt(0).toUpperCase()), 1 /* TEXT */), + (config.targetApi === 'chat_completions' || config.targetApi === 'ollama') + ? (_openBlock(), _createElementBlock("span", { + key: 0, + class: "card-icon-dot", + title: _ctx.t('config.transformProvider.title') + }, null, 8 /* PROPS */, ["title"])) + : _createCommentVNode("v-if", true) + ]), _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 */), @@ -5805,7 +5814,10 @@ return function render(_ctx, _cache) { }, [ _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 */), - _createElementVNode("option", { value: "ollama" }, _toDisplayString(_ctx.t('claude.targetApi.ollama')), 1 /* TEXT */) + _createElementVNode("option", { + value: "ollama", + disabled: "" + }, _toDisplayString(_ctx.t('claude.targetApi.ollama')) + " · " + _toDisplayString(_ctx.t('common.temporarilyDisabled')), 1 /* TEXT */) ], 8 /* PROPS */, ["onUpdate:modelValue"]), [ [_vModelSelect, _ctx.newClaudeConfig.targetApi] ]), @@ -5958,7 +5970,10 @@ return function render(_ctx, _cache) { }, [ _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 */), - _createElementVNode("option", { value: "ollama" }, _toDisplayString(_ctx.t('claude.targetApi.ollama')), 1 /* TEXT */) + _createElementVNode("option", { + value: "ollama", + disabled: "" + }, _toDisplayString(_ctx.t('claude.targetApi.ollama')) + " · " + _toDisplayString(_ctx.t('common.temporarilyDisabled')), 1 /* TEXT */) ], 8 /* PROPS */, ["onUpdate:modelValue"]), [ [_vModelSelect, _ctx.editingConfig.targetApi] ]), From 2f252ef4ec308921505c24015fdc3d794c2bea42 Mon Sep 17 00:00:00 2001 From: awsl233777 Date: Mon, 1 Jun 2026 18:19:24 +0000 Subject: [PATCH 11/22] fix: prevent provider permission switch flicker --- tests/unit/claude-settings-sync.test.mjs | 34 +++++++++++++++++++ tests/unit/config-tabs-ui.test.mjs | 10 +++++- web-ui/modules/i18n/locales/en.mjs | 1 - web-ui/modules/i18n/locales/ja.mjs | 1 - web-ui/modules/i18n/locales/vi.mjs | 1 - web-ui/modules/i18n/locales/zh.mjs | 1 - web-ui/partials/index/modals-basic.html | 4 +-- .../partials/index/panel-config-claude.html | 1 + web-ui/partials/index/panel-config-codex.html | 3 +- web-ui/res/web-ui-render.precompiled.js | 12 +++---- 10 files changed, 52 insertions(+), 16 deletions(-) diff --git a/tests/unit/claude-settings-sync.test.mjs b/tests/unit/claude-settings-sync.test.mjs index 425392a5..198449be 100644 --- a/tests/unit/claude-settings-sync.test.mjs +++ b/tests/unit/claude-settings-sync.test.mjs @@ -314,6 +314,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'); diff --git a/tests/unit/config-tabs-ui.test.mjs b/tests/unit/config-tabs-ui.test.mjs index 08f2bcbb..4a5e60e3 100644 --- a/tests/unit/config-tabs-ui.test.mjs +++ b/tests/unit/config-tabs-ui.test.mjs @@ -288,7 +288,15 @@ test('config template keeps expected config tabs in top and side navigation', () ); assert.match( html, - /
@@ -218,7 +218,7 @@
{{ 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 4d299b44..ff2103c1 100644 --- a/web-ui/partials/index/panel-config-claude.html +++ b/web-ui/partials/index/panel-config-claude.html @@ -34,6 +34,7 @@
- \ No newline at end of file + diff --git a/web-ui/res/web-ui-render.precompiled.js b/web-ui/res/web-ui-render.precompiled.js index 3a358d2e..40950d8c 100644 --- a/web-ui/res/web-ui-render.precompiled.js +++ b/web-ui/res/web-ui-render.precompiled.js @@ -958,6 +958,7 @@ return function render(_ctx, _cache) { _createElementVNode("label", { class: "settings-toggle-row tool-config-write-toggle" }, [ _createElementVNode("input", { type: "checkbox", + autocomplete: "off", checked: _ctx.isToolConfigWriteAllowed('codex'), disabled: _ctx.toolConfigPermissionSaving.codex, onChange: $event => (_ctx.setToolConfigPermission('codex', $event.target.checked)) @@ -1583,6 +1584,7 @@ return function render(_ctx, _cache) { _createElementVNode("label", { class: "settings-toggle-row tool-config-write-toggle" }, [ _createElementVNode("input", { type: "checkbox", + autocomplete: "off", checked: _ctx.isToolConfigWriteAllowed('claude'), disabled: _ctx.toolConfigPermissionSaving.claude, onChange: $event => (_ctx.setToolConfigPermission('claude', $event.target.checked)) @@ -5814,10 +5816,7 @@ return function render(_ctx, _cache) { }, [ _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 */), - _createElementVNode("option", { - value: "ollama", - disabled: "" - }, _toDisplayString(_ctx.t('claude.targetApi.ollama')) + " · " + _toDisplayString(_ctx.t('common.temporarilyDisabled')), 1 /* TEXT */) + _createElementVNode("option", { value: "ollama" }, _toDisplayString(_ctx.t('claude.targetApi.ollama')), 1 /* TEXT */) ], 8 /* PROPS */, ["onUpdate:modelValue"]), [ [_vModelSelect, _ctx.newClaudeConfig.targetApi] ]), @@ -5970,10 +5969,7 @@ return function render(_ctx, _cache) { }, [ _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 */), - _createElementVNode("option", { - value: "ollama", - disabled: "" - }, _toDisplayString(_ctx.t('claude.targetApi.ollama')) + " · " + _toDisplayString(_ctx.t('common.temporarilyDisabled')), 1 /* TEXT */) + _createElementVNode("option", { value: "ollama" }, _toDisplayString(_ctx.t('claude.targetApi.ollama')), 1 /* TEXT */) ], 8 /* PROPS */, ["onUpdate:modelValue"]), [ [_vModelSelect, _ctx.editingConfig.targetApi] ]), From 7ec2afa85691f4e66b8bc27118b848d978392968 Mon Sep 17 00:00:00 2001 From: awsl233777 Date: Mon, 1 Jun 2026 18:28:53 +0000 Subject: [PATCH 12/22] feat: show sidebar update notice --- cli.js | 23 +++++- cli/update.js | 34 +++++++-- tests/unit/config-tabs-ui.test.mjs | 5 ++ tests/unit/install-target-cards.test.mjs | 34 +++++++++ tests/unit/web-ui-behavior-parity.test.mjs | 13 ++++ web-ui/app.js | 6 ++ web-ui/modules/app.methods.index.mjs | 2 +- web-ui/modules/app.methods.install.mjs | 84 +++++++++++++++++++++- web-ui/modules/i18n/locales/en.mjs | 5 ++ web-ui/modules/i18n/locales/ja.mjs | 5 ++ web-ui/modules/i18n/locales/vi.mjs | 5 ++ web-ui/modules/i18n/locales/zh.mjs | 5 ++ web-ui/partials/index/layout-header.html | 12 ++++ web-ui/res/web-ui-render.precompiled.js | 17 ++++- web-ui/styles/layout-shell.css | 55 ++++++++++++++ 15 files changed, 294 insertions(+), 11 deletions(-) diff --git a/cli.js b/cli.js index ad5377fe..41f86c51 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, @@ -11073,6 +11073,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; 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/unit/config-tabs-ui.test.mjs b/tests/unit/config-tabs-ui.test.mjs index 4a5e60e3..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"/); diff --git a/tests/unit/install-target-cards.test.mjs b/tests/unit/install-target-cards.test.mjs index c473bab6..a1119b67 100644 --- a/tests/unit/install-target-cards.test.mjs +++ b/tests/unit/install-target-cards.test.mjs @@ -42,3 +42,37 @@ test('installTargetCards falls back when install-status is missing', () => { assert(codex.termuxCommand.includes('@mmmbuto/codex-cli-termux')); }); +test('app update notice only appears when latest package version is newer', () => { + const ctx = createContext({ + appVersion: '0.0.40', + appLatestVersion: '0.0.41', + t(key, params = {}) { + if (key === 'side.update.availableWithVersion') return `Update v${params.version}`; + if (key === 'side.update.metaVersions') return `${params.current}->${params.latest}`; + return key; + } + }); + + assert.strictEqual(ctx.comparePackageVersions('0.0.40', '0.0.41'), -1); + assert.strictEqual(ctx.comparePackageVersions('0.0.41', '0.0.40'), 1); + assert.strictEqual(ctx.comparePackageVersions('v0.0.41', '0.0.41'), 0); + assert.strictEqual(ctx.isAppUpdateAvailable(), true); + assert.strictEqual(ctx.appUpdateNoticeText(), 'Update v0.0.41'); + assert.strictEqual(ctx.appUpdateNoticeMeta(), '0.0.40->0.0.41'); + + ctx.appLatestVersion = '0.0.40'; + assert.strictEqual(ctx.isAppUpdateAvailable(), false); +}); + +test('openAppUpdateDocs switches to docs update command without running update', () => { + const calls = []; + const ctx = createContext({ + installCommandAction: 'install', + switchMainTab(tab) { calls.push(tab); } + }); + + ctx.openAppUpdateDocs(); + + assert.strictEqual(ctx.installCommandAction, 'update'); + assert.deepStrictEqual(calls, ['docs']); +}); diff --git a/tests/unit/web-ui-behavior-parity.test.mjs b/tests/unit/web-ui-behavior-parity.test.mjs index 1afe6e8f..f7c266ef 100644 --- a/tests/unit/web-ui-behavior-parity.test.mjs +++ b/tests/unit/web-ui-behavior-parity.test.mjs @@ -325,6 +325,9 @@ test('captured bundled app skeleton only exposes expected data key drift versus const missingCurrentKeys = headDataKeys.filter((key) => !currentDataKeys.includes(key)).sort(); const allowedExtraCurrentKeys = parityAgainstHead ? [ 'appVersion', + 'appLatestVersion', + 'appVersionStatusError', + 'appVersionStatusLoading', 'sessionListInitialBatchSize', 'sessionListLoadStep', 'sessionListVisibleCount', @@ -358,6 +361,9 @@ test('captured bundled app skeleton only exposes expected data key drift versus 'toolConfigPermissions' ] : [ 'appVersion', + 'appLatestVersion', + 'appVersionStatusError', + 'appVersionStatusLoading', '__mainTabSwitchState', 'openclawAuthProfilesByProvider', 'openclawPendingAuthProfileUpdates', @@ -577,6 +583,13 @@ test('captured bundled app skeleton only exposes expected data key drift versus 'setToolConfigPermission' ]; allowedExtraCurrentMethodKeys.push( + 'normalizePackageVersion', + 'comparePackageVersions', + 'isAppUpdateAvailable', + 'appUpdateNoticeText', + 'appUpdateNoticeMeta', + 'loadAppVersionStatus', + 'openAppUpdateDocs', 'hasActiveSessionFilters', 'getSessionFilterChips', 'clearSessionFilterChip', diff --git a/web-ui/app.js b/web-ui/app.js index c116ae8b..c379e4f1 100644 --- a/web-ui/app.js +++ b/web-ui/app.js @@ -270,6 +270,9 @@ document.addEventListener('DOMContentLoaded', () => { installRegistryPreset: 'default', installRegistryCustom: '', installStatusTargets: null, + appLatestVersion: '', + appVersionStatusLoading: false, + appVersionStatusError: '', newProvider: { name: '', url: '', key: '', model: '', useTransform: false }, resetConfigLoading: false, editingProvider: { name: '', url: '', key: '', readOnly: false, nonEditable: false }, @@ -630,6 +633,9 @@ document.addEventListener('DOMContentLoaded', () => { } } } + if (typeof this.loadAppVersionStatus === 'function') { + void this.loadAppVersionStatus({ silent: true }); + } void this.refreshClaudeSelectionFromSettings({ silent: true }); void this.syncDefaultOpenclawConfigEntry({ silent: true }); }; diff --git a/web-ui/modules/app.methods.index.mjs b/web-ui/modules/app.methods.index.mjs index d8517362..e8657d7e 100644 --- a/web-ui/modules/app.methods.index.mjs +++ b/web-ui/modules/app.methods.index.mjs @@ -89,7 +89,7 @@ export function createAppMethods() { api, defaultOpenclawTemplate: DEFAULT_OPENCLAW_TEMPLATE }), - ...createInstallMethods(), + ...createInstallMethods({ api }), ...createRuntimeMethods({ api }), ...createTaskOrchestrationMethods({ api }) }; diff --git a/web-ui/modules/app.methods.install.mjs b/web-ui/modules/app.methods.install.mjs index 4bb9f731..e68117c4 100644 --- a/web-ui/modules/app.methods.install.mjs +++ b/web-ui/modules/app.methods.install.mjs @@ -1,4 +1,5 @@ -export function createInstallMethods() { +export function createInstallMethods(options = {}) { + const { api } = options; return { normalizeInstallPackageManager(value) { const normalized = typeof value === 'string' ? value.trim().toLowerCase() : ''; @@ -16,6 +17,87 @@ export function createInstallMethods() { return 'install'; }, + normalizePackageVersion(value) { + const normalized = typeof value === 'string' ? value.trim().replace(/^v/i, '') : ''; + return /^\d+(?:\.\d+){0,2}(?:[-+][0-9A-Za-z.-]+)?$/.test(normalized) ? normalized : ''; + }, + + comparePackageVersions(left, right) { + const normalizeParts = (value) => { + const normalized = this.normalizePackageVersion(value); + if (!normalized) return null; + return normalized.split(/[+-]/)[0].split('.').map((part) => Number.parseInt(part, 10) || 0); + }; + const a = normalizeParts(left); + const b = normalizeParts(right); + if (!a || !b) return 0; + for (let i = 0; i < 3; i += 1) { + const diff = (a[i] || 0) - (b[i] || 0); + if (diff < 0) return -1; + if (diff > 0) return 1; + } + return 0; + }, + + isAppUpdateAvailable() { + const current = this.normalizePackageVersion(this.appVersion); + const latest = this.normalizePackageVersion(this.appLatestVersion); + if (!current || !latest) return false; + return this.comparePackageVersions(current, latest) < 0; + }, + + appUpdateNoticeText() { + const latest = this.normalizePackageVersion(this.appLatestVersion); + return latest + ? this.t('side.update.availableWithVersion', { version: latest }) + : this.t('side.update.available'); + }, + + appUpdateNoticeMeta() { + const current = this.normalizePackageVersion(this.appVersion); + const latest = this.normalizePackageVersion(this.appLatestVersion); + if (current && latest) { + return this.t('side.update.metaVersions', { current, latest }); + } + return this.t('side.update.meta'); + }, + + async loadAppVersionStatus(options = {}) { + if (typeof api !== 'function') return false; + if (this.appVersionStatusLoading) return false; + this.appVersionStatusLoading = true; + this.appVersionStatusError = ''; + try { + const res = await api('version-status'); + if (res && res.currentVersion && !this.appVersion) { + this.appVersion = this.normalizePackageVersion(res.currentVersion) || String(res.currentVersion || ''); + } + if (res && res.latestVersion) { + this.appLatestVersion = this.normalizePackageVersion(res.latestVersion) || String(res.latestVersion || ''); + } + if (res && res.error) { + this.appVersionStatusError = res.error; + if (!options.silent) this.showMessage(res.error, 'error'); + return false; + } + return true; + } catch (e) { + const message = e && e.message ? e.message : this.t('side.update.checkFailed'); + this.appVersionStatusError = message; + if (!options.silent) this.showMessage(message, 'error'); + return false; + } finally { + this.appVersionStatusLoading = false; + } + }, + + openAppUpdateDocs() { + this.installCommandAction = 'update'; + if (typeof this.switchMainTab === 'function') { + this.switchMainTab('docs'); + } + }, + normalizeInstallRegistryPreset(value) { const normalized = typeof value === 'string' ? value.trim().toLowerCase() : ''; if (normalized === 'default' || normalized === 'npmmirror' || normalized === 'tencent' || normalized === 'custom') { diff --git a/web-ui/modules/i18n/locales/en.mjs b/web-ui/modules/i18n/locales/en.mjs index f6d2c0c6..11770c3a 100644 --- a/web-ui/modules/i18n/locales/en.mjs +++ b/web-ui/modules/i18n/locales/en.mjs @@ -143,6 +143,11 @@ const en = Object.freeze({ 'side.overview.doctor.meta': 'Overview / Diagnostics', 'side.docs.cliInstall': 'CLI Install', 'side.docs.cliInstall.meta': 'Install / Update / Uninstall', + 'side.update.available': 'Update available', + 'side.update.availableWithVersion': 'Update available v{version}', + 'side.update.meta': 'Open update command', + 'side.update.metaVersions': 'Current v{current} → latest v{latest}', + 'side.update.checkFailed': 'Failed to check latest version', 'side.config.codex': 'Codex', 'side.config.codex.meta': 'Provider / Model', 'side.config.claude': 'Claude Code', diff --git a/web-ui/modules/i18n/locales/ja.mjs b/web-ui/modules/i18n/locales/ja.mjs index 25015b7d..923224bd 100644 --- a/web-ui/modules/i18n/locales/ja.mjs +++ b/web-ui/modules/i18n/locales/ja.mjs @@ -144,6 +144,11 @@ const ja = Object.freeze({ 'side.overview.doctor.meta': '概要 / 診断 / ジャンプ', 'side.docs.cliInstall': 'CLI インストール', 'side.docs.cliInstall.meta': 'インストール / 更新 / 削除', + 'side.update.available': '新しいバージョンがあります', + 'side.update.availableWithVersion': '新しいバージョン v{version}', + 'side.update.meta': '更新コマンドを開く', + 'side.update.metaVersions': '現在 v{current} → 最新 v{latest}', + 'side.update.checkFailed': '最新バージョンの確認に失敗しました', 'side.config.codex': 'Codex', 'side.config.codex.meta': 'Provider / Model', 'side.config.claude': 'Claude Code', diff --git a/web-ui/modules/i18n/locales/vi.mjs b/web-ui/modules/i18n/locales/vi.mjs index ceb36f1f..8b8b19b2 100644 --- a/web-ui/modules/i18n/locales/vi.mjs +++ b/web-ui/modules/i18n/locales/vi.mjs @@ -104,6 +104,11 @@ const vi = Object.freeze({ 'side.overview.doctor.meta': 'Tổng quan / Chẩn đoán', 'side.docs.cliInstall': 'Cài CLI', 'side.docs.cliInstall.meta': 'Cài đặt / Cập nhật / Gỡ', + 'side.update.available': 'Có bản cập nhật', + 'side.update.availableWithVersion': 'Có bản cập nhật v{version}', + 'side.update.meta': 'Mở lệnh cập nhật', + 'side.update.metaVersions': 'Hiện tại v{current} → mới nhất v{latest}', + 'side.update.checkFailed': 'Không thể kiểm tra phiên bản mới nhất', 'side.config.codex': 'Codex', 'side.config.codex.meta': 'Provider / Model', 'side.config.claude': 'Claude Code', diff --git a/web-ui/modules/i18n/locales/zh.mjs b/web-ui/modules/i18n/locales/zh.mjs index d98d9775..08c6bd60 100644 --- a/web-ui/modules/i18n/locales/zh.mjs +++ b/web-ui/modules/i18n/locales/zh.mjs @@ -143,6 +143,11 @@ const zh = Object.freeze({ 'side.overview.doctor.meta': '总览 / 诊断 / 跳转', 'side.docs.cliInstall': 'CLI 安装', 'side.docs.cliInstall.meta': '安装 / 升级 / 卸载', + 'side.update.available': '有新版本', + 'side.update.availableWithVersion': '有新版本 v{version}', + 'side.update.meta': '点击查看更新命令', + 'side.update.metaVersions': '当前 v{current} → 最新 v{latest}', + 'side.update.checkFailed': '检查最新版本失败', 'side.config.codex': 'Codex', 'side.config.codex.meta': 'Provider / Model', 'side.config.claude': 'Claude Code', diff --git a/web-ui/partials/index/layout-header.html b/web-ui/partials/index/layout-header.html index 98b2a445..f2700e34 100644 --- a/web-ui/partials/index/layout-header.html +++ b/web-ui/partials/index/layout-header.html @@ -110,6 +110,18 @@
Codex Mate v{{ appVersion }}
+
diff --git a/web-ui/res/web-ui-render.precompiled.js b/web-ui/res/web-ui-render.precompiled.js index 40950d8c..ae860041 100644 --- a/web-ui/res/web-ui-render.precompiled.js +++ b/web-ui/res/web-ui-render.precompiled.js @@ -163,7 +163,22 @@ return function render(_ctx, _cache) { : _createCommentVNode("v-if", true) ]) ]) - ]) + ]), + (_ctx.isAppUpdateAvailable()) + ? (_openBlock(), _createElementBlock("button", { + key: 0, + type: "button", + class: "side-update-notice", + title: _ctx.appUpdateNoticeMeta(), + onClick: _ctx.openAppUpdateDocs + }, [ + _createElementVNode("span", { class: "side-update-dot" }), + _createElementVNode("span", { class: "side-update-copy" }, [ + _createElementVNode("span", { class: "side-update-title" }, _toDisplayString(_ctx.appUpdateNoticeText()), 1 /* TEXT */), + _createElementVNode("span", { class: "side-update-meta" }, _toDisplayString(_ctx.appUpdateNoticeMeta()), 1 /* TEXT */) + ]) + ], 8 /* PROPS */, ["title", "onClick"])) + : _createCommentVNode("v-if", true) ]), _createElementVNode("div", { class: "side-rail-nav" }, [ _createElementVNode("div", { diff --git a/web-ui/styles/layout-shell.css b/web-ui/styles/layout-shell.css index 62c6a927..c54258b5 100644 --- a/web-ui/styles/layout-shell.css +++ b/web-ui/styles/layout-shell.css @@ -507,6 +507,61 @@ body::after { -webkit-text-fill-color: #8e8e93; } +.side-update-notice { + width: 100%; + margin-top: 12px; + padding: 8px 9px; + border: 1px solid rgba(200, 121, 99, 0.16); + border-radius: 12px; + background: rgba(255, 255, 255, 0.52); + color: var(--color-text-secondary); + display: flex; + align-items: center; + gap: 8px; + text-align: left; + cursor: pointer; + box-shadow: none; + transition: border-color 160ms ease, background 160ms ease, color 160ms ease; +} + +.side-update-notice:hover { + border-color: rgba(200, 121, 99, 0.30); + background: rgba(255, 255, 255, 0.74); + color: var(--color-text-primary); +} + +.side-update-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: rgba(200, 121, 99, 0.72); + box-shadow: 0 0 0 4px rgba(200, 121, 99, 0.08); + flex-shrink: 0; +} + +.side-update-copy { + min-width: 0; + display: flex; + flex-direction: column; + gap: 1px; +} + +.side-update-title { + font-size: 11px; + line-height: 1.25; + font-weight: 650; + color: inherit; +} + +.side-update-meta { + font-size: 10px; + line-height: 1.2; + color: var(--color-text-tertiary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + .brand-version-fade-enter-active, .brand-version-fade-leave-active { transition: opacity 0.2s var(--ease-smooth); From d22d26d0a8a85e99131be1c22e6e64ea4e1d5ce1 Mon Sep 17 00:00:00 2001 From: awsl233777 Date: Tue, 2 Jun 2026 16:27:26 +0000 Subject: [PATCH 13/22] fix: preserve ollama claude share imports --- cli.js | 54 +++++++++++--- tests/e2e/test-claude.js | 29 ++++++++ tests/unit/provider-share-command.test.mjs | 72 +++++++++++++++++++ .../modules/app.methods.session-actions.mjs | 9 ++- web-ui/modules/i18n/locales/en.mjs | 2 +- web-ui/modules/i18n/locales/ja.mjs | 2 +- web-ui/modules/i18n/locales/vi.mjs | 2 +- web-ui/modules/i18n/locales/zh.mjs | 2 +- 8 files changed, 158 insertions(+), 14 deletions(-) diff --git a/cli.js b/cli.js index 41f86c51..3f3675f0 100644 --- a/cli.js +++ b/cli.js @@ -8000,15 +8000,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 } }; } @@ -9575,6 +9577,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 : []; // 无参数 → 代理启动 @@ -9582,7 +9618,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() @@ -9591,19 +9627,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 = await applyToClaudeSettings({ baseUrl: normalizedBaseUrl, apiKey: normalizedKey, - model: normalizedModel + model: normalizedModel, + targetApi }); if (!result || result.success === false) { @@ -16320,7 +16358,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/tests/e2e/test-claude.js b/tests/e2e/test-claude.js index 057043b7..0d9eb4c1 100644 --- a/tests/e2e/test-claude.js +++ b/tests/e2e/test-claude.js @@ -45,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'); @@ -95,6 +104,26 @@ module.exports = async function testClaude(ctx) { 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 } diff --git a/tests/unit/provider-share-command.test.mjs b/tests/unit/provider-share-command.test.mjs index a0c17ea9..72bbbf9e 100644 --- a/tests/unit/provider-share-command.test.mjs +++ b/tests/unit/provider-share-command.test.mjs @@ -374,6 +374,44 @@ test('buildProviderShareCommand adds bridge flag when present', () => { ); }); +test('buildClaudeSharePayload allows ollama share without api key and preserves target api', () => { + const normalizeClaudeTargetApiSource = extractBlockBySignature(cliSource, 'function normalizeClaudeTargetApi(value) {'); + const normalizeClaudeTargetApi = instantiateFunction(normalizeClaudeTargetApiSource, 'normalizeClaudeTargetApi'); + const buildClaudeSharePayloadSource = extractBlockBySignature(cliSource, 'function buildClaudeSharePayload(config = {}) {'); + const buildClaudeSharePayload = instantiateFunction(buildClaudeSharePayloadSource, 'buildClaudeSharePayload', { + normalizeClaudeTargetApi, + DEFAULT_CLAUDE_MODEL: 'glm-4.7' + }); + + const result = buildClaudeSharePayload({ + baseUrl: 'http://127.0.0.1:11434', + apiKey: '', + model: 'llama3.1:8b', + targetApi: 'ollama' + }); + + assert(result && result.payload, 'ollama share payload should exist without api key'); + assert.strictEqual(result.payload.baseUrl, 'http://127.0.0.1:11434'); + assert.strictEqual(result.payload.apiKey, ''); + assert.strictEqual(result.payload.model, 'llama3.1:8b'); + assert.strictEqual(result.payload.targetApi, 'ollama'); +}); + +test('buildClaudeSharePayload still requires api key for non-ollama targets', () => { + const normalizeClaudeTargetApiSource = extractBlockBySignature(cliSource, 'function normalizeClaudeTargetApi(value) {'); + const normalizeClaudeTargetApi = instantiateFunction(normalizeClaudeTargetApiSource, 'normalizeClaudeTargetApi'); + const buildClaudeSharePayloadSource = extractBlockBySignature(cliSource, 'function buildClaudeSharePayload(config = {}) {'); + const buildClaudeSharePayload = instantiateFunction(buildClaudeSharePayloadSource, 'buildClaudeSharePayload', { + normalizeClaudeTargetApi, + DEFAULT_CLAUDE_MODEL: 'glm-4.7' + }); + + assert.deepStrictEqual( + buildClaudeSharePayload({ baseUrl: 'https://claude.example.com', apiKey: '', targetApi: 'responses' }), + { error: 'Claude API 密钥未设置' } + ); +}); + test('buildClaudeShareCommand respects the configured share prefix', () => { const buildClaudeShareCommand = createClaudeShareCommandBuilder(appSource); const command = buildClaudeShareCommand({ @@ -388,6 +426,40 @@ test('buildClaudeShareCommand respects the configured share prefix', () => { ); }); +test('buildClaudeShareCommand keeps ollama target api and empty api key in import command', () => { + const buildClaudeShareCommand = createClaudeShareCommandBuilder(appSource, 'codexmate'); + const command = buildClaudeShareCommand({ + baseUrl: 'http://127.0.0.1:11434', + apiKey: '', + model: 'llama3.1:8b', + targetApi: 'ollama' + }); + + assert.strictEqual( + command, + "codexmate claude http://127.0.0.1:11434 '' llama3.1:8b --target-api ollama" + ); +}); + +test('parseClaudeCommandArgs preserves ollama target api from shared import command', () => { + const normalizeClaudeTargetApiSource = extractBlockBySignature(cliSource, 'function normalizeClaudeTargetApi(value) {'); + const normalizeClaudeTargetApi = instantiateFunction(normalizeClaudeTargetApiSource, 'normalizeClaudeTargetApi'); + const parseClaudeCommandArgsSource = extractBlockBySignature(cliSource, 'function parseClaudeCommandArgs(argv = []) {'); + const parseClaudeCommandArgs = instantiateFunction(parseClaudeCommandArgsSource, 'parseClaudeCommandArgs', { + normalizeClaudeTargetApi + }); + + assert.deepStrictEqual( + parseClaudeCommandArgs(['http://127.0.0.1:11434', '', 'llama3.1:8b', '--target-api', 'ollama']), + { + baseUrl: 'http://127.0.0.1:11434', + apiKey: '', + model: 'llama3.1:8b', + targetApi: 'ollama' + } + ); +}); + test('applyConfigTemplate rejects invalid positive integer context budget values', () => { const normalizePositiveIntegerParamSource = extractBlockBySignature( cliSource, diff --git a/web-ui/modules/app.methods.session-actions.mjs b/web-ui/modules/app.methods.session-actions.mjs index 3ef8a246..9a04aa5e 100644 --- a/web-ui/modules/app.methods.session-actions.mjs +++ b/web-ui/modules/app.methods.session-actions.mjs @@ -386,11 +386,16 @@ export function createSessionActionMethods(options = {}) { const model = typeof payload.model === 'string' && payload.model.trim() ? payload.model.trim() : 'glm-4.7'; - if (!baseUrl || !apiKey) return ''; + const targetApiRaw = typeof payload.targetApi === 'string' ? payload.targetApi.trim().toLowerCase() : ''; + const targetApi = targetApiRaw === 'chat_completions' || targetApiRaw === 'chat-completions' || targetApiRaw === 'chat/completions' + ? 'chat_completions' + : (targetApiRaw === 'ollama' ? 'ollama' : 'responses'); + if (!baseUrl || (!apiKey && targetApi !== 'ollama')) return ''; const urlArg = this.quoteShellArg(baseUrl); const keyArg = this.quoteShellArg(apiKey); const modelArg = this.quoteShellArg(model); - return `${this.getShareCommandPrefixInvocation()} claude ${urlArg} ${keyArg} ${modelArg}`; + const targetArg = targetApi !== 'responses' ? ` --target-api ${this.quoteShellArg(targetApi)}` : ''; + return `${this.getShareCommandPrefixInvocation()} claude ${urlArg} ${keyArg} ${modelArg}${targetArg}`; }, async copyProviderShareCommand(provider) { diff --git a/web-ui/modules/i18n/locales/en.mjs b/web-ui/modules/i18n/locales/en.mjs index 11770c3a..3f310160 100644 --- a/web-ui/modules/i18n/locales/en.mjs +++ b/web-ui/modules/i18n/locales/en.mjs @@ -1097,7 +1097,7 @@ const en = Object.freeze({ 'claude.model.placeholder': 'e.g. claude-3-7-sonnet', 'claude.model.hint': 'Model changes are saved and applied to the current config automatically.', 'claude.targetApi.label': 'Target API', - 'claude.targetApi.responses': 'Anthropic / OpenAI Responses', + 'claude.targetApi.responses': 'Anthropic', 'claude.targetApi.chatCompletions': 'OpenAI Chat Completions (/v1/chat/completions)', 'claude.targetApi.ollama': 'Ollama Chat (/api/chat)', 'claude.targetApi.chatCompletionsBadge': 'OpenAI Chat Completions', diff --git a/web-ui/modules/i18n/locales/ja.mjs b/web-ui/modules/i18n/locales/ja.mjs index 923224bd..57764607 100644 --- a/web-ui/modules/i18n/locales/ja.mjs +++ b/web-ui/modules/i18n/locales/ja.mjs @@ -1089,7 +1089,7 @@ const ja = Object.freeze({ 'claude.model.placeholder': '例: claude-3-7-sonnet', 'claude.model.hint': 'モデル変更後は自動保存され、現在の設定に適用されます。', 'claude.targetApi.label': 'ターゲット API', - 'claude.targetApi.responses': 'Anthropic / OpenAI Responses', + 'claude.targetApi.responses': 'Anthropic', 'claude.targetApi.chatCompletions': 'OpenAI Chat Completions (/v1/chat/completions)', 'claude.targetApi.ollama': 'Ollama Chat (/api/chat)', 'claude.targetApi.chatCompletionsBadge': 'OpenAI Chat Completions', diff --git a/web-ui/modules/i18n/locales/vi.mjs b/web-ui/modules/i18n/locales/vi.mjs index 8b8b19b2..c950cdc8 100644 --- a/web-ui/modules/i18n/locales/vi.mjs +++ b/web-ui/modules/i18n/locales/vi.mjs @@ -242,7 +242,7 @@ const vi = Object.freeze({ // Claude target API 'claude.targetApi.label': 'API đích', - 'claude.targetApi.responses': 'Anthropic / OpenAI Responses', + 'claude.targetApi.responses': 'Anthropic', 'claude.targetApi.chatCompletions': 'OpenAI Chat Completions (/v1/chat/completions)', 'claude.targetApi.ollama': 'Ollama Chat (/api/chat)', 'claude.targetApi.chatCompletionsBadge': 'OpenAI Chat Completions', diff --git a/web-ui/modules/i18n/locales/zh.mjs b/web-ui/modules/i18n/locales/zh.mjs index 08c6bd60..3b422bf1 100644 --- a/web-ui/modules/i18n/locales/zh.mjs +++ b/web-ui/modules/i18n/locales/zh.mjs @@ -1100,7 +1100,7 @@ const zh = Object.freeze({ 'claude.model.placeholder': '例如: claude-3-7-sonnet', 'claude.model.hint': '模型修改后会自动保存并应用到当前配置。', 'claude.targetApi.label': '目标 API', - 'claude.targetApi.responses': 'Anthropic / OpenAI Responses', + 'claude.targetApi.responses': 'Anthropic', 'claude.targetApi.chatCompletions': 'OpenAI Chat Completions (/v1/chat/completions)', 'claude.targetApi.ollama': 'Ollama Chat (/api/chat)', 'claude.targetApi.chatCompletionsBadge': 'OpenAI Chat Completions', From f1dce82a149adafcff490538852878a97c8adb37 Mon Sep 17 00:00:00 2001 From: awsl233777 Date: Tue, 2 Jun 2026 16:28:40 +0000 Subject: [PATCH 14/22] test: restore claude settings after rollback check --- tests/e2e/test-claude.js | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/tests/e2e/test-claude.js b/tests/e2e/test-claude.js index 0d9eb4c1..8328252f 100644 --- a/tests/e2e/test-claude.js +++ b/tests/e2e/test-claude.js @@ -136,13 +136,16 @@ module.exports = async function testClaude(ctx) { // ========== 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'); + 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'); + } }; From 8d20258a96acca132fa42930dfbfee9d00420bb1 Mon Sep 17 00:00:00 2001 From: awsl233777 Date: Tue, 2 Jun 2026 16:45:58 +0000 Subject: [PATCH 15/22] fix: persist ollama claude share imports --- cli.js | 5 +- cli/claude-proxy.js | 29 ++++++- tests/e2e/test-claude-proxy.js | 29 +++---- tests/unit/claude-proxy-adapter.test.mjs | 97 ++++++++++++++++++++++++ tests/unit/claude-settings-sync.test.mjs | 2 +- tests/unit/web-ui-logic.test.mjs | 8 ++ web-ui/logic.claude.mjs | 3 +- 7 files changed, 154 insertions(+), 19 deletions(-) diff --git a/cli.js b/cli.js index 3f3675f0..577c1558 100644 --- a/cli.js +++ b/cli.js @@ -291,6 +291,9 @@ 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 @@ -15897,7 +15900,7 @@ function createMcpTools(options = {}) { if: { not: { type: 'object', - properties: { targetApi: { const: 'ollama' } }, + properties: { targetApi: { type: 'string', pattern: '^[\\s]*[oO][lL][lL][aA][mM][aA][\\s]*$' } }, required: ['targetApi'] } }, diff --git a/cli/claude-proxy.js b/cli/claude-proxy.js index c7871c9d..d746a734 100644 --- a/cli/claude-proxy.js +++ b/cli/claude-proxy.js @@ -916,6 +916,9 @@ 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); @@ -934,6 +937,9 @@ function createBuiltinClaudeProxyRuntimeController(deps = {}) { 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 @@ -969,6 +975,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); @@ -1066,21 +1083,27 @@ function createBuiltinClaudeProxyRuntimeController(deps = {}) { const targetApi = settings.targetApi === 'chat_completions' ? 'chat_completions' : (settings.targetApi === 'ollama' ? 'ollama' : 'responses'); - const baseUrl = typeof payload.upstreamBaseUrl === 'string' ? payload.upstreamBaseUrl.trim() : ''; + 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() : ''; + 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() - : 'claude-config'; + : (typeof settings.upstreamProviderName === 'string' && settings.upstreamProviderName.trim() + ? settings.upstreamProviderName.trim() + : 'claude-config'); return { providerName, baseUrl: normalizeBaseUrl(baseUrl), diff --git a/tests/e2e/test-claude-proxy.js b/tests/e2e/test-claude-proxy.js index 9df15dee..98fb67f4 100644 --- a/tests/e2e/test-claude-proxy.js +++ b/tests/e2e/test-claude-proxy.js @@ -357,19 +357,22 @@ module.exports = async function testClaudeProxy(ctx) { 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'); + 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', diff --git a/tests/unit/claude-proxy-adapter.test.mjs b/tests/unit/claude-proxy-adapter.test.mjs index 280b467c..01370d46 100644 --- a/tests/unit/claude-proxy-adapter.test.mjs +++ b/tests/unit/claude-proxy-adapter.test.mjs @@ -497,3 +497,100 @@ test('builtin Claude proxy sends Ollama traffic to /api paths without injecting await closeServerForTest(upstream); } }); + +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 198449be..59f8b6a9 100644 --- a/tests/unit/claude-settings-sync.test.mjs +++ b/tests/unit/claude-settings-sync.test.mjs @@ -1289,7 +1289,7 @@ test('MCP Claude config schema allows Ollama without API key only for ollama tar assert.notStrictEqual(schemaEnd, -1); const schemaSource = cliSource.slice(toolIndex, schemaEnd); assert.match(schemaSource, /allOf:\s*\[\{/); - assert.match(schemaSource, /properties:\s*\{ targetApi:\s*\{ const: 'ollama' \} \}/); + 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/web-ui-logic.test.mjs b/tests/unit/web-ui-logic.test.mjs index 91c925ad..a56dafd3 100644 --- a/tests/unit/web-ui-logic.test.mjs +++ b/tests/unit/web-ui-logic.test.mjs @@ -230,6 +230,14 @@ test('findDuplicateClaudeConfigName detects duplicates', () => { assert.strictEqual(findDuplicateClaudeConfigName(configs, duplicate), 'second'); }); +test('findDuplicateClaudeConfigName keeps different target APIs distinct', () => { + const configs = { + anthropic: { apiKey: 'k', baseUrl: 'https://api.example.com', model: 'm', targetApi: 'responses' } + }; + const ollama = { apiKey: 'k', baseUrl: 'https://api.example.com', model: 'm', targetApi: 'ollama' }; + assert.strictEqual(findDuplicateClaudeConfigName(configs, ollama), ''); +}); + test('findDuplicateClaudeConfigName detects external credential duplicates', () => { const configs = { imported: { apiKey: '', baseUrl: 'https://example.com/anthropic/', model: 'm', externalCredentialType: 'auth-token' } diff --git a/web-ui/logic.claude.mjs b/web-ui/logic.claude.mjs index cab26bc6..4c9ac652 100644 --- a/web-ui/logic.claude.mjs +++ b/web-ui/logic.claude.mjs @@ -164,7 +164,8 @@ export function findDuplicateClaudeConfigName(claudeConfigs = {}, config) { continue; } if (normalizeClaudeComparableUrl(normalizedExisting.baseUrl) !== comparableUrl - || normalizedExisting.model !== normalized.model) { + || normalizedExisting.model !== normalized.model + || normalizedExisting.targetApi !== normalized.targetApi) { continue; } if (normalized.apiKey && normalizedExisting.apiKey === normalized.apiKey) { From 8de91519890f8350aa603c7d9b39d79c0591de3c Mon Sep 17 00:00:00 2001 From: awsl233777 Date: Thu, 4 Jun 2026 13:10:36 +0000 Subject: [PATCH 16/22] fix: align claude ollama adapter contract --- cli/claude-proxy.js | 42 ++++++++- tests/unit/claude-proxy-adapter.test.mjs | 112 +++++++++++++++++++++-- 2 files changed, 142 insertions(+), 12 deletions(-) diff --git a/cli/claude-proxy.js b/cli/claude-proxy.js index d746a734..38bad69a 100644 --- a/cli/claude-proxy.js +++ b/cli/claude-proxy.js @@ -113,6 +113,11 @@ function isDroppableAnthropicBridgeBlock(block) { 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() : ''; @@ -453,7 +458,7 @@ function appendAnthropicMessageToOllamaMessages(target, message) { if (image) images.push(image); continue; } - if (isDroppableAnthropicBridgeBlock(block)) { + if (isDroppableAnthropicOllamaBlock(block)) { continue; } if (block.type === 'tool_use' && role === 'assistant' && typeof block.name === 'string' && block.name.trim()) { @@ -516,6 +521,14 @@ function buildBuiltinClaudeOllamaChatRequest(payload = {}) { } 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) => { @@ -694,6 +707,9 @@ function buildAnthropicMessageFromChatCompletion(payload, requestPayload = {}) { 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 }); } @@ -733,7 +749,7 @@ function buildAnthropicMessageFromOllamaChat(payload, requestPayload = {}) { content, stop_reason: Array.isArray(content) && content.some((item) => item && item.type === 'tool_use') ? 'tool_use' - : (doneReason === 'length' ? 'max_tokens' : 'end_turn'), + : (doneReason === 'length' || doneReason === 'max_tokens' ? 'max_tokens' : 'end_turn'), stop_sequence: null, usage }; @@ -803,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', diff --git a/tests/unit/claude-proxy-adapter.test.mjs b/tests/unit/claude-proxy-adapter.test.mjs index 01370d46..97af667a 100644 --- a/tests/unit/claude-proxy-adapter.test.mjs +++ b/tests/unit/claude-proxy-adapter.test.mjs @@ -194,11 +194,13 @@ test('buildBuiltinClaudeOllamaChatRequest maps anthropic messages/tools into Oll { 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'] + 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' }, @@ -225,6 +227,7 @@ test('buildBuiltinClaudeOllamaChatRequest drops incompatible bridge-only blocks { 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' } } ] }] @@ -306,6 +309,7 @@ test('buildAnthropicMessageFromOllamaChat maps Ollama /api/chat output into anth model: 'qwen2.5-coder:7b', message: { role: 'assistant', + thinking: 'checking the tool result', content: 'proxy ok', tool_calls: [{ function: { name: 'lookup', arguments: { city: 'tokyo' } } }] }, @@ -317,10 +321,11 @@ test('buildAnthropicMessageFromOllamaChat maps Ollama /api/chat output into anth 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[1].id, name: 'lookup', input: { city: 'tokyo' } } + { type: 'tool_use', id: message.content[2].id, name: 'lookup', input: { city: 'tokyo' } } ]); - assert(message.content[1].id.startsWith('toolu_')); + assert(message.content[2].id.startsWith('toolu_')); }); test('buildAnthropicStreamEvents emits anthropic-style SSE events', () => { @@ -330,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' } } ], @@ -349,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', () => { @@ -422,7 +432,7 @@ test('builtin Claude proxy sends Ollama traffic to /api paths without injecting if (req.method === 'POST' && req.url === '/api/chat') { res.end(JSON.stringify({ model: 'qwen2.5-coder:7b', - message: { role: 'assistant', content: 'proxy ok' }, + message: { role: 'assistant', thinking: 'short thought', content: 'proxy ok' }, done: true, done_reason: 'stop', prompt_eval_count: 3, @@ -481,23 +491,105 @@ test('builtin Claude proxy sends Ollama traffic to /api paths without injecting headers: { 'content-type': 'application/json' }, body: JSON.stringify({ model: 'qwen2.5-coder:7b', - messages: [{ role: 'user', content: [{ type: 'text', text: 'hello' }] }] + 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: 'text', text: 'proxy ok' }]); + 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) => { From bbb0699f0e5129112480ea1a2969b5ca98b1f917 Mon Sep 17 00:00:00 2001 From: awsl233777 Date: Thu, 4 Jun 2026 15:16:44 +0000 Subject: [PATCH 17/22] fix claude proxy refresh import --- tests/unit/claude-settings-sync.test.mjs | 89 +++++++++++++++++++ tests/unit/web-ui-behavior-parity.test.mjs | 2 + web-ui/logic.claude.mjs | 54 +++++++++++ web-ui/modules/app.methods.startup-claude.mjs | 26 +++++- 4 files changed, 169 insertions(+), 2 deletions(-) diff --git a/tests/unit/claude-settings-sync.test.mjs b/tests/unit/claude-settings-sync.test.mjs index 1528af77..7cd281e0 100644 --- a/tests/unit/claude-settings-sync.test.mjs +++ b/tests/unit/claude-settings-sync.test.mjs @@ -9,6 +9,12 @@ const __dirname = path.dirname(__filename); const { createI18nMethods } = await import( pathToFileURL(path.join(__dirname, '..', '..', 'web-ui', 'modules', 'i18n.mjs')) ); +const { + isLikelyBuiltinClaudeProxySettingsEnv, + matchBuiltinClaudeProxyConfigFromSettings +} = await import( + pathToFileURL(path.join(__dirname, '..', '..', 'web-ui', 'logic.mjs')) +); const appSource = readBundledWebUiScript(); const claudeConfigModuleSource = readProjectFile('web-ui/modules/app.methods.claude-config.mjs'); @@ -518,6 +524,8 @@ test('refreshClaudeSelectionFromSettings selects imported config when settings m currentClaudeConfig: '', currentClaudeModel: '', matchClaudeConfigFromSettings: () => '', + matchBuiltinClaudeProxyConfigFromSettings: () => '', + shouldSuppressClaudeSettingsImport: () => false, ensureClaudeConfigFromSettings: function () { this.claudeConfigs['导入-maxx-direct.cloverstd.com'] = { apiKey: 'maxx-key', @@ -542,6 +550,87 @@ test('refreshClaudeSelectionFromSettings selects imported config when settings m assert.deepStrictEqual(messages, []); }); +test('refreshClaudeSelectionFromSettings keeps builtin Claude proxy selection without importing duplicate config', async () => { + const source = extractMethodAsFunction(appSource, 'refreshClaudeSelectionFromSettings'); + const proxyEnv = { + ANTHROPIC_API_KEY: '0123456789abcdef0123456789abcdef0123456789abcdef', + ANTHROPIC_BASE_URL: 'http://127.0.0.1:34567', + ANTHROPIC_MODEL: 'deepseek-r1:8b' + }; + const refreshClaudeSelectionFromSettings = instantiateFunction(source, 'refreshClaudeSelectionFromSettings', { + api: async () => ({ exists: true, env: proxyEnv }) + }); + + let refreshCount = 0; + let ensureCount = 0; + let saveCount = 0; + const messages = []; + const context = { + claudeConfigs: { + 'Local Ollama': { + apiKey: '', + baseUrl: 'http://127.0.0.1:11434', + model: 'deepseek-r1:8b', + hasKey: false, + externalCredentialType: '', + targetApi: 'ollama' + } + }, + currentClaudeConfig: 'Local Ollama', + currentClaudeModel: '', + matchClaudeConfigFromSettings(env) { + return ''; + }, + matchBuiltinClaudeProxyConfigFromSettings(env) { + return matchBuiltinClaudeProxyConfigFromSettings(this.claudeConfigs, env, this.currentClaudeConfig); + }, + shouldSuppressClaudeSettingsImport(env) { + return isLikelyBuiltinClaudeProxySettingsEnv(env); + }, + ensureClaudeConfigFromSettings() { + ensureCount += 1; + throw new Error('builtin proxy settings must not be imported as external config'); + }, + saveClaudeConfigs() { + saveCount += 1; + }, + refreshClaudeModelContext: () => { + refreshCount += 1; + }, + resetClaudeModelsState: () => { + throw new Error('should not reset when builtin proxy selection matches current config'); + }, + showMessage: (msg, type) => messages.push({ msg, type }) + }; + + await refreshClaudeSelectionFromSettings.call(context, { silent: true }); + + assert.strictEqual(context.currentClaudeConfig, 'Local Ollama'); + assert.deepStrictEqual(Object.keys(context.claudeConfigs), ['Local Ollama']); + assert.strictEqual(ensureCount, 0); + assert.strictEqual(saveCount, 0); + assert.strictEqual(refreshCount, 1); + assert.deepStrictEqual(messages, []); +}); + +test('builtin Claude proxy settings detection requires loopback URL and generated proxy token shape', () => { + assert.strictEqual(isLikelyBuiltinClaudeProxySettingsEnv({ + ANTHROPIC_API_KEY: '0123456789abcdef0123456789abcdef0123456789abcdef', + ANTHROPIC_BASE_URL: 'http://localhost:34567', + ANTHROPIC_MODEL: 'deepseek-r1:8b' + }), true); + assert.strictEqual(isLikelyBuiltinClaudeProxySettingsEnv({ + ANTHROPIC_API_KEY: 'sk-real-provider-key', + ANTHROPIC_BASE_URL: 'https://api.example.com/anthropic', + ANTHROPIC_MODEL: 'claude-opus-4-6' + }), false); + assert.strictEqual(isLikelyBuiltinClaudeProxySettingsEnv({ + ANTHROPIC_API_KEY: '0123456789abcdef0123456789abcdef0123456789abcdef', + ANTHROPIC_BASE_URL: 'https://api.example.com/anthropic', + ANTHROPIC_MODEL: 'claude-opus-4-6' + }), false); +}); + test('ensureClaudeConfigFromSettings imports external auth-token backed Claude settings', () => { const source = extractMethodAsFunction(appSource, 'ensureClaudeConfigFromSettings'); const ensureClaudeConfigFromSettings = instantiateFunction(source, 'ensureClaudeConfigFromSettings'); diff --git a/tests/unit/web-ui-behavior-parity.test.mjs b/tests/unit/web-ui-behavior-parity.test.mjs index 182c067d..be8db20d 100644 --- a/tests/unit/web-ui-behavior-parity.test.mjs +++ b/tests/unit/web-ui-behavior-parity.test.mjs @@ -664,6 +664,8 @@ test('captured bundled app skeleton only exposes expected data key drift versus 'getClaudeConfigValidation', 'claudeConfigFieldError', 'canSubmitClaudeConfig', + 'matchBuiltinClaudeProxyConfigFromSettings', + 'shouldSuppressClaudeSettingsImport', 'toggleAccordionStep', 'nextAccordionStep', 'prevAccordionStep', diff --git a/web-ui/logic.claude.mjs b/web-ui/logic.claude.mjs index 4c9ac652..4a1d5c5d 100644 --- a/web-ui/logic.claude.mjs +++ b/web-ui/logic.claude.mjs @@ -110,6 +110,60 @@ function normalizeClaudeComparableUrl(value) { return trimmed.replace(/\/+$/g, ''); } +function isLoopbackClaudeProxyUrl(value) { + const raw = normalizeClaudeComparableUrl(value); + if (!raw) return false; + try { + const parsed = new URL(raw); + if (parsed.protocol !== 'http:') return false; + const host = normalizeClaudeValue(parsed.hostname).toLowerCase(); + return host === '127.0.0.1' || host === 'localhost' || host === '[::1]' || host === '::1'; + } catch (_) { + return false; + } +} + +export function isLikelyBuiltinClaudeProxySettingsEnv(env = {}) { + const normalized = normalizeClaudeSettingsEnv(env); + return !!( + normalized.baseUrl + && normalized.model + && /^[a-f0-9]{48}$/i.test(normalized.apiKey) + && isLoopbackClaudeProxyUrl(normalized.baseUrl) + ); +} + +function isClaudeTransformConfig(config = {}) { + const targetApi = normalizeClaudeConfig(config).targetApi; + return targetApi === 'chat_completions' || targetApi === 'ollama'; +} + +export function matchBuiltinClaudeProxyConfigFromSettings(claudeConfigs = {}, env = {}, preferredName = '') { + if (!isLikelyBuiltinClaudeProxySettingsEnv(env)) { + return ''; + } + const normalizedSettings = normalizeClaudeSettingsEnv(env); + const preferred = normalizeClaudeValue(preferredName); + if (preferred && claudeConfigs && claudeConfigs[preferred]) { + const config = normalizeClaudeConfig(claudeConfigs[preferred]); + if (isClaudeTransformConfig(config) && config.model === normalizedSettings.model) { + return preferred; + } + } + + const matches = []; + for (const [name, config] of Object.entries(claudeConfigs || {})) { + const normalizedConfig = normalizeClaudeConfig(config); + if (!isClaudeTransformConfig(normalizedConfig)) { + continue; + } + if (normalizedConfig.model === normalizedSettings.model) { + matches.push(name); + } + } + return matches.length === 1 ? matches[0] : ''; +} + function hasClaudeCredential(config = {}) { return !!(config.apiKey || config.authToken || config.useKey); } diff --git a/web-ui/modules/app.methods.startup-claude.mjs b/web-ui/modules/app.methods.startup-claude.mjs index 5aa75bec..b8d236e6 100644 --- a/web-ui/modules/app.methods.startup-claude.mjs +++ b/web-ui/modules/app.methods.startup-claude.mjs @@ -1,6 +1,8 @@ import { findDuplicateClaudeConfigName, getClaudeModelCatalogForBaseUrl, + isLikelyBuiltinClaudeProxySettingsEnv, + matchBuiltinClaudeProxyConfigFromSettings, matchClaudeConfigFromSettings, normalizeClaudeConfig, normalizeClaudeSettingsEnv, @@ -241,6 +243,14 @@ export function createStartupClaudeMethods(options = {}) { return matchClaudeConfigFromSettings(this.claudeConfigs, env); }, + matchBuiltinClaudeProxyConfigFromSettings(env) { + return matchBuiltinClaudeProxyConfigFromSettings(this.claudeConfigs, env, this.currentClaudeConfig); + }, + + shouldSuppressClaudeSettingsImport(env) { + return isLikelyBuiltinClaudeProxySettingsEnv(env); + }, + findDuplicateClaudeConfigName(config) { return findDuplicateClaudeConfigName(this.claudeConfigs, config); }, @@ -333,7 +343,8 @@ export function createStartupClaudeMethods(options = {}) { } return; } - const matchName = this.matchClaudeConfigFromSettings((res && res.env) || {}); + const settingsEnv = (res && res.env) || {}; + const matchName = this.matchClaudeConfigFromSettings(settingsEnv); if (matchName) { if (this.currentClaudeConfig !== matchName) { this.currentClaudeConfig = matchName; @@ -342,7 +353,18 @@ export function createStartupClaudeMethods(options = {}) { this.refreshClaudeModelContext({ silentError: silentModelError }); return; } - const importedName = this.ensureClaudeConfigFromSettings((res && res.env) || {}); + const builtinProxyMatch = this.matchBuiltinClaudeProxyConfigFromSettings(settingsEnv); + if (builtinProxyMatch) { + if (this.currentClaudeConfig !== builtinProxyMatch) { + this.currentClaudeConfig = builtinProxyMatch; + try { localStorage.setItem('currentClaudeConfig', builtinProxyMatch); } catch (_) {} + } + this.refreshClaudeModelContext({ silentError: silentModelError }); + return; + } + const importedName = this.shouldSuppressClaudeSettingsImport(settingsEnv) + ? '' + : this.ensureClaudeConfigFromSettings(settingsEnv); if (importedName) { if (this.currentClaudeConfig !== importedName) { this.currentClaudeConfig = importedName; From 41fac7d857f48c76d07489e8ca58f09efe0af24e Mon Sep 17 00:00:00 2001 From: awsl233777 Date: Thu, 4 Jun 2026 15:23:26 +0000 Subject: [PATCH 18/22] test: harden claude proxy port allocation --- tests/unit/claude-proxy-adapter.test.mjs | 39 ++++++++++++++++-------- 1 file changed, 27 insertions(+), 12 deletions(-) diff --git a/tests/unit/claude-proxy-adapter.test.mjs b/tests/unit/claude-proxy-adapter.test.mjs index 97af667a..8c847ae4 100644 --- a/tests/unit/claude-proxy-adapter.test.mjs +++ b/tests/unit/claude-proxy-adapter.test.mjs @@ -417,6 +417,27 @@ function findFreePortForTest() { }); } +async function startBuiltinClaudeProxyRuntimeForTest(controller, payload = {}, attempts = 4) { + let lastResult = null; + const payloadHasPort = Object.prototype.hasOwnProperty.call(payload, 'port'); + for (let i = 0; i < attempts; i += 1) { + const nextPayload = payloadHasPort + ? { ...payload } + : { ...payload, port: await findFreePortForTest() }; + const result = await controller.startBuiltinClaudeProxyRuntime(nextPayload); + if (result && result.success === true) { + return result; + } + lastResult = result; + const error = result && typeof result.error === 'string' ? result.error : ''; + if (payloadHasPort || !/EADDRINUSE|address already in use/i.test(error) || i === attempts - 1) { + return result; + } + await new Promise((resolve) => setTimeout(resolve, 30 * (i + 1))); + } + return lastResult || { error: 'failed to start test Claude proxy' }; +} + test('builtin Claude proxy sends Ollama traffic to /api paths without injecting /v1', async () => { const upstreamRequests = []; const upstream = http.createServer((req, res) => { @@ -446,14 +467,13 @@ test('builtin Claude proxy sends Ollama traffic to /api paths without injecting }); 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, + port: 1, provider: '', authSource: 'none', targetApi: 'ollama', @@ -471,9 +491,8 @@ test('builtin Claude proxy sends Ollama traffic to /api paths without injecting }); try { - const start = await controller.startBuiltinClaudeProxyRuntime({ + const start = await startBuiltinClaudeProxyRuntimeForTest(controller, { host: '127.0.0.1', - port: proxyPort, authSource: 'none', targetApi: 'ollama', upstreamBaseUrl: `http://127.0.0.1:${upstreamAddress.port}`, @@ -534,14 +553,13 @@ test('builtin Claude proxy maps Ollama upstream errors into Anthropic errors', a }); 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, + port: 1, provider: '', authSource: 'none', targetApi: 'ollama', @@ -559,9 +577,8 @@ test('builtin Claude proxy maps Ollama upstream errors into Anthropic errors', a }); try { - const start = await controller.startBuiltinClaudeProxyRuntime({ + const start = await startBuiltinClaudeProxyRuntimeForTest(controller, { host: '127.0.0.1', - port: proxyPort, authSource: 'none', targetApi: 'ollama', upstreamBaseUrl: `http://127.0.0.1:${upstreamAddress.port}`, @@ -613,14 +630,13 @@ test('builtin Claude proxy can restart Ollama direct upstream from saved share i }); 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, + port: 1, provider: '', upstreamProviderName: '', upstreamBaseUrl: '', @@ -643,9 +659,8 @@ test('builtin Claude proxy can restart Ollama direct upstream from saved share i const firstController = createBuiltinClaudeProxyRuntimeController(baseOptions); let secondController = null; try { - const firstStart = await firstController.startBuiltinClaudeProxyRuntime({ + const firstStart = await startBuiltinClaudeProxyRuntimeForTest(firstController, { host: '127.0.0.1', - port: proxyPort, authSource: 'none', targetApi: 'ollama', upstreamBaseUrl: `http://127.0.0.1:${upstreamAddress.port}`, From fee14ccd7a20b0f07aa7beeb20357e8c7a3cf078 Mon Sep 17 00:00:00 2001 From: awsl233777 Date: Thu, 4 Jun 2026 15:45:48 +0000 Subject: [PATCH 19/22] docs: mention claude provider bridge --- README.md | 2 ++ README.zh.md | 2 ++ 2 files changed, 4 insertions(+) diff --git a/README.md b/README.md index ff790f05..cebca168 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,7 @@ Have you ever felt overwhelmed by managing multiple local AI agents? Each has it Unlike simple wrappers, Codex Mate acts as a **Local Agent Bridge**: - **Unified Session Browser**: Search and export sessions across all tools in one place. - **OpenAI-Compatible Bridge**: Use Codex with any OpenAI-compatible UI by normalizing the Responses API. +- **Claude Provider Bridge**: Connect Claude Code to OpenAI Chat Completions-compatible providers and Ollama through the built-in local Claude-compatible proxy. - **Skills Marketplace**: A local-first market to share and import skills between different agent apps. - **Task Orchestrator**: Plan and execute complex tasks with dependency tracking. @@ -63,6 +64,7 @@ Unlike simple wrappers, Codex Mate acts as a **Local Agent Bridge**: | **Local Skills Market** | ✅ | Cross-app import/export of agent skills | | **Task Queue** | ✅ | DAG-based task execution and logs | | **OpenAI Bridge** | ✅ | Convert Codex Responses API to standard OpenAI format | +| **Claude Provider Bridge** | ✅ | Connect Claude Code to OpenAI Chat Completions-compatible providers and Ollama via the built-in Claude-compatible proxy | | **Prompt Templates** | ✅ | Reusable prompt plugins with variables | | **MCP Integration** | ✅ | Expose local tools and resources via MCP stdio | | **Auto Update** | ✅ | Quick update CLI via `codexmate update` | diff --git a/README.zh.md b/README.zh.md index 6806a530..1f38b683 100644 --- a/README.zh.md +++ b/README.zh.md @@ -47,6 +47,7 @@ 不同于简单的封装,Codex Mate 充当了 **本地智能体桥接器**: - **统一会话浏览器**:在一个地方搜索并导出所有工具的会话。 - **OpenAI 兼容桥接**:通过归一化 Responses API,让 Codex 能够与任何支持 OpenAI 格式的 UI 配合使用。 +- **Claude Provider 桥接**:通过内建本地 Claude 兼容代理,让 Claude Code 接入 OpenAI Chat Completions 兼容 provider 与 Ollama。 - **Skills 市场**:本地优先的市场,支持在不同的智能体应用之间共享和导入 Skills。 - **任务编排器**:支持带有依赖跟踪的复杂任务规划与执行。 @@ -63,6 +64,7 @@ | **本地 Skills 市场** | ✅ | 跨应用的智能体 Skills 导入与导出 | | **任务队列** | ✅ | 基于 DAG 的任务执行与日志查看 | | **OpenAI 桥接** | ✅ | 将 Codex Responses API 转换为标准 OpenAI 格式 | +| **Claude Provider 桥接** | ✅ | 通过内建 Claude 兼容代理,让 Claude Code 接入 OpenAI Chat Completions 兼容 provider 与 Ollama | | **提示词模板** | ✅ | 支持变量的可复用提示词插件 | | **MCP 集成** | ✅ | 通过 MCP stdio 暴露本地工具与资源 | | **自动更新** | ✅ | 通过 `codexmate update` 快速更新 CLI | From 199419cdc7656d5d807f9e09e17799d72de32648 Mon Sep 17 00:00:00 2001 From: awsl233777 Date: Thu, 4 Jun 2026 16:19:43 +0000 Subject: [PATCH 20/22] fix: show version status on mobile --- cli.js | 10 ++- cli/update.js | 52 ++++++++++++- tests/unit/config-tabs-ui.test.mjs | 2 +- tests/unit/install-target-cards.test.mjs | 90 ++++++++++++++++++++++ tests/unit/run.mjs | 1 + tests/unit/update-version-status.test.mjs | 21 +++++ tests/unit/web-ui-behavior-parity.test.mjs | 10 +++ web-ui/app.js | 3 + web-ui/modules/app.methods.install.mjs | 50 +++++++++++- web-ui/modules/i18n/locales/en.mjs | 6 ++ web-ui/modules/i18n/locales/ja.mjs | 6 ++ web-ui/modules/i18n/locales/vi.mjs | 6 ++ web-ui/modules/i18n/locales/zh.mjs | 6 ++ web-ui/partials/index/layout-header.html | 20 ++++- web-ui/res/web-ui-render.precompiled.js | 74 ++++++++++++------ web-ui/styles/layout-shell.css | 90 ++++++++++++++++++++++ web-ui/styles/responsive.css | 12 +++ 17 files changed, 425 insertions(+), 34 deletions(-) create mode 100644 tests/unit/update-version-status.test.mjs diff --git a/cli.js b/cli.js index 02477634..d2a4f3ef 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, fetchLatestVersion } = require('./cli/update'); +const { cmdToolUpdate, fetchLatestVersionStatus } = require('./cli/update'); const { getFileStatSafe, isBootstrapLikeText, @@ -11246,12 +11246,16 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser } })(); try { - const latestVersion = await fetchLatestVersion({ timeoutMs: 2000 }); - result = { currentVersion, latestVersion }; + const force = !!(params && params.force); + result = await fetchLatestVersionStatus({ currentVersion, timeoutMs: 2000, cacheTtlMs: force ? 0 : undefined }); } catch (e) { result = { currentVersion, latestVersion: '', + updateAvailable: false, + source: 'npm', + checkedAt: new Date().toISOString(), + cached: false, error: e && e.message ? e.message : '获取最新版本失败' }; } diff --git a/cli/update.js b/cli/update.js index 97197d5c..f810fec6 100644 --- a/cli/update.js +++ b/cli/update.js @@ -103,6 +103,53 @@ async function fetchLatestVersion(options = {}) { }); } +function normalizePackageVersion(value) { + const normalized = typeof value === 'string' ? value.trim().replace(/^v/i, '') : ''; + return /^\d+(?:\.\d+){0,2}(?:[-+][0-9A-Za-z.-]+)?$/.test(normalized) ? normalized : ''; +} + +function comparePackageVersions(left, right) { + const normalizeParts = (value) => { + const normalized = normalizePackageVersion(value); + if (!normalized) return null; + return normalized.split(/[+-]/)[0].split('.').map((part) => Number.parseInt(part, 10) || 0); + }; + const a = normalizeParts(left); + const b = normalizeParts(right); + if (!a || !b) return 0; + for (let i = 0; i < 3; i += 1) { + const diff = (a[i] || 0) - (b[i] || 0); + if (diff < 0) return -1; + if (diff > 0) return 1; + } + return 0; +} + +let latestVersionStatusCache = null; + +async function fetchLatestVersionStatus(options = {}) { + const currentVersion = normalizePackageVersion(options.currentVersion) || String(options.currentVersion || ''); + const timeoutMs = Number.isFinite(Number(options.timeoutMs)) ? Number(options.timeoutMs) : 5000; + const cacheTtlMs = Number.isFinite(Number(options.cacheTtlMs)) ? Math.max(0, Number(options.cacheTtlMs)) : 10 * 60 * 1000; + const now = typeof options.now === 'function' ? options.now() : Date.now(); + if (latestVersionStatusCache && cacheTtlMs > 0 && now - latestVersionStatusCache.checkedAtMs < cacheTtlMs) { + return { ...latestVersionStatusCache.payload, cached: true }; + } + + const latestVersionRaw = await fetchLatestVersion({ timeoutMs }); + const latestVersion = normalizePackageVersion(latestVersionRaw) || String(latestVersionRaw || ''); + const payload = { + currentVersion, + latestVersion, + updateAvailable: !!currentVersion && !!latestVersion && comparePackageVersions(currentVersion, latestVersion) < 0, + source: 'npm', + checkedAt: new Date(now).toISOString(), + cached: false + }; + latestVersionStatusCache = { checkedAtMs: now, payload }; + return payload; +} + function detectInstallMethod() { const cliPath = path.resolve(__dirname, '..'); @@ -187,5 +234,8 @@ function updateViaStandalone(version) { module.exports = { cmdToolUpdate, - fetchLatestVersion + fetchLatestVersion, + fetchLatestVersionStatus, + normalizePackageVersion, + comparePackageVersions }; diff --git a/tests/unit/config-tabs-ui.test.mjs b/tests/unit/config-tabs-ui.test.mjs index 4313f921..36e0242a 100644 --- a/tests/unit/config-tabs-ui.test.mjs +++ b/tests/unit/config-tabs-ui.test.mjs @@ -135,7 +135,7 @@ 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, /v-if="isAppVersionStatusVisible\(\)"[\s\S]*side-update-notice--'[\s\S]*appVersionStatusKind\(\)[\s\S]*@click="handleAppVersionStatusClick"/); assert.match(html, /\{\{\s*appUpdateNoticeText\(\)\s*\}\}<\/span>/); assert.match(html, /\{\{\s*appUpdateNoticeMeta\(\)\s*\}\}<\/span>/); assert.doesNotMatch(html, /class="brand-block" tabindex="0"/); diff --git a/tests/unit/install-target-cards.test.mjs b/tests/unit/install-target-cards.test.mjs index a1119b67..e16c8d57 100644 --- a/tests/unit/install-target-cards.test.mjs +++ b/tests/unit/install-target-cards.test.mjs @@ -46,13 +46,19 @@ test('app update notice only appears when latest package version is newer', () = const ctx = createContext({ appVersion: '0.0.40', appLatestVersion: '0.0.41', + appVersionStatusChecked: true, + appVersionStatusLoading: false, + appVersionStatusError: '', t(key, params = {}) { if (key === 'side.update.availableWithVersion') return `Update v${params.version}`; if (key === 'side.update.metaVersions') return `${params.current}->${params.latest}`; + if (key === 'side.update.upToDate') return 'Up to date'; return key; } }); + assert.strictEqual(ctx.isAppVersionStatusVisible(), true); + assert.strictEqual(ctx.appVersionStatusKind(), 'available'); assert.strictEqual(ctx.comparePackageVersions('0.0.40', '0.0.41'), -1); assert.strictEqual(ctx.comparePackageVersions('0.0.41', '0.0.40'), 1); assert.strictEqual(ctx.comparePackageVersions('v0.0.41', '0.0.41'), 0); @@ -62,6 +68,90 @@ test('app update notice only appears when latest package version is newer', () = ctx.appLatestVersion = '0.0.40'; assert.strictEqual(ctx.isAppUpdateAvailable(), false); + assert.strictEqual(ctx.appVersionStatusKind(), 'current'); + assert.strictEqual(ctx.appUpdateNoticeText(), 'Up to date'); +}); + +test('app version status exposes loading and retry states in the side rail', () => { + const ctx = createContext({ + appVersion: '0.0.40', + appLatestVersion: '', + appVersionStatusChecked: false, + appVersionStatusLoading: true, + appVersionStatusError: '', + t(key, params = {}) { + if (key === 'side.update.checking') return 'Checking'; + if (key === 'side.update.checkingMeta') return 'Contacting registry'; + if (key === 'side.update.retry') return 'Retry'; + if (key === 'side.update.currentOnly') return `Current ${params.current}`; + return key; + } + }); + + assert.strictEqual(ctx.isAppVersionStatusVisible(), true); + assert.strictEqual(ctx.appVersionStatusKind(), 'loading'); + assert.strictEqual(ctx.appUpdateNoticeText(), 'Checking'); + assert.strictEqual(ctx.appUpdateNoticeMeta(), 'Contacting registry'); + + ctx.appVersionStatusLoading = false; + ctx.appVersionStatusError = 'network down'; + assert.strictEqual(ctx.appVersionStatusKind(), 'error'); + assert.strictEqual(ctx.appUpdateNoticeText(), 'Retry'); + assert.strictEqual(ctx.appUpdateNoticeMeta(), 'network down'); +}); + +test('app version status click retries checks unless an update is available', () => { + const calls = []; + const ctx = createContext({ + appVersion: '0.0.40', + appLatestVersion: '0.0.40', + loadAppVersionStatus(options) { calls.push(['load', options]); }, + openAppUpdateDocs() { calls.push(['docs']); } + }); + + ctx.handleAppVersionStatusClick(); + assert.deepStrictEqual(calls, [['load', { silent: false, force: true }]]); + + calls.length = 0; + ctx.appLatestVersion = '0.0.41'; + ctx.handleAppVersionStatusClick(); + assert.deepStrictEqual(calls, [['docs']]); +}); + +test('loadAppVersionStatus passes force refresh through to the API', async () => { + const calls = []; + const forcedMethods = createInstallMethods({ + api(action, params) { + calls.push([action, params]); + return Promise.resolve({ + currentVersion: '0.0.40', + latestVersion: '0.0.41', + source: 'npm', + checkedAt: '1970-01-01T00:00:00.000Z' + }); + } + }); + const ctx = { + ...forcedMethods, + appVersion: '', + appLatestVersion: '', + appVersionStatusLoading: false, + appVersionStatusError: '', + appVersionStatusChecked: false, + appVersionStatusCheckedAt: '', + appVersionStatusSource: '', + showMessage() {}, + t(key) { return key; } + }; + + const ok = await ctx.loadAppVersionStatus({ silent: true, force: true }); + + assert.strictEqual(ok, true); + assert.deepStrictEqual(calls, [['version-status', { force: true }]]); + assert.strictEqual(ctx.appVersion, '0.0.40'); + assert.strictEqual(ctx.appLatestVersion, '0.0.41'); + assert.strictEqual(ctx.appVersionStatusSource, 'npm'); + assert.strictEqual(ctx.appVersionStatusChecked, true); }); test('openAppUpdateDocs switches to docs update command without running update', () => { diff --git a/tests/unit/run.mjs b/tests/unit/run.mjs index 06085f8c..4a0f731f 100644 --- a/tests/unit/run.mjs +++ b/tests/unit/run.mjs @@ -54,6 +54,7 @@ await import(pathToFileURL(path.join(__dirname, 'builtin-proxy-responses-shim.te await import(pathToFileURL(path.join(__dirname, 'claude-proxy-adapter.test.mjs'))); await import(pathToFileURL(path.join(__dirname, 'coderabbit-workflows.test.mjs'))); await import(pathToFileURL(path.join(__dirname, 'release-changelog.test.mjs'))); +await import(pathToFileURL(path.join(__dirname, 'update-version-status.test.mjs'))); await import(pathToFileURL(path.join(__dirname, 'ci-workflow-contract.test.mjs'))); await import(pathToFileURL(path.join(__dirname, 'lint-contract.test.mjs'))); await import(pathToFileURL(path.join(__dirname, 'npm-package-files.test.mjs'))); diff --git a/tests/unit/update-version-status.test.mjs b/tests/unit/update-version-status.test.mjs new file mode 100644 index 00000000..5d29b9d6 --- /dev/null +++ b/tests/unit/update-version-status.test.mjs @@ -0,0 +1,21 @@ +import assert from 'assert'; +import path from 'path'; +import { createRequire } from 'module'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const require = createRequire(import.meta.url); +const update = require(path.join(__dirname, '..', '..', 'cli', 'update.js')); + +test('comparePackageVersions normalizes v-prefixed package versions', () => { + assert.strictEqual(update.comparePackageVersions('v0.0.41', '0.0.41'), 0); + assert.strictEqual(update.comparePackageVersions('0.0.40', '0.0.41'), -1); + assert.strictEqual(update.comparePackageVersions('0.0.42', '0.0.41'), 1); +}); + +test('normalizePackageVersion rejects non-package version labels', () => { + assert.strictEqual(update.normalizePackageVersion('v0.0.43'), '0.0.43'); + assert.strictEqual(update.normalizePackageVersion('latest'), ''); + assert.strictEqual(update.normalizePackageVersion(''), ''); +}); diff --git a/tests/unit/web-ui-behavior-parity.test.mjs b/tests/unit/web-ui-behavior-parity.test.mjs index be8db20d..d4193d4a 100644 --- a/tests/unit/web-ui-behavior-parity.test.mjs +++ b/tests/unit/web-ui-behavior-parity.test.mjs @@ -339,6 +339,9 @@ test('captured bundled app skeleton only exposes expected data key drift versus 'appLatestVersion', 'appVersionStatusError', 'appVersionStatusLoading', + 'appVersionStatusChecked', + 'appVersionStatusCheckedAt', + 'appVersionStatusSource', 'sessionListInitialBatchSize', 'sessionListLoadStep', 'sessionListVisibleCount', @@ -375,6 +378,9 @@ test('captured bundled app skeleton only exposes expected data key drift versus 'appLatestVersion', 'appVersionStatusError', 'appVersionStatusLoading', + 'appVersionStatusChecked', + 'appVersionStatusCheckedAt', + 'appVersionStatusSource', '__mainTabSwitchState', 'openclawAuthProfilesByProvider', 'openclawPendingAuthProfileUpdates', @@ -597,8 +603,12 @@ test('captured bundled app skeleton only exposes expected data key drift versus 'normalizePackageVersion', 'comparePackageVersions', 'isAppUpdateAvailable', + 'isAppVersionStatusVisible', + 'appVersionStatusKind', 'appUpdateNoticeText', 'appUpdateNoticeMeta', + 'appVersionStatusTitle', + 'handleAppVersionStatusClick', 'loadAppVersionStatus', 'openAppUpdateDocs', 'hasActiveSessionFilters', diff --git a/web-ui/app.js b/web-ui/app.js index b9892674..02724681 100644 --- a/web-ui/app.js +++ b/web-ui/app.js @@ -273,6 +273,9 @@ document.addEventListener('DOMContentLoaded', () => { appLatestVersion: '', appVersionStatusLoading: false, appVersionStatusError: '', + appVersionStatusChecked: false, + appVersionStatusCheckedAt: '', + appVersionStatusSource: '', newProvider: { name: '', url: '', key: '', model: '', useTransform: false }, resetConfigLoading: false, editingProvider: { name: '', url: '', key: '', readOnly: false, nonEditable: false }, diff --git a/web-ui/modules/app.methods.install.mjs b/web-ui/modules/app.methods.install.mjs index e68117c4..604c44ee 100644 --- a/web-ui/modules/app.methods.install.mjs +++ b/web-ui/modules/app.methods.install.mjs @@ -46,44 +46,90 @@ export function createInstallMethods(options = {}) { return this.comparePackageVersions(current, latest) < 0; }, + isAppVersionStatusVisible() { + return !!(this.appVersion || this.appLatestVersion || this.appVersionStatusLoading || this.appVersionStatusChecked || this.appVersionStatusError); + }, + + appVersionStatusKind() { + if (this.appVersionStatusLoading) return 'loading'; + if (this.appVersionStatusError) return 'error'; + if (this.isAppUpdateAvailable()) return 'available'; + if (this.appVersionStatusChecked) return 'current'; + return 'idle'; + }, + appUpdateNoticeText() { + if (this.appVersionStatusLoading) return this.t('side.update.checking'); + if (this.appVersionStatusError) return this.t('side.update.retry'); const latest = this.normalizePackageVersion(this.appLatestVersion); - return latest + if (this.isAppUpdateAvailable()) return latest ? this.t('side.update.availableWithVersion', { version: latest }) : this.t('side.update.available'); + if (this.appVersionStatusChecked) return this.t('side.update.upToDate'); + return this.t('side.update.check'); }, appUpdateNoticeMeta() { + if (this.appVersionStatusLoading) return this.t('side.update.checkingMeta'); + if (this.appVersionStatusError) return this.appVersionStatusError; const current = this.normalizePackageVersion(this.appVersion); const latest = this.normalizePackageVersion(this.appLatestVersion); if (current && latest) { return this.t('side.update.metaVersions', { current, latest }); } + if (current) { + return this.t('side.update.currentOnly', { current }); + } return this.t('side.update.meta'); }, + appVersionStatusTitle() { + const source = typeof this.appVersionStatusSource === 'string' ? this.appVersionStatusSource.trim() : ''; + const checkedAt = typeof this.appVersionStatusCheckedAt === 'string' ? this.appVersionStatusCheckedAt.trim() : ''; + const suffix = [source, checkedAt].filter(Boolean).join(' · '); + const meta = this.appUpdateNoticeMeta(); + return suffix ? `${meta} · ${suffix}` : meta; + }, + + handleAppVersionStatusClick() { + if (this.isAppUpdateAvailable()) { + this.openAppUpdateDocs(); + return; + } + void this.loadAppVersionStatus({ silent: false, force: true }); + }, + async loadAppVersionStatus(options = {}) { if (typeof api !== 'function') return false; if (this.appVersionStatusLoading) return false; this.appVersionStatusLoading = true; this.appVersionStatusError = ''; try { - const res = await api('version-status'); + const res = await api('version-status', options.force ? { force: true } : {}); if (res && res.currentVersion && !this.appVersion) { this.appVersion = this.normalizePackageVersion(res.currentVersion) || String(res.currentVersion || ''); } if (res && res.latestVersion) { this.appLatestVersion = this.normalizePackageVersion(res.latestVersion) || String(res.latestVersion || ''); } + if (res && typeof res.source === 'string') { + this.appVersionStatusSource = res.source; + } + if (res && typeof res.checkedAt === 'string') { + this.appVersionStatusCheckedAt = res.checkedAt; + } if (res && res.error) { this.appVersionStatusError = res.error; + this.appVersionStatusChecked = true; if (!options.silent) this.showMessage(res.error, 'error'); return false; } + this.appVersionStatusChecked = true; return true; } catch (e) { const message = e && e.message ? e.message : this.t('side.update.checkFailed'); this.appVersionStatusError = message; + this.appVersionStatusChecked = true; if (!options.silent) this.showMessage(message, 'error'); return false; } finally { diff --git a/web-ui/modules/i18n/locales/en.mjs b/web-ui/modules/i18n/locales/en.mjs index a8e4af84..a8ecc8ba 100644 --- a/web-ui/modules/i18n/locales/en.mjs +++ b/web-ui/modules/i18n/locales/en.mjs @@ -150,6 +150,12 @@ const en = Object.freeze({ 'side.update.meta': 'Open update command', 'side.update.metaVersions': 'Current v{current} → latest v{latest}', 'side.update.checkFailed': 'Failed to check latest version', + 'side.update.check': 'Check for updates', + 'side.update.checking': 'Checking for updates…', + 'side.update.checkingMeta': 'Contacting npm registry', + 'side.update.upToDate': 'Up to date', + 'side.update.currentOnly': 'Current v{current}', + 'side.update.retry': 'Retry version check', 'side.config.codex': 'Codex', 'side.config.codex.meta': 'Provider / Model', 'side.config.claude': 'Claude Code', diff --git a/web-ui/modules/i18n/locales/ja.mjs b/web-ui/modules/i18n/locales/ja.mjs index dacc486e..f90afca8 100644 --- a/web-ui/modules/i18n/locales/ja.mjs +++ b/web-ui/modules/i18n/locales/ja.mjs @@ -151,6 +151,12 @@ const ja = Object.freeze({ 'side.update.meta': '更新コマンドを開く', 'side.update.metaVersions': '現在 v{current} → 最新 v{latest}', 'side.update.checkFailed': '最新バージョンの確認に失敗しました', + 'side.update.check': '更新を確認', + 'side.update.checking': '更新を確認中…', + 'side.update.checkingMeta': 'npm registry に接続中', + 'side.update.upToDate': '最新です', + 'side.update.currentOnly': '現在 v{current}', + 'side.update.retry': 'バージョン確認を再試行', 'side.config.codex': 'Codex', 'side.config.codex.meta': 'Provider / Model', 'side.config.claude': 'Claude Code', diff --git a/web-ui/modules/i18n/locales/vi.mjs b/web-ui/modules/i18n/locales/vi.mjs index 9ff610c6..63642f65 100644 --- a/web-ui/modules/i18n/locales/vi.mjs +++ b/web-ui/modules/i18n/locales/vi.mjs @@ -111,6 +111,12 @@ const vi = Object.freeze({ 'side.update.meta': 'Mở lệnh cập nhật', 'side.update.metaVersions': 'Hiện tại v{current} → mới nhất v{latest}', 'side.update.checkFailed': 'Không thể kiểm tra phiên bản mới nhất', + 'side.update.check': 'Kiểm tra cập nhật', + 'side.update.checking': 'Đang kiểm tra cập nhật…', + 'side.update.checkingMeta': 'Đang kết nối npm registry', + 'side.update.upToDate': 'Đã là phiên bản mới nhất', + 'side.update.currentOnly': 'Hiện tại v{current}', + 'side.update.retry': 'Thử kiểm tra phiên bản lại', 'side.config.codex': 'Codex', 'side.config.codex.meta': 'Provider / Model', 'side.config.claude': 'Claude Code', diff --git a/web-ui/modules/i18n/locales/zh.mjs b/web-ui/modules/i18n/locales/zh.mjs index a8aeecec..27b4e713 100644 --- a/web-ui/modules/i18n/locales/zh.mjs +++ b/web-ui/modules/i18n/locales/zh.mjs @@ -150,6 +150,12 @@ const zh = Object.freeze({ 'side.update.meta': '点击查看更新命令', 'side.update.metaVersions': '当前 v{current} → 最新 v{latest}', 'side.update.checkFailed': '检查最新版本失败', + 'side.update.check': '检查更新', + 'side.update.checking': '正在检查更新…', + 'side.update.checkingMeta': '正在连接 npm registry', + 'side.update.upToDate': '已是最新', + 'side.update.currentOnly': '当前 v{current}', + 'side.update.retry': '重试版本检查', 'side.config.codex': 'Codex', 'side.config.codex.meta': 'Provider / Model', 'side.config.claude': 'Claude Code', diff --git a/web-ui/partials/index/layout-header.html b/web-ui/partials/index/layout-header.html index f2700e34..bc11248d 100644 --- a/web-ui/partials/index/layout-header.html +++ b/web-ui/partials/index/layout-header.html @@ -1,4 +1,16 @@
+
+
Codex Mate v{{ appVersion }}
+ +