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

Commit 00ac6ea

Browse files
committed
fix: preserve gateway billing metadata
1 parent 5a84860 commit 00ac6ea

6 files changed

Lines changed: 177 additions & 94 deletions

File tree

src/gateway-types.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,3 +225,15 @@ export function addUsage(a: WireUsage, b: WireUsage): WireUsage {
225225
...(costUsd > 0 ? { costUsd } : {}),
226226
};
227227
}
228+
229+
/** Extract the authoritative billed USD amount from gateway metadata events. */
230+
export function costFromProviderMetadata(event: unknown): number | undefined {
231+
if (!event || typeof event !== "object") return undefined;
232+
const providerMetadata = (event as { providerMetadata?: unknown }).providerMetadata;
233+
if (!providerMetadata || typeof providerMetadata !== "object") return undefined;
234+
const gateway = (providerMetadata as { gateway?: unknown }).gateway;
235+
if (!gateway || typeof gateway !== "object") return undefined;
236+
const raw = (gateway as { cost?: unknown }).cost;
237+
const cost = typeof raw === "number" ? raw : typeof raw === "string" ? Number(raw) : NaN;
238+
return Number.isFinite(cost) && cost >= 0 ? cost : undefined;
239+
}

src/gateway.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { spawnSync } from "node:child_process";
88
import { COMMAND_CODE_GATEWAY_VERSION } from "./constants.js";
99
import {
1010
GENERATE_ROUTE,
11+
costFromProviderMetadata,
1112
usageFromFinishEvent,
1213
getApiBaseUrl,
1314
type GenerateBody,
@@ -440,6 +441,8 @@ export async function* streamGenerate(
440441
let finished = false;
441442
let retry = false;
442443
let terminalError = false;
444+
let pendingFinish: Extract<GatewayMappedEvent, { kind: "finish" }> | null = null;
445+
let providerCostUsd: number | undefined;
443446
try {
444447
for await (const line of readNdjsonLines(stream)) {
445448
let parsed: StreamEvent;
@@ -464,6 +467,10 @@ export async function* streamGenerate(
464467
break;
465468
}
466469
}
470+
if (parsed.type === "provider-metadata") {
471+
providerCostUsd = costFromProviderMetadata(parsed) ?? providerCostUsd;
472+
continue;
473+
}
467474
const mapped = mapStreamEvent(parsed);
468475
if (
469476
mapped.kind === "text" ||
@@ -473,10 +480,18 @@ export async function* streamGenerate(
473480
) {
474481
emittedVisible = true;
475482
}
476-
if (mapped.kind === "finish") finished = true;
483+
if (mapped.kind === "finish") {
484+
finished = true;
485+
pendingFinish = mapped;
486+
continue;
487+
}
477488
if (mapped.kind === "error") terminalError = true;
478489
yield mapped;
479490
}
491+
if (pendingFinish) {
492+
if (providerCostUsd !== undefined) pendingFinish.usage.costUsd = providerCostUsd;
493+
yield pendingFinish;
494+
}
480495
} catch (err) {
481496
if (params.signal?.aborted) throw err;
482497
if (!emittedVisible && attempt < MODEL_CALL_MAX_ATTEMPTS - 1) {

src/index.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,40 @@ function zeroCost() {
7777
};
7878
}
7979

80+
function billedCostUsd(value: unknown): number | undefined {
81+
if (!value || typeof value !== "object") return undefined;
82+
const usage = (value as { usage?: unknown }).usage;
83+
if (!usage || typeof usage !== "object") return undefined;
84+
const raw = (usage as { cost_usd?: unknown }).cost_usd;
85+
const cost = typeof raw === "number" ? raw : typeof raw === "string" ? Number(raw) : NaN;
86+
return Number.isFinite(cost) && cost >= 0 ? cost : undefined;
87+
}
88+
89+
/** Bridge Command Code's exact billed USD into OpenCode's supported billed-cost metadata. */
90+
export function commandCodeMetadataExtractor() {
91+
const metadata = (value: unknown) => {
92+
const cost = billedCostUsd(value);
93+
if (cost === undefined) return undefined;
94+
return { copilot: { totalNanoAiu: Math.round(cost * 100_000_000_000) } };
95+
};
96+
return {
97+
async extractMetadata({ parsedBody }: { parsedBody: unknown }) {
98+
return metadata(parsedBody);
99+
},
100+
createStreamExtractor() {
101+
let result: ReturnType<typeof metadata>;
102+
return {
103+
processChunk(chunk: unknown) {
104+
result = metadata(chunk) ?? result;
105+
},
106+
buildMetadata() {
107+
return result;
108+
},
109+
};
110+
},
111+
};
112+
}
113+
80114
function buildProviderModel(
81115
model: CommandModel,
82116
id: string,
@@ -210,6 +244,7 @@ function ensureProviderConfig(
210244
baseURL,
211245
apiKey: "command-code-proxy",
212246
includeUsage: true,
247+
metadataExtractor: commandCodeMetadataExtractor(),
213248
...existingOptions,
214249
},
215250
models: {
@@ -329,6 +364,7 @@ export const CommandCodePlugin: Plugin = async (
329364
return {
330365
baseURL: getCommandProxyBaseUrl(),
331366
apiKey: "command-code-proxy",
367+
metadataExtractor: commandCodeMetadataExtractor(),
332368
async fetch(
333369
requestInput: RequestInfo | URL,
334370
init?: RequestInit,

src/prompt.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,7 @@ export function openaiMessagesToWire(
326326
): { system: string; messages: WireMessage[] } {
327327
const systemParts: string[] = [];
328328
const wire: WireMessage[] = [];
329+
const toolNames = new Map<string, string>();
329330

330331
for (const msg of messages) {
331332
const role = msg.role || "";
@@ -350,6 +351,7 @@ export function openaiMessagesToWire(
350351
const id = tc.id || "";
351352
const name = tc.function?.name || "";
352353
if (!id || !name) continue;
354+
toolNames.set(id, name);
353355
let input: Record<string, unknown> = {};
354356
try {
355357
input = JSON.parse(tc.function?.arguments || "{}") as Record<
@@ -374,13 +376,19 @@ export function openaiMessagesToWire(
374376
if (role === "tool") {
375377
const toolCallId = msg.tool_call_id || "";
376378
if (!toolCallId) continue;
379+
const toolName = msg.name || toolNames.get(toolCallId) || "";
380+
if (!toolName) {
381+
throw new Error(
382+
`Tool result ${toolCallId} has no matching assistant tool call`,
383+
);
384+
}
377385
wire.push({
378386
role: "tool",
379387
content: [
380388
{
381389
type: "tool-result",
382390
toolCallId,
383-
toolName: msg.name ? toWireName(msg.name) : "",
391+
toolName: toWireName(toolName),
384392
output: {
385393
type: "text",
386394
value: extractTextContent(msg.content),

src/proxy.ts

Lines changed: 70 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -619,78 +619,78 @@ function safeParse(raw: string): Record<string, unknown> {
619619
}
620620
}
621621

622-
function streamOpenAIResponse(
622+
function upstreamErrorResponse(message: string, status = 502): Response {
623+
return Response.json(
624+
{
625+
error: {
626+
message,
627+
type: "upstream_error",
628+
code: "provider_error",
629+
},
630+
},
631+
{ status },
632+
);
633+
}
634+
635+
async function streamOpenAIResponse(
623636
events: AsyncIterable<unknown>,
624637
model: string,
625638
stream: boolean,
626639
bridge: ParkedBridge,
627640
includeUsage: boolean,
628-
): Response {
641+
): Promise<Response> {
629642
const completionId = stableCompletionId(bridge.id);
630643
const created = Math.floor(Date.now() / 1000);
631644

632645
if (!stream) {
633-
const bodyStream = new ReadableStream<Uint8Array>({
634-
async start(controller) {
635-
try {
636-
let content = "";
637-
let reasoning = "";
638-
const toolCalls: ParkedToolCall[] = [];
639-
let usage = emptyUsage();
640-
for await (const event of events) {
641-
const mapped = normalizeEvent(event);
642-
if (mapped.kind === "park") toolCalls.push(...mapped.tools);
643-
else if (mapped.kind === "text") content += mapped.text;
644-
else if (mapped.kind === "reasoning") reasoning += mapped.text;
645-
else if (mapped.kind === "finish") usage = mapped.usage;
646-
else if (mapped.kind === "error") content += `\n\n[command-code error] ${mapped.text}`;
647-
}
648-
const payload = {
649-
id: completionId,
650-
object: "chat.completion",
651-
created,
652-
model,
653-
choices: [
654-
{
655-
index: 0,
656-
message: {
657-
role: "assistant",
658-
content,
659-
...(reasoning ? { reasoning_content: reasoning } : {}),
660-
...(toolCalls.length
661-
? {
662-
tool_calls: toolCalls.map((t) => ({
663-
id: t.id,
664-
type: "function",
665-
function: {
666-
name: t.name,
667-
arguments: t.arguments,
668-
},
669-
})),
670-
}
671-
: {}),
672-
},
673-
finish_reason: toolCalls.length ? "tool_calls" : "stop",
674-
},
675-
],
676-
...(includeUsage ? usageChunkFields(usage) : {}),
677-
};
678-
controller.enqueue(
679-
new TextEncoder().encode(JSON.stringify(payload)),
680-
);
681-
} catch (err) {
682-
const message = err instanceof Error ? err.message : String(err);
683-
controller.enqueue(
684-
new TextEncoder().encode(
685-
JSON.stringify({ error: { message, type: "server_error" } }),
686-
),
687-
);
688-
} finally {
689-
controller.close();
646+
let content = "";
647+
let reasoning = "";
648+
const toolCalls: ParkedToolCall[] = [];
649+
let usage = emptyUsage();
650+
try {
651+
for await (const event of events) {
652+
const mapped = normalizeEvent(event);
653+
if (mapped.kind === "park") toolCalls.push(...mapped.tools);
654+
else if (mapped.kind === "text") content += mapped.text;
655+
else if (mapped.kind === "reasoning") reasoning += mapped.text;
656+
else if (mapped.kind === "finish") usage = mapped.usage;
657+
else if (mapped.kind === "error") {
658+
deleteBridge(bridge.id);
659+
return upstreamErrorResponse(mapped.text);
690660
}
691-
},
692-
});
693-
return new Response(bodyStream, {
661+
}
662+
} catch (err) {
663+
deleteBridge(bridge.id);
664+
return upstreamErrorResponse(err instanceof Error ? err.message : String(err));
665+
}
666+
const payload = {
667+
id: completionId,
668+
object: "chat.completion",
669+
created,
670+
model,
671+
choices: [
672+
{
673+
index: 0,
674+
message: {
675+
role: "assistant",
676+
content,
677+
...(reasoning ? { reasoning_content: reasoning } : {}),
678+
...(toolCalls.length
679+
? {
680+
tool_calls: toolCalls.map((t) => ({
681+
id: t.id,
682+
type: "function",
683+
function: { name: t.name, arguments: t.arguments },
684+
})),
685+
}
686+
: {}),
687+
},
688+
finish_reason: toolCalls.length ? "tool_calls" : "stop",
689+
},
690+
],
691+
...(includeUsage ? usageChunkFields(usage) : {}),
692+
};
693+
return new Response(JSON.stringify(payload), {
694694
headers: { "Content-Type": "application/json" },
695695
});
696696
}
@@ -787,38 +787,18 @@ function streamOpenAIResponse(
787787
usage = mapped.usage;
788788
}
789789
if (mapped.kind === "error") {
790-
send({
791-
id: completionId,
792-
object: "chat.completion.chunk",
793-
created,
794-
model,
795-
choices: [
796-
{
797-
index: 0,
798-
delta: {
799-
content: `\n\n[command-code error] ${mapped.text}`,
800-
},
801-
finish_reason: null,
802-
},
803-
],
804-
});
790+
deleteBridge(bridge.id);
791+
send({ error: { message: mapped.text, type: "upstream_error", code: "provider_error" } });
792+
controller.close();
793+
return;
805794
}
806795
}
807796
} catch (err) {
808797
const message = err instanceof Error ? err.message : String(err);
809-
send({
810-
id: completionId,
811-
object: "chat.completion.chunk",
812-
created,
813-
model,
814-
choices: [
815-
{
816-
index: 0,
817-
delta: { content: `\n\n[command-code error] ${message}` },
818-
finish_reason: null,
819-
},
820-
],
821-
});
798+
deleteBridge(bridge.id);
799+
send({ error: { message, type: "upstream_error", code: "provider_error" } });
800+
controller.close();
801+
return;
822802
}
823803

824804
send({

0 commit comments

Comments
 (0)