Skip to content
This repository was archived by the owner on Aug 21, 2026. It is now read-only.

Commit 8170c31

Browse files
committed
Fix token + cost accounting in OpenAI usage
- prompt_tokens is now inclusive of cache read/write (OpenAI contract); OpenCode derives the non-cached count by subtracting the details - surface cost_usd from the gateway finish event, with an estimated fallback from advertised $/1M rates when the gateway omits it - parse advertised $/1M rates from models.md and set provider model.cost (was zeroCost), so OpenCode computes real message costs - forward usage before parking tool-call turns instead of emitting prompt_tokens:0 that overwrote OpenCode's session meter - drop empty usage chunks (zero prompt+completion) instead of emitting them
1 parent f839cfa commit 8170c31

8 files changed

Lines changed: 288 additions & 40 deletions

File tree

src/gateway-types.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,9 @@ export type WireUsage = {
5454
outputTokens: number;
5555
cacheReadTokens: number;
5656
cacheWriteTokens: number;
57+
reasoningTokens?: number;
58+
/** Estimated USD from the gateway finish event, when provided. */
59+
costUsd?: number;
5760
};
5861

5962
export type GenerateBody = {
@@ -124,11 +127,23 @@ export type StreamEvent =
124127
totalUsage?: {
125128
inputTokens?: number;
126129
outputTokens?: number;
130+
reasoningTokens?: number;
131+
cachedInputTokens?: number;
132+
costUSD?: number;
133+
costUsd?: number;
134+
cost_usd?: number;
135+
total_cost_usd?: number;
136+
totalCostUSD?: number;
137+
cost?: number;
127138
inputTokenDetails?: {
128139
cacheReadTokens?: number;
129140
cacheWriteTokens?: number;
130141
};
131142
};
143+
costUSD?: number;
144+
costUsd?: number;
145+
cost_usd?: number;
146+
total_cost_usd?: number;
132147
systemPromptTokens?: number;
133148
}
134149
| { type: "error"; error?: unknown }
@@ -144,11 +159,69 @@ export function emptyUsage(): WireUsage {
144159
};
145160
}
146161

162+
function asFiniteNumber(value: unknown): number {
163+
return typeof value === "number" && Number.isFinite(value) ? value : 0;
164+
}
165+
166+
function costFromUnknown(...candidates: unknown[]): number {
167+
for (const candidate of candidates) {
168+
if (!candidate || typeof candidate !== "object") continue;
169+
const o = candidate as Record<string, unknown>;
170+
const cost =
171+
asFiniteNumber(o.costUSD) ||
172+
asFiniteNumber(o.costUsd) ||
173+
asFiniteNumber(o.cost_usd) ||
174+
asFiniteNumber(o.total_cost_usd) ||
175+
asFiniteNumber(o.totalCostUSD) ||
176+
asFiniteNumber(o.cost);
177+
if (cost > 0) return cost;
178+
}
179+
return 0;
180+
}
181+
182+
/** Map a gateway `finish` event's usage/cost fields onto WireUsage. */
183+
export function usageFromFinishEvent(event: {
184+
totalUsage?: {
185+
inputTokens?: number;
186+
outputTokens?: number;
187+
reasoningTokens?: number;
188+
cachedInputTokens?: number;
189+
inputTokenDetails?: {
190+
cacheReadTokens?: number;
191+
cacheWriteTokens?: number;
192+
};
193+
[key: string]: unknown;
194+
};
195+
[key: string]: unknown;
196+
}): WireUsage {
197+
const usage = emptyUsage();
198+
const total = event.totalUsage;
199+
if (total) {
200+
usage.inputTokens = asFiniteNumber(total.inputTokens);
201+
usage.outputTokens = asFiniteNumber(total.outputTokens);
202+
usage.cacheReadTokens =
203+
asFiniteNumber(total.inputTokenDetails?.cacheReadTokens) ||
204+
asFiniteNumber(total.cachedInputTokens);
205+
usage.cacheWriteTokens = asFiniteNumber(
206+
total.inputTokenDetails?.cacheWriteTokens,
207+
);
208+
const reasoning = asFiniteNumber(total.reasoningTokens);
209+
if (reasoning > 0) usage.reasoningTokens = reasoning;
210+
}
211+
const cost = costFromUnknown(total, event);
212+
if (cost > 0) usage.costUsd = cost;
213+
return usage;
214+
}
215+
147216
export function addUsage(a: WireUsage, b: WireUsage): WireUsage {
217+
const reasoningTokens = (a.reasoningTokens ?? 0) + (b.reasoningTokens ?? 0);
218+
const costUsd = (a.costUsd ?? 0) + (b.costUsd ?? 0);
148219
return {
149220
inputTokens: a.inputTokens + b.inputTokens,
150221
outputTokens: a.outputTokens + b.outputTokens,
151222
cacheReadTokens: a.cacheReadTokens + b.cacheReadTokens,
152223
cacheWriteTokens: a.cacheWriteTokens + b.cacheWriteTokens,
224+
...(reasoningTokens > 0 ? { reasoningTokens } : {}),
225+
...(costUsd > 0 ? { costUsd } : {}),
153226
};
154227
}

src/gateway.ts

Lines changed: 2 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { basename } from "node:path";
77
import { spawnSync } from "node:child_process";
88
import {
99
GENERATE_ROUTE,
10-
emptyUsage,
10+
usageFromFinishEvent,
1111
getApiBaseUrl,
1212
type GenerateBody,
1313
type StreamEvent,
@@ -340,25 +340,7 @@ export function mapStreamEvent(event: StreamEvent): GatewayMappedEvent {
340340
};
341341
}
342342
case "finish": {
343-
const usage = emptyUsage();
344-
const total = event.totalUsage as
345-
| {
346-
inputTokens?: number;
347-
outputTokens?: number;
348-
inputTokenDetails?: {
349-
cacheReadTokens?: number;
350-
cacheWriteTokens?: number;
351-
};
352-
}
353-
| undefined;
354-
if (total) {
355-
usage.inputTokens = total.inputTokens ?? 0;
356-
usage.outputTokens = total.outputTokens ?? 0;
357-
usage.cacheReadTokens =
358-
total.inputTokenDetails?.cacheReadTokens ?? 0;
359-
usage.cacheWriteTokens =
360-
total.inputTokenDetails?.cacheWriteTokens ?? 0;
361-
}
343+
const usage = usageFromFinishEvent(event);
362344
const finishReason =
363345
event.finishReason === "tool-calls"
364346
? "tool_calls"

src/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@ function buildProviderModel(
120120
input: model.vision ? ["text", "image"] : ["text"],
121121
output: ["text"],
122122
},
123-
cost: zeroCost(),
123+
cost: model.cost ?? zeroCost(),
124124
limit: {
125125
context: model.contextWindow,
126126
output: model.maxTokens,
@@ -158,6 +158,7 @@ function buildConfigModelEntry(model: CommandModel): Record<string, unknown> {
158158
includeUsage: true,
159159
},
160160
variants,
161+
cost: model.cost ?? zeroCost(),
161162
};
162163
}
163164

src/model-discover.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,12 @@ import { isCommandEffort, LAGUNA_MODEL_ID } from "./constants.js";
1111
import { resolveCommandCodeExecutable } from "./executable-path.js";
1212
import { log } from "./log.js";
1313

14+
export type ModelCostRates = {
15+
input: number;
16+
output: number;
17+
cache: { read: number; write: number };
18+
};
19+
1420
export type DiscoveredModelMeta = {
1521
id: string;
1622
name: string;
@@ -21,8 +27,33 @@ export type DiscoveredModelMeta = {
2127
free: boolean;
2228
efforts: CommandEffort[];
2329
reasoning: boolean;
30+
/** Advertised $/1M rates from Command Code models.md, when present. */
31+
cost?: ModelCostRates;
2432
};
2533

34+
/**
35+
* Parse advertised `$in/$out · cache $read (write $write)` rates from a
36+
* models.md table row. Write is optional and defaults to 0.
37+
*/
38+
export function parseAdvertisedCost(line: string): ModelCostRates | undefined {
39+
const m = line.match(
40+
/\$([0-9]*\.?[0-9]+)\/\$([0-9]*\.?[0-9]+)\s*[·*]\s*cache\s*\$([0-9]*\.?[0-9]+)(?:\s*\(write\s*\$([0-9]*\.?[0-9]+)\))?/i,
41+
);
42+
if (!m) return undefined;
43+
const input = Number(m[1]);
44+
const output = Number(m[2]);
45+
const read = Number(m[3]);
46+
const write = m[4] != null ? Number(m[4]) : 0;
47+
if (![input, output, read, write].every((n) => Number.isFinite(n))) {
48+
return undefined;
49+
}
50+
return {
51+
input,
52+
output,
53+
cache: { read, write },
54+
};
55+
}
56+
2657
/**
2758
* Command Code deliberately models vision support as an exclusion list. Read
2859
* that list from the installed CLI bundle so our capabilities stay in lockstep
@@ -152,6 +183,7 @@ export function parseModelsMarkdown(md: string): Map<string, DiscoveredModelMeta
152183
const vision =
153184
/vision|multimodal|image/i.test(line) ||
154185
/kimi|minimax|gemini|inkling|step-/i.test(id);
186+
const cost = parseAdvertisedCost(line);
155187
map.set(id, {
156188
id,
157189
name,
@@ -161,6 +193,7 @@ export function parseModelsMarkdown(md: string): Map<string, DiscoveredModelMeta
161193
free,
162194
efforts,
163195
reasoning: efforts.length > 0,
196+
...(cost ? { cost } : {}),
164197
});
165198
// Case-insensitive alias for CLI lowercase ids
166199
map.set(id.toLowerCase(), map.get(id)!);
@@ -290,6 +323,7 @@ export function discoverCommandModels(): DiscoveredModelMeta[] {
290323
free,
291324
efforts: enrich?.efforts ?? [],
292325
reasoning: enrich ? enrich.reasoning : true,
326+
...(enrich?.cost ? { cost: enrich.cost } : {}),
293327
});
294328
}
295329

@@ -315,6 +349,7 @@ export function discoverCommandModels(): DiscoveredModelMeta[] {
315349
free: true,
316350
efforts: [],
317351
reasoning: true,
352+
cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
318353
});
319354
}
320355

src/models.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,12 @@ import {
1212
import {
1313
discoverCommandModels,
1414
type DiscoveredModelMeta,
15+
type ModelCostRates,
1516
} from "./model-discover.js";
1617
import { log } from "./log.js";
1718

19+
export type { ModelCostRates };
20+
1821
export type CommandModel = {
1922
id: string;
2023
name: string;
@@ -28,6 +31,8 @@ export type CommandModel = {
2831
free?: boolean;
2932
/** Supported effort levels from Command Code (empty → model decides). */
3033
efforts?: CommandEffort[];
34+
/** Advertised $/1M token rates (OpenCode `model.cost`). */
35+
cost?: ModelCostRates;
3136
};
3237

3338
const CATALOG_TTL_MS = 30 * 60 * 1000;
@@ -69,6 +74,7 @@ function metaToModel(meta: DiscoveredModelMeta): CommandModel {
6974
vision: meta.vision,
7075
free: meta.free || undefined,
7176
efforts: meta.efforts.length ? meta.efforts : undefined,
77+
cost: meta.cost,
7278
};
7379
}
7480

src/proxy.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,9 @@ import {
4646
recordToolCall,
4747
recordTurnUsage,
4848
totalUsageAcrossSessions,
49+
usageChunkFields,
4950
usageToOpenAI,
51+
withEstimatedCost,
5052
} from "./usage.js";
5153

5254
const DEFAULT_PROXY_PORT = 8797;
@@ -471,7 +473,7 @@ async function handleChatCompletions(
471473
continue;
472474
}
473475
if (event.kind === "finish") {
474-
turnUsage = event.usage;
476+
turnUsage = withEstimatedCost(event.usage, modelMeta.cost);
475477
systemPromptTokens = event.systemPromptTokens;
476478
finishReason = event.finishReason;
477479
recordTurnUsage(
@@ -519,6 +521,14 @@ async function handleChatCompletions(
519521
pendingTools.clear();
520522
yield* runTurn(resumed, tools, null);
521523
};
524+
// Forward this turn's usage before parking. Omitting it emits
525+
// prompt_tokens:0 and OpenCode overwrites the session meter.
526+
yield {
527+
kind: "finish",
528+
finishReason,
529+
usage: turnUsage,
530+
systemPromptTokens,
531+
} satisfies GatewayMappedEvent;
522532
yield { type: "__park__", tools: assistantToolCalls };
523533
return;
524534
}
@@ -534,6 +544,7 @@ async function handleChatCompletions(
534544
}
535545

536546
// Stream ended without explicit finish.
547+
turnUsage = withEstimatedCost(turnUsage, modelMeta.cost);
537548
recordTurnUsage(
538549
conversationKey,
539550
model,
@@ -624,7 +635,7 @@ function streamOpenAIResponse(
624635
finish_reason: toolCalls.length ? "tool_calls" : "stop",
625636
},
626637
],
627-
...(includeUsage ? { usage: usageToOpenAI(usage) } : {}),
638+
...(includeUsage ? usageChunkFields(usage) : {}),
628639
};
629640
controller.enqueue(
630641
new TextEncoder().encode(JSON.stringify(payload)),
@@ -778,7 +789,7 @@ function streamOpenAIResponse(
778789
created,
779790
model,
780791
choices: [{ index: 0, delta: {}, finish_reason: finishReason }],
781-
...(includeUsage ? { usage: usageToOpenAI(usage) } : {}),
792+
...(includeUsage ? usageChunkFields(usage) : {}),
782793
});
783794
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
784795
controller.close();

0 commit comments

Comments
 (0)