llm-switcher 1.6.9 → 1.6.11
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/CHANGELOG.md +16 -0
- package/README.md +2 -1
- package/README.vi.md +2 -1
- package/formats.mjs +136 -11
- package/package.json +1 -1
- package/proxy.mjs +51 -6
- package/tests/cdp.mjs +8 -1
- package/tests/dashboard-fixture.mjs +28 -16
- package/tests/formats.test.mjs +223 -2
- package/tests/gateway.e2e.test.mjs +99 -8
- package/tests/helpers.mjs +60 -0
- package/tests/teardown.test.mjs +84 -0
- package/tests/windows.test.mjs +31 -1
- package/windows.mjs +5 -3
package/tests/formats.test.mjs
CHANGED
|
@@ -12,7 +12,8 @@ import {
|
|
|
12
12
|
createChatStream, buildChatMessage, chatFinish, anthropicStopReason,
|
|
13
13
|
smartText, smartReasoning, sanitizeJsonSchema, normalizeUpstream, buildVertexMessage,
|
|
14
14
|
createCodeAssistStream,
|
|
15
|
-
budgetToEffort, effortToBudget, normalizeEffort, capEffort, mapEquivalentEffort, EFFORT_LEVELS, parseArgs, buildAnthropicMessage
|
|
15
|
+
budgetToEffort, effortToBudget, normalizeEffort, capEffort, mapEquivalentEffort, EFFORT_LEVELS, parseArgs, buildAnthropicMessage,
|
|
16
|
+
refusesImageInput, omitChatImages, IMAGE_OMITTED_TEXT
|
|
16
17
|
} from '../formats.mjs';
|
|
17
18
|
import { assertValidAnthropicEvents } from './helpers.mjs';
|
|
18
19
|
|
|
@@ -39,6 +40,50 @@ test('Chat emitter restores thinking for versioned Claude Opus model IDs', () =>
|
|
|
39
40
|
}
|
|
40
41
|
});
|
|
41
42
|
|
|
43
|
+
// Only an answer that says the model takes no images may turn into a text-only retry.
|
|
44
|
+
test('refusesImageInput matches the known no-image errors and nothing else', () => {
|
|
45
|
+
for (const t of [
|
|
46
|
+
'{"error":{"message":"Invalid content type. image_url is only supported by certain models."}}',
|
|
47
|
+
'Failed to deserialize the JSON body into the target type: messages[0]: unknown variant `image_url`, expected `text`',
|
|
48
|
+
'This model does not support image input',
|
|
49
|
+
'Image input is not supported for this model',
|
|
50
|
+
'Vision is not supported by deepseek-chat'
|
|
51
|
+
]) assert.equal(refusesImageInput(t), true, t);
|
|
52
|
+
for (const t of ['', 'temperature must be between 0 and 2', 'maximum context length exceeded', 'vision model overloaded', 'not supported: tool_choice',
|
|
53
|
+
'Invalid image_url: could not decode base64', "Invalid 'messages[0].content[1].image_url.url'. Expected a base64-encoded data URL"])
|
|
54
|
+
assert.equal(refusesImageInput(t), false, t);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('omitChatImages replaces every image part with text and counts them', () => {
|
|
58
|
+
const body = { messages: [
|
|
59
|
+
{ role: 'system', content: 'sys' },
|
|
60
|
+
{ role: 'user', content: [{ type: 'image_url', image_url: { url: 'data:image/png;base64,AA' } }, { type: 'text', text: 'what?' }] },
|
|
61
|
+
{ role: 'assistant', content: 'a cat' },
|
|
62
|
+
{ role: 'user', content: [{ type: 'image_url', image_url: { url: 'https://x/y.png' } }] }
|
|
63
|
+
] };
|
|
64
|
+
assert.equal(omitChatImages(body), 2);
|
|
65
|
+
assert.deepEqual(body.messages[1].content, [{ type: 'text', text: IMAGE_OMITTED_TEXT }, { type: 'text', text: 'what?' }]);
|
|
66
|
+
assert.deepEqual(body.messages[3].content, [{ type: 'text', text: IMAGE_OMITTED_TEXT }]);
|
|
67
|
+
assert.equal(IMAGE_OMITTED_TEXT, '[image omitted: this model does not accept images]');
|
|
68
|
+
assert.equal(omitChatImages(body), 0, 'nothing left to replace');
|
|
69
|
+
assert.equal(omitChatImages({}), 0);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// A max_tokens above the model's own output limit is refused upstream, so it is clamped when the limit is known.
|
|
73
|
+
test('emitUpstreamBody clamps max tokens to a known output limit, and leaves it when the limit is unknown', () => {
|
|
74
|
+
const ir = anthropicToIR({ model: 'x', max_tokens: 64000, messages: [{ role: 'user', content: 'hi' }] });
|
|
75
|
+
assert.equal(emitUpstreamBody('openai-chat', ir, 'm', { maxOutputTokens: 8192 }).max_tokens, 8192);
|
|
76
|
+
assert.equal(emitUpstreamBody('openai-chat', ir, 'm', { thinkingMode: 'native', maxOutputTokens: 8192 }).max_completion_tokens, 8192);
|
|
77
|
+
assert.equal(emitUpstreamBody('anthropic', ir, 'm', { maxOutputTokens: 8192 }).max_tokens, 8192);
|
|
78
|
+
assert.equal(emitUpstreamBody('vertex', ir, 'm', { maxOutputTokens: 8192 }).generationConfig.maxOutputTokens, 8192);
|
|
79
|
+
assert.equal(emitUpstreamBody('openai-chat', ir, 'm').max_tokens, 64000, 'unknown limit: unchanged');
|
|
80
|
+
assert.equal(emitUpstreamBody('openai-chat', ir, 'm', { maxOutputTokens: 128000 }).max_tokens, 64000, 'under the limit: unchanged');
|
|
81
|
+
assert.equal(ir.params.maxTokens, 64000, 'the client IR is not changed');
|
|
82
|
+
const bare = anthropicToIR({ model: 'x', messages: [{ role: 'user', content: 'hi' }] });
|
|
83
|
+
assert.equal('max_tokens' in emitUpstreamBody('openai-chat', bare, 'm', { maxOutputTokens: 8192 }), false, 'no max tokens sent: none invented');
|
|
84
|
+
assert.equal(emitUpstreamBody('anthropic', bare, 'm', { maxOutputTokens: 2048 }).max_tokens, 2048, 'the Anthropic default of 4096 is clamped too');
|
|
85
|
+
});
|
|
86
|
+
|
|
42
87
|
test('emitUpstreamBody: billing header stripped only for antigravity (ag/) models', () => {
|
|
43
88
|
const header = 'x-anthropic-billing-header: cc_version=2.1.275.f15; cc_entrypoint=cli;';
|
|
44
89
|
const fromArray = anthropicToIR({
|
|
@@ -420,7 +465,7 @@ test('toGeminiSchema: bare string shorthands become Schema objects (Vertex 400 f
|
|
|
420
465
|
assert.deepEqual(toGeminiSchema('string'), { type: 'string' });
|
|
421
466
|
assert.deepEqual(
|
|
422
467
|
toGeminiSchema({ type: 'object', properties: { tags: { type: 'array', items: 'object' }, n: 'string' } }),
|
|
423
|
-
{ type: 'object', properties: { tags: { type: 'array', items: { type: 'object', properties: {} } }, n: { type: 'string' } } }
|
|
468
|
+
{ type: 'object', properties: { tags: { type: 'array', items: { type: 'object', properties: { _placeholder: { type: 'boolean', description: 'Unused. Leave unset.' } } } }, n: { type: 'string' } } }
|
|
424
469
|
);
|
|
425
470
|
});
|
|
426
471
|
|
|
@@ -1167,3 +1212,179 @@ test('Gemini MALFORMED_FUNCTION_CALL finish is an error, not stop', () => {
|
|
|
1167
1212
|
const chat = normalizeUpstream({ choices: [{ delta: {}, finish_reason: 'malformed_function_call' }] }, 'openai-chat');
|
|
1168
1213
|
assert.match(String(chat.error), /malformed_function_call/i);
|
|
1169
1214
|
});
|
|
1215
|
+
|
|
1216
|
+
// DeepSeek/Kimi thinking mode refuse a tool step whose earlier tool-call turn lost its reasoning_content.
|
|
1217
|
+
test('irToChatBody replays thinking as reasoning_content on tool-call turns', () => {
|
|
1218
|
+
const ir = anthropicToIR({ model: 'x', max_tokens: 8000, thinking: { type: 'enabled', budget_tokens: 4000 }, messages: [
|
|
1219
|
+
{ role: 'user', content: 'list files' },
|
|
1220
|
+
{ role: 'assistant', content: [{ type: 'thinking', thinking: 'I should run ls', signature: 'sig' }, { type: 'tool_use', id: 'toolu_1', name: 'Bash', input: { command: 'ls' } }] },
|
|
1221
|
+
{ role: 'user', content: [{ type: 'tool_result', tool_use_id: 'toolu_1', content: 'a.txt' }] },
|
|
1222
|
+
{ role: 'assistant', content: [{ type: 'tool_use', id: 'toolu_2', name: 'Bash', input: { command: 'pwd' } }] },
|
|
1223
|
+
{ role: 'user', content: [{ type: 'tool_result', tool_use_id: 'toolu_2', content: '/' }] }
|
|
1224
|
+
] });
|
|
1225
|
+
for (const opts of [{ thinkingMode: 'auto' }, { thinkingMode: 'native', replayReasoning: true }]) {
|
|
1226
|
+
const calls = irToChatBody(ir, 'deepseek-reasoner', opts).messages.filter(m => m.tool_calls);
|
|
1227
|
+
assert.equal(calls[0].reasoning_content, 'I should run ls', opts.thinkingMode);
|
|
1228
|
+
assert.equal(calls[1].reasoning_content, '', opts.thinkingMode);
|
|
1229
|
+
}
|
|
1230
|
+
const off = emitUpstreamBody('openai-chat', ir, 'deepseek-reasoner', { thinkingMode: 'off' });
|
|
1231
|
+
assert.ok(off.messages.every(m => !('reasoning_content' in m)));
|
|
1232
|
+
|
|
1233
|
+
const rir = responsesToIR({ model: 'x', reasoning: { effort: 'high' }, input: [
|
|
1234
|
+
{ type: 'message', role: 'user', content: 'go' },
|
|
1235
|
+
{ type: 'reasoning', summary: [{ type: 'summary_text', text: 'Plan: run ls' }], encrypted_content: 'opaque' },
|
|
1236
|
+
{ type: 'function_call', call_id: 'c1', name: 'shell', arguments: '{}' },
|
|
1237
|
+
{ type: 'function_call_output', call_id: 'c1', output: 'ok' }
|
|
1238
|
+
] });
|
|
1239
|
+
const rb = irToChatBody(rir, 'kimi-k2-thinking', { thinkingMode: 'native', replayReasoning: true });
|
|
1240
|
+
assert.equal(rb.messages.find(m => m.tool_calls).reasoning_content, 'Plan: run ls');
|
|
1241
|
+
});
|
|
1242
|
+
|
|
1243
|
+
// Strict OpenAI-compatible servers may refuse the extra key, so native mode sends it only on the profile opt-in.
|
|
1244
|
+
test('irToChatBody native mode sends no reasoning_content without the profile opt-in', () => {
|
|
1245
|
+
const ir = anthropicToIR({ model: 'x', max_tokens: 8000, thinking: { type: 'enabled', budget_tokens: 4000 }, messages: [
|
|
1246
|
+
{ role: 'user', content: 'list files' },
|
|
1247
|
+
{ role: 'assistant', content: [{ type: 'thinking', thinking: 'I should run ls', signature: 'sig' }, { type: 'tool_use', id: 'toolu_1', name: 'Bash', input: { command: 'ls' } }] },
|
|
1248
|
+
{ role: 'user', content: [{ type: 'tool_result', tool_use_id: 'toolu_1', content: 'a.txt' }] },
|
|
1249
|
+
{ role: 'assistant', content: [{ type: 'tool_use', id: 'toolu_2', name: 'Bash', input: { command: 'pwd' } }] },
|
|
1250
|
+
{ role: 'user', content: [{ type: 'tool_result', tool_use_id: 'toolu_2', content: '/' }] }
|
|
1251
|
+
] });
|
|
1252
|
+
const cases = [
|
|
1253
|
+
['gpt-5', ir, { thinkingMode: 'native' }],
|
|
1254
|
+
['deepseek-reasoner', ir, { thinkingMode: 'native' }],
|
|
1255
|
+
['gpt-5', ir, { thinkingMode: 'native', replayReasoning: 'yes' }],
|
|
1256
|
+
['gpt-5', { ...ir, replayReasoning: true }, { thinkingMode: 'native' }]
|
|
1257
|
+
];
|
|
1258
|
+
for (const [model, input, opts] of cases) {
|
|
1259
|
+
const body = irToChatBody(input, model, opts);
|
|
1260
|
+
assert.ok(body.messages.some(m => m.tool_calls), 'fixture has tool-call turns');
|
|
1261
|
+
assert.ok(body.messages.every(m => !('reasoning_content' in m)), `${model} ${JSON.stringify(opts)}`);
|
|
1262
|
+
}
|
|
1263
|
+
});
|
|
1264
|
+
|
|
1265
|
+
test('irToAnthropicBody drops thinking for an unsigned tool loop', () => {
|
|
1266
|
+
const ir = responsesToIR({ model: 'x', reasoning: { effort: 'high' }, input: [
|
|
1267
|
+
{ type: 'message', role: 'user', content: 'run ls' },
|
|
1268
|
+
{ type: 'function_call', call_id: 'c1', name: 'shell', arguments: '{"command":["ls"]}' },
|
|
1269
|
+
{ type: 'function_call_output', call_id: 'c1', output: 'a.txt' }
|
|
1270
|
+
] });
|
|
1271
|
+
const body = irToAnthropicBody(ir, 'claude-opus-4-6');
|
|
1272
|
+
assert.equal(body.thinking, undefined);
|
|
1273
|
+
assert.equal(body.messages.at(-1).content[0].type, 'tool_result');
|
|
1274
|
+
const forced = irToAnthropicBody(ir, 'claude-opus-4-6', { thinkingEffort: 'high' });
|
|
1275
|
+
assert.equal(forced.thinking, undefined);
|
|
1276
|
+
const fresh = irToAnthropicBody(responsesToIR({ model: 'x', reasoning: { effort: 'high' }, input: 'hi' }), 'claude-opus-4-6');
|
|
1277
|
+
assert.equal(fresh.thinking?.type, 'enabled');
|
|
1278
|
+
});
|
|
1279
|
+
|
|
1280
|
+
test('toGeminiSchema never emits an empty nested OBJECT', () => {
|
|
1281
|
+
const out = toGeminiSchema({ type: 'object', properties: {
|
|
1282
|
+
env: { type: 'object', additionalProperties: { type: 'string' } },
|
|
1283
|
+
list: { type: 'array', items: { type: 'object' } },
|
|
1284
|
+
either: { anyOf: [{ type: 'object' }, { type: 'string' }] },
|
|
1285
|
+
short: 'object'
|
|
1286
|
+
} });
|
|
1287
|
+
const walk = (s, nested) => {
|
|
1288
|
+
if (!s || typeof s !== 'object') return;
|
|
1289
|
+
if (nested && s.type === 'object') assert.ok(Object.keys(s.properties || {}).length, JSON.stringify(s));
|
|
1290
|
+
for (const p of Object.values(s.properties || {})) walk(p, true);
|
|
1291
|
+
if (s.items) walk(s.items, true);
|
|
1292
|
+
for (const b of s.anyOf || []) walk(b, true);
|
|
1293
|
+
};
|
|
1294
|
+
walk(out, false);
|
|
1295
|
+
assert.deepEqual(toGeminiSchema({ type: 'object' }), { type: 'object', properties: {} });
|
|
1296
|
+
});
|
|
1297
|
+
|
|
1298
|
+
test('irToVertexBody wraps tool output with $ keys', () => {
|
|
1299
|
+
const output = JSON.stringify({ $ref: '#/definitions/Pet', definitions: { Pet: { $id: 'p' } } });
|
|
1300
|
+
const nested = JSON.stringify({ a: { b: [{ $schema: 'x' }] } });
|
|
1301
|
+
const ir = { ...anthropicToIR({ model: 'x', messages: [
|
|
1302
|
+
{ role: 'user', content: 'go' },
|
|
1303
|
+
{ role: 'assistant', content: [{ type: 'tool_use', id: 't1', name: 'Bash', input: {} }, { type: 'tool_use', id: 't2', name: 'Bash', input: {} }, { type: 'tool_use', id: 't3', name: 'Bash', input: {} }] },
|
|
1304
|
+
{ role: 'user', content: [
|
|
1305
|
+
{ type: 'tool_result', tool_use_id: 't1', content: output },
|
|
1306
|
+
{ type: 'tool_result', tool_use_id: 't2', content: nested },
|
|
1307
|
+
{ type: 'tool_result', tool_use_id: 't3', content: '{"ok":true}' }
|
|
1308
|
+
] }
|
|
1309
|
+
] }) };
|
|
1310
|
+
const responses = irToVertexBody(ir, 'gemini-2.5-pro').contents.flatMap(c => c.parts).filter(p => p.functionResponse).map(p => p.functionResponse.response);
|
|
1311
|
+
assert.deepEqual(responses[0], { result: output });
|
|
1312
|
+
assert.deepEqual(responses[1], { result: nested });
|
|
1313
|
+
assert.deepEqual(responses[2], { ok: true });
|
|
1314
|
+
});
|
|
1315
|
+
|
|
1316
|
+
test('toGeminiSchema stringifies an untyped const', () => {
|
|
1317
|
+
const out = toGeminiSchema({ type: 'object', properties: { flag: { const: true }, n: { const: 3 }, s: { const: 'x' } } });
|
|
1318
|
+
assert.deepEqual(out.properties.flag, { enum: ['true'], type: 'string' });
|
|
1319
|
+
assert.deepEqual(out.properties.n, { enum: ['3'], type: 'string' });
|
|
1320
|
+
assert.deepEqual(out.properties.s, { enum: ['x'], type: 'string' });
|
|
1321
|
+
});
|
|
1322
|
+
|
|
1323
|
+
test('responsesToIR keeps long colliding tool names distinct', () => {
|
|
1324
|
+
const ns = `mcp__${'server_with_a_long_name_'.repeat(3)}`;
|
|
1325
|
+
const ir = responsesToIR({ model: 'x', input: [
|
|
1326
|
+
{ type: 'message', role: 'user', content: 'go' },
|
|
1327
|
+
{ type: 'function_call', call_id: 'c1', namespace: ns, name: 'list_items_with_filter_b', arguments: '{}' },
|
|
1328
|
+
{ type: 'function_call_output', call_id: 'c1', output: 'ok' }
|
|
1329
|
+
], tools: [{ type: 'namespace', name: ns, tools: [
|
|
1330
|
+
{ type: 'function', name: 'list_items_with_filter_a', parameters: { type: 'object', properties: {} } },
|
|
1331
|
+
{ type: 'function', name: 'list_items_with_filter_b', parameters: { type: 'object', properties: {} } }
|
|
1332
|
+
] }] });
|
|
1333
|
+
const names = ir.tools.map(t => t.name);
|
|
1334
|
+
assert.equal(names.length, 2);
|
|
1335
|
+
assert.notEqual(names[0], names[1]);
|
|
1336
|
+
assert.ok(names.every(n => n.length <= 64 && /^[A-Za-z0-9_-]+$/.test(n)), names.join());
|
|
1337
|
+
assert.equal(ir.toolMeta[names[1]].name, 'list_items_with_filter_b');
|
|
1338
|
+
assert.equal(ir.messages[1].toolCalls[0].name, names[1]);
|
|
1339
|
+
assert.equal(responsesToIR({ model: 'x', input: 'hi', tools: [{ type: 'function', name: 'read_file', parameters: {} }] }).tools[0].name, 'read_file');
|
|
1340
|
+
});
|
|
1341
|
+
|
|
1342
|
+
test('sanitizeJsonSchema drops null keywords and lookaround patterns', () => {
|
|
1343
|
+
const out = sanitizeJsonSchema({ type: 'object', required: null, description: null, properties: {
|
|
1344
|
+
slug: { type: 'string', pattern: '^(?!-)[a-z-]+$' },
|
|
1345
|
+
tag: { type: 'string', pattern: '(?<=#)\\w+' },
|
|
1346
|
+
plain: { type: 'string', pattern: '^[a-z]+$', default: null },
|
|
1347
|
+
named: { type: 'string', pattern: '^(?<year>\\d{4})$' },
|
|
1348
|
+
pattern: { type: 'string', enum: ['a', null], const: null }
|
|
1349
|
+
} });
|
|
1350
|
+
assert.equal('required' in out, false);
|
|
1351
|
+
assert.equal('description' in out, false);
|
|
1352
|
+
assert.equal('pattern' in out.properties.slug, false);
|
|
1353
|
+
assert.equal('pattern' in out.properties.tag, false);
|
|
1354
|
+
assert.equal(out.properties.plain.pattern, '^[a-z]+$');
|
|
1355
|
+
assert.equal(out.properties.plain.default, null);
|
|
1356
|
+
assert.equal(out.properties.named.pattern, '^(?<year>\\d{4})$');
|
|
1357
|
+
assert.deepEqual(out.properties.pattern, { type: 'string', enum: ['a', null], const: null });
|
|
1358
|
+
assert.deepEqual(sanitizeJsonSchema({ type: 'object', properties: {}, default: { a: null, b: {} } }).default, { a: null, b: {} });
|
|
1359
|
+
});
|
|
1360
|
+
|
|
1361
|
+
test('irToVertexBody never ends on a model turn', () => {
|
|
1362
|
+
const ir = anthropicToIR({ model: 'x', messages: [
|
|
1363
|
+
{ role: 'user', content: 'Write a haiku' },
|
|
1364
|
+
{ role: 'assistant', content: 'Autumn moon' }
|
|
1365
|
+
] });
|
|
1366
|
+
const body = irToVertexBody(ir, 'gemini-2.5-pro');
|
|
1367
|
+
assert.equal(body.contents.at(-1).role, 'user');
|
|
1368
|
+
assert.ok(body.contents.some(c => c.role === 'model' && c.parts.some(p => p.text === 'Autumn moon')), 'the prefill text is kept');
|
|
1369
|
+
});
|
|
1370
|
+
|
|
1371
|
+
test('healAnthropicPayload dedupes repeated tool_use ids', () => {
|
|
1372
|
+
const use = (id, cmd) => ({ type: 'tool_use', id, name: 'Bash', input: { command: cmd } });
|
|
1373
|
+
const result = (id, text) => ({ type: 'tool_result', tool_use_id: id, content: text });
|
|
1374
|
+
const r = healAnthropicPayload({ model: 'claude', messages: [
|
|
1375
|
+
{ role: 'user', content: 'go' },
|
|
1376
|
+
{ role: 'assistant', content: [use('functions_Bash_0', 'ls')] },
|
|
1377
|
+
{ role: 'user', content: [result('functions_Bash_0', 'a.txt')] },
|
|
1378
|
+
{ role: 'assistant', content: [use('functions_Bash_0', 'pwd'), use('functions_Bash_1', 'id')] },
|
|
1379
|
+
{ role: 'user', content: [result('functions_Bash_0', '/'), result('functions_Bash_1', 'uid')] }
|
|
1380
|
+
] });
|
|
1381
|
+
assert.equal(r.changed, true);
|
|
1382
|
+
const msgs = r.payload.messages;
|
|
1383
|
+
const ids = msgs.filter(m => m.role === 'assistant').flatMap(m => m.content.filter(b => b.type === 'tool_use').map(b => b.id));
|
|
1384
|
+
assert.equal(new Set(ids).size, ids.length, ids.join());
|
|
1385
|
+
assert.ok(ids.every(id => /^[a-zA-Z0-9_-]+$/.test(id)));
|
|
1386
|
+
assert.equal(ids[0], 'functions_Bash_0');
|
|
1387
|
+
// Each result still answers its own call.
|
|
1388
|
+
assert.deepEqual(msgs[2].content.map(b => [b.tool_use_id, b.content]), [[ids[0], 'a.txt']]);
|
|
1389
|
+
assert.deepEqual(msgs[4].content.map(b => [b.tool_use_id, b.content]), [[ids[1], '/'], [ids[2], 'uid']]);
|
|
1390
|
+
});
|
|
@@ -11,14 +11,14 @@ import path from 'node:path';
|
|
|
11
11
|
import { spawn, spawnSync } from 'node:child_process';
|
|
12
12
|
import zlib from 'node:zlib';
|
|
13
13
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
14
|
-
import { assertValidAnthropicEvents } from './helpers.mjs';
|
|
14
|
+
import { assertValidAnthropicEvents, stopChild, reapDir, reapDirSync, onAbnormalExit } from './helpers.mjs';
|
|
15
15
|
import { createFrameReader } from '../blindfold/wsframe.mjs';
|
|
16
16
|
|
|
17
17
|
const MASKED = '__LLM_SWITCHER_KEEP_KEY__';
|
|
18
18
|
|
|
19
19
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
20
20
|
|
|
21
|
-
let upstream, upstreamPort, proxy, proxyPort, tmpDir, fakeInterceptor, blindfoldPort, certsDir;
|
|
21
|
+
let upstream, upstreamPort, proxy, proxyPort, tmpDir, fakeInterceptor, blindfoldPort, certsDir, cancelExitReap;
|
|
22
22
|
const received = []; // { url, headers, body }
|
|
23
23
|
const hangState = { closed: false, slowAborted: false };
|
|
24
24
|
const bigState = { finishedAt: 0 };
|
|
@@ -83,7 +83,8 @@ function startUpstream() {
|
|
|
83
83
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
84
84
|
return res.end(JSON.stringify({ data: [
|
|
85
85
|
{ id: 'gpt-5.6-sol', context_length: 1048576, compact_window: 272000 },
|
|
86
|
-
{ id: 'up-opus', context_length: 200000 }
|
|
86
|
+
{ id: 'up-opus', context_length: 200000 },
|
|
87
|
+
{ id: 'up-capped', context_length: 200000, max_output_tokens: 8192 }
|
|
87
88
|
] }));
|
|
88
89
|
}
|
|
89
90
|
if (req.url.startsWith('/intact/v1/messages')) {
|
|
@@ -95,6 +96,20 @@ function startUpstream() {
|
|
|
95
96
|
return res.end(JSON.stringify({ id: 'c1', object: 'chat.completion', model: json.model, choices: [{ index: 0, message: { role: 'assistant', content: 'converted ok' }, finish_reason: 'stop' }], usage: { prompt_tokens: 1, completion_tokens: 1 } }));
|
|
96
97
|
}
|
|
97
98
|
if (req.url.startsWith('/chat/v1/chat/completions')) {
|
|
99
|
+
// A text-only model: it refuses any image part, and one that refuses for an unrelated reason.
|
|
100
|
+
const hasImage = (json.messages || []).some(m => Array.isArray(m.content) && m.content.some(p => p?.type === 'image_url'));
|
|
101
|
+
if (json.model === 'up-textonly' && hasImage) {
|
|
102
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
103
|
+
return res.end(JSON.stringify({ error: { message: 'Failed to deserialize the JSON body: messages[0]: unknown variant `image_url`, expected `text`', type: 'invalid_request_error' } }));
|
|
104
|
+
}
|
|
105
|
+
if (json.model === 'up-textonly' && lastUser.includes('RETRY_FAILS')) {
|
|
106
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
107
|
+
return res.end(JSON.stringify({ error: { message: 'try again later', type: 'invalid_request_error' } }));
|
|
108
|
+
}
|
|
109
|
+
if (json.model === 'up-badreq') {
|
|
110
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
111
|
+
return res.end(JSON.stringify({ error: { message: 'temperature must be between 0 and 2', type: 'invalid_request_error' } }));
|
|
112
|
+
}
|
|
98
113
|
if (lastUser.includes('TOOL_ALIAS_REPLAY')) {
|
|
99
114
|
const reference = json.messages.find(m => m.role === 'tool');
|
|
100
115
|
const name = JSON.parse(reference.content).tool_name;
|
|
@@ -112,6 +127,10 @@ function startUpstream() {
|
|
|
112
127
|
res.writeHead(429, { 'Content-Type': 'application/json', 'retry-after': '7' });
|
|
113
128
|
return res.end(JSON.stringify({ error: { message: 'slow down' } }));
|
|
114
129
|
}
|
|
130
|
+
if (lastUser.includes('CONTEXT_OVERFLOW')) {
|
|
131
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
132
|
+
return res.end(JSON.stringify({ error: { message: "This model's maximum context length is 131072 tokens. However, you requested 140000 tokens (132000 in the messages, 8000 in the completion). Please reduce the length of the messages or completion.", type: 'invalid_request_error', code: 'invalid_request_error' } }));
|
|
133
|
+
}
|
|
115
134
|
if (lastUser.includes('APPLY_PATCH')) {
|
|
116
135
|
return sse(res, [
|
|
117
136
|
{ choices: [{ index: 0, delta: { tool_calls: [{ index: 0, id: 'call_patch', type: 'function', function: { name: 'apply_patch', arguments: '{"input":"*** Begin Patch\\n' } }] } }] },
|
|
@@ -216,6 +235,8 @@ before(async () => {
|
|
|
216
235
|
await startUpstream();
|
|
217
236
|
proxyPort = await freePort();
|
|
218
237
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'llm-switcher-test-'));
|
|
238
|
+
// A run that ends with no after() still stops the gateway and the interceptor it started detached.
|
|
239
|
+
cancelExitReap = onAbnormalExit(() => reapDirSync(tmpDir, [proxy?.pid, fakeInterceptor?.pid]));
|
|
219
240
|
|
|
220
241
|
// R3b: both tools are active in this fixture, so every admin write runs through
|
|
221
242
|
// checkBlindfoldTarget. Give the interceptor a port of its own and a certificate minted for
|
|
@@ -239,6 +260,8 @@ before(async () => {
|
|
|
239
260
|
roles: { name: 'Mock Public Roles', mode: 'convert', inFormat: 'responses', baseURL: `${base}/chat/v1`, apiKey: 'sk-secret-roles', publicModels: ['gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-luna'], defaultModels: { main: 'ag/mock-flash', review: 'ag/mock-review', subagent: 'ag/mock-low' } },
|
|
240
261
|
native: { name: 'Mock Strict OpenAI', mode: 'convert', inFormat: 'auto', outFormat: 'openai-chat', thinkingMode: 'native', baseURL: `${base}/chat/v1`, apiKey: 'sk-secret-native', defaultModels: models },
|
|
241
262
|
intactcc: { name: 'Mock intact', mode: 'convert', tool: 'claude', outFormat: 'openai-chat', thinkingMode: 'off', baseURL: `${base}/intact/v1`, apiKey: 'sk-intact-user', defaultModels: { opus: 'claude/claude-opus-5', sonnet: 'gem/flash', haiku: 'claude/claude-haiku-4-5', fable: 'gem/flash' } },
|
|
263
|
+
textonly: { name: 'Mock Text Only', mode: 'convert', inFormat: 'auto', outFormat: 'openai-chat', baseURL: `${base}/chat/v1`, apiKey: 'sk-secret-textonly', defaultModels: { opus: 'up-textonly', sonnet: 'up-badreq', haiku: 'up-haiku', fable: 'up-fable' } },
|
|
264
|
+
capped: { name: 'Mock Capped Output', mode: 'convert', inFormat: 'auto', outFormat: 'openai-chat', baseURL: `${base}/chat/v1`, apiKey: 'sk-secret-capped', defaultModels: { opus: 'up-capped', sonnet: 'up-sonnet', haiku: 'up-haiku', fable: 'up-fable' } },
|
|
242
265
|
ant: { name: 'Mock Anthropic', mode: 'direct', inFormat: 'auto', outFormat: 'anthropic', baseURL: `${base}/ant`, apiKey: 'sk-secret-ant', defaultModels: models }
|
|
243
266
|
}
|
|
244
267
|
};
|
|
@@ -298,9 +321,11 @@ before(async () => {
|
|
|
298
321
|
}
|
|
299
322
|
});
|
|
300
323
|
|
|
301
|
-
after(() => {
|
|
302
|
-
|
|
303
|
-
|
|
324
|
+
after(async () => {
|
|
325
|
+
cancelExitReap?.();
|
|
326
|
+
// The gateway first: once it is gone it can start no interceptor.
|
|
327
|
+
await stopChild(proxy);
|
|
328
|
+
await stopChild(fakeInterceptor);
|
|
304
329
|
upstream?.close();
|
|
305
330
|
// A test that changes the tool set makes the gateway start a real interceptor. It is detached on
|
|
306
331
|
// purpose, so it outlives the gateway; stop the one the gateway recorded before its state goes.
|
|
@@ -308,7 +333,8 @@ after(() => {
|
|
|
308
333
|
const { pid } = JSON.parse(fs.readFileSync(path.join(tmpDir, 'blindfold.json'), 'utf8'));
|
|
309
334
|
if (Number.isInteger(pid) && pid !== fakeInterceptor?.pid) process.kill(pid);
|
|
310
335
|
} catch {}
|
|
311
|
-
|
|
336
|
+
// One it had not recorded yet still names the dir on its command line.
|
|
337
|
+
if (tmpDir) await reapDir(tmpDir);
|
|
312
338
|
});
|
|
313
339
|
|
|
314
340
|
const url = (p) => `http://127.0.0.1:${proxyPort}${p}`;
|
|
@@ -407,7 +433,7 @@ test('Codex via ag/* target: Gemini-hostile tool schemas are rewritten before up
|
|
|
407
433
|
const params = up.body.tools[0].function.parameters;
|
|
408
434
|
assert.deepEqual(params.properties.ids, { type: 'array', items: { type: 'string' }, nullable: true });
|
|
409
435
|
assert.deepEqual(params.properties.part, { type: 'object', properties: { t: { type: 'string' } } });
|
|
410
|
-
assert.deepEqual(params.properties.raw, { type: 'object', properties: {} });
|
|
436
|
+
assert.deepEqual(params.properties.raw, { type: 'object', properties: { _placeholder: { type: 'boolean', description: 'Unused. Leave unset.' } } });
|
|
411
437
|
assert.deepEqual(params.required, ['ids']);
|
|
412
438
|
assert.ok(!JSON.stringify(params).includes('$ref'), 'no $ref survives');
|
|
413
439
|
assert.equal(params.$defs, undefined);
|
|
@@ -586,6 +612,57 @@ test('A8: /v1/chat/completions and /chat/completions answer 404 on the gateway',
|
|
|
586
612
|
assert.equal(received.length, before, 'a removed route never reaches an upstream');
|
|
587
613
|
});
|
|
588
614
|
|
|
615
|
+
// The profile's own list names the output limit, so a max_tokens above it is clamped instead of refused.
|
|
616
|
+
test('max_tokens above the listed output limit is clamped; a model with no listed limit keeps it', async () => {
|
|
617
|
+
const listed = await fetch(url('/v1/models'), { headers: { 'x-llm-profile': 'capped' } });
|
|
618
|
+
assert.equal(listed.status, 200);
|
|
619
|
+
await listed.text();
|
|
620
|
+
const res = await post('/v1/messages', { model: 'claude-opus-4-6', max_tokens: 64000, messages: [{ role: 'user', content: 'hi' }] }, { 'x-llm-profile': 'capped' });
|
|
621
|
+
assert.equal(res.status, 200);
|
|
622
|
+
await res.text();
|
|
623
|
+
assert.equal(received.at(-1).body.model, 'up-capped');
|
|
624
|
+
assert.equal(received.at(-1).body.max_tokens, 8192);
|
|
625
|
+
const free = await post('/v1/messages', { model: 'claude-sonnet-4-6', max_tokens: 64000, messages: [{ role: 'user', content: 'hi' }] }, { 'x-llm-profile': 'capped' });
|
|
626
|
+
await free.text();
|
|
627
|
+
assert.equal(received.at(-1).body.model, 'up-sonnet');
|
|
628
|
+
assert.equal(received.at(-1).body.max_tokens, 64000);
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
// A text-only model refuses an image that stays in the history, so every later turn would fail too.
|
|
632
|
+
test('An image refused by a text-only chat model is retried once as text in each request', async () => {
|
|
633
|
+
const image = { type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'iVBORw0KGgo=' } };
|
|
634
|
+
const calls = model => received.filter(r => r.url.startsWith('/chat/v1/chat/completions') && r.body.model === model).length;
|
|
635
|
+
const sentImage = r => r.body.messages.some(m => Array.isArray(m.content) && m.content.some(p => p?.type === 'image_url'));
|
|
636
|
+
const first = await post('/v1/messages', { model: 'claude-opus-4-6', max_tokens: 100, messages: [{ role: 'user', content: [image, { type: 'text', text: 'what is this' }] }] }, { 'x-llm-profile': 'textonly' });
|
|
637
|
+
assert.equal(first.status, 200);
|
|
638
|
+
assert.equal((await first.json()).type, 'message');
|
|
639
|
+
assert.equal(calls('up-textonly'), 2, 'one refusal, one retry');
|
|
640
|
+
assert.equal(sentImage(received.at(-1)), false);
|
|
641
|
+
assert.ok(JSON.stringify(received.at(-1).body).includes('[image omitted: this model does not accept images]'));
|
|
642
|
+
|
|
643
|
+
const second = await post('/v1/messages', { model: 'claude-opus-4-6', max_tokens: 100, stream: true, messages: [
|
|
644
|
+
{ role: 'user', content: [image, { type: 'text', text: 'what is this' }] },
|
|
645
|
+
{ role: 'assistant', content: 'an image' },
|
|
646
|
+
{ role: 'user', content: 'and now?' }
|
|
647
|
+
] }, { 'x-llm-profile': 'textonly' });
|
|
648
|
+
assert.equal(second.status, 200);
|
|
649
|
+
await second.text();
|
|
650
|
+
assert.equal(calls('up-textonly'), 4, 'nothing is remembered: one refusal, one retry again');
|
|
651
|
+
assert.equal(sentImage(received.at(-2)), true);
|
|
652
|
+
assert.equal(sentImage(received.at(-1)), false);
|
|
653
|
+
|
|
654
|
+
const failed = await post('/v1/messages', { model: 'claude-opus-4-6', max_tokens: 100, messages: [{ role: 'user', content: [image, { type: 'text', text: 'RETRY_FAILS' }] }] }, { 'x-llm-profile': 'textonly' });
|
|
655
|
+
assert.equal(failed.status, 400);
|
|
656
|
+
assert.match((await failed.json()).error.message, /try again later/, 'the client gets the error of the retry, not the first refusal');
|
|
657
|
+
assert.equal(calls('up-textonly'), 6);
|
|
658
|
+
|
|
659
|
+
const other = await post('/v1/messages', { model: 'claude-sonnet-4-6', max_tokens: 100, messages: [{ role: 'user', content: [image, { type: 'text', text: 'hi' }] }] }, { 'x-llm-profile': 'textonly' });
|
|
660
|
+
assert.equal(other.status, 400);
|
|
661
|
+
assert.match((await other.json()).error.message, /temperature must be between 0 and 2/);
|
|
662
|
+
assert.equal(calls('up-badreq'), 1, 'an unrelated 400 is relayed, never retried');
|
|
663
|
+
assert.equal(sentImage(received.at(-1)), true);
|
|
664
|
+
});
|
|
665
|
+
|
|
589
666
|
test('Upstream 429 is returned in Anthropic error shape with retry-after', async () => {
|
|
590
667
|
const res = await post('/v1/messages', { model: 'claude-opus-4-6', max_tokens: 10, messages: [{ role: 'user', content: 'RATE_LIMIT' }] });
|
|
591
668
|
assert.equal(res.status, 429);
|
|
@@ -594,6 +671,20 @@ test('Upstream 429 is returned in Anthropic error shape with retry-after', async
|
|
|
594
671
|
assert.deepEqual(json, { type: 'error', error: { type: 'rate_limit_error', message: 'slow down' } });
|
|
595
672
|
});
|
|
596
673
|
|
|
674
|
+
// Claude Code compacts only on "prompt is too long", and Codex only on context_length_exceeded.
|
|
675
|
+
test('sendClientError maps context overflow per client', async () => {
|
|
676
|
+
const cc = await post('/v1/messages', { model: 'claude-opus-4-6', max_tokens: 10, messages: [{ role: 'user', content: 'CONTEXT_OVERFLOW' }] });
|
|
677
|
+
assert.equal(cc.status, 400);
|
|
678
|
+
const ccJson = await cc.json();
|
|
679
|
+
assert.equal(ccJson.error.type, 'invalid_request_error');
|
|
680
|
+
assert.match(ccJson.error.message, /^prompt is too long: 140000 tokens > 131072 maximum/);
|
|
681
|
+
const codex = await post('/v1/responses', { model: 'gpt-5-codex', input: 'CONTEXT_OVERFLOW' });
|
|
682
|
+
assert.equal(codex.status, 400);
|
|
683
|
+
const codexJson = await codex.json();
|
|
684
|
+
assert.equal(codexJson.error.code, 'context_length_exceeded');
|
|
685
|
+
assert.match(codexJson.error.message, /maximum context length is 131072/);
|
|
686
|
+
});
|
|
687
|
+
|
|
597
688
|
test('Mid-stream upstream error surfaces as an error event instead of a fake end_turn', async () => {
|
|
598
689
|
const res = await post('/v1/messages', { model: 'claude-opus-4-6', max_tokens: 10, stream: true, messages: [{ role: 'user', content: 'MID_STREAM_ERROR' }] });
|
|
599
690
|
const events = parseSSE(await res.text());
|
package/tests/helpers.mjs
CHANGED
|
@@ -1,4 +1,64 @@
|
|
|
1
1
|
import assert from 'node:assert/strict';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
|
|
5
|
+
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
|
6
|
+
|
|
7
|
+
// The pids of every process whose command line names `dir`, this one excluded. A gateway records
|
|
8
|
+
// its detached interceptor only once it answers, so a teardown that trusts the record misses one
|
|
9
|
+
// still starting. POSIX only: on Windows it returns [].
|
|
10
|
+
export function pidsNaming(dir) {
|
|
11
|
+
if (process.platform === 'win32' || !dir) return [];
|
|
12
|
+
const r = spawnSync('ps', ['-eo', 'pid=,args='], { encoding: 'utf8' });
|
|
13
|
+
if (r.status !== 0) return [];
|
|
14
|
+
return r.stdout.split('\n')
|
|
15
|
+
.map(line => line.trim().match(/^(\d+)\s+(.*)$/))
|
|
16
|
+
.filter(m => m && m[2].includes(dir) && Number(m[1]) !== process.pid)
|
|
17
|
+
.map(m => Number(m[1]));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Stops a child and waits for its exit, so nothing it does later lands in a removed directory.
|
|
21
|
+
export async function stopChild(child, ms = 3000) {
|
|
22
|
+
if (!child || child.exitCode !== null || child.signalCode !== null) return;
|
|
23
|
+
const exited = new Promise(r => child.once('exit', r));
|
|
24
|
+
child.kill('SIGTERM');
|
|
25
|
+
if (await Promise.race([exited.then(() => true), sleep(ms).then(() => false)])) return;
|
|
26
|
+
child.kill('SIGKILL');
|
|
27
|
+
await Promise.race([exited, sleep(ms)]);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Stops every process that still names `dir` (SIGTERM, then SIGKILL), then removes the dir.
|
|
31
|
+
export async function reapDir(dir) {
|
|
32
|
+
for (let i = 0; i < 30; i++) {
|
|
33
|
+
const left = pidsNaming(dir);
|
|
34
|
+
if (!left.length) break;
|
|
35
|
+
for (const pid of left) try { process.kill(pid, i < 20 ? 'SIGTERM' : 'SIGKILL'); } catch { /* gone */ }
|
|
36
|
+
await sleep(100);
|
|
37
|
+
}
|
|
38
|
+
fs.rmSync(dir, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// The same, synchronous, for a process that is about to end. A negative pid is a process group.
|
|
42
|
+
export function reapDirSync(dir, pids = []) {
|
|
43
|
+
for (const pid of [...pids.filter(Number.isInteger), ...pidsNaming(dir)]) try { process.kill(pid, 'SIGKILL'); } catch { /* gone */ }
|
|
44
|
+
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* best effort at exit */ }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Runs `fn` when this process ends before its teardown did: at exit, or on a signal, which ends a
|
|
48
|
+
// test process with no after() hook. Returns the function that cancels it.
|
|
49
|
+
export function onAbnormalExit(fn) {
|
|
50
|
+
const SIGNALS = ['SIGINT', 'SIGTERM', 'SIGHUP'];
|
|
51
|
+
const run = () => { off(); try { fn(); } catch { /* best effort at exit */ } };
|
|
52
|
+
// The listener replaces the default action, so the signal is raised again once it is gone.
|
|
53
|
+
const onSignal = (sig) => { run(); process.kill(process.pid, sig); };
|
|
54
|
+
function off() {
|
|
55
|
+
process.off('exit', run);
|
|
56
|
+
for (const sig of SIGNALS) process.off(sig, onSignal);
|
|
57
|
+
}
|
|
58
|
+
process.once('exit', run);
|
|
59
|
+
for (const sig of SIGNALS) process.on(sig, onSignal);
|
|
60
|
+
return off;
|
|
61
|
+
}
|
|
2
62
|
|
|
3
63
|
// Validate an Anthropic event sequence: indexes increase in start order, deltas only target an open block.
|
|
4
64
|
export function assertValidAnthropicEvents(events) {
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// The browser tests start a gateway, a stand-in interceptor and a Chromium, and the gateway can
|
|
2
|
+
// start a real interceptor of its own, detached. Whatever ends the run, none of them and none of
|
|
3
|
+
// their temp dirs may outlive it. Each case runs in a child process with a private TMPDIR, so
|
|
4
|
+
// "nothing left" is a check on that one directory.
|
|
5
|
+
import { test } from 'node:test';
|
|
6
|
+
import assert from 'node:assert/strict';
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import os from 'node:os';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { spawn } from 'node:child_process';
|
|
11
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
12
|
+
import { skipReason } from './cdp.mjs';
|
|
13
|
+
import { pidsNaming } from './helpers.mjs';
|
|
14
|
+
|
|
15
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
const url = (f) => JSON.stringify(pathToFileURL(path.join(HERE, f)).href);
|
|
17
|
+
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
|
18
|
+
const skip = process.platform === 'win32' ? 'lists processes with ps' : skipReason();
|
|
19
|
+
|
|
20
|
+
// Starts the fixture and a browser, then moves the interceptor to a new port so the gateway starts
|
|
21
|
+
// a real one. `awaitStart` false: the script does not wait for the gateway to record it.
|
|
22
|
+
const PRELUDE = (awaitStart) => `
|
|
23
|
+
import { startDashboardFixture } from ${url('dashboard-fixture.mjs')};
|
|
24
|
+
import { launchBrowser } from ${url('cdp.mjs')};
|
|
25
|
+
import { pidsNaming } from ${url('helpers.mjs')};
|
|
26
|
+
import net from 'node:net';
|
|
27
|
+
const free = () => new Promise(r => { const s = net.createServer().listen(0, '127.0.0.1', () => { const { port } = s.address(); s.close(() => r(port)); }); });
|
|
28
|
+
const fx = await startDashboardFixture();
|
|
29
|
+
const browser = await launchBrowser();
|
|
30
|
+
await browser.newPage();
|
|
31
|
+
const cfg = fx.config();
|
|
32
|
+
cfg.blindfold.port = await free();
|
|
33
|
+
fx.writeConfig(cfg);
|
|
34
|
+
const sync = fx.api('/api/blindfold/sync', {});
|
|
35
|
+
${awaitStart ? `const r = await sync; if (r.status !== 200) throw new Error('no interceptor: ' + JSON.stringify(r.json));` : 'sync.catch(() => {});'}
|
|
36
|
+
for (let i = 0; i < 200 && !pidsNaming(fx.dir).length; i++) await new Promise(r => setTimeout(r, 25));
|
|
37
|
+
if (!pidsNaming(fx.dir).length) throw new Error('the gateway started no interceptor');
|
|
38
|
+
`;
|
|
39
|
+
|
|
40
|
+
async function inPrivateTmp(t, script, act) {
|
|
41
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'lsw-teardown-'));
|
|
42
|
+
t.after(() => {
|
|
43
|
+
// A red run must not leave what it found behind.
|
|
44
|
+
for (const pid of pidsNaming(tmp)) try { process.kill(pid, 'SIGKILL'); } catch { /* gone */ }
|
|
45
|
+
fs.rmSync(tmp, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 });
|
|
46
|
+
});
|
|
47
|
+
const child = spawn(process.execPath, ['--input-type=module', '-e', script], {
|
|
48
|
+
env: { ...process.env, TMPDIR: tmp, TMP: tmp, TEMP: tmp }, stdio: ['ignore', 'pipe', 'pipe']
|
|
49
|
+
});
|
|
50
|
+
let out = '';
|
|
51
|
+
child.stdout.on('data', d => { out += d; });
|
|
52
|
+
child.stderr.on('data', d => { out += d; });
|
|
53
|
+
const exited = new Promise(r => child.once('exit', r));
|
|
54
|
+
await act(child, () => out);
|
|
55
|
+
await Promise.race([exited, sleep(30000)]);
|
|
56
|
+
// A detached process may take a moment to die after the signal.
|
|
57
|
+
let left = pidsNaming(tmp);
|
|
58
|
+
for (let i = 0; i < 50 && left.length; i++) { await sleep(100); left = pidsNaming(tmp); }
|
|
59
|
+
return { left, files: fs.readdirSync(tmp), out: () => out };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
test('stop() and close() remove an interceptor the gateway had not recorded yet, and every temp dir', { skip, timeout: 90000 }, async (t) => {
|
|
63
|
+
const r = await inPrivateTmp(t, `${PRELUDE(false)}
|
|
64
|
+
await fx.stop();
|
|
65
|
+
await browser.close();
|
|
66
|
+
console.log('DONE');
|
|
67
|
+
`, async () => {});
|
|
68
|
+
assert.match(r.out(), /DONE/, r.out());
|
|
69
|
+
assert.deepEqual(r.left, [], 'no process names the temp dir');
|
|
70
|
+
assert.deepEqual(r.files, [], 'no temp dir is left');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('a test process stopped by SIGTERM still stops its gateway, interceptor and browser', { skip, timeout: 90000 }, async (t) => {
|
|
74
|
+
const r = await inPrivateTmp(t, `${PRELUDE(true)}
|
|
75
|
+
console.log('READY');
|
|
76
|
+
setInterval(() => {}, 1000);
|
|
77
|
+
`, async (child, out) => {
|
|
78
|
+
for (let i = 0; i < 600 && !/READY/.test(out()) && child.exitCode === null; i++) await sleep(100);
|
|
79
|
+
assert.match(out(), /READY/, out());
|
|
80
|
+
child.kill('SIGTERM');
|
|
81
|
+
});
|
|
82
|
+
assert.deepEqual(r.left, [], 'no process names the temp dir');
|
|
83
|
+
assert.deepEqual(r.files, [], 'no temp dir is left');
|
|
84
|
+
});
|
package/tests/windows.test.mjs
CHANGED
|
@@ -20,11 +20,22 @@ test('a list entry gives the whole window and the window to compress at', () =>
|
|
|
20
20
|
assert.deepEqual(parseModelWindows({ id: 'groq/q', context_window: 131072 }), { context: 131072, compact: 0 },
|
|
21
21
|
'one window is the whole window, not a threshold');
|
|
22
22
|
assert.deepEqual(parseModelWindows({ id: 'or/r', context_length: 262144, top_provider: { context_length: 262144, max_completion_tokens: 32768 } }),
|
|
23
|
-
{ context: 262144, compact: 0 });
|
|
23
|
+
{ context: 262144, compact: 0, output: 32768 });
|
|
24
24
|
assert.deepEqual(parseModelWindows({ id: 'bare' }), null, 'a model with no limits says nothing');
|
|
25
25
|
assert.deepEqual(parseModelWindows({ id: 'zero', context_length: 0, compact_window: 0 }), null);
|
|
26
26
|
});
|
|
27
27
|
|
|
28
|
+
// The output limit lets the gateway clamp a max_tokens the model would refuse.
|
|
29
|
+
test('a list entry keeps the output limit it names, and leaves it out when it names none', () => {
|
|
30
|
+
assert.deepEqual(parseModelWindows({ id: 'i/m', context_length: 200000, max_output_tokens: 8192 }),
|
|
31
|
+
{ context: 200000, compact: 0, output: 8192 });
|
|
32
|
+
assert.deepEqual(parseModelWindows({ id: 'only-out', max_output_tokens: 4096 }), { context: 0, compact: 0, output: 4096 },
|
|
33
|
+
'an output limit alone is still worth keeping');
|
|
34
|
+
assert.equal('output' in parseModelWindows({ id: 'g', context_length: 131072 }), false);
|
|
35
|
+
assert.deepEqual(parseModelList({ data: [{ id: 'i/m', context_length: 200000, max_output_tokens: 8192 }] }),
|
|
36
|
+
{ 'i/m': { context: 200000, compact: 0, output: 8192 } });
|
|
37
|
+
});
|
|
38
|
+
|
|
28
39
|
test('parseModelList keys the windows by the id a profile maps, and drops the nameless', () => {
|
|
29
40
|
const out = parseModelList({ data: [{ id: 'models/big', context_length: 1048576 }, { id: 'bare' }, 'plain'] });
|
|
30
41
|
assert.deepEqual(Object.keys(out), ['big'], 'a bare string carries no window, and a models/ prefix is dropped');
|
|
@@ -90,6 +101,25 @@ test('windows are cached for an hour, and a failed refresh keeps the stale answe
|
|
|
90
101
|
}
|
|
91
102
|
});
|
|
92
103
|
|
|
104
|
+
test('the cache persists the output limit, and an old cache file without it still reads', async () => {
|
|
105
|
+
const dir = tmpDir();
|
|
106
|
+
const sink = http.createServer((req, res) => {
|
|
107
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
108
|
+
res.end(JSON.stringify({ data: [{ id: 'm', context_length: 200000, max_output_tokens: 8192 }] }));
|
|
109
|
+
});
|
|
110
|
+
await new Promise(r => sink.listen(0, '127.0.0.1', r));
|
|
111
|
+
const base = `http://127.0.0.1:${sink.address().port}/v1`;
|
|
112
|
+
try {
|
|
113
|
+
await resolveProfileWindows(dir, 'p@' + base, base, 'k', { now: 1_000 });
|
|
114
|
+
assert.equal(readWindowsCache(dir)['p@' + base].models.m.output, 8192);
|
|
115
|
+
fs.writeFileSync(path.join(dir, 'model-windows.json'), JSON.stringify({ old: { at: 1_000, baseURL: base, models: { m: { context: 200000, compact: 0 } } } }));
|
|
116
|
+
assert.deepEqual(await resolveProfileWindows(dir, 'old', base, 'k', { now: 2_000 }), { m: { context: 200000, compact: 0 } });
|
|
117
|
+
} finally {
|
|
118
|
+
sink.close();
|
|
119
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
93
123
|
test('an empty cache file reads as no windows instead of throwing', () => {
|
|
94
124
|
const dir = tmpDir();
|
|
95
125
|
try {
|