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
5 changes: 4 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ import {
} from "./launch-lock.js";
import { forceKillWindowsProcessTree } from "./process-tree.js";
import { compactOversizedTraceLine } from "./relevant-trace.js";
import { OpencodeUsageAccumulator } from "./opencode-usage.js";
import { handleRunCommand as handleRunCommandImpl } from "./run-commands.js";
import { handleCronCommand as handleCronCommandImpl, type CronCommand } from "./cron-commands.js";
import { runCronDaemon } from "./cron.js";
Expand Down Expand Up @@ -1769,6 +1770,7 @@ async function executeCommand(
let traceBuffer = "";
let traceRowDiscarded = false;
const relevantTrace: string[] = [];
const opencodeUsage = agent === "opencode" ? new OpencodeUsageAccumulator() : undefined;
let relevantTraceBytes = 0;
const finalMessageTrace: string[] = [];
let finalMessageTraceBytes = 0;
Expand All @@ -1793,6 +1795,7 @@ async function executeCommand(
const codexIdentityPattern = /"type"\s*:\s*"thread\.started"/;
const appendRelevantTrace = (line: string) => {
let trimmed = line.trim();
if (opencodeUsage?.addLine(trimmed)) return;
if (!trimmed || !relevantTracePattern.test(trimmed)) return;
let entryBytes = Buffer.byteLength(trimmed, "utf8") + 1;
if (entryBytes > maxRelevantTraceBytes) {
Expand Down Expand Up @@ -1858,7 +1861,7 @@ async function executeCommand(
traceBuffer = "";
};
const readRelevantTrace = () => {
const rollingTrace = relevantTrace.join("");
const rollingTrace = relevantTrace.join("") + (opencodeUsage?.trace() ?? "");
return pinnedIdentityTrace && !rollingTrace.includes(pinnedIdentityTrace)
? `${pinnedIdentityTrace}${rollingTrace}`
: rollingTrace;
Expand Down
69 changes: 69 additions & 0 deletions src/opencode-usage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { createHash } from "node:crypto";

type JsonRecord = Record<string, unknown>;

function asRecord(value: unknown): JsonRecord {
return value && typeof value === "object" && !Array.isArray(value) ? value as JsonRecord : {};
}

function asString(value: unknown): string {
return typeof value === "string" ? value : "";
}

function numbers(record: JsonRecord, fields: string[]): JsonRecord {
const result: JsonRecord = {};
for (const field of fields) {
const value = record[field];
if (typeof value === "number" && Number.isFinite(value) && value >= 0) result[field] = value;
}
return result;
}

// Retain only compact usage for each completed step, independently of text trace eviction.
export class OpencodeUsageAccumulator {
private readonly steps = new Map<string | number, JsonRecord>();
private anonymousSteps = 0;

add(value: unknown): boolean {
const record = asRecord(value);
if (record.type !== "step_finish") return false;
const part = asRecord(record.part);
const sourceTokens = asRecord(part.tokens);
const tokens = numbers(sourceTokens, ["input", "output", "reasoning"]);
const cache = numbers(asRecord(sourceTokens.cache), ["read", "write"]);
if (Object.keys(tokens).length === 0 && Object.keys(cache).length === 0) return false;
if (Object.keys(cache).length > 0) tokens.cache = cache;
const id = asString(part.id);
const sessionID = asString(part.sessionID) || asString(record.sessionID);
const messageID = asString(part.messageID);
const key = id
? createHash("sha256").update(JSON.stringify([sessionID, messageID, id])).digest("hex")
: this.anonymousSteps++;
this.steps.set(key, {
id: typeof key === "string" ? key : "",
sessionID: Buffer.byteLength(sessionID, "utf8") <= 16 * 1024 ? sessionID : "",
messageID: Buffer.byteLength(messageID, "utf8") <= 16 * 1024 ? messageID : "",
tokens,
...numbers(part, ["cost"]),
});
return true;
}

addLine(line: string): boolean {
try {
return this.add(JSON.parse(line) as unknown);
} catch {
return false;
}
}

parts(): JsonRecord[] {
return [...this.steps.values()];
}

trace(): string {
return this.parts().map((part) => JSON.stringify({
type: "step_finish", sessionID: part.sessionID, part,
}) + "\n").join("");
}
}
46 changes: 30 additions & 16 deletions src/usage.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { AgentName } from "./types.js";
import type { ModelsDevPricingData } from "./models-dev.js";
import { OpencodeUsageAccumulator } from "./opencode-usage.js";

export { fetchModelsDevPricing } from "./models-dev.js";

Expand Down Expand Up @@ -417,31 +418,44 @@ function extractGeminiUsage(records: JsonRecord[], context: UsageContext): Usage
}

function extractOpencodeUsage(records: JsonRecord[], context: UsageContext): UsageSummary | undefined {
const record = latestRecordWith(records, (item) => {
const tokens = asRecord(asRecord(item.part).tokens);
const cache = asRecord(tokens.cache);
return hasNumericField(tokens, ["input", "output", "reasoning"]) || hasNumericField(cache, ["read", "write"]);
});
if (!record) return undefined;
const part = asRecord(record.part);
const tokens = asRecord(part.tokens);
const cache = asRecord(tokens.cache);
const steps = new OpencodeUsageAccumulator();
for (const record of records) steps.add(record);
const parts = steps.parts();
if (parts.length === 0) return undefined;
const tokens = parts.map((part) => asRecord(part.tokens));
const sumTokens = (field: string) => tokens.reduce((sum, token) => sum + asNumber(token[field]), 0);
const sumCache = (field: string) => tokens.reduce((sum, token) => sum + asNumber(asRecord(token.cache)[field]), 0);
const costs = parts.map((part) => asOptionalNumber(part.cost));
const totalCost = costs.every((cost) => cost !== undefined)
? roundCost(costs.reduce((sum, cost) => sum + cost, 0))
: undefined;
const requested = requestedProviderModel(context);
const totalCost = asOptionalNumber(part.cost);
const model = extractModel(records, context);
const usage = {
inputTokens: sumTokens("input"),
cacheReadTokens: sumCache("read"),
cacheWriteTokens: sumCache("write"),
outputTokens: sumTokens("output"),
reasoningOutputTokens: sumTokens("reasoning"),
};
return summarizeUsage({
agent: "opencode",
provider: requested.provider,
model: extractModel(records, context),
inputTokens: asNumber(tokens.input),
cacheReadTokens: asNumber(cache.read),
cacheWriteTokens: asNumber(cache.write),
outputTokens: asNumber(tokens.output),
reasoningOutputTokens: asNumber(tokens.reasoning),
model,
...usage,
reasoningOutputIncludedInOutput: false,
cost: totalCost === undefined ? null : nativeCost(totalCost),
costBasis: totalCost === undefined ? null : "native-reported",
pricingSource: totalCost === undefined ? null : "native",
pricingStatus: totalCost === undefined ? "missing" : "native",
modelBreakdowns: totalCost === undefined ? [{
provider: requested.provider,
model,
inputTokens: usage.inputTokens,
cacheReadTokens: usage.cacheReadTokens,
cacheWriteTokens: usage.cacheWriteTokens,
outputTokens: usage.outputTokens + usage.reasoningOutputTokens,
}] : undefined,
});
}

Expand Down
6 changes: 5 additions & 1 deletion tests/headless.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4543,7 +4543,7 @@ test("CLI --usage splits Pi provider/model specs", async () => {
}
});

test("CLI --usage reports OpenCode hard default model", async () => {
test("CLI --usage reports OpenCode hard default model and sums completed steps", async () => {
const dir = mkdtempSync(join(tmpdir(), "headless-test-"));
try {
const binDir = join(dir, "bin");
Expand All @@ -4556,6 +4556,7 @@ test("CLI --usage reports OpenCode hard default model", async () => {
"#!/usr/bin/env node",
"console.log(JSON.stringify({ role: 'assistant', parts: [{ type: 'text', text: 'final answer' }] }));",
"console.log(JSON.stringify({ type: 'step_finish', part: { tokens: { input: 100, output: 10, reasoning: 5, cache: { read: 0, write: 0 } }, cost: 0.5 } }));",
"console.log(JSON.stringify({ type: 'step_finish', part: { tokens: { input: 200, output: 20, reasoning: 10, cache: { read: 30, write: 4 } }, cost: 1 } }));",
"",
].join("\n"),
);
Expand All @@ -4574,6 +4575,9 @@ test("CLI --usage reports OpenCode hard default model", async () => {
assert.equal(usage.model, "gpt-5.4");
assert.equal(usage.pricingStatus, "native");
assert.equal(usage.costBasis, "native-reported");
assert.equal(usage.cost.total, 1.5);
assert.equal(usage.inputTokens, 300);
assert.equal(usage.totalTokens, 379);
} finally {
rmSync(dir, { force: true, recursive: true });
}
Expand Down
54 changes: 54 additions & 0 deletions tests/opencode-streaming-usage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import assert from "node:assert/strict";
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";

import { runCli } from "../src/cli.ts";

test("CLI --json --usage retains distinct OpenCode steps across long streamed output", async () => {
const dir = mkdtempSync(join(tmpdir(), "headless-opencode-stream-"));
try {
const binary = join(dir, "opencode");
writeFileSync(
binary,
[
"#!/usr/bin/env node",
"function step(id, input, cost) {",
" return { type: 'step_finish', sessionID: 'session-1', part: {",
" id, sessionID: 'session-1', messageID: 'message-1', type: 'step-finish',",
" tokens: { input, output: 0 }, cost,",
" } };",
"}",
"const first = step('step-1', 100, 1);",
"console.log(JSON.stringify(first));",
"console.log(JSON.stringify(first));",
"for (let i = 0; i < 3000; i++) {",
" console.log(JSON.stringify({ type: 'text', part: { text: 'x'.repeat(150) } }));",
"}",
"const last = step('step-2', 200, 2);",
"console.log(JSON.stringify(last));",
"console.log(JSON.stringify(last));",
"",
].join("\n"),
);
chmodSync(binary, 0o755);

const stdout: string[] = [];
const code = await runCli(["opencode", "--prompt", "hello", "--json", "--usage"], {
env: { ...process.env, PATH: `${dir}:${process.env.PATH ?? ""}` },
stdout: (text) => stdout.push(text),
});

assert.equal(code, 0);
const output = stdout.join("");
assert.ok(Buffer.byteLength(output) > 256 * 1024);
const { usage } = JSON.parse(output.trim().split("\n").at(-1)!);
assert.equal(usage.cost.total, 3);
assert.equal(usage.inputTokens, 300);
assert.equal(usage.totalTokens, 300);
assert.equal(usage.costBasis, "native-reported");
} finally {
rmSync(dir, { force: true, recursive: true });
}
});
124 changes: 124 additions & 0 deletions tests/opencode-usage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { extractUsageSummary, priceUsageSummary } from "../src/output.ts";
import { OpencodeUsageAccumulator } from "../src/opencode-usage.ts";

function step(id: string | undefined, cost: unknown = 1) {
return {
type: "step_finish",
part: {
id,
sessionID: "session-1",
messageID: "message-1",
type: "step-finish",
cost,
tokens: { input: 100, output: 10, reasoning: 5, cache: { read: 20, write: 3 } },
},
};
}

function summarize(...records: unknown[]) {
return extractUsageSummary("opencode", records.map((record) => JSON.stringify(record)).join("\n"), {
model: "deepseek/deepseek-v4-pro",
});
}

test("OpenCode sums every completed step including cache and reasoning tokens", () => {
const second = step("step-2", 2);
second.part.tokens.input = 200;
const result = summarize(step("step-1"), second);
assert.equal(result.cost?.total, 3);
assert.equal(result.inputTokens, 300);
assert.equal(result.outputTokens, 20);
assert.equal(result.reasoningOutputTokens, 10);
assert.equal(result.cacheReadTokens, 40);
assert.equal(result.cacheWriteTokens, 6);
assert.equal(result.totalTokens, 376);
assert.equal(result.provider, "deepseek");
assert.equal(result.model, "deepseek-v4-pro");
assert.equal(result.costBasis, "native-reported");
});

test("OpenCode replaces repeated step snapshots with the latest valid occurrence", () => {
const first = step("step-1");
const updated = step("step-1", 2);
updated.part.tokens.input = 200;
const invalid = { ...updated, part: { ...updated.part, tokens: { input: "invalid" } } };
const result = summarize(first, first, step("step-2", 3), updated, invalid);
assert.equal(result.cost?.total, 5);
assert.equal(result.inputTokens, 300);
});

test("OpenCode step identities include the message and session", () => {
const otherMessage = step("same-id", 2);
otherMessage.part.messageID = "message-2";
const otherSession = step("same-id", 3);
otherSession.part.sessionID = "session-2";
const result = summarize(step("same-id"), otherMessage, otherSession);
assert.equal(result.cost?.total, 6);
assert.equal(result.inputTokens, 300);
});

test("OpenCode preserves steps without identifiers", () => {
const result = summarize(step(undefined), step(undefined));
assert.equal(result.cost?.total, 2);
assert.equal(result.inputTokens, 200);
});

test("OpenCode ignores unrelated token records and cumulative message costs", () => {
const result = summarize(
step("step-1"),
{ type: "message.updated", info: { cost: 999, tokens: { input: 999 } } },
{ type: "tool_use", part: { cost: 999, tokens: { input: 999 } } },
{ type: "step_start", part: { cost: 999, tokens: { input: 999 } } },
);
assert.equal(result.cost?.total, 1);
assert.equal(result.inputTokens, 100);
});

test("OpenCode preserves explicit zero usage and zero cost", () => {
const result = summarize({ type: "step_finish", part: { cost: 0, tokens: { input: 0 } } });
assert.equal(result.cost?.total, 0);
assert.equal(result.totalTokens, 0);
assert.equal(result.usageStatus, "reported");
});

for (const cost of [undefined, null, -1, "2"]) {
test(`OpenCode does not report a partial native total when a step cost is ${String(cost)}`, () => {
const unpriced = step("step-1", cost);
unpriced.part.cost = cost;
const result = summarize(unpriced, step("step-2", 2));
assert.equal(result.cost, null);
assert.equal(result.costBasis, null);
assert.equal(result.pricingStatus, "missing");
assert.equal(result.inputTokens, 200);
const priced = priceUsageSummary(result, {
deepseek: { models: { "deepseek-v4-pro": { cost: { input: 1, output: 1, cache_read: 1, cache_write: 1 } } } },
});
assert.equal(priced.costBasis, "api-list-price-estimate");
assert.equal(priced.inputTokens, 200);
assert.equal(priced.cost?.output, 0.00003);
assert.equal(priced.cost?.total, 0.000276);
});
}

test("OpenCode with no valid completed usage remains missing", () => {
const result = summarize({ type: "step_finish", part: { tokens: { input: -1, output: "4" } } });
assert.equal(result.usageStatus, "missing");
assert.equal(result.cost, null);
});

test("OpenCode retains compact usage and deduplication with oversized identity fields", () => {
const accumulator = new OpencodeUsageAccumulator();
const record = step("x".repeat(1024 * 1024));
record.part.sessionID = "s".repeat(1024 * 1024);
record.part.messageID = "m".repeat(1024 * 1024);
accumulator.add(record);
accumulator.add(record);
accumulator.add({ ...record, part: { ...record.part, id: "different" } });
const trace = accumulator.trace();
assert.ok(Buffer.byteLength(trace) < 1024);
const summary = extractUsageSummary("opencode", trace);
assert.equal(summary.cost?.total, 2);
assert.equal(summary.inputTokens, 200);
});
2 changes: 2 additions & 0 deletions tests/run-coordination.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1093,6 +1093,8 @@ test("run wait reconciles completed tmux nodes from native transcripts", async (
"",
].join("\n"),
);
const transcriptTime = new Date(Date.now() + 1000);
utimesSync(transcriptPath, transcriptTime, transcriptTime);

const stdout: string[] = [];
assert.equal(
Expand Down
Loading