auto-model-router 0.22.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,14 +7,14 @@
7
7
  },
8
8
  "metadata": {
9
9
  "description": "auto-model-router: a local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter",
10
- "version": "0.22.0",
10
+ "version": "0.24.0",
11
11
  "pluginRoot": "."
12
12
  },
13
13
  "plugins": [
14
14
  {
15
15
  "name": "auto-model-router",
16
16
  "description": "Local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter. Runs in-process, routes per turn by price and task complexity, with budget caps, mid-stream escalation, and cache-aware hysteresis.",
17
- "version": "0.22.0",
17
+ "version": "0.24.0",
18
18
  "author": {
19
19
  "name": "drewappling",
20
20
  "email": "drewappling@gmail.com"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auto-model-router",
3
- "version": "0.22.0",
3
+ "version": "0.24.0",
4
4
  "private": false,
5
5
  "description": "Local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter",
6
6
  "type": "module",
@@ -35,7 +35,12 @@ export interface CompositeBias {
35
35
  /** False when OpenRouter cannot dispatch (no key): its models are listed for metadata only, never served. Default true. */
36
36
  serveOpenRouter?: () => boolean;
37
37
  /** Named upstreams' models, built from the OpenRouter models (twins) and filtered by each upstream's breaker. */
38
- named?: { models(openrouter: readonly CatalogModel[]): readonly CatalogModel[]; serving(id: string): boolean };
38
+ named?: {
39
+ models(openrouter: readonly CatalogModel[]): readonly CatalogModel[];
40
+ serving(id: string): boolean;
41
+ /** That upstream's `costBias`, so prepaid capacity ranks below list price. 1 when it has none. */
42
+ bias?(id: string): number;
43
+ };
39
44
  }
40
45
 
41
46
  /** Shared empty list, so a deployment without named upstreams keeps the merged snapshot's identity. */
@@ -81,8 +86,14 @@ export function createCompositeCatalog(
81
86
  merged = serveBase ? mergeSnapshots(base, available ? models : []) : { ...base, models: available ? [...models] : [] };
82
87
  if (named.length > 0) merged = { ...merged, models: [...merged.models, ...named] };
83
88
  // A fresh object either way once anything changed; stamp the live bias so
84
- // candidate scoring reads it off the snapshot it is ranking.
85
- merged = { ...merged, providerBias: { ollama: providerBias } };
89
+ // candidate scoring reads it off the snapshot it is ranking. Each named upstream
90
+ // adds its own, so prepaid capacity ranks below list price without being free.
91
+ const biases: Record<string, number> = { ollama: providerBias };
92
+ for (const m of named) {
93
+ const b = bias.named?.bias?.(m.provider) ?? 1;
94
+ if (b !== 1) biases[m.provider] = b;
95
+ }
96
+ merged = { ...merged, providerBias: biases };
86
97
  return merged;
87
98
  }
88
99
 
@@ -29,9 +29,17 @@ export function buildUpstreamModels(entry: UpstreamEntry, openrouter: readonly C
29
29
  const twin = (m.twin !== undefined ? bySlug.get(m.twin) : undefined) ?? twins.get(normalizeModelKey(m.id)) ?? null;
30
30
  const modalities: Modality[] = ["text"];
31
31
  if (m.vision ?? twin?.inputModalities.includes("image") ?? false) modalities.push("image");
32
- const price: CatalogModel["price"] = { prompt: m.input / 1e6, completion: m.output / 1e6 };
33
- if (m.cachedInput !== undefined) price.cacheRead = m.cachedInput / 1e6;
34
- if (m.cacheWrite !== undefined) price.cacheWrite = m.cacheWrite / 1e6;
32
+ // An omitted price means "whatever the twin costs": a subscription sells the same
33
+ // weights per token on OpenRouter, so the twin is the only honest figure. Falling
34
+ // back to zero instead would win every tier outright and silence the quality floors.
35
+ const price: CatalogModel["price"] = {
36
+ prompt: m.input !== undefined ? m.input / 1e6 : (twin?.price.prompt ?? 0),
37
+ completion: m.output !== undefined ? m.output / 1e6 : (twin?.price.completion ?? 0),
38
+ };
39
+ const cacheRead = m.cachedInput !== undefined ? m.cachedInput / 1e6 : twin?.price.cacheRead;
40
+ const cacheWrite = m.cacheWrite !== undefined ? m.cacheWrite / 1e6 : twin?.price.cacheWrite;
41
+ if (cacheRead !== undefined) price.cacheRead = cacheRead;
42
+ if (cacheWrite !== undefined) price.cacheWrite = cacheWrite;
35
43
  const model: CatalogModel = {
36
44
  slug: `${entry.id}/${m.id}`,
37
45
  canonicalSlug: `${entry.id}/${m.id}`,
@@ -64,8 +64,8 @@ export const RESERVED_UPSTREAM_IDS: readonly string[] = ["openrouter", "ollama",
64
64
  const upstreamModel = z.strictObject({
65
65
  id: z.string().min(1),
66
66
  name: z.string().optional(),
67
- input: z.number().nonnegative(),
68
- output: z.number().nonnegative(),
67
+ input: z.number().nonnegative().optional(),
68
+ output: z.number().nonnegative().optional(),
69
69
  cachedInput: z.number().nonnegative().optional(),
70
70
  cacheWrite: z.number().nonnegative().optional(),
71
71
  contextLength: z.number().int().positive().optional(),
@@ -87,11 +87,13 @@ const upstream = z.strictObject({
87
87
  enabled: z.boolean().optional(),
88
88
  baseUrl: z.string().min(1),
89
89
  apiKey: z.string().optional(),
90
+ auth: z.enum(["api-key", "oauth-bearer"]).optional(),
90
91
  apiVersion: z.string().optional(),
91
92
  headers: z.record(z.string(), z.string()).optional(),
92
93
  timeoutMs: z.number().positive().optional(),
93
94
  rateLimitCooldownMs: z.number().nonnegative().optional(),
94
95
  quotaCooldownMs: z.number().nonnegative().optional(),
96
+ costBias: z.number().positive().max(1).optional(),
95
97
  models: z.array(upstreamModel),
96
98
  });
97
99
 
@@ -906,10 +906,15 @@ export interface UpstreamModelConfig {
906
906
  /** The model id the provider knows (an Azure deployment name for `azure`). The catalog slug is `<upstream id>/<id>`. */
907
907
  id: string;
908
908
  name?: string;
909
- /** USD per million prompt tokens. */
910
- input: number;
911
- /** USD per million completion tokens. */
912
- output: number;
909
+ /**
910
+ * USD per million prompt tokens. Absent ⇒ the OpenRouter twin's price, which is what
911
+ * a subscription upstream wants: the same weights are sold per token there, so the
912
+ * twin is the only honest figure for what a turn is worth. Pair it with `costBias`
913
+ * to rank prepaid capacity below list without pretending it is free.
914
+ */
915
+ input?: number;
916
+ /** USD per million completion tokens. Absent ⇒ the OpenRouter twin's. */
917
+ output?: number;
913
918
  /** USD per million cached prompt tokens, when the provider discounts them. */
914
919
  cachedInput?: number;
915
920
  /** USD per million prompt tokens written to cache (Anthropic). */
@@ -943,11 +948,20 @@ export interface UpstreamEntry {
943
948
  apiKey: string;
944
949
  /** Azure only: the `api-version` query parameter. */
945
950
  apiVersion: string;
951
+ /** `api-key` sends `x-api-key`; `oauth-bearer` sends `Authorization: Bearer` plus the Claude OAuth beta headers (a Claude Pro/Max subscription token). */
952
+ auth: "api-key" | "oauth-bearer";
946
953
  /** Extra request headers, e.g. a gateway's own auth. */
947
954
  headers: Record<string, string>;
948
955
  timeoutMs: number;
949
956
  rateLimitCooldownMs: number;
950
957
  quotaCooldownMs: number;
958
+ /**
959
+ * Ranking multiplier on this upstream's forecast cost, like `ollama.costBias`: below 1
960
+ * prefers capacity that is already paid for (a Claude Pro/Max subscription) without
961
+ * pricing it at zero, which would win every tier and make quality floors meaningless.
962
+ * Ranking only — the ledger still records the price the catalog carries. Default 1.
963
+ */
964
+ costBias: number;
951
965
  models: UpstreamModelConfig[];
952
966
  }
953
967
 
@@ -17,9 +17,11 @@ export function completeUpstreamEntry(raw: Record<string, unknown>): UpstreamEnt
17
17
  apiKey: typeof raw.apiKey === "string" ? raw.apiKey : "",
18
18
  apiVersion: typeof raw.apiVersion === "string" ? raw.apiVersion : "2024-10-21",
19
19
  headers: (raw.headers as Record<string, string> | undefined) ?? {},
20
+ auth: raw.auth === "oauth-bearer" ? "oauth-bearer" : "api-key",
20
21
  timeoutMs: typeof raw.timeoutMs === "number" ? raw.timeoutMs : 600_000,
21
22
  rateLimitCooldownMs: typeof raw.rateLimitCooldownMs === "number" ? raw.rateLimitCooldownMs : 60_000,
22
23
  quotaCooldownMs: typeof raw.quotaCooldownMs === "number" ? raw.quotaCooldownMs : 15 * 60_000,
24
+ costBias: typeof raw.costBias === "number" && raw.costBias > 0 ? raw.costBias : 1,
23
25
  models: (raw.models as UpstreamEntry["models"] | undefined) ?? [],
24
26
  };
25
27
  }
@@ -20,6 +20,7 @@ import { computeBlendedRate } from "./blended.ts";
20
20
  import { computeCost } from "./forecast.ts";
21
21
  import type {
22
22
  BlendedRate,
23
+ CostBreakdown,
23
24
  EscalationCost,
24
25
  Ledger,
25
26
  LedgerEntry,
@@ -283,6 +284,9 @@ export function toEntry(row: LedgerRow): LedgerEntry {
283
284
  // Likewise a row from before v19, or a turn with redaction off: absent,
284
285
  // which is a different fact from 0 (the rules ran and matched nothing).
285
286
  ...(row.redactions === null || row.redactions === undefined ? {} : { redactions: row.redactions }),
287
+ // A row recorded before pricing, or one whose model could not be priced,
288
+ // stores NULL; absent is the front door's cue to fall back to the blend.
289
+ ...(row.cost_breakdown === null || row.cost_breakdown === undefined ? {} : { costBreakdown: JSON.parse(row.cost_breakdown) as CostBreakdown }),
286
290
  };
287
291
  }
288
292
 
package/src/cost/types.ts CHANGED
@@ -167,6 +167,13 @@ export interface LedgerEntry {
167
167
  * provider (Ollama) exists only in memory, so the orchestrator hands it over.
168
168
  */
169
169
  priceModel?: CatalogModel;
170
+ /**
171
+ * The cost component split the router computed when it recorded this row,
172
+ * from its own catalog prices. Absent when the row predates pricing, or
173
+ * when the model could not be priced (NULL in the ledger) — a front door
174
+ * shows input/output/cache costs from this rather than re-deriving them.
175
+ */
176
+ costBreakdown?: CostBreakdown;
170
177
  }
171
178
 
172
179
  /**
@@ -357,12 +357,14 @@ export function buildCandidates(args: BuildCandidatesArgs): { candidates: Candid
357
357
  filters.escalationCostWeight > 0 && args.escalationUsdPerPromptToken !== undefined
358
358
  ? filters.escalationCostWeight * escalationRate * args.escalationUsdPerPromptToken * features.promptTokens
359
359
  : 0;
360
- // Provider bias: an Ollama plan's included credits are money already
361
- // spent, so an operator may value them below list price in ranking. The
362
- // ledger still records list price.
363
- // The snapshot carries the LIVE bias (credit-aware); the static config
364
- // value is the fallback for snapshots built without one.
365
- const providerBias = snapshot.providerBias?.[model.provider] ?? (model.provider === "ollama" ? cfg.ollama.costBias : 1);
360
+ // Provider bias: capacity already paid for — an Ollama plan's included credits, a
361
+ // Claude Pro/Max subscription — is money already spent, so an operator may value it
362
+ // below list price in ranking. The ledger still records list price.
363
+ // The snapshot carries the LIVE bias (credit-aware, and each named upstream's own);
364
+ // the static config value is the fallback for snapshots built without one.
365
+ const providerBias =
366
+ snapshot.providerBias?.[model.provider] ??
367
+ (model.provider === "ollama" ? cfg.ollama.costBias : (cfg.upstreams.find((u) => u.id === model.provider)?.costBias ?? 1));
366
368
  const effectiveUsd = (fc.expectedUsd / Math.max(trustScore, 0.5) + escalationUsd) * latencyMult * providerBias;
367
369
  // Score is assigned in a SECOND PASS below: both qualityNormalization and
368
370
  // capabilityFloorUsd are properties of the candidate SET, not of one
@@ -381,7 +383,7 @@ export function buildCandidates(args: BuildCandidatesArgs): { candidates: Candid
381
383
  if (escalationUsd > 0) {
382
384
  reasons.push(`escalation risk +$${escalationUsd.toFixed(6)} (rate ${(escalationRate * 100).toFixed(2)}% × measured retry cost)`);
383
385
  }
384
- if (providerBias !== 1) reasons.push(`provider bias ×${providerBias} (ollama.costBias, plan credits remaining)`);
386
+ if (providerBias !== 1) reasons.push(`provider bias ×${providerBias} (${model.provider === "ollama" ? "ollama.costBias, plan credits remaining" : `${model.provider}.costBias, capacity already paid for`})`);
385
387
  if (latencyMult > 1 && latency !== null) {
386
388
  reasons.push(
387
389
  `latency penalty ×${latencyMult.toFixed(2)} (ttft ${Math.round(latency.ttftMs)}ms, ${latency.tokensPerSec.toFixed(0)} tok/s over ${latency.samples} samples)`,
@@ -77,7 +77,7 @@ export function createProviders(cfg: RouterConfig, db: Database, log: Logger = c
77
77
  };
78
78
  const namedServingOne = (id: string): boolean => {
79
79
  const entry = cfg.upstreams.find((u) => u.id === id);
80
- if (entry === undefined || !entry.enabled || entry.apiKey === "" && entry.kind !== "openai") return entry !== undefined && entry.enabled && (named(id)?.available() ?? false);
80
+ if (entry === undefined || !entry.enabled || (entry.apiKey === "" && entry.kind !== "openai" && entry.auth !== "oauth-bearer")) return entry !== undefined && entry.enabled && (named(id)?.available() ?? false);
81
81
  return named(id)?.available() ?? false;
82
82
  };
83
83
  const namedServing = (): string[] => cfg.upstreams.filter((u) => u.enabled && namedServingOne(u.id)).map((u) => u.id);
@@ -91,7 +91,7 @@ export function createProviders(cfg: RouterConfig, db: Database, log: Logger = c
91
91
  usage: ollamaUsage,
92
92
  live: () => ({ costBias: cfg.ollama.costBias, biasUntilUsage: cfg.ollama.biasUntilUsage }),
93
93
  serveOpenRouter: () => cfg.openrouter.apiKey !== "",
94
- named: { models: (base) => staticCatalog.get(base), serving: namedServingOne },
94
+ named: { models: (base) => staticCatalog.get(base), serving: namedServingOne, bias: (id) => cfg.upstreams.find((u) => u.id === id)?.costBias ?? 1 },
95
95
  }),
96
96
  ollama,
97
97
  ollamaServing,
@@ -355,6 +355,27 @@ function transportError(id: string, err: unknown): UpstreamError {
355
355
  return new UpstreamError("network", 0, err instanceof Error ? err.message : String(err), true);
356
356
  }
357
357
 
358
+ /**
359
+ * Anthropic accepts a Pro/Max subscription token only on Claude Code's own traffic: unless
360
+ * the FIRST system block carries this exact line, the API answers 429 `rate_limit_error`
361
+ * with the message "Error" — a refusal wearing a quota's clothes, not a real rate limit,
362
+ * which the breaker would otherwise read as "slow down" and cool the upstream off.
363
+ *
364
+ * Claude Code sends it itself. Every other client — an OpenAI-wire caller, the router's own
365
+ * classifier and digest chores — would be refused, so an `oauth-bearer` upstream adds it
366
+ * when it is missing. It goes in as its own leading block rather than being merged into the
367
+ * caller's text, because that is the shape Claude Code sends and it keeps the caller's
368
+ * `cache_control` markers attached to the blocks they were written for.
369
+ */
370
+ const CLAUDE_CODE_IDENTITY = "You are Claude Code, Anthropic's official CLI for Claude.";
371
+
372
+ function withClaudeCodeIdentity(body: Record<string, unknown>): Record<string, unknown> {
373
+ const system = Array.isArray(body.system) ? (body.system as Block[]) : [];
374
+ const first = asRec(system[0]);
375
+ if (first !== null && typeof first.text === "string" && first.text.startsWith(CLAUDE_CODE_IDENTITY)) return body;
376
+ return { ...body, system: [{ type: "text", text: CLAUDE_CODE_IDENTITY }, ...system] };
377
+ }
378
+
358
379
  export function createAnthropicClient(cfg: RouterConfig, id: string, fetchImpl: FetchLike = fetch): NamedUpstreamClient {
359
380
  const lookup = upstreamLookup(cfg, id);
360
381
  const log = createLogger(cfg.logLevel);
@@ -379,7 +400,8 @@ export function createAnthropicClient(cfg: RouterConfig, id: string, fetchImpl:
379
400
  const { modelId, model } = modelInfo(e, slug);
380
401
  const opts: AnthropicBodyOptions = { modelId, supportsReasoning: model?.supportsReasoning ?? false };
381
402
  if (model?.maxCompletionTokens !== undefined) opts.maxCompletionTokens = model.maxCompletionTokens;
382
- return { rendered: toAnthropicBody(body, opts), servedSlug: `${id}/${modelId}` };
403
+ const rendered = toAnthropicBody(body, opts);
404
+ return { rendered: e.auth === "oauth-bearer" ? withClaudeCodeIdentity(rendered) : rendered, servedSlug: `${id}/${modelId}` };
383
405
  }
384
406
  function composeSignal(e: UpstreamEntry, caller: AbortSignal | undefined): AbortSignal | null {
385
407
  const timeout = e.timeoutMs > 0 ? AbortSignal.timeout(e.timeoutMs) : null;
@@ -388,7 +410,11 @@ export function createAnthropicClient(cfg: RouterConfig, id: string, fetchImpl:
388
410
  }
389
411
  async function post(e: UpstreamEntry, body: Record<string, unknown>, signal: AbortSignal | undefined): Promise<Response> {
390
412
  const headers: Record<string, string> = { "content-type": "application/json", "anthropic-version": ANTHROPIC_VERSION, ...e.headers };
391
- if (e.apiKey !== "") headers["x-api-key"] = e.apiKey;
413
+ if (e.auth === "oauth-bearer") {
414
+ // A Claude Pro/Max subscription token: Bearer auth at the first-party API, with the OAuth beta. No per-token cost is reported.
415
+ headers["authorization"] = `Bearer ${e.apiKey}`;
416
+ headers["anthropic-beta"] = e.headers["anthropic-beta"] ?? "oauth-2025-04-20,claude-code-20250219";
417
+ } else if (e.apiKey !== "") headers["x-api-key"] = e.apiKey;
392
418
  try {
393
419
  return await fetchImpl(`${e.baseUrl.replace(/\/+$/, "")}/v1/messages`, { method: "POST", headers, body: JSON.stringify(body), signal: composeSignal(e, signal) });
394
420
  } catch (err) {
@@ -100,6 +100,23 @@ describe("static catalog", () => {
100
100
  expect(unknown!.tokenizer).toBe("Claude");
101
101
  });
102
102
 
103
+ test("a subscription model with no price of its own inherits the twin's, so it never ranks as free", () => {
104
+ // A Pro/Max subscription publishes no per-token rates, and pricing it at zero would beat
105
+ // every model in every tier outright. The twin sells the same weights, so it is the rate.
106
+ const e = entry({ id: "anthropic-subscription", kind: "anthropic", costBias: 0.1, models: [{ id: "claude-sonnet-4-20250514", twin: "anthropic/claude-sonnet-4" }] });
107
+ const [sub] = buildUpstreamModels(e, twins);
108
+ const twin = twins.find((m) => m.slug === "anthropic/claude-sonnet-4")!;
109
+ expect(sub!.price.prompt).toBe(twin.price.prompt);
110
+ expect(sub!.price.completion).toBe(twin.price.completion);
111
+ expect(sub!.price.prompt).toBeGreaterThan(0);
112
+ expect(sub!.isFree).toBe(false);
113
+ // The discount is a RANKING bias on the entry, never a rewrite of the recorded price.
114
+ expect(e.costBias).toBe(0.1);
115
+ // An explicit zero still means zero: a self-hosted server is genuinely free.
116
+ const free = entry({ id: "vllm", kind: "openai", models: [{ id: "local", input: 0, output: 0 }] });
117
+ expect(buildUpstreamModels(free, twins)[0]!.price).toEqual({ prompt: 0, completion: 0 });
118
+ });
119
+
103
120
  test("the source rebuilds only when the entries or the OpenRouter models change", () => {
104
121
  const cfg = cfgWith([entry({ id: "vllm", kind: "openai" })]);
105
122
  const src = createStaticCatalogSource(cfg);
@@ -349,6 +366,32 @@ describe("the Anthropic client", () => {
349
366
  expect(classifyAnthropicStatus("a", 400, { error: { message: "prompt is too long: 250000 tokens" } })).toMatchObject({ kind: "context_length", retryable: false });
350
367
  expect(classifyAnthropicStatus("a", 401, {})).toMatchObject({ kind: "auth", retryable: false });
351
368
  });
369
+
370
+ test("a subscription upstream: OAuth bearer instead of x-api-key, and the Claude Code identity leads the system blocks", async () => {
371
+ // Anthropic answers a Pro/Max token 429 "Error" unless the first system block says this,
372
+ // so the literal is the wire contract and is spelled out here rather than imported.
373
+ const identity = "You are Claude Code, Anthropic's official CLI for Claude.";
374
+ let headers: Record<string, string> = {};
375
+ let sent: Record<string, unknown> = {};
376
+ const fetchImpl = async (_u: string, init?: RequestInit): Promise<Response> => {
377
+ headers = init?.headers as Record<string, string>;
378
+ sent = JSON.parse(String(init?.body)) as Record<string, unknown>;
379
+ return sse([`event: message_stop${NL}data: {"type":"message_stop"}`]);
380
+ };
381
+ const cfg = cfgWith([entry({ id: "sub", kind: "anthropic", auth: "oauth-bearer", baseUrl: "https://api.anthropic.com", apiKey: "sk-ant-oat01-x", models: [{ id: "claude-sonnet-5", input: 0, output: 0 }] })]);
382
+ const drain = async (messages: unknown[]): Promise<void> => {
383
+ const d = await createAnthropicClient(cfg, "sub", fetchImpl).dispatch({ body: { model: "sub/claude-sonnet-5", messages, max_tokens: 16 }, sessionId: "s", signal: new AbortController().signal });
384
+ for await (const c of d.chunks) void c;
385
+ };
386
+ await drain([{ role: "user", content: "ping" }]);
387
+ expect(headers.authorization).toBe("Bearer sk-ant-oat01-x");
388
+ expect(headers["anthropic-beta"]).toContain("oauth-2025-04-20");
389
+ expect(headers["x-api-key"]).toBeUndefined();
390
+ expect(sent.system).toEqual([{ type: "text", text: identity }]);
391
+ // A caller that already identifies as Claude Code keeps its own block; it is not said twice.
392
+ await drain([{ role: "system", content: `${identity} Be brief.` }, { role: "user", content: "ping" }]);
393
+ expect(sent.system).toEqual([{ type: "text", text: `${identity} Be brief.` }]);
394
+ });
352
395
  });
353
396
 
354
397
  describe("the composite catalog with named upstreams", () => {
@@ -215,4 +215,20 @@ describe("decision entries", () => {
215
215
  expect(bob.find((e) => e.id === "l3")?.error).toBe("boom");
216
216
  expect(bob.find((e) => e.id === "l3")?.feedback[0]?.note).toBe("looped");
217
217
  });
218
+
219
+ test("the recorded cost split rides along; unpriced rows leave it absent", () => {
220
+ const { decisionEntries } = require("../src/cost/views.ts") as typeof import("../src/cost/views.ts");
221
+ // The fixture records before any catalog fetch, so every row stored NULL;
222
+ // one priced row stands in for a turn the router could price at record time.
223
+ db.run("UPDATE ledger SET cost_breakdown = ? WHERE id = 'l1'", [
224
+ JSON.stringify({ freshPrompt: 0.006, cacheRead: 0.004, cacheWrite: 0, completion: 0.002, reasoning: 0, images: 0, request: 0, total: 0.006, tierAtPromptTokens: 0 }),
225
+ ]);
226
+ const entries = decisionEntries(db, { sinceMs: since, harness: null });
227
+ const priced = entries.find((e) => e.id === "l1");
228
+ expect(priced?.costBreakdown?.total).toBeCloseTo(0.006, 6);
229
+ expect(priced?.costBreakdown?.cacheRead).toBeCloseTo(0.004, 6);
230
+ // A row the ledger could not price (all of them here, NULL column) has no
231
+ // breakdown at all — the front door's cue to fall back to the blend.
232
+ expect(entries.find((e) => e.id === "l2")?.costBreakdown).toBeUndefined();
233
+ });
218
234
  });