auto-model-router 0.4.13 → 0.5.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 +52 -0
- package/package.json +1 -1
- package/src/cli/args.ts +1 -0
- package/src/cli/config-wizard.ts +7 -0
- package/src/cli/export.ts +29 -0
- package/src/config/defaults.ts +4 -0
- package/src/config/schema.ts +1 -0
- package/src/config/types.ts +11 -0
- package/src/cost/views.ts +153 -0
- package/src/index.ts +5 -0
- package/src/lib.ts +1 -0
- package/src/server/http.ts +52 -9
- package/src/wire/anthropic/messages.ts +481 -0
- package/src/wire/types.ts +1 -1
- package/test/anthropic-wire.test.ts +287 -0
- package/test/failover.test.ts +1 -0
- package/test/fixtures/harness/claude-code.json +858 -0
- package/test/turn.test.ts +1 -0
- package/test/views.test.ts +161 -0
package/test/turn.test.ts
CHANGED
|
@@ -77,6 +77,7 @@ function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
|
|
|
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, digestToolResults: false, digestMaxPerTurn: 2 },
|
|
78
78
|
budget: { onExceeded: "downgrade" },
|
|
79
79
|
report: { baselines: [], dailySummary: false },
|
|
80
|
+
anthropic: { models: { "*haiku*": "auto-cheap", "claude-*": "auto" } },
|
|
80
81
|
harnessSwitch: { enabled: false, models: {}, minConfidence: 0.6 },
|
|
81
82
|
digest: { enabled: false, minBytes: 12_000, maxBytes: 400_000, tools: ["read"], fromTier: "moderate", tier: "simple", model: "", maxOutputTokens: 700, maxCostUsd: 0.02, timeoutMs: 25_000, toolAliases: {} },
|
|
82
83
|
profiles: [],
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
import { DEFAULT_CONFIG } from "../src/config/defaults.ts";
|
|
7
|
+
import { createFeedbackStore } from "../src/cost/feedback.ts";
|
|
8
|
+
import { createLedger } from "../src/cost/ledger.ts";
|
|
9
|
+
import type { LedgerEntry } from "../src/cost/types.ts";
|
|
10
|
+
import { exportCsv, exportRows, feedbackView, harnessScopeParam, spendUsdSince } from "../src/cost/views.ts";
|
|
11
|
+
import { startServer, type StartedServer } from "../src/server/http.ts";
|
|
12
|
+
import { openDb } from "../src/util/sqlite.ts";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The ledger views a front door reads instead of the ledger file: spend over
|
|
16
|
+
* a harness set, feedback with the judging harness, and the day × harness ×
|
|
17
|
+
* model export. Pinned over the public functions and over the HTTP routes.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const DAY = 86_400_000;
|
|
21
|
+
const NOW = Date.UTC(2026, 8, 7, 12, 0, 0);
|
|
22
|
+
|
|
23
|
+
function entry(over: Partial<LedgerEntry>): LedgerEntry {
|
|
24
|
+
return {
|
|
25
|
+
id: crypto.randomUUID(),
|
|
26
|
+
createdAtMs: NOW - 3_600_000,
|
|
27
|
+
conversationKey: "k",
|
|
28
|
+
sessionId: "s",
|
|
29
|
+
turn: 1,
|
|
30
|
+
requestedModel: "auto",
|
|
31
|
+
harnessId: "",
|
|
32
|
+
ompSessionId: "",
|
|
33
|
+
slug: "vendor/model",
|
|
34
|
+
servedSlug: "vendor/model",
|
|
35
|
+
tier: "simple",
|
|
36
|
+
classificationSource: "heuristic",
|
|
37
|
+
reasons: [],
|
|
38
|
+
features: null,
|
|
39
|
+
score: null,
|
|
40
|
+
confidence: null,
|
|
41
|
+
task: null,
|
|
42
|
+
classifierReasons: null,
|
|
43
|
+
exploredFrom: null,
|
|
44
|
+
holdArm: null,
|
|
45
|
+
predictedUsd: 0.001,
|
|
46
|
+
reportedUsd: 0.001,
|
|
47
|
+
usage: { promptTokens: 1000, cachedTokens: 400, cacheWriteTokens: 0, completionTokens: 50, reasoningTokens: 0, images: 0 },
|
|
48
|
+
attempt: 0,
|
|
49
|
+
escalationSignal: null,
|
|
50
|
+
latencyMs: 1_100,
|
|
51
|
+
ttftMs: 100,
|
|
52
|
+
finishReason: "stop",
|
|
53
|
+
wasted: false,
|
|
54
|
+
upstreamGenerationId: null,
|
|
55
|
+
error: null,
|
|
56
|
+
promptTokensSaved: null,
|
|
57
|
+
...over,
|
|
58
|
+
} as LedgerEntry;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function seeded() {
|
|
62
|
+
const cfg = structuredClone(DEFAULT_CONFIG);
|
|
63
|
+
cfg.ledger.path = ":memory:";
|
|
64
|
+
const db = openDb(":memory:");
|
|
65
|
+
const ledger = createLedger(db, cfg);
|
|
66
|
+
const feedback = createFeedbackStore(db);
|
|
67
|
+
ledger.record(entry({ id: "l1", harnessId: "u_ada", slug: "anthropic/claude-sonnet-5", servedSlug: "anthropic/claude-sonnet-5", predictedUsd: 0.01, reportedUsd: 0.012 }));
|
|
68
|
+
ledger.record(entry({ id: "l2", harnessId: "u_ada", slug: "anthropic/claude-sonnet-5", servedSlug: null, predictedUsd: 0.01, reportedUsd: null, escalationSignal: "circular" }));
|
|
69
|
+
ledger.record(entry({ id: "l3", harnessId: "u_bob", slug: "ollama/glm-5.3-flash", servedSlug: "ollama/glm-5.3-flash", predictedUsd: 0.001, reportedUsd: 0.001, error: "boom" }));
|
|
70
|
+
ledger.record(entry({ id: "l4", harnessId: "u_bob", requestedModel: "digest", slug: "ollama/glm-5.3-flash", servedSlug: "ollama/glm-5.3-flash", predictedUsd: 0.5, reportedUsd: 0.5 }));
|
|
71
|
+
ledger.record(entry({ id: "l5", harnessId: "u_bob", createdAtMs: NOW - 40 * DAY, slug: "ollama/glm-5.3-flash", predictedUsd: 5, reportedUsd: 5 }));
|
|
72
|
+
feedback.record({ ledgerId: "l1", ompSessionId: "s", slug: "anthropic/claude-sonnet-5", tier: "simple", verdict: "good", note: "" }, NOW - 1000);
|
|
73
|
+
feedback.record({ ledgerId: "l2", ompSessionId: "s", slug: "anthropic/claude-sonnet-5", tier: "simple", verdict: "bad", note: "" }, NOW - 900);
|
|
74
|
+
feedback.record({ ledgerId: "l3", ompSessionId: "s", slug: "ollama/glm-5.3-flash", tier: "simple", verdict: "bad", note: "looped" }, NOW - 800);
|
|
75
|
+
return db;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
describe("ledger views", () => {
|
|
79
|
+
const db = seeded();
|
|
80
|
+
const since = NOW - DAY;
|
|
81
|
+
|
|
82
|
+
test("spend over a harness set, everything, or nothing", () => {
|
|
83
|
+
expect(spendUsdSince(db, since, ["u_ada"])).toBeCloseTo(0.022, 6); // reported where present, predicted otherwise
|
|
84
|
+
expect(spendUsdSince(db, since, ["u_ada", "u_bob"])).toBeCloseTo(0.523, 6); // the digest row counts as spend
|
|
85
|
+
expect(spendUsdSince(db, since, null)).toBeCloseTo(0.523, 6);
|
|
86
|
+
expect(spendUsdSince(db, NOW - 60 * DAY, null)).toBeCloseTo(5.523, 6);
|
|
87
|
+
expect(spendUsdSince(db, since, [])).toBe(0);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("feedback by model with distinct judges, scoped by harness", () => {
|
|
91
|
+
const all = feedbackView(db, since, null);
|
|
92
|
+
expect(all.byModel).toEqual([
|
|
93
|
+
{ slug: "anthropic/claude-sonnet-5", good: 1, bad: 1, judges: 1 },
|
|
94
|
+
{ slug: "ollama/glm-5.3-flash", good: 0, bad: 1, judges: 1 },
|
|
95
|
+
]);
|
|
96
|
+
expect(all.recent.map((r) => [r.harnessId, r.verdict, r.note])).toEqual([
|
|
97
|
+
["u_bob", "bad", "looped"],
|
|
98
|
+
["u_ada", "bad", ""],
|
|
99
|
+
["u_ada", "good", ""],
|
|
100
|
+
]);
|
|
101
|
+
expect(feedbackView(db, since, ["u_bob"]).byModel).toEqual([{ slug: "ollama/glm-5.3-flash", good: 0, bad: 1, judges: 1 }]);
|
|
102
|
+
expect(feedbackView(db, since, []).recent).toEqual([]);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("export rows by day, harness and served model; digest and old rows out; CSV quoting", () => {
|
|
106
|
+
const rows = exportRows(db, since, null);
|
|
107
|
+
expect(rows).toHaveLength(2);
|
|
108
|
+
expect(rows[0]).toMatchObject({ day: "2026-09-07", harnessId: "u_ada", slug: "anthropic/claude-sonnet-5", provider: "openrouter", dispatches: 2, promptTokens: 2000, cachedTokens: 800, completionTokens: 100, escalations: 1, errors: 0 });
|
|
109
|
+
expect(rows[0]!.spendUsd).toBeCloseTo(0.022, 6);
|
|
110
|
+
expect(rows[1]).toMatchObject({ harnessId: "u_bob", provider: "ollama", dispatches: 1, errors: 1 });
|
|
111
|
+
expect(exportRows(db, since, ["u_bob"])).toHaveLength(1);
|
|
112
|
+
expect(exportRows(db, since, [])).toEqual([]);
|
|
113
|
+
const csv = exportCsv([{ ...rows[0]!, harnessId: 'ada, "L"' }]);
|
|
114
|
+
expect(csv.split("\n")[0]).toBe("day,harness,model,provider,dispatches,prompt_tokens,cached_tokens,completion_tokens,spend_usd,escalations,errors");
|
|
115
|
+
expect(csv.split("\n")[1]).toBe('2026-09-07,"ada, ""L""",anthropic/claude-sonnet-5,openrouter,2,2000,800,100,0.022000,1,0');
|
|
116
|
+
expect(harnessScopeParam(null)).toBeNull();
|
|
117
|
+
expect(harnessScopeParam(" , ")).toBeNull();
|
|
118
|
+
expect(harnessScopeParam("a, b")).toEqual(["a", "b"]);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe("view routes", () => {
|
|
123
|
+
let handle: StartedServer;
|
|
124
|
+
const dir = mkdtempSync(join(tmpdir(), "amr-views-"));
|
|
125
|
+
beforeAll(() => {
|
|
126
|
+
const cfg = structuredClone(DEFAULT_CONFIG);
|
|
127
|
+
cfg.server.host = "127.0.0.1";
|
|
128
|
+
cfg.server.port = 0;
|
|
129
|
+
cfg.server.apiKey = "k";
|
|
130
|
+
cfg.ledger.path = join(dir, "router.db");
|
|
131
|
+
// Seed through the ledger on the same file before the server opens it.
|
|
132
|
+
const db = openDb(cfg.ledger.path);
|
|
133
|
+
createLedger(db, cfg).record(entry({ id: "r1", createdAtMs: Date.now() - 1000, harnessId: "u_x", predictedUsd: 0.2, reportedUsd: 0.25 }));
|
|
134
|
+
db.close();
|
|
135
|
+
handle = startServer(cfg);
|
|
136
|
+
});
|
|
137
|
+
afterAll(async () => {
|
|
138
|
+
await handle.stop();
|
|
139
|
+
try {
|
|
140
|
+
rmSync(dir, { recursive: true, force: true });
|
|
141
|
+
} catch {
|
|
142
|
+
/* Windows may hold the WAL briefly */
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
const get = (path: string) => fetch(`http://127.0.0.1:${handle.server.port}${path}`, { headers: { authorization: "Bearer k" } });
|
|
146
|
+
|
|
147
|
+
test("spend, feedback and export answer with the auth every router route needs", async () => {
|
|
148
|
+
expect((await fetch(`http://127.0.0.1:${handle.server.port}/v1/router/spend?sinceMs=0`)).status).toBe(401);
|
|
149
|
+
expect((await get("/v1/router/spend")).status).toBe(400);
|
|
150
|
+
expect(((await (await get(`/v1/router/spend?sinceMs=${Date.now() - DAY}&harness=u_x`)).json()) as { usd: number }).usd).toBeCloseTo(0.25, 6);
|
|
151
|
+
expect(((await (await get(`/v1/router/spend?sinceMs=${Date.now() - DAY}&harness=u_other`)).json()) as { usd: number }).usd).toBe(0);
|
|
152
|
+
const fb = (await (await get("/v1/router/feedback?days=7")).json()) as { days: number; byModel: unknown[]; recent: unknown[] };
|
|
153
|
+
expect(fb).toEqual({ days: 7, byModel: [], recent: [] });
|
|
154
|
+
const csv = await get("/v1/router/export?days=1");
|
|
155
|
+
expect(csv.headers.get("content-type")).toContain("text/csv");
|
|
156
|
+
expect((await csv.text()).split("\n")[1]).toContain("u_x,vendor/model,openrouter,1,1000,400,50,0.250000,0,0");
|
|
157
|
+
const js = (await (await get("/v1/router/export?days=1&format=json&harness=u_x")).json()) as { days: number; rows: { harnessId: string }[] };
|
|
158
|
+
expect(js.days).toBe(1);
|
|
159
|
+
expect(js.rows[0]?.harnessId).toBe("u_x");
|
|
160
|
+
});
|
|
161
|
+
});
|