icebear0828 Claude Opus 4.6 commited on
Commit
0fedcca
·
1 Parent(s): c0825ff

feat: reactive API config, live code examples, quota display & curl fix

Browse files

- Dashboard API config fields are now editable (input/select) with live
code example updates and localStorage persistence
- Model dropdown populated from /v1/models endpoint
- Account cards show official rate limit quota with progress bar
- Fix curl getUsage() failing on Windows by removing Accept-Encoding
header and using --compressed flag instead
- Remove .claude/settings.local.json from tracking (contains local keys)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

.claude/settings.local.json DELETED
@@ -1,56 +0,0 @@
1
- {
2
- "permissions": {
3
- "allow": [
4
- "Bash(du:*)",
5
- "Bash(npx tsc:*)",
6
- "Bash(npx tsx --eval:*)",
7
- "Bash(npx tsx:*)",
8
- "Bash(node -e:*)",
9
- "Bash(ping:*)",
10
- "Bash(curl:*)",
11
- "Bash(netstat:*)",
12
- "Bash(taskkill:*)",
13
- "Bash(while read pid)",
14
- "Bash(do taskkill //PID $pid //F)",
15
- "Bash(done echo \"killed\")",
16
- "Bash(ssh:*)",
17
- "Bash(scp:*)",
18
- "Bash(npm run dev:*)",
19
- "Bash(tasklist:*)",
20
- "Bash(sort:*)",
21
- "Bash(done)",
22
- "Bash(# Check if there''s a Session Storage with task data for f in \"\"C:/Users/14323/AppData/Roaming/Codex/Session Storage/\"\"*.log; do strings \"\"$f\"\")",
23
- "Bash(\"D:/xwechat_files/wxid_l9axc218tplc22_eddc/msg/file/2026-02/Codex-win32-x64/Codex-win32-x64/resources/codex.exe\" features)",
24
- "Bash(\"D:/xwechat_files/wxid_l9axc218tplc22_eddc/msg/file/2026-02/Codex-win32-x64/Codex-win32-x64/resources/codex.exe\" features list)",
25
- "Bash(\"D:/xwechat_files/wxid_l9axc218tplc22_eddc/msg/file/2026-02/Codex-win32-x64/Codex-win32-x64/resources/codex.exe\" cloud --help)",
26
- "Bash(\"D:/xwechat_files/wxid_l9axc218tplc22_eddc/msg/file/2026-02/Codex-win32-x64/Codex-win32-x64/resources/codex.exe\" cloud exec --help)",
27
- "Bash(\"D:/xwechat_files/wxid_l9axc218tplc22_eddc/msg/file/2026-02/Codex-win32-x64/Codex-win32-x64/resources/codex.exe\" debug --help)",
28
- "Bash(npm run build:*)",
29
- "Bash(for model in gpt-5.3-codex gpt-5.2-codex gpt-5.1-codex-max gpt-5.2 gpt-5.1-codex-mini)",
30
- "Bash(do echo \"=== $model ===\")",
31
- "Bash(python:*)",
32
- "WebSearch",
33
- "Bash(xargs:*)",
34
- "Bash(node:*)",
35
- "WebFetch(domain:github.com)",
36
- "Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\(d[''accounts''][0][''accountId'']\\)\" curl -s -o /dev/null -w \"%{http_code}\" -H \"Authorization: Bearer $TOKEN\" -H \"ChatGPT-Account-Id: $ACCT_ID\" -H \"originator: Codex Desktop\" -H \"User-Agent: Codex Desktop/260202.0859 \\(darwin; arm64\\)\" -H \"Accept: application/json\" \"https://chatgpt.com/backend-api/codex/usage\")",
37
- "Bash(git init:*)",
38
- "Bash(git add:*)",
39
- "Bash(git commit:*)",
40
- "Bash(gh repo create:*)",
41
- "Bash(git push:*)",
42
- "Bash(git checkout:*)",
43
- "Bash(python3:*)",
44
- "Bash(gh pr create:*)",
45
- "Bash(gh pr merge:*)",
46
- "Bash(git pull:*)",
47
- "Bash(git branch:*)",
48
- "Bash(wc:*)",
49
- "Bash(git status:*)",
50
- "Bash(rsync:*)",
51
- "Bash(tar:*)",
52
- "Bash(/usr/bin/scp:*)",
53
- "WebFetch(domain:auth.openai.com)"
54
- ]
55
- }
56
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
.gitignore CHANGED
@@ -4,3 +4,4 @@ data/
4
  .env
5
  *.log
6
  .asar-out/
 
 
4
  .env
5
  *.log
6
  .asar-out/
7
+ .claude/settings.local.json
public/dashboard.html CHANGED
@@ -4,307 +4,488 @@
4
  <meta charset="UTF-8">
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
  <title>Codex Proxy - Dashboard</title>
 
 
 
7
  <style>
8
  * { margin: 0; padding: 0; box-sizing: border-box; }
9
  body {
10
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
11
- background: #0d1117;
12
- color: #c9d1d9;
13
  min-height: 100vh;
14
- padding: 2rem;
15
  }
16
- .container { max-width: 720px; margin: 0 auto; }
17
- header {
 
 
 
 
 
18
  display: flex;
19
  align-items: center;
20
  justify-content: space-between;
21
- margin-bottom: 2rem;
22
- padding-bottom: 1rem;
23
- border-bottom: 1px solid #30363d;
24
- }
25
- header h1 { font-size: 1.3rem; color: #58a6ff; }
26
- .user-info { font-size: 0.85rem; color: #8b949e; }
27
- .btn-logout {
28
- padding: 6px 14px;
29
- background: #21262d;
30
- border: 1px solid #30363d;
31
- border-radius: 6px;
32
- color: #c9d1d9;
33
- cursor: pointer;
34
- font-size: 0.85rem;
35
  }
36
- .btn-logout:hover { background: #30363d; }
37
- .card {
38
- background: #161b22;
39
- border: 1px solid #30363d;
40
- border-radius: 12px;
41
- padding: 1.5rem;
42
- margin-bottom: 1.5rem;
43
  }
44
- .card h2 {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
  font-size: 1rem;
46
- margin-bottom: 1rem;
47
- color: #f0f6fc;
48
  }
49
- .field {
50
  display: flex;
51
  align-items: center;
52
  gap: 0.75rem;
53
- margin-bottom: 0.75rem;
54
  }
55
- .field label {
56
- flex-shrink: 0;
57
- width: 100px;
58
- font-size: 0.85rem;
59
- color: #8b949e;
 
 
 
 
 
60
  }
61
- .field .value {
62
- flex: 1;
63
- background: #0d1117;
64
- padding: 8px 12px;
65
- border-radius: 6px;
66
- border: 1px solid #30363d;
67
- font-family: monospace;
68
- font-size: 0.85rem;
69
- word-break: break-all;
70
  }
71
- .btn-copy {
72
- padding: 6px 12px;
73
- background: #21262d;
74
- border: 1px solid #30363d;
75
- border-radius: 6px;
76
- color: #c9d1d9;
 
 
 
 
 
 
77
  cursor: pointer;
78
- font-size: 0.8rem;
79
- flex-shrink: 0;
80
  }
81
- .btn-copy:hover { background: #30363d; }
82
- .btn-copy.copied { background: #238636; border-color: #238636; }
83
- .code-block {
84
- background: #0d1117;
85
- border: 1px solid #30363d;
86
- border-radius: 8px;
87
- padding: 1rem;
88
- font-family: monospace;
89
- font-size: 0.8rem;
90
- line-height: 1.6;
91
- overflow-x: auto;
92
- position: relative;
93
- white-space: pre;
 
 
94
  margin-bottom: 1rem;
95
  }
96
- .code-block .copy-btn {
97
- position: absolute;
98
- top: 8px;
99
- right: 8px;
100
- padding: 4px 10px;
101
- background: #21262d;
102
- border: 1px solid #30363d;
103
- border-radius: 4px;
104
- color: #8b949e;
105
- cursor: pointer;
106
- font-size: 0.75rem;
107
  }
108
- .code-block .copy-btn:hover { background: #30363d; }
109
- .tabs {
 
 
 
 
 
 
 
 
110
  display: flex;
111
- gap: 0.5rem;
 
112
  margin-bottom: 1rem;
113
  }
114
- .tab {
115
- padding: 6px 14px;
116
- background: #21262d;
117
- border: 1px solid #30363d;
118
- border-radius: 6px;
119
- color: #8b949e;
120
- cursor: pointer;
121
- font-size: 0.85rem;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
122
  }
123
- .tab.active { background: #30363d; color: #f0f6fc; }
124
- .tab-content { display: none; }
125
- .tab-content.active { display: block; }
126
  .status-badge {
127
  display: inline-block;
128
- padding: 2px 8px;
129
- border-radius: 10px;
130
  font-size: 0.75rem;
131
  font-weight: 500;
132
  }
133
- .status-ok { background: #238636; color: #fff; }
134
- .status-expired { background: #da3633; color: #fff; }
135
- .status-rate-limited { background: #d29922; color: #fff; }
136
- .status-refreshing { background: #58a6ff; color: #fff; }
137
- .status-disabled { background: #484f58; color: #c9d1d9; }
138
- .loading { color: #8b949e; font-style: italic; }
139
-
140
- /* Account list styles */
141
- .account-item {
142
- background: #0d1117;
143
- border: 1px solid #30363d;
144
- border-radius: 8px;
145
- padding: 1rem;
146
- margin-bottom: 0.75rem;
147
- }
148
- .account-header {
149
  display: flex;
150
- align-items: center;
151
- justify-content: space-between;
152
- margin-bottom: 0.5rem;
153
  }
154
- .account-email {
155
- font-weight: 600;
156
- font-size: 0.9rem;
157
- color: #f0f6fc;
158
- }
159
- .account-meta {
160
  display: flex;
161
- flex-wrap: wrap;
162
- gap: 0.5rem 1.5rem;
163
- font-size: 0.8rem;
164
- color: #8b949e;
165
- margin-bottom: 0.5rem;
166
  }
167
- .account-api-key {
168
- display: flex;
169
- align-items: center;
170
- gap: 0.5rem;
171
- margin-top: 0.5rem;
 
172
  }
173
- .account-api-key .key-value {
174
- flex: 1;
175
- background: #161b22;
176
- padding: 4px 8px;
177
- border-radius: 4px;
178
- border: 1px solid #30363d;
179
- font-family: monospace;
180
- font-size: 0.75rem;
181
- color: #8b949e;
182
- word-break: break-all;
183
  }
184
- .btn-delete {
 
 
 
185
  padding: 4px 10px;
186
- background: #21262d;
187
- border: 1px solid #da3633;
188
  border-radius: 6px;
189
- color: #da3633;
 
 
190
  cursor: pointer;
191
- font-size: 0.75rem;
192
  }
193
- .btn-delete:hover { background: #da3633; color: #fff; }
194
- .btn-small {
195
- padding: 3px 8px;
196
- background: #21262d;
197
- border: 1px solid #30363d;
198
- border-radius: 4px;
199
- color: #c9d1d9;
200
- cursor: pointer;
201
- font-size: 0.7rem;
 
202
  }
203
- .btn-small:hover { background: #30363d; }
204
 
205
- /* Add account */
206
- .add-account-section {
207
- margin-top: 1rem;
208
- padding-top: 1rem;
209
- border-top: 1px solid #30363d;
 
 
 
210
  }
211
- .btn-login {
212
- display: inline-flex;
213
- align-items: center;
 
 
 
 
 
 
214
  gap: 0.5rem;
215
- padding: 10px 20px;
216
- background: #238636;
217
- border: 1px solid #238636;
 
 
218
  border-radius: 8px;
219
- color: #fff;
220
- cursor: pointer;
221
- font-size: 0.9rem;
 
 
 
 
 
 
 
 
 
 
 
222
  font-weight: 500;
 
 
 
223
  }
224
- .btn-login:hover { background: #2ea043; }
225
- .btn-login:disabled { opacity: 0.5; cursor: not-allowed; }
226
- .login-info {
227
- color: #58a6ff;
228
  font-size: 0.8rem;
229
  margin-top: 0.5rem;
 
230
  }
231
- .login-error {
232
- color: #da3633;
233
  font-size: 0.8rem;
234
  margin-top: 0.5rem;
235
- }
236
- .paste-section {
237
  display: none;
238
- margin-top: 1rem;
239
- background: #0d1117;
240
- border: 1px solid #30363d;
241
- border-radius: 8px;
242
- padding: 1rem;
243
  }
244
- .paste-section label {
245
- display: block;
246
- font-size: 0.82rem;
247
- color: #8b949e;
248
- margin-bottom: 0.5rem;
 
 
 
249
  }
250
- .paste-section textarea {
251
- width: 100%;
252
- padding: 8px;
253
- background: #161b22;
254
- border: 1px solid #30363d;
255
- border-radius: 6px;
256
- color: #c9d1d9;
257
- font-family: monospace;
258
- font-size: 0.8rem;
259
- resize: vertical;
260
- min-height: 60px;
261
- margin-bottom: 0.5rem;
262
  }
263
- .paste-section textarea:focus {
264
- outline: none;
265
- border-color: #58a6ff;
 
 
266
  }
267
- .paste-section .hint {
 
268
  font-size: 0.75rem;
269
- color: #484f58;
270
- margin-bottom: 0.75rem;
271
- line-height: 1.4;
272
  }
273
- .btn-device {
274
- display: inline-flex;
275
  align-items: center;
276
  gap: 0.5rem;
277
- padding: 8px 16px;
278
- background: #21262d;
279
- border: 1px solid #30363d;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
280
  border-radius: 8px;
281
- color: #c9d1d9;
 
 
282
  cursor: pointer;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
283
  font-size: 0.85rem;
 
 
 
 
 
 
 
284
  font-weight: 500;
285
- margin-top: 0.5rem;
286
  }
287
- .btn-device:hover { background: #30363d; }
288
- .btn-device:disabled { opacity: 0.5; cursor: not-allowed; }
289
- .device-code-panel {
290
- background: #0d1117;
291
- border: 1px solid #30363d;
292
- border-radius: 8px;
293
  padding: 1.25rem;
294
- text-align: center;
295
- margin-top: 0.75rem;
296
- display: none;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
297
  }
298
- .device-code-panel .code-display {
299
- font-family: monospace;
300
- font-size: 1.6rem;
301
- font-weight: 700;
302
- color: #58a6ff;
303
- letter-spacing: 0.1em;
304
- margin: 0.5rem 0;
 
 
 
305
  }
306
- .device-code-panel a { color: #3fb950; font-size: 0.85rem; }
307
- .device-code-panel .wait-text { color: #8b949e; font-size: 0.8rem; margin-top: 0.5rem; }
308
  .spinner {
309
  display: inline-block;
310
  width: 14px;
@@ -313,125 +494,124 @@
313
  border-top-color: #fff;
314
  border-radius: 50%;
315
  animation: spin 0.8s linear infinite;
 
316
  }
317
- @keyframes spin { to { transform: rotate(360deg); } }
318
- .empty-state {
319
- text-align: center;
320
- color: #484f58;
321
- padding: 1.5rem;
322
- font-size: 0.85rem;
323
  }
 
324
  </style>
325
  </head>
326
  <body>
327
- <div class="container">
328
- <header>
329
- <div>
330
- <h1>Codex Proxy</h1>
331
- <span class="user-info" id="userInfo">Loading...</span>
 
 
 
332
  </div>
333
- <button class="btn-logout" onclick="logout()">Logout</button>
334
- </header>
335
-
336
- <!-- Accounts Card -->
337
- <div class="card">
338
- <h2>Accounts</h2>
339
- <div id="accountList" class="loading">Loading accounts...</div>
340
- <div class="add-account-section">
341
- <button class="btn-login" id="addAccountBtn" onclick="startAddAccount()">Add Account via ChatGPT Login</button>
342
- <div class="login-info" id="addInfo" style="display:none"></div>
343
- <div class="login-error" id="addError" style="display:none"></div>
344
- <div class="paste-section" id="addPasteSection">
345
- <div class="hint">
346
- If the popup shows an error or you're on a different machine,
347
- copy the full URL from the popup's address bar and paste it here.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
348
  </div>
349
- <label>Paste the callback URL</label>
350
- <textarea id="addCallbackInput" placeholder="http://localhost:54321/auth/callback?code=...&state=..."></textarea>
351
- <button class="btn-login" id="addRelayBtn" onclick="submitAddRelay()" style="font-size:0.8rem;padding:6px 14px">Submit</button>
352
  </div>
353
-
354
- <div style="display:flex;gap:0.5rem;flex-wrap:wrap;margin-top:0.75rem">
355
- <button class="btn-device" id="dashDeviceBtn" onclick="dashStartDeviceCode()">Device Code Login</button>
356
- <button class="btn-device" id="dashCliBtn" onclick="dashImportCli()">Import CLI Token</button>
 
 
 
 
357
  </div>
358
- <div class="device-code-panel" id="dashDevicePanel">
359
- <div style="color:#8b949e;font-size:0.82rem">Enter this code at:</div>
360
- <a id="dashVerifyLink" href="#" target="_blank" rel="noopener"></a>
361
- <div class="code-display" id="dashUserCode"></div>
362
- <div class="wait-text" id="dashDeviceStatus"><span class="spinner"></span> Waiting for authorization...</div>
363
- <div class="login-error" id="dashDeviceError" style="display:none"></div>
364
- <div class="login-info" id="dashDeviceSuccess" style="display:none"></div>
 
365
  </div>
366
- <div class="login-error" id="dashCliError" style="display:none"></div>
367
- <div class="login-info" id="dashCliSuccess" style="display:none"></div>
368
- </div>
369
- </div>
370
-
371
- <div class="card">
372
- <h2>API Configuration</h2>
373
- <div class="field">
374
- <label>Base URL</label>
375
- <div class="value" id="baseUrl">Loading...</div>
376
- <button class="btn-copy" onclick="copyText('baseUrl', this)">Copy</button>
377
- </div>
378
- <div class="field">
379
- <label>API Key</label>
380
- <div class="value" id="apiKey">Loading...</div>
381
- <button class="btn-copy" onclick="copyText('apiKey', this)">Copy</button>
382
- </div>
383
- <div class="field">
384
- <label>Model</label>
385
- <div class="value" id="defaultModel">codex</div>
386
- </div>
387
- <div class="field">
388
- <label>All Models</label>
389
- <div class="value" id="allModels" style="font-size:0.75rem;line-height:1.6">Loading...</div>
390
  </div>
 
391
  </div>
392
 
393
- <div class="card">
394
- <h2>Usage Examples</h2>
 
395
  <div class="tabs">
396
- <div class="tab active" onclick="switchTab('python', this)">Python</div>
397
- <div class="tab" onclick="switchTab('curl', this)">curl</div>
398
- <div class="tab" onclick="switchTab('node', this)">Node.js</div>
399
  </div>
400
-
401
  <div class="tab-content active" id="tab-python">
402
- <div class="code-block" id="code-python">
403
- <button class="copy-btn" onclick="copyCode('code-python')">Copy</button>
404
- Loading...
405
- </div>
406
  </div>
407
  <div class="tab-content" id="tab-curl">
408
- <div class="code-block" id="code-curl">
409
- <button class="copy-btn" onclick="copyCode('code-curl')">Copy</button>
410
- Loading...
411
- </div>
412
  </div>
413
  <div class="tab-content" id="tab-node">
414
- <div class="code-block" id="code-node">
415
- <button class="copy-btn" onclick="copyCode('code-node')">Copy</button>
416
- Loading...
417
- </div>
418
  </div>
419
  </div>
420
-
421
- <div class="card">
422
- <h2>Status</h2>
423
- <div id="statusInfo" class="loading">Checking...</div>
424
- </div>
425
  </div>
426
 
 
 
427
  <script>
428
  let authData = null;
429
 
430
- // Listen for postMessage from OAuth callback popup (cross-port communication)
 
431
  window.addEventListener('message', async (event) => {
432
  if (event.data?.type === 'oauth-callback-success') {
433
  if (addPollTimer) clearInterval(addPollTimer);
434
- document.getElementById('addPasteSection').style.display = 'none';
435
  const infoEl = document.getElementById('addInfo');
436
  infoEl.textContent = 'Account added successfully!';
437
  infoEl.style.display = 'block';
@@ -451,10 +631,15 @@ Loading...
451
  return map[status] || 'status-disabled';
452
  }
453
 
454
- function formatTime(iso) {
455
- if (!iso) return '-';
456
- const d = new Date(iso);
457
- return d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
 
 
 
 
 
458
  }
459
 
460
  function escapeHtml(str) {
@@ -463,15 +648,21 @@ Loading...
463
  return div.innerHTML;
464
  }
465
 
 
 
 
 
 
 
466
  async function loadAccounts() {
467
  try {
468
- const resp = await fetch('/auth/accounts');
469
  const data = await resp.json();
470
  const accounts = data.accounts || [];
471
  renderAccounts(accounts);
472
  } catch (err) {
473
  document.getElementById('accountList').innerHTML =
474
- '<div class="empty-state">Failed to load accounts: ' + escapeHtml(err.message) + '</div>';
475
  }
476
  }
477
 
@@ -479,42 +670,75 @@ Loading...
479
  const container = document.getElementById('accountList');
480
 
481
  if (accounts.length === 0) {
482
- container.innerHTML = '<div class="empty-state">No accounts added. Paste a token below to get started.</div>';
483
  return;
484
  }
485
 
486
  let html = '';
487
- for (const acct of accounts) {
488
  const usage = acct.usage || {};
489
- html += `<div class="account-item">
490
- <div class="account-header">
491
- <span class="account-email">${escapeHtml(acct.email || 'Unknown email')}</span>
492
- <div style="display:flex;gap:0.5rem;align-items:center">
493
- <span class="status-badge ${statusClass(acct.status)}">${escapeHtml(acct.status)}</span>
494
- <button class="btn-delete" onclick="deleteAccount('${escapeHtml(acct.id)}')">Delete</button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
495
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
496
  </div>
497
- <div class="account-meta">
498
- <span>Plan: ${escapeHtml(acct.planType || 'unknown')}</span>
499
- <span>Requests: ${usage.request_count ?? 0}</span>
500
- <span>Tokens: ${(usage.input_tokens ?? 0) + (usage.output_tokens ?? 0)}</span>
501
- <span>Added: ${formatTime(acct.addedAt)}</span>
502
- ${acct.expiresAt ? `<span>Expires: ${formatTime(acct.expiresAt)}</span>` : ''}
503
- ${usage.last_used ? `<span>Last used: ${formatTime(usage.last_used)}</span>` : ''}
504
- </div>
505
- <div class="account-api-key">
506
- <span style="font-size:0.75rem;color:#8b949e;">ID:</span>
507
- <span class="key-value" id="key-${escapeHtml(acct.id)}">${escapeHtml(acct.id)}</span>
508
- <button class="btn-small" onclick="copyElText('key-${escapeHtml(acct.id)}', this)">Copy</button>
509
  </div>
 
510
  </div>`;
511
- }
512
  container.innerHTML = html;
513
  }
514
 
515
  async function deleteAccount(id) {
516
  if (!confirm('Remove this account?')) return;
517
-
518
  try {
519
  const resp = await fetch('/auth/accounts/' + encodeURIComponent(id), { method: 'DELETE' });
520
  if (!resp.ok) {
@@ -529,6 +753,10 @@ Loading...
529
  }
530
  }
531
 
 
 
 
 
532
  async function loadStatus() {
533
  try {
534
  const resp = await fetch('/auth/status');
@@ -539,56 +767,74 @@ Loading...
539
  return;
540
  }
541
 
542
- // Header: pool summary
543
- const pool = authData.pool || {};
544
- const total = pool.total || 0;
545
- const active = pool.active || 0;
546
- document.getElementById('userInfo').textContent =
547
- `${active} active / ${total} total account${total !== 1 ? 's' : ''}`;
548
-
549
- // API config
550
- const baseUrl = `${window.location.origin}/v1`;
551
- const apiKey = authData.proxy_api_key || 'any-string';
552
- document.getElementById('baseUrl').textContent = baseUrl;
553
- document.getElementById('apiKey').textContent = apiKey;
554
-
555
- // Code examples
556
- document.getElementById('code-python').innerHTML =
557
- `<button class="copy-btn" onclick="copyCode('code-python')">Copy</button>` +
558
- `from openai import OpenAI
559
-
560
- client = OpenAI(
561
- base_url="${baseUrl}",
562
- api_key="${apiKey}",
563
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
564
 
565
- response = client.chat.completions.create(
566
- model="codex",
 
 
567
  messages=[
568
- {"role": "user", "content": "Write a hello world in Python"}
569
- ],
570
- stream=True,
571
  )
572
 
573
- for chunk in response:
574
- if chunk.choices[0].delta.content:
575
- print(chunk.choices[0].delta.content, end="")`;
576
 
577
- document.getElementById('code-curl').innerHTML =
578
- `<button class="copy-btn" onclick="copyCode('code-curl')">Copy</button>` +
579
  `curl ${baseUrl}/chat/completions \\
580
  -H "Content-Type: application/json" \\
581
  -H "Authorization: Bearer ${apiKey}" \\
582
  -d '{
583
- "model": "codex",
584
  "messages": [
585
- {"role": "user", "content": "Write a hello world in Python"}
586
- ],
587
- "stream": false
588
- }'`;
589
 
590
- document.getElementById('code-node').innerHTML =
591
- `<button class="copy-btn" onclick="copyCode('code-node')">Copy</button>` +
592
  `import OpenAI from "openai";
593
 
594
  const client = new OpenAI({
@@ -597,35 +843,62 @@ const client = new OpenAI({
597
  });
598
 
599
  const stream = await client.chat.completions.create({
600
- model: "codex",
601
  messages: [
602
- { role: "user", content: "Write a hello world in Python" },
603
  ],
604
  stream: true,
605
  });
606
 
607
  for await (const chunk of stream) {
608
  process.stdout.write(chunk.choices[0]?.delta?.content || "");
609
- }`;
610
-
611
- // Fetch models
612
- try {
613
- const modelsResp = await fetch('/v1/models');
614
- const modelsData = await modelsResp.json();
615
- const modelNames = modelsData.data.map(m => m.id);
616
- const defaultModel = modelNames.find(n => n.includes('5.3-codex')) || modelNames[0];
617
- document.getElementById('defaultModel').textContent = defaultModel + ' (default)';
618
- document.getElementById('allModels').textContent = modelNames.join(', ');
619
- } catch {}
620
-
621
- // Health status
622
- const health = await fetch('/health').then(r => r.json());
623
- document.getElementById('statusInfo').innerHTML =
624
- `<span class="status-badge status-ok">Connected</span> ` +
625
- `Server running at ${window.location.origin}`;
626
- } catch (err) {
627
- document.getElementById('statusInfo').textContent = 'Error: ' + err.message;
628
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
629
  }
630
 
631
  function switchTab(tab, el) {
@@ -635,24 +908,21 @@ for await (const chunk of stream) {
635
  document.getElementById('tab-' + tab).classList.add('active');
636
  }
637
 
638
- function copyText(id, btn) {
639
- const text = document.getElementById(id).textContent;
 
640
  navigator.clipboard.writeText(text);
641
- btn.textContent = 'Copied!';
642
  btn.classList.add('copied');
643
- setTimeout(() => { btn.textContent = 'Copy'; btn.classList.remove('copied'); }, 2000);
644
- }
645
-
646
- function copyElText(id, btn) {
647
- const text = document.getElementById(id).textContent;
648
- navigator.clipboard.writeText(text);
649
- btn.textContent = 'Copied!';
650
- setTimeout(() => { btn.textContent = 'Copy'; }, 1500);
651
  }
652
 
653
  function copyCode(id) {
654
  const el = document.getElementById(id);
655
- const code = el.textContent.replace('Copy', '').trim();
 
 
 
 
656
  navigator.clipboard.writeText(code);
657
  }
658
 
@@ -670,7 +940,7 @@ for await (const chunk of stream) {
670
  infoEl.style.display = 'none';
671
  errEl.style.display = 'none';
672
  btn.disabled = true;
673
- btn.textContent = 'Opening login...';
674
 
675
  try {
676
  const resp = await fetch('/auth/login-start', { method: 'POST' });
@@ -682,11 +952,10 @@ for await (const chunk of stream) {
682
 
683
  window.open(data.authUrl, 'oauth_add', 'width=600,height=700,scrollbars=yes');
684
 
685
- document.getElementById('addPasteSection').style.display = 'block';
686
- btn.textContent = 'Add Account via ChatGPT Login';
687
  btn.disabled = false;
688
 
689
- // Poll for new account (callback server handles same-machine)
690
  if (addPollTimer) clearInterval(addPollTimer);
691
  const prevCount = (await fetch('/auth/accounts').then(r => r.json())).accounts?.length || 0;
692
  addPollTimer = setInterval(async () => {
@@ -695,7 +964,7 @@ for await (const chunk of stream) {
695
  const d = await r.json();
696
  if ((d.accounts?.length || 0) > prevCount) {
697
  clearInterval(addPollTimer);
698
- document.getElementById('addPasteSection').style.display = 'none';
699
  infoEl.textContent = 'Account added successfully!';
700
  infoEl.style.display = 'block';
701
  await loadAccounts();
@@ -706,7 +975,7 @@ for await (const chunk of stream) {
706
  setTimeout(() => { if (addPollTimer) clearInterval(addPollTimer); }, 5 * 60 * 1000);
707
 
708
  } catch (err) {
709
- btn.textContent = 'Add Account via ChatGPT Login';
710
  btn.disabled = false;
711
  errEl.textContent = err.message;
712
  errEl.style.display = 'block';
@@ -728,7 +997,7 @@ for await (const chunk of stream) {
728
 
729
  const btn = document.getElementById('addRelayBtn');
730
  btn.disabled = true;
731
- btn.textContent = 'Exchanging...';
732
 
733
  try {
734
  const resp = await fetch('/auth/code-relay', {
@@ -740,7 +1009,7 @@ for await (const chunk of stream) {
740
 
741
  if (resp.ok && data.success) {
742
  if (addPollTimer) clearInterval(addPollTimer);
743
- document.getElementById('addPasteSection').style.display = 'none';
744
  document.getElementById('addCallbackInput').value = '';
745
  infoEl.textContent = 'Account added successfully!';
746
  infoEl.style.display = 'block';
@@ -759,113 +1028,8 @@ for await (const chunk of stream) {
759
  }
760
  }
761
 
762
- // ── Device Code Flow (dashboard) ─────────────────
763
- let dashDevicePollTimer = null;
764
-
765
- async function dashStartDeviceCode() {
766
- const btn = document.getElementById('dashDeviceBtn');
767
- const panel = document.getElementById('dashDevicePanel');
768
- const errEl = document.getElementById('dashDeviceError');
769
- const successEl = document.getElementById('dashDeviceSuccess');
770
- const statusEl = document.getElementById('dashDeviceStatus');
771
- errEl.style.display = 'none';
772
- successEl.style.display = 'none';
773
-
774
- btn.disabled = true;
775
- btn.textContent = 'Requesting code...';
776
-
777
- try {
778
- const resp = await fetch('/auth/device-login', { method: 'POST' });
779
- const data = await resp.json();
780
-
781
- if (!resp.ok || !data.userCode) {
782
- throw new Error(data.error || 'Failed to request device code');
783
- }
784
-
785
- panel.style.display = 'block';
786
- document.getElementById('dashUserCode').textContent = data.userCode;
787
- const link = document.getElementById('dashVerifyLink');
788
- link.href = data.verificationUriComplete || data.verificationUri;
789
- link.textContent = data.verificationUri;
790
- statusEl.innerHTML = '<span class="spinner"></span> Waiting for authorization...';
791
-
792
- btn.textContent = 'Device Code Login';
793
- btn.disabled = false;
794
-
795
- const interval = (data.interval || 5) * 1000;
796
- const deviceCode = data.deviceCode;
797
- if (dashDevicePollTimer) clearInterval(dashDevicePollTimer);
798
-
799
- dashDevicePollTimer = setInterval(async () => {
800
- try {
801
- const pollResp = await fetch('/auth/device-poll/' + encodeURIComponent(deviceCode));
802
- const pollData = await pollResp.json();
803
-
804
- if (pollData.success) {
805
- clearInterval(dashDevicePollTimer);
806
- statusEl.innerHTML = '';
807
- successEl.textContent = 'Account added successfully!';
808
- successEl.style.display = 'block';
809
- panel.style.display = 'none';
810
- await loadAccounts();
811
- await loadStatus();
812
- } else if (pollData.error) {
813
- clearInterval(dashDevicePollTimer);
814
- statusEl.innerHTML = '';
815
- errEl.textContent = pollData.error;
816
- errEl.style.display = 'block';
817
- }
818
- } catch {}
819
- }, interval);
820
-
821
- setTimeout(() => {
822
- if (dashDevicePollTimer) {
823
- clearInterval(dashDevicePollTimer);
824
- statusEl.textContent = 'Code expired. Please try again.';
825
- }
826
- }, (data.expiresIn || 900) * 1000);
827
-
828
- } catch (err) {
829
- btn.textContent = 'Device Code Login';
830
- btn.disabled = false;
831
- errEl.textContent = err.message;
832
- errEl.style.display = 'block';
833
- }
834
- }
835
-
836
- // ── CLI Token Import (dashboard) ─────────────────
837
- async function dashImportCli() {
838
- const btn = document.getElementById('dashCliBtn');
839
- const errEl = document.getElementById('dashCliError');
840
- const successEl = document.getElementById('dashCliSuccess');
841
- errEl.style.display = 'none';
842
- successEl.style.display = 'none';
843
- btn.disabled = true;
844
- btn.textContent = 'Importing...';
845
-
846
- try {
847
- const resp = await fetch('/auth/import-cli', { method: 'POST' });
848
- const data = await resp.json();
849
-
850
- if (resp.ok && data.success) {
851
- successEl.textContent = 'CLI token imported!';
852
- successEl.style.display = 'block';
853
- await loadAccounts();
854
- await loadStatus();
855
- } else {
856
- errEl.textContent = data.error || 'Failed to import CLI token';
857
- errEl.style.display = 'block';
858
- }
859
- } catch (err) {
860
- errEl.textContent = 'Network error: ' + err.message;
861
- errEl.style.display = 'block';
862
- } finally {
863
- btn.textContent = 'Import CLI Token';
864
- btn.disabled = false;
865
- }
866
- }
867
-
868
  loadStatus();
 
869
  loadAccounts();
870
  </script>
871
  </body>
 
4
  <meta charset="UTF-8">
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
  <title>Codex Proxy - Dashboard</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
10
  <style>
11
  * { margin: 0; padding: 0; box-sizing: border-box; }
12
  body {
13
+ font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
14
+ background: #f5f5f5;
15
+ color: #111827;
16
  min-height: 100vh;
 
17
  }
18
+
19
+ /* Top Nav */
20
+ .topnav {
21
+ background: #fff;
22
+ border-bottom: 1px solid #e5e7eb;
23
+ padding: 0 2rem;
24
+ height: 56px;
25
  display: flex;
26
  align-items: center;
27
  justify-content: space-between;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
  }
29
+ .topnav-left {
30
+ display: flex;
31
+ align-items: center;
32
+ gap: 0.75rem;
 
 
 
33
  }
34
+ .topnav-logo {
35
+ width: 28px;
36
+ height: 28px;
37
+ background: #e5e7eb;
38
+ border-radius: 7px;
39
+ display: flex;
40
+ align-items: center;
41
+ justify-content: center;
42
+ }
43
+ .topnav-logo svg {
44
+ width: 16px;
45
+ height: 16px;
46
+ color: #6b7280;
47
+ }
48
+ .topnav-title {
49
  font-size: 1rem;
50
+ font-weight: 600;
51
+ color: #111827;
52
  }
53
+ .topnav-right {
54
  display: flex;
55
  align-items: center;
56
  gap: 0.75rem;
 
57
  }
58
+ .badge-online {
59
+ display: inline-flex;
60
+ align-items: center;
61
+ gap: 6px;
62
+ padding: 4px 12px;
63
+ background: #ecfdf5;
64
+ color: #10a37f;
65
+ border-radius: 20px;
66
+ font-size: 0.8rem;
67
+ font-weight: 500;
68
  }
69
+ .badge-online .dot {
70
+ width: 7px;
71
+ height: 7px;
72
+ background: #10a37f;
73
+ border-radius: 50%;
 
 
 
 
74
  }
75
+ .btn-add {
76
+ display: inline-flex;
77
+ align-items: center;
78
+ gap: 0.4rem;
79
+ padding: 7px 16px;
80
+ background: #10a37f;
81
+ color: #fff;
82
+ border: none;
83
+ border-radius: 8px;
84
+ font-size: 0.85rem;
85
+ font-weight: 500;
86
+ font-family: inherit;
87
  cursor: pointer;
88
+ transition: background 0.2s;
 
89
  }
90
+ .btn-add:hover { background: #0e8f6e; }
91
+ .btn-add:disabled { opacity: 0.6; cursor: not-allowed; }
92
+
93
+ /* Main content */
94
+ .main {
95
+ max-width: 960px;
96
+ margin: 0 auto;
97
+ padding: 2rem;
98
+ }
99
+
100
+ /* Section headings */
101
+ .section-title {
102
+ font-size: 1.1rem;
103
+ font-weight: 600;
104
+ color: #111827;
105
  margin-bottom: 1rem;
106
  }
107
+
108
+ /* Accounts */
109
+ .accounts-grid {
110
+ display: flex;
111
+ gap: 1rem;
112
+ flex-wrap: wrap;
113
+ margin-bottom: 2rem;
 
 
 
 
114
  }
115
+ .account-card {
116
+ background: #fff;
117
+ border: 1px solid #e5e7eb;
118
+ border-radius: 12px;
119
+ padding: 1.25rem;
120
+ min-width: 280px;
121
+ flex: 1;
122
+ position: relative;
123
+ }
124
+ .account-top {
125
  display: flex;
126
+ align-items: flex-start;
127
+ gap: 0.75rem;
128
  margin-bottom: 1rem;
129
  }
130
+ .avatar {
131
+ width: 40px;
132
+ height: 40px;
133
+ border-radius: 50%;
134
+ display: flex;
135
+ align-items: center;
136
+ justify-content: center;
137
+ font-size: 1rem;
138
+ font-weight: 600;
139
+ color: #fff;
140
+ flex-shrink: 0;
141
+ }
142
+ .avatar-purple { background: #8b5cf6; }
143
+ .avatar-amber { background: #f59e0b; }
144
+ .avatar-blue { background: #3b82f6; }
145
+ .avatar-green { background: #10a37f; }
146
+ .avatar-red { background: #ef4444; }
147
+ .account-info { flex: 1; min-width: 0; }
148
+ .account-email {
149
+ font-size: 0.9rem;
150
+ font-weight: 600;
151
+ color: #111827;
152
+ white-space: nowrap;
153
+ overflow: hidden;
154
+ text-overflow: ellipsis;
155
+ }
156
+ .account-plan {
157
+ font-size: 0.8rem;
158
+ color: #6b7280;
159
  }
 
 
 
160
  .status-badge {
161
  display: inline-block;
162
+ padding: 2px 10px;
163
+ border-radius: 20px;
164
  font-size: 0.75rem;
165
  font-weight: 500;
166
  }
167
+ .status-ok { background: #ecfdf5; color: #10a37f; }
168
+ .status-expired { background: #fef2f2; color: #ef4444; }
169
+ .status-rate-limited { background: #fffbeb; color: #d97706; }
170
+ .status-refreshing { background: #eff6ff; color: #3b82f6; }
171
+ .status-disabled { background: #f3f4f6; color: #6b7280; }
172
+ .account-stats {
 
 
 
 
 
 
 
 
 
 
173
  display: flex;
174
+ gap: 1.5rem;
 
 
175
  }
176
+ .stat {
 
 
 
 
 
177
  display: flex;
178
+ flex-direction: column;
 
 
 
 
179
  }
180
+ .stat-label {
181
+ font-size: 0.7rem;
182
+ text-transform: uppercase;
183
+ letter-spacing: 0.05em;
184
+ color: #9ca3af;
185
+ margin-bottom: 2px;
186
  }
187
+ .stat-value {
188
+ font-size: 1rem;
189
+ font-weight: 600;
190
+ color: #111827;
 
 
 
 
 
 
191
  }
192
+ .btn-delete-account {
193
+ position: absolute;
194
+ top: 12px;
195
+ right: 12px;
196
  padding: 4px 10px;
197
+ background: #fff;
198
+ border: 1px solid #fca5a5;
199
  border-radius: 6px;
200
+ color: #ef4444;
201
+ font-size: 0.7rem;
202
+ font-family: inherit;
203
  cursor: pointer;
204
+ transition: all 0.2s;
205
  }
206
+ .btn-delete-account:hover { background: #ef4444; color: #fff; }
207
+ .empty-state {
208
+ text-align: center;
209
+ color: #9ca3af;
210
+ padding: 2rem;
211
+ font-size: 0.9rem;
212
+ background: #fff;
213
+ border: 1px solid #e5e7eb;
214
+ border-radius: 12px;
215
+ margin-bottom: 2rem;
216
  }
 
217
 
218
+ /* Add account modal area */
219
+ .add-section {
220
+ display: none;
221
+ background: #fff;
222
+ border: 1px solid #e5e7eb;
223
+ border-radius: 12px;
224
+ padding: 1.25rem;
225
+ margin-bottom: 2rem;
226
  }
227
+ .add-section.open { display: block; }
228
+ .add-section .hint {
229
+ font-size: 0.82rem;
230
+ color: #6b7280;
231
+ margin-bottom: 0.75rem;
232
+ line-height: 1.5;
233
+ }
234
+ .add-section .input-row {
235
+ display: flex;
236
  gap: 0.5rem;
237
+ }
238
+ .add-section input {
239
+ flex: 1;
240
+ padding: 10px 12px;
241
+ border: 1px solid #e5e7eb;
242
  border-radius: 8px;
243
+ font-size: 0.85rem;
244
+ font-family: inherit;
245
+ color: #111827;
246
+ outline: none;
247
+ }
248
+ .add-section input::placeholder { color: #9ca3af; }
249
+ .add-section input:focus { border-color: #10a37f; }
250
+ .btn-submit-add {
251
+ padding: 10px 16px;
252
+ border: 1px solid #e5e7eb;
253
+ border-radius: 8px;
254
+ background: #fff;
255
+ color: #111827;
256
+ font-size: 0.85rem;
257
  font-weight: 500;
258
+ font-family: inherit;
259
+ cursor: pointer;
260
+ white-space: nowrap;
261
  }
262
+ .btn-submit-add:hover { background: #f9fafb; }
263
+ .btn-submit-add:disabled { opacity: 0.5; cursor: not-allowed; }
264
+ .add-info {
265
+ color: #10a37f;
266
  font-size: 0.8rem;
267
  margin-top: 0.5rem;
268
+ display: none;
269
  }
270
+ .add-error {
271
+ color: #ef4444;
272
  font-size: 0.8rem;
273
  margin-top: 0.5rem;
 
 
274
  display: none;
 
 
 
 
 
275
  }
276
+
277
+ /* API Configuration */
278
+ .config-card {
279
+ background: #fff;
280
+ border: 1px solid #e5e7eb;
281
+ border-radius: 12px;
282
+ padding: 1.5rem;
283
+ margin-bottom: 2rem;
284
  }
285
+ .config-card .card-subtitle {
286
+ font-size: 0.82rem;
287
+ color: #6b7280;
288
+ margin-bottom: 1.25rem;
 
 
 
 
 
 
 
 
289
  }
290
+ .config-grid {
291
+ display: grid;
292
+ grid-template-columns: 1fr 1fr;
293
+ gap: 1rem;
294
+ margin-bottom: 1rem;
295
  }
296
+ .config-field label {
297
+ display: block;
298
  font-size: 0.75rem;
299
+ font-weight: 500;
300
+ color: #6b7280;
301
+ margin-bottom: 0.35rem;
302
  }
303
+ .config-value {
304
+ display: flex;
305
  align-items: center;
306
  gap: 0.5rem;
307
+ }
308
+ .config-value .val {
309
+ flex: 1;
310
+ padding: 9px 12px;
311
+ background: #f9fafb;
312
+ border: 1px solid #e5e7eb;
313
+ border-radius: 8px;
314
+ font-family: 'Inter', monospace;
315
+ font-size: 0.85rem;
316
+ color: #111827;
317
+ white-space: nowrap;
318
+ overflow: hidden;
319
+ text-overflow: ellipsis;
320
+ }
321
+ .config-value .val-muted {
322
+ color: #6b7280;
323
+ font-size: 0.8rem;
324
+ }
325
+ .btn-copy {
326
+ padding: 8px 10px;
327
+ background: #f9fafb;
328
+ border: 1px solid #e5e7eb;
329
+ border-radius: 8px;
330
+ color: #6b7280;
331
+ cursor: pointer;
332
+ flex-shrink: 0;
333
+ display: flex;
334
+ align-items: center;
335
+ transition: all 0.2s;
336
+ }
337
+ .btn-copy:hover { background: #e5e7eb; }
338
+ .btn-copy.copied { background: #ecfdf5; border-color: #10a37f; color: #10a37f; }
339
+ .btn-copy svg { width: 16px; height: 16px; }
340
+ .config-full {
341
+ grid-column: 1 / -1;
342
+ }
343
+ .val-input {
344
+ background: #fff;
345
+ outline: none;
346
+ transition: border-color 0.2s;
347
+ }
348
+ .val-input:focus {
349
+ border-color: #10a37f;
350
+ box-shadow: 0 0 0 2px rgba(16, 163, 127, 0.1);
351
+ }
352
+ select.val-input { cursor: pointer; appearance: auto; }
353
+ .btn-reset {
354
+ padding: 7px 16px;
355
+ background: #f9fafb;
356
+ border: 1px solid #e5e7eb;
357
  border-radius: 8px;
358
+ color: #6b7280;
359
+ font-size: 0.8rem;
360
+ font-family: inherit;
361
  cursor: pointer;
362
+ transition: all 0.2s;
363
+ }
364
+ .btn-reset:hover { background: #e5e7eb; color: #111827; }
365
+ .quota-section {
366
+ margin-top: 0.75rem;
367
+ padding-top: 0.75rem;
368
+ border-top: 1px solid #f3f4f6;
369
+ }
370
+ .quota-header {
371
+ display: flex;
372
+ justify-content: space-between;
373
+ align-items: center;
374
+ margin-bottom: 6px;
375
+ }
376
+ .quota-label {
377
+ font-size: 0.7rem;
378
+ text-transform: uppercase;
379
+ letter-spacing: 0.05em;
380
+ color: #9ca3af;
381
+ }
382
+ .quota-value {
383
+ font-size: 0.75rem;
384
+ font-weight: 500;
385
+ color: #111827;
386
+ }
387
+ .quota-bar {
388
+ width: 100%;
389
+ height: 6px;
390
+ background: #f3f4f6;
391
+ border-radius: 3px;
392
+ overflow: hidden;
393
+ }
394
+ .quota-bar-fill {
395
+ height: 100%;
396
+ border-radius: 3px;
397
+ transition: width 0.3s;
398
+ }
399
+ .quota-bar-green { background: #10a37f; }
400
+ .quota-bar-amber { background: #f59e0b; }
401
+ .quota-bar-red { background: #ef4444; }
402
+ .quota-reset {
403
+ font-size: 0.7rem;
404
+ color: #9ca3af;
405
+ margin-top: 4px;
406
+ }
407
+ .quota-limit-badge {
408
+ display: inline-block;
409
+ padding: 1px 8px;
410
+ border-radius: 10px;
411
+ font-size: 0.7rem;
412
+ font-weight: 500;
413
+ background: #fef2f2;
414
+ color: #ef4444;
415
+ }
416
+
417
+ /* Integration Examples */
418
+ .examples-card {
419
+ background: #fff;
420
+ border: 1px solid #e5e7eb;
421
+ border-radius: 12px;
422
+ padding: 1.5rem;
423
+ margin-bottom: 2rem;
424
+ }
425
+ .tabs {
426
+ display: flex;
427
+ gap: 0;
428
+ border-bottom: 1px solid #e5e7eb;
429
+ margin-bottom: 1rem;
430
+ }
431
+ .tab {
432
+ padding: 8px 16px;
433
+ background: none;
434
+ border: none;
435
+ border-bottom: 2px solid transparent;
436
+ color: #6b7280;
437
  font-size: 0.85rem;
438
+ font-family: inherit;
439
+ cursor: pointer;
440
+ transition: all 0.2s;
441
+ }
442
+ .tab:hover { color: #111827; }
443
+ .tab.active {
444
+ color: #111827;
445
  font-weight: 500;
446
+ border-bottom-color: #10a37f;
447
  }
448
+ .code-block {
449
+ background: #1e1e1e;
450
+ border-radius: 10px;
 
 
 
451
  padding: 1.25rem;
452
+ font-family: 'Courier New', Courier, monospace;
453
+ font-size: 0.82rem;
454
+ line-height: 1.7;
455
+ color: #d4d4d4;
456
+ overflow-x: auto;
457
+ white-space: pre;
458
+ position: relative;
459
+ }
460
+ .code-block .copy-code-btn {
461
+ position: absolute;
462
+ bottom: 10px;
463
+ right: 10px;
464
+ display: inline-flex;
465
+ align-items: center;
466
+ gap: 4px;
467
+ padding: 5px 12px;
468
+ background: rgba(255,255,255,0.1);
469
+ border: none;
470
+ border-radius: 6px;
471
+ color: #d4d4d4;
472
+ font-size: 0.75rem;
473
+ font-family: inherit;
474
+ cursor: pointer;
475
+ transition: background 0.2s;
476
  }
477
+ .code-block .copy-code-btn:hover { background: rgba(255,255,255,0.2); }
478
+ .tab-content { display: none; }
479
+ .tab-content.active { display: block; }
480
+
481
+ /* Footer */
482
+ .footer {
483
+ text-align: center;
484
+ padding: 1rem 2rem 2rem;
485
+ color: #9ca3af;
486
+ font-size: 0.8rem;
487
  }
488
+
 
489
  .spinner {
490
  display: inline-block;
491
  width: 14px;
 
494
  border-top-color: #fff;
495
  border-radius: 50%;
496
  animation: spin 0.8s linear infinite;
497
+ vertical-align: middle;
498
  }
499
+ .spinner-dark {
500
+ border-color: rgba(0,0,0,0.1);
501
+ border-top-color: #10a37f;
 
 
 
502
  }
503
+ @keyframes spin { to { transform: rotate(360deg); } }
504
  </style>
505
  </head>
506
  <body>
507
+ <!-- Top Navigation -->
508
+ <nav class="topnav">
509
+ <div class="topnav-left">
510
+ <div class="topnav-logo">
511
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
512
+ <circle cx="12" cy="12" r="10"/>
513
+ <path d="M8 12l2 2 4-4"/>
514
+ </svg>
515
  </div>
516
+ <span class="topnav-title">Codex Proxy</span>
517
+ </div>
518
+ <div class="topnav-right">
519
+ <span class="badge-online" id="serverBadge"><span class="dot"></span> Server Online</span>
520
+ <button class="btn-add" id="addAccountBtn" onclick="startAddAccount()">+ Add Account</button>
521
+ </div>
522
+ </nav>
523
+
524
+ <div class="main">
525
+ <!-- Add Account Section (hidden by default) -->
526
+ <div class="add-section" id="addSection">
527
+ <div class="hint">
528
+ If the popup shows an error or you're on a different machine,
529
+ copy the full callback URL and paste it below.
530
+ </div>
531
+ <div class="input-row">
532
+ <input type="text" id="addCallbackInput" placeholder="Paste callback URL">
533
+ <button class="btn-submit-add" id="addRelayBtn" onclick="submitAddRelay()">Submit</button>
534
+ </div>
535
+ <div class="add-info" id="addInfo"></div>
536
+ <div class="add-error" id="addError"></div>
537
+ </div>
538
+
539
+ <!-- Connected Accounts -->
540
+ <h2 class="section-title">Connected Accounts</h2>
541
+ <div class="accounts-grid" id="accountList">
542
+ <div class="empty-state" style="width:100%">Loading accounts...</div>
543
+ </div>
544
+
545
+ <!-- API Configuration -->
546
+ <div class="config-card">
547
+ <h2 class="section-title" style="margin-bottom:0.25rem">API Configuration</h2>
548
+ <p class="card-subtitle">Configure your client to use these settings.</p>
549
+ <div class="config-grid">
550
+ <div class="config-field">
551
+ <label>Base URL</label>
552
+ <div class="config-value">
553
+ <input type="text" class="val val-input" id="baseUrl" value="Loading...">
554
+ <button class="btn-copy" onclick="copyField('baseUrl', this)" title="Copy">
555
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
556
+ </button>
557
  </div>
 
 
 
558
  </div>
559
+ <div class="config-field">
560
+ <label>Default Model</label>
561
+ <div class="config-value">
562
+ <select class="val val-input" id="defaultModel">
563
+ <option value="codex">codex</option>
564
+ </select>
565
+ </div>
566
+ <div class="val-muted" style="margin-top:4px;font-size:0.75rem;color:#9ca3af">This model will be used if no specific model is requested in the API call.</div>
567
  </div>
568
+ <div class="config-field config-full">
569
+ <label>API Key</label>
570
+ <div class="config-value">
571
+ <input type="text" class="val val-input" id="apiKey" value="Loading...">
572
+ <button class="btn-copy" onclick="copyField('apiKey', this)" title="Copy">
573
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
574
+ </button>
575
+ </div>
576
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
577
  </div>
578
+ <button class="btn-reset" onclick="resetConfigDefaults()">Reset to defaults</button>
579
  </div>
580
 
581
+ <!-- Integration Examples -->
582
+ <div class="examples-card">
583
+ <h2 class="section-title">Integration Examples</h2>
584
  <div class="tabs">
585
+ <button class="tab active" onclick="switchTab('python', this)">Python</button>
586
+ <button class="tab" onclick="switchTab('curl', this)">cURL</button>
587
+ <button class="tab" onclick="switchTab('node', this)">Node.js</button>
588
  </div>
 
589
  <div class="tab-content active" id="tab-python">
590
+ <div class="code-block" id="code-python">Loading...
591
+ <button class="copy-code-btn" onclick="copyCode('code-python')">&#9112; Copy Code</button></div>
 
 
592
  </div>
593
  <div class="tab-content" id="tab-curl">
594
+ <div class="code-block" id="code-curl">Loading...
595
+ <button class="copy-code-btn" onclick="copyCode('code-curl')">&#9112; Copy Code</button></div>
 
 
596
  </div>
597
  <div class="tab-content" id="tab-node">
598
+ <div class="code-block" id="code-node">Loading...
599
+ <button class="copy-code-btn" onclick="copyCode('code-node')">&#9112; Copy Code</button></div>
 
 
600
  </div>
601
  </div>
 
 
 
 
 
602
  </div>
603
 
604
+ <footer class="footer">&copy; 2025 Codex Proxy. All rights reserved.</footer>
605
+
606
  <script>
607
  let authData = null;
608
 
609
+ const avatarColors = ['avatar-purple', 'avatar-amber', 'avatar-blue', 'avatar-green', 'avatar-red'];
610
+
611
  window.addEventListener('message', async (event) => {
612
  if (event.data?.type === 'oauth-callback-success') {
613
  if (addPollTimer) clearInterval(addPollTimer);
614
+ document.getElementById('addSection').classList.remove('open');
615
  const infoEl = document.getElementById('addInfo');
616
  infoEl.textContent = 'Account added successfully!';
617
  infoEl.style.display = 'block';
 
631
  return map[status] || 'status-disabled';
632
  }
633
 
634
+ function statusLabel(status) {
635
+ const map = {
636
+ active: 'Active',
637
+ expired: 'Expired',
638
+ rate_limited: 'Rate Limited',
639
+ refreshing: 'Refreshing',
640
+ disabled: 'Disabled',
641
+ };
642
+ return map[status] || status;
643
  }
644
 
645
  function escapeHtml(str) {
 
648
  return div.innerHTML;
649
  }
650
 
651
+ function formatNumber(n) {
652
+ if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
653
+ if (n >= 1000) return (n / 1000).toFixed(1) + 'k';
654
+ return String(n);
655
+ }
656
+
657
  async function loadAccounts() {
658
  try {
659
+ const resp = await fetch('/auth/accounts?quota=true');
660
  const data = await resp.json();
661
  const accounts = data.accounts || [];
662
  renderAccounts(accounts);
663
  } catch (err) {
664
  document.getElementById('accountList').innerHTML =
665
+ '<div class="empty-state" style="width:100%">Failed to load accounts: ' + escapeHtml(err.message) + '</div>';
666
  }
667
  }
668
 
 
670
  const container = document.getElementById('accountList');
671
 
672
  if (accounts.length === 0) {
673
+ container.innerHTML = '<div class="empty-state" style="width:100%">No accounts connected. Click "+ Add Account" to get started.</div>';
674
  return;
675
  }
676
 
677
  let html = '';
678
+ accounts.forEach((acct, i) => {
679
  const usage = acct.usage || {};
680
+ const email = acct.email || 'Unknown';
681
+ const initial = email.charAt(0).toUpperCase();
682
+ const colorClass = avatarColors[i % avatarColors.length];
683
+ const requests = usage.request_count ?? 0;
684
+ const tokens = (usage.input_tokens ?? 0) + (usage.output_tokens ?? 0);
685
+
686
+ // Build quota HTML if available
687
+ let quotaHtml = '';
688
+ const q = acct.quota;
689
+ if (q && q.rate_limit) {
690
+ const rl = q.rate_limit;
691
+ const pct = rl.used_percent != null ? Math.round(rl.used_percent) : null;
692
+ const barColor = pct == null ? 'quota-bar-green'
693
+ : pct >= 90 ? 'quota-bar-red'
694
+ : pct >= 60 ? 'quota-bar-amber'
695
+ : 'quota-bar-green';
696
+ const resetAt = rl.reset_at
697
+ ? new Date(rl.reset_at * 1000).toLocaleTimeString()
698
+ : null;
699
+
700
+ quotaHtml = `<div class="quota-section">
701
+ <div class="quota-header">
702
+ <span class="quota-label">Rate Limit</span>
703
+ ${rl.limit_reached
704
+ ? '<span class="quota-limit-badge">Limit Reached</span>'
705
+ : pct != null
706
+ ? `<span class="quota-value">${pct}% used</span>`
707
+ : '<span class="quota-value">OK</span>'}
708
  </div>
709
+ ${pct != null ? `<div class="quota-bar"><div class="quota-bar-fill ${barColor}" style="width:${pct}%"></div></div>` : ''}
710
+ ${resetAt ? `<div class="quota-reset">Resets at ${escapeHtml(resetAt)}</div>` : ''}
711
+ </div>`;
712
+ }
713
+
714
+ html += `<div class="account-card">
715
+ <button class="btn-delete-account" onclick="deleteAccount('${escapeHtml(acct.id)}')">Delete</button>
716
+ <div class="account-top">
717
+ <div class="avatar ${colorClass}">${initial}</div>
718
+ <div class="account-info">
719
+ <div class="account-email">${escapeHtml(email)}</div>
720
+ <div class="account-plan">${escapeHtml(acct.planType || 'Free Tier')}</div>
721
+ </div>
722
+ <span class="status-badge ${statusClass(acct.status)}">${escapeHtml(statusLabel(acct.status))}</span>
723
  </div>
724
+ <div class="account-stats">
725
+ <div class="stat">
726
+ <span class="stat-label">Requests</span>
727
+ <span class="stat-value">${formatNumber(requests)}</span>
728
+ </div>
729
+ <div class="stat">
730
+ <span class="stat-label">Tokens</span>
731
+ <span class="stat-value">${formatNumber(tokens)}</span>
732
+ </div>
 
 
 
733
  </div>
734
+ ${quotaHtml}
735
  </div>`;
736
+ });
737
  container.innerHTML = html;
738
  }
739
 
740
  async function deleteAccount(id) {
741
  if (!confirm('Remove this account?')) return;
 
742
  try {
743
  const resp = await fetch('/auth/accounts/' + encodeURIComponent(id), { method: 'DELETE' });
744
  if (!resp.ok) {
 
753
  }
754
  }
755
 
756
+ // --- Server defaults (set once by loadStatus) ---
757
+ let serverBaseUrl = '';
758
+ let serverApiKey = '';
759
+
760
  async function loadStatus() {
761
  try {
762
  const resp = await fetch('/auth/status');
 
767
  return;
768
  }
769
 
770
+ serverBaseUrl = `${window.location.origin}/v1`;
771
+ serverApiKey = authData.proxy_api_key || 'any-string';
772
+ document.getElementById('baseUrl').value = serverBaseUrl;
773
+ document.getElementById('apiKey').value = serverApiKey;
774
+
775
+ await populateModelDropdown();
776
+ restoreOverrides();
777
+ updateCodeExamples();
778
+ } catch (err) {
779
+ console.error('Status load error:', err);
780
+ }
781
+ }
782
+
783
+ async function populateModelDropdown() {
784
+ const sel = document.getElementById('defaultModel');
785
+ try {
786
+ const resp = await fetch('/v1/models');
787
+ const data = await resp.json();
788
+ const models = data.data.map(m => m.id);
789
+ if (models.length > 0) {
790
+ sel.innerHTML = '';
791
+ models.forEach(id => {
792
+ const opt = document.createElement('option');
793
+ opt.value = id;
794
+ opt.textContent = id;
795
+ sel.appendChild(opt);
796
+ });
797
+ const preferred = models.find(n => n.includes('5.3-codex'));
798
+ if (preferred) sel.value = preferred;
799
+ }
800
+ } catch {
801
+ sel.innerHTML = '<option value="codex">codex</option>';
802
+ }
803
+ }
804
+
805
+ function updateCodeExamples() {
806
+ const baseUrl = escapeHtml(document.getElementById('baseUrl').value);
807
+ const apiKey = escapeHtml(document.getElementById('apiKey').value || 'any-string');
808
+ const model = escapeHtml(document.getElementById('defaultModel').value);
809
+
810
+ document.getElementById('code-python').innerHTML =
811
+ `import openai
812
 
813
+ openai.api_base = "${baseUrl}"
814
+ openai.api_key = "${apiKey}"
815
+ response = openai.ChatCompletion.create(
816
+ model="${model}",
817
  messages=[
818
+ {"role": "user", "content": "Explain quantum computing in simple terms"}
819
+ ]
 
820
  )
821
 
822
+ print(response.choices[0].message.content)
823
+ <button class="copy-code-btn" onclick="copyCode('code-python')">&#9112; Copy Code</button>`;
 
824
 
825
+ document.getElementById('code-curl').innerHTML =
 
826
  `curl ${baseUrl}/chat/completions \\
827
  -H "Content-Type: application/json" \\
828
  -H "Authorization: Bearer ${apiKey}" \\
829
  -d '{
830
+ "model": "${model}",
831
  "messages": [
832
+ {"role": "user", "content": "Explain quantum computing in simple terms"}
833
+ ]
834
+ }'
835
+ <button class="copy-code-btn" onclick="copyCode('code-curl')">&#9112; Copy Code</button>`;
836
 
837
+ document.getElementById('code-node').innerHTML =
 
838
  `import OpenAI from "openai";
839
 
840
  const client = new OpenAI({
 
843
  });
844
 
845
  const stream = await client.chat.completions.create({
846
+ model: "${model}",
847
  messages: [
848
+ { role: "user", content: "Explain quantum computing in simple terms" },
849
  ],
850
  stream: true,
851
  });
852
 
853
  for await (const chunk of stream) {
854
  process.stdout.write(chunk.choices[0]?.delta?.content || "");
855
+ }
856
+ <button class="copy-code-btn" onclick="copyCode('code-node')">&#9112; Copy Code</button>`;
857
+ }
858
+
859
+ function saveOverrides() {
860
+ try {
861
+ const overrides = {
862
+ baseUrl: document.getElementById('baseUrl').value,
863
+ apiKey: document.getElementById('apiKey').value,
864
+ model: document.getElementById('defaultModel').value,
865
+ };
866
+ localStorage.setItem('codex-proxy-config-overrides', JSON.stringify(overrides));
867
+ } catch {}
868
+ }
869
+
870
+ function restoreOverrides() {
871
+ try {
872
+ const raw = localStorage.getItem('codex-proxy-config-overrides');
873
+ if (!raw) return;
874
+ const overrides = JSON.parse(raw);
875
+ if (overrides.baseUrl) document.getElementById('baseUrl').value = overrides.baseUrl;
876
+ if (overrides.apiKey) document.getElementById('apiKey').value = overrides.apiKey;
877
+ if (overrides.model) {
878
+ const sel = document.getElementById('defaultModel');
879
+ // Only apply if the option exists in the dropdown
880
+ const opts = Array.from(sel.options).map(o => o.value);
881
+ if (opts.includes(overrides.model)) sel.value = overrides.model;
882
+ }
883
+ } catch {}
884
+ }
885
+
886
+ function resetConfigDefaults() {
887
+ try { localStorage.removeItem('codex-proxy-config-overrides'); } catch {}
888
+ document.getElementById('baseUrl').value = serverBaseUrl;
889
+ document.getElementById('apiKey').value = serverApiKey;
890
+ // Re-select preferred model
891
+ const sel = document.getElementById('defaultModel');
892
+ const preferred = Array.from(sel.options).find(o => o.value.includes('5.3-codex'));
893
+ if (preferred) sel.value = preferred.value;
894
+ else if (sel.options.length) sel.selectedIndex = 0;
895
+ updateCodeExamples();
896
+ }
897
+
898
+ function setupReactiveBindings() {
899
+ document.getElementById('baseUrl').addEventListener('input', () => { updateCodeExamples(); saveOverrides(); });
900
+ document.getElementById('apiKey').addEventListener('input', () => { updateCodeExamples(); saveOverrides(); });
901
+ document.getElementById('defaultModel').addEventListener('change', () => { updateCodeExamples(); saveOverrides(); });
902
  }
903
 
904
  function switchTab(tab, el) {
 
908
  document.getElementById('tab-' + tab).classList.add('active');
909
  }
910
 
911
+ function copyField(id, btn) {
912
+ const el = document.getElementById(id);
913
+ const text = el.value !== undefined ? el.value : el.textContent;
914
  navigator.clipboard.writeText(text);
 
915
  btn.classList.add('copied');
916
+ setTimeout(() => btn.classList.remove('copied'), 2000);
 
 
 
 
 
 
 
917
  }
918
 
919
  function copyCode(id) {
920
  const el = document.getElementById(id);
921
+ // Get text content excluding the copy button
922
+ const clone = el.cloneNode(true);
923
+ const btns = clone.querySelectorAll('.copy-code-btn');
924
+ btns.forEach(b => b.remove());
925
+ const code = clone.textContent.trim();
926
  navigator.clipboard.writeText(code);
927
  }
928
 
 
940
  infoEl.style.display = 'none';
941
  errEl.style.display = 'none';
942
  btn.disabled = true;
943
+ btn.textContent = 'Opening...';
944
 
945
  try {
946
  const resp = await fetch('/auth/login-start', { method: 'POST' });
 
952
 
953
  window.open(data.authUrl, 'oauth_add', 'width=600,height=700,scrollbars=yes');
954
 
955
+ document.getElementById('addSection').classList.add('open');
956
+ btn.textContent = '+ Add Account';
957
  btn.disabled = false;
958
 
 
959
  if (addPollTimer) clearInterval(addPollTimer);
960
  const prevCount = (await fetch('/auth/accounts').then(r => r.json())).accounts?.length || 0;
961
  addPollTimer = setInterval(async () => {
 
964
  const d = await r.json();
965
  if ((d.accounts?.length || 0) > prevCount) {
966
  clearInterval(addPollTimer);
967
+ document.getElementById('addSection').classList.remove('open');
968
  infoEl.textContent = 'Account added successfully!';
969
  infoEl.style.display = 'block';
970
  await loadAccounts();
 
975
  setTimeout(() => { if (addPollTimer) clearInterval(addPollTimer); }, 5 * 60 * 1000);
976
 
977
  } catch (err) {
978
+ btn.textContent = '+ Add Account';
979
  btn.disabled = false;
980
  errEl.textContent = err.message;
981
  errEl.style.display = 'block';
 
997
 
998
  const btn = document.getElementById('addRelayBtn');
999
  btn.disabled = true;
1000
+ btn.textContent = 'Submitting...';
1001
 
1002
  try {
1003
  const resp = await fetch('/auth/code-relay', {
 
1009
 
1010
  if (resp.ok && data.success) {
1011
  if (addPollTimer) clearInterval(addPollTimer);
1012
+ document.getElementById('addSection').classList.remove('open');
1013
  document.getElementById('addCallbackInput').value = '';
1014
  infoEl.textContent = 'Account added successfully!';
1015
  infoEl.style.display = 'block';
 
1028
  }
1029
  }
1030
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1031
  loadStatus();
1032
+ setupReactiveBindings();
1033
  loadAccounts();
1034
  </script>
1035
  </body>
src/proxy/codex-api.ts CHANGED
@@ -94,9 +94,13 @@ export class CodexApi {
94
  buildHeaders(this.token, this.accountId),
95
  );
96
  headers["Accept"] = "application/json";
 
 
 
 
97
 
98
  // Build curl args
99
- const args = ["-s", "--max-time", "15"];
100
  for (const [key, value] of Object.entries(headers)) {
101
  args.push("-H", `${key}: ${value}`);
102
  }
 
94
  buildHeaders(this.token, this.accountId),
95
  );
96
  headers["Accept"] = "application/json";
97
+ // Remove Accept-Encoding — let curl negotiate its own supported encodings
98
+ // via --compressed. Passing unsupported encodings (br, zstd) causes curl
99
+ // to fail when it can't decompress the response.
100
+ delete headers["Accept-Encoding"];
101
 
102
  // Build curl args
103
+ const args = ["-s", "--compressed", "--max-time", "15"];
104
  for (const [key, value] of Object.entries(headers)) {
105
  args.push("-H", `${key}: ${value}`);
106
  }