Files
learnharness/scripts/sync-pricing.ts
T
amosblomqvist 83a00800e9 Observability contracts: usage accounting, plan blocks, price table (Phase 0)
The 2026-09 observability wave's type-level foundation, landed before the
streams so nothing renders against guessed shapes:

- contracts/chat.ts: CallUsage + TurnUsage (per-model-call token accounting,
  one entry per teacher/wake turn keyed by messageId) and ChatRecord.turns —
  a DELIBERATELY parallel append-only usage log, not per-message metadata:
  the client re-POSTs its message list every turn and its copies never
  carry server-stamped fields, so anything inside messages gets clobbered
  by the next persist (the wave-3 merge-rule lesson).
- contracts/chat.ts: PlanBlockData — the persisted data-plan-block part
  (full DAG on plan_init, one-level neighborhood on plan updates, snapshot
  semantics so history renders what was true then).
- contracts/jobs.ts: JobUsage + JobRecord.usage — a sub-agent run's
  accumulated accounting, written by the runner.
- core/ports.ts + store/chats.ts: ChatStore.appendTurnUsage — idempotent
  per messageId (abort replays / wake retries append once); listChats no
  longer leaks turns into the list payload.
- core/prices.ts: PRICES + CONTEXT_LIMITS (generated from OpenRouter's
  published pricing via scripts/sync-pricing.ts) + pure folds: callCost,
  turnCost, sessionCost, contextWindow, jobCost. Unknown model or absent
  fields render as absent, never zero.
- server/observability.ts: barrel-approved surface for the transport layer
  (fixes the import-law violation in the graph status route that slipped
  through the wave-3 merge verification).

Capture wiring (agent onStepFinish -> persist seam, plan-block emission)
lands next as master seam work; the streams start against these shapes.
2026-09-11 02:43:35 +03:00

97 lines
3.6 KiB
TypeScript

/**
* Regenerate src/core/prices.ts tables from OpenRouter's published pricing.
*
* Usage: npx tsx scripts/sync-pricing.ts
*
* Pulls GET https://openrouter.ai/api/v1/models, filters to the models in
* config.ts MODELS (+ known spelling variants), and rewrites the PRICES +
* CONTEXT_LIMITS tables in src/core/prices.ts — the tables are generated;
* hand edits are overwritten. Per-token prices convert to per-1M-token USD;
* fields OpenRouter doesn't publish (null) are omitted. A model missing from
* OpenRouter's list gets no entry — cost renders as absent ("—"), never zero.
*/
import { MODELS } from "../config.ts";
import * as fs from "node:fs";
import * as path from "node:path";
interface OpenRouterModel {
id: string;
context_length?: number;
pricing?: Record<string, string | null>;
}
/** Spelling variants worth keeping priced alongside a config id (the config's
* id must always resolve even if OpenRouter renames the slug). */
const VARIANTS: Record<string, string[]> = {
"qwen/qwen3.8-max": ["qwen/qwen3.8-max-0902"],
"qwen/qwen3.8-max-0902": ["qwen/qwen3.8-max"],
};
const API = "https://openrouter.ai/api/v1/models";
const OUT = path.join(import.meta.dirname, "..", "src", "core", "prices.ts");
const perM = (v: string | null | undefined): number | undefined => {
if (v === null || v === undefined || v === "") return undefined;
const n = Number(v) * 1e6;
return Number.isFinite(n) ? Number(n.toFixed(4)) : undefined;
};
function priceLine(id: string, model: OpenRouterModel): string {
const p = model.pricing ?? {};
const fields = [
`input: ${perM(p.prompt)}`,
`output: ${perM(p.completion)}`,
`cacheRead: ${perM(p.input_cache_read)}`,
...(perM(p.input_cache_write) !== undefined ? [`cacheWrite: ${perM(p.input_cache_write)}`] : []),
];
return `\t${JSON.stringify(id)}: { ${fields.join(", ")} },`;
}
async function main(): Promise<void> {
const res = await fetch(API);
if (!res.ok) throw new Error(`OpenRouter models request failed: ${res.status} ${res.statusText}`);
const { data } = (await res.json()) as { data: OpenRouterModel[] };
const byId = new Map(data.map((m) => [m.id, m]));
const wanted = Object.values(MODELS).map((m) => m.model);
const all = [...new Set([...wanted, ...wanted.flatMap((id) => VARIANTS[id] ?? [])])];
const priced = new Map<string, { price: string; limit: string }>();
for (const id of all) {
const model = byId.get(id);
if (!model) {
console.warn(`!! ${id}: not on OpenRouter — entry omitted (cost renders as "—")`);
continue;
}
const limit = model.context_length;
priced.set(id, {
price: priceLine(id, model),
limit: `\t${JSON.stringify(id)}: ${limit ?? "undefined"},`,
});
}
const tables = `/** Token prices, USD per 1M tokens. A missing model makes cost render
* as absent ("—"), never zero. Refresh: npx tsx scripts/sync-pricing.ts */
export const PRICES: Record<string, ModelPrice> = {
${[...priced.values()].map((p) => p.price).join("\n")}
};
/** Context-window limits (tokens) per model — generated alongside PRICES. */
export const CONTEXT_LIMITS: Record<string, number> = {
${[...priced.values()].map((p) => p.limit).join("\n")}
};
`;
const src = fs.readFileSync(OUT, "utf-8");
const start = src.indexOf("/** Token prices");
const end = src.indexOf("// ─── Pure helpers");
if (start === -1 || end === -1) throw new Error("prices.ts markers not found — file structure changed");
fs.writeFileSync(OUT, src.slice(0, start) + tables + "\n" + src.slice(end), "utf-8");
console.log(`prices.ts regenerated: ${all.length} models checked, ${priced.size} priced.`);
for (const id of all) if (!priced.has(id)) console.warn(` unpriced: ${id}`);
}
void main();