diff --git a/CHANGELOG.md b/CHANGELOG.md index a28d77485..69ebd1acc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -71,6 +71,7 @@ - Control UI: standardize UI build instructions on `bun run ui:*` (fallback supported). - Status: show runtime (docker/direct) and move shortcuts to `/help`. - Status: show model auth source (api-key/oauth). +- Status: fix zero token counters for Anthropic (Opus) sessions by normalizing usage fields and ignoring empty usage updates. - Block streaming: avoid splitting Markdown fenced blocks and reopen fences when forced to split. - Block streaming: preserve leading indentation in block replies (lists, indented fences). - Docs: document systemd lingering and logged-in session requirements on macOS/Windows. diff --git a/src/agents/pi-embedded-runner.ts b/src/agents/pi-embedded-runner.ts index 982c702a4..813a4ba0d 100644 --- a/src/agents/pi-embedded-runner.ts +++ b/src/agents/pi-embedded-runner.ts @@ -65,6 +65,7 @@ import { type SkillSnapshot, } from "./skills.js"; import { buildAgentSystemPromptAppend } from "./system-prompt.js"; +import { normalizeUsage, type UsageLike } from "./usage.js"; import { loadWorkspaceBootstrapFiles } from "./workspace.js"; export type EmbeddedPiAgentMeta = { @@ -1000,20 +1001,12 @@ export async function runEmbeddedPiAgent(params: { } } - const usage = lastAssistant?.usage; + const usage = normalizeUsage(lastAssistant?.usage as UsageLike); const agentMeta: EmbeddedPiAgentMeta = { sessionId: sessionIdUsed, provider: lastAssistant?.provider ?? provider, model: lastAssistant?.model ?? model.id, - usage: usage - ? { - input: usage.input, - output: usage.output, - cacheRead: usage.cacheRead, - cacheWrite: usage.cacheWrite, - total: usage.totalTokens, - } - : undefined, + usage, }; const replyItems: Array<{ text: string; media?: string[] }> = []; diff --git a/src/agents/usage.test.ts b/src/agents/usage.test.ts new file mode 100644 index 000000000..f0b0d53b4 --- /dev/null +++ b/src/agents/usage.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; + +import { hasNonzeroUsage, normalizeUsage } from "./usage.js"; + +describe("normalizeUsage", () => { + it("normalizes Anthropic-style snake_case usage", () => { + const usage = normalizeUsage({ + input_tokens: 1200, + output_tokens: 340, + cache_creation_input_tokens: 200, + cache_read_input_tokens: 50, + total_tokens: 1790, + }); + expect(usage).toEqual({ + input: 1200, + output: 340, + cacheRead: 50, + cacheWrite: 200, + total: 1790, + }); + }); + + it("normalizes OpenAI-style prompt/completion usage", () => { + const usage = normalizeUsage({ + prompt_tokens: 987, + completion_tokens: 123, + total_tokens: 1110, + }); + expect(usage).toEqual({ + input: 987, + output: 123, + cacheRead: undefined, + cacheWrite: undefined, + total: 1110, + }); + }); + + it("returns undefined for empty usage objects", () => { + expect(normalizeUsage({})).toBeUndefined(); + }); + + it("guards against empty/zero usage overwrites", () => { + expect(hasNonzeroUsage(undefined)).toBe(false); + expect(hasNonzeroUsage(null)).toBe(false); + expect(hasNonzeroUsage({})).toBe(false); + expect(hasNonzeroUsage({ input: 0, output: 0 })).toBe(false); + expect(hasNonzeroUsage({ input: 1 })).toBe(true); + expect(hasNonzeroUsage({ total: 1 })).toBe(true); + }); +}); diff --git a/src/agents/usage.ts b/src/agents/usage.ts index bc33a942b..45189bd01 100644 --- a/src/agents/usage.ts +++ b/src/agents/usage.ts @@ -4,6 +4,17 @@ export type UsageLike = { cacheRead?: number; cacheWrite?: number; total?: number; + // Common alternates across providers/SDKs. + inputTokens?: number; + outputTokens?: number; + promptTokens?: number; + completionTokens?: number; + input_tokens?: number; + output_tokens?: number; + prompt_tokens?: number; + completion_tokens?: number; + cache_read_input_tokens?: number; + cache_creation_input_tokens?: number; // Some agents/logs emit alternate naming. totalTokens?: number; total_tokens?: number; @@ -11,27 +22,58 @@ export type UsageLike = { cache_write?: number; }; +export type NormalizedUsage = { + input?: number; + output?: number; + cacheRead?: number; + cacheWrite?: number; + total?: number; +}; + const asFiniteNumber = (value: unknown): number | undefined => { if (typeof value !== "number") return undefined; if (!Number.isFinite(value)) return undefined; return value; }; +export function hasNonzeroUsage( + usage?: NormalizedUsage | null, +): usage is NormalizedUsage { + if (!usage) return false; + return [ + usage.input, + usage.output, + usage.cacheRead, + usage.cacheWrite, + usage.total, + ].some((v) => typeof v === "number" && Number.isFinite(v) && v > 0); +} + export function normalizeUsage(raw?: UsageLike | null): - | { - input?: number; - output?: number; - cacheRead?: number; - cacheWrite?: number; - total?: number; - } + | NormalizedUsage | undefined { if (!raw) return undefined; - const input = asFiniteNumber(raw.input); - const output = asFiniteNumber(raw.output); - const cacheRead = asFiniteNumber(raw.cacheRead ?? raw.cache_read); - const cacheWrite = asFiniteNumber(raw.cacheWrite ?? raw.cache_write); + const input = asFiniteNumber( + raw.input ?? + raw.inputTokens ?? + raw.input_tokens ?? + raw.promptTokens ?? + raw.prompt_tokens, + ); + const output = asFiniteNumber( + raw.output ?? + raw.outputTokens ?? + raw.output_tokens ?? + raw.completionTokens ?? + raw.completion_tokens, + ); + const cacheRead = asFiniteNumber( + raw.cacheRead ?? raw.cache_read ?? raw.cache_read_input_tokens, + ); + const cacheWrite = asFiniteNumber( + raw.cacheWrite ?? raw.cache_write ?? raw.cache_creation_input_tokens, + ); const total = asFiniteNumber( raw.total ?? raw.totalTokens ?? raw.total_tokens, ); diff --git a/src/auto-reply/reply/agent-runner.ts b/src/auto-reply/reply/agent-runner.ts index 29fab4b55..e7ee357e6 100644 --- a/src/auto-reply/reply/agent-runner.ts +++ b/src/auto-reply/reply/agent-runner.ts @@ -6,6 +6,7 @@ import { queueEmbeddedPiMessage, runEmbeddedPiAgent, } from "../../agents/pi-embedded.js"; +import { hasNonzeroUsage } from "../../agents/usage.js"; import { loadSessionStore, type SessionEntry, @@ -450,7 +451,7 @@ export async function runReplyAgent(params: { sessionEntry?.contextTokens ?? DEFAULT_CONTEXT_TOKENS; - if (usage) { + if (hasNonzeroUsage(usage)) { const entry = sessionEntry ?? sessionStore[sessionKey]; if (entry) { const input = usage.input ?? 0; diff --git a/src/auto-reply/reply/followup-runner.ts b/src/auto-reply/reply/followup-runner.ts index a6cb82da9..71e183263 100644 --- a/src/auto-reply/reply/followup-runner.ts +++ b/src/auto-reply/reply/followup-runner.ts @@ -3,6 +3,7 @@ import { lookupContextTokens } from "../../agents/context.js"; import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js"; import { runWithModelFallback } from "../../agents/model-fallback.js"; import { runEmbeddedPiAgent } from "../../agents/pi-embedded.js"; +import { hasNonzeroUsage } from "../../agents/usage.js"; import { type SessionEntry, saveSessionStore } from "../../config/sessions.js"; import { logVerbose } from "../../globals.js"; import { registerAgentRunContext } from "../../infra/agent-events.js"; @@ -171,7 +172,7 @@ export function createFollowupRunner(params: { sessionEntry?.contextTokens ?? DEFAULT_CONTEXT_TOKENS; - if (usage) { + if (hasNonzeroUsage(usage)) { const entry = sessionStore[sessionKey]; if (entry) { const input = usage.input ?? 0; diff --git a/src/commands/agent.ts b/src/commands/agent.ts index 32e2f0ed5..d96d98aea 100644 --- a/src/commands/agent.ts +++ b/src/commands/agent.ts @@ -17,6 +17,7 @@ import { import { runEmbeddedPiAgent } from "../agents/pi-embedded.js"; import { buildWorkspaceSkillSnapshot } from "../agents/skills.js"; import { resolveAgentTimeoutMs } from "../agents/timeout.js"; +import { hasNonzeroUsage } from "../agents/usage.js"; import { DEFAULT_AGENT_WORKSPACE_DIR, ensureAgentWorkspace, @@ -482,7 +483,7 @@ export async function agentCommand( contextTokens, }; next.abortedLastRun = result.meta.aborted ?? false; - if (usage) { + if (hasNonzeroUsage(usage)) { const input = usage.input ?? 0; const output = usage.output ?? 0; const promptTokens = diff --git a/src/cron/isolated-agent.ts b/src/cron/isolated-agent.ts index bd495b395..a6dc11eed 100644 --- a/src/cron/isolated-agent.ts +++ b/src/cron/isolated-agent.ts @@ -14,6 +14,7 @@ import { import { runEmbeddedPiAgent } from "../agents/pi-embedded.js"; import { buildWorkspaceSkillSnapshot } from "../agents/skills.js"; import { resolveAgentTimeoutMs } from "../agents/timeout.js"; +import { hasNonzeroUsage } from "../agents/usage.js"; import { DEFAULT_AGENT_WORKSPACE_DIR, ensureAgentWorkspace, @@ -357,7 +358,7 @@ export async function runCronIsolatedAgentTurn(params: { cronSession.sessionEntry.modelProvider = providerUsed; cronSession.sessionEntry.model = modelUsed; cronSession.sessionEntry.contextTokens = contextTokens; - if (usage) { + if (hasNonzeroUsage(usage)) { const input = usage.input ?? 0; const output = usage.output ?? 0; const promptTokens =