Files
learnharness/src/core/prices.ts
T

71 lines
2.9 KiB
TypeScript

/** Client-safe accounting folds. Rates and calculation live on the server,
* using Pi's catalog and calculateCost; no copied pricing table lives here. */
import type { CallUsage, ChatRecord, JobRecord, TurnUsage } from "./contracts.ts";
export type CallPricer = (usage: Partial<CallUsage>, model: string) => number | null;
export interface SessionPricing {
cost: number;
unpriced: number;
subscription?: number;
/** Derived at read time, keyed by the persisted turn's id. Never stored. */
turnCosts: Record<string, number | null>;
}
export function isChatGPTPlan(model: string | undefined): boolean {
return model?.startsWith("chatgpt:") === true;
}
/** Absent stays absent. Long-cache precision is unknown if any write-bearing
* step lacks the split; never silently turn that missing detail into zero. */
export function sumSteps(steps: readonly CallUsage[]): CallUsage {
const sumIfAny = (vals: (number | undefined)[]): number | undefined =>
vals.every((v) => v === undefined) ? undefined : vals.reduce<number>((a, v) => a + (v ?? 0), 0);
return {
inputTokens: sumIfAny(steps.map((s) => s.inputTokens)),
outputTokens: sumIfAny(steps.map((s) => s.outputTokens)),
cacheReadTokens: sumIfAny(steps.map((s) => s.cacheReadTokens)),
cacheWriteTokens: sumIfAny(steps.map((s) => s.cacheWriteTokens)),
cacheWrite1hTokens: steps.some((s) => (s.cacheWriteTokens ?? 0) > 0 && s.cacheWrite1hTokens === undefined)
? undefined : sumIfAny(steps.map((s) => s.cacheWrite1hTokens)),
reasoningTokens: sumIfAny(steps.map((s) => s.reasoningTokens)),
};
}
export function turnCost(turn: Pick<TurnUsage, "model" | "steps">, price: CallPricer): number | null {
if (isChatGPTPlan(turn.model) || turn.steps.length === 0) return null;
let total = 0;
for (const step of turn.steps) {
const cost = price(step, turn.model);
if (cost === null) return null;
total += cost;
}
return total;
}
export function sessionCost(record: Pick<ChatRecord, "turns">, price: CallPricer): SessionPricing {
let cost = 0;
let unpriced = 0;
let subscription = 0;
const turnCosts: Record<string, number | null> = {};
for (const turn of record.turns ?? []) {
const dollars = turnCost(turn, price);
turnCosts[turn.id] = dollars;
if (isChatGPTPlan(turn.model)) subscription++;
else if (dollars === null) unpriced++;
else cost += dollars;
}
return { cost, unpriced, ...(subscription ? { subscription } : {}), turnCosts };
}
export function jobCost(job: Pick<JobRecord, "usage">, price: CallPricer): number | null {
const usage = job.usage;
if (!usage || isChatGPTPlan(usage.model)) return null;
if (usage.calls && usage.calls.length === usage.steps) {
return turnCost({ model: usage.model, steps: usage.calls }, price);
}
// An old one-call job is fully reconstructible from its total. For a
// multi-call job, the server resolver decides whether aggregate pricing
// is safe for this model (tiered models need the lost per-call sizes).
return price(usage, usage.model);
}