llm-switcher 1.1.4 → 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,11 @@ flowchart LR
191
191
 
192
192
  ---
193
193
 
194
- ## Changes in 1.1.4
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
195
199
 
196
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.
197
201
 
@@ -556,7 +560,7 @@ re-opened from a shell where the shim is on `PATH`.
556
560
 
557
561
  The contract lab finds fields that the converter loses. It is off by default.
558
562
 
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.
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.
560
564
 
561
565
  - Set `contractLab: {url, apiKey, enabled}` in `config.json`. If `enabled` is `true`, the gateway sends a sample of complete exchanges to intact.
562
566
  - `switch contract-probe [--model m]` sends six test requests per model and format through the gateway.
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.4
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
195
199
 
196
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.
197
201
 
@@ -556,7 +560,7 @@ trong `PATH`.
556
560
 
557
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.
558
562
 
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.
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ì.
560
564
 
561
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.
562
566
  - `switch contract-probe [--model m]` gửi sáu request thử cho mỗi model và mỗi format qua gateway.
package/contract.mjs CHANGED
@@ -62,43 +62,36 @@ export function switcherVersion() {
62
62
 
63
63
  /** Copies what the gateway writes to the client. Past the cap the side is dropped, not cut. */
64
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']);
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']);
71
74
  const SSE_EVENT_RE = /^[A-Za-z0-9_.:-]{1,64}$/;
72
75
 
73
76
  const maskString = (s) => 'x'.repeat(Buffer.byteLength(s));
74
77
 
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 {}
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);
88
87
  }
89
- return maskString(s);
90
- }
91
-
92
- function maskValue(v, parentKey = '') {
93
- if (Array.isArray(v)) return v.map((c) => maskValue(c, parentKey));
88
+ if (Array.isArray(v)) return v.map((c) => maskValue(c, key, ctx));
94
89
  if (!v || typeof v !== 'object') return v;
95
90
  const out = {};
91
+ let i = 0;
96
92
  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);
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 });
102
95
  }
103
96
  return out;
104
97
  }
@@ -109,18 +102,20 @@ export function maskHalf(text) {
109
102
  try { return JSON.stringify(maskValue(JSON.parse(text))); } catch {}
110
103
  const lines = text.split('\n');
111
104
  if (!lines.some((l) => l.startsWith('data:'))) return maskString(text);
112
- return lines.map((line) => {
113
- if (line === '' || line === '\r' || line.startsWith(':')) return line;
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;
114
109
  if (line.startsWith('event:')) {
115
110
  const name = line.slice(6).trim();
116
- return SSE_EVENT_RE.test(name) ? line : `event: ${maskString(name)}`;
111
+ return (SSE_EVENT_RE.test(name) ? line : `event: ${maskString(name)}`) + cr;
117
112
  }
118
113
  if (line.startsWith('data:')) {
119
114
  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)}`; }
115
+ if (data === '[DONE]') return raw;
116
+ try { return `data: ${JSON.stringify(maskValue(JSON.parse(data)))}${cr}`; } catch { return `data: ${maskString(data)}${cr}`; }
122
117
  }
123
- return maskString(line);
118
+ return maskString(line) + cr;
124
119
  }).join('\n');
125
120
  }
126
121
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "llm-switcher",
3
- "version": "1.1.4",
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",
@@ -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
+ });
@@ -6,8 +6,8 @@ const SECRET = 'SECRET-PII-marker';
6
6
  const KEEP = 'keep-model-name';
7
7
  const bytes = (s) => Buffer.byteLength(s);
8
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.
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
11
  const bodies = {
12
12
  anthropic: {
13
13
  model: KEEP, max_tokens: 64, system: [{ type: 'text', text: `sys ${SECRET}` }],
@@ -56,7 +56,7 @@ for (const [name, body] of Object.entries(bodies)) {
56
56
  assert.ok(!out.includes(SECRET), `${name} leaks content: ${out}`);
57
57
  const parsed = JSON.parse(out);
58
58
  if (body.model) assert.equal(parsed.model, KEEP);
59
- assert.ok(out.includes('get_weather'), 'tool names stay');
59
+ assert.ok(!out.includes('get_weather'), 'tool names are not enum values: masked');
60
60
  if (raw.includes('"type":')) assert.ok(out.includes('"type":'), 'types stay');
61
61
  });
62
62
  }
@@ -67,11 +67,12 @@ test('masking keeps the byte length of every masked string', () => {
67
67
  assert.match(out.messages[0].content, /^x+$/);
68
68
  });
69
69
 
70
- test('a JSON string argument keeps its keys and masks its values', () => {
70
+ test('a JSON string argument keeps its shape and masks its keys and values', () => {
71
71
  const out = JSON.parse(maskHalf(JSON.stringify(bodies.openaiChat)));
72
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+$/);
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+$/);
75
76
  });
76
77
 
77
78
  test('an SSE stream masks every data line and keeps event names', () => {