auto-model-router 0.4.7 → 0.4.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.omp-plugin/marketplace.json +2 -2
- package/README.md +74 -14
- package/hermes-plugin/native/__pycache__/__init__.cpython-311.pyc +0 -0
- package/hermes-plugin/native/selftest.py +103 -0
- package/opencode-plugin/auto-model-router.ts +151 -0
- package/opencode-plugin/opencode-plugin.d.ts +39 -0
- package/package.json +1 -1
- package/src/cost/ledger.ts +6 -1
- package/src/cost/summary.ts +3 -1
- package/src/server/http.ts +22 -7
- package/src/util/sse.ts +1 -1
- package/src/wire/openai/responses.ts +392 -0
- package/src/wire/openai/sink.ts +19 -2
- package/src/wire/types.ts +1 -1
- package/test/fixtures/harness/aider.json +47 -0
- package/test/fixtures/harness/codex-responses.json +507 -0
- package/test/fixtures/harness/opencode.json +338 -0
- package/test/harness-requests.test.ts +58 -24
- package/test/hermes-plugin.test.ts +32 -0
- package/test/summary.test.ts +1 -1
- package/test/tokens.test.ts +2 -0
- package/test/wire-responses.test.ts +188 -0
- package/test/wire-sink.test.ts +5 -0
- package/tools/capture-proxy.ts +79 -0
- package/tsconfig.all.json +1 -1
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { WireErrorException } from "../src/wire/openai/errors.ts";
|
|
4
|
+
import { createResponsesBufferedSink, createResponsesStreamingSink, identityHeadersFromBody, parseResponsesRequest, responsesToChatBody } from "../src/wire/openai/responses.ts";
|
|
5
|
+
import type { StreamEvent, TurnSummary, UpstreamChunk } from "../src/wire/types.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The Responses API wire: request translation into the chat shape the router
|
|
9
|
+
* routes on, and rendering of the upstream chat stream as Responses events.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const HEADERS = new Headers({ "X-Omp-Harness": "codex" });
|
|
13
|
+
|
|
14
|
+
describe("responsesToChatBody", () => {
|
|
15
|
+
test("instructions, messages, function calls and their outputs become chat messages", () => {
|
|
16
|
+
const chat = responsesToChatBody({
|
|
17
|
+
model: "auto",
|
|
18
|
+
instructions: "Be terse.",
|
|
19
|
+
input: [
|
|
20
|
+
{ type: "message", role: "user", content: [{ type: "input_text", text: "list files" }] },
|
|
21
|
+
{ type: "function_call", call_id: "call_1", name: "shell", arguments: '{"cmd":"ls"}' },
|
|
22
|
+
{ type: "function_call", call_id: "call_2", name: "shell", arguments: '{"cmd":"pwd"}' },
|
|
23
|
+
{ type: "function_call_output", call_id: "call_1", output: "a.ts b.ts" },
|
|
24
|
+
{ type: "function_call_output", call_id: "call_2", output: [{ type: "input_text", text: "/repo" }] },
|
|
25
|
+
{ type: "reasoning", summary: [] },
|
|
26
|
+
{ role: "user", content: "and now?" },
|
|
27
|
+
],
|
|
28
|
+
tools: [{ type: "function", name: "shell", description: "run", parameters: { type: "object" }, strict: false }, { type: "web_search" }],
|
|
29
|
+
tool_choice: { type: "function", name: "shell" },
|
|
30
|
+
max_output_tokens: 512,
|
|
31
|
+
reasoning: { effort: "low", summary: "auto" },
|
|
32
|
+
store: false,
|
|
33
|
+
include: ["reasoning.encrypted_content"],
|
|
34
|
+
prompt_cache_key: "k",
|
|
35
|
+
stream: true,
|
|
36
|
+
parallel_tool_calls: true,
|
|
37
|
+
});
|
|
38
|
+
expect(chat.messages).toEqual([
|
|
39
|
+
{ role: "system", content: "Be terse." },
|
|
40
|
+
{ role: "user", content: "list files" },
|
|
41
|
+
{
|
|
42
|
+
role: "assistant",
|
|
43
|
+
content: null,
|
|
44
|
+
tool_calls: [
|
|
45
|
+
{ id: "call_1", type: "function", function: { name: "shell", arguments: '{"cmd":"ls"}' } },
|
|
46
|
+
{ id: "call_2", type: "function", function: { name: "shell", arguments: '{"cmd":"pwd"}' } },
|
|
47
|
+
],
|
|
48
|
+
},
|
|
49
|
+
{ role: "tool", tool_call_id: "call_1", content: "a.ts b.ts" },
|
|
50
|
+
{ role: "tool", tool_call_id: "call_2", content: "/repo" },
|
|
51
|
+
{ role: "user", content: "and now?" },
|
|
52
|
+
]);
|
|
53
|
+
expect(chat.tools).toEqual([{ type: "function", function: { name: "shell", description: "run", parameters: { type: "object" } } }]);
|
|
54
|
+
expect(chat.tool_choice).toEqual({ type: "function", function: { name: "shell" } });
|
|
55
|
+
expect(chat.max_tokens).toBe(512);
|
|
56
|
+
expect(chat.reasoning).toEqual({ effort: "low" });
|
|
57
|
+
expect(chat.stream).toBe(true);
|
|
58
|
+
expect(chat.parallel_tool_calls).toBe(true);
|
|
59
|
+
for (const k of ["instructions", "input", "store", "include", "prompt_cache_key", "max_output_tokens"]) expect(k in chat).toBe(false);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("a string input is one user message; images survive; previous_response_id is refused", () => {
|
|
63
|
+
expect(responsesToChatBody({ model: "auto", input: "hi" }).messages).toEqual([{ role: "user", content: "hi" }]);
|
|
64
|
+
const withImage = responsesToChatBody({ model: "auto", input: [{ type: "message", role: "user", content: [{ type: "input_text", text: "what is this" }, { type: "input_image", image_url: "data:image/png;base64,AAAA" }] }] });
|
|
65
|
+
expect(withImage.messages).toEqual([{ role: "user", content: [{ type: "text", text: "what is this" }, { type: "image_url", image_url: { url: "data:image/png;base64,AAAA" } }] }]);
|
|
66
|
+
expect(() => responsesToChatBody({ model: "auto", input: "x", previous_response_id: "resp_1" })).toThrow(WireErrorException);
|
|
67
|
+
expect(() => responsesToChatBody({ model: "auto", input: [] })).toThrow(WireErrorException);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("Codex identity is read from the body when the headers carry none", () => {
|
|
71
|
+
const meta = (agent: string) => JSON.stringify({ session_id: "t1", thread_id: "t1", agent_name: agent, turn_id: "u1" });
|
|
72
|
+
const body = { model: "auto", input: "x", prompt_cache_key: "t1", client_metadata: { thread_id: "t1", session_id: "t1", "x-codex-turn-metadata": meta("/root") } };
|
|
73
|
+
const main = parseResponsesRequest(body, HEADERS);
|
|
74
|
+
expect(main.ompSessionId).toBe("t1");
|
|
75
|
+
expect(main.isSubagent).toBe(false);
|
|
76
|
+
const sub = parseResponsesRequest({ ...body, client_metadata: { ...body.client_metadata, "x-codex-turn-metadata": meta("/root/explorer") } }, HEADERS);
|
|
77
|
+
expect(sub.isSubagent).toBe(true);
|
|
78
|
+
// Explicit headers win; a body without metadata adds nothing.
|
|
79
|
+
expect(identityHeadersFromBody(body, new Headers({ "X-Omp-Session": "mine" })).get("x-omp-session")).toBe("mine");
|
|
80
|
+
expect(identityHeadersFromBody({ model: "auto", input: "x" }, HEADERS).get("x-omp-session")).toBeNull();
|
|
81
|
+
expect(identityHeadersFromBody({ model: "auto", input: "x", client_metadata: { "x-codex-turn-metadata": "not json" } }, HEADERS).get("x-omp-subagent")).toBeNull();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("parseResponsesRequest yields a routed request tagged with the wire", () => {
|
|
85
|
+
const req = parseResponsesRequest({ model: "auto-model-router/auto-cheap", input: "hello", stream: false }, HEADERS);
|
|
86
|
+
expect(req.protocol).toBe("openai-responses");
|
|
87
|
+
expect(req.requestedModel).toBe("auto-cheap");
|
|
88
|
+
expect(req.harnessId).toBe("codex");
|
|
89
|
+
expect(req.messages).toHaveLength(1);
|
|
90
|
+
expect(req.stream).toBe(false);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const SUMMARY: TurnSummary = {
|
|
95
|
+
servedSlug: "openai/gpt-5.5",
|
|
96
|
+
tier: "simple",
|
|
97
|
+
attempts: 1,
|
|
98
|
+
predictedUsd: 0.001,
|
|
99
|
+
reportedUsd: 0.0012,
|
|
100
|
+
usage: { promptTokens: 120, cachedTokens: 100, cacheWriteTokens: 0, completionTokens: 9, reasoningTokens: 2, images: 0 },
|
|
101
|
+
reasons: [],
|
|
102
|
+
escalated: false,
|
|
103
|
+
};
|
|
104
|
+
const chunk = (...events: StreamEvent[]): UpstreamChunk => ({ raw: { id: "gen-1", object: "chat.completion.chunk", model: "openai/gpt-5.5" }, events });
|
|
105
|
+
|
|
106
|
+
function parseEvents(text: string): { event: string; data: Record<string, unknown> }[] {
|
|
107
|
+
return text
|
|
108
|
+
.split("\n\n")
|
|
109
|
+
.filter((f) => f.startsWith("event: "))
|
|
110
|
+
.map((f) => {
|
|
111
|
+
const [eventLine = "", ...rest] = f.split("\n");
|
|
112
|
+
const dataLine = rest.find((l) => l.startsWith("data: ")) ?? "data: {}";
|
|
113
|
+
return { event: eventLine.slice("event: ".length), data: JSON.parse(dataLine.slice("data: ".length)) as Record<string, unknown> };
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
describe("createResponsesStreamingSink", () => {
|
|
118
|
+
test("renders text then a function call as the standard event sequence, ending in response.completed with usage", async () => {
|
|
119
|
+
const { sink, response } = createResponsesStreamingSink("auto");
|
|
120
|
+
expect(response.headers.get("content-type")).toBe("text/event-stream");
|
|
121
|
+
sink.chunk(chunk({ type: "start", servedSlug: "openai/gpt-5.5", generationId: "gen-1" }));
|
|
122
|
+
sink.chunk(chunk({ type: "text", delta: "Run" }));
|
|
123
|
+
sink.chunk(chunk({ type: "text", delta: "ning" }));
|
|
124
|
+
sink.chunk(chunk({ type: "tool_call", index: 0, id: "call_9", name: "shell", argsDelta: '{"cmd":' }));
|
|
125
|
+
sink.chunk(chunk({ type: "tool_call", index: 0, argsDelta: '"ls"}' }));
|
|
126
|
+
sink.chunk(chunk({ type: "finish", reason: "tool_calls" }, { type: "usage", usage: SUMMARY.usage, reportedCostUsd: 0.0012 }));
|
|
127
|
+
sink.finish(SUMMARY);
|
|
128
|
+
const text = await response.text();
|
|
129
|
+
const events = parseEvents(text);
|
|
130
|
+
expect(events.map((e) => e.event)).toEqual([
|
|
131
|
+
"response.created",
|
|
132
|
+
"response.in_progress",
|
|
133
|
+
"response.output_item.added",
|
|
134
|
+
"response.content_part.added",
|
|
135
|
+
"response.output_text.delta",
|
|
136
|
+
"response.output_text.delta",
|
|
137
|
+
"response.output_text.done",
|
|
138
|
+
"response.content_part.done",
|
|
139
|
+
"response.output_item.done",
|
|
140
|
+
"response.output_item.added",
|
|
141
|
+
"response.function_call_arguments.delta",
|
|
142
|
+
"response.function_call_arguments.delta",
|
|
143
|
+
"response.function_call_arguments.done",
|
|
144
|
+
"response.output_item.done",
|
|
145
|
+
"response.completed",
|
|
146
|
+
]);
|
|
147
|
+
// Every frame names its type and carries a rising sequence number.
|
|
148
|
+
events.forEach((e, i) => {
|
|
149
|
+
expect(e.data.type).toBe(e.event);
|
|
150
|
+
expect(e.data.sequence_number).toBe(i);
|
|
151
|
+
});
|
|
152
|
+
const completed = events.at(-1)!.data;
|
|
153
|
+
const resp = completed.response as { status: string; model: string; output: Record<string, unknown>[]; usage: Record<string, unknown> };
|
|
154
|
+
expect(resp.status).toBe("completed");
|
|
155
|
+
expect(resp.model).toBe("auto");
|
|
156
|
+
expect(resp.output).toHaveLength(2);
|
|
157
|
+
expect(resp.output[0]).toMatchObject({ type: "message", role: "assistant", status: "completed", content: [{ type: "output_text", text: "Running" }] });
|
|
158
|
+
expect(resp.output[1]).toMatchObject({ type: "function_call", call_id: "call_9", name: "shell", arguments: '{"cmd":"ls"}', status: "completed" });
|
|
159
|
+
expect(resp.usage).toEqual({ input_tokens: 120, input_tokens_details: { cached_tokens: 100 }, output_tokens: 9, output_tokens_details: { reasoning_tokens: 2 }, total_tokens: 129 });
|
|
160
|
+
expect(completed.x_auto_model_router).toEqual({ model: "openai/gpt-5.5", tier: "simple", cost_usd: 0.0012, attempts: 1 });
|
|
161
|
+
// The Responses stream has no [DONE] sentinel.
|
|
162
|
+
expect(text).not.toContain("[DONE]");
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("an error becomes an error event and response.failed", async () => {
|
|
166
|
+
const { sink, response } = createResponsesStreamingSink("auto");
|
|
167
|
+
sink.error({ status: 502, code: "upstream_error", message: "boom" });
|
|
168
|
+
const events = parseEvents(await response.text());
|
|
169
|
+
expect(events.map((e) => e.event)).toEqual(["error", "response.failed"]);
|
|
170
|
+
expect(events[0]!.data).toMatchObject({ code: "upstream_error", message: "boom" });
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
describe("createResponsesBufferedSink", () => {
|
|
175
|
+
test("aggregates the stream into one Response object with the routing headers", async () => {
|
|
176
|
+
const { sink, response } = createResponsesBufferedSink("auto");
|
|
177
|
+
sink.chunk(chunk({ type: "start", servedSlug: "openai/gpt-5.5", generationId: "gen-1" }, { type: "text", delta: "4" }, { type: "finish", reason: "stop" }));
|
|
178
|
+
sink.finish(SUMMARY);
|
|
179
|
+
const res = await response;
|
|
180
|
+
expect(res.status).toBe(200);
|
|
181
|
+
expect(res.headers.get("x-auto-model-router-model")).toBe("openai/gpt-5.5");
|
|
182
|
+
const body = (await res.json()) as { object: string; status: string; output: { type: string; content: { text: string }[] }[]; usage: { total_tokens: number } };
|
|
183
|
+
expect(body.object).toBe("response");
|
|
184
|
+
expect(body.status).toBe("completed");
|
|
185
|
+
expect(body.output[0]?.content[0]?.text).toBe("4");
|
|
186
|
+
expect(body.usage.total_tokens).toBe(129);
|
|
187
|
+
});
|
|
188
|
+
});
|
package/test/wire-sink.test.ts
CHANGED
|
@@ -53,6 +53,11 @@ describe("createStreamingSink", () => {
|
|
|
53
53
|
// Headers flushed with the first chunk, so the summary arrives as the
|
|
54
54
|
// final x_auto_model_router frame before [DONE].
|
|
55
55
|
const last = frames[frames.length - 1] as Record<string, unknown>;
|
|
56
|
+
// A well-formed chunk with no choices, so strict SSE clients (OpenCode's AI SDK) accept it.
|
|
57
|
+
expect(last.object).toBe("chat.completion.chunk");
|
|
58
|
+
expect(last.choices).toEqual([]);
|
|
59
|
+
expect(last.id).toBe("gen-1");
|
|
60
|
+
expect(last.model).toBe("auto");
|
|
56
61
|
expect(last.x_auto_model_router).toEqual({
|
|
57
62
|
model: "openai/gpt-5.5",
|
|
58
63
|
tier: "simple",
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Request-capture proxy for harness fixtures.
|
|
4
|
+
*
|
|
5
|
+
* bun tools/capture-proxy.ts --listen 8799 --upstream http://127.0.0.1:8788 --out test/fixtures/harness --name codex
|
|
6
|
+
*
|
|
7
|
+
* Point a harness at http://127.0.0.1:8799/v1, run one turn, and every
|
|
8
|
+
* POST /v1/chat/completions body it sent is written to
|
|
9
|
+
* `<out>/<name>-<n>.json` with the request headers that matter (harness,
|
|
10
|
+
* session, subagent, content-type, user-agent) beside it. Everything is
|
|
11
|
+
* forwarded to the real router unchanged, streaming included, so the turn
|
|
12
|
+
* completes normally. Authorization headers are never written.
|
|
13
|
+
*
|
|
14
|
+
* The saved bodies are what test/harness-requests.test.ts parses: a harness
|
|
15
|
+
* release that changes its request shape then shows up as a failing test
|
|
16
|
+
* rather than a user report. Re-run only to refresh a harness's fixture.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { mkdirSync } from "node:fs";
|
|
20
|
+
import { join } from "node:path";
|
|
21
|
+
|
|
22
|
+
const argv = process.argv.slice(2);
|
|
23
|
+
const flag = (name: string, fallback: string): string => {
|
|
24
|
+
const i = argv.indexOf(name);
|
|
25
|
+
return i >= 0 && argv[i + 1] !== undefined ? argv[i + 1]! : fallback;
|
|
26
|
+
};
|
|
27
|
+
const listen = Number.parseInt(flag("--listen", "8799"), 10);
|
|
28
|
+
const upstream = flag("--upstream", "http://127.0.0.1:8788").replace(/\/$/, "");
|
|
29
|
+
const out = flag("--out", "test/fixtures/harness");
|
|
30
|
+
const name = flag("--name", "harness");
|
|
31
|
+
mkdirSync(out, { recursive: true });
|
|
32
|
+
|
|
33
|
+
const KEEP_HEADERS = ["content-type", "user-agent", "x-omp-harness", "x-omp-session", "x-omp-subagent", "x-title", "http-referer"];
|
|
34
|
+
let n = 0;
|
|
35
|
+
|
|
36
|
+
/** Large text is not what the fixture guards; cap message text so files stay small. */
|
|
37
|
+
function trim(body: unknown): unknown {
|
|
38
|
+
if (typeof body === "string") return body.length > 400 ? `${body.slice(0, 400)}…[${body.length} chars]` : body;
|
|
39
|
+
if (Array.isArray(body)) return body.map(trim);
|
|
40
|
+
if (body !== null && typeof body === "object") return Object.fromEntries(Object.entries(body as Record<string, unknown>).map(([k, v]) => [k, trim(v)]));
|
|
41
|
+
return body;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
Bun.serve({
|
|
45
|
+
port: listen,
|
|
46
|
+
hostname: "127.0.0.1",
|
|
47
|
+
idleTimeout: 255,
|
|
48
|
+
async fetch(req) {
|
|
49
|
+
const url = new URL(req.url);
|
|
50
|
+
const raw = req.method === "POST" ? await req.text() : "";
|
|
51
|
+
if (req.method === "POST" && (url.pathname.endsWith("/chat/completions") || url.pathname.endsWith("/responses") || url.pathname.endsWith("/messages"))) {
|
|
52
|
+
n += 1;
|
|
53
|
+
let body: unknown = raw;
|
|
54
|
+
try {
|
|
55
|
+
body = JSON.parse(raw);
|
|
56
|
+
} catch {
|
|
57
|
+
// Not JSON: keep the raw text.
|
|
58
|
+
}
|
|
59
|
+
const headers: Record<string, string> = {};
|
|
60
|
+
for (const h of KEEP_HEADERS) {
|
|
61
|
+
const v = req.headers.get(h);
|
|
62
|
+
if (v !== null) headers[h] = v;
|
|
63
|
+
}
|
|
64
|
+
const file = join(out, `${name}-${n}.json`);
|
|
65
|
+
await Bun.write(file, JSON.stringify({ harness: name, capturedAtMs: Date.now(), headers, body: trim(body) }, null, 1));
|
|
66
|
+
console.log(`captured ${file} (${raw.length} bytes)`);
|
|
67
|
+
}
|
|
68
|
+
const fwd = new Headers(req.headers);
|
|
69
|
+
fwd.delete("host");
|
|
70
|
+
fwd.delete("content-length");
|
|
71
|
+
const res = await fetch(upstream + url.pathname + url.search, {
|
|
72
|
+
method: req.method,
|
|
73
|
+
headers: fwd,
|
|
74
|
+
...(req.method === "POST" ? { body: raw } : {}),
|
|
75
|
+
});
|
|
76
|
+
return new Response(res.body, { status: res.status, headers: res.headers });
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
console.log(`capture proxy on http://127.0.0.1:${listen} → ${upstream}; writing ${out}/${name}-<n>.json`);
|
package/tsconfig.all.json
CHANGED