icebear0828 Claude Opus 4.6 commited on
Commit
d85b21d
·
1 Parent(s): 34cd3f0

feat: auto proxy detection for curl + tighten dashboard layout

Browse files

- Add tls.proxy_url config + HTTPS_PROXY env var support
- Inject proxy args into both curlPost() and getUsage() curl calls
- Explicitly set Accept-Encoding to gzip,deflate for system curl compat
- Include setup-curl.ts postinstall script (was gitignored)
- Tighten dashboard font sizes and spacing for compact layout
- Rename dashboard1.html → dashboard.html, fix web.ts reference

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

.gitignore CHANGED
@@ -9,11 +9,10 @@ bin/
9
  tmp/
10
  .claude/settings.local.json
11
 
12
- # Maintenance scripts (private — not for public repo)
13
  scripts/extract-fingerprint.ts
14
  scripts/full-update.ts
15
  scripts/apply-update.ts
16
- scripts/setup-curl.ts
17
  scripts/check-update.ts
18
  scripts/test-*.ts
19
  scripts/cron-update.sh
 
9
  tmp/
10
  .claude/settings.local.json
11
 
12
+ # Maintenance scripts (private)
13
  scripts/extract-fingerprint.ts
14
  scripts/full-update.ts
15
  scripts/apply-update.ts
 
16
  scripts/check-update.ts
17
  scripts/test-*.ts
18
  scripts/cron-update.sh
config/default.yaml CHANGED
@@ -41,6 +41,9 @@ tls:
41
  curl_binary: "auto"
42
  # Chrome profile for --impersonate flag (auto-detected when curl-impersonate supports it)
43
  impersonate_profile: "chrome136"
 
 
 
44
 
45
  streaming:
46
  status_as_content: false
 
41
  curl_binary: "auto"
42
  # Chrome profile for --impersonate flag (auto-detected when curl-impersonate supports it)
43
  impersonate_profile: "chrome136"
44
+ # HTTP/SOCKS5 proxy for curl requests to chatgpt.com
45
+ # Example: "http://127.0.0.1:7890" Also respects HTTPS_PROXY env var
46
+ proxy_url: null
47
 
48
  streaming:
49
  status_as_content: false
public/dashboard.html CHANGED
@@ -1,850 +1,532 @@
1
  <!DOCTYPE html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="UTF-8">
5
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
- <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: 0.9rem;
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: 6px 14px;
80
- background: #10a37f;
81
- color: #fff;
82
- border: none;
83
- border-radius: 8px;
84
- font-size: 0.78rem;
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: 0.95rem;
103
- font-weight: 600;
104
- color: #111827;
105
- margin-bottom: 0.75rem;
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: 1rem;
120
- min-width: 260px;
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: 34px;
132
- height: 34px;
133
- border-radius: 50%;
134
- display: flex;
135
- align-items: center;
136
- justify-content: center;
137
- font-size: 0.85rem;
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.82rem;
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.75rem;
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: 0.85rem;
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.25rem;
283
- margin-bottom: 1.5rem;
284
- }
285
- .config-card .card-subtitle {
286
- font-size: 0.78rem;
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: 7px 10px;
311
- background: #f9fafb;
312
- border: 1px solid #e5e7eb;
313
- border-radius: 8px;
314
- font-family: 'SF Mono', 'Consolas', monospace;
315
- font-size: 0.78rem;
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.25rem;
423
- margin-bottom: 1.5rem;
424
- }
425
- .tabs {
426
- display: flex;
427
- gap: 0;
428
- border-bottom: 1px solid #e5e7eb;
429
- margin-bottom: 1rem;
430
- }
431
- .tab {
432
- padding: 6px 14px;
433
- background: none;
434
- border: none;
435
- border-bottom: 2px solid transparent;
436
- color: #6b7280;
437
- font-size: 0.8rem;
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: 1rem 1.25rem;
452
- font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
453
- font-size: 0.75rem;
454
- line-height: 1.5;
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
- .tabs-protocol {
479
- margin-bottom: 0;
480
- border-bottom: none;
481
- }
482
- .tabs-protocol .tab {
483
- font-weight: 600;
484
- font-size: 0.82rem;
485
- }
486
- .tabs-protocol .tab.active {
487
- border-bottom-color: #10a37f;
488
- }
489
- .tabs-lang {
490
- border-bottom: 1px solid #e5e7eb;
491
- margin-bottom: 0.75rem;
492
- }
493
- .tabs-lang .tab {
494
- font-size: 0.75rem;
495
- padding: 5px 12px;
496
- color: #9ca3af;
497
- }
498
- .tabs-lang .tab.active {
499
- color: #111827;
500
- border-bottom-color: #6b7280;
501
- }
502
- .code-panel { display: none; }
503
- .code-panel.active { display: block; }
504
-
505
- /* Footer */
506
- .footer {
507
- text-align: center;
508
- padding: 1rem 2rem 2rem;
509
- color: #9ca3af;
510
- font-size: 0.8rem;
511
  }
512
 
513
- .spinner {
514
- display: inline-block;
515
- width: 14px;
516
- height: 14px;
517
- border: 2px solid rgba(255,255,255,0.3);
518
- border-top-color: #fff;
519
- border-radius: 50%;
520
- animation: spin 0.8s linear infinite;
521
- vertical-align: middle;
522
- }
523
- .spinner-dark {
524
- border-color: rgba(0,0,0,0.1);
525
- border-top-color: #10a37f;
526
- }
527
- @keyframes spin { to { transform: rotate(360deg); } }
528
- </style>
529
- </head>
530
- <body>
531
- <!-- Top Navigation -->
532
- <nav class="topnav">
533
- <div class="topnav-left">
534
- <div class="topnav-logo">
535
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
536
- <circle cx="12" cy="12" r="10"/>
537
- <path d="M8 12l2 2 4-4"/>
538
- </svg>
539
- </div>
540
- <span class="topnav-title">Codex Proxy</span>
541
- </div>
542
- <div class="topnav-right">
543
- <span class="badge-online" id="serverBadge"><span class="dot"></span> Server Online</span>
544
- <button class="btn-add" id="addAccountBtn" onclick="startAddAccount()">+ Add Account</button>
545
- </div>
546
- </nav>
547
-
548
- <div class="main">
549
- <!-- Add Account Section (hidden by default) -->
550
- <div class="add-section" id="addSection">
551
- <div class="hint">
552
- If the popup shows an error or you're on a different machine,
553
- copy the full callback URL and paste it below.
554
- </div>
555
- <div class="input-row">
556
- <input type="text" id="addCallbackInput" placeholder="Paste callback URL">
557
- <button class="btn-submit-add" id="addRelayBtn" onclick="submitAddRelay()">Submit</button>
558
- </div>
559
- <div class="add-info" id="addInfo"></div>
560
- <div class="add-error" id="addError"></div>
561
- </div>
562
-
563
- <!-- Connected Accounts -->
564
- <h2 class="section-title">Connected Accounts</h2>
565
- <div class="accounts-grid" id="accountList">
566
- <div class="empty-state" style="width:100%">Loading accounts...</div>
567
- </div>
568
-
569
- <!-- API Configuration -->
570
- <div class="config-card">
571
- <h2 class="section-title" style="margin-bottom:0.25rem">API Configuration</h2>
572
- <p class="card-subtitle">Configure your client to use these settings.</p>
573
- <div class="config-grid">
574
- <div class="config-field">
575
- <label>Base URL</label>
576
- <div class="config-value">
577
- <input type="text" class="val val-input" id="baseUrl" value="Loading...">
578
- <button class="btn-copy" onclick="copyField('baseUrl', this)" title="Copy">
579
- <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>
580
- </button>
581
  </div>
582
  </div>
583
- <div class="config-field">
584
- <label>Default Model</label>
585
- <div class="config-value">
586
- <select class="val val-input" id="defaultModel">
587
- <option value="codex">codex</option>
588
- </select>
589
- </div>
590
- <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>
591
  </div>
592
- <div class="config-field config-full">
593
- <label>API Key</label>
594
- <div class="config-value">
595
- <input type="text" class="val val-input" id="apiKey" value="Loading...">
596
- <button class="btn-copy" onclick="copyField('apiKey', this)" title="Copy">
597
- <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>
598
- </button>
599
- </div>
600
  </div>
601
  </div>
602
- <button class="btn-reset" onclick="resetConfigDefaults()">Reset to defaults</button>
603
- </div>
604
-
605
- <!-- Integration Examples -->
606
- <div class="examples-card">
607
- <h2 class="section-title">Integration Examples</h2>
608
- <!-- Protocol tabs (top level) -->
609
- <div class="tabs tabs-protocol">
610
- <button class="tab active" onclick="switchProtocol('openai', this)">OpenAI</button>
611
- <button class="tab" onclick="switchProtocol('anthropic', this)">Anthropic</button>
612
- <button class="tab" onclick="switchProtocol('gemini', this)">Gemini</button>
613
- </div>
614
- <!-- Language tabs (sub level) -->
615
- <div class="tabs tabs-lang">
616
- <button class="tab active" data-lang="python" onclick="switchLang('python', this)">Python</button>
617
- <button class="tab" data-lang="curl" onclick="switchLang('curl', this)">cURL</button>
618
- <button class="tab" data-lang="node" onclick="switchLang('node', this)">Node.js</button>
619
- </div>
620
- <!-- Code panels: {protocol}-{lang} -->
621
- <div class="code-panel active" id="panel-openai-python"><div class="code-block" id="code-openai-python">Loading...<button class="copy-code-btn" onclick="copyCode('code-openai-python')">&#9112; Copy</button></div></div>
622
- <div class="code-panel" id="panel-openai-curl"><div class="code-block" id="code-openai-curl">Loading...<button class="copy-code-btn" onclick="copyCode('code-openai-curl')">&#9112; Copy</button></div></div>
623
- <div class="code-panel" id="panel-openai-node"><div class="code-block" id="code-openai-node">Loading...<button class="copy-code-btn" onclick="copyCode('code-openai-node')">&#9112; Copy</button></div></div>
624
- <div class="code-panel" id="panel-anthropic-python"><div class="code-block" id="code-anthropic-python">Loading...<button class="copy-code-btn" onclick="copyCode('code-anthropic-python')">&#9112; Copy</button></div></div>
625
- <div class="code-panel" id="panel-anthropic-curl"><div class="code-block" id="code-anthropic-curl">Loading...<button class="copy-code-btn" onclick="copyCode('code-anthropic-curl')">&#9112; Copy</button></div></div>
626
- <div class="code-panel" id="panel-anthropic-node"><div class="code-block" id="code-anthropic-node">Loading...<button class="copy-code-btn" onclick="copyCode('code-anthropic-node')">&#9112; Copy</button></div></div>
627
- <div class="code-panel" id="panel-gemini-python"><div class="code-block" id="code-gemini-python">Loading...<button class="copy-code-btn" onclick="copyCode('code-gemini-python')">&#9112; Copy</button></div></div>
628
- <div class="code-panel" id="panel-gemini-curl"><div class="code-block" id="code-gemini-curl">Loading...<button class="copy-code-btn" onclick="copyCode('code-gemini-curl')">&#9112; Copy</button></div></div>
629
- <div class="code-panel" id="panel-gemini-node"><div class="code-block" id="code-gemini-node">Loading...<button class="copy-code-btn" onclick="copyCode('code-gemini-node')">&#9112; Copy</button></div></div>
630
- </div>
631
- </div>
632
-
633
- <footer class="footer">&copy; 2025 Codex Proxy. All rights reserved.</footer>
634
-
635
- <script>
636
- let authData = null;
637
-
638
- const avatarColors = ['avatar-purple', 'avatar-amber', 'avatar-blue', 'avatar-green', 'avatar-red'];
639
-
640
- window.addEventListener('message', async (event) => {
641
- if (event.data?.type === 'oauth-callback-success') {
642
- if (addPollTimer) clearInterval(addPollTimer);
643
- document.getElementById('addSection').classList.remove('open');
644
- const infoEl = document.getElementById('addInfo');
645
- infoEl.textContent = 'Account added successfully!';
646
- infoEl.style.display = 'block';
647
- await loadAccounts();
648
- await loadStatus();
649
- }
650
- });
651
-
652
- function statusClass(status) {
653
- const map = {
654
- active: 'status-ok',
655
- expired: 'status-expired',
656
- rate_limited: 'status-rate-limited',
657
- refreshing: 'status-refreshing',
658
- disabled: 'status-disabled',
659
- };
660
- return map[status] || 'status-disabled';
661
- }
662
-
663
- function statusLabel(status) {
664
- const map = {
665
- active: 'Active',
666
- expired: 'Expired',
667
- rate_limited: 'Rate Limited',
668
- refreshing: 'Refreshing',
669
- disabled: 'Disabled',
670
- };
671
- return map[status] || status;
672
- }
673
-
674
- function escapeHtml(str) {
675
- const div = document.createElement('div');
676
- div.textContent = str;
677
- return div.innerHTML;
678
- }
679
-
680
- function formatNumber(n) {
681
- if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
682
- if (n >= 1000) return (n / 1000).toFixed(1) + 'k';
683
- return String(n);
684
- }
685
-
686
- async function loadAccounts() {
687
  try {
688
- const resp = await fetch('/auth/accounts?quota=true');
689
- const data = await resp.json();
690
- const accounts = data.accounts || [];
691
- renderAccounts(accounts);
692
- } catch (err) {
693
- document.getElementById('accountList').innerHTML =
694
- '<div class="empty-state" style="width:100%">Failed to load accounts: ' + escapeHtml(err.message) + '</div>';
695
- }
696
- }
697
-
698
- function renderAccounts(accounts) {
699
- const container = document.getElementById('accountList');
700
-
701
- if (accounts.length === 0) {
702
- container.innerHTML = '<div class="empty-state" style="width:100%">No accounts connected. Click "+ Add Account" to get started.</div>';
703
- return;
704
- }
705
-
706
- let html = '';
707
- accounts.forEach((acct, i) => {
708
- const usage = acct.usage || {};
709
- const email = acct.email || 'Unknown';
710
- const initial = email.charAt(0).toUpperCase();
711
- const colorClass = avatarColors[i % avatarColors.length];
712
- const requests = usage.request_count ?? 0;
713
- const tokens = (usage.input_tokens ?? 0) + (usage.output_tokens ?? 0);
714
-
715
- // Build quota HTML if available
716
- let quotaHtml = '';
717
- const q = acct.quota;
718
- if (q && q.rate_limit) {
719
- const rl = q.rate_limit;
720
- const pct = rl.used_percent != null ? Math.round(rl.used_percent) : null;
721
- const barColor = pct == null ? 'quota-bar-green'
722
- : pct >= 90 ? 'quota-bar-red'
723
- : pct >= 60 ? 'quota-bar-amber'
724
- : 'quota-bar-green';
725
- const resetAt = rl.reset_at
726
- ? new Date(rl.reset_at * 1000).toLocaleTimeString()
727
- : null;
728
-
729
- quotaHtml = `<div class="quota-section">
730
- <div class="quota-header">
731
- <span class="quota-label">Rate Limit</span>
732
- ${rl.limit_reached
733
- ? '<span class="quota-limit-badge">Limit Reached</span>'
734
- : pct != null
735
- ? `<span class="quota-value">${pct}% used</span>`
736
- : '<span class="quota-value">OK</span>'}
737
- </div>
738
- ${pct != null ? `<div class="quota-bar"><div class="quota-bar-fill ${barColor}" style="width:${pct}%"></div></div>` : ''}
739
- ${resetAt ? `<div class="quota-reset">Resets at ${escapeHtml(resetAt)}</div>` : ''}
740
- </div>`;
741
  }
742
-
743
- html += `<div class="account-card">
744
- <button class="btn-delete-account" onclick="deleteAccount('${escapeHtml(acct.id)}')">Delete</button>
745
- <div class="account-top">
746
- <div class="avatar ${colorClass}">${initial}</div>
747
- <div class="account-info">
748
- <div class="account-email">${escapeHtml(email)}</div>
749
- <div class="account-plan">${escapeHtml(acct.planType || 'Free Tier')}</div>
750
- </div>
751
- <span class="status-badge ${statusClass(acct.status)}">${escapeHtml(statusLabel(acct.status))}</span>
752
- </div>
753
- <div class="account-stats">
754
- <div class="stat">
755
- <span class="stat-label">Requests</span>
756
- <span class="stat-value">${formatNumber(requests)}</span>
757
- </div>
758
- <div class="stat">
759
- <span class="stat-label">Tokens</span>
760
- <span class="stat-value">${formatNumber(tokens)}</span>
761
- </div>
762
- </div>
763
- ${quotaHtml}
764
- </div>`;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
765
  });
766
- container.innerHTML = html;
767
- }
768
-
769
- async function deleteAccount(id) {
770
- if (!confirm('Remove this account?')) return;
771
- try {
772
- const resp = await fetch('/auth/accounts/' + encodeURIComponent(id), { method: 'DELETE' });
773
- if (!resp.ok) {
774
- const data = await resp.json();
775
- alert(data.error || 'Failed to delete account.');
776
- return;
777
- }
778
- await loadAccounts();
779
- await loadStatus();
780
- } catch (err) {
781
- alert('Network error: ' + err.message);
782
- }
783
  }
 
 
 
 
784
 
785
- // --- Server defaults (set once by loadStatus) ---
786
- let serverBaseUrl = '';
787
- let serverApiKey = '';
 
 
 
788
 
789
- async function loadStatus() {
790
- try {
791
- const resp = await fetch('/auth/status');
792
- authData = await resp.json();
793
-
794
- if (!authData.authenticated) {
795
- window.location.href = '/';
796
- return;
797
- }
798
-
799
- serverBaseUrl = `${window.location.origin}/v1`;
800
- serverApiKey = authData.proxy_api_key || 'any-string';
801
- document.getElementById('baseUrl').value = serverBaseUrl;
802
- document.getElementById('apiKey').value = serverApiKey;
803
-
804
- await populateModelDropdown();
805
- restoreOverrides();
806
- updateCodeExamples();
807
- } catch (err) {
808
- console.error('Status load error:', err);
809
- }
810
- }
811
-
812
- async function populateModelDropdown() {
813
- const sel = document.getElementById('defaultModel');
814
- try {
815
- const resp = await fetch('/v1/models');
816
- const data = await resp.json();
817
- const models = data.data.map(m => m.id);
818
- if (models.length > 0) {
819
- sel.innerHTML = '';
820
- models.forEach(id => {
821
- const opt = document.createElement('option');
822
- opt.value = id;
823
- opt.textContent = id;
824
- sel.appendChild(opt);
825
- });
826
- const preferred = models.find(n => n.includes('5.3-codex'));
827
- if (preferred) sel.value = preferred;
828
- }
829
- } catch {
830
- sel.innerHTML = '<option value="codex">codex</option>';
831
- }
832
- }
833
-
834
- function setCode(id, code) {
835
- const el = document.getElementById(id);
836
- if (el) el.innerHTML = code + `\n<button class="copy-code-btn" onclick="copyCode('${id}')">&#9112; Copy</button>`;
837
- }
838
-
839
- function updateCodeExamples() {
840
- const baseUrl = escapeHtml(document.getElementById('baseUrl').value);
841
- const apiKey = escapeHtml(document.getElementById('apiKey').value || 'any-string');
842
- const model = escapeHtml(document.getElementById('defaultModel').value);
843
- const origin = escapeHtml(window.location.origin);
844
-
845
- // ── OpenAI ──
846
- setCode('code-openai-python',
847
- `from openai import OpenAI
848
 
849
  client = OpenAI(
850
  base_url="${baseUrl}",
@@ -855,19 +537,17 @@ response = client.chat.completions.create(
855
  model="${model}",
856
  messages=[{"role": "user", "content": "Hello"}],
857
  )
858
- print(response.choices[0].message.content)`);
859
 
860
- setCode('code-openai-curl',
861
- `curl ${baseUrl}/chat/completions \\
862
  -H "Content-Type: application/json" \\
863
  -H "Authorization: Bearer ${apiKey}" \\
864
  -d '{
865
  "model": "${model}",
866
  "messages": [{"role": "user", "content": "Hello"}]
867
- }'`);
868
 
869
- setCode('code-openai-node',
870
- `import OpenAI from "openai";
871
 
872
  const client = new OpenAI({
873
  baseURL: "${baseUrl}",
@@ -881,11 +561,9 @@ const stream = await client.chat.completions.create({
881
  });
882
  for await (const chunk of stream) {
883
  process.stdout.write(chunk.choices[0]?.delta?.content || "");
884
- }`);
885
 
886
- // ── Anthropic ──
887
- setCode('code-anthropic-python',
888
- `import anthropic
889
 
890
  client = anthropic.Anthropic(
891
  base_url="${origin}/v1",
@@ -897,10 +575,9 @@ message = client.messages.create(
897
  max_tokens=1024,
898
  messages=[{"role": "user", "content": "Hello"}],
899
  )
900
- print(message.content[0].text)`);
901
 
902
- setCode('code-anthropic-curl',
903
- `curl ${origin}/v1/messages \\
904
  -H "Content-Type: application/json" \\
905
  -H "x-api-key: ${apiKey}" \\
906
  -H "anthropic-version: 2023-06-01" \\
@@ -908,10 +585,9 @@ print(message.content[0].text)`);
908
  "model": "claude-sonnet-4-20250514",
909
  "max_tokens": 1024,
910
  "messages": [{"role": "user", "content": "Hello"}]
911
- }'`);
912
 
913
- setCode('code-anthropic-node',
914
- `import Anthropic from "@anthropic-ai/sdk";
915
 
916
  const client = new Anthropic({
917
  baseURL: "${origin}/v1",
@@ -923,11 +599,9 @@ const message = await client.messages.create({
923
  max_tokens: 1024,
924
  messages: [{ role: "user", content: "Hello" }],
925
  });
926
- console.log(message.content[0].text);`);
927
 
928
- // ── Gemini ──
929
- setCode('code-gemini-python',
930
- `from google import genai
931
 
932
  client = genai.Client(
933
  api_key="${apiKey}",
@@ -938,17 +612,15 @@ response = client.models.generate_content(
938
  model="gemini-2.5-pro",
939
  contents="Hello",
940
  )
941
- print(response.text)`);
942
 
943
- setCode('code-gemini-curl',
944
- `curl "${origin}/v1beta/models/gemini-2.5-pro:generateContent?key=${apiKey}" \\
945
  -H "Content-Type: application/json" \\
946
  -d '{
947
  "contents": [{"role": "user", "parts": [{"text": "Hello"}]}]
948
- }'`);
949
 
950
- setCode('code-gemini-node',
951
- `import { GoogleGenAI } from "@google/genai";
952
 
953
  const ai = new GoogleGenAI({
954
  apiKey: "${apiKey}",
@@ -959,200 +631,104 @@ const response = await ai.models.generateContent({
959
  model: "gemini-2.5-pro",
960
  contents: "Hello",
961
  });
962
- console.log(response.text);`);
963
- }
964
-
965
- function saveOverrides() {
966
- try {
967
- const overrides = {
968
- baseUrl: document.getElementById('baseUrl').value,
969
- apiKey: document.getElementById('apiKey').value,
970
- model: document.getElementById('defaultModel').value,
971
- };
972
- localStorage.setItem('codex-proxy-config-overrides', JSON.stringify(overrides));
973
- } catch {}
974
- }
975
-
976
- function restoreOverrides() {
977
- try {
978
- const raw = localStorage.getItem('codex-proxy-config-overrides');
979
- if (!raw) return;
980
- const overrides = JSON.parse(raw);
981
- if (overrides.baseUrl) document.getElementById('baseUrl').value = overrides.baseUrl;
982
- if (overrides.apiKey) document.getElementById('apiKey').value = overrides.apiKey;
983
- if (overrides.model) {
984
- const sel = document.getElementById('defaultModel');
985
- // Only apply if the option exists in the dropdown
986
- const opts = Array.from(sel.options).map(o => o.value);
987
- if (opts.includes(overrides.model)) sel.value = overrides.model;
988
- }
989
- } catch {}
990
- }
991
-
992
- function resetConfigDefaults() {
993
- try { localStorage.removeItem('codex-proxy-config-overrides'); } catch {}
994
- document.getElementById('baseUrl').value = serverBaseUrl;
995
- document.getElementById('apiKey').value = serverApiKey;
996
- // Re-select preferred model
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
997
  const sel = document.getElementById('defaultModel');
998
- const preferred = Array.from(sel.options).find(o => o.value.includes('5.3-codex'));
999
- if (preferred) sel.value = preferred.value;
1000
- else if (sel.options.length) sel.selectedIndex = 0;
1001
- updateCodeExamples();
1002
- }
1003
-
1004
- function setupReactiveBindings() {
1005
- document.getElementById('baseUrl').addEventListener('input', () => { updateCodeExamples(); saveOverrides(); });
1006
- document.getElementById('apiKey').addEventListener('input', () => { updateCodeExamples(); saveOverrides(); });
1007
- document.getElementById('defaultModel').addEventListener('change', () => { updateCodeExamples(); saveOverrides(); });
1008
- }
1009
-
1010
- let currentProtocol = 'openai';
1011
- let currentLang = 'python';
1012
-
1013
- function switchProtocol(proto, el) {
1014
- currentProtocol = proto;
1015
- document.querySelectorAll('.tabs-protocol .tab').forEach(t => t.classList.remove('active'));
1016
- el.classList.add('active');
1017
- showActivePanel();
1018
- }
1019
-
1020
- function switchLang(lang, el) {
1021
- currentLang = lang;
1022
- document.querySelectorAll('.tabs-lang .tab').forEach(t => t.classList.remove('active'));
1023
- el.classList.add('active');
1024
- showActivePanel();
1025
- }
1026
-
1027
- function showActivePanel() {
1028
- document.querySelectorAll('.code-panel').forEach(p => p.classList.remove('active'));
1029
- const panel = document.getElementById('panel-' + currentProtocol + '-' + currentLang);
1030
- if (panel) panel.classList.add('active');
1031
- }
1032
-
1033
- function copyField(id, btn) {
1034
- const el = document.getElementById(id);
1035
- const text = el.value !== undefined ? el.value : el.textContent;
1036
- navigator.clipboard.writeText(text);
1037
- btn.classList.add('copied');
1038
- setTimeout(() => btn.classList.remove('copied'), 2000);
1039
- }
1040
-
1041
- function copyCode(id) {
1042
- const el = document.getElementById(id);
1043
- // Get text content excluding the copy button
1044
- const clone = el.cloneNode(true);
1045
- const btns = clone.querySelectorAll('.copy-code-btn');
1046
- btns.forEach(b => b.remove());
1047
- const code = clone.textContent.trim();
1048
- navigator.clipboard.writeText(code);
1049
- }
1050
-
1051
- async function logout() {
1052
- await fetch('/auth/logout', { method: 'POST' });
1053
- window.location.href = '/';
1054
- }
1055
-
1056
- let addPollTimer = null;
1057
-
1058
- async function startAddAccount() {
1059
- const btn = document.getElementById('addAccountBtn');
1060
- const infoEl = document.getElementById('addInfo');
1061
- const errEl = document.getElementById('addError');
1062
- infoEl.style.display = 'none';
1063
- errEl.style.display = 'none';
1064
- btn.disabled = true;
1065
- btn.textContent = 'Opening...';
1066
-
1067
- try {
1068
- const resp = await fetch('/auth/login-start', { method: 'POST' });
1069
- const data = await resp.json();
1070
-
1071
- if (!resp.ok || !data.authUrl) {
1072
- throw new Error(data.error || 'Failed to start login');
1073
- }
1074
-
1075
- window.open(data.authUrl, 'oauth_add', 'width=600,height=700,scrollbars=yes');
1076
-
1077
- document.getElementById('addSection').classList.add('open');
1078
- btn.textContent = '+ Add Account';
1079
- btn.disabled = false;
1080
-
1081
- if (addPollTimer) clearInterval(addPollTimer);
1082
- const prevCount = (await fetch('/auth/accounts').then(r => r.json())).accounts?.length || 0;
1083
- addPollTimer = setInterval(async () => {
1084
- try {
1085
- const r = await fetch('/auth/accounts');
1086
- const d = await r.json();
1087
- if ((d.accounts?.length || 0) > prevCount) {
1088
- clearInterval(addPollTimer);
1089
- document.getElementById('addSection').classList.remove('open');
1090
- infoEl.textContent = 'Account added successfully!';
1091
- infoEl.style.display = 'block';
1092
- await loadAccounts();
1093
- await loadStatus();
1094
- }
1095
- } catch {}
1096
- }, 2000);
1097
- setTimeout(() => { if (addPollTimer) clearInterval(addPollTimer); }, 5 * 60 * 1000);
1098
-
1099
- } catch (err) {
1100
- btn.textContent = '+ Add Account';
1101
- btn.disabled = false;
1102
- errEl.textContent = err.message;
1103
- errEl.style.display = 'block';
1104
- }
1105
- }
1106
-
1107
- async function submitAddRelay() {
1108
- const callbackUrl = document.getElementById('addCallbackInput').value.trim();
1109
- const infoEl = document.getElementById('addInfo');
1110
- const errEl = document.getElementById('addError');
1111
- infoEl.style.display = 'none';
1112
- errEl.style.display = 'none';
1113
-
1114
- if (!callbackUrl) {
1115
- errEl.textContent = 'Please paste the callback URL';
1116
- errEl.style.display = 'block';
1117
- return;
1118
- }
1119
-
1120
- const btn = document.getElementById('addRelayBtn');
1121
- btn.disabled = true;
1122
- btn.textContent = 'Submitting...';
1123
-
1124
- try {
1125
- const resp = await fetch('/auth/code-relay', {
1126
- method: 'POST',
1127
- headers: { 'Content-Type': 'application/json' },
1128
- body: JSON.stringify({ callbackUrl }),
1129
- });
1130
- const data = await resp.json();
1131
-
1132
- if (resp.ok && data.success) {
1133
- if (addPollTimer) clearInterval(addPollTimer);
1134
- document.getElementById('addSection').classList.remove('open');
1135
- document.getElementById('addCallbackInput').value = '';
1136
- infoEl.textContent = 'Account added successfully!';
1137
- infoEl.style.display = 'block';
1138
- await loadAccounts();
1139
- await loadStatus();
1140
- } else {
1141
- errEl.textContent = data.error || 'Failed to exchange code';
1142
- errEl.style.display = 'block';
1143
- }
1144
- } catch (err) {
1145
- errEl.textContent = 'Network error: ' + err.message;
1146
- errEl.style.display = 'block';
1147
- } finally {
1148
- btn.textContent = 'Submit';
1149
- btn.disabled = false;
1150
- }
1151
- }
1152
-
1153
- loadStatus();
1154
- setupReactiveBindings();
1155
- loadAccounts();
1156
- </script>
1157
- </body>
1158
- </html>
 
1
  <!DOCTYPE html>
2
+ <html lang="en"><head>
3
+ <meta charset="utf-8"/>
4
+ <meta content="width=device-width, initial-scale=1.0" name="viewport"/>
5
+ <title>Codex Proxy Developer Dashboard</title>
6
+ <script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
7
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&amp;family=JetBrains+Mono:wght@400;500&amp;display=swap" rel="stylesheet"/>
8
+ <link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
9
+ <script>
10
+ tailwind.config = {
11
+ darkMode: "class",
12
+ theme: {
13
+ extend: {
14
+ colors: {
15
+ "primary": "rgb(var(--primary) / <alpha-value>)",
16
+ "primary-hover": "rgb(var(--primary-hover) / <alpha-value>)",
17
+ "bg-light": "#f6f8f6",
18
+ "bg-dark": "#0d1117",
19
+ "card-dark": "#161b22",
20
+ "border-dark": "#30363d",
21
+ "text-main": "#e6edf3",
22
+ "text-dim": "#8b949e",
23
+ },
24
+ fontFamily: {
25
+ "display": ["Inter", "sans-serif"],
26
+ "mono": ["JetBrains Mono", "monospace"],
27
+ },
28
+ borderRadius: {"DEFAULT": "0.25rem", "lg": "0.5rem", "xl": "0.75rem", "full": "9999px"},
29
+ },
30
+ },
31
+ }
32
+ </script>
33
+ <style>
34
+ :root { --primary: 16 162 53; --primary-hover: 14 140 46; }
35
+ .dark { --primary: 16 163 127; --primary-hover: 14 140 108; }
36
+
37
+ /* Dark scrollbar for code blocks */
38
+ pre::-webkit-scrollbar { height: 8px; }
39
+ pre::-webkit-scrollbar-track { background: #0d1117; }
40
+ pre::-webkit-scrollbar-thumb { background: #30363d; border-radius: 4px; }
41
+ pre::-webkit-scrollbar-thumb:hover { background: #8b949e; }
42
+ </style>
43
+ <!-- Prevent flash: apply theme before render -->
44
+ <script>
45
+ try {
46
+ const t = localStorage.getItem('codex-proxy-theme');
47
+ if (t === 'dark' || (!t && window.matchMedia('(prefers-color-scheme: dark)').matches))
48
+ document.documentElement.classList.add('dark');
49
+ } catch {}
50
+ </script>
51
+ </head>
52
+ <body class="bg-bg-light dark:bg-bg-dark font-display text-slate-900 dark:text-text-main antialiased min-h-screen flex flex-col transition-colors">
53
+ <!-- Top Navigation -->
54
+ <header class="sticky top-0 z-50 w-full bg-white dark:bg-card-dark border-b border-gray-200 dark:border-border-dark shadow-sm transition-colors">
55
+ <div class="px-4 md:px-8 lg:px-40 flex h-14 items-center justify-center">
56
+ <div class="flex w-full max-w-[960px] items-center justify-between">
57
+ <!-- Logo & Title -->
58
+ <div class="flex items-center gap-3">
59
+ <div class="flex items-center justify-center size-8 rounded-full bg-primary/10 text-primary border border-primary/20">
60
+ <span class="material-symbols-outlined text-xl font-bold">check_circle</span>
61
+ </div>
62
+ <h1 class="text-[0.9rem] font-bold tracking-tight">Codex Proxy</h1>
63
+ </div>
64
+ <!-- Actions -->
65
+ <div class="flex items-center gap-3">
66
+ <div class="hidden sm:flex items-center gap-2 px-3 py-1.5 rounded-full bg-primary/10 border border-primary/20" id="serverBadge">
67
+ <span class="relative flex h-2.5 w-2.5">
68
+ <span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary opacity-75"></span>
69
+ <span class="relative inline-flex rounded-full h-2.5 w-2.5 bg-primary"></span>
70
+ </span>
71
+ <span class="text-xs font-semibold text-primary">Server Online</span>
72
+ </div>
73
+ <!-- Theme Toggle -->
74
+ <button id="themeToggle" onclick="toggleTheme()" class="p-2 rounded-lg text-slate-500 dark:text-text-dim hover:bg-slate-100 dark:hover:bg-border-dark transition-colors" title="Toggle theme">
75
+ <span class="material-symbols-outlined text-xl" id="themeIcon">dark_mode</span>
76
+ </button>
77
+ <button id="addAccountBtn" onclick="startAddAccount()" class="flex items-center gap-2 px-4 py-2 bg-primary hover:bg-primary-hover text-white text-xs font-semibold rounded-lg transition-colors shadow-sm active:scale-95">
78
+ <span class="material-symbols-outlined text-sm">add</span>
79
+ <span>Add Account</span>
80
+ </button>
81
+ </div>
82
+ </div>
83
+ </div>
84
+ </header>
85
+ <!-- Main Content -->
86
+ <main class="flex-grow px-4 md:px-8 lg:px-40 py-8 flex justify-center">
87
+ <div class="flex flex-col w-full max-w-[960px] gap-6">
88
+
89
+ <!-- Add Account Section (hidden by default) -->
90
+ <section id="addSection" class="hidden bg-white dark:bg-card-dark border border-gray-200 dark:border-border-dark rounded-xl p-5 shadow-sm transition-colors">
91
+ <p class="text-sm text-slate-500 dark:text-text-dim mb-3">If the popup shows an error or you're on a different machine, copy the full callback URL and paste it below.</p>
92
+ <div class="flex gap-3">
93
+ <input id="addCallbackInput" type="text" placeholder="Paste callback URL" class="flex-1 px-3 py-2.5 bg-slate-50 dark:bg-bg-dark border border-gray-200 dark:border-border-dark rounded-lg text-sm font-mono text-slate-600 dark:text-text-main focus:ring-2 focus:ring-primary/50 focus:border-primary outline-none transition-colors"/>
94
+ <button id="addRelayBtn" onclick="submitAddRelay()" class="px-4 py-2.5 bg-white dark:bg-card-dark border border-gray-200 dark:border-border-dark rounded-lg text-sm font-medium text-slate-700 dark:text-text-main hover:bg-slate-50 dark:hover:bg-border-dark transition-colors">Submit</button>
95
+ </div>
96
+ <p id="addInfo" class="text-sm text-primary mt-2 hidden"></p>
97
+ <p id="addError" class="text-sm text-red-500 mt-2 hidden"></p>
98
+ </section>
99
+
100
+ <!-- Section 1: Connected Accounts -->
101
+ <section class="flex flex-col gap-4">
102
+ <div class="flex items-end justify-between">
103
+ <div class="flex flex-col gap-1">
104
+ <h2 class="text-[0.95rem] font-bold tracking-tight">Connected Accounts</h2>
105
+ <p class="text-slate-500 dark:text-text-dim text-[0.8rem]">Manage your AI model proxy services and usage limits.</p>
106
+ </div>
107
+ </div>
108
+ <div id="accountList" class="grid grid-cols-1 md:grid-cols-2 gap-4">
109
+ <div class="md:col-span-2 text-center py-8 text-slate-400 dark:text-text-dim text-sm bg-white dark:bg-card-dark border border-gray-200 dark:border-border-dark rounded-xl transition-colors">Loading accounts...</div>
110
+ </div>
111
+ </section>
112
+
113
+ <!-- Section 2: API Configuration -->
114
+ <section class="bg-white dark:bg-card-dark border border-gray-200 dark:border-border-dark rounded-xl p-5 shadow-sm transition-colors">
115
+ <div class="flex items-center justify-between mb-6 border-b border-slate-100 dark:border-border-dark pb-4">
116
+ <div class="flex items-center gap-2">
117
+ <span class="material-symbols-outlined text-primary">settings</span>
118
+ <h2 class="text-[0.95rem] font-bold">API Configuration</h2>
119
+ </div>
120
+ <button onclick="resetConfigDefaults()" class="text-xs text-slate-400 dark:text-text-dim hover:text-slate-600 dark:hover:text-text-main transition-colors">Reset to defaults</button>
121
+ </div>
122
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
123
+ <!-- Base URL -->
124
+ <div class="space-y-1.5">
125
+ <label class="text-xs font-semibold text-slate-700 dark:text-text-main">Base Proxy URL</label>
126
+ <div class="relative flex items-center">
127
+ <input id="baseUrl" class="w-full pl-3 pr-10 py-2.5 bg-slate-50 dark:bg-bg-dark border border-gray-200 dark:border-border-dark rounded-lg text-[0.78rem] font-mono text-slate-600 dark:text-text-main focus:ring-1 focus:ring-primary focus:border-primary outline-none transition-all" type="text" value="Loading..."/>
128
+ <button onclick="copyField('baseUrl', this)" class="absolute right-2 p-1.5 text-slate-400 dark:text-text-dim hover:text-primary transition-colors rounded-md hover:bg-slate-100 dark:hover:bg-border-dark" title="Copy URL">
129
+ <span class="material-symbols-outlined text-lg">content_copy</span>
130
+ </button>
131
+ </div>
132
+ </div>
133
+ <!-- Default Model -->
134
+ <div class="space-y-1.5">
135
+ <label class="text-xs font-semibold text-slate-700 dark:text-text-main">Default Model</label>
136
+ <div class="relative">
137
+ <select id="defaultModel" class="w-full appearance-none pl-3 pr-10 py-2.5 bg-white dark:bg-bg-dark border border-gray-200 dark:border-border-dark rounded-lg text-[0.78rem] text-slate-700 dark:text-text-main font-medium focus:ring-1 focus:ring-primary focus:border-primary outline-none cursor-pointer transition-colors">
138
+ <option value="codex">codex</option>
139
+ </select>
140
+ <div class="pointer-events-none absolute inset-y-0 right-0 flex items-center px-2 text-slate-500 dark:text-text-dim">
141
+ <span class="material-symbols-outlined text-lg">expand_more</span>
142
+ </div>
143
+ </div>
144
+ </div>
145
+ <!-- API Key -->
146
+ <div class="space-y-1.5 md:col-span-2">
147
+ <label class="text-xs font-semibold text-slate-700 dark:text-text-main">Your API Key</label>
148
+ <div class="relative flex items-center">
149
+ <div class="absolute left-3 text-slate-400 dark:text-text-dim">
150
+ <span class="material-symbols-outlined text-lg">key</span>
151
+ </div>
152
+ <input id="apiKey" class="w-full pl-10 pr-10 py-2.5 bg-slate-50 dark:bg-bg-dark border border-gray-200 dark:border-border-dark rounded-lg text-[0.78rem] font-mono text-slate-600 dark:text-text-main focus:ring-1 focus:ring-primary focus:border-primary outline-none transition-all tracking-wider" type="text" value="Loading..."/>
153
+ <button onclick="copyField('apiKey', this)" class="absolute right-2 p-1.5 text-slate-400 dark:text-text-dim hover:text-primary transition-colors rounded-md hover:bg-slate-100 dark:hover:bg-border-dark" title="Copy API Key">
154
+ <span class="material-symbols-outlined text-lg">content_copy</span>
155
+ </button>
156
+ </div>
157
+ <p class="text-xs text-slate-400 dark:text-text-dim mt-1">Use this key to authenticate requests to the proxy. Do not share it.</p>
158
+ </div>
159
+ </div>
160
+ </section>
161
+
162
+ <!-- Section 3: Integration Examples -->
163
+ <section class="flex flex-col gap-4">
164
+ <h2 class="text-[0.95rem] font-bold">Integration Examples</h2>
165
+ <div class="bg-white dark:bg-card-dark border border-gray-200 dark:border-border-dark rounded-xl overflow-hidden shadow-sm transition-colors">
166
+ <!-- Top Tabs: Providers -->
167
+ <div id="protocolTabs" class="flex border-b border-gray-200 dark:border-border-dark bg-slate-50/50 dark:bg-bg-dark/30">
168
+ <button onclick="switchProtocol('openai', this)" class="proto-tab px-6 py-3 text-[0.82rem] font-semibold text-primary border-b-2 border-primary bg-white dark:bg-card-dark transition-colors" data-proto="openai">OpenAI</button>
169
+ <button onclick="switchProtocol('anthropic', this)" class="proto-tab px-6 py-3 text-[0.82rem] font-medium text-slate-500 dark:text-text-dim hover:text-slate-700 dark:hover:text-text-main hover:bg-slate-50 dark:hover:bg-[#21262d] border-b-2 border-transparent transition-colors" data-proto="anthropic">Anthropic</button>
170
+ <button onclick="switchProtocol('gemini', this)" class="proto-tab px-6 py-3 text-[0.82rem] font-medium text-slate-500 dark:text-text-dim hover:text-slate-700 dark:hover:text-text-main hover:bg-slate-50 dark:hover:bg-[#21262d] border-b-2 border-transparent transition-colors" data-proto="gemini">Gemini</button>
171
+ </div>
172
+ <!-- Sub Tabs: Languages & Content -->
173
+ <div class="p-5">
174
+ <div class="flex items-center justify-between mb-4">
175
+ <div id="langTabs" class="flex gap-2 p-1 bg-slate-100 dark:bg-bg-dark dark:border dark:border-border-dark rounded-lg">
176
+ <button onclick="switchLang('python', this)" class="lang-tab px-3 py-1.5 text-xs font-semibold rounded bg-white dark:bg-[#21262d] text-slate-800 dark:text-text-main shadow-sm border border-transparent dark:border-border-dark transition-all" data-lang="python">Python</button>
177
+ <button onclick="switchLang('node', this)" class="lang-tab px-3 py-1.5 text-xs font-medium rounded text-slate-500 dark:text-text-dim hover:text-slate-700 dark:hover:text-text-main hover:bg-white/50 dark:hover:bg-[#21262d] border border-transparent transition-all" data-lang="node">Node.js</button>
178
+ <button onclick="switchLang('curl', this)" class="lang-tab px-3 py-1.5 text-xs font-medium rounded text-slate-500 dark:text-text-dim hover:text-slate-700 dark:hover:text-text-main hover:bg-white/50 dark:hover:bg-[#21262d] border border-transparent transition-all" data-lang="curl">cURL</button>
179
+ </div>
180
+ </div>
181
+ <!-- Code Block -->
182
+ <div id="codeContainer" class="relative group rounded-lg overflow-hidden bg-[#0d1117] text-slate-300 font-mono text-xs border border-slate-800 dark:border-border-dark">
183
+ <div class="absolute right-2 top-2 z-10 opacity-0 group-hover:opacity-100 transition-opacity">
184
+ <button onclick="copyCurrentCode()" class="flex items-center gap-1.5 px-3 py-1.5 bg-slate-700 hover:bg-slate-600 text-white rounded text-xs font-medium transition-colors">
185
+ <span class="material-symbols-outlined text-sm">content_copy</span> Copy
186
+ </button>
187
+ </div>
188
+ <div class="p-4 overflow-x-auto">
189
+ <pre class="m-0"><code id="codeBlock">Loading...</code></pre>
190
+ </div>
191
+ </div>
192
+ </div>
193
+ </div>
194
+ </section>
195
+ </div>
196
+ </main>
197
+ <!-- Footer -->
198
+ <footer class="mt-auto border-t border-gray-200 dark:border-border-dark bg-white dark:bg-card-dark py-6 transition-colors">
199
+ <div class="container mx-auto px-4 text-center">
200
+ <p class="text-[0.8rem] text-slate-500 dark:text-text-dim">&copy; 2025 Codex Proxy. All rights reserved.</p>
201
+ </div>
202
+ </footer>
203
+
204
+ <script>
205
+ // ── State ──────────────────────────────────────────────────────
206
+ let authData = null;
207
+ let serverBaseUrl = '';
208
+ let serverApiKey = '';
209
+ let currentProtocol = 'openai';
210
+ let currentLang = 'python';
211
+ let addPollTimer = null;
212
+
213
+ const avatarColors = [
214
+ ['bg-purple-100 dark:bg-[#2a1a3f]', 'text-purple-600 dark:text-purple-400'],
215
+ ['bg-amber-100 dark:bg-[#3d2c16]', 'text-amber-600 dark:text-amber-500'],
216
+ ['bg-blue-100 dark:bg-[#1a2a3f]', 'text-blue-600 dark:text-blue-400'],
217
+ ['bg-emerald-100 dark:bg-[#112a1f]', 'text-emerald-600 dark:text-emerald-400'],
218
+ ['bg-red-100 dark:bg-[#3f1a1a]', 'text-red-600 dark:text-red-400'],
219
+ ];
220
+
221
+ // ── Theme Toggle ───────────────────────────────────────────────
222
+ function toggleTheme() {
223
+ const html = document.documentElement;
224
+ const isDark = html.classList.toggle('dark');
225
+ localStorage.setItem('codex-proxy-theme', isDark ? 'dark' : 'light');
226
+ updateThemeIcon();
227
+ }
228
+
229
+ function updateThemeIcon() {
230
+ const icon = document.getElementById('themeIcon');
231
+ if (!icon) return;
232
+ icon.textContent = document.documentElement.classList.contains('dark') ? 'light_mode' : 'dark_mode';
233
+ }
234
+ updateThemeIcon();
235
+
236
+ // ── Helpers ────────────────────────────────────────────────────
237
+ function escapeHtml(str) {
238
+ const div = document.createElement('div');
239
+ div.textContent = str;
240
+ return div.innerHTML;
241
+ }
242
+
243
+ function formatNumber(n) {
244
+ if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
245
+ if (n >= 1000) return (n / 1000).toFixed(1) + 'k';
246
+ return String(n);
247
+ }
248
+
249
+ function statusBadge(status) {
250
+ const map = {
251
+ active: ['bg-green-100 text-green-700 border-green-200 dark:bg-[#11281d] dark:text-primary dark:border-[#1a442e]', 'Active'],
252
+ expired: ['bg-red-100 text-red-600 border-red-200 dark:bg-red-900/20 dark:text-red-400 dark:border-red-800/30', 'Expired'],
253
+ rate_limited: ['bg-amber-100 text-amber-700 border-amber-200 dark:bg-amber-900/20 dark:text-amber-400 dark:border-amber-800/30', 'Rate Limited'],
254
+ refreshing: ['bg-blue-100 text-blue-600 border-blue-200 dark:bg-blue-900/20 dark:text-blue-400 dark:border-blue-800/30', 'Refreshing'],
255
+ disabled: ['bg-slate-100 text-slate-500 border-slate-200 dark:bg-slate-800/30 dark:text-slate-400 dark:border-slate-700/30', 'Disabled'],
256
+ };
257
+ const [cls, label] = map[status] || map.disabled;
258
+ return `<span class="px-2.5 py-1 rounded-full ${cls} text-xs font-medium border">${escapeHtml(label)}</span>`;
259
+ }
260
+
261
+ // ── OAuth message listener ─────────────────────────────────────
262
+ window.addEventListener('message', async (event) => {
263
+ if (event.data?.type === 'oauth-callback-success') {
264
+ if (addPollTimer) clearInterval(addPollTimer);
265
+ document.getElementById('addSection').classList.add('hidden');
266
+ showAddInfo('Account added successfully!');
267
+ await loadAccounts();
268
+ await loadStatus();
269
+ }
270
+ });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
271
 
272
+ // ── Accounts ───────────────────────────────────────────────────
273
+ async function loadAccounts() {
274
+ try {
275
+ const resp = await fetch('/auth/accounts?quota=true');
276
+ const data = await resp.json();
277
+ renderAccounts(data.accounts || []);
278
+ } catch (err) {
279
+ document.getElementById('accountList').innerHTML =
280
+ `<div class="md:col-span-2 text-center py-8 text-red-400 text-sm bg-white dark:bg-card-dark border border-gray-200 dark:border-border-dark rounded-xl">${escapeHtml(err.message)}</div>`;
281
+ }
282
+ }
283
+
284
+ function renderAccounts(accounts) {
285
+ const container = document.getElementById('accountList');
286
+
287
+ if (accounts.length === 0) {
288
+ container.innerHTML = `<div class="md:col-span-2 text-center py-8 text-slate-400 dark:text-text-dim text-sm bg-white dark:bg-card-dark border border-gray-200 dark:border-border-dark rounded-xl">
289
+ No accounts connected. Click "Add Account" to get started.
290
+ </div>`;
291
+ return;
292
+ }
293
+
294
+ container.innerHTML = accounts.map((acct, i) => {
295
+ const usage = acct.usage || {};
296
+ const email = acct.email || 'Unknown';
297
+ const initial = email.charAt(0).toUpperCase();
298
+ const [bgColor, textColor] = avatarColors[i % avatarColors.length];
299
+ const requests = usage.request_count ?? 0;
300
+ const tokens = (usage.input_tokens ?? 0) + (usage.output_tokens ?? 0);
301
+ const plan = acct.planType || 'Free Tier';
302
+
303
+ // Quota bar
304
+ let quotaHtml = '';
305
+ const q = acct.quota;
306
+ if (q && q.rate_limit) {
307
+ const rl = q.rate_limit;
308
+ const pct = rl.used_percent != null ? Math.round(rl.used_percent) : null;
309
+ const barColor = pct == null ? 'bg-primary'
310
+ : pct >= 90 ? 'bg-red-500'
311
+ : pct >= 60 ? 'bg-amber-500'
312
+ : 'bg-primary';
313
+ const pctColor = pct == null ? 'text-primary'
314
+ : pct >= 90 ? 'text-red-500'
315
+ : pct >= 60 ? 'text-amber-600 dark:text-amber-500'
316
+ : 'text-primary';
317
+ const resetAt = rl.reset_at ? new Date(rl.reset_at * 1000).toLocaleTimeString() : null;
318
+
319
+ quotaHtml = `
320
+ <div class="pt-3 mt-3 border-t border-slate-100 dark:border-border-dark">
321
+ <div class="flex justify-between text-[0.78rem] mb-1.5">
322
+ <span class="text-slate-500 dark:text-text-dim">Rate Limit</span>
323
+ ${rl.limit_reached
324
+ ? '<span class="px-2 py-0.5 rounded-full bg-red-100 dark:bg-red-900/20 text-red-600 dark:text-red-400 text-xs font-medium">Limit Reached</span>'
325
+ : pct != null
326
+ ? `<span class="font-medium ${pctColor}">${pct}% Used</span>`
327
+ : '<span class="font-medium text-primary">OK</span>'}
328
+ </div>
329
+ ${pct != null ? `
330
+ <div class="w-full bg-slate-100 dark:bg-border-dark rounded-full h-2 overflow-hidden">
331
+ <div class="${barColor} h-2 rounded-full transition-all" style="width: ${pct}%"></div>
332
+ </div>` : ''}
333
+ ${resetAt ? `<p class="text-xs text-slate-400 dark:text-text-dim mt-1">Resets at ${escapeHtml(resetAt)}</p>` : ''}
334
+ </div>`;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
335
  }
336
 
337
+ return `
338
+ <div class="group bg-white dark:bg-card-dark border border-gray-200 dark:border-border-dark rounded-xl p-4 shadow-sm hover:shadow-md transition-all hover:border-primary/30 dark:hover:border-primary/50 relative">
339
+ <button onclick="deleteAccount('${escapeHtml(acct.id)}')" class="absolute top-3 right-3 opacity-0 group-hover:opacity-100 p-1.5 text-slate-300 dark:text-text-dim hover:text-red-500 transition-all rounded-md hover:bg-red-50 dark:hover:bg-red-900/20" title="Delete account">
340
+ <span class="material-symbols-outlined text-lg">delete</span>
341
+ </button>
342
+ <div class="flex justify-between items-start mb-4">
343
+ <div class="flex items-center gap-3">
344
+ <div class="size-10 rounded-full ${bgColor} ${textColor} flex items-center justify-center font-bold text-lg">${initial}</div>
345
+ <div>
346
+ <h3 class="text-[0.82rem] font-semibold leading-tight">${escapeHtml(email)}</h3>
347
+ <p class="text-xs text-slate-500 dark:text-text-dim">${escapeHtml(plan)}</p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
348
  </div>
349
  </div>
350
+ ${statusBadge(acct.status)}
351
+ </div>
352
+ <div class="space-y-2">
353
+ <div class="flex justify-between text-[0.78rem]">
354
+ <span class="text-slate-500 dark:text-text-dim">Total Requests</span>
355
+ <span class="font-medium">${formatNumber(requests)}</span>
 
 
356
  </div>
357
+ <div class="flex justify-between text-[0.78rem]">
358
+ <span class="text-slate-500 dark:text-text-dim">Tokens Used</span>
359
+ <span class="font-medium">${formatNumber(tokens)}</span>
 
 
 
 
 
360
  </div>
361
  </div>
362
+ ${quotaHtml}
363
+ </div>`;
364
+ }).join('');
365
+ }
366
+
367
+ async function deleteAccount(id) {
368
+ if (!confirm('Remove this account?')) return;
369
+ try {
370
+ const resp = await fetch('/auth/accounts/' + encodeURIComponent(id), { method: 'DELETE' });
371
+ if (!resp.ok) {
372
+ const data = await resp.json();
373
+ alert(data.error || 'Failed to delete account.');
374
+ return;
375
+ }
376
+ await loadAccounts();
377
+ await loadStatus();
378
+ } catch (err) {
379
+ alert('Network error: ' + err.message);
380
+ }
381
+ }
382
+
383
+ // ── Add Account ────────────────────────────────────────────────
384
+ function showAddInfo(msg) {
385
+ const el = document.getElementById('addInfo');
386
+ el.textContent = msg;
387
+ el.classList.remove('hidden');
388
+ document.getElementById('addError').classList.add('hidden');
389
+ }
390
+
391
+ function showAddError(msg) {
392
+ const el = document.getElementById('addError');
393
+ el.textContent = msg;
394
+ el.classList.remove('hidden');
395
+ document.getElementById('addInfo').classList.add('hidden');
396
+ }
397
+
398
+ async function startAddAccount() {
399
+ const btn = document.getElementById('addAccountBtn');
400
+ const infoEl = document.getElementById('addInfo');
401
+ const errEl = document.getElementById('addError');
402
+ infoEl.classList.add('hidden');
403
+ errEl.classList.add('hidden');
404
+ btn.disabled = true;
405
+
406
+ try {
407
+ const resp = await fetch('/auth/login-start', { method: 'POST' });
408
+ const data = await resp.json();
409
+
410
+ if (!resp.ok || !data.authUrl) {
411
+ throw new Error(data.error || 'Failed to start login');
412
+ }
413
+
414
+ window.open(data.authUrl, 'oauth_add', 'width=600,height=700,scrollbars=yes');
415
+ document.getElementById('addSection').classList.remove('hidden');
416
+ btn.disabled = false;
417
+
418
+ if (addPollTimer) clearInterval(addPollTimer);
419
+ const prevCount = (await fetch('/auth/accounts').then(r => r.json())).accounts?.length || 0;
420
+ addPollTimer = setInterval(async () => {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
421
  try {
422
+ const r = await fetch('/auth/accounts');
423
+ const d = await r.json();
424
+ if ((d.accounts?.length || 0) > prevCount) {
425
+ clearInterval(addPollTimer);
426
+ document.getElementById('addSection').classList.add('hidden');
427
+ showAddInfo('Account added successfully!');
428
+ await loadAccounts();
429
+ await loadStatus();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
430
  }
431
+ } catch {}
432
+ }, 2000);
433
+ setTimeout(() => { if (addPollTimer) clearInterval(addPollTimer); }, 5 * 60 * 1000);
434
+
435
+ } catch (err) {
436
+ btn.disabled = false;
437
+ showAddError(err.message);
438
+ }
439
+ }
440
+
441
+ async function submitAddRelay() {
442
+ const callbackUrl = document.getElementById('addCallbackInput').value.trim();
443
+ document.getElementById('addInfo').classList.add('hidden');
444
+ document.getElementById('addError').classList.add('hidden');
445
+
446
+ if (!callbackUrl) { showAddError('Please paste the callback URL'); return; }
447
+
448
+ const btn = document.getElementById('addRelayBtn');
449
+ btn.disabled = true;
450
+ btn.textContent = 'Submitting...';
451
+
452
+ try {
453
+ const resp = await fetch('/auth/code-relay', {
454
+ method: 'POST',
455
+ headers: { 'Content-Type': 'application/json' },
456
+ body: JSON.stringify({ callbackUrl }),
457
+ });
458
+ const data = await resp.json();
459
+
460
+ if (resp.ok && data.success) {
461
+ if (addPollTimer) clearInterval(addPollTimer);
462
+ document.getElementById('addSection').classList.add('hidden');
463
+ document.getElementById('addCallbackInput').value = '';
464
+ showAddInfo('Account added successfully!');
465
+ await loadAccounts();
466
+ await loadStatus();
467
+ } else {
468
+ showAddError(data.error || 'Failed to exchange code');
469
+ }
470
+ } catch (err) {
471
+ showAddError('Network error: ' + err.message);
472
+ } finally {
473
+ btn.textContent = 'Submit';
474
+ btn.disabled = false;
475
+ }
476
+ }
477
+
478
+ // ── Status & Config ────────────────────────────────────────────
479
+ async function loadStatus() {
480
+ try {
481
+ const resp = await fetch('/auth/status');
482
+ authData = await resp.json();
483
+
484
+ if (!authData.authenticated) { window.location.href = '/'; return; }
485
+
486
+ serverBaseUrl = `${window.location.origin}/v1`;
487
+ serverApiKey = authData.proxy_api_key || 'any-string';
488
+ document.getElementById('baseUrl').value = serverBaseUrl;
489
+ document.getElementById('apiKey').value = serverApiKey;
490
+
491
+ await populateModelDropdown();
492
+ restoreOverrides();
493
+ updateCodeExamples();
494
+ } catch (err) {
495
+ console.error('Status load error:', err);
496
+ }
497
+ }
498
+
499
+ async function populateModelDropdown() {
500
+ const sel = document.getElementById('defaultModel');
501
+ try {
502
+ const resp = await fetch('/v1/models');
503
+ const data = await resp.json();
504
+ const models = data.data.map(m => m.id);
505
+ if (models.length > 0) {
506
+ sel.innerHTML = '';
507
+ models.forEach(id => {
508
+ const opt = document.createElement('option');
509
+ opt.value = id;
510
+ opt.textContent = id;
511
+ sel.appendChild(opt);
512
  });
513
+ const preferred = models.find(n => n.includes('5.3-codex'));
514
+ if (preferred) sel.value = preferred;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
515
  }
516
+ } catch {
517
+ sel.innerHTML = '<option value="codex">codex</option>';
518
+ }
519
+ }
520
 
521
+ // ── Code Examples ──────────────────────────────────────────────
522
+ function updateCodeExamples() {
523
+ const baseUrl = document.getElementById('baseUrl').value;
524
+ const apiKey = document.getElementById('apiKey').value || 'any-string';
525
+ const model = document.getElementById('defaultModel').value;
526
+ const origin = window.location.origin;
527
 
528
+ window._codeExamples = {
529
+ 'openai-python': `from openai import OpenAI
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
530
 
531
  client = OpenAI(
532
  base_url="${baseUrl}",
 
537
  model="${model}",
538
  messages=[{"role": "user", "content": "Hello"}],
539
  )
540
+ print(response.choices[0].message.content)`,
541
 
542
+ 'openai-curl': `curl ${baseUrl}/chat/completions \\
 
543
  -H "Content-Type: application/json" \\
544
  -H "Authorization: Bearer ${apiKey}" \\
545
  -d '{
546
  "model": "${model}",
547
  "messages": [{"role": "user", "content": "Hello"}]
548
+ }'`,
549
 
550
+ 'openai-node': `import OpenAI from "openai";
 
551
 
552
  const client = new OpenAI({
553
  baseURL: "${baseUrl}",
 
561
  });
562
  for await (const chunk of stream) {
563
  process.stdout.write(chunk.choices[0]?.delta?.content || "");
564
+ }`,
565
 
566
+ 'anthropic-python': `import anthropic
 
 
567
 
568
  client = anthropic.Anthropic(
569
  base_url="${origin}/v1",
 
575
  max_tokens=1024,
576
  messages=[{"role": "user", "content": "Hello"}],
577
  )
578
+ print(message.content[0].text)`,
579
 
580
+ 'anthropic-curl': `curl ${origin}/v1/messages \\
 
581
  -H "Content-Type: application/json" \\
582
  -H "x-api-key: ${apiKey}" \\
583
  -H "anthropic-version: 2023-06-01" \\
 
585
  "model": "claude-sonnet-4-20250514",
586
  "max_tokens": 1024,
587
  "messages": [{"role": "user", "content": "Hello"}]
588
+ }'`,
589
 
590
+ 'anthropic-node': `import Anthropic from "@anthropic-ai/sdk";
 
591
 
592
  const client = new Anthropic({
593
  baseURL: "${origin}/v1",
 
599
  max_tokens: 1024,
600
  messages: [{ role: "user", content: "Hello" }],
601
  });
602
+ console.log(message.content[0].text);`,
603
 
604
+ 'gemini-python': `from google import genai
 
 
605
 
606
  client = genai.Client(
607
  api_key="${apiKey}",
 
612
  model="gemini-2.5-pro",
613
  contents="Hello",
614
  )
615
+ print(response.text)`,
616
 
617
+ 'gemini-curl': `curl "${origin}/v1beta/models/gemini-2.5-pro:generateContent?key=${apiKey}" \\
 
618
  -H "Content-Type: application/json" \\
619
  -d '{
620
  "contents": [{"role": "user", "parts": [{"text": "Hello"}]}]
621
+ }'`,
622
 
623
+ 'gemini-node': `import { GoogleGenAI } from "@google/genai";
 
624
 
625
  const ai = new GoogleGenAI({
626
  apiKey: "${apiKey}",
 
631
  model: "gemini-2.5-pro",
632
  contents: "Hello",
633
  });
634
+ console.log(response.text);`,
635
+ };
636
+
637
+ showCurrentCode();
638
+ }
639
+
640
+ function showCurrentCode() {
641
+ const key = `${currentProtocol}-${currentLang}`;
642
+ const code = (window._codeExamples && window._codeExamples[key]) || 'Loading...';
643
+ document.getElementById('codeBlock').textContent = code;
644
+ }
645
+
646
+ // ── Tab Switching ──────────────────────────────────────────────
647
+ const PROTO_ACTIVE = 'proto-tab px-6 py-3 text-[0.82rem] font-semibold text-primary border-b-2 border-primary bg-white dark:bg-card-dark transition-colors';
648
+ const PROTO_INACTIVE = 'proto-tab px-6 py-3 text-[0.82rem] font-medium text-slate-500 dark:text-text-dim hover:text-slate-700 dark:hover:text-text-main hover:bg-slate-50 dark:hover:bg-[#21262d] border-b-2 border-transparent transition-colors';
649
+ const LANG_ACTIVE = 'lang-tab px-3 py-1.5 text-xs font-semibold rounded bg-white dark:bg-[#21262d] text-slate-800 dark:text-text-main shadow-sm border border-transparent dark:border-border-dark transition-all';
650
+ const LANG_INACTIVE = 'lang-tab px-3 py-1.5 text-xs font-medium rounded text-slate-500 dark:text-text-dim hover:text-slate-700 dark:hover:text-text-main hover:bg-white/50 dark:hover:bg-[#21262d] border border-transparent transition-all';
651
+
652
+ function switchProtocol(proto, el) {
653
+ currentProtocol = proto;
654
+ document.querySelectorAll('.proto-tab').forEach(t => t.className = PROTO_INACTIVE);
655
+ el.className = PROTO_ACTIVE;
656
+ showCurrentCode();
657
+ }
658
+
659
+ function switchLang(lang, el) {
660
+ currentLang = lang;
661
+ document.querySelectorAll('.lang-tab').forEach(t => t.className = LANG_INACTIVE);
662
+ el.className = LANG_ACTIVE;
663
+ showCurrentCode();
664
+ }
665
+
666
+ // ── Copy ───────────────────────────────────────────────────────
667
+ function copyField(id, btn) {
668
+ const el = document.getElementById(id);
669
+ const text = el.value !== undefined ? el.value : el.textContent;
670
+ navigator.clipboard.writeText(text);
671
+ const icon = btn.querySelector('.material-symbols-outlined');
672
+ if (icon) {
673
+ icon.textContent = 'check';
674
+ btn.classList.add('text-primary');
675
+ setTimeout(() => { icon.textContent = 'content_copy'; btn.classList.remove('text-primary'); }, 2000);
676
+ }
677
+ }
678
+
679
+ function copyCurrentCode() {
680
+ const key = `${currentProtocol}-${currentLang}`;
681
+ const code = window._codeExamples?.[key] || '';
682
+ navigator.clipboard.writeText(code);
683
+ }
684
+
685
+ // ── Config Overrides (localStorage) ────────────────────────────
686
+ function saveOverrides() {
687
+ try {
688
+ localStorage.setItem('codex-proxy-config-overrides', JSON.stringify({
689
+ baseUrl: document.getElementById('baseUrl').value,
690
+ apiKey: document.getElementById('apiKey').value,
691
+ model: document.getElementById('defaultModel').value,
692
+ }));
693
+ } catch {}
694
+ }
695
+
696
+ function restoreOverrides() {
697
+ try {
698
+ const raw = localStorage.getItem('codex-proxy-config-overrides');
699
+ if (!raw) return;
700
+ const o = JSON.parse(raw);
701
+ if (o.baseUrl) document.getElementById('baseUrl').value = o.baseUrl;
702
+ if (o.apiKey) document.getElementById('apiKey').value = o.apiKey;
703
+ if (o.model) {
704
  const sel = document.getElementById('defaultModel');
705
+ const opts = Array.from(sel.options).map(op => op.value);
706
+ if (opts.includes(o.model)) sel.value = o.model;
707
+ }
708
+ } catch {}
709
+ }
710
+
711
+ function resetConfigDefaults() {
712
+ try { localStorage.removeItem('codex-proxy-config-overrides'); } catch {}
713
+ document.getElementById('baseUrl').value = serverBaseUrl;
714
+ document.getElementById('apiKey').value = serverApiKey;
715
+ const sel = document.getElementById('defaultModel');
716
+ const preferred = Array.from(sel.options).find(o => o.value.includes('5.3-codex'));
717
+ if (preferred) sel.value = preferred.value;
718
+ else if (sel.options.length) sel.selectedIndex = 0;
719
+ updateCodeExamples();
720
+ }
721
+
722
+ // ── Reactive bindings ──────────────────────────────────────────
723
+ function setupReactiveBindings() {
724
+ document.getElementById('baseUrl').addEventListener('input', () => { updateCodeExamples(); saveOverrides(); });
725
+ document.getElementById('apiKey').addEventListener('input', () => { updateCodeExamples(); saveOverrides(); });
726
+ document.getElementById('defaultModel').addEventListener('change', () => { updateCodeExamples(); saveOverrides(); });
727
+ }
728
+
729
+ // ── Init ───────────────────────────────────────────────────────
730
+ loadStatus();
731
+ setupReactiveBindings();
732
+ loadAccounts();
733
+ </script>
734
+ </body></html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
public/dashboard1.html DELETED
@@ -1,734 +0,0 @@
1
- <!DOCTYPE html>
2
- <html lang="en"><head>
3
- <meta charset="utf-8"/>
4
- <meta content="width=device-width, initial-scale=1.0" name="viewport"/>
5
- <title>Codex Proxy Developer Dashboard</title>
6
- <script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
7
- <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&amp;family=JetBrains+Mono:wght@400;500&amp;display=swap" rel="stylesheet"/>
8
- <link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
9
- <script>
10
- tailwind.config = {
11
- darkMode: "class",
12
- theme: {
13
- extend: {
14
- colors: {
15
- "primary": "rgb(var(--primary) / <alpha-value>)",
16
- "primary-hover": "rgb(var(--primary-hover) / <alpha-value>)",
17
- "bg-light": "#f6f8f6",
18
- "bg-dark": "#0d1117",
19
- "card-dark": "#161b22",
20
- "border-dark": "#30363d",
21
- "text-main": "#e6edf3",
22
- "text-dim": "#8b949e",
23
- },
24
- fontFamily: {
25
- "display": ["Inter", "sans-serif"],
26
- "mono": ["JetBrains Mono", "monospace"],
27
- },
28
- borderRadius: {"DEFAULT": "0.25rem", "lg": "0.5rem", "xl": "0.75rem", "full": "9999px"},
29
- },
30
- },
31
- }
32
- </script>
33
- <style>
34
- :root { --primary: 16 162 53; --primary-hover: 14 140 46; }
35
- .dark { --primary: 16 163 127; --primary-hover: 14 140 108; }
36
-
37
- /* Dark scrollbar for code blocks */
38
- pre::-webkit-scrollbar { height: 8px; }
39
- pre::-webkit-scrollbar-track { background: #0d1117; }
40
- pre::-webkit-scrollbar-thumb { background: #30363d; border-radius: 4px; }
41
- pre::-webkit-scrollbar-thumb:hover { background: #8b949e; }
42
- </style>
43
- <!-- Prevent flash: apply theme before render -->
44
- <script>
45
- try {
46
- const t = localStorage.getItem('codex-proxy-theme');
47
- if (t === 'dark' || (!t && window.matchMedia('(prefers-color-scheme: dark)').matches))
48
- document.documentElement.classList.add('dark');
49
- } catch {}
50
- </script>
51
- </head>
52
- <body class="bg-bg-light dark:bg-bg-dark font-display text-slate-900 dark:text-text-main antialiased min-h-screen flex flex-col transition-colors">
53
- <!-- Top Navigation -->
54
- <header class="sticky top-0 z-50 w-full bg-white dark:bg-card-dark border-b border-gray-200 dark:border-border-dark shadow-sm transition-colors">
55
- <div class="px-4 md:px-8 lg:px-40 flex h-16 items-center justify-center">
56
- <div class="flex w-full max-w-[960px] items-center justify-between">
57
- <!-- Logo & Title -->
58
- <div class="flex items-center gap-3">
59
- <div class="flex items-center justify-center size-8 rounded-full bg-primary/10 text-primary border border-primary/20">
60
- <span class="material-symbols-outlined text-xl font-bold">check_circle</span>
61
- </div>
62
- <h1 class="text-lg font-bold tracking-tight">Codex Proxy</h1>
63
- </div>
64
- <!-- Actions -->
65
- <div class="flex items-center gap-3">
66
- <div class="hidden sm:flex items-center gap-2 px-3 py-1.5 rounded-full bg-primary/10 border border-primary/20" id="serverBadge">
67
- <span class="relative flex h-2.5 w-2.5">
68
- <span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary opacity-75"></span>
69
- <span class="relative inline-flex rounded-full h-2.5 w-2.5 bg-primary"></span>
70
- </span>
71
- <span class="text-xs font-semibold text-primary">Server Online</span>
72
- </div>
73
- <!-- Theme Toggle -->
74
- <button id="themeToggle" onclick="toggleTheme()" class="p-2 rounded-lg text-slate-500 dark:text-text-dim hover:bg-slate-100 dark:hover:bg-border-dark transition-colors" title="Toggle theme">
75
- <span class="material-symbols-outlined text-xl" id="themeIcon">dark_mode</span>
76
- </button>
77
- <button id="addAccountBtn" onclick="startAddAccount()" class="flex items-center gap-2 px-4 py-2 bg-primary hover:bg-primary-hover text-white text-sm font-semibold rounded-lg transition-colors shadow-sm active:scale-95">
78
- <span class="material-symbols-outlined text-sm">add</span>
79
- <span>Add Account</span>
80
- </button>
81
- </div>
82
- </div>
83
- </div>
84
- </header>
85
- <!-- Main Content -->
86
- <main class="flex-grow px-4 md:px-8 lg:px-40 py-8 flex justify-center">
87
- <div class="flex flex-col w-full max-w-[960px] gap-8">
88
-
89
- <!-- Add Account Section (hidden by default) -->
90
- <section id="addSection" class="hidden bg-white dark:bg-card-dark border border-gray-200 dark:border-border-dark rounded-xl p-5 shadow-sm transition-colors">
91
- <p class="text-sm text-slate-500 dark:text-text-dim mb-3">If the popup shows an error or you're on a different machine, copy the full callback URL and paste it below.</p>
92
- <div class="flex gap-3">
93
- <input id="addCallbackInput" type="text" placeholder="Paste callback URL" class="flex-1 px-3 py-2.5 bg-slate-50 dark:bg-bg-dark border border-gray-200 dark:border-border-dark rounded-lg text-sm font-mono text-slate-600 dark:text-text-main focus:ring-2 focus:ring-primary/50 focus:border-primary outline-none transition-colors"/>
94
- <button id="addRelayBtn" onclick="submitAddRelay()" class="px-4 py-2.5 bg-white dark:bg-card-dark border border-gray-200 dark:border-border-dark rounded-lg text-sm font-medium text-slate-700 dark:text-text-main hover:bg-slate-50 dark:hover:bg-border-dark transition-colors">Submit</button>
95
- </div>
96
- <p id="addInfo" class="text-sm text-primary mt-2 hidden"></p>
97
- <p id="addError" class="text-sm text-red-500 mt-2 hidden"></p>
98
- </section>
99
-
100
- <!-- Section 1: Connected Accounts -->
101
- <section class="flex flex-col gap-4">
102
- <div class="flex items-end justify-between">
103
- <div class="flex flex-col gap-1">
104
- <h2 class="text-2xl font-bold tracking-tight">Connected Accounts</h2>
105
- <p class="text-slate-500 dark:text-text-dim text-sm">Manage your AI model proxy services and usage limits.</p>
106
- </div>
107
- </div>
108
- <div id="accountList" class="grid grid-cols-1 md:grid-cols-2 gap-4">
109
- <div class="md:col-span-2 text-center py-8 text-slate-400 dark:text-text-dim text-sm bg-white dark:bg-card-dark border border-gray-200 dark:border-border-dark rounded-xl transition-colors">Loading accounts...</div>
110
- </div>
111
- </section>
112
-
113
- <!-- Section 2: API Configuration -->
114
- <section class="bg-white dark:bg-card-dark border border-gray-200 dark:border-border-dark rounded-xl p-6 shadow-sm transition-colors">
115
- <div class="flex items-center justify-between mb-6 border-b border-slate-100 dark:border-border-dark pb-4">
116
- <div class="flex items-center gap-2">
117
- <span class="material-symbols-outlined text-primary">settings</span>
118
- <h2 class="text-lg font-bold">API Configuration</h2>
119
- </div>
120
- <button onclick="resetConfigDefaults()" class="text-xs text-slate-400 dark:text-text-dim hover:text-slate-600 dark:hover:text-text-main transition-colors">Reset to defaults</button>
121
- </div>
122
- <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
123
- <!-- Base URL -->
124
- <div class="space-y-1.5">
125
- <label class="text-sm font-semibold text-slate-700 dark:text-text-main">Base Proxy URL</label>
126
- <div class="relative flex items-center">
127
- <input id="baseUrl" class="w-full pl-3 pr-10 py-2.5 bg-slate-50 dark:bg-bg-dark border border-gray-200 dark:border-border-dark rounded-lg text-sm font-mono text-slate-600 dark:text-text-main focus:ring-1 focus:ring-primary focus:border-primary outline-none transition-all" type="text" value="Loading..."/>
128
- <button onclick="copyField('baseUrl', this)" class="absolute right-2 p-1.5 text-slate-400 dark:text-text-dim hover:text-primary transition-colors rounded-md hover:bg-slate-100 dark:hover:bg-border-dark" title="Copy URL">
129
- <span class="material-symbols-outlined text-lg">content_copy</span>
130
- </button>
131
- </div>
132
- </div>
133
- <!-- Default Model -->
134
- <div class="space-y-1.5">
135
- <label class="text-sm font-semibold text-slate-700 dark:text-text-main">Default Model</label>
136
- <div class="relative">
137
- <select id="defaultModel" class="w-full appearance-none pl-3 pr-10 py-2.5 bg-white dark:bg-bg-dark border border-gray-200 dark:border-border-dark rounded-lg text-sm text-slate-700 dark:text-text-main font-medium focus:ring-1 focus:ring-primary focus:border-primary outline-none cursor-pointer transition-colors">
138
- <option value="codex">codex</option>
139
- </select>
140
- <div class="pointer-events-none absolute inset-y-0 right-0 flex items-center px-2 text-slate-500 dark:text-text-dim">
141
- <span class="material-symbols-outlined text-lg">expand_more</span>
142
- </div>
143
- </div>
144
- </div>
145
- <!-- API Key -->
146
- <div class="space-y-1.5 md:col-span-2">
147
- <label class="text-sm font-semibold text-slate-700 dark:text-text-main">Your API Key</label>
148
- <div class="relative flex items-center">
149
- <div class="absolute left-3 text-slate-400 dark:text-text-dim">
150
- <span class="material-symbols-outlined text-lg">key</span>
151
- </div>
152
- <input id="apiKey" class="w-full pl-10 pr-10 py-2.5 bg-slate-50 dark:bg-bg-dark border border-gray-200 dark:border-border-dark rounded-lg text-sm font-mono text-slate-600 dark:text-text-main focus:ring-1 focus:ring-primary focus:border-primary outline-none transition-all tracking-wider" type="text" value="Loading..."/>
153
- <button onclick="copyField('apiKey', this)" class="absolute right-2 p-1.5 text-slate-400 dark:text-text-dim hover:text-primary transition-colors rounded-md hover:bg-slate-100 dark:hover:bg-border-dark" title="Copy API Key">
154
- <span class="material-symbols-outlined text-lg">content_copy</span>
155
- </button>
156
- </div>
157
- <p class="text-xs text-slate-400 dark:text-text-dim mt-1">Use this key to authenticate requests to the proxy. Do not share it.</p>
158
- </div>
159
- </div>
160
- </section>
161
-
162
- <!-- Section 3: Integration Examples -->
163
- <section class="flex flex-col gap-4">
164
- <h2 class="text-lg font-bold">Integration Examples</h2>
165
- <div class="bg-white dark:bg-card-dark border border-gray-200 dark:border-border-dark rounded-xl overflow-hidden shadow-sm transition-colors">
166
- <!-- Top Tabs: Providers -->
167
- <div id="protocolTabs" class="flex border-b border-gray-200 dark:border-border-dark bg-slate-50/50 dark:bg-bg-dark/30">
168
- <button onclick="switchProtocol('openai', this)" class="proto-tab px-6 py-3 text-sm font-semibold text-primary border-b-2 border-primary bg-white dark:bg-card-dark transition-colors" data-proto="openai">OpenAI</button>
169
- <button onclick="switchProtocol('anthropic', this)" class="proto-tab px-6 py-3 text-sm font-medium text-slate-500 dark:text-text-dim hover:text-slate-700 dark:hover:text-text-main hover:bg-slate-50 dark:hover:bg-[#21262d] border-b-2 border-transparent transition-colors" data-proto="anthropic">Anthropic</button>
170
- <button onclick="switchProtocol('gemini', this)" class="proto-tab px-6 py-3 text-sm font-medium text-slate-500 dark:text-text-dim hover:text-slate-700 dark:hover:text-text-main hover:bg-slate-50 dark:hover:bg-[#21262d] border-b-2 border-transparent transition-colors" data-proto="gemini">Gemini</button>
171
- </div>
172
- <!-- Sub Tabs: Languages & Content -->
173
- <div class="p-6">
174
- <div class="flex items-center justify-between mb-4">
175
- <div id="langTabs" class="flex gap-2 p-1 bg-slate-100 dark:bg-bg-dark dark:border dark:border-border-dark rounded-lg">
176
- <button onclick="switchLang('python', this)" class="lang-tab px-3 py-1.5 text-xs font-semibold rounded bg-white dark:bg-[#21262d] text-slate-800 dark:text-text-main shadow-sm border border-transparent dark:border-border-dark transition-all" data-lang="python">Python</button>
177
- <button onclick="switchLang('node', this)" class="lang-tab px-3 py-1.5 text-xs font-medium rounded text-slate-500 dark:text-text-dim hover:text-slate-700 dark:hover:text-text-main hover:bg-white/50 dark:hover:bg-[#21262d] border border-transparent transition-all" data-lang="node">Node.js</button>
178
- <button onclick="switchLang('curl', this)" class="lang-tab px-3 py-1.5 text-xs font-medium rounded text-slate-500 dark:text-text-dim hover:text-slate-700 dark:hover:text-text-main hover:bg-white/50 dark:hover:bg-[#21262d] border border-transparent transition-all" data-lang="curl">cURL</button>
179
- </div>
180
- </div>
181
- <!-- Code Block -->
182
- <div id="codeContainer" class="relative group rounded-lg overflow-hidden bg-[#0d1117] text-slate-300 font-mono text-sm border border-slate-800 dark:border-border-dark">
183
- <div class="absolute right-2 top-2 z-10 opacity-0 group-hover:opacity-100 transition-opacity">
184
- <button onclick="copyCurrentCode()" class="flex items-center gap-1.5 px-3 py-1.5 bg-slate-700 hover:bg-slate-600 text-white rounded text-xs font-medium transition-colors">
185
- <span class="material-symbols-outlined text-sm">content_copy</span> Copy
186
- </button>
187
- </div>
188
- <div class="p-4 overflow-x-auto">
189
- <pre class="m-0"><code id="codeBlock">Loading...</code></pre>
190
- </div>
191
- </div>
192
- </div>
193
- </div>
194
- </section>
195
- </div>
196
- </main>
197
- <!-- Footer -->
198
- <footer class="mt-auto border-t border-gray-200 dark:border-border-dark bg-white dark:bg-card-dark py-6 transition-colors">
199
- <div class="container mx-auto px-4 text-center">
200
- <p class="text-sm text-slate-500 dark:text-text-dim">&copy; 2025 Codex Proxy. All rights reserved.</p>
201
- </div>
202
- </footer>
203
-
204
- <script>
205
- // ── State ──────────────────────────────────────────────────────
206
- let authData = null;
207
- let serverBaseUrl = '';
208
- let serverApiKey = '';
209
- let currentProtocol = 'openai';
210
- let currentLang = 'python';
211
- let addPollTimer = null;
212
-
213
- const avatarColors = [
214
- ['bg-purple-100 dark:bg-[#2a1a3f]', 'text-purple-600 dark:text-purple-400'],
215
- ['bg-amber-100 dark:bg-[#3d2c16]', 'text-amber-600 dark:text-amber-500'],
216
- ['bg-blue-100 dark:bg-[#1a2a3f]', 'text-blue-600 dark:text-blue-400'],
217
- ['bg-emerald-100 dark:bg-[#112a1f]', 'text-emerald-600 dark:text-emerald-400'],
218
- ['bg-red-100 dark:bg-[#3f1a1a]', 'text-red-600 dark:text-red-400'],
219
- ];
220
-
221
- // ── Theme Toggle ───────────────────────────────────────────────
222
- function toggleTheme() {
223
- const html = document.documentElement;
224
- const isDark = html.classList.toggle('dark');
225
- localStorage.setItem('codex-proxy-theme', isDark ? 'dark' : 'light');
226
- updateThemeIcon();
227
- }
228
-
229
- function updateThemeIcon() {
230
- const icon = document.getElementById('themeIcon');
231
- if (!icon) return;
232
- icon.textContent = document.documentElement.classList.contains('dark') ? 'light_mode' : 'dark_mode';
233
- }
234
- updateThemeIcon();
235
-
236
- // ── Helpers ────────────────────────────────────────────────────
237
- function escapeHtml(str) {
238
- const div = document.createElement('div');
239
- div.textContent = str;
240
- return div.innerHTML;
241
- }
242
-
243
- function formatNumber(n) {
244
- if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
245
- if (n >= 1000) return (n / 1000).toFixed(1) + 'k';
246
- return String(n);
247
- }
248
-
249
- function statusBadge(status) {
250
- const map = {
251
- active: ['bg-green-100 text-green-700 border-green-200 dark:bg-[#11281d] dark:text-primary dark:border-[#1a442e]', 'Active'],
252
- expired: ['bg-red-100 text-red-600 border-red-200 dark:bg-red-900/20 dark:text-red-400 dark:border-red-800/30', 'Expired'],
253
- rate_limited: ['bg-amber-100 text-amber-700 border-amber-200 dark:bg-amber-900/20 dark:text-amber-400 dark:border-amber-800/30', 'Rate Limited'],
254
- refreshing: ['bg-blue-100 text-blue-600 border-blue-200 dark:bg-blue-900/20 dark:text-blue-400 dark:border-blue-800/30', 'Refreshing'],
255
- disabled: ['bg-slate-100 text-slate-500 border-slate-200 dark:bg-slate-800/30 dark:text-slate-400 dark:border-slate-700/30', 'Disabled'],
256
- };
257
- const [cls, label] = map[status] || map.disabled;
258
- return `<span class="px-2.5 py-1 rounded-full ${cls} text-xs font-medium border">${escapeHtml(label)}</span>`;
259
- }
260
-
261
- // ── OAuth message listener ─────────────────────────────────────
262
- window.addEventListener('message', async (event) => {
263
- if (event.data?.type === 'oauth-callback-success') {
264
- if (addPollTimer) clearInterval(addPollTimer);
265
- document.getElementById('addSection').classList.add('hidden');
266
- showAddInfo('Account added successfully!');
267
- await loadAccounts();
268
- await loadStatus();
269
- }
270
- });
271
-
272
- // ── Accounts ───────────────────────────────────────────────────
273
- async function loadAccounts() {
274
- try {
275
- const resp = await fetch('/auth/accounts?quota=true');
276
- const data = await resp.json();
277
- renderAccounts(data.accounts || []);
278
- } catch (err) {
279
- document.getElementById('accountList').innerHTML =
280
- `<div class="md:col-span-2 text-center py-8 text-red-400 text-sm bg-white dark:bg-card-dark border border-gray-200 dark:border-border-dark rounded-xl">${escapeHtml(err.message)}</div>`;
281
- }
282
- }
283
-
284
- function renderAccounts(accounts) {
285
- const container = document.getElementById('accountList');
286
-
287
- if (accounts.length === 0) {
288
- container.innerHTML = `<div class="md:col-span-2 text-center py-8 text-slate-400 dark:text-text-dim text-sm bg-white dark:bg-card-dark border border-gray-200 dark:border-border-dark rounded-xl">
289
- No accounts connected. Click "Add Account" to get started.
290
- </div>`;
291
- return;
292
- }
293
-
294
- container.innerHTML = accounts.map((acct, i) => {
295
- const usage = acct.usage || {};
296
- const email = acct.email || 'Unknown';
297
- const initial = email.charAt(0).toUpperCase();
298
- const [bgColor, textColor] = avatarColors[i % avatarColors.length];
299
- const requests = usage.request_count ?? 0;
300
- const tokens = (usage.input_tokens ?? 0) + (usage.output_tokens ?? 0);
301
- const plan = acct.planType || 'Free Tier';
302
-
303
- // Quota bar
304
- let quotaHtml = '';
305
- const q = acct.quota;
306
- if (q && q.rate_limit) {
307
- const rl = q.rate_limit;
308
- const pct = rl.used_percent != null ? Math.round(rl.used_percent) : null;
309
- const barColor = pct == null ? 'bg-primary'
310
- : pct >= 90 ? 'bg-red-500'
311
- : pct >= 60 ? 'bg-amber-500'
312
- : 'bg-primary';
313
- const pctColor = pct == null ? 'text-primary'
314
- : pct >= 90 ? 'text-red-500'
315
- : pct >= 60 ? 'text-amber-600 dark:text-amber-500'
316
- : 'text-primary';
317
- const resetAt = rl.reset_at ? new Date(rl.reset_at * 1000).toLocaleTimeString() : null;
318
-
319
- quotaHtml = `
320
- <div class="pt-3 mt-3 border-t border-slate-100 dark:border-border-dark">
321
- <div class="flex justify-between text-sm mb-1.5">
322
- <span class="text-slate-500 dark:text-text-dim">Rate Limit</span>
323
- ${rl.limit_reached
324
- ? '<span class="px-2 py-0.5 rounded-full bg-red-100 dark:bg-red-900/20 text-red-600 dark:text-red-400 text-xs font-medium">Limit Reached</span>'
325
- : pct != null
326
- ? `<span class="font-medium ${pctColor}">${pct}% Used</span>`
327
- : '<span class="font-medium text-primary">OK</span>'}
328
- </div>
329
- ${pct != null ? `
330
- <div class="w-full bg-slate-100 dark:bg-border-dark rounded-full h-2 overflow-hidden">
331
- <div class="${barColor} h-2 rounded-full transition-all" style="width: ${pct}%"></div>
332
- </div>` : ''}
333
- ${resetAt ? `<p class="text-xs text-slate-400 dark:text-text-dim mt-1">Resets at ${escapeHtml(resetAt)}</p>` : ''}
334
- </div>`;
335
- }
336
-
337
- return `
338
- <div class="group bg-white dark:bg-card-dark border border-gray-200 dark:border-border-dark rounded-xl p-5 shadow-sm hover:shadow-md transition-all hover:border-primary/30 dark:hover:border-primary/50 relative">
339
- <button onclick="deleteAccount('${escapeHtml(acct.id)}')" class="absolute top-3 right-3 opacity-0 group-hover:opacity-100 p-1.5 text-slate-300 dark:text-text-dim hover:text-red-500 transition-all rounded-md hover:bg-red-50 dark:hover:bg-red-900/20" title="Delete account">
340
- <span class="material-symbols-outlined text-lg">delete</span>
341
- </button>
342
- <div class="flex justify-between items-start mb-4">
343
- <div class="flex items-center gap-3">
344
- <div class="size-10 rounded-full ${bgColor} ${textColor} flex items-center justify-center font-bold text-lg">${initial}</div>
345
- <div>
346
- <h3 class="font-semibold leading-tight">${escapeHtml(email)}</h3>
347
- <p class="text-xs text-slate-500 dark:text-text-dim">${escapeHtml(plan)}</p>
348
- </div>
349
- </div>
350
- ${statusBadge(acct.status)}
351
- </div>
352
- <div class="space-y-2">
353
- <div class="flex justify-between text-sm">
354
- <span class="text-slate-500 dark:text-text-dim">Total Requests</span>
355
- <span class="font-medium">${formatNumber(requests)}</span>
356
- </div>
357
- <div class="flex justify-between text-sm">
358
- <span class="text-slate-500 dark:text-text-dim">Tokens Used</span>
359
- <span class="font-medium">${formatNumber(tokens)}</span>
360
- </div>
361
- </div>
362
- ${quotaHtml}
363
- </div>`;
364
- }).join('');
365
- }
366
-
367
- async function deleteAccount(id) {
368
- if (!confirm('Remove this account?')) return;
369
- try {
370
- const resp = await fetch('/auth/accounts/' + encodeURIComponent(id), { method: 'DELETE' });
371
- if (!resp.ok) {
372
- const data = await resp.json();
373
- alert(data.error || 'Failed to delete account.');
374
- return;
375
- }
376
- await loadAccounts();
377
- await loadStatus();
378
- } catch (err) {
379
- alert('Network error: ' + err.message);
380
- }
381
- }
382
-
383
- // ── Add Account ────────────────────────────────────────────────
384
- function showAddInfo(msg) {
385
- const el = document.getElementById('addInfo');
386
- el.textContent = msg;
387
- el.classList.remove('hidden');
388
- document.getElementById('addError').classList.add('hidden');
389
- }
390
-
391
- function showAddError(msg) {
392
- const el = document.getElementById('addError');
393
- el.textContent = msg;
394
- el.classList.remove('hidden');
395
- document.getElementById('addInfo').classList.add('hidden');
396
- }
397
-
398
- async function startAddAccount() {
399
- const btn = document.getElementById('addAccountBtn');
400
- const infoEl = document.getElementById('addInfo');
401
- const errEl = document.getElementById('addError');
402
- infoEl.classList.add('hidden');
403
- errEl.classList.add('hidden');
404
- btn.disabled = true;
405
-
406
- try {
407
- const resp = await fetch('/auth/login-start', { method: 'POST' });
408
- const data = await resp.json();
409
-
410
- if (!resp.ok || !data.authUrl) {
411
- throw new Error(data.error || 'Failed to start login');
412
- }
413
-
414
- window.open(data.authUrl, 'oauth_add', 'width=600,height=700,scrollbars=yes');
415
- document.getElementById('addSection').classList.remove('hidden');
416
- btn.disabled = false;
417
-
418
- if (addPollTimer) clearInterval(addPollTimer);
419
- const prevCount = (await fetch('/auth/accounts').then(r => r.json())).accounts?.length || 0;
420
- addPollTimer = setInterval(async () => {
421
- try {
422
- const r = await fetch('/auth/accounts');
423
- const d = await r.json();
424
- if ((d.accounts?.length || 0) > prevCount) {
425
- clearInterval(addPollTimer);
426
- document.getElementById('addSection').classList.add('hidden');
427
- showAddInfo('Account added successfully!');
428
- await loadAccounts();
429
- await loadStatus();
430
- }
431
- } catch {}
432
- }, 2000);
433
- setTimeout(() => { if (addPollTimer) clearInterval(addPollTimer); }, 5 * 60 * 1000);
434
-
435
- } catch (err) {
436
- btn.disabled = false;
437
- showAddError(err.message);
438
- }
439
- }
440
-
441
- async function submitAddRelay() {
442
- const callbackUrl = document.getElementById('addCallbackInput').value.trim();
443
- document.getElementById('addInfo').classList.add('hidden');
444
- document.getElementById('addError').classList.add('hidden');
445
-
446
- if (!callbackUrl) { showAddError('Please paste the callback URL'); return; }
447
-
448
- const btn = document.getElementById('addRelayBtn');
449
- btn.disabled = true;
450
- btn.textContent = 'Submitting...';
451
-
452
- try {
453
- const resp = await fetch('/auth/code-relay', {
454
- method: 'POST',
455
- headers: { 'Content-Type': 'application/json' },
456
- body: JSON.stringify({ callbackUrl }),
457
- });
458
- const data = await resp.json();
459
-
460
- if (resp.ok && data.success) {
461
- if (addPollTimer) clearInterval(addPollTimer);
462
- document.getElementById('addSection').classList.add('hidden');
463
- document.getElementById('addCallbackInput').value = '';
464
- showAddInfo('Account added successfully!');
465
- await loadAccounts();
466
- await loadStatus();
467
- } else {
468
- showAddError(data.error || 'Failed to exchange code');
469
- }
470
- } catch (err) {
471
- showAddError('Network error: ' + err.message);
472
- } finally {
473
- btn.textContent = 'Submit';
474
- btn.disabled = false;
475
- }
476
- }
477
-
478
- // ── Status & Config ────────────────────────────────────────────
479
- async function loadStatus() {
480
- try {
481
- const resp = await fetch('/auth/status');
482
- authData = await resp.json();
483
-
484
- if (!authData.authenticated) { window.location.href = '/'; return; }
485
-
486
- serverBaseUrl = `${window.location.origin}/v1`;
487
- serverApiKey = authData.proxy_api_key || 'any-string';
488
- document.getElementById('baseUrl').value = serverBaseUrl;
489
- document.getElementById('apiKey').value = serverApiKey;
490
-
491
- await populateModelDropdown();
492
- restoreOverrides();
493
- updateCodeExamples();
494
- } catch (err) {
495
- console.error('Status load error:', err);
496
- }
497
- }
498
-
499
- async function populateModelDropdown() {
500
- const sel = document.getElementById('defaultModel');
501
- try {
502
- const resp = await fetch('/v1/models');
503
- const data = await resp.json();
504
- const models = data.data.map(m => m.id);
505
- if (models.length > 0) {
506
- sel.innerHTML = '';
507
- models.forEach(id => {
508
- const opt = document.createElement('option');
509
- opt.value = id;
510
- opt.textContent = id;
511
- sel.appendChild(opt);
512
- });
513
- const preferred = models.find(n => n.includes('5.3-codex'));
514
- if (preferred) sel.value = preferred;
515
- }
516
- } catch {
517
- sel.innerHTML = '<option value="codex">codex</option>';
518
- }
519
- }
520
-
521
- // ── Code Examples ──────────────────────────────────────────────
522
- function updateCodeExamples() {
523
- const baseUrl = document.getElementById('baseUrl').value;
524
- const apiKey = document.getElementById('apiKey').value || 'any-string';
525
- const model = document.getElementById('defaultModel').value;
526
- const origin = window.location.origin;
527
-
528
- window._codeExamples = {
529
- 'openai-python': `from openai import OpenAI
530
-
531
- client = OpenAI(
532
- base_url="${baseUrl}",
533
- api_key="${apiKey}",
534
- )
535
-
536
- response = client.chat.completions.create(
537
- model="${model}",
538
- messages=[{"role": "user", "content": "Hello"}],
539
- )
540
- print(response.choices[0].message.content)`,
541
-
542
- 'openai-curl': `curl ${baseUrl}/chat/completions \\
543
- -H "Content-Type: application/json" \\
544
- -H "Authorization: Bearer ${apiKey}" \\
545
- -d '{
546
- "model": "${model}",
547
- "messages": [{"role": "user", "content": "Hello"}]
548
- }'`,
549
-
550
- 'openai-node': `import OpenAI from "openai";
551
-
552
- const client = new OpenAI({
553
- baseURL: "${baseUrl}",
554
- apiKey: "${apiKey}",
555
- });
556
-
557
- const stream = await client.chat.completions.create({
558
- model: "${model}",
559
- messages: [{ role: "user", content: "Hello" }],
560
- stream: true,
561
- });
562
- for await (const chunk of stream) {
563
- process.stdout.write(chunk.choices[0]?.delta?.content || "");
564
- }`,
565
-
566
- 'anthropic-python': `import anthropic
567
-
568
- client = anthropic.Anthropic(
569
- base_url="${origin}/v1",
570
- api_key="${apiKey}",
571
- )
572
-
573
- message = client.messages.create(
574
- model="claude-sonnet-4-20250514",
575
- max_tokens=1024,
576
- messages=[{"role": "user", "content": "Hello"}],
577
- )
578
- print(message.content[0].text)`,
579
-
580
- 'anthropic-curl': `curl ${origin}/v1/messages \\
581
- -H "Content-Type: application/json" \\
582
- -H "x-api-key: ${apiKey}" \\
583
- -H "anthropic-version: 2023-06-01" \\
584
- -d '{
585
- "model": "claude-sonnet-4-20250514",
586
- "max_tokens": 1024,
587
- "messages": [{"role": "user", "content": "Hello"}]
588
- }'`,
589
-
590
- 'anthropic-node': `import Anthropic from "@anthropic-ai/sdk";
591
-
592
- const client = new Anthropic({
593
- baseURL: "${origin}/v1",
594
- apiKey: "${apiKey}",
595
- });
596
-
597
- const message = await client.messages.create({
598
- model: "claude-sonnet-4-20250514",
599
- max_tokens: 1024,
600
- messages: [{ role: "user", content: "Hello" }],
601
- });
602
- console.log(message.content[0].text);`,
603
-
604
- 'gemini-python': `from google import genai
605
-
606
- client = genai.Client(
607
- api_key="${apiKey}",
608
- http_options={"base_url": "${origin}/v1beta"},
609
- )
610
-
611
- response = client.models.generate_content(
612
- model="gemini-2.5-pro",
613
- contents="Hello",
614
- )
615
- print(response.text)`,
616
-
617
- 'gemini-curl': `curl "${origin}/v1beta/models/gemini-2.5-pro:generateContent?key=${apiKey}" \\
618
- -H "Content-Type: application/json" \\
619
- -d '{
620
- "contents": [{"role": "user", "parts": [{"text": "Hello"}]}]
621
- }'`,
622
-
623
- 'gemini-node': `import { GoogleGenAI } from "@google/genai";
624
-
625
- const ai = new GoogleGenAI({
626
- apiKey: "${apiKey}",
627
- httpOptions: { baseUrl: "${origin}/v1beta" },
628
- });
629
-
630
- const response = await ai.models.generateContent({
631
- model: "gemini-2.5-pro",
632
- contents: "Hello",
633
- });
634
- console.log(response.text);`,
635
- };
636
-
637
- showCurrentCode();
638
- }
639
-
640
- function showCurrentCode() {
641
- const key = `${currentProtocol}-${currentLang}`;
642
- const code = (window._codeExamples && window._codeExamples[key]) || 'Loading...';
643
- document.getElementById('codeBlock').textContent = code;
644
- }
645
-
646
- // ── Tab Switching ──────────────────────────────────────────────
647
- const PROTO_ACTIVE = 'proto-tab px-6 py-3 text-sm font-semibold text-primary border-b-2 border-primary bg-white dark:bg-card-dark transition-colors';
648
- const PROTO_INACTIVE = 'proto-tab px-6 py-3 text-sm font-medium text-slate-500 dark:text-text-dim hover:text-slate-700 dark:hover:text-text-main hover:bg-slate-50 dark:hover:bg-[#21262d] border-b-2 border-transparent transition-colors';
649
- const LANG_ACTIVE = 'lang-tab px-3 py-1.5 text-xs font-semibold rounded bg-white dark:bg-[#21262d] text-slate-800 dark:text-text-main shadow-sm border border-transparent dark:border-border-dark transition-all';
650
- const LANG_INACTIVE = 'lang-tab px-3 py-1.5 text-xs font-medium rounded text-slate-500 dark:text-text-dim hover:text-slate-700 dark:hover:text-text-main hover:bg-white/50 dark:hover:bg-[#21262d] border border-transparent transition-all';
651
-
652
- function switchProtocol(proto, el) {
653
- currentProtocol = proto;
654
- document.querySelectorAll('.proto-tab').forEach(t => t.className = PROTO_INACTIVE);
655
- el.className = PROTO_ACTIVE;
656
- showCurrentCode();
657
- }
658
-
659
- function switchLang(lang, el) {
660
- currentLang = lang;
661
- document.querySelectorAll('.lang-tab').forEach(t => t.className = LANG_INACTIVE);
662
- el.className = LANG_ACTIVE;
663
- showCurrentCode();
664
- }
665
-
666
- // ── Copy ───────────────────────────────────────────────────────
667
- function copyField(id, btn) {
668
- const el = document.getElementById(id);
669
- const text = el.value !== undefined ? el.value : el.textContent;
670
- navigator.clipboard.writeText(text);
671
- const icon = btn.querySelector('.material-symbols-outlined');
672
- if (icon) {
673
- icon.textContent = 'check';
674
- btn.classList.add('text-primary');
675
- setTimeout(() => { icon.textContent = 'content_copy'; btn.classList.remove('text-primary'); }, 2000);
676
- }
677
- }
678
-
679
- function copyCurrentCode() {
680
- const key = `${currentProtocol}-${currentLang}`;
681
- const code = window._codeExamples?.[key] || '';
682
- navigator.clipboard.writeText(code);
683
- }
684
-
685
- // ── Config Overrides (localStorage) ────────────────────────────
686
- function saveOverrides() {
687
- try {
688
- localStorage.setItem('codex-proxy-config-overrides', JSON.stringify({
689
- baseUrl: document.getElementById('baseUrl').value,
690
- apiKey: document.getElementById('apiKey').value,
691
- model: document.getElementById('defaultModel').value,
692
- }));
693
- } catch {}
694
- }
695
-
696
- function restoreOverrides() {
697
- try {
698
- const raw = localStorage.getItem('codex-proxy-config-overrides');
699
- if (!raw) return;
700
- const o = JSON.parse(raw);
701
- if (o.baseUrl) document.getElementById('baseUrl').value = o.baseUrl;
702
- if (o.apiKey) document.getElementById('apiKey').value = o.apiKey;
703
- if (o.model) {
704
- const sel = document.getElementById('defaultModel');
705
- const opts = Array.from(sel.options).map(op => op.value);
706
- if (opts.includes(o.model)) sel.value = o.model;
707
- }
708
- } catch {}
709
- }
710
-
711
- function resetConfigDefaults() {
712
- try { localStorage.removeItem('codex-proxy-config-overrides'); } catch {}
713
- document.getElementById('baseUrl').value = serverBaseUrl;
714
- document.getElementById('apiKey').value = serverApiKey;
715
- const sel = document.getElementById('defaultModel');
716
- const preferred = Array.from(sel.options).find(o => o.value.includes('5.3-codex'));
717
- if (preferred) sel.value = preferred.value;
718
- else if (sel.options.length) sel.selectedIndex = 0;
719
- updateCodeExamples();
720
- }
721
-
722
- // ── Reactive bindings ──────────────────────────────────────────
723
- function setupReactiveBindings() {
724
- document.getElementById('baseUrl').addEventListener('input', () => { updateCodeExamples(); saveOverrides(); });
725
- document.getElementById('apiKey').addEventListener('input', () => { updateCodeExamples(); saveOverrides(); });
726
- document.getElementById('defaultModel').addEventListener('change', () => { updateCodeExamples(); saveOverrides(); });
727
- }
728
-
729
- // ── Init ───────────────────────────────────────────────────────
730
- loadStatus();
731
- setupReactiveBindings();
732
- loadAccounts();
733
- </script>
734
- </body></html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
scripts/setup-curl.ts ADDED
@@ -0,0 +1,244 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Download curl-impersonate (lexiforest fork) prebuilt binary.
4
+ *
5
+ * Usage: npm run setup
6
+ * tsx scripts/setup-curl.ts
7
+ *
8
+ * Detects platform + arch, downloads the matching release from GitHub,
9
+ * extracts curl-impersonate into bin/.
10
+ */
11
+
12
+ import { execSync } from "child_process";
13
+ import { existsSync, mkdirSync, chmodSync, readdirSync, copyFileSync, rmSync } from "fs";
14
+ import { resolve, join } from "path";
15
+
16
+ const REPO = "lexiforest/curl-impersonate";
17
+ const FALLBACK_VERSION = "v1.4.4";
18
+ const BIN_DIR = resolve(process.cwd(), "bin");
19
+
20
+ interface PlatformInfo {
21
+ /** Pattern to match the asset name in GitHub Releases */
22
+ assetPattern: RegExp;
23
+ /** Name of the binary inside the archive */
24
+ binaryName: string;
25
+ /** Name to save the binary as in bin/ */
26
+ destName: string;
27
+ }
28
+
29
+ function getPlatformInfo(version: string): PlatformInfo {
30
+ const platform = process.platform;
31
+ const arch = process.arch;
32
+
33
+ if (platform === "linux") {
34
+ const archStr = arch === "arm64" ? "aarch64-linux-gnu" : "x86_64-linux-gnu";
35
+ return {
36
+ assetPattern: new RegExp(`^curl-impersonate-${version.replaceAll(".", "\\.")}\\.${archStr}\\.tar\\.gz$`),
37
+ binaryName: "curl-impersonate",
38
+ destName: "curl-impersonate",
39
+ };
40
+ }
41
+
42
+ if (platform === "darwin") {
43
+ const archStr = arch === "arm64" ? "arm64-macos" : "x86_64-macos";
44
+ return {
45
+ assetPattern: new RegExp(`^curl-impersonate-${version.replaceAll(".", "\\.")}\\.${archStr}\\.tar\\.gz$`),
46
+ binaryName: "curl-impersonate",
47
+ destName: "curl-impersonate",
48
+ };
49
+ }
50
+
51
+ if (platform === "win32") {
52
+ throw new Error(
53
+ "curl-impersonate CLI binary is not available for Windows.\n" +
54
+ "The proxy will fall back to system curl.\n" +
55
+ "For full TLS fingerprint matching, run the proxy on Linux or macOS.",
56
+ );
57
+ }
58
+
59
+ throw new Error(`Unsupported platform: ${platform}-${arch}`);
60
+ }
61
+
62
+ /** Fetch the latest release tag from GitHub. */
63
+ async function getLatestVersion(): Promise<string> {
64
+ const apiUrl = `https://api.github.com/repos/${REPO}/releases/latest`;
65
+ console.log(`[setup] Checking latest release...`);
66
+ const resp = await fetch(apiUrl, {
67
+ headers: { "Accept": "application/vnd.github+json" },
68
+ });
69
+ if (!resp.ok) {
70
+ console.warn(`[setup] Could not fetch latest release (${resp.status}), using fallback ${FALLBACK_VERSION}`);
71
+ return FALLBACK_VERSION;
72
+ }
73
+ const release = (await resp.json()) as { tag_name: string };
74
+ return release.tag_name;
75
+ }
76
+
77
+ async function getDownloadUrl(info: PlatformInfo, version: string): Promise<string> {
78
+ const apiUrl = `https://api.github.com/repos/${REPO}/releases/tags/${version}`;
79
+ console.log(`[setup] Fetching release info from ${apiUrl}`);
80
+
81
+ const resp = await fetch(apiUrl);
82
+ if (!resp.ok) {
83
+ throw new Error(`GitHub API returned ${resp.status}: ${await resp.text()}`);
84
+ }
85
+
86
+ const release = (await resp.json()) as { assets: { name: string; browser_download_url: string }[] };
87
+
88
+ const asset = release.assets.find((a) => info.assetPattern.test(a.name));
89
+
90
+ if (!asset) {
91
+ const cliAssets = release.assets
92
+ .filter((a) => a.name.startsWith("curl-impersonate-") && !a.name.startsWith("libcurl"))
93
+ .map((a) => a.name)
94
+ .join("\n ");
95
+ throw new Error(
96
+ `No matching asset for pattern ${info.assetPattern}.\nAvailable CLI assets:\n ${cliAssets}`,
97
+ );
98
+ }
99
+
100
+ console.log(`[setup] Found asset: ${asset.name}`);
101
+ return asset.browser_download_url;
102
+ }
103
+
104
+ function downloadAndExtract(url: string, info: PlatformInfo): void {
105
+ if (!existsSync(BIN_DIR)) {
106
+ mkdirSync(BIN_DIR, { recursive: true });
107
+ }
108
+
109
+ const tmpDir = resolve(BIN_DIR, ".tmp-extract");
110
+ if (existsSync(tmpDir)) {
111
+ rmSync(tmpDir, { recursive: true });
112
+ }
113
+ mkdirSync(tmpDir, { recursive: true });
114
+
115
+ const archivePath = resolve(tmpDir, "archive.tar.gz");
116
+
117
+ console.log(`[setup] Downloading ${url}...`);
118
+ execSync(`curl -L -o "${archivePath}" "${url}"`, { stdio: "inherit" });
119
+
120
+ console.log(`[setup] Extracting...`);
121
+ execSync(`tar xzf "${archivePath}" -C "${tmpDir}"`, { stdio: "inherit" });
122
+
123
+ // Find the binary in extracted files (may be in a subdirectory)
124
+ const binary = findFile(tmpDir, info.binaryName);
125
+ if (!binary) {
126
+ const files = listFilesRecursive(tmpDir);
127
+ throw new Error(
128
+ `Could not find ${info.binaryName} in extracted archive.\nFiles found:\n ${files.join("\n ")}`,
129
+ );
130
+ }
131
+
132
+ const destPath = resolve(BIN_DIR, info.destName);
133
+ copyFileSync(binary, destPath);
134
+
135
+ // Also copy shared libraries (.so/.dylib) if present alongside the binary
136
+ const libDir = resolve(binary, "..");
137
+ if (existsSync(libDir)) {
138
+ const libs = readdirSync(libDir).filter(
139
+ (f) => f.endsWith(".so") || f.includes(".so.") || f.endsWith(".dylib"),
140
+ );
141
+ for (const lib of libs) {
142
+ copyFileSync(resolve(libDir, lib), resolve(BIN_DIR, lib));
143
+ }
144
+ }
145
+
146
+ chmodSync(destPath, 0o755);
147
+
148
+ // Cleanup
149
+ rmSync(tmpDir, { recursive: true });
150
+ console.log(`[setup] Installed ${info.destName} to ${destPath}`);
151
+ }
152
+
153
+ function findFile(dir: string, name: string): string | null {
154
+ const entries = readdirSync(dir, { withFileTypes: true });
155
+ for (const entry of entries) {
156
+ const fullPath = join(dir, entry.name);
157
+ if (entry.isDirectory()) {
158
+ const found = findFile(fullPath, name);
159
+ if (found) return found;
160
+ } else if (entry.name === name) {
161
+ return fullPath;
162
+ }
163
+ }
164
+ return null;
165
+ }
166
+
167
+ function listFilesRecursive(dir: string): string[] {
168
+ const results: string[] = [];
169
+ const entries = readdirSync(dir, { withFileTypes: true });
170
+ for (const entry of entries) {
171
+ const fullPath = join(dir, entry.name);
172
+ if (entry.isDirectory()) {
173
+ results.push(...listFilesRecursive(fullPath));
174
+ } else {
175
+ results.push(fullPath);
176
+ }
177
+ }
178
+ return results;
179
+ }
180
+
181
+ async function main() {
182
+ const checkOnly = process.argv.includes("--check");
183
+ const force = process.argv.includes("--force");
184
+
185
+ // Resolve latest version from GitHub
186
+ const version = await getLatestVersion();
187
+ console.log(`[setup] curl-impersonate setup (${version})`);
188
+ console.log(`[setup] Platform: ${process.platform}-${process.arch}`);
189
+
190
+ if (process.platform === "win32") {
191
+ console.warn(
192
+ "[setup] curl-impersonate CLI binary is not available for Windows.\n" +
193
+ "[setup] The proxy will use system curl. For full TLS fingerprint matching,\n" +
194
+ "[setup] deploy on Linux or macOS.",
195
+ );
196
+ return;
197
+ }
198
+
199
+ const destBinary = resolve(BIN_DIR, "curl-impersonate");
200
+
201
+ if (checkOnly) {
202
+ if (existsSync(destBinary)) {
203
+ try {
204
+ const ver = execSync(`"${destBinary}" --version`, { encoding: "utf-8" }).trim().split("\n")[0];
205
+ console.log(`[setup] Current: ${ver}`);
206
+ console.log(`[setup] Latest: ${version}`);
207
+ } catch {
208
+ console.log(`[setup] Binary exists but version check failed`);
209
+ }
210
+ } else {
211
+ console.log(`[setup] Not installed. Latest: ${version}`);
212
+ }
213
+ return;
214
+ }
215
+
216
+ if (existsSync(destBinary) && !force) {
217
+ console.log(`[setup] ${destBinary} already exists. Use --force to re-download.`);
218
+ return;
219
+ }
220
+
221
+ if (force && existsSync(destBinary)) {
222
+ rmSync(destBinary);
223
+ console.log(`[setup] Removed existing binary for forced re-download.`);
224
+ }
225
+
226
+ const info = getPlatformInfo(version);
227
+ const url = await getDownloadUrl(info, version);
228
+ downloadAndExtract(url, info);
229
+
230
+ // Verify the binary runs
231
+ try {
232
+ const ver = execSync(`"${destBinary}" --version`, { encoding: "utf-8" }).trim().split("\n")[0];
233
+ console.log(`[setup] Verified: ${ver}`);
234
+ } catch {
235
+ console.warn(`[setup] Warning: could not verify binary. It may need shared libraries.`);
236
+ }
237
+
238
+ console.log(`[setup] Done! curl-impersonate is ready.`);
239
+ }
240
+
241
+ main().catch((err) => {
242
+ console.error(`[setup] Error: ${err.message}`);
243
+ process.exit(1);
244
+ });
src/config.ts CHANGED
@@ -45,6 +45,7 @@ const ConfigSchema = z.object({
45
  tls: z.object({
46
  curl_binary: z.string().default("auto"),
47
  impersonate_profile: z.string().default("chrome136"),
 
48
  }).default({}),
49
  streaming: z.object({
50
  status_as_content: z.boolean().default(false),
@@ -86,6 +87,11 @@ function applyEnvOverrides(raw: Record<string, unknown>): Record<string, unknown
86
  if (process.env.PORT) {
87
  (raw.server as Record<string, unknown>).port = parseInt(process.env.PORT, 10);
88
  }
 
 
 
 
 
89
  return raw;
90
  }
91
 
 
45
  tls: z.object({
46
  curl_binary: z.string().default("auto"),
47
  impersonate_profile: z.string().default("chrome136"),
48
+ proxy_url: z.string().nullable().default(null),
49
  }).default({}),
50
  streaming: z.object({
51
  status_as_content: z.boolean().default(false),
 
87
  if (process.env.PORT) {
88
  (raw.server as Record<string, unknown>).port = parseInt(process.env.PORT, 10);
89
  }
90
+ const proxyEnv = process.env.HTTPS_PROXY || process.env.https_proxy;
91
+ if (proxyEnv) {
92
+ if (!raw.tls) raw.tls = {};
93
+ (raw.tls as Record<string, unknown>).proxy_url = proxyEnv;
94
+ }
95
  return raw;
96
  }
97
 
src/proxy/codex-api.ts CHANGED
@@ -11,7 +11,7 @@
11
 
12
  import { spawn, execFile } from "child_process";
13
  import { getConfig } from "../config.js";
14
- import { resolveCurlBinary, getChromeTlsArgs } from "../tls/curl-binary.js";
15
  import {
16
  buildHeaders,
17
  buildHeadersWithContentType,
@@ -109,6 +109,7 @@ export class CodexApi {
109
  return new Promise((resolve, reject) => {
110
  const args = [
111
  ...getChromeTlsArgs(), // Chrome TLS profile (ciphers, HTTP/2, etc.)
 
112
  "-s", "-S", // silent but show errors
113
  "--compressed", // curl negotiates compression
114
  "-N", // no output buffering (SSE)
@@ -241,13 +242,13 @@ export class CodexApi {
241
  buildHeaders(this.token, this.accountId),
242
  );
243
  headers["Accept"] = "application/json";
244
- // Remove Accept-Encoding let curl negotiate its own supported encodings
245
- // via --compressed. Passing unsupported encodings (br, zstd) causes curl
246
- // to fail when it can't decompress the response.
247
- delete headers["Accept-Encoding"];
248
 
249
- // Build curl args (Chrome TLS profile + request params)
250
- const args = [...getChromeTlsArgs(), "-s", "--compressed", "--max-time", "15"];
251
  for (const [key, value] of Object.entries(headers)) {
252
  args.push("-H", `${key}: ${value}`);
253
  }
 
11
 
12
  import { spawn, execFile } from "child_process";
13
  import { getConfig } from "../config.js";
14
+ import { resolveCurlBinary, getChromeTlsArgs, getProxyArgs } from "../tls/curl-binary.js";
15
  import {
16
  buildHeaders,
17
  buildHeadersWithContentType,
 
109
  return new Promise((resolve, reject) => {
110
  const args = [
111
  ...getChromeTlsArgs(), // Chrome TLS profile (ciphers, HTTP/2, etc.)
112
+ ...getProxyArgs(), // HTTP/SOCKS5 proxy if configured
113
  "-s", "-S", // silent but show errors
114
  "--compressed", // curl negotiates compression
115
  "-N", // no output buffering (SSE)
 
242
  buildHeaders(this.token, this.accountId),
243
  );
244
  headers["Accept"] = "application/json";
245
+ // Explicitly set Accept-Encoding to encodings that system curl can always
246
+ // decompress. Without this, HTTP/2 servers may send brotli/zstd which
247
+ // system curl (without br/zstd support) cannot decode, producing garbage.
248
+ headers["Accept-Encoding"] = "gzip, deflate";
249
 
250
+ // Build curl args (Chrome TLS profile + proxy + request params)
251
+ const args = [...getChromeTlsArgs(), ...getProxyArgs(), "-s", "--compressed", "--max-time", "15"];
252
  for (const [key, value] of Object.entries(headers)) {
253
  args.push("-H", `${key}: ${value}`);
254
  }
src/routes/web.ts CHANGED
@@ -13,7 +13,7 @@ export function createWebRoutes(accountPool: AccountPool): Hono {
13
  app.get("/", (c) => {
14
  try {
15
  if (accountPool.isAuthenticated()) {
16
- const html = readFileSync(resolve(publicDir, "dashboard1.html"), "utf-8");
17
  return c.html(html);
18
  }
19
  const html = readFileSync(resolve(publicDir, "login.html"), "utf-8");
 
13
  app.get("/", (c) => {
14
  try {
15
  if (accountPool.isAuthenticated()) {
16
+ const html = readFileSync(resolve(publicDir, "dashboard.html"), "utf-8");
17
  return c.html(html);
18
  }
19
  const html = readFileSync(resolve(publicDir, "login.html"), "utf-8");
src/tls/curl-binary.ts CHANGED
@@ -141,6 +141,18 @@ export function getChromeTlsArgs(): string[] {
141
  return [..._tlsArgs];
142
  }
143
 
 
 
 
 
 
 
 
 
 
 
 
 
144
  /**
145
  * Reset the cached binary path (useful for testing).
146
  */
 
141
  return [..._tlsArgs];
142
  }
143
 
144
+ /**
145
+ * Get proxy args to prepend to curl commands.
146
+ * Reads from config tls.proxy_url (which also picks up HTTPS_PROXY env var).
147
+ * Returns empty array when no proxy is configured.
148
+ */
149
+ export function getProxyArgs(): string[] {
150
+ const config = getConfig();
151
+ const url = config.tls.proxy_url;
152
+ if (url) return ["-x", url];
153
+ return [];
154
+ }
155
+
156
  /**
157
  * Reset the cached binary path (useful for testing).
158
  */