auto-model-router 0.32.0 → 0.34.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/.omp-plugin/marketplace.json +2 -2
- package/README.md +39 -0
- package/docs/data-governance.md +42 -1
- package/package.json +1 -1
- package/src/cost/ledger-sql.ts +41 -3
- package/src/router/candidates.ts +9 -0
- package/src/router/types.ts +2 -0
- package/src/server/http.ts +7 -1
- package/src/server/turn.ts +3 -1
- package/src/upstream/anthropic.ts +5 -4
- package/src/upstream/compat.ts +13 -7
- package/src/upstream/ollama.ts +4 -3
- package/src/upstream/openrouter.ts +4 -3
- package/src/upstream/types.ts +7 -0
- package/src/util/schema.ts +148 -19
- package/src/wire/openai/request.ts +29 -0
- package/src/wire/types.ts +15 -0
- package/test/failover.test.ts +41 -0
- package/test/ledger-partitions.test.ts +321 -0
- package/test/upstream-keys.test.ts +240 -0
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { buildUpstreamModels } from "../src/catalog/static-catalog.ts";
|
|
4
|
+
import type { CatalogSnapshot } from "../src/catalog/types.ts";
|
|
5
|
+
import { DEFAULT_CONFIG } from "../src/config/defaults.ts";
|
|
6
|
+
import type { RouterConfig, UpstreamEntry } from "../src/config/types.ts";
|
|
7
|
+
import { completeUpstreamEntry } from "../src/config/upstreams.ts";
|
|
8
|
+
import { buildCandidates } from "../src/router/candidates.ts";
|
|
9
|
+
import { extractFeatures } from "../src/router/features.ts";
|
|
10
|
+
import type { Rejection } from "../src/router/types.ts";
|
|
11
|
+
import { createAnthropicClient } from "../src/upstream/anthropic.ts";
|
|
12
|
+
import { compatEndpoint, createCompatClient } from "../src/upstream/compat.ts";
|
|
13
|
+
import { createOllamaClient } from "../src/upstream/ollama.ts";
|
|
14
|
+
import { createOpenRouterClient } from "../src/upstream/openrouter.ts";
|
|
15
|
+
import { parseMessagesRequest } from "../src/wire/anthropic/messages.ts";
|
|
16
|
+
import { parseChatRequest, parseUpstreamKeysHeader } from "../src/wire/openai/request.ts";
|
|
17
|
+
import { parseResponsesRequest } from "../src/wire/openai/responses.ts";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Per-turn upstream credentials (X-Omp-Upstream-Keys): a front door whose
|
|
21
|
+
* callers bring their own keys sends the turn's credentials in a header, so one
|
|
22
|
+
* router fleet serves every tenant instead of one process per credential set.
|
|
23
|
+
*
|
|
24
|
+
* The property everything else rests on: the credential belongs to the TURN.
|
|
25
|
+
* The shared `UpstreamEntry` is never written to, so two concurrent turns
|
|
26
|
+
* cannot see each other's key.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
const NL = String.fromCharCode(10);
|
|
30
|
+
|
|
31
|
+
function entry(over: Partial<UpstreamEntry> & { id: string; kind: UpstreamEntry["kind"] }): UpstreamEntry {
|
|
32
|
+
return completeUpstreamEntry({ baseUrl: "https://api.example/v1", apiKey: "sk-configured", models: [{ id: "m1", input: 1, output: 4 }], ...over });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function cfgWith(upstreams: UpstreamEntry[]): RouterConfig {
|
|
36
|
+
return { ...structuredClone(DEFAULT_CONFIG), upstreams, logLevel: "silent" };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function sse(frames: string[]): Response {
|
|
40
|
+
return new Response(
|
|
41
|
+
new ReadableStream({
|
|
42
|
+
start: (c) => {
|
|
43
|
+
for (const f of frames) c.enqueue(new TextEncoder().encode(`${f}${NL}${NL}`));
|
|
44
|
+
c.close();
|
|
45
|
+
},
|
|
46
|
+
}),
|
|
47
|
+
{ status: 200, headers: { "content-type": "text/event-stream" } },
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Drains a dispatch so the fake upstream's stream is consumed like a real turn's. */
|
|
52
|
+
async function drain(d: { chunks: AsyncIterable<unknown> }): Promise<void> {
|
|
53
|
+
for await (const c of d.chunks) void c;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const OK_FRAMES = [`data: ${JSON.stringify({ id: "gen-1", model: "m1", choices: [{ delta: { content: "hi" } }] })}`, "data: [DONE]"];
|
|
57
|
+
const ANTHROPIC_FRAMES = [
|
|
58
|
+
`event: message_start${NL}data: ${JSON.stringify({ type: "message_start", message: { id: "msg_1", model: "claude-sonnet-4", usage: { input_tokens: 1, output_tokens: 0 } } })}`,
|
|
59
|
+
`event: message_stop${NL}data: ${JSON.stringify({ type: "message_stop" })}`,
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
const SIGNAL = new AbortController().signal;
|
|
63
|
+
|
|
64
|
+
describe("parseUpstreamKeysHeader", () => {
|
|
65
|
+
test("accepts an id→credential object, drops junk, and never rejects a turn", async () => {
|
|
66
|
+
// Same defensive contract as X-Omp-Policy: anything unusable is simply no override.
|
|
67
|
+
expect(parseUpstreamKeysHeader(null)).toBeUndefined();
|
|
68
|
+
expect(parseUpstreamKeysHeader(" ")).toBeUndefined();
|
|
69
|
+
expect(parseUpstreamKeysHeader("not json")).toBeUndefined();
|
|
70
|
+
expect(parseUpstreamKeysHeader('["sk-x"]')).toBeUndefined();
|
|
71
|
+
expect(parseUpstreamKeysHeader('"sk-x"')).toBeUndefined();
|
|
72
|
+
expect(parseUpstreamKeysHeader("{}")).toBeUndefined();
|
|
73
|
+
// Non-string values are dropped; an empty string is KEPT — it means "no credential this turn".
|
|
74
|
+
expect(parseUpstreamKeysHeader(JSON.stringify({ openrouter: "sk-or-1", " azure-eu ": "az", bad: 7, worse: null, "": "x", off: "" }))).toEqual({
|
|
75
|
+
openrouter: "sk-or-1",
|
|
76
|
+
"azure-eu": "az",
|
|
77
|
+
off: "",
|
|
78
|
+
});
|
|
79
|
+
// The credential is copied verbatim: trimming one would break a key whose bytes matter.
|
|
80
|
+
expect(parseUpstreamKeysHeader(JSON.stringify({ up: " sk-pad " }))).toEqual({ up: " sk-pad " });
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("every wire carries the map, and its absence leaves the property off", async () => {
|
|
84
|
+
const header = new Headers({ "X-Omp-Upstream-Keys": '{"openrouter":"sk-or-tenant"}' });
|
|
85
|
+
const chat = parseChatRequest({ model: "auto", messages: [{ role: "user", content: "hi" }] }, header);
|
|
86
|
+
const messages = parseMessagesRequest({ model: "claude-sonnet-4", messages: [{ role: "user", content: "hi" }], max_tokens: 16 }, header);
|
|
87
|
+
const responses = parseResponsesRequest({ model: "auto", input: "hi" }, header);
|
|
88
|
+
for (const req of [chat, messages, responses]) expect(req.upstreamKeys).toEqual({ openrouter: "sk-or-tenant" });
|
|
89
|
+
expect([chat.protocol, messages.protocol, responses.protocol]).toEqual(["openai-chat", "anthropic-messages", "openai-responses"]);
|
|
90
|
+
// exactOptionalPropertyTypes: absent means absent, not `undefined`.
|
|
91
|
+
expect("upstreamKeys" in parseChatRequest({ model: "auto", messages: [{ role: "user", content: "hi" }] }, new Headers())).toBe(false);
|
|
92
|
+
expect("upstreamKeys" in parseMessagesRequest({ model: "claude-sonnet-4", messages: [{ role: "user", content: "hi" }], max_tokens: 16 }, new Headers())).toBe(false);
|
|
93
|
+
expect("upstreamKeys" in parseResponsesRequest({ model: "auto", input: "hi" }, new Headers())).toBe(false);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe("per-turn credentials at dispatch", () => {
|
|
98
|
+
test("compatEndpoint prefers the per-turn key without touching the entry", async () => {
|
|
99
|
+
const e = entry({ id: "openai-direct", kind: "openai" });
|
|
100
|
+
expect(compatEndpoint(e, "m1").headers.authorization).toBe("Bearer sk-configured");
|
|
101
|
+
expect(compatEndpoint(e, "m1", "sk-turn").headers.authorization).toBe("Bearer sk-turn");
|
|
102
|
+
// Azure keys with its own header, and an empty credential sends none at all.
|
|
103
|
+
const az = entry({ id: "azure-eu", kind: "azure" });
|
|
104
|
+
expect(compatEndpoint(az, "dep", "az-turn").headers["api-key"]).toBe("az-turn");
|
|
105
|
+
expect(compatEndpoint(az, "dep", "").headers["api-key"]).toBeUndefined();
|
|
106
|
+
expect(e.apiKey).toBe("sk-configured");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("an OpenAI-compatible upstream uses the turn's key, else the configured one", async () => {
|
|
110
|
+
const seen: Array<string | null> = [];
|
|
111
|
+
const cfg = cfgWith([entry({ id: "openai-direct", kind: "openai" })]);
|
|
112
|
+
const client = createCompatClient(cfg, "openai-direct", async (_url, init) => {
|
|
113
|
+
seen.push(new Headers(init?.headers).get("authorization"));
|
|
114
|
+
return sse(OK_FRAMES);
|
|
115
|
+
});
|
|
116
|
+
const body = { model: "openai-direct/m1", messages: [] };
|
|
117
|
+
await drain(await client.dispatch({ body, sessionId: "s", signal: SIGNAL, upstreamKeys: { "openai-direct": "sk-turn" } }));
|
|
118
|
+
await drain(await client.dispatch({ body, sessionId: "s", signal: SIGNAL }));
|
|
119
|
+
// A map that names a DIFFERENT upstream leaves this one on its own key.
|
|
120
|
+
await drain(await client.dispatch({ body, sessionId: "s", signal: SIGNAL, upstreamKeys: { openrouter: "sk-or" } }));
|
|
121
|
+
// An empty credential is "none", never a fallback to the configured key.
|
|
122
|
+
await drain(await client.dispatch({ body, sessionId: "s", signal: SIGNAL, upstreamKeys: { "openai-direct": "" } }));
|
|
123
|
+
expect(seen).toEqual(["Bearer sk-turn", "Bearer sk-configured", "Bearer sk-configured", null]);
|
|
124
|
+
expect(cfg.upstreams[0]!.apiKey).toBe("sk-configured");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("an Anthropic upstream uses the turn's key on both auth shapes", async () => {
|
|
128
|
+
const seen: Array<Record<string, string | null>> = [];
|
|
129
|
+
const cfg = cfgWith([
|
|
130
|
+
entry({ id: "anthropic-direct", kind: "anthropic", apiKey: "sk-ant-configured" }),
|
|
131
|
+
entry({ id: "claude-sub", kind: "anthropic", auth: "oauth-bearer", apiKey: "oauth-configured" }),
|
|
132
|
+
]);
|
|
133
|
+
const fetchImpl = async (_url: unknown, init?: RequestInit): Promise<Response> => {
|
|
134
|
+
const h = new Headers(init?.headers);
|
|
135
|
+
seen.push({ "x-api-key": h.get("x-api-key"), authorization: h.get("authorization") });
|
|
136
|
+
return sse(ANTHROPIC_FRAMES);
|
|
137
|
+
};
|
|
138
|
+
const direct = createAnthropicClient(cfg, "anthropic-direct", fetchImpl);
|
|
139
|
+
const sub = createAnthropicClient(cfg, "claude-sub", fetchImpl);
|
|
140
|
+
await drain(await direct.dispatch({ body: { model: "anthropic-direct/m1", messages: [{ role: "user", content: "hi" }], max_tokens: 16 }, sessionId: "s", signal: SIGNAL, upstreamKeys: { "anthropic-direct": "sk-ant-turn" } }));
|
|
141
|
+
await drain(await direct.dispatch({ body: { model: "anthropic-direct/m1", messages: [{ role: "user", content: "hi" }], max_tokens: 16 }, sessionId: "s", signal: SIGNAL }));
|
|
142
|
+
await drain(await sub.dispatch({ body: { model: "claude-sub/m1", messages: [{ role: "user", content: "hi" }], max_tokens: 16 }, sessionId: "s", signal: SIGNAL, upstreamKeys: { "claude-sub": "oauth-turn" } }));
|
|
143
|
+
expect(seen).toEqual([
|
|
144
|
+
{ "x-api-key": "sk-ant-turn", authorization: null },
|
|
145
|
+
{ "x-api-key": "sk-ant-configured", authorization: null },
|
|
146
|
+
{ "x-api-key": null, authorization: "Bearer oauth-turn" },
|
|
147
|
+
]);
|
|
148
|
+
expect(cfg.upstreams.map((u) => u.apiKey)).toEqual(["sk-ant-configured", "oauth-configured"]);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("OpenRouter and Ollama take an override under their own reserved ids", async () => {
|
|
152
|
+
const cfg = cfgWith([]);
|
|
153
|
+
cfg.openrouter.apiKey = "sk-or-configured";
|
|
154
|
+
cfg.ollama = { ...cfg.ollama, apiKey: "sk-ollama-configured" };
|
|
155
|
+
const seen: Array<string | null> = [];
|
|
156
|
+
const realFetch = globalThis.fetch;
|
|
157
|
+
globalThis.fetch = (async (_url: unknown, init?: RequestInit) => {
|
|
158
|
+
seen.push(new Headers(init?.headers).get("authorization"));
|
|
159
|
+
return sse(OK_FRAMES);
|
|
160
|
+
}) as unknown as typeof fetch;
|
|
161
|
+
try {
|
|
162
|
+
const or = createOpenRouterClient(cfg);
|
|
163
|
+
await drain(await or.dispatch({ body: { model: "a/b", messages: [] }, sessionId: "s", signal: SIGNAL, upstreamKeys: { openrouter: "sk-or-turn" } }));
|
|
164
|
+
await drain(await or.dispatch({ body: { model: "a/b", messages: [] }, sessionId: "s", signal: SIGNAL }));
|
|
165
|
+
} finally {
|
|
166
|
+
globalThis.fetch = realFetch;
|
|
167
|
+
}
|
|
168
|
+
const ollama = createOllamaClient(cfg, async (_url, init) => {
|
|
169
|
+
seen.push(new Headers(init?.headers).get("authorization"));
|
|
170
|
+
return sse(OK_FRAMES);
|
|
171
|
+
});
|
|
172
|
+
await drain(await ollama.dispatch({ body: { model: "ollama/m", messages: [] }, sessionId: "s", signal: SIGNAL, upstreamKeys: { ollama: "sk-ollama-turn" } }));
|
|
173
|
+
await drain(await ollama.dispatch({ body: { model: "ollama/m", messages: [] }, sessionId: "s", signal: SIGNAL }));
|
|
174
|
+
expect(seen).toEqual(["Bearer sk-or-turn", "Bearer sk-or-configured", "Bearer sk-ollama-turn", "Bearer sk-ollama-configured"]);
|
|
175
|
+
expect(cfg.openrouter.apiKey).toBe("sk-or-configured");
|
|
176
|
+
expect(cfg.ollama.apiKey).toBe("sk-ollama-configured");
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("two concurrent turns over one entry never see each other's credential", async () => {
|
|
180
|
+
const cfg = cfgWith([entry({ id: "openai-direct", kind: "openai" })]);
|
|
181
|
+
let release!: () => void;
|
|
182
|
+
const gate = new Promise<void>((resolve) => {
|
|
183
|
+
release = resolve;
|
|
184
|
+
});
|
|
185
|
+
const seen: Array<string | null> = [];
|
|
186
|
+
let inFlight = 0;
|
|
187
|
+
const client = createCompatClient(cfg, "openai-direct", async (_url, init) => {
|
|
188
|
+
const auth = new Headers(init?.headers).get("authorization");
|
|
189
|
+
// Hold both requests open at once: a mutation-based implementation
|
|
190
|
+
// would have overwritten the first turn's key by the time it reads.
|
|
191
|
+
inFlight++;
|
|
192
|
+
if (inFlight === 1) await gate;
|
|
193
|
+
else release();
|
|
194
|
+
seen.push(auth);
|
|
195
|
+
return sse(OK_FRAMES);
|
|
196
|
+
});
|
|
197
|
+
const turn = async (key: string): Promise<void> => {
|
|
198
|
+
const d = await client.dispatch({ body: { model: "openai-direct/m1", messages: [] }, sessionId: "s", signal: SIGNAL, upstreamKeys: { "openai-direct": key } });
|
|
199
|
+
await drain(d);
|
|
200
|
+
};
|
|
201
|
+
await Promise.all([turn("sk-tenant-a"), turn("sk-tenant-b")]);
|
|
202
|
+
expect(seen.sort()).toEqual(["Bearer sk-tenant-a", "Bearer sk-tenant-b"]);
|
|
203
|
+
// The shared config is exactly as it was configured.
|
|
204
|
+
expect(cfg.upstreams[0]!.apiKey).toBe("sk-configured");
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
describe("candidate selection with per-turn credentials", () => {
|
|
209
|
+
const twoUpstreams = [
|
|
210
|
+
...buildUpstreamModels(entry({ id: "up-a", kind: "openai", models: [{ id: "m1", input: 1, output: 4, quality: { coding: 80, intelligence: 80, agentic: 80 } }] }), []),
|
|
211
|
+
...buildUpstreamModels(entry({ id: "up-b", kind: "openai", models: [{ id: "m1", input: 1, output: 4, quality: { coding: 80, intelligence: 80, agentic: 80 } }] }), []),
|
|
212
|
+
];
|
|
213
|
+
const snapshot: CatalogSnapshot = { models: twoUpstreams, fetchedAtMs: Date.now(), keyScoped: true };
|
|
214
|
+
const cfg: RouterConfig = { ...DEFAULT_CONFIG, adaptiveTierFloors: false, filters: { ...DEFAULT_CONFIG.filters, minTrust: 0 }, tiers: { ...DEFAULT_CONFIG.tiers, moderate: { ...DEFAULT_CONFIG.tiers.moderate, minQuality: 0, maxInputPerMtok: 10 } } };
|
|
215
|
+
|
|
216
|
+
function build(keys: Record<string, string> | undefined): { slugs: string[]; rejected: Rejection[] } {
|
|
217
|
+
const req = parseChatRequest(
|
|
218
|
+
{ model: "auto", messages: [{ role: "user", content: "hi" }] },
|
|
219
|
+
new Headers(keys === undefined ? {} : { "x-omp-upstream-keys": JSON.stringify(keys) }),
|
|
220
|
+
);
|
|
221
|
+
const { candidates, rejected } = buildCandidates({ req, features: extractFeatures(req, 100), tier: "moderate", task: "chat", snapshot, cfg, expectedCompletionTokens: 128, warmSlug: null });
|
|
222
|
+
return { slugs: candidates.map((c) => c.model.slug), rejected };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
test("an upstream with an empty credential is excluded for that turn only", async () => {
|
|
226
|
+
expect(build(undefined).slugs.sort()).toEqual(["up-a/m1", "up-b/m1"]);
|
|
227
|
+
// A real credential changes nothing about who may be selected.
|
|
228
|
+
expect(build({ "up-a": "sk-turn" }).slugs.sort()).toEqual(["up-a/m1", "up-b/m1"]);
|
|
229
|
+
const off = build({ "up-a": "" });
|
|
230
|
+
expect(off.slugs).toEqual(["up-b/m1"]);
|
|
231
|
+
expect(off.rejected).toContainEqual({ slug: "up-a/m1", reason: "no_credential", detail: "upstream up-a has no credential on this turn" });
|
|
232
|
+
// The next turn, carrying a key, sees it again: nothing was recorded anywhere.
|
|
233
|
+
expect(build({ "up-a": "sk-turn" }).slugs.sort()).toEqual(["up-a/m1", "up-b/m1"]);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test("no rejection reason repeats the credential", async () => {
|
|
237
|
+
const { rejected } = build({ "up-a": "", "up-b": "sk-secret-value" });
|
|
238
|
+
expect(JSON.stringify(rejected)).not.toContain("sk-secret-value");
|
|
239
|
+
});
|
|
240
|
+
});
|