From 979231627ad846ec3fcfd5a5e9db54b9ad3e783c Mon Sep 17 00:00:00 2001 From: AJ Green Date: Sat, 30 May 2026 11:31:51 -0600 Subject: [PATCH 1/6] fix(status-line): show N/A instead of 0% for providers that strip stream_options (#448) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: MiMo and Gitlawb OpenGateway explicitly strip `stream_options` from request bodies (it causes a "Param Incorrect" error on MiMo's API). Without `stream_options: { include_usage: true }`, the provider never includes usage in its streaming response. The shim seeds each message with { input_tokens: 0, output_tokens: 0 } and those values never get updated, so the status-line always shows 0% context used. Fix 1 — tokens.ts: `getTokenUsage` now returns `undefined` when both `input_tokens` and `output_tokens` are zero. A real API response always has ≥ 1 output token, so all-zero usage unambiguously means "provider did not report usage". Returning `undefined` causes `getCurrentUsage()` to return `null` → `calculateContextPercentages(null, ...)` returns `{ used: null }` → status-line shows "N/A" (empty) instead of misleading "0% used". Fix 2 — openaiShim.ts: Accumulate `lastSeenUsage` across all SSE chunks. Some providers emit usage in an early chunk (before the finish_reason chunk); without this accumulator the `message_delta` is emitted without usage even when the stream contained real usage data earlier. Co-Authored-By: Claude Sonnet 4.6 --- src/services/api/openaiShim.test.ts | 65 ++++++++++++++++++++++++++++ src/services/api/openaiShim.ts | 21 +++++++-- src/utils/tokens.test.ts | 66 +++++++++++++++++++++++++++++ src/utils/tokens.ts | 13 +++++- 4 files changed, 160 insertions(+), 5 deletions(-) diff --git a/src/services/api/openaiShim.test.ts b/src/services/api/openaiShim.test.ts index 19e4a96d73..72d648b22d 100644 --- a/src/services/api/openaiShim.test.ts +++ b/src/services/api/openaiShim.test.ts @@ -879,6 +879,71 @@ test('preserves usage from final OpenAI stream chunk with empty choices', async expect(usageEvent?.usage?.output_tokens).toBe(45) }) +test('preserves usage from early stream chunk when finish_reason chunk has no usage', async () => { + // Some providers emit usage in an early chunk (before the finish_reason chunk). + // lastSeenUsage accumulation ensures this is captured and forwarded in message_delta. + globalThis.fetch = (async (_input, init) => { + const chunks = makeStreamChunks([ + { + id: 'chatcmpl-early-usage', + object: 'chat.completion.chunk', + model: 'fake-model', + choices: [ + { + index: 0, + delta: { role: 'assistant', content: 'hi' }, + finish_reason: null, + }, + ], + usage: { + prompt_tokens: 77, + completion_tokens: 11, + total_tokens: 88, + }, + }, + // Stop chunk — no usage field + { + id: 'chatcmpl-early-usage', + object: 'chat.completion.chunk', + model: 'fake-model', + choices: [ + { + index: 0, + delta: {}, + finish_reason: 'stop', + }, + ], + }, + ]) + return makeSseResponse(chunks) + }) as FetchType + + const client = createOpenAIShimClient({}) as OpenAIShimClient + + const result = await client.beta.messages + .create({ + model: 'fake-model', + messages: [{ role: 'user', content: 'hello' }], + max_tokens: 64, + stream: true, + }) + .withResponse() + + const events: Array> = [] + for await (const event of result.data) { + events.push(event) + } + + const usageEvent = events.find( + event => event.type === 'message_delta' && typeof event.usage === 'object' && event.usage !== null, + ) as { usage?: { input_tokens?: number; output_tokens?: number } } | undefined + + // The early-chunk usage should be carried forward to the message_delta + expect(usageEvent).toBeDefined() + expect(usageEvent?.usage?.input_tokens).toBe(77) + expect(usageEvent?.usage?.output_tokens).toBe(11) +}) + test('uses max_tokens instead of max_completion_tokens for local providers', async () => { process.env.OPENAI_BASE_URL = 'http://localhost:11434/v1' diff --git a/src/services/api/openaiShim.ts b/src/services/api/openaiShim.ts index a645e3df23..c7c6d4daf8 100644 --- a/src/services/api/openaiShim.ts +++ b/src/services/api/openaiShim.ts @@ -1274,6 +1274,10 @@ async function* openaiStreamToAnthropic( let lastStopReason: 'tool_use' | 'max_tokens' | 'end_turn' | null = null let hasEmittedFinalUsage = false let hasProcessedFinishReason = false + // Accumulate the most recent non-undefined usage seen across all chunks. + // Some providers send usage in an early chunk before the finish_reason chunk; + // without this accumulator the message_delta would be emitted without usage. + let lastSeenUsage: Partial | undefined const streamState = createStreamState() let bufferedRawToolCallsText: string | null = null @@ -1476,6 +1480,11 @@ async function* openaiStreamToAnthropic( } const chunkUsage = convertChunkUsage(chunk.usage) + // Keep a running record of the most recent usage seen across all chunks. + // Some providers emit usage in an early chunk before the finish_reason + // chunk arrives, so chunkUsage may be undefined at stop time even though + // we already observed real usage data earlier in the stream. + if (chunkUsage) lastSeenUsage = chunkUsage for (const choice of chunk.choices ?? []) { const delta = choice.delta @@ -1755,12 +1764,16 @@ async function* openaiStreamToAnthropic( } lastStopReason = stopReason + // Prefer usage from the current stop chunk; fall back to the last + // usage seen anywhere in the stream (handles providers that emit + // usage in an early chunk rather than the finish_reason chunk). + const finalUsage = chunkUsage ?? lastSeenUsage yield { type: 'message_delta', delta: { stop_reason: stopReason, stop_sequence: null }, - ...(chunkUsage ? { usage: chunkUsage } : {}), + ...(finalUsage ? { usage: finalUsage } : {}), } - if (chunkUsage) { + if (finalUsage) { hasEmittedFinalUsage = true } } @@ -1768,14 +1781,14 @@ async function* openaiStreamToAnthropic( if ( !hasEmittedFinalUsage && - chunkUsage && + (chunkUsage ?? lastSeenUsage) && (chunk.choices?.length ?? 0) === 0 && lastStopReason !== null ) { yield { type: 'message_delta', delta: { stop_reason: lastStopReason, stop_sequence: null }, - usage: chunkUsage, + usage: (chunkUsage ?? lastSeenUsage)!, } hasEmittedFinalUsage = true } diff --git a/src/utils/tokens.test.ts b/src/utils/tokens.test.ts index e385966d35..7e792d8fcc 100644 --- a/src/utils/tokens.test.ts +++ b/src/utils/tokens.test.ts @@ -1,8 +1,10 @@ import { describe, expect, it, beforeEach } from 'bun:test' import { getTokenCountFromUsage, + getTokenUsage, } from './tokens.js' import { IncrementalTokenCounter } from './incrementalTokenCounter.js' +import type { AssistantMessage } from '../types/message.js' interface FakeUsage { input_tokens: number @@ -11,6 +13,70 @@ interface FakeUsage { cache_creation_input_tokens?: number } +function makeAssistantMessage(usage: FakeUsage): AssistantMessage { + return { + type: 'assistant', + uuid: 'test-uuid', + timestamp: new Date().toISOString(), + message: { + id: 'msg_test', + type: 'message', + role: 'assistant', + model: 'test-model', + content: [{ type: 'text', text: 'Hello' }], + stop_reason: 'end_turn', + stop_sequence: null, + usage: usage as any, + }, + } +} + +describe('getTokenUsage', () => { + it('returns usage when tokens are non-zero', () => { + const msg = makeAssistantMessage({ + input_tokens: 100, + output_tokens: 50, + }) + const result = getTokenUsage(msg) + expect(result).toBeDefined() + expect(result?.input_tokens).toBe(100) + expect(result?.output_tokens).toBe(50) + }) + + it('returns undefined when both input and output tokens are zero', () => { + // Providers that strip stream_options (e.g. MiMo, Gitlawb OpenGateway) + // never include usage in streaming responses. The shim seeds the message + // with {0, 0} and it never gets updated. We return undefined so the + // status line shows N/A instead of a misleading "0% used". + const msg = makeAssistantMessage({ + input_tokens: 0, + output_tokens: 0, + }) + expect(getTokenUsage(msg)).toBeUndefined() + }) + + it('returns usage when only input_tokens is non-zero', () => { + const msg = makeAssistantMessage({ + input_tokens: 200, + output_tokens: 0, + }) + // input_tokens > 0 means real data (partial usage), keep it + const result = getTokenUsage(msg) + expect(result).toBeDefined() + expect(result?.input_tokens).toBe(200) + }) + + it('returns usage when only output_tokens is non-zero', () => { + const msg = makeAssistantMessage({ + input_tokens: 0, + output_tokens: 25, + }) + const result = getTokenUsage(msg) + expect(result).toBeDefined() + expect(result?.output_tokens).toBe(25) + }) +}) + describe('tokens', () => { }) diff --git a/src/utils/tokens.ts b/src/utils/tokens.ts index 422c95eead..3f264b331e 100644 --- a/src/utils/tokens.ts +++ b/src/utils/tokens.ts @@ -28,7 +28,18 @@ export function getTokenUsage(message: Message): Usage | undefined { ) && message.message.model !== SYNTHETIC_MODEL ) { - return message.message.usage + const usage = message.message.usage + // Providers that strip stream_options (e.g. Xiaomi MiMo, Gitlawb OpenGateway) + // cannot include usage in streaming responses. The shim initialises the message + // with { input_tokens: 0, output_tokens: 0 } and the values stay at zero for + // the whole turn. A real API response always has ≥ 1 output token, so an + // all-zero record is a reliable signal that the provider did not report usage. + // Returning undefined here causes getCurrentUsage() → null → status line + // shows "N/A" instead of a misleading "0% used". + if (usage && usage.input_tokens === 0 && usage.output_tokens === 0) { + return undefined + } + return usage } return undefined } From bd095dd31f7a8ed144d122a2734e5376f6981efa Mon Sep 17 00:00:00 2001 From: AJ Green Date: Sat, 30 May 2026 13:55:18 -0600 Subject: [PATCH 2/6] fix(getCurrentUsage): don't fall back to stale Anthropic data after 3P turn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a user switches providers mid-session (Anthropic → MiMo), the most recent assistant messages have all-zero usage (provider doesn't report it). Previously, getCurrentUsage() would skip those zero-usage messages and return the last Anthropic message's numbers — surfacing stale context % from a previous API call instead of N/A. Fix: in the getCurrentUsage loop, when we encounter a real (non-synthetic) assistant message that getTokenUsage() skipped because usage is all-zero, stop iteration immediately and return null rather than walking further back through the message history. Also add: - Tests for getCurrentUsage mixed-session and empty-session behavior - Shim test asserting hasEmittedFinalUsage prevents a duplicate message_delta when a trailing empty-choices chunk arrives after lastSeenUsage already triggered the first message_delta Co-Authored-By: Claude Sonnet 4.6 --- src/services/api/openaiShim.test.ts | 58 +++++++++++++++++++++++++++++ src/utils/tokens.test.ts | 41 +++++++++++++++++++- src/utils/tokens.ts | 23 ++++++++++++ 3 files changed, 121 insertions(+), 1 deletion(-) diff --git a/src/services/api/openaiShim.test.ts b/src/services/api/openaiShim.test.ts index 72d648b22d..ea96aba136 100644 --- a/src/services/api/openaiShim.test.ts +++ b/src/services/api/openaiShim.test.ts @@ -944,6 +944,64 @@ test('preserves usage from early stream chunk when finish_reason chunk has no us expect(usageEvent?.usage?.output_tokens).toBe(11) }) +test('does not emit a second message_delta when lastSeenUsage triggers hasEmittedFinalUsage', async () => { + // When usage arrives in an early chunk, lastSeenUsage is set and the stop + // message_delta uses it (hasEmittedFinalUsage = true). If a trailing + // empty-choices chunk also happens to carry usage (or lastSeenUsage is set), + // the guard `!hasEmittedFinalUsage` must prevent a duplicate message_delta. + globalThis.fetch = (async () => { + const chunks = makeStreamChunks([ + // Early chunk with usage + { + id: 'chatcmpl-no-double', + object: 'chat.completion.chunk', + model: 'fake-model', + choices: [{ index: 0, delta: { content: 'hi' }, finish_reason: null }], + usage: { prompt_tokens: 55, completion_tokens: 9, total_tokens: 64 }, + }, + // Stop chunk — no usage + { + id: 'chatcmpl-no-double', + object: 'chat.completion.chunk', + model: 'fake-model', + choices: [{ index: 0, delta: {}, finish_reason: 'stop' }], + }, + // Trailing empty-choices chunk — would trigger fallback if not guarded + { + id: 'chatcmpl-no-double', + object: 'chat.completion.chunk', + model: 'fake-model', + choices: [], + usage: { prompt_tokens: 55, completion_tokens: 9, total_tokens: 64 }, + }, + ]) + return makeSseResponse(chunks) + }) as FetchType + + const client = createOpenAIShimClient({}) as OpenAIShimClient + + const result = await client.beta.messages + .create({ + model: 'fake-model', + messages: [{ role: 'user', content: 'hi' }], + max_tokens: 32, + stream: true, + }) + .withResponse() + + const events: Array> = [] + for await (const event of result.data) { + events.push(event) + } + + const messageDeltaEvents = events.filter(e => e.type === 'message_delta') + // Only one message_delta should be emitted — the one from the stop chunk. + expect(messageDeltaEvents).toHaveLength(1) + const delta = messageDeltaEvents[0] as { usage?: { input_tokens?: number; output_tokens?: number } } + expect(delta.usage?.input_tokens).toBe(55) + expect(delta.usage?.output_tokens).toBe(9) +}) + test('uses max_tokens instead of max_completion_tokens for local providers', async () => { process.env.OPENAI_BASE_URL = 'http://localhost:11434/v1' diff --git a/src/utils/tokens.test.ts b/src/utils/tokens.test.ts index 7e792d8fcc..2af3643f6c 100644 --- a/src/utils/tokens.test.ts +++ b/src/utils/tokens.test.ts @@ -2,9 +2,10 @@ import { describe, expect, it, beforeEach } from 'bun:test' import { getTokenCountFromUsage, getTokenUsage, + getCurrentUsage, } from './tokens.js' import { IncrementalTokenCounter } from './incrementalTokenCounter.js' -import type { AssistantMessage } from '../types/message.js' +import type { AssistantMessage, Message } from '../types/message.js' interface FakeUsage { input_tokens: number @@ -77,6 +78,44 @@ describe('getTokenUsage', () => { }) }) +describe('getCurrentUsage', () => { + function makeUserMessage(): Message { + return { + type: 'user', + uuid: 'u-uuid', + timestamp: new Date().toISOString(), + message: { role: 'user', content: 'hello' }, + } + } + + it('returns usage from the most recent assistant message with non-zero tokens', () => { + const msg = makeAssistantMessage({ input_tokens: 300, output_tokens: 80 }) + const result = getCurrentUsage([makeUserMessage(), msg]) + expect(result?.input_tokens).toBe(300) + expect(result?.output_tokens).toBe(80) + }) + + it('returns null when the most recent assistant message has all-zero usage', () => { + // Pure 3P session: provider never reported usage, message stays at {0, 0}. + const msg = makeAssistantMessage({ input_tokens: 0, output_tokens: 0 }) + expect(getCurrentUsage([makeUserMessage(), msg])).toBeNull() + }) + + it('returns null — does NOT fall back to older Anthropic message after a 3P turn', () => { + // Mixed-session: user started on Anthropic (real usage), then switched to + // MiMo (zero usage). getCurrentUsage must NOT surface the stale Anthropic numbers. + const anthropicMsg = makeAssistantMessage({ input_tokens: 1000, output_tokens: 200 }) + const mimoMsg = makeAssistantMessage({ input_tokens: 0, output_tokens: 0 }) + const messages: Message[] = [anthropicMsg, makeUserMessage(), mimoMsg] + // Should stop at mimoMsg (all-zero real assistant message) and return null. + expect(getCurrentUsage(messages)).toBeNull() + }) + + it('returns null when there are no assistant messages', () => { + expect(getCurrentUsage([makeUserMessage()])).toBeNull() + }) +}) + describe('tokens', () => { }) diff --git a/src/utils/tokens.ts b/src/utils/tokens.ts index 3f264b331e..c9484fa97b 100644 --- a/src/utils/tokens.ts +++ b/src/utils/tokens.ts @@ -177,6 +177,29 @@ export function getCurrentUsage(messages: Message[]): { cache_read_input_tokens: usage.cache_read_input_tokens ?? 0, } } + // `getTokenUsage` returns undefined for two distinct reasons: + // (a) Not a real assistant message (user message, tool_result, synthetic) → skip, + // continue looking at older messages in the same conversation turn. + // (b) Real assistant message with all-zero usage → the provider didn't report + // usage (e.g. MiMo / Gitlawb OpenGateway with stream_options stripped). + // + // For case (b): stop here and return null. Do NOT fall back to an older + // assistant message — that would surface stale numbers from a previous API + // call, which is especially misleading after a provider switch mid-session. + if ( + message?.type === 'assistant' && + 'usage' in message.message && + message.message.model !== SYNTHETIC_MODEL && + !( + message.message.content[0]?.type === 'text' && + SYNTHETIC_MESSAGES.has(message.message.content[0].text) + ) + ) { + // Confirmed real assistant message — getTokenUsage returned undefined only + // because usage is all-zero (case b). Bail out rather than showing stale data. + return null + } + // Case (a): non-assistant or synthetic — keep walking backwards. } return null } From bc5f2fe9935b08f8c292b53e67647457053b3f74 Mon Sep 17 00:00:00 2001 From: AJ Green Date: Sat, 30 May 2026 21:08:21 -0600 Subject: [PATCH 3/6] fix: move zero-usage guard from getTokenUsage to getCurrentUsage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getTokenUsage returning undefined for all-zero usage broke every shared helper that walks messages backward: tokenCountFromLastAPIResponse, finalContextTokensFromLastResponse, messageTokenCountFromLastAPIResponse, and tokenCountWithEstimation would skip past a MiMo/OpenGateway turn (zero usage) and find stale Anthropic data from the previous provider. This restores getTokenUsage to its original form — it always returns the raw usage object. The all-zero sentinel check is now inlined into getCurrentUsage alone (via getTokenCountFromUsage(usage) === 0), which is the only caller where "provider didn't report usage → show N/A" is the correct behavior. All other callers see the same zero values they saw before this PR, which is harmless and not a regression. The removed case-b fallback block in getCurrentUsage was dead code after this change: getTokenUsage no longer returns undefined for real assistant messages, so the inline message-type guard was never reached. Co-Authored-By: Claude Sonnet 4.6 --- src/utils/tokens.test.ts | 23 +++++++++++------- src/utils/tokens.ts | 51 +++++++++++++--------------------------- 2 files changed, 30 insertions(+), 44 deletions(-) diff --git a/src/utils/tokens.test.ts b/src/utils/tokens.test.ts index 2af3643f6c..43a52608fb 100644 --- a/src/utils/tokens.test.ts +++ b/src/utils/tokens.test.ts @@ -44,16 +44,19 @@ describe('getTokenUsage', () => { expect(result?.output_tokens).toBe(50) }) - it('returns undefined when both input and output tokens are zero', () => { - // Providers that strip stream_options (e.g. MiMo, Gitlawb OpenGateway) - // never include usage in streaming responses. The shim seeds the message - // with {0, 0} and it never gets updated. We return undefined so the - // status line shows N/A instead of a misleading "0% used". + it('returns zero-usage object when both input and output tokens are zero', () => { + // getTokenUsage returns the raw usage regardless of whether tokens are zero. + // Callers that care about "no usage reported" (e.g. getCurrentUsage) must + // check for all-zero themselves — keeping that guard here would break callers + // like tokenCountFromLastAPIResponse that need to walk back past zero records. const msg = makeAssistantMessage({ input_tokens: 0, output_tokens: 0, }) - expect(getTokenUsage(msg)).toBeUndefined() + const result = getTokenUsage(msg) + expect(result).toBeDefined() + expect(result?.input_tokens).toBe(0) + expect(result?.output_tokens).toBe(0) }) it('returns usage when only input_tokens is non-zero', () => { @@ -61,7 +64,6 @@ describe('getTokenUsage', () => { input_tokens: 200, output_tokens: 0, }) - // input_tokens > 0 means real data (partial usage), keep it const result = getTokenUsage(msg) expect(result).toBeDefined() expect(result?.input_tokens).toBe(200) @@ -96,7 +98,10 @@ describe('getCurrentUsage', () => { }) it('returns null when the most recent assistant message has all-zero usage', () => { - // Pure 3P session: provider never reported usage, message stays at {0, 0}. + // Pure 3P session: provider stripped stream_options so usage was never + // reported. The shim seeds the message with {0,0} and it stays there. + // getCurrentUsage detects getTokenCountFromUsage === 0 and returns null + // so the status line shows N/A instead of a misleading "0% used". const msg = makeAssistantMessage({ input_tokens: 0, output_tokens: 0 }) expect(getCurrentUsage([makeUserMessage(), msg])).toBeNull() }) @@ -104,10 +109,10 @@ describe('getCurrentUsage', () => { it('returns null — does NOT fall back to older Anthropic message after a 3P turn', () => { // Mixed-session: user started on Anthropic (real usage), then switched to // MiMo (zero usage). getCurrentUsage must NOT surface the stale Anthropic numbers. + // It stops at the MiMo message (getTokenCountFromUsage === 0) and returns null. const anthropicMsg = makeAssistantMessage({ input_tokens: 1000, output_tokens: 200 }) const mimoMsg = makeAssistantMessage({ input_tokens: 0, output_tokens: 0 }) const messages: Message[] = [anthropicMsg, makeUserMessage(), mimoMsg] - // Should stop at mimoMsg (all-zero real assistant message) and return null. expect(getCurrentUsage(messages)).toBeNull() }) diff --git a/src/utils/tokens.ts b/src/utils/tokens.ts index c9484fa97b..3276194df0 100644 --- a/src/utils/tokens.ts +++ b/src/utils/tokens.ts @@ -28,18 +28,7 @@ export function getTokenUsage(message: Message): Usage | undefined { ) && message.message.model !== SYNTHETIC_MODEL ) { - const usage = message.message.usage - // Providers that strip stream_options (e.g. Xiaomi MiMo, Gitlawb OpenGateway) - // cannot include usage in streaming responses. The shim initialises the message - // with { input_tokens: 0, output_tokens: 0 } and the values stay at zero for - // the whole turn. A real API response always has ≥ 1 output token, so an - // all-zero record is a reliable signal that the provider did not report usage. - // Returning undefined here causes getCurrentUsage() → null → status line - // shows "N/A" instead of a misleading "0% used". - if (usage && usage.input_tokens === 0 && usage.output_tokens === 0) { - return undefined - } - return usage + return message.message.usage } return undefined } @@ -170,6 +159,20 @@ export function getCurrentUsage(messages: Message[]): { const message = messages[i] const usage = message ? getTokenUsage(message) : undefined if (usage) { + // Providers that strip stream_options (e.g. MiMo, Gitlawb OpenGateway) + // cannot include usage in streaming responses. The shim initialises the + // message with {input_tokens:0, output_tokens:0, ...} and those values + // stay at zero for the whole turn. A real API response always has ≥ 1 + // total token, so an all-zero record is a reliable signal that the + // provider did not report usage. + // + // Stop here and return null — do NOT fall back to an older assistant + // message. Falling back would surface stale data from a prior API call, + // which is especially misleading when the user switches providers + // mid-session (e.g. Anthropic → MiMo). + if (getTokenCountFromUsage(usage) === 0) { + return null + } return { input_tokens: usage.input_tokens, output_tokens: usage.output_tokens, @@ -177,29 +180,7 @@ export function getCurrentUsage(messages: Message[]): { cache_read_input_tokens: usage.cache_read_input_tokens ?? 0, } } - // `getTokenUsage` returns undefined for two distinct reasons: - // (a) Not a real assistant message (user message, tool_result, synthetic) → skip, - // continue looking at older messages in the same conversation turn. - // (b) Real assistant message with all-zero usage → the provider didn't report - // usage (e.g. MiMo / Gitlawb OpenGateway with stream_options stripped). - // - // For case (b): stop here and return null. Do NOT fall back to an older - // assistant message — that would surface stale numbers from a previous API - // call, which is especially misleading after a provider switch mid-session. - if ( - message?.type === 'assistant' && - 'usage' in message.message && - message.message.model !== SYNTHETIC_MODEL && - !( - message.message.content[0]?.type === 'text' && - SYNTHETIC_MESSAGES.has(message.message.content[0].text) - ) - ) { - // Confirmed real assistant message — getTokenUsage returned undefined only - // because usage is all-zero (case b). Bail out rather than showing stale data. - return null - } - // Case (a): non-assistant or synthetic — keep walking backwards. + // usage is undefined: non-assistant or synthetic message — keep walking. } return null } From 71f9d84cd190f1d9a17e451087483694c126d992 Mon Sep 17 00:00:00 2001 From: AJ Green Date: Sun, 31 May 2026 13:18:25 -0600 Subject: [PATCH 4/6] fix(openai-shim): only mark hasEmittedFinalUsage when stop chunk has real usage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a stop chunk arrives without chunkUsage and falls back to lastSeenUsage, hasEmittedFinalUsage must NOT be set — a subsequent trailing empty-choices chunk carrying the definitive final token counts should still be allowed to fire. Previously `if (finalUsage)` used the fallback value, preventing the trailing chunk from emitting. Changed to `if (chunkUsage)` so only a stop chunk with real provider-supplied usage marks the flag done. Also updates the existing duplicate-emission test to reflect the corrected behavior and adds two new tests covering the regression scenario. Co-Authored-By: Claude Sonnet 4.6 --- src/services/api/openaiShim.test.ts | 146 +++++++++++++++++++++++++--- src/services/api/openaiShim.ts | 2 +- 2 files changed, 134 insertions(+), 14 deletions(-) diff --git a/src/services/api/openaiShim.test.ts b/src/services/api/openaiShim.test.ts index ea96aba136..44a10d2630 100644 --- a/src/services/api/openaiShim.test.ts +++ b/src/services/api/openaiShim.test.ts @@ -944,29 +944,146 @@ test('preserves usage from early stream chunk when finish_reason chunk has no us expect(usageEvent?.usage?.output_tokens).toBe(11) }) -test('does not emit a second message_delta when lastSeenUsage triggers hasEmittedFinalUsage', async () => { - // When usage arrives in an early chunk, lastSeenUsage is set and the stop - // message_delta uses it (hasEmittedFinalUsage = true). If a trailing - // empty-choices chunk also happens to carry usage (or lastSeenUsage is set), - // the guard `!hasEmittedFinalUsage` must prevent a duplicate message_delta. +test('trailing empty-choices chunk fires when stop chunk used lastSeenUsage fallback', async () => { + // Regression test: previously the stop chunk with lastSeenUsage incorrectly set + // hasEmittedFinalUsage = true, suppressing the trailing chunk. Now only a stop + // chunk with real chunkUsage should set hasEmittedFinalUsage. globalThis.fetch = (async () => { const chunks = makeStreamChunks([ // Early chunk with usage { - id: 'chatcmpl-no-double', + id: 'chatcmpl-trailing-fires', object: 'chat.completion.chunk', model: 'fake-model', choices: [{ index: 0, delta: { content: 'hi' }, finish_reason: null }], usage: { prompt_tokens: 55, completion_tokens: 9, total_tokens: 64 }, }, - // Stop chunk — no usage + // Stop chunk — no chunkUsage, will use lastSeenUsage fallback + { + id: 'chatcmpl-trailing-fires', + object: 'chat.completion.chunk', + model: 'fake-model', + choices: [{ index: 0, delta: {}, finish_reason: 'stop' }], + }, + // Trailing empty-choices chunk with real definitive counts + { + id: 'chatcmpl-trailing-fires', + object: 'chat.completion.chunk', + model: 'fake-model', + choices: [], + usage: { prompt_tokens: 55, completion_tokens: 9, total_tokens: 64 }, + }, + ]) + return makeSseResponse(chunks) + }) as FetchType + + const client = createOpenAIShimClient({}) as OpenAIShimClient + + const result = await client.beta.messages + .create({ + model: 'fake-model', + messages: [{ role: 'user', content: 'hi' }], + max_tokens: 32, + stream: true, + }) + .withResponse() + + const events: Array> = [] + for await (const event of result.data) { + events.push(event) + } + + const messageDeltaEvents = events.filter(e => e.type === 'message_delta') as Array<{ + usage?: { input_tokens?: number; output_tokens?: number } + }> + // Two message_delta events: stop chunk (lastSeenUsage fallback) + trailing chunk (real counts). + expect(messageDeltaEvents).toHaveLength(2) + // The last (trailing) message_delta carries the definitive final counts. + const lastDelta = messageDeltaEvents[messageDeltaEvents.length - 1] + expect(lastDelta.usage?.input_tokens).toBe(55) + expect(lastDelta.usage?.output_tokens).toBe(9) +}) + +test('trailing empty-choices chunk with different real usage supersedes lastSeenUsage fallback', async () => { + // Confirms the trailing chunk's values are used as the definitive answer when + // the stop chunk only had provisional lastSeenUsage (e.g. completion_tokens grew). + globalThis.fetch = (async () => { + const chunks = makeStreamChunks([ + // Early chunk with provisional usage + { + id: 'chatcmpl-trailing-real', + object: 'chat.completion.chunk', + model: 'fake-model', + choices: [{ index: 0, delta: { content: 'hi' }, finish_reason: null }], + usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, + }, + // Stop chunk — no chunkUsage, uses lastSeenUsage (10/5) as fallback + { + id: 'chatcmpl-trailing-real', + object: 'chat.completion.chunk', + model: 'fake-model', + choices: [{ index: 0, delta: {}, finish_reason: 'stop' }], + }, + // Trailing empty-choices chunk with REAL definitive counts (completion grew to 20) + { + id: 'chatcmpl-trailing-real', + object: 'chat.completion.chunk', + model: 'fake-model', + choices: [], + usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 }, + }, + ]) + return makeSseResponse(chunks) + }) as FetchType + + const client = createOpenAIShimClient({}) as OpenAIShimClient + + const result = await client.beta.messages + .create({ + model: 'fake-model', + messages: [{ role: 'user', content: 'hello' }], + max_tokens: 64, + stream: true, + }) + .withResponse() + + const events: Array> = [] + for await (const event of result.data) { + events.push(event) + } + + const messageDeltaEvents = events.filter(e => e.type === 'message_delta') as Array<{ + usage?: { input_tokens?: number; output_tokens?: number } + }> + + // Two message_delta events: stop chunk (fallback 10/5) + trailing chunk (real 10/20). + expect(messageDeltaEvents).toHaveLength(2) + // The last message_delta carries the definitive final counts from the trailing chunk. + const lastDelta = messageDeltaEvents[messageDeltaEvents.length - 1] + expect(lastDelta.usage?.input_tokens).toBe(10) + expect(lastDelta.usage?.output_tokens).toBe(20) +}) + +test('does not emit a second message_delta when stop chunk has real chunkUsage', async () => { + // When the stop chunk itself carries chunkUsage, hasEmittedFinalUsage is set to true + // and any subsequent trailing empty-choices chunk must be suppressed. + globalThis.fetch = (async () => { + const chunks = makeStreamChunks([ + { + id: 'chatcmpl-no-double', + object: 'chat.completion.chunk', + model: 'fake-model', + choices: [{ index: 0, delta: { content: 'hi' }, finish_reason: null }], + }, + // Stop chunk WITH real chunkUsage — hasEmittedFinalUsage should be set { id: 'chatcmpl-no-double', object: 'chat.completion.chunk', model: 'fake-model', choices: [{ index: 0, delta: {}, finish_reason: 'stop' }], + usage: { prompt_tokens: 55, completion_tokens: 9, total_tokens: 64 }, }, - // Trailing empty-choices chunk — would trigger fallback if not guarded + // Trailing empty-choices chunk — must be suppressed (stop chunk had real usage) { id: 'chatcmpl-no-double', object: 'chat.completion.chunk', @@ -994,12 +1111,15 @@ test('does not emit a second message_delta when lastSeenUsage triggers hasEmitte events.push(event) } - const messageDeltaEvents = events.filter(e => e.type === 'message_delta') - // Only one message_delta should be emitted — the one from the stop chunk. + const messageDeltaEvents = events.filter(e => e.type === 'message_delta') as Array<{ + usage?: { input_tokens?: number; output_tokens?: number } + }> + + // Only one message_delta: the stop chunk had real chunkUsage, so hasEmittedFinalUsage + // is set and the trailing chunk is correctly suppressed. expect(messageDeltaEvents).toHaveLength(1) - const delta = messageDeltaEvents[0] as { usage?: { input_tokens?: number; output_tokens?: number } } - expect(delta.usage?.input_tokens).toBe(55) - expect(delta.usage?.output_tokens).toBe(9) + expect(messageDeltaEvents[0].usage?.input_tokens).toBe(55) + expect(messageDeltaEvents[0].usage?.output_tokens).toBe(9) }) test('uses max_tokens instead of max_completion_tokens for local providers', async () => { diff --git a/src/services/api/openaiShim.ts b/src/services/api/openaiShim.ts index c7c6d4daf8..70e02c54bc 100644 --- a/src/services/api/openaiShim.ts +++ b/src/services/api/openaiShim.ts @@ -1773,7 +1773,7 @@ async function* openaiStreamToAnthropic( delta: { stop_reason: stopReason, stop_sequence: null }, ...(finalUsage ? { usage: finalUsage } : {}), } - if (finalUsage) { + if (chunkUsage) { hasEmittedFinalUsage = true } } From ff12bddf7255f42d8793026aca81cdb6460ef65e Mon Sep 17 00:00:00 2001 From: AJ Green Date: Sun, 31 May 2026 17:39:36 -0600 Subject: [PATCH 5/6] fix(openai-shim): prevent double-counting usage when provider emits early provisional + trailing final chunks Providers like MiMo/OpenGateway emit an early chunk with provisional usage data, then a trailing empty-choices chunk with the real final totals. Previously the stop-chunk handler fell back to `lastSeenUsage` when `chunkUsage` was absent, which emitted the provisional counts at stop time; the in-loop post-check then fired again on the trailing chunk and emitted the real final counts -- both flowing into addToTotalSessionCost and doubling the session totals. Fix: - At stop time, only attach usage when the stop chunk itself carries it. - Add a post-stream fallback (after the reader is released) that emits lastSeenUsage once for providers that send usage only in an early chunk with no trailing empty-choices chunk. The existing in-loop trailing-chunk check already handles the trailing-chunk case; the post-stream path closes the gap for providers that omit it. Co-Authored-By: Claude Sonnet 4.6 --- src/services/api/openaiShim.ts | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/src/services/api/openaiShim.ts b/src/services/api/openaiShim.ts index 70e02c54bc..81a94371a2 100644 --- a/src/services/api/openaiShim.ts +++ b/src/services/api/openaiShim.ts @@ -1764,14 +1764,18 @@ async function* openaiStreamToAnthropic( } lastStopReason = stopReason - // Prefer usage from the current stop chunk; fall back to the last - // usage seen anywhere in the stream (handles providers that emit - // usage in an early chunk rather than the finish_reason chunk). - const finalUsage = chunkUsage ?? lastSeenUsage + // Only attach usage when this stop chunk actually carries it. + // Do NOT fall back to lastSeenUsage here: some providers emit an + // early/provisional usage chunk followed by a separate trailing + // empty-choices chunk with the real final totals. Emitting + // lastSeenUsage at stop time AND again at the trailing chunk would + // double-count both in addToTotalSessionCost. The in-loop post check + // below handles trailing chunks; the post-stream fallback handles + // providers that send no trailing chunk at all. yield { type: 'message_delta', delta: { stop_reason: stopReason, stop_sequence: null }, - ...(finalUsage ? { usage: finalUsage } : {}), + ...(chunkUsage ? { usage: chunkUsage } : {}), } if (chunkUsage) { hasEmittedFinalUsage = true @@ -1798,6 +1802,18 @@ async function* openaiStreamToAnthropic( reader.releaseLock() } + // Post-stream fallback: if a provider sent usage only in an early chunk + // (not the stop chunk, not a trailing empty-choices chunk), we still need + // to emit exactly one message_delta with that accumulated usage so the + // status-line and session-cost accounting see it. + if (!hasEmittedFinalUsage && lastSeenUsage && lastStopReason !== null) { + yield { + type: 'message_delta', + delta: { stop_reason: lastStopReason, stop_sequence: null }, + usage: lastSeenUsage, + } + } + const stats = getStreamStats(streamState) if (stats.totalChunks > 0) { logForDebugging( From 6dfc9226c0802cf40712fddd6a85f9edbb1b0b07 Mon Sep 17 00:00:00 2001 From: AJ Green Date: Mon, 1 Jun 2026 08:04:02 -0600 Subject: [PATCH 6/6] docs(status-line): update schema to document unsupported-usage null state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getCurrentUsage() returns null in two distinct cases: 1. No assistant messages yet (existing behavior, already documented) 2. Active provider does not report token usage — e.g. providers that strip stream_options (MiMo, OpenGateway) return all-zero usage records, which getCurrentUsage() treats as unsupported and returns null for. The /statusline setup prompt previously documented only case 1, so generated status-line commands would mislead users into thinking null only means "session not started". Update current_usage, used_percentage, and remaining_percentage inline docs to call out both null triggers. The existing // empty jq fallback in the examples already handles both cases correctly — no example changes needed. Co-Authored-By: Claude Sonnet 4.6 --- src/tools/AgentTool/built-in/statuslineSetup.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/tools/AgentTool/built-in/statuslineSetup.ts b/src/tools/AgentTool/built-in/statuslineSetup.ts index 520248b3e6..a924f9c250 100644 --- a/src/tools/AgentTool/built-in/statuslineSetup.ts +++ b/src/tools/AgentTool/built-in/statuslineSetup.ts @@ -55,14 +55,19 @@ How to use the statusLine command: "total_input_tokens": number, // Total input tokens used in session (cumulative) "total_output_tokens": number, // Total output tokens used in session (cumulative) "context_window_size": number, // Context window size for current model (e.g., 200000) - "current_usage": { // Token usage from last API call (null if no messages yet) + "current_usage": { // Token usage from last API call. + // null when: no messages yet, OR the active provider does not + // report token usage (e.g. providers that strip stream_options + // such as MiMo/OpenGateway). "input_tokens": number, // Input tokens for current context "output_tokens": number, // Output tokens generated "cache_creation_input_tokens": number, // Tokens written to cache "cache_read_input_tokens": number // Tokens read from cache } | null, - "used_percentage": number | null, // Pre-calculated: % of context used (0-100), null if no messages yet - "remaining_percentage": number | null // Pre-calculated: % of context remaining (0-100), null if no messages yet + "used_percentage": number | null, // Pre-calculated: % of context used (0-100). + // null when: no messages yet, OR provider does not report usage. + "remaining_percentage": number | null // Pre-calculated: % of context remaining (0-100). + // null when: no messages yet, OR provider does not report usage. }, "rate_limits": { // Optional: Claude.ai subscription usage limits. Only present for subscribers after first API response. "five_hour": { // Optional: 5-hour session limit (may be absent)