fix: restore Anthropic token accounting

This commit is contained in:
Peter Steinberger
2026-01-06 18:51:45 +00:00
parent 672762bdd0
commit 2f24ea492b
8 changed files with 115 additions and 25 deletions

View File

@@ -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.

View File

@@ -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[] }> = [];

50
src/agents/usage.test.ts Normal file
View File

@@ -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);
});
});

View File

@@ -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,
);

View File

@@ -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;

View File

@@ -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;

View File

@@ -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 =

View File

@@ -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 =