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 {
|
return {
|
||||||
text: streamResult.text,
|
text: streamResult.text,
|
||||||
sessionId: streamResult.sessionId,
|
sessionId: streamResult.sessionId,
|
||||||
@ -327,6 +334,12 @@ export async function runCliAgent(params: {
|
|||||||
const text = output.text?.trim();
|
const text = output.text?.trim();
|
||||||
const payloads = text ? [{ text }] : undefined;
|
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 {
|
return {
|
||||||
payloads,
|
payloads,
|
||||||
meta: {
|
meta: {
|
||||||
|
|||||||
@ -93,3 +93,22 @@ export function derivePromptTokens(usage?: {
|
|||||||
const sum = input + cacheRead + cacheWrite;
|
const sum = input + cacheRead + cacheWrite;
|
||||||
return sum > 0 ? sum : undefined;
|
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
|
// CLI backends bypass SessionManager, so persist response to transcript
|
||||||
// directly to ensure it's available on TUI reload.
|
// directly to ensure it's available on TUI reload.
|
||||||
// Record even empty responses to maintain message turn consistency.
|
// 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({
|
const assistantPersistResult = appendAssistantMessageToTranscript({
|
||||||
message: cliText || "",
|
message: cliText || "",
|
||||||
sessionId: params.followupRun.run.sessionId,
|
sessionId: params.followupRun.run.sessionId,
|
||||||
@ -219,7 +224,7 @@ export async function runAgentTurnWithFallback(params: {
|
|||||||
createIfMissing: true,
|
createIfMissing: true,
|
||||||
provider,
|
provider,
|
||||||
model,
|
model,
|
||||||
usage: result.meta.agentMeta?.usage,
|
usage: usageForTranscript,
|
||||||
});
|
});
|
||||||
if (!assistantPersistResult.ok) {
|
if (!assistantPersistResult.ok) {
|
||||||
logVerbose(
|
logVerbose(
|
||||||
|
|||||||
@ -41,6 +41,7 @@ import { incrementCompactionCount } from "./session-updates.js";
|
|||||||
import type { TypingController } from "./typing.js";
|
import type { TypingController } from "./typing.js";
|
||||||
import { createTypingSignaler } from "./typing-mode.js";
|
import { createTypingSignaler } from "./typing-mode.js";
|
||||||
import { emitDiagnosticEvent, isDiagnosticsEnabled } from "../../infra/diagnostic-events.js";
|
import { emitDiagnosticEvent, isDiagnosticsEnabled } from "../../infra/diagnostic-events.js";
|
||||||
|
import { logVerbose } from "../../globals.js";
|
||||||
|
|
||||||
const BLOCK_REPLY_SEND_TIMEOUT_MS = 15_000;
|
const BLOCK_REPLY_SEND_TIMEOUT_MS = 15_000;
|
||||||
|
|
||||||
@ -377,6 +378,12 @@ export async function runReplyAgent(params: {
|
|||||||
activeSessionEntry?.contextTokens ??
|
activeSessionEntry?.contextTokens ??
|
||||||
DEFAULT_CONTEXT_TOKENS;
|
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({
|
await persistSessionUsageUpdate({
|
||||||
storePath,
|
storePath,
|
||||||
sessionKey,
|
sessionKey,
|
||||||
|
|||||||
@ -1,5 +1,10 @@
|
|||||||
import { setCliSessionId } from "../../agents/cli-session.js";
|
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 {
|
import {
|
||||||
type SessionSystemPromptReport,
|
type SessionSystemPromptReport,
|
||||||
type SessionEntry,
|
type SessionEntry,
|
||||||
@ -19,10 +24,13 @@ export async function persistSessionUsageUpdate(params: {
|
|||||||
logLabel?: string;
|
logLabel?: string;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const { storePath, sessionKey } = params;
|
const { storePath, sessionKey } = params;
|
||||||
if (!storePath || !sessionKey) return;
|
if (!storePath || !sessionKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const label = params.logLabel ? `${params.logLabel} ` : "";
|
const label = params.logLabel ? `${params.logLabel} ` : "";
|
||||||
if (hasNonzeroUsage(params.usage)) {
|
const hasUsage = hasNonzeroUsage(params.usage);
|
||||||
|
if (hasUsage) {
|
||||||
try {
|
try {
|
||||||
await updateSessionStoreEntry({
|
await updateSessionStoreEntry({
|
||||||
storePath,
|
storePath,
|
||||||
@ -30,12 +38,18 @@ export async function persistSessionUsageUpdate(params: {
|
|||||||
update: async (entry) => {
|
update: async (entry) => {
|
||||||
const input = params.usage?.input ?? 0;
|
const input = params.usage?.input ?? 0;
|
||||||
const output = params.usage?.output ?? 0;
|
const output = params.usage?.output ?? 0;
|
||||||
const promptTokens =
|
const cacheRead = params.usage?.cacheRead ?? 0;
|
||||||
input + (params.usage?.cacheRead ?? 0) + (params.usage?.cacheWrite ?? 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> = {
|
const patch: Partial<SessionEntry> = {
|
||||||
inputTokens: input,
|
inputTokens: input,
|
||||||
outputTokens: output,
|
outputTokens: output,
|
||||||
totalTokens: promptTokens > 0 ? promptTokens : (params.usage?.total ?? input),
|
totalTokens: newTotalTokens,
|
||||||
modelProvider: params.providerUsed ?? entry.modelProvider,
|
modelProvider: params.providerUsed ?? entry.modelProvider,
|
||||||
model: params.modelUsed ?? entry.model,
|
model: params.modelUsed ?? entry.model,
|
||||||
contextTokens: params.contextTokensUsed ?? entry.contextTokens,
|
contextTokens: params.contextTokensUsed ?? entry.contextTokens,
|
||||||
|
|||||||
@ -3,9 +3,14 @@ import fs from "node:fs";
|
|||||||
import { lookupContextTokens } from "../agents/context.js";
|
import { lookupContextTokens } from "../agents/context.js";
|
||||||
import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js";
|
import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js";
|
||||||
import { resolveModelAuthMode } from "../agents/model-auth.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 { 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 type { ClawdbotConfig } from "../config/config.js";
|
||||||
import {
|
import {
|
||||||
resolveMainSessionKey,
|
resolveMainSessionKey,
|
||||||
@ -29,6 +34,7 @@ import {
|
|||||||
resolveModelCostConfig,
|
resolveModelCostConfig,
|
||||||
} from "../utils/usage-format.js";
|
} from "../utils/usage-format.js";
|
||||||
import { VERSION } from "../version.js";
|
import { VERSION } from "../version.js";
|
||||||
|
import { logVerbose } from "../globals.js";
|
||||||
import { listChatCommands, listChatCommandsForConfig } from "./commands-registry.js";
|
import { listChatCommands, listChatCommandsForConfig } from "./commands-registry.js";
|
||||||
import { listPluginCommands } from "../plugins/commands.js";
|
import { listPluginCommands } from "../plugins/commands.js";
|
||||||
import type { SkillCommandSpec } from "../agents/skills.js";
|
import type { SkillCommandSpec } from "../agents/skills.js";
|
||||||
@ -155,6 +161,7 @@ const formatQueueDetails = (queue?: QueueStatus) => {
|
|||||||
const readUsageFromSessionLog = (
|
const readUsageFromSessionLog = (
|
||||||
sessionId?: string,
|
sessionId?: string,
|
||||||
sessionEntry?: SessionEntry,
|
sessionEntry?: SessionEntry,
|
||||||
|
provider?: string,
|
||||||
):
|
):
|
||||||
| {
|
| {
|
||||||
input: number;
|
input: number;
|
||||||
@ -165,17 +172,26 @@ const readUsageFromSessionLog = (
|
|||||||
}
|
}
|
||||||
| undefined => {
|
| undefined => {
|
||||||
// Transcripts are stored at the session file path (fallback: ~/.clawdbot/sessions/<SessionId>.jsonl)
|
// 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);
|
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 {
|
try {
|
||||||
const lines = fs.readFileSync(logPath, "utf-8").split(/\n+/);
|
const lines = fs.readFileSync(logPath, "utf-8").split(/\n+/);
|
||||||
|
logVerbose(`[status/readUsageFromSessionLog] transcript has ${lines.length} lines`);
|
||||||
let input = 0;
|
let input = 0;
|
||||||
let output = 0;
|
let output = 0;
|
||||||
let promptTokens = 0;
|
let promptTokens = 0;
|
||||||
let model: string | undefined;
|
let model: string | undefined;
|
||||||
let lastUsage: ReturnType<typeof normalizeUsage> | undefined;
|
let lastUsage: ReturnType<typeof normalizeUsage> | undefined;
|
||||||
|
let usageEntriesFound = 0;
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
if (!line.trim()) continue;
|
if (!line.trim()) continue;
|
||||||
@ -190,18 +206,37 @@ const readUsageFromSessionLog = (
|
|||||||
};
|
};
|
||||||
const usageRaw = parsed.message?.usage ?? parsed.usage;
|
const usageRaw = parsed.message?.usage ?? parsed.usage;
|
||||||
const usage = normalizeUsage(usageRaw);
|
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;
|
model = parsed.message?.model ?? parsed.model ?? model;
|
||||||
} catch {
|
} catch {
|
||||||
// ignore bad lines
|
// 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;
|
input = lastUsage.input ?? 0;
|
||||||
output = lastUsage.output ?? 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;
|
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;
|
if (promptTokens === 0 && total === 0) return undefined;
|
||||||
return { input, output, promptTokens, total, model };
|
return { input, output, promptTokens, total, model };
|
||||||
} catch {
|
} catch {
|
||||||
@ -299,9 +334,12 @@ export function buildStatusMessage(args: StatusArgs): string {
|
|||||||
// Prefer prompt-size tokens from the session transcript when it looks larger
|
// Prefer prompt-size tokens from the session transcript when it looks larger
|
||||||
// (cached prompt tokens are often missing from agent meta/store).
|
// (cached prompt tokens are often missing from agent meta/store).
|
||||||
if (args.includeTranscriptUsage) {
|
if (args.includeTranscriptUsage) {
|
||||||
const logUsage = readUsageFromSessionLog(entry?.sessionId, entry);
|
const logUsage = readUsageFromSessionLog(entry?.sessionId, entry, provider);
|
||||||
if (logUsage) {
|
if (logUsage) {
|
||||||
const candidate = logUsage.promptTokens || logUsage.total;
|
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) {
|
if (!totalTokens || totalTokens === 0 || candidate > totalTokens) {
|
||||||
totalTokens = candidate;
|
totalTokens = candidate;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,7 +4,8 @@ import os from "node:os";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
import { CURRENT_SESSION_VERSION } from "@mariozechner/pi-coding-agent";
|
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 { resolveSessionTranscriptPath } from "../config/sessions.js";
|
||||||
import { stripEnvelope } from "./chat-sanitize.js";
|
import { stripEnvelope } from "./chat-sanitize.js";
|
||||||
import type { SessionPreviewItem } from "./session-utils.types.js";
|
import type { SessionPreviewItem } from "./session-utils.types.js";
|
||||||
@ -141,13 +142,21 @@ function appendMessageToTranscriptImpl(
|
|||||||
if (params.role === "assistant") {
|
if (params.role === "assistant") {
|
||||||
messageBody.stopReason = params.stopReason ?? "cli_backend";
|
messageBody.stopReason = params.stopReason ?? "cli_backend";
|
||||||
const u = params.usage;
|
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,
|
input: u?.input ?? 0,
|
||||||
output: u?.output ?? 0,
|
output: u?.output ?? 0,
|
||||||
cacheRead: u?.cacheRead,
|
cacheRead: u?.cacheRead,
|
||||||
cacheWrite: u?.cacheWrite,
|
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.provider) messageBody.provider = params.provider;
|
||||||
if (params.model) messageBody.model = params.model;
|
if (params.model) messageBody.model = params.model;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user