From 5eee4e83a6530552d0bc966d91c24266f8cd5006 Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Thu, 5 Nov 2026 16:08:23 +0000 Subject: [PATCH] feat(ai): report GitHub Copilot billing as usage cost --- .../ai/src/protocols/anthropic-messages.ts | 14 +++++++--- packages/ai/src/protocols/open-responses.ts | 11 +++++--- packages/ai/src/protocols/openai-chat.ts | 12 ++++++--- packages/ai/src/protocols/shared.ts | 27 ++++++++++++++----- .../test/provider/anthropic-messages.test.ts | 23 ++++++++++++++++ packages/ai/test/provider/openai-chat.test.ts | 17 ++++++++++++ .../openai-compatible-responses.test.ts | 19 +++++++++++++ packages/core/src/session/usage.ts | 1 - 8 files changed, 107 insertions(+), 17 deletions(-) diff --git a/packages/ai/src/protocols/anthropic-messages.ts b/packages/ai/src/protocols/anthropic-messages.ts index 5fd3b33b96a..0fa7a6cb27a 100644 --- a/packages/ai/src/protocols/anthropic-messages.ts +++ b/packages/ai/src/protocols/anthropic-messages.ts @@ -1032,7 +1032,11 @@ const mapFinishReason = (reason: string | null | undefined): FinishReason => { // inclusive `inputTokens` the rest of the contract expects. Extended // thinking tokens are included in `output_tokens`; newer responses also // expose that subset through `output_tokens_details.thinking_tokens`. -const mapUsage = (usage: AnthropicUsage | undefined, providerMetadataKey: string): Usage | undefined => { +const mapUsage = ( + usage: AnthropicUsage | undefined, + providerMetadataKey: string, + frame?: AnthropicEvent, +): Usage | undefined => { if (!usage) return undefined const iterations = usage.iterations?.length ? usage.iterations : [usage] const last = usage.iterations?.at(-1) @@ -1059,7 +1063,7 @@ const mapUsage = (usage: AnthropicUsage | undefined, providerMetadataKey: string cacheWriteInputTokens: cacheWrite, reasoningTokens: ProviderShared.sumTokens(...iterations.map((item) => item.output_tokens_details?.thinking_tokens)), totalTokens: ProviderShared.totalTokens(inputTokens, outputTokens, undefined), - ...ProviderShared.reportedBilling(usage), + ...ProviderShared.reportedBilling(usage, frame), providerMetadata: { [providerMetadataKey]: usage }, }) } @@ -1331,7 +1335,11 @@ const onMessageDelta = ( state: ParserState, event: AnthropicEvent & { readonly delta?: AnthropicStreamDelta }, ): StepResult => { - const usage = mergeUsage(state.usage, mapUsage(event.usage, state.providerMetadataKey), state.providerMetadataKey) + const usage = mergeUsage( + state.usage, + mapUsage(event.usage, state.providerMetadataKey, event), + state.providerMetadataKey, + ) const pendingFinish = (() => { const stopReason = event.delta?.stop_reason if (stopReason === null || stopReason === undefined) return state.pendingFinish diff --git a/packages/ai/src/protocols/open-responses.ts b/packages/ai/src/protocols/open-responses.ts index 27fe1865e12..d493e844d88 100644 --- a/packages/ai/src/protocols/open-responses.ts +++ b/packages/ai/src/protocols/open-responses.ts @@ -890,7 +890,11 @@ export const fromRequest = (request: LLMRequest) => fromRequestWithAdapter(reque // cached-read and cache-write subsets, and `output_tokens` (inclusive total) // with a `reasoning_tokens` subset. Pass the totals through and derive the // non-cached breakdown. -export const mapUsage = (usage: OpenResponsesUsage | null | undefined, providerMetadataKey: string) => { +export const mapUsage = ( + usage: OpenResponsesUsage | null | undefined, + providerMetadataKey: string, + ...frames: ReadonlyArray +) => { if (!usage) return undefined const cached = usage.input_tokens_details?.cached_tokens const cacheWrite = usage.input_tokens_details?.cache_write_tokens @@ -904,7 +908,7 @@ export const mapUsage = (usage: OpenResponsesUsage | null | undefined, providerM cacheWriteInputTokens: cacheWrite, reasoningTokens: reasoning, totalTokens: ProviderShared.totalTokens(usage.input_tokens, usage.output_tokens, usage.total_tokens), - ...ProviderShared.reportedBilling(usage), + ...ProviderShared.reportedBilling(usage, ...frames), providerMetadata: { [providerMetadataKey]: usage }, }) } @@ -1397,7 +1401,8 @@ const onResponseFinish = Effect.fnUntraced(function* (state: ParserState, event: normalized: mapFinishReason(event, current.hasFunctionCall), raw: event.response?.incomplete_details?.reason, }, - usage: mapUsage(event.response?.usage, current.providerMetadataKey), + // Copilot reports its billing on the terminal event, beside `response`. + usage: mapUsage(event.response?.usage, current.providerMetadataKey, event, event.response), providerMetadata: event.response?.id || event.response?.service_tier ? providerMetadata(current, { diff --git a/packages/ai/src/protocols/openai-chat.ts b/packages/ai/src/protocols/openai-chat.ts index 05815b1e9ff..af97167e240 100644 --- a/packages/ai/src/protocols/openai-chat.ts +++ b/packages/ai/src/protocols/openai-chat.ts @@ -960,7 +960,11 @@ const mapFinishReason = Effect.fnUntraced(function* (event: OpenAIChatEvent, rea // `prompt_tokens_details.cached_tokens`, DeepSeek uses // `prompt_cache_hit_tokens`, Zai uses top-level `cached_tokens`, and // DigitalOcean uses top-level `cache_read_input_tokens` / `cache_created_input_tokens`. -const mapUsage = (usage: OpenAIChatEvent["usage"], providerMetadataKey: string): Usage | undefined => { +const mapUsage = ( + usage: OpenAIChatEvent["usage"], + providerMetadataKey: string, + frame: OpenAIChatEvent, +): Usage | undefined => { if (!usage) return undefined const input = usage.prompt_tokens ?? undefined const output = usage.completion_tokens ?? undefined @@ -981,7 +985,7 @@ const mapUsage = (usage: OpenAIChatEvent["usage"], providerMetadataKey: string): cacheWriteInputTokens: cacheWrite, reasoningTokens: reasoning, totalTokens: ProviderShared.totalTokens(input, output, usage.total_tokens ?? undefined), - ...ProviderShared.reportedBilling(usage), + ...ProviderShared.reportedBilling(usage, frame), providerMetadata: { [providerMetadataKey]: usage }, }) } @@ -1116,8 +1120,8 @@ const step = (state: ParserState, event: OpenAIChatEvent) => // Moonshot (and a few other OpenAI-compatible providers) attach usage to // `choice.usage` instead of the top-level `usage` field. const usage = - mapUsage(event.usage, state.providerMetadataKey) ?? - mapUsage(choice?.usage, state.providerMetadataKey) ?? + mapUsage(event.usage, state.providerMetadataKey, event) ?? + mapUsage(choice?.usage, state.providerMetadataKey, event) ?? state.usage const rawFinishReason = choice?.finish_reason const finishReason = rawFinishReason diff --git a/packages/ai/src/protocols/shared.ts b/packages/ai/src/protocols/shared.ts index 47414d12e9b..8f5c9c5c745 100644 --- a/packages/ai/src/protocols/shared.ts +++ b/packages/ai/src/protocols/shared.ts @@ -54,12 +54,27 @@ export const ReportedBilling = Schema.Struct({ cost: billedAmount, }) -/** Reads `ReportedBilling` from a raw usage object, which provider metadata keeps as the endpoint sent it. */ -export const reportedBilling = (usage: unknown) => - Option.match(Schema.decodeUnknownOption(ReportedBilling)(usage), { - onNone: () => ({}), - onSome: (billing) => ({ cost: billing.cost }), - }) +/** + * GitHub Copilot reports its charge beside usage on the frame that carries it, as `copilot_usage.total_nano_aiu`: + * billionths of an AI credit, which costs $0.01. + */ +const CopilotBilling = Schema.Struct({ + copilot_usage: Schema.Struct({ total_nano_aiu: Schema.Finite.check(Schema.isGreaterThanOrEqualTo(0)) }), +}) +const decodeCopilotBilling = Schema.decodeUnknownOption(CopilotBilling) + +/** + * Reads `ReportedBilling` from a raw usage object, which provider metadata keeps as the endpoint sent it, or + * Copilot's billing from the frames that carry the usage. + */ +export const reportedBilling = (usage: unknown, ...frames: ReadonlyArray) => { + const reported = Option.getOrUndefined(Schema.decodeUnknownOption(ReportedBilling)(usage))?.cost + if (reported !== undefined) return { cost: reported } + const nanoAiu = frames + .map((frame) => Option.getOrUndefined(decodeCopilotBilling(frame))?.copilot_usage.total_nano_aiu) + .find((value) => value !== undefined) + return nanoAiu === undefined ? {} : { cost: nanoAiu / 100_000_000_000 } +} /** * Accepts stream frames that carry no `type`, alongside the typed event schema. Gateways inject such frames as diff --git a/packages/ai/test/provider/anthropic-messages.test.ts b/packages/ai/test/provider/anthropic-messages.test.ts index b708df2bd2c..7e32f4fa8d4 100644 --- a/packages/ai/test/provider/anthropic-messages.test.ts +++ b/packages/ai/test/provider/anthropic-messages.test.ts @@ -1360,6 +1360,29 @@ describe("Anthropic Messages route", () => { }), ) + it.effect("converts the GitHub Copilot billing on the message delta to USD", () => + Effect.gen(function* () { + const response = yield* LLMClient.generate(request).pipe( + Effect.provide( + fixedResponse( + sseEvents( + { type: "message_start", message: { usage: { input_tokens: 5 } } }, + { + type: "message_delta", + delta: { stop_reason: "end_turn" }, + usage: { output_tokens: 2 }, + copilot_usage: { total_nano_aiu: 4_473_525_000 }, + }, + { type: "message_stop" }, + ), + ), + ), + ) + + expect(response.usage).toMatchObject({ inputTokens: 5, outputTokens: 2, cost: 0.04473525 }) + }), + ) + it.effect("preserves terminal state across usage-only message deltas", () => Effect.gen(function* () { const response = yield* LLMClient.generate(request).pipe( diff --git a/packages/ai/test/provider/openai-chat.test.ts b/packages/ai/test/provider/openai-chat.test.ts index 18664f77521..a97a04b92ff 100644 --- a/packages/ai/test/provider/openai-chat.test.ts +++ b/packages/ai/test/provider/openai-chat.test.ts @@ -1264,6 +1264,23 @@ describe("OpenAI Chat route", () => { }), ) + it.effect("converts the GitHub Copilot billing beside usage to USD", () => + Effect.gen(function* () { + const response = yield* LLMClient.generate(request).pipe( + Effect.provide( + fixedResponse( + sseEvents(deltaChunk({ content: "Hello" }, "stop"), { + ...usageChunk({ prompt_tokens: 5, completion_tokens: 2 }), + copilot_usage: { total_nano_aiu: 4_473_525_000, token_details: [] }, + }), + ), + ), + ) + + expect(response.usage).toMatchObject({ inputTokens: 5, outputTokens: 2, cost: 0.04473525 }) + }), + ) + it.effect("drops a malformed billed cost without failing the stream", () => Effect.gen(function* () { const response = yield* LLMClient.generate(request).pipe( diff --git a/packages/ai/test/provider/openai-compatible-responses.test.ts b/packages/ai/test/provider/openai-compatible-responses.test.ts index b961e4a024a..a26f79bcc58 100644 --- a/packages/ai/test/provider/openai-compatible-responses.test.ts +++ b/packages/ai/test/provider/openai-compatible-responses.test.ts @@ -400,6 +400,25 @@ describe("Open Responses-compatible route", () => { }), ) + it.effect("converts the GitHub Copilot billing on the completed event to USD", () => + Effect.gen(function* () { + const model = configure({ apiKey: "test-key", baseURL: "https://api.githubcopilot.test" }).model("gpt-5") + const response = yield* LLMClient.generate(ai.request({ model, prompt: "Say hello." })).pipe( + Effect.provide( + fixedResponse( + sseEvents({ + type: "response.completed", + response: { id: "resp_1", usage: { input_tokens: 5, output_tokens: 2, total_tokens: 7 } }, + copilot_usage: { total_nano_aiu: 4_473_525_000 }, + }), + ), + ), + ) + + expect(response.usage).toMatchObject({ inputTokens: 5, outputTokens: 2, cost: 0.04473525 }) + }), + ) + it.effect("ignores bare null frames between events", () => Effect.gen(function* () { const model = configure({ diff --git a/packages/core/src/session/usage.ts b/packages/core/src/session/usage.ts index 1e2ca812ca4..417ba78874c 100644 --- a/packages/core/src/session/usage.ts +++ b/packages/core/src/session/usage.ts @@ -19,7 +19,6 @@ export const tokens = (usage: Usage | undefined): TokenUsage.Info => ({ }) // Catalog pricing is the fallback for endpoints that don't report what they charged (`Usage.cost`). -// TODO(#35765): Normalize Copilot's reported billed amount into `Usage.cost`. export function calculateCost(costs: Model.Info["cost"], usage: TokenUsage.Info) { const context = usage.input + usage.cache.read + usage.cache.write const tier = costs