From c7dc60a3aa058739f1f3029d30bcacba55bd86ce Mon Sep 17 00:00:00 2001 From: Amogh Sunil Date: Tue, 7 Jul 2026 17:18:52 +0530 Subject: [PATCH 1/2] fix: compute costUsd from model rates when the provider reports none MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit costs() reported costUsd: 0 even when tokens were billed. The provider only supplies msg.usage.cost.total when pi-ai's calculateCost runs — which it does not on the openai-completions path, and cannot for custom/unregistered models that carry no price table (issue #67). sdk.ts read that 0 verbatim. - cost-tracker: add computeCostUsd(usage, rates) — the same per-million-token formula pi-ai uses — returning null when tokens were used but no pricing exists (cost unknown, not zero) - sdk: prefer the provider's cost when present, otherwise recompute from loaded.model.cost; feed a costResolved flag into the tracker - cost-tracker: add SessionCosts.costDataAvailable so callers can tell a real $0 apart from "pricing unavailable" - export computeCostUsd + the new types Note: the underlying gaps live in pi-ai (calculateCost emits 0 for unknown models; openai-completions never calls it) and pi-agent-core (EMPTY_USAGE on the error path); those need upstream fixes. This addresses the gitagent-side symptom and makes the unknown-cost case explicit. Covered by unit tests for computeCostUsd and the costDataAvailable flag. Closes #67 --- src/cost-tracker.ts | 56 +++++++++++++++++++++++++++++++ src/exports.ts | 4 +-- src/sdk.ts | 34 +++++++++++++++++-- test/cost-compute.test.ts | 69 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 159 insertions(+), 4 deletions(-) create mode 100644 test/cost-compute.test.ts diff --git a/src/cost-tracker.ts b/src/cost-tracker.ts index 3340da9..53865e8 100644 --- a/src/cost-tracker.ts +++ b/src/cost-tracker.ts @@ -17,6 +17,57 @@ export interface SessionCosts { totalRequests: number; startTime: number; modelUsage: Record; + /** + * False when at least one request consumed tokens but no pricing was + * available to price it (custom/unregistered model with no cost table). + * Lets callers tell "cost was $0" apart from "cost is unknown" — the + * root confusion behind `costs()` reporting 0 (issue #67). + */ + costDataAvailable: boolean; +} + +/** Per-million-token pricing, as carried on a pi-ai Model's `cost` field. */ +export interface ModelCostRates { + input: number; + output: number; + cacheRead: number; + cacheWrite: number; +} + +export interface TokenCounts { + inputTokens: number; + outputTokens: number; + cacheReadTokens?: number; + cacheWriteTokens?: number; +} + +/** + * Compute the USD cost of a request from token counts and the model's + * per-million-token rates (same formula pi-ai uses internally). + * + * Returns `null` when the model carries no pricing (all rates 0) yet tokens + * were actually consumed — i.e. the cost is *unknown*, not zero. Returns a + * number (possibly 0) otherwise. + */ +export function computeCostUsd(usage: TokenCounts, rates: ModelCostRates): number | null { + const input = usage.inputTokens ?? 0; + const output = usage.outputTokens ?? 0; + const cacheRead = usage.cacheReadTokens ?? 0; + const cacheWrite = usage.cacheWriteTokens ?? 0; + + const hasRates = + rates.input > 0 || rates.output > 0 || rates.cacheRead > 0 || rates.cacheWrite > 0; + if (!hasRates) { + const tokens = input + output + cacheRead + cacheWrite; + return tokens > 0 ? null : 0; + } + + return ( + (rates.input / 1_000_000) * input + + (rates.output / 1_000_000) * output + + (rates.cacheRead / 1_000_000) * cacheRead + + (rates.cacheWrite / 1_000_000) * cacheWrite + ); } /** @@ -34,6 +85,7 @@ export class CostTracker { totalRequests: 0, startTime: Date.now(), modelUsage: {}, + costDataAvailable: true, }; } @@ -47,7 +99,10 @@ export class CostTracker { totalTokens?: number; costUsd?: number; }, + /** Pass false when tokens were used but cost could not be priced. */ + costResolved: boolean = true, ): void { + if (!costResolved) this.costs.costDataAvailable = false; this.costs.totalInputTokens += usage.inputTokens; this.costs.totalOutputTokens += usage.outputTokens; this.costs.totalCostUsd += usage.costUsd ?? 0; @@ -89,6 +144,7 @@ export class CostTracker { totalRequests: 0, startTime: Date.now(), modelUsage: {}, + costDataAvailable: true, }; } } diff --git a/src/exports.ts b/src/exports.ts index 787d60f..a5fb079 100644 --- a/src/exports.ts +++ b/src/exports.ts @@ -112,8 +112,8 @@ export { buildTool, getToolMetadata } from "./tool-factory.js"; export type { ToolDefinition, ToolMetadata } from "./tool-factory.js"; // Cost tracking -export { CostTracker } from "./cost-tracker.js"; -export type { SessionCosts, ModelUsage } from "./cost-tracker.js"; +export { CostTracker, computeCostUsd } from "./cost-tracker.js"; +export type { SessionCosts, ModelUsage, ModelCostRates, TokenCounts } from "./cost-tracker.js"; // Context compaction export { estimateTokens, estimateMessageTokens, needsCompaction, truncateToolResults, messagesToText, buildCompactPrompt } from "./compact.js"; diff --git a/src/sdk.ts b/src/sdk.ts index dfa9a80..9bac7db 100644 --- a/src/sdk.ts +++ b/src/sdk.ts @@ -22,7 +22,7 @@ import type { QueryOptions, SandboxOptions, } from "./sdk-types.js"; -import { CostTracker } from "./cost-tracker.js"; +import { CostTracker, computeCostUsd } from "./cost-tracker.js"; import { context as otelContext } from "@opentelemetry/api"; import { wrapToolWithOtel, @@ -370,6 +370,35 @@ export function query(options: QueryOptions): Query { const { text, thinking } = extractContent(msg); + // Resolve cost. Providers that call pi-ai's calculateCost supply + // msg.usage.cost.total; the openai-completions path and custom + // models do not, leaving it 0 even when tokens were billed + // (issue #67). When it's missing, recompute from the model's own + // per-million-token rates. costResolved is false only when tokens + // were used but no pricing exists — so callers can distinguish + // "unknown" from a real $0. + let costUsd = 0; + let costResolved = true; + if (msg.usage) { + const tokens = { + inputTokens: msg.usage.input ?? 0, + outputTokens: msg.usage.output ?? 0, + cacheReadTokens: msg.usage.cacheRead ?? 0, + cacheWriteTokens: msg.usage.cacheWrite ?? 0, + }; + const providerCost = msg.usage.cost?.total ?? 0; + if (providerCost > 0) { + costUsd = providerCost; + } else { + const computed = computeCostUsd(tokens, loaded.model.cost); + if (computed === null) { + costResolved = false; // tokens used, no pricing → unknown + } else { + costUsd = computed; + } + } + } + const assistantMsg: GCAssistantMessage = { type: "assistant", content: text || accText, @@ -384,7 +413,7 @@ export function query(options: QueryOptions): Query { cacheReadTokens: msg.usage.cacheRead ?? 0, cacheWriteTokens: msg.usage.cacheWrite ?? 0, totalTokens: msg.usage.totalTokens ?? 0, - costUsd: msg.usage.cost?.total ?? 0, + costUsd, } : undefined, }; pushMsg(assistantMsg); @@ -394,6 +423,7 @@ export function query(options: QueryOptions): Query { costTracker.add( `${assistantMsg.provider}:${assistantMsg.model}`, assistantMsg.usage, + costResolved, ); _totalCostUsd += assistantMsg.usage.costUsd ?? 0; } diff --git a/test/cost-compute.test.ts b/test/cost-compute.test.ts new file mode 100644 index 0000000..016869b --- /dev/null +++ b/test/cost-compute.test.ts @@ -0,0 +1,69 @@ +import { describe, it, before } from "node:test"; +import assert from "node:assert/strict"; + +let computeCostUsd: typeof import("../dist/cost-tracker.js").computeCostUsd; +let CostTracker: typeof import("../dist/cost-tracker.js").CostTracker; + +before(async () => { + ({ computeCostUsd, CostTracker } = await import("../dist/cost-tracker.js")); +}); + +// gpt-4o-style rates: $/1M tokens. +const RATES = { input: 2.5, output: 10, cacheRead: 1.25, cacheWrite: 0 }; +const ZERO_RATES = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }; + +describe("computeCostUsd", () => { + it("prices input + output from per-million rates", () => { + // 1,000,000 input @ $2.5 + 1,000,000 output @ $10 = $12.5 + const c = computeCostUsd({ inputTokens: 1_000_000, outputTokens: 1_000_000 }, RATES); + assert.equal(c, 12.5); + }); + + it("includes cache read/write pricing", () => { + const c = computeCostUsd( + { inputTokens: 0, outputTokens: 0, cacheReadTokens: 2_000_000, cacheWriteTokens: 1_000_000 }, + { input: 0, output: 0, cacheRead: 1.25, cacheWrite: 5 }, + ); + // 2M * 1.25 + 1M * 5 = 2.5 + 5 = 7.5 + assert.equal(c, 7.5); + }); + + it("returns null when tokens were used but the model has no pricing", () => { + const c = computeCostUsd({ inputTokens: 500, outputTokens: 200 }, ZERO_RATES); + assert.equal(c, null, "unknown cost, not a misleading $0"); + }); + + it("returns 0 (not null) when there are no tokens and no rates", () => { + assert.equal(computeCostUsd({ inputTokens: 0, outputTokens: 0 }, ZERO_RATES), 0); + }); + + it("computes even when only some rates are non-zero", () => { + // cacheWrite rate is 0 for gpt-4o but input>0 means we still price it. + const c = computeCostUsd({ inputTokens: 400_000, outputTokens: 0 }, RATES); + assert.equal(c, 1); // 0.4M * 2.5 = 1.0 + }); +}); + +describe("CostTracker.costDataAvailable", () => { + it("defaults to true and stays true for priced requests", () => { + const t = new CostTracker(); + assert.equal(t.get().costDataAvailable, true); + t.add("openai:gpt-4o", { inputTokens: 100, outputTokens: 50, costUsd: 0.01 }, true); + assert.equal(t.get().costDataAvailable, true); + }); + + it("flips to false once a request can't be priced", () => { + const t = new CostTracker(); + t.add("custom:model", { inputTokens: 100, outputTokens: 50, costUsd: 0 }, false); + assert.equal(t.get().costDataAvailable, false); + // tokens still tracked even though cost is unknown + assert.equal(t.get().totalInputTokens, 100); + }); + + it("reset() restores costDataAvailable to true", () => { + const t = new CostTracker(); + t.add("custom:model", { inputTokens: 1, outputTokens: 1 }, false); + t.reset(); + assert.equal(t.get().costDataAvailable, true); + }); +}); From 23de972bdbbe1907b64d2e75a7daae43f4692ef2 Mon Sep 17 00:00:00 2001 From: Amogh Sunil Date: Thu, 9 Jul 2026 16:03:11 +0900 Subject: [PATCH 2/2] fix: guard against a model object with no cost table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit If loaded.model.cost is absent (e.g. a plugin-provided model), treat the request as unpriceable — costDataAvailable becomes false — instead of throwing in the cost path. --- src/sdk.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/sdk.ts b/src/sdk.ts index 9bac7db..d1f898e 100644 --- a/src/sdk.ts +++ b/src/sdk.ts @@ -387,10 +387,14 @@ export function query(options: QueryOptions): Query { cacheWriteTokens: msg.usage.cacheWrite ?? 0, }; const providerCost = msg.usage.cost?.total ?? 0; + const rates = loaded.model.cost; if (providerCost > 0) { costUsd = providerCost; + } else if (!rates) { + // Model object carries no cost table at all — can't price it. + costResolved = false; } else { - const computed = computeCostUsd(tokens, loaded.model.cost); + const computed = computeCostUsd(tokens, rates); if (computed === null) { costResolved = false; // tokens used, no pricing → unknown } else {