llm-switcher 1.1.6 → 1.1.8
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/README.md +10 -1
- package/README.vi.md +10 -1
- package/formats.mjs +19 -10
- package/package.json +1 -1
- package/shim.mjs +9 -0
- package/state.mjs +5 -1
- package/switch.mjs +3 -1
- package/tests/formats.test.mjs +23 -0
- package/tests/gateway.e2e.test.mjs +20 -0
- package/tests/shim.test.mjs +17 -1
- package/tests/state.test.mjs +10 -0
package/README.md
CHANGED
|
@@ -191,7 +191,16 @@ flowchart LR
|
|
|
191
191
|
|
|
192
192
|
---
|
|
193
193
|
|
|
194
|
-
## Changes in 1.1.
|
|
194
|
+
## Changes in 1.1.8
|
|
195
|
+
|
|
196
|
+
- **Claude Code tools.** A tool schema value of `0`, `false`, `""` or `null` (for example `minimum: 0`) became an empty object schema. Gemini refused every Claude Code request with HTTP 400 "Starting an object on a scalar field". These values now stay as they are.
|
|
197
|
+
- **Shim PATH order.** If the shim folder is on `PATH` but after the real `claude` or `codex`, `switch shim status` and `switch doctor` now tell you to put the export line last in your shell files.
|
|
198
|
+
|
|
199
|
+
### Changes in 1.1.7
|
|
200
|
+
|
|
201
|
+
- **Codex tools.** With `publicModels` set, Codex lost its tools and ended after one answer. The model catalog copied the metadata of a real OpenAI model, which puts Codex in the "Responses Lite" form. The catalog now keeps Codex in its direct tool mode, and the gateway also reads tools that arrive as an `additional_tools` input item.
|
|
202
|
+
|
|
203
|
+
### Changes in 1.1.6
|
|
195
204
|
|
|
196
205
|
- **Codex over WebSocket.** The gateway keeps the turns of each WebSocket session. A turn that sends `previous_response_id` gets the earlier turns back, so Codex no longer loses the task after the first tool call. An unknown id fails the turn with `previous_response_not_found`.
|
|
197
206
|
- **Codex warmup.** A `response.create` frame with `generate: false` gets a local answer. It no longer spends a model call.
|
package/README.vi.md
CHANGED
|
@@ -191,7 +191,16 @@ flowchart LR
|
|
|
191
191
|
|
|
192
192
|
---
|
|
193
193
|
|
|
194
|
-
## Thay đổi trong bản 1.1.
|
|
194
|
+
## Thay đổi trong bản 1.1.8
|
|
195
|
+
|
|
196
|
+
- **Tool của Claude Code.** Giá trị `0`, `false`, `""` hoặc `null` trong schema của tool (ví dụ `minimum: 0`) bị đổi thành schema object rỗng. Gemini từ chối mọi request của Claude Code với HTTP 400 "Starting an object on a scalar field". Giờ các giá trị này được giữ nguyên.
|
|
197
|
+
- **Thứ tự PATH của shim.** Nếu thư mục shim có trong `PATH` nhưng đứng sau `claude` hoặc `codex` thật, `switch shim status` và `switch doctor` giờ chỉ cách sửa: đặt dòng export ở cuối các file cấu hình shell.
|
|
198
|
+
|
|
199
|
+
### Thay đổi trong bản 1.1.7
|
|
200
|
+
|
|
201
|
+
- **Tool của Codex.** Khi có `publicModels`, Codex mất hết tool và dừng sau một câu trả lời. Model catalog chép metadata của một model OpenAI thật, và metadata này đưa Codex sang dạng "Responses Lite". Giờ catalog giữ Codex ở chế độ tool trực tiếp, và gateway cũng đọc tool gửi đến dưới dạng input item `additional_tools`.
|
|
202
|
+
|
|
203
|
+
### Thay đổi trong bản 1.1.6
|
|
195
204
|
|
|
196
205
|
- **Codex qua WebSocket.** Gateway giữ các lượt của mỗi phiên WebSocket. Lượt nào gửi `previous_response_id` sẽ nhận lại các lượt trước, nên Codex không còn mất nhiệm vụ sau lần gọi tool đầu tiên. Id không tồn tại làm lượt đó lỗi với `previous_response_not_found`.
|
|
197
206
|
- **Warmup của Codex.** Frame `response.create` có `generate: false` được trả lời ngay tại máy. Frame này không còn tốn một lần gọi model.
|
package/formats.mjs
CHANGED
|
@@ -253,22 +253,24 @@ function smartDelta(choice) {
|
|
|
253
253
|
const SCHEMA_MAPS = new Set(['properties', 'patternProperties', '$defs', 'definitions']);
|
|
254
254
|
|
|
255
255
|
function sanitizeJsonSchema(schema) {
|
|
256
|
-
if (
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
256
|
+
if (schema === null || schema === undefined) return { type: 'object', properties: {} };
|
|
257
|
+
return sanitizeSchemaNode(schema);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Inside a schema 0, false, "" and null are values (minimum: 0, additionalProperties: false), not a missing schema.
|
|
261
|
+
function sanitizeSchemaNode(schema) {
|
|
262
|
+
if (schema === null || typeof schema !== 'object') return schema;
|
|
263
|
+
if (Array.isArray(schema)) return schema.map(sanitizeSchemaNode);
|
|
262
264
|
const clean = {};
|
|
263
265
|
for (const [k, v] of Object.entries(schema)) {
|
|
264
266
|
if (k === '$schema' || k === 'cache_control' || k === 'encrypted') continue;
|
|
265
267
|
if (k === 'format' && ['uri', 'uri-reference'].includes(v)) continue;
|
|
266
268
|
// A name map: each value is a schema, the map itself is not. A parameter may be named "properties".
|
|
267
269
|
if (SCHEMA_MAPS.has(k) && v && typeof v === 'object' && !Array.isArray(v)) {
|
|
268
|
-
clean[k] = Object.fromEntries(Object.entries(v).map(([name, sub]) => [name,
|
|
270
|
+
clean[k] = Object.fromEntries(Object.entries(v).map(([name, sub]) => [name, sanitizeSchemaNode(sub)]));
|
|
269
271
|
continue;
|
|
270
272
|
}
|
|
271
|
-
clean[k] =
|
|
273
|
+
clean[k] = sanitizeSchemaNode(v);
|
|
272
274
|
}
|
|
273
275
|
if (!clean.type && clean.properties) {
|
|
274
276
|
clean.type = 'object';
|
|
@@ -612,6 +614,8 @@ function responsesToIR(payload) {
|
|
|
612
614
|
else ir.messages.push({ role: 'assistant', toolCalls: [call] });
|
|
613
615
|
};
|
|
614
616
|
|
|
617
|
+
// Responses Lite sends the tools as an input item, not in payload.tools.
|
|
618
|
+
const toolList = Array.isArray(payload.tools) ? [...payload.tools] : [];
|
|
615
619
|
const input = payload.input;
|
|
616
620
|
if (typeof input === 'string' && input) {
|
|
617
621
|
ir.messages.push({ role: 'user', content: input });
|
|
@@ -655,14 +659,19 @@ function responsesToIR(payload) {
|
|
|
655
659
|
pushCall({ id: item.call_id || item.id || null, name: 'local_shell', args: { command: a.command || [], workdir: a.working_directory ?? undefined, timeout_ms: a.timeout_ms ?? undefined } });
|
|
656
660
|
} else if (item.type === 'function_call_output' || item.type === 'custom_tool_call_output' || item.type === 'local_shell_call_output') {
|
|
657
661
|
ir.messages.push({ role: 'tool', toolCallId: item.call_id || item.id, content: responsesOutputToText(item.output) });
|
|
662
|
+
} else if (item.type === 'additional_tools' && Array.isArray(item.tools)) {
|
|
663
|
+
toolList.push(...item.tools);
|
|
658
664
|
}
|
|
659
665
|
}
|
|
660
666
|
}
|
|
661
667
|
|
|
662
|
-
if (
|
|
668
|
+
if (toolList.length) {
|
|
663
669
|
ir.toolMeta = {};
|
|
664
670
|
const addTool = (t, namespace) => {
|
|
665
671
|
if (!t || typeof t !== 'object') return;
|
|
672
|
+
// A tool in both payload.tools and an additional_tools item is sent once: upstreams refuse duplicate names.
|
|
673
|
+
if (t.name && t.type !== 'namespace' && ir.toolMeta[responsesToolName(namespace, t.name)]) return;
|
|
674
|
+
if (t.type === 'local_shell' && ir.toolMeta.local_shell) return;
|
|
666
675
|
if (t.type === 'namespace' && Array.isArray(t.tools)) {
|
|
667
676
|
for (const inner of t.tools) addTool(inner, t.name);
|
|
668
677
|
return;
|
|
@@ -681,7 +690,7 @@ function responsesToIR(payload) {
|
|
|
681
690
|
}
|
|
682
691
|
// web_search / file_search / tool_search / image_generation...: hosted tools, skipped.
|
|
683
692
|
};
|
|
684
|
-
for (const t of
|
|
693
|
+
for (const t of toolList) addTool(t, null);
|
|
685
694
|
}
|
|
686
695
|
|
|
687
696
|
const tc = payload.tool_choice;
|
package/package.json
CHANGED
package/shim.mjs
CHANGED
|
@@ -233,6 +233,15 @@ export function suggestedRcFiles(platform = process.platform) {
|
|
|
233
233
|
return [path.join(home, '.profile')];
|
|
234
234
|
}
|
|
235
235
|
|
|
236
|
+
// The shim is on PATH but behind the real binary: a line that runs later prepends another directory
|
|
237
|
+
// (npm-global, Homebrew). Login shells read the profile file, interactive shells the rc file.
|
|
238
|
+
export function pathOrderHint(platform = process.platform) {
|
|
239
|
+
const files = suggestedRcFiles(platform);
|
|
240
|
+
if (!files.length) return [`Run this command, then open a new terminal: ${pathExportLine(platform)}`];
|
|
241
|
+
return [`Put this line LAST in ${files.join(' and ')}, after every other PATH line, then open a new terminal:`,
|
|
242
|
+
` ${pathExportLine(platform)}`];
|
|
243
|
+
}
|
|
244
|
+
|
|
236
245
|
/**
|
|
237
246
|
* Check whether running CLI processes have the gateway env.
|
|
238
247
|
* This is the hardest failure to spot: a `claude` session opened BEFORE the gateway
|
package/state.mjs
CHANGED
|
@@ -346,8 +346,12 @@ export function buildCodexCatalog(profile) {
|
|
|
346
346
|
|
|
347
347
|
// One catalog entry. The catalog file and the gateway's /v1/models both use it, so they cannot drift.
|
|
348
348
|
export function codexModelEntry(name, is1M) {
|
|
349
|
+
// The template copies a real OpenAI model. Its Responses Lite and code modes send the tools in a
|
|
350
|
+
// form made for OpenAI's own tools, and an upstream then gets none (LS-5).
|
|
351
|
+
const { tool_mode, ...template } = structuredClone(codexCatalogTemplate() || {});
|
|
349
352
|
return {
|
|
350
|
-
...
|
|
353
|
+
...template,
|
|
354
|
+
use_responses_lite: false,
|
|
351
355
|
slug: name,
|
|
352
356
|
display_name: name,
|
|
353
357
|
...(is1M ? { context_window: 1000000, max_context_window: 1000000 } : {})
|
package/switch.mjs
CHANGED
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
} from './state.mjs';
|
|
14
14
|
import { runProbe, runCheck } from './contract.mjs';
|
|
15
15
|
import {
|
|
16
|
-
SHIM_DIR, installShims, uninstallShims, shimStatus, pathExportLine,
|
|
16
|
+
SHIM_DIR, installShims, uninstallShims, shimStatus, pathExportLine, pathOrderHint,
|
|
17
17
|
suggestedRcFiles, auditRunningProcesses
|
|
18
18
|
} from './shim.mjs';
|
|
19
19
|
import {
|
|
@@ -739,6 +739,7 @@ async function manageShim(action = 'status') {
|
|
|
739
739
|
if (s.active) console.log(`[PASS] ${s.name}: shim active → ${s.effective}`);
|
|
740
740
|
else console.log(`[WARN] ${s.name}: shim installed but '${s.name}' resolves to ${s.effective || '(not found)'} — PATH order wrong`);
|
|
741
741
|
}
|
|
742
|
+
if (st.onPath && st.shims.some(s => s.installed && !s.active)) for (const line of pathOrderHint()) console.log(` ${line}`);
|
|
742
743
|
|
|
743
744
|
const audit = auditActiveClis();
|
|
744
745
|
if (!audit.supported) console.log('\n[INFO] The running-session check is not supported on Windows.');
|
|
@@ -832,6 +833,7 @@ async function runDoctor() {
|
|
|
832
833
|
else if (!s.active) warn(`[WARN] '${s.name}' resolves to ${s.effective || '(not found)'} instead of the shim — PATH order wrong.`);
|
|
833
834
|
else console.log(`[PASS] '${s.name}' routed through shim.`);
|
|
834
835
|
}
|
|
836
|
+
if (sh.onPath && sh.shims.some(s => s.installed && !s.active)) for (const line of pathOrderHint()) console.log(` ${line}`);
|
|
835
837
|
|
|
836
838
|
// 7. Running processes missing env => those sessions call the provider directly
|
|
837
839
|
const audit = auditActiveClis();
|
package/tests/formats.test.mjs
CHANGED
|
@@ -792,3 +792,26 @@ test('allowed_tools that matches no declared tool gives a request without tools,
|
|
|
792
792
|
}
|
|
793
793
|
}
|
|
794
794
|
});
|
|
795
|
+
|
|
796
|
+
test('responsesToIR reads additional_tools items and sends a tool once', () => {
|
|
797
|
+
const tool = { type: 'function', name: 'exec_command', parameters: { type: 'object' } };
|
|
798
|
+
const ir = responsesToIR({ model: 'm', tools: [tool], input: [{ type: 'additional_tools', role: 'developer', tools: [tool, { type: 'custom', name: 'apply_patch' }] }] });
|
|
799
|
+
assert.deepEqual(ir.tools.map(t => t.name), ['exec_command', 'apply_patch']);
|
|
800
|
+
assert.equal(ir.messages.length, 0, 'the item is not a message');
|
|
801
|
+
});
|
|
802
|
+
|
|
803
|
+
// ISS-CC-LS-001: a falsy value inside a schema (minimum: 0, additionalProperties: false) is a value,
|
|
804
|
+
// not a missing schema. Gemini refuses an object where it expects a number.
|
|
805
|
+
test('tool schemas keep 0, false, "" and null values', () => {
|
|
806
|
+
const schema = { type: 'object', additionalProperties: false, properties: {
|
|
807
|
+
total: { type: 'integer', minimum: 0, maximum: 9007199254740991 },
|
|
808
|
+
tags: { type: 'array', minItems: 0, items: { type: 'string', default: '' } },
|
|
809
|
+
note: { type: ['string', 'null'], default: null } } };
|
|
810
|
+
const ir = responsesToIR({ model: 'm', input: 'hi', tools: [{ type: 'function', name: 'f', parameters: schema }] });
|
|
811
|
+
const p = ir.tools[0].parameters;
|
|
812
|
+
assert.equal(p.additionalProperties, false);
|
|
813
|
+
assert.equal(p.properties.total.minimum, 0);
|
|
814
|
+
assert.equal(p.properties.tags.minItems, 0);
|
|
815
|
+
assert.equal(p.properties.tags.items.default, '');
|
|
816
|
+
assert.equal(p.properties.note.default, null);
|
|
817
|
+
});
|
|
@@ -845,6 +845,26 @@ test('Codex WS: a generate:false warmup is answered locally, never sent upstream
|
|
|
845
845
|
ws.socket.destroy();
|
|
846
846
|
});
|
|
847
847
|
|
|
848
|
+
// LS-5: in the Responses Lite form Codex sends its tools as an additional_tools input item of the
|
|
849
|
+
// warmup, not as a tools field. The next turn must reach upstream with those tools.
|
|
850
|
+
test('Codex WS: tools sent as an additional_tools item reach upstream', async () => {
|
|
851
|
+
const ws = await rawWs();
|
|
852
|
+
ws.socket.write(clientFrame(1, JSON.stringify({ type: 'response.create', model: 'main', generate: false, input: [
|
|
853
|
+
{ type: 'additional_tools', id: 'at_1', role: 'developer', tools: [
|
|
854
|
+
{ type: 'function', name: 'exec_command', description: 'Run a command', parameters: { type: 'object', properties: { cmd: { type: 'string' } } } },
|
|
855
|
+
{ type: 'custom', name: 'apply_patch', description: 'Patch files' }] },
|
|
856
|
+
{ type: 'message', role: 'developer', content: [{ type: 'input_text', text: 'You are a coding agent.' }] }] })));
|
|
857
|
+
assert.ok(await until(() => ws.messages.some(m => m.type === 'response.completed')), 'the warmup completes');
|
|
858
|
+
const warmId = ws.messages.find(m => m.type === 'response.completed').response.id;
|
|
859
|
+
const before = received.length;
|
|
860
|
+
ws.socket.write(clientFrame(1, JSON.stringify({ type: 'response.create', model: 'main', previous_response_id: warmId,
|
|
861
|
+
input: [{ type: 'message', role: 'user', content: [{ type: 'input_text', text: 'fix calc.py' }] }] })));
|
|
862
|
+
assert.ok(await until(() => received.length > before && ws.messages.filter(m => m.type === 'response.completed' || m.type === 'response.failed').length === 2), 'turn 2 ends');
|
|
863
|
+
const sent = JSON.stringify(received.slice(before).at(-1).body);
|
|
864
|
+
assert.ok(sent.includes('exec_command') && sent.includes('apply_patch'), `the tools are lost: ${sent.slice(0, 400)}`);
|
|
865
|
+
ws.socket.destroy();
|
|
866
|
+
});
|
|
867
|
+
|
|
848
868
|
test('Codex WS: a mid-stream error is logged with its text', async () => {
|
|
849
869
|
const ws = await rawWs();
|
|
850
870
|
ws.socket.write(clientFrame(1, JSON.stringify({ type: 'response.create', model: 'main', input: 'MID_STREAM_ERROR over ws' })));
|
package/tests/shim.test.mjs
CHANGED
|
@@ -19,7 +19,7 @@ const ROOT = path.resolve(__dirname, '..');
|
|
|
19
19
|
const STATE = fs.mkdtempSync(path.join(os.tmpdir(), 'shimstate-'));
|
|
20
20
|
process.env.LLM_SWITCHER_STATE_DIR = STATE;
|
|
21
21
|
|
|
22
|
-
const { SHIM_DIR, SHIMMED, pathExportLine, suggestedRcFiles, shimStatus, renderShim } =
|
|
22
|
+
const { SHIM_DIR, SHIMMED, pathExportLine, pathOrderHint, suggestedRcFiles, shimStatus, renderShim } =
|
|
23
23
|
await import(pathToFileURL(path.join(ROOT, 'shim.mjs')).href);
|
|
24
24
|
|
|
25
25
|
// The behavioural tests run the CURRENT template, rendered into a temp dir. Running the copy
|
|
@@ -226,3 +226,19 @@ test('the POSIX shims source launch files only when this account owns them', ()
|
|
|
226
226
|
}
|
|
227
227
|
}
|
|
228
228
|
});
|
|
229
|
+
|
|
230
|
+
// ISS-CC-LS-002: on zsh a later line in .zshrc or .zprofile can prepend npm-global or Homebrew
|
|
231
|
+
// ahead of the shim. The hint names both files and says the line must come last.
|
|
232
|
+
test('pathOrderHint tells a zsh user to put the export last in .zshrc and .zprofile', () => {
|
|
233
|
+
const shell = process.env.SHELL;
|
|
234
|
+
process.env.SHELL = '/bin/zsh';
|
|
235
|
+
try {
|
|
236
|
+
const text = pathOrderHint('darwin').join('\n');
|
|
237
|
+
assert.match(text, /LAST/);
|
|
238
|
+
assert.match(text, /\.zshrc/);
|
|
239
|
+
assert.match(text, /\.zprofile/);
|
|
240
|
+
assert.ok(text.includes(pathExportLine('darwin')));
|
|
241
|
+
} finally {
|
|
242
|
+
if (shell === undefined) delete process.env.SHELL; else process.env.SHELL = shell;
|
|
243
|
+
}
|
|
244
|
+
});
|
package/tests/state.test.mjs
CHANGED
|
@@ -699,3 +699,13 @@ test('an empty launch lock is held, not taken over', async (t) => {
|
|
|
699
699
|
assert.ok(waited >= 100 && waited < 4000, `the writer waited ${waited} ms`);
|
|
700
700
|
assert.equal(fs.existsSync(lock), false);
|
|
701
701
|
});
|
|
702
|
+
|
|
703
|
+
// LS-5: the template copies a real OpenAI model that runs Codex in Responses Lite and code mode.
|
|
704
|
+
// Those modes send the tools in a form made for OpenAI's own tools, so every entry turns them off.
|
|
705
|
+
test('buildCodexCatalog entries keep Codex in its direct tool mode', () => {
|
|
706
|
+
const catalog = buildCodexCatalog({ inFormat: 'responses', publicModels: ['gpt-5.6-sol'] });
|
|
707
|
+
for (const entry of catalog.models) {
|
|
708
|
+
assert.equal(entry.use_responses_lite, false);
|
|
709
|
+
assert.equal(Object.hasOwn(entry, 'tool_mode'), false);
|
|
710
|
+
}
|
|
711
|
+
});
|