llm-switcher 1.1.0

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 (51) hide show
  1. package/.gitattributes +16 -0
  2. package/LICENSE +21 -0
  3. package/README.md +587 -0
  4. package/README.vi.md +585 -0
  5. package/blindfold/blindfold.mjs +633 -0
  6. package/blindfold/make-certs.sh +88 -0
  7. package/blindfold/wsframe.mjs +176 -0
  8. package/codex-catalog-template.json +1 -0
  9. package/config.example.json +84 -0
  10. package/contract-exclusions.json +41 -0
  11. package/contract.mjs +561 -0
  12. package/docs/LLM-RESPONSE-MATRIX.md +165 -0
  13. package/docs/TOKEN-OPTIMIZER-INTEROP.md +110 -0
  14. package/docs/codex-blindfold.md +214 -0
  15. package/docs/cross-platform.md +136 -0
  16. package/docs/diagrams/blindfold-request-routing.html +14972 -0
  17. package/docs/diagrams/blindfold-request-routing.sequence.json +175 -0
  18. package/docs/diagrams/blindfold-switch-lifecycle.html +14958 -0
  19. package/docs/diagrams/blindfold-switch-lifecycle.lifecycle.json +159 -0
  20. package/docs/diagrams/codex-model-name-resolution.html +15005 -0
  21. package/docs/diagrams/codex-model-name-resolution.workflow.json +71 -0
  22. package/docs/response-matrix.json +1131 -0
  23. package/formats.mjs +2308 -0
  24. package/mcp.mjs +340 -0
  25. package/package.json +36 -0
  26. package/proxy.mjs +1743 -0
  27. package/service.mjs +132 -0
  28. package/shim.mjs +292 -0
  29. package/skills/llm-switcher/SKILL.md +88 -0
  30. package/state.mjs +978 -0
  31. package/switch +5 -0
  32. package/switch.cmd +2 -0
  33. package/switch.mjs +930 -0
  34. package/tests/blindfold.test.mjs +307 -0
  35. package/tests/blindfold.wire.test.mjs +170 -0
  36. package/tests/contract/run.test.mjs +214 -0
  37. package/tests/contract-check.test.mjs +458 -0
  38. package/tests/contract-lab.test.mjs +755 -0
  39. package/tests/datadir.test.mjs +37 -0
  40. package/tests/formats.test.mjs +794 -0
  41. package/tests/gateway.e2e.test.mjs +999 -0
  42. package/tests/helpers.mjs +24 -0
  43. package/tests/lifecycle.test.mjs +416 -0
  44. package/tests/live-optimizer-interop.mjs +205 -0
  45. package/tests/mcp.test.mjs +91 -0
  46. package/tests/service.test.mjs +69 -0
  47. package/tests/shim.test.mjs +228 -0
  48. package/tests/state.test.mjs +675 -0
  49. package/tests/switch.test.mjs +156 -0
  50. package/tests/wsframe.test.mjs +154 -0
  51. package/ui.html +2234 -0
package/ui.html ADDED
@@ -0,0 +1,2234 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
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:,">
9
+ <script>
10
+ try {
11
+ const savedTheme = localStorage.getItem('lsw-theme');
12
+ if (savedTheme === 'light' || (!savedTheme && window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches)) {
13
+ document.documentElement.dataset.theme = 'light';
14
+ }
15
+ } catch (e) {}
16
+ </script>
17
+ <style>
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);
44
+ --radius-sm: 6px;
45
+ --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);
77
+ color-scheme: light;
78
+ }
79
+
80
+ * { box-sizing: border-box; margin: 0; padding: 0; font-family: var(--font-sans); }
81
+ body {
82
+ background: var(--bg);
83
+ color: var(--text);
84
+ padding: 24px 16px 48px;
85
+ min-height: 100dvh;
86
+ line-height: 1.5;
87
+ -webkit-font-smoothing: antialiased;
88
+ text-rendering: optimizeLegibility;
89
+ }
90
+ .container { max-width: 1180px; margin: 0 auto; }
91
+
92
+ /* Header */
93
+ header {
94
+ 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;
127
+ }
128
+ .header-actions { display: flex; gap: 8px; align-items: center; }
129
+
130
+ /* Buttons */
131
+ .btn {
132
+ display: inline-flex;
133
+ 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;
144
+ text-decoration: none;
145
+ }
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);
167
+ }
168
+
169
+ .btn-outline {
170
+ background: var(--surface);
171
+ color: var(--text);
172
+ border-color: var(--border-strong);
173
+ }
174
+ .btn-outline:hover:not(:disabled) {
175
+ background: var(--surface-elevated);
176
+ border-color: var(--border-strong);
177
+ }
178
+
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);
186
+ }
187
+
188
+ .btn-success {
189
+ background: var(--success-muted);
190
+ color: var(--success);
191
+ border-color: rgba(16, 185, 129, 0.25);
192
+ }
193
+ .btn-success:hover:not(:disabled) {
194
+ background: rgba(16, 185, 129, 0.2);
195
+ }
196
+
197
+ /* Telemetry Status Banner */
198
+ .status-banner {
199
+ 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;
213
+ display: flex;
214
+ 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;
235
+ }
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;
242
+ }
243
+
244
+ /* Section Headers */
245
+ .section-header {
246
+ display: flex;
247
+ justify-content: space-between;
248
+ 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;
264
+ }
265
+
266
+ /* Active Multi-CLI Slots (Rackmount Grid) */
267
+ .cli-slots-grid {
268
+ 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;
278
+ display: flex;
279
+ flex-direction: column;
280
+ gap: 10px;
281
+ transition: border-color 0.15s, background 0.15s;
282
+ }
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 {
293
+ display: flex;
294
+ 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;
311
+ }
312
+ .cli-slot-name {
313
+ font-size: 12px;
314
+ 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;
343
+ overflow: hidden;
344
+ clip: rect(0, 0, 0, 0);
345
+ white-space: nowrap;
346
+ border: 0;
347
+ }
348
+
349
+ /* Profile Cards Grid */
350
+ .profiles-grid {
351
+ 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);
374
+ }
375
+
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;
385
+ align-items: center;
386
+ gap: 6px;
387
+ min-width: 0;
388
+ flex-wrap: wrap;
389
+ }
390
+ .card-name {
391
+ font-size: 13px;
392
+ font-weight: 600;
393
+ color: var(--text);
394
+ }
395
+
396
+ /* Badges */
397
+ .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;
404
+ white-space: nowrap;
405
+ border: 1px solid transparent;
406
+ }
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;
466
+ display: flex;
467
+ gap: 10px;
468
+ align-items: flex-start;
469
+ }
470
+
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 {
493
+ display: flex;
494
+ justify-content: space-between;
495
+ align-items: center;
496
+ font-size: 11px;
497
+ margin-top: 6px;
498
+ }
499
+
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;
536
+ }
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;
545
+ }
546
+
547
+ /* Segmented Tab Bar */
548
+ .tab-bar {
549
+ 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;
564
+ font-weight: 500;
565
+ border-radius: var(--radius-sm);
566
+ cursor: pointer;
567
+ transition: background 0.15s, border-color 0.15s, color 0.15s;
568
+ }
569
+ .tab-btn:hover { color: var(--text); }
570
+ .tab-btn.active {
571
+ color: var(--text);
572
+ background: var(--surface-elevated);
573
+ 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
+ }
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;
611
+ width: 100%;
612
+ max-width: 100%;
613
+ min-width: 0;
614
+ }
615
+ input:focus, select:focus {
616
+ border-color: var(--border-active);
617
+ }
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 {
640
+ 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;
691
+ }
692
+ .model-browser-header {
693
+ display: flex;
694
+ justify-content: space-between;
695
+ align-items: center;
696
+ margin-bottom: 10px;
697
+ gap: 10px;
698
+ flex-wrap: wrap;
699
+ }
700
+ .model-list-scroll {
701
+ max-height: 220px;
702
+ overflow-y: auto;
703
+ display: flex;
704
+ flex-direction: column;
705
+ gap: 4px;
706
+ padding-right: 4px;
707
+ }
708
+ .model-row {
709
+ display: flex;
710
+ justify-content: space-between;
711
+ 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;
718
+ }
719
+ .model-row:hover {
720
+ border-color: var(--border-strong);
721
+ background: var(--surface-elevated);
722
+ }
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
+
726
+ /* Inspector Logs */
727
+ .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;
733
+ display: flex;
734
+ flex-direction: column;
735
+ gap: 6px;
736
+ }
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;
757
+ }
758
+
759
+ /* Toast Notification */
760
+ .toast {
761
+ 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);
772
+ display: none;
773
+ z-index: 1000;
774
+ }
775
+
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
+ }
783
+ }
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); }
794
+ }
795
+ </style>
796
+ </head>
797
+ <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>
811
+ </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>
823
+ </div>
824
+ </div>
825
+ <div id="status-action">
826
+ <button id="btn-toggle" class="btn btn-primary" onclick="toggleProxy()" type="button">Toggle all</button>
827
+ </div>
828
+ </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>
835
+ </div>
836
+ </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>
846
+ </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
+
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>
864
+ </div>
865
+ </div>
866
+ <span id="badge-slot-responses" class="badge">OFF</span>
867
+ </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
+ </div>
873
+
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>
882
+ </div>
883
+ </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
+
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>
900
+ </div>
901
+ </div>
902
+ <span id="badge-slot-vertex" class="badge">OFF</span>
903
+ </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
+ </div>
909
+ </div>
910
+
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>
937
+
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>Codex</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>
945
+ </div>
946
+
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">
954
+ </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">
962
+ </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-...">
966
+ </div>
967
+ </div>
968
+ </div>
969
+
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>
1012
+ </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
+ </div>
1017
+ </div>
1018
+ </div>
1019
+
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>
1035
+
1036
+ <div class="form-grid" id="slot-fields">
1037
+ <!-- Dynamically populated via renderModelSlots -->
1038
+ </div>
1039
+
1040
+ <!-- Model Explorer Box -->
1041
+ <div class="form-group full" id="model-browser" style="display: none; margin-top: 14px;">
1042
+ <div class="model-browser">
1043
+ <div class="model-browser-header">
1044
+ <span id="model-count-label" style="font-size: 11px; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.04em;">Available Upstream Models</span>
1045
+ <label class="sr-only" for="model-filter">Filter upstream models</label>
1046
+ <input type="search" id="model-filter" placeholder="Filter models" style="padding: 5px 9px; font-size: 11px; width: 200px;" oninput="filterModels()">
1047
+ </div>
1048
+ <div id="model-items" class="model-list-scroll"></div>
1049
+ </div>
1050
+ </div>
1051
+ </div>
1052
+
1053
+ <!-- Tab 4: Live Inspector -->
1054
+ <!-- Tab 4: Codex — only the names this CLI is allowed to observe -->
1055
+ <div id="tab-codex" class="tab-pane" role="tabpanel" aria-labelledby="tab-button-codex" hidden>
1056
+ <div class="warning-callout">
1057
+ <span class="badge badge-convert">NOTE</span>
1058
+ <div>
1059
+ <strong>Codex sees only these names.</strong> The slot aliases <code>main</code>, <code>review</code> and
1060
+ <code>subagent</code> stay inside the gateway. Leave a field empty to let Codex keep its own default for that role.
1061
+ </div>
1062
+ </div>
1063
+
1064
+ <div class="form-grid" style="margin-top: 12px;">
1065
+ <div class="form-group full">
1066
+ <label for="p-public-main">Session model name</label>
1067
+ <input type="text" id="p-public-main" list="model-options" placeholder="e.g. gpt-5.6-sol">
1068
+ <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>
1069
+ </div>
1070
+ <div class="form-group">
1071
+ <label for="p-public-review">Review model name</label>
1072
+ <input type="text" id="p-public-review" list="model-options" placeholder="e.g. gpt-5.6-terra">
1073
+ </div>
1074
+ <div class="form-group">
1075
+ <label for="p-public-subagent">Subagent model name</label>
1076
+ <input type="text" id="p-public-subagent" list="model-options" placeholder="e.g. gpt-5.6-luna">
1077
+ </div>
1078
+ </div>
1079
+
1080
+ <div class="form-grid" style="border-top: 1px solid var(--border); padding-top: 12px; margin-top: 12px;">
1081
+ <div class="form-group full">
1082
+ <label style="display: flex; align-items: center; gap: 6px; cursor: pointer; text-transform: none;">
1083
+ <input type="checkbox" id="p-blindfold" style="width: 13px; height: 13px;" onchange="onBlindfoldToggle(this.checked)">
1084
+ Blindfold mode — hide the gateway address from the CLI
1085
+ </label>
1086
+ <span style="font-size: 11px; color: var(--text-dim); margin-top: 2px;">
1087
+ Off: the CLI is given a base URL override, and it prints a warning on its own <code>/model</code> screen.
1088
+ On: the CLI keeps its official endpoint and reaches the gateway through a local interceptor.
1089
+ Build the certificates first with <code>bash blindfold/make-certs.sh &lt;host&gt;</code>.
1090
+ </span>
1091
+ </div>
1092
+ </div>
1093
+
1094
+ <div class="form-grid" id="blindfold-fields" hidden>
1095
+ <div class="form-group">
1096
+ <label for="p-blindfold-host">Intercepted host</label>
1097
+ <input type="text" id="p-blindfold-host" placeholder="chatgpt.com">
1098
+ <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>
1099
+ </div>
1100
+ <div class="form-group">
1101
+ <label for="p-blindfold-port">Interceptor port</label>
1102
+ <input type="number" id="p-blindfold-port" min="1" max="65535" placeholder="3457">
1103
+ </div>
1104
+ <div class="form-group full">
1105
+ <label for="p-blindfold-prefix">API path prefix</label>
1106
+ <input type="text" id="p-blindfold-prefix" placeholder="/backend-api/codex">
1107
+ <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>
1108
+ </div>
1109
+ </div>
1110
+ </div>
1111
+
1112
+ <div id="tab-inspector" class="tab-pane" role="tabpanel" aria-labelledby="tab-button-inspector" hidden>
1113
+ <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; flex-wrap: wrap; gap: 8px;">
1114
+ <div>
1115
+ <span style="font-size: 12px; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.04em;">Real-time Request Inspector</span>
1116
+ <span style="font-size: 11px; color: var(--text-dim); margin-left: 8px;">Inspect prompt, tokens, latency & reasoning traces</span>
1117
+ </div>
1118
+ <div style="display: flex; gap: 8px; align-items: center;">
1119
+ <label style="font-size: 11px; color: var(--text-muted); display: flex; align-items: center; gap: 4px; cursor: pointer; text-transform: none;">
1120
+ <input type="checkbox" id="chk-auto-refresh-logs" checked onchange="toggleLogAutoRefresh(this.checked)"> Auto-refresh
1121
+ </label>
1122
+ <button type="button" class="btn btn-outline btn-sm" onclick="loadInspectorLogs()">Refresh</button>
1123
+ <button type="button" class="btn btn-danger btn-sm" onclick="clearInspectorLogs()">Clear</button>
1124
+ </div>
1125
+ </div>
1126
+
1127
+ <div id="inspector-logs-container" style="display: flex; flex-direction: column; gap: 8px; max-height: 440px; overflow-y: auto;">
1128
+ <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);">
1129
+ No requests recorded yet. Make a request via Claude Code, Codex, or curl.
1130
+ </div>
1131
+ </div>
1132
+ </div>
1133
+
1134
+ <datalist id="model-options"></datalist>
1135
+
1136
+ <!-- Test Connection Result Box -->
1137
+ <div id="test-result" class="test-result-box">
1138
+ <div class="test-meta">
1139
+ <span id="test-status-badge"></span>
1140
+ <span id="test-latency"></span>
1141
+ </div>
1142
+ <div id="test-sample" class="test-sample"></div>
1143
+ </div>
1144
+
1145
+ <div class="form-actions">
1146
+ <button type="button" id="btn-test-conn" class="btn btn-outline" onclick="testConnection()">Test connection</button>
1147
+ <div style="display: flex; gap: 8px;">
1148
+ <button type="button" id="btn-delete" class="btn btn-danger" onclick="deleteProfile()" style="display: none;">Delete</button>
1149
+ <button type="submit" class="btn btn-primary">Save Provider</button>
1150
+ </div>
1151
+ </div>
1152
+ </form>
1153
+ </div>
1154
+ </main>
1155
+
1156
+ <div id="toast" class="toast" role="status" aria-live="polite" aria-atomic="true"></div>
1157
+
1158
+ <script>
1159
+ // /api/* needs the per-install admin token. `switch ui` passes it in the URL fragment, which
1160
+ // the browser never sends to the server; keep it here and clear the address bar. sessionStorage, not
1161
+ // localStorage: while the gateway is off, another account can serve a page on this origin, and that
1162
+ // page must not find the token.
1163
+ const ADMIN_TOKEN_KEY = 'llmSwitcherAdminToken';
1164
+ (() => {
1165
+ localStorage.removeItem(ADMIN_TOKEN_KEY);
1166
+ const m = location.hash.match(/(?:^#|&)token=([0-9a-f]+)/);
1167
+ if (m) {
1168
+ sessionStorage.setItem(ADMIN_TOKEN_KEY, m[1]);
1169
+ history.replaceState(null, '', location.pathname + location.search);
1170
+ }
1171
+ })();
1172
+
1173
+ // A config change carries the revision this page loaded. The gateway answers 409 when another tab
1174
+ // or the CLI saved in the meantime; the page then reloads instead of overwriting that change.
1175
+ const CHANGE_PATHS = new Set(['/api/switch', '/api/toggle', '/api/save-profile', '/api/delete-profile']);
1176
+
1177
+ async function api(path, opts = {}) {
1178
+ const headers = { ...(opts.headers || {}), 'x-llm-switcher-token': sessionStorage.getItem(ADMIN_TOKEN_KEY) || '' };
1179
+ let body = opts.body;
1180
+ if (CHANGE_PATHS.has(path) && typeof body === 'string' && state.revision) {
1181
+ body = JSON.stringify({ ...JSON.parse(body), revision: state.revision });
1182
+ }
1183
+ const res = await fetch(path, { ...opts, headers, body });
1184
+ if (res.status === 401) showToast('Not authorized. Open the dashboard with `switch ui`.', true);
1185
+ if (res.status === 409) setTimeout(loadStatus, 0);
1186
+ return res;
1187
+ }
1188
+
1189
+ let state = {
1190
+ config: null,
1191
+ activeProfile: '',
1192
+ activeProfiles: {},
1193
+ claudeBaseURL: ''
1194
+ };
1195
+ let currentlyEditingKey = '';
1196
+ let creatingNew = false;
1197
+ let formLoadedKey = null;
1198
+ let formDirty = false;
1199
+ let loadedModels = [];
1200
+ let currentProfileBuffer = null;
1201
+ let renderedSlotFormat = 'auto';
1202
+ const MASKED_KEY = '__LLM_SWITCHER_KEEP_KEY__';
1203
+ // Same list as TARGETS in state.mjs. The page is served as a static file and cannot import it.
1204
+ const TARGETS = ['anthropic', 'responses', 'openai-chat', 'vertex'];
1205
+ // Order matters: publicModels is read by position when codexRoles has no entry.
1206
+ const CODEX_ROLE_SLOTS = ['main', 'review', 'subagent'];
1207
+
1208
+ const LEGACY_SLOT_KEYS = {
1209
+ main: ['opus'],
1210
+ review: ['sonnet'],
1211
+ subagent: ['fast', 'fallback', 'haiku', 'fable'],
1212
+ default: ['sonnet', 'opus', 'haiku', 'fable']
1213
+ };
1214
+
1215
+ const SLOT_CONFIG = {
1216
+ anthropic: {
1217
+ heading: 'Claude Code tier mapping',
1218
+ slots: [
1219
+ { id: 'opus', label: 'Opus Model', placeholder: 'e.g. ag/claude-opus-4-6-thinking', allow1M: true },
1220
+ { id: 'sonnet', label: 'Sonnet Model', placeholder: 'e.g. ag/claude-sonnet-4-6', allow1M: true },
1221
+ { id: 'haiku', label: 'Haiku Model', placeholder: 'e.g. ag/gemini-3.7-flash-high', allow1M: true },
1222
+ { id: 'fable', label: 'Fable Model', placeholder: 'e.g. ag/gemini-3.8-flash-high', allow1M: true }
1223
+ ],
1224
+ 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>.',
1225
+ icon: 'NOTE'
1226
+ },
1227
+ responses: {
1228
+ heading: 'Codex model roles',
1229
+ slots: [
1230
+ { id: 'main', label: 'Main model', placeholder: 'e.g. ag/gemini-3.8-flash or gpt-4o', allow1M: true },
1231
+ { id: 'review', label: 'Review model', placeholder: 'e.g. ag/gemini-3.7-flash-medium', allow1M: false },
1232
+ { id: 'subagent', label: 'Subagent model', placeholder: 'e.g. ag/gemini-3.6-flash-low', allow1M: false }
1233
+ ],
1234
+ 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.',
1235
+ icon: 'NOTE'
1236
+ },
1237
+ 'openai-chat': {
1238
+ heading: 'Default Model (OpenAI Chat)',
1239
+ slots: [
1240
+ { id: 'default', label: 'Default Chat Model', placeholder: 'e.g. gpt-4o or ag/gemini-3.8-flash', allow1M: true }
1241
+ ],
1242
+ 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.',
1243
+ icon: 'NOTE'
1244
+ },
1245
+ vertex: {
1246
+ heading: 'Default Model (Vertex / Gemini)',
1247
+ slots: [
1248
+ { id: 'default', label: 'Default Model (Vertex/Gemini)', placeholder: 'e.g. gemini-2.5-pro or gemini-2.5-flash', allow1M: false }
1249
+ ],
1250
+ 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.',
1251
+ icon: 'INFO'
1252
+ }
1253
+ };
1254
+ SLOT_CONFIG.auto = SLOT_CONFIG.anthropic;
1255
+
1256
+ // Helper: Anti-XSS Sanitizer
1257
+ function escapeHtml(str) {
1258
+ if (str === null || str === undefined) return '';
1259
+ return String(str)
1260
+ .replace(/&/g, '&amp;')
1261
+ .replace(/</g, '&lt;')
1262
+ .replace(/>/g, '&gt;')
1263
+ .replace(/"/g, '&quot;')
1264
+ .replace(/'/g, '&#039;');
1265
+ }
1266
+
1267
+ function applyTheme(theme) {
1268
+ document.documentElement.dataset.theme = theme;
1269
+ const btn = document.getElementById('btn-theme');
1270
+ if (btn) {
1271
+ const nextTheme = theme === 'light' ? 'dark' : 'light';
1272
+ btn.textContent = `${nextTheme[0].toUpperCase()}${nextTheme.slice(1)} theme`;
1273
+ btn.setAttribute('aria-label', `Use ${nextTheme} theme`);
1274
+ }
1275
+ try { localStorage.setItem('lsw-theme', theme); } catch (e) {}
1276
+ }
1277
+
1278
+ function toggleTheme() {
1279
+ const current = document.documentElement.dataset.theme || 'dark';
1280
+ applyTheme(current === 'light' ? 'dark' : 'light');
1281
+ }
1282
+
1283
+ function showToast(msg, isError = false) {
1284
+ const t = document.getElementById('toast');
1285
+ t.innerText = msg;
1286
+ t.style.borderColor = isError ? 'rgba(239, 68, 68, 0.4)' : 'var(--border-strong)';
1287
+ t.style.background = isError ? 'var(--danger-muted)' : 'var(--surface-elevated)';
1288
+ t.style.color = isError ? 'var(--danger)' : 'var(--text)';
1289
+ t.style.display = 'block';
1290
+ setTimeout(() => t.style.display = 'none', 3500);
1291
+ }
1292
+
1293
+ function switchTab(tabId) {
1294
+ document.querySelectorAll('.tab-btn').forEach(btn => {
1295
+ const selected = btn.getAttribute('aria-controls') === tabId;
1296
+ btn.classList.toggle('active', selected);
1297
+ btn.setAttribute('aria-selected', String(selected));
1298
+ btn.tabIndex = selected ? 0 : -1;
1299
+ });
1300
+ document.querySelectorAll('.tab-pane').forEach(pane => {
1301
+ const selected = pane.id === tabId;
1302
+ pane.classList.toggle('active', selected);
1303
+ pane.hidden = !selected;
1304
+ });
1305
+ }
1306
+
1307
+ function getActiveSlotConfig(format) {
1308
+ return SLOT_CONFIG[format] || SLOT_CONFIG.auto;
1309
+ }
1310
+
1311
+ function valueForSlot(values, slot) {
1312
+ const source = values || {};
1313
+ if (Object.hasOwn(source, slot)) return source[slot] || '';
1314
+ for (const legacyKey of LEGACY_SLOT_KEYS[slot] || []) {
1315
+ if (source[legacyKey]) return source[legacyKey];
1316
+ }
1317
+ return '';
1318
+ }
1319
+
1320
+ function flagForSlot(values, slot) {
1321
+ const source = values || {};
1322
+ if (Object.hasOwn(source, slot)) return Boolean(source[slot]);
1323
+ for (const legacyKey of LEGACY_SLOT_KEYS[slot] || []) {
1324
+ if (Object.hasOwn(source, legacyKey)) return Boolean(source[legacyKey]);
1325
+ }
1326
+ return false;
1327
+ }
1328
+
1329
+ function renderModelSlots(format) {
1330
+ const cfg = getActiveSlotConfig(format);
1331
+ const heading = document.getElementById('model-slot-heading');
1332
+ const warnText = document.getElementById('warn-1m-text');
1333
+ const warnIcon = document.getElementById('warn-1m-icon');
1334
+ const container = document.getElementById('slot-fields');
1335
+
1336
+ if (heading) heading.textContent = cfg.heading;
1337
+ if (warnText) warnText.innerHTML = cfg.warning;
1338
+ if (warnIcon) warnIcon.textContent = cfg.icon;
1339
+ if (!container) return;
1340
+
1341
+ const p = currentProfileBuffer || {};
1342
+ const defs = p.defaultModels || {};
1343
+ const m1m = p.model1M || {};
1344
+
1345
+ container.innerHTML = cfg.slots.map(s => {
1346
+ const val = valueForSlot(defs, s.id);
1347
+ const checked = flagForSlot(m1m, s.id);
1348
+
1349
+ return `
1350
+ <div class="form-group ${cfg.slots.length === 1 ? 'full' : ''}">
1351
+ <div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 4px;">
1352
+ <label for="p-${s.id}">${escapeHtml(s.label)}</label>
1353
+ ${s.allow1M ? `
1354
+ <label style="display: flex; align-items: center; gap: 4px; font-size: 11px; cursor: pointer; color: var(--accent); text-transform: none;">
1355
+ <input type="checkbox" id="p-${s.id}-1m" style="width: 13px; height: 13px;" onchange="update1mWarnings()" ${checked ? 'checked' : ''}> 1M context
1356
+ <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>
1357
+ </label>
1358
+ ` : ''}
1359
+ </div>
1360
+ <input type="text" id="p-${s.id}" list="model-options" placeholder="${escapeHtml(s.placeholder)}" value="${escapeHtml(val)}">
1361
+ </div>
1362
+ `;
1363
+ }).join('');
1364
+
1365
+ renderedSlotFormat = format;
1366
+ update1mWarnings();
1367
+ }
1368
+
1369
+ function onInFormatChange(newFmt) {
1370
+ collectCurrentSlotValues(renderedSlotFormat);
1371
+ renderModelSlots(newFmt);
1372
+ renderCodexTab(newFmt);
1373
+ if (loadedModels.length > 0) renderModelBrowser(loadedModels);
1374
+ formDirty = true;
1375
+ }
1376
+
1377
+ // The Codex tab applies to every profile that can serve the Responses protocol: `responses` and
1378
+ // `auto`. The gateway applies these settings to both, so hiding them would hide live settings.
1379
+ const servesCodex = (format) => format === 'responses' || !format || format === 'auto';
1380
+
1381
+ function renderCodexTab(format) {
1382
+ const applies = servesCodex(format);
1383
+ const btn = document.getElementById('tab-button-codex');
1384
+ if (btn) btn.hidden = !applies;
1385
+ if (!applies && document.getElementById('tab-codex')?.classList.contains('active')) {
1386
+ switchTab('tab-general');
1387
+ }
1388
+
1389
+ const p = currentProfileBuffer || {};
1390
+ const roles = p.codexRoles || {};
1391
+ const published = Array.isArray(p.publicModels) ? p.publicModels : [];
1392
+ CODEX_ROLE_SLOTS.forEach((slot, i) => {
1393
+ const input = document.getElementById(`p-public-${slot}`);
1394
+ // A saved role key wins even when blank; the position fallback is for profiles without codexRoles.
1395
+ if (input) input.value = Object.hasOwn(roles, slot) ? (roles[slot] || '') : (published[i] || '');
1396
+ });
1397
+
1398
+ const chk = document.getElementById('p-blindfold');
1399
+ if (chk) chk.checked = Boolean(p.blindfold);
1400
+ const host = document.getElementById('p-blindfold-host');
1401
+ if (host) host.value = p.blindfoldHost || '';
1402
+ const port = document.getElementById('p-blindfold-port');
1403
+ if (port) port.value = p.blindfoldPort || '';
1404
+ const prefix = document.getElementById('p-blindfold-prefix');
1405
+ if (prefix) prefix.value = p.blindfoldPrefix || '';
1406
+ onBlindfoldToggle(Boolean(p.blindfold));
1407
+ }
1408
+
1409
+ // The form already marks itself dirty through one input/change listener, so this
1410
+ // only reveals the fields that the toggle governs.
1411
+ function onBlindfoldToggle(enabled) {
1412
+ const fields = document.getElementById('blindfold-fields');
1413
+ if (fields) fields.hidden = !enabled;
1414
+ }
1415
+
1416
+ // One set of inputs feeds both keys: codexRoles is the role mapping the gateway
1417
+ // resolves, publicModels is the flat list /v1/models serves. Writing only one of
1418
+ // them lets the two drift apart.
1419
+ function collectCodexValues(profile) {
1420
+ if (!servesCodex(profile.inFormat)) return;
1421
+
1422
+ const roles = {};
1423
+ const published = [];
1424
+ CODEX_ROLE_SLOTS.forEach(slot => {
1425
+ const value = document.getElementById(`p-public-${slot}`)?.value.trim() || '';
1426
+ roles[slot] = value;
1427
+ if (value && !published.includes(value)) published.push(value);
1428
+ });
1429
+ profile.codexRoles = roles;
1430
+ profile.publicModels = published;
1431
+
1432
+ profile.blindfold = Boolean(document.getElementById('p-blindfold')?.checked);
1433
+ const host = document.getElementById('p-blindfold-host')?.value.trim() || '';
1434
+ const prefix = document.getElementById('p-blindfold-prefix')?.value.trim() || '';
1435
+ const port = parseInt(document.getElementById('p-blindfold-port')?.value, 10);
1436
+ profile.blindfoldHost = host;
1437
+ profile.blindfoldPrefix = prefix;
1438
+ profile.blindfoldPort = Number.isInteger(port) && port > 0 && port <= 65535 ? port : '';
1439
+ }
1440
+
1441
+ function collectCurrentSlotValues(format = renderedSlotFormat) {
1442
+ if (!currentProfileBuffer) currentProfileBuffer = {};
1443
+ if (!currentProfileBuffer.defaultModels) currentProfileBuffer.defaultModels = {};
1444
+ if (!currentProfileBuffer.model1M) currentProfileBuffer.model1M = {};
1445
+
1446
+ const cfg = getActiveSlotConfig(format);
1447
+ cfg.slots.forEach(s => {
1448
+ const inp = document.getElementById(`p-${s.id}`);
1449
+ if (inp) currentProfileBuffer.defaultModels[s.id] = inp.value.trim();
1450
+ const chk = document.getElementById(`p-${s.id}-1m`);
1451
+ if (chk) currentProfileBuffer.model1M[s.id] = chk.checked;
1452
+ });
1453
+ }
1454
+
1455
+ function update1mWarnings() {
1456
+ const currentFmt = document.getElementById('p-informat')?.value || 'auto';
1457
+ const cfg = getActiveSlotConfig(currentFmt);
1458
+ cfg.slots.forEach(s => {
1459
+ const chk = document.getElementById(`p-${s.id}-1m`);
1460
+ const badge = document.getElementById(`warn-${s.id}-1m`);
1461
+ if (chk && badge) {
1462
+ badge.style.display = chk.checked ? 'inline-block' : 'none';
1463
+ }
1464
+ });
1465
+ }
1466
+
1467
+ async function loadStatus() {
1468
+ try {
1469
+ const res = await api('/api/status');
1470
+ const data = await res.json();
1471
+ if (!res.ok) {
1472
+ showToast(data.error || `Gateway error HTTP ${res.status}`, true);
1473
+ return;
1474
+ }
1475
+ state = data;
1476
+ document.querySelectorAll('.gateway-port').forEach(el => { el.textContent = state.port; });
1477
+ if (!creatingNew && (!currentlyEditingKey || !state.config.profiles[currentlyEditingKey])) {
1478
+ // Pick the first profile that is actually active for any CLI target,
1479
+ // falling back to the legacy activeProfile or the first profile key.
1480
+ const activeMap = state.activeProfiles || {};
1481
+ const firstActiveKey = Object.values(activeMap).find(k => k && state.config.profiles[k]);
1482
+ currentlyEditingKey = firstActiveKey
1483
+ || (state.config.profiles[state.activeProfile] ? state.activeProfile : null)
1484
+ || (Object.keys(state.config.profiles)[0] || '');
1485
+ formDirty = false;
1486
+ }
1487
+ render();
1488
+ } catch (err) {
1489
+ showToast('Failed to connect to gateway service', true);
1490
+ }
1491
+ }
1492
+
1493
+ function isAnyTargetActive() {
1494
+ return Object.values(state.activeProfiles || {}).some(Boolean);
1495
+ }
1496
+
1497
+ function render() {
1498
+ const dot = document.getElementById('status-dot');
1499
+ const title = document.getElementById('status-title');
1500
+ const sub = document.getElementById('status-sub');
1501
+ const toggleBtn = document.getElementById('btn-toggle');
1502
+
1503
+ const activeMap = state.activeProfiles || (state.config.activeProfiles || {});
1504
+ const activeTargets = Object.entries(activeMap).filter(([_, v]) => Boolean(v));
1505
+ const isAnyActive = activeTargets.length > 0;
1506
+
1507
+ if (isAnyActive) {
1508
+ dot.className = 'status-dot active';
1509
+ title.innerText = `Gateway active for ${activeTargets.length} client${activeTargets.length > 1 ? 's' : ''}`;
1510
+ const parts = [];
1511
+ if (activeMap.anthropic) parts.push(`Claude: ${activeMap.anthropic}`);
1512
+ if (activeMap.responses) parts.push(`Codex: ${activeMap.responses}`);
1513
+ if (activeMap['openai-chat']) parts.push(`OpenAI: ${activeMap['openai-chat']}`);
1514
+ if (activeMap.vertex) parts.push(`Vertex: ${activeMap.vertex}`);
1515
+ sub.innerText = `Port ${state.port} | ${parts.join(' | ')}`;
1516
+ toggleBtn.className = 'btn btn-danger';
1517
+ toggleBtn.innerText = 'Use official endpoints';
1518
+ } else {
1519
+ dot.className = 'status-dot';
1520
+ title.innerText = 'Official endpoints in use';
1521
+ sub.innerText = 'Gateway is idle. Clients connect directly to official endpoints.';
1522
+ toggleBtn.className = 'btn btn-success';
1523
+ toggleBtn.innerText = 'Activate compatible routes';
1524
+ }
1525
+
1526
+ // Update 4 CLI Slots
1527
+ const slots = [
1528
+ { id: 'anthropic', name: 'Claude Code', key: 'anthropic' },
1529
+ { id: 'responses', name: 'Codex', key: 'responses' },
1530
+ { id: 'openai-chat', name: 'OpenAI Chat', key: 'openai-chat' },
1531
+ { id: 'vertex', name: 'Vertex / Gemini', key: 'vertex' }
1532
+ ];
1533
+
1534
+ for (const slot of slots) {
1535
+ const card = document.getElementById(`card-slot-${slot.id}`);
1536
+ const badge = document.getElementById(`badge-slot-${slot.id}`);
1537
+ const select = document.getElementById(`select-slot-${slot.id}`);
1538
+ const currentActive = activeMap[slot.key];
1539
+
1540
+ if (select) {
1541
+ select.innerHTML = '<option value="">Official endpoint</option>';
1542
+ for (const [key, p] of Object.entries(state.config.profiles)) {
1543
+ const inFmt = p.inFormat || 'auto';
1544
+ if (inFmt === 'auto' || inFmt === slot.key) {
1545
+ const opt = document.createElement('option');
1546
+ opt.value = key;
1547
+ opt.textContent = `${p.name} (${key})`;
1548
+ select.appendChild(opt);
1549
+ }
1550
+ }
1551
+ select.value = currentActive || '';
1552
+ }
1553
+
1554
+ if (currentActive) {
1555
+ if (card) card.classList.add('active');
1556
+ if (badge) {
1557
+ badge.className = 'badge badge-direct';
1558
+ badge.textContent = currentActive;
1559
+ }
1560
+ } else {
1561
+ if (card) card.classList.remove('active');
1562
+ if (badge) {
1563
+ badge.className = 'badge badge-outline';
1564
+ badge.textContent = 'OFF';
1565
+ }
1566
+ }
1567
+ }
1568
+
1569
+ // Render Cards Grid
1570
+ const grid = document.getElementById('profiles-grid');
1571
+ grid.innerHTML = '';
1572
+
1573
+ for (const [key, p] of Object.entries(state.config.profiles)) {
1574
+ const activeCLIs = [];
1575
+ if (activeMap.anthropic === key) activeCLIs.push('Claude');
1576
+ if (activeMap.responses === key) activeCLIs.push('Codex');
1577
+ if (activeMap['openai-chat'] === key) activeCLIs.push('OpenAI');
1578
+ if (activeMap.vertex === key) activeCLIs.push('Vertex');
1579
+ const isActiveAny = activeCLIs.length > 0;
1580
+ const isEditing = (key === currentlyEditingKey);
1581
+ const shortFmt = (f) => ({ auto: 'auto', anthropic: 'anth', 'openai-chat': 'chat', responses: 'resp', vertex: 'vtx' }[f] || f || 'auto');
1582
+ const routeBadge = `${shortFmt(p.inFormat)}→${shortFmt(p.outFormat || p.mode)}`;
1583
+
1584
+ const card = document.createElement('div');
1585
+ card.className = `profile-card ${isEditing ? 'editing' : ''} ${isActiveAny ? 'active' : ''}`;
1586
+ card.setAttribute('aria-label', `${p.name} provider profile`);
1587
+ card.style.cursor = 'pointer';
1588
+ card.addEventListener('click', (ev) => {
1589
+ // Don't trigger if clicked on toggle switch or interactive buttons
1590
+ if (ev.target.closest('.card-toggle') || ev.target.closest('input') || ev.target.closest('button')) return;
1591
+ selectProfileForEdit(key);
1592
+ });
1593
+
1594
+ const pCfg = getActiveSlotConfig(p.inFormat || 'auto');
1595
+ const modelsSummaryHtml = pCfg.slots.map(s => {
1596
+ const val = valueForSlot(p.defaultModels, s.id) || '-';
1597
+ const has1m = flagForSlot(p.model1M, s.id);
1598
+ const label = s.id.charAt(0).toUpperCase() + s.id.slice(1);
1599
+ return `<div><strong>${label}:</strong> ${escapeHtml(val)} ${has1m ? '<span class="badge-1m">1M</span>' : ''}</div>`;
1600
+ }).join('');
1601
+
1602
+ card.innerHTML = `
1603
+ <div class="card-top">
1604
+ <div class="card-name-group">
1605
+ <div class="card-name">${escapeHtml(p.name)}</div>
1606
+ <span class="badge badge-${escapeHtml(p.mode || 'hybrid')}">${escapeHtml(p.mode || 'hybrid')}</span>
1607
+ <span class="badge badge-route" title="Client input -> Upstream output">${escapeHtml(routeBadge)}</span>
1608
+ </div>
1609
+ <div class="card-toggle" style="display: flex; align-items: center; gap: 6px; flex-shrink: 0;">
1610
+ <span style="font-size: 11px; font-weight: 600; color: ${isActiveAny ? 'var(--success)' : 'var(--text-dim)'};">${isActiveAny ? 'ACTIVE' : 'OFF'}</span>
1611
+ <label class="switch">
1612
+ <input type="checkbox" aria-label="Activate ${escapeHtml(p.name)} for compatible clients" ${isActiveAny ? 'checked' : ''}>
1613
+ <span class="slider"></span>
1614
+ </label>
1615
+ </div>
1616
+ </div>
1617
+ <div class="card-url">${escapeHtml(p.baseURL)}</div>
1618
+ <div class="card-models">
1619
+ ${modelsSummaryHtml}
1620
+ </div>
1621
+ <div class="card-footer">
1622
+ <button type="button" class="btn btn-outline btn-sm profile-edit" aria-pressed="${isEditing}" onclick="selectProfileForEdit('${escapeHtml(key)}')">
1623
+ ${isEditing ? 'Editing' : 'Edit profile'}
1624
+ </button>
1625
+ <div>
1626
+ ${activeCLIs.map(c => `<span class="badge badge-direct" style="font-size: 9px; margin-left: 3px;">${escapeHtml(c)}</span>`).join('')}
1627
+ </div>
1628
+ </div>
1629
+ `;
1630
+
1631
+ const toggleWrap = card.querySelector('.card-toggle');
1632
+ toggleWrap.addEventListener('click', ev => ev.stopPropagation());
1633
+ toggleWrap.querySelector('input').addEventListener('change', ev => toggleProfileAllCli(key, ev.target.checked));
1634
+ grid.appendChild(card);
1635
+ }
1636
+
1637
+ if (!creatingNew && (formLoadedKey !== currentlyEditingKey || !formDirty)) {
1638
+ loadProfileToForm(currentlyEditingKey);
1639
+ }
1640
+ }
1641
+
1642
+ function selectProfileForEdit(key) {
1643
+ if (formDirty && key !== formLoadedKey && !confirm('Discard unsaved changes to this provider?')) return;
1644
+ currentlyEditingKey = key;
1645
+ creatingNew = false;
1646
+ formDirty = false;
1647
+ render();
1648
+ }
1649
+
1650
+ async function switchCliTarget(target, profileKey) {
1651
+ showToast(`Setting ${target} active profile...`);
1652
+ try {
1653
+ const res = await api('/api/switch', {
1654
+ method: 'POST',
1655
+ headers: { 'Content-Type': 'application/json' },
1656
+ body: JSON.stringify({ target, profile: profileKey || null })
1657
+ });
1658
+ const data = await res.json();
1659
+ if (data.success) {
1660
+ showToast(`Updated ${target} to "${profileKey || 'Official'}"!`);
1661
+ loadStatus();
1662
+ } else {
1663
+ showToast(data.error || 'Failed to switch', true);
1664
+ }
1665
+ } catch (err) {
1666
+ showToast('Error switching target', true);
1667
+ }
1668
+ }
1669
+
1670
+ async function toggleProfileAllCli(key, isChecked) {
1671
+ if (isChecked) {
1672
+ showToast(`Activating provider "${key}"...`);
1673
+ try {
1674
+ const res = await api('/api/switch', {
1675
+ method: 'POST',
1676
+ headers: { 'Content-Type': 'application/json' },
1677
+ body: JSON.stringify({ profile: key })
1678
+ });
1679
+ const data = await res.json();
1680
+ if (data.success) {
1681
+ showToast(`Provider "${key}" activated!`);
1682
+ loadStatus();
1683
+ } else {
1684
+ showToast(data.error || 'Failed to switch', true);
1685
+ loadStatus();
1686
+ }
1687
+ } catch (err) {
1688
+ showToast('Error activating provider', true);
1689
+ loadStatus();
1690
+ }
1691
+ } else {
1692
+ showToast(`Deactivating provider "${key}"...`);
1693
+ try {
1694
+ const res = await api('/api/switch', {
1695
+ method: 'POST',
1696
+ headers: { 'Content-Type': 'application/json' },
1697
+ body: JSON.stringify({ deactivate: key })
1698
+ });
1699
+ const data = await res.json();
1700
+ if (!data.success) throw new Error(data.error || 'Failed');
1701
+ showToast(`Deactivated "${key}"!`);
1702
+ loadStatus();
1703
+ } catch (err) {
1704
+ showToast('Error deactivating provider', true);
1705
+ loadStatus();
1706
+ }
1707
+ }
1708
+ }
1709
+
1710
+ function loadProfileToForm(key) {
1711
+ const p = state.config?.profiles?.[key];
1712
+ if (!p) return;
1713
+ currentProfileBuffer = JSON.parse(JSON.stringify(p));
1714
+
1715
+ document.getElementById('form-title').innerText = `Edit Provider: ${p.name}`;
1716
+ document.getElementById('p-key').value = key;
1717
+ document.getElementById('p-key').disabled = true;
1718
+ document.getElementById('p-name').value = p.name;
1719
+ document.getElementById('p-mode').value = p.mode || 'hybrid';
1720
+ document.getElementById('p-informat').value = p.inFormat || 'auto';
1721
+ document.getElementById('p-thinking-mode').value = p.thinkingMode || 'auto';
1722
+ document.getElementById('p-outformat').value = p.outFormat || '';
1723
+ document.getElementById('p-url').value = p.baseURL;
1724
+ document.getElementById('p-key-val').value = p.apiKey || '';
1725
+
1726
+ renderModelSlots(p.inFormat || 'auto');
1727
+ renderCodexTab(p.inFormat || 'auto');
1728
+
1729
+ document.getElementById('btn-delete').style.display = Object.keys(state.config.profiles).length > 1 ? 'block' : 'none';
1730
+ const usedBy = Object.entries(state.activeProfiles || {}).filter(([, v]) => v === key).map(([t]) => t);
1731
+ document.getElementById('active-tag').innerText = usedBy.length ? `(Active: ${usedBy.join(', ')})` : '';
1732
+
1733
+ document.getElementById('model-browser').style.display = 'none';
1734
+ document.getElementById('test-result').style.display = 'none';
1735
+ loadedModels = [];
1736
+ formLoadedKey = key;
1737
+ formDirty = false;
1738
+ }
1739
+
1740
+ function initNewProfile() {
1741
+ if (formDirty && !confirm('Discard unsaved changes to this provider?')) return;
1742
+ currentlyEditingKey = '';
1743
+ creatingNew = true;
1744
+ formLoadedKey = null;
1745
+ formDirty = false;
1746
+ currentProfileBuffer = {
1747
+ name: '',
1748
+ mode: 'hybrid',
1749
+ inFormat: 'auto',
1750
+ outFormat: '',
1751
+ thinkingMode: 'auto',
1752
+ baseURL: 'https://',
1753
+ apiKey: '',
1754
+ defaultModels: {},
1755
+ model1M: {}
1756
+ };
1757
+
1758
+ render();
1759
+ document.getElementById('form-title').innerText = 'Create New Provider';
1760
+ document.getElementById('p-key').value = '';
1761
+ document.getElementById('p-key').disabled = false;
1762
+ document.getElementById('p-name').value = '';
1763
+ document.getElementById('p-mode').value = 'hybrid';
1764
+ document.getElementById('p-informat').value = 'auto';
1765
+ document.getElementById('p-outformat').value = '';
1766
+ document.getElementById('p-thinking-mode').value = 'auto';
1767
+ document.getElementById('p-url').value = 'https://';
1768
+ document.getElementById('p-key-val').value = '';
1769
+
1770
+ renderModelSlots('auto');
1771
+ renderCodexTab('auto');
1772
+
1773
+ document.getElementById('btn-delete').style.display = 'none';
1774
+ document.getElementById('active-tag').innerText = '';
1775
+ document.getElementById('model-browser').style.display = 'none';
1776
+ document.getElementById('test-result').style.display = 'none';
1777
+ loadedModels = [];
1778
+ switchTab('tab-general');
1779
+ }
1780
+
1781
+ function setKeyIfNew(k) {
1782
+ const input = document.getElementById('p-key');
1783
+ if (!input.disabled) input.value = k;
1784
+ }
1785
+
1786
+ function applyTemplate(type) {
1787
+ if (type === '9router') {
1788
+ setKeyIfNew('9router');
1789
+ document.getElementById('p-name').value = '9Router Cloud';
1790
+ document.getElementById('p-mode').value = 'convert';
1791
+ document.getElementById('p-informat').value = 'auto';
1792
+ document.getElementById('p-outformat').value = 'openai-chat';
1793
+ document.getElementById('p-url').value = 'https://api.9router.com/v1';
1794
+ document.getElementById('p-thinking-mode').value = 'auto';
1795
+ currentProfileBuffer = {
1796
+ defaultModels: {
1797
+ opus: 'ag/claude-opus-4-6-thinking',
1798
+ sonnet: 'ag/claude-sonnet-4-6',
1799
+ haiku: 'ag/gemini-3.7-flash-high',
1800
+ fable: 'ag/gemini-3.8-flash-high'
1801
+ },
1802
+ model1M: { opus: true, sonnet: true, haiku: false, fable: true }
1803
+ };
1804
+ renderModelSlots('auto');
1805
+ } else if (type === 'openrouter') {
1806
+ setKeyIfNew('openrouter');
1807
+ document.getElementById('p-name').value = 'OpenRouter';
1808
+ document.getElementById('p-mode').value = 'convert';
1809
+ document.getElementById('p-informat').value = 'auto';
1810
+ document.getElementById('p-outformat').value = 'openai-chat';
1811
+ document.getElementById('p-url').value = 'https://openrouter.ai/api/v1';
1812
+ currentProfileBuffer = {
1813
+ defaultModels: {
1814
+ opus: 'anthropic/claude-3.7-sonnet:thinking',
1815
+ sonnet: 'anthropic/claude-3.7-sonnet',
1816
+ haiku: 'google/gemini-2.0-flash-001',
1817
+ fable: 'qwen/qwq-32b'
1818
+ },
1819
+ model1M: { opus: true, sonnet: true, haiku: false, fable: false }
1820
+ };
1821
+ renderModelSlots('auto');
1822
+ } else if (type === 'codex-vtx') {
1823
+ setKeyIfNew('codex-vertex');
1824
+ document.getElementById('p-name').value = 'Codex to Vertex';
1825
+ document.getElementById('p-mode').value = 'convert';
1826
+ document.getElementById('p-informat').value = 'responses';
1827
+ document.getElementById('p-outformat').value = 'vertex';
1828
+ document.getElementById('p-url').value = 'https://api.9router.com/v1';
1829
+ document.getElementById('p-thinking-mode').value = 'auto';
1830
+ currentProfileBuffer = {
1831
+ defaultModels: {
1832
+ main: 'gemini-3.8-flash',
1833
+ review: 'gemini-3.7-flash-medium',
1834
+ subagent: 'gemini-3.6-flash-low'
1835
+ },
1836
+ model1M: { main: true, review: false, subagent: false }
1837
+ };
1838
+ renderModelSlots('responses');
1839
+ } else if (type === 'momo') {
1840
+ setKeyIfNew('selfhost');
1841
+ document.getElementById('p-name').value = 'Selfhost Hybrid';
1842
+ document.getElementById('p-mode').value = 'hybrid';
1843
+ document.getElementById('p-informat').value = 'auto';
1844
+ document.getElementById('p-outformat').value = '';
1845
+ document.getElementById('p-url').value = 'https://llm.example.com/v1';
1846
+ currentProfileBuffer = {
1847
+ defaultModels: {
1848
+ opus: 'claude-opus-4-7',
1849
+ sonnet: 'claude-sonnet-4-6',
1850
+ haiku: 'claude-haiku-4-5-20251001',
1851
+ fable: 'claude-haiku-4-5-20251001'
1852
+ },
1853
+ model1M: { opus: true, sonnet: true, haiku: false, fable: false }
1854
+ };
1855
+ renderModelSlots('auto');
1856
+ } else if (type === 'custom') {
1857
+ setKeyIfNew('local-openai');
1858
+ document.getElementById('p-name').value = 'Local OpenAI Server';
1859
+ document.getElementById('p-mode').value = 'convert';
1860
+ document.getElementById('p-informat').value = 'openai-chat';
1861
+ document.getElementById('p-outformat').value = 'openai-chat';
1862
+ document.getElementById('p-url').value = 'http://127.0.0.1:8000/v1';
1863
+ document.getElementById('p-thinking-mode').value = 'native';
1864
+ currentProfileBuffer = {
1865
+ defaultModels: { default: 'gpt-4o' },
1866
+ model1M: { default: false }
1867
+ };
1868
+ renderModelSlots('openai-chat');
1869
+ }
1870
+ formDirty = true;
1871
+ showToast(`Applied ${type} template!`);
1872
+ }
1873
+
1874
+ async function fetchModelList() {
1875
+ const baseURL = document.getElementById('p-url').value.trim();
1876
+ const apiKey = document.getElementById('p-key-val').value.trim();
1877
+ if (!baseURL) {
1878
+ showToast('Please enter Base URL first', true);
1879
+ switchTab('tab-general');
1880
+ return;
1881
+ }
1882
+
1883
+ const btn = document.getElementById('btn-fetch-models');
1884
+ btn.innerText = 'Loading...';
1885
+ btn.disabled = true;
1886
+
1887
+ try {
1888
+ const res = await api('/api/fetch-models', {
1889
+ method: 'POST',
1890
+ headers: { 'Content-Type': 'application/json' },
1891
+ body: JSON.stringify({ baseURL, apiKey, key: formLoadedKey })
1892
+ });
1893
+ const data = await res.json();
1894
+ if (data.ok && Array.isArray(data.models)) {
1895
+ loadedModels = data.models;
1896
+ populateModelOptions(loadedModels);
1897
+ renderModelBrowser(loadedModels);
1898
+ showToast(`Loaded ${loadedModels.length} models from upstream!`);
1899
+ } else {
1900
+ showToast(`Failed to load models: ${data.error || 'Unknown error'}`, true);
1901
+ }
1902
+ } catch (err) {
1903
+ showToast(`Fetch error: ${err.message}`, true);
1904
+ } finally {
1905
+ btn.innerText = 'Fetch models';
1906
+ btn.disabled = false;
1907
+ }
1908
+ }
1909
+
1910
+ function populateModelOptions(models) {
1911
+ const datalist = document.getElementById('model-options');
1912
+ datalist.innerHTML = '';
1913
+ for (const m of models) {
1914
+ const opt = document.createElement('option');
1915
+ opt.value = m;
1916
+ datalist.appendChild(opt);
1917
+ }
1918
+ }
1919
+
1920
+ function renderModelBrowser(models) {
1921
+ const container = document.getElementById('model-browser');
1922
+ const items = document.getElementById('model-items');
1923
+ const countLabel = document.getElementById('model-count-label');
1924
+
1925
+ container.style.display = 'block';
1926
+ countLabel.innerText = `Available Models (${models.length})`;
1927
+ items.innerHTML = '';
1928
+
1929
+ if (models.length === 0) {
1930
+ items.innerHTML = '<div style="color: var(--text-dim); font-size: 12px; padding: 6px;">No matching models found.</div>';
1931
+ return;
1932
+ }
1933
+
1934
+ const currentFmt = document.getElementById('p-informat')?.value || 'auto';
1935
+ const cfg = getActiveSlotConfig(currentFmt);
1936
+
1937
+ for (const m of models) {
1938
+ const row = document.createElement('div');
1939
+ row.className = 'model-row';
1940
+
1941
+ const idSpan = document.createElement('span');
1942
+ idSpan.className = 'model-id';
1943
+ idSpan.textContent = m;
1944
+
1945
+ const btnGroup = document.createElement('div');
1946
+ btnGroup.className = 'model-btn-group';
1947
+
1948
+ cfg.slots.forEach(s => {
1949
+ const btn = document.createElement('button');
1950
+ btn.type = 'button';
1951
+ btn.className = 'btn btn-outline btn-sm';
1952
+ btn.textContent = s.id.charAt(0).toUpperCase() + s.id.slice(1);
1953
+ btn.onclick = () => selectModel(s.id, m);
1954
+ btnGroup.appendChild(btn);
1955
+ });
1956
+
1957
+ row.appendChild(idSpan);
1958
+ row.appendChild(btnGroup);
1959
+ items.appendChild(row);
1960
+ }
1961
+ }
1962
+
1963
+ function filterModels() {
1964
+ const q = (document.getElementById('model-filter').value || '').toLowerCase().trim();
1965
+ const filtered = loadedModels.filter(m => m.toLowerCase().includes(q));
1966
+ renderModelBrowser(filtered);
1967
+ }
1968
+
1969
+ function selectModel(slotId, modelId) {
1970
+ const targetInput = document.getElementById('p-' + slotId);
1971
+ if (targetInput) targetInput.value = modelId;
1972
+ const is1M = /1m|gemini-3|glm-5|kimi|nemotron-3-ultra/i.test(modelId);
1973
+ const chk1M = document.getElementById(`p-${slotId}-1m`);
1974
+ if (chk1M && is1M) {
1975
+ chk1M.checked = true;
1976
+ }
1977
+ update1mWarnings();
1978
+ formDirty = true;
1979
+ showToast(`Assigned "${modelId}" to ${slotId.toUpperCase()}`);
1980
+ }
1981
+
1982
+ async function toggleProxy() {
1983
+ const nextState = !isAnyTargetActive();
1984
+ showToast(nextState ? 'Activating compatible routes...' : 'Restoring official endpoints...');
1985
+ try {
1986
+ const res = await api('/api/toggle', {
1987
+ method: 'POST',
1988
+ headers: { 'Content-Type': 'application/json' },
1989
+ body: JSON.stringify({ enabled: nextState })
1990
+ });
1991
+ const data = await res.json();
1992
+ if (data.success) {
1993
+ showToast(nextState ? 'Compatible routes are active.' : 'Official endpoints restored.');
1994
+ } else {
1995
+ showToast(data.error || 'Failed to toggle gateway state', true);
1996
+ }
1997
+ loadStatus();
1998
+ } catch (err) {
1999
+ showToast('Failed to toggle gateway state', true);
2000
+ }
2001
+ }
2002
+
2003
+ async function saveProfile(e) {
2004
+ e.preventDefault();
2005
+ const key = document.getElementById('p-key').value.trim();
2006
+ const outFormat = document.getElementById('p-outformat').value;
2007
+ if (creatingNew && state.config?.profiles?.[key] && !confirm(`Provider "${key}" already exists. Overwrite it?`)) return;
2008
+
2009
+ collectCurrentSlotValues();
2010
+ const existing = state.config?.profiles?.[key] || {};
2011
+ const mergedModels = { ...(existing.defaultModels || {}), ...(currentProfileBuffer?.defaultModels || {}) };
2012
+ const merged1M = { ...(existing.model1M || {}), ...(currentProfileBuffer?.model1M || {}) };
2013
+
2014
+ const profile = {
2015
+ name: document.getElementById('p-name').value.trim(),
2016
+ mode: document.getElementById('p-mode').value,
2017
+ inFormat: document.getElementById('p-informat').value || 'auto',
2018
+ thinkingMode: document.getElementById('p-thinking-mode').value || 'auto',
2019
+ baseURL: document.getElementById('p-url').value.trim(),
2020
+ apiKey: document.getElementById('p-key-val').value.trim(),
2021
+ defaultModels: mergedModels,
2022
+ model1M: merged1M
2023
+ };
2024
+ profile.outFormat = outFormat;
2025
+ collectCodexValues(profile);
2026
+
2027
+ try {
2028
+ const res = await api('/api/save-profile', {
2029
+ method: 'POST',
2030
+ headers: { 'Content-Type': 'application/json' },
2031
+ body: JSON.stringify({ key, profile })
2032
+ });
2033
+ const data = await res.json();
2034
+ if (data.success) {
2035
+ showToast('Provider profile saved.');
2036
+ currentlyEditingKey = key;
2037
+ creatingNew = false;
2038
+ formDirty = false;
2039
+ formLoadedKey = null;
2040
+ loadStatus();
2041
+ } else {
2042
+ showToast(data.error || 'Failed to save', true);
2043
+ }
2044
+ } catch (err) {
2045
+ showToast('Error saving provider', true);
2046
+ }
2047
+ }
2048
+
2049
+ async function deleteProfile() {
2050
+ const key = document.getElementById('p-key').value;
2051
+ if (!confirm(`Are you sure you want to delete provider "${key}"?`)) return;
2052
+
2053
+ try {
2054
+ const res = await api('/api/delete-profile', {
2055
+ method: 'POST',
2056
+ headers: { 'Content-Type': 'application/json' },
2057
+ body: JSON.stringify({ key })
2058
+ });
2059
+ const data = await res.json();
2060
+ if (data.success) {
2061
+ showToast('Provider deleted.');
2062
+ currentlyEditingKey = '';
2063
+ creatingNew = false;
2064
+ formDirty = false;
2065
+ loadStatus();
2066
+ } else {
2067
+ showToast(data.error || 'Failed to delete', true);
2068
+ }
2069
+ } catch (err) {
2070
+ showToast('Error deleting provider', true);
2071
+ }
2072
+ }
2073
+
2074
+ // Modern Non-blocking Connection Tester
2075
+ async function testConnection() {
2076
+ const btn = document.getElementById('btn-test-conn');
2077
+ const box = document.getElementById('test-result');
2078
+ const statusBadge = document.getElementById('test-status-badge');
2079
+ const latencyBadge = document.getElementById('test-latency');
2080
+ const sampleBox = document.getElementById('test-sample');
2081
+
2082
+ btn.innerText = 'Testing...';
2083
+ btn.disabled = true;
2084
+ box.style.display = 'none';
2085
+
2086
+ try {
2087
+ const currentFmt = document.getElementById('p-informat')?.value || 'auto';
2088
+ const cfg = getActiveSlotConfig(currentFmt);
2089
+ const primarySlotId = cfg.slots[0]?.id || 'default';
2090
+ const modelInput = document.getElementById(`p-${primarySlotId}`) || document.getElementById('p-sonnet') || document.getElementById('p-opus');
2091
+ const testModel = (modelInput?.value || '').trim() || 'default';
2092
+
2093
+ const res = await api('/api/test-upstream', {
2094
+ method: 'POST',
2095
+ headers: { 'Content-Type': 'application/json' },
2096
+ body: JSON.stringify({
2097
+ baseURL: document.getElementById('p-url').value.trim(),
2098
+ apiKey: document.getElementById('p-key-val').value.trim(),
2099
+ mode: document.getElementById('p-mode').value,
2100
+ outFormat: document.getElementById('p-outformat').value,
2101
+ key: formLoadedKey,
2102
+ model: testModel
2103
+ })
2104
+ });
2105
+ const data = await res.json();
2106
+ box.style.display = 'block';
2107
+
2108
+ if (data.ok) {
2109
+ box.className = 'test-result-box success';
2110
+ statusBadge.textContent = `✓ HTTP 200 OK (${data.outFormat || 'upstream'})`;
2111
+ latencyBadge.textContent = `Latency: ${data.latency}ms`;
2112
+ sampleBox.textContent = `Sample Output: ${data.sample}`;
2113
+ showToast(`Connection successful (${data.latency}ms)`);
2114
+ } else {
2115
+ box.className = 'test-result-box error';
2116
+ statusBadge.textContent = `✗ Failed (${data.status || 'Error'})`;
2117
+ latencyBadge.textContent = '';
2118
+ sampleBox.textContent = `Error details: ${data.error || 'Connection failed'}`;
2119
+ showToast('Upstream test failed', true);
2120
+ }
2121
+ } catch (err) {
2122
+ box.style.display = 'block';
2123
+ box.className = 'test-result-box error';
2124
+ statusBadge.textContent = '✗ Network Error';
2125
+ latencyBadge.textContent = '';
2126
+ sampleBox.textContent = err.message;
2127
+ showToast('Network error testing upstream', true);
2128
+ } finally {
2129
+ btn.innerText = 'Test connection';
2130
+ btn.disabled = false;
2131
+ }
2132
+ }
2133
+
2134
+ let logRefreshInterval = null;
2135
+
2136
+ function toggleLogAutoRefresh(enabled) {
2137
+ if (logRefreshInterval) {
2138
+ clearInterval(logRefreshInterval);
2139
+ logRefreshInterval = null;
2140
+ }
2141
+ if (enabled) {
2142
+ logRefreshInterval = setInterval(() => {
2143
+ const tab = document.getElementById('tab-inspector');
2144
+ if (tab && tab.classList.contains('active')) {
2145
+ loadInspectorLogs();
2146
+ }
2147
+ }, 2500);
2148
+ }
2149
+ }
2150
+
2151
+ async function loadInspectorLogs() {
2152
+ try {
2153
+ const res = await api('/api/logs');
2154
+ const data = await res.json();
2155
+ const container = document.getElementById('inspector-logs-container');
2156
+ if (!container) return;
2157
+
2158
+ if (!data.logs || data.logs.length === 0) {
2159
+ 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>';
2160
+ return;
2161
+ }
2162
+
2163
+ container.innerHTML = '';
2164
+ for (const l of data.logs) {
2165
+ const row = document.createElement('div');
2166
+ row.className = 'log-item';
2167
+ const isErr = l.status >= 400;
2168
+ const statusClass = isErr ? 'badge-convert' : 'badge-direct';
2169
+
2170
+ let details = '';
2171
+ if (l.requestPreview) {
2172
+ details += `<div><strong style="color: var(--accent);">Prompt:</strong> ${escapeHtml(l.requestPreview)}</div>`;
2173
+ }
2174
+ if (l.thinkingChars > 0) {
2175
+ details += `<div><strong style="color: #fbbf24;">Thinking:</strong> ${l.thinkingChars} chars extracted</div>`;
2176
+ }
2177
+ if (l.responsePreview) {
2178
+ details += `<div><strong style="color: #34d399;">Output:</strong> ${escapeHtml(l.responsePreview)}</div>`;
2179
+ }
2180
+ if (l.error) {
2181
+ details += `<div><strong style="color: #f87171;">Error:</strong> ${escapeHtml(l.error)}</div>`;
2182
+ }
2183
+
2184
+ row.innerHTML = `
2185
+ <div class="log-header">
2186
+ <div class="log-badges">
2187
+ <span class="badge ${statusClass}">${l.status}</span>
2188
+ <span style="font-family: var(--font-mono); font-size: 11px; color: var(--text);">${escapeHtml(l.clientFormat)} ➔ ${escapeHtml(l.outFormat)}</span>
2189
+ <span style="font-size: 11px; color: var(--text-muted); font-family: var(--font-mono);">[${escapeHtml(l.model)}]</span>
2190
+ <span style="font-size: 10px; color: var(--text-dim);">${escapeHtml(l.timestamp)}</span>
2191
+ </div>
2192
+ <div style="display: flex; gap: 8px; font-size: 11px; color: var(--text-dim); font-family: var(--font-mono);">
2193
+ <span>${l.duration} ms</span>
2194
+ <span>in ${l.tokens?.prompt || 0} tokens</span>
2195
+ <span>out ${l.tokens?.completion || 0} tokens</span>
2196
+ </div>
2197
+ </div>
2198
+ ${details ? `<div class="log-body">${details}</div>` : ''}
2199
+ `;
2200
+ container.appendChild(row);
2201
+ }
2202
+ } catch (err) {
2203
+ console.error('Failed to load logs', err);
2204
+ }
2205
+ }
2206
+
2207
+ async function clearInspectorLogs() {
2208
+ try {
2209
+ const res = await api('/api/logs/clear', { method: 'POST' });
2210
+ loadInspectorLogs();
2211
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
2212
+ showToast('Logs cleared');
2213
+ } catch (err) {
2214
+ showToast('Failed to clear logs', true);
2215
+ }
2216
+ }
2217
+
2218
+ // Track form edits so status refreshes don't overwrite them.
2219
+ const markDirty = (ev) => {
2220
+ if (ev.target.id !== 'chk-auto-refresh-logs' && ev.target.id !== 'model-filter') formDirty = true;
2221
+ };
2222
+ document.getElementById('profile-form').addEventListener('input', markDirty);
2223
+ document.getElementById('profile-form').addEventListener('change', markDirty);
2224
+
2225
+ toggleLogAutoRefresh(true);
2226
+ try {
2227
+ applyTheme(localStorage.getItem('lsw-theme') || document.documentElement.dataset.theme || 'dark');
2228
+ } catch (e) {
2229
+ applyTheme(document.documentElement.dataset.theme || 'dark');
2230
+ }
2231
+ loadStatus();
2232
+ </script>
2233
+ </body>
2234
+ </html>