auto-model-router 0.2.31 → 0.3.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.
Files changed (68) hide show
  1. package/.omp-plugin/marketplace.json +2 -2
  2. package/README.md +211 -29
  3. package/docs/review-2026-09-05.md +267 -0
  4. package/omp-extension/configure-logic.ts +71 -15
  5. package/omp-extension/pi-coding-agent.d.ts +79 -2
  6. package/omp-extension/report-hub.ts +376 -0
  7. package/omp-extension/report-logic.ts +115 -0
  8. package/omp-extension/router-configure.ts +203 -51
  9. package/omp-extension/router-url.ts +52 -0
  10. package/omp-extension/toast-logic.ts +14 -2
  11. package/package.json +1 -1
  12. package/src/catalog/composite.ts +97 -0
  13. package/src/catalog/ollama-catalog.ts +309 -0
  14. package/src/catalog/ollama-prices.ts +85 -0
  15. package/src/catalog/openrouter-catalog.ts +39 -1
  16. package/src/catalog/types.ts +31 -1
  17. package/src/cli/args.ts +1 -0
  18. package/src/cli/config-wizard.ts +189 -28
  19. package/src/cli/explain.ts +2 -4
  20. package/src/cli/models.ts +2 -4
  21. package/src/cli/report.ts +37 -0
  22. package/src/config/defaults.ts +43 -2
  23. package/src/config/load.ts +25 -1
  24. package/src/config/omp-credentials.ts +31 -7
  25. package/src/config/schema.ts +28 -0
  26. package/src/config/types.ts +127 -2
  27. package/src/cost/ledger.ts +73 -4
  28. package/src/cost/report.ts +340 -0
  29. package/src/cost/types.ts +33 -1
  30. package/src/index.ts +5 -8
  31. package/src/router/candidates.ts +90 -11
  32. package/src/router/classify.ts +33 -6
  33. package/src/router/features.ts +13 -1
  34. package/src/router/select.ts +55 -8
  35. package/src/router/state.ts +6 -2
  36. package/src/router/tier-plan.ts +49 -11
  37. package/src/router/types.ts +11 -0
  38. package/src/server/http.ts +47 -6
  39. package/src/server/providers.ts +54 -0
  40. package/src/server/turn.ts +122 -34
  41. package/src/tokens/estimate.ts +16 -0
  42. package/src/upstream/multi.ts +26 -0
  43. package/src/upstream/ollama-usage.ts +157 -0
  44. package/src/upstream/ollama.ts +275 -0
  45. package/src/upstream/openrouter.ts +19 -1
  46. package/src/upstream/types.ts +2 -0
  47. package/src/util/sqlite.ts +25 -1
  48. package/test/catalog.test.ts +44 -0
  49. package/test/classify.test.ts +41 -5
  50. package/test/compaction.test.ts +1 -0
  51. package/test/config-wizard.test.ts +77 -1
  52. package/test/configure-logic.test.ts +129 -33
  53. package/test/embed-lifecycle.test.ts +1 -0
  54. package/test/failover.test.ts +148 -3
  55. package/test/features.test.ts +35 -0
  56. package/test/http-resilience.test.ts +24 -0
  57. package/test/ollama.test.ts +506 -0
  58. package/test/omp-credentials.test.ts +43 -1
  59. package/test/report-hub.test.ts +341 -0
  60. package/test/report-logic.test.ts +92 -0
  61. package/test/report.test.ts +217 -0
  62. package/test/select.test.ts +176 -1
  63. package/test/tier-plan.test.ts +159 -1
  64. package/test/toast-logic.test.ts +11 -2
  65. package/test/tokens.test.ts +71 -1
  66. package/test/trust-attribution.test.ts +2 -2
  67. package/test/turn.test.ts +124 -7
  68. package/tools/build-site.ts +1 -0
@@ -33,6 +33,28 @@ const openrouter = z.strictObject({
33
33
  catalogRefreshMs: z.number().nonnegative().optional(),
34
34
  });
35
35
 
36
+ const ollamaRate = z.strictObject({
37
+ input: z.number().nonnegative(),
38
+ cachedInput: z.number().nonnegative().optional(),
39
+ output: z.number().nonnegative(),
40
+ });
41
+
42
+ const ollama = z.strictObject({
43
+ enabled: z.boolean().optional(),
44
+ baseUrl: z.string().min(1).optional(),
45
+ apiKey: z.string().optional(),
46
+ timeoutMs: z.number().positive().optional(),
47
+ catalogTtlMs: z.number().positive().optional(),
48
+ includeLocal: z.boolean().optional(),
49
+ prices: z.record(z.string(), ollamaRate).optional(),
50
+ twins: z.record(z.string(), z.string()).optional(),
51
+ costBias: z.number().positive().optional(),
52
+ biasUntilUsage: z.number().min(0).max(1).optional(),
53
+ usagePollMs: z.number().nonnegative().optional(),
54
+ quotaCooldownMs: z.number().nonnegative().optional(),
55
+ rateLimitCooldownMs: z.number().nonnegative().optional(),
56
+ });
57
+
36
58
  const benchmarks = z.strictObject({
37
59
  enabled: z.boolean().optional(),
38
60
  artificialAnalysisApiKey: z.string().optional(),
@@ -73,6 +95,8 @@ const filters = z.strictObject({
73
95
  latencyReferenceMs: z.number().positive().optional(),
74
96
  latencyReferenceTokensPerSec: z.number().positive().optional(),
75
97
  latencyMinSamples: z.number().int().nonnegative().optional(),
98
+ maxExpectedWaitMs: z.number().positive().optional(),
99
+ escalationCostWeight: z.number().min(0).max(1).optional(),
76
100
  });
77
101
 
78
102
  const classifier = z.strictObject({
@@ -85,6 +109,7 @@ const classifier = z.strictObject({
85
109
  toolAxis: qualityAxis.optional(),
86
110
  chatAxis: qualityAxis.optional(),
87
111
  agenticLoopDepth: z.number().int().nonnegative().optional(),
112
+ mechanicalRetryFactor: z.number().min(0).max(1).optional(),
88
113
  reasoningWeights: z
89
114
  .strictObject({
90
115
  medium: z.number().nonnegative().optional(),
@@ -109,6 +134,7 @@ const hysteresis = z.strictObject({
109
134
  holdTurns: z.number().int().nonnegative().optional(),
110
135
  holdTurnsAfterEscalation: z.number().int().nonnegative().optional(),
111
136
  switchMargin: z.number().positive().optional(),
137
+ switchHorizonTurns: z.number().int().positive().optional(),
112
138
  cacheWarmTtlMs: z.number().nonnegative().optional(),
113
139
  maxDowngradePerTurn: z.number().int().nonnegative().optional(),
114
140
  breakHoldOnMechanical: z.boolean().optional(),
@@ -163,6 +189,7 @@ const compaction = z.strictObject({
163
189
  enabled: z.boolean().optional(),
164
190
  budgetTokens: z.number().int().positive().optional(),
165
191
  floorRatio: z.number().positive().max(1).optional(),
192
+ replanGrowthRatio: z.number().min(1).optional(),
166
193
  fitToWindow: z.boolean().optional(),
167
194
  protectRecentTurns: z.number().int().positive().optional(),
168
195
  maxToolResultBytes: z.number().int().positive().optional(),
@@ -225,6 +252,7 @@ export const configInputSchema = z.strictObject({
225
252
  chat: taskConfig.optional(),
226
253
  })
227
254
  .optional(),
255
+ ollama: ollama.optional(),
228
256
  filters: filters.optional(),
229
257
  classifier: classifier.optional(),
230
258
  escalation: escalation.optional(),
@@ -64,6 +64,58 @@ export interface OpenRouterConfig {
64
64
  catalogRefreshMs: number;
65
65
  }
66
66
 
67
+ /**
68
+ * Ollama Cloud as a second upstream, ranked in the same catalog as OpenRouter.
69
+ *
70
+ * Off by default. Reached either through a local Ollama daemon (the default
71
+ * `baseUrl`; it proxies `:cloud` models under the signed-in account and lists
72
+ * their context length and capabilities) or directly at `https://ollama.com/v1`
73
+ * with an API key. Slugs are `ollama/<id>`.
74
+ *
75
+ * Ollama publishes no prices via API, so rates come from a shipped snapshot
76
+ * plus `prices`; a model with no rate is dropped. Quality scores come from the
77
+ * model's OpenRouter twin (matched by name, or pinned via `twins`), because
78
+ * Ollama publishes none — an unmatched model is unscored and serves only
79
+ * `trivial`. Ollama reports no cost per response, so the ledger's predicted
80
+ * figure is what gets recorded.
81
+ */
82
+ export interface OllamaConfig {
83
+ enabled: boolean;
84
+ /** `http://127.0.0.1:11434/v1` (daemon) or `https://ollama.com/v1` (direct). */
85
+ baseUrl: string;
86
+ /** Resolved from config, then `OLLAMA_API_KEY`. Needed for ollama.com; the daemon uses its own sign-in. */
87
+ apiKey: string;
88
+ /** Per-request timeout, ms. */
89
+ timeoutMs: number;
90
+ /** Re-list models when the last listing is older than this, ms. */
91
+ catalogTtlMs: number;
92
+ /** Also expose the daemon's LOCAL models (unpriced unless `prices` names them). Off: cloud only. */
93
+ includeLocal: boolean;
94
+ /** USD per million tokens, keyed by bare cloud name; overrides/extends the shipped snapshot. */
95
+ prices: Record<string, { input: number; cachedInput?: number; output: number }>;
96
+ /** Bare cloud name → OpenRouter slug, when the name-based twin match is wrong or missing. */
97
+ twins: Record<string, string>;
98
+ /**
99
+ * Multiplier on an Ollama model's effective cost in ranking, 1 = list price.
100
+ * Below 1 prefers Ollama when a plan's included credits would otherwise go
101
+ * unused; the ledger still records list price, so spend stays honest.
102
+ */
103
+ costBias: number;
104
+ /**
105
+ * Plan-usage fraction (0-1) at which `costBias` switches off and Ollama
106
+ * ranks at list price. Read from ollama.com's `/api/usage`, which reports
107
+ * consumption as a share of the plan's included monthly credits — so the
108
+ * same setting is right on Pro, Max or Team. 1 keeps the bias regardless.
109
+ */
110
+ biasUntilUsage: number;
111
+ /** How often to re-read plan usage, ms. 0 disables the read (bias stays static). */
112
+ usagePollMs: number;
113
+ /** How long to route around Ollama after a 402 (credits exhausted), ms. */
114
+ quotaCooldownMs: number;
115
+ /** How long to route around Ollama after a 429 (concurrency cap), ms. */
116
+ rateLimitCooldownMs: number;
117
+ }
118
+
67
119
  /**
68
120
  * External benchmark feeds that BACKFILL quality scores OpenRouter does not
69
121
  * publish. OpenRouter embeds Artificial Analysis scores for the models it has,
@@ -206,6 +258,37 @@ export interface FilterConfig {
206
258
  latencyReferenceTokensPerSec: number;
207
259
  /** Streamed samples required before latency is scored against a model. */
208
260
  latencyMinSamples: number;
261
+ /**
262
+ * Absolute expected-wait ceiling (ms). A hard drop, mirroring the price
263
+ * ceiling: any model whose expected total wait (TTFT + streaming the expected
264
+ * completion at its measured throughput) exceeds this is rejected outright,
265
+ * regardless of tier or price. This is the gate the latency *penalty* cannot
266
+ * be — the penalty is multiplicative on cost and capped, so on an ultra-cheap
267
+ * model even the capped multiple leaves it cheapest; a slow-but-cheap model is
268
+ * never demoted by scoring alone. Only models with at least `latencyMinSamples`
269
+ * observations are dropped, so a new model still gets its cold-start turns.
270
+ * Relaxed alongside trust in tier rescue so a narrowed catalog never 500s.
271
+ * Undefined ⇒ off (the default).
272
+ */
273
+ maxExpectedWaitMs?: number;
274
+ /**
275
+ * How much of a model's measured escalation risk to price into its effective
276
+ * cost, 0-1. 0 (the default) disables the term.
277
+ *
278
+ * The trust divisor treats a failure as a proportional retry of the same
279
+ * model, so a 4% escalation rate reads as a 4% surcharge. The real cost of
280
+ * a probe escalation is a whole re-dispatch on the NEXT tier's model:
281
+ * measured over a week, escalated attempts billed ~$0.08 each while the
282
+ * cheap model that failed had billed ~$0.0006 — a 700x multiple, not 4%. A
283
+ * cheap model with a 3.6% escalation rate therefore cost more than a
284
+ * reliable one at 4x its price, and the divisor could never see it.
285
+ *
286
+ * At 1, `effectiveUsd += escalationRate × (this prompt × the ledger's
287
+ * measured $/prompt-token of escalated attempts)`, so flakiness is priced
288
+ * at what it actually costs. Inert until the ledger holds enough escalated
289
+ * attempts to measure.
290
+ */
291
+ escalationCostWeight: number;
209
292
  }
210
293
 
211
294
  export interface ClassifierConfig {
@@ -229,6 +312,13 @@ export interface ClassifierConfig {
229
312
  chatAxis: QualityAxis;
230
313
  /** Tool-loop depth above which the agentic axis takes over. */
231
314
  agenticLoopDepth: number;
315
+ /**
316
+ * Fraction of the failed-tool weight that survives when the turn is a
317
+ * mechanical tool-result continuation. A retry after a failed tool call is
318
+ * the most mechanical turn there is; the flat weight let automated retry
319
+ * loops buy the hard tier. 1 preserves the shipped behaviour.
320
+ */
321
+ mechanicalRetryFactor: number;
232
322
  /**
233
323
  * Score added when the CLIENT asks for a reasoning effort, per level. The
234
324
  * premise is that asking for reasoning states expected difficulty directly.
@@ -283,6 +373,19 @@ export interface HysteresisConfig {
283
373
  * discount by this multiple. 1.0 ⇒ break even; higher ⇒ stickier.
284
374
  */
285
375
  switchMargin: number;
376
+ /**
377
+ * Turns over which a model switch is amortised in the stay/switch decision.
378
+ * 1 (the default) is the one-turn comparison: stay at the warm model's
379
+ * cache-read price vs switch at the new model's cold price. That is right
380
+ * for one turn and wrong for the run that follows: it kept a $2.55/Mtok
381
+ * model warm for 33 consecutive `moderate` dispatches ("stay $0.0589 ≤
382
+ * switch $0.1131 × 1.3") where the ranked winner would have been $0.003
383
+ * per turn once ITS cache was warm. With a horizon H the comparison is
384
+ * `H × stayWarm` against `switchCold + (H − 1) × newWarm`, so a switch that
385
+ * pays for itself within H turns is taken. Deep loops average ~25
386
+ * dispatches per user-visible turn, so single digits are conservative.
387
+ */
388
+ switchHorizonTurns: number;
286
389
  /** Assume a warm cache expires after this long. OpenRouter sticky sessions: 5-10 min. */
287
390
  cacheWarmTtlMs: number;
288
391
  /** Downgrade at most this many tiers per turn, so quality never falls off a cliff. */
@@ -504,6 +607,24 @@ export interface CompactionConfig {
504
607
  * already-cached prompt bytes, and a cold prompt costs ~4.3x a warm one.
505
608
  */
506
609
  floorRatio: number;
610
+ /**
611
+ * Only extend an existing plan once the compacted prompt has grown by this
612
+ * factor since the plan was last made. 1 (the default) re-plans on every
613
+ * over-budget turn.
614
+ *
615
+ * `floorRatio` rations re-planning only when the budget is reachable. On
616
+ * the traffic actually observed it is not — compacted prompts sit at
617
+ * 100–160k tokens against a 40k budget — so every turn is over budget and a
618
+ * new edit is added the moment a tool result ages out of the protected
619
+ * window. Measured over a week of same-model turns: the plan changed on
620
+ * 1,031 dispatches at a 79.5% cache hit and $0.0120 each, against 92.6% and
621
+ * $0.0067 when it held. At 1.1 a plan holds until the prompt is 10% larger
622
+ * than when it was made — several turns in a deep loop — at the cost of
623
+ * that much more stale tool output riding along in between. Fit-to-window
624
+ * compaction is never rationed; a prompt that would overflow always
625
+ * re-plans.
626
+ */
627
+ replanGrowthRatio: number;
507
628
  /** Also compact when the prompt would overflow the profile's context window. */
508
629
  fitToWindow: boolean;
509
630
  /** Never touch the last N user/assistant turns or the volatile tail. */
@@ -523,6 +644,7 @@ export interface CompactionConfig {
523
644
  export interface RouterConfig {
524
645
  server: ServerConfig;
525
646
  openrouter: OpenRouterConfig;
647
+ ollama: OllamaConfig;
526
648
  benchmarks: BenchmarksConfig;
527
649
  tiers: Record<Tier, TierConfig>;
528
650
  tasks: Record<TaskType, TaskConfig>;
@@ -538,10 +660,13 @@ export interface RouterConfig {
538
660
  profiles: ProfileConfig[];
539
661
  ledger: LedgerConfig;
540
662
  /**
541
- * Derive each tier's quality floor from the models actually available at
542
- * every catalog refresh, relaxing (never tightening) the configured floors.
663
+ * Relax a tier's quality floor to a catalog-derived band when the configured
664
+ * floor is met by fewer than three available models (never tightening it).
543
665
  * Without this, a narrow OpenRouter guardrail leaves every tier above
544
666
  * `trivial` permanently empty and the router is stuck on the cheapest model.
667
+ * A floor that at least three models meet stands exactly as configured, so
668
+ * on a wide catalog this is a no-op — an earlier version relaxed
669
+ * unconditionally and a wide catalog's weak tail dragged every floor down.
545
670
  */
546
671
  adaptiveTierFloors: boolean;
547
672
  /**
@@ -18,10 +18,26 @@ import type { RouterConfig } from "../config/types.ts";
18
18
  import { consumePendingEstimate } from "../tokens/estimate.ts";
19
19
  import { computeBlendedRate } from "./blended.ts";
20
20
  import { computeCost } from "./forecast.ts";
21
- import type { BlendedRate, Ledger, LedgerEntry, ModelLatency, ModelTrust, UsageCounts } from "./types.ts";
21
+ import type {
22
+ BlendedRate,
23
+ EscalationCost,
24
+ Ledger,
25
+ LedgerEntry,
26
+ LedgerSignals,
27
+ ModelLatency,
28
+ ModelTrust,
29
+ UsageCounts,
30
+ } from "./types.ts";
22
31
 
23
32
  /** Estimates below this many samples are noise; the default ratio is better. */
24
33
  const MIN_CALIBRATION_SAMPLES = 20;
34
+ /** Calibration samples outside this bytes-per-token band are provider accounting quirks, not tokenizer facts. */
35
+ const MIN_SANE_BYTES_PER_TOKEN = 1.5;
36
+ const MAX_SANE_BYTES_PER_TOKEN = 8;
37
+ /** Escalated attempts needed before their measured cost is trusted. */
38
+ const MIN_ESCALATION_SAMPLES = 10;
39
+ /** The escalation-cost aggregate scans a window of rows; memoised for this long. */
40
+ const ESCALATION_COST_MEMO_MS = 60_000;
25
41
  const DAY_MS = 86_400_000;
26
42
 
27
43
  // Row shapes below are fixed by our own schema in util/sqlite.ts.
@@ -99,7 +115,9 @@ interface CalibrationRow {
99
115
  * unclassifiable legacy row and stays attributable, preserving the old,
100
116
  * stricter behaviour rather than silently forgiving it.
101
117
  */
102
- const UNATTRIBUTABLE_KINDS = "('aborted', 'auth', 'moderation', 'model_unavailable')";
118
+ // `quota` joins the list for the same reason as `auth`: an exhausted plan
119
+ // allowance is a fact about the account, identical for every model behind it.
120
+ const UNATTRIBUTABLE_KINDS = "('aborted', 'auth', 'moderation', 'model_unavailable', 'quota')";
103
121
 
104
122
  const ATTRIBUTABLE_ERROR = `error IS NOT NULL AND (error_kind IS NULL OR error_kind NOT IN ${UNATTRIBUTABLE_KINDS})`;
105
123
 
@@ -258,6 +276,16 @@ export function createLedger(db: Database, cfg: RouterConfig): Ledger {
258
276
  );
259
277
  const ratioStmt = db.query("SELECT est_bytes, actual_tokens, samples FROM token_calibration WHERE tokenizer = ?");
260
278
  const recentStmt = db.query("SELECT * FROM ledger ORDER BY created_at_ms DESC LIMIT ?");
279
+ // What an escalated retry actually bills, per prompt token, over a window.
280
+ // attempt > 0 rows are the re-dispatches that followed a rejected attempt;
281
+ // errored ones carry no usage and are excluded.
282
+ const escalationCostStmt = db.query(
283
+ `SELECT COUNT(*) AS samples,
284
+ COALESCE(SUM(COALESCE(reported_usd, predicted_usd)), 0) AS usd,
285
+ COALESCE(SUM(json_extract(usage, '$.promptTokens')), 0) AS prompt_tokens
286
+ FROM ledger WHERE attempt > 0 AND error IS NULL AND created_at_ms >= ?`,
287
+ );
288
+ let escalationMemo: { atMs: number; windowDays: number; value: EscalationCost | null } | null = null;
261
289
  const cacheMetaStmt = db.query("SELECT fetched_at_ms FROM catalog_cache WHERE id = 1");
262
290
  const cachePayloadStmt = db.query("SELECT payload FROM catalog_cache WHERE id = 1");
263
291
 
@@ -286,7 +314,8 @@ export function createLedger(db: Database, cfg: RouterConfig): Ledger {
286
314
  return {
287
315
  record(entry: LedgerEntry): void {
288
316
  const models = priceIndex();
289
- const model = (entry.servedSlug !== null ? models?.get(entry.servedSlug) : undefined) ?? models?.get(entry.slug) ?? null;
317
+ const model =
318
+ entry.priceModel ?? (entry.servedSlug !== null ? models?.get(entry.servedSlug) : undefined) ?? models?.get(entry.slug) ?? null;
290
319
  insertStmt.run(
291
320
  entry.id,
292
321
  entry.createdAtMs,
@@ -331,7 +360,15 @@ export function createLedger(db: Database, cfg: RouterConfig): Ledger {
331
360
  // The SERVED model's tokenizer produced the billing; the estimate-time
332
361
  // family is the fallback when the model is unknown to the catalog.
333
362
  const tokenizer = (model?.tokenizer ?? pending.tokenizer).trim().toLowerCase();
334
- calibrationStmt.run(tokenizer, pending.bytes, entry.usage.promptTokens);
363
+ // Reject samples no real tokenizer could produce. The rows are
364
+ // running sums, so one provider that reports inflated counts (seen:
365
+ // ~8x the bytes-implied tokens, i.e. 0.4 bytes/token) poisons a
366
+ // whole family for thousands of samples. Text tokenizers land
367
+ // between ~2 and ~5 bytes/token; the band is generous around that.
368
+ const bytesPerToken = pending.bytes / entry.usage.promptTokens;
369
+ if (bytesPerToken >= MIN_SANE_BYTES_PER_TOKEN && bytesPerToken <= MAX_SANE_BYTES_PER_TOKEN) {
370
+ calibrationStmt.run(tokenizer, pending.bytes, entry.usage.promptTokens);
371
+ }
335
372
  }
336
373
  },
337
374
 
@@ -379,6 +416,38 @@ export function createLedger(db: Database, cfg: RouterConfig): Ledger {
379
416
  if (row === null) return null;
380
417
  return toLatency(slug, row);
381
418
  },
419
+ signals(slugs: readonly string[], harnessId?: string): Map<string, LedgerSignals> {
420
+ const cutoff = cfg.filters.trustWindowDays > 0 ? Date.now() - cfg.filters.trustWindowDays * DAY_MS : 0;
421
+ const hasHarness = harnessId !== undefined && harnessId !== "";
422
+ const out = new Map<string, LedgerSignals>();
423
+ for (const slug of slugs) {
424
+ const trustRow = hasHarness
425
+ ? (trustHarnessStmt.get(slug, harnessId, cutoff) as TrustRow | null)
426
+ : (trustStmt.get(slug, cutoff) as TrustRow | null);
427
+ const latencyRow = hasHarness
428
+ ? (latencyHarnessStmt.get(slug, harnessId) as LatencyRow | null)
429
+ : (latencyStmt.get(slug) as LatencyRow | null);
430
+ out.set(slug, {
431
+ trust: trustRow === null || trustRow.attempts === 0 ? null : toTrust(slug, trustRow),
432
+ latency: latencyRow === null ? null : toLatency(slug, latencyRow),
433
+ });
434
+ }
435
+ return out;
436
+ },
437
+
438
+ escalationCost(windowDays: number): EscalationCost | null {
439
+ const now = Date.now();
440
+ if (escalationMemo !== null && escalationMemo.windowDays === windowDays && now - escalationMemo.atMs < ESCALATION_COST_MEMO_MS) {
441
+ return escalationMemo.value;
442
+ }
443
+ const row = escalationCostStmt.get(now - windowDays * DAY_MS) as { samples: number; usd: number; prompt_tokens: number } | null;
444
+ const value: EscalationCost | null =
445
+ row === null || row.samples < MIN_ESCALATION_SAMPLES || row.prompt_tokens <= 0
446
+ ? null
447
+ : { usdPerPromptToken: row.usd / row.prompt_tokens, samples: row.samples, windowDays };
448
+ escalationMemo = { atMs: now, windowDays, value };
449
+ return value;
450
+ },
382
451
 
383
452
  tokenRatio(tokenizer: string): number | null {
384
453
  const row = ratioStmt.get(tokenizer.trim().toLowerCase()) as CalibrationRow | null;