auto-model-router 0.1.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.
Files changed (83) hide show
  1. package/.env.example +24 -0
  2. package/.github/workflows/publish.yml +40 -0
  3. package/.omp-plugin/marketplace.json +30 -0
  4. package/LICENSE +21 -0
  5. package/README.md +639 -0
  6. package/bun.lock +32 -0
  7. package/docs/claude-anthropic-wire.md +116 -0
  8. package/omp-extension/configure-logic.ts +128 -0
  9. package/omp-extension/embed-logic.ts +141 -0
  10. package/omp-extension/router-configure.ts +111 -0
  11. package/omp-extension/router-embed.ts +118 -0
  12. package/omp-extension/router-toast.ts +130 -0
  13. package/omp-extension/toast-logic.ts +136 -0
  14. package/package.json +56 -0
  15. package/src/catalog/openrouter-catalog.ts +428 -0
  16. package/src/catalog/types.ts +104 -0
  17. package/src/cli/args.ts +105 -0
  18. package/src/cli/config-cmd.ts +362 -0
  19. package/src/cli/config-wizard.ts +636 -0
  20. package/src/cli/explain.ts +167 -0
  21. package/src/cli/models.ts +240 -0
  22. package/src/cli/stats.ts +69 -0
  23. package/src/config/defaults.ts +136 -0
  24. package/src/config/load.ts +143 -0
  25. package/src/config/omp-credentials.ts +124 -0
  26. package/src/config/schema.ts +161 -0
  27. package/src/config/types.ts +244 -0
  28. package/src/cost/blended.ts +80 -0
  29. package/src/cost/forecast.ts +129 -0
  30. package/src/cost/ledger.ts +291 -0
  31. package/src/cost/types.ts +148 -0
  32. package/src/index.ts +93 -0
  33. package/src/router/cache-control.ts +66 -0
  34. package/src/router/candidates.ts +246 -0
  35. package/src/router/classify.ts +329 -0
  36. package/src/router/escalate.ts +264 -0
  37. package/src/router/features.ts +225 -0
  38. package/src/router/index.ts +99 -0
  39. package/src/router/select.ts +365 -0
  40. package/src/router/state.ts +118 -0
  41. package/src/router/tier-plan.ts +151 -0
  42. package/src/router/types.ts +222 -0
  43. package/src/server/http.ts +343 -0
  44. package/src/server/turn.ts +393 -0
  45. package/src/tokens/estimate.ts +74 -0
  46. package/src/upstream/openrouter.ts +221 -0
  47. package/src/upstream/sse-parse.ts +208 -0
  48. package/src/upstream/types.ts +75 -0
  49. package/src/util/hash.ts +0 -0
  50. package/src/util/log.ts +53 -0
  51. package/src/util/sqlite.ts +140 -0
  52. package/src/util/sse.ts +23 -0
  53. package/src/wire/openai/errors.ts +48 -0
  54. package/src/wire/openai/models.ts +37 -0
  55. package/src/wire/openai/request.ts +279 -0
  56. package/src/wire/openai/sink.ts +213 -0
  57. package/src/wire/types.ts +156 -0
  58. package/test/catalog.test.ts +319 -0
  59. package/test/classify.test.ts +269 -0
  60. package/test/config-wizard.test.ts +482 -0
  61. package/test/config.test.ts +121 -0
  62. package/test/configure-logic.test.ts +151 -0
  63. package/test/cost.test.ts +137 -0
  64. package/test/embed-logic.test.ts +107 -0
  65. package/test/escalate.test.ts +223 -0
  66. package/test/failover.test.ts +494 -0
  67. package/test/features.test.ts +228 -0
  68. package/test/fixtures/openrouter-models.json +15340 -0
  69. package/test/models-yml.test.ts +186 -0
  70. package/test/omp-credentials.test.ts +185 -0
  71. package/test/select.test.ts +538 -0
  72. package/test/sse-parse.test.ts +142 -0
  73. package/test/tier-plan.test.ts +302 -0
  74. package/test/toast-logic.test.ts +160 -0
  75. package/test/tokens.test.ts +160 -0
  76. package/test/trust-attribution.test.ts +175 -0
  77. package/test/turn.test.ts +498 -0
  78. package/test/wire-request.test.ts +297 -0
  79. package/test/wire-sink.test.ts +179 -0
  80. package/tools/install.ts +140 -0
  81. package/tools/mock-openrouter.ts +269 -0
  82. package/tools/smoke.ts +326 -0
  83. package/tsconfig.json +23 -0
@@ -0,0 +1,167 @@
1
+ /**
2
+ * `auto-model-router explain` - route a real request and show the reasoning, without
3
+ * dispatching it.
4
+ *
5
+ * The only network call this can make is a catalog refresh; no completion is
6
+ * ever requested, so it never spends money on inference. It is also
7
+ * side-effect-free on conversation state, because `createRouter` is read-only
8
+ * there by construction.
9
+ */
10
+
11
+ import { existsSync } from "node:fs";
12
+
13
+ import { createCatalog } from "../catalog/openrouter-catalog.ts";
14
+ import { loadConfig } from "../config/load.ts";
15
+ import { createLedger } from "../cost/ledger.ts";
16
+ import { createRouter } from "../router/index.ts";
17
+ import { createConversationStore } from "../router/state.ts";
18
+ import type { Candidate, Decision, Features, Rejection } from "../router/types.ts";
19
+ import { createOpenRouterClient } from "../upstream/openrouter.ts";
20
+ import { openDb } from "../util/sqlite.ts";
21
+ import { parseChatRequest } from "../wire/openai/request.ts";
22
+ import { configOpts, flagString, type CliArgs } from "./args.ts";
23
+
24
+ /** Sub-cent routing decisions need real significant figures, not two decimals. */
25
+ function usd(value: number): string {
26
+ return `$${value.toFixed(6)}`;
27
+ }
28
+
29
+ /**
30
+ * Feature rows worth printing even at zero, because their absence is itself
31
+ * the signal that explains a cheap route.
32
+ */
33
+ const ALWAYS_SHOW: Record<string, true> = {
34
+ promptTokens: true,
35
+ newContentTokens: true,
36
+ toolCount: true,
37
+ isToolResultContinuation: true,
38
+ toolLoopDepth: true,
39
+ lastToolFailed: true,
40
+ repeatedToolCall: true,
41
+ turnDepth: true,
42
+ };
43
+
44
+ function renderFeatures(f: Features): void {
45
+ console.log("features:");
46
+ const entries = Object.entries(f) as [string, unknown][];
47
+ const width = Math.max(...entries.map(([k]) => k.length));
48
+ for (const [key, value] of entries) {
49
+ const empty =
50
+ value === false ||
51
+ value === 0 ||
52
+ value === undefined ||
53
+ (Array.isArray(value) && value.length === 0);
54
+ if (empty && ALWAYS_SHOW[key] !== true) continue;
55
+ const rendered = Array.isArray(value) ? (value.length === 0 ? "-" : value.join(", ")) : String(value);
56
+ console.log(` ${key.padEnd(width)} ${rendered}`);
57
+ }
58
+ }
59
+
60
+ function renderCandidates(considered: Candidate[]): void {
61
+ console.log("\nranked candidates:");
62
+ if (considered.length === 0) {
63
+ console.log(" (none survived filtering)");
64
+ return;
65
+ }
66
+ const rows = considered.slice(0, 10);
67
+ const width = Math.max(5, ...rows.map((c) => c.model.slug.length));
68
+ console.log(
69
+ ` ${"model".padEnd(width)} ${"qual".padStart(5)} ${"trust".padStart(5)} ${"expected".padStart(10)} ${"cold".padStart(10)} ${"score".padStart(12)}`,
70
+ );
71
+ for (const c of rows) {
72
+ console.log(
73
+ ` ${c.model.slug.padEnd(width)} ${c.qualityScore.toFixed(1).padStart(5)} ${c.trustScore.toFixed(2).padStart(5)} ${usd(c.forecast.expectedUsd).padStart(10)} ${usd(c.forecast.coldUsd).padStart(10)} ${c.score.toExponential(3).padStart(12)}`,
74
+ );
75
+ }
76
+ if (considered.length > rows.length) console.log(` ... ${considered.length - rows.length} more`);
77
+ }
78
+
79
+ function renderRejections(rejected: Rejection[]): void {
80
+ if (rejected.length === 0) return;
81
+ const byReason = new Map<string, string[]>();
82
+ for (const r of rejected) {
83
+ const list = byReason.get(r.reason);
84
+ if (list === undefined) byReason.set(r.reason, [r.slug]);
85
+ else list.push(r.slug);
86
+ }
87
+ console.log("\nexcluded:");
88
+ for (const [reason, slugs] of [...byReason.entries()].sort((a, b) => b[1].length - a[1].length)) {
89
+ console.log(` ${reason.padEnd(22)} ${String(slugs.length).padStart(4)} e.g. ${slugs.slice(0, 3).join(", ")}`);
90
+ }
91
+ }
92
+
93
+ function renderDecision(d: Decision): void {
94
+ console.log("\ndecision:");
95
+ console.log(` model ${d.slug}`);
96
+ console.log(` fallbacks ${d.fallbacks.length === 0 ? "-" : d.fallbacks.join(", ")}`);
97
+ console.log(` tier ${d.tier}`);
98
+ console.log(` sticky ${d.sticky}`);
99
+ console.log(` budgetDowngraded ${d.budgetDowngraded}`);
100
+ console.log(` expected cost ${usd(d.forecast.expectedUsd)} (cold ${usd(d.forecast.coldUsd)})`);
101
+ console.log(` reasoning ${d.reasoning ?? "-"}`);
102
+ console.log(` maxTokens ${d.maxTokens ?? "-"}`);
103
+ console.log(` stripReasoning ${d.stripAssistantReasoning}`);
104
+ console.log(
105
+ ` cache points ${d.cacheBreakpointMessageIndices.length === 0 ? "none" : d.cacheBreakpointMessageIndices.join(", ")}`,
106
+ );
107
+ console.log(
108
+ ` probe ${d.probe.enabled ? `${d.probe.maxTokens} tokens / ${d.probe.maxHoldMs}ms, escalate to ${d.probe.escalateTo ?? "-"}` : "disabled"}`,
109
+ );
110
+ console.log(` session ${d.sessionId}`);
111
+ if (d.reasons.length > 0) {
112
+ console.log(" why:");
113
+ for (const reason of d.reasons) console.log(` - ${reason}`);
114
+ }
115
+ }
116
+
117
+ export async function explainCommand(args: CliArgs): Promise<void> {
118
+ const cfg = loadConfig(configOpts(args));
119
+ const file = flagString(args, "file");
120
+ const raw = file === undefined ? await Bun.stdin.text() : await Bun.file(file).text();
121
+ if (raw.trim() === "") throw new Error("no request body supplied (pass --file <request.json> or pipe JSON on stdin)");
122
+
123
+ let body: unknown;
124
+ try {
125
+ body = JSON.parse(raw);
126
+ } catch (err) {
127
+ throw new Error(`request body is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
128
+ }
129
+
130
+ const req = parseChatRequest(body, new Headers());
131
+
132
+ const db = openDb(cfg.ledger.path);
133
+ try {
134
+ const ledger = createLedger(db, cfg);
135
+ const upstream = createOpenRouterClient(cfg);
136
+ const catalog = createCatalog(cfg, upstream, db);
137
+ const conversations = createConversationStore(db);
138
+ const router = createRouter({ config: cfg, catalog, ledger, conversations, upstream });
139
+
140
+ const decision = await router.route(req, { attempt: 0 });
141
+
142
+ if (args.flags.has("json")) {
143
+ console.log(JSON.stringify(decision, null, 2));
144
+ return;
145
+ }
146
+
147
+ const state = conversations.get(req.conversationKey);
148
+ console.log(`request: ${req.messages.length} messages, ${req.tools.length} tools, model "${req.requestedModel}"`);
149
+ console.log(`conversation: ${req.conversationKey} (turn ${state?.turn ?? 0}, prior model ${state?.currentSlug ?? "none"})`);
150
+ console.log(
151
+ `ledger: ${existsSync(cfg.ledger.path) ? `${cfg.ledger.path}` : "(new)"} spent so far ${usd(state?.spentUsd ?? 0)}`,
152
+ );
153
+ console.log("");
154
+ renderFeatures(decision.features);
155
+ const c = decision.classification;
156
+ console.log(
157
+ `\nclassification: ${c.tier} (score ${c.score.toFixed(3)}, confidence ${c.confidence.toFixed(3)}, source ${c.source})`,
158
+ );
159
+ for (const reason of c.reasons) console.log(` - ${reason}`);
160
+ renderCandidates(decision.considered);
161
+ renderRejections(decision.rejected);
162
+ renderDecision(decision);
163
+ console.log("\n(no completion was dispatched; nothing was billed)");
164
+ } finally {
165
+ db.close();
166
+ }
167
+ }
@@ -0,0 +1,240 @@
1
+ /**
2
+ * `auto-model-router models` - the operator's window into routing policy.
3
+ *
4
+ * Eligibility is computed by calling the REAL `buildCandidates` path against a
5
+ * representative synthetic request, not by reimplementing the filters here.
6
+ * A second copy of the filter logic would drift from the router and this
7
+ * command's whole value is that it cannot lie about what would be chosen.
8
+ */
9
+
10
+ import { existsSync } from "node:fs";
11
+ import type { Database } from "bun:sqlite";
12
+
13
+ import { createCatalog } from "../catalog/openrouter-catalog.ts";
14
+ import { effectiveQualityFloor, tierPlanFor } from "../router/tier-plan.ts";
15
+ import { loadConfig } from "../config/load.ts";
16
+ import type { QualityAxis, RouterConfig } from "../config/types.ts";
17
+ import { createLedger } from "../cost/ledger.ts";
18
+ import type { Ledger } from "../cost/types.ts";
19
+ import { buildCandidates } from "../router/candidates.ts";
20
+ import { classifyTask } from "../router/classify.ts";
21
+ import { extractFeatures } from "../router/features.ts";
22
+ import { TIER_ORDER, type Candidate, type Rejection, type Tier } from "../router/types.ts";
23
+ import { estimatePromptTokens } from "../tokens/estimate.ts";
24
+ import { createOpenRouterClient } from "../upstream/openrouter.ts";
25
+ import { openDb } from "../util/sqlite.ts";
26
+ import { parseChatRequest } from "../wire/openai/request.ts";
27
+ import { configOpts, flagInt, flagString, type CliArgs } from "./args.ts";
28
+
29
+ /** Mid-range completion assumption, matching select.ts so forecasts line up. */
30
+ const EXPECTED_COMPLETION_TOKENS = 1024;
31
+ const DEFAULT_LIMIT = 15;
32
+
33
+ /**
34
+ * A representative agent turn: tools offered, some history, no images. The
35
+ * survey is only meaningful relative to a concrete request shape, and this is
36
+ * the shape omp actually sends.
37
+ */
38
+ function syntheticRequest() {
39
+ const tools = [
40
+ {
41
+ type: "function",
42
+ function: {
43
+ name: "read",
44
+ description: "Read a file from disk",
45
+ parameters: { type: "object", properties: { path: { type: "string" } }, required: ["path"] },
46
+ },
47
+ },
48
+ {
49
+ type: "function",
50
+ function: {
51
+ name: "edit",
52
+ description: "Apply a line-anchored patch to a file",
53
+ parameters: { type: "object", properties: { path: { type: "string" }, patch: { type: "string" } } },
54
+ },
55
+ },
56
+ ];
57
+ return parseChatRequest(
58
+ {
59
+ model: "auto",
60
+ stream: true,
61
+ tools,
62
+ messages: [
63
+ { role: "system", content: "You are a coding agent operating in a repository." },
64
+ { role: "user", content: "Refactor the retry helper so the backoff is testable." },
65
+ ],
66
+ },
67
+ new Headers(),
68
+ );
69
+ }
70
+
71
+ function perMtok(perToken: number): string {
72
+ return (perToken * 1e6).toFixed(3);
73
+ }
74
+
75
+ /**
76
+ * "no published benchmark" and "benchmarked as weak" are different facts, and
77
+ * collapsing both to 0.0 would misrepresent why a model is or is not eligible.
78
+ */
79
+ function qualityCell(c: Candidate): string {
80
+ const q = c.model.quality;
81
+ const unscored = q.coding === undefined && q.agentic === undefined && q.intelligence === undefined;
82
+ return unscored ? "-" : c.qualityScore.toFixed(1);
83
+ }
84
+
85
+ interface TierReport {
86
+ tier: Tier;
87
+ /** The tier's configured `minQuality`. */
88
+ minQuality: number;
89
+ /** Floor actually enforced; differs under adaptive floors or a task floor. */
90
+ effectiveQuality: number;
91
+ /** `tasks.*.minQuality`, 0 when unset. Never relaxed by adaptive floors. */
92
+ taskFloor: number;
93
+ axis: QualityAxis;
94
+ candidates: Candidate[];
95
+ rejected: Rejection[];
96
+ }
97
+
98
+ function renderTier(report: TierReport, limit: number): void {
99
+ const { tier, candidates, rejected } = report;
100
+ // Name the mechanism that moved the floor, so a surprising eligible set is
101
+ // explainable: a task floor RAISES it, an adaptive band LOWERS it.
102
+ const { minQuality, effectiveQuality, taskFloor } = report;
103
+ let floor = String(minQuality);
104
+ if (effectiveQuality !== minQuality) {
105
+ const why = taskFloor > 0 && effectiveQuality === taskFloor ? "task floor" : "adaptive";
106
+ floor = `${minQuality} → ${effectiveQuality.toFixed(1)} (${why})`;
107
+ }
108
+ console.log(
109
+ `\n[${tier}] quality floor ${floor} on the ${report.axis} axis - ${candidates.length} eligible, ${rejected.length} excluded`,
110
+ );
111
+ if (candidates.length === 0) {
112
+ console.log(" (nothing eligible: loosen the tier's floor or price ceiling)");
113
+ } else {
114
+ const rows = candidates.slice(0, limit);
115
+ const slugWidth = Math.max(5, ...rows.map((c) => c.model.slug.length));
116
+ console.log(
117
+ ` ${"model".padEnd(slugWidth)} ${"qual".padStart(5)} ${"trust".padStart(5)} ${"$/Mtok in".padStart(10)} ${"$/Mtok out".padStart(10)} ${"ctx".padStart(9)} ${"turn $".padStart(9)}`,
118
+ );
119
+ console.log(
120
+ ` ${"-".repeat(slugWidth)} ${"-".repeat(5)} ${"-".repeat(5)} ${"-".repeat(10)} ${"-".repeat(10)} ${"-".repeat(9)} ${"-".repeat(9)}`,
121
+ );
122
+ for (const c of rows) {
123
+ console.log(
124
+ ` ${c.model.slug.padEnd(slugWidth)} ${qualityCell(c).padStart(5)} ${c.trustScore.toFixed(2).padStart(5)} ${perMtok(c.model.price.prompt).padStart(10)} ${perMtok(c.model.price.completion).padStart(10)} ${c.model.contextLength.toLocaleString("en-US").padStart(9)} ${`$${c.forecast.expectedUsd.toFixed(5)}`.padStart(9)}`,
125
+ );
126
+ }
127
+ if (candidates.length > rows.length) console.log(` ... ${candidates.length - rows.length} more`);
128
+ }
129
+
130
+ // Hundreds of rejections per tier: summarise by cause, with examples.
131
+ const byReason = new Map<string, string[]>();
132
+ for (const r of rejected) {
133
+ const list = byReason.get(r.reason);
134
+ if (list === undefined) byReason.set(r.reason, [r.slug]);
135
+ else list.push(r.slug);
136
+ }
137
+ if (byReason.size > 0) {
138
+ console.log(" excluded:");
139
+ const ordered = [...byReason.entries()].sort((a, b) => b[1].length - a[1].length);
140
+ for (const [reason, slugs] of ordered) {
141
+ console.log(` ${reason.padEnd(22)} ${String(slugs.length).padStart(4)} e.g. ${slugs.slice(0, 3).join(", ")}`);
142
+ }
143
+ }
144
+ }
145
+
146
+ export async function modelsCommand(args: CliArgs): Promise<void> {
147
+ const cfg: RouterConfig = loadConfig(configOpts(args));
148
+ const limit = flagInt(args, "limit") ?? DEFAULT_LIMIT;
149
+ const tierFlag = flagString(args, "tier");
150
+ if (tierFlag !== undefined && !(TIER_ORDER as readonly string[]).includes(tierFlag)) {
151
+ throw new Error(`--tier must be one of ${TIER_ORDER.join(", ")}, got "${tierFlag}"`);
152
+ }
153
+ const tiers = tierFlag === undefined ? TIER_ORDER : [tierFlag as Tier];
154
+
155
+ // Reuse the on-disk catalog cache when present so a survey costs no network.
156
+ const db: Database = openDb(cfg.ledger.path);
157
+ const ledger: Ledger | null = existsSync(cfg.ledger.path) ? createLedger(db, cfg) : null;
158
+ try {
159
+ const upstream = createOpenRouterClient(cfg);
160
+ const catalog = createCatalog(cfg, upstream, db);
161
+ const snapshot = await catalog.get();
162
+
163
+ const req = syntheticRequest();
164
+ const promptTokens = estimatePromptTokens(req, "gpt", ledger);
165
+ const features = extractFeatures(req, promptTokens);
166
+
167
+ const reports: TierReport[] = tiers.map((tier) => {
168
+ const tierCfg = cfg.tiers[tier];
169
+ const task = classifyTask(features);
170
+ const axis = cfg.tasks[task].axis;
171
+ const { candidates, rejected } = buildCandidates({
172
+ req,
173
+ features,
174
+ tier,
175
+ task,
176
+ snapshot,
177
+ ledger,
178
+ cfg,
179
+ expectedCompletionTokens: EXPECTED_COMPLETION_TOKENS,
180
+ warmSlug: null,
181
+ });
182
+ // Report the floor actually enforced, which is what `buildCandidates`
183
+ // computes: the adaptive tier floor raised by any task floor. Printing
184
+ // the bare tier constant made an eligible tier look impossible under
185
+ // adaptive floors, and hid a `tasks.*.minQuality` entirely.
186
+ const adaptive = cfg.adaptiveTierFloors
187
+ ? effectiveQualityFloor(tierCfg.minQuality, tier, axis, tierPlanFor(snapshot, cfg))
188
+ : tierCfg.minQuality;
189
+ const taskFloor = cfg.tasks[task].minQuality ?? 0;
190
+ return {
191
+ tier,
192
+ minQuality: tierCfg.minQuality,
193
+ effectiveQuality: Math.max(adaptive, taskFloor),
194
+ taskFloor,
195
+ axis,
196
+ candidates,
197
+ rejected,
198
+ };
199
+ });
200
+
201
+ if (args.flags.has("json")) {
202
+ console.log(
203
+ JSON.stringify(
204
+ {
205
+ catalogModels: snapshot.models.length,
206
+ catalogAgeMs: Date.now() - snapshot.fetchedAtMs,
207
+ promptTokens,
208
+ tiers: reports.map((r) => ({
209
+ tier: r.tier,
210
+ minQuality: r.minQuality,
211
+ axis: r.axis,
212
+ eligible: r.candidates.slice(0, limit).map((c) => ({
213
+ slug: c.model.slug,
214
+ quality: c.qualityScore,
215
+ trust: c.trustScore,
216
+ inputPerMtok: c.model.price.prompt * 1e6,
217
+ outputPerMtok: c.model.price.completion * 1e6,
218
+ contextLength: c.model.contextLength,
219
+ expectedUsd: c.forecast.expectedUsd,
220
+ score: c.score,
221
+ })),
222
+ excluded: r.rejected.length,
223
+ })),
224
+ },
225
+ null,
226
+ 2,
227
+ ),
228
+ );
229
+ return;
230
+ }
231
+
232
+ console.log(
233
+ `catalog: ${snapshot.models.length} usable models, fetched ${Math.round((Date.now() - snapshot.fetchedAtMs) / 1000)}s ago`,
234
+ );
235
+ console.log(`survey request: ${promptTokens} estimated prompt tokens, ${req.tools.length} tools offered`);
236
+ for (const report of reports) renderTier(report, limit);
237
+ } finally {
238
+ db.close();
239
+ }
240
+ }
@@ -0,0 +1,69 @@
1
+ import { existsSync } from "node:fs";
2
+ import type { Database } from "bun:sqlite";
3
+ import { loadConfig } from "../config/load.ts";
4
+ import { createLedger } from "../cost/ledger.ts";
5
+ import { computeStats, type RouterStats } from "../server/http.ts";
6
+ import { openDb } from "../util/sqlite.ts";
7
+ import { configOpts, flagInt, type CliArgs } from "./args.ts";
8
+
9
+ function usd(v: number): string {
10
+ return `$${v.toFixed(4)}`;
11
+ }
12
+
13
+ function renderStats(stats: RouterStats): void {
14
+ const windowLabel = stats.windowDays === null ? "all time" : `last ${stats.windowDays}d`;
15
+ console.log(`window: ${windowLabel} requests: ${stats.requests} escalations: ${stats.escalations} (${(stats.escalationRate * 100).toFixed(1)}%)`);
16
+ console.log(`spend: window ${usd(stats.windowSpendUsd)} today ${usd(stats.spendTodayUsd)} 7d ${usd(stats.spend7dUsd)} all-time ${usd(stats.spendAllTimeUsd)}`);
17
+ console.log(
18
+ `predicted-vs-reported drift: ${stats.meanPredictionError === null ? "n/a (no reported costs yet)" : `${(stats.meanPredictionError * 100).toFixed(1)}% mean relative error`}`,
19
+ );
20
+ console.log("");
21
+
22
+ const slugWidth = Math.max(5, ...stats.perModel.map((r) => r.slug.length));
23
+ console.log(`${"model".padEnd(slugWidth)} ${"req".padStart(5)} ${"spend".padStart(9)} ${"share".padStart(6)}`);
24
+ console.log(`${"-".repeat(slugWidth)} ${"-".repeat(5)} ${"-".repeat(9)} ${"-".repeat(6)}`);
25
+ if (stats.perModel.length === 0) {
26
+ console.log("(no ledger entries in this window)");
27
+ return;
28
+ }
29
+ for (const r of stats.perModel) {
30
+ console.log(
31
+ `${r.slug.padEnd(slugWidth)} ${String(r.requests).padStart(5)} ${usd(r.spendUsd).padStart(9)} ${(r.share * 100).toFixed(1).padStart(5)}%`,
32
+ );
33
+ }
34
+ }
35
+
36
+ export async function statsCommand(args: CliArgs): Promise<void> {
37
+ const days = flagInt(args, "days") ?? 7;
38
+ const cfg = loadConfig(configOpts(args));
39
+
40
+ // A stats query must not create the ledger file just by looking.
41
+ let db: Database | null = null;
42
+ let stats: RouterStats;
43
+ if (existsSync(cfg.ledger.path)) {
44
+ db = openDb(cfg.ledger.path);
45
+ stats = computeStats(createLedger(db, cfg), { windowDays: days });
46
+ } else {
47
+ stats = {
48
+ generatedAtMs: Date.now(),
49
+ windowDays: days,
50
+ spendTodayUsd: 0,
51
+ spend7dUsd: 0,
52
+ spendAllTimeUsd: 0,
53
+ windowSpendUsd: 0,
54
+ requests: 0,
55
+ escalations: 0,
56
+ escalationRate: 0,
57
+ meanPredictionError: null,
58
+ perModel: [],
59
+ trust: [],
60
+ };
61
+ }
62
+
63
+ try {
64
+ if (args.flags.has("json")) console.log(JSON.stringify(stats, null, 2));
65
+ else renderStats(stats);
66
+ } finally {
67
+ db?.close();
68
+ }
69
+ }
@@ -0,0 +1,136 @@
1
+ import type { RouterConfig } from "./types.ts";
2
+
3
+ /**
4
+ * Built-in configuration. Layered beneath `$AUTO_MODEL_ROUTER_HOME/config.yml`,
5
+ * environment variables, and CLI overrides (see `load.ts`).
6
+ *
7
+ * `ledger.path` is intentionally empty: `loadConfig` resolves it to
8
+ * `$AUTO_MODEL_ROUTER_HOME/router.db`, which is only known at load time.
9
+ */
10
+ export const DEFAULT_CONFIG: RouterConfig = {
11
+ server: {
12
+ host: "127.0.0.1",
13
+ port: 8788,
14
+ },
15
+ openrouter: {
16
+ baseUrl: "https://openrouter.ai/api/v1",
17
+ // May stay empty: catalog and `config` work keyless; only dispatch fails.
18
+ apiKey: "",
19
+ title: "auto-model-router",
20
+ // Agent turns are long; a frontier model with tools can stream for minutes.
21
+ timeoutMs: 600_000,
22
+ catalogTtlMs: 6 * 60 * 60 * 1000,
23
+ // Refetch the key-scoped catalog every 5 minutes in the background so
24
+ // guardrail changes are picked up without waiting for traffic + TTL.
25
+ catalogRefreshMs: 5 * 60 * 1000,
26
+ },
27
+ tiers: {
28
+ // minQuality 0 ⇒ unscored models are eligible here; the floor does the
29
+ // quality work on every other tier (qualityExponent 0 ⇒ cheapest above floor).
30
+ trivial: { minQuality: 0, maxInputPerMtok: 0.3, qualityExponent: 0, pin: [] },
31
+ simple: { minQuality: 40, maxInputPerMtok: 1.5, qualityExponent: 0, pin: [] },
32
+ moderate: { minQuality: 60, maxInputPerMtok: 4.0, qualityExponent: 1, pin: [] },
33
+ // No price ceiling on `hard`: quality is the point of the top tier.
34
+ hard: { minQuality: 72, qualityExponent: 3, pin: [] },
35
+ },
36
+ tasks: {
37
+ // Task selects the axis + capability; the tier's quality floor, price
38
+ // ceiling, and budget guard still govern cost. A task minQuality only
39
+ // RAISES the tier floor for special cases (e.g. vision needs quality);
40
+ // it must not force cheap tiers to be expensive, or escalation has no
41
+ // room to move.
42
+ coding: { axis: "coding" },
43
+ vision: { axis: "intelligence", requireImage: true },
44
+ documentation: { axis: "intelligence" },
45
+ data: { axis: "intelligence" },
46
+ chat: { axis: "intelligence" },
47
+ },
48
+ filters: {
49
+ allow: [],
50
+ deny: [],
51
+ // Free models are rate-limited hard enough that retries cost more than they save.
52
+ includeFree: false,
53
+ requireToolSupport: true,
54
+ minTrust: 0.7,
55
+ minTrustSamples: 12,
56
+ // Shared trust by default: more samples, demotion guard stays effective
57
+ // even with a tiny guardrail-narrowed catalog.
58
+ trustScopedByHarness: false,
59
+ contextHeadroom: 1.25,
60
+ },
61
+ classifier: {
62
+ ambiguityThreshold: 0.6,
63
+ // Cheapest competent slug in the catalog; adjudication prompts are tiny.
64
+ model: "qwen/qwen3.7-flash",
65
+ maxCostFraction: 0.02,
66
+ maxCostUsd: 0.002,
67
+ timeoutMs: 4_000,
68
+ cacheSize: 512,
69
+ toolAxis: "coding",
70
+ chatAxis: "intelligence",
71
+ agenticLoopDepth: 3,
72
+ },
73
+ escalation: {
74
+ enabled: true,
75
+ probeTokens: 48,
76
+ maxHoldMs: 8_000,
77
+ // 3 attempts = the original try plus two retries: enough runway for a
78
+ // probe-driven escalation AND a same-tier failover on an upstream error.
79
+ // Each attempt beyond the first can abandon already-generated tokens, so
80
+ // this is the direct dial between turn reliability and wasted spend.
81
+ maxAttempts: 3,
82
+ // Never probe `hard`: the top tier has nowhere to escalate to.
83
+ probeTiers: ["trivial", "simple", "moderate"],
84
+ triggers: [
85
+ "malformed_tool_args",
86
+ "refusal",
87
+ "empty_completion",
88
+ "repeat_tool_call",
89
+ "missing_expected_tool_call",
90
+ ],
91
+ // Scoped to the case it can actually fix: a `length` finish that truncated
92
+ // tool-call arguments leaves unusable output, and another model may emit a
93
+ // well-formed call before the cap. A length finish on prose does NOT
94
+ // escalate — that is the caller's own max_tokens, and the retry truncates
95
+ // in the same place, so escalating just bills twice for one truncation.
96
+ escalateOnLengthStop: true,
97
+ },
98
+ hysteresis: {
99
+ holdTurns: 2,
100
+ holdTurnsAfterEscalation: 4,
101
+ switchMargin: 1.3,
102
+ // OpenRouter sticky sessions expire in 5-10 minutes.
103
+ cacheWarmTtlMs: 300_000,
104
+ maxDowngradePerTurn: 1,
105
+ },
106
+ cache: {
107
+ injectBreakpoints: true,
108
+ // Anthropic allows 4 breakpoints; OpenRouter translates for other vendors.
109
+ maxBreakpoints: 4,
110
+ minPromptTokens: 2_048,
111
+ },
112
+ budget: {
113
+ // No caps by default; at a configured ceiling, downgrade rather than fail.
114
+ onExceeded: "downgrade",
115
+ },
116
+ profiles: [
117
+ { id: "auto", name: "Auto (auto-model-router)", minTier: "trivial", maxTier: "hard", contextWindow: 400_000, maxTokens: 32_000 },
118
+ { id: "auto-cheap", name: "Auto Cheap (auto-model-router)", minTier: "trivial", maxTier: "simple", contextWindow: 400_000, maxTokens: 32_000 },
119
+ { id: "auto-max", name: "Auto Max (auto-model-router)", minTier: "moderate", maxTier: "hard", contextWindow: 400_000, maxTokens: 32_000 },
120
+ ],
121
+ ledger: {
122
+ // Resolved by loadConfig: empty ⇒ `$AUTO_MODEL_ROUTER_HOME/router.db`.
123
+ path: "",
124
+ blendWindowDays: 7,
125
+ blendMinSamples: 25,
126
+ // Pre-measurement blend for omp's cost display: a moderate-heavy mix.
127
+ // Consumers publish cache tokens at the full input rate until measured,
128
+ // so early cost reporting never underreports.
129
+ fallbackBlend: { inputPerMtok: 1.5, outputPerMtok: 7.5 },
130
+ conversationTtlMs: 7 * 24 * 60 * 60 * 1000,
131
+ },
132
+ // On by default: an absolute floor that no available model meets is how the
133
+ // router ends up serving every turn from the cheapest tier.
134
+ adaptiveTierFloors: true,
135
+ logLevel: "info",
136
+ };