diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 54372cce2f9..fa02f36a8db 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -402,6 +402,14 @@ those providers, but `ocx login codex --reauth` routes to their account-pool rea the dashboard Codex account pool also performs. See [`ocx status` / `ocx doctor`](/reference/cli/) in the CLI reference. +### Kiro request credits + +When Kiro emits credit metering, request logs preserve the reported spend as +`usage.providerCredits`, including in the persisted usage ledger. These are Kiro credits; +token counts may still be estimated, and the credit value does not replace USD cost estimates. +Completion fallback requests add their reported credits. An absent value means Kiro did not +report credit usage; an explicit zero means it reported no spend. + ### Kiro credential import Kiro login expects the Kiro CLI: on Unix, install it with `curl -fsSL https://cli.kiro.dev/install | bash`; diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index c10d490a8e3..81281010190 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1077,6 +1077,8 @@ "kiro-remote-image.test.ts": "providers/kiro", "kiro-retry.test.ts": "providers/kiro", "kiro-review-regressions.test.ts": "providers/kiro", + "kiro-metering-events.test.ts": "providers/kiro", + "kiro-metering-usage.test.ts": "providers/kiro", "kiro-stream.test.ts": "providers/kiro", "kiro-usage-quota.test.ts": "providers/kiro", "kiro-windows-cli-db-path.test.ts": "providers/kiro", diff --git a/src/adapters/kiro-events.ts b/src/adapters/kiro-events.ts index 730ad2a2c4e..1ef837d9f62 100644 --- a/src/adapters/kiro-events.ts +++ b/src/adapters/kiro-events.ts @@ -1,10 +1,12 @@ import type { OcxUsage } from "../types"; +import { debugProviderDiagnostic } from "../lib/debug"; import { kiroTruncationReason } from "./kiro-truncation"; export type ParsedKiroEvent = | { type: "content"; data?: string; modelId?: string } | { type: "reasoning"; data?: string; signature?: string; redactedContent?: string } | { type: "context_usage"; contextUsagePercentage: number } + | { type: "metering"; unit: string; usage: number; unitPlural?: string } | { type: "tool"; name?: string; toolUseId?: string; input?: string; stop?: boolean } | { type: "truncation"; data: string } | { type: "metadata"; usage?: OcxUsage; contextUsagePercentage?: number; stopReason?: string } @@ -17,10 +19,12 @@ const KNOWN_EVENT_TYPES = new Set([ "reasoningContentEvent", "toolUseEvent", "messageMetadataEvent", + "initial-response", "metadataEvent", // Authoritative context pressure. Every capture (kiro-cli 2.14.1 and 2.16.0) put the percentage // HERE and left `metadataEvent` carrying only `stopReason`; metadataEvent's own // contextUsagePercentage stays supported as a fallback rather than being dropped. + "meteringEvent", "contextUsageEvent", "invalidStateEvent", "error", @@ -111,7 +115,10 @@ function parseTokenUsage(eventType: string, value: unknown): OcxUsage | undefine /** Decode a known Kiro event using its Smithy `:event-type` header. */ export function parseKiroEvent(eventType: string, payload: Uint8Array): ParsedKiroEvent | null { // Unknown event types are intentionally ignored without parsing or logging their payload. - if (!KNOWN_EVENT_TYPES.has(eventType)) return null; + if (!KNOWN_EVENT_TYPES.has(eventType)) { + debugProviderDiagnostic("kiro", "unknown_event", { eventType }); + return null; + } const parsed = parseObject(eventType, payload); // A metadataEvent's `stopReason` is Kiro's own terminal verdict and must reach the parser // intact. The generic truncation sniffer matches substrings ("max_tokens", "length", @@ -174,7 +181,25 @@ export function parseKiroEvent(eventType: string, payload: Uint8Array): ParsedKi ? { stop: optionalBoolean(eventType, parsed, "stop") } : {}), }; + case "meteringEvent": { + const unit = optionalString(eventType, parsed, "unit"); + if (unit === undefined) { + return malformed(eventType, "unit must be a string"); + } + const unitPlural = optionalString(eventType, parsed, "unitPlural"); + const rawUsage = parsed.usage !== undefined ? parsed.usage : parsed.amount; + if (typeof rawUsage !== "number" || !Number.isFinite(rawUsage) || rawUsage < 0) { + return malformed(eventType, "usage must be a finite non-negative number"); + } + return { + type: "metering", + unit, + usage: rawUsage, + ...(unitPlural !== undefined ? { unitPlural } : {}), + }; + } case "messageMetadataEvent": + case "initial-response": return { type: "message_metadata", conversationId: diff --git a/src/adapters/kiro/stream.ts b/src/adapters/kiro/stream.ts index 1955c2785df..3c9f01fbf6d 100644 --- a/src/adapters/kiro/stream.ts +++ b/src/adapters/kiro/stream.ts @@ -175,6 +175,7 @@ function mergeKiroUsage( ...(sumOptional("cachedInputTokens") !== undefined ? { cachedInputTokens: sumOptional("cachedInputTokens") } : {}), ...(sumOptional("cacheReadInputTokens") !== undefined ? { cacheReadInputTokens: sumOptional("cacheReadInputTokens") } : {}), ...(sumOptional("cacheCreationInputTokens") !== undefined ? { cacheCreationInputTokens: sumOptional("cacheCreationInputTokens") } : {}), + ...(sumOptional("providerCredits") !== undefined ? { providerCredits: sumOptional("providerCredits") } : {}), ...(sumOptional("reasoningOutputTokens") !== undefined ? { reasoningOutputTokens: sumOptional("reasoningOutputTokens") } : {}), ...(first.estimated || second.estimated ? { estimated: true } : {}), }; @@ -319,6 +320,7 @@ async function* parseKiroAttemptEvents( let completionAnswer: string | undefined; let completionCalls = 0; let authoritativeUsage: OcxUsage | undefined; + let providerCredits: number | undefined; let stopReason: string | undefined; const fallbackEvents: AdapterEvent[] = []; const thinking = new InlineThinkTagParser(budget); @@ -379,7 +381,11 @@ async function* parseKiroAttemptEvents( contextUsageTotalFloor() ?? 0, authoritativeTurnTotal, ); - return contextTotal > 0 ? { ...base, contextTotalTokens: contextTotal } : base; + return { + ...base, + ...(contextTotal > 0 ? { contextTotalTokens: contextTotal } : {}), + ...(providerCredits !== undefined ? { providerCredits } : {}), + }; }; const classifiedTerminal = (failure: KiroErrorClassification): AdapterEvent => { @@ -599,6 +605,9 @@ async function* parseKiroAttemptEvents( const ev = parseKiroEvent(eventType, msg.payload); if (!ev) continue; switch (ev.type) { + case "metering": + if (ev.unit === "credit" || ev.unit === "credits") providerCredits = ev.usage; + break; case "metadata": if (ev.usage) authoritativeUsage = ev.usage; if (ev.contextUsagePercentage !== undefined && ev.contextUsagePercentage > 0) { diff --git a/src/server/request-log.ts b/src/server/request-log.ts index e565f546b06..2bc6cb6308e 100644 --- a/src/server/request-log.ts +++ b/src/server/request-log.ts @@ -1905,7 +1905,7 @@ export function aggregateAttemptUsage( const sumOptional = ( key: "cachedInputTokens" | "cacheReadInputTokens" | "cacheCreationInputTokens" - | "reasoningOutputTokens", + | "reasoningOutputTokens" | "providerCredits", ): number | undefined => { const present = usages.flatMap(usage => ( typeof usage[key] === "number" ? [usage[key] as number] : [] @@ -1916,6 +1916,7 @@ export function aggregateAttemptUsage( const cacheReadInputTokens = sumOptional("cacheReadInputTokens"); const cacheCreationInputTokens = sumOptional("cacheCreationInputTokens"); const reasoningOutputTokens = sumOptional("reasoningOutputTokens"); + const providerCredits = sumOptional("providerCredits"); const totalTokens = usages.reduce( (sum, usage) => sum + (usageTotalTokens(usage) ?? 0), 0, @@ -1928,6 +1929,7 @@ export function aggregateAttemptUsage( ...(cacheReadInputTokens !== undefined ? { cacheReadInputTokens } : {}), ...(cacheCreationInputTokens !== undefined ? { cacheCreationInputTokens } : {}), ...(reasoningOutputTokens !== undefined ? { reasoningOutputTokens } : {}), + ...(providerCredits !== undefined ? { providerCredits } : {}), ...(status === "estimated" ? { estimated: true } : {}), }; return { usage: aggregate, status, totalTokens }; diff --git a/src/server/responses/empty-completion-guard.ts b/src/server/responses/empty-completion-guard.ts index 92243523e1a..10a1b563b72 100644 --- a/src/server/responses/empty-completion-guard.ts +++ b/src/server/responses/empty-completion-guard.ts @@ -173,6 +173,7 @@ export function mergeUsage( const cacheReadInputTokens = sumOptional("cacheReadInputTokens"); const cacheCreationInputTokens = sumOptional("cacheCreationInputTokens"); const reasoningOutputTokens = sumOptional("reasoningOutputTokens"); + const providerCredits = sumOptional("providerCredits"); const contextTotalTokens = second.contextTotalTokens ?? first.contextTotalTokens; const inputTokens = first.inputTokens + second.inputTokens; const outputTokens = first.outputTokens + second.outputTokens; @@ -188,6 +189,7 @@ export function mergeUsage( ...(cacheReadInputTokens !== undefined ? { cacheReadInputTokens } : {}), ...(cacheCreationInputTokens !== undefined ? { cacheCreationInputTokens } : {}), ...(reasoningOutputTokens !== undefined ? { reasoningOutputTokens } : {}), + ...(providerCredits !== undefined ? { providerCredits } : {}), ...(first.estimated || second.estimated ? { estimated: true } : {}), ...(rawUsage !== undefined ? { rawUsage } : {}), }; diff --git a/src/server/responses/terminal-guard.ts b/src/server/responses/terminal-guard.ts index aa4a870645d..7f4859640bd 100644 --- a/src/server/responses/terminal-guard.ts +++ b/src/server/responses/terminal-guard.ts @@ -184,6 +184,7 @@ function mergeUsage(first: OcxUsage | undefined, second: OcxUsage | undefined): const cacheReadInputTokens = sumOptional("cacheReadInputTokens"); const cacheCreationInputTokens = sumOptional("cacheCreationInputTokens"); const reasoningOutputTokens = sumOptional("reasoningOutputTokens"); + const providerCredits = sumOptional("providerCredits"); const inputTokens = first.inputTokens + second.inputTokens; const outputTokens = first.outputTokens + second.outputTokens; return { @@ -194,6 +195,7 @@ function mergeUsage(first: OcxUsage | undefined, second: OcxUsage | undefined): ...(cacheReadInputTokens !== undefined ? { cacheReadInputTokens } : {}), ...(cacheCreationInputTokens !== undefined ? { cacheCreationInputTokens } : {}), ...(reasoningOutputTokens !== undefined ? { reasoningOutputTokens } : {}), + ...(providerCredits !== undefined ? { providerCredits } : {}), ...(first.estimated || second.estimated ? { estimated: true } : {}), }; } diff --git a/src/types/request.ts b/src/types/request.ts index c365826de94..459c12d19cf 100644 --- a/src/types/request.ts +++ b/src/types/request.ts @@ -438,6 +438,8 @@ export interface OcxUrlCitation { * - `totalTokens` = inputTokens + outputTokens. Never re-add cache detail on top. */ export interface OcxUsage { + /** Provider-reported credit spend, independent of token estimates and USD pricing. */ + providerCredits?: number; inputTokens: number; outputTokens: number; /** diff --git a/src/usage/log.ts b/src/usage/log.ts index d382f202b42..0c3b37e5b89 100644 --- a/src/usage/log.ts +++ b/src/usage/log.ts @@ -579,6 +579,7 @@ function normalizeUsageValue(usage: OcxUsage | undefined): OcxUsage | undefined ...(typeof usage.cacheReadInputTokens === "number" ? { cacheReadInputTokens: usage.cacheReadInputTokens } : {}), ...(typeof usage.cacheCreationInputTokens === "number" ? { cacheCreationInputTokens: usage.cacheCreationInputTokens } : {}), ...(typeof usage.reasoningOutputTokens === "number" ? { reasoningOutputTokens: usage.reasoningOutputTokens } : {}), + ...(isNonNegativeFiniteNumber(usage.providerCredits) ? { providerCredits: usage.providerCredits } : {}), ...(usage.estimated ? { estimated: true } : {}), }; } @@ -622,6 +623,7 @@ function normalizeAttemptUsage(raw: unknown): OcxUsage | null { "cacheReadInputTokens", "cacheCreationInputTokens", "reasoningOutputTokens", + "providerCredits", ] as const) { if (key in usage && !isNonNegativeFiniteNumber(usage[key])) return null; } diff --git a/structure/dashboard-and-usage.md b/structure/dashboard-and-usage.md index 93fb16fdca2..d5d747ab24e 100644 --- a/structure/dashboard-and-usage.md +++ b/structure/dashboard-and-usage.md @@ -163,6 +163,11 @@ keeps the saved state and renders fixed `ocx sync` guidance without server/accou ## Usage accounting +`OcxUsage.providerCredits` preserves provider-reported credit spend in request and attempt rows +through `src/usage/log.ts` normalization and ledger reloads. Missing readings stay absent, and zero +is a measured value. Separate attempts add credits when usage is merged. The field is independent +of token estimation (`estimated` describes tokens) and is never treated as USD or token usage. + ### Upstream key account attribution API-key attempts in `src/usage/log.ts` carry `accountLogLabel` as `k` plus 32 lowercase diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index 9871c973ec4..22024a18dd8 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -97,6 +97,9 @@ Inline document admission shares one encoding predicate between its scanner and `src/responses/inline-document.ts`: malformed base64 quantum/padding lengths are refused, and valid padded or unpadded payloads pass unchanged without a decoding allocation. +Kiro metering uses the [provider credit contract](providers/kiro.md#kiro-reasoning-round-trip-signature); +`src/types/request.ts` keeps reported credits separate from estimated token usage. + Adapter output must stay in internal `AdapterEvent` form until `src/bridge/sse.ts` converts it back to Responses SSE or WebSocket frames, or `src/bridge/response-json.ts` buffers it into a JSON response. `src/bridge.ts` is the compatibility facade that re-exports both. diff --git a/structure/providers/kiro.md b/structure/providers/kiro.md index 75551b579fd..0237d11b805 100644 --- a/structure/providers/kiro.md +++ b/structure/providers/kiro.md @@ -124,8 +124,17 @@ from `metadataEvent` is legitimate rather than impossible. Both feed the same fi positive value overwrites an earlier one. Spend arrives in `meteringEvent` as **credits, not tokens**. No captured response carried -`tokenUsage` on any event, which is why Kiro usage stays estimated; `meteringEvent` is currently -ignored because a credit is not a token count. +`tokenUsage` on any event, which is why Kiro token usage stays estimated. The parser preserves +`meteringEvent` unit/usage (`amount` is an alias) and optional `unitPlural`; credit readings populate +`OcxUsage.providerCredits` independently of token metadata. The latest reading within a response +is a snapshot; separate completion-fallback responses add their credits. Missing metering stays +absent and measured zero stays zero. `initial-response` carries `conversationId` through the same +validated provider-state path as `messageMetadataEvent`. Unknown event types produce opt-in +`debugProviderDiagnostic` entries containing only the event type, never the payload. +Coverage: `tests/providers/kiro/kiro-metering-events.test.ts`, +`tests/providers/kiro/kiro-metering-usage.test.ts`, and +`tests/server/server-kiro-completion-e2e.test.ts`. + ## Remote image references Kiro's wire inlines base64 bytes only, so a remote `https` image reference cannot be diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 22679a1b04f..6003c6b45da 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -898,6 +898,8 @@ "kiro-remote-image.test.ts": "providers/kiro", "kiro-retry.test.ts": "providers/kiro", "kiro-review-regressions.test.ts": "providers/kiro", + "kiro-metering-events.test.ts": "providers/kiro", + "kiro-metering-usage.test.ts": "providers/kiro", "kiro-stream.test.ts": "providers/kiro", "kiro-usage-quota.test.ts": "providers/kiro", "kiro-windows-cli-db-path.test.ts": "providers/kiro", diff --git a/tests/providers/kiro/kiro-metering-events.test.ts b/tests/providers/kiro/kiro-metering-events.test.ts new file mode 100644 index 00000000000..4a1dfdccfed --- /dev/null +++ b/tests/providers/kiro/kiro-metering-events.test.ts @@ -0,0 +1,173 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { parseKiroEvent } from "../../../src/adapters/kiro-events"; +import { getDebugLogEntries, resetDebugLogBufferForTests } from "../../../src/lib/debug-log-buffer"; +import { clearDebugSetting, getDebugSettings, setDebugSettings } from "../../../src/lib/debug-settings"; + +const enc = new TextEncoder(); + +describe("parseKiroEvent - meteringEvent", () => { + test("parses real precise sample metering event with unit and usage", () => { + const raw = enc.encode(JSON.stringify({ unit: "credit", usage: 0.04582331509121062 })); + expect(parseKiroEvent("meteringEvent", raw)).toEqual({ + type: "metering", + unit: "credit", + usage: 0.04582331509121062, + }); + + const withPlural = enc.encode( + JSON.stringify({ unit: "credit", unitPlural: "credits", usage: 0.04582331509121062 }), + ); + expect(parseKiroEvent("meteringEvent", withPlural)).toEqual({ + type: "metering", + unit: "credit", + usage: 0.04582331509121062, + unitPlural: "credits", + }); + }); + + test("parses zero usage", () => { + const raw = enc.encode(JSON.stringify({ unit: "credit", usage: 0 })); + expect(parseKiroEvent("meteringEvent", raw)).toEqual({ + type: "metering", + unit: "credit", + usage: 0, + }); + }); + + test("parses amount alias and prefers usage over amount when both present", () => { + const aliasOnly = enc.encode(JSON.stringify({ unit: "credit", amount: 0.01 })); + expect(parseKiroEvent("meteringEvent", aliasOnly)).toEqual({ + type: "metering", + unit: "credit", + usage: 0.01, + }); + + const both = enc.encode(JSON.stringify({ unit: "credit", usage: 0.05, amount: 0.01 })); + expect(parseKiroEvent("meteringEvent", both)).toEqual({ + type: "metering", + unit: "credit", + usage: 0.05, + }); + }); + + test("rejects invalid values", () => { + const invalidCases = [ + { payload: { unit: "credit", usage: -1 }, desc: "negative usage" }, + { payload: { unit: "credit", amount: -0.01 }, desc: "negative amount" }, + { payload: { unit: "credit", usage: "0.5" }, desc: "string usage" }, + { payload: { unit: "credit", amount: "0.5" }, desc: "string amount" }, + { payload: { unit: "credit", usage: null }, desc: "null usage" }, + { payload: { unit: "credit" }, desc: "missing usage and amount" }, + { payload: { usage: 1 }, desc: "missing unit" }, + { payload: { unit: 123, usage: 1 }, desc: "non-string unit" }, + { payload: { unit: null, usage: 1 }, desc: "null unit" }, + { payload: { unit: "credit", unitPlural: 123, usage: 1 }, desc: "non-string unitPlural" }, + ]; + + for (const { payload, desc } of invalidCases) { + const raw = enc.encode(JSON.stringify(payload)); + expect(() => parseKiroEvent("meteringEvent", raw), desc).toThrow( + /invalid Kiro meteringEvent payload/, + ); + } + + // Non-finite number + expect(() => + parseKiroEvent("meteringEvent", enc.encode('{"unit":"credit","usage":Infinity}')), + ).toThrow(/invalid Kiro meteringEvent payload/); + + // Malformed JSON / non-object + expect(() => parseKiroEvent("meteringEvent", enc.encode("not-json"))).toThrow( + /invalid Kiro meteringEvent payload/, + ); + expect(() => parseKiroEvent("meteringEvent", enc.encode("123"))).toThrow( + /invalid Kiro meteringEvent payload/, + ); + }); +}); + +describe("parseKiroEvent - initial-response", () => { + test("aliases message_metadata conversationId parsing", () => { + const withConv = enc.encode(JSON.stringify({ conversationId: "conv-12345" })); + expect(parseKiroEvent("initial-response", withConv)).toEqual({ + type: "message_metadata", + conversationId: "conv-12345", + }); + + const withUtt = enc.encode(JSON.stringify({ utteranceId: "utt-67890" })); + expect(parseKiroEvent("initial-response", withUtt)).toEqual({ + type: "message_metadata", + conversationId: "utt-67890", + }); + + const empty = enc.encode(JSON.stringify({})); + expect(parseKiroEvent("initial-response", empty)).toEqual({ + type: "message_metadata", + conversationId: undefined, + }); + }); +}); + +describe("parseKiroEvent - unknown event diagnostics", () => { + let origDebug: string | undefined; + let origDebugFrames: string | undefined; + let origDebugOverride: boolean | undefined; + + beforeEach(() => { + origDebug = process.env.OCX_DEBUG; + origDebugFrames = process.env.OCX_DEBUG_FRAMES; + origDebugOverride = getDebugSettings().runtimeOverride.debug; + delete process.env.OCX_DEBUG; + delete process.env.OCX_DEBUG_FRAMES; + clearDebugSetting("debug"); + resetDebugLogBufferForTests(); + }); + + afterEach(() => { + if (origDebug === undefined) delete process.env.OCX_DEBUG; else process.env.OCX_DEBUG = origDebug; + if (origDebugFrames === undefined) delete process.env.OCX_DEBUG_FRAMES; else process.env.OCX_DEBUG_FRAMES = origDebugFrames; + if (origDebugOverride === undefined) clearDebugSetting("debug"); + else setDebugSettings({ debug: origDebugOverride }); + resetDebugLogBufferForTests(); + }); + + test("unknown event type calls debugProviderDiagnostic with { eventType } and does not log/parse payload when debug enabled", () => { + setDebugSettings({ debug: true }); + const error = spyOn(console, "error").mockImplementation(() => {}); + + try { + const payload = enc.encode("sensitive-payload-that-must-not-be-parsed-or-logged"); + const result = parseKiroEvent("someUnknownFutureEvent", payload); + + expect(result).toBeNull(); + expect(error).toHaveBeenCalledTimes(1); + + const line = String(error.mock.calls[0]?.[0] ?? ""); + expect(line).toContain("[ocx:kiro:unknown_event]"); + expect(line).toContain('"eventType":"someUnknownFutureEvent"'); + expect(line).not.toContain("sensitive-payload"); + + const logEntries = getDebugLogEntries(); + expect(logEntries.some((entry) => entry.line.includes("[ocx:kiro:unknown_event]"))).toBe(true); + expect(logEntries.some((entry) => entry.line.includes("sensitive-payload"))).toBe(false); + } finally { + error.mockRestore(); + } + }); + + test("unknown event stays quiet when debug is disabled and does not log or parse payload", () => { + setDebugSettings({ debug: false }); + const error = spyOn(console, "error").mockImplementation(() => {}); + + try { + const payload = enc.encode("sensitive-payload-that-must-not-be-parsed-or-logged"); + const result = parseKiroEvent("someUnknownFutureEvent", payload); + + expect(result).toBeNull(); + expect(error).not.toHaveBeenCalled(); + expect(getDebugLogEntries()).toHaveLength(0); + } finally { + error.mockRestore(); + } + }); +}); diff --git a/tests/providers/kiro/kiro-metering-usage.test.ts b/tests/providers/kiro/kiro-metering-usage.test.ts new file mode 100644 index 00000000000..0cbcf940046 --- /dev/null +++ b/tests/providers/kiro/kiro-metering-usage.test.ts @@ -0,0 +1,64 @@ +import { expect, test } from "bun:test"; +import { parseKiroStream } from "../../../src/adapters/kiro"; +import { encodeMessage } from "../../../src/lib/eventstream-decoder"; +import { normalizeUsageEntryForTest } from "../../../src/usage/log"; +import { createTestTranslatorBudget } from "../../helpers/translator-budget"; + +const credit = 0.04582331509121062; +const conversationId = "11111111-1111-4111-8111-111111111111"; +const tokens = { uncachedInputTokens: 10, outputTokens: 2, totalTokens: 12 }; + +async function terminal(frames: Array<[string, unknown]>) { + const response = new Response(new ReadableStream({ + start(controller) { + for (const [type, payload] of frames) { + controller.enqueue(encodeMessage({ ":message-type": "event", ":event-type": type }, + new TextEncoder().encode(JSON.stringify(payload)))); + } + controller.close(); + }, + })); + const events = await Array.fromAsync(parseKiroStream(response, createTestTranslatorBudget())); + const result = events.at(-1); + if (!result || !("usage" in result)) throw new Error("missing terminal usage"); + return result; +} + +test.each([true, false])("credit snapshot survives token metadata (metering first=%s)", async first => { + const metering: [string, unknown] = ["meteringEvent", { unit: "credit", unitPlural: "credits", usage: credit }]; + const metadata: [string, unknown] = ["metadataEvent", { tokenUsage: tokens }]; + const result = await terminal([ + ["initial-response", { conversationId }], + ["assistantResponseEvent", { content: "ok" }], + ...(first ? [metering, metadata] : [metadata, metering]), + ]); + expect(result).toMatchObject({ type: "done", usage: { providerCredits: credit, inputTokens: 10, outputTokens: 2 }, + providerState: { kiro: { conversationId } } }); + const persisted = normalizeUsageEntryForTest({ requestId: "metering", timestamp: 1, provider: "kiro", + model: "test", status: 200, durationMs: 1, usageStatus: "reported", usage: result.usage }); + expect(persisted.usage?.providerCredits).toBe(credit); +}); + +test("repeated per-request readings replace the prior snapshot, including zero", async () => { + const result = await terminal([ + ["assistantResponseEvent", { content: "ok" }], + ["meteringEvent", { unit: "credit", usage: credit }], + ["meteringEvent", { unit: "credits", amount: 0 }], + ]); + expect(result.usage).toMatchObject({ providerCredits: 0, estimated: true }); +}); + +test("unreported credits and other units do not become measured zero", async () => { + for (const extra of [[], [["meteringEvent", { unit: "token", usage: 10 }]] as Array<[string, unknown]>]) { + const result = await terminal([["assistantResponseEvent", { content: "ok" }], ...extra]); + expect(result.usage).not.toHaveProperty("providerCredits"); + } +}); + +test("a stream error preserves credits already reported", async () => { + const result = await terminal([ + ["meteringEvent", { unit: "credit", usage: credit }], + ["error", { message: "upstream failed" }], + ]); + expect(result).toMatchObject({ type: "error", usage: { providerCredits: credit } }); +}); diff --git a/tests/responses/empty-completion-guard.test.ts b/tests/responses/empty-completion-guard.test.ts index b1985f31b0e..227b5c4936b 100644 --- a/tests/responses/empty-completion-guard.test.ts +++ b/tests/responses/empty-completion-guard.test.ts @@ -125,13 +125,13 @@ describe("empty-completion guard retry", () => { const events = await collect(guardEmptyCompletionEventStream({ firstEvents: eventsOf( { type: "thinking_delta", thinking: "..." }, - { type: "done", usage: { inputTokens: 100, outputTokens: 0, cachedInputTokens: 40 } }, + { type: "done", usage: { inputTokens: 100, outputTokens: 0, cachedInputTokens: 40, providerCredits: 0.04 } }, ), continuation: () => eventsOf( { type: "tool_call_start", id: "c1", name: "run" }, { type: "tool_call_delta", arguments: "{}" }, { type: "tool_call_end" }, - { type: "done", usage: { inputTokens: 200, outputTokens: 30, reasoningOutputTokens: 12 } }, + { type: "done", usage: { inputTokens: 200, outputTokens: 30, reasoningOutputTokens: 12, providerCredits: 0.01 } }, ), })); @@ -142,6 +142,7 @@ describe("empty-completion guard retry", () => { totalTokens: 330, cachedInputTokens: 40, reasoningOutputTokens: 12, + providerCredits: 0.05, }); }); diff --git a/tests/server/server-kiro-completion-e2e.test.ts b/tests/server/server-kiro-completion-e2e.test.ts index 01327b482c8..4d35b2100d3 100644 --- a/tests/server/server-kiro-completion-e2e.test.ts +++ b/tests/server/server-kiro-completion-e2e.test.ts @@ -7,6 +7,7 @@ import { saveConfig } from "../../src/config"; import { encodeMessage } from "../../src/lib/eventstream-decoder"; import { startServer } from "../../src/server"; import { clearRequestLogsForTests, getRequestLogEntries } from "../../src/server/request-log"; +import { readUsageEntries, resetUsageReadCacheForTests } from "../../src/usage/log"; import type { OcxConfig } from "../../src/types"; import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; import { removeTreeWithRetry } from "../helpers/remove-tree"; @@ -132,8 +133,8 @@ function anthropicEvents(sse: string): Array<{ name: string; data: Record { test("/v1/responses keeps progress nonterminal and lets only the bounded fallback complete", async () => { const upstream = scriptedKiroUpstream([ - [textFrame("Checking the workspace.")], - completionFrames("The workspace is ready."), + [textFrame("Checking the workspace."), eventFrame("meteringEvent", { unit: "credit", usage: 0.04582331509121062 })], + [...completionFrames("The workspace is ready."), eventFrame("meteringEvent", { unit: "credit", amount: 0.01 })], ]); saveConfig(kiroConfig(upstream.server.url.toString())); const proxy = startServer(0); @@ -164,6 +165,13 @@ describe("Kiro completion through public server endpoints", () => { expect(messages.map((item: { phase?: string }) => item.phase)).toEqual(["commentary", "final_answer"]); expect(wire).not.toContain(KIRO_COMPLETION_TOOL_NAME); + const expectedCredits = 0.04582331509121062 + 0.01; + const log = getRequestLogEntries().find(entry => entry.provider === "kiro-test"); + expect(log?.usage).toMatchObject({ providerCredits: expectedCredits, estimated: true }); + resetUsageReadCacheForTests(); + const persisted = readUsageEntries().find(entry => entry.requestId === log?.requestId); + expect(persisted?.usage).toMatchObject({ providerCredits: expectedCredits, estimated: true }); + expect(upstream.requests).toHaveLength(2); expect(kiroToolNames(upstream.requests[0])).toEqual(["bash", KIRO_COMPLETION_TOOL_NAME]); expect(kiroToolNames(upstream.requests[1])).toEqual(["bash", KIRO_COMPLETION_TOOL_NAME]); diff --git a/tests/server/terminal-guard.test.ts b/tests/server/terminal-guard.test.ts index 61234f80a29..2370a4e6cec 100644 --- a/tests/server/terminal-guard.test.ts +++ b/tests/server/terminal-guard.test.ts @@ -169,7 +169,7 @@ describe("terminal guard", () => { parsed: parsed("请检查这个问题并修复代码"), firstEvents: (async function* () { yield { type: "text_delta", text: "我接下来会修改相关文件。" } as AdapterEvent; - yield { type: "done", usage: { inputTokens: 10, outputTokens: 2 } } as AdapterEvent; + yield { type: "done", usage: { inputTokens: 10, outputTokens: 2, providerCredits: 0.04 } } as AdapterEvent; })(), continuation: next => { continuations += 1; @@ -177,7 +177,7 @@ describe("terminal guard", () => { return (async function* () { yield { type: "tool_call_start", id: "call_1", name: "exec_command" } as AdapterEvent; yield { type: "tool_call_end" } as AdapterEvent; - yield { type: "done", usage: { inputTokens: 20, outputTokens: 3 } } as AdapterEvent; + yield { type: "done", usage: { inputTokens: 20, outputTokens: 3, providerCredits: 0.01 } } as AdapterEvent; })(); }, adapterName: "anthropic", @@ -186,7 +186,7 @@ describe("terminal guard", () => { expect(continuations).toBe(1); expect(actual.filter(event => event.type === "done")).toHaveLength(1); expect(actual.some(event => event.type === "assistant_boundary")).toBe(true); - expect(actual.at(-1)).toMatchObject({ usage: { inputTokens: 30, outputTokens: 5, totalTokens: 35 } }); + expect(actual.at(-1)).toMatchObject({ usage: { inputTokens: 30, outputTokens: 5, totalTokens: 35, providerCredits: 0.05 } }); }); diff --git a/tests/usage/key-attribution.test.ts b/tests/usage/key-attribution.test.ts index 9b2c096b674..70dd335b7a0 100644 --- a/tests/usage/key-attribution.test.ts +++ b/tests/usage/key-attribution.test.ts @@ -17,14 +17,17 @@ describe("key attempt accounting", () => { const key = (reference: string) => ({ adapter: "openai-chat" as const, authMode: "key" as const, baseUrl: "https://example.test", _apiKeyAttempt: { reference } }); noteProviderAttemptSend(child, "test", key("synthetic-a"), undefined); - recordKeyAttemptUsage(child, { inputTokens: 100, outputTokens: 10 }); + recordKeyAttemptUsage(child, { inputTokens: 100, outputTokens: 10, providerCredits: 0.04 }); const parent = { ...child }; noteProviderAttemptSend(child, "test", key("synthetic-b"), undefined, "key-429"); - recordKeyAttemptUsage(child, { inputTokens: 200, outputTokens: 20 }); + recordKeyAttemptUsage(child, { inputTokens: 200, outputTokens: 20, providerCredits: 0.01 }); const rows: RequestLogEntry[] = []; addFinalRequestLog("stream-key-switch", Date.now(), parent, 200, undefined, row => rows.push(row)); expect(rows[0].attempts?.map(attempt => attempt.usage?.inputTokens)).toEqual([100, 200]); - expect(rows[0].usage).toMatchObject({ inputTokens: 300, outputTokens: 30 }); + expect(rows[0].usage).toMatchObject({ inputTokens: 300, outputTokens: 30, providerCredits: 0.05 }); + const persisted = normalizeUsageEntryForTest({ ...rows[0], timestamp: 1, durationMs: 1 }); + expect(persisted.usage?.providerCredits).toBe(0.05); + expect(persisted.attempts?.map(attempt => attempt.usage?.providerCredits)).toEqual([0.04, 0.01]); }); test("a reader takes the attempts or the request total, never both", () => {