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,343 @@
1
+ import { mkdirSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+ import type { Server } from "bun";
4
+ import { createCatalog } from "../catalog/openrouter-catalog.ts";
5
+ import { createLedger } from "../cost/ledger.ts";
6
+ import type { Ledger, ModelTrust } from "../cost/types.ts";
7
+ import { createRouter } from "../router/index.ts";
8
+ import { createConversationStore } from "../router/state.ts";
9
+ import { createOpenRouterClient } from "../upstream/openrouter.ts";
10
+ import { UpstreamError } from "../upstream/types.ts";
11
+ import { apiKeySource } from "../config/load.ts";
12
+ import type { RouterConfig } from "../config/types.ts";
13
+ import { createLogger } from "../util/log.ts";
14
+ import { openDb } from "../util/sqlite.ts";
15
+ import { WireErrorException, renderErrorEnvelope } from "../wire/openai/errors.ts";
16
+ import { renderModelList } from "../wire/openai/models.ts";
17
+ import { parseChatRequest } from "../wire/openai/request.ts";
18
+ import { createBufferedSink, createStreamingSink } from "../wire/openai/sink.ts";
19
+ import type { NormRequest, WireError } from "../wire/types.ts";
20
+ import { runTurn } from "./turn.ts";
21
+
22
+ export interface StartedServer {
23
+ // No websocket upgrade path, so the Server payload type is `undefined`.
24
+ server: Server<undefined>;
25
+ stop(): Promise<void>;
26
+ }
27
+
28
+ export interface ModelSpendRow {
29
+ slug: string;
30
+ requests: number;
31
+ spendUsd: number;
32
+ /** Fraction of window spend attributable to this model, 0-1. */
33
+ share: number;
34
+ }
35
+
36
+ export interface RouterStats {
37
+ generatedAtMs: number;
38
+ /** Window the entry aggregation covers; null ⇒ all retained entries. */
39
+ windowDays: number | null;
40
+ spendTodayUsd: number;
41
+ spend7dUsd: number;
42
+ spendAllTimeUsd: number;
43
+ windowSpendUsd: number;
44
+ requests: number;
45
+ escalations: number;
46
+ escalationRate: number;
47
+ /** Mean |reported - predicted| / predicted over reported entries; null without samples. */
48
+ meanPredictionError: number | null;
49
+ perModel: ModelSpendRow[];
50
+ trust: ModelTrust[];
51
+ }
52
+
53
+ /**
54
+ * Aggregates the ledger for `/v1/router/stats` and `auto-model-router stats`.
55
+ *
56
+ * The ledger exposes no aggregate queries, so per-model breakdowns are
57
+ * computed over a bounded tail of recent entries; the headline spend numbers
58
+ * use `spendSince`, which is exact.
59
+ */
60
+ export function computeStats(ledger: Ledger, opts?: { windowDays?: number; nowMs?: number }): RouterStats {
61
+ const nowMs = opts?.nowMs ?? Date.now();
62
+ const windowDays = opts?.windowDays;
63
+ const cutoffMs = windowDays === undefined ? 0 : nowMs - windowDays * 86_400_000;
64
+
65
+ // 100k turns is operational eternity for a single-operator router; the cap
66
+ // only bounds memory on this read, never what the ledger retains.
67
+ const entries = ledger.recentEntries(100_000).filter((e) => e.createdAtMs >= cutoffMs);
68
+
69
+ const dayStart = new Date(nowMs);
70
+ dayStart.setHours(0, 0, 0, 0);
71
+
72
+ let escalations = 0;
73
+ let errorSamples = 0;
74
+ let errorSum = 0;
75
+ let windowSpendUsd = 0;
76
+ const perModel = new Map<string, { requests: number; spendUsd: number }>();
77
+
78
+ for (const e of entries) {
79
+ if (e.escalationSignal !== null) escalations += 1;
80
+ if (e.reportedUsd !== null && e.predictedUsd > 0) {
81
+ errorSamples += 1;
82
+ errorSum += Math.abs(e.reportedUsd - e.predictedUsd) / e.predictedUsd;
83
+ }
84
+ // Reported cost is authoritative; predicted stands in while it is missing.
85
+ const spend = e.reportedUsd ?? e.predictedUsd;
86
+ windowSpendUsd += spend;
87
+ const row = perModel.get(e.slug) ?? { requests: 0, spendUsd: 0 };
88
+ row.requests += 1;
89
+ row.spendUsd += spend;
90
+ perModel.set(e.slug, row);
91
+ }
92
+
93
+ const rows: ModelSpendRow[] = [];
94
+ for (const [slug, row] of perModel) {
95
+ rows.push({
96
+ slug,
97
+ requests: row.requests,
98
+ spendUsd: row.spendUsd,
99
+ share: windowSpendUsd > 0 ? row.spendUsd / windowSpendUsd : 0,
100
+ });
101
+ }
102
+ rows.sort((a, b) => b.spendUsd - a.spendUsd);
103
+
104
+ return {
105
+ generatedAtMs: nowMs,
106
+ windowDays: windowDays ?? null,
107
+ spendTodayUsd: ledger.spendSince(dayStart.getTime()),
108
+ spend7dUsd: ledger.spendSince(nowMs - 7 * 86_400_000),
109
+ spendAllTimeUsd: ledger.spendSince(0),
110
+ windowSpendUsd,
111
+ requests: entries.length,
112
+ escalations,
113
+ escalationRate: entries.length > 0 ? escalations / entries.length : 0,
114
+ meanPredictionError: errorSamples > 0 ? errorSum / errorSamples : null,
115
+ perModel: rows,
116
+ trust: ledger.allTrust(),
117
+ };
118
+ }
119
+
120
+ function json(data: unknown, status = 200): Response {
121
+ return new Response(JSON.stringify(data), {
122
+ status,
123
+ headers: { "content-type": "application/json" },
124
+ });
125
+ }
126
+
127
+ function wireErrorResponse(err: WireError): Response {
128
+ return new Response(JSON.stringify(renderErrorEnvelope(err)), {
129
+ status: err.status,
130
+ headers: { "content-type": "application/json" },
131
+ });
132
+ }
133
+
134
+ /** Maps any failure thrown or rejected inside the turn pipeline to a wire error. */
135
+ function toWireError(err: unknown): WireError {
136
+ if (err instanceof WireErrorException) return err.wireError;
137
+ if (err instanceof UpstreamError) return err.toWireError();
138
+ // Never forward the raw exception text to the client: it can contain
139
+ // filesystem paths, internal URLs, or unexpected exception detail that aids
140
+ // reconnaissance. The caller logs the real message server-side.
141
+ return {
142
+ status: 500,
143
+ code: "internal_error",
144
+ message: "internal error",
145
+ };
146
+ }
147
+
148
+ /** True when the server is bound to a loopback address (the default). */
149
+ function isLoopbackHost(host: string): boolean {
150
+ return host === "127.0.0.1" || host === "localhost" || host === "::1" || host === "::";
151
+ }
152
+
153
+ /**
154
+ * True when a request's Host header names a loopback address. Blunts DNS
155
+ * rebinding: a malicious page that resolves a host to 127.0.0.1 sends a Host
156
+ * header naming its own domain, which this rejects. Only enforced when the
157
+ * server itself is bound to loopback; an operator who explicitly widens the
158
+ * bind to 0.0.0.0 opts out of the check.
159
+ */
160
+ function isLoopbackHostHeader(hostHeader: string | null): boolean {
161
+ if (hostHeader === null) return false;
162
+ const host = hostHeader.split(":")[0] ?? "";
163
+ return host === "127.0.0.1" || host === "localhost" || host === "::1" || host === "[::1]";
164
+ }
165
+
166
+ export function startServer(cfg: RouterConfig): StartedServer {
167
+ const log = createLogger(cfg.logLevel);
168
+
169
+ mkdirSync(dirname(cfg.ledger.path), { recursive: true });
170
+ const db = openDb(cfg.ledger.path);
171
+ const ledger = createLedger(db, cfg);
172
+ const upstream = createOpenRouterClient(cfg);
173
+ const catalog = createCatalog(cfg, upstream, db);
174
+ const conversations = createConversationStore(db);
175
+ const router = createRouter({ config: cfg, catalog, ledger, conversations, upstream });
176
+ const turnDeps = { config: cfg, router, upstream, ledger, conversations, catalog };
177
+
178
+ if (cfg.openrouter.apiKey === "") {
179
+ log.warn("OPENROUTER_API_KEY is not set; /v1/chat/completions will fail at dispatch time");
180
+ }
181
+
182
+ // Warm the catalog without blocking listen; the first request may race it,
183
+ // which CatalogSource.get() already serializes.
184
+ catalog.get().catch((err: unknown) => {
185
+ log.warn("initial catalog fetch failed", { error: err instanceof Error ? err.message : String(err) });
186
+ });
187
+
188
+ const pruneTimer = setInterval(() => {
189
+ try {
190
+ const dropped = conversations.prune(cfg.ledger.conversationTtlMs);
191
+ if (dropped > 0) log.debug("pruned stale conversations", { dropped });
192
+ } catch (err) {
193
+ log.warn("conversation prune failed", { error: err instanceof Error ? err.message : String(err) });
194
+ }
195
+ }, 60_000);
196
+ pruneTimer.unref();
197
+
198
+ // Periodically refetch the (key-scoped) catalog in the background so
199
+ // guardrail/preference changes are picked up without needing traffic and a
200
+ // TTL expiry. catalogRefreshMs === 0 disables this.
201
+ let catalogRefreshTimer: ReturnType<typeof setInterval> | undefined;
202
+ if (cfg.openrouter.catalogRefreshMs > 0) {
203
+ catalogRefreshTimer = setInterval(() => {
204
+ catalog.refresh().catch((err: unknown) => {
205
+ log.warn("periodic catalog refresh failed; keeping last snapshot", {
206
+ error: err instanceof Error ? err.message : String(err),
207
+ });
208
+ });
209
+ }, cfg.openrouter.catalogRefreshMs);
210
+ catalogRefreshTimer.unref();
211
+ }
212
+
213
+ // Cap concurrent in-flight turns so a burst of requests cannot hold many
214
+ // upstream streams at once (each can run up to idleTimeout). Excess requests
215
+ // are rejected with 429 rather than queued, so a local flood cannot pile up
216
+ // unbounded upstream spend or memory.
217
+ const MAX_CONCURRENT_TURNS = 8;
218
+ let inFlightTurns = 0;
219
+ const acquireTurn = (): boolean => {
220
+ if (inFlightTurns >= MAX_CONCURRENT_TURNS) return false;
221
+ inFlightTurns++;
222
+ return true;
223
+ };
224
+ const releaseTurn = (): void => {
225
+ inFlightTurns--;
226
+ };
227
+
228
+ const handleChatCompletions = async (req: Request): Promise<Response> => {
229
+ let normReq: NormRequest;
230
+ try {
231
+ normReq = parseChatRequest(await req.json(), req.headers);
232
+ } catch (err) {
233
+ if (err instanceof WireErrorException) return wireErrorResponse(err.wireError);
234
+ return wireErrorResponse({
235
+ status: 400,
236
+ code: "invalid_json",
237
+ message: err instanceof Error ? err.message : "request body is not valid JSON",
238
+ });
239
+ }
240
+
241
+ const { sink, response } = normReq.stream
242
+ ? createStreamingSink(normReq.requestedModel)
243
+ : createBufferedSink(normReq.requestedModel);
244
+
245
+ // The client signal aborts the upstream dispatch on disconnect. runTurn is
246
+ // expected to render its own failures into the sink; this catch is the last
247
+ // line of defence so a rejected turn can never wedge the response.
248
+ runTurn(normReq, sink, turnDeps, req.signal)
249
+ .catch((err: unknown) => {
250
+ log.error("turn failed", { error: err instanceof Error ? err.message : String(err) });
251
+ return Promise.resolve(sink.error(toWireError(err))).catch(() => {});
252
+ })
253
+ .finally(() => {
254
+ // Release the concurrency slot when the turn settles, not when the
255
+ // streaming response object is handed back.
256
+ releaseTurn();
257
+ });
258
+
259
+ return response;
260
+ };
261
+
262
+ const server: Server<undefined> = Bun.serve({
263
+ hostname: cfg.server.host,
264
+ port: cfg.server.port,
265
+ // Bun's default idleTimeout is 10s, which is far shorter than a real
266
+ // generation can sit silent: the escalation guard holds the first tokens
267
+ // for up to maxHoldMs while a reasoning model is still producing its
268
+ // first token, and frontier models can think for tens of seconds between
269
+ // chunks. A 10s gap would close the socket mid-stream and surface to omp
270
+ // as "socket connection was closed unexpectedly". idleTimeout is in
271
+ // SECONDS (max 255), so convert from the ms upstream timeout and cap.
272
+ idleTimeout: Math.min(Math.ceil(cfg.openrouter.timeoutMs / 1000), 255),
273
+ async fetch(req: Request): Promise<Response> {
274
+ // Reject requests whose Host header does not name a loopback address
275
+ // when the server is bound to loopback. This blunts DNS rebinding: a
276
+ // malicious page resolving a host to 127.0.0.1 sends its own domain as
277
+ // the Host header, which this rejects. An operator who explicitly
278
+ // widens the bind to 0.0.0.0 opts out of the check.
279
+ if (isLoopbackHost(cfg.server.host) && !isLoopbackHostHeader(req.headers.get("host"))) {
280
+ return wireErrorResponse({ status: 403, code: "forbidden", message: "invalid host" });
281
+ }
282
+
283
+ if (cfg.server.apiKey !== undefined && cfg.server.apiKey !== "") {
284
+ if (req.headers.get("authorization") !== `Bearer ${cfg.server.apiKey}`) {
285
+ return wireErrorResponse({ status: 401, code: "unauthorized", message: "invalid or missing bearer token" });
286
+ }
287
+ }
288
+
289
+ const url = new URL(req.url);
290
+ try {
291
+ if (req.method === "POST" && url.pathname === "/v1/chat/completions") {
292
+ if (!acquireTurn()) {
293
+ return wireErrorResponse({ status: 429, code: "too_many_requests", message: "too many concurrent turns" });
294
+ }
295
+ return await handleChatCompletions(req);
296
+ }
297
+ if (req.method === "GET" && url.pathname === "/v1/models") {
298
+ return json(renderModelList(cfg, ledger.blendedRate(cfg.ledger.blendWindowDays)));
299
+ }
300
+ if (req.method === "GET" && url.pathname === "/v1/router/stats") {
301
+ return json(computeStats(ledger));
302
+ }
303
+ if (req.method === "GET" && url.pathname === "/v1/router/decisions") {
304
+ const rawLimit = url.searchParams.get("limit");
305
+ const parsed = rawLimit === null ? 50 : Number.parseInt(rawLimit, 10);
306
+ const limit = Number.isInteger(parsed) ? Math.min(Math.max(parsed, 1), 1_000) : 50;
307
+ return json({ entries: ledger.recentEntries(limit) });
308
+ }
309
+ if (req.method === "GET" && url.pathname === "/health") {
310
+ const snap = catalog.peek();
311
+ return json({
312
+ status: "ok",
313
+ apiKeyConfigured: cfg.openrouter.apiKey !== "",
314
+ // Provenance only; never the key itself.
315
+ apiKeySource: apiKeySource(cfg).source,
316
+ catalog: snap === null
317
+ ? null
318
+ : {
319
+ models: snap.models.length,
320
+ fetchedAtMs: snap.fetchedAtMs,
321
+ ageMs: Date.now() - snap.fetchedAtMs,
322
+ keyScoped: snap.keyScoped === true,
323
+ },
324
+ });
325
+ }
326
+ return wireErrorResponse({ status: 404, code: "not_found", message: `no route for ${req.method} ${url.pathname}` });
327
+ } catch (err) {
328
+ log.error("request failed", { path: url.pathname, error: err instanceof Error ? err.message : String(err) });
329
+ return wireErrorResponse(toWireError(err));
330
+ }
331
+ },
332
+ });
333
+
334
+ return {
335
+ server,
336
+ stop: async () => {
337
+ clearInterval(pruneTimer);
338
+ clearInterval(catalogRefreshTimer);
339
+ await server.stop(true);
340
+ db.close();
341
+ },
342
+ };
343
+ }