Defines a single invariant for `LLM.Usage`: every field is non-negative and every meaningful aggregate is a *sum*, never a difference. Total billable input = inputTokens + cacheReadInputTokens + cacheWriteInputTokens. Total billable output = outputTokens + reasoningTokens. Adding two non-negatives cannot underflow, so consumers can no longer reproduce the underflow-then-clamp bug class fixed by #26620. Each protocol mapper now enforces the contract at the provider boundary via `ProviderShared.subtractTokens`, which clamps with `Math.max(0, …)` for defense against provider bugs: - OpenAI Chat / Responses: pull `cached_tokens` out of `prompt_tokens` / `input_tokens`; pull `reasoning_tokens` out of `completion_tokens` / `output_tokens`. The provider's `total_tokens` is preserved verbatim. - Gemini: pull `cachedContentTokenCount` out of `promptTokenCount`. Gemini already split visible candidates from thoughts. - Bedrock: pull `cacheReadInputTokens` and `cacheWriteInputTokens` out of `inputTokens`, matching AWS prompt-caching docs. - Anthropic: already non-overlapping per the Messages API; pass through. Adds `Usage.totalInput` / `Usage.totalOutput` helpers for callers that want the merged view, and a regression test covering the clamp behavior. The reasoning underflow fixed in #26620 was the most visible symptom of a broader semantic inconsistency in this package: providers also disagreed on whether `inputTokens` includes cache reads (Anthropic excluded; OpenAI/Gemini/Bedrock included), which would silently double-subtract the moment v2 wired LLM.Usage into Session.getUsage. Normalizing now, pre-integration, closes both holes in one move.
76 lines
2.8 KiB
TypeScript
76 lines
2.8 KiB
TypeScript
import { describe, expect, test } from "bun:test"
|
|
import { Schema } from "effect"
|
|
import { ContentPart, LLMEvent, LLMRequest, ModelID, ModelLimits, ModelRef, ProviderID, Usage } from "../src/schema"
|
|
import { ProviderShared } from "../src/protocols/shared"
|
|
|
|
const model = new ModelRef({
|
|
id: ModelID.make("fake-model"),
|
|
provider: ProviderID.make("fake-provider"),
|
|
route: "openai-chat",
|
|
baseURL: "https://fake.local",
|
|
limits: new ModelLimits({}),
|
|
})
|
|
|
|
describe("llm schema", () => {
|
|
test("decodes a minimal request", () => {
|
|
const input: unknown = {
|
|
id: "req_1",
|
|
model,
|
|
system: [{ type: "text", text: "You are terse." }],
|
|
messages: [{ role: "user", content: [{ type: "text", text: "hi" }] }],
|
|
tools: [],
|
|
generation: {},
|
|
}
|
|
|
|
const decoded = Schema.decodeUnknownSync(LLMRequest)(input)
|
|
|
|
expect(decoded.id).toBe("req_1")
|
|
expect(decoded.messages[0]?.content[0]?.type).toBe("text")
|
|
})
|
|
|
|
test("accepts custom route ids", () => {
|
|
const decoded = Schema.decodeUnknownSync(LLMRequest)({
|
|
model: { ...model, route: "custom-route" },
|
|
system: [],
|
|
messages: [],
|
|
tools: [],
|
|
generation: {},
|
|
})
|
|
|
|
expect(decoded.model.route).toBe("custom-route")
|
|
})
|
|
|
|
test("rejects invalid event type", () => {
|
|
expect(() => Schema.decodeUnknownSync(LLMEvent)({ type: "bogus" })).toThrow()
|
|
})
|
|
|
|
test("content part tagged union exposes guards", () => {
|
|
expect(ContentPart.guards.text({ type: "text", text: "hi" })).toBe(true)
|
|
expect(ContentPart.guards.media({ type: "text", text: "hi" })).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe("LLM.Usage additive contract", () => {
|
|
test("subtractTokens clamps non-sensical breakdowns to zero", () => {
|
|
// Defense against a provider reporting cached_tokens > prompt_tokens or
|
|
// reasoning_tokens > completion_tokens. The clamp prevents the negative
|
|
// values that triggered opencode#26620 from ever entering the pipeline.
|
|
expect(ProviderShared.subtractTokens(5, 3)).toBe(2)
|
|
expect(ProviderShared.subtractTokens(5, 10)).toBe(0)
|
|
expect(ProviderShared.subtractTokens(5, undefined)).toBe(5)
|
|
expect(ProviderShared.subtractTokens(undefined, 3)).toBeUndefined()
|
|
expect(ProviderShared.subtractTokens(undefined, undefined)).toBeUndefined()
|
|
})
|
|
|
|
test("totalInput sums every input-side category", () => {
|
|
expect(Usage.totalInput(new Usage({ inputTokens: 10, cacheReadInputTokens: 3, cacheWriteInputTokens: 2 }))).toBe(15)
|
|
expect(Usage.totalInput(new Usage({ inputTokens: 10 }))).toBe(10)
|
|
expect(Usage.totalInput(new Usage({}))).toBe(0)
|
|
})
|
|
|
|
test("totalOutput sums every output-side category", () => {
|
|
expect(Usage.totalOutput(new Usage({ outputTokens: 7, reasoningTokens: 4 }))).toBe(11)
|
|
expect(Usage.totalOutput(new Usage({ outputTokens: 7 }))).toBe(7)
|
|
expect(Usage.totalOutput(new Usage({}))).toBe(0)
|
|
})
|
|
})
|