auto-model-router 0.2.32 → 0.3.1

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 (70) hide show
  1. package/.omp-plugin/marketplace.json +2 -2
  2. package/README.md +225 -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 +117 -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 +190 -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 +46 -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 +120 -2
  27. package/src/cost/cache-estimate.ts +52 -0
  28. package/src/cost/ledger.ts +73 -4
  29. package/src/cost/report.ts +351 -0
  30. package/src/cost/types.ts +39 -1
  31. package/src/index.ts +5 -8
  32. package/src/router/candidates.ts +52 -4
  33. package/src/router/classify.ts +33 -6
  34. package/src/router/features.ts +13 -1
  35. package/src/router/select.ts +55 -8
  36. package/src/router/state.ts +6 -2
  37. package/src/router/tier-plan.ts +49 -11
  38. package/src/router/types.ts +10 -0
  39. package/src/server/http.ts +50 -6
  40. package/src/server/providers.ts +54 -0
  41. package/src/server/turn.ts +138 -34
  42. package/src/tokens/estimate.ts +16 -0
  43. package/src/upstream/multi.ts +26 -0
  44. package/src/upstream/ollama-usage.ts +163 -0
  45. package/src/upstream/ollama.ts +275 -0
  46. package/src/upstream/openrouter.ts +19 -1
  47. package/src/upstream/types.ts +2 -0
  48. package/src/util/sqlite.ts +25 -1
  49. package/test/cache-estimate.test.ts +48 -0
  50. package/test/catalog.test.ts +44 -0
  51. package/test/classify.test.ts +41 -5
  52. package/test/compaction.test.ts +1 -0
  53. package/test/config-wizard.test.ts +77 -1
  54. package/test/configure-logic.test.ts +129 -33
  55. package/test/embed-lifecycle.test.ts +1 -0
  56. package/test/failover.test.ts +148 -3
  57. package/test/features.test.ts +35 -0
  58. package/test/http-resilience.test.ts +24 -0
  59. package/test/ollama.test.ts +521 -0
  60. package/test/omp-credentials.test.ts +43 -1
  61. package/test/report-hub.test.ts +343 -0
  62. package/test/report-logic.test.ts +93 -0
  63. package/test/report.test.ts +233 -0
  64. package/test/select.test.ts +151 -1
  65. package/test/tier-plan.test.ts +159 -1
  66. package/test/toast-logic.test.ts +11 -2
  67. package/test/tokens.test.ts +71 -1
  68. package/test/trust-attribution.test.ts +2 -2
  69. package/test/turn.test.ts +173 -7
  70. package/tools/recompute-ollama-cache.ts +129 -0
@@ -33,6 +33,29 @@ 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
+ planCreditsUsd: z.number().nonnegative().optional(),
57
+ });
58
+
36
59
  const benchmarks = z.strictObject({
37
60
  enabled: z.boolean().optional(),
38
61
  artificialAnalysisApiKey: z.string().optional(),
@@ -74,6 +97,7 @@ const filters = z.strictObject({
74
97
  latencyReferenceTokensPerSec: z.number().positive().optional(),
75
98
  latencyMinSamples: z.number().int().nonnegative().optional(),
76
99
  maxExpectedWaitMs: z.number().positive().optional(),
100
+ escalationCostWeight: z.number().min(0).max(1).optional(),
77
101
  });
78
102
 
79
103
  const classifier = z.strictObject({
@@ -86,6 +110,7 @@ const classifier = z.strictObject({
86
110
  toolAxis: qualityAxis.optional(),
87
111
  chatAxis: qualityAxis.optional(),
88
112
  agenticLoopDepth: z.number().int().nonnegative().optional(),
113
+ mechanicalRetryFactor: z.number().min(0).max(1).optional(),
89
114
  reasoningWeights: z
90
115
  .strictObject({
91
116
  medium: z.number().nonnegative().optional(),
@@ -110,6 +135,7 @@ const hysteresis = z.strictObject({
110
135
  holdTurns: z.number().int().nonnegative().optional(),
111
136
  holdTurnsAfterEscalation: z.number().int().nonnegative().optional(),
112
137
  switchMargin: z.number().positive().optional(),
138
+ switchHorizonTurns: z.number().int().positive().optional(),
113
139
  cacheWarmTtlMs: z.number().nonnegative().optional(),
114
140
  maxDowngradePerTurn: z.number().int().nonnegative().optional(),
115
141
  breakHoldOnMechanical: z.boolean().optional(),
@@ -164,6 +190,7 @@ const compaction = z.strictObject({
164
190
  enabled: z.boolean().optional(),
165
191
  budgetTokens: z.number().int().positive().optional(),
166
192
  floorRatio: z.number().positive().max(1).optional(),
193
+ replanGrowthRatio: z.number().min(1).optional(),
167
194
  fitToWindow: z.boolean().optional(),
168
195
  protectRecentTurns: z.number().int().positive().optional(),
169
196
  maxToolResultBytes: z.number().int().positive().optional(),
@@ -226,6 +253,7 @@ export const configInputSchema = z.strictObject({
226
253
  chat: taskConfig.optional(),
227
254
  })
228
255
  .optional(),
256
+ ollama: ollama.optional(),
229
257
  filters: filters.optional(),
230
258
  classifier: classifier.optional(),
231
259
  escalation: escalation.optional(),
@@ -64,6 +64,64 @@ 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
+ * Dollar value of the plan's included monthly credits (Pro 60, Max 300), so
119
+ * the plan-relative usage reading can be shown as dollars beside the
120
+ * ledger's own Ollama figure. 0 ⇒ unknown: usage is shown as a share only.
121
+ */
122
+ planCreditsUsd: number;
123
+ }
124
+
67
125
  /**
68
126
  * External benchmark feeds that BACKFILL quality scores OpenRouter does not
69
127
  * publish. OpenRouter embeds Artificial Analysis scores for the models it has,
@@ -219,6 +277,24 @@ export interface FilterConfig {
219
277
  * Undefined ⇒ off (the default).
220
278
  */
221
279
  maxExpectedWaitMs?: number;
280
+ /**
281
+ * How much of a model's measured escalation risk to price into its effective
282
+ * cost, 0-1. 0 (the default) disables the term.
283
+ *
284
+ * The trust divisor treats a failure as a proportional retry of the same
285
+ * model, so a 4% escalation rate reads as a 4% surcharge. The real cost of
286
+ * a probe escalation is a whole re-dispatch on the NEXT tier's model:
287
+ * measured over a week, escalated attempts billed ~$0.08 each while the
288
+ * cheap model that failed had billed ~$0.0006 — a 700x multiple, not 4%. A
289
+ * cheap model with a 3.6% escalation rate therefore cost more than a
290
+ * reliable one at 4x its price, and the divisor could never see it.
291
+ *
292
+ * At 1, `effectiveUsd += escalationRate × (this prompt × the ledger's
293
+ * measured $/prompt-token of escalated attempts)`, so flakiness is priced
294
+ * at what it actually costs. Inert until the ledger holds enough escalated
295
+ * attempts to measure.
296
+ */
297
+ escalationCostWeight: number;
222
298
  }
223
299
 
224
300
  export interface ClassifierConfig {
@@ -242,6 +318,13 @@ export interface ClassifierConfig {
242
318
  chatAxis: QualityAxis;
243
319
  /** Tool-loop depth above which the agentic axis takes over. */
244
320
  agenticLoopDepth: number;
321
+ /**
322
+ * Fraction of the failed-tool weight that survives when the turn is a
323
+ * mechanical tool-result continuation. A retry after a failed tool call is
324
+ * the most mechanical turn there is; the flat weight let automated retry
325
+ * loops buy the hard tier. 1 preserves the shipped behaviour.
326
+ */
327
+ mechanicalRetryFactor: number;
245
328
  /**
246
329
  * Score added when the CLIENT asks for a reasoning effort, per level. The
247
330
  * premise is that asking for reasoning states expected difficulty directly.
@@ -296,6 +379,19 @@ export interface HysteresisConfig {
296
379
  * discount by this multiple. 1.0 ⇒ break even; higher ⇒ stickier.
297
380
  */
298
381
  switchMargin: number;
382
+ /**
383
+ * Turns over which a model switch is amortised in the stay/switch decision.
384
+ * 1 (the default) is the one-turn comparison: stay at the warm model's
385
+ * cache-read price vs switch at the new model's cold price. That is right
386
+ * for one turn and wrong for the run that follows: it kept a $2.55/Mtok
387
+ * model warm for 33 consecutive `moderate` dispatches ("stay $0.0589 ≤
388
+ * switch $0.1131 × 1.3") where the ranked winner would have been $0.003
389
+ * per turn once ITS cache was warm. With a horizon H the comparison is
390
+ * `H × stayWarm` against `switchCold + (H − 1) × newWarm`, so a switch that
391
+ * pays for itself within H turns is taken. Deep loops average ~25
392
+ * dispatches per user-visible turn, so single digits are conservative.
393
+ */
394
+ switchHorizonTurns: number;
299
395
  /** Assume a warm cache expires after this long. OpenRouter sticky sessions: 5-10 min. */
300
396
  cacheWarmTtlMs: number;
301
397
  /** Downgrade at most this many tiers per turn, so quality never falls off a cliff. */
@@ -517,6 +613,24 @@ export interface CompactionConfig {
517
613
  * already-cached prompt bytes, and a cold prompt costs ~4.3x a warm one.
518
614
  */
519
615
  floorRatio: number;
616
+ /**
617
+ * Only extend an existing plan once the compacted prompt has grown by this
618
+ * factor since the plan was last made. 1 (the default) re-plans on every
619
+ * over-budget turn.
620
+ *
621
+ * `floorRatio` rations re-planning only when the budget is reachable. On
622
+ * the traffic actually observed it is not — compacted prompts sit at
623
+ * 100–160k tokens against a 40k budget — so every turn is over budget and a
624
+ * new edit is added the moment a tool result ages out of the protected
625
+ * window. Measured over a week of same-model turns: the plan changed on
626
+ * 1,031 dispatches at a 79.5% cache hit and $0.0120 each, against 92.6% and
627
+ * $0.0067 when it held. At 1.1 a plan holds until the prompt is 10% larger
628
+ * than when it was made — several turns in a deep loop — at the cost of
629
+ * that much more stale tool output riding along in between. Fit-to-window
630
+ * compaction is never rationed; a prompt that would overflow always
631
+ * re-plans.
632
+ */
633
+ replanGrowthRatio: number;
520
634
  /** Also compact when the prompt would overflow the profile's context window. */
521
635
  fitToWindow: boolean;
522
636
  /** Never touch the last N user/assistant turns or the volatile tail. */
@@ -536,6 +650,7 @@ export interface CompactionConfig {
536
650
  export interface RouterConfig {
537
651
  server: ServerConfig;
538
652
  openrouter: OpenRouterConfig;
653
+ ollama: OllamaConfig;
539
654
  benchmarks: BenchmarksConfig;
540
655
  tiers: Record<Tier, TierConfig>;
541
656
  tasks: Record<TaskType, TaskConfig>;
@@ -551,10 +666,13 @@ export interface RouterConfig {
551
666
  profiles: ProfileConfig[];
552
667
  ledger: LedgerConfig;
553
668
  /**
554
- * Derive each tier's quality floor from the models actually available at
555
- * every catalog refresh, relaxing (never tightening) the configured floors.
669
+ * Relax a tier's quality floor to a catalog-derived band when the configured
670
+ * floor is met by fewer than three available models (never tightening it).
556
671
  * Without this, a narrow OpenRouter guardrail leaves every tier above
557
672
  * `trivial` permanently empty and the router is stuck on the cheapest model.
673
+ * A floor that at least three models meet stands exactly as configured, so
674
+ * on a wide catalog this is a no-op — an earlier version relaxed
675
+ * unconditionally and a wide catalog's weak tail dragged every floor down.
558
676
  */
559
677
  adaptiveTierFloors: boolean;
560
678
  /**
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Cache accounting for upstreams that cache but do not say so.
3
+ *
4
+ * Ollama Cloud caches prompt prefixes automatically and bills them at its
5
+ * published cached-input rate, but neither its OpenAI-compatible usage nor
6
+ * the native API carries a cached-token count. Measured 2026-09-07: twelve
7
+ * identical 162k-token requests to glm-5.3-flash moved the plan meter by
8
+ * $0.06 against $0.29 at the full input rate, and repeats answered in ~1.5s.
9
+ * Pricing every token fresh overstated a week of Ollama spend 3.7x ($23.01
10
+ * booked against $6.24 metered).
11
+ *
12
+ * The estimate reuses the rule the router already applies when it decides
13
+ * whether staying on a model keeps a warm cache (select.ts step 4): the
14
+ * previous turn's prompt is the cached prefix when the same model served the
15
+ * previous turn within `cacheWarmTtlMs`. Tokens beyond that prefix are fresh.
16
+ * A conversation's first turn, a model switch, or an idle gap past the TTL
17
+ * count as fully cold — the same assumption the stay/switch comparison makes.
18
+ *
19
+ * Estimated counts are flagged (`cachedEstimated`) so reports can show them
20
+ * as estimates and never be mistaken for provider-reported figures.
21
+ */
22
+
23
+ import type { UsageCounts } from "./types.ts";
24
+
25
+ export interface CacheEstimateContext {
26
+ /** Model that served the previous turn of this conversation, if any. */
27
+ previousSlug: string | null;
28
+ /** Prompt tokens of that previous turn (0 when unknown). */
29
+ previousPromptTokens: number;
30
+ /** When the previous turn settled, ms epoch. */
31
+ previousAtMs: number;
32
+ /** Model that served this turn. */
33
+ servedSlug: string;
34
+ nowMs: number;
35
+ /** How long a warm prefix is assumed to survive; `hysteresis.cacheWarmTtlMs`. */
36
+ cacheWarmTtlMs: number;
37
+ }
38
+
39
+ /**
40
+ * Returns usage with `cachedTokens` filled in when the upstream reported no
41
+ * cache activity and the router's warm-cache rule says a prefix was warm.
42
+ * Reported cache counts (either field non-zero) are left untouched.
43
+ */
44
+ export function estimateUnreportedCache(usage: UsageCounts, ctx: CacheEstimateContext): UsageCounts {
45
+ if (usage.cachedTokens > 0 || usage.cacheWriteTokens > 0) return usage;
46
+ if (usage.promptTokens <= 0) return usage;
47
+ if (ctx.previousSlug === null || ctx.previousSlug !== ctx.servedSlug) return usage;
48
+ if (ctx.previousPromptTokens <= 0) return usage;
49
+ if (ctx.cacheWarmTtlMs <= 0 || ctx.nowMs - ctx.previousAtMs > ctx.cacheWarmTtlMs) return usage;
50
+ const cachedTokens = Math.min(ctx.previousPromptTokens, usage.promptTokens);
51
+ return { ...usage, cachedTokens, cachedEstimated: true };
52
+ }
@@ -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;