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.
- package/.env.example +24 -0
- package/.github/workflows/publish.yml +40 -0
- package/.omp-plugin/marketplace.json +30 -0
- package/LICENSE +21 -0
- package/README.md +639 -0
- package/bun.lock +32 -0
- package/docs/claude-anthropic-wire.md +116 -0
- package/omp-extension/configure-logic.ts +128 -0
- package/omp-extension/embed-logic.ts +141 -0
- package/omp-extension/router-configure.ts +111 -0
- package/omp-extension/router-embed.ts +118 -0
- package/omp-extension/router-toast.ts +130 -0
- package/omp-extension/toast-logic.ts +136 -0
- package/package.json +56 -0
- package/src/catalog/openrouter-catalog.ts +428 -0
- package/src/catalog/types.ts +104 -0
- package/src/cli/args.ts +105 -0
- package/src/cli/config-cmd.ts +362 -0
- package/src/cli/config-wizard.ts +636 -0
- package/src/cli/explain.ts +167 -0
- package/src/cli/models.ts +240 -0
- package/src/cli/stats.ts +69 -0
- package/src/config/defaults.ts +136 -0
- package/src/config/load.ts +143 -0
- package/src/config/omp-credentials.ts +124 -0
- package/src/config/schema.ts +161 -0
- package/src/config/types.ts +244 -0
- package/src/cost/blended.ts +80 -0
- package/src/cost/forecast.ts +129 -0
- package/src/cost/ledger.ts +291 -0
- package/src/cost/types.ts +148 -0
- package/src/index.ts +93 -0
- package/src/router/cache-control.ts +66 -0
- package/src/router/candidates.ts +246 -0
- package/src/router/classify.ts +329 -0
- package/src/router/escalate.ts +264 -0
- package/src/router/features.ts +225 -0
- package/src/router/index.ts +99 -0
- package/src/router/select.ts +365 -0
- package/src/router/state.ts +118 -0
- package/src/router/tier-plan.ts +151 -0
- package/src/router/types.ts +222 -0
- package/src/server/http.ts +343 -0
- package/src/server/turn.ts +393 -0
- package/src/tokens/estimate.ts +74 -0
- package/src/upstream/openrouter.ts +221 -0
- package/src/upstream/sse-parse.ts +208 -0
- package/src/upstream/types.ts +75 -0
- package/src/util/hash.ts +0 -0
- package/src/util/log.ts +53 -0
- package/src/util/sqlite.ts +140 -0
- package/src/util/sse.ts +23 -0
- package/src/wire/openai/errors.ts +48 -0
- package/src/wire/openai/models.ts +37 -0
- package/src/wire/openai/request.ts +279 -0
- package/src/wire/openai/sink.ts +213 -0
- package/src/wire/types.ts +156 -0
- package/test/catalog.test.ts +319 -0
- package/test/classify.test.ts +269 -0
- package/test/config-wizard.test.ts +482 -0
- package/test/config.test.ts +121 -0
- package/test/configure-logic.test.ts +151 -0
- package/test/cost.test.ts +137 -0
- package/test/embed-logic.test.ts +107 -0
- package/test/escalate.test.ts +223 -0
- package/test/failover.test.ts +494 -0
- package/test/features.test.ts +228 -0
- package/test/fixtures/openrouter-models.json +15340 -0
- package/test/models-yml.test.ts +186 -0
- package/test/omp-credentials.test.ts +185 -0
- package/test/select.test.ts +538 -0
- package/test/sse-parse.test.ts +142 -0
- package/test/tier-plan.test.ts +302 -0
- package/test/toast-logic.test.ts +160 -0
- package/test/tokens.test.ts +160 -0
- package/test/trust-attribution.test.ts +175 -0
- package/test/turn.test.ts +498 -0
- package/test/wire-request.test.ts +297 -0
- package/test/wire-sink.test.ts +179 -0
- package/tools/install.ts +140 -0
- package/tools/mock-openrouter.ts +269 -0
- package/tools/smoke.ts +326 -0
- package/tsconfig.json +23 -0
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scriptable stand-in for the OpenRouter API, for end-to-end verification
|
|
3
|
+
* without an API key or real spend.
|
|
4
|
+
*
|
|
5
|
+
* Serves the real 147-model catalog fixture from `GET /models`, and synthesizes
|
|
6
|
+
* SSE chat completions whose shape and `usage` accounting mirror OpenRouter's
|
|
7
|
+
* wire format (including `prompt_tokens_details` and `cost`).
|
|
8
|
+
*
|
|
9
|
+
* Behaviour is driven at runtime via `POST /__control` so a driver can force
|
|
10
|
+
* the exact failure a guarded probe is supposed to catch.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { existsSync } from "node:fs";
|
|
14
|
+
|
|
15
|
+
export interface MockControl {
|
|
16
|
+
/** Slugs that emit a truncated tool call, tripping `malformed_tool_args`. */
|
|
17
|
+
truncateToolArgs: string[];
|
|
18
|
+
/**
|
|
19
|
+
* Truncate the tool call on the next N tool-offering generations, whatever
|
|
20
|
+
* model serves them. Lets a test exercise escalation without predicting
|
|
21
|
+
* which slug the router will pick.
|
|
22
|
+
*/
|
|
23
|
+
truncateFirstN: number;
|
|
24
|
+
/** Slugs that finish `stop` with no content, tripping `empty_completion`. */
|
|
25
|
+
emptyCompletion: string[];
|
|
26
|
+
/** Slugs that open with a refusal, tripping `refusal`. */
|
|
27
|
+
refuse: string[];
|
|
28
|
+
/** Slugs that answer with the tool call named here, to trip `repeat_tool_call`. */
|
|
29
|
+
echoToolCall: Record<string, { name: string; arguments: string }>;
|
|
30
|
+
/** Slugs that fail with this HTTP status before streaming. */
|
|
31
|
+
failWith: Record<string, number>;
|
|
32
|
+
/**
|
|
33
|
+
* Guardrail allowlist: when non-empty, `/models/user` returns ONLY models
|
|
34
|
+
* matching these slugs.
|
|
35
|
+
*/
|
|
36
|
+
allowedModels: string[];
|
|
37
|
+
/** Fraction of prompt tokens reported as cache reads. */
|
|
38
|
+
cacheHitRate: number;
|
|
39
|
+
/** Artificial delay before the first content chunk, ms. */
|
|
40
|
+
ttftMs: number;
|
|
41
|
+
}
|
|
42
|
+
const DEFAULT_CONTROL: MockControl = {
|
|
43
|
+
truncateToolArgs: [],
|
|
44
|
+
truncateFirstN: 0,
|
|
45
|
+
emptyCompletion: [],
|
|
46
|
+
refuse: [],
|
|
47
|
+
echoToolCall: {},
|
|
48
|
+
failWith: {},
|
|
49
|
+
allowedModels: [],
|
|
50
|
+
cacheHitRate: 0,
|
|
51
|
+
ttftMs: 0,
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export interface MockServer {
|
|
55
|
+
url: string;
|
|
56
|
+
port: number;
|
|
57
|
+
control: MockControl;
|
|
58
|
+
/** Every completion request observed, in order. Assertions read this. */
|
|
59
|
+
requests: { model: string; models: string[] | undefined; sessionId: string | null; body: Record<string, unknown> }[];
|
|
60
|
+
stop(): Promise<void>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function sse(payload: unknown): string {
|
|
64
|
+
return `data: ${JSON.stringify(payload)}\n\n`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function startMockOpenRouter(fixturePath: string, port = 0): Promise<MockServer> {
|
|
68
|
+
if (!existsSync(fixturePath)) throw new Error(`mock catalog fixture missing: ${fixturePath}`);
|
|
69
|
+
const catalog = await Bun.file(fixturePath).text();
|
|
70
|
+
const control: MockControl = { ...DEFAULT_CONTROL };
|
|
71
|
+
const requests: MockServer["requests"] = [];
|
|
72
|
+
|
|
73
|
+
const server = Bun.serve({
|
|
74
|
+
port,
|
|
75
|
+
hostname: "127.0.0.1",
|
|
76
|
+
idleTimeout: 60,
|
|
77
|
+
async fetch(req) {
|
|
78
|
+
const url = new URL(req.url);
|
|
79
|
+
|
|
80
|
+
if (url.pathname === "/__control" && req.method === "POST") {
|
|
81
|
+
Object.assign(control, (await req.json()) as Partial<MockControl>);
|
|
82
|
+
return Response.json({ ok: true, control });
|
|
83
|
+
}
|
|
84
|
+
if (url.pathname === "/__requests") return Response.json(requests);
|
|
85
|
+
if (url.pathname.endsWith("/models/user")) {
|
|
86
|
+
if (control.allowedModels.length === 0) {
|
|
87
|
+
return new Response(catalog, { headers: { "content-type": "application/json" } });
|
|
88
|
+
}
|
|
89
|
+
try {
|
|
90
|
+
const parsed = JSON.parse(catalog) as { data: Array<{ id?: string }> };
|
|
91
|
+
const filtered = parsed.data.filter((m) => typeof m.id === "string" && control.allowedModels.includes(m.id));
|
|
92
|
+
return Response.json({ ...parsed, data: filtered });
|
|
93
|
+
} catch {
|
|
94
|
+
return new Response(catalog, { headers: { "content-type": "application/json" } });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (url.pathname.endsWith("/models")) {
|
|
98
|
+
return new Response(catalog, { headers: { "content-type": "application/json" } });
|
|
99
|
+
}
|
|
100
|
+
if (!url.pathname.endsWith("/chat/completions")) {
|
|
101
|
+
return Response.json({ error: { message: "not found" } }, { status: 404 });
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const body = (await req.json()) as Record<string, unknown>;
|
|
105
|
+
const model = String(body.model ?? "");
|
|
106
|
+
requests.push({
|
|
107
|
+
model,
|
|
108
|
+
models: Array.isArray(body.models) ? (body.models as string[]) : undefined,
|
|
109
|
+
sessionId: req.headers.get("x-session-id"),
|
|
110
|
+
body,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const status = control.failWith[model];
|
|
114
|
+
if (status !== undefined) {
|
|
115
|
+
return Response.json(
|
|
116
|
+
{ error: { message: `mock forced status ${status} for ${model}`, code: status } },
|
|
117
|
+
{ status },
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Plausible magnitudes suffice here; the mock is not a tokenizer.
|
|
122
|
+
const promptTokens = Math.max(1, Math.ceil(JSON.stringify(body).length / 3.6));
|
|
123
|
+
const cached = Math.floor(promptTokens * control.cacheHitRate);
|
|
124
|
+
const offersTools = Array.isArray(body.tools) && body.tools.length > 0;
|
|
125
|
+
const generationId = `gen-mock-${Math.random().toString(36).slice(2, 10)}`;
|
|
126
|
+
|
|
127
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
128
|
+
async start(controller) {
|
|
129
|
+
const enc = new TextEncoder();
|
|
130
|
+
const push = (s: string) => controller.enqueue(enc.encode(s));
|
|
131
|
+
const base = { id: generationId, object: "chat.completion.chunk", created: Math.floor(Date.now() / 1e3), model };
|
|
132
|
+
|
|
133
|
+
// OpenRouter emits keep-alive comments on slow upstreams; exercise that path.
|
|
134
|
+
push(": OPENROUTER PROCESSING\n\n");
|
|
135
|
+
push(sse({ ...base, choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }] }));
|
|
136
|
+
if (control.ttftMs > 0) await Bun.sleep(control.ttftMs);
|
|
137
|
+
|
|
138
|
+
let finishReason = "stop";
|
|
139
|
+
let completionTokens = 8;
|
|
140
|
+
|
|
141
|
+
const echo = control.echoToolCall[model];
|
|
142
|
+
if (control.emptyCompletion.includes(model)) {
|
|
143
|
+
completionTokens = 0;
|
|
144
|
+
} else if (control.refuse.includes(model)) {
|
|
145
|
+
for (const piece of ["I'm sorry, ", "but I can't ", "help with that."]) {
|
|
146
|
+
push(sse({ ...base, choices: [{ index: 0, delta: { content: piece }, finish_reason: null }] }));
|
|
147
|
+
}
|
|
148
|
+
} else if (echo) {
|
|
149
|
+
push(
|
|
150
|
+
sse({
|
|
151
|
+
...base,
|
|
152
|
+
choices: [
|
|
153
|
+
{
|
|
154
|
+
index: 0,
|
|
155
|
+
delta: {
|
|
156
|
+
tool_calls: [
|
|
157
|
+
{ index: 0, id: "call_echo", type: "function", function: { name: echo.name, arguments: "" } },
|
|
158
|
+
],
|
|
159
|
+
},
|
|
160
|
+
finish_reason: null,
|
|
161
|
+
},
|
|
162
|
+
],
|
|
163
|
+
}),
|
|
164
|
+
);
|
|
165
|
+
push(
|
|
166
|
+
sse({
|
|
167
|
+
...base,
|
|
168
|
+
choices: [
|
|
169
|
+
{
|
|
170
|
+
index: 0,
|
|
171
|
+
delta: { tool_calls: [{ index: 0, function: { arguments: echo.arguments } }] },
|
|
172
|
+
finish_reason: null,
|
|
173
|
+
},
|
|
174
|
+
],
|
|
175
|
+
}),
|
|
176
|
+
);
|
|
177
|
+
finishReason = "tool_calls";
|
|
178
|
+
} else if (offersTools) {
|
|
179
|
+
const full = JSON.stringify({ path: "src/index.ts" });
|
|
180
|
+
// Split the argument JSON across frames; truncate it when scripted to,
|
|
181
|
+
// which is exactly the malformed-tool-args case the probe must catch.
|
|
182
|
+
let truncate = control.truncateToolArgs.includes(model);
|
|
183
|
+
if (!truncate && control.truncateFirstN > 0) {
|
|
184
|
+
truncate = true;
|
|
185
|
+
control.truncateFirstN--;
|
|
186
|
+
}
|
|
187
|
+
const args = truncate ? full.slice(0, full.length - 4) : full;
|
|
188
|
+
push(
|
|
189
|
+
sse({
|
|
190
|
+
...base,
|
|
191
|
+
choices: [
|
|
192
|
+
{
|
|
193
|
+
index: 0,
|
|
194
|
+
delta: {
|
|
195
|
+
tool_calls: [
|
|
196
|
+
{ index: 0, id: "call_mock", type: "function", function: { name: "read", arguments: "" } },
|
|
197
|
+
],
|
|
198
|
+
},
|
|
199
|
+
finish_reason: null,
|
|
200
|
+
},
|
|
201
|
+
],
|
|
202
|
+
}),
|
|
203
|
+
);
|
|
204
|
+
for (let i = 0; i < args.length; i += 7) {
|
|
205
|
+
push(
|
|
206
|
+
sse({
|
|
207
|
+
...base,
|
|
208
|
+
choices: [
|
|
209
|
+
{
|
|
210
|
+
index: 0,
|
|
211
|
+
delta: { tool_calls: [{ index: 0, function: { arguments: args.slice(i, i + 7) } }] },
|
|
212
|
+
finish_reason: null,
|
|
213
|
+
},
|
|
214
|
+
],
|
|
215
|
+
}),
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
finishReason = "tool_calls";
|
|
219
|
+
completionTokens = 24;
|
|
220
|
+
} else {
|
|
221
|
+
push(sse({ ...base, choices: [{ index: 0, delta: { reasoning: "weighing options" }, finish_reason: null }] }));
|
|
222
|
+
for (const piece of ["Mock ", "answer ", "from ", model, "."]) {
|
|
223
|
+
push(sse({ ...base, choices: [{ index: 0, delta: { content: piece }, finish_reason: null }] }));
|
|
224
|
+
}
|
|
225
|
+
completionTokens = 12;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
push(sse({ ...base, choices: [{ index: 0, delta: {}, finish_reason: finishReason }] }));
|
|
229
|
+
push(
|
|
230
|
+
sse({
|
|
231
|
+
...base,
|
|
232
|
+
choices: [],
|
|
233
|
+
usage: {
|
|
234
|
+
prompt_tokens: promptTokens,
|
|
235
|
+
completion_tokens: completionTokens,
|
|
236
|
+
total_tokens: promptTokens + completionTokens,
|
|
237
|
+
prompt_tokens_details: { cached_tokens: cached, cache_write_tokens: cached > 0 ? 0 : promptTokens },
|
|
238
|
+
completion_tokens_details: { reasoning_tokens: 0 },
|
|
239
|
+
cost: (promptTokens * 3e-6 + completionTokens * 1.5e-5) as number,
|
|
240
|
+
cost_details: { upstream_inference_cost: 0 },
|
|
241
|
+
},
|
|
242
|
+
}),
|
|
243
|
+
);
|
|
244
|
+
push("data: [DONE]\n\n");
|
|
245
|
+
controller.close();
|
|
246
|
+
},
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
return new Response(stream, {
|
|
250
|
+
headers: { "content-type": "text/event-stream", "cache-control": "no-cache" },
|
|
251
|
+
});
|
|
252
|
+
},
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
return {
|
|
256
|
+
url: `http://127.0.0.1:${server.port}`,
|
|
257
|
+
port: server.port,
|
|
258
|
+
control,
|
|
259
|
+
requests,
|
|
260
|
+
stop: async () => {
|
|
261
|
+
await server.stop(true);
|
|
262
|
+
},
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (import.meta.main) {
|
|
267
|
+
const mock = await startMockOpenRouter("test/fixtures/openrouter-models.json", Number(process.env.MOCK_PORT ?? 8799));
|
|
268
|
+
console.log(`mock openrouter listening on ${mock.url}`);
|
|
269
|
+
}
|
package/tools/smoke.ts
ADDED
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* End-to-end smoke test: real router process, real HTTP, real SQLite ledger,
|
|
3
|
+
* mock OpenRouter upstream. Proves the pipeline actually routes, escalates,
|
|
4
|
+
* and accounts for spend without needing an API key or spending money.
|
|
5
|
+
*
|
|
6
|
+
* Responses are parsed with schemas rather than asserted with casts, so the
|
|
7
|
+
* harness also verifies the client-facing wire contract.
|
|
8
|
+
*
|
|
9
|
+
* Run: bun run tools/smoke.ts
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
13
|
+
import { tmpdir } from "node:os";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { z } from "zod";
|
|
16
|
+
|
|
17
|
+
import { loadConfig } from "../src/config/load.ts";
|
|
18
|
+
import { startServer } from "../src/server/http.ts";
|
|
19
|
+
import { startMockOpenRouter } from "./mock-openrouter.ts";
|
|
20
|
+
|
|
21
|
+
const failures: string[] = [];
|
|
22
|
+
let checks = 0;
|
|
23
|
+
|
|
24
|
+
function check(label: string, ok: boolean, detail?: unknown) {
|
|
25
|
+
checks++;
|
|
26
|
+
if (ok) {
|
|
27
|
+
console.log(` PASS ${label}`);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
failures.push(label);
|
|
31
|
+
console.log(` FAIL ${label}${detail === undefined ? "" : `\n ${JSON.stringify(detail)}`}`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const HealthSchema = z
|
|
35
|
+
.object({
|
|
36
|
+
status: z.string(),
|
|
37
|
+
apiKeyConfigured: z.boolean(),
|
|
38
|
+
catalog: z.object({ models: z.number(), fetchedAtMs: z.number(), ageMs: z.number() }).nullable(),
|
|
39
|
+
})
|
|
40
|
+
.loose();
|
|
41
|
+
const ModelListSchema = z.object({ data: z.array(z.object({ id: z.string() }).loose()) });
|
|
42
|
+
const StatsSchema = z
|
|
43
|
+
.object({
|
|
44
|
+
spendAllTimeUsd: z.number(),
|
|
45
|
+
requests: z.number(),
|
|
46
|
+
escalations: z.number(),
|
|
47
|
+
perModel: z.array(z.object({ slug: z.string(), spendUsd: z.number() }).loose()),
|
|
48
|
+
})
|
|
49
|
+
.loose();
|
|
50
|
+
const DecisionsSchema = z.object({
|
|
51
|
+
entries: z.array(
|
|
52
|
+
z.object({ wasted: z.boolean(), slug: z.string(), tier: z.string(), attempt: z.number() }).loose(),
|
|
53
|
+
),
|
|
54
|
+
});
|
|
55
|
+
const BufferedSchema = z
|
|
56
|
+
.object({
|
|
57
|
+
choices: z.array(z.object({ message: z.object({ content: z.string().nullable() }).loose() }).loose()),
|
|
58
|
+
usage: z.object({ prompt_tokens: z.number(), completion_tokens: z.number() }).loose(),
|
|
59
|
+
})
|
|
60
|
+
.loose();
|
|
61
|
+
|
|
62
|
+
/** Streaming frame, modelled loosely: unknown fields must survive passthrough. */
|
|
63
|
+
const FrameSchema = z.object({
|
|
64
|
+
model: z.string().optional(),
|
|
65
|
+
x_auto_model_router: z.object({ model: z.string().optional(), tier: z.string().optional() }).loose().optional(),
|
|
66
|
+
choices: z
|
|
67
|
+
.array(
|
|
68
|
+
z.object({
|
|
69
|
+
delta: z
|
|
70
|
+
.object({
|
|
71
|
+
content: z.string().nullish(),
|
|
72
|
+
tool_calls: z
|
|
73
|
+
.array(z.object({ function: z.object({ arguments: z.string().optional() }).loose().optional() }).loose())
|
|
74
|
+
.optional(),
|
|
75
|
+
})
|
|
76
|
+
.loose()
|
|
77
|
+
.optional(),
|
|
78
|
+
}).loose(),
|
|
79
|
+
)
|
|
80
|
+
.optional(),
|
|
81
|
+
}).loose();
|
|
82
|
+
|
|
83
|
+
const TOOLS = [
|
|
84
|
+
{
|
|
85
|
+
type: "function",
|
|
86
|
+
function: {
|
|
87
|
+
name: "read",
|
|
88
|
+
description: "Read a file from disk",
|
|
89
|
+
parameters: { type: "object", properties: { path: { type: "string" } }, required: ["path"] },
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
type: "function",
|
|
94
|
+
function: {
|
|
95
|
+
name: "bash",
|
|
96
|
+
description: "Run a shell command",
|
|
97
|
+
parameters: { type: "object", properties: { command: { type: "string" } }, required: ["command"] },
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
];
|
|
101
|
+
|
|
102
|
+
const SYSTEM = "You are a coding agent operating in a repository. Use tools to inspect files before editing.";
|
|
103
|
+
|
|
104
|
+
interface Turn {
|
|
105
|
+
role: string;
|
|
106
|
+
content: unknown;
|
|
107
|
+
tool_calls?: unknown[];
|
|
108
|
+
tool_call_id?: string;
|
|
109
|
+
name?: string;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function body(messages: Turn[], opts: { tools?: boolean; model?: string; stream?: boolean } = {}) {
|
|
113
|
+
const out: Record<string, unknown> = {
|
|
114
|
+
model: opts.model ?? "auto",
|
|
115
|
+
messages: [{ role: "system", content: SYSTEM }, ...messages],
|
|
116
|
+
stream: opts.stream ?? true,
|
|
117
|
+
};
|
|
118
|
+
if (opts.tools !== false) out.tools = TOOLS;
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Drains an SSE response into concatenated content, tool arguments, and router metadata. */
|
|
123
|
+
async function drain(res: Response) {
|
|
124
|
+
const text = await res.text();
|
|
125
|
+
let content = "";
|
|
126
|
+
let toolArgs = "";
|
|
127
|
+
let seenModel = "";
|
|
128
|
+
let meta: { model?: string; tier?: string } | undefined;
|
|
129
|
+
|
|
130
|
+
for (const line of text.split("\n")) {
|
|
131
|
+
if (!line.startsWith("data: ")) continue;
|
|
132
|
+
const payload = line.slice(6).trim();
|
|
133
|
+
if (payload === "[DONE]") break;
|
|
134
|
+
let parsed: unknown;
|
|
135
|
+
try {
|
|
136
|
+
parsed = JSON.parse(payload);
|
|
137
|
+
} catch {
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
const frame = FrameSchema.safeParse(parsed);
|
|
141
|
+
if (!frame.success) continue;
|
|
142
|
+
if (frame.data.model !== undefined) seenModel = frame.data.model;
|
|
143
|
+
if (frame.data.x_auto_model_router !== undefined) meta = frame.data.x_auto_model_router;
|
|
144
|
+
const delta = frame.data.choices?.[0]?.delta;
|
|
145
|
+
if (typeof delta?.content === "string") content += delta.content;
|
|
146
|
+
for (const call of delta?.tool_calls ?? []) toolArgs += call.function?.arguments ?? "";
|
|
147
|
+
}
|
|
148
|
+
return { content, meta, toolArgs, seenModel, raw: text };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const home = mkdtempSync(join(tmpdir(), "auto-model-router-smoke-"));
|
|
152
|
+
const mock = await startMockOpenRouter("test/fixtures/openrouter-models.json");
|
|
153
|
+
console.log(`mock openrouter: ${mock.url}`);
|
|
154
|
+
|
|
155
|
+
const cfg = loadConfig({});
|
|
156
|
+
cfg.server = { host: "127.0.0.1", port: 0 };
|
|
157
|
+
cfg.openrouter.baseUrl = `${mock.url}/api/v1`;
|
|
158
|
+
cfg.openrouter.apiKey = "sk-mock";
|
|
159
|
+
cfg.ledger.path = join(home, "router.db");
|
|
160
|
+
cfg.logLevel = "warn";
|
|
161
|
+
// The adjudicator would call the mock and obscure which tier the heuristic chose.
|
|
162
|
+
cfg.classifier.ambiguityThreshold = 0;
|
|
163
|
+
|
|
164
|
+
const app = startServer(cfg);
|
|
165
|
+
const base = `http://127.0.0.1:${app.server.port}`;
|
|
166
|
+
console.log(`auto-model-router: ${base}\n`);
|
|
167
|
+
|
|
168
|
+
const post = (path: string, payload: unknown) =>
|
|
169
|
+
fetch(`${base}${path}`, {
|
|
170
|
+
method: "POST",
|
|
171
|
+
headers: { "content-type": "application/json" },
|
|
172
|
+
body: JSON.stringify(payload),
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
const TIER_RANK = ["trivial", "simple", "moderate", "hard"];
|
|
177
|
+
const tierRank = (tier: string): number => TIER_RANK.indexOf(tier);
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Tier the most recent turn's FIRST attempt used. `/decisions` is newest-first,
|
|
181
|
+
* so the first `attempt === 0` row belongs to the turn that just finished.
|
|
182
|
+
*/
|
|
183
|
+
async function firstAttemptTier(): Promise<string> {
|
|
184
|
+
const res = await fetch(`${base}/v1/router/decisions?limit=50`);
|
|
185
|
+
const entries = DecisionsSchema.parse(await res.json()).entries;
|
|
186
|
+
return entries.find((e) => e.attempt === 0)?.tier ?? "";
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
try {
|
|
190
|
+
console.log("[1] discovery surface");
|
|
191
|
+
// The server warms the catalog without blocking listen, so poll rather than race it.
|
|
192
|
+
let health = HealthSchema.parse(await (await fetch(`${base}/health`)).json());
|
|
193
|
+
for (let i = 0; i < 100 && health.catalog === null; i++) {
|
|
194
|
+
await Bun.sleep(100);
|
|
195
|
+
health = HealthSchema.parse(await (await fetch(`${base}/health`)).json());
|
|
196
|
+
}
|
|
197
|
+
check("health reports a warm catalog", (health.catalog?.models ?? 0) > 50, health);
|
|
198
|
+
const models = ModelListSchema.parse(await (await fetch(`${base}/v1/models`)).json());
|
|
199
|
+
const ids = models.data.map((m) => m.id);
|
|
200
|
+
check("advertises the auto profiles", ids.includes("auto") && ids.includes("auto-cheap"), ids);
|
|
201
|
+
|
|
202
|
+
console.log("\n[2] plain chat turn routes cheap");
|
|
203
|
+
mock.requests.length = 0;
|
|
204
|
+
const chat = await drain(await post("/v1/chat/completions", body([{ role: "user", content: "hi" }], { tools: false })));
|
|
205
|
+
const chatSlug = mock.requests[0]?.model ?? "";
|
|
206
|
+
check("dispatched exactly one upstream generation", mock.requests.length === 1, mock.requests.length);
|
|
207
|
+
check("client sees the virtual model, not the slug", chat.seenModel === "auto", chat.seenModel);
|
|
208
|
+
check("served slug is reported in metadata", typeof chat.meta?.model === "string", chat.meta);
|
|
209
|
+
check("never routes to an openrouter meta-model", !chatSlug.startsWith("openrouter/"), chatSlug);
|
|
210
|
+
check("never routes to a batch endpoint", !chatSlug.endsWith(":batch"), chatSlug);
|
|
211
|
+
check("never routes to a floating alias", !chatSlug.startsWith("~"), chatSlug);
|
|
212
|
+
check("forwards a session id for cache stickiness", Boolean(mock.requests[0]?.sessionId), mock.requests[0]?.sessionId);
|
|
213
|
+
console.log(` -> ${chatSlug} (tier ${chat.meta?.tier})`);
|
|
214
|
+
|
|
215
|
+
console.log("\n[3] complexity separates tiers within one conversation");
|
|
216
|
+
mock.requests.length = 0;
|
|
217
|
+
const hardRes = await drain(
|
|
218
|
+
await post(
|
|
219
|
+
"/v1/chat/completions",
|
|
220
|
+
body([
|
|
221
|
+
{
|
|
222
|
+
role: "user",
|
|
223
|
+
content:
|
|
224
|
+
"Our worker pool deadlocks under load. Walk through the root cause, explain the race between the queue drain and the shutdown path, and propose an architecture that removes the invariant violation.",
|
|
225
|
+
},
|
|
226
|
+
]),
|
|
227
|
+
),
|
|
228
|
+
);
|
|
229
|
+
const hardSlug = mock.requests[0]?.model ?? "";
|
|
230
|
+
const hardFirstTier = await firstAttemptTier();
|
|
231
|
+
|
|
232
|
+
mock.requests.length = 0;
|
|
233
|
+
const mechRes = await drain(
|
|
234
|
+
await post(
|
|
235
|
+
"/v1/chat/completions",
|
|
236
|
+
body([
|
|
237
|
+
{ role: "user", content: "read src/index.ts" },
|
|
238
|
+
{
|
|
239
|
+
role: "assistant",
|
|
240
|
+
content: null,
|
|
241
|
+
tool_calls: [{ id: "c1", type: "function", function: { name: "read", arguments: '{"path":"src/index.ts"}' } }],
|
|
242
|
+
},
|
|
243
|
+
{ role: "tool", tool_call_id: "c1", name: "read", content: "export const version = '1.0.0';\n" },
|
|
244
|
+
]),
|
|
245
|
+
),
|
|
246
|
+
);
|
|
247
|
+
const mechSlug = mock.requests[0]?.model ?? "";
|
|
248
|
+
const mechFirstTier = await firstAttemptTier();
|
|
249
|
+
|
|
250
|
+
check("hard reasoning and mechanical tool-result turns route differently", hardSlug !== mechSlug, {
|
|
251
|
+
hard: hardSlug,
|
|
252
|
+
mechanical: mechSlug,
|
|
253
|
+
});
|
|
254
|
+
// Compare what CLASSIFICATION chose, i.e. each turn's first attempt. The
|
|
255
|
+
// final tier is a poor probe here: the mock replays the same tool call the
|
|
256
|
+
// request already contains, so the mechanical turn legitimately trips
|
|
257
|
+
// `repeat_tool_call` and escalates, which would mask the classifier's
|
|
258
|
+
// separation with an escalation artifact.
|
|
259
|
+
check("the hard turn classified into a higher tier than the mechanical one", tierRank(hardFirstTier) > tierRank(mechFirstTier), {
|
|
260
|
+
hard: hardFirstTier,
|
|
261
|
+
mechanical: mechFirstTier,
|
|
262
|
+
});
|
|
263
|
+
console.log(` hard -> ${hardSlug} (classified ${hardFirstTier}, served ${hardRes.meta?.tier})`);
|
|
264
|
+
console.log(` mechanical -> ${mechSlug} (classified ${mechFirstTier}, served ${mechRes.meta?.tier})`);
|
|
265
|
+
|
|
266
|
+
console.log("\n[4] guarded probe escalates on a malformed tool call");
|
|
267
|
+
mock.requests.length = 0;
|
|
268
|
+
// Truncate only the first generation, whichever model serves it: the retry
|
|
269
|
+
// must then succeed on a stronger model.
|
|
270
|
+
mock.control.truncateFirstN = 1;
|
|
271
|
+
const escalated = await drain(
|
|
272
|
+
await post(
|
|
273
|
+
"/v1/chat/completions",
|
|
274
|
+
body([
|
|
275
|
+
{ role: "user", content: "open the config file" },
|
|
276
|
+
{
|
|
277
|
+
role: "assistant",
|
|
278
|
+
content: null,
|
|
279
|
+
tool_calls: [{ id: "c9", type: "function", function: { name: "bash", arguments: '{"command":"ls"}' } }],
|
|
280
|
+
},
|
|
281
|
+
{ role: "tool", tool_call_id: "c9", name: "bash", content: "config.yml\n" },
|
|
282
|
+
]),
|
|
283
|
+
),
|
|
284
|
+
);
|
|
285
|
+
let toolArgsValid = false;
|
|
286
|
+
try {
|
|
287
|
+
JSON.parse(escalated.toolArgs);
|
|
288
|
+
toolArgsValid = escalated.toolArgs.length > 0;
|
|
289
|
+
} catch {
|
|
290
|
+
toolArgsValid = false;
|
|
291
|
+
}
|
|
292
|
+
const attemptSlugs = mock.requests.map((r) => r.model);
|
|
293
|
+
check("retried upstream after the malformed tool call", mock.requests.length >= 2, attemptSlugs);
|
|
294
|
+
check("escalated to a different model", new Set(attemptSlugs).size >= 2, attemptSlugs);
|
|
295
|
+
check("client received only well-formed tool arguments", toolArgsValid, escalated.toolArgs);
|
|
296
|
+
mock.control.truncateFirstN = 0;
|
|
297
|
+
console.log(` attempts -> ${attemptSlugs.join(" then ")}`);
|
|
298
|
+
|
|
299
|
+
console.log("\n[5] buffered (non-streaming) responses");
|
|
300
|
+
mock.requests.length = 0;
|
|
301
|
+
const buffered = BufferedSchema.parse(
|
|
302
|
+
await (
|
|
303
|
+
await post("/v1/chat/completions", body([{ role: "user", content: "say hello" }], { tools: false, stream: false }))
|
|
304
|
+
).json(),
|
|
305
|
+
);
|
|
306
|
+
check("buffered response carries assembled content", Boolean(buffered.choices[0]?.message.content), buffered.choices);
|
|
307
|
+
check("buffered response reports usage", buffered.usage.prompt_tokens > 0, buffered.usage);
|
|
308
|
+
|
|
309
|
+
console.log("\n[6] ledger accounting");
|
|
310
|
+
const stats = StatsSchema.parse(await (await fetch(`${base}/v1/router/stats`)).json());
|
|
311
|
+
const decisions = DecisionsSchema.parse(await (await fetch(`${base}/v1/router/decisions?limit=50`)).json()).entries;
|
|
312
|
+
check("ledger recorded every attempt", decisions.length >= 6, decisions.length);
|
|
313
|
+
check("stats report non-zero spend", stats.spendAllTimeUsd > 0, stats);
|
|
314
|
+
const wasted = decisions.filter((d) => d.wasted).length;
|
|
315
|
+
check("the abandoned escalation attempt is booked as wasted spend", wasted >= 1, wasted);
|
|
316
|
+
} finally {
|
|
317
|
+
await app.stop();
|
|
318
|
+
await mock.stop();
|
|
319
|
+
rmSync(home, { recursive: true, force: true });
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
console.log(`\n${checks - failures.length}/${checks} checks passed`);
|
|
323
|
+
if (failures.length > 0) {
|
|
324
|
+
console.log(`failed:\n ${failures.join("\n ")}`);
|
|
325
|
+
process.exit(1);
|
|
326
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"lib": ["ESNext"],
|
|
4
|
+
"target": "ESNext",
|
|
5
|
+
"module": "Preserve",
|
|
6
|
+
"moduleResolution": "bundler",
|
|
7
|
+
"moduleDetection": "force",
|
|
8
|
+
"types": ["bun"],
|
|
9
|
+
"allowImportingTsExtensions": true,
|
|
10
|
+
"verbatimModuleSyntax": true,
|
|
11
|
+
"noEmit": true,
|
|
12
|
+
|
|
13
|
+
"strict": true,
|
|
14
|
+
"exactOptionalPropertyTypes": true,
|
|
15
|
+
"noUncheckedIndexedAccess": true,
|
|
16
|
+
"noImplicitOverride": true,
|
|
17
|
+
"noFallthroughCasesInSwitch": true,
|
|
18
|
+
"noUnusedLocals": true,
|
|
19
|
+
"noUnusedParameters": true,
|
|
20
|
+
"skipLibCheck": true
|
|
21
|
+
},
|
|
22
|
+
"include": ["src", "test"]
|
|
23
|
+
}
|