feat(ai): report GitHub Copilot billing as usage cost

This commit is contained in:
exe.dev user
2026-11-05 16:08:23 +00:00
Unverified
parent 3ccea5a785
commit 5eee4e83a6
8 changed files with 107 additions and 17 deletions
@@ -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
+8 -3
View File
@@ -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<unknown>
) => {
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, {
+8 -4
View File
@@ -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
+21 -6
View File
@@ -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<unknown>) => {
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
@@ -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(
@@ -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(
@@ -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({
-1
View File
@@ -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