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,297 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { UpstreamMutations } from "../src/wire/types.ts";
|
|
3
|
+
import { WireErrorException } from "../src/wire/openai/errors.ts";
|
|
4
|
+
import { parseChatRequest } from "../src/wire/openai/request.ts";
|
|
5
|
+
|
|
6
|
+
const HEADERS = new Headers();
|
|
7
|
+
|
|
8
|
+
function mutations(overrides: Partial<UpstreamMutations> = {}): UpstreamMutations {
|
|
9
|
+
return {
|
|
10
|
+
slug: "openai/gpt-5.5",
|
|
11
|
+
fallbacks: [],
|
|
12
|
+
sessionId: "omp-test",
|
|
13
|
+
cacheBreakpointMessageIndices: [],
|
|
14
|
+
reasoning: undefined,
|
|
15
|
+
maxTokens: undefined,
|
|
16
|
+
stripAssistantReasoning: false,
|
|
17
|
+
...overrides,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function userBody(content: unknown): Record<string, unknown> {
|
|
22
|
+
return { model: "auto", messages: [{ role: "user", content }] };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe("parseChatRequest normalization", () => {
|
|
26
|
+
test("string content and equivalent content-part array normalize to the same text", () => {
|
|
27
|
+
const fromString = parseChatRequest(userBody("hello world"), HEADERS);
|
|
28
|
+
const fromParts = parseChatRequest(
|
|
29
|
+
userBody([{ type: "text", text: "hello world" }]),
|
|
30
|
+
HEADERS,
|
|
31
|
+
);
|
|
32
|
+
expect(fromParts.messages[0]!.text).toBe(fromString.messages[0]!.text);
|
|
33
|
+
expect(fromParts.messages[0]!.textBytes).toBe(fromString.messages[0]!.textBytes);
|
|
34
|
+
expect(fromParts.messages[0]!.images).toBe(0);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("image parts are counted and flag hasImages", () => {
|
|
38
|
+
const req = parseChatRequest(
|
|
39
|
+
userBody([
|
|
40
|
+
{ type: "text", text: "look at these" },
|
|
41
|
+
{ type: "image_url", image_url: { url: "data:image/png;base64,AA==" } },
|
|
42
|
+
{ type: "image_url", image_url: { url: "https://example.com/x.png" } },
|
|
43
|
+
]),
|
|
44
|
+
HEADERS,
|
|
45
|
+
);
|
|
46
|
+
expect(req.messages[0]!.images).toBe(2);
|
|
47
|
+
expect(req.messages[0]!.text).toBe("look at these");
|
|
48
|
+
expect(req.hasImages).toBe(true);
|
|
49
|
+
expect(parseChatRequest(userBody("plain"), HEADERS).hasImages).toBe(false);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("provider prefix is stripped from model", () => {
|
|
53
|
+
expect(parseChatRequest(userBody("hi"), HEADERS).requestedModel).toBe("auto");
|
|
54
|
+
const prefixed = parseChatRequest(
|
|
55
|
+
{ model: "auto-model-router/auto", messages: [{ role: "user", content: "hi" }] },
|
|
56
|
+
HEADERS,
|
|
57
|
+
);
|
|
58
|
+
expect(prefixed.requestedModel).toBe("auto");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("tool schemas, names, and descriptions contribute to promptBytes", () => {
|
|
62
|
+
const parameters = { type: "object", properties: { path: { type: "string" } } };
|
|
63
|
+
const withTools = parseChatRequest(
|
|
64
|
+
{
|
|
65
|
+
model: "auto",
|
|
66
|
+
messages: [{ role: "user", content: "hi" }],
|
|
67
|
+
tools: [
|
|
68
|
+
{
|
|
69
|
+
type: "function",
|
|
70
|
+
function: { name: "read", description: "Read a file", parameters },
|
|
71
|
+
},
|
|
72
|
+
],
|
|
73
|
+
},
|
|
74
|
+
HEADERS,
|
|
75
|
+
);
|
|
76
|
+
const withoutTools = parseChatRequest(userBody("hi"), HEADERS);
|
|
77
|
+
const tool = withTools.tools[0]!;
|
|
78
|
+
expect(tool.schemaBytes).toBe(new TextEncoder().encode(JSON.stringify(parameters)).length);
|
|
79
|
+
expect(withTools.promptBytes).toBe(
|
|
80
|
+
withoutTools.promptBytes + tool.schemaBytes + 4 + "Read a file".length,
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("tool calls and tool results are carried into NormMessage", () => {
|
|
85
|
+
const req = parseChatRequest(
|
|
86
|
+
{
|
|
87
|
+
model: "auto",
|
|
88
|
+
messages: [
|
|
89
|
+
{
|
|
90
|
+
role: "assistant",
|
|
91
|
+
content: null,
|
|
92
|
+
tool_calls: [
|
|
93
|
+
{ id: "call_1", type: "function", function: { name: "read", arguments: "{\"path\":\"x\"}" } },
|
|
94
|
+
],
|
|
95
|
+
},
|
|
96
|
+
{ role: "tool", tool_call_id: "call_1", name: "read", content: "file bytes" },
|
|
97
|
+
],
|
|
98
|
+
},
|
|
99
|
+
HEADERS,
|
|
100
|
+
);
|
|
101
|
+
expect(req.messages[0]!.toolCalls).toEqual([{ id: "call_1", name: "read", argsJson: "{\"path\":\"x\"}" }]);
|
|
102
|
+
expect(req.messages[1]!.toolCallId).toBe("call_1");
|
|
103
|
+
expect(req.messages[1]!.toolName).toBe("read");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("forcedToolChoice only for objects and non-auto/none strings", () => {
|
|
107
|
+
const base = userBody("hi");
|
|
108
|
+
expect(parseChatRequest(base, HEADERS).forcedToolChoice).toBe(false);
|
|
109
|
+
expect(parseChatRequest({ ...base, tool_choice: "auto" }, HEADERS).forcedToolChoice).toBe(false);
|
|
110
|
+
expect(parseChatRequest({ ...base, tool_choice: "none" }, HEADERS).forcedToolChoice).toBe(false);
|
|
111
|
+
expect(parseChatRequest({ ...base, tool_choice: "required" }, HEADERS).forcedToolChoice).toBe(true);
|
|
112
|
+
expect(
|
|
113
|
+
parseChatRequest(
|
|
114
|
+
{ ...base, tool_choice: { type: "function", function: { name: "read" } } },
|
|
115
|
+
HEADERS,
|
|
116
|
+
).forcedToolChoice,
|
|
117
|
+
).toBe(true);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("reasoning accepted from both spellings, omitted when absent", () => {
|
|
121
|
+
expect(
|
|
122
|
+
parseChatRequest({ ...userBody("hi"), reasoning_effort: "high" }, HEADERS).reasoning,
|
|
123
|
+
).toBe("high");
|
|
124
|
+
expect(
|
|
125
|
+
parseChatRequest({ ...userBody("hi"), reasoning: { effort: "low" } }, HEADERS).reasoning,
|
|
126
|
+
).toBe("low");
|
|
127
|
+
expect(
|
|
128
|
+
parseChatRequest({ ...userBody("hi"), reasoning: { enabled: false } }, HEADERS).reasoning,
|
|
129
|
+
).toBe("off");
|
|
130
|
+
const plain = parseChatRequest(userBody("hi"), HEADERS);
|
|
131
|
+
expect("reasoning" in plain).toBe(false);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("malformed input throws WireErrorException", () => {
|
|
135
|
+
expect(() => parseChatRequest(null, HEADERS)).toThrow(WireErrorException);
|
|
136
|
+
expect(() => parseChatRequest({ model: "auto", messages: [] }, HEADERS)).toThrow(WireErrorException);
|
|
137
|
+
expect(() => parseChatRequest({ messages: [{ role: "user", content: "hi" }] }, HEADERS)).toThrow(
|
|
138
|
+
WireErrorException,
|
|
139
|
+
);
|
|
140
|
+
try {
|
|
141
|
+
parseChatRequest({ model: "auto", messages: [{ role: "user", content: 42 }] }, HEADERS);
|
|
142
|
+
expect.unreachable();
|
|
143
|
+
} catch (e) {
|
|
144
|
+
expect(e).toBeInstanceOf(WireErrorException);
|
|
145
|
+
expect((e as WireErrorException).wireError.status).toBe(400);
|
|
146
|
+
expect((e as WireErrorException).wireError.code).toBe("invalid_request");
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
describe("conversationKey", () => {
|
|
152
|
+
const system = { role: "system", content: "You are a coding agent." };
|
|
153
|
+
const first = { role: "user", content: "Fix the bug in main.ts" };
|
|
154
|
+
|
|
155
|
+
test("stable across later turns of the same conversation", () => {
|
|
156
|
+
const turn1 = parseChatRequest({ model: "auto", messages: [system, first] }, HEADERS);
|
|
157
|
+
const turn3 = parseChatRequest(
|
|
158
|
+
{
|
|
159
|
+
model: "auto",
|
|
160
|
+
messages: [
|
|
161
|
+
system,
|
|
162
|
+
first,
|
|
163
|
+
{ role: "assistant", content: "Done." },
|
|
164
|
+
{ role: "user", content: "Now add a test" },
|
|
165
|
+
],
|
|
166
|
+
},
|
|
167
|
+
HEADERS,
|
|
168
|
+
);
|
|
169
|
+
expect(turn1.conversationKey).toBe(turn3.conversationKey);
|
|
170
|
+
expect(turn1.conversationKey).toMatch(/^[0-9a-f]{32}$/);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("differs when the first non-system message differs", () => {
|
|
174
|
+
const a = parseChatRequest({ model: "auto", messages: [system, first] }, HEADERS);
|
|
175
|
+
const b = parseChatRequest(
|
|
176
|
+
{ model: "auto", messages: [system, { role: "user", content: "Write a poem" }] },
|
|
177
|
+
HEADERS,
|
|
178
|
+
);
|
|
179
|
+
expect(a.conversationKey).not.toBe(b.conversationKey);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
describe("renderUpstreamBody", () => {
|
|
184
|
+
test("two renders are independent and never mutate the original body", () => {
|
|
185
|
+
const original = {
|
|
186
|
+
model: "auto",
|
|
187
|
+
messages: [{ role: "user", content: "hi" }],
|
|
188
|
+
stream: false,
|
|
189
|
+
max_tokens: 100,
|
|
190
|
+
reasoning_effort: "low",
|
|
191
|
+
};
|
|
192
|
+
const req = parseChatRequest(original, HEADERS);
|
|
193
|
+
const before = JSON.stringify(original);
|
|
194
|
+
|
|
195
|
+
const a = req.renderUpstreamBody(mutations({ slug: "openai/aaa" }));
|
|
196
|
+
const b = req.renderUpstreamBody(
|
|
197
|
+
mutations({
|
|
198
|
+
slug: "openai/bbb",
|
|
199
|
+
fallbacks: ["openai/ccc"],
|
|
200
|
+
sessionId: "omp-2",
|
|
201
|
+
maxTokens: 50,
|
|
202
|
+
reasoning: "high",
|
|
203
|
+
cacheBreakpointMessageIndices: [0],
|
|
204
|
+
}),
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
expect(a.model).toBe("openai/aaa");
|
|
208
|
+
expect(b.model).toBe("openai/bbb");
|
|
209
|
+
expect(a.models).toBeUndefined();
|
|
210
|
+
expect(b.models).toEqual(["openai/bbb", "openai/ccc"]);
|
|
211
|
+
expect(b.session_id).toBe("omp-2");
|
|
212
|
+
expect(a.stream).toBe(true);
|
|
213
|
+
expect(b.stream).toBe(true);
|
|
214
|
+
expect("stream_options" in b).toBe(false);
|
|
215
|
+
expect(a.max_tokens).toBe(100);
|
|
216
|
+
expect(b.max_tokens).toBe(50);
|
|
217
|
+
expect("reasoning_effort" in a).toBe(false);
|
|
218
|
+
expect("reasoning_effort" in b).toBe(false);
|
|
219
|
+
expect("reasoning" in a).toBe(false);
|
|
220
|
+
expect(b.reasoning).toEqual({ effort: "high" });
|
|
221
|
+
|
|
222
|
+
// Mutating one render must not leak into the other or the original.
|
|
223
|
+
(b.messages as Record<string, unknown>[])[0]!.content = "mutated";
|
|
224
|
+
expect((a.messages as Record<string, unknown>[])[0]!.content).toBe("hi");
|
|
225
|
+
expect(JSON.stringify(original)).toBe(before);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("maxTokens lands on whichever spelling the client used", () => {
|
|
229
|
+
const req = parseChatRequest(
|
|
230
|
+
{ model: "auto", messages: [{ role: "user", content: "hi" }], max_completion_tokens: 200 },
|
|
231
|
+
HEADERS,
|
|
232
|
+
);
|
|
233
|
+
const out = req.renderUpstreamBody(mutations({ maxTokens: 64 }));
|
|
234
|
+
expect(out.max_completion_tokens).toBe(64);
|
|
235
|
+
expect("max_tokens" in out).toBe(false);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test("reasoning off renders as { enabled: false }", () => {
|
|
239
|
+
const req = parseChatRequest(userBody("hi"), HEADERS);
|
|
240
|
+
const out = req.renderUpstreamBody(mutations({ reasoning: "off" }));
|
|
241
|
+
expect(out.reasoning).toEqual({ enabled: false });
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test("cache breakpoints land on named messages and promote string content to parts", () => {
|
|
245
|
+
const req = parseChatRequest(
|
|
246
|
+
{
|
|
247
|
+
model: "auto",
|
|
248
|
+
messages: [
|
|
249
|
+
{ role: "system", content: "system prompt" },
|
|
250
|
+
{ role: "user", content: "first question" },
|
|
251
|
+
{ role: "assistant", content: [{ type: "text", text: "answer" }, { type: "text", text: "more" }] },
|
|
252
|
+
{ role: "user", content: "follow up" },
|
|
253
|
+
],
|
|
254
|
+
},
|
|
255
|
+
HEADERS,
|
|
256
|
+
);
|
|
257
|
+
const out = req.renderUpstreamBody(mutations({ cacheBreakpointMessageIndices: [0, 2] }));
|
|
258
|
+
const msgs = out.messages as Array<Record<string, unknown>>;
|
|
259
|
+
|
|
260
|
+
expect(msgs[0]!.content).toEqual([
|
|
261
|
+
{ type: "text", text: "system prompt", cache_control: { type: "ephemeral" } },
|
|
262
|
+
]);
|
|
263
|
+
// Untouched message keeps its original shape.
|
|
264
|
+
expect(msgs[1]!.content).toBe("first question");
|
|
265
|
+
// Breakpoint lands on the LAST text part of the message.
|
|
266
|
+
expect(msgs[2]!.content).toEqual([
|
|
267
|
+
{ type: "text", text: "answer" },
|
|
268
|
+
{ type: "text", text: "more", cache_control: { type: "ephemeral" } },
|
|
269
|
+
]);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test("stripAssistantReasoning removes all three spellings from assistant messages only", () => {
|
|
273
|
+
const req = parseChatRequest(
|
|
274
|
+
{
|
|
275
|
+
model: "auto",
|
|
276
|
+
messages: [
|
|
277
|
+
{
|
|
278
|
+
role: "assistant",
|
|
279
|
+
content: "a1",
|
|
280
|
+
reasoning: "r",
|
|
281
|
+
reasoning_content: "rc",
|
|
282
|
+
reasoning_details: [{ type: "reasoning.text", text: "rc" }],
|
|
283
|
+
},
|
|
284
|
+
{ role: "user", content: "u1", reasoning: "keep-me" },
|
|
285
|
+
],
|
|
286
|
+
},
|
|
287
|
+
HEADERS,
|
|
288
|
+
);
|
|
289
|
+
const out = req.renderUpstreamBody(mutations({ stripAssistantReasoning: true }));
|
|
290
|
+
const msgs = out.messages as Array<Record<string, unknown>>;
|
|
291
|
+
expect("reasoning" in msgs[0]!).toBe(false);
|
|
292
|
+
expect("reasoning_content" in msgs[0]!).toBe(false);
|
|
293
|
+
expect("reasoning_details" in msgs[0]!).toBe(false);
|
|
294
|
+
expect(msgs[0]!.content).toBe("a1");
|
|
295
|
+
expect(msgs[1]!.reasoning).toBe("keep-me");
|
|
296
|
+
});
|
|
297
|
+
});
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { EMPTY_USAGE } from "../src/cost/types.ts";
|
|
3
|
+
import type { TurnSummary } from "../src/wire/types.ts";
|
|
4
|
+
import { createBufferedSink, createStreamingSink } from "../src/wire/openai/sink.ts";
|
|
5
|
+
|
|
6
|
+
const SUMMARY: TurnSummary = {
|
|
7
|
+
servedSlug: "openai/gpt-5.5",
|
|
8
|
+
tier: "simple",
|
|
9
|
+
attempts: 1,
|
|
10
|
+
predictedUsd: 0.001,
|
|
11
|
+
reportedUsd: 0.0012,
|
|
12
|
+
usage: EMPTY_USAGE,
|
|
13
|
+
reasons: [],
|
|
14
|
+
escalated: false,
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
function chunk(raw: Record<string, unknown>): { raw: Record<string, unknown>; events: [] } {
|
|
18
|
+
return { raw, events: [] };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function parseFrames(text: string): unknown[] {
|
|
22
|
+
return text
|
|
23
|
+
.split("\n\n")
|
|
24
|
+
.filter((f) => f.startsWith("data: ") && f !== "data: [DONE]")
|
|
25
|
+
.map((f) => JSON.parse(f.slice("data: ".length)));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe("createStreamingSink", () => {
|
|
29
|
+
test("rewrites model to the virtual id and preserves the served slug under x_auto_model_router", async () => {
|
|
30
|
+
const { sink, response } = createStreamingSink("auto");
|
|
31
|
+
expect(response.headers.get("content-type")).toBe("text/event-stream");
|
|
32
|
+
expect(response.headers.get("cache-control")).toBe("no-cache");
|
|
33
|
+
|
|
34
|
+
sink.chunk(
|
|
35
|
+
chunk({
|
|
36
|
+
id: "gen-1",
|
|
37
|
+
object: "chat.completion.chunk",
|
|
38
|
+
created: 1700000000,
|
|
39
|
+
model: "openai/gpt-5.5",
|
|
40
|
+
choices: [{ index: 0, delta: { content: "hi" }, finish_reason: null }],
|
|
41
|
+
}),
|
|
42
|
+
);
|
|
43
|
+
sink.finish(SUMMARY);
|
|
44
|
+
|
|
45
|
+
const text = await response.text();
|
|
46
|
+
const frames = parseFrames(text);
|
|
47
|
+
const first = frames[0] as Record<string, unknown>;
|
|
48
|
+
expect(first.model).toBe("auto");
|
|
49
|
+
expect(first.x_auto_model_router).toEqual({ model: "openai/gpt-5.5" });
|
|
50
|
+
// Unknown upstream fields ride along verbatim.
|
|
51
|
+
expect(first.id).toBe("gen-1");
|
|
52
|
+
|
|
53
|
+
// Headers flushed with the first chunk, so the summary arrives as the
|
|
54
|
+
// final x_auto_model_router frame before [DONE].
|
|
55
|
+
const last = frames[frames.length - 1] as Record<string, unknown>;
|
|
56
|
+
expect(last.x_auto_model_router).toEqual({
|
|
57
|
+
model: "openai/gpt-5.5",
|
|
58
|
+
tier: "simple",
|
|
59
|
+
cost_usd: 0.0012,
|
|
60
|
+
attempts: 1,
|
|
61
|
+
});
|
|
62
|
+
expect(text.trimEnd().endsWith("data: [DONE]")).toBe(true);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("error emits one OpenAI-envelope frame before closing", async () => {
|
|
66
|
+
const { sink, response } = createStreamingSink("auto");
|
|
67
|
+
sink.error({ status: 502, code: "upstream_error", message: "boom" });
|
|
68
|
+
const frames = parseFrames(await response.text());
|
|
69
|
+
expect(frames).toEqual([
|
|
70
|
+
{ error: { message: "boom", type: "server_error", code: "upstream_error" } },
|
|
71
|
+
]);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("createBufferedSink", () => {
|
|
76
|
+
test("reassembles split tool_calls argument fragments into one valid JSON string", async () => {
|
|
77
|
+
const { sink, response } = createBufferedSink("auto");
|
|
78
|
+
sink.chunk(
|
|
79
|
+
chunk({
|
|
80
|
+
id: "gen-1",
|
|
81
|
+
created: 1700000000,
|
|
82
|
+
model: "anthropic/claude-haiku-4.5",
|
|
83
|
+
choices: [
|
|
84
|
+
{
|
|
85
|
+
index: 0,
|
|
86
|
+
delta: {
|
|
87
|
+
role: "assistant",
|
|
88
|
+
tool_calls: [
|
|
89
|
+
{
|
|
90
|
+
index: 0,
|
|
91
|
+
id: "call_abc",
|
|
92
|
+
type: "function",
|
|
93
|
+
function: { name: "read", arguments: "{\"path\":" },
|
|
94
|
+
},
|
|
95
|
+
],
|
|
96
|
+
},
|
|
97
|
+
finish_reason: null,
|
|
98
|
+
},
|
|
99
|
+
],
|
|
100
|
+
}),
|
|
101
|
+
);
|
|
102
|
+
// Second fragment carries neither id nor name; they must survive from the first.
|
|
103
|
+
sink.chunk(
|
|
104
|
+
chunk({
|
|
105
|
+
model: "anthropic/claude-haiku-4.5",
|
|
106
|
+
choices: [
|
|
107
|
+
{
|
|
108
|
+
index: 0,
|
|
109
|
+
delta: { tool_calls: [{ index: 0, function: { arguments: "\"src/main.ts\"}" } }] },
|
|
110
|
+
finish_reason: null,
|
|
111
|
+
},
|
|
112
|
+
],
|
|
113
|
+
}),
|
|
114
|
+
);
|
|
115
|
+
sink.chunk(
|
|
116
|
+
chunk({
|
|
117
|
+
model: "anthropic/claude-haiku-4.5",
|
|
118
|
+
choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }],
|
|
119
|
+
usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
|
|
120
|
+
}),
|
|
121
|
+
);
|
|
122
|
+
sink.finish(SUMMARY);
|
|
123
|
+
|
|
124
|
+
const res = await response;
|
|
125
|
+
expect(res.headers.get("x-auto-model-router-model")).toBe("openai/gpt-5.5");
|
|
126
|
+
expect(res.headers.get("x-auto-model-router-tier")).toBe("simple");
|
|
127
|
+
expect(res.headers.get("x-auto-model-router-cost-usd")).toBe("0.0012");
|
|
128
|
+
expect(res.headers.get("x-auto-model-router-attempts")).toBe("1");
|
|
129
|
+
|
|
130
|
+
const body = (await res.json()) as {
|
|
131
|
+
object: string;
|
|
132
|
+
model: string;
|
|
133
|
+
choices: Array<{
|
|
134
|
+
finish_reason: string;
|
|
135
|
+
message: { tool_calls: Array<{ id: string; function: { name: string; arguments: string } }> };
|
|
136
|
+
}>;
|
|
137
|
+
usage: unknown;
|
|
138
|
+
};
|
|
139
|
+
expect(body.object).toBe("chat.completion");
|
|
140
|
+
expect(body.model).toBe("auto");
|
|
141
|
+
expect(body.choices[0]!.finish_reason).toBe("tool_calls");
|
|
142
|
+
const call = body.choices[0]!.message.tool_calls[0]!;
|
|
143
|
+
expect(call.id).toBe("call_abc");
|
|
144
|
+
expect(call.function.name).toBe("read");
|
|
145
|
+
expect(JSON.parse(call.function.arguments)).toEqual({ path: "src/main.ts" });
|
|
146
|
+
expect(body.usage).toEqual({ prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 });
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("merges text and reasoning deltas per choice", async () => {
|
|
150
|
+
const { sink, response } = createBufferedSink("auto-cheap");
|
|
151
|
+
sink.chunk(
|
|
152
|
+
chunk({
|
|
153
|
+
model: "x/y",
|
|
154
|
+
choices: [{ index: 0, delta: { role: "assistant", reasoning: "think ", content: "Hel" } }],
|
|
155
|
+
}),
|
|
156
|
+
);
|
|
157
|
+
sink.chunk(
|
|
158
|
+
chunk({
|
|
159
|
+
model: "x/y",
|
|
160
|
+
choices: [{ index: 0, delta: { reasoning: "more", content: "lo" }, finish_reason: "stop" }],
|
|
161
|
+
}),
|
|
162
|
+
);
|
|
163
|
+
sink.finish(SUMMARY);
|
|
164
|
+
const body = (await (await response).json()) as {
|
|
165
|
+
choices: Array<{ message: { role: string; content: string; reasoning: string } }>;
|
|
166
|
+
};
|
|
167
|
+
expect(body.choices[0]!.message).toEqual({ role: "assistant", content: "Hello", reasoning: "think more" });
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("error before finish resolves with the envelope at the WireError status", async () => {
|
|
171
|
+
const { sink, response } = createBufferedSink("auto");
|
|
172
|
+
sink.error({ status: 429, code: "rate_limit", message: "slow down" });
|
|
173
|
+
const res = await response;
|
|
174
|
+
expect(res.status).toBe(429);
|
|
175
|
+
expect(await res.json()).toEqual({
|
|
176
|
+
error: { message: "slow down", type: "rate_limit_error", code: "rate_limit" },
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
});
|
package/tools/install.ts
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* auto-model-router installer.
|
|
4
|
+
*
|
|
5
|
+
* Wires the auto-model-router extensions into omp's `~/.omp/agent/config.yml`
|
|
6
|
+
* (or `$PI_CODING_AGENT_DIR/config.yml` when that env var relocates the agent
|
|
7
|
+
* dir). Cross-platform: Windows, macOS, and Linux.
|
|
8
|
+
*
|
|
9
|
+
* It adds the three extension paths under `extensions:`:
|
|
10
|
+
* - router-embed.ts (required — runs the router in-process)
|
|
11
|
+
* - router-toast.ts (optional — chosen-model toasts)
|
|
12
|
+
* - router-configure.ts (optional — /router configure command)
|
|
13
|
+
*
|
|
14
|
+
* Idempotent: already-present paths are left untouched, so re-running is safe.
|
|
15
|
+
* The previous config.yml is backed up to a timestamped .bak before writing.
|
|
16
|
+
*
|
|
17
|
+
* Usage:
|
|
18
|
+
* bun tools/install.ts
|
|
19
|
+
* bun tools/install.ts --no-toast --no-configure # only the embed extension
|
|
20
|
+
* PI_CODING_AGENT_DIR=/custom/agent bun tools/install.ts
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
24
|
+
import { homedir } from "node:os";
|
|
25
|
+
import { dirname, join, resolve } from "node:path";
|
|
26
|
+
|
|
27
|
+
/** The repo's omp-extension directory, resolved from this script's location. */
|
|
28
|
+
const EXT_DIR = resolve(import.meta.dir, "..", "omp-extension");
|
|
29
|
+
|
|
30
|
+
/** The three extensions, in install order. */
|
|
31
|
+
const EXTENSIONS = [
|
|
32
|
+
{ file: "router-embed.ts", required: true, label: "router-embed (required)" },
|
|
33
|
+
{ file: "router-toast.ts", required: false, label: "router-toast (toasts)" },
|
|
34
|
+
{ file: "router-configure.ts", required: false, label: "router-configure (/router configure)" },
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
/** omp's agent config.yml path, honoring $PI_CODING_AGENT_DIR. */
|
|
38
|
+
function agentConfigPath(): string {
|
|
39
|
+
const agentDir = process.env.PI_CODING_AGENT_DIR;
|
|
40
|
+
const base = agentDir !== undefined && agentDir !== "" ? agentDir : join(homedir(), ".omp", "agent");
|
|
41
|
+
return join(base, "config.yml");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The absolute path of an extension file, using forward slashes for portability. */
|
|
45
|
+
function extensionPath(file: string): string {
|
|
46
|
+
return join(EXT_DIR, file).replace(/\\/g, "/");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Inserts the extension paths under an `extensions:` key, preserving comments
|
|
51
|
+
* and existing content. Creates the key if absent; appends to it if present.
|
|
52
|
+
*/
|
|
53
|
+
function spliceExtensions(text: string, paths: string[]): string {
|
|
54
|
+
const eol = text.includes("\r\n") ? "\r\n" : "\n";
|
|
55
|
+
const lines = text.split(/\r?\n/);
|
|
56
|
+
|
|
57
|
+
// Find the `extensions:` key and its indentation.
|
|
58
|
+
const extIdx = lines.findIndex((l) => /^extensions\s*:/.test(l));
|
|
59
|
+
if (extIdx === -1) {
|
|
60
|
+
// No extensions key: append one at the end (or after the last non-empty line).
|
|
61
|
+
const indent = " ";
|
|
62
|
+
const block = ["extensions:", ...paths.map((p) => `${indent}- ${p}`)];
|
|
63
|
+
const trimmed = lines.map((l) => l.trimEnd());
|
|
64
|
+
while (trimmed.length > 0 && trimmed[trimmed.length - 1] === "") trimmed.pop();
|
|
65
|
+
return [...trimmed, "", ...block, ""].join(eol);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Existing key: find its child indentation (or default to 2 spaces).
|
|
69
|
+
const extLine = lines[extIdx] ?? "";
|
|
70
|
+
const indent = /^([ \t]*)/.exec(extLine)?.[1] ?? "";
|
|
71
|
+
const childIndent = `${indent} `;
|
|
72
|
+
|
|
73
|
+
// Collect existing children until the next top-level key.
|
|
74
|
+
const children: string[] = [];
|
|
75
|
+
let i = extIdx + 1;
|
|
76
|
+
while (i < lines.length) {
|
|
77
|
+
const line = lines[i] ?? "";
|
|
78
|
+
if (line.trim() === "") {
|
|
79
|
+
i++;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
// A line at the same or less indentation than the key ends the list.
|
|
83
|
+
if (/^[ \t]*\S/.test(line) && !line.startsWith(childIndent)) break;
|
|
84
|
+
children.push(line);
|
|
85
|
+
i++;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const existing = children.map((l) => l.trim());
|
|
89
|
+
const toAdd = paths.filter((p) => !existing.includes(`- ${p}`));
|
|
90
|
+
if (toAdd.length === 0) return text;
|
|
91
|
+
|
|
92
|
+
const newChildren = [...children, ...toAdd.map((p) => `${childIndent}- ${p}`)];
|
|
93
|
+
const next = [...lines.slice(0, extIdx + 1), ...newChildren, ...lines.slice(i)];
|
|
94
|
+
return next.join(eol);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function main(): void {
|
|
98
|
+
const args = process.argv.slice(2);
|
|
99
|
+
const noToast = args.includes("--no-toast");
|
|
100
|
+
const noConfigure = args.includes("--no-configure");
|
|
101
|
+
|
|
102
|
+
const selected = EXTENSIONS.filter((e) => {
|
|
103
|
+
if (e.file === "router-toast.ts" && noToast) return false;
|
|
104
|
+
if (e.file === "router-configure.ts" && noConfigure) return false;
|
|
105
|
+
return true;
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// Verify the required extension files exist before touching anything.
|
|
109
|
+
const missing = selected.filter((e) => !existsSync(extensionPath(e.file)));
|
|
110
|
+
if (missing.length > 0) {
|
|
111
|
+
console.error(`error: missing extension file(s): ${missing.map((m) => extensionPath(m.file)).join(", ")}`);
|
|
112
|
+
process.exit(1);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const target = agentConfigPath();
|
|
116
|
+
const existing = existsSync(target) ? readFileSync(target, "utf8") : "";
|
|
117
|
+
const paths = selected.map((e) => extensionPath(e.file));
|
|
118
|
+
const next = spliceExtensions(existing, paths);
|
|
119
|
+
|
|
120
|
+
if (next === existing) {
|
|
121
|
+
console.log(`already installed: ${target}`);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Back up the previous file.
|
|
126
|
+
if (existing !== "") {
|
|
127
|
+
const backup = `${target}.${new Date().toISOString().replace(/[:.]/g, "-")}.bak`;
|
|
128
|
+
copyFileSync(target, backup);
|
|
129
|
+
console.log(`backup: ${backup}`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
133
|
+
writeFileSync(target, next, "utf8");
|
|
134
|
+
|
|
135
|
+
console.log(`installed auto-model-router extensions into ${target}:`);
|
|
136
|
+
for (const e of selected) console.log(` - ${extensionPath(e.file)} (${e.label})`);
|
|
137
|
+
console.log("restart the omp session to load them.");
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
main();
|