fix(cli): correct token context calculation for CLI providers

CLI providers (Claude CLI) report cache_read_input_tokens as the full
cached context for each turn, unlike the embedded/API flow. This change
adds CLI-aware token calculation that uses the correct formula:
cacheRead + cacheWrite + input = total context tokens.

- Add deriveCliContextTokens() for CLI-specific calculation
- Apply CLI detection via isCliProvider() before calculating
- Update session-usage.ts, status.ts, session-utils.fs.ts to use
  CLI-aware calculation
- Add verbose logging for token flow debugging
This commit is contained in:
Ross 2026-01-27 11:47:57 +00:00
parent 22b30e2c5b
commit b9bcae3a11
7 changed files with 123 additions and 18 deletions

View File

@ -261,6 +261,13 @@ export async function runCliAgent(params: {
);
}
log.info(
`[cli-runner] streaming result usage: input=${streamResult.usage?.input} ` +
`output=${streamResult.usage?.output} cacheRead=${streamResult.usage?.cacheRead} ` +
`cacheWrite=${streamResult.usage?.cacheWrite} total=${streamResult.usage?.total} ` +
`sessionId=${streamResult.sessionId}`,
);
return {
text: streamResult.text,
sessionId: streamResult.sessionId,
@ -327,6 +334,12 @@ export async function runCliAgent(params: {
const text = output.text?.trim();
const payloads = text ? [{ text }] : undefined;
log.info(
`[cli-runner] FINAL agentMeta.usage: input=${output.usage?.input} ` +
`output=${output.usage?.output} cacheRead=${output.usage?.cacheRead} ` +
`cacheWrite=${output.usage?.cacheWrite} total=${output.usage?.total}`,
);
return {
payloads,
meta: {

View File

@ -93,3 +93,22 @@ export function derivePromptTokens(usage?: {
const sum = input + cacheRead + cacheWrite;
return sum > 0 ? sum : undefined;
}
/**
* CLI-specific context token calculation.
* For CLI, we use the latest turn's values directly (not accumulated across turns)
* since cache_read_input_tokens already represents the full cached context.
* Formula: cacheRead + cacheWrite + input = total context for this turn
*/
export function deriveCliContextTokens(usage?: {
input?: number;
cacheRead?: number;
cacheWrite?: number;
}): number | undefined {
if (!usage) return undefined;
const input = usage.input ?? 0;
const cacheRead = usage.cacheRead ?? 0;
const cacheWrite = usage.cacheWrite ?? 0;
const sum = input + cacheRead + cacheWrite;
return sum > 0 ? sum : undefined;
}

View File

@ -211,6 +211,11 @@ export async function runAgentTurnWithFallback(params: {
// CLI backends bypass SessionManager, so persist response to transcript
// directly to ensure it's available on TUI reload.
// Record even empty responses to maintain message turn consistency.
const usageForTranscript = result.meta.agentMeta?.usage;
logVerbose(
`[agent-runner-execution] CLI transcript persist: usage=${JSON.stringify(usageForTranscript)} ` +
`provider=${provider} model=${model}`,
);
const assistantPersistResult = appendAssistantMessageToTranscript({
message: cliText || "",
sessionId: params.followupRun.run.sessionId,
@ -219,7 +224,7 @@ export async function runAgentTurnWithFallback(params: {
createIfMissing: true,
provider,
model,
usage: result.meta.agentMeta?.usage,
usage: usageForTranscript,
});
if (!assistantPersistResult.ok) {
logVerbose(

View File

@ -41,6 +41,7 @@ import { incrementCompactionCount } from "./session-updates.js";
import type { TypingController } from "./typing.js";
import { createTypingSignaler } from "./typing-mode.js";
import { emitDiagnosticEvent, isDiagnosticsEnabled } from "../../infra/diagnostic-events.js";
import { logVerbose } from "../../globals.js";
const BLOCK_REPLY_SEND_TIMEOUT_MS = 15_000;
@ -377,6 +378,12 @@ export async function runReplyAgent(params: {
activeSessionEntry?.contextTokens ??
DEFAULT_CONTEXT_TOKENS;
logVerbose(
`[agent-runner] usage from runResult: input=${usage?.input} output=${usage?.output} ` +
`cacheRead=${usage?.cacheRead} cacheWrite=${usage?.cacheWrite} total=${usage?.total} ` +
`provider=${providerUsed} model=${modelUsed} cliSessionId=${cliSessionId}`,
);
await persistSessionUsageUpdate({
storePath,
sessionKey,

View File

@ -1,5 +1,10 @@
import { setCliSessionId } from "../../agents/cli-session.js";
import { hasNonzeroUsage, type NormalizedUsage } from "../../agents/usage.js";
import { isCliProvider } from "../../agents/model-selection.js";
import {
deriveCliContextTokens,
hasNonzeroUsage,
type NormalizedUsage,
} from "../../agents/usage.js";
import {
type SessionSystemPromptReport,
type SessionEntry,
@ -19,10 +24,13 @@ export async function persistSessionUsageUpdate(params: {
logLabel?: string;
}): Promise<void> {
const { storePath, sessionKey } = params;
if (!storePath || !sessionKey) return;
if (!storePath || !sessionKey) {
return;
}
const label = params.logLabel ? `${params.logLabel} ` : "";
if (hasNonzeroUsage(params.usage)) {
const hasUsage = hasNonzeroUsage(params.usage);
if (hasUsage) {
try {
await updateSessionStoreEntry({
storePath,
@ -30,12 +38,18 @@ export async function persistSessionUsageUpdate(params: {
update: async (entry) => {
const input = params.usage?.input ?? 0;
const output = params.usage?.output ?? 0;
const promptTokens =
input + (params.usage?.cacheRead ?? 0) + (params.usage?.cacheWrite ?? 0);
const cacheRead = params.usage?.cacheRead ?? 0;
const cacheWrite = params.usage?.cacheWrite ?? 0;
// For CLI providers, use the full context for this turn (cacheRead + cacheWrite + input)
const isCli = isCliProvider(params.providerUsed ?? "", undefined);
const promptTokens = isCli
? (deriveCliContextTokens(params.usage) ?? input)
: input + cacheRead + cacheWrite;
const newTotalTokens = promptTokens > 0 ? promptTokens : (params.usage?.total ?? input);
const patch: Partial<SessionEntry> = {
inputTokens: input,
outputTokens: output,
totalTokens: promptTokens > 0 ? promptTokens : (params.usage?.total ?? input),
totalTokens: newTotalTokens,
modelProvider: params.providerUsed ?? entry.modelProvider,
model: params.modelUsed ?? entry.model,
contextTokens: params.contextTokensUsed ?? entry.contextTokens,

View File

@ -3,9 +3,14 @@ import fs from "node:fs";
import { lookupContextTokens } from "../agents/context.js";
import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js";
import { resolveModelAuthMode } from "../agents/model-auth.js";
import { resolveConfiguredModelRef } from "../agents/model-selection.js";
import { isCliProvider, resolveConfiguredModelRef } from "../agents/model-selection.js";
import { resolveSandboxRuntimeStatus } from "../agents/sandbox.js";
import { derivePromptTokens, normalizeUsage, type UsageLike } from "../agents/usage.js";
import {
deriveCliContextTokens,
derivePromptTokens,
normalizeUsage,
type UsageLike,
} from "../agents/usage.js";
import type { ClawdbotConfig } from "../config/config.js";
import {
resolveMainSessionKey,
@ -29,6 +34,7 @@ import {
resolveModelCostConfig,
} from "../utils/usage-format.js";
import { VERSION } from "../version.js";
import { logVerbose } from "../globals.js";
import { listChatCommands, listChatCommandsForConfig } from "./commands-registry.js";
import { listPluginCommands } from "../plugins/commands.js";
import type { SkillCommandSpec } from "../agents/skills.js";
@ -155,6 +161,7 @@ const formatQueueDetails = (queue?: QueueStatus) => {
const readUsageFromSessionLog = (
sessionId?: string,
sessionEntry?: SessionEntry,
provider?: string,
):
| {
input: number;
@ -165,17 +172,26 @@ const readUsageFromSessionLog = (
}
| undefined => {
// Transcripts are stored at the session file path (fallback: ~/.clawdbot/sessions/<SessionId>.jsonl)
if (!sessionId) return undefined;
if (!sessionId) {
logVerbose(`[status/readUsageFromSessionLog] no sessionId, returning undefined`);
return undefined;
}
const logPath = resolveSessionFilePath(sessionId, sessionEntry);
if (!fs.existsSync(logPath)) return undefined;
logVerbose(`[status/readUsageFromSessionLog] reading transcript: ${logPath}`);
if (!fs.existsSync(logPath)) {
logVerbose(`[status/readUsageFromSessionLog] transcript file not found`);
return undefined;
}
try {
const lines = fs.readFileSync(logPath, "utf-8").split(/\n+/);
logVerbose(`[status/readUsageFromSessionLog] transcript has ${lines.length} lines`);
let input = 0;
let output = 0;
let promptTokens = 0;
let model: string | undefined;
let lastUsage: ReturnType<typeof normalizeUsage> | undefined;
let usageEntriesFound = 0;
for (const line of lines) {
if (!line.trim()) continue;
@ -190,18 +206,37 @@ const readUsageFromSessionLog = (
};
const usageRaw = parsed.message?.usage ?? parsed.usage;
const usage = normalizeUsage(usageRaw);
if (usage) lastUsage = usage;
if (usage) {
usageEntriesFound++;
logVerbose(
`[status/readUsageFromSessionLog] entry #${usageEntriesFound}: ` +
`input=${usage.input} output=${usage.output} cacheRead=${usage.cacheRead} ` +
`cacheWrite=${usage.cacheWrite} total=${usage.total}`,
);
lastUsage = usage;
}
model = parsed.message?.model ?? parsed.model ?? model;
} catch {
// ignore bad lines
}
}
if (!lastUsage) return undefined;
logVerbose(`[status/readUsageFromSessionLog] found ${usageEntriesFound} usage entries`);
if (!lastUsage) {
logVerbose(`[status/readUsageFromSessionLog] no lastUsage found, returning undefined`);
return undefined;
}
input = lastUsage.input ?? 0;
output = lastUsage.output ?? 0;
promptTokens = derivePromptTokens(lastUsage) ?? lastUsage.total ?? input + output;
// For CLI providers, use full context for this turn (cacheRead + cacheWrite + input)
const isCli = isCliProvider(provider ?? "", undefined);
promptTokens = isCli
? (deriveCliContextTokens(lastUsage) ?? lastUsage.total ?? input + output)
: (derivePromptTokens(lastUsage) ?? lastUsage.total ?? input + output);
const total = lastUsage.total ?? promptTokens + output;
logVerbose(
`[status/readUsageFromSessionLog] lastUsage: input=${lastUsage.input} output=${lastUsage.output} cacheRead=${lastUsage.cacheRead} cacheWrite=${lastUsage.cacheWrite} total=${lastUsage.total} → derived promptTokens=${promptTokens} total=${total}`,
);
if (promptTokens === 0 && total === 0) return undefined;
return { input, output, promptTokens, total, model };
} catch {
@ -299,9 +334,12 @@ export function buildStatusMessage(args: StatusArgs): string {
// Prefer prompt-size tokens from the session transcript when it looks larger
// (cached prompt tokens are often missing from agent meta/store).
if (args.includeTranscriptUsage) {
const logUsage = readUsageFromSessionLog(entry?.sessionId, entry);
const logUsage = readUsageFromSessionLog(entry?.sessionId, entry, provider);
if (logUsage) {
const candidate = logUsage.promptTokens || logUsage.total;
logVerbose(
`[status] transcript: promptTokens=${logUsage.promptTokens} total=${logUsage.total} candidate=${candidate} currentTotal=${totalTokens} willReplace=${!totalTokens || totalTokens === 0 || candidate > totalTokens}`,
);
if (!totalTokens || totalTokens === 0 || candidate > totalTokens) {
totalTokens = candidate;
}

View File

@ -4,7 +4,8 @@ import os from "node:os";
import path from "node:path";
import { CURRENT_SESSION_VERSION } from "@mariozechner/pi-coding-agent";
import type { NormalizedUsage } from "../agents/usage.js";
import { isCliProvider } from "../agents/model-selection.js";
import { deriveCliContextTokens, type NormalizedUsage } from "../agents/usage.js";
import { resolveSessionTranscriptPath } from "../config/sessions.js";
import { stripEnvelope } from "./chat-sanitize.js";
import type { SessionPreviewItem } from "./session-utils.types.js";
@ -141,13 +142,21 @@ function appendMessageToTranscriptImpl(
if (params.role === "assistant") {
messageBody.stopReason = params.stopReason ?? "cli_backend";
const u = params.usage;
messageBody.usage = {
// For CLI providers, use CLI-specific calculation (cacheRead + cacheWrite + input)
// representing the full context for this turn
const isCli = isCliProvider(params.provider ?? "", undefined);
const derivedTotal = isCli
? (deriveCliContextTokens(u) ?? 0)
: (u?.input ?? 0) + (u?.cacheRead ?? 0) + (u?.cacheWrite ?? 0);
const usageToWrite = {
input: u?.input ?? 0,
output: u?.output ?? 0,
cacheRead: u?.cacheRead,
cacheWrite: u?.cacheWrite,
totalTokens: u?.total ?? (u?.input ?? 0) + (u?.output ?? 0),
totalTokens:
u?.total ?? (derivedTotal > 0 ? derivedTotal : (u?.input ?? 0) + (u?.output ?? 0)),
};
messageBody.usage = usageToWrite;
if (params.provider) messageBody.provider = params.provider;
if (params.model) messageBody.model = params.model;
}