refactor(agents): consolidate CLI usage normalization (#121873)

This commit is contained in:
Peter Steinberger
2026-08-10 23:07:53 -07:00
committed by GitHub
parent f95fe8fb5d
commit e7350b4ac6
5 changed files with 126 additions and 81 deletions
+6 -8
View File
@@ -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;
+11
View File
@@ -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 = "") {
+31 -66
View File
@@ -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<string, unknown>): 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<string, unknown>): 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 {
+44
View File
@@ -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,
+34 -7
View File
@@ -53,6 +53,14 @@ export type UsageLike = {
cost?: Partial<Usage["cost"]>;
};
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)