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:
parent
22b30e2c5b
commit
b9bcae3a11
@ -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: {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user