From 29ffae048a3a7a1625344eb6be52823b0dab1bf0 Mon Sep 17 00:00:00 2001 From: "Mohamed R. Khalil" Date: Thu, 3 Sep 2026 19:59:20 +0300 Subject: [PATCH 01/18] fix: report per-turn usage across compaction continuations --- src/bridge-pool.ts | 3 + src/proxy.ts | 41 +++++-- src/usage.ts | 70 +++++++++++ test/usage-regression.ts | 243 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 346 insertions(+), 11 deletions(-) create mode 100644 test/usage-regression.ts diff --git a/src/bridge-pool.ts b/src/bridge-pool.ts index 9259084..b880d66 100644 --- a/src/bridge-pool.ts +++ b/src/bridge-pool.ts @@ -3,6 +3,7 @@ * (Cursor bridge-pool pattern). */ import type { ClaudeQueryHandle } from "./query.js"; +import type { OpenAIUsage } from "./usage.js"; export type ParkedToolCall = { id: string; @@ -19,6 +20,8 @@ export type ParkedBridge = { pendingTools: Map; /** SDK assistant messages whose usage was already reported to OpenCode. */ seenAssistantUsageIds: Set; + /** Latest assistant usage, retained for replay-only tool continuations. */ + lastAssistantUsage?: OpenAIUsage; createdAt: number; /** Continues consuming the SDK stream after tools resolve. */ continueStream?: () => AsyncGenerator; diff --git a/src/proxy.ts b/src/proxy.ts index 8aae174..53ba639 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -67,11 +67,13 @@ import { requestKeyNamespace, } from "./request-kind.js"; import { - addUniqueAssistantUsage, + addUniqueAssistantUsageState, formatCompactNote, - resolveTurnUsage, + resolveOpenCodeUsage, usageFromAssistantEvent, usageFromSdkResult, + usageFromSdkTurnResult, + type AssistantUsageState, type OpenAIUsage, } from "./usage.js"; @@ -922,7 +924,10 @@ async function collectTurnResponse( let content = ""; let reasoning = ""; - let turnUsage: OpenAIUsage | null = null; + let usageState: AssistantUsageState = { + aggregate: null, + latest: bridge.lastAssistantUsage ?? null, + }; let resultUsage: OpenAIUsage | null = null; let lastErrorNorm: string | null = null; let errorText: string | null = null; @@ -949,12 +954,13 @@ async function collectTurnResponse( } else if (mapped.kind === "reasoning") { if (!suppressReasoning) reasoning += mapped.text; } else if (mapped.kind === "usage-delta") { - turnUsage = addUniqueAssistantUsage( - turnUsage, + usageState = addUniqueAssistantUsageState( + usageState, mapped.usage, mapped.messageId, bridge.seenAssistantUsageIds, ); + if (usageState.latest) bridge.lastAssistantUsage = usageState.latest; } else if (mapped.kind === "usage") { resultUsage = mapped.usage; } else if (mapped.kind === "error") { @@ -972,7 +978,7 @@ async function collectTurnResponse( noteError(message); } - const usage = resolveTurnUsage(turnUsage, resultUsage); + const usage = resolveOpenCodeUsage(usageState, resultUsage); // Buffered responses have not committed HTTP headers yet. Even if an agent // produced partial work first, preserve the real 429 so OpenCode starts its @@ -1248,7 +1254,10 @@ function streamOpenAIResponse( }); let finishReason: string | null = "stop"; - let turnUsage: OpenAIUsage | null = null; + let usageState: AssistantUsageState = { + aggregate: null, + latest: bridge.lastAssistantUsage ?? null, + }; let resultUsage: OpenAIUsage | null = null; let lastErrorNorm: string | null = null; const sendError = (text: string) => { @@ -1362,12 +1371,13 @@ function streamOpenAIResponse( } if (mapped.kind === "usage-delta") { - turnUsage = addUniqueAssistantUsage( - turnUsage, + usageState = addUniqueAssistantUsageState( + usageState, mapped.usage, mapped.messageId, bridge.seenAssistantUsageIds, ); + if (usageState.latest) bridge.lastAssistantUsage = usageState.latest; } if (mapped.kind === "usage") { @@ -1401,7 +1411,7 @@ function streamOpenAIResponse( finishReason = "stop"; } - const usage = resolveTurnUsage(turnUsage, resultUsage); + const usage = resolveOpenCodeUsage(usageState, resultUsage); if (!streamClosed) { send({ id: completionId, @@ -1586,7 +1596,16 @@ function mapSdkEvent(event: unknown): MappedEvent { } if (e.type === "result") { - const usage = usageFromSdkResult(event); + const turnUsage = usageFromSdkTurnResult(event); + const accountingUsage = usageFromSdkResult(event); + const usage = turnUsage + ? { + ...turnUsage, + ...(accountingUsage?.model_usage !== undefined + ? { model_usage: accountingUsage.model_usage } + : {}), + } + : null; if (e.is_error) { const text = typeof e.result === "string" diff --git a/src/usage.ts b/src/usage.ts index 49b6852..89e8866 100644 --- a/src/usage.ts +++ b/src/usage.ts @@ -30,6 +30,12 @@ export type OpenAIUsage = { context_window?: number; } >; + aggregate_usage?: OpenAIUsage; +}; + +export type AssistantUsageState = { + readonly aggregate: OpenAIUsage | null; + readonly latest: OpenAIUsage | null; }; function asNumber(value: unknown): number { @@ -146,6 +152,23 @@ export function usageFromSdkResult(event: unknown): OpenAIUsage | null { return null; } +/** Extract the per-turn usage snapshot from an Agent SDK `result` event. */ +export function usageFromSdkTurnResult(event: unknown): OpenAIUsage | null { + if (!event || typeof event !== "object") return null; + const e = event as Record; + if (e.type !== "result") return null; + if (!e.usage || typeof e.usage !== "object") return null; + + const usage = fromAnthropicUsage(e.usage as Record); + if ( + typeof e.total_cost_usd === "number" && + Number.isFinite(e.total_cost_usd) + ) { + usage.cost_usd = e.total_cost_usd; + } + return usage; +} + /** * Extract per-API-call usage from an Agent SDK `assistant` event * (`message.usage`). Each assistant event carries the usage of exactly one @@ -210,6 +233,22 @@ export function addUniqueAssistantUsage( return addOpenAIUsage(acc, delta); } +export function addUniqueAssistantUsageState( + state: AssistantUsageState, + delta: OpenAIUsage, + messageId: string | null, + seen: Set, +): AssistantUsageState { + if (messageId) { + if (seen.has(messageId)) return state; + seen.add(messageId); + } + return { + aggregate: addOpenAIUsage(state.aggregate, delta), + latest: delta, + }; +} + /** * Combine the per-response accumulated usage (one entry per Anthropic API * call seen during this HTTP response) with the SDK `result` snapshot. @@ -237,6 +276,37 @@ export function resolveTurnUsage( }; } +export function resolveOpenCodeUsage( + state: AssistantUsageState, + result: OpenAIUsage | null, +): OpenAIUsage | null { + const current = state.latest ?? state.aggregate ?? result; + if (!current) return null; + const aggregate = state.aggregate; + const aggregateDiffers = + aggregate !== null && + state.latest !== null && + (aggregate.prompt_tokens !== state.latest.prompt_tokens || + aggregate.completion_tokens !== state.latest.completion_tokens || + aggregate.total_tokens !== state.latest.total_tokens || + aggregate.prompt_tokens_details?.cached_tokens !== + state.latest.prompt_tokens_details?.cached_tokens || + aggregate.prompt_tokens_details?.cache_write_tokens !== + state.latest.prompt_tokens_details?.cache_write_tokens); + return { + ...current, + ...(current.cost_usd === undefined && result?.cost_usd !== undefined + ? { cost_usd: result.cost_usd } + : {}), + ...(current.model_usage === undefined && result?.model_usage !== undefined + ? { model_usage: result.model_usage } + : {}), + ...(aggregateDiffers + ? { aggregate_usage: aggregate } + : {}), + }; +} + export function formatCompactNote(meta: unknown): string { if (!meta || typeof meta !== "object") { return "[compact] Conversation compacted.\n"; diff --git a/test/usage-regression.ts b/test/usage-regression.ts new file mode 100644 index 0000000..f0c5233 --- /dev/null +++ b/test/usage-regression.ts @@ -0,0 +1,243 @@ +import assert from "node:assert/strict"; +import { + addUniqueAssistantUsageState, + resolveOpenCodeUsage, + usageFromSdkResult, + usageFromSdkTurnResult, +} from "../src/usage.ts"; + +const firstContext = { + prompt_tokens: 899_900, + completion_tokens: 100, + total_tokens: 900_000, + prompt_tokens_details: { cached_tokens: 899_000 }, +}; +const secondContext = { + prompt_tokens: 949_900, + completion_tokens: 100, + total_tokens: 950_000, + prompt_tokens_details: { cached_tokens: 949_000 }, +}; +const seen = new Set(); +const firstState = addUniqueAssistantUsageState( + { aggregate: null, latest: null }, + firstContext, + "sdk-context-1", + seen, +); +const secondState = addUniqueAssistantUsageState( + firstState, + secondContext, + "sdk-context-2", + seen, +); +const contextUsage = resolveOpenCodeUsage(secondState, null); + +assert.equal(contextUsage?.total_tokens, secondContext.total_tokens); +assert.equal( + contextUsage?.aggregate_usage?.total_tokens, + firstContext.total_tokens + secondContext.total_tokens, +); +const replayOnlyUsage = resolveOpenCodeUsage( + { aggregate: null, latest: firstContext }, + { + prompt_tokens: 1_850_000, + completion_tokens: 200, + total_tokens: 1_850_200, + }, +); +assert.equal(replayOnlyUsage?.total_tokens, firstContext.total_tokens); +assert.deepEqual( + addUniqueAssistantUsageState( + secondState, + firstContext, + "sdk-context-1", + seen, + ), + secondState, +); +const continuationSeen = new Set(["sdk-context-1"]); +const replayedContinuation = addUniqueAssistantUsageState( + { aggregate: null, latest: firstContext }, + firstContext, + "sdk-context-1", + continuationSeen, +); +const continuedState = addUniqueAssistantUsageState( + replayedContinuation, + secondContext, + "sdk-context-2", + continuationSeen, +); +assert.equal(continuedState.latest?.total_tokens, secondContext.total_tokens); +assert.equal( + continuedState.aggregate?.total_tokens, + secondContext.total_tokens, +); + +const singleState = addUniqueAssistantUsageState( + { aggregate: null, latest: null }, + firstContext, + "sdk-single", + new Set(), +); +assert.equal( + resolveOpenCodeUsage(singleState, null)?.aggregate_usage, + undefined, +); + +const resultEvent = { + type: "result", + usage: { + input_tokens: 900, + output_tokens: 100, + cache_read_input_tokens: 949_000, + }, + modelUsage: { + sonnet: { + inputTokens: 1_800, + outputTokens: 200, + cacheReadInputTokens: 1_848_000, + cacheCreationInputTokens: 0, + costUSD: 0.42, + }, + }, +}; +assert.equal(usageFromSdkResult(resultEvent)?.total_tokens, 1_850_000); +assert.equal(usageFromSdkTurnResult(resultEvent)?.total_tokens, 950_000); + +const { + getClaudeProxyBaseUrl, + setClaudeQueryStarter, + startProxy, + stopProxy, +} = await import("../src/proxy.ts"); +const proxyEvents = [ + { + type: "assistant", + message: { + id: "sdk-proxy-1", + usage: { + input_tokens: 900, + output_tokens: 100, + cache_read_input_tokens: 899_000, + }, + }, + }, + { + type: "stream_event", + event: { + type: "content_block_delta", + delta: { type: "text_delta", text: "ok" }, + }, + }, + { + type: "assistant", + message: { + id: "sdk-proxy-2", + usage: { + input_tokens: 900, + output_tokens: 100, + cache_read_input_tokens: 949_000, + }, + }, + }, + { type: "result", is_error: false, usage: {} }, +]; +setClaudeQueryStarter(async () => ({ + stream: (async function* () { + for (const event of proxyEvents) yield event; + })(), + interrupt: async () => {}, + close: () => {}, + getPid: () => null, +})); +await startProxy(); +try { + const request = async (stream: boolean, suffix: string): Promise => + fetch(`${getClaudeProxyBaseUrl()}/chat/completions`, { + method: "POST", + headers: { + "content-type": "application/json", + "x-opencode-claude-session": `usage-regression-${suffix}`, + }, + body: JSON.stringify({ + model: "sonnet", + stream, + messages: [{ role: "user", content: "measure usage" }], + }), + }); + + const bufferedResponse = await request(false, "buffered"); + assert.equal(bufferedResponse.status, 200); + const bufferedUsage = (await bufferedResponse.json()) as { + usage?: { + total_tokens?: number; + aggregate_usage?: { total_tokens?: number }; + }; + }; + assert.equal(bufferedUsage.usage?.total_tokens, 950_000); + assert.equal(bufferedUsage.usage?.aggregate_usage?.total_tokens, 1_850_000); + + const streamingResponse = await request(true, "streaming"); + assert.equal(streamingResponse.status, 200); + const payloads = (await streamingResponse.text()) + .split("\n\n") + .flatMap((block) => { + const line = block + .split("\n") + .find((candidate) => candidate.startsWith("data: ")); + return line && line !== "data: [DONE]" + ? [ + JSON.parse(line.slice("data: ".length)) as { + usage?: { + total_tokens?: number; + aggregate_usage?: { total_tokens?: number }; + }; + }, + ] + : []; + }); + const usage = payloads.find((payload) => payload.usage)?.usage; + assert.equal(usage?.total_tokens, 950_000); + assert.equal(usage?.aggregate_usage?.total_tokens, 1_850_000); + + setClaudeQueryStarter(async () => ({ + stream: (async function* () { + yield { + type: "stream_event", + event: { + type: "content_block_delta", + delta: { type: "text_delta", text: "fallback" }, + }, + }; + yield resultEvent; + })(), + interrupt: async () => {}, + close: () => {}, + getPid: () => null, + })); + const fallbackResponse = await request(true, "result-fallback"); + assert.equal(fallbackResponse.status, 200); + const fallbackPayloads = (await fallbackResponse.text()) + .split("\n\n") + .flatMap((block) => { + const line = block + .split("\n") + .find((candidate) => candidate.startsWith("data: ")); + return line && line !== "data: [DONE]" + ? [ + JSON.parse(line.slice("data: ".length)) as { + usage?: { total_tokens?: number }; + }, + ] + : []; + }); + assert.equal( + fallbackPayloads.find((payload) => payload.usage)?.usage?.total_tokens, + 950_000, + ); +} finally { + setClaudeQueryStarter(null); + await stopProxy(); +} From 994db2b40e6bc802b29348c7b563793aacdfe66b Mon Sep 17 00:00:00 2001 From: "Mohamed R. Khalil" Date: Thu, 3 Sep 2026 20:00:35 +0300 Subject: [PATCH 02/18] fix: classify OpenCode summary updates as metadata --- src/request-kind.ts | 4 ++++ test/request-kind-regression.ts | 34 +++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+) create mode 100644 test/request-kind-regression.ts diff --git a/src/request-kind.ts b/src/request-kind.ts index de5296b..2a70b75 100644 --- a/src/request-kind.ts +++ b/src/request-kind.ts @@ -11,6 +11,9 @@ type MessageLike = { content?: unknown; }; +const OPENCODE_UPDATE_SUMMARY_PATTERN = + /here is the conversation so far:\s*[\s\S]*?<\/conversation>\s*here is the summary of the conversation before the above:\s*[\s\S]*?<\/prior-summary>\s*the summarizes everything that happened before the \. construct a new summary that combines both\.\s*output exactly the markdown structure shown inside