From cd11eb964228403fa9b93107bde8e7ecba70cdf6 Mon Sep 17 00:00:00 2001 From: 4ndreello <4ndreello@users.noreply.github.com> Date: Sat, 19 Sep 2026 22:46:48 -0300 Subject: [PATCH] fix(opencode): Support incremental step usage, reasoning tokens, and model pricing - Add incremental flag to UsageUpdatedEvent for step-level usage deltas - Intercept step_finish in OpenCode parser, folding reasoning tokens into output - Discard dummy cost: 0 to allow CodeDeck pricing table fallback - Atomically accumulate incremental usage in daemon and persist to SQLite - Normalize model IDs in pricing.ts (strip provider prefixes/display labels) - Add pricing entries for Alibaba Qwen models and Claude cached rates - Fix Claude parser cache creation token drop - Add backfill script for recovering historical session metrics - Add comprehensive test coverage across parser, pricing, events, and daemon --- scripts/backfill-opencode-usage.ts | 120 +++++++++++++++++++++++++++++ src/core/events.ts | 6 ++ src/core/pricing.ts | 59 ++++++++++++-- src/daemon/daemon.ts | 31 +++++++- src/drivers/claude/parser.ts | 5 +- src/drivers/opencode/parser.ts | 29 ++++++- tests/events.test.ts | 18 +++++ tests/opencode-parser.test.ts | 94 +++++++++++++++++++++- tests/pricing.test.ts | 115 ++++++++++++++++++++++++++- tests/usage-daemon.test.ts | 93 ++++++++++++++++++++++ 10 files changed, 557 insertions(+), 13 deletions(-) create mode 100644 scripts/backfill-opencode-usage.ts diff --git a/scripts/backfill-opencode-usage.ts b/scripts/backfill-opencode-usage.ts new file mode 100644 index 0000000..d6704de --- /dev/null +++ b/scripts/backfill-opencode-usage.ts @@ -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"); +} diff --git a/src/core/events.ts b/src/core/events.ts index 53e6a32..3c5c720 100644 --- a/src/core/events.ts +++ b/src/core/events.ts @@ -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; diff --git a/src/core/pricing.ts b/src/core/pricing.ts index 9952563..7908297 100644 --- a/src/core/pricing.ts +++ b/src/core/pricing.ts @@ -26,11 +26,18 @@ export const MODEL_PRICES: Readonly> = { // 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 }, @@ -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 @@ -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; diff --git a/src/daemon/daemon.ts b/src/daemon/daemon.ts index 623095f..d9bb8d4 100644 --- a/src/daemon/daemon.ts +++ b/src/daemon/daemon.ts @@ -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() }); diff --git a/src/drivers/claude/parser.ts b/src/drivers/claude/parser.ts index ebb51ba..6424b5d 100644 --- a/src/drivers/claude/parser.ts +++ b/src/drivers/claude/parser.ts @@ -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, }, diff --git a/src/drivers/opencode/parser.ts b/src/drivers/opencode/parser.ts index ddc71b7..6facbb9 100644 --- a/src/drivers/opencode/parser.ts +++ b/src/drivers/opencode/parser.ts @@ -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; } diff --git a/tests/events.test.ts b/tests/events.test.ts index d5d102a..0b889c8 100644 --- a/tests/events.test.ts +++ b/tests/events.test.ts @@ -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"); diff --git a/tests/opencode-parser.test.ts b/tests/opencode-parser.test.ts index 6d44902..47c27d1 100644 --- a/tests/opencode-parser.test.ts +++ b/tests/opencode-parser.test.ts @@ -84,11 +84,99 @@ describe("Opencode parser (real --format json schema)", () => { expect(message?.content).toBe("thinking about it"); }); - it("ignores step_start and step_finish (no events)", () => { + it("ignores step_start and empty step_finish (no events)", () => { const start = parseOpencodeLine(JSON.stringify({ type: "step_start", sessionID: "s", part: { type: "step-start" } }), "s1"); - const finish = parseOpencodeLine(JSON.stringify({ type: "step_finish", sessionID: "s", part: { type: "step-finish" } }), "s1"); + const emptyFinish = parseOpencodeLine(JSON.stringify({ type: "step_finish", sessionID: "s", part: { type: "step-finish" } }), "s1"); expect(start).toEqual([]); - expect(finish).toEqual([]); + expect(emptyFinish).toEqual([]); + }); + + it("parses step_finish into incremental usage.updated with reasoning folded into output", () => { + const line = JSON.stringify({ + type: "step_finish", + timestamp: 1789852051989, + sessionID: "ses_abc", + part: { + id: "prt_123", + reason: "tool-calls", + type: "step-finish", + tokens: { + total: 15584, + input: 1107, + output: 76, + reasoning: 65, + cache: { write: 0, read: 14336 }, + }, + cost: 0, + }, + }); + const evs = parseOpencodeLine(line, "s1"); + expect(evs).toHaveLength(1); + const usageEv = evs[0] as any; + expect(usageEv.type).toBe("usage.updated"); + expect(usageEv.sessionId).toBe("s1"); + expect(usageEv.incremental).toBe(true); + expect(usageEv.usage).toEqual({ + inputTokens: 1107, + outputTokens: 141, // 76 + 65 (reasoning tokens billed as output) + cachedTokens: 14336, + cost: undefined, // cost: 0 is ignored so table fallback can price it + }); + }); + + it("preserves positive reported cost from step_finish", () => { + const line = JSON.stringify({ + type: "step_finish", + timestamp: 1789852051989, + sessionID: "ses_abc", + part: { + tokens: { input: 100, output: 50 }, + cost: 0.0042, + }, + }); + const evs = parseOpencodeLine(line, "s1"); + expect(evs).toHaveLength(1); + const usageEv = evs[0] as any; + expect(usageEv.usage.cost).toBe(0.0042); + expect(usageEv.usage.inputTokens).toBe(100); + expect(usageEv.usage.outputTokens).toBe(50); + }); + + it("extracts tokens and cost from root object when part lacks them", () => { + const line = JSON.stringify({ + type: "step_finish", + timestamp: 1789852051989, + sessionID: "ses_abc", + part: { type: "step-finish" }, + tokens: { input: 200, output: 40 }, + cost: 0.0015, + }); + const evs = parseOpencodeLine(line, "s1"); + expect(evs).toHaveLength(1); + const usageEv = evs[0] as any; + expect(usageEv.type).toBe("usage.updated"); + expect(usageEv.incremental).toBe(true); + expect(usageEv.usage.inputTokens).toBe(200); + expect(usageEv.usage.outputTokens).toBe(40); + expect(usageEv.usage.cost).toBe(0.0015); + }); + + it("ignores negative reported cost and defaults missing token fields to 0", () => { + const line = JSON.stringify({ + type: "step_finish", + sessionID: "ses_abc", + part: { + tokens: { input: 100 }, + cost: -0.05, + }, + }); + const evs = parseOpencodeLine(line, "s1"); + expect(evs).toHaveLength(1); + const usageEv = evs[0] as any; + expect(usageEv.usage.cost).toBeUndefined(); + expect(usageEv.usage.inputTokens).toBe(100); + expect(usageEv.usage.outputTokens).toBe(0); + expect(usageEv.usage.cachedTokens).toBe(0); }); it("maps an error event to session.failed with the API message", () => { diff --git a/tests/pricing.test.ts b/tests/pricing.test.ts index de82a3b..afd7e47 100644 --- a/tests/pricing.test.ts +++ b/tests/pricing.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { computeSessionCost, MODEL_PRICES } from "../src/core/pricing.js"; +import { computeSessionCost, MODEL_PRICES, resolveModelPrice } from "../src/core/pricing.js"; describe("computeSessionCost", () => { it("uses a valid reported cost, including zero, without recalculating", () => { @@ -90,4 +90,117 @@ describe("computeSessionCost", () => { }), ).toBeNull(); }); + + it("resolves and prices models with provider prefixes (e.g. alibaba-token-plan, opencode)", () => { + // alibaba-token-plan/qwen3.8-max -> qwen3.8-max (input: 2.0, output: 6.0, cached: 0.2) + const cost = computeSessionCost({ + model: "alibaba-token-plan/qwen3.8-max", + usage: { inputTokens: 1_000_000, outputTokens: 500_000, cachedTokens: 1_000_000 }, + }); + // (1M * 2.0 + 0.5M * 6.0 + 1M * 0.2) / 1M = 2.0 + 3.0 + 0.2 = 5.2 + expect(cost).toBeCloseTo(5.2, 5); + + // alibaba-token-plan/qwen3.8-flash -> qwen3.8-flash (input: 0.16, output: 0.47, cached: 0.016) + const flashCost = computeSessionCost({ + model: "alibaba-token-plan/qwen3.8-flash", + usage: { inputTokens: 1_000_000, outputTokens: 1_000_000 }, + }); + expect(flashCost).toBeCloseTo(0.63, 5); + + // opencode/claude-sonnet-4-6 -> claude-sonnet-4-6 (input: 3, output: 15, cached: 0.3) + const claudeCost = computeSessionCost({ + model: "opencode/claude-sonnet-4-6", + usage: { inputTokens: 1_000_000, cachedTokens: 1_000_000 }, + }); + expect(claudeCost).toBeCloseTo(3.3, 5); + }); + + it("handles models with display name suffixes (e.g. 'gemini-3.8-flash-high Gemini 3.8 Flash (High)')", () => { + const cost = computeSessionCost({ + model: "gemini-3.8-flash-high Gemini 3.8 Flash (High)", + usage: { inputTokens: 1_000_000, outputTokens: 1_000_000 }, + }); + expect(cost).toBeCloseTo(0.5, 5); + }); + + it("resolves multi-level nested provider prefixes (e.g. openrouter/meta/muse-spark-1.3-contributor)", () => { + const price = resolveModelPrice("openrouter/meta/muse-spark-1.3-contributor"); + expect(price).toEqual(MODEL_PRICES["meta/muse-spark-1.3-contributor"]); + }); + + it("returns null when any token count is negative", () => { + expect( + computeSessionCost({ + model: "gpt-5.6-luna", + usage: { inputTokens: -1, outputTokens: 100, cachedTokens: 0 }, + }), + ).toBeNull(); + expect( + computeSessionCost({ + model: "gpt-5.6-luna", + usage: { inputTokens: 100, outputTokens: -1, cachedTokens: 0 }, + }), + ).toBeNull(); + expect( + computeSessionCost({ + model: "gpt-5.6-luna", + usage: { inputTokens: 100, outputTokens: 100, cachedTokens: -1 }, + }), + ).toBeNull(); + }); + + it("returns null for sessions without a model or with empty/whitespace model", () => { + expect( + computeSessionCost({ + model: undefined, + usage: { inputTokens: 1_000_000, outputTokens: 1_000_000 }, + }), + ).toBeNull(); + expect( + computeSessionCost({ + model: null, + usage: { inputTokens: 1_000_000, outputTokens: 1_000_000 }, + }), + ).toBeNull(); + expect( + computeSessionCost({ + model: "", + usage: { inputTokens: 1_000_000, outputTokens: 1_000_000 }, + }), + ).toBeNull(); + expect( + computeSessionCost({ + model: " ", + usage: { inputTokens: 1_000_000, outputTokens: 1_000_000 }, + }), + ).toBeNull(); + }); + + it("handles missing or empty usage gracefully (pricing defaults to 0)", () => { + expect( + computeSessionCost({ + model: "gpt-5.6-luna", + usage: undefined, + }), + ).toBe(0); + expect( + computeSessionCost({ + model: "gpt-5.6-luna", + usage: {}, + }), + ).toBe(0); + }); + + it("resolveModelPrice returns undefined for null, undefined, whitespace, or unknown models", () => { + expect(resolveModelPrice(undefined)).toBeUndefined(); + expect(resolveModelPrice(null)).toBeUndefined(); + expect(resolveModelPrice("")).toBeUndefined(); + expect(resolveModelPrice(" ")).toBeUndefined(); + expect(resolveModelPrice("unknown-provider/nonexistent-model")).toBeUndefined(); + }); + + it("resolves model names case-insensitively", () => { + expect(resolveModelPrice("Alibaba-Token-Plan/Qwen3.8-Max")).toEqual(MODEL_PRICES["qwen3.8-max"]); + expect(resolveModelPrice("CLAUDE-SONNET-4-6")).toEqual(MODEL_PRICES["claude-sonnet-4-6"]); + }); }); diff --git a/tests/usage-daemon.test.ts b/tests/usage-daemon.test.ts index 513a428..8018f1d 100644 --- a/tests/usage-daemon.test.ts +++ b/tests/usage-daemon.test.ts @@ -116,5 +116,98 @@ describe("usage daemon methods", () => { expect(response.result.byAgent.length).toBe(1); expect(response.result.byAgent[0].key).toBe("codex"); }); + + it("accumulates incremental usage events across steps", async () => { + const createResponse = await request("session.create", createParams("run-incremental")); + const created = createResponse.result.session; + + const daemonAny = daemon as any; + // Step 1 + daemonAny.updateSessionFromEvent(created.id, { + type: "usage.updated", + sessionId: created.id, + timestamp: new Date().toISOString(), + incremental: true, + usage: { inputTokens: 100, outputTokens: 20, cachedTokens: 50, cost: 0.01 }, + }); + let sess = seam(daemon!).sessions.get(created.id); + expect(sess?.usage).toEqual({ + inputTokens: 100, + outputTokens: 20, + cachedTokens: 50, + cost: 0.01, + }); + + // Step 2 + daemonAny.updateSessionFromEvent(created.id, { + type: "usage.updated", + sessionId: created.id, + timestamp: new Date().toISOString(), + incremental: true, + usage: { inputTokens: 200, outputTokens: 30, cachedTokens: 80, cost: 0.02 }, + }); + sess = seam(daemon!).sessions.get(created.id); + expect(sess?.usage?.inputTokens).toBe(300); + expect(sess?.usage?.outputTokens).toBe(50); + expect(sess?.usage?.cachedTokens).toBe(130); + expect(sess?.usage?.cost).toBeCloseTo(0.03, 5); + + // Non-incremental event overwrites + daemonAny.updateSessionFromEvent(created.id, { + type: "usage.updated", + sessionId: created.id, + timestamp: new Date().toISOString(), + incremental: false, + usage: { inputTokens: 500, outputTokens: 100, cachedTokens: 0, cost: 0.1 }, + }); + sess = seam(daemon!).sessions.get(created.id); + expect(sess?.usage?.inputTokens).toBe(500); + expect(sess?.usage?.outputTokens).toBe(100); + }); + + it("accumulates incremental events without cost and computes table cost on usage.get", async () => { + const runId = "run-incremental-no-cost"; + const createResponse = await request("session.create", { + ...createParams(runId), + agent: "opencode", + model: "alibaba-token-plan/qwen3.8-max", + }); + const created = createResponse.result.session; + const daemonAny = daemon as any; + + // Step 1: incremental usage with cost: undefined (OpenCode behavior) + daemonAny.updateSessionFromEvent(created.id, { + type: "usage.updated", + sessionId: created.id, + timestamp: new Date().toISOString(), + incremental: true, + usage: { inputTokens: 500_000, outputTokens: 250_000, cachedTokens: 500_000, cost: undefined }, + }); + + // Step 2: second step incremental usage with cost: undefined + daemonAny.updateSessionFromEvent(created.id, { + type: "usage.updated", + sessionId: created.id, + timestamp: new Date().toISOString(), + incremental: true, + usage: { inputTokens: 500_000, outputTokens: 250_000, cachedTokens: 500_000, cost: undefined }, + }); + + const sess = seam(daemon!).sessions.get(created.id); + expect(sess?.usage?.inputTokens).toBe(1_000_000); + expect(sess?.usage?.outputTokens).toBe(500_000); + expect(sess?.usage?.cachedTokens).toBe(1_000_000); + expect(sess?.usage?.cost).toBeUndefined(); + + // Query usage: should compute cost using alibaba-token-plan/qwen3.8-max table price: + // (1M * 2.0 + 0.5M * 6.0 + 1M * 0.2) = 5.2 + const usageResponse = await request("usage.get", { runId }); + expect(usageResponse.result.inputTokens).toBe(1_000_000); + expect(usageResponse.result.outputTokens).toBe(500_000); + expect(usageResponse.result.cachedTokens).toBe(1_000_000); + expect(usageResponse.result.costUsd).toBeCloseTo(5.2, 5); + expect(usageResponse.result.costComplete).toBe(true); + expect(usageResponse.result.sessionsWithoutCost).toBe(0); + }); });