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 +7 -1
- package/README.vi.md +7 -1
- package/contract.mjs +65 -2
- package/package.json +1 -1
- package/tests/contract-lab.test.mjs +9 -4
- package/tests/contract-mask.test.mjs +99 -0
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.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.
|
|
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
|
@@ -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
|
-
|
|
402
|
-
|
|
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
|
+
});
|