llm-switcher 1.1.3 → 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,11 @@ flowchart LR
191
191
 
192
192
  ---
193
193
 
194
- ## Changes in 1.1.3
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
195
199
 
196
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.
197
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.
@@ -552,6 +556,8 @@ re-opened from a shell where the shim is on `PATH`.
552
556
 
553
557
  The contract lab finds fields that the converter loses. It is off by default.
554
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
+
555
561
  - Set `contractLab: {url, apiKey, enabled}` in `config.json`. If `enabled` is `true`, the gateway sends a sample of complete exchanges to intact.
556
562
  - `switch contract-probe [--model m]` sends six test requests per model and format through the gateway.
557
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,11 @@ flowchart LR
191
191
 
192
192
  ---
193
193
 
194
- ## Thay đổi trong bản 1.1.3
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
195
199
 
196
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.
197
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.
@@ -552,6 +556,8 @@ trong `PATH`.
552
556
 
553
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.
554
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
+
555
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.
556
562
  - `switch contract-probe [--model m]` gửi sáu request thử cho mỗi model và mỗi format qua gateway.
557
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.3",
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
+ });