auto-model-router 0.3.4 → 0.4.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 +24 -2
- package/omp-extension/report-logic.ts +93 -0
- package/omp-extension/router-configure.ts +128 -2
- package/package.json +1 -1
- package/src/catalog/ollama-catalog.ts +30 -2
- package/src/cli/config-wizard.ts +8 -0
- package/src/cli/report.ts +7 -2
- package/src/config/defaults.ts +9 -0
- package/src/config/schema.ts +5 -0
- package/src/config/types.ts +41 -0
- package/src/cost/feedback.ts +81 -0
- package/src/cost/ledger.ts +73 -0
- package/src/cost/report.ts +106 -2
- package/src/cost/types.ts +28 -0
- package/src/router/candidates.ts +13 -4
- package/src/router/classify.ts +10 -0
- package/src/router/features.ts +20 -1
- package/src/router/index.ts +12 -1
- package/src/router/learned.ts +202 -0
- package/src/router/select.ts +64 -8
- package/src/router/types.ts +31 -1
- package/src/server/http.ts +82 -5
- package/src/server/overrides.ts +83 -0
- package/src/server/providers.ts +10 -2
- package/src/server/turn.ts +16 -1
- package/src/upstream/ollama-usage.ts +79 -2
- package/src/util/sqlite.ts +30 -0
- package/test/config-wizard.test.ts +2 -1
- package/test/controls.test.ts +223 -0
- package/test/failover.test.ts +3 -2
- package/test/features.test.ts +31 -0
- package/test/learned.test.ts +61 -0
- package/test/ollama.test.ts +74 -2
- package/test/report-hub.test.ts +4 -2
- package/test/report-logic.test.ts +3 -0
- package/test/report.test.ts +43 -0
- package/test/select.test.ts +126 -1
- package/test/trust-attribution.test.ts +42 -0
- package/test/turn.test.ts +33 -2
- package/tools/replay.ts +266 -156
- package/tools/train-classifier.ts +111 -0
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A learned escalation-risk model over the recorded classifier features.
|
|
3
|
+
*
|
|
4
|
+
* The heuristic classifier's weights are hand-set. The ledger holds, for
|
|
5
|
+
* every served turn, the exact feature vector the classifier saw and whether
|
|
6
|
+
* the turn escalated (a cheaper attempt was probe-rejected). That is a
|
|
7
|
+
* labelled dataset — ~13k rows, ~1% positive — and this module is the
|
|
8
|
+
* smallest honest learner for it: standardised features, L2-regularised
|
|
9
|
+
* logistic regression by full-batch gradient descent, evaluated by AUC on a
|
|
10
|
+
* time-ordered holdout so the future is never trained on.
|
|
11
|
+
*
|
|
12
|
+
* Advisory by design. `tools/train-classifier.ts` fits and writes a model
|
|
13
|
+
* file; with `classifier.learnedModelPath` set, `classify()` scores each
|
|
14
|
+
* turn and records `learned: p(escalate)=…` in the decision trail so the
|
|
15
|
+
* signal can be judged against outcomes (and replayed) before it is ever
|
|
16
|
+
* allowed to move a tier.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { Features } from "./types.ts";
|
|
20
|
+
|
|
21
|
+
export const LEARNED_MODEL_VERSION = 1;
|
|
22
|
+
|
|
23
|
+
/** Feature names in vector order. Adding one is a model-version change. */
|
|
24
|
+
export const FEATURE_NAMES: readonly string[] = [
|
|
25
|
+
"log_prompt_tokens",
|
|
26
|
+
"log_new_content_tokens",
|
|
27
|
+
"turn_depth",
|
|
28
|
+
"tool_count",
|
|
29
|
+
"log_tool_schema_bytes",
|
|
30
|
+
"is_tool_result_continuation",
|
|
31
|
+
"tool_loop_depth",
|
|
32
|
+
"distinct_tools_used",
|
|
33
|
+
"last_tool_failed",
|
|
34
|
+
"repeated_tool_call",
|
|
35
|
+
"circular_tool_call",
|
|
36
|
+
"has_images",
|
|
37
|
+
"has_new_image",
|
|
38
|
+
"code_blocks",
|
|
39
|
+
"log_code_bytes",
|
|
40
|
+
"looks_like_diff",
|
|
41
|
+
"complexity_keywords",
|
|
42
|
+
"triviality_keywords",
|
|
43
|
+
"requested_reasoning",
|
|
44
|
+
"question_count",
|
|
45
|
+
"is_terse_instruction",
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
const REASONING_ORDINAL: Record<string, number> = { off: 0, minimal: 0.5, low: 1, medium: 2, high: 3, xhigh: 4, max: 5 };
|
|
49
|
+
|
|
50
|
+
const log1p = (v: number): number => Math.log1p(Math.max(0, v));
|
|
51
|
+
const b = (v: boolean | undefined): number => (v === true ? 1 : 0);
|
|
52
|
+
|
|
53
|
+
/** The numeric vector for one turn, in FEATURE_NAMES order. Tolerates old rows missing fields. */
|
|
54
|
+
export function learnedVector(f: Partial<Features>): number[] {
|
|
55
|
+
return [
|
|
56
|
+
log1p(f.promptTokens ?? 0),
|
|
57
|
+
log1p(f.newContentTokens ?? 0),
|
|
58
|
+
f.turnDepth ?? 0,
|
|
59
|
+
f.toolCount ?? 0,
|
|
60
|
+
log1p(f.toolSchemaBytes ?? 0),
|
|
61
|
+
b(f.isToolResultContinuation),
|
|
62
|
+
f.toolLoopDepth ?? 0,
|
|
63
|
+
f.distinctToolsUsed ?? 0,
|
|
64
|
+
b(f.lastToolFailed),
|
|
65
|
+
b(f.repeatedToolCall),
|
|
66
|
+
b(f.circularToolCall),
|
|
67
|
+
b(f.hasImages),
|
|
68
|
+
b(f.hasNewImage),
|
|
69
|
+
f.codeBlocks ?? 0,
|
|
70
|
+
log1p(f.codeBytes ?? 0),
|
|
71
|
+
b(f.looksLikeDiff),
|
|
72
|
+
Array.isArray(f.complexityKeywords) ? f.complexityKeywords.length : 0,
|
|
73
|
+
Array.isArray(f.trivialityKeywords) ? f.trivialityKeywords.length : 0,
|
|
74
|
+
REASONING_ORDINAL[f.requestedReasoning ?? "off"] ?? 0,
|
|
75
|
+
f.questionCount ?? 0,
|
|
76
|
+
b(f.isTerseInstruction),
|
|
77
|
+
];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface LearnedModel {
|
|
81
|
+
version: number;
|
|
82
|
+
trainedAtMs: number;
|
|
83
|
+
rows: number;
|
|
84
|
+
positives: number;
|
|
85
|
+
names: string[];
|
|
86
|
+
means: number[];
|
|
87
|
+
stds: number[];
|
|
88
|
+
weights: number[];
|
|
89
|
+
bias: number;
|
|
90
|
+
/** Holdout AUC at training time, for the record. */
|
|
91
|
+
auc: number;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const sigmoid = (z: number): number => 1 / (1 + Math.exp(-z));
|
|
95
|
+
|
|
96
|
+
/** P(escalate) for one turn under a model. */
|
|
97
|
+
export function predictRisk(model: LearnedModel, f: Partial<Features>): number {
|
|
98
|
+
const x = learnedVector(f);
|
|
99
|
+
let z = model.bias;
|
|
100
|
+
for (let i = 0; i < model.weights.length && i < x.length; i++) {
|
|
101
|
+
const std = model.stds[i] ?? 1;
|
|
102
|
+
z += (model.weights[i] ?? 0) * (((x[i] ?? 0) - (model.means[i] ?? 0)) / (std > 0 ? std : 1));
|
|
103
|
+
}
|
|
104
|
+
return sigmoid(z);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface TrainOptions {
|
|
108
|
+
epochs?: number;
|
|
109
|
+
learningRate?: number;
|
|
110
|
+
/** L2 strength on the weights (not the bias). */
|
|
111
|
+
l2?: number;
|
|
112
|
+
/** Weight on positive examples, to counter the ~1% base rate. Default: negatives/positives. */
|
|
113
|
+
positiveWeight?: number;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Fits weights on already-vectorised rows. Pure, deterministic. */
|
|
117
|
+
export function trainLogistic(xs: number[][], ys: number[], opts: TrainOptions = {}): { weights: number[]; bias: number; means: number[]; stds: number[] } {
|
|
118
|
+
const n = xs.length;
|
|
119
|
+
const d = xs[0]?.length ?? 0;
|
|
120
|
+
if (n === 0 || d === 0) throw new Error("no training rows");
|
|
121
|
+
const means = new Array<number>(d).fill(0);
|
|
122
|
+
const stds = new Array<number>(d).fill(0);
|
|
123
|
+
for (const x of xs) for (let j = 0; j < d; j++) means[j]! += (x[j] ?? 0) / n;
|
|
124
|
+
for (const x of xs) for (let j = 0; j < d; j++) stds[j]! += ((x[j] ?? 0) - means[j]!) ** 2 / n;
|
|
125
|
+
for (let j = 0; j < d; j++) stds[j] = Math.sqrt(stds[j]!) || 1;
|
|
126
|
+
const z = xs.map((x) => x.map((v, j) => (v - means[j]!) / stds[j]!));
|
|
127
|
+
|
|
128
|
+
const positives = ys.reduce((s, y) => s + y, 0);
|
|
129
|
+
const posWeight = opts.positiveWeight ?? (positives > 0 ? (n - positives) / positives : 1);
|
|
130
|
+
const epochs = opts.epochs ?? 400;
|
|
131
|
+
const lr = opts.learningRate ?? 0.1;
|
|
132
|
+
const l2 = opts.l2 ?? 0.01;
|
|
133
|
+
const w = new Array<number>(d).fill(0);
|
|
134
|
+
let bias = 0;
|
|
135
|
+
for (let e = 0; e < epochs; e++) {
|
|
136
|
+
const gw = new Array<number>(d).fill(0);
|
|
137
|
+
let gb = 0;
|
|
138
|
+
let totalWeight = 0;
|
|
139
|
+
for (let i = 0; i < n; i++) {
|
|
140
|
+
const row = z[i]!;
|
|
141
|
+
let s = bias;
|
|
142
|
+
for (let j = 0; j < d; j++) s += w[j]! * row[j]!;
|
|
143
|
+
const y = ys[i]!;
|
|
144
|
+
const sw = y === 1 ? posWeight : 1;
|
|
145
|
+
const err = (sigmoid(s) - y) * sw;
|
|
146
|
+
for (let j = 0; j < d; j++) gw[j]! += err * row[j]!;
|
|
147
|
+
gb += err;
|
|
148
|
+
totalWeight += sw;
|
|
149
|
+
}
|
|
150
|
+
for (let j = 0; j < d; j++) w[j] = w[j]! - lr * (gw[j]! / totalWeight + l2 * w[j]!);
|
|
151
|
+
bias -= lr * (gb / totalWeight);
|
|
152
|
+
}
|
|
153
|
+
return { weights: w, bias, means, stds };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Area under the ROC curve by rank (Mann-Whitney), ties counted half. */
|
|
157
|
+
export function auc(scores: number[], labels: number[]): number {
|
|
158
|
+
const pos: number[] = [];
|
|
159
|
+
const neg: number[] = [];
|
|
160
|
+
scores.forEach((s, i) => (labels[i] === 1 ? pos : neg).push(s));
|
|
161
|
+
if (pos.length === 0 || neg.length === 0) return 0.5;
|
|
162
|
+
const sortedNeg = [...neg].sort((a, b) => a - b);
|
|
163
|
+
let sum = 0;
|
|
164
|
+
for (const p of pos) {
|
|
165
|
+
// count negatives below p, plus half of ties
|
|
166
|
+
let lo = 0;
|
|
167
|
+
let hi = sortedNeg.length;
|
|
168
|
+
while (lo < hi) {
|
|
169
|
+
const mid = (lo + hi) >> 1;
|
|
170
|
+
if (sortedNeg[mid]! < p) lo = mid + 1;
|
|
171
|
+
else hi = mid;
|
|
172
|
+
}
|
|
173
|
+
let ties = 0;
|
|
174
|
+
for (let k = lo; k < sortedNeg.length && sortedNeg[k] === p; k++) ties++;
|
|
175
|
+
sum += lo + ties / 2;
|
|
176
|
+
}
|
|
177
|
+
return sum / (pos.length * neg.length);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Loads a model file once per path; null when absent or unreadable. */
|
|
181
|
+
const modelCache = new Map<string, LearnedModel | null>();
|
|
182
|
+
export async function loadLearnedModel(path: string): Promise<LearnedModel | null> {
|
|
183
|
+
if (path === "") return null;
|
|
184
|
+
const cached = modelCache.get(path);
|
|
185
|
+
if (cached !== undefined) return cached;
|
|
186
|
+
let model: LearnedModel | null = null;
|
|
187
|
+
try {
|
|
188
|
+
const parsed = (await Bun.file(path).json()) as Partial<LearnedModel>;
|
|
189
|
+
if (parsed.version === LEARNED_MODEL_VERSION && Array.isArray(parsed.weights) && Array.isArray(parsed.means) && Array.isArray(parsed.stds)) {
|
|
190
|
+
model = parsed as LearnedModel;
|
|
191
|
+
}
|
|
192
|
+
} catch {
|
|
193
|
+
model = null;
|
|
194
|
+
}
|
|
195
|
+
modelCache.set(path, model);
|
|
196
|
+
return model;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Test seam: forget loaded models. */
|
|
200
|
+
export function resetLearnedModels(): void {
|
|
201
|
+
modelCache.clear();
|
|
202
|
+
}
|
package/src/router/select.ts
CHANGED
|
@@ -43,6 +43,12 @@ export interface SelectArgs {
|
|
|
43
43
|
* `buildCandidates` so a failover retry lands on a different model.
|
|
44
44
|
*/
|
|
45
45
|
excludeSlugs?: readonly string[];
|
|
46
|
+
/**
|
|
47
|
+
* Session override (/router pin): route to this slug. Admitted into the
|
|
48
|
+
* tier as if pinned there, then chosen over ranking, hysteresis and the
|
|
49
|
+
* stay/switch comparison. Ignored when the catalog has no such model.
|
|
50
|
+
*/
|
|
51
|
+
forceSlug?: string;
|
|
46
52
|
}
|
|
47
53
|
|
|
48
54
|
/**
|
|
@@ -108,6 +114,20 @@ function hardSignal(f: Features): boolean {
|
|
|
108
114
|
return f.lastToolFailed || f.circularToolCall || f.repeatedToolCall || reasoning === "high" || reasoning === "xhigh" || reasoning === "max";
|
|
109
115
|
}
|
|
110
116
|
|
|
117
|
+
/** Start of the UTC calendar month containing `nowMs`. */
|
|
118
|
+
export function monthStartMs(nowMs: number): number {
|
|
119
|
+
const d = new Date(nowMs);
|
|
120
|
+
return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The daily ceiling that spends what remains of a monthly target evenly over the days left (today included). */
|
|
124
|
+
export function monthPace(nowMs: number, perMonthUsd: number, spentUsd: number): { spentUsd: number; daysLeft: number; dailyCapUsd: number } {
|
|
125
|
+
const d = new Date(nowMs);
|
|
126
|
+
const daysInMonth = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 0)).getUTCDate();
|
|
127
|
+
const daysLeft = Math.max(1, daysInMonth - d.getUTCDate() + 1);
|
|
128
|
+
return { spentUsd, daysLeft, dailyCapUsd: Math.max(0, (perMonthUsd - spentUsd) / daysLeft) };
|
|
129
|
+
}
|
|
130
|
+
|
|
111
131
|
export function select(args: SelectArgs): Decision {
|
|
112
132
|
const { req, features, classification, profile, state, snapshot, ledger, cfg, nowMs } = args;
|
|
113
133
|
const reasons: string[] = [];
|
|
@@ -329,6 +349,14 @@ export function select(args: SelectArgs): Decision {
|
|
|
329
349
|
// The task type selects the quality axis and capability filters; the tier
|
|
330
350
|
// still bounds cost (task selects, tier budgets).
|
|
331
351
|
const warmSlug = cacheWarm ? state.cacheWarmSlug : null;
|
|
352
|
+
// Expected cache hit for a model: its observed rate once enough warm-expected
|
|
353
|
+
// samples exist (filters.cacheReliabilityMinSamples), else a reliable 1.
|
|
354
|
+
const cacheHitExpectation = (slug: string): { rate: number; measured: boolean; samples: number } => {
|
|
355
|
+
const min = cfg.filters.cacheReliabilityMinSamples;
|
|
356
|
+
const rel = min > 0 ? (ledger?.cacheReliability?.(slug) ?? null) : null;
|
|
357
|
+
if (rel === null || rel.samples < min) return { rate: 1, measured: false, samples: rel?.samples ?? 0 };
|
|
358
|
+
return { rate: rel.hitRate, measured: true, samples: rel.samples };
|
|
359
|
+
};
|
|
332
360
|
// Pre-fetch trust/latency signals for all candidate slugs in one batch
|
|
333
361
|
// query per signal kind, instead of per-model individual lookups.
|
|
334
362
|
const candidateSignals =
|
|
@@ -342,6 +370,11 @@ export function select(args: SelectArgs): Decision {
|
|
|
342
370
|
cfg.filters.escalationCostWeight > 0
|
|
343
371
|
? (ledger?.escalationCost?.(cfg.ledger.blendWindowDays)?.usdPerPromptToken ?? null)
|
|
344
372
|
: null;
|
|
373
|
+
// A pinned slug is admitted the way a config pin is: into the tier's pin
|
|
374
|
+
// list for this call only, so the quality floor cannot keep it out.
|
|
375
|
+
const pinSlug = args.forceSlug !== undefined && snapshot.models.some((m) => m.slug === args.forceSlug) ? args.forceSlug : undefined;
|
|
376
|
+
const buildCfg = pinSlug === undefined ? cfg : { ...cfg, tiers: { ...cfg.tiers, [effective]: { ...cfg.tiers[effective], pin: [...cfg.tiers[effective].pin, pinSlug] } } };
|
|
377
|
+
if (args.forceSlug !== undefined && pinSlug === undefined) reasons.push(`pin ${args.forceSlug} ignored: not in the catalog`);
|
|
345
378
|
const build = (t: Tier, relaxLevel = 0): { candidates: Candidate[]; rejected: Rejection[] } =>
|
|
346
379
|
buildCandidates({
|
|
347
380
|
req,
|
|
@@ -350,7 +383,7 @@ export function select(args: SelectArgs): Decision {
|
|
|
350
383
|
task: classification.task,
|
|
351
384
|
snapshot,
|
|
352
385
|
ledger,
|
|
353
|
-
cfg,
|
|
386
|
+
cfg: buildCfg,
|
|
354
387
|
expectedCompletionTokens: EXPECTED_COMPLETION_TOKENS,
|
|
355
388
|
warmSlug,
|
|
356
389
|
relaxLevel,
|
|
@@ -364,7 +397,9 @@ export function select(args: SelectArgs): Decision {
|
|
|
364
397
|
// search must rebuild at the same level, or it re-applies the strict config
|
|
365
398
|
let rescuedRelax = 0;
|
|
366
399
|
for (const t of wideningOrder(effective, profile.minTier, profile.maxTier)) {
|
|
367
|
-
|
|
400
|
+
// A session pin is absolute: price ceiling, quality floor and trust all
|
|
401
|
+
// relax so the pinned model is admitted; hard exclusions still apply.
|
|
402
|
+
const b = build(t, pinSlug === undefined ? 0 : 3);
|
|
368
403
|
if (b.candidates.length > 0) {
|
|
369
404
|
built = b;
|
|
370
405
|
chosenTier = t;
|
|
@@ -417,6 +452,11 @@ export function select(args: SelectArgs): Decision {
|
|
|
417
452
|
const first = candidates[0];
|
|
418
453
|
if (first === undefined) throw new Error(`no viable model: catalog exhausted across profile ${profile.id}`);
|
|
419
454
|
let chosen = first;
|
|
455
|
+
const pinnedCandidate = pinSlug === undefined ? undefined : candidates.find((c) => c.model.slug === pinSlug);
|
|
456
|
+
if (pinnedCandidate !== undefined) {
|
|
457
|
+
chosen = pinnedCandidate;
|
|
458
|
+
reasons.push(`pinned to ${pinSlug} by session override (/router pin)`);
|
|
459
|
+
}
|
|
420
460
|
|
|
421
461
|
// 4. Cache-aware switch decision. Staying prices the previous turn's prompt
|
|
422
462
|
// at the warm model's cache-read rate; switching prices the full current
|
|
@@ -424,14 +464,19 @@ export function select(args: SelectArgs): Decision {
|
|
|
424
464
|
// assume the whole prompt is written). Switch only when the saving
|
|
425
465
|
// clears switchMargin.
|
|
426
466
|
let sticky = false;
|
|
427
|
-
if (warmSlug !== null && chosen.model.slug !== warmSlug) {
|
|
467
|
+
if (pinnedCandidate === undefined && warmSlug !== null && chosen.model.slug !== warmSlug) {
|
|
428
468
|
const warm = candidates.find((c) => c.model.slug === warmSlug);
|
|
429
469
|
if (warm !== undefined) {
|
|
430
470
|
const warmPrice = priceAt(warm.model, Math.max(1, state.lastPromptTokens));
|
|
431
471
|
const newPrice = priceAt(chosen.model, Math.max(1, effFeatures.promptTokens));
|
|
432
|
-
|
|
472
|
+
// A warm read is only as cheap as the cache is reliable: price the
|
|
473
|
+
// expected mix of hits and provider-side misses, per model.
|
|
474
|
+
const warmHit = cacheHitExpectation(warm.model.slug);
|
|
475
|
+
const newHit = cacheHitExpectation(chosen.model.slug);
|
|
476
|
+
const stayWarm = state.lastPromptTokens * (warmHit.rate * (warmPrice.cacheRead ?? warmPrice.prompt) + (1 - warmHit.rate) * warmPrice.prompt);
|
|
433
477
|
const switchCold = effFeatures.promptTokens * (newPrice.prompt + (newPrice.cacheWrite ?? 0));
|
|
434
|
-
const newWarm = effFeatures.promptTokens * (newPrice.cacheRead ?? newPrice.prompt);
|
|
478
|
+
const newWarm = effFeatures.promptTokens * (newHit.rate * (newPrice.cacheRead ?? newPrice.prompt) + (1 - newHit.rate) * newPrice.prompt);
|
|
479
|
+
const hitNote = warmHit.measured ? `, warm hit ${(warmHit.rate * 100).toFixed(0)}% over ${warmHit.samples}` : "";
|
|
435
480
|
// Amortise over the horizon: H turns of staying warm against one cold
|
|
436
481
|
// switch plus H−1 turns warm on the new model. H = 1 is the one-turn
|
|
437
482
|
// comparison, which kept a 25x-priced model warm for a 33-dispatch run
|
|
@@ -442,13 +487,13 @@ export function select(args: SelectArgs): Decision {
|
|
|
442
487
|
const over = horizon > 1 ? ` over ${horizon} turns` : "";
|
|
443
488
|
if (stayCost > switchCost * cfg.hysteresis.switchMargin) {
|
|
444
489
|
reasons.push(
|
|
445
|
-
`cache: switch ${warmSlug} → ${chosen.model.slug} (stay $${stayCost.toFixed(4)} > switch $${switchCost.toFixed(4)} × ${cfg.hysteresis.switchMargin}${over})`,
|
|
490
|
+
`cache: switch ${warmSlug} → ${chosen.model.slug} (stay $${stayCost.toFixed(4)} > switch $${switchCost.toFixed(4)} × ${cfg.hysteresis.switchMargin}${over}${hitNote})`,
|
|
446
491
|
);
|
|
447
492
|
} else {
|
|
448
493
|
chosen = warm;
|
|
449
494
|
sticky = true;
|
|
450
495
|
reasons.push(
|
|
451
|
-
`cache: keeping warm ${warmSlug} (stay $${stayCost.toFixed(4)} ≤ switch $${switchCost.toFixed(4)} × ${cfg.hysteresis.switchMargin}${over})`,
|
|
496
|
+
`cache: keeping warm ${warmSlug} (stay $${stayCost.toFixed(4)} ≤ switch $${switchCost.toFixed(4)} × ${cfg.hysteresis.switchMargin}${over}${hitNote})`,
|
|
452
497
|
);
|
|
453
498
|
}
|
|
454
499
|
}
|
|
@@ -459,8 +504,19 @@ export function select(args: SelectArgs): Decision {
|
|
|
459
504
|
perTurnUsd: profile.budget?.perTurnUsd ?? cfg.budget.perTurnUsd,
|
|
460
505
|
perConversationUsd: profile.budget?.perConversationUsd ?? cfg.budget.perConversationUsd,
|
|
461
506
|
perDayUsd: profile.budget?.perDayUsd ?? cfg.budget.perDayUsd,
|
|
507
|
+
perMonthUsd: profile.budget?.perMonthUsd ?? cfg.budget.perMonthUsd,
|
|
462
508
|
onExceeded: profile.budget?.onExceeded ?? cfg.budget.onExceeded,
|
|
463
509
|
};
|
|
510
|
+
// Month pacing: what is left of the month's target, spread over the days
|
|
511
|
+
// left, becomes a daily ceiling that tightens as the month runs ahead.
|
|
512
|
+
let paceNote = "";
|
|
513
|
+
if (budget.perMonthUsd !== undefined) {
|
|
514
|
+
const pace = monthPace(nowMs, budget.perMonthUsd, ledger?.spendSince(monthStartMs(nowMs), req.harnessId) ?? 0);
|
|
515
|
+
if (budget.perDayUsd === undefined || pace.dailyCapUsd < budget.perDayUsd) {
|
|
516
|
+
budget.perDayUsd = pace.dailyCapUsd;
|
|
517
|
+
paceNote = ` (month pacing: $${pace.spentUsd.toFixed(2)} of $${budget.perMonthUsd} spent, $${pace.dailyCapUsd.toFixed(2)}/day for ${pace.daysLeft} more days)`;
|
|
518
|
+
}
|
|
519
|
+
}
|
|
464
520
|
const daySpend = budget.perDayUsd !== undefined ? (ledger?.spendSince(nowMs - DAY_MS, req.harnessId) ?? 0) : 0;
|
|
465
521
|
const breach = (c: Candidate): string | null => {
|
|
466
522
|
if (budget.perTurnUsd !== undefined && c.forecast.coldUsd > budget.perTurnUsd) {
|
|
@@ -474,7 +530,7 @@ export function select(args: SelectArgs): Decision {
|
|
|
474
530
|
// identifies itself, so multiple harnesses sharing one router each get
|
|
475
531
|
// their own daily budget instead of one exhausting it for the others.
|
|
476
532
|
if (daySpend + c.forecast.coldUsd > budget.perDayUsd) {
|
|
477
|
-
return `24h spend $${daySpend.toFixed(4)} + cold forecast > per-day budget $${budget.perDayUsd}`;
|
|
533
|
+
return `24h spend $${daySpend.toFixed(4)} + cold forecast > per-day budget $${budget.perDayUsd.toFixed(2)}${paceNote}`;
|
|
478
534
|
}
|
|
479
535
|
}
|
|
480
536
|
return null;
|
package/src/router/types.ts
CHANGED
|
@@ -90,6 +90,26 @@ export interface Features {
|
|
|
90
90
|
questionCount: number;
|
|
91
91
|
/** Newest user content is a single short imperative sentence. */
|
|
92
92
|
isTerseInstruction: boolean;
|
|
93
|
+
/**
|
|
94
|
+
* Where the prompt's bytes are. Recorded with every turn so the question
|
|
95
|
+
* "what is the prompt made of, and how old is it?" can be answered from the
|
|
96
|
+
* ledger before deciding what compaction should shrink next. Absent on rows
|
|
97
|
+
* recorded before it existed.
|
|
98
|
+
*/
|
|
99
|
+
anatomy?: PromptAnatomy;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Prompt bytes by message role and by age, plus the tool-schema bytes beside them. */
|
|
103
|
+
export interface PromptAnatomy {
|
|
104
|
+
messages: number;
|
|
105
|
+
systemBytes: number;
|
|
106
|
+
userBytes: number;
|
|
107
|
+
assistantBytes: number;
|
|
108
|
+
toolBytes: number;
|
|
109
|
+
/** Bytes of non-system messages in the OLDER half of the conversation (by message index). */
|
|
110
|
+
olderHalfBytes: number;
|
|
111
|
+
/** Bytes of tool results older than the newest 20 messages: what compaction can reach. */
|
|
112
|
+
staleToolBytes: number;
|
|
93
113
|
}
|
|
94
114
|
|
|
95
115
|
export type ClassificationSource = "heuristic" | "llm" | "sticky" | "forced" | "escalation";
|
|
@@ -105,6 +125,8 @@ export interface Classification {
|
|
|
105
125
|
reasons: string[];
|
|
106
126
|
/** Raw heuristic score before tier bucketing, 0-1. */
|
|
107
127
|
score: number;
|
|
128
|
+
/** Learned P(escalate) when `classifier.learnedModelPath` is set (advisory; see router/learned.ts). */
|
|
129
|
+
learnedRisk?: number;
|
|
108
130
|
}
|
|
109
131
|
|
|
110
132
|
/** A model that survived capability filtering, with its economics attached. */
|
|
@@ -303,6 +325,14 @@ export interface Router {
|
|
|
303
325
|
*/
|
|
304
326
|
route(
|
|
305
327
|
req: NormRequest,
|
|
306
|
-
opts: {
|
|
328
|
+
opts: {
|
|
329
|
+
attempt: number;
|
|
330
|
+
escalateFrom?: Tier;
|
|
331
|
+
excludeSlugs?: readonly string[];
|
|
332
|
+
/** Session override (/router tier): classify as this tier. */
|
|
333
|
+
forceTier?: Tier;
|
|
334
|
+
/** Session override (/router pin): route to this slug when it exists in the catalog. */
|
|
335
|
+
forceSlug?: string;
|
|
336
|
+
},
|
|
307
337
|
): Promise<Decision>;
|
|
308
338
|
}
|
package/src/server/http.ts
CHANGED
|
@@ -3,8 +3,11 @@ import { dirname } from "node:path";
|
|
|
3
3
|
import type { Server } from "bun";
|
|
4
4
|
import { createProviders } from "./providers.ts";
|
|
5
5
|
import { createBridgeFromConfig } from "../context/index.ts";
|
|
6
|
+
import { createFeedbackStore, type Verdict } from "../cost/feedback.ts";
|
|
6
7
|
import { createLedger } from "../cost/ledger.ts";
|
|
7
|
-
import {
|
|
8
|
+
import { createSessionOverrides } from "./overrides.ts";
|
|
9
|
+
import { TIER_ORDER, type Tier } from "../router/types.ts";
|
|
10
|
+
import { baselinePrices, buildUsageReport } from "../cost/report.ts";
|
|
8
11
|
import type { Ledger, ModelTrust } from "../cost/types.ts";
|
|
9
12
|
import { createRouter } from "../router/index.ts";
|
|
10
13
|
import { createConversationStore } from "../router/state.ts";
|
|
@@ -121,6 +124,18 @@ export function computeStats(ledger: Ledger, opts?: { windowDays?: number; nowMs
|
|
|
121
124
|
};
|
|
122
125
|
}
|
|
123
126
|
|
|
127
|
+
/** Days of included credits left at the last 7 days' burn (ledger, scaled by the calibration). */
|
|
128
|
+
export function ollamaRunway(
|
|
129
|
+
meter: { usedUsd: number; creditsUsd: number } | null,
|
|
130
|
+
ledgerUsd7d: number,
|
|
131
|
+
factor: number,
|
|
132
|
+
): { dailyBurnUsd: number; creditsLeftUsd: number; days: number | null } | null {
|
|
133
|
+
if (meter === null) return null;
|
|
134
|
+
const dailyBurnUsd = (ledgerUsd7d / 7) * factor;
|
|
135
|
+
const creditsLeftUsd = Math.max(0, meter.creditsUsd - meter.usedUsd);
|
|
136
|
+
return { dailyBurnUsd, creditsLeftUsd, days: dailyBurnUsd > 0 ? creditsLeftUsd / dailyBurnUsd : null };
|
|
137
|
+
}
|
|
138
|
+
|
|
124
139
|
function json(data: unknown, status = 200): Response {
|
|
125
140
|
return new Response(JSON.stringify(data), {
|
|
126
141
|
status,
|
|
@@ -173,11 +188,13 @@ export function startServer(cfg: RouterConfig): StartedServer {
|
|
|
173
188
|
if (cfg.ledger.path !== ":memory:") mkdirSync(dirname(cfg.ledger.path), { recursive: true });
|
|
174
189
|
const db = openDb(cfg.ledger.path);
|
|
175
190
|
const ledger = createLedger(db, cfg);
|
|
176
|
-
const { upstream, catalog, ollama, ollamaUsage } = createProviders(cfg, db, log);
|
|
191
|
+
const { upstream, catalog, ollama, ollamaUsage, ollamaCostScale } = createProviders(cfg, db, log);
|
|
177
192
|
const conversations = createConversationStore(db);
|
|
178
193
|
const router = createRouter({ config: cfg, catalog, ledger, conversations, upstream });
|
|
179
194
|
const context = createBridgeFromConfig(cfg, db);
|
|
180
|
-
const
|
|
195
|
+
const overrides = createSessionOverrides();
|
|
196
|
+
const feedback = createFeedbackStore(db);
|
|
197
|
+
const turnDeps = { config: cfg, router, upstream, ledger, conversations, catalog, context, overrides, ollamaCostScale };
|
|
181
198
|
|
|
182
199
|
// Hot reload: ranking knobs (tiers, filters, escalation, budgets, …) take
|
|
183
200
|
// effect on the next turn without a restart, because every consumer reads
|
|
@@ -377,13 +394,70 @@ export function startServer(cfg: RouterConfig): StartedServer {
|
|
|
377
394
|
const parsedDays = rawDays === null ? 7 : Number.parseInt(rawDays, 10);
|
|
378
395
|
const windowDays = Number.isInteger(parsedDays) ? Math.min(Math.max(parsedDays, 1), 365) : 7;
|
|
379
396
|
const harnessId = url.searchParams.get("harness") ?? "";
|
|
380
|
-
return json(buildUsageReport(db, { windowDays, harnessId }));
|
|
397
|
+
return json(buildUsageReport(db, { windowDays, harnessId, baselines: baselinePrices(cfg.report.baselines, (s) => catalog.find(s)) }));
|
|
381
398
|
}
|
|
382
399
|
if (req.method === "GET" && url.pathname === "/v1/router/decisions") {
|
|
383
400
|
const rawLimit = url.searchParams.get("limit");
|
|
384
401
|
const parsed = rawLimit === null ? 50 : Number.parseInt(rawLimit, 10);
|
|
385
402
|
const limit = Number.isInteger(parsed) ? Math.min(Math.max(parsed, 1), 1_000) : 50;
|
|
386
|
-
|
|
403
|
+
// ?session=<omp session id> narrows to one session (/router why).
|
|
404
|
+
const session = url.searchParams.get("session") ?? "";
|
|
405
|
+
const entries = session === "" ? ledger.recentEntries(limit) : (ledger.entriesForSession?.(session, limit) ?? []);
|
|
406
|
+
return json({ entries: entries.map((e) => ({ ...e, feedback: feedback.forLedgerId(e.id) })) });
|
|
407
|
+
}
|
|
408
|
+
if (url.pathname === "/v1/router/override") {
|
|
409
|
+
// Per-session pin / tier overrides from omp. GET shows, POST sets or clears.
|
|
410
|
+
if (req.method === "GET") {
|
|
411
|
+
const session = url.searchParams.get("session") ?? "";
|
|
412
|
+
return json(session === "" ? { overrides: overrides.list() } : { override: overrides.get(session) });
|
|
413
|
+
}
|
|
414
|
+
if (req.method === "POST") {
|
|
415
|
+
const body = (await req.json().catch(() => null)) as Record<string, unknown> | null;
|
|
416
|
+
const session = typeof body?.ompSessionId === "string" ? body.ompSessionId : "";
|
|
417
|
+
if (session === "") return wireErrorResponse({ status: 400, code: "invalid_request_error", message: "ompSessionId required" });
|
|
418
|
+
if (body?.clear === true) {
|
|
419
|
+
overrides.clear(session);
|
|
420
|
+
return json({ override: null });
|
|
421
|
+
}
|
|
422
|
+
const tier = body?.tier;
|
|
423
|
+
if (tier !== undefined && tier !== null && !(TIER_ORDER as readonly string[]).includes(String(tier))) {
|
|
424
|
+
return wireErrorResponse({ status: 400, code: "invalid_request_error", message: `unknown tier ${String(tier)}` });
|
|
425
|
+
}
|
|
426
|
+
const slug = body?.slug;
|
|
427
|
+
if (typeof slug === "string" && slug !== "" && catalog.find(slug) === undefined) {
|
|
428
|
+
return wireErrorResponse({ status: 404, code: "not_found", message: `no model ${slug} in the catalog` });
|
|
429
|
+
}
|
|
430
|
+
const turns = typeof body?.turns === "number" ? body.turns : undefined;
|
|
431
|
+
const set = overrides.set(session, {
|
|
432
|
+
...(tier === undefined ? {} : { tier: tier === null ? null : (String(tier) as Tier) }),
|
|
433
|
+
...(slug === undefined ? {} : { slug: typeof slug === "string" && slug !== "" ? slug : null }),
|
|
434
|
+
...(turns === undefined ? {} : { turns }),
|
|
435
|
+
});
|
|
436
|
+
return json({ override: set });
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
if (req.method === "POST" && url.pathname === "/v1/router/feedback") {
|
|
440
|
+
// A user verdict on the newest routed turn of an omp session.
|
|
441
|
+
const body = (await req.json().catch(() => null)) as Record<string, unknown> | null;
|
|
442
|
+
const session = typeof body?.ompSessionId === "string" ? body.ompSessionId : "";
|
|
443
|
+
const verdict = body?.verdict;
|
|
444
|
+
if (session === "" || (verdict !== "good" && verdict !== "bad")) {
|
|
445
|
+
return wireErrorResponse({ status: 400, code: "invalid_request_error", message: "ompSessionId and verdict (good|bad) required" });
|
|
446
|
+
}
|
|
447
|
+
const target =
|
|
448
|
+
typeof body?.ledgerId === "string"
|
|
449
|
+
? (ledger.recentEntries(1_000).find((e) => e.id === body.ledgerId) ?? null)
|
|
450
|
+
: (ledger.latestForSession?.(session) ?? null);
|
|
451
|
+
if (target === null) return wireErrorResponse({ status: 404, code: "not_found", message: "no routed turn for that session yet" });
|
|
452
|
+
const id = feedback.record({
|
|
453
|
+
ledgerId: target.id,
|
|
454
|
+
ompSessionId: session,
|
|
455
|
+
slug: target.servedSlug ?? target.slug,
|
|
456
|
+
tier: target.tier,
|
|
457
|
+
verdict: verdict as Verdict,
|
|
458
|
+
note: typeof body?.note === "string" ? body.note : "",
|
|
459
|
+
});
|
|
460
|
+
return json({ id, ledgerId: target.id, slug: target.servedSlug ?? target.slug, tier: target.tier, verdict });
|
|
387
461
|
}
|
|
388
462
|
if (req.method === "GET" && url.pathname === "/health") {
|
|
389
463
|
const snap = catalog.peek();
|
|
@@ -413,6 +487,9 @@ export function startServer(cfg: RouterConfig): StartedServer {
|
|
|
413
487
|
usage: ollamaUsage.peek(),
|
|
414
488
|
// The dashboard's dollar figure: plan share × included credits, when known.
|
|
415
489
|
meter: ollamaMeter(ollamaUsage.peek(), cfg.ollama.planCreditsUsd),
|
|
490
|
+
// Ledger vs meter, and how long the credits last at the recent burn.
|
|
491
|
+
calibration: ollamaUsage.calibration(),
|
|
492
|
+
runway: ollamaRunway(ollamaMeter(ollamaUsage.peek(), cfg.ollama.planCreditsUsd), ledger.providerSpendSince?.("ollama/", Date.now() - 7 * 86_400_000) ?? 0, ollamaUsage.calibration()?.factor ?? 1),
|
|
416
493
|
costBias: { configured: cfg.ollama.costBias, effective: catalog.ollamaBias?.() ?? cfg.ollama.costBias, biasUntilUsage: cfg.ollama.biasUntilUsage },
|
|
417
494
|
},
|
|
418
495
|
catalog: snap === null
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-session routing overrides set from omp (`/router pin`, `/router tier`).
|
|
3
|
+
*
|
|
4
|
+
* Keyed by the omp session id the embed extension sends as `X-Omp-Session`,
|
|
5
|
+
* so an override never leaks into another session sharing the router.
|
|
6
|
+
* Process-local on purpose: an override is a user's momentary intent, not
|
|
7
|
+
* state to survive a restart. `turns` counts down per committed dispatch;
|
|
8
|
+
* 0 means until cleared or the entry expires.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { Tier } from "../router/types.ts";
|
|
12
|
+
|
|
13
|
+
export interface SessionOverride {
|
|
14
|
+
/** Model to route every turn to, when set. */
|
|
15
|
+
slug: string | null;
|
|
16
|
+
/** Tier to classify every turn as, when set. */
|
|
17
|
+
tier: Tier | null;
|
|
18
|
+
/** Dispatches left; 0 ⇒ unlimited. */
|
|
19
|
+
turnsLeft: number;
|
|
20
|
+
setAtMs: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface SessionOverrides {
|
|
24
|
+
get(ompSessionId: string): SessionOverride | null;
|
|
25
|
+
set(ompSessionId: string, override: { slug?: string | null; tier?: Tier | null; turns?: number }, nowMs?: number): SessionOverride;
|
|
26
|
+
clear(ompSessionId: string): void;
|
|
27
|
+
/** A committed dispatch used one turn of the override. */
|
|
28
|
+
consume(ompSessionId: string): void;
|
|
29
|
+
/** Every live override, for status. */
|
|
30
|
+
list(): Array<{ ompSessionId: string } & SessionOverride>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Overrides older than this are forgotten: a session that idled a day is a new session. */
|
|
34
|
+
export const OVERRIDE_TTL_MS = 12 * 60 * 60 * 1000;
|
|
35
|
+
|
|
36
|
+
export function createSessionOverrides(): SessionOverrides {
|
|
37
|
+
const map = new Map<string, SessionOverride>();
|
|
38
|
+
const live = (id: string, nowMs: number): SessionOverride | null => {
|
|
39
|
+
const o = map.get(id);
|
|
40
|
+
if (o === undefined) return null;
|
|
41
|
+
if (nowMs - o.setAtMs > OVERRIDE_TTL_MS) {
|
|
42
|
+
map.delete(id);
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
return o;
|
|
46
|
+
};
|
|
47
|
+
return {
|
|
48
|
+
get(id) {
|
|
49
|
+
if (id === "") return null;
|
|
50
|
+
const o = live(id, Date.now());
|
|
51
|
+
return o !== null && (o.slug !== null || o.tier !== null) ? o : null;
|
|
52
|
+
},
|
|
53
|
+
set(id, over, nowMs = Date.now()) {
|
|
54
|
+
const prev = live(id, nowMs) ?? { slug: null, tier: null, turnsLeft: 0, setAtMs: nowMs };
|
|
55
|
+
const next: SessionOverride = {
|
|
56
|
+
slug: over.slug === undefined ? prev.slug : over.slug,
|
|
57
|
+
tier: over.tier === undefined ? prev.tier : over.tier,
|
|
58
|
+
turnsLeft: over.turns === undefined ? prev.turnsLeft : Math.max(0, Math.floor(over.turns)),
|
|
59
|
+
setAtMs: nowMs,
|
|
60
|
+
};
|
|
61
|
+
map.set(id, next);
|
|
62
|
+
return next;
|
|
63
|
+
},
|
|
64
|
+
clear(id) {
|
|
65
|
+
map.delete(id);
|
|
66
|
+
},
|
|
67
|
+
consume(id) {
|
|
68
|
+
const o = map.get(id);
|
|
69
|
+
if (o === undefined || o.turnsLeft === 0) return;
|
|
70
|
+
o.turnsLeft -= 1;
|
|
71
|
+
if (o.turnsLeft === 0) map.delete(id);
|
|
72
|
+
},
|
|
73
|
+
list() {
|
|
74
|
+
const now = Date.now();
|
|
75
|
+
const out: Array<{ ompSessionId: string } & SessionOverride> = [];
|
|
76
|
+
for (const [id] of map) {
|
|
77
|
+
const o = live(id, now);
|
|
78
|
+
if (o !== null) out.push({ ompSessionId: id, ...o });
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
package/src/server/providers.ts
CHANGED
|
@@ -12,6 +12,7 @@ import type { CatalogSource } from "../catalog/types.ts";
|
|
|
12
12
|
import type { RouterConfig } from "../config/types.ts";
|
|
13
13
|
import { createMultiUpstream } from "../upstream/multi.ts";
|
|
14
14
|
import { createOllamaClient, type OllamaClient } from "../upstream/ollama.ts";
|
|
15
|
+
import { createLedger } from "../cost/ledger.ts";
|
|
15
16
|
import { createOllamaUsageSource, NO_USAGE, type OllamaUsageSource } from "../upstream/ollama-usage.ts";
|
|
16
17
|
import { createOpenRouterClient } from "../upstream/openrouter.ts";
|
|
17
18
|
import type { UpstreamClient } from "../upstream/types.ts";
|
|
@@ -24,31 +25,38 @@ export interface Providers {
|
|
|
24
25
|
ollama: OllamaClient | null;
|
|
25
26
|
/** Plan usage reader; inert without a key. */
|
|
26
27
|
ollamaUsage: OllamaUsageSource;
|
|
28
|
+
/** Multiplier that brings the ledger's Ollama estimate in line with the plan meter; 1 until calibrated. */
|
|
29
|
+
ollamaCostScale: () => number;
|
|
27
30
|
}
|
|
28
31
|
|
|
29
32
|
export function createProviders(cfg: RouterConfig, db: Database, log: Logger = createLogger(cfg.logLevel)): Providers {
|
|
30
33
|
const openrouter = createOpenRouterClient(cfg);
|
|
31
34
|
const openrouterCatalog = createCatalog(cfg, openrouter, db);
|
|
32
|
-
if (!cfg.ollama.enabled) return { upstream: openrouter, catalog: openrouterCatalog, ollama: null, ollamaUsage: NO_USAGE };
|
|
35
|
+
if (!cfg.ollama.enabled) return { upstream: openrouter, catalog: openrouterCatalog, ollama: null, ollamaUsage: NO_USAGE, ollamaCostScale: () => 1 };
|
|
33
36
|
// Ollama Cloud is a second upstream ranked in the same catalog: `ollama/…`
|
|
34
37
|
// slugs dispatch to it, everything else to OpenRouter.
|
|
35
38
|
const ollama = createOllamaClient(cfg);
|
|
36
39
|
// Plan usage lives on ollama.com whichever base URL dispatches; it needs the
|
|
37
40
|
// key, so the daemon path without `/login ollama-cloud` keeps a static bias.
|
|
41
|
+
const ledgerForCalibration = createLedger(db, cfg);
|
|
38
42
|
const ollamaUsage = createOllamaUsageSource({
|
|
39
43
|
apiKey: cfg.ollama.apiKey,
|
|
40
44
|
pollMs: cfg.ollama.usagePollMs,
|
|
41
45
|
timeoutMs: Math.min(cfg.ollama.timeoutMs, 15_000),
|
|
42
46
|
log,
|
|
47
|
+
// Each poll records the meter beside the ledger's Ollama total, so the
|
|
48
|
+
// estimate can be scaled to what ollama.com actually bills.
|
|
49
|
+
calibration: { db, ledgerUsd: () => ledgerForCalibration.providerSpendSince?.("ollama/", 0) ?? 0, planCreditsOverrideUsd: cfg.ollama.planCreditsUsd },
|
|
43
50
|
});
|
|
44
51
|
return {
|
|
45
52
|
upstream: createMultiUpstream(openrouter, ollama),
|
|
46
|
-
catalog: createCompositeCatalog(openrouterCatalog, createOllamaCatalog(cfg.ollama, log), ollama, {
|
|
53
|
+
catalog: createCompositeCatalog(openrouterCatalog, createOllamaCatalog(cfg.ollama, log, fetch, db), ollama, {
|
|
47
54
|
costBias: cfg.ollama.costBias,
|
|
48
55
|
biasUntilUsage: cfg.ollama.biasUntilUsage,
|
|
49
56
|
usage: ollamaUsage,
|
|
50
57
|
}),
|
|
51
58
|
ollama,
|
|
52
59
|
ollamaUsage,
|
|
60
|
+
ollamaCostScale: () => ollamaUsage.calibration()?.factor ?? 1,
|
|
53
61
|
};
|
|
54
62
|
}
|