auto-model-router 0.2.32 → 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 +208 -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 +27 -0
- package/src/config/types.ts +114 -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 +52 -4
- 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 +10 -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 +151 -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/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(),
|
|
@@ -74,6 +96,7 @@ const filters = z.strictObject({
|
|
|
74
96
|
latencyReferenceTokensPerSec: z.number().positive().optional(),
|
|
75
97
|
latencyMinSamples: z.number().int().nonnegative().optional(),
|
|
76
98
|
maxExpectedWaitMs: z.number().positive().optional(),
|
|
99
|
+
escalationCostWeight: z.number().min(0).max(1).optional(),
|
|
77
100
|
});
|
|
78
101
|
|
|
79
102
|
const classifier = z.strictObject({
|
|
@@ -86,6 +109,7 @@ const classifier = z.strictObject({
|
|
|
86
109
|
toolAxis: qualityAxis.optional(),
|
|
87
110
|
chatAxis: qualityAxis.optional(),
|
|
88
111
|
agenticLoopDepth: z.number().int().nonnegative().optional(),
|
|
112
|
+
mechanicalRetryFactor: z.number().min(0).max(1).optional(),
|
|
89
113
|
reasoningWeights: z
|
|
90
114
|
.strictObject({
|
|
91
115
|
medium: z.number().nonnegative().optional(),
|
|
@@ -110,6 +134,7 @@ const hysteresis = z.strictObject({
|
|
|
110
134
|
holdTurns: z.number().int().nonnegative().optional(),
|
|
111
135
|
holdTurnsAfterEscalation: z.number().int().nonnegative().optional(),
|
|
112
136
|
switchMargin: z.number().positive().optional(),
|
|
137
|
+
switchHorizonTurns: z.number().int().positive().optional(),
|
|
113
138
|
cacheWarmTtlMs: z.number().nonnegative().optional(),
|
|
114
139
|
maxDowngradePerTurn: z.number().int().nonnegative().optional(),
|
|
115
140
|
breakHoldOnMechanical: z.boolean().optional(),
|
|
@@ -164,6 +189,7 @@ const compaction = z.strictObject({
|
|
|
164
189
|
enabled: z.boolean().optional(),
|
|
165
190
|
budgetTokens: z.number().int().positive().optional(),
|
|
166
191
|
floorRatio: z.number().positive().max(1).optional(),
|
|
192
|
+
replanGrowthRatio: z.number().min(1).optional(),
|
|
167
193
|
fitToWindow: z.boolean().optional(),
|
|
168
194
|
protectRecentTurns: z.number().int().positive().optional(),
|
|
169
195
|
maxToolResultBytes: z.number().int().positive().optional(),
|
|
@@ -226,6 +252,7 @@ export const configInputSchema = z.strictObject({
|
|
|
226
252
|
chat: taskConfig.optional(),
|
|
227
253
|
})
|
|
228
254
|
.optional(),
|
|
255
|
+
ollama: ollama.optional(),
|
|
229
256
|
filters: filters.optional(),
|
|
230
257
|
classifier: classifier.optional(),
|
|
231
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,
|
|
@@ -219,6 +271,24 @@ export interface FilterConfig {
|
|
|
219
271
|
* Undefined ⇒ off (the default).
|
|
220
272
|
*/
|
|
221
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;
|
|
222
292
|
}
|
|
223
293
|
|
|
224
294
|
export interface ClassifierConfig {
|
|
@@ -242,6 +312,13 @@ export interface ClassifierConfig {
|
|
|
242
312
|
chatAxis: QualityAxis;
|
|
243
313
|
/** Tool-loop depth above which the agentic axis takes over. */
|
|
244
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;
|
|
245
322
|
/**
|
|
246
323
|
* Score added when the CLIENT asks for a reasoning effort, per level. The
|
|
247
324
|
* premise is that asking for reasoning states expected difficulty directly.
|
|
@@ -296,6 +373,19 @@ export interface HysteresisConfig {
|
|
|
296
373
|
* discount by this multiple. 1.0 ⇒ break even; higher ⇒ stickier.
|
|
297
374
|
*/
|
|
298
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;
|
|
299
389
|
/** Assume a warm cache expires after this long. OpenRouter sticky sessions: 5-10 min. */
|
|
300
390
|
cacheWarmTtlMs: number;
|
|
301
391
|
/** Downgrade at most this many tiers per turn, so quality never falls off a cliff. */
|
|
@@ -517,6 +607,24 @@ export interface CompactionConfig {
|
|
|
517
607
|
* already-cached prompt bytes, and a cold prompt costs ~4.3x a warm one.
|
|
518
608
|
*/
|
|
519
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;
|
|
520
628
|
/** Also compact when the prompt would overflow the profile's context window. */
|
|
521
629
|
fitToWindow: boolean;
|
|
522
630
|
/** Never touch the last N user/assistant turns or the volatile tail. */
|
|
@@ -536,6 +644,7 @@ export interface CompactionConfig {
|
|
|
536
644
|
export interface RouterConfig {
|
|
537
645
|
server: ServerConfig;
|
|
538
646
|
openrouter: OpenRouterConfig;
|
|
647
|
+
ollama: OllamaConfig;
|
|
539
648
|
benchmarks: BenchmarksConfig;
|
|
540
649
|
tiers: Record<Tier, TierConfig>;
|
|
541
650
|
tasks: Record<TaskType, TaskConfig>;
|
|
@@ -551,10 +660,13 @@ export interface RouterConfig {
|
|
|
551
660
|
profiles: ProfileConfig[];
|
|
552
661
|
ledger: LedgerConfig;
|
|
553
662
|
/**
|
|
554
|
-
*
|
|
555
|
-
*
|
|
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).
|
|
556
665
|
* Without this, a narrow OpenRouter guardrail leaves every tier above
|
|
557
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.
|
|
558
670
|
*/
|
|
559
671
|
adaptiveTierFloors: boolean;
|
|
560
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;
|