mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
refactor(agents): consolidate CLI usage normalization (#121873)
This commit is contained in:
committed by
GitHub
parent
f95fe8fb5d
commit
e7350b4ac6
@@ -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;
|
||||
|
||||
@@ -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 = "") {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user