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 +6 -2
- package/README.vi.md +6 -2
- package/contract.mjs +30 -35
- package/package.json +1 -1
- package/tests/contract-mask-leaks.test.mjs +58 -0
- package/tests/contract-mask.test.mjs +7 -6
package/README.md
CHANGED
|
@@ -191,7 +191,11 @@ flowchart LR
|
|
|
191
191
|
|
|
192
192
|
---
|
|
193
193
|
|
|
194
|
-
## Changes in 1.1.
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
//
|
|
76
|
-
function
|
|
77
|
-
if (typeof v === 'string')
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
|
|
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
|
|
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
|
|
98
|
-
|
|
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((
|
|
113
|
-
|
|
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
|
|
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
|
@@ -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
|
|
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
|
|
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
|
|
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.
|
|
74
|
-
assert.
|
|
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', () => {
|