llm-switcher 1.1.2 → 1.1.4

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 CHANGED
@@ -191,7 +191,16 @@ flowchart LR
191
191
 
192
192
  ---
193
193
 
194
- ## Changes in 1.1.2
194
+ ## Changes in 1.1.4
195
+
196
+ - **Contract lab privacy.** A sample that the gateway sends to intact carries no client content. Prompts, answers, tool arguments and results, files and user ids are masked on this machine before the upload. The fields that intact needs for analysis stay.
197
+
198
+ ### Changes in 1.1.3
199
+
200
+ - **Dashboard.** Opened without its access token, the page no longer waits on "Checking status...". It says that it is locked and names `switch ui`, which opens it with the token.
201
+ - **Dashboard.** The Codex model names (session, review, subagent) are on the **Models** tab, next to the other model settings. The **Blindfold** tab holds only the interceptor settings.
202
+
203
+ ### Changes in 1.1.2
195
204
 
196
205
  - **npm package.** Install with `npm install -g llm-switcher` and run `switch`. An npm install keeps its data in `~/.llm-switcher`, so an upgrade does not erase your configuration. A git checkout keeps its data next to the code, as before.
197
206
  - **Contract lab.** The gateway can send a small sample of complete exchanges to an [intact](https://github.com/louisphamdev/intact) server, which finds fields that the converter loses. It is off by default. See "Contract lab" below.
@@ -547,6 +556,8 @@ re-opened from a shell where the shim is on `PATH`.
547
556
 
548
557
  The contract lab finds fields that the converter loses. It is off by default.
549
558
 
559
+ Before it sends a sample, the gateway masks the content of the client: prompts, answers, system text, thinking, tool arguments and results, files, images and user ids become `x` of the same length. Keys, types, roles, model names, tool names and schemas, numbers and event names stay, so intact can analyse the shape.
560
+
550
561
  - Set `contractLab: {url, apiKey, enabled}` in `config.json`. If `enabled` is `true`, the gateway sends a sample of complete exchanges to intact.
551
562
  - `switch contract-probe [--model m]` sends six test requests per model and format through the gateway.
552
563
  - `switch contract-check` gets the open findings from intact and writes one test file for each lost field.
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.2
194
+ ## Thay đổi trong bản 1.1.4
195
+
196
+ - **Bảo mật contract lab.** Mẫu mà gateway gửi lên intact không chứa nội dung của client. Prompt, câu trả lời, tham số và kết quả của tool, file và user id bị che ngay trên máy trước khi gửi. Các field mà intact cần để phân tích được giữ nguyên.
197
+
198
+ ### Thay đổi trong bản 1.1.3
199
+
200
+ - **Dashboard.** Khi mở mà thiếu access token, trang không còn đứng ở "Checking status...". Trang báo đang bị khoá và chỉ lệnh `switch ui`, lệnh này mở trang kèm token.
201
+ - **Dashboard.** Tên model của Codex (session, review, subagent) nằm ở tab **Models**, cạnh các cấu hình model khác. Tab **Blindfold** chỉ còn cấu hình interceptor.
202
+
203
+ ### Thay đổi trong bản 1.1.2
195
204
 
196
205
  - **Gói npm.** Cài bằng `npm install -g llm-switcher` rồi chạy `switch`. Bản cài bằng npm lưu dữ liệu trong `~/.llm-switcher`, nên nâng cấp không xoá cấu hình. Bản git checkout vẫn lưu dữ liệu cạnh mã nguồn như trước.
197
206
  - **Contract lab.** Gateway có thể gửi một phần nhỏ các lượt trao đổi hoàn chỉnh lên server [intact](https://github.com/louisphamdev/intact) để tìm field mà converter làm mất. Mặc định tính năng này tắt. Xem mục "Contract lab" bên dưới.
@@ -547,6 +556,8 @@ trong `PATH`.
547
556
 
548
557
  Contract lab tìm các field mà converter làm mất. Mặc định tính năng này tắt.
549
558
 
559
+ Trước khi gửi mẫu, gateway che nội dung của client: prompt, câu trả lời, system text, thinking, tham số và kết quả của tool, file, ảnh và user id đều thành chuỗi `x` cùng độ dài. Tên key, type, role, tên model, tên và schema của tool, số và tên event được giữ nguyên, để intact phân tích được hình dạng.
560
+
550
561
  - Đặt `contractLab: {url, apiKey, enabled}` trong `config.json`. Nếu `enabled` là `true`, gateway gửi một phần các lượt trao đổi hoàn chỉnh lên intact.
551
562
  - `switch contract-probe [--model m]` gửi sáu request thử cho mỗi model và mỗi format qua gateway.
552
563
  - `switch contract-check` lấy các finding còn mở từ intact và ghi một file test cho mỗi field bị mất.
package/contract.mjs CHANGED
@@ -61,6 +61,69 @@ export function switcherVersion() {
61
61
  }
62
62
 
63
63
  /** Copies what the gateway writes to the client. Past the cap the side is dropped, not cut. */
64
+ // ---------------- masking of client content ----------------
65
+ // A half leaves this machine. The client's own content (prompts, answers, tool arguments and results,
66
+ // files, user ids) is replaced by "x" of the same byte length; everything intact needs to analyse the
67
+ // shape (keys, types, roles, models, tool names, numbers, flags, event names) stays as sent.
68
+ const TEXT_KEYS = new Set(['text', 'content', 'thinking', 'system', 'instructions', 'delta', 'reasoning_content',
69
+ 'refusal', 'output', 'input', 'data', 'url', 'file_data', 'encrypted_content', 'user', 'user_id', 'prompt',
70
+ 'summary', 'output_text', 'partial_json', 'arguments', 'args']);
71
+ const SSE_EVENT_RE = /^[A-Za-z0-9_.:-]{1,64}$/;
72
+
73
+ const maskString = (s) => 'x'.repeat(Buffer.byteLength(s));
74
+
75
+ // Inside a payload (tool arguments, tool input, a function response) every value belongs to the client.
76
+ function maskPayload(v) {
77
+ if (typeof v === 'string') return maskString(v);
78
+ if (Array.isArray(v)) return v.map(maskPayload);
79
+ if (v && typeof v === 'object') return Object.fromEntries(Object.entries(v).map(([k, c]) => [k, maskPayload(c)]));
80
+ return v;
81
+ }
82
+
83
+ // A string that holds JSON (tool arguments) keeps its keys, so intact still sees the nested shape.
84
+ function maskJsonString(s) {
85
+ const t = s.trim();
86
+ if ((t.startsWith('{') && t.endsWith('}')) || (t.startsWith('[') && t.endsWith(']'))) {
87
+ try { return JSON.stringify(maskPayload(JSON.parse(t))); } catch {}
88
+ }
89
+ return maskString(s);
90
+ }
91
+
92
+ function maskValue(v, parentKey = '') {
93
+ if (Array.isArray(v)) return v.map((c) => maskValue(c, parentKey));
94
+ if (!v || typeof v !== 'object') return v;
95
+ const out = {};
96
+ for (const [k, c] of Object.entries(v)) {
97
+ const payload = k === 'args' || k === 'arguments' || (k === 'input' && c && typeof c === 'object' && !Array.isArray(c))
98
+ || (k === 'response' && parentKey === 'functionResponse');
99
+ if (payload) out[k] = typeof c === 'string' ? maskJsonString(c) : maskPayload(c);
100
+ else if (typeof c === 'string' && TEXT_KEYS.has(k)) out[k] = (k === 'partial_json') ? maskJsonString(c) : maskString(c);
101
+ else out[k] = maskValue(c, k);
102
+ }
103
+ return out;
104
+ }
105
+
106
+ /** Masks the client's content in a request or response body: JSON, SSE, or anything else (masked whole). */
107
+ export function maskHalf(text) {
108
+ if (!text) return '';
109
+ try { return JSON.stringify(maskValue(JSON.parse(text))); } catch {}
110
+ const lines = text.split('\n');
111
+ if (!lines.some((l) => l.startsWith('data:'))) return maskString(text);
112
+ return lines.map((line) => {
113
+ if (line === '' || line === '\r' || line.startsWith(':')) return line;
114
+ if (line.startsWith('event:')) {
115
+ const name = line.slice(6).trim();
116
+ return SSE_EVENT_RE.test(name) ? line : `event: ${maskString(name)}`;
117
+ }
118
+ if (line.startsWith('data:')) {
119
+ const data = line.slice(5).trim();
120
+ if (data === '[DONE]') return line;
121
+ try { return `data: ${JSON.stringify(maskValue(JSON.parse(data)))}`; } catch { return `data: ${maskString(data)}`; }
122
+ }
123
+ return maskString(line);
124
+ }).join('\n');
125
+ }
126
+
64
127
  export function createHalfTap(limit = MAX_HALF_BYTES) {
65
128
  const chunks = [];
66
129
  let size = 0;
@@ -206,8 +269,8 @@ export function createContractLab(options = {}) {
206
269
 
207
270
  async function send(s, { traceId, half }) {
208
271
  const body = JSON.stringify({
209
- toolRequest: half.toolRequest || '',
210
- toolResponse: half.toolResponse || '',
272
+ toolRequest: maskHalf(half.toolRequest || ''),
273
+ toolResponse: maskHalf(half.toolResponse || ''),
211
274
  ...(half.toolVersion ? { toolVersion: half.toolVersion } : {}),
212
275
  switcherVersion: version(),
213
276
  converter: { inFormat: half.inFormat, outFormat: half.outFormat }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "llm-switcher",
3
- "version": "1.1.2",
3
+ "version": "1.1.4",
4
4
  "description": "Zero-dependency multi-protocol edge gateway & provider switcher for Claude Code, Codex, OpenAI and Gemini clients",
5
5
  "keywords": [
6
6
  "llm",
@@ -398,8 +398,13 @@ test('the half of a sampled request reaches intact with the exchange and the ver
398
398
  assert.deepEqual(last.body.converter, { inFormat: 'anthropic', outFormat: 'anthropic' });
399
399
  assert.equal(last.body.toolVersion, '1.2.3');
400
400
  assert.match(last.body.switcherVersion, SWITCHER_VERSION_RE);
401
- assert.equal(JSON.parse(last.body.toolRequest).messages[0].content, 'hello');
402
- assert.equal(JSON.parse(last.body.toolResponse).content[0].text, 'direct ok');
401
+ // The client's content never leaves the machine: it arrives masked, same length; the shape stays.
402
+ const sentReq = JSON.parse(last.body.toolRequest);
403
+ assert.equal(sentReq.messages[0].content, 'x'.repeat('hello'.length));
404
+ assert.equal(sentReq.messages[0].role, 'user');
405
+ assert.equal(sentReq.model, 'claude-opus-4-6');
406
+ assert.equal(JSON.parse(last.body.toolResponse).content[0].text, 'x'.repeat('direct ok'.length));
407
+ assert.ok(!last.body.toolRequest.includes('hello') && !last.body.toolResponse.includes('direct ok'), 'no raw content in the half');
403
408
  });
404
409
 
405
410
  test('with intact at a closed port the tool answer is byte-identical to the answer with the lab off', async () => {
@@ -552,9 +557,9 @@ test('a sampled Codex WS turn is tagged and its half reaches intact', async () =
552
557
  assert.match(body.switcherVersion, SWITCHER_VERSION_RE);
553
558
  const asked = JSON.parse(body.toolRequest);
554
559
  assert.equal(asked.type, 'response.create');
555
- assert.equal(asked.input, 'hello over ws');
560
+ assert.equal(asked.input, 'x'.repeat('hello over ws'.length), 'the WS request is masked');
556
561
  assert.ok(body.toolResponse.includes('event: response.completed'), 'the half holds the events the turn wrote');
557
- assert.ok(body.toolResponse.includes('ws ok'), 'the answer text is in the half');
562
+ assert.ok(!body.toolResponse.includes('ws ok'), 'the answer text is masked in the half');
558
563
  });
559
564
 
560
565
  test('a Codex WS client can never choose the trace id, and a down intact leaves the turn unchanged', async () => {
@@ -0,0 +1,99 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { maskHalf } from '../contract.mjs';
4
+
5
+ const SECRET = 'SECRET-PII-marker';
6
+ const KEEP = 'keep-model-name';
7
+ const bytes = (s) => Buffer.byteLength(s);
8
+
9
+ // Every body puts SECRET in each place a client writes its own content, and KEEP in fields that
10
+ // analysis needs. After masking, SECRET must be gone and KEEP must stay.
11
+ const bodies = {
12
+ anthropic: {
13
+ model: KEEP, max_tokens: 64, system: [{ type: 'text', text: `sys ${SECRET}` }],
14
+ metadata: { user_id: `u ${SECRET}` },
15
+ tools: [{ name: 'get_weather', description: 'Current weather', input_schema: { type: 'object', properties: { city: { type: 'string' } } } }],
16
+ messages: [
17
+ { role: 'user', content: `hi ${SECRET}` },
18
+ { role: 'assistant', content: [{ type: 'thinking', thinking: `t ${SECRET}`, signature: 'sig' },
19
+ { type: 'tool_use', id: 'toolu_1', name: 'get_weather', input: { city: `c ${SECRET}` } }] },
20
+ { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'toolu_1', content: [{ type: 'text', text: `r ${SECRET}` }] },
21
+ { type: 'image', source: { type: 'base64', media_type: 'image/png', data: `img ${SECRET}` } }] }
22
+ ]
23
+ },
24
+ openaiChat: {
25
+ model: KEEP, user: `u ${SECRET}`, temperature: 0.2,
26
+ messages: [
27
+ { role: 'system', content: `s ${SECRET}` },
28
+ { role: 'user', content: [{ type: 'text', text: `q ${SECRET}` }, { type: 'image_url', image_url: { url: `data:${SECRET}` } }] },
29
+ { role: 'assistant', content: null, tool_calls: [{ id: 'call_1', type: 'function', function: { name: 'get_weather', arguments: JSON.stringify({ city: `c ${SECRET}` }) } }] },
30
+ { role: 'tool', tool_call_id: 'call_1', content: `o ${SECRET}` }
31
+ ]
32
+ },
33
+ responses: {
34
+ model: KEEP, instructions: `i ${SECRET}`,
35
+ input: [
36
+ { type: 'message', role: 'user', content: [{ type: 'input_text', text: `q ${SECRET}` }] },
37
+ { type: 'function_call', call_id: 'c1', name: 'get_weather', arguments: JSON.stringify({ city: `c ${SECRET}` }) },
38
+ { type: 'function_call_output', call_id: 'c1', output: `o ${SECRET}` },
39
+ { type: 'reasoning', summary: [{ type: 'summary_text', text: `r ${SECRET}` }], encrypted_content: `e ${SECRET}` }
40
+ ]
41
+ },
42
+ gemini: {
43
+ systemInstruction: { parts: [{ text: `s ${SECRET}` }] },
44
+ contents: [
45
+ { role: 'user', parts: [{ text: `q ${SECRET}` }] },
46
+ { role: 'model', parts: [{ functionCall: { name: 'get_weather', args: { city: `c ${SECRET}` } } }] },
47
+ { role: 'user', parts: [{ functionResponse: { name: 'get_weather', response: { temp: `t ${SECRET}` } } }] }
48
+ ]
49
+ }
50
+ };
51
+
52
+ for (const [name, body] of Object.entries(bodies)) {
53
+ test(`${name}: client content is masked, analysis fields stay`, () => {
54
+ const raw = JSON.stringify(body);
55
+ const out = maskHalf(raw);
56
+ assert.ok(!out.includes(SECRET), `${name} leaks content: ${out}`);
57
+ const parsed = JSON.parse(out);
58
+ if (body.model) assert.equal(parsed.model, KEEP);
59
+ assert.ok(out.includes('get_weather'), 'tool names stay');
60
+ if (raw.includes('"type":')) assert.ok(out.includes('"type":'), 'types stay');
61
+ });
62
+ }
63
+
64
+ test('masking keeps the byte length of every masked string', () => {
65
+ const out = JSON.parse(maskHalf(JSON.stringify({ model: 'm', messages: [{ role: 'user', content: 'héllo wörld' }] })));
66
+ assert.equal(bytes(out.messages[0].content), bytes('héllo wörld'));
67
+ assert.match(out.messages[0].content, /^x+$/);
68
+ });
69
+
70
+ test('a JSON string argument keeps its keys and masks its values', () => {
71
+ const out = JSON.parse(maskHalf(JSON.stringify(bodies.openaiChat)));
72
+ const args = JSON.parse(out.messages[2].tool_calls[0].function.arguments);
73
+ assert.deepEqual(Object.keys(args), ['city']);
74
+ assert.match(args.city, /^x+$/);
75
+ });
76
+
77
+ test('an SSE stream masks every data line and keeps event names', () => {
78
+ const sse = [
79
+ 'event: message_start', `data: ${JSON.stringify({ type: 'message_start', message: { model: KEEP, role: 'assistant', content: [] } })}`, '',
80
+ 'event: content_block_delta', `data: ${JSON.stringify({ type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: `a ${SECRET}` } })}`, '',
81
+ `data: ${JSON.stringify({ choices: [{ index: 0, delta: { content: `b ${SECRET}` } }] })}`, '',
82
+ `data: ${JSON.stringify({ type: 'response.output_text.delta', delta: `c ${SECRET}` })}`, '',
83
+ 'data: [DONE]', ''
84
+ ].join('\n');
85
+ const out = maskHalf(sse);
86
+ assert.ok(!out.includes(SECRET), out);
87
+ assert.ok(out.includes('event: content_block_delta'));
88
+ assert.ok(out.includes('"text_delta"') && out.includes(KEEP));
89
+ assert.ok(out.includes('data: [DONE]'));
90
+ });
91
+
92
+ test('text that is not JSON, or a cut body, is masked whole', () => {
93
+ const cut = JSON.stringify(bodies.anthropic).slice(0, 120);
94
+ for (const t of [`plain ${SECRET}`, cut]) {
95
+ const out = maskHalf(t);
96
+ assert.ok(!out.includes('SECRET'), out);
97
+ assert.equal(bytes(out), bytes(t));
98
+ }
99
+ });
package/ui.html CHANGED
@@ -940,7 +940,7 @@
940
940
  <button id="tab-button-general" type="button" class="tab-btn active" role="tab" aria-selected="true" aria-controls="tab-general" tabindex="0" onclick="switchTab('tab-general')">General</button>
941
941
  <button id="tab-button-routing" type="button" class="tab-btn" role="tab" aria-selected="false" aria-controls="tab-routing" tabindex="-1" onclick="switchTab('tab-routing')">Routing</button>
942
942
  <button id="tab-button-models" type="button" class="tab-btn" role="tab" aria-selected="false" aria-controls="tab-models" tabindex="-1" onclick="switchTab('tab-models')">Models</button>
943
- <button id="tab-button-codex" type="button" class="tab-btn" role="tab" aria-selected="false" aria-controls="tab-codex" tabindex="-1" onclick="switchTab('tab-codex')" hidden>Codex</button>
943
+ <button id="tab-button-codex" type="button" class="tab-btn" role="tab" aria-selected="false" aria-controls="tab-codex" tabindex="-1" onclick="switchTab('tab-codex')" hidden>Blindfold</button>
944
944
  <button id="tab-button-inspector" type="button" class="tab-btn" role="tab" aria-selected="false" aria-controls="tab-inspector" tabindex="-1" onclick="switchTab('tab-inspector'); loadInspectorLogs();">Request log</button>
945
945
  </div>
946
946
 
@@ -1037,23 +1037,10 @@
1037
1037
  <!-- Dynamically populated via renderModelSlots -->
1038
1038
  </div>
1039
1039
 
1040
- <!-- Model Explorer Box -->
1041
- <div class="form-group full" id="model-browser" style="display: none; margin-top: 14px;">
1042
- <div class="model-browser">
1043
- <div class="model-browser-header">
1044
- <span id="model-count-label" style="font-size: 11px; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.04em;">Available Upstream Models</span>
1045
- <label class="sr-only" for="model-filter">Filter upstream models</label>
1046
- <input type="search" id="model-filter" placeholder="Filter models" style="padding: 5px 9px; font-size: 11px; width: 200px;" oninput="filterModels()">
1047
- </div>
1048
- <div id="model-items" class="model-list-scroll"></div>
1049
- </div>
1050
- </div>
1051
- </div>
1052
-
1053
- <!-- Tab 4: Live Inspector -->
1054
- <!-- Tab 4: Codex — only the names this CLI is allowed to observe -->
1055
- <div id="tab-codex" class="tab-pane" role="tabpanel" aria-labelledby="tab-button-codex" hidden>
1056
- <div class="warning-callout">
1040
+ <!-- Codex model config lives with the other model config; only the interceptor stays on its own tab. -->
1041
+ <div id="codex-names" style="margin-top: 16px;" hidden>
1042
+ <span style="font-size: 12px; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.04em;">Names Codex sees</span>
1043
+ <div class="warning-callout">
1057
1044
  <span class="badge badge-convert">NOTE</span>
1058
1045
  <div>
1059
1046
  <strong>Codex sees only these names.</strong> The slot aliases <code>main</code>, <code>review</code> and
@@ -1076,7 +1063,24 @@
1076
1063
  <input type="text" id="p-public-subagent" list="model-options" placeholder="e.g. gpt-5.6-luna">
1077
1064
  </div>
1078
1065
  </div>
1066
+ </div>
1079
1067
 
1068
+ <!-- Model Explorer Box -->
1069
+ <div class="form-group full" id="model-browser" style="display: none; margin-top: 14px;">
1070
+ <div class="model-browser">
1071
+ <div class="model-browser-header">
1072
+ <span id="model-count-label" style="font-size: 11px; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.04em;">Available Upstream Models</span>
1073
+ <label class="sr-only" for="model-filter">Filter upstream models</label>
1074
+ <input type="search" id="model-filter" placeholder="Filter models" style="padding: 5px 9px; font-size: 11px; width: 200px;" oninput="filterModels()">
1075
+ </div>
1076
+ <div id="model-items" class="model-list-scroll"></div>
1077
+ </div>
1078
+ </div>
1079
+ </div>
1080
+
1081
+ <!-- Tab 4: Live Inspector -->
1082
+ <!-- Tab 4: Codex — only the names this CLI is allowed to observe -->
1083
+ <div id="tab-codex" class="tab-pane" role="tabpanel" aria-labelledby="tab-button-codex" hidden>
1080
1084
  <div class="form-grid" style="border-top: 1px solid var(--border); padding-top: 12px; margin-top: 12px;">
1081
1085
  <div class="form-group full">
1082
1086
  <label style="display: flex; align-items: center; gap: 6px; cursor: pointer; text-transform: none;">
@@ -1382,6 +1386,8 @@
1382
1386
  const applies = servesCodex(format);
1383
1387
  const btn = document.getElementById('tab-button-codex');
1384
1388
  if (btn) btn.hidden = !applies;
1389
+ const names = document.getElementById('codex-names');
1390
+ if (names) names.hidden = !applies;
1385
1391
  if (!applies && document.getElementById('tab-codex')?.classList.contains('active')) {
1386
1392
  switchTab('tab-general');
1387
1393
  }
@@ -1468,6 +1474,11 @@
1468
1474
  try {
1469
1475
  const res = await api('/api/status');
1470
1476
  const data = await res.json();
1477
+ if (res.status === 401) {
1478
+ document.getElementById('status-title').textContent = 'Dashboard locked';
1479
+ document.getElementById('status-sub').textContent = 'Run `switch ui` in a terminal. It opens this page with its access token.';
1480
+ return;
1481
+ }
1471
1482
  if (!res.ok) {
1472
1483
  showToast(data.error || `Gateway error HTTP ${res.status}`, true);
1473
1484
  return;