diff --git a/src/agents/cli-output-contracts.ts b/src/agents/cli-output-contracts.ts index c9a2962d6cae..2463146f66be 100644 --- a/src/agents/cli-output-contracts.ts +++ b/src/agents/cli-output-contracts.ts @@ -1,17 +1,15 @@ -import type { CliBackendConfig, CliBackendParseJsonlEvent } from "../plugins/cli-backend.types.js"; +import type { + CliBackendConfig, + CliBackendJsonlUsage, + CliBackendParseJsonlEvent, +} from "../plugins/cli-backend.types.js"; import type { MessagingToolSend, MessagingToolSourceReplyPayload, } from "./embedded-agent-messaging.types.js"; import type { ToolSummaryTrace } from "./embedded-agent-runner/types.js"; -export type CliUsage = { - input?: number; - output?: number; - cacheRead?: number; - cacheWrite?: number; - total?: number; -}; +export type CliUsage = CliBackendJsonlUsage; type CliProcessDiagnostics = { backendId: string; diff --git a/src/agents/cli-output-records.test.ts b/src/agents/cli-output-records.test.ts index df91bea40dc7..1d0a2d2e1736 100644 --- a/src/agents/cli-output-records.test.ts +++ b/src/agents/cli-output-records.test.ts @@ -65,6 +65,17 @@ const OPENAI_COMPATIBLE_CLI_USAGE_CASES = [ }, normalized: { input: 0, output: 10, cacheRead: 40, cacheWrite: 60, total: undefined }, }, + { + name: "all-zero token fields are treated as absent usage", + raw: { + input_tokens: 0, + output_tokens: 0, + cached_input_tokens: 0, + cache_write_input_tokens: 0, + total_tokens: 0, + }, + normalized: undefined, + }, ] as const; function parseCliJson(raw: string, backend: ParseCliOutputParams["backend"], providerId = "") { diff --git a/src/agents/cli-output-records.ts b/src/agents/cli-output-records.ts index d792968cb5df..b6fb67236f19 100644 --- a/src/agents/cli-output-records.ts +++ b/src/agents/cli-output-records.ts @@ -3,6 +3,7 @@ import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; import type { CliBackendConfig } from "../plugins/cli-backend.types.js"; import type { CliOutput, CliTerminalFailure, CliUsage } from "./cli-output-contracts.js"; +import { normalizeUsage, type UsageLike } from "./usage.js"; function isClaudeCliProvider(providerId: string): boolean { return normalizeLowercaseStringOrEmpty(providerId) === "claude-cli"; @@ -130,77 +131,41 @@ function unwrapCliErrorText(raw: string): string { return trimmed; } -function toCliUsage(raw: Record): CliUsage | undefined { - const readNestedCached = ( - key: "input_tokens_details" | "prompt_tokens_details", - field: "cached_tokens" | "cache_write_tokens" = "cached_tokens", - ) => { - const nested = raw[key]; - if (!isRecord(nested)) { - return undefined; - } - return typeof nested[field] === "number" && nested[field] > 0 ? nested[field] : undefined; - }; - const pick = (key: string) => - typeof raw[key] === "number" && raw[key] > 0 ? raw[key] : undefined; - // Chat Completions calls these prompt/completion tokens; preserve existing CLI-field precedence. - const totalInput = - pick("input_tokens") ?? pick("inputTokens") ?? pick("prompt_tokens") ?? pick("promptTokens"); - const output = - pick("output_tokens") ?? - pick("outputTokens") ?? - pick("completion_tokens") ?? - pick("completionTokens"); - const nestedCached = - readNestedCached("input_tokens_details") ?? readNestedCached("prompt_tokens_details"); - const cacheRead = - pick("cache_read_input_tokens") ?? - pick("cached_input_tokens") ?? - pick("cacheRead") ?? - pick("cached") ?? - nestedCached; - const nestedCacheWrite = - readNestedCached("input_tokens_details", "cache_write_tokens") ?? - readNestedCached("prompt_tokens_details", "cache_write_tokens"); - const cacheWrite = - pick("cache_creation_input_tokens") ?? - pick("cache_write_input_tokens") ?? - pick("cacheWrite") ?? - nestedCacheWrite; - const input = - pick("input") ?? - ((Object.hasOwn(raw, "cached") || - Object.hasOwn(raw, "cached_input_tokens") || - Object.hasOwn(raw, "cache_write_input_tokens") || - nestedCached !== undefined || - nestedCacheWrite !== undefined) && - typeof totalInput === "number" - ? Math.max(0, totalInput - (cacheRead ?? 0) - (cacheWrite ?? 0)) - : totalInput); - const total = pick("total_tokens") ?? pick("total"); - if (!input && !output && !cacheRead && !cacheWrite && !total) { +function normalizeCliUsageRecord(raw: unknown): CliUsage | undefined { + if (!isRecord(raw)) { return undefined; } - return { input, output, cacheRead, cacheWrite, total }; + const usageRaw = raw as UsageLike; + const usage = normalizeUsage(usageRaw); + if (!usage) { + return undefined; + } + const reportedInputTotal = [ + usageRaw.inputTokens, + usageRaw.input_tokens, + usageRaw.promptTokens, + usageRaw.prompt_tokens, + ].some((value) => typeof value === "number" && value > 0); + const cacheAdjustedInput = + usage.input === 0 && reportedInputTotal && Boolean(usage.cacheRead || usage.cacheWrite); + const cliUsage: CliUsage = { + input: cacheAdjustedInput ? 0 : usage.input || undefined, + output: usage.output || undefined, + cacheRead: usage.cacheRead || undefined, + cacheWrite: usage.cacheWrite || undefined, + total: usage.total || undefined, + }; + return Object.values(cliUsage).some((value) => typeof value === "number" && value > 0) + ? cliUsage + : undefined; } export function readCliUsage(parsed: Record): CliUsage | undefined { - if (isRecord(parsed.message) && isRecord(parsed.message.usage)) { - const usage = toCliUsage(parsed.message.usage); - if (usage) { - return usage; - } - } - if (isRecord(parsed.usage)) { - const usage = toCliUsage(parsed.usage); - if (usage) { - return usage; - } - } - if (isRecord(parsed.stats)) { - return toCliUsage(parsed.stats); - } - return undefined; + return ( + normalizeCliUsageRecord(isRecord(parsed.message) ? parsed.message.usage : undefined) ?? + normalizeCliUsageRecord(parsed.usage) ?? + normalizeCliUsageRecord(parsed.stats) + ); } function collectCliText(value: unknown): string { diff --git a/src/agents/usage.test.ts b/src/agents/usage.test.ts index afc3958159c7..60b71471efb9 100644 --- a/src/agents/usage.test.ts +++ b/src/agents/usage.test.ts @@ -136,6 +136,50 @@ describe("normalizeUsage", () => { }); }); + it.each([ + { + name: "flat CLI cache fields", + raw: { + input_tokens: 100, + output_tokens: 10, + cached_input_tokens: 40, + cache_write_input_tokens: 60, + }, + }, + { + name: "nested CLI cache fields", + raw: { + input_tokens: 100, + output_tokens: 10, + input_tokens_details: { cached_tokens: 40, cache_write_tokens: 60 }, + }, + }, + ])("normalizes $name without double-counting input", ({ raw }) => { + expect(normalizeUsage(raw)).toEqual({ + input: 0, + output: 10, + cacheRead: 40, + cacheWrite: 60, + total: undefined, + }); + }); + + it("preserves Gemini CLI's explicit uncached input", () => { + const raw = { + input: 5, + input_tokens: 13, + output_tokens: 5, + cached: 8, + }; + expect(normalizeUsage(raw)).toEqual({ + input: 5, + output: 5, + cacheRead: 8, + cacheWrite: undefined, + total: undefined, + }); + }); + it("handles OpenAI Chat Completions reasoning token details", () => { const usage = normalizeUsage({ prompt_tokens: 120, diff --git a/src/agents/usage.ts b/src/agents/usage.ts index 649666cc9368..2dfe241dc094 100644 --- a/src/agents/usage.ts +++ b/src/agents/usage.ts @@ -53,6 +53,14 @@ export type UsageLike = { cost?: Partial; }; +type CliUsageAliases = { + cached_input_tokens?: number; + cache_write_input_tokens?: number; + cached?: number; + input_tokens_details?: { cached_tokens?: number; cache_write_tokens?: number }; + prompt_tokens_details?: { cached_tokens?: number; cache_write_tokens?: number }; +}; + /** Normalized token counts used by runtime accounting. */ export type NormalizedUsage = { input?: number; @@ -142,16 +150,28 @@ export function normalizeUsage(raw?: UsageLike | null): NormalizedUsage | undefi if (!raw) { return undefined; } + const cli = raw as UsageLike & CliUsageAliases; const cacheRead = normalizeTokenCount( raw.cacheRead ?? raw.cache_read ?? raw.cache_read_input_tokens ?? + cli.cached_input_tokens ?? + cli.cached ?? raw.cached_tokens ?? raw.input_tokens_details?.cached_tokens ?? raw.prompt_tokens_details?.cached_tokens, ); + const cacheWrite = normalizeTokenCount( + raw.cacheWrite ?? + raw.cache_write ?? + raw.cache_creation_input_tokens ?? + cli.cache_write_input_tokens ?? + cli.input_tokens_details?.cache_write_tokens ?? + cli.prompt_tokens_details?.cache_write_tokens, + ); + const directInput = asFiniteNumber(raw.input); const rawInputValue = raw.input ?? raw.inputTokens ?? @@ -161,20 +181,30 @@ export function normalizeUsage(raw?: UsageLike | null): NormalizedUsage | undefi raw.prompt_n ?? raw.timings?.prompt_n; - const usesOpenAIStylePromptTotals = + const cliCacheReadIncludedInInput = + cli.cached_input_tokens !== undefined || cli.cached !== undefined; + const openAiCacheReadIncludedInInput = raw.cached_tokens !== undefined || raw.input_tokens_details?.cached_tokens !== undefined || raw.prompt_tokens_details?.cached_tokens !== undefined; + const cacheWriteIncludedInInput = + cli.cache_write_input_tokens !== undefined || + cli.input_tokens_details?.cache_write_tokens !== undefined || + cli.prompt_tokens_details?.cache_write_tokens !== undefined; // Some providers (shared model runtime OpenAI-format) pre-subtract cached_tokens from // prompt/input totals upstream, while OpenAI-style prompt/input aliases // include cached tokens in the reported prompt total. Normalize both cases // to uncached input tokens so downstream prompt-token math does not double- - // count cache reads. + // count cache reads or writes. const rawInput = asFiniteNumber(rawInputValue); + const subtractCacheRead = + openAiCacheReadIncludedInInput || (directInput === undefined && cliCacheReadIncludedInInput); const normalizedInput = - rawInput !== undefined && usesOpenAIStylePromptTotals && cacheRead !== undefined - ? rawInput - cacheRead + rawInput !== undefined + ? rawInput - + (subtractCacheRead ? (cacheRead ?? 0) : 0) - + (directInput === undefined && cacheWriteIncludedInInput ? (cacheWrite ?? 0) : 0) : rawInput; const input = normalizeTokenCount(normalizedInput); const output = normalizeTokenCount( @@ -186,9 +216,6 @@ export function normalizeUsage(raw?: UsageLike | null): NormalizedUsage | undefi raw.predicted_n ?? raw.timings?.predicted_n, ); - const cacheWrite = normalizeTokenCount( - raw.cacheWrite ?? raw.cache_write ?? raw.cache_creation_input_tokens, - ); const contextPromptTokens = raw.contextUsage?.state === "available" ? normalizeTokenCount(raw.contextUsage.promptTokens)