llm-switcher 1.1.11 → 1.2.2

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.
Files changed (61) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/README.md +202 -257
  3. package/README.vi.md +200 -256
  4. package/blindfold/blindfold.mjs +200 -53
  5. package/blindfold/make-certs.sh +26 -7
  6. package/catalog.mjs +246 -0
  7. package/classifier.mjs +238 -0
  8. package/config.example.json +12 -34
  9. package/docs/codex-blindfold.md +28 -17
  10. package/docs/cross-platform.md +16 -7
  11. package/docs/diagrams/ir-healer-pipeline.mmd +16 -0
  12. package/docs/diagrams/ir-healer-pipeline.png +0 -0
  13. package/docs/diagrams/ir-healer-pipeline.svg +90 -0
  14. package/docs/diagrams/ir-translation-pipeline.html +14925 -0
  15. package/docs/diagrams/ir-translation-pipeline.sequence.json +31 -0
  16. package/docs/diagrams/ir-translation-pipeline.svg +5128 -0
  17. package/docs/diagrams/system-architecture.architecture.json +76 -0
  18. package/docs/diagrams/system-architecture.html +14978 -0
  19. package/docs/diagrams/system-architecture.svg +5147 -0
  20. package/docs/diagrams/system-topology.mmd +30 -0
  21. package/docs/diagrams/system-topology.png +0 -0
  22. package/docs/diagrams/system-topology.svg +125 -0
  23. package/ensure-ca-bundle.mjs +28 -0
  24. package/formats.mjs +43 -156
  25. package/icons/antigravity.png +0 -0
  26. package/icons/claude.png +0 -0
  27. package/icons/codex.png +0 -0
  28. package/icons/deepseek.png +0 -0
  29. package/icons/gemini.png +0 -0
  30. package/icons/github.png +0 -0
  31. package/icons/groq.png +0 -0
  32. package/icons/intact.svg +1 -0
  33. package/icons/ollama.png +0 -0
  34. package/icons/openai.png +0 -0
  35. package/icons/openrouter.png +0 -0
  36. package/icons/qwen.png +0 -0
  37. package/icons/vertex.png +0 -0
  38. package/mcp.mjs +39 -11
  39. package/package.json +1 -1
  40. package/proxy.mjs +114 -37
  41. package/shim.mjs +200 -57
  42. package/skills/llm-switcher/SKILL.md +15 -10
  43. package/state.mjs +1100 -191
  44. package/switch.cmd +2 -2
  45. package/switch.mjs +228 -53
  46. package/tests/blindfold-e2e.test.mjs +380 -0
  47. package/tests/blindfold-task5.test.mjs +429 -0
  48. package/tests/blindfold.task3.test.mjs +700 -0
  49. package/tests/blindfold.test.mjs +10 -5
  50. package/tests/catalog.test.mjs +147 -0
  51. package/tests/classifier.test.mjs +210 -0
  52. package/tests/contract-lab.test.mjs +22 -7
  53. package/tests/formats.test.mjs +63 -46
  54. package/tests/gateway.e2e.test.mjs +136 -36
  55. package/tests/lifecycle.test.mjs +16 -10
  56. package/tests/mcp.test.mjs +78 -2
  57. package/tests/real-user-sim.test.mjs +464 -0
  58. package/tests/shim.test.mjs +159 -66
  59. package/tests/state.test.mjs +975 -193
  60. package/tests/switch.test.mjs +446 -2
  61. package/ui.html +1710 -1726
package/ui.html CHANGED
@@ -1,14 +1,14 @@
1
- <!DOCTYPE html>
1
+ <!doctype html>
2
2
  <html lang="en">
3
3
  <head>
4
- <meta charset="UTF-8">
5
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
- <meta name="description" content="Local dashboard for the LLM Switcher gateway: route Claude Code, Codex, OpenAI and Vertex clients to a provider of your choice.">
7
- <title>LLM Switcher | Local gateway</title>
8
- <link rel="icon" href="data:,">
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>LLM Switcher · Edge Gateway</title>
7
+ <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0' y1='0' x2='1' y2='1'%3E%3Cstop offset='0%25' stop-color='%237c6cf6'/%3E%3Cstop offset='100%25' stop-color='%235eead4'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='32' height='32' rx='9' fill='url(%23g)'/%3E%3Cpath d='M8 12h16m-4-4 4 4-4 4M24 20H8m4-4-4 4 4 4' fill='none' stroke='%23fff' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E">
8
+ <meta name="theme-color" content="#7c6cf6">
9
9
  <script>
10
10
  try {
11
- const savedTheme = localStorage.getItem('lsw-theme');
11
+ const savedTheme = localStorage.getItem('intact-theme') || localStorage.getItem('lsw-theme');
12
12
  if (savedTheme === 'light' || (!savedTheme && window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches)) {
13
13
  document.documentElement.dataset.theme = 'light';
14
14
  }
@@ -16,1178 +16,1078 @@
16
16
  </script>
17
17
  <style>
18
18
  :root {
19
- /* Dark Theme (Default) - Carbon / Zinc Developer Aesthetic */
20
- --bg: #090a0f;
21
- --surface: #11131a;
22
- --surface-card: #141722;
23
- --surface-elevated: #1a1e2d;
24
- --surface-well: #0a0c12;
25
- --border: rgba(255, 255, 255, 0.08);
26
- --border-strong: rgba(255, 255, 255, 0.16);
27
- --border-active: #38bdf8;
28
- --text: #f8fafc;
29
- --text-muted: #94a3b8;
30
- /* Measured against the darkest and the lightest surface this token sits on
31
- (--bg through --surface-elevated). #64748b read 3.48:1 on the elevated card. */
32
- --text-dim: #7d8ba1;
33
- --primary-bg: #f8fafc;
34
- --primary-fg: #090a0f;
35
- --primary-hover: #e2e8f0;
36
- --accent: #38bdf8;
37
- --accent-muted: rgba(56, 189, 248, 0.1);
38
- --success: #10b981;
39
- --success-muted: rgba(16, 185, 129, 0.12);
40
- --warning: #f59e0b;
41
- --warning-muted: rgba(245, 158, 11, 0.12);
42
- --danger: #f87171;
43
- --danger-muted: rgba(248, 113, 113, 0.12);
19
+ color-scheme: dark;
20
+ --bg: #0c1017;
21
+ --side: #10151e;
22
+ --card: #141a25;
23
+ --card2: #1b2331;
24
+ --line: #232d3d;
25
+ --fg: #e4e9f1;
26
+ --muted: #95a0b3;
27
+ --faint: #8b94a7;
28
+ --accent: #8b80f9;
29
+ --accent-strong: #6c5ce7;
30
+ --accent-soft: rgba(139, 128, 249, 0.15);
31
+ --accent-fg: #ffffff;
32
+ --mint: #5eead4;
33
+ --ok: #4ade80;
34
+ --ok-soft: rgba(74, 222, 128, 0.12);
35
+ --warn: #fbbf24;
36
+ --warn-soft: rgba(251, 191, 36, 0.1);
37
+ --err: #fb7185;
38
+ --err-soft: rgba(251, 113, 133, 0.12);
39
+ --code: #5eead4;
40
+ --shadow: 0 20px 50px rgba(0, 0, 0, 0.55);
41
+
42
+ /* Compatibility aliases */
43
+ --surface: var(--side);
44
+ --surface-card: var(--card);
45
+ --surface-elevated: var(--card2);
46
+ --surface-well: var(--bg);
47
+ --border: var(--line);
48
+ --border-strong: #334155;
49
+ --border-active: var(--accent);
50
+ --text: var(--fg);
51
+ --text-muted: var(--muted);
52
+ --text-dim: var(--faint);
53
+ --primary-bg: var(--accent-strong);
54
+ --primary-fg: var(--accent-fg);
55
+ --primary-hover: var(--accent);
56
+ --accent-muted: var(--accent-soft);
57
+ --success: var(--ok);
58
+ --success-muted: var(--ok-soft);
59
+ --warning: var(--warn);
60
+ --warning-muted: var(--warn-soft);
61
+ --danger: var(--err);
62
+ --danger-muted: var(--err-soft);
44
63
  --radius-sm: 6px;
45
64
  --radius: 8px;
46
- --radius-lg: 10px;
47
- --font-sans: "Segoe UI Variable", "Aptos", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
48
- --font-mono: ui-monospace, "SF Mono", "Cascadia Code", "JetBrains Mono", Menlo, Consolas, monospace;
49
- }
50
-
51
- [data-theme="light"] {
52
- --bg: #f8fafc;
53
- --surface: #ffffff;
54
- --surface-card: #ffffff;
55
- --surface-elevated: #f1f5f9;
56
- --surface-well: #f8fafc;
57
- --border: #e2e8f0;
58
- --border-strong: #cbd5e1;
59
- --border-active: #0284c7;
60
- --text: #0f172a;
61
- --text-muted: #475569;
62
- /* Measured against the two backgrounds these tokens actually sit on (#ffffff and
63
- --bg), and against the 10% tint the badges blend to. The previous values read
64
- 2.45:1 to 4.13:1, below the 4.5:1 floor. */
65
- --text-dim: #64748b;
66
- --primary-bg: #0f172a;
67
- --primary-fg: #ffffff;
68
- --primary-hover: #1e293b;
69
- --accent: #0284c7;
70
- --accent-muted: rgba(2, 132, 199, 0.08);
71
- --success: #047857;
72
- --success-muted: rgba(4, 120, 87, 0.1);
73
- --warning: #92400e;
74
- --warning-muted: rgba(146, 64, 14, 0.1);
75
- --danger: #b91c1c;
76
- --danger-muted: rgba(185, 28, 28, 0.1);
65
+ --radius-lg: 12px;
66
+ --font-sans: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
67
+ --font-mono: ui-monospace, SFMono-Regular, Consolas, monospace;
68
+ }
69
+
70
+ @media (prefers-color-scheme: light) {
71
+ :root:not([data-theme="dark"]) {
72
+ color-scheme: light;
73
+ --bg: #f3f5f9;
74
+ --side: #ffffff;
75
+ --card: #ffffff;
76
+ --card2: #eef1f6;
77
+ --line: #dfe4ec;
78
+ --fg: #141a25;
79
+ --muted: #526076;
80
+ --faint: #5f6b7d;
81
+ --accent: #5b4bdb;
82
+ --accent-strong: #5b4bdb;
83
+ --accent-soft: rgba(91, 75, 219, 0.1);
84
+ --mint: #0f766e;
85
+ --ok: #166534;
86
+ --ok-soft: rgba(22, 101, 52, 0.1);
87
+ --warn: #92400e;
88
+ --warn-soft: rgba(146, 64, 14, 0.08);
89
+ --err: #be123c;
90
+ --err-soft: rgba(190, 18, 60, 0.08);
91
+ --code: #0f766e;
92
+ --shadow: 0 20px 50px rgba(20, 26, 37, 0.14);
93
+ --border-strong: #cbd5e1;
94
+ }
95
+ }
96
+
97
+ :root[data-theme="light"] {
77
98
  color-scheme: light;
99
+ --bg: #f3f5f9;
100
+ --side: #ffffff;
101
+ --card: #ffffff;
102
+ --card2: #eef1f6;
103
+ --line: #dfe4ec;
104
+ --fg: #141a25;
105
+ --muted: #526076;
106
+ --faint: #5f6b7d;
107
+ --accent: #5b4bdb;
108
+ --accent-strong: #5b4bdb;
109
+ --accent-soft: rgba(91, 75, 219, 0.1);
110
+ --mint: #0f766e;
111
+ --ok: #166534;
112
+ --ok-soft: rgba(22, 101, 52, 0.1);
113
+ --warn: #92400e;
114
+ --warn-soft: rgba(146, 64, 14, 0.08);
115
+ --err: #be123c;
116
+ --err-soft: rgba(190, 18, 60, 0.08);
117
+ --code: #0f766e;
118
+ --shadow: 0 20px 50px rgba(20, 26, 37, 0.14);
119
+ --border-strong: #cbd5e1;
78
120
  }
79
121
 
80
- * { box-sizing: border-box; margin: 0; padding: 0; font-family: var(--font-sans); }
122
+ * { box-sizing: border-box; margin: 0; padding: 0; }
123
+ html, body { height: 100%; }
81
124
  body {
125
+ font: 14px/1.5 var(--font-sans);
82
126
  background: var(--bg);
83
- color: var(--text);
84
- padding: 24px 16px 48px;
85
- min-height: 100dvh;
86
- line-height: 1.5;
127
+ color: var(--fg);
128
+ display: grid;
129
+ grid-template-columns: 15rem 1fr;
87
130
  -webkit-font-smoothing: antialiased;
88
- text-rendering: optimizeLegibility;
89
131
  }
90
- .container { max-width: 1180px; margin: 0 auto; }
91
132
 
92
- /* Header */
93
- header {
133
+ /* Aside Navigation */
134
+ aside {
135
+ background: var(--side);
136
+ border-right: 1px solid var(--line);
137
+ padding: 1.1rem 0.75rem;
94
138
  display: flex;
95
- justify-content: space-between;
96
- align-items: center;
97
- margin-bottom: 20px;
98
- padding-bottom: 16px;
99
- border-bottom: 1px solid var(--border);
100
- }
101
- .title-group { display: flex; align-items: center; gap: 12px; }
102
- .logo-badge {
103
- width: 34px;
104
- height: 32px;
105
- border-radius: var(--radius-sm);
106
- background: var(--surface-elevated);
107
- border: 1px solid var(--border-strong);
108
- display: flex;
109
- align-items: center;
110
- justify-content: center;
111
- color: var(--text);
112
- font-family: var(--font-mono);
113
- font-weight: 700;
114
- font-size: 11px;
115
- letter-spacing: 0.06em;
116
- }
117
- .title-group h1 {
118
- font-size: 17px;
119
- font-weight: 600;
120
- color: var(--text);
121
- letter-spacing: -0.025em;
122
- }
123
- .title-group p {
124
- font-size: 12px;
125
- color: var(--text-dim);
126
- margin-top: 1px;
139
+ flex-direction: column;
140
+ gap: 0.25rem;
141
+ position: sticky;
142
+ top: 0;
143
+ height: 100vh;
144
+ overflow-y: auto;
145
+ z-index: 10;
127
146
  }
128
- .header-actions { display: flex; gap: 8px; align-items: center; }
129
147
 
130
- /* Buttons */
131
- .btn {
132
- display: inline-flex;
148
+ .brand {
149
+ display: flex;
133
150
  align-items: center;
134
- justify-content: center;
135
- gap: 6px;
136
- padding: 7px 14px;
137
- font-size: 12px;
138
- font-weight: 500;
139
- border-radius: var(--radius-sm);
140
- border: 1px solid transparent;
141
- cursor: pointer;
142
- transition: background 0.15s, border-color 0.15s, color 0.15s;
143
- white-space: nowrap;
151
+ gap: 0.7rem;
152
+ padding: 0.35rem 0.5rem 1.25rem;
153
+ margin-bottom: 0.5rem;
154
+ border-bottom: 1px solid var(--line);
144
155
  text-decoration: none;
156
+ color: var(--fg);
157
+ }
158
+ .logo {
159
+ width: 38px;
160
+ height: 38px;
161
+ flex: none;
162
+ filter: drop-shadow(0 4px 14px rgba(124, 108, 246, 0.45));
163
+ }
164
+ .logo svg { width: 100%; height: 100%; display: block; }
165
+ .brand b {
166
+ display: block;
167
+ font-size: 1.25rem;
168
+ font-weight: 800;
169
+ letter-spacing: -0.03em;
170
+ line-height: 1.1;
171
+ color: var(--fg);
172
+ }
173
+ .brand small {
174
+ display: block;
175
+ color: var(--muted);
176
+ font-size: 0.72rem;
177
+ letter-spacing: 0.02em;
178
+ }
179
+
180
+ aside nav { display: grid; gap: 0.25rem; }
181
+ aside a.nav-link {
182
+ display: flex;
183
+ align-items: center;
184
+ gap: 0.65rem;
185
+ padding: 0.5rem 0.7rem;
186
+ border-radius: 8px;
187
+ color: var(--muted);
188
+ text-decoration: none;
189
+ font-size: 0.88rem;
190
+ font-weight: 500;
191
+ transition: background 0.15s, color 0.15s;
145
192
  }
146
- .btn:disabled { opacity: 0.5; cursor: not-allowed; }
147
- .btn:active:not(:disabled) { transform: translateY(1px); }
148
- .btn-sm { padding: 4px 10px; font-size: 11px; border-radius: 5px; }
149
-
150
- .btn:focus-visible,
151
- .tab-btn:focus-visible,
152
- .switch input:focus-visible + .slider,
153
- input:focus-visible,
154
- select:focus-visible {
155
- outline: 2px solid var(--accent);
156
- outline-offset: 2px;
157
- }
158
-
159
- .btn-primary {
160
- background: var(--primary-bg);
161
- color: var(--primary-fg);
162
- border-color: var(--primary-bg);
163
- }
164
- .btn-primary:hover:not(:disabled) {
165
- background: var(--primary-hover);
166
- border-color: var(--primary-hover);
193
+ aside a.nav-link:hover { color: var(--fg); background: var(--card2); }
194
+ aside a.nav-link[aria-current="page"] {
195
+ background: var(--accent-soft);
196
+ color: var(--accent);
197
+ font-weight: 600;
167
198
  }
168
-
169
- .btn-outline {
170
- background: var(--surface);
171
- color: var(--text);
172
- border-color: var(--border-strong);
199
+ aside a.nav-link svg { width: 18px; height: 18px; flex: none; }
200
+ aside a.nav-link .ct {
201
+ margin-left: auto;
202
+ font-size: 0.72rem;
203
+ color: var(--faint);
204
+ font-variant-numeric: tabular-nums;
205
+ padding: 0.1rem 0.4rem;
206
+ border-radius: 999px;
207
+ background: var(--card2);
173
208
  }
174
- .btn-outline:hover:not(:disabled) {
175
- background: var(--surface-elevated);
176
- border-color: var(--border-strong);
209
+ aside a.nav-link .ct:empty { display: none; }
210
+ aside a.nav-link[aria-current="page"] .ct {
211
+ background: color-mix(in srgb, var(--accent) 20%, transparent);
212
+ color: var(--accent);
177
213
  }
214
+ aside .spacer { flex: 1; }
178
215
 
179
- .btn-danger {
180
- background: var(--danger-muted);
181
- color: var(--danger);
182
- border-color: rgba(239, 68, 68, 0.25);
183
- }
184
- .btn-danger:hover:not(:disabled) {
185
- background: rgba(239, 68, 68, 0.2);
216
+ .prefs {
217
+ display: flex;
218
+ flex-direction: column;
219
+ gap: 0.65rem;
220
+ padding: 16px 14px;
221
+ border-top: 1px solid var(--line);
222
+ margin-top: 0.5rem;
186
223
  }
187
-
188
- .btn-success {
189
- background: var(--success-muted);
190
- color: var(--success);
191
- border-color: rgba(16, 185, 129, 0.25);
224
+ .seg {
225
+ display: inline-flex;
226
+ background: var(--bg);
227
+ border: 1px solid var(--line);
228
+ border-radius: 8px;
229
+ padding: 4px;
230
+ gap: 3px;
231
+ }
232
+ .seg button {
233
+ font: inherit;
234
+ min-height: 24px;
235
+ padding: 0.15rem 0.6rem;
236
+ font-size: 0.75rem;
237
+ background: transparent;
238
+ color: var(--muted);
239
+ border: 0;
240
+ border-radius: 6px;
241
+ cursor: pointer;
192
242
  }
193
- .btn-success:hover:not(:disabled) {
194
- background: rgba(16, 185, 129, 0.2);
243
+ .seg button:hover { color: var(--fg); }
244
+ .seg button[aria-pressed="true"] {
245
+ background: var(--accent-soft);
246
+ color: var(--accent);
247
+ font-weight: 600;
195
248
  }
196
249
 
197
- /* Telemetry Status Banner */
198
- .status-banner {
250
+ /* Page Area */
251
+ .page {
252
+ min-width: 0;
199
253
  display: flex;
200
- justify-content: space-between;
201
- align-items: center;
202
- background: var(--surface);
203
- border: 1px solid var(--border);
204
- border-radius: var(--radius);
205
- padding: 14px 18px;
206
- margin-bottom: 24px;
207
- }
208
- .status-left { display: flex; align-items: center; gap: 12px; min-width: 0; }
209
- .status-dot-wrapper {
210
- position: relative;
211
- width: 12px;
212
- height: 12px;
254
+ flex-direction: column;
255
+ min-height: 100vh;
256
+ }
257
+ .ph {
213
258
  display: flex;
214
259
  align-items: center;
215
- justify-content: center;
216
- flex-shrink: 0;
217
- }
218
- .status-dot {
219
- width: 8px;
220
- height: 8px;
221
- border-radius: 50%;
222
- background: var(--text-dim);
223
- box-shadow: 0 0 0 2px rgba(100, 116, 139, 0.2);
224
- transition: background 0.2s, box-shadow 0.2s;
225
- }
226
- .status-dot.active {
227
- background: var(--success);
228
- box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.2);
229
- }
230
- .status-title {
231
- font-size: 13px;
232
- font-weight: 600;
233
- color: var(--text);
234
- letter-spacing: -0.01em;
260
+ justify-content: space-between;
261
+ gap: 0.75rem;
262
+ padding: 1.1rem 2rem;
263
+ border-bottom: 1px solid var(--line);
264
+ min-height: 4.5rem;
265
+ flex-wrap: wrap;
235
266
  }
236
- .status-sub {
237
- font-size: 11px;
238
- color: var(--text-muted);
239
- margin-top: 1px;
240
- font-family: var(--font-mono);
241
- word-break: break-all;
267
+ .title-box h1 { font-size: 1.25rem; margin: 0; font-weight: 650; }
268
+ .title-box p { margin: 0.15rem 0 0; color: var(--muted); font-size: 0.85rem; }
269
+ .ph-actions { display: flex; align-items: center; gap: 0.6rem; flex-wrap: wrap; }
270
+
271
+ .content {
272
+ padding: 1.5rem 2rem 3rem;
273
+ width: 100%;
274
+ display: grid;
275
+ gap: 1.5rem;
276
+ align-content: start;
242
277
  }
278
+ .content > * { min-width: 0; }
243
279
 
244
- /* Section Headers */
245
- .section-header {
280
+ /* Cards & Grids */
281
+ .sec { display: grid; gap: 0.85rem; }
282
+ .sech { display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; flex-wrap: wrap; }
283
+ .card { background: var(--card); border: 1px solid var(--line); border-radius: 12px; }
284
+ .card.pad { padding: 1.1rem 1.25rem; }
285
+ .cardh {
246
286
  display: flex;
247
- justify-content: space-between;
248
287
  align-items: center;
249
- margin-bottom: 12px;
250
- }
251
- .section-title {
252
- font-size: 11px;
253
- font-weight: 600;
254
- letter-spacing: 0.05em;
255
- color: var(--text-muted);
256
- text-transform: uppercase;
257
- }
258
- .section-subtitle {
259
- font-size: 11px;
260
- color: var(--text-dim);
261
- font-weight: 400;
262
- text-transform: none;
263
- letter-spacing: 0;
288
+ gap: 0.75rem;
289
+ padding: 0.9rem 1.25rem;
290
+ border-bottom: 1px solid var(--line);
291
+ flex-wrap: wrap;
264
292
  }
293
+ .cardh .grow { min-width: min(12rem, 100%); }
294
+ .grow { flex: 1; }
295
+ .muted { color: var(--muted); }
296
+ .faint { color: var(--faint); }
297
+ .small { font-size: 0.8rem; }
298
+ code, .mono { font-family: var(--font-mono); font-size: 0.82rem; }
299
+ code { color: var(--code); }
265
300
 
266
- /* Active Multi-CLI Slots (Rackmount Grid) */
267
- .cli-slots-grid {
301
+ /* Provider Grid (Intact Style) */
302
+ .pgrid {
268
303
  display: grid;
269
- grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
270
- gap: 12px;
271
- margin-bottom: 24px;
272
- }
273
- .cli-slot-card {
274
- background: var(--surface-card);
275
- border: 1px solid var(--border);
276
- border-radius: var(--radius);
277
- padding: 12px 14px;
304
+ grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr));
305
+ gap: 0.85rem;
306
+ }
307
+ .pcard {
278
308
  display: flex;
279
309
  flex-direction: column;
280
- gap: 10px;
310
+ gap: 0.75rem;
311
+ padding: 1rem 1.15rem;
312
+ background: var(--card);
313
+ border: 1px solid var(--line);
314
+ border-radius: 11px;
315
+ color: inherit;
316
+ min-width: 0;
317
+ cursor: pointer;
281
318
  transition: border-color 0.15s, background 0.15s;
282
319
  }
283
- .cli-slot-card.active {
284
- border-color: rgba(56, 189, 248, 0.35);
285
- background: var(--surface);
286
- }
287
- .cli-slot-header {
288
- display: flex;
289
- justify-content: space-between;
290
- align-items: center;
291
- }
292
- .cli-slot-title-group {
320
+ .pcard:hover { border-color: var(--accent); }
321
+ .pcard.active { border-color: color-mix(in srgb, var(--ok) 50%, transparent); }
322
+ .pcard .top {
293
323
  display: flex;
294
324
  align-items: center;
295
- gap: 8px;
296
- }
297
- .cli-slot-icon {
298
- display: inline-flex;
299
- align-items: center;
300
- justify-content: center;
301
- width: 24px;
302
- height: 24px;
303
- border: 1px solid var(--border-strong);
304
- border-radius: 5px;
305
- color: var(--text-muted);
306
- background: var(--surface-well);
307
- font-family: var(--font-mono);
308
- font-size: 9px;
309
- font-weight: 700;
310
- letter-spacing: 0.04em;
325
+ gap: 0.75rem;
326
+ min-width: 0;
311
327
  }
312
- .cli-slot-name {
313
- font-size: 12px;
328
+ .pcard .nm {
314
329
  font-weight: 600;
315
- color: var(--text);
316
- }
317
- .cli-slot-route {
318
- font-size: 10px;
319
- color: var(--text-dim);
320
- font-family: var(--font-mono);
321
- }
322
- .cli-slot-select {
323
- background: var(--surface-well);
324
- border: 1px solid var(--border);
325
- border-radius: var(--radius-sm);
326
- padding: 7px 10px;
327
- color: var(--text);
328
- font-size: 12px;
329
- width: 100%;
330
- outline: none;
331
- cursor: pointer;
332
- transition: border-color 0.15s;
333
- }
334
- .cli-slot-select:focus {
335
- border-color: var(--border-active);
336
- }
337
- .sr-only {
338
- position: absolute;
339
- width: 1px;
340
- height: 1px;
341
- padding: 0;
342
- margin: -1px;
330
+ font-size: 0.95rem;
343
331
  overflow: hidden;
344
- clip: rect(0, 0, 0, 0);
332
+ text-overflow: ellipsis;
345
333
  white-space: nowrap;
346
- border: 0;
347
334
  }
348
335
 
349
- /* Profile Cards Grid */
350
- .profiles-grid {
336
+ .av {
337
+ width: 36px;
338
+ height: 36px;
339
+ border-radius: 9px;
351
340
  display: grid;
352
- grid-template-columns: repeat(auto-fill, minmax(310px, 1fr));
353
- gap: 14px;
354
- margin-bottom: 28px;
355
- }
356
- .profile-card {
357
- background: var(--surface-card);
358
- border: 1px solid var(--border);
359
- border-radius: var(--radius);
360
- padding: 14px 16px;
361
- transition: border-color 0.15s, background 0.15s;
362
- position: relative;
363
- }
364
- .profile-card:hover {
365
- border-color: var(--border-strong);
366
- background: var(--surface);
367
- }
368
- .profile-card.editing {
369
- border-color: var(--border-active);
370
- box-shadow: 0 0 0 1px var(--border-active);
371
- }
372
- .profile-card.active {
373
- border-left: 3px solid var(--success);
341
+ place-items: center;
342
+ font-weight: 700;
343
+ font-size: 0.85rem;
344
+ color: #ffffff;
345
+ flex: none;
346
+ }
347
+ .av.sm { width: 24px; height: 24px; border-radius: 6px; font-size: 0.65rem; }
348
+ .av.logo {
349
+ background: #ffffff;
350
+ padding: 5px;
351
+ box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.08);
352
+ }
353
+ .av.logo.sm { padding: 3px; }
354
+ .av.logo img {
355
+ width: 100%;
356
+ height: 100%;
357
+ object-fit: contain;
358
+ display: block;
374
359
  }
375
360
 
376
- .card-top {
377
- display: flex;
378
- justify-content: space-between;
379
- align-items: center;
380
- gap: 8px;
381
- margin-bottom: 6px;
382
- }
383
- .card-name-group {
384
- display: flex;
361
+ .status {
362
+ display: inline-flex;
385
363
  align-items: center;
386
- gap: 6px;
387
- min-width: 0;
388
- flex-wrap: wrap;
364
+ gap: 0.35rem;
365
+ font-size: 0.74rem;
366
+ color: var(--faint);
389
367
  }
390
- .card-name {
391
- font-size: 13px;
392
- font-weight: 600;
393
- color: var(--text);
368
+ .status.on { color: var(--ok); font-weight: 500; }
369
+ .status.on::before {
370
+ content: "";
371
+ width: 6px;
372
+ height: 6px;
373
+ border-radius: 50%;
374
+ background: currentColor;
394
375
  }
395
376
 
396
- /* Badges */
397
377
  .badge {
398
- font-size: 9px;
399
- font-weight: 600;
400
- text-transform: uppercase;
401
- letter-spacing: 0.04em;
402
- padding: 2px 6px;
403
- border-radius: 4px;
378
+ display: inline-flex;
379
+ align-items: center;
380
+ gap: 0.25rem;
381
+ font-size: 0.72rem;
382
+ font-weight: 500;
383
+ border-radius: 6px;
384
+ padding: 0.1rem 0.45rem;
385
+ background: var(--card2);
386
+ color: var(--muted);
404
387
  white-space: nowrap;
405
388
  border: 1px solid transparent;
406
389
  }
407
- .badge-hybrid {
408
- background: rgba(99, 102, 241, 0.12);
409
- color: #818cf8;
410
- border-color: rgba(99, 102, 241, 0.25);
411
- }
412
- .badge-direct {
413
- background: var(--success-muted);
414
- color: var(--success);
415
- border-color: rgba(16, 185, 129, 0.25);
416
- }
417
- .badge-convert {
418
- background: var(--warning-muted);
419
- color: var(--warning);
420
- border-color: rgba(245, 158, 11, 0.25);
421
- }
422
- .badge-outline {
423
- background: transparent;
424
- color: var(--text-dim);
425
- border-color: var(--border);
426
- }
427
- .badge-route {
428
- background: var(--surface-well);
429
- color: var(--text-muted);
430
- border-color: var(--border);
431
- font-family: var(--font-mono);
432
- font-size: 10px;
433
- }
434
- .badge-1m {
435
- background: var(--success-muted);
436
- color: var(--success);
437
- border: 1px solid rgba(16, 185, 129, 0.25);
438
- font-size: 9px;
439
- font-weight: 600;
440
- padding: 1px 4px;
441
- border-radius: 3px;
442
- margin-left: 4px;
443
- }
444
- .badge-1m-warn {
445
- background: var(--warning-muted);
446
- color: var(--warning);
447
- border: 1px solid rgba(245, 158, 11, 0.3);
448
- font-size: 9px;
449
- font-weight: 600;
450
- padding: 1px 5px;
451
- border-radius: 4px;
452
- margin-left: 4px;
453
- white-space: nowrap;
454
- cursor: help;
455
- }
456
-
457
- .warning-callout {
458
- background: var(--warning-muted);
459
- border: 1px solid rgba(245, 158, 11, 0.25);
460
- color: var(--warning);
461
- padding: 10px 12px;
462
- border-radius: var(--radius-sm);
463
- font-size: 12px;
464
- margin-bottom: 14px;
465
- line-height: 1.5;
390
+ .badge.ok { background: var(--ok-soft); color: var(--ok); }
391
+ .badge.warn { background: var(--warn-soft); color: var(--warn); }
392
+ .badge.accent { background: var(--accent-soft); color: var(--accent); }
393
+ .badge.err { background: var(--err-soft); color: var(--err); }
394
+ .badge.outline { border-color: var(--line); }
395
+
396
+ .row {
466
397
  display: flex;
467
- gap: 10px;
468
- align-items: flex-start;
398
+ align-items: center;
399
+ gap: 0.85rem;
400
+ padding: 0.85rem 1.25rem;
401
+ border-bottom: 1px solid var(--line);
469
402
  }
403
+ .row:last-child { border-bottom: 0; }
404
+ .row .who { flex: 1; min-width: 0; }
405
+ .row .who .l { font-weight: 600; font-size: 0.92rem; }
406
+ .row .meta { display: flex; gap: 0.4rem; align-items: center; flex-wrap: wrap; margin-top: 0.2rem; color: var(--muted); font-size: 0.8rem; }
470
407
 
471
- .card-url {
472
- font-size: 11px;
473
- color: var(--text-dim);
474
- word-break: break-all;
475
- margin-bottom: 8px;
476
- font-family: var(--font-mono);
477
- }
478
- .card-models {
479
- font-size: 11px;
480
- color: var(--text-muted);
481
- display: flex;
482
- flex-direction: column;
483
- gap: 4px;
484
- background: var(--surface-well);
485
- border: 1px solid var(--border);
486
- padding: 8px 10px;
487
- border-radius: var(--radius-sm);
488
- margin-bottom: 8px;
489
- font-family: var(--font-mono);
490
- }
491
- .card-models strong { color: var(--text); font-weight: 500; }
492
- .card-footer {
408
+ .chips { display: flex; flex-direction: column; gap: 0.35rem; min-width: 0; }
409
+ .chip {
410
+ display: grid;
411
+ grid-template-columns: minmax(0, 1fr) auto;
412
+ align-items: center;
413
+ gap: 0.1rem 0.5rem;
414
+ min-width: 0;
415
+ background: var(--card2);
416
+ border: 1px solid var(--line);
417
+ border-radius: 6px;
418
+ padding: 0.35rem 0.55rem;
419
+ font-size: 0.75rem;
420
+ color: var(--fg);
421
+ }
422
+ .chip .lbl { grid-column: 1 / -1; font-size: 0.7rem; overflow-wrap: anywhere; }
423
+ .chip .val { font-family: var(--font-mono); overflow-wrap: anywhere; }
424
+ .chip .b1m { font-weight: 600; font-size: 0.7rem; }
425
+
426
+ .copyline {
493
427
  display: flex;
494
- justify-content: space-between;
495
428
  align-items: center;
496
- font-size: 11px;
497
- margin-top: 6px;
429
+ gap: 0.5rem;
430
+ background: var(--bg);
431
+ border: 1px solid var(--line);
432
+ border-radius: 8px;
433
+ padding: 0.35rem 0.5rem 0.35rem 0.75rem;
434
+ min-width: 0;
498
435
  }
436
+ .copyline code { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 0.8rem; }
499
437
 
500
- /* Minimalist Switch Toggle */
501
- .switch {
502
- position: relative;
503
- display: inline-block;
504
- width: 32px;
505
- height: 18px;
506
- flex-shrink: 0;
507
- }
508
- .switch input { position: absolute; opacity: 0; width: 1px; height: 1px; }
509
- .slider {
510
- position: absolute;
511
- cursor: pointer;
512
- top: 0; left: 0; right: 0; bottom: 0;
513
- background-color: var(--surface-elevated);
514
- border: 1px solid var(--border-strong);
515
- transition: 0.2s;
516
- border-radius: 18px;
517
- }
518
- .slider:before {
519
- position: absolute;
520
- content: "";
521
- height: 12px;
522
- width: 12px;
523
- left: 2px;
524
- bottom: 2px;
525
- background-color: var(--text-dim);
526
- transition: 0.2s;
527
- border-radius: 50%;
528
- }
529
- input:checked + .slider {
530
- background-color: var(--success);
531
- border-color: var(--success);
532
- }
533
- input:checked + .slider:before {
534
- transform: translateX(14px);
535
- background-color: #ffffff;
438
+ .stats {
439
+ display: grid;
440
+ grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr));
441
+ gap: 0.85rem;
536
442
  }
537
-
538
- /* Editor Form Panel */
539
- .form-panel {
540
- background: var(--surface);
541
- border: 1px solid var(--border);
542
- border-radius: var(--radius-lg);
543
- padding: 20px;
544
- margin-bottom: 30px;
443
+ .stat {
444
+ background: var(--card);
445
+ border: 1px solid var(--line);
446
+ border-radius: 12px;
447
+ padding: 1rem 1.15rem;
545
448
  }
449
+ .stat .k { color: var(--muted); font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.04em; }
450
+ .stat .v { font-size: 1.5rem; font-weight: 700; margin-top: 0.2rem; }
546
451
 
547
- /* Segmented Tab Bar */
548
- .tab-bar {
452
+ /* Buttons */
453
+ button, .btn {
454
+ font: inherit;
455
+ min-height: 34px;
456
+ padding: 0.35rem 0.85rem;
457
+ border: 0;
458
+ border-radius: 8px;
459
+ cursor: pointer;
549
460
  display: inline-flex;
550
- gap: 4px;
551
- margin-bottom: 20px;
552
- background: var(--surface-well);
553
- border: 1px solid var(--border);
554
- border-radius: var(--radius);
555
- padding: 3px;
556
- flex-wrap: wrap;
557
- }
558
- .tab-btn {
559
- background: transparent;
560
- border: none;
561
- color: var(--text-muted);
562
- padding: 6px 12px;
563
- font-size: 12px;
461
+ align-items: center;
462
+ gap: 0.4rem;
463
+ justify-content: center;
464
+ text-decoration: none;
465
+ font-size: 0.84rem;
564
466
  font-weight: 500;
565
- border-radius: var(--radius-sm);
566
- cursor: pointer;
567
- transition: background 0.15s, border-color 0.15s, color 0.15s;
467
+ transition: filter 0.15s, background 0.15s, color 0.15s, border-color 0.15s;
568
468
  }
569
- .tab-btn:hover { color: var(--text); }
570
- .tab-btn.active {
571
- color: var(--text);
572
- background: var(--surface-elevated);
469
+ button.primary, .btn.btn-primary {
470
+ background: var(--accent-strong);
471
+ color: var(--accent-fg);
573
472
  font-weight: 600;
574
- border: 1px solid var(--border-strong);
575
- }
576
-
577
- .tab-pane { display: none; }
578
- .tab-pane.active { display: block; }
579
-
580
- .form-grid {
581
- display: grid;
582
- grid-template-columns: repeat(2, minmax(0, 1fr));
583
- gap: 14px;
584
- }
585
- .form-group {
586
- display: flex;
587
- flex-direction: column;
588
- gap: 6px;
589
- min-width: 0;
590
473
  }
591
- .form-group.full { grid-column: span 2; }
592
- /* .field-heading names a group of settings. A <label> that points at no field
593
- reads to assistive technology as a broken association, so headings use this
594
- instead and keep the same appearance. */
595
- label, .field-heading {
596
- font-size: 11px;
597
- font-weight: 500;
598
- color: var(--text-muted);
599
- text-transform: uppercase;
600
- letter-spacing: 0.04em;
601
- }
602
- input, select {
603
- background: var(--surface-well);
604
- border: 1px solid var(--border);
605
- border-radius: var(--radius-sm);
606
- padding: 8px 11px;
607
- color: var(--text);
608
- font-size: 12px;
609
- outline: none;
610
- transition: border-color 0.15s;
474
+ button.primary:hover, .btn.btn-primary:hover { filter: brightness(1.1); }
475
+ button.ghost, .btn.btn-outline {
476
+ background: transparent;
477
+ color: var(--fg);
478
+ border: 1px solid var(--line);
479
+ }
480
+ button.ghost:hover, .btn.btn-outline:hover {
481
+ background: var(--card2);
482
+ border-color: var(--faint);
483
+ }
484
+ button.plain { background: transparent; color: var(--muted); }
485
+ button.plain:hover { color: var(--fg); background: var(--card2); }
486
+ button.danger, .btn.btn-danger {
487
+ background: var(--err-soft);
488
+ color: var(--err);
489
+ border: 1px solid color-mix(in srgb, var(--err) 30%, transparent);
490
+ }
491
+ button.danger:hover, .btn.btn-danger:hover {
492
+ background: var(--err);
493
+ color: #ffffff;
494
+ }
495
+ button.icon { min-height: 28px; min-width: 28px; padding: 0.2rem; background: transparent; color: var(--muted); border-radius: 6px; }
496
+ button.icon:hover { color: var(--fg); background: var(--card2); }
497
+ button:disabled, .btn:disabled { opacity: 0.4; cursor: not-allowed; pointer-events: none; }
498
+ .btn-sm { min-height: 26px; padding: 0.2rem 0.6rem; font-size: 0.78rem; border-radius: 6px; }
499
+
500
+ /* Inputs */
501
+ input, select, textarea {
502
+ font: inherit;
503
+ padding: 0.45rem 0.7rem;
504
+ border: 1px solid var(--line);
505
+ border-radius: 8px;
506
+ background: var(--bg);
507
+ color: var(--fg);
508
+ min-height: 36px;
611
509
  width: 100%;
612
- max-width: 100%;
613
- min-width: 0;
614
- }
615
- input:focus, select:focus {
616
- border-color: var(--border-active);
510
+ font-size: 0.85rem;
617
511
  }
618
- input:disabled, select:disabled {
619
- color: var(--text-dim);
620
- cursor: not-allowed;
621
- }
622
-
623
- .preset-bar {
624
- display: flex;
625
- gap: 6px;
626
- align-items: center;
627
- margin-bottom: 16px;
628
- flex-wrap: wrap;
629
- }
630
- .preset-label {
631
- font-size: 10px;
632
- color: var(--text-dim);
633
- font-weight: 600;
634
- text-transform: uppercase;
635
- letter-spacing: 0.06em;
636
- margin-right: 4px;
637
- }
638
-
639
- .form-actions {
512
+ input:focus, select:focus, textarea:focus, button:focus-visible, a:focus-visible {
513
+ outline: none;
514
+ border-color: var(--accent);
515
+ box-shadow: 0 0 0 3px var(--accent-soft);
516
+ }
517
+ label.f { display: grid; gap: 0.35rem; font-size: 0.82rem; font-weight: 500; }
518
+ .hint { font-size: 0.75rem; color: var(--faint); font-weight: 400; }
519
+ .search { position: relative; max-width: 18rem; width: 100%; }
520
+ .search svg { position: absolute; left: 0.65rem; top: 50%; transform: translateY(-50%); color: var(--faint); width: 15px; height: 15px; pointer-events: none; }
521
+ .search input { padding-left: 2rem; min-height: 32px; }
522
+
523
+ /* Switch */
524
+ .switch { position: relative; width: 36px; height: 20px; flex: none; display: inline-block; }
525
+ .switch input { position: absolute; opacity: 0; inset: 0; margin: 0; cursor: pointer; min-height: 0; }
526
+ .switch span { position: absolute; inset: 0; border-radius: 999px; background: var(--line); transition: background 0.15s; pointer-events: none; }
527
+ .switch span::after { content: ""; position: absolute; top: 2px; left: 2px; width: 16px; height: 16px; border-radius: 50%; background: #ffffff; transition: transform 0.15s; }
528
+ .switch input:checked + span { background: var(--accent-strong); }
529
+ .switch input:checked + span::after { transform: translateX(16px); }
530
+
531
+ /* Modal */
532
+ .overlay {
533
+ position: fixed;
534
+ inset: 0;
535
+ background: rgba(0, 0, 0, 0.65);
536
+ backdrop-filter: blur(3px);
537
+ display: grid;
538
+ place-items: center;
539
+ padding: 1rem;
540
+ z-index: 50;
541
+ }
542
+ .modal {
543
+ background: var(--card);
544
+ border: 1px solid var(--line);
545
+ border-radius: 14px;
546
+ box-shadow: var(--shadow);
547
+ width: min(38rem, 100%);
548
+ max-height: 90vh;
640
549
  display: flex;
641
- justify-content: space-between;
642
- align-items: center;
643
- margin-top: 20px;
644
- padding-top: 16px;
645
- border-top: 1px solid var(--border);
646
- gap: 12px;
647
- flex-wrap: wrap;
648
- }
649
-
650
- /* Test Results Card */
651
- .test-result-box {
652
- margin-top: 14px;
653
- border-radius: var(--radius-sm);
654
- padding: 12px 14px;
655
- font-size: 12px;
656
- display: none;
657
- border: 1px solid transparent;
658
- }
659
- .test-result-box.success {
660
- background: var(--success-muted);
661
- border-color: rgba(16, 185, 129, 0.25);
662
- color: var(--success);
663
- }
664
- .test-result-box.error {
665
- background: var(--danger-muted);
666
- border-color: rgba(239, 68, 68, 0.25);
667
- color: var(--danger);
668
- }
669
- .test-meta { display: flex; gap: 12px; font-weight: 600; margin-bottom: 6px; font-family: var(--font-mono); }
670
- .test-sample {
671
- background: var(--surface-well);
672
- border: 1px solid var(--border);
673
- padding: 8px 10px;
674
- border-radius: 4px;
675
- font-family: var(--font-mono);
676
- font-size: 11px;
677
- white-space: pre-wrap;
678
- word-break: break-all;
679
- max-height: 120px;
680
- overflow-y: auto;
681
- color: var(--text-muted);
682
- }
683
-
684
- /* Model Explorer */
685
- .model-browser {
686
- margin-top: 10px;
687
- background: var(--surface-well);
688
- border: 1px solid var(--border);
689
- border-radius: var(--radius-sm);
690
- padding: 12px;
550
+ flex-direction: column;
551
+ overflow: hidden;
691
552
  }
692
- .model-browser-header {
553
+ .mh {
693
554
  display: flex;
694
555
  justify-content: space-between;
695
556
  align-items: center;
696
- margin-bottom: 10px;
697
- gap: 10px;
698
- flex-wrap: wrap;
557
+ padding: 1rem 1.25rem;
558
+ border-bottom: 1px solid var(--line);
699
559
  }
700
- .model-list-scroll {
701
- max-height: 220px;
560
+ .mh strong { font-size: 1.05rem; }
561
+ .mb {
562
+ padding: 1.15rem 1.25rem;
702
563
  overflow-y: auto;
703
564
  display: flex;
704
565
  flex-direction: column;
705
- gap: 4px;
706
- padding-right: 4px;
566
+ gap: 1rem;
707
567
  }
708
- .model-row {
568
+ .mf {
709
569
  display: flex;
710
570
  justify-content: space-between;
711
571
  align-items: center;
712
- padding: 6px 10px;
713
- background: var(--surface);
714
- border: 1px solid var(--border);
715
- border-radius: 5px;
716
- font-size: 12px;
717
- gap: 10px;
572
+ padding: 0.85rem 1.25rem;
573
+ border-top: 1px solid var(--line);
574
+ background: var(--card2);
718
575
  }
719
- .model-row:hover {
720
- border-color: var(--border-strong);
721
- background: var(--surface-elevated);
576
+
577
+ /* Tabs (Intact style) */
578
+ .tabs {
579
+ display: flex;
580
+ gap: 0.25rem;
581
+ box-shadow: inset 0 -1px 0 var(--line);
582
+ overflow-x: auto;
583
+ overflow-y: hidden;
584
+ scrollbar-width: none;
585
+ }
586
+ .tabs::-webkit-scrollbar { display: none; }
587
+ .tabs button.tab-btn {
588
+ padding: 0.55rem 0.95rem;
589
+ color: var(--muted);
590
+ background: transparent;
591
+ border: 0;
592
+ border-bottom: 2px solid transparent;
593
+ white-space: nowrap;
594
+ font-weight: 500;
595
+ font-size: 0.85rem;
596
+ cursor: pointer;
597
+ border-radius: 0;
598
+ min-height: 0;
599
+ }
600
+ .tabs button.tab-btn:hover { color: var(--fg); }
601
+ .tabs button.tab-btn.active {
602
+ color: var(--fg);
603
+ border-bottom-color: var(--accent);
604
+ font-weight: 600;
722
605
  }
723
- .model-id { font-family: var(--font-mono); color: var(--text); font-size: 11px; word-break: break-all; }
724
- .model-btn-group { display: flex; gap: 4px; flex-shrink: 0; }
725
606
 
726
- /* Inspector Logs */
607
+ /* Log Items */
727
608
  .log-item {
728
- background: var(--surface-well);
729
- border: 1px solid var(--border);
730
- border-radius: var(--radius-sm);
731
- padding: 10px 12px;
732
- font-size: 12px;
609
+ background: var(--card);
610
+ border: 1px solid var(--line);
611
+ border-radius: 9px;
612
+ padding: 0.75rem 1rem;
613
+ margin-bottom: 0.6rem;
733
614
  display: flex;
734
615
  flex-direction: column;
735
- gap: 6px;
616
+ gap: 0.45rem;
736
617
  }
737
- .log-header {
738
- display: flex;
739
- justify-content: space-between;
740
- align-items: center;
741
- gap: 8px;
742
- flex-wrap: wrap;
743
- }
744
- .log-badges { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
745
- .log-body {
746
- background: var(--surface);
747
- border: 1px solid var(--border);
748
- padding: 8px 10px;
749
- border-radius: 4px;
750
- font-family: var(--font-mono);
751
- font-size: 11px;
752
- color: var(--text-muted);
753
- white-space: pre-wrap;
754
- word-break: break-all;
755
- max-height: 120px;
756
- overflow-y: auto;
618
+ .log-item details { margin-top: 0.2rem; }
619
+ .log-item details summary { cursor: pointer; color: var(--accent); font-size: 0.78rem; user-select: none; }
620
+ .log-item pre {
621
+ margin-top: 0.4rem;
622
+ background: var(--bg);
623
+ border: 1px solid var(--line);
624
+ border-radius: 6px;
625
+ padding: 0.6rem 0.75rem;
626
+ overflow-x: auto;
627
+ font-size: 0.76rem;
628
+ line-height: 1.4;
757
629
  }
758
630
 
759
- /* Toast Notification */
631
+ /* Toast */
760
632
  .toast {
761
633
  position: fixed;
762
- bottom: 24px;
763
- right: 24px;
764
- background: var(--surface-elevated);
765
- border: 1px solid var(--border-strong);
766
- color: var(--text);
767
- padding: 9px 16px;
768
- border-radius: var(--radius-sm);
769
- font-size: 12px;
770
- font-weight: 500;
771
- box-shadow: 0 8px 24px rgba(0,0,0,0.4);
634
+ bottom: 1.25rem;
635
+ left: 50%;
636
+ transform: translateX(-50%);
637
+ background: var(--card2);
638
+ color: var(--fg);
639
+ border: 1px solid var(--line);
640
+ box-shadow: var(--shadow);
641
+ padding: 0.55rem 1.1rem;
642
+ border-radius: 10px;
643
+ font-size: 0.85rem;
644
+ z-index: 60;
772
645
  display: none;
773
- z-index: 1000;
646
+ animation: fadeIn 0.15s ease-out;
774
647
  }
648
+ .toast.err { border-color: var(--err); color: var(--err); }
649
+ @keyframes fadeIn { from { opacity: 0; transform: translate(-50%, 8px); } to { opacity: 1; transform: translate(-50%, 0); } }
775
650
 
776
- @media (prefers-reduced-motion: reduce) {
777
- *, *::before, *::after {
778
- scroll-behavior: auto !important;
779
- transition-duration: 0.01ms !important;
780
- animation-duration: 0.01ms !important;
781
- animation-iteration-count: 1 !important;
782
- }
651
+ /* Test Result Box in Modal */
652
+ .test-result-box {
653
+ border: 1px solid var(--line);
654
+ border-radius: 8px;
655
+ padding: 0.75rem;
656
+ background: var(--bg);
657
+ font-size: 0.8rem;
658
+ display: none;
783
659
  }
784
-
785
- @media (max-width: 640px) {
786
- body { padding: 16px 12px; }
787
- .form-grid { grid-template-columns: minmax(0, 1fr); }
788
- .form-group.full { grid-column: span 1; }
789
- .status-banner { flex-direction: column; align-items: stretch; gap: 12px; }
790
- #status-action .btn { width: 100%; }
791
- .form-actions { flex-direction: column; align-items: stretch; }
792
- .form-actions > div { display: flex; flex-direction: column; width: 100%; }
793
- .cli-slots-grid { grid-template-columns: minmax(0, 1fr); }
660
+ .test-result-box.success { border-color: color-mix(in srgb, var(--ok) 40%, transparent); }
661
+ .test-result-box.error { border-color: color-mix(in srgb, var(--err) 40%, transparent); }
662
+
663
+ /* Responsive Design (Matching Intact) */
664
+ @media (max-width: 820px) {
665
+ body { grid-template-columns: minmax(0, 1fr); }
666
+ aside, main, .page { min-width: 0; }
667
+ aside {
668
+ position: sticky;
669
+ top: 0;
670
+ height: auto;
671
+ flex-direction: row;
672
+ flex-wrap: wrap;
673
+ align-items: center;
674
+ padding: 0.55rem 0.85rem 0.4rem;
675
+ gap: 0.4rem;
676
+ border-right: 0;
677
+ border-bottom: 1px solid var(--line);
678
+ z-index: 10;
679
+ }
680
+ aside .brand { padding: 0 0.5rem 0 0; margin: 0; border: 0; }
681
+ aside .brand small { display: none; }
682
+ aside .brand b { font-size: 1.15rem; }
683
+ aside .logo { width: 30px; height: 30px; }
684
+ aside nav {
685
+ order: 3;
686
+ flex: 1 0 100%;
687
+ min-width: 0;
688
+ display: flex;
689
+ gap: 0.25rem;
690
+ overflow-x: auto;
691
+ scrollbar-width: none;
692
+ margin: 0 -0.25rem;
693
+ }
694
+ aside nav::-webkit-scrollbar { display: none; }
695
+ aside a.nav-link { padding: 0.45rem 0.65rem; white-space: nowrap; font-size: 0.82rem; }
696
+ aside a.nav-link svg { width: 15px; height: 15px; }
697
+ aside a.nav-link .ct { display: inline-block; font-size: 0.68rem; }
698
+ aside .spacer { flex: 1; }
699
+ aside .prefs {
700
+ width: auto;
701
+ padding: 0;
702
+ border: 0;
703
+ flex-direction: row;
704
+ gap: 0.5rem;
705
+ align-items: center;
706
+ }
707
+ aside .prefs > div { width: auto !important; margin-top: 0 !important; gap: 0.4rem; }
708
+ aside .prefs span { display: none; }
709
+ .ph { padding: 1rem; flex-wrap: wrap; gap: 0.75rem; }
710
+ .content { padding: 1rem 1rem 2.5rem; }
711
+ .row { padding: 0.75rem 1rem; flex-wrap: wrap; }
712
+ .pgrid { grid-template-columns: 1fr; }
794
713
  }
795
714
  </style>
796
715
  </head>
797
716
  <body>
798
- <main class="container">
799
- <!-- Header -->
800
- <header>
801
- <div class="title-group">
802
- <div class="logo-badge" aria-hidden="true">LS</div>
803
- <div>
804
- <h1>LLM Switcher</h1>
805
- <p>Local gateway on <span class="gateway-port">3456</span>. Route each coding CLI independently.</p>
806
- </div>
807
- </div>
808
- <div class="header-actions">
809
- <button id="btn-theme" class="btn btn-outline btn-sm" onclick="toggleTheme()" type="button">Theme</button>
810
- <button class="btn btn-outline btn-sm" onclick="loadStatus()" type="button">Refresh</button>
717
+ <!-- Sidebar -->
718
+ <aside>
719
+ <a class="brand" href="#/routes" aria-label="LLM Switcher home">
720
+ <span class="logo">
721
+ <svg viewBox="0 0 32 32" aria-hidden="true">
722
+ <defs>
723
+ <linearGradient id="lsw-grad" x1="0" y1="0" x2="1" y2="1">
724
+ <stop offset="0%" stop-color="#7c6cf6"/>
725
+ <stop offset="100%" stop-color="#5eead4"/>
726
+ </linearGradient>
727
+ </defs>
728
+ <rect width="32" height="32" rx="9" fill="url(#lsw-grad)"/>
729
+ <path d="M8 12h16m-4-4 4 4-4 4M24 20H8m4-4-4 4 4 4" fill="none" stroke="#fff" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>
730
+ </svg>
731
+ </span>
732
+ <div>
733
+ <b>LLM Switcher</b>
734
+ <small>Edge Gateway · :<span class="gateway-port">3456</span></small>
811
735
  </div>
812
- </header>
813
-
814
- <!-- Status Banner -->
815
- <div class="status-banner" role="status" aria-live="polite">
816
- <div class="status-left">
817
- <div class="status-dot-wrapper">
818
- <div id="status-dot" class="status-dot"></div>
819
- </div>
820
- <div>
821
- <div id="status-title" class="status-title">Checking status...</div>
822
- <div id="status-sub" class="status-sub">Detecting active configuration</div>
736
+ </a>
737
+
738
+ <nav id="sideNav">
739
+ <a class="nav-link" href="#/routes" data-nav="routes">
740
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M4 12h16M12 4v16"/><circle cx="12" cy="12" r="8"/></svg>
741
+ Routes & Profiles<span class="ct" id="ct-routes"></span>
742
+ </a>
743
+ <a class="nav-link" href="#/models" data-nav="models">
744
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="3" y="4" width="18" height="6" rx="1.5"/><rect x="3" y="14" width="18" height="6" rx="1.5"/></svg>
745
+ Model Catalog<span class="ct" id="ct-models"></span>
746
+ </a>
747
+ <a class="nav-link" href="#/logs" data-nav="logs">
748
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/><path d="m9 15 2 2 4-4"/></svg>
749
+ Request Inspector<span class="ct" id="ct-logs"></span>
750
+ </a>
751
+ <a class="nav-link" href="#/doctor" data-nav="doctor">
752
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="9"/><path d="m9 12 2 2 4-4"/></svg>
753
+ System Doctor
754
+ </a>
755
+ </nav>
756
+
757
+ <div class="spacer"></div>
758
+
759
+ <div class="prefs" style="padding-top: 18px;">
760
+ <div style="display:flex; justify-content:space-between; align-items:center; width:100%;">
761
+ <span style="font-size:0.75rem; color:var(--muted);">Theme</span>
762
+ <div class="seg sm">
763
+ <button type="button" id="btn-theme-dark" onclick="applyThemeMode('dark')">Dark</button>
764
+ <button type="button" id="btn-theme-light" onclick="applyThemeMode('light')">Light</button>
823
765
  </div>
824
766
  </div>
825
- <div id="status-action">
826
- <button id="btn-toggle" class="btn btn-primary" onclick="toggleProxy()" type="button">Toggle all</button>
767
+ <div style="display:flex; justify-content:space-between; align-items:center; width:100%; margin-top:0.35rem;">
768
+ <span class="status on" id="status-aside-indicator">Gateway Active</span>
769
+ <button type="button" id="btn-toggle" class="btn btn-outline btn-sm" onclick="toggleProxy()">Toggle All</button>
827
770
  </div>
828
771
  </div>
829
-
830
- <!-- Active Multi-CLI Slots -->
831
- <div class="section-header" style="margin-bottom: 8px;">
832
- <div>
833
- <span class="section-title">CLI routes</span>
834
- <span class="section-subtitle" style="margin-left: 8px;">Profiles apply independently and at the same time</span>
772
+ </aside>
773
+
774
+ <!-- Main Panel -->
775
+ <div class="page">
776
+ <div class="ph">
777
+ <div class="title-box">
778
+ <h1 id="page-title">Routes & Profiles</h1>
779
+ <p id="status-sub">Route Claude Code, Codex, and OpenAI clients independently.</p>
780
+ </div>
781
+ <div class="ph-actions" id="ph-actions">
782
+ <!-- Rendered dynamically per route -->
835
783
  </div>
836
784
  </div>
837
- <div class="cli-slots-grid">
838
- <!-- Slot: Claude Code -->
839
- <div id="card-slot-anthropic" class="cli-slot-card">
840
- <div class="cli-slot-header">
841
- <div class="cli-slot-title-group">
842
- <span class="cli-slot-icon" aria-hidden="true">CC</span>
843
- <div>
844
- <div class="cli-slot-name">Claude Code</div>
845
- <div class="cli-slot-route">/v1/messages</div>
785
+
786
+ <div class="content">
787
+ <!-- VIEW 1: Routes & Profiles -->
788
+ <div id="view-routes" class="view-panel" style="display:none;">
789
+ <div class="sec">
790
+ <!-- Active Coding CLI Targets -->
791
+ <div class="card">
792
+ <div class="cardh">
793
+ <h2 style="font-size:1.02rem; font-weight:600;">Active Coding CLI Targets</h2>
794
+ <span class="badge ok" style="margin-left:auto;">Multi-Active Invariant</span>
795
+ </div>
796
+
797
+ <!-- Claude Code Target Row -->
798
+ <div class="row" id="card-slot-claude">
799
+ <span class="av logo" title="Claude Code CLI"><img src="/icons/claude.png" alt="Claude Code"></span>
800
+ <div class="who">
801
+ <div class="l">Claude Code CLI</div>
802
+ <div class="meta">Native Anthropic Messages · <code>/v1/messages</code></div>
803
+ </div>
804
+ <div style="min-width:14rem;">
805
+ <select id="select-slot-claude" onchange="switchCliTarget('claude', this.value)">
806
+ <option value="">Official endpoint</option>
807
+ </select>
808
+ </div>
809
+ <span id="badge-slot-claude" class="badge outline">OFF</span>
846
810
  </div>
847
- </div>
848
- <span id="badge-slot-anthropic" class="badge">OFF</span>
849
- </div>
850
- <label class="sr-only" for="select-slot-anthropic">Profile for Claude Code</label>
851
- <select id="select-slot-anthropic" class="cli-slot-select" onchange="switchCliTarget('anthropic', this.value)">
852
- <option value="">Official endpoint</option>
853
- </select>
854
- </div>
855
811
 
856
- <!-- Slot: Codex -->
857
- <div id="card-slot-responses" class="cli-slot-card">
858
- <div class="cli-slot-header">
859
- <div class="cli-slot-title-group">
860
- <span class="cli-slot-icon" aria-hidden="true">CX</span>
861
- <div>
862
- <div class="cli-slot-name">Codex</div>
863
- <div class="cli-slot-route">/v1/responses</div>
812
+ <!-- Codex Target Row -->
813
+ <div class="row" id="card-slot-codex">
814
+ <span class="av logo" title="OpenAI Codex CLI"><img src="/icons/codex.png" alt="OpenAI Codex"></span>
815
+ <div class="who">
816
+ <div class="l">OpenAI Codex CLI</div>
817
+ <div class="meta">Responses WebSocket · <code>/v1/responses</code></div>
818
+ </div>
819
+ <div style="min-width:14rem;">
820
+ <select id="select-slot-codex" onchange="switchCliTarget('codex', this.value)">
821
+ <option value="">Official endpoint</option>
822
+ </select>
823
+ </div>
824
+ <span id="badge-slot-codex" class="badge outline">OFF</span>
864
825
  </div>
865
826
  </div>
866
- <span id="badge-slot-responses" class="badge">OFF</span>
827
+
828
+ <!-- Configured Profiles Header -->
829
+ <div class="sech" style="margin-top:0.5rem;">
830
+ <h2 style="font-size:1.02rem; font-weight:600;">Configured Provider Profiles</h2>
831
+ <span class="hint">Click any card to edit configuration</span>
832
+ </div>
833
+
834
+ <!-- Profiles Grid -->
835
+ <div id="profiles-grid" class="pgrid"></div>
867
836
  </div>
868
- <label class="sr-only" for="select-slot-responses">Profile for Codex</label>
869
- <select id="select-slot-responses" class="cli-slot-select" onchange="switchCliTarget('responses', this.value)">
870
- <option value="">Official endpoint</option>
871
- </select>
872
837
  </div>
873
838
 
874
- <!-- Slot: OpenAI Chat -->
875
- <div id="card-slot-openai-chat" class="cli-slot-card">
876
- <div class="cli-slot-header">
877
- <div class="cli-slot-title-group">
878
- <span class="cli-slot-icon" aria-hidden="true">OA</span>
879
- <div>
880
- <div class="cli-slot-name">OpenAI Chat</div>
881
- <div class="cli-slot-route">/v1/chat/completions</div>
839
+ <!-- VIEW 2: Discovered Models Catalog -->
840
+ <div id="view-models" class="view-panel" style="display:none;">
841
+ <div class="sec">
842
+ <div class="card pad">
843
+ <div class="sech">
844
+ <div>
845
+ <h2 style="font-size:1.05rem; font-weight:600;">Dynamic Model Discovery</h2>
846
+ <p class="hint" style="margin-top:0.2rem;">Official model names are auto-polled on tool version bumps to prevent drift and 404 errors.</p>
847
+ </div>
848
+ <div class="search">
849
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
850
+ <input type="text" id="catalog-search" placeholder="Search models…" oninput="filterCatalogView(this.value)">
851
+ </div>
882
852
  </div>
883
853
  </div>
884
- <span id="badge-slot-openai-chat" class="badge">OFF</span>
885
- </div>
886
- <label class="sr-only" for="select-slot-openai-chat">Profile for OpenAI Chat</label>
887
- <select id="select-slot-openai-chat" class="cli-slot-select" onchange="switchCliTarget('openai-chat', this.value)">
888
- <option value="">Official endpoint</option>
889
- </select>
890
- </div>
891
854
 
892
- <!-- Slot: Vertex -->
893
- <div id="card-slot-vertex" class="cli-slot-card">
894
- <div class="cli-slot-header">
895
- <div class="cli-slot-title-group">
896
- <span class="cli-slot-icon" aria-hidden="true">VX</span>
897
- <div>
898
- <div class="cli-slot-name">Vertex / Gemini</div>
899
- <div class="cli-slot-route">/v1beta/models/*</div>
855
+ <div style="display:grid; grid-template-columns:repeat(auto-fit, minmax(20rem, 1fr)); gap:1.2rem;">
856
+ <div class="card pad">
857
+ <div class="sech" style="margin-bottom:0.8rem;">
858
+ <h3 style="font-size:0.95rem; font-weight:600; color:var(--accent); display:flex; align-items:center; gap:0.5rem;"><span class="av logo sm" style="width:22px; height:22px; padding:2px;"><img src="/icons/claude.png" alt="Claude"></span> Claude Code Tiers</h3>
859
+ <span class="badge accent" id="badge-claude-count">0 tiers</span>
860
+ </div>
861
+ <div id="catalog-claude-items" style="display:grid; gap:0.6rem;"></div>
862
+ </div>
863
+ <div class="card pad">
864
+ <div class="sech" style="margin-bottom:0.8rem;">
865
+ <h3 style="font-size:0.95rem; font-weight:600; color:var(--mint); display:flex; align-items:center; gap:0.5rem;"><span class="av logo sm" style="width:22px; height:22px; padding:2px;"><img src="/icons/codex.png" alt="Codex"></span> Codex Roles</h3>
866
+ <span class="badge ok" id="badge-codex-count">0 roles</span>
867
+ </div>
868
+ <div id="catalog-codex-items" style="display:grid; gap:0.6rem;"></div>
900
869
  </div>
901
870
  </div>
902
- <span id="badge-slot-vertex" class="badge">OFF</span>
903
871
  </div>
904
- <label class="sr-only" for="select-slot-vertex">Profile for Vertex or Gemini</label>
905
- <select id="select-slot-vertex" class="cli-slot-select" onchange="switchCliTarget('vertex', this.value)">
906
- <option value="">Official endpoint</option>
907
- </select>
908
872
  </div>
909
- </div>
910
873
 
911
- <!-- Providers & Profiles List -->
912
- <div class="section-header">
913
- <div>
914
- <span class="section-title">Provider profiles</span>
915
- <span class="section-subtitle" style="margin-left: 8px;">Select a profile to edit it</span>
916
- </div>
917
- <button class="btn btn-outline btn-sm" onclick="initNewProfile()" type="button">Add provider</button>
918
- </div>
919
- <div id="profiles-grid" class="profiles-grid"></div>
920
-
921
- <!-- Edit Profile Form Panel -->
922
- <div class="form-panel">
923
- <div class="section-header">
924
- <div id="form-title" class="section-title" style="color: var(--text);">Edit Provider</div>
925
- <span id="active-tag" style="font-size: 11px; color: var(--success); font-weight: 600; font-family: var(--font-mono);"></span>
926
- </div>
927
-
928
- <!-- Quick Presets -->
929
- <div class="preset-bar">
930
- <span class="preset-label">Presets</span>
931
- <button type="button" class="btn btn-outline btn-sm" onclick="applyTemplate('9router')">9Router</button>
932
- <button type="button" class="btn btn-outline btn-sm" onclick="applyTemplate('openrouter')">OpenRouter</button>
933
- <button type="button" class="btn btn-outline btn-sm" onclick="applyTemplate('codex-vtx')">Codex Vertex</button>
934
- <button type="button" class="btn btn-outline btn-sm" onclick="applyTemplate('momo')">Selfhost Hybrid</button>
935
- <button type="button" class="btn btn-outline btn-sm" onclick="applyTemplate('custom')">Local OpenAI</button>
936
- </div>
874
+ <!-- VIEW 3: Request Inspector -->
875
+ <div id="view-logs" class="view-panel" style="display:none;">
876
+ <div class="sec">
877
+ <div class="card pad">
878
+ <div class="sech">
879
+ <div>
880
+ <h2 style="font-size:1.05rem; font-weight:600;">Live Request Inspector</h2>
881
+ <p class="hint" style="margin-top:0.2rem;">In-memory ring buffer capturing the most recent traffic through the edge gateway.</p>
882
+ </div>
883
+ <div style="display:flex; align-items:center; gap:0.6rem;">
884
+ <label class="hint" style="display:flex; align-items:center; gap:0.4rem; cursor:pointer;">
885
+ <input type="checkbox" id="chk-auto-refresh-logs" onchange="toggleLogAutoRefresh()" style="width:auto; min-height:0;" checked> Auto-refresh (3s)
886
+ </label>
887
+ <button type="button" class="btn btn-outline btn-sm" onclick="clearInspectorLogs()">Clear Logs</button>
888
+ </div>
889
+ </div>
890
+ </div>
937
891
 
938
- <!-- Tabs Bar -->
939
- <div class="tab-bar" role="tablist" aria-label="Provider settings">
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
- <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
- <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>Blindfold</button>
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>
892
+ <div id="inspector-logs-container"></div>
893
+ </div>
945
894
  </div>
946
895
 
947
- <form id="profile-form" onsubmit="saveProfile(event)">
948
- <!-- Tab 1: General & Endpoint -->
949
- <div id="tab-general" class="tab-pane active" role="tabpanel" aria-labelledby="tab-button-general">
950
- <div class="form-grid">
951
- <div class="form-group">
952
- <label for="p-key">Provider key</label>
953
- <input type="text" id="p-key" required pattern="[A-Za-z0-9][A-Za-z0-9._\-]{0,63}" title="Letters, digits, '.', '_' or '-' (max 64 chars)" placeholder="e.g. 9router, openrouter, my-llm">
896
+ <!-- VIEW 4: System Doctor -->
897
+ <div id="view-doctor" class="view-panel" style="display:none;">
898
+ <div class="sec">
899
+ <div class="stats">
900
+ <div class="stat">
901
+ <div class="k">Edge Gateway Port</div>
902
+ <div class="v gateway-port" style="color:var(--accent);">3456</div>
903
+ <div class="status on" style="margin-top:0.3rem;">Bound to 127.0.0.1</div>
954
904
  </div>
955
- <div class="form-group">
956
- <label for="p-name">Display name</label>
957
- <input type="text" id="p-name" required placeholder="e.g. 9Router Cloud, OpenRouter Pro">
958
- </div>
959
- <div class="form-group full">
960
- <label for="p-url">Base URL</label>
961
- <input type="url" id="p-url" required placeholder="https://api.your-provider.com/v1">
905
+
906
+ <div class="stat">
907
+ <div class="k">Blindfold Interceptor</div>
908
+ <div class="v" id="blindfold-port-display" style="color:var(--mint); font-size:1.15rem;">Active</div>
909
+ <div class="status on" style="margin-top:0.3rem;">Zero tool mutation</div>
962
910
  </div>
963
- <div class="form-group full">
964
- <label for="p-key-val">API key or bearer token</label>
965
- <input type="password" id="p-key-val" autocomplete="off" placeholder="sk-...">
911
+
912
+ <div class="stat">
913
+ <div class="k">Security Invariant</div>
914
+ <div class="v" style="color:var(--ok);">Clean (100%)</div>
915
+ <div class="hint" style="margin-top:0.3rem;">No settings.json edits</div>
966
916
  </div>
967
917
  </div>
968
- </div>
969
918
 
970
- <!-- Tab 2: Protocols & Routing -->
971
- <div id="tab-routing" class="tab-pane" role="tabpanel" aria-labelledby="tab-button-routing" hidden>
972
- <div class="form-grid">
973
- <div class="form-group">
974
- <label for="p-informat">Client protocol</label>
975
- <select id="p-informat" onchange="onInFormatChange(this.value)">
976
- <option value="auto">AUTO (Accepts Claude / OpenAI / Codex / Vertex)</option>
977
- <option value="anthropic">Anthropic Messages (/v1/messages)</option>
978
- <option value="openai-chat">OpenAI Chat (/v1/chat/completions)</option>
979
- <option value="responses">OpenAI Responses — Codex (/v1/responses)</option>
980
- <option value="vertex">Vertex generateContent (/v1beta/models/*)</option>
981
- </select>
982
- </div>
983
- <div class="form-group">
984
- <label for="p-outformat">Upstream protocol</label>
985
- <select id="p-outformat">
986
- <option value="">AUTO (Determined by Routing Mode)</option>
987
- <option value="openai-chat">OpenAI Chat Completions</option>
988
- <option value="anthropic">Anthropic Messages</option>
989
- <option value="vertex">Vertex generateContent</option>
990
- </select>
991
- </div>
992
- <div class="form-group full">
993
- <label for="p-thinking-mode">Reasoning mode for Chat upstreams</label>
994
- <select id="p-thinking-mode">
995
- <option value="auto">AUTO — gateway (9Router): restore thinking, inject &lt;think&gt; guide, send thinking + reasoning_effort</option>
996
- <option value="native">NATIVE — strict OpenAI API: only reasoning_effort when client asks, no prompt changes</option>
997
- <option value="off">OFF — never send reasoning parameters</option>
998
- </select>
999
- </div>
1000
- <div class="form-group full">
1001
- <label for="p-mode">Routing mode</label>
1002
- <select id="p-mode">
1003
- <option value="hybrid">HYBRID (Claude models -> Direct Native | Other models -> Convert)</option>
1004
- <option value="convert">CONVERT (Always convert protocols via gateway)</option>
1005
- <option value="direct">DIRECT (Always forward directly via native Anthropic API)</option>
1006
- </select>
1007
- </div>
1008
- <div class="form-group full" style="border-top: 1px solid var(--border); padding-top: 12px; margin-top: 4px;">
1009
- <div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 4px;">
1010
- <span class="field-heading">Token Optimizers</span>
1011
- <span style="font-size: 11px; color: var(--accent); font-family: var(--font-mono);">Headroom • Ponytail • RTK</span>
919
+ <div class="card pad">
920
+ <h3 style="font-size:0.95rem; font-weight:600; margin-bottom:0.75rem;">Environment Isolation & Host Routing</h3>
921
+ <div class="sec">
922
+ <div class="copyline">
923
+ <code>NO_PROXY=127.0.0.1,localhost (Safe from RTK/Headroom/VPN conflicts)</code>
924
+ </div>
925
+ <div class="copyline">
926
+ <code>Blindfold Hosts: api.anthropic.com, api.openai.com, chatgpt.com</code>
927
+ </div>
928
+ <div class="copyline">
929
+ <code>Admin Token Protection: mode 0600 (admin.token) required for state mutation</code>
1012
930
  </div>
1013
- <span style="font-size: 11px; color: var(--text-dim); margin-top: 2px;">
1014
- <strong>Optimizer chain:</strong> Point the CLI at the optimizer. Point the optimizer upstream to <code>http://127.0.0.1:<span class="gateway-port">3456</span></code>.
1015
- </span>
1016
931
  </div>
1017
932
  </div>
1018
933
  </div>
934
+ </div>
935
+ </div>
936
+ </div>
1019
937
 
1020
- <!-- Tab 3: Models & 1M Context -->
1021
- <div id="tab-models" class="tab-pane" role="tabpanel" aria-labelledby="tab-button-models" hidden>
1022
- <div class="warning-callout" id="warn-1m-callout">
1023
- <span class="badge badge-convert" id="warn-1m-icon">NOTE</span>
1024
- <div id="warn-1m-text">
1025
- <strong>1M Context Warning:</strong> Enabling <code>[1M Context]</code> for a tier sets <code>ANTHROPIC_DEFAULT_&lt;TIER&gt;_MODEL=&lt;tier&gt;[1m]</code> for that tier. If the upstream model lacks native 1M token support, long conversations will fail with <code>HTTP 400 (Context Exceeded)</code>.
1026
- </div>
1027
- </div>
1028
-
1029
- <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; flex-wrap: wrap; gap: 8px;">
1030
- <span id="model-slot-heading" style="font-size: 12px; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.04em;">Tier Mapping (Claude Code / Switcher)</span>
1031
- <button type="button" id="btn-fetch-models" class="btn btn-outline btn-sm" onclick="fetchModelList()">
1032
- Fetch models
1033
- </button>
1034
- </div>
938
+ <!-- Profile Modal Editor -->
939
+ <div id="profile-modal-overlay" class="overlay" style="display:none;" onclick="if(event.target===this) closeProfileModal()">
940
+ <div class="modal" role="dialog" aria-modal="true" aria-labelledby="form-title">
941
+ <div class="mh">
942
+ <div>
943
+ <strong id="form-title">Edit Provider Profile</strong>
944
+ <span id="active-tag" style="font-size:0.75rem; color:var(--ok); margin-left:0.5rem; font-weight:600;"></span>
945
+ </div>
946
+ <button class="icon" type="button" onclick="closeProfileModal()" aria-label="Close dialog">✕</button>
947
+ </div>
1035
948
 
1036
- <div class="form-grid" id="slot-fields">
1037
- <!-- Dynamically populated via renderModelSlots -->
949
+ <form id="profile-form" onsubmit="saveProfile(event)" style="display:flex; flex-direction:column; overflow:hidden;">
950
+ <!-- Modal Inset Tabs -->
951
+ <div style="padding:0.85rem 1.25rem 0.5rem; background:var(--card2);">
952
+ <div class="tabs">
953
+ <button type="button" id="tab-button-general" class="tab-btn active" onclick="switchTab('tab-general')">General</button>
954
+ <button type="button" id="tab-button-routing" class="tab-btn" onclick="switchTab('tab-routing')">Routing & Protocol</button>
955
+ <button type="button" id="tab-button-models" class="tab-btn" onclick="switchTab('tab-models')">Model Slots</button>
1038
956
  </div>
957
+ </div>
1039
958
 
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">
1044
- <span class="badge badge-convert">NOTE</span>
1045
- <div>
1046
- <strong>Codex sees only these names.</strong> The slot aliases <code>main</code>, <code>review</code> and
1047
- <code>subagent</code> stay inside the gateway. Leave a field empty to let Codex keep its own default for that role.
959
+ <div class="mb">
960
+ <!-- TAB 1: General -->
961
+ <div id="tab-general" class="tab-content active" style="display:grid; gap:0.9rem;">
962
+ <div style="background:var(--card2); padding:0.75rem 1rem; border-radius:8px; border:1px solid var(--line);">
963
+ <span class="hint" style="font-weight:600; display:block; margin-bottom:0.4rem;">Template Quick-Fill:</span>
964
+ <div style="display:flex; gap:0.4rem; flex-wrap:wrap;">
965
+ <button type="button" class="btn btn-outline btn-sm" onclick="applyTemplate('intact')">intact</button>
966
+ <button type="button" class="btn btn-outline btn-sm" onclick="applyTemplate('9router')">9Router</button>
967
+ <button type="button" class="btn btn-outline btn-sm" onclick="applyTemplate('openrouter')">OpenRouter</button>
968
+ <button type="button" class="btn btn-outline btn-sm" onclick="applyTemplate('vertex')">Vertex AI</button>
969
+ <button type="button" class="btn btn-outline btn-sm" onclick="applyTemplate('local')">Local Server</button>
970
+ </div>
1048
971
  </div>
1049
- </div>
1050
972
 
1051
- <div class="form-grid" style="margin-top: 12px;">
1052
- <div class="form-group full">
1053
- <label for="p-public-main">Session model name</label>
1054
- <input type="text" id="p-public-main" list="model-options" placeholder="e.g. gpt-5.6-sol">
1055
- <span style="font-size: 11px; color: var(--text-dim); margin-top: 2px;">Shown in the CLI <code>/model</code> picker and sent with every request.</span>
1056
- </div>
1057
- <div class="form-group">
1058
- <label for="p-public-review">Review model name</label>
1059
- <input type="text" id="p-public-review" list="model-options" placeholder="e.g. gpt-5.6-terra">
1060
- </div>
1061
- <div class="form-group">
1062
- <label for="p-public-subagent">Subagent model name</label>
1063
- <input type="text" id="p-public-subagent" list="model-options" placeholder="e.g. gpt-5.6-luna">
973
+ <div style="display:grid; grid-template-columns:1fr 1fr; gap:0.9rem;">
974
+ <label class="f">Profile Key (ID)
975
+ <input type="text" id="p-key" required pattern="[A-Za-z0-9][A-Za-z0-9._\-]{0,63}" placeholder="e.g. intact-claude">
976
+ </label>
977
+ <label class="f">Display Name
978
+ <input type="text" id="p-name" required placeholder="e.g. Intact Claude Gateway">
979
+ </label>
1064
980
  </div>
1065
- </div>
981
+
982
+ <label class="f">Base URL
983
+ <input type="url" id="p-url" required placeholder="https://api.example.com/v1">
984
+ </label>
985
+
986
+ <label class="f">API Key
987
+ <input type="password" id="p-key-val" placeholder="sk-...">
988
+ </label>
1066
989
  </div>
1067
990
 
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>
991
+ <!-- TAB 2: Routing -->
992
+ <div id="tab-routing" class="tab-content" style="display:none; gap:0.9rem; flex-direction:column;">
993
+ <div style="display:grid; grid-template-columns:1fr 1fr; gap:0.9rem;">
994
+ <label class="f">Tool Target
995
+ <select id="p-tool" onchange="onToolChange()">
996
+ <option value="auto">All compatible tools (auto)</option>
997
+ <option value="claude">Claude Code only (claude)</option>
998
+ <option value="codex">Codex only (codex)</option>
999
+ </select>
1000
+ </label>
1001
+ <label class="f">Outbound Format
1002
+ <select id="p-outformat">
1003
+ <option value="openai-chat">OpenAI Chat (/v1/chat/completions)</option>
1004
+ <option value="anthropic">Anthropic Messages (/v1/messages)</option>
1005
+ <option value="vertex">Google Vertex AI (/models/...)</option>
1006
+ </select>
1007
+ </label>
1077
1008
  </div>
1078
- </div>
1079
- </div>
1080
1009
 
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>
1084
- <div class="form-grid" style="border-top: 1px solid var(--border); padding-top: 12px; margin-top: 12px;">
1085
- <div class="form-group full">
1086
- <label style="display: flex; align-items: center; gap: 6px; cursor: pointer; text-transform: none;">
1087
- <input type="checkbox" id="p-blindfold" style="width: 13px; height: 13px;" onchange="onBlindfoldToggle(this.checked)">
1088
- Blindfold mode — hide the gateway address from the CLI
1010
+ <div style="display:grid; grid-template-columns:1fr 1fr; gap:0.9rem;">
1011
+ <label class="f">Thinking Mode
1012
+ <select id="p-thinking-mode">
1013
+ <option value="auto">AUTO (restores reasoning, strips tags safely)</option>
1014
+ <option value="native">NATIVE (strict reasoning_effort)</option>
1015
+ <option value="off">OFF (never send thinking)</option>
1016
+ </select>
1017
+ </label>
1018
+ <label class="f">Routing Mode
1019
+ <select id="p-mode">
1020
+ <option value="convert">CONVERT (Bi-directional translation)</option>
1021
+ <option value="direct">DIRECT (Passthrough)</option>
1022
+ <option value="hybrid">HYBRID (Automatic)</option>
1023
+ </select>
1089
1024
  </label>
1090
- <span style="font-size: 11px; color: var(--text-dim); margin-top: 2px;">
1091
- Off: the CLI is given a base URL override, and it prints a warning on its own <code>/model</code> screen.
1092
- On: the CLI keeps its official endpoint and reaches the gateway through a local interceptor.
1093
- Build the certificates first with <code>bash blindfold/make-certs.sh &lt;host&gt;</code>.
1094
- </span>
1095
1025
  </div>
1096
1026
  </div>
1097
1027
 
1098
- <div class="form-grid" id="blindfold-fields" hidden>
1099
- <div class="form-group">
1100
- <label for="p-blindfold-host">Intercepted host</label>
1101
- <input type="text" id="p-blindfold-host" placeholder="chatgpt.com">
1102
- <span style="font-size: 11px; color: var(--text-dim); margin-top: 2px;">ChatGPT sign-in: <code>chatgpt.com</code>. API key: <code>api.openai.com</code>.</span>
1103
- </div>
1104
- <div class="form-group">
1105
- <label for="p-blindfold-port">Interceptor port</label>
1106
- <input type="number" id="p-blindfold-port" min="1" max="65535" placeholder="3457">
1028
+ <!-- TAB 3: Models & 1M -->
1029
+ <div id="tab-models" class="tab-content" style="display:none; gap:0.9rem; flex-direction:column;">
1030
+ <div class="badge warn" id="warn-1m-callout" style="display:none; padding:0.5rem 0.75rem; border-radius:8px;">
1031
+ <span id="warn-1m-icon">⚠️</span> <span id="warn-1m-text">1M context flag is enabled</span>
1107
1032
  </div>
1108
- <div class="form-group full">
1109
- <label for="p-blindfold-prefix">API path prefix</label>
1110
- <input type="text" id="p-blindfold-prefix" placeholder="/backend-api/codex">
1111
- <span style="font-size: 11px; color: var(--text-dim); margin-top: 2px;">Requests under this path reach the gateway. Every other path goes to the real host. API key accounts use <code>/v1</code>.</span>
1112
- </div>
1113
- </div>
1114
- </div>
1115
1033
 
1116
- <div id="tab-inspector" class="tab-pane" role="tabpanel" aria-labelledby="tab-button-inspector" hidden>
1117
- <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; flex-wrap: wrap; gap: 8px;">
1118
- <div>
1119
- <span style="font-size: 12px; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.04em;">Real-time Request Inspector</span>
1120
- <span style="font-size: 11px; color: var(--text-dim); margin-left: 8px;">Inspect prompt, tokens, latency & reasoning traces</span>
1034
+ <div style="display:flex; justify-content:space-between; align-items:center;">
1035
+ <h3 id="model-slot-heading" style="font-size:0.92rem; font-weight:600;">Model Slots Mapping</h3>
1036
+ <button type="button" id="btn-fetch-models" class="btn btn-outline btn-sm" onclick="fetchModelList()">Browse Models</button>
1121
1037
  </div>
1122
- <div style="display: flex; gap: 8px; align-items: center;">
1123
- <label style="font-size: 11px; color: var(--text-muted); display: flex; align-items: center; gap: 4px; cursor: pointer; text-transform: none;">
1124
- <input type="checkbox" id="chk-auto-refresh-logs" checked onchange="toggleLogAutoRefresh(this.checked)"> Auto-refresh
1125
- </label>
1126
- <button type="button" class="btn btn-outline btn-sm" onclick="loadInspectorLogs()">Refresh</button>
1127
- <button type="button" class="btn btn-danger btn-sm" onclick="clearInspectorLogs()">Clear</button>
1038
+
1039
+ <div id="slot-fields" style="display:grid; gap:0.75rem;"></div>
1040
+
1041
+ <div id="model-browser" style="display:none; background:var(--card2); padding:0.85rem; border-radius:8px; border:1px solid var(--line);">
1042
+ <div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:0.5rem;">
1043
+ <span id="model-count-label" class="hint">Loaded models</span>
1044
+ <input type="text" id="model-filter" placeholder="Search..." oninput="filterModels()" style="width:12rem; min-height:28px;">
1045
+ </div>
1046
+ <div id="model-items" style="max-height:8rem; overflow-y:auto; display:flex; flex-wrap:wrap; gap:0.35rem;"></div>
1128
1047
  </div>
1129
1048
  </div>
1130
1049
 
1131
- <div id="inspector-logs-container" style="display: flex; flex-direction: column; gap: 8px; max-height: 440px; overflow-y: auto;">
1132
- <div style="color: var(--text-dim); font-size: 12px; text-align: center; padding: 24px; background: var(--surface-well); border-radius: var(--radius-sm); border: 1px solid var(--border);">
1133
- No requests recorded yet. Make a request via Claude Code, Codex, or curl.
1050
+ <!-- Test Connection Box -->
1051
+ <div id="test-result" class="test-result-box">
1052
+ <div style="display:flex; justify-content:space-between; align-items:center;">
1053
+ <span id="test-status-badge" class="badge"></span>
1054
+ <span id="test-latency" class="hint mono"></span>
1134
1055
  </div>
1056
+ <div id="test-sample" class="mono" style="margin-top:0.4rem; font-size:0.75rem; max-height:4rem; overflow:auto;"></div>
1135
1057
  </div>
1136
1058
  </div>
1137
1059
 
1138
- <datalist id="model-options"></datalist>
1139
-
1140
- <!-- Test Connection Result Box -->
1141
- <div id="test-result" class="test-result-box">
1142
- <div class="test-meta">
1143
- <span id="test-status-badge"></span>
1144
- <span id="test-latency"></span>
1060
+ <div class="mf">
1061
+ <div>
1062
+ <button type="button" id="btn-delete" class="btn btn-danger" onclick="deleteProfile()" style="display:none;">Delete Profile</button>
1145
1063
  </div>
1146
- <div id="test-sample" class="test-sample"></div>
1147
- </div>
1148
-
1149
- <div class="form-actions">
1150
- <button type="button" id="btn-test-conn" class="btn btn-outline" onclick="testConnection()">Test connection</button>
1151
- <div style="display: flex; gap: 8px;">
1152
- <button type="button" id="btn-delete" class="btn btn-danger" onclick="deleteProfile()" style="display: none;">Delete</button>
1153
- <button type="submit" class="btn btn-primary">Save Provider</button>
1064
+ <div style="display:flex; gap:0.6rem;">
1065
+ <button type="button" id="btn-test-conn" class="btn btn-outline" onclick="testConnection()">Test Connection</button>
1066
+ <button type="button" class="btn btn-outline" onclick="closeProfileModal()">Cancel</button>
1067
+ <button type="submit" class="btn btn-primary">Save Profile</button>
1154
1068
  </div>
1155
1069
  </div>
1156
1070
  </form>
1157
1071
  </div>
1158
- </main>
1072
+ </div>
1159
1073
 
1160
- <div id="toast" class="toast" role="status" aria-live="polite" aria-atomic="true"></div>
1074
+ <div id="toast" class="toast"></div>
1075
+ <datalist id="model-options"></datalist>
1161
1076
 
1162
1077
  <script>
1163
- // /api/* needs the admin token. The gateway puts it in this page; an older `switch ui` link still
1164
- // brings it in the URL fragment, which is cleared from the address bar.
1165
- const ADMIN_TOKEN_KEY = 'llmSwitcherAdminToken';
1166
- (() => {
1167
- localStorage.removeItem(ADMIN_TOKEN_KEY);
1168
- const served = document.querySelector('meta[name="llm-switcher-token"]')?.content;
1169
- const m = location.hash.match(/(?:^#|&)token=([0-9a-f]+)/);
1170
- if (served || m) sessionStorage.setItem(ADMIN_TOKEN_KEY, served || m[1]);
1171
- if (m) history.replaceState(null, '', location.pathname + location.search);
1172
- })();
1173
-
1174
- // A config change carries the revision this page loaded. The gateway answers 409 when another tab
1175
- // or the CLI saved in the meantime; the page then reloads instead of overwriting that change.
1176
- const CHANGE_PATHS = new Set(['/api/switch', '/api/toggle', '/api/save-profile', '/api/delete-profile']);
1177
-
1178
- async function api(path, opts = {}) {
1179
- const headers = { ...(opts.headers || {}), 'x-llm-switcher-token': sessionStorage.getItem(ADMIN_TOKEN_KEY) || '' };
1180
- let body = opts.body;
1181
- if (CHANGE_PATHS.has(path) && typeof body === 'string' && state.revision) {
1182
- body = JSON.stringify({ ...JSON.parse(body), revision: state.revision });
1183
- }
1184
- const res = await fetch(path, { ...opts, headers, body });
1185
- if (res.status === 401) showToast('Not authorized. Open the dashboard with `switch ui`.', true);
1186
- if (res.status === 409) setTimeout(loadStatus, 0);
1187
- return res;
1078
+ // State & Globals
1079
+ const ADMIN_TOKEN_KEY = 'llm-switcher-admin-token';
1080
+ const servedToken = document.querySelector('meta[name="llm-switcher-token"]')?.content;
1081
+ const hashTokenMatch = location.hash.match(/(?:^#|&)token=([0-9a-f]+)/);
1082
+ if (servedToken || hashTokenMatch) {
1083
+ sessionStorage.setItem(ADMIN_TOKEN_KEY, servedToken || hashTokenMatch[1]);
1084
+ }
1085
+ if (hashTokenMatch) {
1086
+ history.replaceState(null, '', location.pathname + location.search + '#/routes');
1188
1087
  }
1189
1088
 
1190
1089
  let state = {
1090
+ port: 3456,
1191
1091
  config: null,
1192
1092
  activeProfile: '',
1193
1093
  activeProfiles: {},
@@ -1200,63 +1100,26 @@
1200
1100
  let loadedModels = [];
1201
1101
  let currentProfileBuffer = null;
1202
1102
  let renderedSlotFormat = 'auto';
1103
+ let logRefreshTimer = null;
1104
+ let cachedCatalog = null;
1105
+ let lastLogsCount = 0;
1203
1106
  const MASKED_KEY = '__LLM_SWITCHER_KEEP_KEY__';
1204
- // Same list as TARGETS in state.mjs. The page is served as a static file and cannot import it.
1205
- const TARGETS = ['anthropic', 'responses', 'openai-chat', 'vertex'];
1206
- // Order matters: publicModels is read by position when codexRoles has no entry.
1207
- const CODEX_ROLE_SLOTS = ['main', 'review', 'subagent'];
1208
-
1209
- const LEGACY_SLOT_KEYS = {
1210
- main: ['opus'],
1211
- review: ['sonnet'],
1212
- subagent: ['fast', 'fallback', 'haiku', 'fable'],
1213
- default: ['sonnet', 'opus', 'haiku', 'fable']
1214
- };
1215
1107
 
1216
- const SLOT_CONFIG = {
1217
- anthropic: {
1218
- heading: 'Claude Code tier mapping',
1219
- slots: [
1220
- { id: 'opus', label: 'Opus Model', placeholder: 'e.g. ag/claude-opus-4-6-thinking', allow1M: true },
1221
- { id: 'sonnet', label: 'Sonnet Model', placeholder: 'e.g. ag/claude-sonnet-4-6', allow1M: true },
1222
- { id: 'haiku', label: 'Haiku Model', placeholder: 'e.g. ag/gemini-3.7-flash-high', allow1M: true },
1223
- { id: 'fable', label: 'Fable Model', placeholder: 'e.g. ag/gemini-3.8-flash-high', allow1M: true }
1224
- ],
1225
- warning: '<strong>1M Context Warning:</strong> Enabling <code>[1M Context]</code> for a tier sets <code>ANTHROPIC_DEFAULT_&lt;TIER&gt;_MODEL=&lt;tier&gt;[1m]</code> for that tier. If the upstream model lacks native 1M token support, long conversations will fail with <code>HTTP 400 (Context Exceeded)</code>.',
1226
- icon: 'NOTE'
1227
- },
1228
- responses: {
1229
- heading: 'Codex model roles',
1230
- slots: [
1231
- { id: 'main', label: 'Main model', placeholder: 'e.g. ag/gemini-3.8-flash or gpt-4o', allow1M: true },
1232
- { id: 'review', label: 'Review model', placeholder: 'e.g. ag/gemini-3.7-flash-medium', allow1M: false },
1233
- { id: 'subagent', label: 'Subagent model', placeholder: 'e.g. ag/gemini-3.6-flash-low', allow1M: false }
1234
- ],
1235
- warning: '<strong>Context notice:</strong> The launcher applies <code>model</code>, <code>review_model</code>, and <code>agents.default_subagent_model</code> through Codex config overrides. The 1M option also sets <code>model_context_window</code> and the auto-compact limit.',
1236
- icon: 'NOTE'
1237
- },
1238
- 'openai-chat': {
1239
- heading: 'Default Model (OpenAI Chat)',
1240
- slots: [
1241
- { id: 'default', label: 'Default Chat Model', placeholder: 'e.g. gpt-4o or ag/gemini-3.8-flash', allow1M: true }
1242
- ],
1243
- warning: '<strong>1M Context Notice:</strong> When enabled, sets <code>OPENAI_MAX_CONTEXT_TOKENS=1000000</code>. Verify that upstream model supports 1M context before enabling.',
1244
- icon: 'NOTE'
1245
- },
1246
- vertex: {
1247
- heading: 'Default Model (Vertex / Gemini)',
1248
- slots: [
1249
- { id: 'default', label: 'Default Model (Vertex/Gemini)', placeholder: 'e.g. gemini-2.5-pro or gemini-2.5-flash', allow1M: false }
1250
- ],
1251
- warning: '<strong>Context Window Notice:</strong> Vertex/Gemini SDK context window is managed directly by upstream Google models. No client-side 1M flag is needed.',
1252
- icon: 'INFO'
1253
- }
1254
- };
1255
- SLOT_CONFIG.auto = SLOT_CONFIG.anthropic;
1108
+ // API Helper
1109
+ async function api(path, opts = {}) {
1110
+ const token = sessionStorage.getItem(ADMIN_TOKEN_KEY) || document.querySelector('meta[name="llm-switcher-token"]')?.content || '';
1111
+ const headers = {
1112
+ ...opts.headers,
1113
+ 'x-llm-switcher-token': token
1114
+ };
1115
+ const res = await fetch(path, { ...opts, headers });
1116
+ if (res.status === 401) showToast('Unauthorized: check admin.token in server directory', true);
1117
+ if (res.status === 409) setTimeout(loadStatus, 0);
1118
+ return res;
1119
+ }
1256
1120
 
1257
- // Helper: Anti-XSS Sanitizer
1258
1121
  function escapeHtml(str) {
1259
- if (str === null || str === undefined) return '';
1122
+ if (!str) return '';
1260
1123
  return String(str)
1261
1124
  .replace(/&/g, '&amp;')
1262
1125
  .replace(/</g, '&lt;')
@@ -1265,380 +1128,485 @@
1265
1128
  .replace(/'/g, '&#039;');
1266
1129
  }
1267
1130
 
1268
- function applyTheme(theme) {
1269
- document.documentElement.dataset.theme = theme;
1270
- const btn = document.getElementById('btn-theme');
1271
- if (btn) {
1272
- const nextTheme = theme === 'light' ? 'dark' : 'light';
1273
- btn.textContent = `${nextTheme[0].toUpperCase()}${nextTheme.slice(1)} theme`;
1274
- btn.setAttribute('aria-label', `Use ${nextTheme} theme`);
1131
+ // Theme Mode
1132
+ function applyThemeMode(mode) {
1133
+ document.documentElement.dataset.theme = mode;
1134
+ localStorage.setItem('intact-theme', mode);
1135
+ localStorage.setItem('lsw-theme', mode);
1136
+ document.getElementById('btn-theme-dark')?.setAttribute('aria-pressed', mode === 'dark');
1137
+ document.getElementById('btn-theme-light')?.setAttribute('aria-pressed', mode === 'light');
1138
+ }
1139
+
1140
+ // Toast
1141
+ let toastTimeout = null;
1142
+ function showToast(msg, isErr = false) {
1143
+ const toast = document.getElementById('toast');
1144
+ if (!toast) return;
1145
+ toast.textContent = msg;
1146
+ toast.className = 'toast' + (isErr ? ' err' : '');
1147
+ toast.style.display = 'block';
1148
+ clearTimeout(toastTimeout);
1149
+ toastTimeout = setTimeout(() => { toast.style.display = 'none'; }, 3200);
1150
+ }
1151
+
1152
+ // Modal Control
1153
+ function openProfileModal(isNew = false) {
1154
+ const modal = document.getElementById('profile-modal-overlay');
1155
+ if (modal) modal.style.display = 'grid';
1156
+ if (isNew) {
1157
+ initNewProfile();
1275
1158
  }
1276
- try { localStorage.setItem('lsw-theme', theme); } catch (e) {}
1277
1159
  }
1278
1160
 
1279
- function toggleTheme() {
1280
- const current = document.documentElement.dataset.theme || 'dark';
1281
- applyTheme(current === 'light' ? 'dark' : 'light');
1161
+ function closeProfileModal() {
1162
+ const modal = document.getElementById('profile-modal-overlay');
1163
+ if (modal) modal.style.display = 'none';
1164
+ }
1165
+
1166
+ // Close modal on Escape
1167
+ window.addEventListener('keydown', (e) => {
1168
+ if (e.key === 'Escape') {
1169
+ const modal = document.getElementById('profile-modal-overlay');
1170
+ if (modal && modal.style.display !== 'none') {
1171
+ closeProfileModal();
1172
+ }
1173
+ }
1174
+ });
1175
+
1176
+ // Hash Router
1177
+ function handleRoute() {
1178
+ const hash = window.location.hash || '#/routes';
1179
+ const route = hash.replace(/^#\/?/, '').split('/')[0] || 'routes';
1180
+ const validRoutes = ['routes', 'models', 'logs', 'doctor'];
1181
+ const activeRoute = validRoutes.includes(route) ? route : 'routes';
1182
+
1183
+ // Update active nav link
1184
+ document.querySelectorAll('#sideNav .nav-link').forEach(link => {
1185
+ if (link.getAttribute('data-nav') === activeRoute) {
1186
+ link.setAttribute('aria-current', 'page');
1187
+ } else {
1188
+ link.removeAttribute('aria-current');
1189
+ }
1190
+ });
1191
+
1192
+ // Show matching view panel
1193
+ document.querySelectorAll('.view-panel').forEach(panel => {
1194
+ panel.style.display = (panel.id === 'view-' + activeRoute) ? 'block' : 'none';
1195
+ });
1196
+
1197
+ // Update dynamic header
1198
+ updateHeader(activeRoute);
1199
+
1200
+ // Lazy load view content
1201
+ if (activeRoute === 'models') renderCatalogView();
1202
+ if (activeRoute === 'logs') loadInspectorLogs();
1282
1203
  }
1204
+ window.addEventListener('hashchange', handleRoute);
1205
+
1206
+ function updateHeader(route) {
1207
+ const titleEl = document.getElementById('page-title');
1208
+ const subEl = document.getElementById('status-sub');
1209
+ const actionsEl = document.getElementById('ph-actions');
1210
+ if (!titleEl || !subEl || !actionsEl) return;
1211
+
1212
+ const activeMap = state.activeProfiles || (state.config?.activeProfiles || {});
1213
+ const activeTargets = Object.entries(activeMap).filter(([_, v]) => Boolean(v));
1283
1214
 
1284
- function showToast(msg, isError = false) {
1285
- const t = document.getElementById('toast');
1286
- t.innerText = msg;
1287
- t.style.borderColor = isError ? 'rgba(239, 68, 68, 0.4)' : 'var(--border-strong)';
1288
- t.style.background = isError ? 'var(--danger-muted)' : 'var(--surface-elevated)';
1289
- t.style.color = isError ? 'var(--danger)' : 'var(--text)';
1290
- t.style.display = 'block';
1291
- setTimeout(() => t.style.display = 'none', 3500);
1215
+ if (route === 'routes') {
1216
+ titleEl.textContent = 'Routes & Profiles';
1217
+ if (activeTargets.length > 0) {
1218
+ const parts = [];
1219
+ if (activeMap.claude) parts.push('Claude: ' + activeMap.claude);
1220
+ if (activeMap.codex) parts.push('Codex: ' + activeMap.codex);
1221
+ subEl.textContent = 'Port ' + state.port + ' · Active for ' + parts.join(' · ');
1222
+ } else {
1223
+ subEl.textContent = 'Gateway is idle. Clients connect directly to official endpoints.';
1224
+ }
1225
+ actionsEl.innerHTML = `
1226
+ <button class="btn btn-primary" onclick="openProfileModal(true)">+ Add Profile</button>
1227
+ <button class="btn btn-outline" id="btn-toggle" onclick="toggleProxy()">${activeTargets.length > 0 ? 'Use official endpoints' : 'Activate compatible routes'}</button>
1228
+ <button class="btn btn-outline" onclick="loadStatus()">Refresh</button>
1229
+ `;
1230
+ } else if (route === 'models') {
1231
+ titleEl.textContent = 'Dynamic Model Catalog';
1232
+ subEl.textContent = 'Auto-polled models from upstream providers to guarantee zero 404/drift errors.';
1233
+ actionsEl.innerHTML = `
1234
+ <button class="btn btn-outline" id="btn-sync-catalog" onclick="refreshCatalogUpstream()">⚡ Sync Models with Upstream</button>
1235
+ <button class="btn btn-outline" onclick="renderCatalogView()">Refresh</button>
1236
+ `;
1237
+ } else if (route === 'logs') {
1238
+ titleEl.textContent = 'Request Inspector';
1239
+ subEl.textContent = 'Real-time HTTP traffic ring buffer passing through the local edge gateway.';
1240
+ actionsEl.innerHTML = `
1241
+ <button class="btn btn-outline btn-sm" onclick="clearInspectorLogs()">Clear</button>
1242
+ <button class="btn btn-outline btn-sm" onclick="loadInspectorLogs()">Refresh</button>
1243
+ `;
1244
+ } else if (route === 'doctor') {
1245
+ titleEl.textContent = 'System Health & Doctor';
1246
+ subEl.textContent = 'Verification of loopback bindings, interceptor certificates, and zero-mutation guarantees.';
1247
+ actionsEl.innerHTML = `
1248
+ <button class="btn btn-outline" onclick="loadStatus()">Re-check Status</button>
1249
+ `;
1250
+ }
1292
1251
  }
1293
1252
 
1253
+ // Modal Tabs
1294
1254
  function switchTab(tabId) {
1295
- document.querySelectorAll('.tab-btn').forEach(btn => {
1296
- const selected = btn.getAttribute('aria-controls') === tabId;
1297
- btn.classList.toggle('active', selected);
1298
- btn.setAttribute('aria-selected', String(selected));
1299
- btn.tabIndex = selected ? 0 : -1;
1300
- });
1301
- document.querySelectorAll('.tab-pane').forEach(pane => {
1302
- const selected = pane.id === tabId;
1303
- pane.classList.toggle('active', selected);
1304
- pane.hidden = !selected;
1255
+ document.querySelectorAll('#profile-form .tab-btn').forEach(btn => btn.classList.remove('active'));
1256
+ document.querySelectorAll('#profile-form .tab-content').forEach(c => {
1257
+ c.classList.remove('active');
1258
+ c.style.display = 'none';
1305
1259
  });
1260
+
1261
+ const btnId = tabId.replace('tab-', 'tab-button-');
1262
+ document.getElementById(btnId)?.classList.add('active');
1263
+ const target = document.getElementById(tabId);
1264
+ if (target) {
1265
+ target.classList.add('active');
1266
+ target.style.display = 'grid';
1267
+ }
1268
+ }
1269
+
1270
+ // Slot Configs
1271
+ function getToolAvatar(profileKey, profile) {
1272
+ const k = (profileKey || '').toLowerCase();
1273
+ const n = (profile?.name || '').toLowerCase();
1274
+ const t = (profile?.tool || '').toLowerCase();
1275
+ const u = (profile?.baseURL || '').toLowerCase();
1276
+
1277
+ let iconFile = 'openai.png';
1278
+ let brandName = 'AI Provider';
1279
+
1280
+ if (k.includes('claude') || n.includes('claude') || t === 'claude' || t === 'anthropic' || u.includes('anthropic')) {
1281
+ iconFile = 'claude.png';
1282
+ brandName = 'Anthropic Claude';
1283
+ } else if (k.includes('codex') || n.includes('codex') || t === 'codex' || t === 'responses') {
1284
+ iconFile = 'codex.png';
1285
+ brandName = 'OpenAI Codex';
1286
+ } else if (k.includes('openai') || n.includes('openai') || u.includes('openai')) {
1287
+ iconFile = 'openai.png';
1288
+ brandName = 'OpenAI';
1289
+ } else if (k.includes('intact') || n.includes('intact')) {
1290
+ iconFile = 'intact.svg';
1291
+ brandName = 'Intact Gateway';
1292
+ } else if (k.includes('gemini') || n.includes('gemini') || k.includes('vertex') || n.includes('vertex') || u.includes('googleapis')) {
1293
+ iconFile = 'gemini.png';
1294
+ brandName = 'Google Gemini';
1295
+ } else if (k.includes('openrouter') || n.includes('openrouter') || u.includes('openrouter')) {
1296
+ iconFile = 'openrouter.png';
1297
+ brandName = 'OpenRouter';
1298
+ } else if (k.includes('local') || k.includes('ollama') || n.includes('ollama') || u.includes('11434')) {
1299
+ iconFile = 'ollama.png';
1300
+ brandName = 'Ollama';
1301
+ } else if (k.includes('antigravity') || n.includes('antigravity')) {
1302
+ iconFile = 'antigravity.png';
1303
+ brandName = 'Antigravity';
1304
+ } else if (k.includes('deepseek') || n.includes('deepseek')) {
1305
+ iconFile = 'deepseek.png';
1306
+ brandName = 'DeepSeek';
1307
+ } else if (k.includes('groq') || n.includes('groq')) {
1308
+ iconFile = 'groq.png';
1309
+ brandName = 'Groq';
1310
+ } else if (k.includes('qwen') || n.includes('qwen')) {
1311
+ iconFile = 'qwen.png';
1312
+ brandName = 'Qwen';
1313
+ } else if (k.includes('github') || n.includes('github')) {
1314
+ iconFile = 'github.png';
1315
+ brandName = 'GitHub Copilot';
1316
+ }
1317
+
1318
+ return '<span class="av logo" title="' + escapeHtml(brandName) + '"><img src="/icons/' + iconFile + '" alt="" loading="lazy"></span>';
1306
1319
  }
1307
1320
 
1308
- function getActiveSlotConfig(format) {
1309
- return SLOT_CONFIG[format] || SLOT_CONFIG.auto;
1321
+ const SLOT_CONFIGS = {
1322
+ auto: {
1323
+ title: 'Universal Routing Slots',
1324
+ slots: [
1325
+ { id: 'sonnet', name: 'Claude Sonnet', placeholder: 'claude-3-7-sonnet or antigravity/gemini-3.8-flash' },
1326
+ { id: 'opus', name: 'Claude Opus', placeholder: 'claude-3-opus or antigravity/gemini-3.8-flash' },
1327
+ { id: 'haiku', name: 'Claude Haiku', placeholder: 'claude-3-5-haiku or antigravity/gemini-3.8-flash' },
1328
+ { id: 'fable', name: 'Claude Fable', placeholder: 'claude-fable-5-1 or antigravity/gemini-3.8-flash' },
1329
+ { id: 'main', name: 'Codex Main Session', placeholder: 'gpt-5.6-sol or gpt-4o' },
1330
+ { id: 'review', name: 'Codex /review Override', placeholder: 'gpt-5.6-terra or gpt-4o' },
1331
+ { id: 'subagent', name: 'Codex Subagent', placeholder: 'gpt-5.6-luna or o3-mini' },
1332
+ { id: 'default', name: 'Default Fallback Model', placeholder: 'gpt-4o' }
1333
+ ]
1334
+ },
1335
+ claude: {
1336
+ title: 'Claude Code Model Tiers (Anthropic)',
1337
+ slots: [
1338
+ { id: 'sonnet', name: 'Sonnet Slot', placeholder: 'claude-3-7-sonnet' },
1339
+ { id: 'opus', name: 'Opus Slot', placeholder: 'claude-3-opus' },
1340
+ { id: 'haiku', name: 'Haiku Slot', placeholder: 'claude-3-5-haiku' },
1341
+ { id: 'fable', name: 'Fable Slot', placeholder: 'claude-fable-5-1' }
1342
+ ]
1343
+ },
1344
+ codex: {
1345
+ title: 'OpenAI Codex Model Roles (Responses API)',
1346
+ slots: [
1347
+ { id: 'main', name: 'Main Session Model', placeholder: 'gpt-5.6-sol' },
1348
+ { id: 'review', name: 'Review Model', placeholder: 'gpt-5.6-terra' },
1349
+ { id: 'subagent', name: 'Subagent Model', placeholder: 'gpt-5.6-luna' }
1350
+ ]
1351
+ }
1352
+ };
1353
+
1354
+ function getActiveSlotConfig(fmt) {
1355
+ if (fmt === 'claude' || fmt === 'anthropic') return SLOT_CONFIGS.claude;
1356
+ if (fmt === 'codex' || fmt === 'responses') return SLOT_CONFIGS.codex;
1357
+ return SLOT_CONFIGS.auto;
1310
1358
  }
1311
1359
 
1312
- function valueForSlot(values, slot) {
1313
- const source = values || {};
1314
- if (Object.hasOwn(source, slot)) return source[slot] || '';
1315
- for (const legacyKey of LEGACY_SLOT_KEYS[slot] || []) {
1316
- if (source[legacyKey]) return source[legacyKey];
1317
- }
1318
- return '';
1360
+ function valueForSlot(defaultModels, slotId) {
1361
+ if (!defaultModels) return '';
1362
+ return defaultModels[slotId] || '';
1319
1363
  }
1320
1364
 
1321
- function flagForSlot(values, slot) {
1322
- const source = values || {};
1323
- if (Object.hasOwn(source, slot)) return Boolean(source[slot]);
1324
- for (const legacyKey of LEGACY_SLOT_KEYS[slot] || []) {
1325
- if (Object.hasOwn(source, legacyKey)) return Boolean(source[legacyKey]);
1326
- }
1327
- return false;
1365
+ function flagForSlot(model1M, slotId) {
1366
+ if (!model1M) return false;
1367
+ return Boolean(model1M[slotId]);
1328
1368
  }
1329
1369
 
1330
- function renderModelSlots(format) {
1331
- const cfg = getActiveSlotConfig(format);
1332
- const heading = document.getElementById('model-slot-heading');
1333
- const warnText = document.getElementById('warn-1m-text');
1334
- const warnIcon = document.getElementById('warn-1m-icon');
1370
+ function renderModelSlots(slotFmt, p) {
1335
1371
  const container = document.getElementById('slot-fields');
1336
-
1337
- if (heading) heading.textContent = cfg.heading;
1338
- if (warnText) warnText.innerHTML = cfg.warning;
1339
- if (warnIcon) warnIcon.textContent = cfg.icon;
1372
+ const heading = document.getElementById('model-slot-heading');
1340
1373
  if (!container) return;
1341
1374
 
1342
- const p = currentProfileBuffer || {};
1343
- const defs = p.defaultModels || {};
1344
- const m1m = p.model1M || {};
1345
-
1346
- container.innerHTML = cfg.slots.map(s => {
1347
- const val = valueForSlot(defs, s.id);
1348
- const checked = flagForSlot(m1m, s.id);
1349
-
1350
- return `
1351
- <div class="form-group ${cfg.slots.length === 1 ? 'full' : ''}">
1352
- <div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 4px;">
1353
- <label for="p-${s.id}">${escapeHtml(s.label)}</label>
1354
- ${s.allow1M ? `
1355
- <label style="display: flex; align-items: center; gap: 4px; font-size: 11px; cursor: pointer; color: var(--accent); text-transform: none;">
1356
- <input type="checkbox" id="p-${s.id}-1m" style="width: 13px; height: 13px;" onchange="update1mWarnings()" ${checked ? 'checked' : ''}> 1M context
1357
- <span id="warn-${s.id}-1m" class="badge-1m-warn" style="display: ${checked ? 'inline-block' : 'none'};" title="Risk of HTTP 400 if model does not support 1M context">Check support</span>
1358
- </label>
1359
- ` : ''}
1360
- </div>
1361
- <input type="text" id="p-${s.id}" list="model-options" placeholder="${escapeHtml(s.placeholder)}" value="${escapeHtml(val)}">
1375
+ const cfg = getActiveSlotConfig(slotFmt);
1376
+ renderedSlotFormat = slotFmt;
1377
+ if (heading) heading.textContent = cfg.title;
1378
+ container.innerHTML = '';
1379
+
1380
+ for (const s of cfg.slots) {
1381
+ const val = currentProfileBuffer ? valueForSlot(currentProfileBuffer.defaultModels, s.id) : valueForSlot(p?.defaultModels, s.id);
1382
+ const checked = currentProfileBuffer ? flagForSlot(currentProfileBuffer.model1M, s.id) : flagForSlot(p?.model1M, s.id);
1383
+
1384
+ const slotRow = document.createElement('div');
1385
+ slotRow.style.cssText = 'display:flex; flex-direction:column; gap:0.25rem;';
1386
+ slotRow.innerHTML = `
1387
+ <div style="display:flex; justify-content:space-between; align-items:center;">
1388
+ <label for="p-${s.id}" style="font-size:0.82rem; font-weight:500;">${escapeHtml(s.name)}</label>
1389
+ <label style="display:flex; align-items:center; gap:0.35rem; font-size:0.75rem; color:var(--accent); cursor:pointer;">
1390
+ <input type="checkbox" id="p-${s.id}-1m" style="width:13px; height:13px; min-height:0;" onchange="update1mWarnings()" ${checked ? 'checked' : ''}>
1391
+ 1M context
1392
+ </label>
1362
1393
  </div>
1394
+ <input type="text" id="p-${s.id}" list="model-options" placeholder="${escapeHtml(s.placeholder)}" value="${escapeHtml(val)}">
1363
1395
  `;
1364
- }).join('');
1365
-
1366
- renderedSlotFormat = format;
1396
+ container.appendChild(slotRow);
1397
+ }
1367
1398
  update1mWarnings();
1368
1399
  }
1369
1400
 
1370
- function onInFormatChange(newFmt) {
1371
- collectCurrentSlotValues(renderedSlotFormat);
1372
- renderModelSlots(newFmt);
1373
- renderCodexTab(newFmt);
1374
- if (loadedModels.length > 0) renderModelBrowser(loadedModels);
1375
- formDirty = true;
1401
+ function onToolChange() {
1402
+ const toolVal = document.getElementById('p-tool').value;
1403
+ renderModelSlots(toolVal, currentProfileBuffer);
1376
1404
  }
1377
1405
 
1378
- // The Codex tab applies to every profile that can serve the Responses protocol: `responses` and
1379
- // `auto`. The gateway applies these settings to both, so hiding them would hide live settings.
1380
- const servesCodex = (format) => format === 'responses' || !format || format === 'auto';
1381
-
1382
- function renderCodexTab(format) {
1383
- const applies = servesCodex(format);
1384
- const btn = document.getElementById('tab-button-codex');
1385
- if (btn) btn.hidden = !applies;
1386
- const names = document.getElementById('codex-names');
1387
- if (names) names.hidden = !applies;
1388
- if (!applies && document.getElementById('tab-codex')?.classList.contains('active')) {
1389
- switchTab('tab-general');
1406
+ function collectCurrentSlotValues() {
1407
+ const cfg = getActiveSlotConfig(renderedSlotFormat);
1408
+ if (!currentProfileBuffer) currentProfileBuffer = {};
1409
+ const defaultModels = { ...(currentProfileBuffer.defaultModels || {}) };
1410
+ const model1M = { ...(currentProfileBuffer.model1M || {}) };
1411
+ for (const s of cfg.slots) {
1412
+ const inp = document.getElementById('p-' + s.id);
1413
+ const chk = document.getElementById('p-' + s.id + '-1m');
1414
+ if (inp) {
1415
+ const val = inp.value.trim();
1416
+ if (val) defaultModels[s.id] = val;
1417
+ else delete defaultModels[s.id];
1418
+ }
1419
+ if (chk) {
1420
+ if (chk.checked) model1M[s.id] = true;
1421
+ else delete model1M[s.id];
1422
+ }
1390
1423
  }
1391
-
1392
- const p = currentProfileBuffer || {};
1393
- const roles = p.codexRoles || {};
1394
- const published = Array.isArray(p.publicModels) ? p.publicModels : [];
1395
- CODEX_ROLE_SLOTS.forEach((slot, i) => {
1396
- const input = document.getElementById(`p-public-${slot}`);
1397
- // A saved role key wins even when blank; the position fallback is for profiles without codexRoles.
1398
- if (input) input.value = Object.hasOwn(roles, slot) ? (roles[slot] || '') : (published[i] || '');
1399
- });
1400
-
1401
- const chk = document.getElementById('p-blindfold');
1402
- if (chk) chk.checked = Boolean(p.blindfold);
1403
- const host = document.getElementById('p-blindfold-host');
1404
- if (host) host.value = p.blindfoldHost || '';
1405
- const port = document.getElementById('p-blindfold-port');
1406
- if (port) port.value = p.blindfoldPort || '';
1407
- const prefix = document.getElementById('p-blindfold-prefix');
1408
- if (prefix) prefix.value = p.blindfoldPrefix || '';
1409
- onBlindfoldToggle(Boolean(p.blindfold));
1410
- }
1411
-
1412
- // The form already marks itself dirty through one input/change listener, so this
1413
- // only reveals the fields that the toggle governs.
1414
- function onBlindfoldToggle(enabled) {
1415
- const fields = document.getElementById('blindfold-fields');
1416
- if (fields) fields.hidden = !enabled;
1417
- }
1418
-
1419
- // One set of inputs feeds both keys: codexRoles is the role mapping the gateway
1420
- // resolves, publicModels is the flat list /v1/models serves. Writing only one of
1421
- // them lets the two drift apart.
1422
- function collectCodexValues(profile) {
1423
- if (!servesCodex(profile.inFormat)) return;
1424
-
1425
- const roles = {};
1426
- const published = [];
1427
- CODEX_ROLE_SLOTS.forEach(slot => {
1428
- const value = document.getElementById(`p-public-${slot}`)?.value.trim() || '';
1429
- roles[slot] = value;
1430
- if (value && !published.includes(value)) published.push(value);
1431
- });
1432
- profile.codexRoles = roles;
1433
- profile.publicModels = published;
1434
-
1435
- profile.blindfold = Boolean(document.getElementById('p-blindfold')?.checked);
1436
- const host = document.getElementById('p-blindfold-host')?.value.trim() || '';
1437
- const prefix = document.getElementById('p-blindfold-prefix')?.value.trim() || '';
1438
- const port = parseInt(document.getElementById('p-blindfold-port')?.value, 10);
1439
- profile.blindfoldHost = host;
1440
- profile.blindfoldPrefix = prefix;
1441
- profile.blindfoldPort = Number.isInteger(port) && port > 0 && port <= 65535 ? port : '';
1424
+ currentProfileBuffer.defaultModels = defaultModels;
1425
+ currentProfileBuffer.model1M = model1M;
1426
+ return { defaultModels, model1M };
1442
1427
  }
1443
1428
 
1444
- function collectCurrentSlotValues(format = renderedSlotFormat) {
1445
- if (!currentProfileBuffer) currentProfileBuffer = {};
1446
- if (!currentProfileBuffer.defaultModels) currentProfileBuffer.defaultModels = {};
1447
- if (!currentProfileBuffer.model1M) currentProfileBuffer.model1M = {};
1448
-
1449
- const cfg = getActiveSlotConfig(format);
1450
- cfg.slots.forEach(s => {
1451
- const inp = document.getElementById(`p-${s.id}`);
1452
- if (inp) currentProfileBuffer.defaultModels[s.id] = inp.value.trim();
1453
- const chk = document.getElementById(`p-${s.id}-1m`);
1454
- if (chk) currentProfileBuffer.model1M[s.id] = chk.checked;
1455
- });
1429
+ function update1mWarnings() {
1430
+ const cfg = getActiveSlotConfig(renderedSlotFormat);
1431
+ let any1m = false;
1432
+ for (const s of cfg.slots) {
1433
+ const chk = document.getElementById('p-' + s.id + '-1m');
1434
+ if (chk && chk.checked) any1m = true;
1435
+ }
1436
+ const warnCallout = document.getElementById('warn-1m-callout');
1437
+ if (warnCallout) warnCallout.style.display = any1m ? 'inline-flex' : 'none';
1456
1438
  }
1457
1439
 
1458
- function update1mWarnings() {
1459
- const currentFmt = document.getElementById('p-informat')?.value || 'auto';
1460
- const cfg = getActiveSlotConfig(currentFmt);
1461
- cfg.slots.forEach(s => {
1462
- const chk = document.getElementById(`p-${s.id}-1m`);
1463
- const badge = document.getElementById(`warn-${s.id}-1m`);
1464
- if (chk && badge) {
1465
- badge.style.display = chk.checked ? 'inline-block' : 'none';
1440
+ // Prefetch Counts for Nav Badges
1441
+ async function prefetchNavData() {
1442
+ try {
1443
+ const res = await api('/api/catalog');
1444
+ if (res.ok) {
1445
+ const catalog = await res.json();
1446
+ cachedCatalog = catalog;
1447
+ const claudeModels = Array.isArray(catalog.claude?.models) ? catalog.claude.models : [];
1448
+ const codexModels = Array.isArray(catalog.codex?.models) ? catalog.codex.models : [];
1449
+ const count = claudeModels.length + codexModels.length;
1450
+ const ctModels = document.getElementById('ct-models');
1451
+ if (ctModels && count > 0) ctModels.textContent = count;
1466
1452
  }
1467
- });
1453
+ } catch {}
1454
+
1455
+ try {
1456
+ const res = await api('/api/logs');
1457
+ if (res.ok) {
1458
+ const data = await res.json();
1459
+ const logs = Array.isArray(data.logs) ? data.logs : [];
1460
+ lastLogsCount = logs.length;
1461
+ const ctLogs = document.getElementById('ct-logs');
1462
+ if (ctLogs && lastLogsCount > 0) ctLogs.textContent = lastLogsCount;
1463
+ }
1464
+ } catch {}
1468
1465
  }
1469
1466
 
1467
+ // Load Status
1470
1468
  async function loadStatus() {
1471
1469
  try {
1472
1470
  const res = await api('/api/status');
1471
+ if (!res.ok) return;
1473
1472
  const data = await res.json();
1474
- if (res.status === 401) {
1475
- document.getElementById('status-title').textContent = 'Dashboard locked';
1476
- document.getElementById('status-sub').textContent = 'Run `switch ui` in a terminal. It opens this page with its access token.';
1477
- return;
1478
- }
1479
- if (!res.ok) {
1480
- showToast(data.error || `Gateway error HTTP ${res.status}`, true);
1481
- return;
1482
- }
1483
- state = data;
1484
- document.querySelectorAll('.gateway-port').forEach(el => { el.textContent = state.port; });
1485
- if (!creatingNew && (!currentlyEditingKey || !state.config.profiles[currentlyEditingKey])) {
1486
- // Pick the first profile that is actually active for any CLI target,
1487
- // falling back to the legacy activeProfile or the first profile key.
1488
- const activeMap = state.activeProfiles || {};
1489
- const firstActiveKey = Object.values(activeMap).find(k => k && state.config.profiles[k]);
1490
- currentlyEditingKey = firstActiveKey
1491
- || (state.config.profiles[state.activeProfile] ? state.activeProfile : null)
1492
- || (Object.keys(state.config.profiles)[0] || '');
1493
- formDirty = false;
1494
- }
1473
+ state = { ...state, ...data };
1495
1474
  render();
1475
+ updateHeader(window.location.hash.replace(/^#\/?/, '').split('/')[0] || 'routes');
1476
+ prefetchNavData();
1496
1477
  } catch (err) {
1497
- showToast('Failed to connect to gateway service', true);
1478
+ console.error('Failed to load status', err);
1498
1479
  }
1499
1480
  }
1500
1481
 
1501
- function isAnyTargetActive() {
1502
- return Object.values(state.activeProfiles || {}).some(Boolean);
1503
- }
1504
-
1505
1482
  function render() {
1506
- const dot = document.getElementById('status-dot');
1507
- const title = document.getElementById('status-title');
1508
- const sub = document.getElementById('status-sub');
1509
- const toggleBtn = document.getElementById('btn-toggle');
1483
+ document.querySelectorAll('.gateway-port').forEach(el => {
1484
+ el.textContent = state.port || '3456';
1485
+ });
1510
1486
 
1511
- const activeMap = state.activeProfiles || (state.config.activeProfiles || {});
1487
+ const activeMap = state.activeProfiles || (state.config?.activeProfiles || {});
1512
1488
  const activeTargets = Object.entries(activeMap).filter(([_, v]) => Boolean(v));
1513
1489
  const isAnyActive = activeTargets.length > 0;
1514
1490
 
1515
- if (isAnyActive) {
1516
- dot.className = 'status-dot active';
1517
- title.innerText = `Gateway active for ${activeTargets.length} client${activeTargets.length > 1 ? 's' : ''}`;
1518
- const parts = [];
1519
- if (activeMap.anthropic) parts.push(`Claude: ${activeMap.anthropic}`);
1520
- if (activeMap.responses) parts.push(`Codex: ${activeMap.responses}`);
1521
- if (activeMap['openai-chat']) parts.push(`OpenAI: ${activeMap['openai-chat']}`);
1522
- if (activeMap.vertex) parts.push(`Vertex: ${activeMap.vertex}`);
1523
- sub.innerText = `Port ${state.port} | ${parts.join(' | ')}`;
1524
- toggleBtn.className = 'btn btn-danger';
1525
- toggleBtn.innerText = 'Use official endpoints';
1526
- } else {
1527
- dot.className = 'status-dot';
1528
- title.innerText = 'Official endpoints in use';
1529
- sub.innerText = 'Gateway is idle. Clients connect directly to official endpoints.';
1530
- toggleBtn.className = 'btn btn-success';
1531
- toggleBtn.innerText = 'Activate compatible routes';
1491
+ // Update Aside Indicators
1492
+ const asideIndicator = document.getElementById('status-aside-indicator');
1493
+ if (asideIndicator) {
1494
+ asideIndicator.className = isAnyActive ? 'status on' : 'status';
1495
+ asideIndicator.textContent = isAnyActive ? 'Active (' + activeTargets.length + ')' : 'Idle';
1532
1496
  }
1533
1497
 
1534
- // Update 4 CLI Slots
1498
+ // Update Nav Counts
1499
+ const profilesList = Object.entries(state.config?.profiles || {});
1500
+ const ctRoutes = document.getElementById('ct-routes');
1501
+ if (ctRoutes) ctRoutes.textContent = isAnyActive ? activeTargets.length + ' on' : profilesList.length;
1502
+
1503
+ const ctModels = document.getElementById('ct-models');
1504
+ if (ctModels && cachedCatalog) {
1505
+ const count = (cachedCatalog.claude?.models?.length || 0) + (cachedCatalog.codex?.models?.length || 0);
1506
+ if (count > 0) ctModels.textContent = count;
1507
+ }
1508
+
1509
+ const ctLogs = document.getElementById('ct-logs');
1510
+ if (ctLogs && lastLogsCount > 0) ctLogs.textContent = lastLogsCount;
1511
+
1512
+ // Update Blindfold display
1513
+ const bfEl = document.getElementById('blindfold-port-display');
1514
+ if (bfEl) {
1515
+ const bf = state.config?.blindfold;
1516
+ bfEl.textContent = (bf && bf.port) ? 'Port ' + bf.port : 'Not active';
1517
+ }
1518
+
1519
+ // Render CLI Targets Rows
1535
1520
  const slots = [
1536
- { id: 'anthropic', name: 'Claude Code', key: 'anthropic' },
1537
- { id: 'responses', name: 'Codex', key: 'responses' },
1538
- { id: 'openai-chat', name: 'OpenAI Chat', key: 'openai-chat' },
1539
- { id: 'vertex', name: 'Vertex / Gemini', key: 'vertex' }
1521
+ { id: 'claude', name: 'Claude Code', key: 'claude' },
1522
+ { id: 'codex', name: 'Codex', key: 'codex' }
1540
1523
  ];
1541
1524
 
1542
1525
  for (const slot of slots) {
1543
- const card = document.getElementById(`card-slot-${slot.id}`);
1544
- const badge = document.getElementById(`badge-slot-${slot.id}`);
1545
- const select = document.getElementById(`select-slot-${slot.id}`);
1526
+ const badge = document.getElementById('badge-slot-' + slot.id);
1527
+ const select = document.getElementById('select-slot-' + slot.id);
1546
1528
  const currentActive = activeMap[slot.key];
1547
1529
 
1548
1530
  if (select) {
1549
1531
  select.innerHTML = '<option value="">Official endpoint</option>';
1550
- for (const [key, p] of Object.entries(state.config.profiles)) {
1551
- const inFmt = p.inFormat || 'auto';
1552
- if (inFmt === 'auto' || inFmt === slot.key) {
1532
+ for (const [key, p] of profilesList) {
1533
+ const tool = p.tool || 'auto';
1534
+ if (tool === 'auto' || tool === slot.key) {
1553
1535
  const opt = document.createElement('option');
1554
1536
  opt.value = key;
1555
- opt.textContent = `${p.name} (${key})`;
1537
+ opt.textContent = p.name + ' (' + key + ')';
1556
1538
  select.appendChild(opt);
1557
1539
  }
1558
1540
  }
1559
1541
  select.value = currentActive || '';
1560
1542
  }
1561
1543
 
1562
- if (currentActive) {
1563
- if (card) card.classList.add('active');
1564
- if (badge) {
1565
- badge.className = 'badge badge-direct';
1544
+ if (badge) {
1545
+ if (currentActive) {
1546
+ badge.className = 'badge ok';
1566
1547
  badge.textContent = currentActive;
1567
- }
1568
- } else {
1569
- if (card) card.classList.remove('active');
1570
- if (badge) {
1571
- badge.className = 'badge badge-outline';
1548
+ } else {
1549
+ badge.className = 'badge outline';
1572
1550
  badge.textContent = 'OFF';
1573
1551
  }
1574
1552
  }
1575
1553
  }
1576
1554
 
1577
- // Render Cards Grid
1555
+ // Render Profiles Grid (Intact Style)
1578
1556
  const grid = document.getElementById('profiles-grid');
1557
+ if (!grid) return;
1579
1558
  grid.innerHTML = '';
1580
1559
 
1581
- for (const [key, p] of Object.entries(state.config.profiles)) {
1560
+ for (const [key, p] of profilesList) {
1582
1561
  const activeCLIs = [];
1583
- if (activeMap.anthropic === key) activeCLIs.push('Claude');
1584
- if (activeMap.responses === key) activeCLIs.push('Codex');
1585
- if (activeMap['openai-chat'] === key) activeCLIs.push('OpenAI');
1586
- if (activeMap.vertex === key) activeCLIs.push('Vertex');
1562
+ if (activeMap.claude === key) activeCLIs.push('Claude');
1563
+ if (activeMap.codex === key) activeCLIs.push('Codex');
1587
1564
  const isActiveAny = activeCLIs.length > 0;
1588
1565
  const isEditing = (key === currentlyEditingKey);
1589
- const shortFmt = (f) => ({ auto: 'auto', anthropic: 'anth', 'openai-chat': 'chat', responses: 'resp', vertex: 'vtx' }[f] || f || 'auto');
1590
- const routeBadge = `${shortFmt(p.inFormat)}→${shortFmt(p.outFormat || p.mode)}`;
1591
1566
 
1592
1567
  const card = document.createElement('div');
1593
- card.className = `profile-card ${isEditing ? 'editing' : ''} ${isActiveAny ? 'active' : ''}`;
1594
- card.setAttribute('aria-label', `${p.name} provider profile`);
1595
- card.style.cursor = 'pointer';
1596
- card.addEventListener('click', (ev) => {
1597
- // Don't trigger if clicked on toggle switch or interactive buttons
1598
- if (ev.target.closest('.card-toggle') || ev.target.closest('input') || ev.target.closest('button')) return;
1599
- selectProfileForEdit(key);
1600
- });
1568
+ card.className = 'pcard' + (isActiveAny ? ' active' : '');
1569
+ card.setAttribute('aria-label', p.name + ' profile');
1601
1570
 
1602
- const pCfg = getActiveSlotConfig(p.inFormat || 'auto');
1603
- const modelsSummaryHtml = pCfg.slots.map(s => {
1604
- const val = valueForSlot(p.defaultModels, s.id) || '-';
1571
+ const toolAv = getToolAvatar(key, p);
1572
+ const routeBadge = (p.tool || 'auto') + ' → ' + (p.outFormat || p.mode || 'chat');
1573
+
1574
+ const slotFmt = p.tool === 'claude' ? 'anthropic' : p.tool === 'codex' ? 'responses' : 'auto';
1575
+ const pCfg = getActiveSlotConfig(slotFmt);
1576
+ const modelChips = pCfg.slots.map(s => {
1577
+ const val = valueForSlot(p.defaultModels, s.id);
1578
+ if (!val) return '';
1605
1579
  const has1m = flagForSlot(p.model1M, s.id);
1606
- const label = s.id.charAt(0).toUpperCase() + s.id.slice(1);
1607
- return `<div><strong>${label}:</strong> ${escapeHtml(val)} ${has1m ? '<span class="badge-1m">1M</span>' : ''}</div>`;
1608
- }).join('');
1580
+ return '<div class="chip"><span class="faint lbl">' + escapeHtml(s.name) + '</span><span class="val">' + escapeHtml(val) + '</span>' + (has1m ? '<b class="b1m">1M</b>' : '') + '</div>';
1581
+ }).filter(Boolean).join('');
1609
1582
 
1610
1583
  card.innerHTML = `
1611
- <div class="card-top">
1612
- <div class="card-name-group">
1613
- <div class="card-name">${escapeHtml(p.name)}</div>
1614
- <span class="badge badge-${escapeHtml(p.mode || 'hybrid')}">${escapeHtml(p.mode || 'hybrid')}</span>
1615
- <span class="badge badge-route" title="Client input -> Upstream output">${escapeHtml(routeBadge)}</span>
1616
- </div>
1617
- <div class="card-toggle" style="display: flex; align-items: center; gap: 6px; flex-shrink: 0;">
1618
- <span style="font-size: 11px; font-weight: 600; color: ${isActiveAny ? 'var(--success)' : 'var(--text-dim)'};">${isActiveAny ? 'ACTIVE' : 'OFF'}</span>
1619
- <label class="switch">
1620
- <input type="checkbox" aria-label="Activate ${escapeHtml(p.name)} for compatible clients" ${isActiveAny ? 'checked' : ''}>
1621
- <span class="slider"></span>
1622
- </label>
1584
+ <div class="top">
1585
+ ${toolAv}
1586
+ <div style="flex:1; min-width:0;">
1587
+ <div class="nm">${escapeHtml(p.name)}</div>
1588
+ <div style="display:flex; gap:0.4rem; align-items:center; flex-wrap:wrap; margin-top:0.15rem;">
1589
+ <span class="status ${isActiveAny ? 'on' : ''}">${isActiveAny ? 'Active (' + activeCLIs.join(', ') + ')' : 'Idle'}</span>
1590
+ <span class="badge accent">${escapeHtml(routeBadge)}</span>
1591
+ </div>
1623
1592
  </div>
1593
+ <label class="switch" onclick="event.stopPropagation()">
1594
+ <input type="checkbox" ${isActiveAny ? 'checked' : ''} onchange="toggleProfileAllCli('${escapeHtml(key)}', this.checked)">
1595
+ <span></span>
1596
+ </label>
1624
1597
  </div>
1625
- <div class="card-url">${escapeHtml(p.baseURL)}</div>
1626
- <div class="card-models">
1627
- ${modelsSummaryHtml}
1598
+ <div class="copyline" onclick="event.stopPropagation()">
1599
+ <code>${escapeHtml(p.baseURL)}</code>
1628
1600
  </div>
1629
- <div class="card-footer">
1630
- <button type="button" class="btn btn-outline btn-sm profile-edit" aria-pressed="${isEditing}" onclick="selectProfileForEdit('${escapeHtml(key)}')">
1631
- ${isEditing ? 'Editing' : 'Edit profile'}
1601
+ ${modelChips ? '<div class="chips">' + modelChips + '</div>' : ''}
1602
+ <div style="display:flex; justify-content:space-between; align-items:center; margin-top:auto; padding-top:0.5rem; border-top:1px solid var(--line);">
1603
+ <button type="button" class="btn btn-outline btn-sm" onclick="selectProfileForEdit('${escapeHtml(key)}')">
1604
+ ${isEditing ? 'Editing Profile' : 'Edit Profile'}
1632
1605
  </button>
1633
- <div>
1634
- ${activeCLIs.map(c => `<span class="badge badge-direct" style="font-size: 9px; margin-left: 3px;">${escapeHtml(c)}</span>`).join('')}
1635
- </div>
1606
+ <span class="mono faint" style="font-size:0.75rem;">${escapeHtml(key)}</span>
1636
1607
  </div>
1637
1608
  `;
1638
1609
 
1639
- const toggleWrap = card.querySelector('.card-toggle');
1640
- toggleWrap.addEventListener('click', ev => ev.stopPropagation());
1641
- toggleWrap.querySelector('input').addEventListener('change', ev => toggleProfileAllCli(key, ev.target.checked));
1642
1610
  grid.appendChild(card);
1643
1611
  }
1644
1612
 
@@ -1648,15 +1616,16 @@
1648
1616
  }
1649
1617
 
1650
1618
  function selectProfileForEdit(key) {
1651
- if (formDirty && key !== formLoadedKey && !confirm('Discard unsaved changes to this provider?')) return;
1619
+ if (formDirty && key !== formLoadedKey && !confirm('Discard unsaved changes?')) return;
1652
1620
  currentlyEditingKey = key;
1653
1621
  creatingNew = false;
1654
1622
  formDirty = false;
1655
1623
  render();
1624
+ openProfileModal(false);
1656
1625
  }
1657
1626
 
1658
1627
  async function switchCliTarget(target, profileKey) {
1659
- showToast(`Setting ${target} active profile...`);
1628
+ showToast('Setting ' + target + ' target...');
1660
1629
  try {
1661
1630
  const res = await api('/api/switch', {
1662
1631
  method: 'POST',
@@ -1664,400 +1633,241 @@
1664
1633
  body: JSON.stringify({ target, profile: profileKey || null })
1665
1634
  });
1666
1635
  const data = await res.json();
1667
- if (data.success) {
1668
- showToast(`Updated ${target} to "${profileKey || 'Official'}"!`);
1669
- loadStatus();
1636
+ if (data.ok) {
1637
+ showToast('Updated ' + target + ' routing');
1638
+ await loadStatus();
1670
1639
  } else {
1671
- showToast(data.error || 'Failed to switch', true);
1640
+ showToast(data.error || 'Failed to switch target', true);
1672
1641
  }
1673
1642
  } catch (err) {
1674
- showToast('Error switching target', true);
1643
+ showToast('Network error switching target', true);
1675
1644
  }
1676
1645
  }
1677
1646
 
1678
- async function toggleProfileAllCli(key, isChecked) {
1679
- if (isChecked) {
1680
- showToast(`Activating provider "${key}"...`);
1681
- try {
1682
- const res = await api('/api/switch', {
1683
- method: 'POST',
1684
- headers: { 'Content-Type': 'application/json' },
1685
- body: JSON.stringify({ profile: key })
1686
- });
1687
- const data = await res.json();
1688
- if (data.success) {
1689
- showToast(`Provider "${key}" activated!`);
1690
- loadStatus();
1691
- } else {
1692
- showToast(data.error || 'Failed to switch', true);
1693
- loadStatus();
1694
- }
1695
- } catch (err) {
1696
- showToast('Error activating provider', true);
1697
- loadStatus();
1698
- }
1699
- } else {
1700
- showToast(`Deactivating provider "${key}"...`);
1701
- try {
1702
- const res = await api('/api/switch', {
1703
- method: 'POST',
1704
- headers: { 'Content-Type': 'application/json' },
1705
- body: JSON.stringify({ deactivate: key })
1706
- });
1707
- const data = await res.json();
1708
- if (!data.success) throw new Error(data.error || 'Failed');
1709
- showToast(`Deactivated "${key}"!`);
1710
- loadStatus();
1711
- } catch (err) {
1712
- showToast('Error deactivating provider', true);
1713
- loadStatus();
1647
+ async function toggleProfileAllCli(key, enable) {
1648
+ showToast((enable ? 'Activating ' : 'Deactivating ') + key + '...');
1649
+ try {
1650
+ const res = await api('/api/switch', {
1651
+ method: 'POST',
1652
+ headers: { 'Content-Type': 'application/json' },
1653
+ body: JSON.stringify({ profile: key, deactivate: !enable })
1654
+ });
1655
+ const data = await res.json();
1656
+ if (data.ok) {
1657
+ showToast(key + (enable ? ' activated' : ' deactivated'));
1658
+ await loadStatus();
1659
+ } else {
1660
+ showToast(data.error || 'Failed to toggle profile', true);
1714
1661
  }
1662
+ } catch (err) {
1663
+ showToast('Network error toggling profile', true);
1715
1664
  }
1716
1665
  }
1717
1666
 
1718
1667
  function loadProfileToForm(key) {
1719
1668
  const p = state.config?.profiles?.[key];
1720
1669
  if (!p) return;
1670
+ formLoadedKey = key;
1721
1671
  currentProfileBuffer = JSON.parse(JSON.stringify(p));
1722
1672
 
1723
- document.getElementById('form-title').innerText = `Edit Provider: ${p.name}`;
1673
+ document.getElementById('form-title').textContent = 'Edit Profile: ' + p.name;
1724
1674
  document.getElementById('p-key').value = key;
1725
- document.getElementById('p-key').disabled = true;
1726
- document.getElementById('p-name').value = p.name;
1727
- document.getElementById('p-mode').value = p.mode || 'hybrid';
1728
- document.getElementById('p-informat').value = p.inFormat || 'auto';
1675
+ document.getElementById('p-key').readOnly = true;
1676
+ document.getElementById('p-name').value = p.name || '';
1677
+ document.getElementById('p-url').value = p.baseURL || '';
1678
+ document.getElementById('p-key-val').value = p.apiKey ? MASKED_KEY : '';
1679
+ const toolVal = p.tool || 'auto';
1680
+ document.getElementById('p-tool').value = toolVal;
1681
+ document.getElementById('p-outformat').value = p.outFormat || 'openai-chat';
1682
+ document.getElementById('p-mode').value = p.mode || 'convert';
1729
1683
  document.getElementById('p-thinking-mode').value = p.thinkingMode || 'auto';
1730
- document.getElementById('p-outformat').value = p.outFormat || '';
1731
- document.getElementById('p-url').value = p.baseURL;
1732
- document.getElementById('p-key-val').value = p.apiKey || '';
1733
-
1734
- renderModelSlots(p.inFormat || 'auto');
1735
- renderCodexTab(p.inFormat || 'auto');
1736
-
1737
- document.getElementById('btn-delete').style.display = Object.keys(state.config.profiles).length > 1 ? 'block' : 'none';
1738
- const usedBy = Object.entries(state.activeProfiles || {}).filter(([, v]) => v === key).map(([t]) => t);
1739
- document.getElementById('active-tag').innerText = usedBy.length ? `(Active: ${usedBy.join(', ')})` : '';
1740
-
1741
- document.getElementById('model-browser').style.display = 'none';
1742
- document.getElementById('test-result').style.display = 'none';
1743
- loadedModels = [];
1744
- formLoadedKey = key;
1745
- formDirty = false;
1684
+ document.getElementById('btn-delete').style.display = 'inline-flex';
1685
+
1686
+ const activeMap = state.activeProfiles || (state.config?.activeProfiles || {});
1687
+ const activeTargets = Object.entries(activeMap).filter(([_, v]) => v === key).map(([t]) => t);
1688
+ const activeTag = document.getElementById('active-tag');
1689
+ if (activeTag) {
1690
+ activeTag.textContent = activeTargets.length ? '● Active: ' + activeTargets.join(', ') : '';
1691
+ }
1692
+
1693
+ renderModelSlots(toolVal, p);
1746
1694
  }
1747
1695
 
1748
1696
  function initNewProfile() {
1749
- if (formDirty && !confirm('Discard unsaved changes to this provider?')) return;
1750
- currentlyEditingKey = '';
1751
1697
  creatingNew = true;
1698
+ currentlyEditingKey = '';
1752
1699
  formLoadedKey = null;
1753
- formDirty = false;
1754
1700
  currentProfileBuffer = {
1755
1701
  name: '',
1756
- mode: 'hybrid',
1757
- inFormat: 'auto',
1758
- outFormat: '',
1702
+ baseURL: '',
1703
+ tool: 'auto',
1704
+ outFormat: 'openai-chat',
1705
+ mode: 'convert',
1759
1706
  thinkingMode: 'auto',
1760
- baseURL: 'https://',
1761
- apiKey: '',
1762
1707
  defaultModels: {},
1763
1708
  model1M: {}
1764
1709
  };
1765
1710
 
1766
- render();
1767
- document.getElementById('form-title').innerText = 'Create New Provider';
1711
+ document.getElementById('form-title').textContent = 'Add New Provider Profile';
1768
1712
  document.getElementById('p-key').value = '';
1769
- document.getElementById('p-key').disabled = false;
1713
+ document.getElementById('p-key').readOnly = false;
1770
1714
  document.getElementById('p-name').value = '';
1771
- document.getElementById('p-mode').value = 'hybrid';
1772
- document.getElementById('p-informat').value = 'auto';
1773
- document.getElementById('p-outformat').value = '';
1774
- document.getElementById('p-thinking-mode').value = 'auto';
1775
- document.getElementById('p-url').value = 'https://';
1715
+ document.getElementById('p-url').value = '';
1776
1716
  document.getElementById('p-key-val').value = '';
1777
-
1778
- renderModelSlots('auto');
1779
- renderCodexTab('auto');
1780
-
1717
+ document.getElementById('p-tool').value = 'auto';
1718
+ document.getElementById('p-outformat').value = 'openai-chat';
1719
+ document.getElementById('p-mode').value = 'convert';
1720
+ document.getElementById('p-thinking-mode').value = 'auto';
1781
1721
  document.getElementById('btn-delete').style.display = 'none';
1782
- document.getElementById('active-tag').innerText = '';
1783
- document.getElementById('model-browser').style.display = 'none';
1784
- document.getElementById('test-result').style.display = 'none';
1785
- loadedModels = [];
1786
- switchTab('tab-general');
1787
- }
1788
-
1789
- function setKeyIfNew(k) {
1790
- const input = document.getElementById('p-key');
1791
- if (!input.disabled) input.value = k;
1792
- }
1793
-
1794
- function applyTemplate(type) {
1795
- if (type === '9router') {
1796
- setKeyIfNew('9router');
1797
- document.getElementById('p-name').value = '9Router Cloud';
1798
- document.getElementById('p-mode').value = 'convert';
1799
- document.getElementById('p-informat').value = 'auto';
1800
- document.getElementById('p-outformat').value = 'openai-chat';
1801
- document.getElementById('p-url').value = 'https://api.9router.com/v1';
1802
- document.getElementById('p-thinking-mode').value = 'auto';
1803
- currentProfileBuffer = {
1804
- defaultModels: {
1805
- opus: 'ag/claude-opus-4-6-thinking',
1806
- sonnet: 'ag/claude-sonnet-4-6',
1807
- haiku: 'ag/gemini-3.7-flash-high',
1808
- fable: 'ag/gemini-3.8-flash-high'
1809
- },
1810
- model1M: { opus: true, sonnet: true, haiku: false, fable: true }
1811
- };
1812
- renderModelSlots('auto');
1813
- } else if (type === 'openrouter') {
1814
- setKeyIfNew('openrouter');
1815
- document.getElementById('p-name').value = 'OpenRouter';
1816
- document.getElementById('p-mode').value = 'convert';
1817
- document.getElementById('p-informat').value = 'auto';
1818
- document.getElementById('p-outformat').value = 'openai-chat';
1819
- document.getElementById('p-url').value = 'https://openrouter.ai/api/v1';
1820
- currentProfileBuffer = {
1821
- defaultModels: {
1822
- opus: 'anthropic/claude-3.7-sonnet:thinking',
1823
- sonnet: 'anthropic/claude-3.7-sonnet',
1824
- haiku: 'google/gemini-2.0-flash-001',
1825
- fable: 'qwen/qwq-32b'
1826
- },
1827
- model1M: { opus: true, sonnet: true, haiku: false, fable: false }
1828
- };
1829
- renderModelSlots('auto');
1830
- } else if (type === 'codex-vtx') {
1831
- setKeyIfNew('codex-vertex');
1832
- document.getElementById('p-name').value = 'Codex to Vertex';
1833
- document.getElementById('p-mode').value = 'convert';
1834
- document.getElementById('p-informat').value = 'responses';
1835
- document.getElementById('p-outformat').value = 'vertex';
1836
- document.getElementById('p-url').value = 'https://api.9router.com/v1';
1837
- document.getElementById('p-thinking-mode').value = 'auto';
1838
- currentProfileBuffer = {
1839
- defaultModels: {
1840
- main: 'gemini-3.8-flash',
1841
- review: 'gemini-3.7-flash-medium',
1842
- subagent: 'gemini-3.6-flash-low'
1843
- },
1844
- model1M: { main: true, review: false, subagent: false }
1845
- };
1846
- renderModelSlots('responses');
1847
- } else if (type === 'momo') {
1848
- setKeyIfNew('selfhost');
1849
- document.getElementById('p-name').value = 'Selfhost Hybrid';
1850
- document.getElementById('p-mode').value = 'hybrid';
1851
- document.getElementById('p-informat').value = 'auto';
1852
- document.getElementById('p-outformat').value = '';
1853
- document.getElementById('p-url').value = 'https://llm.example.com/v1';
1854
- currentProfileBuffer = {
1855
- defaultModels: {
1856
- opus: 'claude-opus-4-7',
1857
- sonnet: 'claude-sonnet-4-6',
1858
- haiku: 'claude-haiku-4-5-20251001',
1859
- fable: 'claude-haiku-4-5-20251001'
1860
- },
1861
- model1M: { opus: true, sonnet: true, haiku: false, fable: false }
1862
- };
1863
- renderModelSlots('auto');
1864
- } else if (type === 'custom') {
1865
- setKeyIfNew('local-openai');
1866
- document.getElementById('p-name').value = 'Local OpenAI Server';
1867
- document.getElementById('p-mode').value = 'convert';
1868
- document.getElementById('p-informat').value = 'openai-chat';
1869
- document.getElementById('p-outformat').value = 'openai-chat';
1870
- document.getElementById('p-url').value = 'http://127.0.0.1:8000/v1';
1871
- document.getElementById('p-thinking-mode').value = 'native';
1872
- currentProfileBuffer = {
1873
- defaultModels: { default: 'gpt-4o' },
1874
- model1M: { default: false }
1875
- };
1876
- renderModelSlots('openai-chat');
1877
- }
1878
- formDirty = true;
1879
- showToast(`Applied ${type} template!`);
1880
- }
1881
-
1882
- async function fetchModelList() {
1883
- const baseURL = document.getElementById('p-url').value.trim();
1884
- const apiKey = document.getElementById('p-key-val').value.trim();
1885
- if (!baseURL) {
1886
- showToast('Please enter Base URL first', true);
1887
- switchTab('tab-general');
1888
- return;
1889
- }
1890
-
1891
- const btn = document.getElementById('btn-fetch-models');
1892
- btn.innerText = 'Loading...';
1893
- btn.disabled = true;
1894
-
1895
- try {
1896
- const res = await api('/api/fetch-models', {
1897
- method: 'POST',
1898
- headers: { 'Content-Type': 'application/json' },
1899
- body: JSON.stringify({ baseURL, apiKey, key: formLoadedKey })
1900
- });
1901
- const data = await res.json();
1902
- if (data.ok && Array.isArray(data.models)) {
1903
- loadedModels = data.models;
1904
- populateModelOptions(loadedModels);
1905
- renderModelBrowser(loadedModels);
1906
- showToast(`Loaded ${loadedModels.length} models from upstream!`);
1907
- } else {
1908
- showToast(`Failed to load models: ${data.error || 'Unknown error'}`, true);
1909
- }
1910
- } catch (err) {
1911
- showToast(`Fetch error: ${err.message}`, true);
1912
- } finally {
1913
- btn.innerText = 'Fetch models';
1914
- btn.disabled = false;
1915
- }
1916
- }
1722
+ document.getElementById('active-tag').textContent = '';
1917
1723
 
1918
- function populateModelOptions(models) {
1919
- const datalist = document.getElementById('model-options');
1920
- datalist.innerHTML = '';
1921
- for (const m of models) {
1922
- const opt = document.createElement('option');
1923
- opt.value = m;
1924
- datalist.appendChild(opt);
1925
- }
1724
+ renderModelSlots('auto', currentProfileBuffer);
1725
+ switchTab('tab-general');
1926
1726
  }
1927
1727
 
1928
- function renderModelBrowser(models) {
1929
- const container = document.getElementById('model-browser');
1930
- const items = document.getElementById('model-items');
1931
- const countLabel = document.getElementById('model-count-label');
1932
-
1933
- container.style.display = 'block';
1934
- countLabel.innerText = `Available Models (${models.length})`;
1935
- items.innerHTML = '';
1936
-
1937
- if (models.length === 0) {
1938
- items.innerHTML = '<div style="color: var(--text-dim); font-size: 12px; padding: 6px;">No matching models found.</div>';
1939
- return;
1940
- }
1941
-
1942
- const currentFmt = document.getElementById('p-informat')?.value || 'auto';
1943
- const cfg = getActiveSlotConfig(currentFmt);
1944
-
1945
- for (const m of models) {
1946
- const row = document.createElement('div');
1947
- row.className = 'model-row';
1948
-
1949
- const idSpan = document.createElement('span');
1950
- idSpan.className = 'model-id';
1951
- idSpan.textContent = m;
1952
-
1953
- const btnGroup = document.createElement('div');
1954
- btnGroup.className = 'model-btn-group';
1955
-
1956
- cfg.slots.forEach(s => {
1957
- const btn = document.createElement('button');
1958
- btn.type = 'button';
1959
- btn.className = 'btn btn-outline btn-sm';
1960
- btn.textContent = s.id.charAt(0).toUpperCase() + s.id.slice(1);
1961
- btn.onclick = () => selectModel(s.id, m);
1962
- btnGroup.appendChild(btn);
1963
- });
1964
-
1965
- row.appendChild(idSpan);
1966
- row.appendChild(btnGroup);
1967
- items.appendChild(row);
1728
+ const TEMPLATES = {
1729
+ intact: {
1730
+ name: 'Intact Credential Proxy',
1731
+ baseURL: 'http://127.0.0.1:8080/v1',
1732
+ tool: 'auto',
1733
+ outFormat: 'openai-chat',
1734
+ mode: 'convert',
1735
+ thinkingMode: 'auto',
1736
+ defaultModels: { default: 'gpt-4o', sonnet: 'claude-3-7-sonnet', opus: 'claude-3-opus', haiku: 'claude-3-5-haiku' }
1737
+ },
1738
+ '9router': {
1739
+ name: '9Router Client Proxy',
1740
+ baseURL: 'http://127.0.0.1:20128/v1',
1741
+ tool: 'auto',
1742
+ outFormat: 'openai-chat',
1743
+ mode: 'hybrid',
1744
+ thinkingMode: 'auto',
1745
+ defaultModels: { default: 'gpt-4o', sonnet: 'claude-3-7-sonnet' }
1746
+ },
1747
+ openrouter: {
1748
+ name: 'OpenRouter Gateway',
1749
+ baseURL: 'https://openrouter.ai/api/v1',
1750
+ tool: 'auto',
1751
+ outFormat: 'openai-chat',
1752
+ mode: 'convert',
1753
+ thinkingMode: 'auto',
1754
+ defaultModels: { default: 'anthropic/claude-3.7-sonnet', sonnet: 'anthropic/claude-3.7-sonnet' }
1755
+ },
1756
+ vertex: {
1757
+ name: 'Google Vertex AI',
1758
+ baseURL: 'https://us-central1-aiplatform.googleapis.com/v1',
1759
+ tool: 'anthropic',
1760
+ outFormat: 'vertex',
1761
+ mode: 'convert',
1762
+ thinkingMode: 'auto',
1763
+ defaultModels: { sonnet: 'claude-3-7-sonnet@20250219' }
1764
+ },
1765
+ local: {
1766
+ name: 'Local Ollama / vLLM Server',
1767
+ baseURL: 'http://127.0.0.1:11434/v1',
1768
+ tool: 'auto',
1769
+ outFormat: 'openai-chat',
1770
+ mode: 'direct',
1771
+ thinkingMode: 'off',
1772
+ defaultModels: { default: 'qwen2.5-coder:32b' }
1968
1773
  }
1969
- }
1970
-
1971
- function filterModels() {
1972
- const q = (document.getElementById('model-filter').value || '').toLowerCase().trim();
1973
- const filtered = loadedModels.filter(m => m.toLowerCase().includes(q));
1974
- renderModelBrowser(filtered);
1975
- }
1774
+ };
1976
1775
 
1977
- function selectModel(slotId, modelId) {
1978
- const targetInput = document.getElementById('p-' + slotId);
1979
- if (targetInput) targetInput.value = modelId;
1980
- const is1M = /1m|gemini-3|glm-5|kimi|nemotron-3-ultra/i.test(modelId);
1981
- const chk1M = document.getElementById(`p-${slotId}-1m`);
1982
- if (chk1M && is1M) {
1983
- chk1M.checked = true;
1776
+ function applyTemplate(tName) {
1777
+ const t = TEMPLATES[tName];
1778
+ if (!t) return;
1779
+ if (creatingNew) {
1780
+ document.getElementById('p-key').value = tName;
1984
1781
  }
1985
- update1mWarnings();
1986
- formDirty = true;
1987
- showToast(`Assigned "${modelId}" to ${slotId.toUpperCase()}`);
1782
+ document.getElementById('p-name').value = t.name;
1783
+ document.getElementById('p-url').value = t.baseURL;
1784
+ document.getElementById('p-tool').value = t.tool;
1785
+ document.getElementById('p-outformat').value = t.outFormat;
1786
+ document.getElementById('p-mode').value = t.mode;
1787
+ document.getElementById('p-thinking-mode').value = t.thinkingMode;
1788
+ currentProfileBuffer = { ...currentProfileBuffer, ...t };
1789
+ renderModelSlots(t.tool || 'auto', t);
1790
+ showToast('Template "' + t.name + '" applied');
1988
1791
  }
1989
1792
 
1990
1793
  async function toggleProxy() {
1991
- const nextState = !isAnyTargetActive();
1992
- showToast(nextState ? 'Activating compatible routes...' : 'Restoring official endpoints...');
1794
+ const isAnyActive = Object.values(state.activeProfiles || (state.config?.activeProfiles || {})).some(Boolean);
1795
+ const action = isAnyActive ? 'deactivate' : 'activate';
1796
+ showToast((isAnyActive ? 'Switching to official endpoints...' : 'Activating compatible routes...'));
1993
1797
  try {
1994
1798
  const res = await api('/api/toggle', {
1995
1799
  method: 'POST',
1996
1800
  headers: { 'Content-Type': 'application/json' },
1997
- body: JSON.stringify({ enabled: nextState })
1801
+ body: JSON.stringify({ enabled: !isAnyActive })
1998
1802
  });
1999
1803
  const data = await res.json();
2000
- if (data.success) {
2001
- showToast(nextState ? 'Compatible routes are active.' : 'Official endpoints restored.');
1804
+ if (data.ok) {
1805
+ showToast(isAnyActive ? 'Using official endpoints' : 'Gateway activated');
1806
+ await loadStatus();
2002
1807
  } else {
2003
- showToast(data.error || 'Failed to toggle gateway state', true);
1808
+ showToast(data.error || 'Failed to toggle routes', true);
2004
1809
  }
2005
- loadStatus();
2006
1810
  } catch (err) {
2007
- showToast('Failed to toggle gateway state', true);
1811
+ showToast('Network error toggling routes', true);
2008
1812
  }
2009
1813
  }
2010
1814
 
2011
1815
  async function saveProfile(e) {
2012
1816
  e.preventDefault();
2013
1817
  const key = document.getElementById('p-key').value.trim();
2014
- const outFormat = document.getElementById('p-outformat').value;
2015
- if (creatingNew && state.config?.profiles?.[key] && !confirm(`Provider "${key}" already exists. Overwrite it?`)) return;
1818
+ if (!key) return;
2016
1819
 
2017
1820
  collectCurrentSlotValues();
2018
1821
  const existing = state.config?.profiles?.[key] || {};
2019
- const mergedModels = { ...(existing.defaultModels || {}), ...(currentProfileBuffer?.defaultModels || {}) };
2020
- const merged1M = { ...(existing.model1M || {}), ...(currentProfileBuffer?.model1M || {}) };
1822
+ const keyVal = document.getElementById('p-key-val').value.trim();
2021
1823
 
2022
- const profile = {
1824
+ const profilePayload = {
2023
1825
  name: document.getElementById('p-name').value.trim(),
2024
- mode: document.getElementById('p-mode').value,
2025
- inFormat: document.getElementById('p-informat').value || 'auto',
2026
- thinkingMode: document.getElementById('p-thinking-mode').value || 'auto',
2027
1826
  baseURL: document.getElementById('p-url').value.trim(),
2028
- apiKey: document.getElementById('p-key-val').value.trim(),
2029
- defaultModels: mergedModels,
2030
- model1M: merged1M
1827
+ tool: document.getElementById('p-tool').value,
1828
+ outFormat: document.getElementById('p-outformat').value,
1829
+ mode: document.getElementById('p-mode').value,
1830
+ thinkingMode: document.getElementById('p-thinking-mode').value,
1831
+ defaultModels: { ...(existing.defaultModels || {}), ...(currentProfileBuffer?.defaultModels || {}) },
1832
+ model1M: { ...(existing.model1M || {}), ...(currentProfileBuffer?.model1M || {}) }
2031
1833
  };
2032
- profile.outFormat = outFormat;
2033
- collectCodexValues(profile);
2034
1834
 
1835
+ if (keyVal === MASKED_KEY) {
1836
+ profilePayload.apiKey = existing.apiKey || '';
1837
+ } else if (keyVal) {
1838
+ profilePayload.apiKey = keyVal;
1839
+ } else {
1840
+ profilePayload.apiKey = '';
1841
+ }
1842
+
1843
+ if (existing.codexRoles) profilePayload.codexRoles = existing.codexRoles;
1844
+ if (existing.publicModels) profilePayload.publicModels = existing.publicModels;
1845
+
1846
+ showToast('Saving profile "' + key + '"...');
2035
1847
  try {
2036
1848
  const res = await api('/api/save-profile', {
2037
1849
  method: 'POST',
2038
1850
  headers: { 'Content-Type': 'application/json' },
2039
- body: JSON.stringify({ key, profile })
1851
+ body: JSON.stringify({ key, profile: profilePayload })
2040
1852
  });
2041
1853
  const data = await res.json();
2042
- if (data.success) {
2043
- showToast('Provider profile saved.');
2044
- currentlyEditingKey = key;
2045
- creatingNew = false;
2046
- formDirty = false;
2047
- formLoadedKey = null;
2048
- loadStatus();
1854
+ if (data.ok) {
1855
+ showToast('Profile "' + key + '" saved successfully');
1856
+ closeProfileModal();
1857
+ await loadStatus();
2049
1858
  } else {
2050
- showToast(data.error || 'Failed to save', true);
1859
+ showToast(data.error || 'Failed to save profile', true);
2051
1860
  }
2052
1861
  } catch (err) {
2053
- showToast('Error saving provider', true);
1862
+ showToast('Network error saving profile', true);
2054
1863
  }
2055
1864
  }
2056
1865
 
2057
1866
  async function deleteProfile() {
2058
- const key = document.getElementById('p-key').value;
2059
- if (!confirm(`Are you sure you want to delete provider "${key}"?`)) return;
1867
+ const key = document.getElementById('p-key').value.trim();
1868
+ if (!key || !confirm('Delete provider profile "' + key + '" permanently?')) return;
2060
1869
 
1870
+ showToast('Deleting profile "' + key + '"...');
2061
1871
  try {
2062
1872
  const res = await api('/api/delete-profile', {
2063
1873
  method: 'POST',
@@ -2065,21 +1875,18 @@
2065
1875
  body: JSON.stringify({ key })
2066
1876
  });
2067
1877
  const data = await res.json();
2068
- if (data.success) {
2069
- showToast('Provider deleted.');
2070
- currentlyEditingKey = '';
2071
- creatingNew = false;
2072
- formDirty = false;
2073
- loadStatus();
1878
+ if (data.ok) {
1879
+ showToast('Profile "' + key + '" deleted');
1880
+ closeProfileModal();
1881
+ await loadStatus();
2074
1882
  } else {
2075
- showToast(data.error || 'Failed to delete', true);
1883
+ showToast(data.error || 'Failed to delete profile', true);
2076
1884
  }
2077
1885
  } catch (err) {
2078
- showToast('Error deleting provider', true);
1886
+ showToast('Network error deleting profile', true);
2079
1887
  }
2080
1888
  }
2081
1889
 
2082
- // Modern Non-blocking Connection Tester
2083
1890
  async function testConnection() {
2084
1891
  const btn = document.getElementById('btn-test-conn');
2085
1892
  const box = document.getElementById('test-result');
@@ -2092,10 +1899,10 @@
2092
1899
  box.style.display = 'none';
2093
1900
 
2094
1901
  try {
2095
- const currentFmt = document.getElementById('p-informat')?.value || 'auto';
1902
+ const currentFmt = document.getElementById('p-tool')?.value || 'auto';
2096
1903
  const cfg = getActiveSlotConfig(currentFmt);
2097
1904
  const primarySlotId = cfg.slots[0]?.id || 'default';
2098
- const modelInput = document.getElementById(`p-${primarySlotId}`) || document.getElementById('p-sonnet') || document.getElementById('p-opus');
1905
+ const modelInput = document.getElementById('p-' + primarySlotId) || document.getElementById('p-sonnet') || document.getElementById('p-opus');
2099
1906
  const testModel = (modelInput?.value || '').trim() || 'default';
2100
1907
 
2101
1908
  const res = await api('/api/test-upstream', {
@@ -2103,7 +1910,7 @@
2103
1910
  headers: { 'Content-Type': 'application/json' },
2104
1911
  body: JSON.stringify({
2105
1912
  baseURL: document.getElementById('p-url').value.trim(),
2106
- apiKey: document.getElementById('p-key-val').value.trim(),
1913
+ apiKey: document.getElementById('p-key-val').value.trim() === MASKED_KEY ? (state.config?.profiles?.[formLoadedKey]?.apiKey || '') : document.getElementById('p-key-val').value.trim(),
2107
1914
  mode: document.getElementById('p-mode').value,
2108
1915
  outFormat: document.getElementById('p-outformat').value,
2109
1916
  key: formLoadedKey,
@@ -2115,47 +1922,202 @@
2115
1922
 
2116
1923
  if (data.ok) {
2117
1924
  box.className = 'test-result-box success';
2118
- statusBadge.textContent = `✓ HTTP 200 OK (${data.outFormat || 'upstream'})`;
2119
- latencyBadge.textContent = `Latency: ${data.latency}ms`;
2120
- sampleBox.textContent = `Sample Output: ${data.sample}`;
2121
- showToast(`Connection successful (${data.latency}ms)`);
1925
+ statusBadge.className = 'badge ok';
1926
+ statusBadge.textContent = '✓ HTTP 200 OK (' + (data.outFormat || 'upstream') + ')';
1927
+ latencyBadge.textContent = 'Latency: ' + data.latency + 'ms';
1928
+ sampleBox.textContent = 'Sample Output: ' + data.sample;
1929
+ showToast('Connection test passed (' + data.latency + 'ms)');
2122
1930
  } else {
2123
1931
  box.className = 'test-result-box error';
2124
- statusBadge.textContent = `✗ Failed (${data.status || 'Error'})`;
1932
+ statusBadge.className = 'badge err';
1933
+ statusBadge.textContent = '✗ Failed (' + (data.status || 'Error') + ')';
2125
1934
  latencyBadge.textContent = '';
2126
- sampleBox.textContent = `Error details: ${data.error || 'Connection failed'}`;
2127
- showToast('Upstream test failed', true);
1935
+ sampleBox.textContent = 'Error: ' + (data.error || 'Upstream connection failed');
1936
+ showToast('Upstream connection failed', true);
2128
1937
  }
2129
1938
  } catch (err) {
2130
1939
  box.style.display = 'block';
2131
1940
  box.className = 'test-result-box error';
1941
+ statusBadge.className = 'badge err';
2132
1942
  statusBadge.textContent = '✗ Network Error';
2133
1943
  latencyBadge.textContent = '';
2134
1944
  sampleBox.textContent = err.message;
2135
1945
  showToast('Network error testing upstream', true);
2136
1946
  } finally {
2137
- btn.innerText = 'Test connection';
1947
+ btn.innerText = 'Test Connection';
2138
1948
  btn.disabled = false;
2139
1949
  }
2140
1950
  }
2141
1951
 
2142
- let logRefreshInterval = null;
1952
+ async function fetchModelList() {
1953
+ const btn = document.getElementById('btn-fetch-models');
1954
+ const browser = document.getElementById('model-browser');
1955
+ const baseURL = document.getElementById('p-url').value.trim();
1956
+ const apiKeyVal = document.getElementById('p-key-val').value.trim();
1957
+ const apiKey = (apiKeyVal === MASKED_KEY) ? (state.config?.profiles?.[formLoadedKey]?.apiKey || '') : apiKeyVal;
2143
1958
 
2144
- function toggleLogAutoRefresh(enabled) {
2145
- if (logRefreshInterval) {
2146
- clearInterval(logRefreshInterval);
2147
- logRefreshInterval = null;
1959
+ if (!baseURL) {
1960
+ showToast('Please enter a Base URL first', true);
1961
+ return;
2148
1962
  }
2149
- if (enabled) {
2150
- logRefreshInterval = setInterval(() => {
2151
- const tab = document.getElementById('tab-inspector');
2152
- if (tab && tab.classList.contains('active')) {
2153
- loadInspectorLogs();
2154
- }
2155
- }, 2500);
1963
+
1964
+ btn.disabled = true;
1965
+ btn.innerText = 'Fetching...';
1966
+ try {
1967
+ const res = await api('/api/fetch-models', {
1968
+ method: 'POST',
1969
+ headers: { 'Content-Type': 'application/json' },
1970
+ body: JSON.stringify({ baseURL, apiKey })
1971
+ });
1972
+ const data = await res.json();
1973
+ if (data.ok && Array.isArray(data.models)) {
1974
+ loadedModels = data.models;
1975
+ populateModelOptions(loadedModels);
1976
+ renderModelBrowser(loadedModels);
1977
+ browser.style.display = 'block';
1978
+ showToast('Loaded ' + loadedModels.length + ' models');
1979
+ } else {
1980
+ showToast(data.error || 'No models returned from provider', true);
1981
+ }
1982
+ } catch (err) {
1983
+ showToast('Error fetching models: ' + err.message, true);
1984
+ } finally {
1985
+ btn.disabled = false;
1986
+ btn.innerText = 'Browse Models';
1987
+ }
1988
+ }
1989
+
1990
+ function populateModelOptions(models) {
1991
+ const dl = document.getElementById('model-options');
1992
+ if (!dl) return;
1993
+ dl.innerHTML = '';
1994
+ for (const m of models) {
1995
+ const opt = document.createElement('option');
1996
+ opt.value = m;
1997
+ dl.appendChild(opt);
1998
+ }
1999
+ }
2000
+
2001
+ function renderModelBrowser(models) {
2002
+ const container = document.getElementById('model-items');
2003
+ const label = document.getElementById('model-count-label');
2004
+ if (!container) return;
2005
+ container.innerHTML = '';
2006
+ if (label) label.textContent = models.length + ' models found';
2007
+
2008
+ for (const m of models) {
2009
+ const btn = document.createElement('button');
2010
+ btn.type = 'button';
2011
+ btn.className = 'btn btn-outline btn-sm';
2012
+ btn.style.cssText = 'font-size:0.75rem; padding:0.15rem 0.5rem;';
2013
+ btn.textContent = m;
2014
+ btn.onclick = () => selectModel(m);
2015
+ container.appendChild(btn);
2016
+ }
2017
+ }
2018
+
2019
+ function filterModels() {
2020
+ const q = (document.getElementById('model-filter')?.value || '').toLowerCase();
2021
+ const filtered = loadedModels.filter(m => m.toLowerCase().includes(q));
2022
+ renderModelBrowser(filtered);
2023
+ }
2024
+
2025
+ function selectModel(modelId) {
2026
+ const activeTab = document.querySelector('.tab-content.active');
2027
+ const focused = activeTab?.querySelector('input[type="text"]:focus');
2028
+ if (focused && focused.id.startsWith('p-')) {
2029
+ focused.value = modelId;
2030
+ } else {
2031
+ const sonnetInput = document.getElementById('p-sonnet') || document.getElementById('p-default');
2032
+ if (sonnetInput) sonnetInput.value = modelId;
2033
+ }
2034
+ showToast('Selected: ' + modelId);
2035
+ }
2036
+
2037
+ // Dynamic Model Catalog View
2038
+ async function renderCatalogView() {
2039
+ const claudeContainer = document.getElementById('catalog-claude-items');
2040
+ const codexContainer = document.getElementById('catalog-codex-items');
2041
+ if (!claudeContainer || !codexContainer) return;
2042
+
2043
+ try {
2044
+ const res = await api('/api/catalog');
2045
+ if (!res.ok) {
2046
+ showToast('Failed to load model catalog', true);
2047
+ return;
2048
+ }
2049
+ const catalog = await res.json();
2050
+ cachedCatalog = catalog;
2051
+
2052
+ const claudeModels = Array.isArray(catalog.claude?.models) ? catalog.claude.models : [];
2053
+ const codexModels = Array.isArray(catalog.codex?.models) ? catalog.codex.models : [];
2054
+
2055
+ const claudeTiers = new Set(claudeModels.map(m => m.tier));
2056
+ const codexRoles = new Set(codexModels.map(m => m.role));
2057
+
2058
+ const ctModels = document.getElementById('ct-models');
2059
+ if (ctModels) ctModels.textContent = claudeModels.length + codexModels.length;
2060
+
2061
+ document.getElementById('badge-claude-count').textContent = claudeModels.length + ' models · ' + claudeTiers.size + ' tiers';
2062
+ document.getElementById('badge-codex-count').textContent = codexModels.length + ' models · ' + codexRoles.size + ' roles';
2063
+
2064
+ claudeContainer.innerHTML = claudeModels.map(m => `
2065
+ <div class="row" style="padding:0.6rem 0.8rem; background:var(--card2); border-radius:8px;">
2066
+ <div class="who">
2067
+ <div style="display:flex; align-items:center; gap:0.45rem;">
2068
+ <span class="badge accent" style="text-transform:uppercase; font-size:0.7rem;">${escapeHtml(m.tier)}</span>
2069
+ <span class="l mono" style="font-size:0.85rem; font-weight:600;">${escapeHtml(m.id)}</span>
2070
+ </div>
2071
+ </div>
2072
+ <button type="button" class="btn btn-outline btn-sm" onclick="navigator.clipboard.writeText('${escapeHtml(m.id)}'); showToast('Copied ${escapeHtml(m.id)}');">Copy</button>
2073
+ </div>
2074
+ `).join('') || '<div class="hint">No Claude tiers discovered</div>';
2075
+
2076
+ codexContainer.innerHTML = codexModels.map(m => `
2077
+ <div class="row" style="padding:0.6rem 0.8rem; background:var(--card2); border-radius:8px;">
2078
+ <div class="who">
2079
+ <div style="display:flex; align-items:center; gap:0.45rem;">
2080
+ <span class="badge ok" style="text-transform:capitalize; font-size:0.7rem;">${escapeHtml(m.role)}</span>
2081
+ <span class="l mono" style="font-size:0.85rem; font-weight:600;">${escapeHtml(m.id)}</span>
2082
+ </div>
2083
+ </div>
2084
+ <button type="button" class="btn btn-outline btn-sm" onclick="navigator.clipboard.writeText('${escapeHtml(m.id)}'); showToast('Copied ${escapeHtml(m.id)}');">Copy</button>
2085
+ </div>
2086
+ `).join('') || '<div class="hint">No Codex roles discovered</div>';
2087
+ } catch (err) {
2088
+ showToast('Error loading model catalog', true);
2089
+ }
2090
+ }
2091
+
2092
+ function filterCatalogView(q) {
2093
+ const term = (q || '').toLowerCase();
2094
+ document.querySelectorAll('#catalog-claude-items .row, #catalog-codex-items .row').forEach(row => {
2095
+ const text = row.textContent.toLowerCase();
2096
+ row.style.display = text.includes(term) ? 'flex' : 'none';
2097
+ });
2098
+ }
2099
+
2100
+ async function refreshCatalogUpstream() {
2101
+ const btn = document.getElementById('btn-sync-catalog');
2102
+ if (btn) { btn.disabled = true; btn.textContent = 'Syncing...'; }
2103
+ showToast('Refreshing model catalog from upstream...');
2104
+ try {
2105
+ const res = await api('/api/catalog/refresh', { method: 'POST' });
2106
+ const data = await res.json();
2107
+ if (data.ok) {
2108
+ showToast('Model catalog synchronized');
2109
+ await renderCatalogView();
2110
+ } else {
2111
+ showToast(data.error || 'Failed to refresh catalog', true);
2112
+ }
2113
+ } catch (err) {
2114
+ showToast('Network error syncing catalog', true);
2115
+ } finally {
2116
+ if (btn) { btn.disabled = false; btn.textContent = '⚡ Sync Models with Upstream'; }
2156
2117
  }
2157
2118
  }
2158
2119
 
2120
+ // Request Inspector Logs View
2159
2121
  async function loadInspectorLogs() {
2160
2122
  try {
2161
2123
  const res = await api('/api/logs');
@@ -2163,49 +2125,52 @@
2163
2125
  const container = document.getElementById('inspector-logs-container');
2164
2126
  if (!container) return;
2165
2127
 
2128
+ lastLogsCount = (data.logs || []).length;
2129
+ const ctLogs = document.getElementById('ct-logs');
2130
+ if (ctLogs) ctLogs.textContent = lastLogsCount;
2131
+
2166
2132
  if (!data.logs || data.logs.length === 0) {
2167
- container.innerHTML = '<div style="color: var(--text-dim); font-size: 12px; text-align: center; padding: 24px; background: var(--surface-well); border-radius: var(--radius-sm); border: 1px solid var(--border);">No requests recorded yet. Make a request via Claude Code, Codex, or curl.</div>';
2133
+ container.innerHTML = '<div class="card pad hint" style="text-align:center; padding:2rem 1rem;">No requests recorded yet in the ring buffer. Make a request via Claude Code or Codex.</div>';
2168
2134
  return;
2169
2135
  }
2170
2136
 
2171
2137
  container.innerHTML = '';
2172
2138
  for (const l of data.logs) {
2173
- const row = document.createElement('div');
2174
- row.className = 'log-item';
2139
+ const item = document.createElement('div');
2140
+ item.className = 'log-item';
2175
2141
  const isErr = l.status >= 400;
2176
- const statusClass = isErr ? 'badge-convert' : 'badge-direct';
2177
2142
 
2178
- let details = '';
2143
+ let detailsHtml = '';
2179
2144
  if (l.requestPreview) {
2180
- details += `<div><strong style="color: var(--accent);">Prompt:</strong> ${escapeHtml(l.requestPreview)}</div>`;
2145
+ detailsHtml += '<div><span class="hint" style="font-weight:600; color:var(--accent);">Prompt:</span><pre>' + escapeHtml(l.requestPreview) + '</pre></div>';
2181
2146
  }
2182
2147
  if (l.thinkingChars > 0) {
2183
- details += `<div><strong style="color: #fbbf24;">Thinking:</strong> ${l.thinkingChars} chars extracted</div>`;
2148
+ detailsHtml += '<div><span class="hint" style="font-weight:600; color:var(--warn);">Thinking:</span><pre>' + l.thinkingChars + ' characters extracted</pre></div>';
2184
2149
  }
2185
2150
  if (l.responsePreview) {
2186
- details += `<div><strong style="color: #34d399;">Output:</strong> ${escapeHtml(l.responsePreview)}</div>`;
2151
+ detailsHtml += '<div><span class="hint" style="font-weight:600; color:var(--ok);">Response Output:</span><pre>' + escapeHtml(l.responsePreview) + '</pre></div>';
2187
2152
  }
2188
2153
  if (l.error) {
2189
- details += `<div><strong style="color: #f87171;">Error:</strong> ${escapeHtml(l.error)}</div>`;
2154
+ detailsHtml += '<div><span class="hint" style="font-weight:600; color:var(--err);">Error:</span><pre>' + escapeHtml(l.error) + '</pre></div>';
2190
2155
  }
2191
2156
 
2192
- row.innerHTML = `
2193
- <div class="log-header">
2194
- <div class="log-badges">
2195
- <span class="badge ${statusClass}">${l.status}</span>
2196
- <span style="font-family: var(--font-mono); font-size: 11px; color: var(--text);">${escapeHtml(l.clientFormat)} ➔ ${escapeHtml(l.outFormat)}</span>
2197
- <span style="font-size: 11px; color: var(--text-muted); font-family: var(--font-mono);">[${escapeHtml(l.model)}]</span>
2198
- <span style="font-size: 10px; color: var(--text-dim);">${escapeHtml(l.timestamp)}</span>
2157
+ item.innerHTML = `
2158
+ <div style="display:flex; justify-content:space-between; align-items:center; flex-wrap:wrap; gap:0.5rem;">
2159
+ <div style="display:flex; align-items:center; gap:0.5rem; flex-wrap:wrap;">
2160
+ <span class="mono faint" style="font-size:0.75rem;">${escapeHtml(l.timestamp)}</span>
2161
+ <span class="badge ${isErr ? 'err' : 'ok'}">${escapeHtml(l.status)} ${escapeHtml(l.method || 'POST')}</span>
2162
+ <strong class="mono" style="font-size:0.85rem; color:var(--fg);">${escapeHtml(l.path || '/v1/messages')}</strong>
2163
+ <span class="badge accent">${escapeHtml(l.client || 'Client')}</span>
2199
2164
  </div>
2200
- <div style="display: flex; gap: 8px; font-size: 11px; color: var(--text-dim); font-family: var(--font-mono);">
2201
- <span>${l.duration} ms</span>
2202
- <span>in ${l.tokens?.prompt || 0} tokens</span>
2203
- <span>out ${l.tokens?.completion || 0} tokens</span>
2165
+ <div style="display:flex; align-items:center; gap:0.5rem;">
2166
+ ${l.latency ? '<span class="hint mono">' + escapeHtml(l.latency) + 'ms</span>' : ''}
2167
+ ${l.profile ? '<span class="badge">→ ' + escapeHtml(l.profile) + '</span>' : ''}
2204
2168
  </div>
2205
2169
  </div>
2206
- ${details ? `<div class="log-body">${details}</div>` : ''}
2170
+ ${detailsHtml ? '<details><summary>View Payload Preview</summary>' + detailsHtml + '</details>' : ''}
2207
2171
  `;
2208
- container.appendChild(row);
2172
+
2173
+ container.appendChild(item);
2209
2174
  }
2210
2175
  } catch (err) {
2211
2176
  console.error('Failed to load logs', err);
@@ -2215,28 +2180,47 @@
2215
2180
  async function clearInspectorLogs() {
2216
2181
  try {
2217
2182
  const res = await api('/api/logs/clear', { method: 'POST' });
2218
- loadInspectorLogs();
2219
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
2220
- showToast('Logs cleared');
2183
+ const data = await res.json();
2184
+ if (data.ok) {
2185
+ showToast('Request inspector logs cleared');
2186
+ await loadInspectorLogs();
2187
+ }
2221
2188
  } catch (err) {
2222
- showToast('Failed to clear logs', true);
2189
+ showToast('Error clearing logs', true);
2223
2190
  }
2224
2191
  }
2225
2192
 
2226
- // Track form edits so status refreshes don't overwrite them.
2227
- const markDirty = (ev) => {
2228
- if (ev.target.id !== 'chk-auto-refresh-logs' && ev.target.id !== 'model-filter') formDirty = true;
2229
- };
2230
- document.getElementById('profile-form').addEventListener('input', markDirty);
2231
- document.getElementById('profile-form').addEventListener('change', markDirty);
2232
-
2233
- toggleLogAutoRefresh(true);
2234
- try {
2235
- applyTheme(localStorage.getItem('lsw-theme') || document.documentElement.dataset.theme || 'dark');
2236
- } catch (e) {
2237
- applyTheme(document.documentElement.dataset.theme || 'dark');
2193
+ function toggleLogAutoRefresh() {
2194
+ const chk = document.getElementById('chk-auto-refresh-logs');
2195
+ if (chk && chk.checked) {
2196
+ clearInterval(logRefreshTimer);
2197
+ logRefreshTimer = setInterval(() => {
2198
+ if (window.location.hash.startsWith('#/logs')) {
2199
+ loadInspectorLogs();
2200
+ }
2201
+ }, 3000);
2202
+ } else {
2203
+ clearInterval(logRefreshTimer);
2204
+ logRefreshTimer = null;
2205
+ }
2238
2206
  }
2239
- loadStatus();
2207
+
2208
+ // Initial Bootstrap
2209
+ document.addEventListener('DOMContentLoaded', () => {
2210
+ // Setup initial theme buttons state
2211
+ const currentTheme = document.documentElement.dataset.theme || 'dark';
2212
+ document.getElementById('btn-theme-dark')?.setAttribute('aria-pressed', currentTheme === 'dark');
2213
+ document.getElementById('btn-theme-light')?.setAttribute('aria-pressed', currentTheme === 'light');
2214
+
2215
+ // Setup log auto-refresh
2216
+ toggleLogAutoRefresh();
2217
+
2218
+ // Trigger initial route
2219
+ handleRoute();
2220
+
2221
+ // Load initial state from edge gateway
2222
+ loadStatus();
2223
+ });
2240
2224
  </script>
2241
2225
  </body>
2242
- </html>
2226
+ </html>