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/router/select.ts
CHANGED
|
@@ -183,6 +183,8 @@ export function select(args: SelectArgs): Decision {
|
|
|
183
183
|
// Overshooting buys several byte-stable turns per plan change.
|
|
184
184
|
let compactionPlan: CompactionEdit[] = [];
|
|
185
185
|
let promptTokensSaved = 0;
|
|
186
|
+
let compactionSavedBytes = 0;
|
|
187
|
+
let compactionPlanTokens = state.compactionPlanTokens ?? 0;
|
|
186
188
|
let effFeatures = features;
|
|
187
189
|
if (cfg.compaction.enabled && req.promptBytes > 0 && features.promptTokens > 0) {
|
|
188
190
|
const bytesPerToken = req.promptBytes / features.promptTokens;
|
|
@@ -197,8 +199,19 @@ export function select(args: SelectArgs): Decision {
|
|
|
197
199
|
const overBudget = compactedTokens > cfg.compaction.budgetTokens;
|
|
198
200
|
const overWindow =
|
|
199
201
|
cfg.compaction.fitToWindow && compactedTokens * headroom + EXPECTED_COMPLETION_TOKENS > profile.contextWindow;
|
|
202
|
+
// Rationing: when the budget is unreachable (the observed case — compacted
|
|
203
|
+
// prompts of 100-160k against a 40k budget) `overBudget` is true on every
|
|
204
|
+
// turn, and a plan gains an edit the moment a tool result ages out of the
|
|
205
|
+
// protected window, so `floorRatio` never gets to hold a plan. Instead,
|
|
206
|
+
// extend an existing plan only once the compacted prompt has grown by
|
|
207
|
+
// `replanGrowthRatio` since it was made. Never rations the window fit.
|
|
208
|
+
const rationed =
|
|
209
|
+
cfg.compaction.replanGrowthRatio > 1 &&
|
|
210
|
+
carried.length > 0 &&
|
|
211
|
+
compactionPlanTokens > 0 &&
|
|
212
|
+
compactedTokens < compactionPlanTokens * cfg.compaction.replanGrowthRatio;
|
|
200
213
|
let plan: CompactionResult = { edits: carried, savedBytes: carriedSavedBytes };
|
|
201
|
-
if (
|
|
214
|
+
if (overWindow || (overBudget && !rationed)) {
|
|
202
215
|
const targets: number[] = [];
|
|
203
216
|
// Overshoot the budget so the next re-plan is several turns away.
|
|
204
217
|
if (overBudget) targets.push(Math.max(1, Math.floor(cfg.compaction.budgetTokens * cfg.compaction.floorRatio)));
|
|
@@ -208,12 +221,18 @@ export function select(args: SelectArgs): Decision {
|
|
|
208
221
|
}
|
|
209
222
|
if (plan.edits.length > 0) {
|
|
210
223
|
compactionPlan = [...plan.edits];
|
|
224
|
+
compactionSavedBytes = plan.savedBytes;
|
|
211
225
|
promptTokensSaved = tokensOf(plan.savedBytes);
|
|
212
226
|
effFeatures = { ...features, promptTokens: features.promptTokens - promptTokensSaved };
|
|
213
227
|
const added = plan.edits.length - carried.length;
|
|
228
|
+
// A plan that gained edits was made at THIS compacted size; a carried
|
|
229
|
+
// plan keeps the size it was made at, so growth accrues against it.
|
|
230
|
+
if (added > 0 || compactionPlanTokens === 0) compactionPlanTokens = effFeatures.promptTokens;
|
|
214
231
|
reasons.push(
|
|
215
|
-
`compaction: ${plan.edits.length} tool result(s) shrunk (${carried.length} carried, ${added} new), ~${promptTokensSaved} tokens saved (prompt ${features.promptTokens}→${effFeatures.promptTokens})`,
|
|
232
|
+
`compaction: ${plan.edits.length} tool result(s) shrunk (${carried.length} carried, ${added} new), ~${promptTokensSaved} tokens saved (prompt ${features.promptTokens}→${effFeatures.promptTokens})${rationed ? " [re-plan rationed]" : ""}`,
|
|
216
233
|
);
|
|
234
|
+
} else {
|
|
235
|
+
compactionPlanTokens = 0;
|
|
217
236
|
}
|
|
218
237
|
}
|
|
219
238
|
|
|
@@ -268,6 +287,19 @@ export function select(args: SelectArgs): Decision {
|
|
|
268
287
|
// The task type selects the quality axis and capability filters; the tier
|
|
269
288
|
// still bounds cost (task selects, tier budgets).
|
|
270
289
|
const warmSlug = cacheWarm ? state.cacheWarmSlug : null;
|
|
290
|
+
// Pre-fetch trust/latency signals for all candidate slugs in one batch
|
|
291
|
+
// query per signal kind, instead of per-model individual lookups.
|
|
292
|
+
const candidateSignals =
|
|
293
|
+
ledger !== null && snapshot.models.length > 0
|
|
294
|
+
? ledger.signals?.(snapshot.models.map((m) => m.slug), cfg.filters.trustScopedByHarness ? req.harnessId : undefined)
|
|
295
|
+
: undefined;
|
|
296
|
+
// What an escalated retry has actually been billing per prompt token, for
|
|
297
|
+
// the escalation-cost term in candidate scoring. Read once per turn; null
|
|
298
|
+
// (term inert) when the weight is 0 or the ledger has too few samples.
|
|
299
|
+
const escalationUsdPerPromptToken =
|
|
300
|
+
cfg.filters.escalationCostWeight > 0
|
|
301
|
+
? (ledger?.escalationCost?.(cfg.ledger.blendWindowDays)?.usdPerPromptToken ?? null)
|
|
302
|
+
: null;
|
|
271
303
|
const build = (t: Tier, relaxLevel = 0): { candidates: Candidate[]; rejected: Rejection[] } =>
|
|
272
304
|
buildCandidates({
|
|
273
305
|
req,
|
|
@@ -281,12 +313,13 @@ export function select(args: SelectArgs): Decision {
|
|
|
281
313
|
warmSlug,
|
|
282
314
|
relaxLevel,
|
|
283
315
|
...(args.excludeSlugs === undefined ? {} : { excludeSlugs: args.excludeSlugs }),
|
|
316
|
+
...(candidateSignals === undefined ? {} : { signals: candidateSignals }),
|
|
317
|
+
...(escalationUsdPerPromptToken === null ? {} : { escalationUsdPerPromptToken }),
|
|
284
318
|
});
|
|
285
319
|
let chosenTier = effective;
|
|
286
320
|
let built: { candidates: Candidate[]; rejected: Rejection[] } | null = null;
|
|
287
321
|
// Relax level the tier rescue used (0 = no rescue). The budget downgrade
|
|
288
322
|
// search must rebuild at the same level, or it re-applies the strict config
|
|
289
|
-
// that excluded every model and throws instead of downgrading.
|
|
290
323
|
let rescuedRelax = 0;
|
|
291
324
|
for (const t of wideningOrder(effective, profile.minTier, profile.maxTier)) {
|
|
292
325
|
const b = build(t);
|
|
@@ -354,17 +387,26 @@ export function select(args: SelectArgs): Decision {
|
|
|
354
387
|
if (warm !== undefined) {
|
|
355
388
|
const warmPrice = priceAt(warm.model, Math.max(1, state.lastPromptTokens));
|
|
356
389
|
const newPrice = priceAt(chosen.model, Math.max(1, effFeatures.promptTokens));
|
|
357
|
-
const
|
|
358
|
-
const
|
|
390
|
+
const stayWarm = state.lastPromptTokens * (warmPrice.cacheRead ?? warmPrice.prompt);
|
|
391
|
+
const switchCold = effFeatures.promptTokens * (newPrice.prompt + (newPrice.cacheWrite ?? 0));
|
|
392
|
+
const newWarm = effFeatures.promptTokens * (newPrice.cacheRead ?? newPrice.prompt);
|
|
393
|
+
// Amortise over the horizon: H turns of staying warm against one cold
|
|
394
|
+
// switch plus H−1 turns warm on the new model. H = 1 is the one-turn
|
|
395
|
+
// comparison, which kept a 25x-priced model warm for a 33-dispatch run
|
|
396
|
+
// because no single turn could recoup the cold write on its own.
|
|
397
|
+
const horizon = Math.max(1, cfg.hysteresis.switchHorizonTurns);
|
|
398
|
+
const stayCost = horizon * stayWarm;
|
|
399
|
+
const switchCost = switchCold + (horizon - 1) * newWarm;
|
|
400
|
+
const over = horizon > 1 ? ` over ${horizon} turns` : "";
|
|
359
401
|
if (stayCost > switchCost * cfg.hysteresis.switchMargin) {
|
|
360
402
|
reasons.push(
|
|
361
|
-
`cache: switch ${warmSlug} → ${chosen.model.slug} (stay $${stayCost.toFixed(4)} > switch $${switchCost.toFixed(4)} × ${cfg.hysteresis.switchMargin})`,
|
|
403
|
+
`cache: switch ${warmSlug} → ${chosen.model.slug} (stay $${stayCost.toFixed(4)} > switch $${switchCost.toFixed(4)} × ${cfg.hysteresis.switchMargin}${over})`,
|
|
362
404
|
);
|
|
363
405
|
} else {
|
|
364
406
|
chosen = warm;
|
|
365
407
|
sticky = true;
|
|
366
408
|
reasons.push(
|
|
367
|
-
`cache: keeping warm ${warmSlug} (stay $${stayCost.toFixed(4)} ≤ switch $${switchCost.toFixed(4)} × ${cfg.hysteresis.switchMargin})`,
|
|
409
|
+
`cache: keeping warm ${warmSlug} (stay $${stayCost.toFixed(4)} ≤ switch $${switchCost.toFixed(4)} × ${cfg.hysteresis.switchMargin}${over})`,
|
|
368
410
|
);
|
|
369
411
|
}
|
|
370
412
|
}
|
|
@@ -377,6 +419,7 @@ export function select(args: SelectArgs): Decision {
|
|
|
377
419
|
perDayUsd: profile.budget?.perDayUsd ?? cfg.budget.perDayUsd,
|
|
378
420
|
onExceeded: profile.budget?.onExceeded ?? cfg.budget.onExceeded,
|
|
379
421
|
};
|
|
422
|
+
const daySpend = budget.perDayUsd !== undefined ? (ledger?.spendSince(nowMs - DAY_MS, req.harnessId) ?? 0) : 0;
|
|
380
423
|
const breach = (c: Candidate): string | null => {
|
|
381
424
|
if (budget.perTurnUsd !== undefined && c.forecast.coldUsd > budget.perTurnUsd) {
|
|
382
425
|
return `cold forecast $${c.forecast.coldUsd.toFixed(4)} > per-turn budget $${budget.perTurnUsd}`;
|
|
@@ -388,7 +431,6 @@ export function select(args: SelectArgs): Decision {
|
|
|
388
431
|
// Scope the rolling 24h ceiling to the requesting harness when it
|
|
389
432
|
// identifies itself, so multiple harnesses sharing one router each get
|
|
390
433
|
// their own daily budget instead of one exhausting it for the others.
|
|
391
|
-
const daySpend = ledger?.spendSince(nowMs - DAY_MS, req.harnessId) ?? 0;
|
|
392
434
|
if (daySpend + c.forecast.coldUsd > budget.perDayUsd) {
|
|
393
435
|
return `24h spend $${daySpend.toFixed(4)} + cold forecast > per-day budget $${budget.perDayUsd}`;
|
|
394
436
|
}
|
|
@@ -425,6 +467,9 @@ export function select(args: SelectArgs): Decision {
|
|
|
425
467
|
const fallbacks: string[] = [];
|
|
426
468
|
for (const c of candidates) {
|
|
427
469
|
if (c.model.slug === chosen.model.slug) continue;
|
|
470
|
+
// The cascade is served by ONE upstream: an OpenRouter `models[]` array
|
|
471
|
+
// cannot name an Ollama model and vice versa.
|
|
472
|
+
if (c.model.provider !== chosen.model.provider) continue;
|
|
428
473
|
fallbacks.push(c.model.slug);
|
|
429
474
|
if (fallbacks.length >= 2) break;
|
|
430
475
|
}
|
|
@@ -475,6 +520,8 @@ export function select(args: SelectArgs): Decision {
|
|
|
475
520
|
cacheBreakpointMessageIndices,
|
|
476
521
|
compactionPlan,
|
|
477
522
|
promptTokensSaved,
|
|
523
|
+
compactionSavedBytes,
|
|
524
|
+
compactionPlanTokens,
|
|
478
525
|
reasoning,
|
|
479
526
|
maxTokens,
|
|
480
527
|
stripAssistantReasoning,
|
package/src/router/state.ts
CHANGED
|
@@ -31,6 +31,7 @@ interface Row {
|
|
|
31
31
|
context_version: string | null;
|
|
32
32
|
context_fetched_at_ms: number;
|
|
33
33
|
compaction_plan: string | null;
|
|
34
|
+
compaction_plan_tokens: number;
|
|
34
35
|
updated_at_ms: number;
|
|
35
36
|
}
|
|
36
37
|
|
|
@@ -51,6 +52,7 @@ function toState(row: Row): ConversationState {
|
|
|
51
52
|
contextVersion: row.context_version,
|
|
52
53
|
contextFetchedAtMs: row.context_fetched_at_ms,
|
|
53
54
|
compactionPlan: row.compaction_plan === null ? null : (JSON.parse(row.compaction_plan) as CompactionEdit[]),
|
|
55
|
+
compactionPlanTokens: row.compaction_plan_tokens,
|
|
54
56
|
updatedAtMs: row.updated_at_ms,
|
|
55
57
|
};
|
|
56
58
|
}
|
|
@@ -69,10 +71,10 @@ export function createConversationStore(db: Database): ConversationStore {
|
|
|
69
71
|
INSERT INTO conversations (
|
|
70
72
|
key, session_id, turn, current_slug, current_tier, sticky_until_turn,
|
|
71
73
|
last_prompt_tokens, cache_warm_slug, cache_warm_at_ms,
|
|
72
|
-
context_version, context_fetched_at_ms, compaction_plan, updated_at_ms
|
|
74
|
+
context_version, context_fetched_at_ms, compaction_plan, compaction_plan_tokens, updated_at_ms
|
|
73
75
|
) VALUES ($key, $sessionId, $turn, $currentSlug, $currentTier, $stickyUntilTurn,
|
|
74
76
|
$lastPromptTokens, $cacheWarmSlug, $cacheWarmAtMs,
|
|
75
|
-
$contextVersion, $contextFetchedAtMs, $compactionPlan, $updatedAtMs)
|
|
77
|
+
$contextVersion, $contextFetchedAtMs, $compactionPlan, $compactionPlanTokens, $updatedAtMs)
|
|
76
78
|
ON CONFLICT(key) DO UPDATE SET
|
|
77
79
|
session_id = excluded.session_id,
|
|
78
80
|
turn = excluded.turn,
|
|
@@ -85,6 +87,7 @@ export function createConversationStore(db: Database): ConversationStore {
|
|
|
85
87
|
context_version = excluded.context_version,
|
|
86
88
|
context_fetched_at_ms = excluded.context_fetched_at_ms,
|
|
87
89
|
compaction_plan = excluded.compaction_plan,
|
|
90
|
+
compaction_plan_tokens = excluded.compaction_plan_tokens,
|
|
88
91
|
updated_at_ms = excluded.updated_at_ms
|
|
89
92
|
`);
|
|
90
93
|
// Read-modify-write in JS lost money: an aborted or failed dispatch is still
|
|
@@ -133,6 +136,7 @@ export function createConversationStore(db: Database): ConversationStore {
|
|
|
133
136
|
$stickyUntilTurn: state.stickyUntilTurn,
|
|
134
137
|
$lastPromptTokens: state.lastPromptTokens,
|
|
135
138
|
$compactionPlan: state.compactionPlan === null ? null : JSON.stringify(state.compactionPlan),
|
|
139
|
+
$compactionPlanTokens: state.compactionPlanTokens ?? 0,
|
|
136
140
|
$cacheWarmSlug: state.cacheWarmSlug,
|
|
137
141
|
$cacheWarmAtMs: state.cacheWarmAtMs,
|
|
138
142
|
$contextVersion: state.contextVersion,
|
package/src/router/tier-plan.ts
CHANGED
|
@@ -13,17 +13,26 @@
|
|
|
13
13
|
*
|
|
14
14
|
* The fix is to treat the floors as relative when the absolute ones cannot be
|
|
15
15
|
* met. Rank the available scored models, split them into four quantile bands,
|
|
16
|
-
* and take each band's lower bound as that tier's adaptive floor. The
|
|
17
|
-
* floor
|
|
16
|
+
* and take each band's lower bound as that tier's adaptive floor. The adaptive
|
|
17
|
+
* floor applies ONLY when the configured one leaves the tier thin:
|
|
18
18
|
*
|
|
19
|
-
* -
|
|
20
|
-
*
|
|
21
|
-
* -
|
|
22
|
-
*
|
|
19
|
+
* - When at least `MIN_FLOOR_ADMITS` rankable models meet the configured
|
|
20
|
+
* floor, the configured floor stands verbatim — no behaviour change.
|
|
21
|
+
* - When fewer do (a narrowed catalog), the effective floor is
|
|
22
|
+
* `min(configured, adaptive)`, so `hard` still gets the best quartile of
|
|
23
|
+
* what is available instead of nothing at all.
|
|
23
24
|
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
25
|
+
* The thinness gate is load-bearing. An earlier version applied `min` always,
|
|
26
|
+
* on the assumption that a healthy catalog's bands sit above the configured
|
|
27
|
+
* floors. They do not: a wide catalog carries a long tail of weak scored models
|
|
28
|
+
* (measured on 347 key-admitted models: coding p50 = 45.8 against a configured
|
|
29
|
+
* `moderate` floor of 60, p75 = 59.9 against `hard`'s 72), so `min` silently
|
|
30
|
+
* relaxed every tier and a coding-50 model won `moderate` — then escalated 14x
|
|
31
|
+
* more often than the model the configured floor would have picked.
|
|
32
|
+
*
|
|
33
|
+
* Adaptive floors may only RELAX a floor, never tighten one. Tightening would
|
|
34
|
+
* let a rich catalog silently price us out of a tier the operator explicitly
|
|
35
|
+
* configured.
|
|
27
36
|
*
|
|
28
37
|
* Unscored models are never imputed a score (see `candidates.ts`), so a catalog
|
|
29
38
|
* with no benchmarks at all yields all-zero floors: every tier admits every
|
|
@@ -46,10 +55,21 @@ export interface TierPlan {
|
|
|
46
55
|
floors: Record<QualityAxis, AxisFloors>;
|
|
47
56
|
/** How many available models carried a score on each axis. */
|
|
48
57
|
scoredCount: Record<QualityAxis, number>;
|
|
58
|
+
/** Rankable models' scores per axis, ascending — what the floors were cut from. */
|
|
59
|
+
scores: Record<QualityAxis, readonly number[]>;
|
|
49
60
|
/** Adaptive input-price ceiling ($/Mtok) per tier, from the catalog's price spread. */
|
|
50
61
|
priceCeilings: Record<Tier, number>;
|
|
51
62
|
}
|
|
52
63
|
|
|
64
|
+
/**
|
|
65
|
+
* Rankable models a configured floor must admit for it to stand as written.
|
|
66
|
+
* Below this the tier is "thin" and the adaptive band may relax the floor.
|
|
67
|
+
* Three, not one: a lone survivor leaves no same-tier failover and no
|
|
68
|
+
* meaningful cost ranking, which is the narrowed-catalog situation this whole
|
|
69
|
+
* module exists to escape.
|
|
70
|
+
*/
|
|
71
|
+
export const MIN_FLOOR_ADMITS = 3;
|
|
72
|
+
|
|
53
73
|
/**
|
|
54
74
|
* Models that could plausibly serve a turn, for ranking purposes: the built-in
|
|
55
75
|
* denials (floating aliases, batch endpoints, stealth, meta-routers) and free
|
|
@@ -121,6 +141,7 @@ function bandCeilings(ascending: readonly number[]): Record<Tier, number> {
|
|
|
121
141
|
export function computeTierPlan(models: readonly CatalogModel[], cfg: RouterConfig): TierPlan {
|
|
122
142
|
const floors: Record<string, AxisFloors> = {};
|
|
123
143
|
const scoredCount: Record<string, number> = {};
|
|
144
|
+
const axisScores: Record<string, readonly number[]> = {};
|
|
124
145
|
const includeFree = cfg.filters.includeFree;
|
|
125
146
|
|
|
126
147
|
for (const axis of AXES) {
|
|
@@ -134,6 +155,7 @@ export function computeTierPlan(models: readonly CatalogModel[], cfg: RouterConf
|
|
|
134
155
|
scores.sort((a, b) => a - b);
|
|
135
156
|
floors[axis] = bandFloors(scores);
|
|
136
157
|
scoredCount[axis] = scores.length;
|
|
158
|
+
axisScores[axis] = scores;
|
|
137
159
|
}
|
|
138
160
|
// Input prices ($/Mtok) of the rankable models, ascending, for the ceilings.
|
|
139
161
|
const prices: number[] = [];
|
|
@@ -146,10 +168,24 @@ export function computeTierPlan(models: readonly CatalogModel[], cfg: RouterConf
|
|
|
146
168
|
return {
|
|
147
169
|
floors: floors as Record<QualityAxis, AxisFloors>,
|
|
148
170
|
scoredCount: scoredCount as Record<QualityAxis, number>,
|
|
171
|
+
scores: axisScores as Record<QualityAxis, readonly number[]>,
|
|
149
172
|
priceCeilings: bandCeilings(prices),
|
|
150
173
|
};
|
|
151
174
|
}
|
|
152
175
|
|
|
176
|
+
/** Rankable models scoring at least `floor` on an axis. `scores` is ascending. */
|
|
177
|
+
export function countAdmitted(scores: readonly number[], floor: number): number {
|
|
178
|
+
// Binary search for the first score >= floor; everything after it qualifies.
|
|
179
|
+
let lo = 0;
|
|
180
|
+
let hi = scores.length;
|
|
181
|
+
while (lo < hi) {
|
|
182
|
+
const mid = (lo + hi) >> 1;
|
|
183
|
+
if ((scores[mid] ?? 0) < floor) lo = mid + 1;
|
|
184
|
+
else hi = mid;
|
|
185
|
+
}
|
|
186
|
+
return scores.length - lo;
|
|
187
|
+
}
|
|
188
|
+
|
|
153
189
|
/**
|
|
154
190
|
* Memoized per snapshot object AND config object. A catalog refresh installs a
|
|
155
191
|
* fresh `CatalogSnapshot`, which misses the cache and recomputes the plan — so
|
|
@@ -173,8 +209,9 @@ export function tierPlanFor(snapshot: CatalogSnapshot, cfg: RouterConfig): TierP
|
|
|
173
209
|
}
|
|
174
210
|
|
|
175
211
|
/**
|
|
176
|
-
* The floor to actually enforce for a tier on an axis.
|
|
177
|
-
*
|
|
212
|
+
* The floor to actually enforce for a tier on an axis. The configured floor
|
|
213
|
+
* stands whenever at least MIN_FLOOR_ADMITS rankable models meet it; only a
|
|
214
|
+
* thin tier is relaxed to the adaptive band, and never tightened.
|
|
178
215
|
*/
|
|
179
216
|
export function effectiveQualityFloor(
|
|
180
217
|
configured: number,
|
|
@@ -182,6 +219,7 @@ export function effectiveQualityFloor(
|
|
|
182
219
|
axis: QualityAxis,
|
|
183
220
|
plan: TierPlan,
|
|
184
221
|
): number {
|
|
222
|
+
if (countAdmitted(plan.scores[axis], configured) >= MIN_FLOOR_ADMITS) return configured;
|
|
185
223
|
const adaptive = plan.floors[axis][tier];
|
|
186
224
|
return Math.min(configured, adaptive);
|
|
187
225
|
}
|
package/src/router/types.ts
CHANGED
|
@@ -178,6 +178,12 @@ export interface ConversationState {
|
|
|
178
178
|
* the prompt cache and un-saves the tokens. Fresh planning only extends it.
|
|
179
179
|
*/
|
|
180
180
|
compactionPlan: CompactionEdit[] | null;
|
|
181
|
+
/**
|
|
182
|
+
* Compacted prompt size (tokens) at which `compactionPlan` was last made.
|
|
183
|
+
* Re-planning is rationed against growth from this point
|
|
184
|
+
* (`compaction.replanGrowthRatio`). 0 / absent = unknown.
|
|
185
|
+
*/
|
|
186
|
+
compactionPlanTokens?: number;
|
|
181
187
|
/** When that block was fetched, for the staleness TTL. */
|
|
182
188
|
contextFetchedAtMs: number;
|
|
183
189
|
updatedAtMs: number;
|
|
@@ -247,6 +253,10 @@ export interface Decision {
|
|
|
247
253
|
compactionPlan: CompactionEdit[];
|
|
248
254
|
/** Estimated prompt tokens removed by `compactionPlan`, for the ledger. */
|
|
249
255
|
promptTokensSaved: number;
|
|
256
|
+
/** Prompt bytes `compactionPlan` removes; the dispatched size is `req.promptBytes` minus this (plus any context block). */
|
|
257
|
+
compactionSavedBytes: number;
|
|
258
|
+
/** Compacted prompt size the plan was (last) made at, persisted for re-plan rationing. */
|
|
259
|
+
compactionPlanTokens: number;
|
|
250
260
|
reasoning: ReasoningLevel | undefined;
|
|
251
261
|
maxTokens: number | undefined;
|
|
252
262
|
stripAssistantReasoning: boolean;
|
package/src/server/http.ts
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
import { mkdirSync } from "node:fs";
|
|
2
2
|
import { dirname } from "node:path";
|
|
3
3
|
import type { Server } from "bun";
|
|
4
|
-
import {
|
|
4
|
+
import { createProviders } from "./providers.ts";
|
|
5
5
|
import { createBridgeFromConfig } from "../context/index.ts";
|
|
6
6
|
import { createLedger } from "../cost/ledger.ts";
|
|
7
|
+
import { buildUsageReport } from "../cost/report.ts";
|
|
7
8
|
import type { Ledger, ModelTrust } from "../cost/types.ts";
|
|
8
9
|
import { createRouter } from "../router/index.ts";
|
|
9
10
|
import { createConversationStore } from "../router/state.ts";
|
|
10
|
-
import { createOpenRouterClient } from "../upstream/openrouter.ts";
|
|
11
11
|
import { UpstreamError } from "../upstream/types.ts";
|
|
12
|
-
import { apiKeySource } from "../config/load.ts";
|
|
12
|
+
import { apiKeySource, ollamaKeySource } from "../config/load.ts";
|
|
13
13
|
import { routerConfigPath } from "../cli/config-cmd.ts";
|
|
14
14
|
import { watchConfig } from "../config/hot-reload.ts";
|
|
15
15
|
import type { RouterConfig } from "../config/types.ts";
|
|
@@ -172,8 +172,7 @@ export function startServer(cfg: RouterConfig): StartedServer {
|
|
|
172
172
|
if (cfg.ledger.path !== ":memory:") mkdirSync(dirname(cfg.ledger.path), { recursive: true });
|
|
173
173
|
const db = openDb(cfg.ledger.path);
|
|
174
174
|
const ledger = createLedger(db, cfg);
|
|
175
|
-
const upstream =
|
|
176
|
-
const catalog = createCatalog(cfg, upstream, db);
|
|
175
|
+
const { upstream, catalog, ollama, ollamaUsage } = createProviders(cfg, db, log);
|
|
177
176
|
const conversations = createConversationStore(db);
|
|
178
177
|
const router = createRouter({ config: cfg, catalog, ledger, conversations, upstream });
|
|
179
178
|
const context = createBridgeFromConfig(cfg, db);
|
|
@@ -189,7 +188,7 @@ export function startServer(cfg: RouterConfig): StartedServer {
|
|
|
189
188
|
routerConfigPath(),
|
|
190
189
|
cfg,
|
|
191
190
|
pinned,
|
|
192
|
-
["server", "openrouter", "context", "ledger"],
|
|
191
|
+
["server", "openrouter", "ollama", "context", "ledger"],
|
|
193
192
|
{
|
|
194
193
|
onReload: ({ changed }) => {
|
|
195
194
|
log.info("config reloaded", { changed: changed.join(", ") });
|
|
@@ -211,6 +210,14 @@ export function startServer(cfg: RouterConfig): StartedServer {
|
|
|
211
210
|
if (cfg.openrouter.apiKey === "") {
|
|
212
211
|
log.warn("OPENROUTER_API_KEY is not set; /v1/chat/completions will fail at dispatch time");
|
|
213
212
|
}
|
|
213
|
+
if (ollama !== null) {
|
|
214
|
+
log.info("ollama cloud upstream enabled", {
|
|
215
|
+
baseUrl: cfg.ollama.baseUrl,
|
|
216
|
+
// Provenance only; never the key itself.
|
|
217
|
+
apiKeySource: ollamaKeySource(cfg).source,
|
|
218
|
+
costBias: cfg.ollama.costBias,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
214
221
|
|
|
215
222
|
// Warm the catalog without blocking listen; the first request may race it,
|
|
216
223
|
// which CatalogSource.get() already serializes.
|
|
@@ -281,6 +288,10 @@ export function startServer(cfg: RouterConfig): StartedServer {
|
|
|
281
288
|
try {
|
|
282
289
|
normReq = parseChatRequest(await req.json(), req.headers);
|
|
283
290
|
} catch (err) {
|
|
291
|
+
// The slot was acquired before parsing; a rejected body never reaches
|
|
292
|
+
// runTurn's `finally`, so it must be released here or every malformed
|
|
293
|
+
// request permanently consumes one of maxConcurrentTurns.
|
|
294
|
+
releaseTurn();
|
|
284
295
|
if (err instanceof WireErrorException) return wireErrorResponse(err.wireError);
|
|
285
296
|
return wireErrorResponse({
|
|
286
297
|
status: 400,
|
|
@@ -358,6 +369,15 @@ export function startServer(cfg: RouterConfig): StartedServer {
|
|
|
358
369
|
if (req.method === "GET" && url.pathname === "/v1/router/stats") {
|
|
359
370
|
return json(computeStats(ledger));
|
|
360
371
|
}
|
|
372
|
+
if (req.method === "GET" && url.pathname === "/v1/router/report") {
|
|
373
|
+
// Usage analytics for `/router report` and the CLI: bounded window,
|
|
374
|
+
// optional harness scope (the X-Omp-Harness header value).
|
|
375
|
+
const rawDays = url.searchParams.get("days");
|
|
376
|
+
const parsedDays = rawDays === null ? 7 : Number.parseInt(rawDays, 10);
|
|
377
|
+
const windowDays = Number.isInteger(parsedDays) ? Math.min(Math.max(parsedDays, 1), 365) : 7;
|
|
378
|
+
const harnessId = url.searchParams.get("harness") ?? "";
|
|
379
|
+
return json(buildUsageReport(db, { windowDays, harnessId }));
|
|
380
|
+
}
|
|
361
381
|
if (req.method === "GET" && url.pathname === "/v1/router/decisions") {
|
|
362
382
|
const rawLimit = url.searchParams.get("limit");
|
|
363
383
|
const parsed = rawLimit === null ? 50 : Number.parseInt(rawLimit, 10);
|
|
@@ -375,6 +395,23 @@ export function startServer(cfg: RouterConfig): StartedServer {
|
|
|
375
395
|
agentdox: context.enabled
|
|
376
396
|
? { url: cfg.context.baseUrl, defaultScope: cfg.context.defaultScope, recordTurns: cfg.context.recordTurns }
|
|
377
397
|
: null,
|
|
398
|
+
// Never the key. `available` is the circuit breaker: false while a
|
|
399
|
+
// 402/429 cooldown routes every turn around Ollama.
|
|
400
|
+
ollama:
|
|
401
|
+
ollama === null
|
|
402
|
+
? null
|
|
403
|
+
: {
|
|
404
|
+
baseUrl: cfg.ollama.baseUrl,
|
|
405
|
+
apiKeySource: ollamaKeySource(cfg).source,
|
|
406
|
+
models: catalog.ollamaModels?.().length ?? 0,
|
|
407
|
+
available: ollama.available(),
|
|
408
|
+
cooldownUntilMs: ollama.cooldownUntilMs(),
|
|
409
|
+
lastTrip: ollama.lastTrip(),
|
|
410
|
+
// Plan usage as ollama.com reports it (share of included monthly
|
|
411
|
+
// credits) and the cost multiplier currently in force.
|
|
412
|
+
usage: ollamaUsage.peek(),
|
|
413
|
+
costBias: { configured: cfg.ollama.costBias, effective: catalog.ollamaBias?.() ?? cfg.ollama.costBias, biasUntilUsage: cfg.ollama.biasUntilUsage },
|
|
414
|
+
},
|
|
378
415
|
catalog: snap === null
|
|
379
416
|
? null
|
|
380
417
|
: {
|
|
@@ -382,6 +419,10 @@ export function startServer(cfg: RouterConfig): StartedServer {
|
|
|
382
419
|
fetchedAtMs: snap.fetchedAtMs,
|
|
383
420
|
ageMs: Date.now() - snap.fetchedAtMs,
|
|
384
421
|
keyScoped: snap.keyScoped === true,
|
|
422
|
+
// Non-null after a refresh kept < half the previous models;
|
|
423
|
+
// the one signal that the key narrowed (or upstream blipped)
|
|
424
|
+
// and routing is now on whatever survived.
|
|
425
|
+
shrink: catalog.lastShrink?.() ?? null,
|
|
385
426
|
},
|
|
386
427
|
});
|
|
387
428
|
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Upstream + catalog assembly shared by the server and the CLIs, so `models`
|
|
3
|
+
* and `explain` see exactly the catalog a turn would route over — including
|
|
4
|
+
* Ollama Cloud when it is enabled.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Database } from "bun:sqlite";
|
|
8
|
+
import { createCompositeCatalog } from "../catalog/composite.ts";
|
|
9
|
+
import { createOllamaCatalog } from "../catalog/ollama-catalog.ts";
|
|
10
|
+
import { createCatalog } from "../catalog/openrouter-catalog.ts";
|
|
11
|
+
import type { CatalogSource } from "../catalog/types.ts";
|
|
12
|
+
import type { RouterConfig } from "../config/types.ts";
|
|
13
|
+
import { createMultiUpstream } from "../upstream/multi.ts";
|
|
14
|
+
import { createOllamaClient, type OllamaClient } from "../upstream/ollama.ts";
|
|
15
|
+
import { createOllamaUsageSource, NO_USAGE, type OllamaUsageSource } from "../upstream/ollama-usage.ts";
|
|
16
|
+
import { createOpenRouterClient } from "../upstream/openrouter.ts";
|
|
17
|
+
import type { UpstreamClient } from "../upstream/types.ts";
|
|
18
|
+
import { createLogger, type Logger } from "../util/log.ts";
|
|
19
|
+
|
|
20
|
+
export interface Providers {
|
|
21
|
+
upstream: UpstreamClient;
|
|
22
|
+
catalog: CatalogSource & { ollamaModels?(): unknown[]; ollamaBias?(): number };
|
|
23
|
+
/** Non-null when Ollama Cloud is enabled; carries the circuit breaker. */
|
|
24
|
+
ollama: OllamaClient | null;
|
|
25
|
+
/** Plan usage reader; inert without a key. */
|
|
26
|
+
ollamaUsage: OllamaUsageSource;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function createProviders(cfg: RouterConfig, db: Database, log: Logger = createLogger(cfg.logLevel)): Providers {
|
|
30
|
+
const openrouter = createOpenRouterClient(cfg);
|
|
31
|
+
const openrouterCatalog = createCatalog(cfg, openrouter, db);
|
|
32
|
+
if (!cfg.ollama.enabled) return { upstream: openrouter, catalog: openrouterCatalog, ollama: null, ollamaUsage: NO_USAGE };
|
|
33
|
+
// Ollama Cloud is a second upstream ranked in the same catalog: `ollama/…`
|
|
34
|
+
// slugs dispatch to it, everything else to OpenRouter.
|
|
35
|
+
const ollama = createOllamaClient(cfg);
|
|
36
|
+
// Plan usage lives on ollama.com whichever base URL dispatches; it needs the
|
|
37
|
+
// key, so the daemon path without `/login ollama-cloud` keeps a static bias.
|
|
38
|
+
const ollamaUsage = createOllamaUsageSource({
|
|
39
|
+
apiKey: cfg.ollama.apiKey,
|
|
40
|
+
pollMs: cfg.ollama.usagePollMs,
|
|
41
|
+
timeoutMs: Math.min(cfg.ollama.timeoutMs, 15_000),
|
|
42
|
+
log,
|
|
43
|
+
});
|
|
44
|
+
return {
|
|
45
|
+
upstream: createMultiUpstream(openrouter, ollama),
|
|
46
|
+
catalog: createCompositeCatalog(openrouterCatalog, createOllamaCatalog(cfg.ollama, log), ollama, {
|
|
47
|
+
costBias: cfg.ollama.costBias,
|
|
48
|
+
biasUntilUsage: cfg.ollama.biasUntilUsage,
|
|
49
|
+
usage: ollamaUsage,
|
|
50
|
+
}),
|
|
51
|
+
ollama,
|
|
52
|
+
ollamaUsage,
|
|
53
|
+
};
|
|
54
|
+
}
|