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.
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* 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();
|
||||
Reference in New Issue
Block a user