llm-switcher 1.3.8 → 1.4.1

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.
@@ -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' }, model1M: { main: true } },
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
- test('/v1/models windows follow model1M and the entry comes from codex-catalog-template.json', async () => {
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 win = Object.fromEntries(pub.models.map(m => [m.slug, m.context_window]));
1173
- assert.deepEqual(win, { 'gpt-5.6-sol': 1000000, 'gpt-5.2': template.context_window });
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.2'), { headers: { 'x-llm-profile': 'pub' } })).json();
1176
- assert.equal(one.context_window, template.context_window);
1177
- assert.equal(one.id, 'gpt-5.2');
1178
- const main = await (await fetch(url('/v1/models/gpt-5.6-sol'), { headers: { 'x-llm-profile': 'pub' } })).json();
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
+ }
@@ -1,205 +1,205 @@
1
- // LIVE test: requires a running gateway (switch on) and a real upstream (costs tokens).
2
- // Offline test, no network needed: npm test
3
- const GATEWAY_PORT = Number(process.env.LLM_SWITCHER_PORT) || 3456;
4
- const BASE_URL = `http://127.0.0.1:${GATEWAY_PORT}`;
5
-
6
- console.log('================================================================');
7
- console.log(' LLM SWITCHER — TOKEN OPTIMIZER INTEROPERABILITY TEST SUITE ');
8
- console.log(' Simulating failure modes from Headroom, RTK, and Ponytail ');
9
- console.log('================================================================\n');
10
-
11
- let passed = 0;
12
- let failed = 0;
13
-
14
- async function runTest(name, fn) {
15
- process.stdout.write(`[TEST] ${name}... `);
16
- const t0 = Date.now();
17
- try {
18
- const detail = await fn();
19
- console.log(`PASS (${Date.now() - t0}ms)`);
20
- if (detail) console.log(` ↳ ${detail}`);
21
- passed++;
22
- } catch (err) {
23
- console.log(`FAIL (${Date.now() - t0}ms)`);
24
- console.log(` ↳ ERROR: ${err.message}`);
25
- failed++;
26
- }
27
- }
28
-
29
- // -----------------------------------------------------------------------------
30
- // TEST 1: Headroom Failure Mode — Orphaned tool_result
31
- // When Headroom prunes history to save tokens, it drops the assistant tool_use turn.
32
- // Anthropic strictly throws: 400 invalid_request_error: 'tool_use_id does not correspond to any tool_use'
33
- // LLM Switcher Healer Engine: Repairs orphaned result into context text -> 200 OK
34
- // -----------------------------------------------------------------------------
35
- await runTest('Headroom Simulation: Orphaned tool_result turn', async () => {
36
- const payload = {
37
- model: 'claude-opus-4-6',
38
- max_tokens: 40,
39
- messages: [
40
- { role: 'user', content: 'Context turn before pruning' },
41
- {
42
- role: 'user',
43
- content: [
44
- { type: 'tool_result', tool_use_id: 'headroom_pruned_call_99', content: 'Database query result: 42 rows found' },
45
- { type: 'text', text: 'Reply: healed successfully' }
46
- ]
47
- }
48
- ],
49
- stream: false
50
- };
51
-
52
- const res = await fetch(`${BASE_URL}/v1/messages`, {
53
- method: 'POST',
54
- headers: { 'Content-Type': 'application/json', 'anthropic-version': '2023-06-01' },
55
- body: JSON.stringify(payload)
56
- });
57
-
58
- if (res.status !== 200) {
59
- const err = await res.text();
60
- throw new Error(`Expected HTTP 200, got ${res.status}: ${err}`);
61
- }
62
- const json = await res.json();
63
- const text = json.content?.map(c => c.text).join('') || '';
64
- return `Healed orphaned tool_result. Response HTTP 200: "${text.slice(0, 50).replace(/\n/g, ' ')}..."`;
65
- });
66
-
67
- // -----------------------------------------------------------------------------
68
- // TEST 2: Headroom Failure Mode — Consecutive User turns (Roles must alternate)
69
- // When Headroom collapses history, multiple user turns occur back-to-back.
70
- // Anthropic strictly throws: 400 invalid_request_error: 'roles must alternate'
71
- // LLM Switcher Healer Engine: Merges consecutive same-role turns -> 200 OK
72
- // -----------------------------------------------------------------------------
73
- await runTest('Headroom Simulation: Consecutive User turns (Role alternation violation)', async () => {
74
- const payload = {
75
- model: 'claude-opus-4-6',
76
- max_tokens: 30,
77
- messages: [
78
- { role: 'user', content: 'User message turn 1' },
79
- { role: 'user', content: 'User message turn 2 (no assistant in between)' },
80
- { role: 'user', content: 'User message turn 3: reply pong' }
81
- ],
82
- stream: false
83
- };
84
-
85
- const res = await fetch(`${BASE_URL}/v1/messages`, {
86
- method: 'POST',
87
- headers: { 'Content-Type': 'application/json', 'anthropic-version': '2023-06-01' },
88
- body: JSON.stringify(payload)
89
- });
90
-
91
- if (res.status !== 200) {
92
- const err = await res.text();
93
- throw new Error(`Expected HTTP 200, got ${res.status}: ${err}`);
94
- }
95
- const json = await res.json();
96
- return `Merged consecutive turns seamlessly. Stop reason: ${json.stop_reason}`;
97
- });
98
-
99
- // -----------------------------------------------------------------------------
100
- // TEST 3: Headroom / RTK Failure Mode — Stripped Thinking Parameter
101
- // Optimizer stripped the 'thinking' object to reduce tokens.
102
- // LLM Switcher Thinking Guard: Detects reasoning model (ag/claude-opus-4-6-thinking)
103
- // and restores thinking.budget_tokens automatically -> thinking_delta emitted!
104
- // -----------------------------------------------------------------------------
105
- await runTest('Thinking Guard: Restoring stripped thinking parameter on reasoning models', async () => {
106
- const payload = {
107
- model: 'claude-opus-4-6',
108
- max_tokens: 200,
109
- // Note: NO thinking parameter included (simulating optimizer stripping it)
110
- messages: [
111
- { role: 'user', content: 'Solve step by step: what is 17 * 23? Show reasoning.' }
112
- ],
113
- stream: true
114
- };
115
-
116
- const res = await fetch(`${BASE_URL}/v1/messages`, {
117
- method: 'POST',
118
- headers: { 'Content-Type': 'application/json', 'anthropic-version': '2023-06-01' },
119
- body: JSON.stringify(payload)
120
- });
121
-
122
- if (res.status !== 200) {
123
- throw new Error(`Expected HTTP 200, got ${res.status}`);
124
- }
125
-
126
- const text = await res.text();
127
- const hasThinkingDelta = text.includes('thinking_delta');
128
- const hasSignatureDelta = text.includes('signature_delta');
129
- const hasTextDelta = text.includes('text_delta');
130
-
131
- if (!hasThinkingDelta) {
132
- throw new Error('Thinking Guard failed: thinking_delta was NOT emitted in stream!');
133
- }
134
-
135
- return `Automatically restored thinking: thinking_delta=${hasThinkingDelta}, signature_delta=${hasSignatureDelta}, text_delta=${hasTextDelta}`;
136
- });
137
-
138
- // -----------------------------------------------------------------------------
139
- // TEST 4: RTK & Intermediary Headers Passthrough
140
- // RTK / tracing tools inject headers: x-rtk-version, traceparent, x-request-id.
141
- // LLM Switcher: Transparently preserves all tracking headers without rejection.
142
- // -----------------------------------------------------------------------------
143
- await runTest('RTK Intermediary: Custom headers and traceparent passthrough', async () => {
144
- const payload = {
145
- model: 'claude-opus-4-6',
146
- max_tokens: 20,
147
- messages: [{ role: 'user', content: 'ping' }],
148
- stream: false
149
- };
150
-
151
- const res = await fetch(`${BASE_URL}/v1/messages`, {
152
- method: 'POST',
153
- headers: {
154
- 'Content-Type': 'application/json',
155
- 'anthropic-version': '2023-06-01',
156
- 'x-rtk-version': '0.37.2',
157
- 'x-optimizer-id': 'rtk-cli-hook',
158
- 'traceparent': '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01'
159
- },
160
- body: JSON.stringify(payload)
161
- });
162
-
163
- if (res.status !== 200) {
164
- throw new Error(`Expected HTTP 200, got ${res.status}`);
165
- }
166
- return `Headers accepted cleanly with HTTP 200 OK`;
167
- });
168
-
169
- // -----------------------------------------------------------------------------
170
- // TEST 5: OpenAI Chat Healer Mode — Orphaned Tool Result in Chat Completions
171
- // Same test but over /v1/chat/completions: tool role message without prior tool_calls assistant message.
172
- // OpenAI API strictly throws: 400 'messages with role tool must be a response to a preceding message with tool_calls'
173
- // LLM Switcher: Converts orphaned tool to user text message -> 200 OK
174
- // -----------------------------------------------------------------------------
175
- await runTest('OpenAI Chat Healer: Orphaned role tool without preceding assistant tool_calls', async () => {
176
- const payload = {
177
- model: 'ag/gpt-oss-120b-medium',
178
- max_tokens: 30,
179
- messages: [
180
- { role: 'user', content: 'Turn 1 context' },
181
- { role: 'tool', tool_call_id: 'orphaned_chat_call_77', content: 'Tool execution logs: OK' },
182
- { role: 'user', content: 'reply pong' }
183
- ],
184
- stream: false
185
- };
186
-
187
- const res = await fetch(`${BASE_URL}/v1/chat/completions`, {
188
- method: 'POST',
189
- headers: { 'Content-Type': 'application/json' },
190
- body: JSON.stringify(payload)
191
- });
192
-
193
- if (res.status !== 200) {
194
- const err = await res.text();
195
- throw new Error(`Expected HTTP 200, got ${res.status}: ${err}`);
196
- }
197
- const json = await res.json();
198
- return `Chat Healer rescued orphaned tool role. Stop reason: ${json.choices?.[0]?.finish_reason}`;
199
- });
200
-
201
- console.log('\n================================================================');
202
- console.log(` TEST RESULTS: ${passed} PASSED / ${failed} FAILED `);
203
- console.log('================================================================\n');
204
-
205
- process.exit(failed ? 1 : 0);
1
+ // LIVE test: requires a running gateway (switch on) and a real upstream (costs tokens).
2
+ // Offline test, no network needed: npm test
3
+ const GATEWAY_PORT = Number(process.env.LLM_SWITCHER_PORT) || 3456;
4
+ const BASE_URL = `http://127.0.0.1:${GATEWAY_PORT}`;
5
+
6
+ console.log('================================================================');
7
+ console.log(' LLM SWITCHER — TOKEN OPTIMIZER INTEROPERABILITY TEST SUITE ');
8
+ console.log(' Simulating failure modes from Headroom, RTK, and Ponytail ');
9
+ console.log('================================================================\n');
10
+
11
+ let passed = 0;
12
+ let failed = 0;
13
+
14
+ async function runTest(name, fn) {
15
+ process.stdout.write(`[TEST] ${name}... `);
16
+ const t0 = Date.now();
17
+ try {
18
+ const detail = await fn();
19
+ console.log(`PASS (${Date.now() - t0}ms)`);
20
+ if (detail) console.log(` ↳ ${detail}`);
21
+ passed++;
22
+ } catch (err) {
23
+ console.log(`FAIL (${Date.now() - t0}ms)`);
24
+ console.log(` ↳ ERROR: ${err.message}`);
25
+ failed++;
26
+ }
27
+ }
28
+
29
+ // -----------------------------------------------------------------------------
30
+ // TEST 1: Headroom Failure Mode — Orphaned tool_result
31
+ // When Headroom prunes history to save tokens, it drops the assistant tool_use turn.
32
+ // Anthropic strictly throws: 400 invalid_request_error: 'tool_use_id does not correspond to any tool_use'
33
+ // LLM Switcher Healer Engine: Repairs orphaned result into context text -> 200 OK
34
+ // -----------------------------------------------------------------------------
35
+ await runTest('Headroom Simulation: Orphaned tool_result turn', async () => {
36
+ const payload = {
37
+ model: 'claude-opus-4-6',
38
+ max_tokens: 40,
39
+ messages: [
40
+ { role: 'user', content: 'Context turn before pruning' },
41
+ {
42
+ role: 'user',
43
+ content: [
44
+ { type: 'tool_result', tool_use_id: 'headroom_pruned_call_99', content: 'Database query result: 42 rows found' },
45
+ { type: 'text', text: 'Reply: healed successfully' }
46
+ ]
47
+ }
48
+ ],
49
+ stream: false
50
+ };
51
+
52
+ const res = await fetch(`${BASE_URL}/v1/messages`, {
53
+ method: 'POST',
54
+ headers: { 'Content-Type': 'application/json', 'anthropic-version': '2023-06-01' },
55
+ body: JSON.stringify(payload)
56
+ });
57
+
58
+ if (res.status !== 200) {
59
+ const err = await res.text();
60
+ throw new Error(`Expected HTTP 200, got ${res.status}: ${err}`);
61
+ }
62
+ const json = await res.json();
63
+ const text = json.content?.map(c => c.text).join('') || '';
64
+ return `Healed orphaned tool_result. Response HTTP 200: "${text.slice(0, 50).replace(/\n/g, ' ')}..."`;
65
+ });
66
+
67
+ // -----------------------------------------------------------------------------
68
+ // TEST 2: Headroom Failure Mode — Consecutive User turns (Roles must alternate)
69
+ // When Headroom collapses history, multiple user turns occur back-to-back.
70
+ // Anthropic strictly throws: 400 invalid_request_error: 'roles must alternate'
71
+ // LLM Switcher Healer Engine: Merges consecutive same-role turns -> 200 OK
72
+ // -----------------------------------------------------------------------------
73
+ await runTest('Headroom Simulation: Consecutive User turns (Role alternation violation)', async () => {
74
+ const payload = {
75
+ model: 'claude-opus-4-6',
76
+ max_tokens: 30,
77
+ messages: [
78
+ { role: 'user', content: 'User message turn 1' },
79
+ { role: 'user', content: 'User message turn 2 (no assistant in between)' },
80
+ { role: 'user', content: 'User message turn 3: reply pong' }
81
+ ],
82
+ stream: false
83
+ };
84
+
85
+ const res = await fetch(`${BASE_URL}/v1/messages`, {
86
+ method: 'POST',
87
+ headers: { 'Content-Type': 'application/json', 'anthropic-version': '2023-06-01' },
88
+ body: JSON.stringify(payload)
89
+ });
90
+
91
+ if (res.status !== 200) {
92
+ const err = await res.text();
93
+ throw new Error(`Expected HTTP 200, got ${res.status}: ${err}`);
94
+ }
95
+ const json = await res.json();
96
+ return `Merged consecutive turns seamlessly. Stop reason: ${json.stop_reason}`;
97
+ });
98
+
99
+ // -----------------------------------------------------------------------------
100
+ // TEST 3: Headroom / RTK Failure Mode — Stripped Thinking Parameter
101
+ // Optimizer stripped the 'thinking' object to reduce tokens.
102
+ // LLM Switcher Thinking Guard: Detects reasoning model (ag/claude-opus-4-6-thinking)
103
+ // and restores thinking.budget_tokens automatically -> thinking_delta emitted!
104
+ // -----------------------------------------------------------------------------
105
+ await runTest('Thinking Guard: Restoring stripped thinking parameter on reasoning models', async () => {
106
+ const payload = {
107
+ model: 'claude-opus-4-6',
108
+ max_tokens: 200,
109
+ // Note: NO thinking parameter included (simulating optimizer stripping it)
110
+ messages: [
111
+ { role: 'user', content: 'Solve step by step: what is 17 * 23? Show reasoning.' }
112
+ ],
113
+ stream: true
114
+ };
115
+
116
+ const res = await fetch(`${BASE_URL}/v1/messages`, {
117
+ method: 'POST',
118
+ headers: { 'Content-Type': 'application/json', 'anthropic-version': '2023-06-01' },
119
+ body: JSON.stringify(payload)
120
+ });
121
+
122
+ if (res.status !== 200) {
123
+ throw new Error(`Expected HTTP 200, got ${res.status}`);
124
+ }
125
+
126
+ const text = await res.text();
127
+ const hasThinkingDelta = text.includes('thinking_delta');
128
+ const hasSignatureDelta = text.includes('signature_delta');
129
+ const hasTextDelta = text.includes('text_delta');
130
+
131
+ if (!hasThinkingDelta) {
132
+ throw new Error('Thinking Guard failed: thinking_delta was NOT emitted in stream!');
133
+ }
134
+
135
+ return `Automatically restored thinking: thinking_delta=${hasThinkingDelta}, signature_delta=${hasSignatureDelta}, text_delta=${hasTextDelta}`;
136
+ });
137
+
138
+ // -----------------------------------------------------------------------------
139
+ // TEST 4: RTK & Intermediary Headers Passthrough
140
+ // RTK / tracing tools inject headers: x-rtk-version, traceparent, x-request-id.
141
+ // LLM Switcher: Transparently preserves all tracking headers without rejection.
142
+ // -----------------------------------------------------------------------------
143
+ await runTest('RTK Intermediary: Custom headers and traceparent passthrough', async () => {
144
+ const payload = {
145
+ model: 'claude-opus-4-6',
146
+ max_tokens: 20,
147
+ messages: [{ role: 'user', content: 'ping' }],
148
+ stream: false
149
+ };
150
+
151
+ const res = await fetch(`${BASE_URL}/v1/messages`, {
152
+ method: 'POST',
153
+ headers: {
154
+ 'Content-Type': 'application/json',
155
+ 'anthropic-version': '2023-06-01',
156
+ 'x-rtk-version': '0.37.2',
157
+ 'x-optimizer-id': 'rtk-cli-hook',
158
+ 'traceparent': '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01'
159
+ },
160
+ body: JSON.stringify(payload)
161
+ });
162
+
163
+ if (res.status !== 200) {
164
+ throw new Error(`Expected HTTP 200, got ${res.status}`);
165
+ }
166
+ return `Headers accepted cleanly with HTTP 200 OK`;
167
+ });
168
+
169
+ // -----------------------------------------------------------------------------
170
+ // TEST 5: OpenAI Chat Healer Mode — Orphaned Tool Result in Chat Completions
171
+ // Same test but over /v1/chat/completions: tool role message without prior tool_calls assistant message.
172
+ // OpenAI API strictly throws: 400 'messages with role tool must be a response to a preceding message with tool_calls'
173
+ // LLM Switcher: Converts orphaned tool to user text message -> 200 OK
174
+ // -----------------------------------------------------------------------------
175
+ await runTest('OpenAI Chat Healer: Orphaned role tool without preceding assistant tool_calls', async () => {
176
+ const payload = {
177
+ model: 'ag/gpt-oss-120b-medium',
178
+ max_tokens: 30,
179
+ messages: [
180
+ { role: 'user', content: 'Turn 1 context' },
181
+ { role: 'tool', tool_call_id: 'orphaned_chat_call_77', content: 'Tool execution logs: OK' },
182
+ { role: 'user', content: 'reply pong' }
183
+ ],
184
+ stream: false
185
+ };
186
+
187
+ const res = await fetch(`${BASE_URL}/v1/chat/completions`, {
188
+ method: 'POST',
189
+ headers: { 'Content-Type': 'application/json' },
190
+ body: JSON.stringify(payload)
191
+ });
192
+
193
+ if (res.status !== 200) {
194
+ const err = await res.text();
195
+ throw new Error(`Expected HTTP 200, got ${res.status}: ${err}`);
196
+ }
197
+ const json = await res.json();
198
+ return `Chat Healer rescued orphaned tool role. Stop reason: ${json.choices?.[0]?.finish_reason}`;
199
+ });
200
+
201
+ console.log('\n================================================================');
202
+ console.log(` TEST RESULTS: ${passed} PASSED / ${failed} FAILED `);
203
+ console.log('================================================================\n');
204
+
205
+ process.exit(failed ? 1 : 0);