@wenathlan/getry 2.0.152 → 2.0.155
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/package.json +189 -13
- package/tests/cli.test.ts +300 -0
- package/tests/debonairlane.test.ts +66 -21
- package/tests/gateway.test.ts +962 -0
- package/tests/gatewayserver.ts +99 -0
- package/tests/intelligence.test.ts +62 -0
- package/tests/jsonpolicy.test.ts +289 -0
- package/tests/llm.test.ts +696 -0
- package/tests/modelroute.test.ts +112 -0
- package/tests/promptlibrary.test.ts +116 -0
- package/tests/provider.constants.test.ts +134 -0
- package/tests/provider.fingerprint.test.ts +149 -0
- package/tests/provider.models.test.ts +250 -0
- package/tests/provider.oauth.test.ts +150 -0
- package/tests/providercli.test.ts +84 -0
- package/tests/providerfixtures.ts +224 -0
- package/tests/researchfixtures.ts +4572 -0
- package/tests/server.auth.test.ts +394 -0
- package/tests/server.engine.test.ts +1074 -0
- package/tests/server.property.test.ts +220 -0
- package/tests/server.utils.test.ts +546 -0
- package/tests/torture.llm.test.ts +807 -0
|
@@ -0,0 +1,1074 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* engine — the universal pipeline under fire
|
|
3
|
+
* engineinternals unit behavior plus every handler against a live mock
|
|
4
|
+
* upstream: retry backoff rotation timeout fallback paused auth format
|
|
5
|
+
* conversion and stream masking — the whole request lifecycle
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
9
|
+
import http from "node:http";
|
|
10
|
+
import type { AddressInfo } from "node:net";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
|
14
|
+
|
|
15
|
+
import { createversion, engineinternals } from "../engine.js";
|
|
16
|
+
import type { gatewayconfig, modeldef } from "../types.js";
|
|
17
|
+
|
|
18
|
+
// keep persistence silent — point the lazy prisma client at a tableless
|
|
19
|
+
// sqlite scratch OUTSIDE the repository tree (the bridge suite walks the
|
|
20
|
+
// Sol tree and refuses every non static file type it finds, and the working
|
|
21
|
+
// tree stays clean whatever the battery leaves behind) so every savemsg
|
|
22
|
+
// no-ops instead of touching the dev db
|
|
23
|
+
const silentdir = mkdtempSync(join(tmpdir(), "devthink-engine-silent-"));
|
|
24
|
+
process.env.DEVTHINK_DATABASE_URL = `file:${join(silentdir, "engine-test-silent.db")}`;
|
|
25
|
+
|
|
26
|
+
const { resolvemodel, findmodel, fusioncontext, buildbody, classifystatus, backoffms, fitcontext, defaultbudgets } =
|
|
27
|
+
engineinternals;
|
|
28
|
+
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// mock upstream — a real http server with scripted responses
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
interface capturedrequest {
|
|
34
|
+
method: string;
|
|
35
|
+
url: string;
|
|
36
|
+
headers: Record<string, string | string[] | undefined>;
|
|
37
|
+
body: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface responsespec {
|
|
41
|
+
status: number;
|
|
42
|
+
headers?: Record<string, string>;
|
|
43
|
+
body?: string;
|
|
44
|
+
delayms?: number;
|
|
45
|
+
destroy?: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
let mockport = 0;
|
|
49
|
+
let captured: capturedrequest[] = [];
|
|
50
|
+
let script: responsespec[] = [];
|
|
51
|
+
|
|
52
|
+
const mockserver = http.createServer((req, res) => {
|
|
53
|
+
const chunks: Buffer[] = [];
|
|
54
|
+
req.on("data", (c: Buffer) => chunks.push(c));
|
|
55
|
+
req.on("end", () => {
|
|
56
|
+
const body = Buffer.concat(chunks).toString("utf8");
|
|
57
|
+
captured.push({
|
|
58
|
+
method: req.method ?? "?",
|
|
59
|
+
url: req.url ?? "?",
|
|
60
|
+
headers: req.headers,
|
|
61
|
+
body,
|
|
62
|
+
});
|
|
63
|
+
const spec = script.shift() ?? { status: 200, body: "{}" };
|
|
64
|
+
const respond = () => {
|
|
65
|
+
if (spec.destroy) {
|
|
66
|
+
res.destroy();
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
res.writeHead(spec.status, spec.headers ?? { "content-type": "application/json" });
|
|
70
|
+
res.end(spec.body ?? "");
|
|
71
|
+
};
|
|
72
|
+
if (spec.delayms) setTimeout(respond, spec.delayms);
|
|
73
|
+
else respond();
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
beforeAll(async () => {
|
|
78
|
+
await new Promise<void>((resolve) => {
|
|
79
|
+
mockserver.listen(0, "127.0.0.1", () => resolve());
|
|
80
|
+
});
|
|
81
|
+
mockport = (mockserver.address() as AddressInfo).port;
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
afterAll(async () => {
|
|
85
|
+
await new Promise<void>((resolve) => mockserver.close(() => resolve()));
|
|
86
|
+
rmSync(silentdir, { recursive: true, force: true });
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
beforeEach(() => {
|
|
90
|
+
captured = [];
|
|
91
|
+
script = [];
|
|
92
|
+
delete process.env.gatewayenginekeys;
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
// config factory
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
const models: modeldef[] = [
|
|
100
|
+
{ id: "m1", context: 8192, maxoutput: 1024 },
|
|
101
|
+
{ id: "m2", context: 4096, maxoutput: 512 },
|
|
102
|
+
];
|
|
103
|
+
|
|
104
|
+
function baseconfig(overrides: Partial<gatewayconfig> = {}): gatewayconfig {
|
|
105
|
+
return {
|
|
106
|
+
id: "vt",
|
|
107
|
+
name: "VT",
|
|
108
|
+
providername: "mock",
|
|
109
|
+
upstreams: [{ name: "mock", baseurl: `http://127.0.0.1:${mockport}` }],
|
|
110
|
+
auth: {
|
|
111
|
+
mode: "bearer",
|
|
112
|
+
required: false,
|
|
113
|
+
keysources: ["inline"],
|
|
114
|
+
inlinekeys: ["testkey-0000000001"],
|
|
115
|
+
},
|
|
116
|
+
models,
|
|
117
|
+
defaultmodel: "m1",
|
|
118
|
+
metamodel: {
|
|
119
|
+
id: "meta",
|
|
120
|
+
maxoutput: 512,
|
|
121
|
+
maskupstreammodel: true,
|
|
122
|
+
alwaysdisplay: true,
|
|
123
|
+
},
|
|
124
|
+
retry: {
|
|
125
|
+
maxretries: 0,
|
|
126
|
+
backoffbasems: 1,
|
|
127
|
+
backoffcapms: 2,
|
|
128
|
+
jitter: 0,
|
|
129
|
+
},
|
|
130
|
+
timeout: { requestms: 5000, streamms: 10000 },
|
|
131
|
+
...overrides,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** openai shaped non stream success response */
|
|
136
|
+
function okssechunchof(content: string, model = "upstream-model"): string {
|
|
137
|
+
return `data: ${JSON.stringify({
|
|
138
|
+
id: "up-1",
|
|
139
|
+
object: "chat.completion.chunk",
|
|
140
|
+
model,
|
|
141
|
+
choices: [{ index: 0, delta: { content }, finish_reason: null }],
|
|
142
|
+
})}\n\n`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function okjson(content = "hello there", model = "upstream-model"): string {
|
|
146
|
+
return JSON.stringify({
|
|
147
|
+
id: "up-1",
|
|
148
|
+
object: "chat.completion",
|
|
149
|
+
created: 1700000000,
|
|
150
|
+
model,
|
|
151
|
+
choices: [
|
|
152
|
+
{
|
|
153
|
+
index: 0,
|
|
154
|
+
message: { role: "assistant", content },
|
|
155
|
+
finish_reason: "stop",
|
|
156
|
+
},
|
|
157
|
+
],
|
|
158
|
+
usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function post(handlers: ReturnType<typeof createversion>, body: unknown, headers = {}) {
|
|
163
|
+
return handlers.handlechatcompletions(
|
|
164
|
+
new Request(`http://x/api/vt/chat/completions`, {
|
|
165
|
+
method: "POST",
|
|
166
|
+
headers: { "content-type": "application/json", ...headers },
|
|
167
|
+
body: typeof body === "string" ? body : JSON.stringify(body),
|
|
168
|
+
}),
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
// model resolution
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
|
|
176
|
+
describe("resolvemodel — rotation and fallback logic", () => {
|
|
177
|
+
const cfg = baseconfig({
|
|
178
|
+
rotation: { mode: "persession", models: ["m1", "m2"], everynmessages: 6 },
|
|
179
|
+
});
|
|
180
|
+
const counter = { n: 0 };
|
|
181
|
+
|
|
182
|
+
it("individual model requests resolve directly", () => {
|
|
183
|
+
const out = resolvemodel(cfg, "m1", { messagecount: 0, rotationindex: 0, model: "" }, counter);
|
|
184
|
+
expect(out.modelid).toBe("m1");
|
|
185
|
+
expect(out.ismeta).toBe(false);
|
|
186
|
+
expect(out.model).toBeDefined();
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("unknown model requests fall back to the default", () => {
|
|
190
|
+
const out = resolvemodel(cfg, "nope", { messagecount: 0, rotationindex: 0, model: "" }, counter);
|
|
191
|
+
expect(out.modelid).toBe("m1");
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("undefined request resolves the meta rotation", () => {
|
|
195
|
+
const out = resolvemodel(cfg, undefined, { messagecount: 0, rotationindex: 0, model: "" }, counter);
|
|
196
|
+
expect(out.ismeta).toBe(true);
|
|
197
|
+
expect(out.modelid).toBe("m1");
|
|
198
|
+
expect(out.display).toBe("meta");
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("explicit meta id also enters rotation", () => {
|
|
202
|
+
const out = resolvemodel(cfg, "meta", { messagecount: 0, rotationindex: 0, model: "" }, counter);
|
|
203
|
+
expect(out.ismeta).toBe(true);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("persession rotation flips every n messages", () => {
|
|
207
|
+
const session = { messagecount: 0, rotationindex: 0, model: "" };
|
|
208
|
+
const seq: string[] = [];
|
|
209
|
+
for (let i = 1; i <= 8; i += 1) {
|
|
210
|
+
session.messagecount = i;
|
|
211
|
+
seq.push(resolvemodel(cfg, undefined, session, counter).modelid);
|
|
212
|
+
}
|
|
213
|
+
// messages 1-5 → m1 (idx floor(n/6)=0), 6-8 → m2 wait floor(6/6)=1
|
|
214
|
+
expect(seq.slice(0, 5)).toEqual(["m1", "m1", "m1", "m1", "m1"]);
|
|
215
|
+
expect(seq.slice(5, 8)).toEqual(["m2", "m2", "m2"]);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("perrequest rotation advances with the global counter", () => {
|
|
219
|
+
const cfgpr = baseconfig({
|
|
220
|
+
rotation: { mode: "perrequest", models: ["m1", "m2"] },
|
|
221
|
+
});
|
|
222
|
+
const counterpr = { n: 0 };
|
|
223
|
+
const session = { messagecount: 0, rotationindex: 0, model: "" };
|
|
224
|
+
const first = resolvemodel(cfgpr, undefined, session, counterpr).modelid;
|
|
225
|
+
const second = resolvemodel(cfgpr, undefined, session, counterpr).modelid;
|
|
226
|
+
expect(first).not.toBe(second);
|
|
227
|
+
expect(counterpr.n).toBe(2);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("staggernewsessions offsets fresh sessions by the global counter", () => {
|
|
231
|
+
const cfgst = baseconfig({
|
|
232
|
+
rotation: { mode: "persession", models: ["m1", "m2"], staggernewsessions: true },
|
|
233
|
+
});
|
|
234
|
+
const counterst = { n: 1 };
|
|
235
|
+
const session = { messagecount: 0, rotationindex: 0, model: "" };
|
|
236
|
+
// idx = floor(0/6)=0 then +counter(1) → m2
|
|
237
|
+
expect(resolvemodel(cfgst, undefined, session, counterst).modelid).toBe("m2");
|
|
238
|
+
expect(counterst.n).toBe(2);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("alwaysdisplay shows the meta id — otherwise the resolved model", () => {
|
|
242
|
+
const cfgplain = baseconfig({
|
|
243
|
+
metamodel: { id: "meta", maxoutput: 512, maskupstreammodel: false, alwaysdisplay: false },
|
|
244
|
+
rotation: { mode: "persession", models: ["m1", "m2"] },
|
|
245
|
+
});
|
|
246
|
+
const out = resolvemodel(cfgplain, "m2", { messagecount: 0, rotationindex: 0, model: "" }, counter);
|
|
247
|
+
expect(out.display).toBe("m2");
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it("findmodel locates catalog entries", () => {
|
|
251
|
+
expect(findmodel(cfg, "m1")?.context).toBe(8192);
|
|
252
|
+
expect(findmodel(cfg, "missing")).toBeUndefined();
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
describe("fusioncontext — context computation", () => {
|
|
257
|
+
it("sums the model contexts by default", () => {
|
|
258
|
+
expect(fusioncontext(baseconfig())).toBe(8192 + 4096);
|
|
259
|
+
});
|
|
260
|
+
it("the override wins when set", () => {
|
|
261
|
+
const cfg = baseconfig({
|
|
262
|
+
metamodel: { id: "meta", contextoverride: 2300000, maxoutput: 512 },
|
|
263
|
+
});
|
|
264
|
+
expect(fusioncontext(cfg)).toBe(2300000);
|
|
265
|
+
});
|
|
266
|
+
it("a subset pool sums only those models", () => {
|
|
267
|
+
expect(fusioncontext(baseconfig(), [models[1]])).toBe(4096);
|
|
268
|
+
});
|
|
269
|
+
it("empty pool sums zero", () => {
|
|
270
|
+
expect(fusioncontext(baseconfig(), [])).toBe(0);
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
// ---------------------------------------------------------------------------
|
|
275
|
+
// body building
|
|
276
|
+
// ---------------------------------------------------------------------------
|
|
277
|
+
|
|
278
|
+
describe("buildbody — upstream body assembly", () => {
|
|
279
|
+
const cfg = baseconfig({
|
|
280
|
+
bodybuild: { optionalparamspolicy: "omit-unspecified", clampmaxtokens: true },
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it("maps the model id and strips gateway only fields", () => {
|
|
284
|
+
const out = buildbody(cfg, {
|
|
285
|
+
model: "m1",
|
|
286
|
+
messages: [{ role: "user", content: "hi" }],
|
|
287
|
+
apiKey: "secret",
|
|
288
|
+
api_key: "secret",
|
|
289
|
+
token: "secret",
|
|
290
|
+
}) as Record<string, unknown>;
|
|
291
|
+
expect(out["model"]).toBe("m1");
|
|
292
|
+
expect(out["apiKey"]).toBeUndefined();
|
|
293
|
+
expect(out["api_key"]).toBeUndefined();
|
|
294
|
+
expect(out["token"]).toBeUndefined();
|
|
295
|
+
expect(out["messages"]).toEqual([{ role: "user", content: "hi" }]);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it("maps to the upstream id when the model declares one", () => {
|
|
299
|
+
const cfgup = baseconfig();
|
|
300
|
+
const model: modeldef = { id: "exposed", upstream: "hidden/model", context: 1, maxoutput: 1 };
|
|
301
|
+
const out = buildbody(cfgup, { model: "exposed" }, model) as Record<string, unknown>;
|
|
302
|
+
expect(out["model"]).toBe("hidden/model");
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it("adds the enabled thinking object by default level high", () => {
|
|
306
|
+
const out = buildbody(cfg, { model: "m1" }) as Record<string, unknown>;
|
|
307
|
+
expect(out["thinking"]).toEqual({ type: "enabled", budget: 68000 });
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it("none level produces the disabled thinking object", () => {
|
|
311
|
+
const out = buildbody(cfg, { model: "m1", thinking: "none" }) as Record<string, unknown>;
|
|
312
|
+
expect(out["thinking"]).toEqual({ type: "disabled" });
|
|
313
|
+
expect(out["reasoning_effort"]).toBeUndefined();
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it("custom budgets flow into the thinking object", () => {
|
|
317
|
+
const cfgb = baseconfig({
|
|
318
|
+
thinking: { budgets: { ...defaultbudgets, low: 999 } },
|
|
319
|
+
});
|
|
320
|
+
const out = buildbody(cfgb, { model: "m1", thinking: "low" }) as Record<string, unknown>;
|
|
321
|
+
expect(out["thinking"]).toEqual({ type: "enabled", budget: 999 });
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it("chattemplatekwargs mode replaces the thinking object", () => {
|
|
325
|
+
const model: modeldef = {
|
|
326
|
+
id: "m1",
|
|
327
|
+
context: 1,
|
|
328
|
+
maxoutput: 1,
|
|
329
|
+
chattemplatekwargs: { enable_thinking: true },
|
|
330
|
+
};
|
|
331
|
+
const out = buildbody(cfg, { model: "m1", thinking: "high" }, model) as Record<string, unknown>;
|
|
332
|
+
expect(out["chat_template_kwargs"]).toEqual({ enable_thinking: true });
|
|
333
|
+
expect(out["thinking"]).toBeUndefined();
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it("thinking param config chattemplatekwargs applies globally", () => {
|
|
337
|
+
const cfgk = baseconfig({ thinking: { param: "chattemplatekwargs" } });
|
|
338
|
+
const out = buildbody(cfgk, { model: "m1", thinking: "high" }) as Record<string, unknown>;
|
|
339
|
+
expect(out["chat_template_kwargs"]).toEqual({ enable_thinking: true });
|
|
340
|
+
expect(out["thinking"]).toBeUndefined();
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it("omit-unspecified strips params the user never set", () => {
|
|
344
|
+
const out = buildbody(cfg, { model: "m1" }) as Record<string, unknown>;
|
|
345
|
+
expect(out["temperature"]).toBeUndefined();
|
|
346
|
+
expect(out["top_p"]).toBeUndefined();
|
|
347
|
+
expect(out["n"]).toBeUndefined();
|
|
348
|
+
expect(out["frequency_penalty"]).toBeUndefined();
|
|
349
|
+
expect(out["presence_penalty"]).toBeUndefined();
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
it("omit-unspecified keeps user specified params clamped", () => {
|
|
353
|
+
const out = buildbody(cfg, { model: "m1", temperature: 5, top_p: -1, n: 99 }) as Record<string, unknown>;
|
|
354
|
+
expect(out["temperature"]).toBe(2);
|
|
355
|
+
expect(out["top_p"]).toBe(0);
|
|
356
|
+
expect(out["n"]).toBe(10);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it("always policy includes every sampling param with defaults", () => {
|
|
360
|
+
const cfga = baseconfig({ bodybuild: { optionalparamspolicy: "always" } });
|
|
361
|
+
const out = buildbody(cfga, { model: "m1" }) as Record<string, unknown>;
|
|
362
|
+
expect(out["temperature"]).toBe(0.7);
|
|
363
|
+
expect(out["top_p"]).toBe(0.9);
|
|
364
|
+
expect(out["n"]).toBe(1);
|
|
365
|
+
expect(out["frequency_penalty"]).toBe(0);
|
|
366
|
+
expect(out["presence_penalty"]).toBe(0);
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
it("max_tokens clamps to the model ceiling", () => {
|
|
370
|
+
const out = buildbody(cfg, { model: "m1", max_tokens: 999999 }, models[0]) as Record<string, unknown>;
|
|
371
|
+
expect(out["max_tokens"]).toBe(1024); // m1 maxoutput
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it("max_tokens clamps to the global ceiling when unclamped models allow", () => {
|
|
375
|
+
const cfgnc = baseconfig({
|
|
376
|
+
bodybuild: { clampmaxtokens: true, optionalparamspolicy: "omit-unspecified" },
|
|
377
|
+
});
|
|
378
|
+
const bigmodel: modeldef = { id: "big", context: 1, maxoutput: 99999999 };
|
|
379
|
+
const out = buildbody(cfgnc, { model: "big", max_tokens: 999999 }, bigmodel) as Record<string, unknown>;
|
|
380
|
+
expect(out["max_tokens"]).toBeLessThanOrEqual(98304);
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
it("max_tokens defaults to 32768 when absent", () => {
|
|
384
|
+
const out = buildbody(cfg, { model: "m1" }) as Record<string, unknown>;
|
|
385
|
+
expect(out["max_tokens"]).toBe(32768);
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
it("numeric string max_tokens parses", () => {
|
|
389
|
+
const out = buildbody(cfg, { model: "m1", max_tokens: "512" }) as Record<string, unknown>;
|
|
390
|
+
expect(out["max_tokens"]).toBe(512);
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
it("seed and stop ride along when specified", () => {
|
|
394
|
+
const out = buildbody(cfg, { model: "m1", seed: 7, stop: ["\n"] }) as Record<string, unknown>;
|
|
395
|
+
expect(out["seed"]).toBe(7);
|
|
396
|
+
expect(out["stop"]).toEqual(["\n"]);
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
it("clampmaxtokens false leaves the user value untouched", () => {
|
|
400
|
+
const cfgf = baseconfig({ bodybuild: { clampmaxtokens: false } });
|
|
401
|
+
const out = buildbody(cfgf, { model: "m1", max_tokens: 999999 }) as Record<string, unknown>;
|
|
402
|
+
expect(out["max_tokens"]).toBe(999999);
|
|
403
|
+
});
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
// ---------------------------------------------------------------------------
|
|
407
|
+
// status classification and backoff
|
|
408
|
+
// ---------------------------------------------------------------------------
|
|
409
|
+
|
|
410
|
+
describe("classifystatus — retry rotate fallback fail", () => {
|
|
411
|
+
const cfg = baseconfig({
|
|
412
|
+
retry: { maxretries: 3, statuses: [429, 502, 503, 504], nonretryable: [400] },
|
|
413
|
+
rotation: { mode: "persession", models: ["m1", "m2"], rotateonstatus: [529] },
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
it("nonretryable statuses fail immediately", () => {
|
|
417
|
+
expect(classifystatus(cfg, 400)).toBe("fail");
|
|
418
|
+
});
|
|
419
|
+
it("retry statuses retry", () => {
|
|
420
|
+
expect(classifystatus(cfg, 429)).toBe("retry");
|
|
421
|
+
expect(classifystatus(cfg, 502)).toBe("retry");
|
|
422
|
+
expect(classifystatus(cfg, 503)).toBe("retry");
|
|
423
|
+
expect(classifystatus(cfg, 504)).toBe("retry");
|
|
424
|
+
});
|
|
425
|
+
it("rotate statuses rotate", () => {
|
|
426
|
+
expect(classifystatus(cfg, 529)).toBe("rotate");
|
|
427
|
+
});
|
|
428
|
+
it("unknown 5xx retry by default", () => {
|
|
429
|
+
expect(classifystatus(cfg, 501)).toBe("retry");
|
|
430
|
+
expect(classifystatus(cfg, 599)).toBe("retry");
|
|
431
|
+
});
|
|
432
|
+
it("unknown 4xx fail", () => {
|
|
433
|
+
expect(classifystatus(cfg, 403)).toBe("fail");
|
|
434
|
+
expect(classifystatus(cfg, 418)).toBe("fail");
|
|
435
|
+
});
|
|
436
|
+
it("success statuses classify as fail (never queried in practice)", () => {
|
|
437
|
+
expect(classifystatus(cfg, 200)).toBe("fail");
|
|
438
|
+
});
|
|
439
|
+
it("fallback statuses hit the crossprovider set when configured", () => {
|
|
440
|
+
const cfgfb = baseconfig({
|
|
441
|
+
retry: { maxretries: 3, fallback: "crossprovider", fallbackstatuses: [404] },
|
|
442
|
+
});
|
|
443
|
+
expect(classifystatus(cfgfb, 404)).toBe("fallback");
|
|
444
|
+
});
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
describe("backoffms — exponential with jitter", () => {
|
|
448
|
+
it("jitter zero produces exact exponential values", () => {
|
|
449
|
+
const cfg = baseconfig({ retry: { backoffbasems: 100, backoffcapms: 8000, jitter: 0 } });
|
|
450
|
+
expect(backoffms(cfg, 0)).toBe(100);
|
|
451
|
+
expect(backoffms(cfg, 1)).toBe(200);
|
|
452
|
+
expect(backoffms(cfg, 3)).toBe(800);
|
|
453
|
+
});
|
|
454
|
+
it("the cap limits growth", () => {
|
|
455
|
+
const cfg = baseconfig({ retry: { backoffbasems: 100, backoffcapms: 500, jitter: 0 } });
|
|
456
|
+
expect(backoffms(cfg, 10)).toBe(500);
|
|
457
|
+
});
|
|
458
|
+
it("jitter stays within the configured fraction", () => {
|
|
459
|
+
const cfg = baseconfig({ retry: { backoffbasems: 1000, backoffcapms: 8000, jitter: 0.2 } });
|
|
460
|
+
for (let i = 0; i < 50; i += 1) {
|
|
461
|
+
const v = backoffms(cfg, 0);
|
|
462
|
+
expect(v).toBeGreaterThanOrEqual(800);
|
|
463
|
+
expect(v).toBeLessThanOrEqual(1200);
|
|
464
|
+
}
|
|
465
|
+
});
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
// ---------------------------------------------------------------------------
|
|
469
|
+
// context fitting
|
|
470
|
+
// ---------------------------------------------------------------------------
|
|
471
|
+
|
|
472
|
+
describe("fitcontext — per call context truncation", () => {
|
|
473
|
+
it("passes messages through when they fit", () => {
|
|
474
|
+
const cfg = baseconfig({
|
|
475
|
+
context: { percallfallback: 100000, truncatemargin: 100 },
|
|
476
|
+
});
|
|
477
|
+
const msgs = Array.from({ length: 5 }, (_, i) => ({ role: "user", content: `m${i}` }));
|
|
478
|
+
expect(fitcontext(cfg, msgs, models[0])).toEqual(msgs);
|
|
479
|
+
});
|
|
480
|
+
it("truncates to the model context minus margin", () => {
|
|
481
|
+
const cfg = baseconfig({
|
|
482
|
+
context: { percallfallback: 100000, truncatemargin: 0 },
|
|
483
|
+
});
|
|
484
|
+
// m1 context 8192 tokens; each message 7 tokens → 1200 messages overflow
|
|
485
|
+
const msgs = Array.from({ length: 2000 }, () => ({ role: "user", content: "0123456789" }));
|
|
486
|
+
const out = fitcontext(cfg, msgs, models[0]);
|
|
487
|
+
expect(out.length).toBeLessThan(2000);
|
|
488
|
+
// newest survive
|
|
489
|
+
expect((out[out.length - 1] as { content: string }).content).toBe("0123456789");
|
|
490
|
+
});
|
|
491
|
+
it("no context config passes everything", () => {
|
|
492
|
+
const msgs = [{ role: "user", content: "x" }];
|
|
493
|
+
expect(fitcontext(baseconfig(), msgs, models[0])).toEqual(msgs);
|
|
494
|
+
});
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
// ---------------------------------------------------------------------------
|
|
498
|
+
// handlers — info keys models
|
|
499
|
+
// ---------------------------------------------------------------------------
|
|
500
|
+
|
|
501
|
+
describe("handleinfo — version descriptor", () => {
|
|
502
|
+
it("describes the version and its routes", async () => {
|
|
503
|
+
const handlers = createversion(baseconfig());
|
|
504
|
+
const res = await handlers.handleinfo(new Request("http://x/api/vt", { method: "GET" }));
|
|
505
|
+
expect(res.status).toBe(200);
|
|
506
|
+
const data = JSON.parse(await res.text());
|
|
507
|
+
expect(data["version"]).toBe("vt");
|
|
508
|
+
expect(data["provider"]).toBe("mock");
|
|
509
|
+
expect(data["models"]).toBe(2);
|
|
510
|
+
expect(data["routes"]).toHaveLength(7);
|
|
511
|
+
expect(data["fusioncontext"]).toBe(8192 + 4096);
|
|
512
|
+
expect(data["metamodel"]["id"]).toBe("meta");
|
|
513
|
+
expect(data["paused"]).toBe(false);
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
it("answers cors preflight with 204", async () => {
|
|
517
|
+
const handlers = createversion(baseconfig());
|
|
518
|
+
const res = await handlers.handleinfo(
|
|
519
|
+
new Request("http://x/api/vt", { method: "OPTIONS", headers: { origin: "https://y" } }),
|
|
520
|
+
);
|
|
521
|
+
expect(res.status).toBe(204);
|
|
522
|
+
});
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
describe("handlekeys — key status endpoint", () => {
|
|
526
|
+
it("counts env keys when the env var is set", async () => {
|
|
527
|
+
process.env.gatewayenginekeys = "key-aaaaaaaaaa,key-bbbbbbbbbb";
|
|
528
|
+
const handlers = createversion(
|
|
529
|
+
baseconfig({
|
|
530
|
+
auth: { mode: "bearer", envvar: "gatewayenginekeys", keysources: ["env"] },
|
|
531
|
+
}),
|
|
532
|
+
);
|
|
533
|
+
const res = await handlers.handlekeys(new Request("http://x/api/vt/keys", { method: "GET" }));
|
|
534
|
+
const data = JSON.parse(await res.text());
|
|
535
|
+
expect(data["authmode"]).toBe("bearer");
|
|
536
|
+
expect(data["envkeycount"]).toBe(2);
|
|
537
|
+
expect(data["models"]).toEqual(["m1", "m2"]);
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
it("reports zero when the env var is empty", async () => {
|
|
541
|
+
process.env.gatewayenginekeys = "";
|
|
542
|
+
const handlers = createversion(
|
|
543
|
+
baseconfig({
|
|
544
|
+
auth: { mode: "bearer", envvar: "gatewayenginekeys", keysources: ["env"] },
|
|
545
|
+
}),
|
|
546
|
+
);
|
|
547
|
+
const res = await handlers.handlekeys(new Request("http://x/api/vt/keys", { method: "GET" }));
|
|
548
|
+
const data = JSON.parse(await res.text());
|
|
549
|
+
expect(data["envkeycount"]).toBe(0);
|
|
550
|
+
});
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
describe("handlemodels — catalog listing", () => {
|
|
554
|
+
it("lists the meta model first then every individual model", async () => {
|
|
555
|
+
const handlers = createversion(
|
|
556
|
+
baseconfig({
|
|
557
|
+
rotation: { mode: "persession", models: ["m1", "m2"] },
|
|
558
|
+
}),
|
|
559
|
+
);
|
|
560
|
+
const res = await handlers.handlemodels(new Request("http://x/api/vt/models", { method: "POST" }));
|
|
561
|
+
expect(res.status).toBe(200);
|
|
562
|
+
const data = JSON.parse(await res.text());
|
|
563
|
+
expect(data["object"]).toBe("list");
|
|
564
|
+
const list = data["data"] as Array<Record<string, unknown>>;
|
|
565
|
+
expect(list).toHaveLength(3);
|
|
566
|
+
expect(list[0]["id"]).toBe("meta");
|
|
567
|
+
expect(list.map((m) => m["id"])).toEqual(["meta", "m1", "m2"]);
|
|
568
|
+
expect(list[1]["max_context_length"]).toBe(8192);
|
|
569
|
+
expect(list[2]["max_output_tokens"]).toBe(512);
|
|
570
|
+
expect((list[0]["meta"] as Record<string, unknown>)["rotationbackends"]).toEqual(["m1", "m2"]);
|
|
571
|
+
});
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
// ---------------------------------------------------------------------------
|
|
575
|
+
// handlechatcompletions — the main route
|
|
576
|
+
// ---------------------------------------------------------------------------
|
|
577
|
+
|
|
578
|
+
describe("handlechatcompletions — non stream happy path", () => {
|
|
579
|
+
it("rebuilds the response with the display model", async () => {
|
|
580
|
+
script.push({ status: 200, body: okjson("mocked reply") });
|
|
581
|
+
const handlers = createversion(baseconfig());
|
|
582
|
+
const res = await post(handlers, {
|
|
583
|
+
model: "m1",
|
|
584
|
+
messages: [{ role: "user", content: "ping" }],
|
|
585
|
+
});
|
|
586
|
+
expect(res.status).toBe(200);
|
|
587
|
+
const data = JSON.parse(await res.text());
|
|
588
|
+
expect(data["object"]).toBe("chat.completion");
|
|
589
|
+
expect(data["model"]).toBe("meta"); // alwaysdisplay
|
|
590
|
+
expect(data["choices"][0]["message"]["content"]).toBe("mocked reply");
|
|
591
|
+
expect(data["usage"]["total_tokens"]).toBe(15);
|
|
592
|
+
expect(data["provider"]).toBe("mock");
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
it("forwards the messages and auth to the upstream", async () => {
|
|
596
|
+
script.push({ status: 200, body: okjson() });
|
|
597
|
+
const handlers = createversion(baseconfig());
|
|
598
|
+
await post(handlers, { model: "m1", messages: [{ role: "user", content: "hello" }] });
|
|
599
|
+
expect(captured).toHaveLength(1);
|
|
600
|
+
const req = captured[0];
|
|
601
|
+
expect(req.method).toBe("POST");
|
|
602
|
+
expect(req.url).toBe("/chat/completions");
|
|
603
|
+
expect(req.headers["authorization"]).toBe("Bearer testkey-0000000001");
|
|
604
|
+
expect(req.headers["content-type"]).toBe("application/json");
|
|
605
|
+
const sentbody = JSON.parse(req.body);
|
|
606
|
+
expect(sentbody["messages"]).toEqual([{ role: "user", content: "hello" }]);
|
|
607
|
+
expect(sentbody["model"]).toBe("m1");
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
it("session ids and request ids from headers are honored", async () => {
|
|
611
|
+
script.push({ status: 200, body: okjson() });
|
|
612
|
+
const handlers = createversion(baseconfig());
|
|
613
|
+
await post(
|
|
614
|
+
handlers,
|
|
615
|
+
{ messages: [{ role: "user", content: "x" }] },
|
|
616
|
+
{
|
|
617
|
+
"x-session-id": "fixed-session",
|
|
618
|
+
"x-request-id": "fixed-req",
|
|
619
|
+
},
|
|
620
|
+
);
|
|
621
|
+
// no crash — ids flow into persistence (silent db)
|
|
622
|
+
expect(captured).toHaveLength(1);
|
|
623
|
+
});
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
describe("handlechatcompletions — streaming", () => {
|
|
627
|
+
it("streams sse frames with the model masked", async () => {
|
|
628
|
+
script.push({
|
|
629
|
+
status: 200,
|
|
630
|
+
headers: { "content-type": "text/event-stream" },
|
|
631
|
+
body: `${okssechunchof("he")}${okssechunchof("llo")}${okssechunchof("!")}data: [DONE]\n\n`,
|
|
632
|
+
});
|
|
633
|
+
const handlers = createversion(baseconfig());
|
|
634
|
+
const res = await post(handlers, {
|
|
635
|
+
model: "m1",
|
|
636
|
+
stream: true,
|
|
637
|
+
messages: [{ role: "user", content: "x" }],
|
|
638
|
+
});
|
|
639
|
+
expect(res.status).toBe(200);
|
|
640
|
+
expect(res.headers.get("content-type")).toBe("text/event-stream");
|
|
641
|
+
const text = await res.text();
|
|
642
|
+
expect(text).toContain('"model":"meta"');
|
|
643
|
+
expect(text).not.toContain("upstream-model");
|
|
644
|
+
expect(text).toContain("he");
|
|
645
|
+
expect(text).toContain("data: [DONE]");
|
|
646
|
+
// upstream saw the accept sse header
|
|
647
|
+
expect(captured[0].headers["accept"]).toBe("text/event-stream");
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
it("streaming an upstream error passes the status through", async () => {
|
|
651
|
+
script.push({ status: 502, body: "bad gateway from upstream" });
|
|
652
|
+
const handlers = createversion(baseconfig());
|
|
653
|
+
const res = await post(handlers, {
|
|
654
|
+
stream: true,
|
|
655
|
+
messages: [{ role: "user", content: "x" }],
|
|
656
|
+
});
|
|
657
|
+
// stream errors are not ok and not non-stream — makestreamresponse wraps body
|
|
658
|
+
expect([502, 200]).toContain(res.status);
|
|
659
|
+
});
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
describe("handlechatcompletions — paused kill switch", () => {
|
|
663
|
+
it("paused versions answer 503 with the fallback hint", async () => {
|
|
664
|
+
const handlers = createversion(
|
|
665
|
+
baseconfig({
|
|
666
|
+
paused: true,
|
|
667
|
+
routes: {
|
|
668
|
+
pausedmessage: "v2 is paused",
|
|
669
|
+
pausedstatus: 503,
|
|
670
|
+
fallbackroute: "/api/v1/chat/completions",
|
|
671
|
+
},
|
|
672
|
+
}),
|
|
673
|
+
);
|
|
674
|
+
const res = await post(handlers, { messages: [{ role: "user", content: "x" }] });
|
|
675
|
+
expect(res.status).toBe(503);
|
|
676
|
+
const data = JSON.parse(await res.text());
|
|
677
|
+
expect(data["error"]).toBe("v2 is paused");
|
|
678
|
+
expect(data["fallback"]).toBe("/api/v1/chat/completions");
|
|
679
|
+
expect(captured).toHaveLength(0); // no upstream call
|
|
680
|
+
});
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
describe("handlechatcompletions — auth enforcement", () => {
|
|
684
|
+
it("required auth with no resolvable keys answers 401", async () => {
|
|
685
|
+
const handlers = createversion(
|
|
686
|
+
baseconfig({
|
|
687
|
+
auth: {
|
|
688
|
+
mode: "bearer",
|
|
689
|
+
required: true,
|
|
690
|
+
envvar: "gatewayenginekeys",
|
|
691
|
+
keysources: ["env"],
|
|
692
|
+
signupurl: "https://mock.example/signup",
|
|
693
|
+
},
|
|
694
|
+
}),
|
|
695
|
+
);
|
|
696
|
+
const res = await post(handlers, { messages: [{ role: "user", content: "x" }] });
|
|
697
|
+
expect(res.status).toBe(401);
|
|
698
|
+
const data = JSON.parse(await res.text());
|
|
699
|
+
expect(data["code"]).toBe("key_required");
|
|
700
|
+
expect(data["signup"]).toBe("https://mock.example/signup");
|
|
701
|
+
expect(String(data["error"])).toContain("gatewayenginekeys");
|
|
702
|
+
expect(captured).toHaveLength(0);
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
it("keyless modes never 401", async () => {
|
|
706
|
+
for (const mode of ["anonymous", "none", "keylesssdk"] as const) {
|
|
707
|
+
script.push({ status: 200, body: okjson() });
|
|
708
|
+
const handlers = createversion(baseconfig({ auth: { mode, required: true } }));
|
|
709
|
+
const res = await post(handlers, { messages: [{ role: "user", content: "x" }] });
|
|
710
|
+
expect(res.status).toBe(200);
|
|
711
|
+
}
|
|
712
|
+
});
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
describe("handlechatcompletions — upstream failures", () => {
|
|
716
|
+
it("passes the upstream error status and body through", async () => {
|
|
717
|
+
script.push({ status: 500, body: '{"error":"model overloaded"}' });
|
|
718
|
+
const handlers = createversion(baseconfig());
|
|
719
|
+
const res = await post(handlers, { messages: [{ role: "user", content: "x" }] });
|
|
720
|
+
expect(res.status).toBe(500);
|
|
721
|
+
expect(await res.text()).toContain("model overloaded");
|
|
722
|
+
});
|
|
723
|
+
|
|
724
|
+
it("connection destroyed mid request answers 502 with a json error", async () => {
|
|
725
|
+
script.push({ status: 200, destroy: true });
|
|
726
|
+
const handlers = createversion(baseconfig());
|
|
727
|
+
const res = await post(handlers, { messages: [{ role: "user", content: "x" }] });
|
|
728
|
+
expect(res.status).toBe(502);
|
|
729
|
+
const data = JSON.parse(await res.text());
|
|
730
|
+
expect(data["object"]).toBe("error");
|
|
731
|
+
});
|
|
732
|
+
|
|
733
|
+
it("missing upstream answers 500 without throwing", async () => {
|
|
734
|
+
const handlers = createversion(baseconfig({ upstreams: [] }));
|
|
735
|
+
const res = await post(handlers, { messages: [{ role: "user", content: "x" }] });
|
|
736
|
+
expect(res.status).toBe(500);
|
|
737
|
+
const data = JSON.parse(await res.text());
|
|
738
|
+
expect(data["error"]).toBe("no upstream configured");
|
|
739
|
+
});
|
|
740
|
+
});
|
|
741
|
+
|
|
742
|
+
describe("handlechatcompletions — retry behavior", () => {
|
|
743
|
+
it("retries a 429 and succeeds on the second attempt", async () => {
|
|
744
|
+
script.push({ status: 429, body: '{"error":"rate limited"}' }, { status: 200, body: okjson("after retry") });
|
|
745
|
+
const handlers = createversion(
|
|
746
|
+
baseconfig({
|
|
747
|
+
retry: { maxretries: 2, backoffbasems: 1, backoffcapms: 2, jitter: 0, statuses: [429] },
|
|
748
|
+
}),
|
|
749
|
+
);
|
|
750
|
+
const res = await post(handlers, { messages: [{ role: "user", content: "x" }] });
|
|
751
|
+
expect(res.status).toBe(200);
|
|
752
|
+
const data = JSON.parse(await res.text());
|
|
753
|
+
expect(data["choices"][0]["message"]["content"]).toBe("after retry");
|
|
754
|
+
expect(captured).toHaveLength(2);
|
|
755
|
+
});
|
|
756
|
+
|
|
757
|
+
it("nonretryable 400 returns immediately without retries", async () => {
|
|
758
|
+
script.push({ status: 400, body: '{"error":"bad request"}' });
|
|
759
|
+
const handlers = createversion(
|
|
760
|
+
baseconfig({
|
|
761
|
+
retry: { maxretries: 5, backoffbasems: 1, backoffcapms: 2, jitter: 0, nonretryable: [400] },
|
|
762
|
+
}),
|
|
763
|
+
);
|
|
764
|
+
const res = await post(handlers, { messages: [{ role: "user", content: "x" }] });
|
|
765
|
+
expect(res.status).toBe(400);
|
|
766
|
+
expect(captured).toHaveLength(1);
|
|
767
|
+
});
|
|
768
|
+
|
|
769
|
+
it("exhausted retries surface the last error status", async () => {
|
|
770
|
+
script.push({ status: 429, body: "{}" }, { status: 429, body: "{}" }, { status: 429, body: "{}" });
|
|
771
|
+
const handlers = createversion(
|
|
772
|
+
baseconfig({
|
|
773
|
+
retry: { maxretries: 2, backoffbasems: 1, backoffcapms: 2, jitter: 0, statuses: [429] },
|
|
774
|
+
}),
|
|
775
|
+
);
|
|
776
|
+
const res = await post(handlers, { messages: [{ role: "user", content: "x" }] });
|
|
777
|
+
expect(res.status).toBe(429);
|
|
778
|
+
expect(captured).toHaveLength(3); // 1 + 2 retries
|
|
779
|
+
});
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
describe("handlechatcompletions — model rotation on status", () => {
|
|
783
|
+
it("rotates to the next model after a 529", async () => {
|
|
784
|
+
script.push({ status: 529, body: "{}" }, { status: 200, body: okjson("rotated") });
|
|
785
|
+
const handlers = createversion(
|
|
786
|
+
baseconfig({
|
|
787
|
+
rotation: {
|
|
788
|
+
mode: "persession",
|
|
789
|
+
models: ["m1", "m2"],
|
|
790
|
+
rotateonstatus: [529],
|
|
791
|
+
maxmodelrotations: 2,
|
|
792
|
+
},
|
|
793
|
+
}),
|
|
794
|
+
);
|
|
795
|
+
const res = await post(handlers, { messages: [{ role: "user", content: "x" }] });
|
|
796
|
+
expect(res.status).toBe(200);
|
|
797
|
+
const first = JSON.parse(captured[0].body);
|
|
798
|
+
const second = JSON.parse(captured[1].body);
|
|
799
|
+
expect(first["model"]).toBe("m1");
|
|
800
|
+
expect(second["model"]).toBe("m2");
|
|
801
|
+
});
|
|
802
|
+
|
|
803
|
+
it("rotation exhausted returns the last rotate status", async () => {
|
|
804
|
+
script.push({ status: 529, body: "{}" }, { status: 529, body: "{}" });
|
|
805
|
+
const handlers = createversion(
|
|
806
|
+
baseconfig({
|
|
807
|
+
rotation: {
|
|
808
|
+
mode: "persession",
|
|
809
|
+
models: ["m1", "m2"],
|
|
810
|
+
rotateonstatus: [529],
|
|
811
|
+
maxmodelrotations: 2,
|
|
812
|
+
},
|
|
813
|
+
}),
|
|
814
|
+
);
|
|
815
|
+
const res = await post(handlers, { messages: [{ role: "user", content: "x" }] });
|
|
816
|
+
expect(res.status).toBe(529);
|
|
817
|
+
expect(captured).toHaveLength(2);
|
|
818
|
+
});
|
|
819
|
+
});
|
|
820
|
+
|
|
821
|
+
describe("handlechatcompletions — per session rotation across messages", () => {
|
|
822
|
+
it("flips the upstream model at the everyn boundary", async () => {
|
|
823
|
+
for (let i = 0; i < 7; i += 1) script.push({ status: 200, body: okjson() });
|
|
824
|
+
const handlers = createversion(
|
|
825
|
+
baseconfig({
|
|
826
|
+
rotation: { mode: "persession", models: ["m1", "m2"], everynmessages: 6 },
|
|
827
|
+
}),
|
|
828
|
+
);
|
|
829
|
+
const headers = { "x-session-id": "rot-session" };
|
|
830
|
+
for (let i = 0; i < 7; i += 1) {
|
|
831
|
+
await post(handlers, { messages: [{ role: "user", content: "x" }] }, headers);
|
|
832
|
+
}
|
|
833
|
+
const sentmodels = captured.map((c) => (JSON.parse(c.body) as Record<string, unknown>)["model"]);
|
|
834
|
+
// message 6 (index 5) flips to m2
|
|
835
|
+
expect(sentmodels.slice(0, 5)).toEqual(["m1", "m1", "m1", "m1", "m1"]);
|
|
836
|
+
expect(sentmodels[5]).toBe("m2");
|
|
837
|
+
expect(sentmodels[6]).toBe("m2");
|
|
838
|
+
});
|
|
839
|
+
});
|
|
840
|
+
|
|
841
|
+
describe("handlechatcompletions — request timeout", () => {
|
|
842
|
+
it("a slow upstream with a tight timeout answers 502", async () => {
|
|
843
|
+
script.push({ status: 200, body: okjson(), delayms: 3000 });
|
|
844
|
+
const handlers = createversion(
|
|
845
|
+
baseconfig({
|
|
846
|
+
timeout: { requestms: 200, streamms: 10000 },
|
|
847
|
+
retry: { maxretries: 0 },
|
|
848
|
+
}),
|
|
849
|
+
);
|
|
850
|
+
const res = await post(handlers, { messages: [{ role: "user", content: "x" }] });
|
|
851
|
+
expect(res.status).toBe(502);
|
|
852
|
+
const data = JSON.parse(await res.text());
|
|
853
|
+
expect(data["object"]).toBe("error");
|
|
854
|
+
});
|
|
855
|
+
});
|
|
856
|
+
|
|
857
|
+
// ---------------------------------------------------------------------------
|
|
858
|
+
// handlecompletions — legacy text completion route
|
|
859
|
+
// ---------------------------------------------------------------------------
|
|
860
|
+
|
|
861
|
+
describe("handlecompletions — prompt to completion", () => {
|
|
862
|
+
it("converts a string prompt and returns text_completion shape", async () => {
|
|
863
|
+
script.push({ status: 200, body: okjson("legacy reply") });
|
|
864
|
+
const handlers = createversion(baseconfig());
|
|
865
|
+
const res = await handlers.handlecompletions(
|
|
866
|
+
new Request("http://x/api/vt/completions", {
|
|
867
|
+
method: "POST",
|
|
868
|
+
headers: { "content-type": "application/json" },
|
|
869
|
+
body: JSON.stringify({ prompt: "once upon a time" }),
|
|
870
|
+
}),
|
|
871
|
+
);
|
|
872
|
+
expect(res.status).toBe(200);
|
|
873
|
+
const data = JSON.parse(await res.text());
|
|
874
|
+
expect(data["object"]).toBe("text_completion");
|
|
875
|
+
expect(data["choices"][0]["text"]).toBe("legacy reply");
|
|
876
|
+
expect(data["model"]).toBe("meta");
|
|
877
|
+
const sent = JSON.parse(captured[0].body);
|
|
878
|
+
expect(sent["messages"]).toEqual([{ role: "user", content: "once upon a time" }]);
|
|
879
|
+
expect(sent["prompt"]).toBeUndefined();
|
|
880
|
+
});
|
|
881
|
+
|
|
882
|
+
it("array prompts become parallel user messages", async () => {
|
|
883
|
+
script.push({ status: 200, body: okjson() });
|
|
884
|
+
const handlers = createversion(baseconfig());
|
|
885
|
+
await handlers.handlecompletions(
|
|
886
|
+
new Request("http://x/api/vt/completions", {
|
|
887
|
+
method: "POST",
|
|
888
|
+
headers: { "content-type": "application/json" },
|
|
889
|
+
body: JSON.stringify({ prompt: ["a", "b"] }),
|
|
890
|
+
}),
|
|
891
|
+
);
|
|
892
|
+
const sent = JSON.parse(captured[0].body);
|
|
893
|
+
expect(sent["messages"]).toEqual([
|
|
894
|
+
{ role: "user", content: "a" },
|
|
895
|
+
{ role: "user", content: "b" },
|
|
896
|
+
]);
|
|
897
|
+
});
|
|
898
|
+
});
|
|
899
|
+
|
|
900
|
+
// ---------------------------------------------------------------------------
|
|
901
|
+
// handlemessages — anthropic format conversion
|
|
902
|
+
// ---------------------------------------------------------------------------
|
|
903
|
+
|
|
904
|
+
describe("handlemessages — anthropic conversion", () => {
|
|
905
|
+
it("converts openai responses into the anthropic message shape", async () => {
|
|
906
|
+
script.push({ status: 200, body: okjson("anthropic styled") });
|
|
907
|
+
const handlers = createversion(baseconfig());
|
|
908
|
+
const res = await handlers.handlemessages(
|
|
909
|
+
new Request("http://x/api/vt/messages", {
|
|
910
|
+
method: "POST",
|
|
911
|
+
headers: { "content-type": "application/json" },
|
|
912
|
+
body: JSON.stringify({
|
|
913
|
+
model: "m1",
|
|
914
|
+
system: "be terse",
|
|
915
|
+
max_tokens: 100,
|
|
916
|
+
messages: [
|
|
917
|
+
{ role: "user", content: "hello" },
|
|
918
|
+
{
|
|
919
|
+
role: "assistant",
|
|
920
|
+
content: [
|
|
921
|
+
{ type: "text", text: "part a" },
|
|
922
|
+
{ type: "text", text: "part b" },
|
|
923
|
+
],
|
|
924
|
+
},
|
|
925
|
+
],
|
|
926
|
+
}),
|
|
927
|
+
}),
|
|
928
|
+
);
|
|
929
|
+
expect(res.status).toBe(200);
|
|
930
|
+
const data = JSON.parse(await res.text());
|
|
931
|
+
expect(data["type"]).toBe("message");
|
|
932
|
+
expect(data["role"]).toBe("assistant");
|
|
933
|
+
expect(data["content"]).toEqual([{ type: "text", text: "anthropic styled" }]);
|
|
934
|
+
expect(data["stop_reason"]).toBe("end_turn");
|
|
935
|
+
expect(data["usage"]["input_tokens"]).toBe(10);
|
|
936
|
+
expect(data["usage"]["output_tokens"]).toBe(5);
|
|
937
|
+
|
|
938
|
+
const sent = JSON.parse(captured[0].body);
|
|
939
|
+
// system prepended, assistant content array joined
|
|
940
|
+
expect(sent["messages"][0]).toEqual({ role: "system", content: "be terse" });
|
|
941
|
+
expect(sent["messages"][2]).toEqual({ role: "assistant", content: "part a\npart b" });
|
|
942
|
+
});
|
|
943
|
+
});
|
|
944
|
+
|
|
945
|
+
// ---------------------------------------------------------------------------
|
|
946
|
+
// handleresponses — openai responses format
|
|
947
|
+
// ---------------------------------------------------------------------------
|
|
948
|
+
|
|
949
|
+
describe("handleresponses — responses format", () => {
|
|
950
|
+
it("accepts a plain string input", async () => {
|
|
951
|
+
script.push({ status: 200, body: okjson("responses reply") });
|
|
952
|
+
const handlers = createversion(baseconfig());
|
|
953
|
+
const res = await handlers.handleresponses(
|
|
954
|
+
new Request("http://x/api/vt/responses", {
|
|
955
|
+
method: "POST",
|
|
956
|
+
headers: { "content-type": "application/json" },
|
|
957
|
+
body: JSON.stringify({ input: "do the thing" }),
|
|
958
|
+
}),
|
|
959
|
+
);
|
|
960
|
+
expect(res.status).toBe(200);
|
|
961
|
+
const data = JSON.parse(await res.text());
|
|
962
|
+
expect(data["object"]).toBe("response");
|
|
963
|
+
expect(data["status"]).toBe("completed");
|
|
964
|
+
expect(data["output"][0]["content"][0]["text"]).toBe("responses reply");
|
|
965
|
+
const sent = JSON.parse(captured[0].body);
|
|
966
|
+
expect(sent["messages"]).toEqual([{ role: "user", content: "do the thing" }]);
|
|
967
|
+
});
|
|
968
|
+
|
|
969
|
+
it("accepts structured message arrays as input", async () => {
|
|
970
|
+
script.push({ status: 200, body: okjson() });
|
|
971
|
+
const handlers = createversion(baseconfig());
|
|
972
|
+
await handlers.handleresponses(
|
|
973
|
+
new Request("http://x/api/vt/responses", {
|
|
974
|
+
method: "POST",
|
|
975
|
+
headers: { "content-type": "application/json" },
|
|
976
|
+
body: JSON.stringify({
|
|
977
|
+
input: [
|
|
978
|
+
{ role: "user", content: "structured" },
|
|
979
|
+
{ role: "assistant", content: "reply" },
|
|
980
|
+
],
|
|
981
|
+
}),
|
|
982
|
+
}),
|
|
983
|
+
);
|
|
984
|
+
const sent = JSON.parse(captured[0].body);
|
|
985
|
+
expect(sent["messages"]).toHaveLength(2);
|
|
986
|
+
});
|
|
987
|
+
});
|
|
988
|
+
|
|
989
|
+
// ---------------------------------------------------------------------------
|
|
990
|
+
// handleembeddings
|
|
991
|
+
// ---------------------------------------------------------------------------
|
|
992
|
+
|
|
993
|
+
describe("handleembeddings — embeddings route", () => {
|
|
994
|
+
it("answers 501 when disabled or sdk transport", async () => {
|
|
995
|
+
const handlers = createversion(
|
|
996
|
+
baseconfig({ routes: { embeddings: "disabled", embeddingsmessage: "no embeddings here" } }),
|
|
997
|
+
);
|
|
998
|
+
const res = await handlers.handleembeddings(
|
|
999
|
+
new Request("http://x/api/vt/embeddings", {
|
|
1000
|
+
method: "POST",
|
|
1001
|
+
headers: { "content-type": "application/json" },
|
|
1002
|
+
body: JSON.stringify({ input: "x" }),
|
|
1003
|
+
}),
|
|
1004
|
+
);
|
|
1005
|
+
expect(res.status).toBe(501);
|
|
1006
|
+
const data = JSON.parse(await res.text());
|
|
1007
|
+
expect(data["code"]).toBe("embeddings_unavailable");
|
|
1008
|
+
expect(data["error"]).toBe("no embeddings here");
|
|
1009
|
+
});
|
|
1010
|
+
|
|
1011
|
+
it("proxies to the embeddings endpoint when enabled", async () => {
|
|
1012
|
+
script.push({
|
|
1013
|
+
status: 200,
|
|
1014
|
+
body: '{"data":[{"embedding":[0.1,0.2]}]}',
|
|
1015
|
+
});
|
|
1016
|
+
const handlers = createversion(
|
|
1017
|
+
baseconfig({
|
|
1018
|
+
upstreams: [
|
|
1019
|
+
{
|
|
1020
|
+
name: "mock",
|
|
1021
|
+
baseurl: `http://127.0.0.1:${mockport}`,
|
|
1022
|
+
endpoints: { embeddings: "/embeddings" },
|
|
1023
|
+
},
|
|
1024
|
+
],
|
|
1025
|
+
}),
|
|
1026
|
+
);
|
|
1027
|
+
const res = await handlers.handleembeddings(
|
|
1028
|
+
new Request("http://x/api/vt/embeddings", {
|
|
1029
|
+
method: "POST",
|
|
1030
|
+
headers: { "content-type": "application/json" },
|
|
1031
|
+
body: JSON.stringify({ input: "embed me" }),
|
|
1032
|
+
}),
|
|
1033
|
+
);
|
|
1034
|
+
expect(res.status).toBe(200);
|
|
1035
|
+
expect(await res.text()).toContain("embedding");
|
|
1036
|
+
expect(captured[0].url).toBe("/embeddings");
|
|
1037
|
+
});
|
|
1038
|
+
});
|
|
1039
|
+
|
|
1040
|
+
// ---------------------------------------------------------------------------
|
|
1041
|
+
// malformed request bodies — the aggressive guard
|
|
1042
|
+
// ---------------------------------------------------------------------------
|
|
1043
|
+
|
|
1044
|
+
describe("handlechatcompletions — malformed bodies must not crash", () => {
|
|
1045
|
+
it("invalid json bodies answer 400 not a silent empty call", async () => {
|
|
1046
|
+
const handlers = createversion(baseconfig());
|
|
1047
|
+
const res = await post(handlers, "this is not json at all {{{");
|
|
1048
|
+
expect(res.status).toBe(400);
|
|
1049
|
+
expect(captured).toHaveLength(0);
|
|
1050
|
+
});
|
|
1051
|
+
|
|
1052
|
+
it("empty bodies answer 400 with a missing messages error", async () => {
|
|
1053
|
+
const handlers = createversion(baseconfig());
|
|
1054
|
+
const res = await post(handlers, {});
|
|
1055
|
+
expect(res.status).toBe(400);
|
|
1056
|
+
const data = JSON.parse(await res.text());
|
|
1057
|
+
expect(data["code"]).toBe("invalid_request");
|
|
1058
|
+
expect(captured).toHaveLength(0);
|
|
1059
|
+
});
|
|
1060
|
+
|
|
1061
|
+
it("messages of the wrong type answer 400", async () => {
|
|
1062
|
+
const handlers = createversion(baseconfig());
|
|
1063
|
+
const res = await post(handlers, { messages: "not an array" });
|
|
1064
|
+
expect(res.status).toBe(400);
|
|
1065
|
+
expect(captured).toHaveLength(0);
|
|
1066
|
+
});
|
|
1067
|
+
|
|
1068
|
+
it("empty message arrays answer 400", async () => {
|
|
1069
|
+
const handlers = createversion(baseconfig());
|
|
1070
|
+
const res = await post(handlers, { messages: [] });
|
|
1071
|
+
expect(res.status).toBe(400);
|
|
1072
|
+
expect(captured).toHaveLength(0);
|
|
1073
|
+
});
|
|
1074
|
+
});
|