llm-switcher 1.1.3 → 1.1.5

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,15 @@ flowchart LR
191
191
 
192
192
  ---
193
193
 
194
- ## Changes in 1.1.3
194
+ ## Changes in 1.1.5
195
+
196
+ - **Contract lab privacy.** Masking now uses an allowlist. Every string value is masked except the enum values that intact reads. 1.1.4 masked a list of content keys and missed 16 fields (citations, document and web titles, web search queries, logprobs tokens, file names and URIs, stop sequences, participant names, error messages, tool descriptions).
197
+
198
+ ### Changes in 1.1.4
199
+
200
+ - **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.
201
+
202
+ ### Changes in 1.1.3
195
203
 
196
204
  - **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
205
  - **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 +560,8 @@ re-opened from a shell where the shim is on `PATH`.
552
560
 
553
561
  The contract lab finds fields that the converter loses. It is off by default.
554
562
 
563
+ Before it sends a sample, the gateway masks every string value with `x` of the same length. Only the short enum values that intact reads stay: `type`, `role`, `object`, `model`, `status`, `event`, `finish_reason` and `stop_reason`. Numbers, flags, SSE event names and the keys of the API also stay. Inside user data (tool arguments, tool input, `metadata`) the keys are masked too. intact keeps only the length of any other string, so the analysis loses nothing.
564
+
555
565
  - Set `contractLab: {url, apiKey, enabled}` in `config.json`. If `enabled` is `true`, the gateway sends a sample of complete exchanges to intact.
556
566
  - `switch contract-probe [--model m]` sends six test requests per model and format through the gateway.
557
567
  - `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,15 @@ 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.5
195
+
196
+ - **Bảo mật contract lab.** Việc che giờ dùng allowlist. Mọi giá trị string đều bị che, trừ các giá trị enum mà intact đọc. Bản 1.1.4 che theo danh sách key nội dung và bỏ sót 16 field (trích dẫn, tiêu đề tài liệu và trang web, câu truy vấn web search, token logprobs, tên và URI file, stop sequence, tên người tham gia, thông báo lỗi, mô tả tool).
197
+
198
+ ### Thay đổi trong bản 1.1.4
199
+
200
+ - **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.
201
+
202
+ ### Thay đổi trong bản 1.1.3
195
203
 
196
204
  - **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
205
  - **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 +560,8 @@ trong `PATH`.
552
560
 
553
561
  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
562
 
563
+ Trước khi gửi mẫu, gateway che mọi giá trị string bằng chuỗi `x` cùng độ dài. Chỉ các giá trị enum ngắn mà intact đọc được giữ nguyên: `type`, `role`, `object`, `model`, `status`, `event`, `finish_reason` và `stop_reason`. Số, flag, tên event SSE và tên key của API cũng được giữ. Trong dữ liệu người dùng (tham số tool, input của tool, `metadata`), tên key cũng bị che. intact chỉ lưu độ dài của các string khác, nên phân tích không mất gì.
564
+
555
565
  - Đặ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
566
  - `switch contract-probe [--model m]` gửi sáu request thử cho mỗi model và mỗi format qua gateway.
557
567
  - `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,64 @@ 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, so every string value is masked with "x" of the same byte length,
66
+ // except the enum values that intact's reducer reads: the same key list, value shape and exclusions as
67
+ // enumLeafNames / validEnumValue in intact internal/contract/reduce.go. The reducer keeps only the
68
+ // length and a hash of any other string, so masking it loses nothing for the analysis. An allowlist,
69
+ // not a list of content keys: a new content field in any API is masked by default.
70
+ const ENUM_KEYS = new Set(['type', 'role', 'object', 'finish_reason', 'stop_reason', 'status', 'event', 'model']);
71
+ const ENUM_VALUE_RE = /^[A-Za-z0-9_.:/-]{1,64}$/;
72
+ // Under these the reducer collapses keys to {*}: they hold user data, so their keys are masked too.
73
+ const DATA_PARENTS = new Set(['args', 'arguments', 'metadata', 'client_metadata', 'extra_body', 'headers']);
74
+ const SSE_EVENT_RE = /^[A-Za-z0-9_.:-]{1,64}$/;
75
+
76
+ const maskString = (s) => 'x'.repeat(Buffer.byteLength(s));
77
+
78
+ // ctx.data: inside user data (a data parent, a JSON string, a tool's input) — no enum kept, keys masked.
79
+ function maskValue(v, key = '', ctx = { data: false }) {
80
+ if (typeof v === 'string') {
81
+ if (!ctx.data && ENUM_KEYS.has(key) && ENUM_VALUE_RE.test(v)) return v;
82
+ const t = v.trim();
83
+ if ((t.startsWith('{') && t.endsWith('}')) || (t.startsWith('[') && t.endsWith(']'))) {
84
+ try { return JSON.stringify(maskValue(JSON.parse(t), key, { data: true })); } catch {}
85
+ }
86
+ return maskString(v);
87
+ }
88
+ if (Array.isArray(v)) return v.map((c) => maskValue(c, key, ctx));
89
+ if (!v || typeof v !== 'object') return v;
90
+ const out = {};
91
+ let i = 0;
92
+ for (const [k, c] of Object.entries(v)) {
93
+ const data = ctx.data || DATA_PARENTS.has(k) || (k === 'input' && c && typeof c === 'object' && !Array.isArray(c));
94
+ out[ctx.data ? `k${i++}` : k] = maskValue(c, k, { data });
95
+ }
96
+ return out;
97
+ }
98
+
99
+ /** Masks the client's content in a request or response body: JSON, SSE, or anything else (masked whole). */
100
+ export function maskHalf(text) {
101
+ if (!text) return '';
102
+ try { return JSON.stringify(maskValue(JSON.parse(text))); } catch {}
103
+ const lines = text.split('\n');
104
+ if (!lines.some((l) => l.startsWith('data:'))) return maskString(text);
105
+ return lines.map((raw) => {
106
+ const cr = raw.endsWith('\r') ? '\r' : '';
107
+ const line = cr ? raw.slice(0, -1) : raw;
108
+ if (line === '' || line.startsWith(':')) return raw;
109
+ if (line.startsWith('event:')) {
110
+ const name = line.slice(6).trim();
111
+ return (SSE_EVENT_RE.test(name) ? line : `event: ${maskString(name)}`) + cr;
112
+ }
113
+ if (line.startsWith('data:')) {
114
+ const data = line.slice(5).trim();
115
+ if (data === '[DONE]') return raw;
116
+ try { return `data: ${JSON.stringify(maskValue(JSON.parse(data)))}${cr}`; } catch { return `data: ${maskString(data)}${cr}`; }
117
+ }
118
+ return maskString(line) + cr;
119
+ }).join('\n');
120
+ }
121
+
64
122
  export function createHalfTap(limit = MAX_HALF_BYTES) {
65
123
  const chunks = [];
66
124
  let size = 0;
@@ -206,8 +264,8 @@ export function createContractLab(options = {}) {
206
264
 
207
265
  async function send(s, { traceId, half }) {
208
266
  const body = JSON.stringify({
209
- toolRequest: half.toolRequest || '',
210
- toolResponse: half.toolResponse || '',
267
+ toolRequest: maskHalf(half.toolRequest || ''),
268
+ toolResponse: maskHalf(half.toolResponse || ''),
211
269
  ...(half.toolVersion ? { toolVersion: half.toolVersion } : {}),
212
270
  switcherVersion: version(),
213
271
  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.5",
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,58 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { maskHalf } from '../contract.mjs';
4
+
5
+ // The 16 leaks of the 1.1.4 report, plus the positions 1.1.4 already covered. A marker anywhere in a
6
+ // value must not survive, whatever the key; only the enum values intact's reducer reads may stay.
7
+ const M = (tag) => `LEAK_${tag}`;
8
+ const cases = {
9
+ anthropic_citation: { content: [{ type: 'text', text: 'x', citations: [{ type: 'char_location', cited_text: M('cited_text'), document_title: M('document_title') }] }] },
10
+ anthropic_document: { messages: [{ role: 'user', content: [{ type: 'document', title: M('doc_title'), context: M('doc_context'), source: { type: 'text', media_type: 'text/plain', data: M('doc_data') } }] }] },
11
+ anthropic_web_result: { content: [{ type: 'web_search_tool_result', tool_use_id: 't', content: [{ type: 'web_search_result', url: M('url'), title: M('web_title'), encrypted_content: M('enc') }] }] },
12
+ anthropic_thinking: { content: [{ type: 'thinking', thinking: M('thinking'), signature: 'SIG_opaque' }] },
13
+ anthropic_system_blocks: { system: [{ type: 'text', text: M('system_block') }] },
14
+ anthropic_stop: { stop_sequences: [M('stop_seq')], stop_sequence: M('stop_sequence_resp') },
15
+ openai_logprobs: { choices: [{ logprobs: { content: [{ token: M('token'), top_logprobs: [{ token: M('top_token') }] }] } }] },
16
+ openai_name: { messages: [{ role: 'user', name: M('participant_name'), content: 'x' }] },
17
+ openai_tool_msg: { messages: [{ role: 'tool', tool_call_id: 'c1', content: M('tool_content') }] },
18
+ responses_annotation: { output: [{ type: 'message', content: [{ type: 'output_text', text: 'x', annotations: [{ type: 'url_citation', url: M('ann_url'), title: M('ann_title') }] }] }] },
19
+ responses_websearch: { output: [{ type: 'web_search_call', action: { type: 'search', query: M('search_query') } }] },
20
+ responses_input_items: { input: [{ type: 'message', role: 'user', content: [{ type: 'input_text', text: M('input_text') }] }, { type: 'function_call_output', call_id: 'c', output: M('fc_output') }, { type: 'input_file', filename: M('filename'), file_data: M('file_data') }] },
21
+ gemini_filedata: { contents: [{ role: 'user', parts: [{ fileData: { mimeType: 'application/pdf', fileUri: M('fileUri') } }, { text: M('gemini_text') }] }] },
22
+ gemini_func: { contents: [{ parts: [{ functionCall: { name: 'f', args: { q: M('fc_args') } } }, { functionResponse: { name: 'f', response: { r: M('fr_resp') } } }] }] },
23
+ error_echo: { type: 'error', error: { type: 'invalid_request_error', message: M('error_message') } },
24
+ tool_desc: { tools: [{ name: 'Bash', description: M('tool_description'), input_schema: { type: 'object' } }] },
25
+ // An enum key does not keep free text: a value with a space, or one under user data, is masked.
26
+ enum_free_text: { type: `${M('type_text')} with space`, metadata: { type: M('meta_type') }, model: M('model_ok') },
27
+ json_keys: { messages: [{ role: 'assistant', tool_calls: [{ type: 'function', function: { name: 'f', arguments: JSON.stringify({ [M('arg_key')]: 1 }) } }] }] }
28
+ };
29
+
30
+ for (const [name, obj] of Object.entries(cases)) {
31
+ test(`no marker survives: ${name}`, () => {
32
+ const out = maskHalf(JSON.stringify(obj));
33
+ const left = (out.match(/LEAK_[A-Za-z_]+/g) || []).filter((m) => m !== 'LEAK_model_ok');
34
+ assert.deepEqual(left, [], out);
35
+ });
36
+ }
37
+
38
+ test('the enum values the reducer reads stay as sent', () => {
39
+ const out = JSON.parse(maskHalf(JSON.stringify({
40
+ model: 'claude-opus-4-6', type: 'message', role: 'assistant', stop_reason: 'end_turn', object: 'chat.completion',
41
+ content: [{ type: 'tool_use', id: 'toolu_1', name: 'Bash', input: { command: 'ls' } }], usage: { input_tokens: 3 }
42
+ })));
43
+ assert.equal(out.model, 'claude-opus-4-6');
44
+ assert.equal(out.type, 'message');
45
+ assert.equal(out.role, 'assistant');
46
+ assert.equal(out.stop_reason, 'end_turn');
47
+ assert.equal(out.object, 'chat.completion');
48
+ assert.equal(out.content[0].type, 'tool_use');
49
+ assert.equal(out.usage.input_tokens, 3);
50
+ assert.match(out.content[0].name, /^x+$/);
51
+ });
52
+
53
+ test('SSE with CRLF and a non-JSON data line leaks nothing', () => {
54
+ const sse = 'event: message_delta\ndata: {"type":"content_block_delta","delta":{"type":"text_delta","text":"LEAK_sse_text"}}\r\n\r\ndata: not json LEAK_sse_raw\n\n';
55
+ const out = maskHalf(sse);
56
+ assert.ok(!/LEAK_/.test(out), out);
57
+ assert.ok(out.includes('"text_delta"'));
58
+ });
@@ -0,0 +1,100 @@
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 the model name,
10
+ // an enum value that 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 are not enum values: masked');
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 shape and masks its keys and 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.equal(Object.keys(args).length, 1);
74
+ assert.ok(!('city' in args), 'keys of user data are masked: the reducer collapses them to {*}');
75
+ assert.match(Object.values(args)[0], /^x+$/);
76
+ });
77
+
78
+ test('an SSE stream masks every data line and keeps event names', () => {
79
+ const sse = [
80
+ 'event: message_start', `data: ${JSON.stringify({ type: 'message_start', message: { model: KEEP, role: 'assistant', content: [] } })}`, '',
81
+ 'event: content_block_delta', `data: ${JSON.stringify({ type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: `a ${SECRET}` } })}`, '',
82
+ `data: ${JSON.stringify({ choices: [{ index: 0, delta: { content: `b ${SECRET}` } }] })}`, '',
83
+ `data: ${JSON.stringify({ type: 'response.output_text.delta', delta: `c ${SECRET}` })}`, '',
84
+ 'data: [DONE]', ''
85
+ ].join('\n');
86
+ const out = maskHalf(sse);
87
+ assert.ok(!out.includes(SECRET), out);
88
+ assert.ok(out.includes('event: content_block_delta'));
89
+ assert.ok(out.includes('"text_delta"') && out.includes(KEEP));
90
+ assert.ok(out.includes('data: [DONE]'));
91
+ });
92
+
93
+ test('text that is not JSON, or a cut body, is masked whole', () => {
94
+ const cut = JSON.stringify(bodies.anthropic).slice(0, 120);
95
+ for (const t of [`plain ${SECRET}`, cut]) {
96
+ const out = maskHalf(t);
97
+ assert.ok(!out.includes('SECRET'), out);
98
+ assert.equal(bytes(out), bytes(t));
99
+ }
100
+ });