fix: restore Anthropic token accounting
This commit is contained in:
@@ -71,6 +71,7 @@
|
|||||||
- Control UI: standardize UI build instructions on `bun run ui:*` (fallback supported).
|
- Control UI: standardize UI build instructions on `bun run ui:*` (fallback supported).
|
||||||
- Status: show runtime (docker/direct) and move shortcuts to `/help`.
|
- Status: show runtime (docker/direct) and move shortcuts to `/help`.
|
||||||
- Status: show model auth source (api-key/oauth).
|
- 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: avoid splitting Markdown fenced blocks and reopen fences when forced to split.
|
||||||
- Block streaming: preserve leading indentation in block replies (lists, indented fences).
|
- Block streaming: preserve leading indentation in block replies (lists, indented fences).
|
||||||
- Docs: document systemd lingering and logged-in session requirements on macOS/Windows.
|
- Docs: document systemd lingering and logged-in session requirements on macOS/Windows.
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ import {
|
|||||||
type SkillSnapshot,
|
type SkillSnapshot,
|
||||||
} from "./skills.js";
|
} from "./skills.js";
|
||||||
import { buildAgentSystemPromptAppend } from "./system-prompt.js";
|
import { buildAgentSystemPromptAppend } from "./system-prompt.js";
|
||||||
|
import { normalizeUsage, type UsageLike } from "./usage.js";
|
||||||
import { loadWorkspaceBootstrapFiles } from "./workspace.js";
|
import { loadWorkspaceBootstrapFiles } from "./workspace.js";
|
||||||
|
|
||||||
export type EmbeddedPiAgentMeta = {
|
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 = {
|
const agentMeta: EmbeddedPiAgentMeta = {
|
||||||
sessionId: sessionIdUsed,
|
sessionId: sessionIdUsed,
|
||||||
provider: lastAssistant?.provider ?? provider,
|
provider: lastAssistant?.provider ?? provider,
|
||||||
model: lastAssistant?.model ?? model.id,
|
model: lastAssistant?.model ?? model.id,
|
||||||
usage: usage
|
usage,
|
||||||
? {
|
|
||||||
input: usage.input,
|
|
||||||
output: usage.output,
|
|
||||||
cacheRead: usage.cacheRead,
|
|
||||||
cacheWrite: usage.cacheWrite,
|
|
||||||
total: usage.totalTokens,
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const replyItems: Array<{ text: string; media?: string[] }> = [];
|
const replyItems: Array<{ text: string; media?: string[] }> = [];
|
||||||
|
|||||||
50
src/agents/usage.test.ts
Normal file
50
src/agents/usage.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,6 +4,17 @@ export type UsageLike = {
|
|||||||
cacheRead?: number;
|
cacheRead?: number;
|
||||||
cacheWrite?: number;
|
cacheWrite?: number;
|
||||||
total?: 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.
|
// Some agents/logs emit alternate naming.
|
||||||
totalTokens?: number;
|
totalTokens?: number;
|
||||||
total_tokens?: number;
|
total_tokens?: number;
|
||||||
@@ -11,27 +22,58 @@ export type UsageLike = {
|
|||||||
cache_write?: number;
|
cache_write?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type NormalizedUsage = {
|
||||||
|
input?: number;
|
||||||
|
output?: number;
|
||||||
|
cacheRead?: number;
|
||||||
|
cacheWrite?: number;
|
||||||
|
total?: number;
|
||||||
|
};
|
||||||
|
|
||||||
const asFiniteNumber = (value: unknown): number | undefined => {
|
const asFiniteNumber = (value: unknown): number | undefined => {
|
||||||
if (typeof value !== "number") return undefined;
|
if (typeof value !== "number") return undefined;
|
||||||
if (!Number.isFinite(value)) return undefined;
|
if (!Number.isFinite(value)) return undefined;
|
||||||
return value;
|
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):
|
export function normalizeUsage(raw?: UsageLike | null):
|
||||||
| {
|
| NormalizedUsage
|
||||||
input?: number;
|
|
||||||
output?: number;
|
|
||||||
cacheRead?: number;
|
|
||||||
cacheWrite?: number;
|
|
||||||
total?: number;
|
|
||||||
}
|
|
||||||
| undefined {
|
| undefined {
|
||||||
if (!raw) return undefined;
|
if (!raw) return undefined;
|
||||||
|
|
||||||
const input = asFiniteNumber(raw.input);
|
const input = asFiniteNumber(
|
||||||
const output = asFiniteNumber(raw.output);
|
raw.input ??
|
||||||
const cacheRead = asFiniteNumber(raw.cacheRead ?? raw.cache_read);
|
raw.inputTokens ??
|
||||||
const cacheWrite = asFiniteNumber(raw.cacheWrite ?? raw.cache_write);
|
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(
|
const total = asFiniteNumber(
|
||||||
raw.total ?? raw.totalTokens ?? raw.total_tokens,
|
raw.total ?? raw.totalTokens ?? raw.total_tokens,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
queueEmbeddedPiMessage,
|
queueEmbeddedPiMessage,
|
||||||
runEmbeddedPiAgent,
|
runEmbeddedPiAgent,
|
||||||
} from "../../agents/pi-embedded.js";
|
} from "../../agents/pi-embedded.js";
|
||||||
|
import { hasNonzeroUsage } from "../../agents/usage.js";
|
||||||
import {
|
import {
|
||||||
loadSessionStore,
|
loadSessionStore,
|
||||||
type SessionEntry,
|
type SessionEntry,
|
||||||
@@ -450,7 +451,7 @@ export async function runReplyAgent(params: {
|
|||||||
sessionEntry?.contextTokens ??
|
sessionEntry?.contextTokens ??
|
||||||
DEFAULT_CONTEXT_TOKENS;
|
DEFAULT_CONTEXT_TOKENS;
|
||||||
|
|
||||||
if (usage) {
|
if (hasNonzeroUsage(usage)) {
|
||||||
const entry = sessionEntry ?? sessionStore[sessionKey];
|
const entry = sessionEntry ?? sessionStore[sessionKey];
|
||||||
if (entry) {
|
if (entry) {
|
||||||
const input = usage.input ?? 0;
|
const input = usage.input ?? 0;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { lookupContextTokens } from "../../agents/context.js";
|
|||||||
import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js";
|
import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js";
|
||||||
import { runWithModelFallback } from "../../agents/model-fallback.js";
|
import { runWithModelFallback } from "../../agents/model-fallback.js";
|
||||||
import { runEmbeddedPiAgent } from "../../agents/pi-embedded.js";
|
import { runEmbeddedPiAgent } from "../../agents/pi-embedded.js";
|
||||||
|
import { hasNonzeroUsage } from "../../agents/usage.js";
|
||||||
import { type SessionEntry, saveSessionStore } from "../../config/sessions.js";
|
import { type SessionEntry, saveSessionStore } from "../../config/sessions.js";
|
||||||
import { logVerbose } from "../../globals.js";
|
import { logVerbose } from "../../globals.js";
|
||||||
import { registerAgentRunContext } from "../../infra/agent-events.js";
|
import { registerAgentRunContext } from "../../infra/agent-events.js";
|
||||||
@@ -171,7 +172,7 @@ export function createFollowupRunner(params: {
|
|||||||
sessionEntry?.contextTokens ??
|
sessionEntry?.contextTokens ??
|
||||||
DEFAULT_CONTEXT_TOKENS;
|
DEFAULT_CONTEXT_TOKENS;
|
||||||
|
|
||||||
if (usage) {
|
if (hasNonzeroUsage(usage)) {
|
||||||
const entry = sessionStore[sessionKey];
|
const entry = sessionStore[sessionKey];
|
||||||
if (entry) {
|
if (entry) {
|
||||||
const input = usage.input ?? 0;
|
const input = usage.input ?? 0;
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
|
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
|
||||||
import { buildWorkspaceSkillSnapshot } from "../agents/skills.js";
|
import { buildWorkspaceSkillSnapshot } from "../agents/skills.js";
|
||||||
import { resolveAgentTimeoutMs } from "../agents/timeout.js";
|
import { resolveAgentTimeoutMs } from "../agents/timeout.js";
|
||||||
|
import { hasNonzeroUsage } from "../agents/usage.js";
|
||||||
import {
|
import {
|
||||||
DEFAULT_AGENT_WORKSPACE_DIR,
|
DEFAULT_AGENT_WORKSPACE_DIR,
|
||||||
ensureAgentWorkspace,
|
ensureAgentWorkspace,
|
||||||
@@ -482,7 +483,7 @@ export async function agentCommand(
|
|||||||
contextTokens,
|
contextTokens,
|
||||||
};
|
};
|
||||||
next.abortedLastRun = result.meta.aborted ?? false;
|
next.abortedLastRun = result.meta.aborted ?? false;
|
||||||
if (usage) {
|
if (hasNonzeroUsage(usage)) {
|
||||||
const input = usage.input ?? 0;
|
const input = usage.input ?? 0;
|
||||||
const output = usage.output ?? 0;
|
const output = usage.output ?? 0;
|
||||||
const promptTokens =
|
const promptTokens =
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
|
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
|
||||||
import { buildWorkspaceSkillSnapshot } from "../agents/skills.js";
|
import { buildWorkspaceSkillSnapshot } from "../agents/skills.js";
|
||||||
import { resolveAgentTimeoutMs } from "../agents/timeout.js";
|
import { resolveAgentTimeoutMs } from "../agents/timeout.js";
|
||||||
|
import { hasNonzeroUsage } from "../agents/usage.js";
|
||||||
import {
|
import {
|
||||||
DEFAULT_AGENT_WORKSPACE_DIR,
|
DEFAULT_AGENT_WORKSPACE_DIR,
|
||||||
ensureAgentWorkspace,
|
ensureAgentWorkspace,
|
||||||
@@ -357,7 +358,7 @@ export async function runCronIsolatedAgentTurn(params: {
|
|||||||
cronSession.sessionEntry.modelProvider = providerUsed;
|
cronSession.sessionEntry.modelProvider = providerUsed;
|
||||||
cronSession.sessionEntry.model = modelUsed;
|
cronSession.sessionEntry.model = modelUsed;
|
||||||
cronSession.sessionEntry.contextTokens = contextTokens;
|
cronSession.sessionEntry.contextTokens = contextTokens;
|
||||||
if (usage) {
|
if (hasNonzeroUsage(usage)) {
|
||||||
const input = usage.input ?? 0;
|
const input = usage.input ?? 0;
|
||||||
const output = usage.output ?? 0;
|
const output = usage.output ?? 0;
|
||||||
const promptTokens =
|
const promptTokens =
|
||||||
|
|||||||
Reference in New Issue
Block a user