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
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
|
-
|
|
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
|
-
|
|
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
|
|
419
|
-
|
|
420
|
-
|
|
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')
|
|
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
|
-
|
|
914
|
-
|
|
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
|
-
|
|
946
|
-
|
|
947
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1045
|
-
|
|
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
|
-
|
|
1302
|
+
const vertexTools = clientTools(ir);
|
|
1303
|
+
if (vertexTools.length) {
|
|
1159
1304
|
body.tools = [{
|
|
1160
|
-
functionDeclarations:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
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
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
2180
|
-
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