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/formats.mjs CHANGED
@@ -285,22 +285,77 @@ function sanitizeSchemaNode(schema) {
285
285
  }
286
286
 
287
287
  // ---------------- thinking helpers ----------------
288
-
288
+ //
289
+ // Thinking crosses the gateway in one IR field and leaves in whatever the upstream speaks: a level
290
+ // (OpenAI Chat `reasoning_effort`, Anthropic `output_config.effort`, Codex `reasoning.effort`) or a
291
+ // token budget (Anthropic `thinking.budget_tokens`, Gemini `thinkingBudget`). The ladder below is
292
+ // the single order both directions use, so a level survives any number of hops and a budget is the
293
+ // level it stands for.
294
+ //
295
+ // The ladder is Codex's own, which is the widest set of levels any of these APIs names today. A
296
+ // target that names fewer takes the highest level it does name: an upstream is refused an unknown
297
+ // enum value, while a lower level than asked for is still the request the person made.
298
+ export const EFFORT_LEVELS = Object.freeze(['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra']);
299
+
300
+ // The token budget each level stands for. Distinct and increasing, so budgetToEffort is its exact
301
+ // inverse. The boundaries are the ones this gateway has always read a request's budget by, because
302
+ // a budget is what Claude Code sends on every request: at or above 1024 is low, at or above ~2K is
303
+ // medium, at or above 8000 is high. Moving a boundary would hand an existing request a different
304
+ // level. Gemini caps thinkingBudget at 32768, which the Vertex emitter applies; nothing clamps it
305
+ // here, or a level would be lost on the next hop.
306
+ const EFFORT_BUDGETS = Object.freeze({ minimal: 512, low: 1024, medium: 2048, high: 8000, xhigh: 16000, max: 32768, ultra: 65536 });
307
+
308
+ // The levels OpenAI Chat names in `reasoning_effort`. A strict OpenAI upstream rejects the rest.
309
+ const OPENAI_CHAT_LEVELS = Object.freeze(['minimal', 'low', 'medium', 'high']);
310
+
311
+ // Gemini's own ceiling for thinkingBudget. Above it the request is refused, not trimmed.
312
+ const GEMINI_MAX_BUDGET = 32768;
313
+
314
+ function effortRank(e) {
315
+ const i = EFFORT_LEVELS.indexOf(String(e || '').toLowerCase());
316
+ return i;
317
+ }
318
+
319
+ /**
320
+ * The level as the ladder spells it. An unknown or missing value is `medium`, which is what a
321
+ * client that asks for no reasoning gets on every API here. `none` is kept: it is a level the
322
+ * ladder names, and a caller reads it as thinking off.
323
+ */
324
+ function normalizeEffort(e) {
325
+ const s = String(e || '').toLowerCase();
326
+ return effortRank(s) >= 0 ? s : 'medium';
327
+ }
328
+
329
+ /**
330
+ * The level a target accepts: the one asked for, or the top of `allowed` when the target names
331
+ * fewer levels than the ladder does. Never silently drops below what was asked for.
332
+ */
333
+ function capEffort(e, allowed) {
334
+ const wanted = normalizeEffort(e);
335
+ if (!Array.isArray(allowed) || !allowed.length || allowed.includes(wanted)) return wanted;
336
+ let best = allowed[0];
337
+ for (const a of allowed) {
338
+ if (effortRank(a) >= 0 && effortRank(a) <= effortRank(wanted) && effortRank(a) > effortRank(best)) best = a;
339
+ }
340
+ return best;
341
+ }
342
+
343
+ /**
344
+ * The level a token budget stands for: the highest whose budget the number reaches. An exact
345
+ * effortToEffort round trip returns the level that was asked for.
346
+ */
289
347
  function budgetToEffort(b) {
290
348
  const n = Number(b) || 0;
291
- return n >= 8000 ? 'high' : n >= 2000 ? 'medium' : 'low';
349
+ let out = 'minimal';
350
+ for (const level of Object.keys(EFFORT_BUDGETS)) {
351
+ if (n >= EFFORT_BUDGETS[level]) out = level;
352
+ }
353
+ return out;
292
354
  }
293
355
 
356
+ /** The token budget a level stands for. */
294
357
  function effortToBudget(e) {
295
- switch (String(e || '').toLowerCase()) {
296
- case 'max':
297
- case 'xhigh':
298
- case 'high': return 8000;
299
- case 'medium': return 4000;
300
- case 'low':
301
- case 'minimal': return 1024;
302
- default: return 2000;
303
- }
358
+ return EFFORT_BUDGETS[normalizeEffort(e)] ?? EFFORT_BUDGETS.medium;
304
359
  }
305
360
 
306
361
  function clampBudget(b, fallback = 2048) {
@@ -415,10 +470,17 @@ function anthropicToIR(payload) {
415
470
  }
416
471
 
417
472
  if (Array.isArray(payload.tools) && payload.tools.length) {
418
- ir.tools = payload.tools.map(t => ({
419
- name: t.name, description: t.description || '',
420
- parameters: sanitizeJsonSchema(t.input_schema || {})
421
- }));
473
+ ir.tools = payload.tools
474
+ .filter(t => t && t.name)
475
+ .map(t => ({
476
+ name: t.name, description: t.description || '',
477
+ parameters: sanitizeJsonSchema(t.input_schema || {}),
478
+ // A client tool is `{"name","input_schema"}`, optionally typed "custom". A dated type is a
479
+ // server tool: Anthropic runs it, its result reaches the caller without the caller executing
480
+ // anything. Sending one to another provider as a function tool moves the work to the caller,
481
+ // which is not what the caller asked for, so it is marked rather than treated as a tool.
482
+ side: isProviderRunTool(t) ? 'provider' : 'client'
483
+ }));
422
484
  }
423
485
 
424
486
  if (payload.tool_choice) {
@@ -442,8 +504,17 @@ function anthropicToIR(payload) {
442
504
  }
443
505
 
444
506
  // Anthropic `thinking` param ({type, budget_tokens}) -> IR thinking ({type, budget, effort}).
507
+ //
508
+ // A client may also ask for depth with `output_config.effort` and no `thinking` object at all.
509
+ // That is a request for thinking, so it becomes one here; the emitter then answers in the same
510
+ // words. Reading only the `thinking` object would drop the request and send the turn unthinking.
445
511
  function thinkingFromAnthropicParam(th, effort) {
446
- if (!th || typeof th !== 'object') return null;
512
+ if (!th || typeof th !== 'object') {
513
+ if (typeof effort === 'string' && effort) {
514
+ return isNoReasoningEffort(effort) ? { type: 'disabled' } : { type: 'enabled', effort };
515
+ }
516
+ return null;
517
+ }
447
518
  // Client explicitly disabled thinking -> honor that intent, don't "restore" thinking.
448
519
  if (th.type === 'disabled') return { type: 'disabled' };
449
520
  const out = { type: th.type === 'adaptive' ? 'adaptive' : 'enabled' };
@@ -458,6 +529,18 @@ function isNoReasoningEffort(e) {
458
529
  return String(e || '').toLowerCase() === 'none';
459
530
  }
460
531
 
532
+ // Anthropic names its server tools by a dated type (web_search_20260209, code_execution_20250825,
533
+ // tool_search_20251119, memory_20250818, bash_20250124, text_editor_20250124). The date moves and
534
+ // the list grows, so the shape decides: a client tool carries input_schema, and the only type a
535
+ // client tool may carry is "custom".
536
+ function isProviderRunTool(t) {
537
+ const type = typeof t?.type === 'string' ? t.type : '';
538
+ if (!type) return false;
539
+ if (type === 'custom') return false;
540
+ if (t.input_schema && typeof t.input_schema === 'object') return false;
541
+ return true;
542
+ }
543
+
461
544
  // OpenAI Chat Completions -> IR (light normalization).
462
545
  // Anthropic rejects an empty stop string, and no upstream can match one.
463
546
  function stopList(stop) {
@@ -773,8 +856,44 @@ function parseToIR(clientFormat, payload) {
773
856
 
774
857
  // ---------------- emitters: IR -> upstream body ----------------
775
858
 
859
+ // The tools the caller executes. A tool the provider runs has no business in another provider's
860
+ // request as a function the caller is then asked to run: it changes who does the work, in a way the
861
+ // caller cannot see. A client tool is the default, so a tool list built elsewhere without `side`
862
+ // behaves as it always did.
863
+ function clientTools(ir) {
864
+ return (ir.tools || []).filter(t => t && t.side !== 'provider');
865
+ }
866
+
867
+ // The tools that were dropped, with the reason, for the log line that says what left the request.
868
+ function providerRunTools(ir) {
869
+ return (ir.tools || []).filter(t => t && t.side === 'provider').map(t => t.name);
870
+ }
871
+
872
+ // A whole tool call, name and arguments, in the vocabulary the caller declared. With no vocabulary
873
+ // the call is returned untouched, which is the case for a request that declared no tools.
874
+ function repairToolCall(tc, vocab) {
875
+ if (!vocab || typeof vocab.repair !== 'function') return { name: tc.name, args: tc.args };
876
+ const fixed = vocab.repair(tc.name, typeof tc.args === 'string' ? tc.args : stringifyArgs(tc.args || {}));
877
+ return { name: fixed.name, args: fixed.args };
878
+ }
879
+
880
+ // The tool names the caller declared, for a model that answers in a vocabulary of its own. Only the
881
+ // names the caller's own system text does not already mention are named back: a prompt that already
882
+ // lists them needs no second list, and one that lists some of them needs only the rest.
883
+ //
884
+ // Nothing is said about a tool fewer than two, because one tool has no other name to be confused
885
+ // with, and nothing about a tool the provider runs, because the caller never executes those.
886
+ function toolNamesNotice(ir, systemText) {
887
+ const names = (ir.tools || [])
888
+ .filter(t => t && typeof t.name === 'string' && t.name && t.side !== 'provider')
889
+ .map(t => t.name);
890
+ if (names.length < 2) return '';
891
+ const missing = names.filter(n => !String(systemText || '').includes(n));
892
+ if (!missing.length) return '';
893
+ return `When you call a tool, use exactly these names and do not substitute another: ${missing.join(', ')}.`;
894
+ }
895
+
776
896
  function hasNativeReasoning(model) {
777
- // Detect capability families instead of pinning exact versions. Claude Opus
778
897
  // model IDs change often, but all current Opus variants support reasoning.
779
898
  const m = String(model || '').toLowerCase();
780
899
  return m.includes('thinking') || m.includes('reasoning') || m.includes('reasoner') || m.includes('opus')
@@ -886,6 +1005,16 @@ function irToChatBody(ir, model, opts = {}) {
886
1005
  const thinkGuide = 'You must provide your internal reasoning and step-by-step thinking inside <think> and </think> tags before your final response.';
887
1006
  systemText = systemText ? `${thinkGuide}\n\n${systemText}` : thinkGuide;
888
1007
  }
1008
+ // A model answers with the tool names it was trained on, whatever the caller declared: a Codex
1009
+ // model says `shell` where the caller wrote `Bash`, and the caller answers "No such tool
1010
+ // available". Naming the caller's own names is cheaper than repairing every such call.
1011
+ //
1012
+ // Only the names the caller's own prompt does not already mention are added. A client that names
1013
+ // its tools in its system prompt (Claude Code does, at length) gets nothing at all, so there is no
1014
+ // second list for the model to weigh against the first; a client with no such prompt gets the
1015
+ // whole list. Fewer tools than two cannot be confused with each other, so nothing is said.
1016
+ const toolNotice = mode === 'auto' ? toolNamesNotice(ir, systemText) : '';
1017
+ if (toolNotice) systemText = systemText ? `${systemText}\n\n${toolNotice}` : toolNotice;
889
1018
  if (systemText.trim()) messages.push({ role: 'system', content: systemText });
890
1019
 
891
1020
  for (const m of healToolPairs(ir.messages)) {
@@ -910,8 +1039,9 @@ function irToChatBody(ir, model, opts = {}) {
910
1039
 
911
1040
  const body = { model, messages, stream: ir.stream === true };
912
1041
  if (body.stream) body.stream_options = { include_usage: true };
913
- if (ir.tools.length) {
914
- body.tools = ir.tools.map(t => ({
1042
+ const tools = clientTools(ir);
1043
+ if (tools.length) {
1044
+ body.tools = tools.map(t => ({
915
1045
  type: 'function',
916
1046
  function: { name: t.name, description: t.description || '', parameters: sanitizeJsonSchema(t.parameters || {}) }
917
1047
  }));
@@ -942,20 +1072,26 @@ function irToChatBody(ir, model, opts = {}) {
942
1072
  }
943
1073
 
944
1074
  if (wantsThinking && mode === 'native') {
945
- body.reasoning_effort = ir.thinking?.effort && !['max', 'xhigh'].includes(ir.thinking.effort)
946
- ? ir.thinking.effort
947
- : (ir.thinking?.type === 'adaptive' ? 'high' : (ir.thinking?.budget ? budgetToEffort(ir.thinking.budget) : 'medium'));
1075
+ // A strict OpenAI upstream names four levels and refuses the rest, so `xhigh` becomes `high`
1076
+ // there and not `medium`: the request keeps as much of the depth as the target can take.
1077
+ const asked = ir.thinking?.effort
1078
+ || (ir.thinking?.type === 'adaptive' ? 'high' : (ir.thinking?.budget ? budgetToEffort(ir.thinking.budget) : 'medium'));
1079
+ body.reasoning_effort = capEffort(asked, OPENAI_CHAT_LEVELS);
948
1080
  } else if (wantsThinking) {
949
1081
  if (ir.thinking?.type === 'adaptive') {
950
1082
  body.thinking = { type: 'adaptive' };
951
- body.reasoning_effort = ir.thinking.effort || 'high';
1083
+ body.reasoning_effort = normalizeEffort(ir.thinking.effort || 'high');
952
1084
  } else {
953
1085
  // Restore thinking: if an external compression tool (RTK/Headroom) dropped the thinking object,
954
1086
  // but the target model is a reasoning model, the gateway auto re-enables thinking with a safe budget.
955
1087
  const rawBudget = ir.thinking?.budget ?? (ir.thinking?.effort ? effortToBudget(ir.thinking.effort) : 2048);
956
1088
  const safeBudget = clampBudget(rawBudget);
957
1089
  body.thinking = { type: 'enabled', budget_tokens: safeBudget };
958
- body.reasoning_effort = ir.thinking?.effort || budgetToEffort(safeBudget);
1090
+ // The level is what the client asked for; only when it asked with a budget is the level read
1091
+ // back out of that budget, and the round trip returns the same level it came from.
1092
+ body.reasoning_effort = ir.thinking?.effort
1093
+ ? normalizeEffort(ir.thinking.effort)
1094
+ : budgetToEffort(safeBudget);
959
1095
  }
960
1096
  }
961
1097
  return body;
@@ -1041,8 +1177,9 @@ function irToAnthropicBody(ir, model) {
1041
1177
  stream: ir.stream === true
1042
1178
  };
1043
1179
  if (ir.system && ir.system.trim()) body.system = ir.system;
1044
- if (ir.tools.length) {
1045
- body.tools = ir.tools.map(t => ({
1180
+ const anthropicTools = clientTools(ir);
1181
+ if (anthropicTools.length) {
1182
+ body.tools = anthropicTools.map(t => ({
1046
1183
  name: t.name, description: t.description || '',
1047
1184
  input_schema: sanitizeJsonSchema(t.parameters || {})
1048
1185
  }));
@@ -1061,6 +1198,13 @@ function irToAnthropicBody(ir, model) {
1061
1198
  if (ir.params.stop.length) body.stop_sequences = ir.params.stop;
1062
1199
  // Anthropic has no JSON mode without a schema.
1063
1200
  if (ir.responseFormat?.type === 'json_schema') body.output_config = { format: { type: 'json_schema', schema: ir.responseFormat.schema } };
1201
+ // A level that arrived without a budget (Codex sends `reasoning.effort`) is passed on as the
1202
+ // level Anthropic names, so the depth survives the hop instead of becoming a number. A client
1203
+ // that sent budget_tokens has already said the same thing in Anthropic's own words, and its
1204
+ // budget stands: budget and effort together would be the same request twice.
1205
+ if (ir.thinking && ir.thinking.type !== 'disabled' && ir.thinking.effort && !ir.thinking.budget) {
1206
+ body.output_config = { ...(body.output_config || {}), effort: capEffort(ir.thinking.effort, EFFORT_LEVELS) };
1207
+ }
1064
1208
  if (ir.thinking && ir.thinking.type === 'adaptive') {
1065
1209
  body.thinking = { type: 'adaptive' };
1066
1210
  } else if (ir.thinking && ir.thinking.type !== 'disabled' && body.max_tokens > 1024) {
@@ -1155,9 +1299,10 @@ function irToVertexBody(ir, model) {
1155
1299
  if (ir.system && ir.system.trim()) {
1156
1300
  body.systemInstruction = { parts: [{ text: ir.system }] };
1157
1301
  }
1158
- if (ir.tools.length) {
1302
+ const vertexTools = clientTools(ir);
1303
+ if (vertexTools.length) {
1159
1304
  body.tools = [{
1160
- functionDeclarations: ir.tools.map(t => ({
1305
+ functionDeclarations: vertexTools.map(t => ({
1161
1306
  name: t.name, description: t.description || '',
1162
1307
  parameters: toGeminiSchema(t.parameters || { type: 'object', properties: {} })
1163
1308
  }))
@@ -1179,7 +1324,11 @@ function irToVertexBody(ir, model) {
1179
1324
  // includeThoughts: without this flag Gemini won't return thought parts -> the client loses thinking.
1180
1325
  gc.thinkingConfig = { includeThoughts: true };
1181
1326
  if (ir.thinking.type === 'enabled') {
1182
- gc.thinkingConfig.thinkingBudget = clampBudget(ir.thinking.budget ?? (ir.thinking.effort ? effortToBudget(ir.thinking.effort) : 2048));
1327
+ const wanted = ir.thinking.budget ?? (ir.thinking.effort ? effortToBudget(ir.thinking.effort) : 2048);
1328
+ // Gemini refuses a budget above its ceiling instead of trimming it, and a level Gemini cannot
1329
+ // reach is asked for with the highest budget it takes. The level itself is not invented here:
1330
+ // a budget is what this API speaks.
1331
+ gc.thinkingConfig.thinkingBudget = Math.min(clampBudget(wanted), GEMINI_MAX_BUDGET);
1183
1332
  }
1184
1333
  }
1185
1334
  if (ir.responseFormat) {
@@ -1786,11 +1935,12 @@ function anthropicStopReason(canonical, hasTools) {
1786
1935
  // --- Anthropic SSE + message ---
1787
1936
  // Sequential block state machine: indexes grow in content_block_start order, never
1788
1937
  // write deltas into a stopped block or reuse an index (thinking -> tool -> thinking -> text are all valid).
1789
- function createAnthropicStream(emit, model) {
1938
+ function createAnthropicStream(emit, model, opts = {}) {
1939
+ const vocab = opts.vocab;
1790
1940
  const msgId = `msg_${Date.now()}_${rand()}`;
1791
1941
  let nextIndex = 0;
1792
1942
  let open = null; // { kind: 'thinking' | 'text', index }
1793
- const tools = new Map(); // tool index -> { index, id, name, closed }
1943
+ const tools = new Map(); // tool index -> { index, id, name, closed, buffered }
1794
1944
  let sig = null;
1795
1945
 
1796
1946
  function closeOpen() {
@@ -1801,9 +1951,21 @@ function createAnthropicStream(emit, model) {
1801
1951
  emit('content_block_stop', { type: 'content_block_stop', index: open.index });
1802
1952
  open = null;
1803
1953
  }
1954
+ // A call whose name was rewritten buffers its arguments: a key can only be renamed once the whole
1955
+ // JSON exists, and a half-written object has no key to rename. The buffer goes out as one delta, so
1956
+ // the block is never closed with arguments still pending and never has two deltas fighting over it.
1957
+ function flushBuffered(st) {
1958
+ if (!st.buffered) return;
1959
+ emit('content_block_delta', {
1960
+ type: 'content_block_delta', index: st.index,
1961
+ delta: { type: 'input_json_delta', partial_json: st.buffered }
1962
+ });
1963
+ st.buffered = '';
1964
+ }
1804
1965
  function closeTools() {
1805
1966
  for (const t of tools.values()) {
1806
1967
  if (!t.closed) {
1968
+ flushBuffered(t);
1807
1969
  emit('content_block_stop', { type: 'content_block_stop', index: t.index });
1808
1970
  t.closed = true;
1809
1971
  }
@@ -1849,7 +2011,12 @@ function createAnthropicStream(emit, model) {
1849
2011
  let st = tools.get(key);
1850
2012
  if (!st) {
1851
2013
  closeOpen();
1852
- st = { index: nextIndex++, id: tc.id || `toolu_${rand(24)}`, name: tc.name || 'tool', closed: false };
2014
+ const fixed = vocab ? vocab.name(tc.name) : tc.name;
2015
+ st = {
2016
+ index: nextIndex++, id: tc.id || `toolu_${rand(24)}`, name: fixed || 'tool', closed: false,
2017
+ // Only a renamed name needs the arguments held back.
2018
+ buffered: '', held: Boolean(vocab && fixed && fixed !== tc.name)
2019
+ };
1853
2020
  tools.set(key, st);
1854
2021
  emit('content_block_start', {
1855
2022
  type: 'content_block_start', index: st.index,
@@ -1857,10 +2024,21 @@ function createAnthropicStream(emit, model) {
1857
2024
  });
1858
2025
  }
1859
2026
  if (tc.args) {
1860
- emit('content_block_delta', {
1861
- type: 'content_block_delta', index: st.index,
1862
- delta: { type: 'input_json_delta', partial_json: tc.args }
1863
- });
2027
+ if (!st.held) {
2028
+ emit('content_block_delta', {
2029
+ type: 'content_block_delta', index: st.index,
2030
+ delta: { type: 'input_json_delta', partial_json: tc.args }
2031
+ });
2032
+ return;
2033
+ }
2034
+ st.buffered += tc.args;
2035
+ // The whole object at last: hand it over once, with the caller's own key spellings.
2036
+ const repaired = vocab.args(st.name, st.buffered);
2037
+ if (repaired !== st.buffered) {
2038
+ st.buffered = repaired;
2039
+ st.held = false;
2040
+ flushBuffered(st);
2041
+ }
1864
2042
  }
1865
2043
  },
1866
2044
  finish(canonical, stats = {}) {
@@ -1888,7 +2066,7 @@ function createAnthropicStream(emit, model) {
1888
2066
  };
1889
2067
  }
1890
2068
 
1891
- function buildAnthropicMessage({ model, think, text, tools, finish, prompt, completion, cached, id, sig }) {
2069
+ function buildAnthropicMessage({ model, think, text, tools, finish, prompt, completion, cached, id, sig, vocab }) {
1892
2070
  const content = [];
1893
2071
  const thinking = (think || []).join('');
1894
2072
  if (thinking) {
@@ -1897,7 +2075,10 @@ function buildAnthropicMessage({ model, think, text, tools, finish, prompt, comp
1897
2075
  const body = (text || []).join('');
1898
2076
  if (body) content.push({ type: 'text', text: body });
1899
2077
  for (const tc of (tools || [])) {
1900
- content.push({ type: 'tool_use', id: tc.id || `toolu_${rand(24)}`, name: tc.name || 'tool', input: parseArgs(tc.args) });
2078
+ // The name is written in the vocabulary the caller declared. A name it already uses is not
2079
+ // touched, so the whole layer costs nothing on a turn where the model agreed with the list.
2080
+ const fixed = repairToolCall(tc, vocab);
2081
+ content.push({ type: 'tool_use', id: tc.id || `toolu_${rand(24)}`, name: fixed.name, input: parseArgs(fixed.args) });
1901
2082
  }
1902
2083
  if (!content.length) content.push({ type: 'text', text: '' });
1903
2084
  const p = prompt || 0;
@@ -1912,15 +2093,26 @@ function buildAnthropicMessage({ model, think, text, tools, finish, prompt, comp
1912
2093
  }
1913
2094
 
1914
2095
  // --- OpenAI Chat SSE + object ---
1915
- function createChatStream(emit, model) {
2096
+ function createChatStream(emit, model, opts = {}) {
2097
+ const vocab = opts.vocab;
1916
2098
  const id = `chatcmpl-${Date.now()}${rand(4)}`;
1917
2099
  const created = Math.floor(Date.now() / 1000);
1918
- const seenTools = new Set();
2100
+ const seenTools = new Map(); // tool index -> { idx, name, buffered, held }
1919
2101
  const chunk = (choices, usage) => {
1920
2102
  const o = { id, object: 'chat.completion.chunk', created, model, choices };
1921
2103
  if (usage) o.usage = usage;
1922
2104
  emit(null, o);
1923
2105
  };
2106
+ // Chat has no rule about which block a delta belongs to, so held arguments go out at the end of the
2107
+ // turn. A key can only be renamed once the whole object exists.
2108
+ const flushHeld = () => {
2109
+ for (const st of seenTools.values()) {
2110
+ if (!st.held) continue;
2111
+ chunk([{ index: 0, delta: { tool_calls: [{ index: st.idx, function: { arguments: st.buffered } }] }, finish_reason: null }]);
2112
+ st.held = false;
2113
+ st.buffered = '';
2114
+ }
2115
+ };
1924
2116
  return {
1925
2117
  start() {
1926
2118
  chunk([{ index: 0, delta: { role: 'assistant', content: '' }, finish_reason: null }]);
@@ -1934,13 +2126,26 @@ function createChatStream(emit, model) {
1934
2126
  tool(tc) {
1935
2127
  const idx = tc.index ?? 0;
1936
2128
  if (!seenTools.has(idx)) {
1937
- seenTools.add(idx);
1938
- chunk([{ index: 0, delta: { tool_calls: [{ index: idx, id: tc.id || `call_${rand(24)}`, type: 'function', function: { name: tc.name || 'tool', arguments: tc.args || '' } }] }, finish_reason: null }]);
1939
- } else if (tc.args) {
2129
+ const name = vocab ? vocab.name(tc.name) : tc.name;
2130
+ seenTools.set(idx, { idx, name, buffered: '', held: Boolean(vocab && name && name !== tc.name) });
2131
+ chunk([{ index: 0, delta: { tool_calls: [{ index: idx, id: tc.id || `call_${rand(24)}`, type: 'function', function: { name: name || 'tool', arguments: '' } }] }, finish_reason: null }]);
2132
+ }
2133
+ const st = seenTools.get(idx);
2134
+ if (!tc.args) return;
2135
+ if (!st.held) {
1940
2136
  chunk([{ index: 0, delta: { tool_calls: [{ index: idx, function: { arguments: tc.args } }] }, finish_reason: null }]);
2137
+ return;
2138
+ }
2139
+ st.buffered += tc.args;
2140
+ const repaired = vocab.args(st.name, st.buffered);
2141
+ if (repaired !== st.buffered) {
2142
+ st.buffered = repaired;
2143
+ st.held = false;
2144
+ flushHeld();
1941
2145
  }
1942
2146
  },
1943
2147
  finish(canonical, stats = {}) {
2148
+ flushHeld();
1944
2149
  const completion = stats.completion || 0;
1945
2150
  const prompt = stats.prompt || 0;
1946
2151
  const usage = { prompt_tokens: prompt, completion_tokens: completion, total_tokens: prompt + completion };
@@ -1949,12 +2154,13 @@ function createChatStream(emit, model) {
1949
2154
  chunk([{ index: 0, delta: {}, finish_reason: chatFinish(canonical, stats.hasTools) }], usage);
1950
2155
  },
1951
2156
  error(message) {
2157
+ flushHeld();
1952
2158
  emit(null, { error: { message: String(message || 'Upstream stream error'), type: 'server_error', code: 'upstream_error' } });
1953
2159
  }
1954
2160
  };
1955
2161
  }
1956
2162
 
1957
- function buildChatMessage({ model, think, text, tools, finish, prompt, completion, cached, reasoning, id, stats }) {
2163
+ function buildChatMessage({ model, think, text, tools, finish, prompt, completion, cached, reasoning, id, stats, vocab }) {
1958
2164
  const msg = { role: 'assistant', content: (text || []).join('') || null };
1959
2165
  const thinking = (think || []).join('');
1960
2166
  if (thinking) {
@@ -1962,11 +2168,14 @@ function buildChatMessage({ model, think, text, tools, finish, prompt, completio
1962
2168
  msg.reasoning = thinking;
1963
2169
  }
1964
2170
  if (tools && tools.length) {
1965
- msg.tool_calls = tools.map(tc => ({
1966
- id: tc.id || `call_${rand(24)}`,
1967
- type: 'function',
1968
- function: { name: tc.name || 'tool', arguments: typeof tc.args === 'string' ? tc.args : stringifyArgs(tc.args) }
1969
- }));
2171
+ msg.tool_calls = tools.map(tc => {
2172
+ const fixed = repairToolCall(tc, vocab);
2173
+ return {
2174
+ id: tc.id || `call_${rand(24)}`,
2175
+ type: 'function',
2176
+ function: { name: fixed.name || 'tool', arguments: fixed.args }
2177
+ };
2178
+ });
1970
2179
  }
1971
2180
  if (msg.content === null && !msg.tool_calls) msg.content = '';
1972
2181
  const usage = { prompt_tokens: prompt || 0, completion_tokens: completion || 0, total_tokens: (prompt || 0) + (completion || 0) };
@@ -2032,6 +2241,7 @@ function responsesToolItem(toolMeta, t, status = 'completed') {
2032
2241
 
2033
2242
  function createResponsesStream(emit, model, opts = {}) {
2034
2243
  const toolMeta = opts.toolMeta || null;
2244
+ const vocab = opts.vocab;
2035
2245
  const respId = `resp_${Date.now()}${rand(8)}`;
2036
2246
  const created = Math.floor(Date.now() / 1000);
2037
2247
  let seq = 0;
@@ -2072,6 +2282,8 @@ function createResponsesStream(emit, model, opts = {}) {
2072
2282
  function closeTool(t) {
2073
2283
  if (t.done) return;
2074
2284
  t.done = true;
2285
+ // A held call gets its arguments in the caller's own spelling here, with the item that Codex runs.
2286
+ if (t.held && vocab) t.args = vocab.args(t.name, t.args || '{}');
2075
2287
  const item = responsesToolItem(toolMeta, { itemId: t.id, callId: t.callId, name: t.name, args: t.args });
2076
2288
  if (item.type === 'function_call') {
2077
2289
  send({ type: 'response.function_call_arguments.done', item_id: t.id, output_index: t.index, arguments: t.args });
@@ -2117,9 +2329,17 @@ function createResponsesStream(emit, model, opts = {}) {
2117
2329
  if (!st) {
2118
2330
  closeReasoning();
2119
2331
  closeMessage();
2120
- const kind = toolMeta?.[tc.name]?.kind || 'function';
2332
+ const name = vocab ? vocab.name(tc.name) : tc.name;
2333
+ const kind = toolMeta?.[name]?.kind || toolMeta?.[tc.name]?.kind || 'function';
2121
2334
  const prefix = kind === 'custom' ? 'ctc' : kind === 'local_shell' ? 'lsh' : 'fc';
2122
- st = { id: `${prefix}_${rand(24)}`, callId: tc.id || `call_${rand(24)}`, index: nextOutput++, name: tc.name || '', args: '', done: false, kind };
2335
+ // Codex runs a tool on output_item.done, and the name is in that item. A renamed call
2336
+ // therefore cannot be streamed argument by argument: the keys are only known once the whole
2337
+ // object exists, so its arguments are held and go out with the item.
2338
+ st = {
2339
+ id: `${prefix}_${rand(24)}`, callId: tc.id || `call_${rand(24)}`, index: nextOutput++,
2340
+ name: name || '', args: '', done: false, kind, upstream: tc.name,
2341
+ held: Boolean(vocab && name && name !== tc.name)
2342
+ };
2123
2343
  tools.set(key, st);
2124
2344
  if (kind !== 'local_shell') {
2125
2345
  const added = responsesToolItem(toolMeta, { itemId: st.id, callId: st.callId, name: st.name, args: '' }, 'in_progress');
@@ -2129,6 +2349,7 @@ function createResponsesStream(emit, model, opts = {}) {
2129
2349
  if (!st.name && tc.name) st.name = tc.name;
2130
2350
  if (tc.args && !st.done) {
2131
2351
  st.args += tc.args;
2352
+ if (st.held) return;
2132
2353
  if (st.kind === 'function') {
2133
2354
  send({ type: 'response.function_call_arguments.delta', item_id: st.id, output_index: st.index, delta: tc.args });
2134
2355
  }
@@ -2162,7 +2383,7 @@ function createResponsesStream(emit, model, opts = {}) {
2162
2383
  };
2163
2384
  }
2164
2385
 
2165
- function buildResponsesMessage({ model, think, text, tools, finish, prompt, completion, cached, reasoning, id, toolMeta, stats }) {
2386
+ function buildResponsesMessage({ model, think, text, tools, finish, prompt, completion, cached, reasoning, id, toolMeta, stats, vocab }) {
2166
2387
  const output = [];
2167
2388
  const thinking = (think || []).join('');
2168
2389
  if (thinking) output.push({ id: `rs_${rand(24)}`, type: 'reasoning', summary: [{ type: 'summary_text', text: thinking }] });
@@ -2175,9 +2396,10 @@ function buildResponsesMessage({ model, think, text, tools, finish, prompt, comp
2175
2396
  }
2176
2397
  const incomplete = finish === 'length';
2177
2398
  for (const tc of (tools || [])) {
2399
+ const fixed = repairToolCall(tc, vocab);
2178
2400
  const item = responsesToolItem(toolMeta, {
2179
- callId: tc.id || `call_${rand(24)}`, name: tc.name,
2180
- args: typeof tc.args === 'string' ? tc.args : stringifyArgs(tc.args)
2401
+ callId: tc.id || `call_${rand(24)}`, name: fixed.name,
2402
+ args: fixed.args
2181
2403
  });
2182
2404
  // Same rule as the stream: a length stop never hands Codex a call with truncated arguments.
2183
2405
  if (incomplete && typeof tc.args === 'string') {
@@ -2306,7 +2528,7 @@ export {
2306
2528
  canonFinish, chatFinish, anthropicStopReason,
2307
2529
  smartReasoning, smartText, smartToolCalls, smartUsage, smartFinish,
2308
2530
  firstChoice, smartDelta, sanitizeJsonSchema, toGeminiSchema, splitParts,
2309
- budgetToEffort, effortToBudget, clampBudget, parseArgs, stringifyArgs,
2531
+ budgetToEffort, effortToBudget, clampBudget, normalizeEffort, capEffort, parseArgs, stringifyArgs,
2310
2532
  anthropicToIR, responsesToIR, codeAssistToIR, parseToIR,
2311
2533
  healToolPairs, healAnthropicPayload, rememberToolSignature, lookupToolSignature, estimateTokens, irToChatBody, irToAnthropicBody, irToVertexBody, emitUpstreamBody,
2312
2534
  normalizeUpstream, createUpstreamNormalizer, createCollector,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "llm-switcher",
3
- "version": "1.3.7",
3
+ "version": "1.4.0",
4
4
  "description": "Zero-dependency multi-protocol edge gateway & provider switcher for Claude Code and Codex, with OpenAI-compatible, Anthropic and Vertex upstreams",
5
5
  "keywords": [
6
6
  "llm",