Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 120 additions & 0 deletions scripts/backfill-opencode-usage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
import { DatabaseSync } from "node:sqlite";
import { computeSessionCost } from "../src/core/pricing.js";

const isApply = process.argv.includes("--apply");
const isForce = process.argv.includes("--force");

const runAgentDir = process.env.RUN_AGENT_DIR || path.join(process.env.HOME || "", ".run-agent");
const dbPath = path.join(runAgentDir, "run-agent.db");
const logsDir = path.join(runAgentDir, "logs");

if (!fs.existsSync(dbPath)) {
console.error(`Database not found at ${dbPath}`);
process.exit(1);
}

const db = new DatabaseSync(dbPath);

const query = isForce
? `SELECT id, model, usage_input_tokens, usage_output_tokens, usage_cached_tokens, usage_cost FROM sessions WHERE agent = 'opencode'`
: `SELECT id, model, usage_input_tokens, usage_output_tokens, usage_cached_tokens, usage_cost FROM sessions WHERE agent = 'opencode' AND usage_input_tokens IS NULL`;

const sessions = db.prepare(query).all() as Array<{
id: string;
model: string | null;
usage_input_tokens: number | null;
usage_output_tokens: number | null;
usage_cached_tokens: number | null;
usage_cost: number | null;
}>;

console.log(`Found ${sessions.length} OpenCode sessions to inspect.`);
console.log(`Mode: ${isApply ? "APPLY (writing to database)" : "DRY-RUN (pass --apply to execute)"}\n`);

let updatedCount = 0;
let totalRecoveredTokens = 0;
let totalRecoveredCost = 0;

const updateStmt = db.prepare(`
UPDATE sessions
SET usage_input_tokens = ?,
usage_output_tokens = ?,
usage_cached_tokens = ?,
usage_cost = ?
WHERE id = ?
`);

for (const session of sessions) {
const logFile = path.join(logsDir, `${session.id}.ndjson`);
if (!fs.existsSync(logFile)) {
continue;
}

const content = fs.readFileSync(logFile, "utf8");
const lines = content.split("\n");

let inputTokens = 0;
let outputTokens = 0;
let cachedTokens = 0;
let reportedCost = 0;
let stepFinishCount = 0;

for (const line of lines) {
if (!line.trim()) continue;
try {
const obj = JSON.parse(line);
if (obj.type === "step_finish") {
stepFinishCount++;
const part = obj.part ?? {};
const tokens = part.tokens ?? obj.tokens;
if (tokens) {
inputTokens += tokens.input ?? 0;
outputTokens += (tokens.output ?? 0) + (tokens.reasoning ?? 0);
cachedTokens += tokens.cache?.read ?? 0;
}
if (typeof part.cost === "number" && part.cost > 0) {
reportedCost += part.cost;
}
}
} catch {}
}

if (stepFinishCount === 0 && inputTokens === 0 && outputTokens === 0) {
continue;
}

const cost =
reportedCost > 0
? reportedCost
: computeSessionCost({
model: session.model,
usage: { inputTokens, outputTokens, cachedTokens },
});

const totalTokens = inputTokens + outputTokens + cachedTokens;
totalRecoveredTokens += totalTokens;
if (cost != null) {
totalRecoveredCost += cost;
}

console.log(
`[${session.id}] Model: ${session.model || "unknown"} | Steps: ${stepFinishCount} | Tokens: ${totalTokens.toLocaleString()} (in: ${inputTokens.toLocaleString()}, out: ${outputTokens.toLocaleString()}, cache: ${cachedTokens.toLocaleString()}) | Cost: $${cost != null ? cost.toFixed(4) : "unpriced"}`
);

if (isApply) {
updateStmt.run(inputTokens, outputTokens, cachedTokens, cost, session.id);
}
updatedCount++;
}

console.log("\n================ SUMMARY ================");
console.log(`Sessions processed: ${updatedCount} / ${sessions.length}`);
console.log(`Total tokens recovered: ${totalRecoveredTokens.toLocaleString()}`);
console.log(`Total cost calculated: $${totalRecoveredCost.toFixed(4)}`);

if (!isApply && updatedCount > 0) {
console.log("\nTo apply these updates, run with --apply:\n pnpm tsx scripts/backfill-opencode-usage.ts --apply\n");
}
6 changes: 6 additions & 0 deletions src/core/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,12 @@ export interface PermissionResolvedEvent extends BaseAgentEvent {

export interface UsageUpdatedEvent extends BaseAgentEvent {
type: "usage.updated";
/**
* When true, the usage payload represents an incremental delta for a step/turn,
* not the session cumulative total. The daemon will add these tokens and costs
* to existing totals rather than replacing them.
*/
incremental?: boolean;
usage: {
inputTokens?: number;
outputTokens?: number;
Expand Down
59 changes: 53 additions & 6 deletions src/core/pricing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,18 @@ export const MODEL_PRICES: Readonly<Record<string, ModelPrice>> = {
// Claude's current CodeDeck default and model examples. Claude normally
// reports its own cost, but these entries keep the fallback table complete.
// TODO: ajustar preço for these model ids if their catalog prices change.
"claude-opus-4-8": { input: 15, output: 75 },
"claude-opus-5": { input: 15, output: 75 },
"claude-sonnet-4-6": { input: 3, output: 15 },
"claude-sonnet-5": { input: 3, output: 15 },
"claude-haiku-4-5": { input: 0.8, output: 4 },
"claude-opus-4-8": { input: 15, output: 75, cached: 1.5 },
"claude-opus-5": { input: 15, output: 75, cached: 1.5 },
"claude-sonnet-4-6": { input: 3, output: 15, cached: 0.3 },
"claude-sonnet-5": { input: 3, output: 15, cached: 0.3 },
"claude-haiku-4-5": { input: 0.8, output: 4, cached: 0.08 },

// Alibaba Qwen models.
"qwen3.8-max": { input: 2, output: 6, cached: 0.2 },
"qwen3.8-flash": { input: 0.16, output: 0.47, cached: 0.016 },
"qwen-max": { input: 2, output: 6, cached: 0.2 },
"qwen-plus": { input: 0.4, output: 1.2, cached: 0.04 },
"qwen-turbo": { input: 0.05, output: 0.2, cached: 0.005 },

// Google Antigravity (Gemini) models.
"gemini-3.8-flash": { input: 0.1, output: 0.4, cached: 0.025 },
Expand Down Expand Up @@ -82,6 +89,46 @@ function isUsablePrice(price: ModelPrice): boolean {
);
}

/**
* Resolves a model name to its ModelPrice configuration.
* Handles exact matches, stripped display labels, and stripping provider prefixes
* like 'opencode/', 'alibaba-token-plan/', 'openrouter/', etc.
*/
export function resolveModelPrice(model: string | undefined | null): ModelPrice | undefined {
if (!model) return undefined;
const trimmed = model.trim();
if (!trimmed) return undefined;

// 1. Direct match with original string
if (MODEL_PRICES[trimmed]) return MODEL_PRICES[trimmed];

// 2. Strip trailing display labels (e.g. "model-id Display Name")
const idOnly = trimmed.split(/\s+/)[0] ?? trimmed;
if (MODEL_PRICES[idOnly]) return MODEL_PRICES[idOnly];

// 3. Strip leading provider prefix (e.g. "opencode/...", "alibaba-token-plan/...", "openrouter/...")
const slashIdx = idOnly.indexOf("/");
if (slashIdx !== -1) {
const afterFirstSlash = idOnly.slice(slashIdx + 1);
if (MODEL_PRICES[afterFirstSlash]) return MODEL_PRICES[afterFirstSlash];

// If there's another slash (e.g. openrouter/meta/model-name), test last component
const lastSlashIdx = idOnly.lastIndexOf("/");
if (lastSlashIdx !== slashIdx) {
const lastComponent = idOnly.slice(lastSlashIdx + 1);
if (MODEL_PRICES[lastComponent]) return MODEL_PRICES[lastComponent];
}
}

// 4. Case-insensitive fallback
const lower = idOnly.toLowerCase();
if (lower !== idOnly) {
return resolveModelPrice(lower);
}

return undefined;
}

/**
* Returns a reported cost when one exists, otherwise calculates a static-table
* estimate. Cached tokens use the input price unless the model declares its
Expand All @@ -96,7 +143,7 @@ export function computeSessionCost({
// Zero is a valid reported cost and must win over every table entry.
if (isFiniteNumber(reportedCost) && reportedCost >= 0) return reportedCost;

const price = model === undefined || model === null ? undefined : MODEL_PRICES[model];
const price = resolveModelPrice(model);
if (!price || !isUsablePrice(price)) return null;

const inputTokens = usage?.inputTokens ?? 0;
Expand Down
31 changes: 30 additions & 1 deletion src/daemon/daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1316,7 +1316,36 @@ class Daemon {
} else if (ev.type === "usage.updated") {
const sess = this.sessions.get(sessionId);
if (sess) {
this.sessions.update(sessionId, { usage: { ...(sess.usage || {}), ...ev.usage } });
const next = ev.usage || {};
if (ev.incremental) {
const cur = sess.usage || {};
const inputTokens = (cur.inputTokens ?? 0) + (next.inputTokens ?? 0);
const outputTokens = (cur.outputTokens ?? 0) + (next.outputTokens ?? 0);
const cachedTokens = (cur.cachedTokens ?? 0) + (next.cachedTokens ?? 0);
const cost =
cur.cost !== undefined || next.cost !== undefined
? (cur.cost ?? 0) + (next.cost ?? 0)
: undefined;
this.sessions.update(sessionId, {
usage: {
inputTokens,
outputTokens,
cachedTokens,
cost,
},
...(next.model ? { model: next.model } : {}),
});
} else {
this.sessions.update(sessionId, {
usage: {
inputTokens: next.inputTokens ?? sess.usage?.inputTokens,
outputTokens: next.outputTokens ?? sess.usage?.outputTokens,
cachedTokens: next.cachedTokens ?? sess.usage?.cachedTokens,
cost: next.cost ?? sess.usage?.cost,
},
...(next.model ? { model: next.model } : {}),
});
}
}
}
this.sessions.update(sessionId, { updatedAt: new Date() });
Expand Down
5 changes: 4 additions & 1 deletion src/drivers/claude/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,10 @@ export function parseClaudeLine(line: string, sessionId: string): AgentEvent[] {
usage: {
inputTokens: obj.usage.input_tokens,
outputTokens: obj.usage.output_tokens,
cachedTokens: obj.usage.cache_read_input_tokens ?? obj.usage.cache_creation_input_tokens,
cachedTokens:
obj.usage.cache_read_input_tokens != null || obj.usage.cache_creation_input_tokens != null
? (obj.usage.cache_read_input_tokens ?? 0) + (obj.usage.cache_creation_input_tokens ?? 0)
: undefined,
cost: obj.total_cost_usd,
model: obj.modelUsage ? Object.keys(obj.modelUsage)[0] : undefined,
},
Expand Down
29 changes: 28 additions & 1 deletion src/drivers/opencode/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,33 @@ export function parseOpencodeLine(line: string, sessionId: string): AgentEvent[]
return events;
}

// step_start / step_finish are turn boundaries with no transcript content.
if (obj.type === "step_finish") {
const part = obj.part ?? {};
const tokens = part.tokens ?? obj.tokens;
const rawCost = typeof part.cost === "number" ? part.cost : typeof obj.cost === "number" ? obj.cost : undefined;
const cost = rawCost !== undefined && rawCost > 0 ? rawCost : undefined;

if (tokens || cost !== undefined) {
const inputTokens = tokens?.input ?? 0;
const outputTokens = (tokens?.output ?? 0) + (tokens?.reasoning ?? 0);
const cachedTokens = tokens?.cache?.read ?? 0;
events.push({
type: "usage.updated",
sessionId,
timestamp: ts,
incremental: true,
usage: {
inputTokens,
outputTokens,
cachedTokens,
cost,
},
raw,
} as AgentEvent);
}
return events;
}

// step_start and any unhandled event types produce no events.
return events;
}
18 changes: 18 additions & 0 deletions tests/events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,24 @@ describe("Claude parser", () => {
expect(evs.some(e => e.type === "session.completed")).toBe(true);
});

it("sums cache_read_input_tokens and cache_creation_input_tokens into cachedTokens", () => {
const line = JSON.stringify({
type: "result",
subtype: "success",
result: "done",
usage: {
input_tokens: 100,
output_tokens: 50,
cache_read_input_tokens: 300,
cache_creation_input_tokens: 200,
},
total_cost_usd: 0.05,
});
const evs = parseClaudeLine(line, "s1");
const usageEv = evs.find((e) => e.type === "usage.updated") as any;
expect(usageEv.usage.cachedTokens).toBe(500);
});

it("preserves raw", () => {
const line = JSON.stringify({ type: "assistant", message: { content: [{ type: "text", text: "hi" }] } });
const evs = parseClaudeLine(line, "s1");
Expand Down
Loading
Loading