llm-switcher 1.1.2 → 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 +12 -1
- package/README.vi.md +12 -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/ui.html +29 -18
package/README.md
CHANGED
|
@@ -191,7 +191,16 @@ 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
|
|
199
|
+
|
|
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.
|
|
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.
|
|
202
|
+
|
|
203
|
+
### Changes in 1.1.2
|
|
195
204
|
|
|
196
205
|
- **npm package.** Install with `npm install -g llm-switcher` and run `switch`. An npm install keeps its data in `~/.llm-switcher`, so an upgrade does not erase your configuration. A git checkout keeps its data next to the code, as before.
|
|
197
206
|
- **Contract lab.** The gateway can send a small sample of complete exchanges to an [intact](https://github.com/louisphamdev/intact) server, which finds fields that the converter loses. It is off by default. See "Contract lab" below.
|
|
@@ -547,6 +556,8 @@ re-opened from a shell where the shim is on `PATH`.
|
|
|
547
556
|
|
|
548
557
|
The contract lab finds fields that the converter loses. It is off by default.
|
|
549
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
|
+
|
|
550
561
|
- Set `contractLab: {url, apiKey, enabled}` in `config.json`. If `enabled` is `true`, the gateway sends a sample of complete exchanges to intact.
|
|
551
562
|
- `switch contract-probe [--model m]` sends six test requests per model and format through the gateway.
|
|
552
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,16 @@ 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
|
|
199
|
+
|
|
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.
|
|
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.
|
|
202
|
+
|
|
203
|
+
### Thay đổi trong bản 1.1.2
|
|
195
204
|
|
|
196
205
|
- **Gói npm.** Cài bằng `npm install -g llm-switcher` rồi chạy `switch`. Bản cài bằng npm lưu dữ liệu trong `~/.llm-switcher`, nên nâng cấp không xoá cấu hình. Bản git checkout vẫn lưu dữ liệu cạnh mã nguồn như trước.
|
|
197
206
|
- **Contract lab.** Gateway có thể gửi một phần nhỏ các lượt trao đổi hoàn chỉnh lên server [intact](https://github.com/louisphamdev/intact) để tìm field mà converter làm mất. Mặc định tính năng này tắt. Xem mục "Contract lab" bên dưới.
|
|
@@ -547,6 +556,8 @@ trong `PATH`.
|
|
|
547
556
|
|
|
548
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.
|
|
549
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
|
+
|
|
550
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.
|
|
551
562
|
- `switch contract-probe [--model m]` gửi sáu request thử cho mỗi model và mỗi format qua gateway.
|
|
552
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
|
+
});
|
package/ui.html
CHANGED
|
@@ -940,7 +940,7 @@
|
|
|
940
940
|
<button id="tab-button-general" type="button" class="tab-btn active" role="tab" aria-selected="true" aria-controls="tab-general" tabindex="0" onclick="switchTab('tab-general')">General</button>
|
|
941
941
|
<button id="tab-button-routing" type="button" class="tab-btn" role="tab" aria-selected="false" aria-controls="tab-routing" tabindex="-1" onclick="switchTab('tab-routing')">Routing</button>
|
|
942
942
|
<button id="tab-button-models" type="button" class="tab-btn" role="tab" aria-selected="false" aria-controls="tab-models" tabindex="-1" onclick="switchTab('tab-models')">Models</button>
|
|
943
|
-
<button id="tab-button-codex" type="button" class="tab-btn" role="tab" aria-selected="false" aria-controls="tab-codex" tabindex="-1" onclick="switchTab('tab-codex')" hidden>
|
|
943
|
+
<button id="tab-button-codex" type="button" class="tab-btn" role="tab" aria-selected="false" aria-controls="tab-codex" tabindex="-1" onclick="switchTab('tab-codex')" hidden>Blindfold</button>
|
|
944
944
|
<button id="tab-button-inspector" type="button" class="tab-btn" role="tab" aria-selected="false" aria-controls="tab-inspector" tabindex="-1" onclick="switchTab('tab-inspector'); loadInspectorLogs();">Request log</button>
|
|
945
945
|
</div>
|
|
946
946
|
|
|
@@ -1037,23 +1037,10 @@
|
|
|
1037
1037
|
<!-- Dynamically populated via renderModelSlots -->
|
|
1038
1038
|
</div>
|
|
1039
1039
|
|
|
1040
|
-
<!--
|
|
1041
|
-
<div
|
|
1042
|
-
<
|
|
1043
|
-
|
|
1044
|
-
<span id="model-count-label" style="font-size: 11px; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.04em;">Available Upstream Models</span>
|
|
1045
|
-
<label class="sr-only" for="model-filter">Filter upstream models</label>
|
|
1046
|
-
<input type="search" id="model-filter" placeholder="Filter models" style="padding: 5px 9px; font-size: 11px; width: 200px;" oninput="filterModels()">
|
|
1047
|
-
</div>
|
|
1048
|
-
<div id="model-items" class="model-list-scroll"></div>
|
|
1049
|
-
</div>
|
|
1050
|
-
</div>
|
|
1051
|
-
</div>
|
|
1052
|
-
|
|
1053
|
-
<!-- Tab 4: Live Inspector -->
|
|
1054
|
-
<!-- Tab 4: Codex — only the names this CLI is allowed to observe -->
|
|
1055
|
-
<div id="tab-codex" class="tab-pane" role="tabpanel" aria-labelledby="tab-button-codex" hidden>
|
|
1056
|
-
<div class="warning-callout">
|
|
1040
|
+
<!-- Codex model config lives with the other model config; only the interceptor stays on its own tab. -->
|
|
1041
|
+
<div id="codex-names" style="margin-top: 16px;" hidden>
|
|
1042
|
+
<span style="font-size: 12px; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.04em;">Names Codex sees</span>
|
|
1043
|
+
<div class="warning-callout">
|
|
1057
1044
|
<span class="badge badge-convert">NOTE</span>
|
|
1058
1045
|
<div>
|
|
1059
1046
|
<strong>Codex sees only these names.</strong> The slot aliases <code>main</code>, <code>review</code> and
|
|
@@ -1076,7 +1063,24 @@
|
|
|
1076
1063
|
<input type="text" id="p-public-subagent" list="model-options" placeholder="e.g. gpt-5.6-luna">
|
|
1077
1064
|
</div>
|
|
1078
1065
|
</div>
|
|
1066
|
+
</div>
|
|
1079
1067
|
|
|
1068
|
+
<!-- Model Explorer Box -->
|
|
1069
|
+
<div class="form-group full" id="model-browser" style="display: none; margin-top: 14px;">
|
|
1070
|
+
<div class="model-browser">
|
|
1071
|
+
<div class="model-browser-header">
|
|
1072
|
+
<span id="model-count-label" style="font-size: 11px; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.04em;">Available Upstream Models</span>
|
|
1073
|
+
<label class="sr-only" for="model-filter">Filter upstream models</label>
|
|
1074
|
+
<input type="search" id="model-filter" placeholder="Filter models" style="padding: 5px 9px; font-size: 11px; width: 200px;" oninput="filterModels()">
|
|
1075
|
+
</div>
|
|
1076
|
+
<div id="model-items" class="model-list-scroll"></div>
|
|
1077
|
+
</div>
|
|
1078
|
+
</div>
|
|
1079
|
+
</div>
|
|
1080
|
+
|
|
1081
|
+
<!-- Tab 4: Live Inspector -->
|
|
1082
|
+
<!-- Tab 4: Codex — only the names this CLI is allowed to observe -->
|
|
1083
|
+
<div id="tab-codex" class="tab-pane" role="tabpanel" aria-labelledby="tab-button-codex" hidden>
|
|
1080
1084
|
<div class="form-grid" style="border-top: 1px solid var(--border); padding-top: 12px; margin-top: 12px;">
|
|
1081
1085
|
<div class="form-group full">
|
|
1082
1086
|
<label style="display: flex; align-items: center; gap: 6px; cursor: pointer; text-transform: none;">
|
|
@@ -1382,6 +1386,8 @@
|
|
|
1382
1386
|
const applies = servesCodex(format);
|
|
1383
1387
|
const btn = document.getElementById('tab-button-codex');
|
|
1384
1388
|
if (btn) btn.hidden = !applies;
|
|
1389
|
+
const names = document.getElementById('codex-names');
|
|
1390
|
+
if (names) names.hidden = !applies;
|
|
1385
1391
|
if (!applies && document.getElementById('tab-codex')?.classList.contains('active')) {
|
|
1386
1392
|
switchTab('tab-general');
|
|
1387
1393
|
}
|
|
@@ -1468,6 +1474,11 @@
|
|
|
1468
1474
|
try {
|
|
1469
1475
|
const res = await api('/api/status');
|
|
1470
1476
|
const data = await res.json();
|
|
1477
|
+
if (res.status === 401) {
|
|
1478
|
+
document.getElementById('status-title').textContent = 'Dashboard locked';
|
|
1479
|
+
document.getElementById('status-sub').textContent = 'Run `switch ui` in a terminal. It opens this page with its access token.';
|
|
1480
|
+
return;
|
|
1481
|
+
}
|
|
1471
1482
|
if (!res.ok) {
|
|
1472
1483
|
showToast(data.error || `Gateway error HTTP ${res.status}`, true);
|
|
1473
1484
|
return;
|