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.
- package/.omp-plugin/marketplace.json +2 -2
- package/README.md +225 -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 +117 -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 +190 -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 +46 -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 +120 -2
- package/src/cost/cache-estimate.ts +52 -0
- package/src/cost/ledger.ts +73 -4
- package/src/cost/report.ts +351 -0
- package/src/cost/types.ts +39 -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 +50 -6
- package/src/server/providers.ts +54 -0
- package/src/server/turn.ts +138 -34
- package/src/tokens/estimate.ts +16 -0
- package/src/upstream/multi.ts +26 -0
- package/src/upstream/ollama-usage.ts +163 -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/cache-estimate.test.ts +48 -0
- 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 +521 -0
- package/test/omp-credentials.test.ts +43 -1
- package/test/report-hub.test.ts +343 -0
- package/test/report-logic.test.ts +93 -0
- package/test/report.test.ts +233 -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 +173 -7
- package/tools/recompute-ollama-cache.ts +129 -0
package/src/config/schema.ts
CHANGED
|
@@ -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(),
|
package/src/config/types.ts
CHANGED
|
@@ -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
|
-
*
|
|
555
|
-
*
|
|
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
|
+
}
|
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;
|