From 34060251711653362600d2fa7d8bfefbb881b534 Mon Sep 17 00:00:00 2001 From: ymkiux Date: Sat, 9 May 2026 17:43:14 +0800 Subject: [PATCH 1/9] fix(proxy): retry transient upstream errors and graceful sse abort --- cli.js | 12 +- cli/builtin-proxy.js | 109 +++++++++++++++++- cli/openai-bridge.js | 52 +++++++-- .../builtin-proxy-responses-shim.test.mjs | 101 ++++++++++++++++ 4 files changed, 262 insertions(+), 12 deletions(-) diff --git a/cli.js b/cli.js index 2906e436..4bd9200f 100644 --- a/cli.js +++ b/cli.js @@ -304,8 +304,16 @@ const CLI_INSTALL_TARGETS = Object.freeze([ } ]); -const HTTP_KEEP_ALIVE_AGENT = new http.Agent({ keepAlive: true }); -const HTTPS_KEEP_ALIVE_AGENT = new https.Agent({ keepAlive: true }); +const HTTP_KEEP_ALIVE_AGENT = new http.Agent({ + keepAlive: true, + keepAliveMsecs: 1000, + maxFreeSockets: 4 +}); +const HTTPS_KEEP_ALIVE_AGENT = new https.Agent({ + keepAlive: true, + keepAliveMsecs: 1000, + maxFreeSockets: 4 +}); const openaiBridgeHandler = createOpenaiBridgeHttpHandler({ settingsFile: OPENAI_BRIDGE_SETTINGS_FILE, diff --git a/cli/builtin-proxy.js b/cli/builtin-proxy.js index 053175fd..a904f7db 100644 --- a/cli/builtin-proxy.js +++ b/cli/builtin-proxy.js @@ -127,6 +127,42 @@ function createBuiltinProxyRuntimeController(deps = {}) { return false; } + function isTransientNetworkError(error) { + const text = String(error || '').trim(); + if (!text) return false; + if (/socket hang up/i.test(text)) return true; + if (/ECONNRESET|ECONNREFUSED|EPIPE|EPROTO|ETIMEDOUT/i.test(text)) return true; + if (/EAI_AGAIN/i.test(text)) return true; + if (/UND_ERR_SOCKET/i.test(text)) return true; + if (/disconnected before|secure tls|tls handshake/i.test(text)) return true; + return false; + } + + const TRANSIENT_RETRY_DELAYS_MS = [200, 600]; + + async function retryTransientRequest(executor) { + let lastResult = null; + for (let attempt = 0; attempt <= TRANSIENT_RETRY_DELAYS_MS.length; attempt += 1) { + if (attempt > 0) { + const delay = TRANSIENT_RETRY_DELAYS_MS[attempt - 1]; + // eslint-disable-next-line no-await-in-loop + await new Promise((r) => { + const t = setTimeout(r, delay); + if (typeof t.unref === 'function') t.unref(); + }); + } + // eslint-disable-next-line no-await-in-loop + const result = await executor(attempt); + lastResult = result; + if (!result) return result; + if (result.ok) return result; + if (result.retry) return result; + if (result.status && result.status > 0) return result; + if (!isTransientNetworkError(result.error)) return result; + } + return lastResult; + } + function proxyRequestJson(targetUrl, options = {}) { const parsed = new URL(targetUrl); const transport = parsed.protocol === 'https:' ? https : http; @@ -206,7 +242,7 @@ function createBuiltinProxyRuntimeController(deps = {}) { } let lastResult = null; for (let index = 0; index < urls.length; index += 1) { - const result = await proxyRequestJson(urls[index], options); + const result = await retryTransientRequest(() => proxyRequestJson(urls[index], options)); lastResult = result; if (!result.ok) { return result; @@ -702,9 +738,34 @@ function createBuiltinProxyRuntimeController(deps = {}) { } } + function stopChatStreamHeartbeat(state) { + if (!state || !state.heartbeatTimer) return; + clearInterval(state.heartbeatTimer); + state.heartbeatTimer = null; + } + + function startChatStreamHeartbeat(state) { + if (!state || state.heartbeatTimer) return; + const timer = setInterval(() => { + if (state.finished) { + stopChatStreamHeartbeat(state); + return; + } + const target = state.res; + if (!target || target.writableEnded || target.destroyed) { + stopChatStreamHeartbeat(state); + return; + } + try { target.write(': keepalive\n\n'); } catch (_) {} + }, 15000); + if (typeof timer.unref === 'function') timer.unref(); + state.heartbeatTimer = timer; + } + function finishChatStreamResponsesSse(state) { if (state.finished) return; state.finished = true; + stopChatStreamHeartbeat(state); if (state.messageItem) { const outputIndex = state.output.indexOf(state.messageItem); @@ -759,6 +820,22 @@ function createBuiltinProxyRuntimeController(deps = {}) { state.res.end(); } + function failResponsesSseRaw(res, message) { + if (!res || res.writableEnded || res.destroyed) return; + try { + writeSse(res, 'response.failed', { type: 'response.failed', error: message || 'upstream stream failed' }); + writeSse(res, 'done', '[DONE]'); + res.end(); + } catch (_) {} + } + + function failChatStreamResponsesSse(state, message) { + if (!state || state.finished) return; + state.finished = true; + stopChatStreamHeartbeat(state); + failResponsesSseRaw(state.res, message); + } + function streamChatCompletionsAsResponsesSse(targetUrl, options = {}) { const parsed = new URL(targetUrl); const transport = parsed.protocol === 'https:' ? https : http; @@ -796,6 +873,29 @@ function createBuiltinProxyRuntimeController(deps = {}) { const status = upstreamRes.statusCode || 0; const chunks = []; const contentType = String(upstreamRes.headers && upstreamRes.headers['content-type'] || ''); + let streamState = null; + + const handleAbort = (reason) => { + if (settled) return; + if (streamState) { + failChatStreamResponsesSse(streamState, reason); + finish({ ok: true }); + return; + } + if (res.headersSent) { + failResponsesSseRaw(res, reason); + finish({ ok: true }); + return; + } + finish({ + ok: false, + status, + error: reason, + bodyText: chunks.length ? Buffer.concat(chunks).toString('utf-8') : '' + }); + }; + upstreamRes.on('error', (err) => handleAbort(err && err.message ? err.message : 'upstream stream failed')); + upstreamRes.on('aborted', () => handleAbort('upstream stream aborted')); if (status === 404 || status === 405) { upstreamRes.on('data', (chunk) => chunk && chunks.push(chunk)); @@ -851,6 +951,11 @@ function createBuiltinProxyRuntimeController(deps = {}) { return sequence; } }; + streamState = state; + startChatStreamHeartbeat(state); + if (typeof res.on === 'function') { + res.on('close', () => stopChatStreamHeartbeat(state)); + } writeSse(res, 'response.created', { type: 'response.created', response: { @@ -914,7 +1019,7 @@ function createBuiltinProxyRuntimeController(deps = {}) { } let lastResult = null; for (const url of urls) { - const result = await streamChatCompletionsAsResponsesSse(url, options); + const result = await retryTransientRequest(() => streamChatCompletionsAsResponsesSse(url, options)); lastResult = result; if (result && result.retry) continue; return result; diff --git a/cli/openai-bridge.js b/cli/openai-bridge.js index b5cba97d..685a554e 100644 --- a/cli/openai-bridge.js +++ b/cli/openai-bridge.js @@ -716,6 +716,42 @@ function isLoopbackAddress(address) { return value === '127.0.0.1' || value === '::1' || value === '::ffff:127.0.0.1'; } +function isTransientNetworkError(error) { + const text = String(error || '').trim(); + if (!text) return false; + if (/socket hang up/i.test(text)) return true; + if (/ECONNRESET|ECONNREFUSED|EPIPE|EPROTO|ETIMEDOUT/i.test(text)) return true; + if (/EAI_AGAIN/i.test(text)) return true; + if (/UND_ERR_SOCKET/i.test(text)) return true; + if (/disconnected before|secure tls|tls handshake/i.test(text)) return true; + return false; +} + +const TRANSIENT_RETRY_DELAYS_MS = [200, 600]; + +async function retryTransientRequest(executor) { + let lastResult = null; + for (let attempt = 0; attempt <= TRANSIENT_RETRY_DELAYS_MS.length; attempt += 1) { + if (attempt > 0) { + const delay = TRANSIENT_RETRY_DELAYS_MS[attempt - 1]; + // eslint-disable-next-line no-await-in-loop + await new Promise((r) => { + const t = setTimeout(r, delay); + if (typeof t.unref === 'function') t.unref(); + }); + } + // eslint-disable-next-line no-await-in-loop + const result = await executor(attempt); + lastResult = result; + if (!result) return result; + if (result.ok) return result; + if (result.retry) return result; + if (result.status && result.status > 0) return result; + if (!isTransientNetworkError(result.error)) return result; + } + return lastResult; +} + function writeSse(res, eventName, dataObj) { if (!res || res.writableEnded || res.destroyed) return; if (eventName) { @@ -1266,7 +1302,7 @@ function createOpenaiBridgeHttpHandler(options = {}) { } const url = joinApiUrl(upstream.baseUrl, 'models'); - const result = await proxyRequestJson(url, { + const result = await retryTransientRequest(() => proxyRequestJson(url, { method: 'GET', headers: { ...(authHeader ? { Authorization: authHeader } : {}), @@ -1275,7 +1311,7 @@ function createOpenaiBridgeHttpHandler(options = {}) { maxBytes: maxUpstreamBytes, httpAgent, httpsAgent - }); + })); if (!result.ok) { res.writeHead(502, { 'Content-Type': 'application/json; charset=utf-8' }); res.end(JSON.stringify({ error: `Upstream request failed: ${result.error}` })); @@ -1325,7 +1361,7 @@ function createOpenaiBridgeHttpHandler(options = {}) { } const upstreamUrl = joinApiUrl(upstream.baseUrl, 'chat/completions'); const chatBody = { ...converted.chat, stream: true }; - const streamed = await streamChatCompletionsAsResponsesSse(upstreamUrl, { + const streamed = await retryTransientRequest(() => streamChatCompletionsAsResponsesSse(upstreamUrl, { method: 'POST', body: chatBody, headers: { @@ -1337,7 +1373,7 @@ function createOpenaiBridgeHttpHandler(options = {}) { httpsAgent, res, model: typeof chatBody.model === 'string' ? chatBody.model : '' - }); + })); if (!streamed.ok) { if (res.writableEnded || res.destroyed) { return; @@ -1357,7 +1393,7 @@ function createOpenaiBridgeHttpHandler(options = {}) { // Maxx-style behavior: prefer upstream /responses if supported. // Fallback to /chat/completions conversion when upstream does not implement /responses (404/405). const upstreamResponsesUrl = joinApiUrl(upstream.baseUrl, 'responses'); - const upstreamResponsesResult = await proxyRequestJson(upstreamResponsesUrl, { + const upstreamResponsesResult = await retryTransientRequest(() => proxyRequestJson(upstreamResponsesUrl, { method: 'POST', body: toUpstreamNonStreamingResponsesPayload(responsesRequest), headers: { @@ -1367,7 +1403,7 @@ function createOpenaiBridgeHttpHandler(options = {}) { maxBytes: maxUpstreamBytes, httpAgent, httpsAgent - }); + })); if (upstreamResponsesResult.ok && upstreamResponsesResult.status >= 200 && upstreamResponsesResult.status < 300) { const upstreamJson = parseJsonOrError(upstreamResponsesResult.bodyText); @@ -1418,7 +1454,7 @@ function createOpenaiBridgeHttpHandler(options = {}) { } const upstreamUrl = joinApiUrl(upstream.baseUrl, 'chat/completions'); - const upstreamResult = await proxyRequestJson(upstreamUrl, { + const upstreamResult = await retryTransientRequest(() => proxyRequestJson(upstreamUrl, { method: 'POST', body: converted.chat, headers: { @@ -1428,7 +1464,7 @@ function createOpenaiBridgeHttpHandler(options = {}) { maxBytes: maxUpstreamBytes, httpAgent, httpsAgent - }); + })); if (!upstreamResult.ok) { res.writeHead(502, { 'Content-Type': 'application/json; charset=utf-8' }); res.end(JSON.stringify({ error: `Upstream request failed: ${upstreamResult.error}` })); diff --git a/tests/unit/builtin-proxy-responses-shim.test.mjs b/tests/unit/builtin-proxy-responses-shim.test.mjs index 727864d0..cde0a2c1 100644 --- a/tests/unit/builtin-proxy-responses-shim.test.mjs +++ b/tests/unit/builtin-proxy-responses-shim.test.mjs @@ -554,3 +554,104 @@ test('builtin-proxy /v1/responses maps Responses tool items through chat fallbac await closeServer(upstream); } }); + +test('builtin-proxy /v1/responses stream=true emits response.failed when upstream stream aborts mid-flight', async () => { + const sockets = new Set(); + const upstream = http.createServer((req, res) => { + if (req.url === '/v1/responses' && req.method === 'POST') { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'responses endpoint unavailable' })); + return; + } + if (req.url === '/v1/chat/completions' && req.method === 'POST') { + const chunks = []; + req.on('data', (chunk) => chunks.push(chunk)); + req.on('end', () => { + res.writeHead(200, { 'Content-Type': 'text/event-stream; charset=utf-8' }); + res.write('data: {"id":"chatcmpl_partial","model":"gpt-test","choices":[{"delta":{"role":"assistant"}}]}\n\n'); + res.write('data: {"id":"chatcmpl_partial","model":"gpt-test","choices":[{"delta":{"content":"partial"}}]}\n\n'); + setTimeout(() => { + try { req.socket.destroy(); } catch (_) {} + }, 30); + }); + return; + } + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'not found' })); + }); + upstream.on('connection', (socket) => { + sockets.add(socket); + socket.on('close', () => sockets.delete(socket)); + }); + const { port: upstreamPort } = await listen(upstream); + let proxyRuntime = null; + + try { + proxyRuntime = await startTestProxy(upstreamPort); + const proxyPort = proxyRuntime.server.address().port; + const sse = await requestText(`http://127.0.0.1:${proxyPort}/v1/responses`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: { model: 'gpt-test', input: 'ping', stream: true } + }); + assert.equal(sse.status, 200); + assert.match(sse.headers['content-type'], /text\/event-stream/i); + assert.match(sse.text, /event: response\.created/); + assert.match(sse.text, /"delta":"partial"/); + assert.match(sse.text, /event: response\.failed/); + assert.match(sse.text, /data: \[DONE\]/); + } finally { + if (proxyRuntime) { + await closeServer(proxyRuntime.server, proxyRuntime.connections); + } + await closeServer(upstream, sockets); + } +}); + +test('builtin-proxy /v1/responses retries upstream after a transient connection reset', async () => { + let connectionCount = 0; + const upstream = http.createServer((req, res) => { + if (req.url === '/v1/responses' && req.method === 'POST') { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'responses endpoint unavailable' })); + return; + } + if (req.url === '/v1/chat/completions' && req.method === 'POST') { + connectionCount += 1; + if (connectionCount === 1) { + try { req.socket.destroy(); } catch (_) {} + return; + } + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + id: 'chatcmpl_after_reset', + model: 'gpt-test', + choices: [{ message: { role: 'assistant', content: 'recovered' } }] + })); + return; + } + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'not found' })); + }); + const { port: upstreamPort } = await listen(upstream); + let proxyRuntime = null; + + try { + proxyRuntime = await startTestProxy(upstreamPort); + const proxyPort = proxyRuntime.server.address().port; + const resp = await requestText(`http://127.0.0.1:${proxyPort}/v1/responses`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: { model: 'gpt-test', input: 'ping', stream: false } + }); + assert.equal(resp.status, 200); + assert.ok(connectionCount >= 2, 'transient reset should be retried'); + const parsed = JSON.parse(resp.text); + assert.equal(parsed.output[0].content[0].text, 'recovered'); + } finally { + if (proxyRuntime) { + await closeServer(proxyRuntime.server, proxyRuntime.connections); + } + await closeServer(upstream); + } +}); From adaea8c62b43ddcdf84fcdc01f2435c169cd3bbf Mon Sep 17 00:00:00 2001 From: ymkiux Date: Mon, 11 May 2026 00:55:12 +0800 Subject: [PATCH 2/9] fix(web-ui): tighten preset button spacing and remove redundant custom config button Co-Authored-By: ymkiux --- .../partials/index/panel-config-claude.html | 3 +-- web-ui/partials/index/panel-config-codex.html | 21 +++++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/web-ui/partials/index/panel-config-claude.html b/web-ui/partials/index/panel-config-claude.html index 027bbf06..14e17cb8 100644 --- a/web-ui/partials/index/panel-config-claude.html +++ b/web-ui/partials/index/panel-config-claude.html @@ -45,8 +45,7 @@
{{ t('claude.presetProviders') }}
-
- +
diff --git a/web-ui/partials/index/panel-config-codex.html b/web-ui/partials/index/panel-config-codex.html index dd4bf346..eca8c838 100644 --- a/web-ui/partials/index/panel-config-codex.html +++ b/web-ui/partials/index/panel-config-codex.html @@ -38,6 +38,27 @@ {{ t('config.addProvider') }} + +
+
+ {{ t('config.providerTemplate.title') }} +
+
+ +
+
+
From e04f3fab10250d5cf3f042e5c2bc01e6e7e878be Mon Sep 17 00:00:00 2001 From: ymkiux Date: Mon, 11 May 2026 01:40:06 +0800 Subject: [PATCH 3/9] fix(web-ui): add -- separator to npm start share command prefix npm does not forward arguments after npm start to the underlying script. The generated share commands used npm start add ... which npm interprets as its own subcommand. Adding -- makes npm pass the remaining arguments through to node cli.js. Co-Authored-By: ymkiux --- tests/unit/provider-share-command.test.mjs | 6 +++--- tests/unit/web-ui-behavior-parity.test.mjs | 4 ++-- web-ui/modules/app.methods.session-actions.mjs | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/unit/provider-share-command.test.mjs b/tests/unit/provider-share-command.test.mjs index 4a144e10..44a4c3e2 100644 --- a/tests/unit/provider-share-command.test.mjs +++ b/tests/unit/provider-share-command.test.mjs @@ -194,7 +194,7 @@ test('buildProviderShareCommand appends model switch command when model exists', assert.strictEqual( command, - "npm start add alpha 'https://api.example.com/v1' sk-alpha && npm start switch alpha && npm start use alpha-share-model" + "npm start -- add alpha 'https://api.example.com/v1' sk-alpha && npm start -- switch alpha && npm start -- use alpha-share-model" ); }); @@ -208,7 +208,7 @@ test('buildProviderShareCommand keeps legacy command when payload model is empty bridge: '' }); - assert.strictEqual(command, "npm start add alpha 'https://api.example.com/v1' sk-alpha && npm start switch alpha"); + assert.strictEqual(command, "npm start -- add alpha 'https://api.example.com/v1' sk-alpha && npm start -- switch alpha"); }); test('buildProviderShareCommand supports codexmate prefix', () => { @@ -253,7 +253,7 @@ test('buildClaudeShareCommand respects the configured share prefix', () => { assert.strictEqual( command, - "npm start claude 'https://claude.example.com' sk-claude claude-3-7-sonnet" + "npm start -- claude 'https://claude.example.com' sk-claude claude-3-7-sonnet" ); }); diff --git a/tests/unit/web-ui-behavior-parity.test.mjs b/tests/unit/web-ui-behavior-parity.test.mjs index 78317962..d6d4c479 100644 --- a/tests/unit/web-ui-behavior-parity.test.mjs +++ b/tests/unit/web-ui-behavior-parity.test.mjs @@ -1190,7 +1190,7 @@ test('share, copy, and standalone helpers remain aligned with HEAD', async () => assert.deepStrictEqual(currentProvider, headProvider); assert.deepStrictEqual(currentProviderEnv.clipboardWrites, [ - "npm start add demo-provider 'https://provider.example.com' provider-secret && npm start switch demo-provider && npm start use gpt-4.1" + "npm start -- add demo-provider 'https://provider.example.com' provider-secret && npm start -- switch demo-provider && npm start -- use gpt-4.1" ]); assert.deepStrictEqual(currentProviderContext.providerShareLoading, headProviderContext.providerShareLoading); assert.deepStrictEqual(currentProviderContext.messages, headProviderContext.messages); @@ -1232,7 +1232,7 @@ test('share, copy, and standalone helpers remain aligned with HEAD', async () => }, () => headMethods.copyClaudeShareCommand.call(headClaudeContext, 'shared')); assert.deepStrictEqual(currentClaudeEnv.clipboardWrites, [ - "npm start claude 'https://claude.example.com' claude-secret claude-3-7" + "npm start -- claude 'https://claude.example.com' claude-secret claude-3-7" ]); assert.deepStrictEqual(currentClaudeContext.claudeShareLoading, headClaudeContext.claudeShareLoading); assert.deepStrictEqual(currentClaudeContext.messages, headClaudeContext.messages); diff --git a/web-ui/modules/app.methods.session-actions.mjs b/web-ui/modules/app.methods.session-actions.mjs index 23188254..ef9bcb95 100644 --- a/web-ui/modules/app.methods.session-actions.mjs +++ b/web-ui/modules/app.methods.session-actions.mjs @@ -254,7 +254,7 @@ export function createSessionActionMethods(options = {}) { getShareCommandPrefixInvocation() { const prefix = this.normalizeShareCommandPrefix(this.shareCommandPrefix); - return prefix === 'codexmate' ? 'codexmate' : 'npm start'; + return prefix === 'codexmate' ? 'codexmate' : 'npm start --'; }, setShareCommandPrefix(value) { From 783454e2c6d767dea92e10a9337c054bbb12fc6b Mon Sep 17 00:00:00 2001 From: ymkiux Date: Mon, 11 May 2026 02:29:08 +0800 Subject: [PATCH 4/9] feat(web-ui): enable cost estimation for claude sessions Add Claude model pricing to known catalog, remove Claude exclusion from cost estimation, and add date-suffix fuzzy matching for model ID lookup (e.g. claude-sonnet-4-6-20250514 matches claude-sonnet-4-6). --- tests/unit/web-ui-logic.test.mjs | 10 ++++----- web-ui/modules/app.computed.session.mjs | 30 +++++++++++++------------ 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/tests/unit/web-ui-logic.test.mjs b/tests/unit/web-ui-logic.test.mjs index 149c701c..508ce5f2 100644 --- a/tests/unit/web-ui-logic.test.mjs +++ b/tests/unit/web-ui-logic.test.mjs @@ -1100,7 +1100,7 @@ test('sessionUsageSummaryCards falls back to public catalog pricing when provide assert.match(costCard.title, /覆盖 1\/1 个会话/); }); -test('sessionUsageSummaryCards excludes Claude sessions from estimated cost coverage', () => { +test('sessionUsageSummaryCards includes Claude sessions in cost estimation', () => { const computed = createSessionComputed(); const cards = computed.sessionUsageSummaryCards.call({ sessionUsageCharts: { @@ -1131,7 +1131,7 @@ test('sessionUsageSummaryCards excludes Claude sessions from estimated cost cove { source: 'claude', provider: 'claude', - model: 'claude-3-7-sonnet', + model: 'claude-sonnet-4-6', totalTokens: 250000, inputTokens: 150000, cachedInputTokens: 0, @@ -1145,10 +1145,8 @@ test('sessionUsageSummaryCards excludes Claude sessions from estimated cost cove const costCard = cards.find((card) => card.key === 'estimated-cost'); assert(costCard, 'missing estimated cost summary card'); - assert.strictEqual(costCard.value, '$1.77'); - assert.ok(!costCard.note); - assert.match(costCard.title, /暂不含 Claude/); - assert.match(costCard.title, /覆盖 1\/1 个会话/); + assert.ok(!costCard.title.includes('暂不含 Claude'), 'Claude exclusion prefix should not appear'); + assert.match(costCard.title, /覆盖 2\/2 个会话/); }); test('sessionUsageSummaryCards respects configured zero-cost pricing instead of falling back to catalog rates', () => { diff --git a/web-ui/modules/app.computed.session.mjs b/web-ui/modules/app.computed.session.mjs index 0d15fd76..1f20372f 100644 --- a/web-ui/modules/app.computed.session.mjs +++ b/web-ui/modules/app.computed.session.mjs @@ -153,7 +153,14 @@ const KNOWN_USAGE_MODEL_PRICING = Object.freeze({ 'gpt-5.4': Object.freeze({ input: 2.5, output: 15, cacheRead: 0.25, cacheWrite: 0 }), 'gpt-5.4-mini': Object.freeze({ input: 0.75, output: 4.5, cacheRead: 0.075, cacheWrite: 0 }), 'gpt-5.3-codex': Object.freeze({ input: 1.75, output: 14, cacheRead: 0.175, cacheWrite: 0 }), - 'gpt-5.2-codex': Object.freeze({ input: 1.75, output: 14, cacheRead: 0.175, cacheWrite: 0 }) + 'gpt-5.2-codex': Object.freeze({ input: 1.75, output: 14, cacheRead: 0.175, cacheWrite: 0 }), + 'claude-opus-4-6': Object.freeze({ input: 15, output: 75, cacheRead: 1.875, cacheWrite: 0, reasoningOutput: 75 }), + 'claude-opus-4-7': Object.freeze({ input: 15, output: 75, cacheRead: 1.875, cacheWrite: 0, reasoningOutput: 75 }), + 'claude-sonnet-4-6': Object.freeze({ input: 3, output: 15, cacheRead: 0.375, cacheWrite: 0, reasoningOutput: 15 }), + 'claude-haiku-4-5': Object.freeze({ input: 0.8, output: 4, cacheRead: 0.1, cacheWrite: 0, reasoningOutput: 4 }), + 'claude-3-5-sonnet': Object.freeze({ input: 3, output: 15, cacheRead: 0.3, cacheWrite: 0, reasoningOutput: 15 }), + 'claude-3-5-haiku': Object.freeze({ input: 0.8, output: 4, cacheRead: 0.08, cacheWrite: 0, reasoningOutput: 4 }), + 'claude-3-opus': Object.freeze({ input: 15, output: 75, cacheRead: 1.5, cacheWrite: 0, reasoningOutput: 75 }) }); function createUsagePricingEntry(pricing, source) { @@ -234,22 +241,17 @@ function resolveUsagePricingForSession(session, pricingIndex, fallbackProvider = if (knownPricing) { return knownPricing; } + const strippedModel = model.replace(/-\d{8}(?=[-_]|$)/, ''); + if (strippedModel !== model) { + const strippedKnown = pricingIndex.knownByModel instanceof Map ? pricingIndex.knownByModel.get(strippedModel) : null; + if (strippedKnown) { + return strippedKnown; + } + } return null; } -function shouldEstimateUsageCostForSession(session) { - if (!session || typeof session !== 'object') { - return false; - } - const source = typeof session.source === 'string' ? session.source.trim().toLowerCase() : ''; - const provider = typeof session.provider === 'string' ? session.provider.trim().toLowerCase() : ''; - const model = typeof session.model === 'string' ? session.model.trim().toLowerCase() : ''; - if (source === 'claude' || provider === 'claude') { - return false; - } - if (/^claude(?:[-_]|$)/.test(model)) { - return false; - } +function shouldEstimateUsageCostForSession() { return true; } From 7afd9d6a06ad13093d352243a3ef011501dc0fe3 Mon Sep 17 00:00:00 2001 From: ymkiux Date: Mon, 11 May 2026 16:02:50 +0800 Subject: [PATCH 5/9] fix(web-ui): register clone modal keys in parity baseline and remove preset btn-group margin-top --- tests/unit/web-ui-behavior-parity.test.mjs | 12 +++++++++--- web-ui/partials/index/panel-config-codex.html | 2 +- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/tests/unit/web-ui-behavior-parity.test.mjs b/tests/unit/web-ui-behavior-parity.test.mjs index 028eb834..5b628283 100644 --- a/tests/unit/web-ui-behavior-parity.test.mjs +++ b/tests/unit/web-ui-behavior-parity.test.mjs @@ -337,7 +337,9 @@ test('captured bundled app skeleton only exposes expected data key drift versus 'taskOrchestration', '_taskOrchestrationPollTimer', 'displayProviderUrl', - 'isTransformProvider' + 'isTransformProvider', + 'openCloneClaudeConfigModal', + 'openCloneProviderModal' ] : [ '__mainTabSwitchState', 'openclawAuthProfilesByProvider', @@ -356,7 +358,9 @@ test('captured bundled app skeleton only exposes expected data key drift versus 'sessionsViewMode', 'taskOrchestrationTabEnabled', 'taskOrchestration', - '_taskOrchestrationPollTimer' + '_taskOrchestrationPollTimer', + 'openCloneClaudeConfigModal', + 'openCloneProviderModal' ]; const allowedMissingCurrentKeys = [ 'localProxyRunning', @@ -551,7 +555,9 @@ test('captured bundled app skeleton only exposes expected data key drift versus 'copySessionsFilterShareUrl', 'getInstallStatusTarget', 'isInstallTargetInstalled', - 'shouldShowCliInstallPlaceholder' + 'shouldShowCliInstallPlaceholder', + 'openCloneClaudeConfigModal', + 'openCloneProviderModal' ); const allowedMissingCurrentMethodKeys = [ 'closeInstallModal', diff --git a/web-ui/partials/index/panel-config-codex.html b/web-ui/partials/index/panel-config-codex.html index f069b11f..c0636780 100644 --- a/web-ui/partials/index/panel-config-codex.html +++ b/web-ui/partials/index/panel-config-codex.html @@ -43,7 +43,7 @@
{{ t('config.providerTemplate.title') }}
-
+
From 8fd8b1c95bd8cc3d63a41f420107d853a7963ca5 Mon Sep 17 00:00:00 2001 From: ymkiux Date: Mon, 11 May 2026 17:45:08 +0800 Subject: [PATCH 7/9] fix(web-ui): clear default baseUrl in claude add config modal --- web-ui/app.js | 4 ++-- web-ui/modules/app.methods.claude-config.mjs | 4 ++-- web-ui/modules/i18n.dict.mjs | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/web-ui/app.js b/web-ui/app.js index c04f11bf..73788a1e 100644 --- a/web-ui/app.js +++ b/web-ui/app.js @@ -274,8 +274,8 @@ document.addEventListener('DOMContentLoaded', () => { newClaudeConfig: { name: '', apiKey: '', - baseUrl: 'https://open.bigmodel.cn/api/anthropic', - model: 'glm-4.7' + baseUrl: '', + model: '' }, currentOpenclawConfig: '', openclawConfigs: { diff --git a/web-ui/modules/app.methods.claude-config.mjs b/web-ui/modules/app.methods.claude-config.mjs index cfb11b75..05553300 100644 --- a/web-ui/modules/app.methods.claude-config.mjs +++ b/web-ui/modules/app.methods.claude-config.mjs @@ -192,8 +192,8 @@ export function createClaudeConfigMethods(options = {}) { this.newClaudeConfig = { name: '', apiKey: '', - baseUrl: 'https://open.bigmodel.cn/api/anthropic', - model: 'glm-4.7' + baseUrl: '', + model: '' }; } }; diff --git a/web-ui/modules/i18n.dict.mjs b/web-ui/modules/i18n.dict.mjs index 2b828775..03a2b84d 100644 --- a/web-ui/modules/i18n.dict.mjs +++ b/web-ui/modules/i18n.dict.mjs @@ -106,7 +106,7 @@ const DICT = Object.freeze({ 'placeholder.modelExample': '例如: gpt-5', 'placeholder.configNameExample': '例如: 智谱GLM', 'placeholder.apiKeyExampleClaude': 'sk-ant-...', - 'placeholder.baseUrlExampleClaude': '', + 'placeholder.baseUrlExampleClaude': 'https://open.bigmodel.cn/api/anthropic', 'placeholder.selectProvider': '请选择提供商', // Roles / labels @@ -1162,7 +1162,7 @@ const DICT = Object.freeze({ 'placeholder.modelExample': 'e.g. gpt-5', 'placeholder.configNameExample': 'e.g. My Claude Setup', 'placeholder.apiKeyExampleClaude': 'sk-ant-...', - 'placeholder.baseUrlExampleClaude': '', + 'placeholder.baseUrlExampleClaude': 'https://open.bigmodel.cn/api/anthropic', 'placeholder.selectProvider': 'Select a provider', // Roles / labels From 5d3f804b81892bbf680a576784cbe73e3024b684 Mon Sep 17 00:00:00 2001 From: ymkiux Date: Mon, 11 May 2026 20:28:55 +0800 Subject: [PATCH 8/9] fix(proxy): forward reasoning_content sse delta --- cli/openai-bridge.js | 11 +++- .../openai-bridge-upstream-responses.test.mjs | 59 +++++++++++++++++++ 2 files changed, 68 insertions(+), 2 deletions(-) diff --git a/cli/openai-bridge.js b/cli/openai-bridge.js index 685a554e..02a22d49 100644 --- a/cli/openai-bridge.js +++ b/cli/openai-bridge.js @@ -794,7 +794,14 @@ function writeChatCompletionChunkAsResponsesSse(state, chunk) { const delta = choice && choice.delta && typeof choice.delta === 'object' ? choice.delta : null; if (!delta) continue; + const segments = []; + if (typeof delta.reasoning_content === 'string' && delta.reasoning_content) { + segments.push(delta.reasoning_content); + } if (typeof delta.content === 'string' && delta.content) { + segments.push(delta.content); + } + for (const seg of segments) { if (!state.messageItem) { state.messageItem = { id: `msg_${crypto.randomBytes(8).toString('hex')}`, @@ -809,14 +816,14 @@ function writeChatCompletionChunkAsResponsesSse(state, chunk) { item: state.messageItem }); } - state.messageText += delta.content; + state.messageText += seg; state.messageItem.content[0].text = state.messageText; writeSse(state.res, 'response.output_text.delta', { type: 'response.output_text.delta', item_id: state.messageItem.id, output_index: state.output.length - 1, content_index: 0, - delta: delta.content, + delta: seg, sequence_number: state.nextSeq() }); } diff --git a/tests/unit/openai-bridge-upstream-responses.test.mjs b/tests/unit/openai-bridge-upstream-responses.test.mjs index b02fd0f3..94f2eaa4 100644 --- a/tests/unit/openai-bridge-upstream-responses.test.mjs +++ b/tests/unit/openai-bridge-upstream-responses.test.mjs @@ -169,6 +169,65 @@ test('openai-bridge streams chat/completions directly when Responses client requ await rm(tmpDir, { recursive: true, force: true }); }); +test('openai-bridge forwards upstream reasoning_content as output_text delta', async () => { + const upstream = http.createServer((req, res) => { + if (req.url === '/v1/chat/completions' && req.method === 'POST') { + let body = ''; + req.on('data', (c) => (body += c)); + req.on('end', () => { + res.writeHead(200, { 'Content-Type': 'text/event-stream; charset=utf-8' }); + res.write('data: {"id":"r1","model":"deepseek-v4","choices":[{"delta":{"reasoning_content":"thinking-"}}]}\n\n'); + res.write('data: {"id":"r1","model":"deepseek-v4","choices":[{"delta":{"reasoning_content":"step"}}]}\n\n'); + res.write('data: {"id":"r1","model":"deepseek-v4","choices":[{"delta":{"content":"answer"}}]}\n\n'); + res.end('data: [DONE]\n\n'); + }); + return; + } + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'not found' })); + }); + const { port: upstreamPort } = await listen(upstream); + + const tmpDir = await mkdtemp(path.join(os.tmpdir(), 'codexmate-bridge-test-')); + const settingsFile = path.join(tmpDir, 'bridge.json'); + await writeFile(settingsFile, JSON.stringify({ + version: 1, + providers: { + test: { baseUrl: `http://127.0.0.1:${upstreamPort}/v1`, apiKey: 'sk-upstream' } + } + }), 'utf-8'); + + const handler = createOpenaiBridgeHttpHandler({ settingsFile, expectedToken: 'codexmate' }); + const bridge = http.createServer((req, res) => { + if (!handler(req, res)) { + res.statusCode = 404; + res.end('not handled'); + } + }); + const { port: bridgePort } = await listen(bridge); + + const base = `http://127.0.0.1:${bridgePort}/bridge/openai/test/v1/responses`; + const sse = await requestText(base, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'text/event-stream', + 'Authorization': 'Bearer codexmate' + }, + body: { model: 'deepseek-v4', input: 'ping', stream: true } + }); + assert.equal(sse.status, 200); + assert.match(sse.text, /"delta":"thinking-"/); + assert.match(sse.text, /"delta":"step"/); + assert.match(sse.text, /"delta":"answer"/); + assert.match(sse.text, /"text":"thinking-stepanswer"/); + assert.match(sse.text, /data: \[DONE\]/); + + await bridge.close(); + await upstream.close(); + await rm(tmpDir, { recursive: true, force: true }); +}); + test('openai-bridge reports failed Responses SSE when upstream chat stream ends before DONE', async () => { const upstream = http.createServer((req, res) => { if (req.url === '/v1/chat/completions' && req.method === 'POST') { From fead0b51ad2b9e32ad3bd68f17dcd7fbb2c23f40 Mon Sep 17 00:00:00 2001 From: ymkiux Date: Mon, 11 May 2026 22:16:40 +0800 Subject: [PATCH 9/9] fix(usage): cache and reasoning cost estimation --- cli.js | 52 ++++++++++--- tests/unit/session-usage-backend.test.mjs | 92 +++++++++++++++++++++++ tests/unit/web-ui-logic.test.mjs | 42 ++++++++++- web-ui/modules/app.computed.session.mjs | 23 +++--- 4 files changed, 188 insertions(+), 21 deletions(-) diff --git a/cli.js b/cli.js index 46eb56ae..b29eb0b7 100644 --- a/cli.js +++ b/cli.js @@ -3647,12 +3647,19 @@ function readTotalTokensFromUsage(usage) { return explicitTotal; } const inputTokens = readNonNegativeInteger(usage.input_tokens ?? usage.inputTokens); + const cachedInputTokens = readNonNegativeInteger( + usage.cached_input_tokens ?? usage.cachedInputTokens + ?? usage.cache_read_input_tokens ?? usage.cacheReadInputTokens + ); + const cacheCreationInputTokens = readNonNegativeInteger( + usage.cache_creation_input_tokens ?? usage.cacheCreationInputTokens + ); const outputTokens = readNonNegativeInteger(usage.output_tokens ?? usage.outputTokens); const reasoningOutputTokens = readNonNegativeInteger(usage.reasoning_output_tokens ?? usage.reasoningOutputTokens); - if (inputTokens === null && outputTokens === null && reasoningOutputTokens === null) { + if (inputTokens === null && cachedInputTokens === null && cacheCreationInputTokens === null && outputTokens === null && reasoningOutputTokens === null) { return null; } - return (inputTokens || 0) + (outputTokens || 0) + (reasoningOutputTokens || 0); + return (inputTokens || 0) + (cachedInputTokens || 0) + (cacheCreationInputTokens || 0) + (outputTokens || 0) + (reasoningOutputTokens || 0); } function readUsageTotalsFromUsage(usage) { @@ -3660,19 +3667,26 @@ function readUsageTotalsFromUsage(usage) { return null; } const inputTokens = readNonNegativeInteger(usage.input_tokens ?? usage.inputTokens); - const cachedInputTokens = readNonNegativeInteger(usage.cached_input_tokens ?? usage.cachedInputTokens); + const cachedInputTokens = readNonNegativeInteger( + usage.cached_input_tokens ?? usage.cachedInputTokens + ?? usage.cache_read_input_tokens ?? usage.cacheReadInputTokens + ); + const cacheCreationInputTokens = readNonNegativeInteger( + usage.cache_creation_input_tokens ?? usage.cacheCreationInputTokens + ); const outputTokens = readNonNegativeInteger(usage.output_tokens ?? usage.outputTokens); const reasoningOutputTokens = readNonNegativeInteger(usage.reasoning_output_tokens ?? usage.reasoningOutputTokens); const totalTokens = readNonNegativeInteger(usage.total_tokens ?? usage.totalTokens) - ?? ((inputTokens === null && cachedInputTokens === null && outputTokens === null && reasoningOutputTokens === null) + ?? ((inputTokens === null && cachedInputTokens === null && cacheCreationInputTokens === null && outputTokens === null && reasoningOutputTokens === null) ? null - : ((inputTokens || 0) + (outputTokens || 0) + (reasoningOutputTokens || 0))); - if (inputTokens === null && cachedInputTokens === null && outputTokens === null && reasoningOutputTokens === null && totalTokens === null) { + : ((inputTokens || 0) + (cachedInputTokens || 0) + (cacheCreationInputTokens || 0) + (outputTokens || 0) + (reasoningOutputTokens || 0))); + if (inputTokens === null && cachedInputTokens === null && cacheCreationInputTokens === null && outputTokens === null && reasoningOutputTokens === null && totalTokens === null) { return null; } return { inputTokens, cachedInputTokens, + cacheCreationInputTokens, outputTokens, reasoningOutputTokens, totalTokens @@ -3698,6 +3712,7 @@ function applyUsageTotalsToState(state, usageTotals) { const pairs = [ ['inputTokens', usageTotals.inputTokens], ['cachedInputTokens', usageTotals.cachedInputTokens], + ['cacheCreationInputTokens', usageTotals.cacheCreationInputTokens], ['outputTokens', usageTotals.outputTokens], ['reasoningOutputTokens', usageTotals.reasoningOutputTokens], ['totalTokens', usageTotals.totalTokens] @@ -3948,12 +3963,13 @@ function parseCodexSessionSummary(filePath, options = {}) { let contextWindow = 0; let inputTokens = 0; let cachedInputTokens = 0; + let cacheCreationInputTokens = 0; let outputTokens = 0; let reasoningOutputTokens = 0; let provider = 'codex'; let model = ''; const models = []; - const usageState = { totalTokens, contextWindow, inputTokens, cachedInputTokens, outputTokens, reasoningOutputTokens }; + const usageState = { totalTokens, contextWindow, inputTokens, cachedInputTokens, cacheCreationInputTokens, outputTokens, reasoningOutputTokens }; const previewMessages = []; for (const record of records) { @@ -3966,6 +3982,7 @@ function parseCodexSessionSummary(filePath, options = {}) { contextWindow = usageState.contextWindow || 0; inputTokens = usageState.inputTokens || 0; cachedInputTokens = usageState.cachedInputTokens || 0; + cacheCreationInputTokens = usageState.cacheCreationInputTokens || 0; outputTokens = usageState.outputTokens || 0; reasoningOutputTokens = usageState.reasoningOutputTokens || 0; @@ -4000,6 +4017,7 @@ function parseCodexSessionSummary(filePath, options = {}) { contextWindow = usageState.contextWindow || 0; inputTokens = usageState.inputTokens || 0; cachedInputTokens = usageState.cachedInputTokens || 0; + cacheCreationInputTokens = usageState.cacheCreationInputTokens || 0; outputTokens = usageState.outputTokens || 0; reasoningOutputTokens = usageState.reasoningOutputTokens || 0; provider = readExplicitSessionProviderFromRecord(record) || provider; @@ -4059,6 +4077,7 @@ function parseCodexSessionSummary(filePath, options = {}) { contextWindow, inputTokens, cachedInputTokens, + cacheCreationInputTokens, outputTokens, reasoningOutputTokens, __messageCountExact: isSessionSummaryMessageCountExact(stat, summaryReadBytes), @@ -4095,12 +4114,13 @@ function parseClaudeSessionSummary(filePath, options = {}) { let contextWindow = 0; let inputTokens = 0; let cachedInputTokens = 0; + let cacheCreationInputTokens = 0; let outputTokens = 0; let reasoningOutputTokens = 0; let provider = 'claude'; let model = ''; const models = []; - const usageState = { totalTokens, contextWindow, inputTokens, cachedInputTokens, outputTokens, reasoningOutputTokens }; + const usageState = { totalTokens, contextWindow, inputTokens, cachedInputTokens, cacheCreationInputTokens, outputTokens, reasoningOutputTokens }; const previewMessages = []; let createdAt = ''; let updatedAt = stat.mtime.toISOString(); @@ -4118,6 +4138,7 @@ function parseClaudeSessionSummary(filePath, options = {}) { contextWindow = usageState.contextWindow || 0; inputTokens = usageState.inputTokens || 0; cachedInputTokens = usageState.cachedInputTokens || 0; + cacheCreationInputTokens = usageState.cacheCreationInputTokens || 0; outputTokens = usageState.outputTokens || 0; reasoningOutputTokens = usageState.reasoningOutputTokens || 0; @@ -4151,6 +4172,7 @@ function parseClaudeSessionSummary(filePath, options = {}) { contextWindow = usageState.contextWindow || 0; inputTokens = usageState.inputTokens || 0; cachedInputTokens = usageState.cachedInputTokens || 0; + cacheCreationInputTokens = usageState.cacheCreationInputTokens || 0; outputTokens = usageState.outputTokens || 0; reasoningOutputTokens = usageState.reasoningOutputTokens || 0; provider = readExplicitSessionProviderFromRecord(record) || provider; @@ -4209,6 +4231,7 @@ function parseClaudeSessionSummary(filePath, options = {}) { contextWindow, inputTokens, cachedInputTokens, + cacheCreationInputTokens, outputTokens, reasoningOutputTokens, __messageCountExact: isSessionSummaryMessageCountExact(stat, summaryReadBytes), @@ -4245,12 +4268,13 @@ function parseCodeBuddySessionSummary(filePath, options = {}) { let contextWindow = 0; let inputTokens = 0; let cachedInputTokens = 0; + let cacheCreationInputTokens = 0; let outputTokens = 0; let reasoningOutputTokens = 0; let provider = 'codebuddy'; let model = ''; const models = []; - const usageState = { totalTokens, contextWindow, inputTokens, cachedInputTokens, outputTokens, reasoningOutputTokens }; + const usageState = { totalTokens, contextWindow, inputTokens, cachedInputTokens, cacheCreationInputTokens, outputTokens, reasoningOutputTokens }; const previewMessages = []; let createdAt = ''; let updatedAt = stat.mtime.toISOString(); @@ -4268,6 +4292,7 @@ function parseCodeBuddySessionSummary(filePath, options = {}) { contextWindow = usageState.contextWindow || 0; inputTokens = usageState.inputTokens || 0; cachedInputTokens = usageState.cachedInputTokens || 0; + cacheCreationInputTokens = usageState.cacheCreationInputTokens || 0; outputTokens = usageState.outputTokens || 0; reasoningOutputTokens = usageState.reasoningOutputTokens || 0; @@ -4306,6 +4331,7 @@ function parseCodeBuddySessionSummary(filePath, options = {}) { contextWindow = usageState.contextWindow || 0; inputTokens = usageState.inputTokens || 0; cachedInputTokens = usageState.cachedInputTokens || 0; + cacheCreationInputTokens = usageState.cacheCreationInputTokens || 0; outputTokens = usageState.outputTokens || 0; reasoningOutputTokens = usageState.reasoningOutputTokens || 0; provider = readExplicitSessionProviderFromRecord(record) || provider; @@ -4366,6 +4392,7 @@ function parseCodeBuddySessionSummary(filePath, options = {}) { contextWindow, inputTokens, cachedInputTokens, + cacheCreationInputTokens, outputTokens, reasoningOutputTokens, __messageCountExact: isSessionSummaryMessageCountExact(stat, summaryReadBytes), @@ -4655,17 +4682,19 @@ function listClaudeSessions(limit, options = {}) { let contextWindow = 0; let inputTokens = 0; let cachedInputTokens = 0; + let cacheCreationInputTokens = 0; let outputTokens = 0; let reasoningOutputTokens = 0; let model = typeof entry.model === 'string' ? entry.model.trim() : ''; const models = model ? [model] : []; - const usageState = { totalTokens, contextWindow, inputTokens, cachedInputTokens, outputTokens, reasoningOutputTokens }; + const usageState = { totalTokens, contextWindow, inputTokens, cachedInputTokens, cacheCreationInputTokens, outputTokens, reasoningOutputTokens }; applySessionUsageSummaryFromIndexEntry(usageState, entry); totalTokens = usageState.totalTokens || 0; contextWindow = usageState.contextWindow || 0; inputTokens = usageState.inputTokens || 0; cachedInputTokens = usageState.cachedInputTokens || 0; + cacheCreationInputTokens = usageState.cacheCreationInputTokens || 0; outputTokens = usageState.outputTokens || 0; reasoningOutputTokens = usageState.reasoningOutputTokens || 0; @@ -4696,6 +4725,7 @@ function listClaudeSessions(limit, options = {}) { contextWindow = usageState.contextWindow || 0; inputTokens = usageState.inputTokens || 0; cachedInputTokens = usageState.cachedInputTokens || 0; + cacheCreationInputTokens = usageState.cacheCreationInputTokens || 0; outputTokens = usageState.outputTokens || 0; reasoningOutputTokens = usageState.reasoningOutputTokens || 0; const filteredQuickMessages = removeLeadingSystemMessage(quickMessages); @@ -4720,6 +4750,7 @@ function listClaudeSessions(limit, options = {}) { contextWindow = usageState.contextWindow || 0; inputTokens = usageState.inputTokens || 0; cachedInputTokens = usageState.cachedInputTokens || 0; + cacheCreationInputTokens = usageState.cacheCreationInputTokens || 0; outputTokens = usageState.outputTokens || 0; reasoningOutputTokens = usageState.reasoningOutputTokens || 0; @@ -4743,6 +4774,7 @@ function listClaudeSessions(limit, options = {}) { contextWindow, inputTokens, cachedInputTokens, + cacheCreationInputTokens, outputTokens, reasoningOutputTokens, model, diff --git a/tests/unit/session-usage-backend.test.mjs b/tests/unit/session-usage-backend.test.mjs index a3e0e40a..ff2363b9 100644 --- a/tests/unit/session-usage-backend.test.mjs +++ b/tests/unit/session-usage-backend.test.mjs @@ -666,6 +666,98 @@ test('parseClaudeSessionSummary reads model from session index and tail records' } }); +test('parseClaudeSessionSummary exposes Anthropic cache_read and cache_creation token fields', () => { + const parseClaudeSessionSummary = instantiateFunctionBundle( + [ + getFileHeadTextSrc, + getFileTailTextSrc, + parseJsonlContentSrc, + parseJsonlHeadRecordsSrc, + parseJsonlTailRecordsSrc, + isSessionSummaryMessageCountExactSrc, + removeLeadingSystemMessageSrc, + readNonNegativeIntegerSrc, + readTotalTokensFromUsageSrc, + readUsageTotalsFromUsageSrc, + readContextWindowValueSrc, + applyUsageTotalsToStateSrc, + readSessionModelsFromRecordSrc, + readSessionModelFromRecordSrc, + readExplicitSessionProviderFromRecordSrc, + readSessionProviderFromRecordSrc, + applySessionUsageSummaryFromRecordSrc, + parseClaudeSessionSummarySrc + ], + 'parseClaudeSessionSummary', + { + fs, + path, + Buffer, + SESSION_SUMMARY_READ_BYTES: 512, + SESSION_TITLE_READ_BYTES: 512, + toIsoTime(value, fallback = '') { + const date = new Date(value); + if (!Number.isFinite(date.getTime())) return fallback; + return date.toISOString(); + }, + updateLatestIso(current, next) { + const currentMs = Date.parse(current || ''); + const nextMs = Date.parse(next || ''); + if (!Number.isFinite(nextMs)) return current || ''; + if (!Number.isFinite(currentMs) || nextMs > currentMs) return new Date(nextMs).toISOString(); + return current || ''; + }, + normalizeRole(role) { + return typeof role === 'string' ? role.trim().toLowerCase() : ''; + }, + extractMessageText(content) { + if (typeof content === 'string') return content; + if (Array.isArray(content)) { + return content.map((item) => item && item.text ? item.text : '').join(' ').trim(); + } + return ''; + }, + truncateText(text) { + return typeof text === 'string' ? text : ''; + }, + isBootstrapLikeText() { + return false; + } + } + ); + + const tempDir = fs.mkdtempSync(path.join(__dirname, 'tmp-session-usage-claude-cache-')); + const filePath = path.join(tempDir, 'claude-cache.jsonl'); + fs.writeFileSync(filePath, [ + JSON.stringify({ type: 'user', message: { content: 'hello' }, timestamp: '2026-04-12T09:07:26.690Z' }), + JSON.stringify({ + type: 'assistant', + message: { + model: 'claude-sonnet-4-6', + usage: { + input_tokens: 1000, + cache_read_input_tokens: 5000, + cache_creation_input_tokens: 2000, + output_tokens: 500 + } + }, + timestamp: '2026-04-12T09:11:35.588Z' + }) + ].join('\n')); + + try { + const result = parseClaudeSessionSummary(filePath, { summaryReadBytes: 512, titleReadBytes: 512 }); + assert(result, 'expected claude session summary'); + assert.strictEqual(result.cachedInputTokens, 5000, 'cache_read_input_tokens should map to cachedInputTokens'); + assert.strictEqual(result.cacheCreationInputTokens, 2000, 'cache_creation_input_tokens should be exposed'); + assert.strictEqual(result.inputTokens, 1000); + assert.strictEqual(result.outputTokens, 500); + assert.strictEqual(result.totalTokens, 8500, 'totalTokens fallback should include cache read and creation'); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); + test('listSessionInventoryBySource reuses cached summaries and registers session lookups', () => { const codexCalls = []; const g_sessionInventoryCache = new Map(); diff --git a/tests/unit/web-ui-logic.test.mjs b/tests/unit/web-ui-logic.test.mjs index 508ce5f2..d2b848ae 100644 --- a/tests/unit/web-ui-logic.test.mjs +++ b/tests/unit/web-ui-logic.test.mjs @@ -1050,7 +1050,7 @@ test('sessionUsageSummaryCards estimates usage cost from configured provider pri assert(costCard, 'missing estimated cost summary card'); assert(activeDurationCard, 'missing active duration summary card'); assert(totalDurationCard, 'missing total duration summary card'); - assert.strictEqual(costCard.value, '$1.25'); + assert.strictEqual(costCard.value, '$0.8500'); assert.strictEqual(costCard.label, '预估费用 · 近 7 天'); assert.ok(!costCard.note); assert.match(costCard.title, /覆盖 1\/2 个会话/); @@ -1100,6 +1100,46 @@ test('sessionUsageSummaryCards falls back to public catalog pricing when provide assert.match(costCard.title, /覆盖 1\/1 个会话/); }); +test('sessionUsageSummaryCards estimates Anthropic cache_creation cost via cacheWrite rate', () => { + const computed = createSessionComputed(); + const cards = computed.sessionUsageSummaryCards.call({ + sessionUsageCharts: { + summary: { + totalSessions: 1, + totalMessages: 4, + totalTokens: 400000, + totalContextWindow: 200000, + activeDurationMs: 30 * 60 * 1000, + totalDurationMs: 30 * 60 * 1000, + activeDays: 1, + avgMessagesPerSession: 4, + busiestDay: null, + busiestHour: null + } + }, + sessionsUsageList: [ + { + source: 'claude', + provider: 'claude', + model: 'claude-sonnet-4-6', + totalTokens: 400000, + inputTokens: 200000, + cachedInputTokens: 50000, + cacheCreationInputTokens: 30000, + outputTokens: 100000, + reasoningOutputTokens: 0 + } + ], + providersList: [], + currentProvider: 'claude' + }); + + const costCard = cards.find((card) => card.key === 'estimated-cost'); + assert(costCard, 'missing estimated cost summary card'); + assert.strictEqual(costCard.value, '$1.99'); + assert.match(costCard.title, /覆盖 1\/1 个会话/); +}); + test('sessionUsageSummaryCards includes Claude sessions in cost estimation', () => { const computed = createSessionComputed(); const cards = computed.sessionUsageSummaryCards.call({ diff --git a/web-ui/modules/app.computed.session.mjs b/web-ui/modules/app.computed.session.mjs index 1f20372f..9ae19e98 100644 --- a/web-ui/modules/app.computed.session.mjs +++ b/web-ui/modules/app.computed.session.mjs @@ -154,13 +154,13 @@ const KNOWN_USAGE_MODEL_PRICING = Object.freeze({ 'gpt-5.4-mini': Object.freeze({ input: 0.75, output: 4.5, cacheRead: 0.075, cacheWrite: 0 }), 'gpt-5.3-codex': Object.freeze({ input: 1.75, output: 14, cacheRead: 0.175, cacheWrite: 0 }), 'gpt-5.2-codex': Object.freeze({ input: 1.75, output: 14, cacheRead: 0.175, cacheWrite: 0 }), - 'claude-opus-4-6': Object.freeze({ input: 15, output: 75, cacheRead: 1.875, cacheWrite: 0, reasoningOutput: 75 }), - 'claude-opus-4-7': Object.freeze({ input: 15, output: 75, cacheRead: 1.875, cacheWrite: 0, reasoningOutput: 75 }), - 'claude-sonnet-4-6': Object.freeze({ input: 3, output: 15, cacheRead: 0.375, cacheWrite: 0, reasoningOutput: 15 }), - 'claude-haiku-4-5': Object.freeze({ input: 0.8, output: 4, cacheRead: 0.1, cacheWrite: 0, reasoningOutput: 4 }), - 'claude-3-5-sonnet': Object.freeze({ input: 3, output: 15, cacheRead: 0.3, cacheWrite: 0, reasoningOutput: 15 }), - 'claude-3-5-haiku': Object.freeze({ input: 0.8, output: 4, cacheRead: 0.08, cacheWrite: 0, reasoningOutput: 4 }), - 'claude-3-opus': Object.freeze({ input: 15, output: 75, cacheRead: 1.5, cacheWrite: 0, reasoningOutput: 75 }) + 'claude-opus-4-6': Object.freeze({ input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75, reasoningOutput: 75 }), + 'claude-opus-4-7': Object.freeze({ input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75, reasoningOutput: 75 }), + 'claude-sonnet-4-6': Object.freeze({ input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75, reasoningOutput: 15 }), + 'claude-haiku-4-5': Object.freeze({ input: 0.8, output: 4, cacheRead: 0.08, cacheWrite: 1, reasoningOutput: 4 }), + 'claude-3-5-sonnet': Object.freeze({ input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75, reasoningOutput: 15 }), + 'claude-3-5-haiku': Object.freeze({ input: 0.8, output: 4, cacheRead: 0.08, cacheWrite: 1, reasoningOutput: 4 }), + 'claude-3-opus': Object.freeze({ input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75, reasoningOutput: 75 }) }); function createUsagePricingEntry(pricing, source) { @@ -315,10 +315,11 @@ function estimateUsageCostSummary(sessions, providersList, currentProvider) { function estimateUsageCostForSession(session, pricingIndex, currentProvider) { const inputTokens = Number.isFinite(Number(session.inputTokens)) ? Math.max(0, Math.floor(Number(session.inputTokens))) : null; const cachedInputTokens = Number.isFinite(Number(session.cachedInputTokens)) ? Math.max(0, Math.floor(Number(session.cachedInputTokens))) : 0; + const cacheCreationInputTokens = Number.isFinite(Number(session.cacheCreationInputTokens)) ? Math.max(0, Math.floor(Number(session.cacheCreationInputTokens))) : 0; const outputTokens = Number.isFinite(Number(session.outputTokens)) ? Math.max(0, Math.floor(Number(session.outputTokens))) : null; const reasoningOutputTokens = Number.isFinite(Number(session.reasoningOutputTokens)) ? Math.max(0, Math.floor(Number(session.reasoningOutputTokens))) : 0; - const billableInputTokens = Math.max(0, (inputTokens || 0) - cachedInputTokens); - const fallbackSessionTokens = billableInputTokens + cachedInputTokens + (outputTokens || 0) + reasoningOutputTokens; + const billableInputTokens = Math.max(0, (inputTokens || 0) - cachedInputTokens - cacheCreationInputTokens); + const fallbackSessionTokens = billableInputTokens + cachedInputTokens + cacheCreationInputTokens + (outputTokens || 0) + reasoningOutputTokens; const totalSessionTokens = Number.isFinite(Number(session.totalTokens)) ? Math.max(0, Math.floor(Number(session.totalTokens))) : fallbackSessionTokens; @@ -327,12 +328,14 @@ function estimateUsageCostForSession(session, pricingIndex, currentProvider) { const reasoningRate = pricing ? ((pricing.reasoningOutput != null ? pricing.reasoningOutput : pricing.output) || 0) : 0; + const visibleOutputTokens = Math.max(0, (outputTokens || 0) - reasoningOutputTokens); const estimatedUsd = pricing && hasTokenBreakdown ? ( ((pricing.input || 0) * billableInputTokens) + ((pricing.cacheRead || 0) * cachedInputTokens) + + ((pricing.cacheWrite || 0) * cacheCreationInputTokens) + (reasoningRate * reasoningOutputTokens) - + ((pricing.output || 0) * (outputTokens || 0)) + + ((pricing.output || 0) * visibleOutputTokens) ) / 1000000 : 0; return {