llm-switcher 1.3.7 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -0
- package/README.md +25 -17
- package/README.vi.md +24 -13
- package/config.example.json +0 -11
- package/docs/TOKEN-OPTIMIZER-INTEROP.md +110 -110
- package/docs/response-matrix.json +1130 -1130
- package/docs/tool-vocabulary.md +236 -0
- package/formats.mjs +277 -55
- package/package.json +1 -1
- package/proxy.mjs +55 -19
- package/service.mjs +6 -3
- package/skills/llm-switcher/SKILL.md +93 -93
- package/state.mjs +23 -91
- package/switch +0 -0
- package/switch.cmd +2 -2
- package/switch.mjs +2 -2
- package/tests/dashboard.e2e.test.mjs +10 -13
- package/tests/formats.test.mjs +98 -1
- package/tests/gateway.e2e.test.mjs +23 -9
- package/tests/helpers.mjs +24 -24
- package/tests/live-optimizer-interop.mjs +205 -205
- package/tests/service.test.mjs +6 -4
- package/tests/state.test.mjs +6 -7
- package/tests/tool-repair.test.mjs +280 -0
- package/tests/ui.test.mjs +22 -15
- package/tests/update.test.mjs +16 -0
- package/tests/windows.test.mjs +124 -0
- package/toolvocab.mjs +272 -0
- package/ui.html +32 -76
- package/update.mjs +13 -5
- package/windows.mjs +141 -0
|
@@ -480,20 +480,19 @@ describe('dashboard, driven in a real browser', { skip: skipReason() }, () => {
|
|
|
480
480
|
assert.equal(models.haiku, 'my-own-model');
|
|
481
481
|
});
|
|
482
482
|
|
|
483
|
-
it('
|
|
483
|
+
it('shows the window each listed model has, and offers no window switch', async () => {
|
|
484
484
|
await act(() => editProfile('intact-claude'));
|
|
485
485
|
await act(() => tab('models'));
|
|
486
486
|
await page.waitFor(`document.querySelector('#models-status')?.dataset.state === 'ready'`, 'the model list');
|
|
487
487
|
await page.type('#p-sonnet', 'up-alpha'); // window 200000
|
|
488
|
-
assert.
|
|
489
|
-
assert.match(await page.text('#p-sonnet-1m-note'), /200K.*under 1M/);
|
|
488
|
+
assert.match(await page.text('#p-sonnet-note'), /200K context/);
|
|
490
489
|
await page.type('#p-opus', 'up-beta'); // window 1000000
|
|
491
|
-
assert.
|
|
492
|
-
await page.
|
|
493
|
-
assert.equal(await page.evaluate(`document.getElementById('p-opus-1m').checked`), true);
|
|
490
|
+
assert.match(await page.text('#p-opus-note'), /1M context/);
|
|
491
|
+
assert.equal(await page.evaluate(`document.querySelectorAll('#slot-fields input[type=checkbox]').length`), 0, 'no window switch is offered');
|
|
494
492
|
await act(() => saveForm());
|
|
495
|
-
|
|
496
|
-
assert.
|
|
493
|
+
const saved = profiles()['intact-claude'];
|
|
494
|
+
assert.equal(saved.defaultModels.opus, 'up-beta');
|
|
495
|
+
assert.equal(saved.model1M, undefined, 'and nothing window-shaped is saved');
|
|
497
496
|
});
|
|
498
497
|
|
|
499
498
|
it('says why the list did not load and loads it on Retry', async () => {
|
|
@@ -508,7 +507,7 @@ describe('dashboard, driven in a real browser', { skip: skipReason() }, () => {
|
|
|
508
507
|
assert.equal(await modelsStatus(), '3 models');
|
|
509
508
|
});
|
|
510
509
|
|
|
511
|
-
it('clears a model slot
|
|
510
|
+
it('clears a model slot the person emptied, and drops the retired 1M flags', async () => {
|
|
512
511
|
const cfg = fx.config();
|
|
513
512
|
cfg.profiles['intact-claude'].model1M = { opus: true };
|
|
514
513
|
fx.writeConfig(cfg);
|
|
@@ -517,14 +516,12 @@ describe('dashboard, driven in a real browser', { skip: skipReason() }, () => {
|
|
|
517
516
|
await act(() => tab('models'));
|
|
518
517
|
await page.waitFor(`document.querySelector('#models-status')?.dataset.state === 'ready'`, 'the model list');
|
|
519
518
|
await page.type('#p-sonnet', '');
|
|
520
|
-
await page.key('Escape'); // the list of suggestions covers the
|
|
521
|
-
await page.click('label:has(#p-opus-1m)');
|
|
522
|
-
assert.equal(await page.evaluate(`document.getElementById('p-opus-1m').checked`), false);
|
|
519
|
+
await page.key('Escape'); // the list of suggestions covers the row below until it closes
|
|
523
520
|
await act(() => saveForm());
|
|
524
521
|
const saved = profiles()['intact-claude'];
|
|
525
522
|
assert.equal(saved.defaultModels.sonnet, undefined, 'the emptied slot came back');
|
|
526
523
|
assert.equal(saved.defaultModels.opus, 'up-opus');
|
|
527
|
-
assert.
|
|
524
|
+
assert.equal(saved.model1M, undefined, 'the retired flags are not written back');
|
|
528
525
|
});
|
|
529
526
|
|
|
530
527
|
it('shows the slots of the tool that the profile serves', async () => {
|
package/tests/formats.test.mjs
CHANGED
|
@@ -11,7 +11,8 @@ import {
|
|
|
11
11
|
isAntigravityModel, smartUsage,
|
|
12
12
|
createChatStream, buildChatMessage, chatFinish, anthropicStopReason,
|
|
13
13
|
smartText, smartReasoning, sanitizeJsonSchema, normalizeUpstream, buildVertexMessage,
|
|
14
|
-
createCodeAssistStream
|
|
14
|
+
createCodeAssistStream,
|
|
15
|
+
budgetToEffort, effortToBudget, normalizeEffort, capEffort, EFFORT_LEVELS
|
|
15
16
|
} from '../formats.mjs';
|
|
16
17
|
import { assertValidAnthropicEvents } from './helpers.mjs';
|
|
17
18
|
|
|
@@ -913,3 +914,99 @@ test('Code Assist stream: a chunk is wrapped with a trace id, an error is sent b
|
|
|
913
914
|
assert.equal(err.error.status, 'INTERNAL');
|
|
914
915
|
assert.equal(err.error.message, 'upstream broke');
|
|
915
916
|
});
|
|
917
|
+
|
|
918
|
+
// ---- thinking: one ladder, both directions ----
|
|
919
|
+
//
|
|
920
|
+
// Codex sends reasoning.effort, an OpenAI strict upstream names four levels, Anthropic speaks
|
|
921
|
+
// budget_tokens or output_config.effort, and Gemini speaks thinkingBudget with a ceiling. The same
|
|
922
|
+
// level has to survive each hop, and no hop may quietly ask for less than the client did.
|
|
923
|
+
|
|
924
|
+
test('the effort ladder is ordered, and a level round-trips through its budget', () => {
|
|
925
|
+
assert.deepEqual([...EFFORT_LEVELS], ['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra']);
|
|
926
|
+
for (const level of ['minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra']) {
|
|
927
|
+
assert.equal(budgetToEffort(effortToBudget(level)), level, `${level} must survive its own budget`);
|
|
928
|
+
}
|
|
929
|
+
// The budgets the gateway has always read a request as. Claude Code sends a budget on every
|
|
930
|
+
// request, so a boundary that moved would hand it a different level.
|
|
931
|
+
assert.equal(effortToBudget('low'), 1024);
|
|
932
|
+
assert.equal(effortToBudget('medium'), 2048);
|
|
933
|
+
assert.equal(effortToBudget('high'), 8000);
|
|
934
|
+
assert.equal(budgetToEffort(1024), 'low');
|
|
935
|
+
assert.equal(budgetToEffort(2048), 'medium');
|
|
936
|
+
assert.equal(budgetToEffort(3000), 'medium');
|
|
937
|
+
assert.equal(budgetToEffort(10000), 'high');
|
|
938
|
+
assert.equal(budgetToEffort(31999), 'xhigh', 'a 32K thinking budget is above high');
|
|
939
|
+
assert.equal(budgetToEffort(0), 'minimal', 'and a budget below the floor is still a level');
|
|
940
|
+
});
|
|
941
|
+
|
|
942
|
+
test('an unknown or missing effort is medium, and none stays none', () => {
|
|
943
|
+
assert.equal(normalizeEffort(undefined), 'medium');
|
|
944
|
+
assert.equal(normalizeEffort(''), 'medium');
|
|
945
|
+
assert.equal(normalizeEffort('Extreme'), 'medium');
|
|
946
|
+
assert.equal(normalizeEffort('XHIGH'), 'xhigh', 'the level is case-insensitive');
|
|
947
|
+
assert.equal(normalizeEffort('none'), 'none', 'none is a level the ladder names');
|
|
948
|
+
// `none` is the one level that means thinking off, and both client vocabularies say it that way.
|
|
949
|
+
assert.deepEqual(responsesToIR({ model: 'm', reasoning: { effort: 'none' }, input: [] }).thinking, { type: 'disabled' });
|
|
950
|
+
assert.deepEqual(anthropicToIR({ model: 'm', messages: [], output_config: { effort: 'none' } }).thinking, { type: 'disabled' });
|
|
951
|
+
});
|
|
952
|
+
|
|
953
|
+
test('capEffort asks a narrower target for the deepest level it names, never for less', () => {
|
|
954
|
+
const openai = ['minimal', 'low', 'medium', 'high'];
|
|
955
|
+
assert.equal(capEffort('medium', openai), 'medium');
|
|
956
|
+
assert.equal(capEffort('xhigh', openai), 'high', 'xhigh becomes the top level OpenAI names, not medium');
|
|
957
|
+
assert.equal(capEffort('max', openai), 'high');
|
|
958
|
+
assert.equal(capEffort('ultra', openai), 'high');
|
|
959
|
+
assert.equal(capEffort('minimal', openai), 'minimal');
|
|
960
|
+
assert.equal(capEffort('high', EFFORT_LEVELS), 'high', 'a target that names the whole ladder is untouched');
|
|
961
|
+
assert.equal(capEffort('ultra', EFFORT_LEVELS), 'ultra');
|
|
962
|
+
assert.equal(capEffort('ultra'), 'ultra', 'no list means no ceiling');
|
|
963
|
+
});
|
|
964
|
+
|
|
965
|
+
// Codex -> OpenAI Chat: the level travels, and a strict upstream gets the top level it names.
|
|
966
|
+
test('Codex reasoning.effort reaches OpenAI Chat, and a strict upstream keeps the depth', () => {
|
|
967
|
+
const at = effort => responsesToIR({ model: 'gpt-5.6-sol', reasoning: { effort }, input: [{ role: 'user', content: 'hi' }] });
|
|
968
|
+
assert.equal(irToChatBody(at('xhigh'), 'gpt-5.6-sol', { thinkingMode: 'native' }).reasoning_effort, 'high',
|
|
969
|
+
'a strict OpenAI upstream names no xhigh, so it is asked for high');
|
|
970
|
+
assert.equal(irToChatBody(at('high'), 'gpt-5.6-sol', { thinkingMode: 'native' }).reasoning_effort, 'high');
|
|
971
|
+
assert.equal(irToChatBody(at('minimal'), 'gpt-5.6-sol', { thinkingMode: 'native' }).reasoning_effort, 'minimal');
|
|
972
|
+
// A gateway that forwards the object keeps the whole ladder: intact and 9Router accept it.
|
|
973
|
+
assert.equal(irToChatBody(at('ultra'), 'gpt-5.6-sol').reasoning_effort, 'ultra');
|
|
974
|
+
assert.equal(irToChatBody(at('xhigh'), 'gpt-5.6-sol').reasoning_effort, 'xhigh');
|
|
975
|
+
assert.equal(irToChatBody(at('none'), 'gpt-5.6-sol', { thinkingMode: 'native' }).reasoning_effort, undefined,
|
|
976
|
+
'none is thinking off, not a level to send');
|
|
977
|
+
});
|
|
978
|
+
|
|
979
|
+
// Codex -> Anthropic: a level with no budget becomes the level Anthropic names, and a client that
|
|
980
|
+
// sent budget_tokens keeps its budget instead of having it restated.
|
|
981
|
+
test('a level reaches Anthropic as output_config.effort, from either client vocabulary', () => {
|
|
982
|
+
const at = payload => irToAnthropicBody(anthropicToIR(payload), 'claude-opus-4-6');
|
|
983
|
+
// Anthropic client, effort only, no thinking object.
|
|
984
|
+
assert.equal(at({ model: 'x', max_tokens: 8000, messages: [{ role: 'user', content: 'hi' }], output_config: { effort: 'xhigh' } }).output_config.effort, 'xhigh');
|
|
985
|
+
assert.equal(at({ model: 'x', max_tokens: 8000, messages: [{ role: 'user', content: 'hi' }], output_config: { effort: 'none' } }).thinking, undefined,
|
|
986
|
+
'effort none is thinking off, so the turn is not asked to think');
|
|
987
|
+
// Codex client: the parser stands a budget behind the level, so Anthropic gets the budget.
|
|
988
|
+
const codex = irToAnthropicBody(responsesToIR({ model: 'x', max_output_tokens: 200000, reasoning: { effort: 'xhigh' }, input: [{ role: 'user', content: 'hi' }] }), 'claude-opus-4-6');
|
|
989
|
+
assert.equal(codex.thinking.budget_tokens, 16000, 'the level becomes the budget Anthropic takes');
|
|
990
|
+
|
|
991
|
+
const fromBudget = at({ model: 'x', max_tokens: 64000, messages: [{ role: 'user', content: 'hi' }], thinking: { type: 'enabled', budget_tokens: 31999 } });
|
|
992
|
+
assert.equal(fromBudget.output_config, undefined, 'a budget already said it in Anthropic own words');
|
|
993
|
+
assert.equal(fromBudget.thinking.budget_tokens, 31999);
|
|
994
|
+
});
|
|
995
|
+
|
|
996
|
+
// Anthropic/Vertex speak budgets, and Gemini refuses a budget above its ceiling rather than trimming.
|
|
997
|
+
test('a level becomes the budget each API speaks, and Gemini gets one it accepts', () => {
|
|
998
|
+
const budgetOf = (effort, format) => emitUpstreamBody(format,
|
|
999
|
+
responsesToIR({ model: 'm', max_output_tokens: 200000, reasoning: { effort }, input: [{ role: 'user', content: 'hi' }] }), 'm').thinking?.budget_tokens;
|
|
1000
|
+
assert.equal(budgetOf('high', 'anthropic'), 8000);
|
|
1001
|
+
assert.equal(budgetOf('xhigh', 'anthropic'), 16000);
|
|
1002
|
+
assert.equal(budgetOf('ultra', 'anthropic'), 65536);
|
|
1003
|
+
const geminiOf = effort => irToVertexBody(
|
|
1004
|
+
responsesToIR({ model: 'm', reasoning: { effort }, input: [{ role: 'user', content: 'hi' }] }), 'm').generationConfig.thinkingConfig.thinkingBudget;
|
|
1005
|
+
assert.equal(geminiOf('high'), 8000);
|
|
1006
|
+
// max and ultra are above the ceiling Gemini takes, and it refuses rather than trims.
|
|
1007
|
+
assert.equal(geminiOf('max'), 32768);
|
|
1008
|
+
assert.equal(geminiOf('ultra'), 32768);
|
|
1009
|
+
assert.equal(irToVertexBody(anthropicToIR({ model: 'm', max_tokens: 200000, messages: [{ role: 'user', content: 'hi' }], thinking: { type: 'enabled', budget_tokens: 100000 } }), 'm').generationConfig.thinkingConfig.thinkingBudget, 32768);
|
|
1010
|
+
// Anthropic keeps the budget it was given, only under max_tokens as its own rule requires.
|
|
1011
|
+
assert.equal(irToAnthropicBody(anthropicToIR({ model: 'm', max_tokens: 200000, messages: [{ role: 'user', content: 'hi' }], thinking: { type: 'enabled', budget_tokens: 100000 } }), 'm').thinking.budget_tokens, 100000);
|
|
1012
|
+
});
|
|
@@ -77,6 +77,15 @@ function startUpstream() {
|
|
|
77
77
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
78
78
|
return res.end(JSON.stringify({ id, owned_by: id.split('/')[0], ...(id.startsWith('claude/') ? { bifrost_ua: 'claude-cli/' } : {}) }));
|
|
79
79
|
}
|
|
80
|
+
// A provider list with real windows: a 1M model that also names the point to compress at,
|
|
81
|
+
// and a smaller one. A model the list says nothing of keeps the catalog template.
|
|
82
|
+
if (req.method === 'GET' && req.url.startsWith('/chat/v1/models')) {
|
|
83
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
84
|
+
return res.end(JSON.stringify({ data: [
|
|
85
|
+
{ id: 'gpt-5.6-sol', context_length: 1048576, compact_window: 272000 },
|
|
86
|
+
{ id: 'up-opus', context_length: 200000 }
|
|
87
|
+
] }));
|
|
88
|
+
}
|
|
80
89
|
if (req.url.startsWith('/intact/v1/messages')) {
|
|
81
90
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
82
91
|
return res.end(JSON.stringify({ id: 'msg_n', type: 'message', role: 'assistant', model: json.model, content: [{ type: 'text', text: 'bifrost ok' }], stop_reason: 'end_turn', usage: { input_tokens: 3, output_tokens: 2 } }));
|
|
@@ -213,7 +222,7 @@ before(async () => {
|
|
|
213
222
|
chat: { name: 'Mock Chat', mode: 'convert', tool: 'claude', outFormat: 'openai-chat', baseURL: `${base}/chat/v1`, apiKey: 'sk-secret-chat', defaultModels: models },
|
|
214
223
|
vtx: { name: 'Mock Vertex', mode: 'convert', inFormat: 'auto', outFormat: 'vertex', baseURL: `${base}/vtx`, apiKey: 'sk-secret-vtx', defaultModels: models },
|
|
215
224
|
agmock: { name: 'Mock AG via chat', mode: 'convert', inFormat: 'responses', baseURL: `${base}/chat/v1`, apiKey: 'sk-secret-ag', defaultModels: { main: 'ag/mock-flash', review: 'ag/mock-review', subagent: 'ag/mock-low' } },
|
|
216
|
-
pub: { name: 'Mock Public', mode: 'convert', inFormat: 'responses', baseURL: `${base}/chat/v1`, apiKey: 'sk-secret-pub', publicModels: ['gpt-5.6-sol', 'gpt-5.2'], defaultModels: { main: 'ag/mock-flash' }
|
|
225
|
+
pub: { name: 'Mock Public', mode: 'convert', inFormat: 'responses', baseURL: `${base}/chat/v1`, apiKey: 'sk-secret-pub', publicModels: ['gpt-5.6-sol', 'gpt-5.2'], defaultModels: { main: 'ag/mock-flash' } },
|
|
217
226
|
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' } },
|
|
218
227
|
native: { name: 'Mock Strict OpenAI', mode: 'convert', inFormat: 'auto', outFormat: 'openai-chat', thinkingMode: 'native', baseURL: `${base}/chat/v1`, apiKey: 'sk-secret-native', defaultModels: models },
|
|
219
228
|
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' } },
|
|
@@ -1166,17 +1175,22 @@ test('Codex WS: a client half-close aborts the running turn', async () => {
|
|
|
1166
1175
|
assert.ok(await until(() => hangState.slowAborted, 2000), 'the upstream turn kept running');
|
|
1167
1176
|
});
|
|
1168
1177
|
|
|
1169
|
-
|
|
1178
|
+
// The window /v1/models serves is the model's own: the whole one as max_context_window, the
|
|
1179
|
+
// window to compress at as context_window. Nothing is hardcoded, so a model the profile's list
|
|
1180
|
+
// names nothing keeps the template's window.
|
|
1181
|
+
test('/v1/models serves the real window of each model, and the template only as a fallback', async () => {
|
|
1170
1182
|
const template = JSON.parse(fs.readFileSync(path.join(ROOT, 'codex-catalog-template.json'), 'utf8'));
|
|
1171
1183
|
const pub = await (await fetch(url('/v1/models'), { headers: { 'x-llm-profile': 'pub' } })).json();
|
|
1172
|
-
const
|
|
1173
|
-
assert.
|
|
1184
|
+
const bySlug = Object.fromEntries(pub.models.map(m => [m.slug, m]));
|
|
1185
|
+
assert.equal(bySlug['gpt-5.6-sol'].max_context_window, 1048576, 'a model with a 1M window gets 1M');
|
|
1186
|
+
assert.equal(bySlug['gpt-5.6-sol'].context_window, 272000, 'and still compacts at its own threshold');
|
|
1187
|
+
assert.equal(bySlug['gpt-5.2'].context_window, template.context_window, 'a model the list says nothing of keeps the template');
|
|
1188
|
+
assert.equal(bySlug['gpt-5.2'].max_context_window, template.max_context_window);
|
|
1174
1189
|
for (const m of pub.models) assert.equal(m.description, template.description);
|
|
1175
|
-
const one = await (await fetch(url('/v1/models/gpt-5.
|
|
1176
|
-
assert.equal(one.context_window,
|
|
1177
|
-
assert.equal(one.
|
|
1178
|
-
|
|
1179
|
-
assert.equal(main.context_window, 1000000);
|
|
1190
|
+
const one = await (await fetch(url('/v1/models/gpt-5.6-sol'), { headers: { 'x-llm-profile': 'pub' } })).json();
|
|
1191
|
+
assert.equal(one.context_window, 272000);
|
|
1192
|
+
assert.equal(one.max_context_window, 1048576);
|
|
1193
|
+
assert.equal(one.id, 'gpt-5.6-sol');
|
|
1180
1194
|
});
|
|
1181
1195
|
|
|
1182
1196
|
// ---- Admin API: stale dashboard writes, control characters, key destination (F06, F33, keeper next-time) ----
|
package/tests/helpers.mjs
CHANGED
|
@@ -1,24 +1,24 @@
|
|
|
1
|
-
import assert from 'node:assert/strict';
|
|
2
|
-
|
|
3
|
-
// Validate an Anthropic event sequence: indexes increase in start order, deltas only target an open block.
|
|
4
|
-
export function assertValidAnthropicEvents(events) {
|
|
5
|
-
const open = new Set();
|
|
6
|
-
const seen = new Set();
|
|
7
|
-
let expectedNext = 0;
|
|
8
|
-
for (const { event, data } of events) {
|
|
9
|
-
if (event === 'content_block_start') {
|
|
10
|
-
assert.equal(data.index, expectedNext, `block index must be sequential (got ${data.index}, want ${expectedNext})`);
|
|
11
|
-
assert.ok(!seen.has(data.index), `index ${data.index} reused`);
|
|
12
|
-
seen.add(data.index);
|
|
13
|
-
open.add(data.index);
|
|
14
|
-
expectedNext++;
|
|
15
|
-
} else if (event === 'content_block_delta') {
|
|
16
|
-
assert.ok(open.has(data.index), `delta for block ${data.index} which is not open`);
|
|
17
|
-
} else if (event === 'content_block_stop') {
|
|
18
|
-
assert.ok(open.has(data.index), `stop for block ${data.index} which is not open`);
|
|
19
|
-
open.delete(data.index);
|
|
20
|
-
} else if (event === 'message_stop') {
|
|
21
|
-
assert.equal(open.size, 0, 'all blocks must be closed before message_stop');
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
}
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
|
|
3
|
+
// Validate an Anthropic event sequence: indexes increase in start order, deltas only target an open block.
|
|
4
|
+
export function assertValidAnthropicEvents(events) {
|
|
5
|
+
const open = new Set();
|
|
6
|
+
const seen = new Set();
|
|
7
|
+
let expectedNext = 0;
|
|
8
|
+
for (const { event, data } of events) {
|
|
9
|
+
if (event === 'content_block_start') {
|
|
10
|
+
assert.equal(data.index, expectedNext, `block index must be sequential (got ${data.index}, want ${expectedNext})`);
|
|
11
|
+
assert.ok(!seen.has(data.index), `index ${data.index} reused`);
|
|
12
|
+
seen.add(data.index);
|
|
13
|
+
open.add(data.index);
|
|
14
|
+
expectedNext++;
|
|
15
|
+
} else if (event === 'content_block_delta') {
|
|
16
|
+
assert.ok(open.has(data.index), `delta for block ${data.index} which is not open`);
|
|
17
|
+
} else if (event === 'content_block_stop') {
|
|
18
|
+
assert.ok(open.has(data.index), `stop for block ${data.index} which is not open`);
|
|
19
|
+
open.delete(data.index);
|
|
20
|
+
} else if (event === 'message_stop') {
|
|
21
|
+
assert.equal(open.size, 0, 'all blocks must be closed before message_stop');
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|