llm-switcher 1.6.12 → 1.6.13
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 +15 -0
- package/README.md +2 -1
- package/README.vi.md +2 -1
- package/formats.mjs +63 -21
- package/package.json +1 -1
- package/proxy.mjs +34 -28
- package/scripts/run-tests.mjs +60 -24
- package/switch.mjs +38 -13
- package/tests/blindfold.test.mjs +1 -1
- package/tests/compaction.e2e.test.mjs +5 -1
- package/tests/contract-lab.test.mjs +2 -2
- package/tests/formats.test.mjs +180 -5
- package/tests/gateway.e2e.test.mjs +102 -1
- package/tests/goalcheck.e2e.test.mjs +44 -6
- package/tests/idlecompact.e2e.test.mjs +6 -1
- package/tests/lifecycle.test.mjs +5 -1
- package/tests/run-tests.test.mjs +50 -0
- package/tests/shim.test.mjs +16 -13
- package/tests/switch.test.mjs +46 -0
- package/tests/update.e2e.test.mjs +6 -1
- package/tests/windows.test.mjs +85 -3
- package/windows.mjs +35 -5
package/tests/formats.test.mjs
CHANGED
|
@@ -14,10 +14,11 @@ import {
|
|
|
14
14
|
createCodeAssistStream,
|
|
15
15
|
budgetToEffort, effortToBudget, normalizeEffort, capEffort, mapEquivalentEffort, EFFORT_LEVELS, parseArgs, buildAnthropicMessage,
|
|
16
16
|
refusesImageInput, omitChatImages, IMAGE_OMITTED_TEXT,
|
|
17
|
-
stripGeminiPlaceholders, geminiPaddedTools, withGeminiPlaceholderStrip
|
|
17
|
+
stripGeminiPlaceholders, geminiPaddedTools, withGeminiPlaceholderStrip, toolVocabForIR, recordGeminiPadding
|
|
18
18
|
} from '../formats.mjs';
|
|
19
19
|
import { NULL_TOOL_VOCAB, createToolVocab } from '../toolvocab.mjs';
|
|
20
20
|
import { assertValidAnthropicEvents } from './helpers.mjs';
|
|
21
|
+
import { encodeCompaction } from '../compaction.mjs';
|
|
21
22
|
|
|
22
23
|
test('anthropicToIR: stream defaults to false and thinking budget_tokens is preserved', () => {
|
|
23
24
|
const ir = anthropicToIR({ model: 'claude-opus-4-6', max_tokens: 64000, messages: [{ role: 'user', content: 'hi' }], thinking: { type: 'enabled', budget_tokens: 31999 } });
|
|
@@ -111,6 +112,15 @@ test('healAnthropicPayload: native Anthropic passthrough keeps the billing heade
|
|
|
111
112
|
assert.equal(payload.system[0].text, header);
|
|
112
113
|
});
|
|
113
114
|
|
|
115
|
+
test('anthropicToIR: a system turn folds into the IR system prompt', () => {
|
|
116
|
+
const ir = anthropicToIR({
|
|
117
|
+
model: 'claude-opus-4-6', max_tokens: 10,
|
|
118
|
+
messages: [{ role: 'system', content: 'You are Rem.' }, { role: 'user', content: 'hi' }]
|
|
119
|
+
});
|
|
120
|
+
assert.equal(ir.system, 'You are Rem.');
|
|
121
|
+
assert.deepEqual(ir.messages.map(m => m.role), ['user']);
|
|
122
|
+
});
|
|
123
|
+
|
|
114
124
|
test('healToolPairs: orphan result -> user text, missing result -> placeholder, adjacency kept', () => {
|
|
115
125
|
const healed = healToolPairs([
|
|
116
126
|
{ role: 'user', content: 'start' },
|
|
@@ -1429,13 +1439,15 @@ test('healAnthropicPayload folds a mid-conversation system message into a user t
|
|
|
1429
1439
|
const msgs = r.payload.messages;
|
|
1430
1440
|
assert.ok(!msgs.some(m => m.role === 'system'), 'no system role remains');
|
|
1431
1441
|
assert.ok(alternates(msgs), `roles alternate: ${roles(msgs)}`);
|
|
1432
|
-
assert.deepEqual(roles(msgs), ['user', 'assistant', 'user', 'assistant']);
|
|
1442
|
+
assert.deepEqual(roles(msgs), ['user', 'assistant', 'user', 'assistant', 'user']);
|
|
1433
1443
|
assert.deepEqual(msgs[2].content, [
|
|
1434
1444
|
{ type: 'tool_result', tool_use_id: 'toolu_1', content: 'a.txt' },
|
|
1435
|
-
{ type: 'text', text: '[system] Use British spelling from now on.' }
|
|
1436
|
-
|
|
1437
|
-
], 'a tool_result stays first; with no user turn after it, the last text joins the previous user turn');
|
|
1445
|
+
{ type: 'text', text: '[system] Use British spelling from now on.' }
|
|
1446
|
+
], 'a tool_result stays first');
|
|
1438
1447
|
assert.ok(!texts(msgs[3]).includes('[system]'));
|
|
1448
|
+
assert.deepEqual(msgs[4], { role: 'user', content: [{ type: 'text', text: '[system] Be brief.' }] }, 'a system message after the last assistant turn stays after it');
|
|
1449
|
+
assert.ok(r.notes.includes('folded system messages into user turns'));
|
|
1450
|
+
assert.ok(!r.notes.includes('dropped system content with no text'));
|
|
1439
1451
|
|
|
1440
1452
|
const only = healAnthropicPayload({ model: 'x', max_tokens: 10, messages: [
|
|
1441
1453
|
{ role: 'system', content: 'Answer in French.' },
|
|
@@ -1513,3 +1525,166 @@ test('the placeholder strip reaches a padded object inside an anyOf branch', ()
|
|
|
1513
1525
|
assert.deepEqual(stripGeminiPlaceholders({ env: { A: '1', _placeholder: true } }, schema), { env: { A: '1' } });
|
|
1514
1526
|
assert.deepEqual(stripGeminiPlaceholders({ env: 'plain' }, schema), { env: 'plain' });
|
|
1515
1527
|
});
|
|
1528
|
+
|
|
1529
|
+
// One anyOf branch padded, another declaring a real _placeholder: the key may be the client's own.
|
|
1530
|
+
test('the placeholder strip keeps _placeholder when another anyOf branch declares it', () => {
|
|
1531
|
+
const ir = anthropicToIR({ model: 'x', max_tokens: 10, messages: [{ role: 'user', content: 'go' }], tools: [{ name: 'run', input_schema: {
|
|
1532
|
+
type: 'object', properties: { env: { oneOf: [{ type: 'object' }, { type: 'object', properties: { _placeholder: { type: 'string' }, x: { type: 'string' } } }] } }
|
|
1533
|
+
} }] });
|
|
1534
|
+
const schema = geminiPaddedTools(JSON.parse(JSON.stringify(irToVertexBody(ir, 'gemini-3-pro')))).get('run');
|
|
1535
|
+
assert.equal(schema?.properties?.env?.anyOf?.length, 2, 'fixture: a padded branch and a declaring branch');
|
|
1536
|
+
assert.deepEqual(stripGeminiPlaceholders({ env: { _placeholder: 'keep', x: '1' } }, schema), { env: { _placeholder: 'keep', x: '1' } });
|
|
1537
|
+
const lone = { anyOf: [schema.properties.env.anyOf[0], { type: 'STRING' }] };
|
|
1538
|
+
assert.deepEqual(stripGeminiPlaceholders({ A: '1', _placeholder: true }, lone), { A: '1' }, 'with no declaring branch the padded branch still strips');
|
|
1539
|
+
});
|
|
1540
|
+
|
|
1541
|
+
// The fold keeps each system text between the same turns it sat between, and says what it did.
|
|
1542
|
+
test('healAnthropicPayload keeps a folded system text in place and reports dropped system content', () => {
|
|
1543
|
+
const heal = (messages) => healAnthropicPayload({ model: 'x', max_tokens: 10, messages });
|
|
1544
|
+
const before = heal([
|
|
1545
|
+
{ role: 'user', content: 'first' },
|
|
1546
|
+
{ role: 'system', content: 'Be brief.' },
|
|
1547
|
+
{ role: 'assistant', content: 'ok' },
|
|
1548
|
+
{ role: 'user', content: 'second' }
|
|
1549
|
+
]).payload.messages;
|
|
1550
|
+
assert.deepEqual(before, [
|
|
1551
|
+
{ role: 'user', content: [{ type: 'text', text: 'first' }, { type: 'text', text: '[system] Be brief.' }] },
|
|
1552
|
+
{ role: 'assistant', content: 'ok' },
|
|
1553
|
+
{ role: 'user', content: 'second' }
|
|
1554
|
+
], 'a system message before an assistant turn stays before it');
|
|
1555
|
+
|
|
1556
|
+
const next = heal([
|
|
1557
|
+
{ role: 'user', content: 'first' },
|
|
1558
|
+
{ role: 'assistant', content: 'ok' },
|
|
1559
|
+
{ role: 'system', content: 'Be brief.' },
|
|
1560
|
+
{ role: 'user', content: 'second' }
|
|
1561
|
+
]).payload.messages;
|
|
1562
|
+
assert.deepEqual(next[2], { role: 'user', content: [{ type: 'text', text: '[system] Be brief.' }, { type: 'text', text: 'second' }] }, 'it precedes the user text that followed it');
|
|
1563
|
+
|
|
1564
|
+
const blank = heal([
|
|
1565
|
+
{ role: 'user', content: 'first' },
|
|
1566
|
+
{ role: 'system', content: ' ' },
|
|
1567
|
+
{ role: 'system', content: [{ type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'AA==' } }] },
|
|
1568
|
+
{ role: 'assistant', content: 'ok' }
|
|
1569
|
+
]);
|
|
1570
|
+
assert.equal(blank.changed, true);
|
|
1571
|
+
assert.deepEqual(blank.payload.messages, [{ role: 'user', content: 'first' }, { role: 'assistant', content: 'ok' }]);
|
|
1572
|
+
assert.deepEqual(blank.notes, ['dropped system content with no text'], 'nothing was folded, so the note does not say so');
|
|
1573
|
+
});
|
|
1574
|
+
|
|
1575
|
+
// Only the blank or non-text blocks of a system message go; its text blocks stay, joined by a newline.
|
|
1576
|
+
test('healAnthropicPayload keeps the text of a partly blank system message and joins its blocks with a newline', () => {
|
|
1577
|
+
const heal = (system) => healAnthropicPayload({ model: 'x', max_tokens: 10, messages: [
|
|
1578
|
+
{ role: 'user', content: 'first' },
|
|
1579
|
+
{ role: 'assistant', content: 'ok' },
|
|
1580
|
+
{ role: 'system', content: system },
|
|
1581
|
+
{ role: 'user', content: 'second' }
|
|
1582
|
+
] });
|
|
1583
|
+
const joined = heal([{ type: 'text', text: 'Be brief.' }, { type: 'text', text: 'Use British spelling.' }]);
|
|
1584
|
+
assert.deepEqual(joined.payload.messages[2], { role: 'user', content: [{ type: 'text', text: '[system] Be brief.\nUse British spelling.' }, { type: 'text', text: 'second' }] });
|
|
1585
|
+
assert.deepEqual(joined.notes, ['folded system messages into user turns'], 'nothing was dropped');
|
|
1586
|
+
|
|
1587
|
+
const partial = heal([
|
|
1588
|
+
{ type: 'text', text: 'Be brief.' },
|
|
1589
|
+
{ type: 'text', text: ' ' },
|
|
1590
|
+
{ type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'AA==' } },
|
|
1591
|
+
{ type: 'text', text: 'Use British spelling.' }
|
|
1592
|
+
]);
|
|
1593
|
+
assert.equal(partial.changed, true);
|
|
1594
|
+
assert.deepEqual(partial.payload.messages[2], { role: 'user', content: [{ type: 'text', text: '[system] Be brief.\nUse British spelling.' }, { type: 'text', text: 'second' }] }, 'the text is kept, the blank and image blocks are not');
|
|
1595
|
+
assert.deepEqual(partial.notes, ['folded system messages into user turns', 'dropped system content with no text'], 'the note reports the dropped part');
|
|
1596
|
+
});
|
|
1597
|
+
|
|
1598
|
+
// A compaction item replaces the turns before it, so reasoning still pending there belongs to none after it.
|
|
1599
|
+
test('responsesToIR drops pending reasoning at a compaction item', () => {
|
|
1600
|
+
const ir = responsesToIR({ model: 'x', input: [
|
|
1601
|
+
{ type: 'message', role: 'user', content: 'go' },
|
|
1602
|
+
{ type: 'reasoning', summary: [{ type: 'summary_text', text: 'stale plan' }] },
|
|
1603
|
+
{ type: 'compaction', encrypted_content: encodeCompaction('earlier work') },
|
|
1604
|
+
{ type: 'message', role: 'assistant', content: 'done' }
|
|
1605
|
+
] });
|
|
1606
|
+
const reply = ir.messages.find(m => m.role === 'assistant');
|
|
1607
|
+
assert.equal(reply.content, 'done');
|
|
1608
|
+
assert.equal(reply.reasoning, undefined);
|
|
1609
|
+
|
|
1610
|
+
const opaque = responsesToIR({ model: 'x', input: [
|
|
1611
|
+
{ type: 'message', role: 'user', content: 'go' },
|
|
1612
|
+
{ type: 'reasoning', summary: [{ type: 'summary_text', text: 'stale plan' }] },
|
|
1613
|
+
{ type: 'compaction', encrypted_content: 'opaque-blob' },
|
|
1614
|
+
{ type: 'message', role: 'assistant', content: 'done' }
|
|
1615
|
+
] }, { allowOpaqueCompaction: true });
|
|
1616
|
+
assert.equal(opaque.opaqueCompaction, true, 'fixture: the opaque path');
|
|
1617
|
+
assert.equal(opaque.messages.find(m => m.role === 'assistant').reasoning, undefined);
|
|
1618
|
+
|
|
1619
|
+
// compaction_trigger must be the last item, so nothing after it can take the reasoning.
|
|
1620
|
+
const trigger = responsesToIR({ model: 'x', input: [
|
|
1621
|
+
{ type: 'message', role: 'user', content: 'go' },
|
|
1622
|
+
{ type: 'reasoning', summary: [{ type: 'summary_text', text: 'stale plan' }] },
|
|
1623
|
+
{ type: 'compaction_trigger' }
|
|
1624
|
+
] });
|
|
1625
|
+
assert.equal(trigger.compaction, true);
|
|
1626
|
+
assert.ok(!trigger.messages.some(m => m.reasoning), 'no turn carries the pending reasoning');
|
|
1627
|
+
});
|
|
1628
|
+
|
|
1629
|
+
test('responsesToIR joins a second reasoning item onto an assistant turn that already has reasoning', () => {
|
|
1630
|
+
const ir = responsesToIR({ model: 'x', input: [
|
|
1631
|
+
{ type: 'message', role: 'user', content: 'go' },
|
|
1632
|
+
{ type: 'reasoning', summary: [{ type: 'summary_text', text: 'first' }] },
|
|
1633
|
+
{ type: 'message', role: 'assistant', content: 'looking' },
|
|
1634
|
+
{ type: 'reasoning', summary: [{ type: 'summary_text', text: 'second' }] },
|
|
1635
|
+
{ type: 'function_call', call_id: 'c1', name: 'shell', arguments: '{}' }
|
|
1636
|
+
] });
|
|
1637
|
+
const turns = ir.messages.filter(m => m.role === 'assistant');
|
|
1638
|
+
assert.equal(turns.length, 1, 'fixture: the call joins the assistant turn');
|
|
1639
|
+
assert.equal(turns[0].reasoning, 'first\nsecond');
|
|
1640
|
+
assert.equal(turns[0].toolCalls.length, 1);
|
|
1641
|
+
});
|
|
1642
|
+
|
|
1643
|
+
// A retry or fallback can emit the same IR again for another upstream; each emit gets its own strip.
|
|
1644
|
+
test('a rebuild for another upstream format does not reuse the tool vocabulary of the first', () => {
|
|
1645
|
+
const schema = { type: 'object', properties: { env: { type: 'object' } } };
|
|
1646
|
+
const ir = anthropicToIR({ model: 'x', max_tokens: 10, messages: [{ role: 'user', content: 'go' }], tools: [{ name: 'my-tool', input_schema: schema }, { name: 'run', input_schema: schema }] });
|
|
1647
|
+
const filled = '{"env":{"A":"1","_placeholder":true}}';
|
|
1648
|
+
|
|
1649
|
+
recordGeminiPadding(ir, irToChatBody(ir, 'gpt-5'), false);
|
|
1650
|
+
assert.equal(toolVocabForIR(ir, 'openai-chat').args('my-tool', filled), filled, 'a chat body padded nothing');
|
|
1651
|
+
recordGeminiPadding(ir, JSON.parse(JSON.stringify(irToVertexBody(ir, 'gemini-3-pro'))), true);
|
|
1652
|
+
const vertex = toolVocabForIR(ir, 'vertex');
|
|
1653
|
+
assert.equal(vertex.name('my_tool'), 'my-tool', 'the Vertex name maps back');
|
|
1654
|
+
assert.deepEqual(JSON.parse(vertex.args('my_tool', filled)), { env: { A: '1' } }, 'the Vertex body padded env');
|
|
1655
|
+
|
|
1656
|
+
assert.deepEqual(JSON.parse(vertex.args('run', filled)), { env: { A: '1' } });
|
|
1657
|
+
|
|
1658
|
+
recordGeminiPadding(ir, irToChatBody(ir, 'gpt-5'), false);
|
|
1659
|
+
assert.equal(toolVocabForIR(ir, 'openai-chat').args('run', filled), filled, 'the padding of the Vertex emit is gone');
|
|
1660
|
+
});
|
|
1661
|
+
|
|
1662
|
+
// Two padded upstream tools that one client name maps back to: neither schema may strip for that name.
|
|
1663
|
+
test('the placeholder strip refuses a client name that two padded tools share', () => {
|
|
1664
|
+
const padded = geminiPaddedTools(JSON.parse(JSON.stringify(irToVertexBody(anthropicToIR({ model: 'x', max_tokens: 10, messages: [{ role: 'user', content: 'go' }], tools: [
|
|
1665
|
+
{ name: 'a_b', input_schema: { type: 'object', properties: { env: { type: 'object' }, opts: { type: 'string' } } } },
|
|
1666
|
+
{ name: 'a_c', input_schema: { type: 'object', properties: { opts: { type: 'object' }, env: { type: 'string' } } } }
|
|
1667
|
+
] }), 'gemini-3-pro'))));
|
|
1668
|
+
assert.deepEqual([...padded.keys()], ['a_b', 'a_c'], 'fixture: both tools are padded, on different keys');
|
|
1669
|
+
const strip = withGeminiPlaceholderStrip({ ...NULL_TOOL_VOCAB, name: n => (n === 'a_b' || n === 'a_c' ? 'a.b' : n) }, padded);
|
|
1670
|
+
const args = '{"env":{"_placeholder":true},"opts":{"_placeholder":true}}';
|
|
1671
|
+
assert.equal(strip.args('a.b', args), args, 'the client name is ambiguous: nothing is stripped');
|
|
1672
|
+
assert.deepEqual(JSON.parse(strip.args('a_b', args)), { env: {}, opts: { _placeholder: true } }, 'an upstream name keeps its own schema');
|
|
1673
|
+
assert.deepEqual(JSON.parse(strip.args('a_c', args)), { env: { _placeholder: true }, opts: {} });
|
|
1674
|
+
|
|
1675
|
+
// A real collision: `a.b` and `a_b` sanitize alike for Vertex, so `a.b` goes up under an alias with its own schema.
|
|
1676
|
+
const ir = anthropicToIR({ model: 'x', max_tokens: 10, messages: [{ role: 'user', content: 'go' }], tools: [
|
|
1677
|
+
{ name: 'a.b', input_schema: { type: 'object', properties: { env: { type: 'object' } } } },
|
|
1678
|
+
{ name: 'a_b', input_schema: { type: 'object', properties: { env: { type: 'object', properties: { _placeholder: { type: 'boolean' } } } } } }
|
|
1679
|
+
] });
|
|
1680
|
+
const body = JSON.parse(JSON.stringify(irToVertexBody(ir, 'gemini-3-pro')));
|
|
1681
|
+
recordGeminiPadding(ir, body, true);
|
|
1682
|
+
const alias = [...ir._geminiPadded.keys()];
|
|
1683
|
+
assert.equal(alias.length, 1, 'fixture: only a.b is padded');
|
|
1684
|
+
assert.notEqual(alias[0], 'a_b');
|
|
1685
|
+
const vocab = toolVocabForIR(ir, 'vertex');
|
|
1686
|
+
const filled = '{"env":{"_placeholder":true}}';
|
|
1687
|
+
assert.equal(vocab.name(alias[0]), 'a.b');
|
|
1688
|
+
assert.deepEqual(JSON.parse(vocab.args(alias[0], filled)), { env: {} });
|
|
1689
|
+
assert.equal(vocab.args('a_b', filled), filled, 'a_b declared its own _placeholder');
|
|
1690
|
+
});
|
|
@@ -20,6 +20,8 @@ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
|
20
20
|
|
|
21
21
|
let upstream, upstreamPort, proxy, proxyPort, tmpDir, fakeInterceptor, blindfoldPort, certsDir, cancelExitReap;
|
|
22
22
|
const received = []; // { url, headers, body }
|
|
23
|
+
// A model list read, kept apart so a background read never shows up as the last request a test made.
|
|
24
|
+
const modelReads = [];
|
|
23
25
|
const hangState = { closed: false, slowAborted: false };
|
|
24
26
|
const bigState = { finishedAt: 0 };
|
|
25
27
|
|
|
@@ -70,6 +72,23 @@ const MOCK_ERRORS = {
|
|
|
70
72
|
underscore: [400, 'maximum context length is 131_072 tokens, you requested 140_000 tokens'],
|
|
71
73
|
prefixed: [400, 'prompt is too long: 200000 tokens > 199999 maximum'],
|
|
72
74
|
nonumber: [400, 'The input exceeds the context window of this model.'],
|
|
75
|
+
ksuffix: [400, 'maximum context length is 128k tokens, you requested 140k tokens'],
|
|
76
|
+
msuffix: [400, "This model's maximum context length is 1m tokens. However, your messages resulted in 1.2M tokens."],
|
|
77
|
+
narrownbsp: [400, 'maximum context length is 131\u202f072 tokens, you requested 140\u202f000 tokens'],
|
|
78
|
+
nbsp: [400, 'maximum context length is 131\u00a0072 tokens, you requested 140\u00a0000 tokens'],
|
|
79
|
+
apostrophe: [400, "maximum context length is 131'072 tokens, you requested 140'000 tokens"],
|
|
80
|
+
indian: [400, 'maximum context length is 1,31,072 tokens, you requested 1,40,000 tokens'],
|
|
81
|
+
tokenlimit: [400, 'context_length_exceeded: input is 140000 tokens, over the token limit of 131072'],
|
|
82
|
+
decimal: [400, 'context_length_exceeded: limit of 3.141 seconds per call; input is 140000 tokens'],
|
|
83
|
+
fraction: [400, 'context_length_exceeded: limit 0.125 of the window; input is 140000 tokens'],
|
|
84
|
+
limitof: [400, 'context_length_exceeded: limit of 131072 tokens, input is 140000 tokens'],
|
|
85
|
+
limitbare: [400, 'context_length_exceeded: limit 131072 tokens, input is 140000 tokens'],
|
|
86
|
+
limitnotokens: [400, 'context_length_exceeded: limit of 131072. input is 140000 tokens'],
|
|
87
|
+
hugek: [400, 'maximum context length is 100000000000000000000k tokens, you requested 200000000000000000000k tokens'],
|
|
88
|
+
hugedigits: [400, 'maximum context length is 123456789012345678901 tokens, you requested 223456789012345678901 tokens'],
|
|
89
|
+
zero: [400, 'maximum context length is 0 tokens, you requested 140000 tokens'],
|
|
90
|
+
zerok: [400, 'maximum context length is 0k tokens, you requested 140k tokens'],
|
|
91
|
+
zeroasked: [400, 'maximum context length is 131072 tokens, you requested 0 tokens'],
|
|
73
92
|
rate: [429, 'Rate limit reached on context tokens per minute: maximum context length is 131,072'],
|
|
74
93
|
server: [500, "Internal error: this model's maximum context length is 131,072 tokens"]
|
|
75
94
|
};
|
|
@@ -80,7 +99,8 @@ function startUpstream() {
|
|
|
80
99
|
req.on('data', c => { body += c; });
|
|
81
100
|
req.on('end', () => {
|
|
82
101
|
const json = body ? JSON.parse(body) : {};
|
|
83
|
-
|
|
102
|
+
if (req.method === 'GET' && req.url.split('?')[0].endsWith('/models')) modelReads.push({ url: req.url, headers: req.headers });
|
|
103
|
+
else received.push({ url: req.url, headers: req.headers, body: json, raw: body });
|
|
84
104
|
const lastUser = JSON.stringify(json.messages?.at(-1) ?? json.contents?.at(-1) ?? '');
|
|
85
105
|
|
|
86
106
|
// A stand-in intact: claude/* is a Claude Code account, the rest is not.
|
|
@@ -91,6 +111,14 @@ function startUpstream() {
|
|
|
91
111
|
}
|
|
92
112
|
// A provider list with real windows: a 1M model that also names the point to compress at,
|
|
93
113
|
// and a smaller one. A model the list says nothing of keeps the catalog template.
|
|
114
|
+
// Two accounts on one baseURL whose lists name different output limits, read a little slowly.
|
|
115
|
+
if (req.method === 'GET' && req.url.startsWith('/chat/v1/models') && /^Bearer sk-cap-[ab]$/.test(req.headers.authorization || '')) {
|
|
116
|
+
const output = req.headers.authorization === 'Bearer sk-cap-a' ? 8192 : 2048;
|
|
117
|
+
return setTimeout(() => {
|
|
118
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
119
|
+
res.end(JSON.stringify({ data: [{ id: 'up-capped', context_length: 200000, max_output_tokens: output }] }));
|
|
120
|
+
}, 100);
|
|
121
|
+
}
|
|
94
122
|
if (req.method === 'GET' && req.url.startsWith('/chat/v1/models')) {
|
|
95
123
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
96
124
|
return res.end(JSON.stringify({ data: [
|
|
@@ -294,7 +322,11 @@ before(async () => {
|
|
|
294
322
|
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' } },
|
|
295
323
|
agyph: { name: 'Mock agy to AG via chat', mode: 'convert', tool: 'agy', outFormat: 'openai-chat', baseURL: `${base}/chat/v1`, apiKey: 'sk-secret-agy', defaultModels: { main: 'ag/mock-flash' } },
|
|
296
324
|
cappedtwin: { name: 'Mock Capped Twin', 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' } },
|
|
325
|
+
capa: { name: 'Mock Capped A', mode: 'convert', inFormat: 'auto', outFormat: 'openai-chat', baseURL: `${base}/chat/v1`, apiKey: 'sk-cap-a', defaultModels: { opus: 'up-capped', sonnet: 'up-sonnet', haiku: 'up-haiku', fable: 'up-fable' } },
|
|
326
|
+
capb: { name: 'Mock Capped B', mode: 'convert', inFormat: 'auto', outFormat: 'openai-chat', baseURL: `${base}/chat/v1`, apiKey: 'sk-cap-b', defaultModels: { opus: 'up-capped', sonnet: 'up-sonnet', haiku: 'up-haiku', fable: 'up-fable' } },
|
|
297
327
|
cappedold: { name: 'Mock Capped Old', 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' } },
|
|
328
|
+
ovr: { name: 'Mock Override', mode: 'convert', inFormat: 'auto', outFormat: 'openai-chat', baseURL: `${base}/elsewhere/v1`, endpoints: { 'openai-chat': `${base}/chat/v1/chat/completions` }, apiKey: 'sk-secret-ovr', defaultModels: { opus: 'up-capped', sonnet: 'up-sonnet', haiku: 'up-haiku', fable: 'up-fable' } },
|
|
329
|
+
ovrplain: { name: 'Mock No Override', mode: 'convert', inFormat: 'auto', outFormat: 'openai-chat', baseURL: `${base}/chat/v1`, apiKey: 'sk-secret-ovrplain', defaultModels: { opus: 'up-capped', sonnet: 'up-sonnet', haiku: 'up-haiku', fable: 'up-fable' } },
|
|
298
330
|
ant: { name: 'Mock Anthropic', mode: 'direct', inFormat: 'auto', outFormat: 'anthropic', baseURL: `${base}/ant`, apiKey: 'sk-secret-ant', defaultModels: models }
|
|
299
331
|
}
|
|
300
332
|
};
|
|
@@ -682,6 +714,57 @@ test('max_tokens is clamped only from a fresh cache entry of the same profile',
|
|
|
682
714
|
assert.equal(await ask('capped'), 8192);
|
|
683
715
|
});
|
|
684
716
|
|
|
717
|
+
// No /v1/models call for these profiles: the first request reads the list in the background, the next is clamped.
|
|
718
|
+
test('a profile with no fresh model list is clamped from the request after a background read', async () => {
|
|
719
|
+
const ask = async (profile, tag) => {
|
|
720
|
+
const res = await post('/v1/messages', { model: 'claude-opus-4-6', max_tokens: 64000, messages: [{ role: 'user', content: tag }] }, { 'x-llm-profile': profile });
|
|
721
|
+
assert.equal(res.status, 200, tag);
|
|
722
|
+
await res.text();
|
|
723
|
+
const sent = received.findLast(r => JSON.stringify(r.body.messages || '').includes(tag));
|
|
724
|
+
assert.equal(sent.body.model, 'up-capped', tag);
|
|
725
|
+
return sent.body.max_tokens;
|
|
726
|
+
};
|
|
727
|
+
const reads = (key) => modelReads.filter(r => r.headers.authorization === `Bearer ${key}`).length;
|
|
728
|
+
const cached = (profile) => {
|
|
729
|
+
try { return JSON.parse(fs.readFileSync(path.join(tmpDir, 'model-windows.json'), 'utf8'))[`${profile}@http://127.0.0.1:${upstreamPort}/chat/v1`]; } catch { return null; }
|
|
730
|
+
};
|
|
731
|
+
|
|
732
|
+
assert.deepEqual(await Promise.all([ask('capa', 'cap-a-1'), ask('capa', 'cap-a-2')]), [64000, 64000], 'the first requests do not wait for the list');
|
|
733
|
+
assert.ok(await until(() => cached('capa')), 'the list of capa was read in the background');
|
|
734
|
+
assert.equal(await ask('capa', 'cap-a-3'), 8192);
|
|
735
|
+
assert.equal(reads('sk-cap-a'), 1, 'one read for the profile, however many requests found no entry');
|
|
736
|
+
|
|
737
|
+
assert.equal(await ask('capb', 'cap-b-1'), 64000, 'capa\'s entry on the same baseURL is not capb\'s');
|
|
738
|
+
assert.ok(await until(() => cached('capb')), 'the list of capb was read in the background');
|
|
739
|
+
assert.equal(await ask('capb', 'cap-b-2'), 2048);
|
|
740
|
+
assert.equal(await ask('capa', 'cap-a-4'), 8192, 'each profile keeps its own limit');
|
|
741
|
+
});
|
|
742
|
+
|
|
743
|
+
// The requests of an override profile never reach its baseURL, so neither may its key.
|
|
744
|
+
test('a profile that routes through an endpoint override reads no model list; a plain profile still does', async () => {
|
|
745
|
+
const ask = async (profile, tag) => {
|
|
746
|
+
const res = await post('/v1/messages', { model: 'claude-opus-4-6', max_tokens: 64000, messages: [{ role: 'user', content: tag }] }, { 'x-llm-profile': profile });
|
|
747
|
+
assert.equal(res.status, 200, tag);
|
|
748
|
+
await res.text();
|
|
749
|
+
return received.findLast(r => JSON.stringify(r.body.messages || '').includes(tag));
|
|
750
|
+
};
|
|
751
|
+
const reads = (key) => modelReads.filter(r => r.headers.authorization === `Bearer ${key}` || r.headers['x-api-key'] === key);
|
|
752
|
+
|
|
753
|
+
const sent = await ask('ovr', 'ovr-1');
|
|
754
|
+
assert.equal(sent.url, '/chat/v1/chat/completions', 'the request went to the override');
|
|
755
|
+
assert.equal(sent.body.max_tokens, 64000);
|
|
756
|
+
await ask('ovrplain', 'ovrplain-1');
|
|
757
|
+
assert.ok(await until(() => reads('sk-secret-ovrplain').length === 1), 'the plain profile reads its list in the background');
|
|
758
|
+
await ask('ovr', 'ovr-2');
|
|
759
|
+
await new Promise(r => setTimeout(r, 200));
|
|
760
|
+
assert.deepEqual(reads('sk-secret-ovr'), [], 'the override profile sent its key to no /models');
|
|
761
|
+
assert.equal((await ask('ovr', 'ovr-3')).body.max_tokens, 64000, 'nothing clamps an override profile');
|
|
762
|
+
const listed = await fetch(url('/v1/models'), { headers: { 'x-llm-profile': 'ovr' } });
|
|
763
|
+
assert.equal(listed.status, 200);
|
|
764
|
+
await listed.text();
|
|
765
|
+
assert.deepEqual(reads('sk-secret-ovr'), [], 'the /v1/models catalog sent the override key to no /models');
|
|
766
|
+
});
|
|
767
|
+
|
|
685
768
|
// Gemini gets `_placeholder` on an empty nested object; the client never declared it, so it must not come back.
|
|
686
769
|
test('A _placeholder the gateway added for Gemini is stripped from tool-call arguments, stream and non-stream', async () => {
|
|
687
770
|
const tools = [{ type: 'function', name: 'run', parameters: { type: 'object', properties: {
|
|
@@ -795,6 +878,19 @@ test('Context overflow numbers with thousands separators reach Claude Code whole
|
|
|
795
878
|
assert.equal(error.type, 'invalid_request_error', key);
|
|
796
879
|
assert.equal(error.message, `prompt is too long: 140000 tokens > 131072 maximum (${MOCK_ERRORS[key][1]})`, key);
|
|
797
880
|
}
|
|
881
|
+
const whole = async (key, asked, max) => assert.equal((await ask(key)).error.message, `prompt is too long: ${asked} tokens > ${max} maximum (${MOCK_ERRORS[key][1]})`, key);
|
|
882
|
+
await whole('ksuffix', 140000, 128000);
|
|
883
|
+
await whole('msuffix', 1200000, 1000000);
|
|
884
|
+
for (const key of ['narrownbsp', 'nbsp', 'apostrophe', 'indian', 'tokenlimit', 'limitof', 'limitbare']) await whole(key, 140000, 131072);
|
|
885
|
+
for (const key of ['decimal', 'fraction', 'limitnotokens']) {
|
|
886
|
+
assert.equal((await ask(key)).error.message, `prompt is too long: ${MOCK_ERRORS[key][1]}`, `${key}: a number after 'limit' that is no token count is ignored`);
|
|
887
|
+
}
|
|
888
|
+
for (const key of ['hugek', 'hugedigits']) {
|
|
889
|
+
assert.equal((await ask(key)).error.message, `prompt is too long: ${MOCK_ERRORS[key][1]}`, `${key}: a count past the safe integer range is no token count`);
|
|
890
|
+
}
|
|
891
|
+
for (const key of ['zero', 'zerok', 'zeroasked']) {
|
|
892
|
+
assert.equal((await ask(key)).error.message, `prompt is too long: ${MOCK_ERRORS[key][1]}`, `${key}: a count of zero is no token count`);
|
|
893
|
+
}
|
|
798
894
|
assert.deepEqual(await ask('prefixed'), { status: 400, error: { type: 'invalid_request_error', message: MOCK_ERRORS.prefixed[1] } });
|
|
799
895
|
assert.deepEqual(await ask('nonumber'), { status: 400, error: { type: 'invalid_request_error', message: `prompt is too long: ${MOCK_ERRORS.nonumber[1]}` } });
|
|
800
896
|
assert.deepEqual(await ask('rate'), { status: 429, error: { type: 'rate_limit_error', message: MOCK_ERRORS.rate[1] } });
|
|
@@ -811,6 +907,11 @@ test('Codex WS: an upstream context overflow fails the turn with context_length_
|
|
|
811
907
|
ws.socket.write(clientFrame(1, JSON.stringify({ type: 'response.create', model: 'main', input: 'MOCK_ERR:rate over ws' })));
|
|
812
908
|
assert.ok(await until(() => ws.messages.filter(m => m.type === 'response.failed').length === 2), 'no second response.failed');
|
|
813
909
|
assert.equal(ws.messages.filter(m => m.type === 'response.failed')[1].response.error.code, 'rate_limit_exceeded');
|
|
910
|
+
ws.socket.write(clientFrame(1, JSON.stringify({ type: 'response.create', model: 'main', input: 'MOCK_ERR:server over ws' })));
|
|
911
|
+
assert.ok(await until(() => ws.messages.filter(m => m.type === 'response.failed').length === 3), 'no third response.failed');
|
|
912
|
+
const server = ws.messages.filter(m => m.type === 'response.failed')[2].response.error;
|
|
913
|
+
assert.equal(server.code, 'server_error', 'a 500 that names the context length is a server error, not an overflow');
|
|
914
|
+
assert.equal(server.message, MOCK_ERRORS.server[1]);
|
|
814
915
|
ws.socket.destroy();
|
|
815
916
|
});
|
|
816
917
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { test } from 'node:test';
|
|
1
|
+
import { test, after } from 'node:test';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
3
|
import fs from 'node:fs';
|
|
4
4
|
import os from 'node:os';
|
|
@@ -6,10 +6,15 @@ import path from 'node:path';
|
|
|
6
6
|
import http from 'node:http';
|
|
7
7
|
import { spawn } from 'node:child_process';
|
|
8
8
|
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import { pidsNaming, reapDir } from './helpers.mjs';
|
|
9
10
|
|
|
10
11
|
const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
|
11
12
|
const listen = server => new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
|
12
13
|
|
|
14
|
+
// Every temp directory goes once more after the file, so a setup that fails early leaves none behind.
|
|
15
|
+
const made = [];
|
|
16
|
+
after(() => { for (const d of made) fs.rmSync(d, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); });
|
|
17
|
+
|
|
13
18
|
async function until(check, timeoutMs = 6000) {
|
|
14
19
|
const deadline = Date.now() + timeoutMs;
|
|
15
20
|
while (Date.now() < deadline) {
|
|
@@ -21,12 +26,14 @@ async function until(check, timeoutMs = 6000) {
|
|
|
21
26
|
|
|
22
27
|
async function fixture(t) {
|
|
23
28
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'llmsw-goalcheck-'));
|
|
29
|
+
made.push(dir);
|
|
24
30
|
const adminToken = 'admin-secret-token-123';
|
|
25
31
|
fs.writeFileSync(path.join(dir, 'admin.token'), adminToken, { mode: 0o600 });
|
|
26
32
|
|
|
27
33
|
let nativeVerdict = { ok: true, reason: 'looks complete' };
|
|
28
34
|
let jevScores = { complete: 0.14, evidence: 0.84, unfinished: 0.63 };
|
|
29
35
|
let jevStatus = 200;
|
|
36
|
+
let nativeDrops = false;
|
|
30
37
|
const chatRequests = [];
|
|
31
38
|
|
|
32
39
|
// Mock native upstream (Anthropic format)
|
|
@@ -38,6 +45,7 @@ async function fixture(t) {
|
|
|
38
45
|
res.setHeader('content-type', 'application/json');
|
|
39
46
|
return res.end('{"data":[]}');
|
|
40
47
|
}
|
|
48
|
+
if (nativeDrops) return req.socket.destroy();
|
|
41
49
|
const body = JSON.parse(raw);
|
|
42
50
|
const isStream = Boolean(body.stream);
|
|
43
51
|
const respText = JSON.stringify(nativeVerdict);
|
|
@@ -102,14 +110,21 @@ async function fixture(t) {
|
|
|
102
110
|
});
|
|
103
111
|
await listen(jevUp);
|
|
104
112
|
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
113
|
+
const freePort = async () => {
|
|
114
|
+
const reserve = http.createServer();
|
|
115
|
+
await listen(reserve);
|
|
116
|
+
const p = reserve.address().port;
|
|
117
|
+
await new Promise(r => reserve.close(r));
|
|
118
|
+
return p;
|
|
119
|
+
};
|
|
120
|
+
const port = await freePort();
|
|
121
|
+
// Without its own port the interceptor the proxy starts takes the default 3457.
|
|
122
|
+
const blindfoldPort = await freePort();
|
|
109
123
|
|
|
110
124
|
const configPath = path.join(dir, 'config.json');
|
|
111
125
|
fs.writeFileSync(configPath, JSON.stringify({
|
|
112
126
|
port,
|
|
127
|
+
blindfold: { port: blindfoldPort },
|
|
113
128
|
activeProfile: 'p1',
|
|
114
129
|
activeProfiles: { claude: 'p1' },
|
|
115
130
|
profiles: {
|
|
@@ -171,7 +186,7 @@ async function fixture(t) {
|
|
|
171
186
|
new Promise(r => nativeUp.close(r)),
|
|
172
187
|
new Promise(r => jevUp.close(r))
|
|
173
188
|
]);
|
|
174
|
-
|
|
189
|
+
await reapDir(dir);
|
|
175
190
|
});
|
|
176
191
|
|
|
177
192
|
await until(async () => (await fetch(`http://127.0.0.1:${port}/health`).catch(() => null))?.ok);
|
|
@@ -204,6 +219,7 @@ async function fixture(t) {
|
|
|
204
219
|
setNativeVerdict: v => { nativeVerdict = v; },
|
|
205
220
|
setJevScores: s => { jevScores = s; },
|
|
206
221
|
setJevStatus: st => { jevStatus = st; },
|
|
222
|
+
setNativeDrops: v => { nativeDrops = v; },
|
|
207
223
|
jevRequests,
|
|
208
224
|
chatRequests,
|
|
209
225
|
logs: () => logs
|
|
@@ -358,3 +374,25 @@ test('goalcheck e2e: an openai-chat evaluator call that a text-only model refuse
|
|
|
358
374
|
assert.equal(f.chatRequests.length, 2, 'one refusal, one retry');
|
|
359
375
|
assert.ok(JSON.stringify(f.chatRequests[1]).includes('[image omitted'), 'the retry carries text in place of the image');
|
|
360
376
|
});
|
|
377
|
+
|
|
378
|
+
// The gate fails closed: an evaluator call that throws never lets the goal through, whatever Jev says.
|
|
379
|
+
test('goalcheck e2e: a native evaluator call that throws fails closed on both upstream routes', async t => {
|
|
380
|
+
const f = await fixture(t);
|
|
381
|
+
await f.sendAdmin('/api/goal-check', 'POST', { enabled: true });
|
|
382
|
+
f.setJevScores({ complete: 0.95, evidence: 0.90, unfinished: 0.05 });
|
|
383
|
+
f.setNativeVerdict({ ok: true, reason: 'claimed done' });
|
|
384
|
+
f.setNativeDrops(true);
|
|
385
|
+
for (const profile of ['p1', 'chat']) {
|
|
386
|
+
const res = await f.sendClaude(evaluatorRequest, false, { 'x-llm-profile': profile });
|
|
387
|
+
assert.equal(res.status, 200, profile);
|
|
388
|
+
const verdict = JSON.parse((await res.json()).content[0].text);
|
|
389
|
+
assert.equal(verdict.ok, false, profile);
|
|
390
|
+
assert.match(verdict.reason, /Native evaluator unavailable/, profile);
|
|
391
|
+
}
|
|
392
|
+
const logs = (await (await f.sendAdmin('/api/goal-check/logs')).json()).logs;
|
|
393
|
+
assert.deepEqual(logs.slice(0, 2).map(l => l.verdictType), ['Unavailable', 'Unavailable']);
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
test('goalcheck e2e: the fixtures leave no process behind', () => {
|
|
397
|
+
assert.deepEqual(made.flatMap(pidsNaming), []);
|
|
398
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Actual proxy -> HTTP mock upstream; no external provider or official account is used.
|
|
2
|
-
import { test } from 'node:test';
|
|
2
|
+
import { test, after } from 'node:test';
|
|
3
3
|
import assert from 'node:assert/strict';
|
|
4
4
|
import fs from 'node:fs';
|
|
5
5
|
import os from 'node:os';
|
|
@@ -28,8 +28,13 @@ function history() {
|
|
|
28
28
|
return messages;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
// Every temp directory goes once more after the file, so a setup that fails early leaves none behind.
|
|
32
|
+
const made = [];
|
|
33
|
+
after(() => { for (const d of made) fs.rmSync(d, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); });
|
|
34
|
+
|
|
31
35
|
async function fixture(t, { model = '', codex = false } = {}) {
|
|
32
36
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'llmsw-idle-e2e-'));
|
|
37
|
+
made.push(dir);
|
|
33
38
|
const received = []; const pendingSummaries = [];
|
|
34
39
|
const upstream = http.createServer((req, res) => {
|
|
35
40
|
let raw = '';
|
package/tests/lifecycle.test.mjs
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// gateway port, `switch on` saved config.json before it refused, and only the CLI ever started the
|
|
5
5
|
// interceptor, so the dashboard, `switch port` and a service start left HTTPS_PROXY pointing at nothing.
|
|
6
6
|
|
|
7
|
-
import test from 'node:test';
|
|
7
|
+
import test, { after } from 'node:test';
|
|
8
8
|
import assert from 'node:assert/strict';
|
|
9
9
|
import fs from 'node:fs';
|
|
10
10
|
import os from 'node:os';
|
|
@@ -42,8 +42,12 @@ const freePort = () => new Promise(r => {
|
|
|
42
42
|
const s = net.createServer().listen(0, '127.0.0.1', () => { const { port } = s.address(); s.close(() => r(port)); });
|
|
43
43
|
});
|
|
44
44
|
|
|
45
|
+
// Every temp directory goes once more after the file, so a setup that fails early leaves none behind.
|
|
46
|
+
const made = [];
|
|
47
|
+
after(() => { for (const d of made) fs.rmSync(d, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); });
|
|
45
48
|
function makeWorkspace({ certs = false } = {}) {
|
|
46
49
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'llmsw-life-'));
|
|
50
|
+
made.push(dir);
|
|
47
51
|
const certDir = path.join(dir, 'certs');
|
|
48
52
|
if (certs) execFileSync('bash', [path.join(ROOT, 'blindfold', 'make-certs.sh'), 'chatgpt.com', certDir], { stdio: 'ignore' });
|
|
49
53
|
return { dir, certDir, cfgPath: path.join(dir, 'config.json'), claudeDir: path.join(dir, 'claude') };
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { compileCacheIsNew, removeQuietly, isMainModule } from '../scripts/run-tests.mjs';
|
|
8
|
+
|
|
9
|
+
const tmpDirs = [];
|
|
10
|
+
test.after(() => { for (const d of tmpDirs) fs.rmSync(d, { recursive: true, force: true }); });
|
|
11
|
+
const tmpDir = () => {
|
|
12
|
+
const d = fs.mkdtempSync(path.join(os.tmpdir(), 'lsw-runtests-'));
|
|
13
|
+
tmpDirs.push(d);
|
|
14
|
+
return d;
|
|
15
|
+
};
|
|
16
|
+
const npm = { npm_lifecycle_event: 'test' };
|
|
17
|
+
|
|
18
|
+
// npm writes its compile cache only when it exits, so the cache it made for this run has no file yet.
|
|
19
|
+
test('npm test removes only a compile cache that npm made for this run', () => {
|
|
20
|
+
const made = path.join(tmpDir(), 'node-compile-cache');
|
|
21
|
+
fs.mkdirSync(path.join(made, 'v26-x64-1000'), { recursive: true });
|
|
22
|
+
assert.equal(compileCacheIsNew(made, npm), true, 'empty: made at the start of this run');
|
|
23
|
+
|
|
24
|
+
const kept = path.join(tmpDir(), 'node-compile-cache');
|
|
25
|
+
fs.mkdirSync(path.join(kept, 'v26-x64-1000'), { recursive: true });
|
|
26
|
+
fs.writeFileSync(path.join(kept, 'v26-x64-1000', '0c92995d'), 'x');
|
|
27
|
+
assert.equal(compileCacheIsNew(kept, npm), false, 'a file in it: it was there before this run, however new');
|
|
28
|
+
|
|
29
|
+
assert.equal(compileCacheIsNew(made, {}), false, 'run without npm: the cache is not ours');
|
|
30
|
+
assert.equal(compileCacheIsNew(path.join(tmpDir(), 'none'), npm), false, 'no cache, nothing to remove');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('a failed cleanup never throws, so the exit status of the tests stays', () => {
|
|
34
|
+
const dir = tmpDir();
|
|
35
|
+
assert.doesNotThrow(() => removeQuietly(dir, () => { throw Object.assign(new Error('busy'), { code: 'EBUSY' }); }));
|
|
36
|
+
removeQuietly(dir);
|
|
37
|
+
assert.equal(fs.existsSync(dir), false);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// node resolves a symlinked script to its real path, so the guard must compare real paths or the tests never run.
|
|
41
|
+
test('the main-module guard sees the script through a symlink, and a false guard only returns false', () => {
|
|
42
|
+
const script = fileURLToPath(new URL('../scripts/run-tests.mjs', import.meta.url));
|
|
43
|
+
const link = path.join(tmpDir(), 'run-tests-link.mjs');
|
|
44
|
+
fs.symlinkSync(script, link);
|
|
45
|
+
assert.equal(isMainModule(script), true);
|
|
46
|
+
assert.equal(isMainModule(link), true, 'an absolute symlinked path is the script');
|
|
47
|
+
assert.equal(isMainModule(fileURLToPath(import.meta.url)), false, 'another file is not');
|
|
48
|
+
assert.equal(isMainModule(path.join(tmpDir(), 'missing.mjs')), false, 'a path that does not exist is not');
|
|
49
|
+
assert.equal(isMainModule(undefined), false);
|
|
50
|
+
});
|
package/tests/shim.test.mjs
CHANGED
|
@@ -541,19 +541,22 @@ async function agyListener(kind) {
|
|
|
541
541
|
async function runAgyWindowsShim(port) {
|
|
542
542
|
const shimDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agyshim-'));
|
|
543
543
|
const fakeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agyfake-'));
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
544
|
+
try {
|
|
545
|
+
fs.writeFileSync(path.join(fakeDir, 'agy.cmd'), `@echo off\r\necho CCU=[%CLOUD_CODE_URL%]\r\nnode "${AGY_REQUEST}"\r\n`);
|
|
546
|
+
for (const f of fs.readdirSync(STATE)) if (fs.statSync(path.join(STATE, f)).isFile()) fs.rmSync(path.join(STATE, f), { force: true });
|
|
547
|
+
fs.writeFileSync(path.join(STATE, 'active.flag'), 'active');
|
|
548
|
+
fs.writeFileSync(path.join(STATE, 'env-agy.cmd'), `SET "CLOUD_CODE_URL=http://127.0.0.1:${port}"\r\n`);
|
|
549
|
+
const shim = path.join(shimDir, 'agy.cmd');
|
|
550
|
+
fs.writeFileSync(shim, renderShim('agy', 'win32'), 'utf8');
|
|
551
|
+
const started = Date.now();
|
|
552
|
+
const child = await runAsync('cmd.exe', ['/d', '/c', shim], {
|
|
553
|
+
env: { ...process.env, LLM_SWITCHER_CONFIG: AGY_CONFIG, PATH: [fakeDir, shimDir, path.join(process.env.SystemRoot, 'System32'), path.dirname(process.execPath)].join(path.delimiter) }
|
|
554
|
+
});
|
|
555
|
+
return { out: child.stdout || '', err: child.stderr || '', ms: Date.now() - started };
|
|
556
|
+
} finally {
|
|
557
|
+
fs.rmSync(shimDir, { recursive: true, force: true });
|
|
558
|
+
fs.rmSync(fakeDir, { recursive: true, force: true });
|
|
559
|
+
}
|
|
557
560
|
}
|
|
558
561
|
|
|
559
562
|
for (const [kind, routed] of [['forged', false], ['silent', false], ['real', true]]) {
|