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.
- package/CHANGELOG.md +63 -0
- package/README.md +202 -257
- package/README.vi.md +200 -256
- package/blindfold/blindfold.mjs +200 -53
- package/blindfold/make-certs.sh +26 -7
- package/catalog.mjs +246 -0
- package/classifier.mjs +238 -0
- package/config.example.json +12 -34
- package/docs/codex-blindfold.md +28 -17
- package/docs/cross-platform.md +16 -7
- package/docs/diagrams/ir-healer-pipeline.mmd +16 -0
- package/docs/diagrams/ir-healer-pipeline.png +0 -0
- package/docs/diagrams/ir-healer-pipeline.svg +90 -0
- package/docs/diagrams/ir-translation-pipeline.html +14925 -0
- package/docs/diagrams/ir-translation-pipeline.sequence.json +31 -0
- package/docs/diagrams/ir-translation-pipeline.svg +5128 -0
- package/docs/diagrams/system-architecture.architecture.json +76 -0
- package/docs/diagrams/system-architecture.html +14978 -0
- package/docs/diagrams/system-architecture.svg +5147 -0
- package/docs/diagrams/system-topology.mmd +30 -0
- package/docs/diagrams/system-topology.png +0 -0
- package/docs/diagrams/system-topology.svg +125 -0
- package/ensure-ca-bundle.mjs +28 -0
- package/formats.mjs +43 -156
- package/icons/antigravity.png +0 -0
- package/icons/claude.png +0 -0
- package/icons/codex.png +0 -0
- package/icons/deepseek.png +0 -0
- package/icons/gemini.png +0 -0
- package/icons/github.png +0 -0
- package/icons/groq.png +0 -0
- package/icons/intact.svg +1 -0
- package/icons/ollama.png +0 -0
- package/icons/openai.png +0 -0
- package/icons/openrouter.png +0 -0
- package/icons/qwen.png +0 -0
- package/icons/vertex.png +0 -0
- package/mcp.mjs +39 -11
- package/package.json +1 -1
- package/proxy.mjs +114 -37
- package/shim.mjs +200 -57
- package/skills/llm-switcher/SKILL.md +15 -10
- package/state.mjs +1100 -191
- package/switch.cmd +2 -2
- package/switch.mjs +228 -53
- package/tests/blindfold-e2e.test.mjs +380 -0
- package/tests/blindfold-task5.test.mjs +429 -0
- package/tests/blindfold.task3.test.mjs +700 -0
- package/tests/blindfold.test.mjs +10 -5
- package/tests/catalog.test.mjs +147 -0
- package/tests/classifier.test.mjs +210 -0
- package/tests/contract-lab.test.mjs +22 -7
- package/tests/formats.test.mjs +63 -46
- package/tests/gateway.e2e.test.mjs +136 -36
- package/tests/lifecycle.test.mjs +16 -10
- package/tests/mcp.test.mjs +78 -2
- package/tests/real-user-sim.test.mjs +464 -0
- package/tests/shim.test.mjs +159 -66
- package/tests/state.test.mjs +975 -193
- package/tests/switch.test.mjs +446 -2
- package/ui.html +1710 -1726
package/ui.html
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
<!
|
|
1
|
+
<!doctype html>
|
|
2
2
|
<html lang="en">
|
|
3
3
|
<head>
|
|
4
|
-
<meta charset="
|
|
5
|
-
<meta name="viewport" content="width=device-width, initial-scale=1
|
|
6
|
-
<
|
|
7
|
-
<
|
|
8
|
-
<
|
|
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
|
-
|
|
20
|
-
--bg: #
|
|
21
|
-
--
|
|
22
|
-
--
|
|
23
|
-
--
|
|
24
|
-
--
|
|
25
|
-
--
|
|
26
|
-
--
|
|
27
|
-
--
|
|
28
|
-
--
|
|
29
|
-
--
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
--
|
|
33
|
-
--
|
|
34
|
-
--
|
|
35
|
-
--
|
|
36
|
-
--
|
|
37
|
-
--
|
|
38
|
-
--
|
|
39
|
-
--
|
|
40
|
-
--
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
--
|
|
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:
|
|
47
|
-
--font-sans:
|
|
48
|
-
--font-mono: ui-monospace,
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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;
|
|
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(--
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
/*
|
|
93
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
display: inline-flex;
|
|
148
|
+
.brand {
|
|
149
|
+
display: flex;
|
|
133
150
|
align-items: center;
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
.
|
|
147
|
-
.
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
.
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
.
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
.
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
189
|
-
background: var(--
|
|
190
|
-
|
|
191
|
-
border-
|
|
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
|
-
.
|
|
194
|
-
|
|
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
|
-
/*
|
|
198
|
-
.
|
|
250
|
+
/* Page Area */
|
|
251
|
+
.page {
|
|
252
|
+
min-width: 0;
|
|
199
253
|
display: flex;
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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:
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
-
.
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
-
/*
|
|
245
|
-
.
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
-
/*
|
|
267
|
-
.
|
|
301
|
+
/* Provider Grid (Intact Style) */
|
|
302
|
+
.pgrid {
|
|
268
303
|
display: grid;
|
|
269
|
-
grid-template-columns: repeat(auto-
|
|
270
|
-
gap:
|
|
271
|
-
|
|
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:
|
|
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
|
-
.
|
|
284
|
-
|
|
285
|
-
|
|
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:
|
|
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
|
-
.
|
|
313
|
-
font-size: 12px;
|
|
328
|
+
.pcard .nm {
|
|
314
329
|
font-weight: 600;
|
|
315
|
-
|
|
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
|
-
|
|
332
|
+
text-overflow: ellipsis;
|
|
345
333
|
white-space: nowrap;
|
|
346
|
-
border: 0;
|
|
347
334
|
}
|
|
348
335
|
|
|
349
|
-
|
|
350
|
-
|
|
336
|
+
.av {
|
|
337
|
+
width: 36px;
|
|
338
|
+
height: 36px;
|
|
339
|
+
border-radius: 9px;
|
|
351
340
|
display: grid;
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
}
|
|
364
|
-
.
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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
|
-
.
|
|
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:
|
|
387
|
-
|
|
388
|
-
|
|
364
|
+
gap: 0.35rem;
|
|
365
|
+
font-size: 0.74rem;
|
|
366
|
+
color: var(--faint);
|
|
389
367
|
}
|
|
390
|
-
.
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
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
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
border-radius:
|
|
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-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
|
|
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
|
-
|
|
468
|
-
|
|
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
|
-
.
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
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
|
-
|
|
497
|
-
|
|
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
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
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
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
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
|
-
/*
|
|
548
|
-
.
|
|
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
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
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-
|
|
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
|
-
.
|
|
570
|
-
|
|
571
|
-
color: var(--
|
|
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
|
-
.
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
}
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
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
|
-
|
|
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:
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
.
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
.
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
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
|
-
|
|
642
|
-
|
|
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
|
-
.
|
|
553
|
+
.mh {
|
|
693
554
|
display: flex;
|
|
694
555
|
justify-content: space-between;
|
|
695
556
|
align-items: center;
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
flex-wrap: wrap;
|
|
557
|
+
padding: 1rem 1.25rem;
|
|
558
|
+
border-bottom: 1px solid var(--line);
|
|
699
559
|
}
|
|
700
|
-
.
|
|
701
|
-
|
|
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:
|
|
706
|
-
padding-right: 4px;
|
|
566
|
+
gap: 1rem;
|
|
707
567
|
}
|
|
708
|
-
.
|
|
568
|
+
.mf {
|
|
709
569
|
display: flex;
|
|
710
570
|
justify-content: space-between;
|
|
711
571
|
align-items: center;
|
|
712
|
-
padding:
|
|
713
|
-
|
|
714
|
-
|
|
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
|
-
|
|
720
|
-
|
|
721
|
-
|
|
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
|
-
/*
|
|
607
|
+
/* Log Items */
|
|
727
608
|
.log-item {
|
|
728
|
-
background: var(--
|
|
729
|
-
border: 1px solid var(--
|
|
730
|
-
border-radius:
|
|
731
|
-
padding:
|
|
732
|
-
|
|
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:
|
|
616
|
+
gap: 0.45rem;
|
|
736
617
|
}
|
|
737
|
-
.log-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
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
|
|
631
|
+
/* Toast */
|
|
760
632
|
.toast {
|
|
761
633
|
position: fixed;
|
|
762
|
-
bottom:
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
color: var(--
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
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
|
-
|
|
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
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
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
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
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
|
-
|
|
799
|
-
|
|
800
|
-
<
|
|
801
|
-
<
|
|
802
|
-
<
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
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
|
-
</
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
<
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
<
|
|
821
|
-
|
|
822
|
-
|
|
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
|
|
826
|
-
<
|
|
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
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
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
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
<
|
|
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
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
<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
|
-
|
|
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
|
-
<!--
|
|
875
|
-
<div id="
|
|
876
|
-
<div class="
|
|
877
|
-
<div class="
|
|
878
|
-
<
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
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
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
<div
|
|
899
|
-
|
|
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
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
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
|
-
|
|
939
|
-
|
|
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
|
-
|
|
948
|
-
|
|
949
|
-
<div
|
|
950
|
-
<div class="
|
|
951
|
-
<div class="
|
|
952
|
-
<
|
|
953
|
-
<
|
|
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
|
-
|
|
956
|
-
|
|
957
|
-
<
|
|
958
|
-
|
|
959
|
-
|
|
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
|
-
|
|
964
|
-
|
|
965
|
-
<
|
|
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
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
<
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
<
|
|
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 <think> 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
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
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
|
-
|
|
1037
|
-
|
|
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
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
<div
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
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
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
<
|
|
1056
|
-
|
|
1057
|
-
|
|
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
|
-
|
|
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
|
-
<!--
|
|
1069
|
-
<div
|
|
1070
|
-
<div
|
|
1071
|
-
<
|
|
1072
|
-
<
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
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
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
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 <host></code>.
|
|
1094
|
-
</span>
|
|
1095
1025
|
</div>
|
|
1096
1026
|
</div>
|
|
1097
1027
|
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
<
|
|
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
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
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
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
<
|
|
1127
|
-
|
|
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
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
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
|
-
<
|
|
1139
|
-
|
|
1140
|
-
|
|
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
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
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
|
-
</
|
|
1072
|
+
</div>
|
|
1159
1073
|
|
|
1160
|
-
<div id="toast" class="toast"
|
|
1074
|
+
<div id="toast" class="toast"></div>
|
|
1075
|
+
<datalist id="model-options"></datalist>
|
|
1161
1076
|
|
|
1162
1077
|
<script>
|
|
1163
|
-
//
|
|
1164
|
-
|
|
1165
|
-
const
|
|
1166
|
-
(()
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
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
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
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
|
|
1122
|
+
if (!str) return '';
|
|
1260
1123
|
return String(str)
|
|
1261
1124
|
.replace(/&/g, '&')
|
|
1262
1125
|
.replace(/</g, '<')
|
|
@@ -1265,380 +1128,485 @@
|
|
|
1265
1128
|
.replace(/'/g, ''');
|
|
1266
1129
|
}
|
|
1267
1130
|
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
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
|
|
1280
|
-
const
|
|
1281
|
-
|
|
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
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
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
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
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
|
-
|
|
1309
|
-
|
|
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(
|
|
1313
|
-
|
|
1314
|
-
|
|
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(
|
|
1322
|
-
|
|
1323
|
-
|
|
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(
|
|
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
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
const
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
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
|
-
|
|
1365
|
-
|
|
1366
|
-
renderedSlotFormat = format;
|
|
1396
|
+
container.appendChild(slotRow);
|
|
1397
|
+
}
|
|
1367
1398
|
update1mWarnings();
|
|
1368
1399
|
}
|
|
1369
1400
|
|
|
1370
|
-
function
|
|
1371
|
-
|
|
1372
|
-
renderModelSlots(
|
|
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
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
const
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
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
|
-
|
|
1393
|
-
|
|
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
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
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
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
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
|
|
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
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
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
|
|
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: '
|
|
1537
|
-
{ id: '
|
|
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
|
|
1544
|
-
const
|
|
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
|
|
1551
|
-
const
|
|
1552
|
-
if (
|
|
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 =
|
|
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 (
|
|
1563
|
-
if (
|
|
1564
|
-
|
|
1565
|
-
badge.className = 'badge badge-direct';
|
|
1544
|
+
if (badge) {
|
|
1545
|
+
if (currentActive) {
|
|
1546
|
+
badge.className = 'badge ok';
|
|
1566
1547
|
badge.textContent = currentActive;
|
|
1567
|
-
}
|
|
1568
|
-
|
|
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
|
|
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
|
|
1560
|
+
for (const [key, p] of profilesList) {
|
|
1582
1561
|
const activeCLIs = [];
|
|
1583
|
-
if (activeMap.
|
|
1584
|
-
if (activeMap.
|
|
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 =
|
|
1594
|
-
card.setAttribute('aria-label',
|
|
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
|
|
1603
|
-
const
|
|
1604
|
-
|
|
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
|
-
|
|
1607
|
-
|
|
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="
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
<
|
|
1615
|
-
<
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
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="
|
|
1626
|
-
|
|
1627
|
-
${modelsSummaryHtml}
|
|
1598
|
+
<div class="copyline" onclick="event.stopPropagation()">
|
|
1599
|
+
<code>${escapeHtml(p.baseURL)}</code>
|
|
1628
1600
|
</div>
|
|
1629
|
-
<div class="
|
|
1630
|
-
|
|
1631
|
-
|
|
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
|
-
<
|
|
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
|
|
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(
|
|
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.
|
|
1668
|
-
showToast(
|
|
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('
|
|
1643
|
+
showToast('Network error switching target', true);
|
|
1675
1644
|
}
|
|
1676
1645
|
}
|
|
1677
1646
|
|
|
1678
|
-
async function toggleProfileAllCli(key,
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
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').
|
|
1673
|
+
document.getElementById('form-title').textContent = 'Edit Profile: ' + p.name;
|
|
1724
1674
|
document.getElementById('p-key').value = key;
|
|
1725
|
-
document.getElementById('p-key').
|
|
1726
|
-
document.getElementById('p-name').value = p.name;
|
|
1727
|
-
document.getElementById('p-
|
|
1728
|
-
document.getElementById('p-
|
|
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('
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
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
|
-
|
|
1757
|
-
|
|
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
|
-
|
|
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').
|
|
1713
|
+
document.getElementById('p-key').readOnly = false;
|
|
1770
1714
|
document.getElementById('p-name').value = '';
|
|
1771
|
-
document.getElementById('p-
|
|
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
|
-
|
|
1779
|
-
|
|
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').
|
|
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
|
-
|
|
1919
|
-
|
|
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
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
}
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
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
|
|
1978
|
-
const
|
|
1979
|
-
if (
|
|
1980
|
-
|
|
1981
|
-
|
|
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
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
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
|
|
1992
|
-
|
|
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:
|
|
1801
|
+
body: JSON.stringify({ enabled: !isAnyActive })
|
|
1998
1802
|
});
|
|
1999
1803
|
const data = await res.json();
|
|
2000
|
-
if (data.
|
|
2001
|
-
showToast(
|
|
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
|
|
1808
|
+
showToast(data.error || 'Failed to toggle routes', true);
|
|
2004
1809
|
}
|
|
2005
|
-
loadStatus();
|
|
2006
1810
|
} catch (err) {
|
|
2007
|
-
showToast('
|
|
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
|
-
|
|
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
|
|
2020
|
-
const merged1M = { ...(existing.model1M || {}), ...(currentProfileBuffer?.model1M || {}) };
|
|
1822
|
+
const keyVal = document.getElementById('p-key-val').value.trim();
|
|
2021
1823
|
|
|
2022
|
-
const
|
|
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
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
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.
|
|
2043
|
-
showToast('
|
|
2044
|
-
|
|
2045
|
-
|
|
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('
|
|
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(
|
|
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.
|
|
2069
|
-
showToast('
|
|
2070
|
-
|
|
2071
|
-
|
|
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('
|
|
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-
|
|
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(
|
|
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.
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
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.
|
|
1932
|
+
statusBadge.className = 'badge err';
|
|
1933
|
+
statusBadge.textContent = '✗ Failed (' + (data.status || 'Error') + ')';
|
|
2125
1934
|
latencyBadge.textContent = '';
|
|
2126
|
-
sampleBox.textContent =
|
|
2127
|
-
showToast('Upstream
|
|
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
|
|
1947
|
+
btn.innerText = 'Test Connection';
|
|
2138
1948
|
btn.disabled = false;
|
|
2139
1949
|
}
|
|
2140
1950
|
}
|
|
2141
1951
|
|
|
2142
|
-
|
|
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
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
logRefreshInterval = null;
|
|
1959
|
+
if (!baseURL) {
|
|
1960
|
+
showToast('Please enter a Base URL first', true);
|
|
1961
|
+
return;
|
|
2148
1962
|
}
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
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
|
|
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
|
|
2174
|
-
|
|
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
|
|
2143
|
+
let detailsHtml = '';
|
|
2179
2144
|
if (l.requestPreview) {
|
|
2180
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2193
|
-
<div
|
|
2194
|
-
<div
|
|
2195
|
-
<span class="
|
|
2196
|
-
<span
|
|
2197
|
-
<
|
|
2198
|
-
<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:
|
|
2201
|
-
<span
|
|
2202
|
-
<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
|
-
${
|
|
2170
|
+
${detailsHtml ? '<details><summary>View Payload Preview</summary>' + detailsHtml + '</details>' : ''}
|
|
2207
2171
|
`;
|
|
2208
|
-
|
|
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
|
-
|
|
2219
|
-
if (
|
|
2220
|
-
|
|
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('
|
|
2189
|
+
showToast('Error clearing logs', true);
|
|
2223
2190
|
}
|
|
2224
2191
|
}
|
|
2225
2192
|
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
if (
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
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
|
-
|
|
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>
|