llm-switcher 1.1.7 → 1.1.9
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 +11 -2
- package/README.vi.md +11 -2
- package/formats.mjs +10 -8
- package/package.json +1 -1
- package/proxy.mjs +4 -1
- package/shim.mjs +9 -0
- package/switch.mjs +3 -1
- package/tests/formats.test.mjs +16 -0
- package/tests/gateway.e2e.test.mjs +19 -4
- package/tests/shim.test.mjs +17 -1
- package/ui.html +5 -8
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.9
|
|
195
|
+
|
|
196
|
+
- **Dashboard.** Open `http://127.0.0.1:3456/ui` directly. The page no longer needs the link from `switch ui`: the gateway puts the admin token in the page. Pages of other sites still cannot read it.
|
|
197
|
+
|
|
198
|
+
### Changes in 1.1.8
|
|
199
|
+
|
|
200
|
+
- **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.
|
|
201
|
+
- **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.
|
|
202
|
+
|
|
203
|
+
### Changes in 1.1.7
|
|
195
204
|
|
|
196
205
|
- **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.
|
|
197
206
|
|
|
@@ -604,7 +613,7 @@ Before it sends a sample, the gateway masks every string value with `x` of the s
|
|
|
604
613
|
## Security Model
|
|
605
614
|
|
|
606
615
|
- The gateway binds to `127.0.0.1` only and rejects requests whose `Host` is not a loopback name (DNS-rebinding protection) or whose `Origin` is not the dashboard itself (CSRF protection).
|
|
607
|
-
- The admin API (`/api/*`) requires the `x-llm-switcher-token` header. The gateway creates the token in `admin.token`, next to `config.json`, with mode 0600.
|
|
616
|
+
- The admin API (`/api/*`) requires the `x-llm-switcher-token` header. The gateway creates the token in `admin.token`, next to `config.json`, with mode 0600. The gateway puts this token in the dashboard page, so `http://127.0.0.1:3456/ui` works when you open it directly. The Host and Origin checks keep the page and the token from other sites. The MCP server reads the file. `/v1/*` and `/health` need no token.
|
|
608
617
|
- API keys are never sent to the browser: `/api/status` returns redacted profiles and the dashboard keeps the stored key unless you type a new one. The stored key goes only to the stored `baseURL` and `endpoints` of that profile. A save that changes either one must carry the key again.
|
|
609
618
|
- Every dashboard change carries the config revision that the page loaded. If another tab, the CLI or the MCP server saved in the meantime, the gateway answers 409 and the page reloads instead of overwriting that change.
|
|
610
619
|
- Client credentials such as `x-api-key`, `authorization` or `x-goog-api-key` are **not** forwarded to upstreams. Other `x-*` headers, `traceparent` and `tracestate` pass through. The gateway drops its own control headers (`x-profile`, `x-llm-profile`) and the network identity headers (`x-forwarded-*`, `x-real-ip`).
|
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.9
|
|
195
|
+
|
|
196
|
+
- **Dashboard.** Mở thẳng `http://127.0.0.1:3456/ui` là dùng được. Trang không cần link từ `switch ui` nữa: gateway đặt admin token vào trang. Trang của web khác vẫn không đọc được token.
|
|
197
|
+
|
|
198
|
+
### Thay đổi trong bản 1.1.8
|
|
199
|
+
|
|
200
|
+
- **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.
|
|
201
|
+
- **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.
|
|
202
|
+
|
|
203
|
+
### Thay đổi trong bản 1.1.7
|
|
195
204
|
|
|
196
205
|
- **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`.
|
|
197
206
|
|
|
@@ -604,7 +613,7 @@ Trước khi gửi mẫu, gateway che mọi giá trị string bằng chuỗi `x`
|
|
|
604
613
|
## Mô hình Bảo mật
|
|
605
614
|
|
|
606
615
|
- Gateway chỉ lắng nghe `127.0.0.1` và từ chối request có `Host` không phải loopback (chống DNS rebinding) hoặc `Origin` không phải chính dashboard (chống CSRF).
|
|
607
|
-
- Admin API (`/api/*`) bắt buộc header `x-llm-switcher-token`. Gateway tạo token trong file `admin.token`, cạnh `config.json`, với mode 0600.
|
|
616
|
+
- Admin API (`/api/*`) bắt buộc header `x-llm-switcher-token`. Gateway tạo token trong file `admin.token`, cạnh `config.json`, với mode 0600. Gateway đặt token này vào trang dashboard, nên mở thẳng `http://127.0.0.1:3456/ui` là dùng được. Lớp kiểm tra Host và Origin ngăn trang web khác đọc trang và token. MCP server đọc token từ file. `/v1/*` và `/health` không cần token.
|
|
608
617
|
- API key không bao giờ gửi xuống trình duyệt: `/api/status` trả profile đã che key, dashboard giữ nguyên key đã lưu nếu bạn không nhập key mới. Key đã lưu chỉ được gửi tới `baseURL` và `endpoints` đã lưu của chính profile đó. Lần lưu nào đổi một trong hai thì phải nhập lại key.
|
|
609
618
|
- Mỗi thay đổi từ dashboard mang theo revision của config mà trang đã tải. Nếu tab khác, CLI hoặc MCP server đã lưu trước đó, gateway trả 409 và trang tải lại thay vì ghi đè thay đổi kia.
|
|
610
619
|
- Credential của client (`x-api-key`, `authorization`, `x-goog-api-key`) **không** được chuyển tiếp lên upstream. Các header `x-*` khác, `traceparent` và `tracestate` được chuyển tiếp. Gateway bỏ header điều khiển của chính nó (`x-profile`, `x-llm-profile`) và header định danh mạng (`x-forwarded-*`, `x-real-ip`).
|
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';
|
package/package.json
CHANGED
package/proxy.mjs
CHANGED
|
@@ -1114,7 +1114,10 @@ async function route(req, res) {
|
|
|
1114
1114
|
'X-Frame-Options': 'DENY',
|
|
1115
1115
|
'X-Content-Type-Options': 'nosniff'
|
|
1116
1116
|
});
|
|
1117
|
-
|
|
1117
|
+
// One gateway serves one user, so the page opened at /ui carries its own token. The Host and Origin
|
|
1118
|
+
// guard above keeps it from other sites; the token is hex, so it needs no escaping.
|
|
1119
|
+
const meta = `<meta name="llm-switcher-token" content="${ADMIN_TOKEN.toString()}">`;
|
|
1120
|
+
return res.end(fs.readFileSync(uiHtmlPath, 'utf8').replace('<head>', `<head>\n ${meta}`));
|
|
1118
1121
|
}
|
|
1119
1122
|
}
|
|
1120
1123
|
|
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/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
|
@@ -799,3 +799,19 @@ test('responsesToIR reads additional_tools items and sends a tool once', () => {
|
|
|
799
799
|
assert.deepEqual(ir.tools.map(t => t.name), ['exec_command', 'apply_patch']);
|
|
800
800
|
assert.equal(ir.messages.length, 0, 'the item is not a message');
|
|
801
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
|
+
});
|
|
@@ -530,6 +530,25 @@ test('Security: foreign Host / Origin are rejected (DNS rebinding & CSRF)', asyn
|
|
|
530
530
|
|
|
531
531
|
// Any local process can reach loopback. Without a token it must get nothing from /api/*,
|
|
532
532
|
// and a masked key must never be resolved for a baseURL the profile does not have.
|
|
533
|
+
// One gateway serves one user on one machine: the dashboard opened at /ui, with no link from `switch ui`,
|
|
534
|
+
// carries its own token. A page of another site and a rebound Host still get nothing.
|
|
535
|
+
test('Dashboard: /ui opened directly carries the admin token, other sites do not get it', async () => {
|
|
536
|
+
for (const p of ['/ui', '/']) {
|
|
537
|
+
const html = await (await fetch(url(p))).text();
|
|
538
|
+
assert.ok(html.includes(`<meta name="llm-switcher-token" content="${adminToken()}">`), `${p} has no token`);
|
|
539
|
+
}
|
|
540
|
+
const foreign = await fetch(url('/ui'), { headers: { origin: 'https://evil.example' } });
|
|
541
|
+
assert.equal(foreign.status, 403);
|
|
542
|
+
assert.ok(!(await foreign.text()).includes(adminToken()));
|
|
543
|
+
const rebound = await new Promise((resolve, reject) => {
|
|
544
|
+
http.get({ host: '127.0.0.1', port: proxyPort, path: '/ui', headers: { host: 'evil.example' } }, res => {
|
|
545
|
+
let b = ''; res.on('data', d => { b += d; }); res.on('end', () => resolve({ status: res.statusCode, body: b }));
|
|
546
|
+
}).on('error', reject);
|
|
547
|
+
});
|
|
548
|
+
assert.equal(rebound.status, 403);
|
|
549
|
+
assert.ok(!rebound.body.includes(adminToken()));
|
|
550
|
+
});
|
|
551
|
+
|
|
533
552
|
test('Security: the admin API refuses a caller without the token and changes nothing', async () => {
|
|
534
553
|
const hits = [];
|
|
535
554
|
const sink = http.createServer((req, res) => { hits.push(req.headers); res.writeHead(200, { 'content-type': 'application/json' }); res.end('{"data":[]}'); });
|
|
@@ -560,10 +579,6 @@ test('Security: the admin API refuses a caller without the token and changes not
|
|
|
560
579
|
assert.equal(hits.length, 1);
|
|
561
580
|
assert.ok(!JSON.stringify(hits[0]).includes('sk-secret-chat'), 'the stored key is not sent to a foreign baseURL');
|
|
562
581
|
|
|
563
|
-
for (const p of ['/', '/ui']) {
|
|
564
|
-
const page = await (await fetch(url(p))).text();
|
|
565
|
-
assert.ok(!page.includes(adminToken()), `${p} must not embed the token`);
|
|
566
|
-
}
|
|
567
582
|
assert.equal((await fetch(url('/health'))).status, 200, '/health needs no token');
|
|
568
583
|
assert.equal((fs.statSync(path.join(tmpDir, 'admin.token')).mode & 0o777).toString(8), '600');
|
|
569
584
|
} finally {
|
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/ui.html
CHANGED
|
@@ -1160,18 +1160,15 @@
|
|
|
1160
1160
|
<div id="toast" class="toast" role="status" aria-live="polite" aria-atomic="true"></div>
|
|
1161
1161
|
|
|
1162
1162
|
<script>
|
|
1163
|
-
// /api/* needs the
|
|
1164
|
-
//
|
|
1165
|
-
// localStorage: while the gateway is off, another account can serve a page on this origin, and that
|
|
1166
|
-
// page must not find the token.
|
|
1163
|
+
// /api/* needs the admin token. The gateway puts it in this page; an older `switch ui` link still
|
|
1164
|
+
// brings it in the URL fragment, which is cleared from the address bar.
|
|
1167
1165
|
const ADMIN_TOKEN_KEY = 'llmSwitcherAdminToken';
|
|
1168
1166
|
(() => {
|
|
1169
1167
|
localStorage.removeItem(ADMIN_TOKEN_KEY);
|
|
1168
|
+
const served = document.querySelector('meta[name="llm-switcher-token"]')?.content;
|
|
1170
1169
|
const m = location.hash.match(/(?:^#|&)token=([0-9a-f]+)/);
|
|
1171
|
-
if (m)
|
|
1172
|
-
|
|
1173
|
-
history.replaceState(null, '', location.pathname + location.search);
|
|
1174
|
-
}
|
|
1170
|
+
if (served || m) sessionStorage.setItem(ADMIN_TOKEN_KEY, served || m[1]);
|
|
1171
|
+
if (m) history.replaceState(null, '', location.pathname + location.search);
|
|
1175
1172
|
})();
|
|
1176
1173
|
|
|
1177
1174
|
// A config change carries the revision this page loaded. The gateway answers 409 when another tab
|