feat(llm): add Usage.totalInputTokens / totalOutputTokens getters

Match the `LLMResponse.text` / `reasoning` / `toolCalls` getter pattern
in the same file — `usage.totalInputTokens` reads naturally and lives
where the Usage data does. Both sums are monotonic under the additive
contract, so callers no longer need to remember which fields are
non-overlapping.

Test fixtures that previously asserted with `usage: { ... }` plain
literals are now wrapped with `new Usage({...})` to match the runtime
shape the mappers actually produce (an instance, not a struct).
This commit is contained in:
Kit Langton 2026-05-10 19:29:41 -04:00
parent 0d4f8d126f
commit f5d199db62
6 changed files with 38 additions and 16 deletions

View file

@ -43,7 +43,17 @@ export class Usage extends Schema.Class<Usage>("LLM.Usage")({
cacheWriteInputTokens: Schema.optional(Schema.Number),
totalTokens: Schema.optional(Schema.Number),
native: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
}) {}
}) {
/** Sum of every input-side category. Monotonic under the additive contract. */
get totalInputTokens() {
return (this.inputTokens ?? 0) + (this.cacheReadInputTokens ?? 0) + (this.cacheWriteInputTokens ?? 0)
}
/** Sum of every output-side category. Monotonic under the additive contract. */
get totalOutputTokens() {
return (this.outputTokens ?? 0) + (this.reasoningTokens ?? 0)
}
}
export const RequestStart = Schema.Struct({
type: Schema.tag("request-start"),

View file

@ -1,6 +1,6 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { CacheHint, LLM, LLMError } from "../../src"
import { CacheHint, LLM, LLMError, Usage } from "../../src"
import { LLMClient } from "../../src/route"
import * as AnthropicMessages from "../../src/protocols/anthropic-messages"
import { it } from "../lib/effect"
@ -152,7 +152,7 @@ describe("Anthropic Messages route", () => {
{
type: "request-finish",
reason: "tool-calls",
usage: { inputTokens: 5, outputTokens: 1, totalTokens: 6, native: { input_tokens: 5, output_tokens: 1 } },
usage: new Usage({ inputTokens: 5, outputTokens: 1, totalTokens: 6, native: { input_tokens: 5, output_tokens: 1 } }),
},
])
}),

View file

@ -1,6 +1,6 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { LLM, LLMError } from "../../src"
import { LLM, LLMError, Usage } from "../../src"
import { LLMClient } from "../../src/route"
import * as Gemini from "../../src/protocols/gemini"
import { it } from "../lib/effect"
@ -210,7 +210,7 @@ describe("Gemini route", () => {
{
type: "request-finish",
reason: "stop",
usage: {
usage: new Usage({
inputTokens: 4,
outputTokens: 2,
reasoningTokens: 1,
@ -223,7 +223,7 @@ describe("Gemini route", () => {
thoughtsTokenCount: 1,
cachedContentTokenCount: 1,
},
},
}),
},
])
}),
@ -257,12 +257,12 @@ describe("Gemini route", () => {
{
type: "request-finish",
reason: "tool-calls",
usage: {
usage: new Usage({
inputTokens: 5,
outputTokens: 1,
totalTokens: 6,
native: { promptTokenCount: 5, candidatesTokenCount: 1 },
},
}),
},
])
}),

View file

@ -1,7 +1,7 @@
import { describe, expect } from "bun:test"
import { Effect, Schema, Stream } from "effect"
import { HttpClientRequest } from "effect/unstable/http"
import { LLM, LLMError } from "../../src"
import { LLM, LLMError, Usage } from "../../src"
import * as Azure from "../../src/providers/azure"
import * as OpenAI from "../../src/providers/openai"
import * as OpenAIChat from "../../src/protocols/openai-chat"
@ -230,7 +230,7 @@ describe("OpenAI Chat route", () => {
{
type: "request-finish",
reason: "stop",
usage: {
usage: new Usage({
inputTokens: 4,
outputTokens: 2,
reasoningTokens: 0,
@ -243,7 +243,7 @@ describe("OpenAI Chat route", () => {
prompt_tokens_details: { cached_tokens: 1 },
completion_tokens_details: { reasoning_tokens: 0 },
},
},
}),
},
])
}),

View file

@ -1,7 +1,7 @@
import { describe, expect } from "bun:test"
import { ConfigProvider, Effect, Layer, Stream } from "effect"
import { Headers, HttpClientRequest } from "effect/unstable/http"
import { LLM, LLMError } from "../../src"
import { LLM, LLMError, Usage } from "../../src"
import { Auth, LLMClient, RequestExecutor, WebSocketExecutor } from "../../src/route"
import * as Azure from "../../src/providers/azure"
import * as OpenAI from "../../src/providers/openai"
@ -342,7 +342,7 @@ describe("OpenAI Responses route", () => {
type: "request-finish",
reason: "stop",
providerMetadata: { openai: { responseId: "resp_1", serviceTier: "default" } },
usage: {
usage: new Usage({
inputTokens: 4,
outputTokens: 2,
reasoningTokens: 0,
@ -355,7 +355,7 @@ describe("OpenAI Responses route", () => {
input_tokens_details: { cached_tokens: 1 },
output_tokens_details: { reasoning_tokens: 0 },
},
},
}),
},
])
}),
@ -411,7 +411,7 @@ describe("OpenAI Responses route", () => {
{
type: "request-finish",
reason: "tool-calls",
usage: { inputTokens: 5, outputTokens: 1, totalTokens: 6, native: { input_tokens: 5, output_tokens: 1 } },
usage: new Usage({ inputTokens: 5, outputTokens: 1, totalTokens: 6, native: { input_tokens: 5, output_tokens: 1 } }),
},
])
}),

View file

@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import { Schema } from "effect"
import { ContentPart, LLMEvent, LLMRequest, ModelID, ModelLimits, ModelRef, ProviderID } from "../src/schema"
import { ContentPart, LLMEvent, LLMRequest, ModelID, ModelLimits, ModelRef, ProviderID, Usage } from "../src/schema"
import { ProviderShared } from "../src/protocols/shared"
const model = new ModelRef({
@ -61,4 +61,16 @@ describe("LLM.Usage additive contract", () => {
expect(ProviderShared.subtractTokens(undefined, 3)).toBeUndefined()
expect(ProviderShared.subtractTokens(undefined, undefined)).toBeUndefined()
})
test("totalInputTokens sums every input-side category", () => {
expect(new Usage({ inputTokens: 10, cacheReadInputTokens: 3, cacheWriteInputTokens: 2 }).totalInputTokens).toBe(15)
expect(new Usage({ inputTokens: 10 }).totalInputTokens).toBe(10)
expect(new Usage({}).totalInputTokens).toBe(0)
})
test("totalOutputTokens sums every output-side category", () => {
expect(new Usage({ outputTokens: 7, reasoningTokens: 4 }).totalOutputTokens).toBe(11)
expect(new Usage({ outputTokens: 7 }).totalOutputTokens).toBe(7)
expect(new Usage({}).totalOutputTokens).toBe(0)
})
})