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
package/src/server/turn.ts
CHANGED
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
type Tier,
|
|
28
28
|
} from "../router/types.ts";
|
|
29
29
|
import { UpstreamError, type Dispatch, type UpstreamClient } from "../upstream/types.ts";
|
|
30
|
+
import type { SessionOverrides } from "./overrides.ts";
|
|
30
31
|
import { createLogger } from "../util/log.ts";
|
|
31
32
|
import type { NormRequest, ResponseSink, TurnSummary, UpstreamChunk } from "../wire/types.ts";
|
|
32
33
|
|
|
@@ -66,6 +67,10 @@ export interface TurnDeps {
|
|
|
66
67
|
catalog: CatalogSource;
|
|
67
68
|
/** agentdox bridge. The disabled bridge makes every call here a no-op. */
|
|
68
69
|
context: ContextBridge;
|
|
70
|
+
/** Per-session pin/tier overrides from omp. Absent ⇒ none. */
|
|
71
|
+
overrides?: SessionOverrides;
|
|
72
|
+
/** Ledger-vs-meter calibration for Ollama's estimated costs; absent ⇒ 1. */
|
|
73
|
+
ollamaCostScale?: () => number;
|
|
69
74
|
}
|
|
70
75
|
|
|
71
76
|
/** A dead client connection surfaces as the sink throwing mid-stream. */
|
|
@@ -117,6 +122,9 @@ export async function runTurn(
|
|
|
117
122
|
else triggers.delete("length_stop");
|
|
118
123
|
|
|
119
124
|
const maxAttempts = Math.max(1, config.escalation.maxAttempts);
|
|
125
|
+
// A session override applies to the first attempt only: an escalation or
|
|
126
|
+
// failover after it is the router's business, not the pin's.
|
|
127
|
+
const override = deps.overrides?.get(req.ompSessionId) ?? null;
|
|
120
128
|
let escalateFrom: Tier | undefined;
|
|
121
129
|
let escalations = 0;
|
|
122
130
|
// Slugs that returned a retryable upstream error on THIS turn, fed back
|
|
@@ -138,9 +146,13 @@ export async function runTurn(
|
|
|
138
146
|
pendingDecision = null;
|
|
139
147
|
} else {
|
|
140
148
|
try {
|
|
141
|
-
const opts: { attempt: number; escalateFrom?: Tier; excludeSlugs?: readonly string[] } = { attempt };
|
|
149
|
+
const opts: { attempt: number; escalateFrom?: Tier; excludeSlugs?: readonly string[]; forceTier?: Tier; forceSlug?: string } = { attempt };
|
|
142
150
|
if (escalateFrom !== undefined) opts.escalateFrom = escalateFrom;
|
|
143
151
|
if (failedSlugs.length > 0) opts.excludeSlugs = failedSlugs;
|
|
152
|
+
if (override !== null && attempt === 0) {
|
|
153
|
+
if (override.tier !== null) opts.forceTier = override.tier;
|
|
154
|
+
if (override.slug !== null) opts.forceSlug = override.slug;
|
|
155
|
+
}
|
|
144
156
|
decision = await router.route(req, opts);
|
|
145
157
|
} catch (err) {
|
|
146
158
|
await sink.error({ status: 500, code: "router_error", message: err instanceof Error ? err.message : String(err) });
|
|
@@ -480,6 +492,8 @@ export async function runTurn(
|
|
|
480
492
|
});
|
|
481
493
|
}
|
|
482
494
|
reportedUsd = computeCost(served, usage).total;
|
|
495
|
+
// Scale the estimate to what the plan meter has been billing for it.
|
|
496
|
+
if (served.provider === "ollama") reportedUsd *= deps.ollamaCostScale?.() ?? 1;
|
|
483
497
|
}
|
|
484
498
|
}
|
|
485
499
|
|
|
@@ -538,6 +552,7 @@ export async function runTurn(
|
|
|
538
552
|
|
|
539
553
|
state.turn = turnNumber;
|
|
540
554
|
state.currentSlug = servedSlug ?? decision.slug;
|
|
555
|
+
if (override !== null) deps.overrides?.consume(req.ompSessionId);
|
|
541
556
|
// Capture the previously-served tier BEFORE overwriting it, so the
|
|
542
557
|
// hysteresis re-arm below can tell whether this turn changed tier.
|
|
543
558
|
const prevTier = state.currentTier;
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
* percents) rather than 100%, which errs toward keeping the bias on.
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
|
+
import type { Database } from "bun:sqlite";
|
|
27
28
|
import type { Logger } from "../util/log.ts";
|
|
28
29
|
|
|
29
30
|
export interface OllamaUsage {
|
|
@@ -96,20 +97,94 @@ export function parseOllamaUsage(json: unknown, nowMs = Date.now()): OllamaUsage
|
|
|
96
97
|
|
|
97
98
|
export type FetchLike = (input: string, init?: RequestInit) => Promise<Response>;
|
|
98
99
|
|
|
100
|
+
/** One reading of the plan meter beside the ledger's own Ollama total. */
|
|
101
|
+
export interface MeterSample {
|
|
102
|
+
atMs: number;
|
|
103
|
+
meterUsd: number;
|
|
104
|
+
ledgerUsd: number;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** How the ledger's Ollama estimate compares with what ollama.com metered over the same span. */
|
|
108
|
+
export interface OllamaCalibration {
|
|
109
|
+
/** metered ÷ ledger over the span, clamped to [0.5, 2]. Multiply estimates by it. */
|
|
110
|
+
factor: number;
|
|
111
|
+
meterDeltaUsd: number;
|
|
112
|
+
ledgerDeltaUsd: number;
|
|
113
|
+
spanHours: number;
|
|
114
|
+
samples: number;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Bounds on the span a calibration may rest on: the meter moves in $0.06 steps on Pro, so a tiny span is noise. */
|
|
118
|
+
const CALIBRATION_MIN_LEDGER_USD = 0.5;
|
|
119
|
+
const CALIBRATION_MIN_METER_USD = 0.06;
|
|
120
|
+
export const CALIBRATION_MIN_FACTOR = 0.5;
|
|
121
|
+
export const CALIBRATION_MAX_FACTOR = 2;
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The calibration from samples ordered oldest→newest. Walks back from the
|
|
125
|
+
* newest reading while the meter is non-decreasing (a drop is a billing-cycle
|
|
126
|
+
* reset), then compares the two ends. Null until the span carries enough
|
|
127
|
+
* spend to mean anything.
|
|
128
|
+
*/
|
|
129
|
+
export function calibrationFrom(samples: readonly MeterSample[]): OllamaCalibration | null {
|
|
130
|
+
if (samples.length < 2) return null;
|
|
131
|
+
const newest = samples[samples.length - 1]!;
|
|
132
|
+
let oldest = newest;
|
|
133
|
+
let n = 1;
|
|
134
|
+
for (let i = samples.length - 2; i >= 0; i--) {
|
|
135
|
+
const s = samples[i]!;
|
|
136
|
+
if (s.meterUsd > oldest.meterUsd || s.ledgerUsd > oldest.ledgerUsd) break; // reset (meter) or a ledger purge
|
|
137
|
+
oldest = s;
|
|
138
|
+
n++;
|
|
139
|
+
}
|
|
140
|
+
const meterDeltaUsd = newest.meterUsd - oldest.meterUsd;
|
|
141
|
+
const ledgerDeltaUsd = newest.ledgerUsd - oldest.ledgerUsd;
|
|
142
|
+
if (ledgerDeltaUsd < CALIBRATION_MIN_LEDGER_USD || meterDeltaUsd < CALIBRATION_MIN_METER_USD) return null;
|
|
143
|
+
const factor = Math.min(CALIBRATION_MAX_FACTOR, Math.max(CALIBRATION_MIN_FACTOR, meterDeltaUsd / ledgerDeltaUsd));
|
|
144
|
+
return { factor, meterDeltaUsd, ledgerDeltaUsd, spanHours: (newest.atMs - oldest.atMs) / 3_600_000, samples: n };
|
|
145
|
+
}
|
|
146
|
+
|
|
99
147
|
export interface OllamaUsageSource {
|
|
100
148
|
/** Latest usage, refreshed when older than the poll interval; last good value on failure. */
|
|
101
149
|
get(): Promise<OllamaUsage | null>;
|
|
102
150
|
/** Last fetched value without touching the network. */
|
|
103
151
|
peek(): OllamaUsage | null;
|
|
152
|
+
/** Ledger-vs-meter calibration, when enough metered spend has accrued. */
|
|
153
|
+
calibration(): OllamaCalibration | null;
|
|
104
154
|
}
|
|
105
155
|
|
|
106
156
|
/** Inert source for setups with no key (the daemon path without `/login ollama-cloud`). */
|
|
107
|
-
export const NO_USAGE: OllamaUsageSource = { get: async () => null, peek: () => null };
|
|
157
|
+
export const NO_USAGE: OllamaUsageSource = { get: async () => null, peek: () => null, calibration: () => null };
|
|
158
|
+
|
|
159
|
+
/** Where calibration samples come from and go: the ledger's Ollama total and the plan's credits. */
|
|
160
|
+
export interface CalibrationDeps {
|
|
161
|
+
db: Database;
|
|
162
|
+
/** The ledger's all-time Ollama spend, read at sampling time. */
|
|
163
|
+
ledgerUsd(): number;
|
|
164
|
+
/** Configured credit override (0 = detect from the plan). */
|
|
165
|
+
planCreditsOverrideUsd: number;
|
|
166
|
+
}
|
|
108
167
|
|
|
109
168
|
export function createOllamaUsageSource(
|
|
110
|
-
opts: { apiKey: string; pollMs: number; timeoutMs: number; log: Logger; fetchImpl?: FetchLike; root?: string },
|
|
169
|
+
opts: { apiKey: string; pollMs: number; timeoutMs: number; log: Logger; fetchImpl?: FetchLike; root?: string; calibration?: CalibrationDeps },
|
|
111
170
|
): OllamaUsageSource {
|
|
112
171
|
if (opts.apiKey === "" || opts.pollMs <= 0) return NO_USAGE;
|
|
172
|
+
const cal = opts.calibration;
|
|
173
|
+
const insertSample = cal === undefined ? null : cal.db.query("INSERT OR REPLACE INTO ollama_meter_samples (at_ms, meter_usd, ledger_usd) VALUES (?, ?, ?)");
|
|
174
|
+
const readSamples = cal === undefined ? null : cal.db.query("SELECT at_ms, meter_usd, ledger_usd FROM ollama_meter_samples WHERE at_ms >= ? ORDER BY at_ms ASC");
|
|
175
|
+
let calibrationMemo: OllamaCalibration | null = null;
|
|
176
|
+
function sample(usage: OllamaUsage): void {
|
|
177
|
+
if (cal === null || cal === undefined || insertSample === null || readSamples === null) return;
|
|
178
|
+
const credits = ollamaPlanCredits(usage, cal.planCreditsOverrideUsd);
|
|
179
|
+
if (credits === null || usage.monthlyUsedFraction === null) return;
|
|
180
|
+
try {
|
|
181
|
+
insertSample.run(usage.fetchedAtMs, usage.monthlyUsedFraction * credits, cal.ledgerUsd());
|
|
182
|
+
const rows = readSamples.all(usage.fetchedAtMs - 30 * 86_400_000) as { at_ms: number; meter_usd: number; ledger_usd: number }[];
|
|
183
|
+
calibrationMemo = calibrationFrom(rows.map((r) => ({ atMs: r.at_ms, meterUsd: r.meter_usd, ledgerUsd: r.ledger_usd })));
|
|
184
|
+
} catch (err) {
|
|
185
|
+
opts.log.debug("ollama calibration sample failed", { error: err instanceof Error ? err.message : String(err) });
|
|
186
|
+
}
|
|
187
|
+
}
|
|
113
188
|
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
114
189
|
const root = (opts.root ?? "https://ollama.com").replace(/\/+$/, "");
|
|
115
190
|
let current: OllamaUsage | null = null;
|
|
@@ -155,6 +230,7 @@ export function createOllamaUsageSource(
|
|
|
155
230
|
if (parsed !== null) {
|
|
156
231
|
current = { ...parsed, plan };
|
|
157
232
|
warned = false;
|
|
233
|
+
sample(current);
|
|
158
234
|
} else if (!warned) {
|
|
159
235
|
warned = true;
|
|
160
236
|
opts.log.warn("ollama usage payload had no recognisable fields; credit-aware bias stays on its last reading");
|
|
@@ -184,6 +260,7 @@ export function createOllamaUsageSource(
|
|
|
184
260
|
return inflight;
|
|
185
261
|
},
|
|
186
262
|
peek: () => current,
|
|
263
|
+
calibration: () => calibrationMemo,
|
|
187
264
|
};
|
|
188
265
|
}
|
|
189
266
|
|
package/src/util/sqlite.ts
CHANGED
|
@@ -29,6 +29,14 @@ CREATE TABLE IF NOT EXISTS catalog_cache (
|
|
|
29
29
|
key_scoped INTEGER NOT NULL DEFAULT 0
|
|
30
30
|
);
|
|
31
31
|
|
|
32
|
+
-- Last built Ollama Cloud model set, so a restart routes from disk and
|
|
33
|
+
-- offline tools (tools/replay.ts) see the same composite catalog the router did.
|
|
34
|
+
CREATE TABLE IF NOT EXISTS ollama_catalog_cache (
|
|
35
|
+
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
36
|
+
payload TEXT NOT NULL,
|
|
37
|
+
fetched_at_ms INTEGER NOT NULL
|
|
38
|
+
);
|
|
39
|
+
|
|
32
40
|
CREATE TABLE IF NOT EXISTS benchmark_cache (
|
|
33
41
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
34
42
|
payload TEXT NOT NULL,
|
|
@@ -111,6 +119,28 @@ CREATE TABLE IF NOT EXISTS context_blocks (
|
|
|
111
119
|
fetched_at_ms INTEGER NOT NULL
|
|
112
120
|
);
|
|
113
121
|
|
|
122
|
+
-- ollama.com plan-meter readings beside the ledger's own Ollama total at the
|
|
123
|
+
-- same instant, so the ledger's estimate can be calibrated against the bill.
|
|
124
|
+
CREATE TABLE IF NOT EXISTS ollama_meter_samples (
|
|
125
|
+
at_ms INTEGER PRIMARY KEY,
|
|
126
|
+
meter_usd REAL NOT NULL,
|
|
127
|
+
ledger_usd REAL NOT NULL
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
-- User verdicts on routed turns (/router feedback), tied to the ledger row judged.
|
|
131
|
+
CREATE TABLE IF NOT EXISTS feedback (
|
|
132
|
+
id TEXT PRIMARY KEY,
|
|
133
|
+
ledger_id TEXT NOT NULL,
|
|
134
|
+
omp_session_id TEXT NOT NULL DEFAULT '',
|
|
135
|
+
slug TEXT NOT NULL,
|
|
136
|
+
tier TEXT NOT NULL,
|
|
137
|
+
verdict TEXT NOT NULL,
|
|
138
|
+
note TEXT NOT NULL DEFAULT '',
|
|
139
|
+
created_at_ms INTEGER NOT NULL
|
|
140
|
+
);
|
|
141
|
+
CREATE INDEX IF NOT EXISTS idx_feedback_created ON feedback (created_at_ms);
|
|
142
|
+
CREATE INDEX IF NOT EXISTS idx_feedback_ledger ON feedback (ledger_id);
|
|
143
|
+
|
|
114
144
|
CREATE TABLE IF NOT EXISTS agentdox_sessions (
|
|
115
145
|
conversation_key TEXT PRIMARY KEY,
|
|
116
146
|
scope TEXT NOT NULL,
|
|
@@ -166,7 +166,7 @@ describe("WIZARD_SECTIONS coverage", () => {
|
|
|
166
166
|
// they are listed here so a typo in a new field path fails loudly.
|
|
167
167
|
const KNOWN_OPTIONAL = new Set([
|
|
168
168
|
"server.apiKey", "server.harnessId", "openrouter.referer",
|
|
169
|
-
"budget.perTurnUsd", "budget.perConversationUsd", "budget.perDayUsd", "filters.maxExpectedWaitMs",
|
|
169
|
+
"budget.perTurnUsd", "budget.perConversationUsd", "budget.perDayUsd", "budget.perMonthUsd", "filters.maxExpectedWaitMs", "filters.latencyWeightContinuation",
|
|
170
170
|
...["trivial", "simple", "moderate", "hard"].flatMap((t) => [
|
|
171
171
|
`tiers.${t}.maxInputPerMtok`, `tiers.${t}.maxOutputPerMtok`, `tiers.${t}.qualityNormalization`, `tiers.${t}.capabilityFloorUsd`,
|
|
172
172
|
]),
|
|
@@ -303,6 +303,7 @@ describe("runWizard", () => {
|
|
|
303
303
|
CLEAR_TOKEN, // clear perTurnUsd
|
|
304
304
|
"",
|
|
305
305
|
"",
|
|
306
|
+
"", // perMonthUsd
|
|
306
307
|
"",
|
|
307
308
|
"s",
|
|
308
309
|
]);
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { DEFAULT_CONFIG } from "../src/config/defaults.ts";
|
|
4
|
+
import type { RouterConfig } from "../src/config/types.ts";
|
|
5
|
+
import { createFeedbackStore } from "../src/cost/feedback.ts";
|
|
6
|
+
import { createLedger } from "../src/cost/ledger.ts";
|
|
7
|
+
import { EMPTY_USAGE, type LedgerEntry } from "../src/cost/types.ts";
|
|
8
|
+
import { startServer, type StartedServer } from "../src/server/http.ts";
|
|
9
|
+
import { ollamaRunway } from "../src/server/http.ts";
|
|
10
|
+
import { createSessionOverrides, OVERRIDE_TTL_MS } from "../src/server/overrides.ts";
|
|
11
|
+
import { openDb } from "../src/util/sqlite.ts";
|
|
12
|
+
import { describeOverride, parseOverrideArgs, renderWhy, type WhyEntry } from "../omp-extension/report-logic.ts";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Session controls from omp: pin/tier overrides (process-local, per omp
|
|
16
|
+
* session, counted down per committed dispatch), user feedback tied to the
|
|
17
|
+
* ledger row judged, and the /router why rendering.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
describe("session overrides", () => {
|
|
21
|
+
test("set, read, count down, clear, and expire", () => {
|
|
22
|
+
const o = createSessionOverrides();
|
|
23
|
+
const now = Date.now();
|
|
24
|
+
expect(o.get("s1")).toBeNull();
|
|
25
|
+
o.set("s1", { tier: "hard", turns: 2 }, now);
|
|
26
|
+
expect(o.get("s1")).toEqual({ slug: null, tier: "hard", turnsLeft: 2, setAtMs: now });
|
|
27
|
+
// A pin added later keeps the tier and its countdown.
|
|
28
|
+
o.set("s1", { slug: "ollama/glm-5.3-flash" }, now + 1);
|
|
29
|
+
expect(o.get("s1")?.slug).toBe("ollama/glm-5.3-flash");
|
|
30
|
+
expect(o.get("s1")?.tier).toBe("hard");
|
|
31
|
+
o.consume("s1");
|
|
32
|
+
expect(o.get("s1")?.turnsLeft).toBe(1);
|
|
33
|
+
o.consume("s1");
|
|
34
|
+
expect(o.get("s1")).toBeNull(); // used up
|
|
35
|
+
o.set("s2", { slug: "x/y", turns: 0 });
|
|
36
|
+
o.consume("s2");
|
|
37
|
+
o.consume("s2");
|
|
38
|
+
expect(o.get("s2")?.turnsLeft).toBe(0); // unlimited never counts down
|
|
39
|
+
o.clear("s2");
|
|
40
|
+
expect(o.get("s2")).toBeNull();
|
|
41
|
+
o.set("s3", { tier: "simple" }, Date.now() - OVERRIDE_TTL_MS - 1);
|
|
42
|
+
expect(o.get("s3")).toBeNull(); // expired
|
|
43
|
+
expect(o.get("")).toBeNull();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("clearing both fields reads as no override", () => {
|
|
47
|
+
const o = createSessionOverrides();
|
|
48
|
+
o.set("s", { tier: "hard" });
|
|
49
|
+
o.set("s", { tier: null });
|
|
50
|
+
expect(o.get("s")).toBeNull();
|
|
51
|
+
expect(o.list()).toHaveLength(1); // the entry exists but carries nothing
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe("feedback store", () => {
|
|
56
|
+
function entry(over: Partial<LedgerEntry>): LedgerEntry {
|
|
57
|
+
return {
|
|
58
|
+
id: crypto.randomUUID(),
|
|
59
|
+
createdAtMs: Date.now(),
|
|
60
|
+
conversationKey: "k",
|
|
61
|
+
sessionId: "s",
|
|
62
|
+
turn: 1,
|
|
63
|
+
requestedModel: "auto",
|
|
64
|
+
harnessId: "",
|
|
65
|
+
ompSessionId: "omp-1",
|
|
66
|
+
slug: "v/m",
|
|
67
|
+
servedSlug: "v/m",
|
|
68
|
+
tier: "simple",
|
|
69
|
+
classificationSource: "heuristic",
|
|
70
|
+
reasons: [],
|
|
71
|
+
features: null,
|
|
72
|
+
score: null,
|
|
73
|
+
confidence: null,
|
|
74
|
+
task: null,
|
|
75
|
+
classifierReasons: null,
|
|
76
|
+
exploredFrom: null,
|
|
77
|
+
holdArm: null,
|
|
78
|
+
predictedUsd: 0.001,
|
|
79
|
+
reportedUsd: 0.001,
|
|
80
|
+
usage: { ...EMPTY_USAGE, promptTokens: 10, completionTokens: 5 },
|
|
81
|
+
attempt: 0,
|
|
82
|
+
escalationSignal: null,
|
|
83
|
+
latencyMs: 100,
|
|
84
|
+
ttftMs: 50,
|
|
85
|
+
finishReason: "stop",
|
|
86
|
+
wasted: false,
|
|
87
|
+
upstreamGenerationId: null,
|
|
88
|
+
error: null,
|
|
89
|
+
promptTokensSaved: null,
|
|
90
|
+
...over,
|
|
91
|
+
} as LedgerEntry;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
test("records verdicts against the session's newest kept turn and counts them by model", () => {
|
|
95
|
+
const db = openDb(":memory:");
|
|
96
|
+
const cfg = structuredClone(DEFAULT_CONFIG);
|
|
97
|
+
cfg.ledger.path = ":memory:";
|
|
98
|
+
const ledger = createLedger(db, cfg);
|
|
99
|
+
const fb = createFeedbackStore(db);
|
|
100
|
+
ledger.record(entry({ createdAtMs: 1_000, slug: "a/m", servedSlug: "a/m" }));
|
|
101
|
+
ledger.record(entry({ createdAtMs: 2_000, slug: "b/m", servedSlug: "b/m", wasted: true })); // a wasted probe is never "the turn"
|
|
102
|
+
ledger.record(entry({ createdAtMs: 3_000, slug: "c/m", servedSlug: "c/m" }));
|
|
103
|
+
ledger.record(entry({ createdAtMs: 4_000, ompSessionId: "omp-2", slug: "d/m", servedSlug: "d/m" }));
|
|
104
|
+
const latest = ledger.latestForSession!("omp-1")!;
|
|
105
|
+
expect(latest.servedSlug).toBe("c/m");
|
|
106
|
+
expect(ledger.entriesForSession!("omp-1", 10).map((e) => e.servedSlug)).toEqual(["c/m", "a/m"]);
|
|
107
|
+
expect(ledger.latestForSession!("")).toBeNull();
|
|
108
|
+
|
|
109
|
+
fb.record({ ledgerId: latest.id, ompSessionId: "omp-1", slug: "c/m", tier: "simple", verdict: "bad", note: "wrong file" }, 5_000);
|
|
110
|
+
fb.record({ ledgerId: latest.id, ompSessionId: "omp-1", slug: "c/m", tier: "simple", verdict: "good", note: "" }, 6_000);
|
|
111
|
+
expect(fb.forLedgerId(latest.id).map((f) => f.verdict)).toEqual(["good", "bad"]);
|
|
112
|
+
expect([...fb.countsBySlug(0).entries()]).toEqual([["c/m", { good: 1, bad: 1 }]]);
|
|
113
|
+
expect(fb.countsBySlug(5_500).get("c/m")).toEqual({ good: 1, bad: 0 });
|
|
114
|
+
db.close();
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe("override and feedback endpoints", () => {
|
|
119
|
+
let handle: StartedServer;
|
|
120
|
+
let baseUrl = "";
|
|
121
|
+
beforeAll(() => {
|
|
122
|
+
const cfg: RouterConfig = {
|
|
123
|
+
...structuredClone(DEFAULT_CONFIG),
|
|
124
|
+
server: { host: "127.0.0.1", port: 0, maxConcurrentTurns: 24 },
|
|
125
|
+
ledger: { ...DEFAULT_CONFIG.ledger, path: ":memory:" },
|
|
126
|
+
logLevel: "silent",
|
|
127
|
+
};
|
|
128
|
+
handle = startServer(cfg);
|
|
129
|
+
baseUrl = `http://127.0.0.1:${handle.server.port}`;
|
|
130
|
+
});
|
|
131
|
+
afterAll(async () => {
|
|
132
|
+
await handle.stop();
|
|
133
|
+
});
|
|
134
|
+
const post = (path: string, body: unknown) =>
|
|
135
|
+
fetch(`${baseUrl}${path}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
|
|
136
|
+
|
|
137
|
+
test("override: validates, sets a tier with a countdown, shows it, clears it", async () => {
|
|
138
|
+
expect((await post("/v1/router/override", { tier: "hard" })).status).toBe(400); // no session
|
|
139
|
+
expect((await post("/v1/router/override", { ompSessionId: "s", tier: "epic" })).status).toBe(400);
|
|
140
|
+
expect((await post("/v1/router/override", { ompSessionId: "s", slug: "nope/model" })).status).toBe(404);
|
|
141
|
+
const set = (await (await post("/v1/router/override", { ompSessionId: "s", tier: "hard", turns: 3 })).json()) as { override: { tier: string; turnsLeft: number } };
|
|
142
|
+
expect(set.override.tier).toBe("hard");
|
|
143
|
+
expect(set.override.turnsLeft).toBe(3);
|
|
144
|
+
const shown = (await (await fetch(`${baseUrl}/v1/router/override?session=s`)).json()) as { override: { tier: string } | null };
|
|
145
|
+
expect(shown.override?.tier).toBe("hard");
|
|
146
|
+
const all = (await (await fetch(`${baseUrl}/v1/router/override`)).json()) as { overrides: unknown[] };
|
|
147
|
+
expect(all.overrides).toHaveLength(1);
|
|
148
|
+
const cleared = (await (await post("/v1/router/override", { ompSessionId: "s", clear: true })).json()) as { override: null };
|
|
149
|
+
expect(cleared.override).toBeNull();
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("feedback: rejects a bad verdict and a session with no turns", async () => {
|
|
153
|
+
expect((await post("/v1/router/feedback", { ompSessionId: "s", verdict: "meh" })).status).toBe(400);
|
|
154
|
+
expect((await post("/v1/router/feedback", { ompSessionId: "never", verdict: "good" })).status).toBe(404);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("decisions can be narrowed to a session", async () => {
|
|
158
|
+
const body = (await (await fetch(`${baseUrl}/v1/router/decisions?session=none`)).json()) as { entries: unknown[] };
|
|
159
|
+
expect(body.entries).toEqual([]);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe("/router why and override parsing", () => {
|
|
164
|
+
const entry: WhyEntry = {
|
|
165
|
+
id: "abc",
|
|
166
|
+
createdAtMs: 1_000_000,
|
|
167
|
+
turn: 12,
|
|
168
|
+
slug: "z-ai/glm-5.3-flash",
|
|
169
|
+
servedSlug: "ollama/glm-5.3-flash",
|
|
170
|
+
tier: "moderate",
|
|
171
|
+
classificationSource: "heuristic",
|
|
172
|
+
confidence: 0.42,
|
|
173
|
+
task: "coding",
|
|
174
|
+
reasons: ["classified moderate", "cache: keeping warm ollama/glm-5.3-flash (stay $0.0010 ≤ switch $0.0300 × 1.3)"],
|
|
175
|
+
classifierReasons: ["+0.10 complexity keyword: refactor"],
|
|
176
|
+
reportedUsd: 0.0123,
|
|
177
|
+
predictedUsd: 0.02,
|
|
178
|
+
usage: { promptTokens: 120_000, cachedTokens: 100_000, completionTokens: 300, cachedEstimated: true },
|
|
179
|
+
latencyMs: 4_200,
|
|
180
|
+
ttftMs: 900,
|
|
181
|
+
attempt: 0,
|
|
182
|
+
escalationSignal: null,
|
|
183
|
+
feedback: [{ verdict: "bad", note: "edited the wrong file", createdAtMs: 1_000_500 }],
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
test("renderWhy names the model, tier, confidence, cost, cache, trail and feedback", () => {
|
|
187
|
+
const text = renderWhy(entry, 1_060_000);
|
|
188
|
+
expect(text).toContain("turn 12 · 1m ago · ollama · ollama/glm-5.3-flash (asked z-ai/glm-5.3-flash) [moderate]");
|
|
189
|
+
expect(text).toContain("classified moderate by heuristic at 42% confidence · task coding");
|
|
190
|
+
expect(text).toContain("cost $0.0123 · prompt 120,000 tok (cache 83% est.) · completion 300 tok · ttft 0.9s · total 4.2s");
|
|
191
|
+
expect(text).toContain(" - cache: keeping warm");
|
|
192
|
+
expect(text).toContain(" - +0.10 complexity keyword: refactor");
|
|
193
|
+
expect(text).toContain(" - bad: edited the wrong file");
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("parseOverrideArgs handles pin, tier with turns, off, and errors", () => {
|
|
197
|
+
expect(parseOverrideArgs("pin", "ollama/glm-5.3-flash")).toEqual({ kind: "pin", slug: "ollama/glm-5.3-flash" });
|
|
198
|
+
expect(parseOverrideArgs("pin", "off")).toEqual({ kind: "pin", slug: null });
|
|
199
|
+
expect(parseOverrideArgs("pin", "")).toEqual({ kind: "show" });
|
|
200
|
+
expect(parseOverrideArgs("tier", "hard")).toEqual({ kind: "tier", tier: "hard", turns: 10 });
|
|
201
|
+
expect(parseOverrideArgs("tier", "Simple 3")).toEqual({ kind: "tier", tier: "simple", turns: 3 });
|
|
202
|
+
expect(parseOverrideArgs("tier", "off")).toEqual({ kind: "tier", tier: null, turns: 0 });
|
|
203
|
+
expect(parseOverrideArgs("tier", "epic").kind).toBe("error");
|
|
204
|
+
expect(parseOverrideArgs("tier", "hard x").kind).toBe("error");
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("describeOverride summarises what is in force", () => {
|
|
208
|
+
expect(describeOverride(null)).toBe("no override on this session");
|
|
209
|
+
expect(describeOverride({ slug: "a/b", tier: "hard", turnsLeft: 1 })).toBe("pinned to a/b, tier forced to hard · 1 turn left");
|
|
210
|
+
expect(describeOverride({ slug: null, tier: "simple", turnsLeft: 0 })).toBe("tier forced to simple · until cleared");
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
describe("ollamaRunway", () => {
|
|
215
|
+
test("days of credits left at the calibrated weekly burn", () => {
|
|
216
|
+
const r = ollamaRunway({ usedUsd: 6.3, creditsUsd: 60 }, 7, 1.25)!;
|
|
217
|
+
expect(r.dailyBurnUsd).toBeCloseTo(1.25, 6);
|
|
218
|
+
expect(r.creditsLeftUsd).toBeCloseTo(53.7, 6);
|
|
219
|
+
expect(r.days).toBeCloseTo(53.7 / 1.25, 6);
|
|
220
|
+
expect(ollamaRunway({ usedUsd: 6.3, creditsUsd: 60 }, 0, 1)!.days).toBeNull();
|
|
221
|
+
expect(ollamaRunway(null, 7, 1)).toBeNull();
|
|
222
|
+
});
|
|
223
|
+
});
|
package/test/failover.test.ts
CHANGED
|
@@ -46,10 +46,10 @@ function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
|
|
|
46
46
|
data: { axis: "intelligence", minQuality: 0 },
|
|
47
47
|
chat: { axis: "intelligence", minQuality: 0 },
|
|
48
48
|
},
|
|
49
|
-
filters: { allow: [], deny: [], includeFree: false, requireToolSupport: true, minTrust: 0.6, minTrustSamples: 5, trustScopedByHarness: false, trustWindowDays: 0, contextHeadroom: 1.2, latencyWeight: 0, latencyReferenceMs: 5000, latencyReferenceTokensPerSec: 30, latencyMinSamples: 20, escalationCostWeight: 0 },
|
|
49
|
+
filters: { allow: [], deny: [], includeFree: false, requireToolSupport: true, minTrust: 0.6, minTrustSamples: 5, trustScopedByHarness: false, trustWindowDays: 0, contextHeadroom: 1.2, latencyWeight: 0, latencyReferenceMs: 5000, latencyReferenceTokensPerSec: 30, cacheReliabilityMinSamples: 10, latencyMinSamples: 20, escalationCostWeight: 0 },
|
|
50
50
|
classifier: {
|
|
51
51
|
ambiguityThreshold: 0,
|
|
52
|
-
model: "test/adjudicator",
|
|
52
|
+
model: "test/adjudicator", learnedModelPath: "",
|
|
53
53
|
maxCostFraction: 0.1,
|
|
54
54
|
maxCostUsd: 0.01,
|
|
55
55
|
timeoutMs: 5000,
|
|
@@ -76,6 +76,7 @@ function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
|
|
|
76
76
|
context: { enabled: false, baseUrl: "", token: "", defaultScope: "", timeoutMs: 3_000, maxStalenessMs: 900_000, maxBlockChars: 24_000, memoryLimit: 8, docsLimit: 2, sessionLimit: 6, briefChars: 0, recordTurns: false, maxQueue: 64 },
|
|
77
77
|
compaction: { enabled: false, budgetTokens: 40_000, floorRatio: 1, fitToWindow: true, protectRecentTurns: 4, maxToolResultBytes: 4_096, keepHeadBytes: 512, keepTailBytes: 512, elideSupersededReads: true, collapseDuplicateResults: true, replanGrowthRatio: 1 },
|
|
78
78
|
budget: { onExceeded: "downgrade" },
|
|
79
|
+
report: { baselines: [] },
|
|
79
80
|
profiles: [],
|
|
80
81
|
ledger: { path: ":memory:", blendWindowDays: 7, blendMinSamples: 20, fallbackBlend: { inputPerMtok: 1, outputPerMtok: 4 }, conversationTtlMs: 86_400_000 },
|
|
81
82
|
adaptiveTierFloors: true,
|
package/test/features.test.ts
CHANGED
|
@@ -306,3 +306,34 @@ describe("user-visible tool failure (review 2026-09-05)", () => {
|
|
|
306
306
|
expect(f.lastToolFailed).toBe(false);
|
|
307
307
|
});
|
|
308
308
|
});
|
|
309
|
+
|
|
310
|
+
describe("prompt anatomy", () => {
|
|
311
|
+
test("splits prompt bytes by role and marks the older half and stale tool results", () => {
|
|
312
|
+
// 24 non-system messages: 12 tool-call/result pairs. The newest 20
|
|
313
|
+
// non-system messages are "fresh"; the 4 before them hold 2 stale tool results.
|
|
314
|
+
const messages: unknown[] = [SYSTEM];
|
|
315
|
+
for (let i = 0; i < 12; i++) {
|
|
316
|
+
messages.push(toolCall(`c${i}`, "bash", `{"command":"ls ${i}"}`));
|
|
317
|
+
messages.push({ role: "tool", tool_call_id: `c${i}`, content: "x".repeat(100) });
|
|
318
|
+
}
|
|
319
|
+
messages.push({ role: "user", content: "y".repeat(50) });
|
|
320
|
+
const f = extractFeatures(req(messages), 5000);
|
|
321
|
+
const a = f.anatomy!;
|
|
322
|
+
expect(a.messages).toBe(26);
|
|
323
|
+
expect(a.systemBytes).toBe("You are a coding agent.".length);
|
|
324
|
+
expect(a.userBytes).toBe(50);
|
|
325
|
+
expect(a.toolBytes).toBe(1200);
|
|
326
|
+
// 25 non-system messages; the older half is the first 12 (6 pairs ⇒ 6 tool results).
|
|
327
|
+
expect(a.olderHalfBytes).toBeGreaterThanOrEqual(600);
|
|
328
|
+
expect(a.olderHalfBytes).toBeLessThan(1200);
|
|
329
|
+
// Stale: tool results among the first 25-20 = 5 non-system messages ⇒ results at index 1 and 3.
|
|
330
|
+
expect(a.staleToolBytes).toBe(200);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
test("a bare chat request has no stale tool bytes", () => {
|
|
334
|
+
const a = extractFeatures(req([SYSTEM, { role: "user", content: "hi" }]), 20).anatomy!;
|
|
335
|
+
expect(a.toolBytes).toBe(0);
|
|
336
|
+
expect(a.staleToolBytes).toBe(0);
|
|
337
|
+
expect(a.olderHalfBytes).toBe(0);
|
|
338
|
+
});
|
|
339
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { auc, FEATURE_NAMES, learnedVector, predictRisk, trainLogistic, type LearnedModel } from "../src/router/learned.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The learned escalation-risk model: a deterministic logistic regression that
|
|
7
|
+
* must separate a synthetic dataset, score by rank correctly, and tolerate
|
|
8
|
+
* old ledger rows missing fields.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
describe("learnedVector", () => {
|
|
12
|
+
test("matches FEATURE_NAMES in length and tolerates missing fields", () => {
|
|
13
|
+
const v = learnedVector({});
|
|
14
|
+
expect(v).toHaveLength(FEATURE_NAMES.length);
|
|
15
|
+
expect(v.every((x) => x === 0)).toBe(true);
|
|
16
|
+
const w = learnedVector({ promptTokens: 1000, lastToolFailed: true, requestedReasoning: "high", complexityKeywords: ["refactor", "migrate"] });
|
|
17
|
+
expect(w[FEATURE_NAMES.indexOf("log_prompt_tokens")]).toBeCloseTo(Math.log1p(1000), 6);
|
|
18
|
+
expect(w[FEATURE_NAMES.indexOf("last_tool_failed")]).toBe(1);
|
|
19
|
+
expect(w[FEATURE_NAMES.indexOf("requested_reasoning")]).toBe(3);
|
|
20
|
+
expect(w[FEATURE_NAMES.indexOf("complexity_keywords")]).toBe(2);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe("auc", () => {
|
|
25
|
+
test("perfect ranking is 1, inverted is 0, ties count half", () => {
|
|
26
|
+
expect(auc([0.9, 0.8, 0.1, 0.2], [1, 1, 0, 0])).toBe(1);
|
|
27
|
+
expect(auc([0.1, 0.2, 0.9, 0.8], [1, 1, 0, 0])).toBe(0);
|
|
28
|
+
expect(auc([0.5, 0.5], [1, 0])).toBe(0.5);
|
|
29
|
+
expect(auc([0.3], [1])).toBe(0.5);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("trainLogistic", () => {
|
|
34
|
+
test("separates a dataset where escalation follows failed tools and long prompts", () => {
|
|
35
|
+
const xs: number[][] = [];
|
|
36
|
+
const ys: number[] = [];
|
|
37
|
+
let seed = 7;
|
|
38
|
+
const rnd = () => ((seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648);
|
|
39
|
+
for (let i = 0; i < 2000; i++) {
|
|
40
|
+
const failed = rnd() < 0.1;
|
|
41
|
+
const prompt = Math.floor(rnd() * 150_000);
|
|
42
|
+
const y = failed && prompt > 60_000 ? (rnd() < 0.9 ? 1 : 0) : rnd() < 0.02 ? 1 : 0;
|
|
43
|
+
xs.push(learnedVector({ promptTokens: prompt, lastToolFailed: failed, turnDepth: Math.floor(rnd() * 100), toolCount: 12 }));
|
|
44
|
+
ys.push(y);
|
|
45
|
+
}
|
|
46
|
+
const fit = trainLogistic(xs.slice(0, 1600), ys.slice(0, 1600), { epochs: 300 });
|
|
47
|
+
const model: LearnedModel = { version: 1, trainedAtMs: 0, rows: 1600, positives: 0, names: [...FEATURE_NAMES], means: fit.means, stds: fit.stds, weights: fit.weights, bias: fit.bias, auc: 0 };
|
|
48
|
+
const scores = xs.slice(1600).map((x) => {
|
|
49
|
+
// predictRisk takes features; rebuild the same vector through it for parity.
|
|
50
|
+
const f = { promptTokens: Math.expm1(x[0]!), lastToolFailed: x[8] === 1, turnDepth: x[2]!, toolCount: x[3]! };
|
|
51
|
+
return predictRisk(model, f);
|
|
52
|
+
});
|
|
53
|
+
expect(auc(scores, ys.slice(1600))).toBeGreaterThan(0.85);
|
|
54
|
+
expect(fit.weights[FEATURE_NAMES.indexOf("last_tool_failed")]!).toBeGreaterThan(0);
|
|
55
|
+
expect(fit.weights[FEATURE_NAMES.indexOf("log_prompt_tokens")]!).toBeGreaterThan(0);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("refuses an empty dataset", () => {
|
|
59
|
+
expect(() => trainLogistic([], [])).toThrow();
|
|
60
|
+
});
|
|
61
|
+
});
|