auto-model-router 0.3.3 → 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 +25 -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 +9 -0
- package/src/cli/report.ts +7 -2
- package/src/config/defaults.ts +12 -0
- package/src/config/schema.ts +6 -0
- package/src/config/types.ts +54 -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 +107 -8
- package/src/router/state.ts +6 -2
- package/src/router/types.ts +40 -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 +18 -1
- package/src/upstream/ollama-usage.ts +79 -2
- package/src/util/sqlite.ts +38 -1
- package/test/config-wizard.test.ts +2 -1
- package/test/controls.test.ts +223 -0
- package/test/failover.test.ts +5 -3
- 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 +180 -1
- package/test/trust-attribution.test.ts +58 -2
- package/test/turn.test.ts +35 -3
- 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
|
/**
|
|
@@ -102,6 +108,26 @@ function wideningOrder(tier: Tier, minTier: Tier, maxTier: Tier): Tier[] {
|
|
|
102
108
|
return out;
|
|
103
109
|
}
|
|
104
110
|
|
|
111
|
+
/** Evidence that an upgrade will stick: the conversation is failing or asked for deep reasoning. */
|
|
112
|
+
function hardSignal(f: Features): boolean {
|
|
113
|
+
const reasoning: string = f.requestedReasoning ?? "";
|
|
114
|
+
return f.lastToolFailed || f.circularToolCall || f.repeatedToolCall || reasoning === "high" || reasoning === "xhigh" || reasoning === "max";
|
|
115
|
+
}
|
|
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
|
+
|
|
105
131
|
export function select(args: SelectArgs): Decision {
|
|
106
132
|
const { req, features, classification, profile, state, snapshot, ledger, cfg, nowMs } = args;
|
|
107
133
|
const reasons: string[] = [];
|
|
@@ -161,6 +187,42 @@ export function select(args: SelectArgs): Decision {
|
|
|
161
187
|
// exploration (2c) and candidate building (3) so both agree on the term.
|
|
162
188
|
const cacheWarm = state.cacheWarmSlug !== null && nowMs - state.cacheWarmAtMs <= cfg.hysteresis.cacheWarmTtlMs;
|
|
163
189
|
|
|
190
|
+
// 2a. Cache-aware upgrade confirmation. A low-confidence heuristic upgrade
|
|
191
|
+
// from a warm model waits one turn; the next turn's classification
|
|
192
|
+
// confirms or forgets it. Measured on 7 days of live traffic: 65 of 67
|
|
193
|
+
// moderate→hard upgrades bounced back within 3 turns, 50 of them below
|
|
194
|
+
// 0.6 confidence, and each paid a cold hard-tier read of a ~120k prompt
|
|
195
|
+
// ($17.90 in total against $0.23 for staying warm). Step 4's stay/switch
|
|
196
|
+
// comparison never sees these — the warm cheap model is below the new
|
|
197
|
+
// tier's floor, so it is not a candidate there. Escalations, explicit
|
|
198
|
+
// high reasoning and failing tool loops bypass the wait: those are the
|
|
199
|
+
// upgrades that stick.
|
|
200
|
+
let upgradeDeferred: Tier | null = null;
|
|
201
|
+
const confirmBelow = cfg.hysteresis.confirmUpgradesBelowConfidence;
|
|
202
|
+
if (
|
|
203
|
+
confirmBelow > 0 &&
|
|
204
|
+
cls.source === "heuristic" &&
|
|
205
|
+
classification.confidence < confirmBelow &&
|
|
206
|
+
state.currentTier !== null &&
|
|
207
|
+
state.currentSlug !== null &&
|
|
208
|
+
tierIdx(effective) > tierIdx(clampTier(state.currentTier)) &&
|
|
209
|
+
cacheWarm &&
|
|
210
|
+
state.cacheWarmSlug === state.currentSlug &&
|
|
211
|
+
(args.excludeSlugs === undefined || args.excludeSlugs.length === 0) &&
|
|
212
|
+
!hardSignal(features)
|
|
213
|
+
) {
|
|
214
|
+
const held = clampTier(state.currentTier);
|
|
215
|
+
if (state.upgradeDeferredTier !== undefined && state.upgradeDeferredTier !== null) {
|
|
216
|
+
reasons.push(`upgrade ${held} → ${effective} confirmed: classified above ${held} on consecutive turns`);
|
|
217
|
+
} else {
|
|
218
|
+
reasons.push(
|
|
219
|
+
`upgrade ${held} → ${effective} deferred one turn: heuristic confidence ${classification.confidence.toFixed(2)} < ${confirmBelow} with ${state.currentSlug} warm`,
|
|
220
|
+
);
|
|
221
|
+
upgradeDeferred = effective;
|
|
222
|
+
effective = held;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
164
226
|
// 2b. Context compaction: shrink stale tool output before dispatch when the
|
|
165
227
|
// prompt exceeds the token budget (or would overflow the profile window).
|
|
166
228
|
// Deterministic and content-only (never removes a message), so downstream
|
|
@@ -287,6 +349,14 @@ export function select(args: SelectArgs): Decision {
|
|
|
287
349
|
// The task type selects the quality axis and capability filters; the tier
|
|
288
350
|
// still bounds cost (task selects, tier budgets).
|
|
289
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
|
+
};
|
|
290
360
|
// Pre-fetch trust/latency signals for all candidate slugs in one batch
|
|
291
361
|
// query per signal kind, instead of per-model individual lookups.
|
|
292
362
|
const candidateSignals =
|
|
@@ -300,6 +370,11 @@ export function select(args: SelectArgs): Decision {
|
|
|
300
370
|
cfg.filters.escalationCostWeight > 0
|
|
301
371
|
? (ledger?.escalationCost?.(cfg.ledger.blendWindowDays)?.usdPerPromptToken ?? null)
|
|
302
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`);
|
|
303
378
|
const build = (t: Tier, relaxLevel = 0): { candidates: Candidate[]; rejected: Rejection[] } =>
|
|
304
379
|
buildCandidates({
|
|
305
380
|
req,
|
|
@@ -308,7 +383,7 @@ export function select(args: SelectArgs): Decision {
|
|
|
308
383
|
task: classification.task,
|
|
309
384
|
snapshot,
|
|
310
385
|
ledger,
|
|
311
|
-
cfg,
|
|
386
|
+
cfg: buildCfg,
|
|
312
387
|
expectedCompletionTokens: EXPECTED_COMPLETION_TOKENS,
|
|
313
388
|
warmSlug,
|
|
314
389
|
relaxLevel,
|
|
@@ -322,7 +397,9 @@ export function select(args: SelectArgs): Decision {
|
|
|
322
397
|
// search must rebuild at the same level, or it re-applies the strict config
|
|
323
398
|
let rescuedRelax = 0;
|
|
324
399
|
for (const t of wideningOrder(effective, profile.minTier, profile.maxTier)) {
|
|
325
|
-
|
|
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);
|
|
326
403
|
if (b.candidates.length > 0) {
|
|
327
404
|
built = b;
|
|
328
405
|
chosenTier = t;
|
|
@@ -375,6 +452,11 @@ export function select(args: SelectArgs): Decision {
|
|
|
375
452
|
const first = candidates[0];
|
|
376
453
|
if (first === undefined) throw new Error(`no viable model: catalog exhausted across profile ${profile.id}`);
|
|
377
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
|
+
}
|
|
378
460
|
|
|
379
461
|
// 4. Cache-aware switch decision. Staying prices the previous turn's prompt
|
|
380
462
|
// at the warm model's cache-read rate; switching prices the full current
|
|
@@ -382,14 +464,19 @@ export function select(args: SelectArgs): Decision {
|
|
|
382
464
|
// assume the whole prompt is written). Switch only when the saving
|
|
383
465
|
// clears switchMargin.
|
|
384
466
|
let sticky = false;
|
|
385
|
-
if (warmSlug !== null && chosen.model.slug !== warmSlug) {
|
|
467
|
+
if (pinnedCandidate === undefined && warmSlug !== null && chosen.model.slug !== warmSlug) {
|
|
386
468
|
const warm = candidates.find((c) => c.model.slug === warmSlug);
|
|
387
469
|
if (warm !== undefined) {
|
|
388
470
|
const warmPrice = priceAt(warm.model, Math.max(1, state.lastPromptTokens));
|
|
389
471
|
const newPrice = priceAt(chosen.model, Math.max(1, effFeatures.promptTokens));
|
|
390
|
-
|
|
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);
|
|
391
477
|
const switchCold = effFeatures.promptTokens * (newPrice.prompt + (newPrice.cacheWrite ?? 0));
|
|
392
|
-
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}` : "";
|
|
393
480
|
// Amortise over the horizon: H turns of staying warm against one cold
|
|
394
481
|
// switch plus H−1 turns warm on the new model. H = 1 is the one-turn
|
|
395
482
|
// comparison, which kept a 25x-priced model warm for a 33-dispatch run
|
|
@@ -400,13 +487,13 @@ export function select(args: SelectArgs): Decision {
|
|
|
400
487
|
const over = horizon > 1 ? ` over ${horizon} turns` : "";
|
|
401
488
|
if (stayCost > switchCost * cfg.hysteresis.switchMargin) {
|
|
402
489
|
reasons.push(
|
|
403
|
-
`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})`,
|
|
404
491
|
);
|
|
405
492
|
} else {
|
|
406
493
|
chosen = warm;
|
|
407
494
|
sticky = true;
|
|
408
495
|
reasons.push(
|
|
409
|
-
`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})`,
|
|
410
497
|
);
|
|
411
498
|
}
|
|
412
499
|
}
|
|
@@ -417,8 +504,19 @@ export function select(args: SelectArgs): Decision {
|
|
|
417
504
|
perTurnUsd: profile.budget?.perTurnUsd ?? cfg.budget.perTurnUsd,
|
|
418
505
|
perConversationUsd: profile.budget?.perConversationUsd ?? cfg.budget.perConversationUsd,
|
|
419
506
|
perDayUsd: profile.budget?.perDayUsd ?? cfg.budget.perDayUsd,
|
|
507
|
+
perMonthUsd: profile.budget?.perMonthUsd ?? cfg.budget.perMonthUsd,
|
|
420
508
|
onExceeded: profile.budget?.onExceeded ?? cfg.budget.onExceeded,
|
|
421
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
|
+
}
|
|
422
520
|
const daySpend = budget.perDayUsd !== undefined ? (ledger?.spendSince(nowMs - DAY_MS, req.harnessId) ?? 0) : 0;
|
|
423
521
|
const breach = (c: Candidate): string | null => {
|
|
424
522
|
if (budget.perTurnUsd !== undefined && c.forecast.coldUsd > budget.perTurnUsd) {
|
|
@@ -432,7 +530,7 @@ export function select(args: SelectArgs): Decision {
|
|
|
432
530
|
// identifies itself, so multiple harnesses sharing one router each get
|
|
433
531
|
// their own daily budget instead of one exhausting it for the others.
|
|
434
532
|
if (daySpend + c.forecast.coldUsd > budget.perDayUsd) {
|
|
435
|
-
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}`;
|
|
436
534
|
}
|
|
437
535
|
}
|
|
438
536
|
return null;
|
|
@@ -531,5 +629,6 @@ export function select(args: SelectArgs): Decision {
|
|
|
531
629
|
reasons,
|
|
532
630
|
explored,
|
|
533
631
|
budgetDowngraded,
|
|
632
|
+
upgradeDeferred,
|
|
534
633
|
};
|
|
535
634
|
}
|
package/src/router/state.ts
CHANGED
|
@@ -32,6 +32,7 @@ interface Row {
|
|
|
32
32
|
context_fetched_at_ms: number;
|
|
33
33
|
compaction_plan: string | null;
|
|
34
34
|
compaction_plan_tokens: number;
|
|
35
|
+
upgrade_deferred_tier: string | null;
|
|
35
36
|
updated_at_ms: number;
|
|
36
37
|
}
|
|
37
38
|
|
|
@@ -53,6 +54,7 @@ function toState(row: Row): ConversationState {
|
|
|
53
54
|
contextFetchedAtMs: row.context_fetched_at_ms,
|
|
54
55
|
compactionPlan: row.compaction_plan === null ? null : (JSON.parse(row.compaction_plan) as CompactionEdit[]),
|
|
55
56
|
compactionPlanTokens: row.compaction_plan_tokens,
|
|
57
|
+
upgradeDeferredTier: row.upgrade_deferred_tier as Tier | null,
|
|
56
58
|
updatedAtMs: row.updated_at_ms,
|
|
57
59
|
};
|
|
58
60
|
}
|
|
@@ -71,10 +73,10 @@ export function createConversationStore(db: Database): ConversationStore {
|
|
|
71
73
|
INSERT INTO conversations (
|
|
72
74
|
key, session_id, turn, current_slug, current_tier, sticky_until_turn,
|
|
73
75
|
last_prompt_tokens, cache_warm_slug, cache_warm_at_ms,
|
|
74
|
-
context_version, context_fetched_at_ms, compaction_plan, compaction_plan_tokens, updated_at_ms
|
|
76
|
+
context_version, context_fetched_at_ms, compaction_plan, compaction_plan_tokens, upgrade_deferred_tier, updated_at_ms
|
|
75
77
|
) VALUES ($key, $sessionId, $turn, $currentSlug, $currentTier, $stickyUntilTurn,
|
|
76
78
|
$lastPromptTokens, $cacheWarmSlug, $cacheWarmAtMs,
|
|
77
|
-
$contextVersion, $contextFetchedAtMs, $compactionPlan, $compactionPlanTokens, $updatedAtMs)
|
|
79
|
+
$contextVersion, $contextFetchedAtMs, $compactionPlan, $compactionPlanTokens, $upgradeDeferredTier, $updatedAtMs)
|
|
78
80
|
ON CONFLICT(key) DO UPDATE SET
|
|
79
81
|
session_id = excluded.session_id,
|
|
80
82
|
turn = excluded.turn,
|
|
@@ -88,6 +90,7 @@ export function createConversationStore(db: Database): ConversationStore {
|
|
|
88
90
|
context_fetched_at_ms = excluded.context_fetched_at_ms,
|
|
89
91
|
compaction_plan = excluded.compaction_plan,
|
|
90
92
|
compaction_plan_tokens = excluded.compaction_plan_tokens,
|
|
93
|
+
upgrade_deferred_tier = excluded.upgrade_deferred_tier,
|
|
91
94
|
updated_at_ms = excluded.updated_at_ms
|
|
92
95
|
`);
|
|
93
96
|
// Read-modify-write in JS lost money: an aborted or failed dispatch is still
|
|
@@ -137,6 +140,7 @@ export function createConversationStore(db: Database): ConversationStore {
|
|
|
137
140
|
$lastPromptTokens: state.lastPromptTokens,
|
|
138
141
|
$compactionPlan: state.compactionPlan === null ? null : JSON.stringify(state.compactionPlan),
|
|
139
142
|
$compactionPlanTokens: state.compactionPlanTokens ?? 0,
|
|
143
|
+
$upgradeDeferredTier: state.upgradeDeferredTier ?? null,
|
|
140
144
|
$cacheWarmSlug: state.cacheWarmSlug,
|
|
141
145
|
$cacheWarmAtMs: state.cacheWarmAtMs,
|
|
142
146
|
$contextVersion: state.contextVersion,
|
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. */
|
|
@@ -186,6 +208,13 @@ export interface ConversationState {
|
|
|
186
208
|
compactionPlanTokens?: number;
|
|
187
209
|
/** When that block was fetched, for the staleness TTL. */
|
|
188
210
|
contextFetchedAtMs: number;
|
|
211
|
+
/**
|
|
212
|
+
* The tier a low-confidence upgrade was deferred to on the previous turn
|
|
213
|
+
* (`hysteresis.confirmUpgradesBelowConfidence`), or null. One-turn memory:
|
|
214
|
+
* every turn overwrites it, so a second consecutive upgrade classification
|
|
215
|
+
* confirms the switch and anything else forgets it.
|
|
216
|
+
*/
|
|
217
|
+
upgradeDeferredTier?: Tier | null;
|
|
189
218
|
updatedAtMs: number;
|
|
190
219
|
}
|
|
191
220
|
|
|
@@ -270,6 +299,8 @@ export interface Decision {
|
|
|
270
299
|
explored: Exploration | null;
|
|
271
300
|
/** Budget guard forced a cheaper tier than the classifier asked for. */
|
|
272
301
|
budgetDowngraded: boolean;
|
|
302
|
+
/** A low-confidence upgrade to this tier was deferred one turn to keep the warm model. */
|
|
303
|
+
upgradeDeferred: Tier | null;
|
|
273
304
|
}
|
|
274
305
|
|
|
275
306
|
/** Why a guarded probe rejected an attempt. */
|
|
@@ -294,6 +325,14 @@ export interface Router {
|
|
|
294
325
|
*/
|
|
295
326
|
route(
|
|
296
327
|
req: NormRequest,
|
|
297
|
-
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
|
+
},
|
|
298
337
|
): Promise<Decision>;
|
|
299
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
|