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.
- package/.omp-plugin/marketplace.json +2 -2
- package/README.md +211 -29
- package/docs/review-2026-09-05.md +267 -0
- package/omp-extension/configure-logic.ts +71 -15
- package/omp-extension/pi-coding-agent.d.ts +79 -2
- package/omp-extension/report-hub.ts +376 -0
- package/omp-extension/report-logic.ts +115 -0
- package/omp-extension/router-configure.ts +203 -51
- package/omp-extension/router-url.ts +52 -0
- package/omp-extension/toast-logic.ts +14 -2
- package/package.json +1 -1
- package/src/catalog/composite.ts +97 -0
- package/src/catalog/ollama-catalog.ts +309 -0
- package/src/catalog/ollama-prices.ts +85 -0
- package/src/catalog/openrouter-catalog.ts +39 -1
- package/src/catalog/types.ts +31 -1
- package/src/cli/args.ts +1 -0
- package/src/cli/config-wizard.ts +189 -28
- package/src/cli/explain.ts +2 -4
- package/src/cli/models.ts +2 -4
- package/src/cli/report.ts +37 -0
- package/src/config/defaults.ts +43 -2
- package/src/config/load.ts +25 -1
- package/src/config/omp-credentials.ts +31 -7
- package/src/config/schema.ts +28 -0
- package/src/config/types.ts +127 -2
- package/src/cost/ledger.ts +73 -4
- package/src/cost/report.ts +340 -0
- package/src/cost/types.ts +33 -1
- package/src/index.ts +5 -8
- package/src/router/candidates.ts +90 -11
- package/src/router/classify.ts +33 -6
- package/src/router/features.ts +13 -1
- package/src/router/select.ts +55 -8
- package/src/router/state.ts +6 -2
- package/src/router/tier-plan.ts +49 -11
- package/src/router/types.ts +11 -0
- package/src/server/http.ts +47 -6
- package/src/server/providers.ts +54 -0
- package/src/server/turn.ts +122 -34
- package/src/tokens/estimate.ts +16 -0
- package/src/upstream/multi.ts +26 -0
- package/src/upstream/ollama-usage.ts +157 -0
- package/src/upstream/ollama.ts +275 -0
- package/src/upstream/openrouter.ts +19 -1
- package/src/upstream/types.ts +2 -0
- package/src/util/sqlite.ts +25 -1
- package/test/catalog.test.ts +44 -0
- package/test/classify.test.ts +41 -5
- package/test/compaction.test.ts +1 -0
- package/test/config-wizard.test.ts +77 -1
- package/test/configure-logic.test.ts +129 -33
- package/test/embed-lifecycle.test.ts +1 -0
- package/test/failover.test.ts +148 -3
- package/test/features.test.ts +35 -0
- package/test/http-resilience.test.ts +24 -0
- package/test/ollama.test.ts +506 -0
- package/test/omp-credentials.test.ts +43 -1
- package/test/report-hub.test.ts +341 -0
- package/test/report-logic.test.ts +92 -0
- package/test/report.test.ts +217 -0
- package/test/select.test.ts +176 -1
- package/test/tier-plan.test.ts +159 -1
- package/test/toast-logic.test.ts +11 -2
- package/test/tokens.test.ts +71 -1
- package/test/trust-attribution.test.ts +2 -2
- package/test/turn.test.ts +124 -7
- package/tools/build-site.ts +1 -0
package/src/config/schema.ts
CHANGED
|
@@ -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(),
|
package/src/config/types.ts
CHANGED
|
@@ -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
|
-
*
|
|
542
|
-
*
|
|
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
|
/**
|
package/src/cost/ledger.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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;
|