Skip to content
Open
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
56 changes: 56 additions & 0 deletions src/cost-tracker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,57 @@ export interface SessionCosts {
totalRequests: number;
startTime: number;
modelUsage: Record<string, ModelUsage>;
/**
* 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
);
}

/**
Expand All @@ -34,6 +85,7 @@ export class CostTracker {
totalRequests: 0,
startTime: Date.now(),
modelUsage: {},
costDataAvailable: true,
};
}

Expand All @@ -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;
Expand Down Expand Up @@ -89,6 +144,7 @@ export class CostTracker {
totalRequests: 0,
startTime: Date.now(),
modelUsage: {},
costDataAvailable: true,
};
}
}
4 changes: 2 additions & 2 deletions src/exports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
38 changes: 36 additions & 2 deletions src/sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -370,6 +370,39 @@ 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;
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, rates);
if (computed === null) {
costResolved = false; // tokens used, no pricing → unknown
} else {
costUsd = computed;
}
}
}

const assistantMsg: GCAssistantMessage = {
type: "assistant",
content: text || accText,
Expand All @@ -384,7 +417,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);
Expand All @@ -394,6 +427,7 @@ export function query(options: QueryOptions): Query {
costTracker.add(
`${assistantMsg.provider}:${assistantMsg.model}`,
assistantMsg.usage,
costResolved,
);
_totalCostUsd += assistantMsg.usage.costUsd ?? 0;
}
Expand Down
69 changes: 69 additions & 0 deletions test/cost-compute.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});