tokenbiryani 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. tokenbiryani/__init__.py +3 -0
  2. tokenbiryani/api/__init__.py +0 -0
  3. tokenbiryani/api/app.py +583 -0
  4. tokenbiryani/api/asgi.py +32 -0
  5. tokenbiryani/cli.py +1045 -0
  6. tokenbiryani/config.py +532 -0
  7. tokenbiryani/core/__init__.py +0 -0
  8. tokenbiryani/core/account.py +258 -0
  9. tokenbiryani/core/batch.py +135 -0
  10. tokenbiryani/core/breaker.py +53 -0
  11. tokenbiryani/core/cacheadvice.py +239 -0
  12. tokenbiryani/core/diagnostics.py +131 -0
  13. tokenbiryani/core/estimator.py +180 -0
  14. tokenbiryani/core/gateway.py +2395 -0
  15. tokenbiryani/core/handoff.py +87 -0
  16. tokenbiryani/core/keys.py +199 -0
  17. tokenbiryani/core/limits.py +440 -0
  18. tokenbiryani/core/oauth.py +222 -0
  19. tokenbiryani/core/pacing.py +320 -0
  20. tokenbiryani/core/queue.py +132 -0
  21. tokenbiryani/core/router.py +323 -0
  22. tokenbiryani/core/secrets.py +114 -0
  23. tokenbiryani/core/session.py +117 -0
  24. tokenbiryani/dashboard/__init__.py +56 -0
  25. tokenbiryani/dashboard/console.css +610 -0
  26. tokenbiryani/dashboard/console.html +3250 -0
  27. tokenbiryani/observability/__init__.py +0 -0
  28. tokenbiryani/observability/events.py +171 -0
  29. tokenbiryani/observability/usage.py +226 -0
  30. tokenbiryani/prices.yaml +77 -0
  31. tokenbiryani/providers/__init__.py +0 -0
  32. tokenbiryani/providers/anthropic_api.py +118 -0
  33. tokenbiryani/providers/base.py +173 -0
  34. tokenbiryani/providers/bedrock.py +182 -0
  35. tokenbiryani/providers/oauth.py +165 -0
  36. tokenbiryani/providers/oauth_credentials.py +293 -0
  37. tokenbiryani/providers/translate.py +35 -0
  38. tokenbiryani/providers/vertex.py +144 -0
  39. tokenbiryani/proxy/__init__.py +0 -0
  40. tokenbiryani/proxy/errors.py +169 -0
  41. tokenbiryani/proxy/sse.py +98 -0
  42. tokenbiryani/store/__init__.py +0 -0
  43. tokenbiryani/store/base.py +150 -0
  44. tokenbiryani/store/memory.py +149 -0
  45. tokenbiryani/store/redis_store.py +222 -0
  46. tokenbiryani/store/sqlite.py +336 -0
  47. tokenbiryani-0.2.0.dist-info/METADATA +697 -0
  48. tokenbiryani-0.2.0.dist-info/RECORD +51 -0
  49. tokenbiryani-0.2.0.dist-info/WHEEL +4 -0
  50. tokenbiryani-0.2.0.dist-info/entry_points.txt +2 -0
  51. tokenbiryani-0.2.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,3250 @@
1
+ <!doctype html>
2
+ <html lang="en" data-theme="dark">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width,initial-scale=1">
6
+ <title>tokenbiryani console</title>
7
+ <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32' fill='none' stroke='%23E9A94A' stroke-width='2.8' stroke-linecap='round' stroke-linejoin='round'><path d='M6 10.5h20v10.5a6 6 0 0 1-6 6h-8a6 6 0 0 1-6-6V10.5z'/><path d='M4.5 6.5h23M11 16.5h10M11 21.5h10'/></svg>">
8
+ <link rel="stylesheet" href="/console.css">
9
+ </head>
10
+ <body>
11
+
12
+ <!-- ============================================================================
13
+ The landing page. Everything an unauthenticated visitor can see: what this
14
+ is, and the one field that gets them in. The shell carries no data, so
15
+ serving it without a key gives nothing away.
16
+ ========================================================================= -->
17
+ <div id="gate" class="lp hidden">
18
+ <header class="lp-top">
19
+ <span class="mark-lockup">
20
+ <svg class="mark" viewBox="0 0 32 32" fill="none" aria-hidden="true">
21
+ <path d="M6 10.5h20v10.5a6 6 0 01-6 6h-8a6 6 0 01-6-6V10.5z" stroke="currentColor"
22
+ stroke-width="2.8" stroke-linejoin="round"/>
23
+ <path d="M4.5 6.5h23M11 16.5h10M11 21.5h10" stroke="var(--accent)"
24
+ stroke-width="2.8" stroke-linecap="round"/>
25
+ </svg>
26
+ <b>tokenbiryani</b>
27
+ </span>
28
+ <span class="lp-addr mono" id="lp-addr"></span>
29
+ </header>
30
+
31
+ <main class="lp-main">
32
+ <section class="lp-hero">
33
+ <span class="lp-eyebrow">Pooling gateway for Claude accounts</span>
34
+ <h1>Every credential you own,<br>behind one endpoint.</h1>
35
+ <p class="lp-lede">Routes on live rate-limit headers, keeps a conversation on the
36
+ account holding its prompt cache, fails over without dropping a stream, and
37
+ queues honestly when the whole pool is dry.</p>
38
+
39
+ <ul class="lp-points">
40
+ <li>
41
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9"
42
+ stroke-linecap="round" stroke-linejoin="round"><path d="M3 12h4l3 8 4-16 3 8h4"/></svg>
43
+ <div><b>Cache-aware by default</b>
44
+ <span>Cache-blind pooling costs 1.94x on the same workload. Affinity first;
45
+ rebalance only when the owner genuinely cannot serve.</span></div>
46
+ </li>
47
+ <li>
48
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9"
49
+ stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7"/><path d="M20 20l-4-4"/></svg>
50
+ <div><b>Every decision inspectable</b>
51
+ <span>Each request records why it went where it did — candidates, scores,
52
+ verdicts. "Load balanced" is not an answer.</span></div>
53
+ </li>
54
+ <li>
55
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9"
56
+ stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="M3 10h18"/></svg>
57
+ <div><b>Accounts you manage here</b>
58
+ <span>Add, name, test and rotate API keys, Bedrock, Vertex and Claude
59
+ subscriptions — no config file, credentials encrypted at rest.</span></div>
60
+ </li>
61
+ </ul>
62
+
63
+ <div class="lp-endpoint">
64
+ <span class="lp-endpoint-label">Point any Anthropic client at</span>
65
+ <pre class="code">export ANTHROPIC_BASE_URL=<b id="lp-base"></b>
66
+ export ANTHROPIC_AUTH_TOKEN=<b>&lt;your key&gt;</b></pre>
67
+ </div>
68
+ </section>
69
+
70
+ <section class="lp-auth">
71
+ <div class="card">
72
+ <div class="pad stack">
73
+ <div>
74
+ <h2>Sign in</h2>
75
+ <p class="lp-sub">An admin key unlocks the pool, the usage charts and
76
+ account management.</p>
77
+ </div>
78
+ <label class="f"><span class="t">Admin key</span>
79
+ <input id="gate-key" type="password" placeholder="bir_…" autocomplete="off"
80
+ spellcheck="false">
81
+ <span class="hint">Kept in this browser and sent as an
82
+ <code>x-api-key</code> header — never in a URL, never anywhere else.</span>
83
+ </label>
84
+ <button class="btn primary lp-go" id="gate-go">Open the console</button>
85
+ <div id="gate-msg"></div>
86
+
87
+ <details class="lp-help">
88
+ <summary>Where do I find it?</summary>
89
+ <p>Any key in your <span class="mono">tokenbiryani.yaml</span> marked
90
+ <span class="mono">admin: true</span>. Mint more from the Keys screen once
91
+ you are in.</p>
92
+ <pre class="code">keys:
93
+ - key: <b>bir_…</b>
94
+ name: default
95
+ <b>admin: true</b></pre>
96
+ </details>
97
+ </div>
98
+ </div>
99
+ </section>
100
+ </main>
101
+
102
+ <footer class="lp-foot">
103
+ <span>Self-hosted. Real credentials never leave this process.</span>
104
+ <span class="lp-dot">·</span>
105
+ <span>Anthropic-compatible on <span class="mono">/v1/messages</span></span>
106
+ </footer>
107
+ </div>
108
+
109
+ <!-- ============================================================================
110
+ First run: no accounts. The activation funnel, not an empty table.
111
+ ========================================================================= -->
112
+ <div id="onboarding" class="onboard hidden">
113
+ <aside class="wiz-rail">
114
+ <div class="brand">
115
+ <svg class="mark" viewBox="0 0 32 32" fill="none" aria-hidden="true">
116
+ <path d="M6 10.5h20v10.5a6 6 0 01-6 6h-8a6 6 0 01-6-6V10.5z" stroke="currentColor"
117
+ stroke-width="2.8" stroke-linejoin="round"/>
118
+ <path d="M4.5 6.5h23M11 16.5h10M11 21.5h10" stroke="var(--accent)"
119
+ stroke-width="2.8" stroke-linecap="round"/>
120
+ </svg>
121
+ <span><b>tokenbiryani</b><span>setup</span></span>
122
+ </div>
123
+
124
+ <!-- Filled by renderSteps(). Static markup would have to repeat the titles
125
+ that the step functions already own. -->
126
+ <nav class="wiz-steps" id="ob-steps" aria-label="Setup steps"></nav>
127
+
128
+ <div class="wiz-foot">
129
+ <span class="addr" id="ob-addr"></span>
130
+ <div class="row">
131
+ <button class="chip" id="ob-theme">Theme</button>
132
+ <button class="chip" id="ob-skip">Skip setup</button>
133
+ </div>
134
+ </div>
135
+ </aside>
136
+
137
+ <main class="wiz-main">
138
+ <div class="wiz-body" id="ob-body"></div>
139
+ </main>
140
+ </div>
141
+
142
+ <!-- ============================================================================
143
+ The console.
144
+ ========================================================================= -->
145
+ <div id="app" class="shell hidden">
146
+ <aside class="side">
147
+ <div class="brand">
148
+ <svg class="mark" viewBox="0 0 32 32" fill="none" aria-hidden="true">
149
+ <path d="M6 10.5h20v10.5a6 6 0 01-6 6h-8a6 6 0 01-6-6V10.5z" stroke="currentColor"
150
+ stroke-width="2.8" stroke-linejoin="round"/>
151
+ <path d="M4.5 6.5h23M11 16.5h10M11 21.5h10" stroke="var(--accent)"
152
+ stroke-width="2.8" stroke-linecap="round"/>
153
+ </svg>
154
+ <span><b>tokenbiryani</b><span class="addr mono" id="addr"></span></span>
155
+ </div>
156
+
157
+ <nav class="nav">
158
+ <div class="sep">Monitor</div>
159
+ <button data-tab="pool" class="on">
160
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"
161
+ stroke-linecap="round" stroke-linejoin="round"><path d="M3 12h4l3 8 4-16 3 8h4"/></svg>
162
+ Overview</button>
163
+ <button data-tab="usage">
164
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"
165
+ stroke-linecap="round" stroke-linejoin="round"><path d="M4 20V10M10 20V4M16 20v-7M22 20H2"/></svg>
166
+ Usage</button>
167
+ <button data-tab="requests">
168
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"
169
+ stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h16M4 12h16M4 17h10"/></svg>
170
+ Requests</button>
171
+ <button data-tab="efficiency">
172
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"
173
+ stroke-linecap="round" stroke-linejoin="round"><path d="M13 2 4 14h7l-1 8 9-12h-7z"/></svg>
174
+ Efficiency</button>
175
+
176
+ <div class="sep">Manage</div>
177
+ <button data-tab="accounts">
178
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"
179
+ stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="M3 10h18"/></svg>
180
+ Accounts</button>
181
+ <button data-tab="keys">
182
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"
183
+ stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="12" r="4"/><path d="M12 12h9M18 12v4"/></svg>
184
+ Keys</button>
185
+
186
+ <div class="sep">Setup</div>
187
+ <button data-tab="guide">
188
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"
189
+ stroke-linecap="round" stroke-linejoin="round"><path d="M4 5v14h16V5z"/><path d="M9 9h6M9 13h6"/></svg>
190
+ Connect a client</button>
191
+ <!-- Not a tab: it leaves the console for the wizard, so it carries no
192
+ data-tab and the tab handler skips it. -->
193
+ <button id="nav-setup">
194
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"
195
+ stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M5 12h14"/></svg>
196
+ Add an account</button>
197
+ <button data-tab="settings">
198
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"
199
+ stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M12 3v3M12 18v3M3 12h3M18 12h3"/></svg>
200
+ Settings</button>
201
+ </nav>
202
+
203
+ <div class="side-foot">
204
+ <div class="status" id="live"><i></i><span id="live-text">connecting</span></div>
205
+ <div class="row">
206
+ <button class="chip" id="theme">Theme</button>
207
+ </div>
208
+ <button class="chip" id="signout">Sign out</button>
209
+ </div>
210
+ </aside>
211
+
212
+ <div class="main">
213
+ <div class="topbar">
214
+ <h1 id="view-title">Overview</h1>
215
+ <span class="sub" id="view-sub"></span>
216
+ </div>
217
+
218
+ <div class="view" id="banner-wrap"><div id="banner"></div></div>
219
+
220
+ <!-- ---- Overview ---- -->
221
+ <section class="view" data-panel="pool">
222
+ <div class="stack">
223
+ <div class="tiles" id="stats"></div>
224
+
225
+ <div class="card">
226
+ <header><h3>Capacity horizon</h3>
227
+ <span class="note">projected input tokens available · next 60 min</span></header>
228
+ <div class="pad">
229
+ <div class="hz" id="hz"></div>
230
+ <div class="hzx" id="hzx"></div>
231
+ <div class="hznote" id="hznote"></div>
232
+ </div>
233
+ </div>
234
+
235
+ <div class="card">
236
+ <header><h3>Accounts</h3><span class="note" id="pool-note"></span></header>
237
+ <div id="accounts-wrap" class="scroll pad"></div>
238
+ </div>
239
+
240
+ <div class="card">
241
+ <header><h3>Live requests</h3>
242
+ <span class="note">newest first · click one to see why it went where it did</span></header>
243
+ <div class="stream" id="stream"></div>
244
+ </div>
245
+ </div>
246
+ </section>
247
+
248
+ <!-- ---- Usage ----
249
+ One filter row scoping every chart below it, never per-card filters. -->
250
+ <section class="view hidden" data-panel="usage">
251
+ <div class="filters">
252
+ <div class="seg-ctl" id="u-range">
253
+ <button data-window="1h">1h</button>
254
+ <button data-window="24h" class="on">24h</button>
255
+ <button data-window="7d">7d</button>
256
+ <button data-window="30d">30d</button>
257
+ </div>
258
+ <div class="seg-ctl" id="u-group">
259
+ <button data-group="account" class="on">By account</button>
260
+ <button data-group="model">By model</button>
261
+ <button data-group="key">By key</button>
262
+ </div>
263
+ <span class="note" id="u-note"></span>
264
+ </div>
265
+ <div class="stack" id="u-body"></div>
266
+ </section>
267
+
268
+ <!-- ---- Efficiency ----
269
+ The four questions the meters cannot answer: will the quota last the week,
270
+ is the cache even switched on, is one conversation running away, and is the
271
+ pool reserving output it never uses. Loaded on demand rather than on the 5s
272
+ poll — pacing and sessions both read the spend ledger. -->
273
+ <section class="view hidden" data-panel="efficiency">
274
+ <div class="stack" id="eff-body"></div>
275
+ </section>
276
+
277
+ <!-- ---- Requests ---- -->
278
+ <section class="view hidden" data-panel="requests">
279
+ <div class="stack">
280
+ <div class="card">
281
+ <header><h3>Requests</h3>
282
+ <span class="note">click one to see why it went where it did</span></header>
283
+ <div class="stream" id="requests"></div>
284
+ </div>
285
+ <div id="inspector"></div>
286
+ </div>
287
+ </section>
288
+
289
+ <!-- ---- Accounts ---- -->
290
+ <section class="view hidden" data-panel="accounts">
291
+ <div class="stack">
292
+ <div class="card">
293
+ <header>
294
+ <h3>Pool</h3>
295
+ <button class="btn" id="acct-test-all">Test all</button>
296
+ <button class="btn primary" id="acct-add">Add account</button>
297
+ </header>
298
+ <div class="scroll" id="accounts-table"></div>
299
+ </div>
300
+ <div id="acct-msg"></div>
301
+ <div id="account-detail"></div>
302
+ </div>
303
+ </section>
304
+
305
+ <!-- ---- Keys ---- -->
306
+ <section class="view hidden" data-panel="keys">
307
+ <div class="stack">
308
+ <div class="card">
309
+ <header><h3>Mint a key</h3>
310
+ <span class="note">virtual keys your clients use — real credentials never leave the gateway</span></header>
311
+ <div class="pad stack">
312
+ <div class="grid g3">
313
+ <label class="f">Name<input id="k-name" placeholder="tenant-1"></label>
314
+ <label class="f">Pool<input id="k-pool" placeholder="acct-01,acct-02">
315
+ <span class="hint">blank = every account</span></label>
316
+ <label class="f">RPM<input id="k-rpm" type="number" min="1" placeholder="unlimited"></label>
317
+ <label class="f">Cap USD<input id="k-cap" type="number" step="0.01" placeholder="none"></label>
318
+ <label class="f">Priority
319
+ <select id="k-pri"><option value="interactive">interactive</option><option value="batch">batch</option></select></label>
320
+ <label class="f">Admin
321
+ <select id="k-admin"><option value="false">no</option><option value="true">yes</option></select>
322
+ <span class="hint">admin keys can reach /admin</span></label>
323
+ </div>
324
+ <div class="row"><button class="btn primary" id="k-create">Create key</button></div>
325
+ <div id="k-result"></div>
326
+ </div>
327
+ </div>
328
+ <div class="card">
329
+ <header><h3>Keys</h3></header>
330
+ <div class="scroll pad"><table id="keys"></table></div>
331
+ </div>
332
+ </div>
333
+ </section>
334
+
335
+ <!-- ---- Connect a client ---- -->
336
+ <section class="view hidden" data-panel="guide">
337
+ <div class="guide" id="guide"></div>
338
+ </section>
339
+
340
+ <!-- ---- Settings ---- -->
341
+ <section class="view hidden" data-panel="settings">
342
+ <div class="stack">
343
+ <div class="card">
344
+ <header><h3>Configuration</h3>
345
+ <span class="note">accounts added here are stored in the gateway; the YAML file is re-read on demand</span></header>
346
+ <div class="pad stack">
347
+ <div id="settings-edit" class="stack"></div>
348
+ <div class="row"><button class="btn" id="reload">Reload config file</button></div>
349
+ <div id="reload-result"></div>
350
+ <div class="kv" id="settings"></div>
351
+ </div>
352
+ </div>
353
+ </div>
354
+ </section>
355
+ </div>
356
+ </div>
357
+
358
+ <div id="modal-root"></div>
359
+
360
+ <script>
361
+ "use strict";
362
+ const KEY_STORE = "tokenbiryani.key", THEME_STORE = "tokenbiryani.theme";
363
+ const el = (id) => document.getElementById(id);
364
+
365
+ const state = {
366
+ key: "", tab: "pool", snapshot: null, requests: [], streaming: false,
367
+ stopStream: null, timer: null, ticker: null, onboardStep: 0, wizardOpen: false, poolEmpty: true,
368
+ usage: null, window: "24h", groupBy: "account", oauthConfig: null,
369
+ //: Which of OAUTH_MODES the add-account dialog is on, and what the machine scan
370
+ //: found. Cached per dialog, not per session: a login can happen while it is open.
371
+ oauthMode: "session", oauthDetect: null, keys: [], settings: null,
372
+ //: A client key minted during onboarding. Held for the session only — it is
373
+ //: stored hashed, so this is the one place it exists after it is created.
374
+ mintedKey: "",
375
+ //: offline = the gateway is not answering at all, as opposed to refusing us.
376
+ offline: false, pollDelay: 5000, streamDelay: 2500, streamTimer: null, open: false,
377
+ //: name -> categorical slot, assigned once and never reassigned, so a range
378
+ //: change that reorders the groups cannot repaint them.
379
+ slots: {},
380
+ };
381
+
382
+ const TITLES = {
383
+ pool: ["Overview", "can I make a request right now — and if not, when?"],
384
+ usage: ["Usage", "what the pool has actually spent, and on what"],
385
+ requests: ["Requests", "every request, and the routing decision behind it"],
386
+ efficiency: ["Efficiency", "is this quota being spent well, and will it last the week?"],
387
+ accounts: ["Accounts", "add, name, test and rotate the credentials in the pool"],
388
+ keys: ["Keys", "virtual keys your clients authenticate with"],
389
+ guide: ["Connect a client", "point Claude Code or the SDK at this gateway"],
390
+ settings: ["Settings", "what this gateway is currently running"],
391
+ };
392
+
393
+ /* The account types this console can create. Each one is a form, so adding an
394
+ upstream is a new entry here plus its adapter — no other UI change. */
395
+ const ACCOUNT_TYPES = {
396
+ anthropic_api: {
397
+ label: "Anthropic API key",
398
+ blurb: "A console key (sk-ant-…). Reports rate-limit headers, so every meter, the " +
399
+ "capacity horizon and headroom routing work at full strength.",
400
+ fields: [
401
+ { name: "api_key", label: "API key", type: "password", required: true,
402
+ placeholder: "sk-ant-…", hint: "Encrypted before it is stored. Never shown again." },
403
+ { name: "base_url", label: "Base URL", placeholder: "https://api.anthropic.com",
404
+ hint: "Override only — leave blank for Anthropic." },
405
+ ],
406
+ },
407
+ oauth: {
408
+ label: "Claude subscription (Max / Pro)",
409
+ subscription: true,
410
+ blurb: "Uses your Claude subscription instead of an API key. A subscription reports " +
411
+ "how much of its 5-hour and 7-day windows is spent, so this account still " +
412
+ "gets headroom-aware routing and failover — what it cannot have is leases " +
413
+ "and the capacity horizon, which need absolute token counts.",
414
+ fields: [],
415
+ },
416
+ bedrock: {
417
+ label: "AWS Bedrock",
418
+ blurb: "Signed with SigV4 from the ambient AWS credential chain. Leave the key " +
419
+ "blank unless you are using a static credential.",
420
+ fields: [
421
+ { name: "opt.region", label: "Region", placeholder: "us-east-1", required: true },
422
+ { name: "opt.profile", label: "AWS profile", placeholder: "default",
423
+ hint: "Blank uses the ambient credential chain." },
424
+ ],
425
+ },
426
+ vertex: {
427
+ label: "Google Vertex AI",
428
+ blurb: "Bearer token from Application Default Credentials.",
429
+ fields: [
430
+ { name: "opt.project", label: "GCP project", placeholder: "my-project", required: true },
431
+ { name: "opt.region", label: "Region", placeholder: "us-central1" },
432
+ ],
433
+ },
434
+ };
435
+
436
+ /* Three ways an account gets a subscription token, in the order they are worth
437
+ trying. The first needs nothing typed at all; the last needs three endpoint
438
+ values Anthropic does not publish, which is why it is no longer the only door. */
439
+ const OAUTH_MODES = {
440
+ session: {
441
+ label: "This machine's Claude Code login",
442
+ tag: "nothing to paste",
443
+ blurb: "The gateway reads the token the Claude Code CLI already wrote, and re-reads " +
444
+ "it whenever the CLI refreshes it. No endpoints to configure, no token to " +
445
+ "rotate — but it only works while something keeps that file current.",
446
+ },
447
+ token: {
448
+ label: "A long-lived token",
449
+ tag: "survives a logout",
450
+ blurb: "Run <span class=\"mono\">claude setup-token</span> and paste what it prints. " +
451
+ "Encrypted before it is stored, and it does not depend on any file staying " +
452
+ "fresh. This is the one to use in a container.",
453
+ },
454
+ login: {
455
+ label: "Log in with Claude",
456
+ tag: "one session per account",
457
+ blurb: "The gateway runs the OAuth flow itself and holds a session per account, " +
458
+ "renewing it before it lapses. This is what pooling more than one " +
459
+ "subscription needs — and it is the one that needs oauth.* configured.",
460
+ },
461
+ };
462
+
463
+ // ---- helpers -----------------------------------------------------------------
464
+ const esc = (v) => String(v == null ? "" : v).replace(/[&<>"']/g,
465
+ (c) => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c]));
466
+ const pct = (f) => f == null ? "—" : Math.round(f * 100) + "%";
467
+ const clock = (s) => {
468
+ if (s == null) return "—";
469
+ s = Math.max(0, Math.round(s));
470
+ return String(Math.floor(s / 60)).padStart(2, "0") + ":" + String(s % 60).padStart(2, "0");
471
+ };
472
+ const tokens = (n) => {
473
+ if (n == null) return "—";
474
+ if (n >= 1e6) return (n / 1e6).toFixed(2) + "M";
475
+ if (n >= 1e3) return Math.round(n / 1e3) + "k";
476
+ return String(n);
477
+ };
478
+ const usd = (n) => n == null ? "—" : "$" + Number(n).toFixed(n < 1 ? 4 : 2);
479
+ const num = (n) => n == null ? "0" : Number(n).toLocaleString();
480
+ /** Axis ticks want round money, not the four decimals a single request needs. */
481
+ const usdAxis = (n) => {
482
+ if (n == null) return "—";
483
+ const value = Number(n);
484
+ if (value === 0) return "$0";
485
+ if (value >= 100) return "$" + Math.round(value).toLocaleString();
486
+ if (value >= 1) return "$" + value.toFixed(2);
487
+ return "$" + value.toFixed(3);
488
+ };
489
+ const hhmmss = (epoch) => new Date(epoch * 1000).toTimeString().slice(0, 8);
490
+ const now = () => Date.now() / 1000;
491
+
492
+ /** A countdown renders once and then ticks itself, so the page has one moving part. */
493
+ function countdown(seconds) {
494
+ if (seconds == null) return '<span class="mono dm">—</span>';
495
+ return `<span class="mono cd" data-until="${(now() + seconds).toFixed(1)}">${clock(seconds)}</span>`;
496
+ }
497
+
498
+ function tick() {
499
+ document.querySelectorAll(".cd").forEach((node) => {
500
+ const remaining = parseFloat(node.dataset.until) - now();
501
+ node.textContent = remaining <= 0 ? "now" : clock(remaining);
502
+ node.classList.toggle("dm", remaining <= 0);
503
+ });
504
+ }
505
+
506
+ function banner(message, kind) {
507
+ el("banner").innerHTML = message
508
+ ? `<div class="note ${kind || "err"}">${esc(message)}</div>` : "";
509
+ }
510
+
511
+ function bannerHTML(html, kind) {
512
+ el("banner").innerHTML = `<div class="note ${kind || "err"}">${html}</div>`;
513
+ }
514
+
515
+ /**
516
+ * The gateway stopped answering.
517
+ *
518
+ * Three things have to happen, and only the first used to. Say so, in terms of the
519
+ * address that is not answering. Stop presenting numbers that are now history as
520
+ * though they were live. And back off — polling four endpoints every five seconds
521
+ * forever fills the browser console with hundreds of failures and finds nothing out
522
+ * that the first one did not.
523
+ */
524
+ function goOffline() {
525
+ if (!state.offline) {
526
+ state.offline = true;
527
+ setLive(false);
528
+ document.querySelector(".main").classList.add("stale");
529
+ }
530
+ state.pollDelay = Math.min(30000, Math.round((state.pollDelay || 5000) * 1.8));
531
+ bannerHTML(
532
+ `<span>Cannot reach the gateway at <b class="mono">${esc(location.host)}</b>.
533
+ It may have stopped, or be restarting. Retrying every
534
+ ${Math.round(state.pollDelay / 1000)}s.
535
+ <button class="btn sm" id="retry-now" style="margin-left:10px">Retry now</button></span>`,
536
+ "err");
537
+ const retry = el("retry-now");
538
+ if (retry) retry.addEventListener("click", () => { state.pollDelay = 5000; pollNow(); });
539
+ }
540
+
541
+ function goOnline() {
542
+ if (state.offline) {
543
+ state.offline = false;
544
+ document.querySelector(".main").classList.remove("stale");
545
+ banner("");
546
+ // The stream is on its own backoff, which may be half a minute out. Cancel it
547
+ // and reconnect now, or the chip reads "offline" for another 30s against a
548
+ // gateway that is plainly answering.
549
+ if (state.streamTimer) { clearTimeout(state.streamTimer); state.streamTimer = null; }
550
+ state.streamDelay = 2500;
551
+ if (!state.streaming) connectStream();
552
+ setLive(state.streaming);
553
+ }
554
+ state.pollDelay = 5000;
555
+ }
556
+
557
+ async function api(path, options) {
558
+ let response;
559
+ try {
560
+ response = await fetch(path, Object.assign({
561
+ headers: { "x-api-key": state.key, "content-type": "application/json" }
562
+ }, options || {}));
563
+ } catch (networkError) {
564
+ // fetch only rejects below HTTP: the gateway is not answering at all. That is
565
+ // a different situation from a request it refused, and saying "Failed to
566
+ // fetch" — which is all the browser gives us — helps nobody.
567
+ const offline = new Error("offline");
568
+ offline.offline = true;
569
+ throw offline;
570
+ }
571
+ if (response.status === 401 || response.status === 403) {
572
+ signOut(response.status === 403
573
+ ? "That key is not an admin key." : "That key was rejected.");
574
+ throw new Error("unauthorized");
575
+ }
576
+ if (!response.ok) {
577
+ let detail = String(response.status);
578
+ try { detail = (await response.json()).error.message; } catch (e) { /* keep status */ }
579
+ throw new Error(detail);
580
+ }
581
+ return response.status === 204 ? null : response.json();
582
+ }
583
+
584
+ async function copy(text, into) {
585
+ try {
586
+ await navigator.clipboard.writeText(text);
587
+ if (into) into.innerHTML = '<div class="note ok">Copied.</div>';
588
+ } catch (e) {
589
+ if (into) into.innerHTML = '<div class="note warn">Copy failed — select the text above.</div>';
590
+ }
591
+ }
592
+
593
+ // ---- overview ----------------------------------------------------------------
594
+ function renderStats(snapshot) {
595
+ const pool = snapshot.pool, stats = snapshot.stats;
596
+ const cooling = pool.cooling, disabled = pool.disabled;
597
+ el("stats").innerHTML = [
598
+ // A pool of subscriptions has no token count to add up — its budget is a
599
+ // percentage of a rolling window. Showing "0 tok" for a pool that is 77%
600
+ // available was the wrong kind of honest.
601
+ ["Ready now",
602
+ pool.ready_input_tokens || pool.ready_headroom == null
603
+ ? tokens(pool.ready_input_tokens) + "<small> tok</small>"
604
+ : pct(pool.ready_headroom) + "<small> headroom</small>",
605
+ `${pool.ready} of ${pool.total} accounts`, ""],
606
+ ["Next reset", countdown(pool.next_reset_seconds), "earliest window", ""],
607
+ ["Queue", String(snapshot.queue.depth), "max " + snapshot.queue.max_size,
608
+ snapshot.queue.depth > 0 ? "bad" : ""],
609
+ ["Cache hit", pct(stats.cache_hit_rate), stats.cache_breaks + " breaks / 1h", "cache"],
610
+ ["Spend 1h", usd(stats.spend_usd), stats.requests + " requests", ""],
611
+ ["Failovers", String(stats.failovers), stats.errors + " errors / 1h",
612
+ stats.errors > 0 ? "bad" : ""],
613
+ ].map(([label, value, sub, cls]) =>
614
+ `<div class="tile ${cls}"><span class="lb">${label}</span>
615
+ <span class="vl mono">${value}</span><span class="sub">${esc(sub)}</span></div>`).join("");
616
+
617
+ el("pool-note").textContent =
618
+ `${pool.total} in pool · strategy ${snapshot.strategy}` +
619
+ (cooling ? ` · ${cooling} cooling` : "") + (disabled ? ` · ${disabled} disabled` : "");
620
+
621
+ // The console's worst moment must still be useful.
622
+ if (pool.ready === 0 && pool.total > 0) {
623
+ banner(
624
+ disabled === pool.total
625
+ ? "Every account is disabled. This does not self-heal — check the credentials on the Accounts screen."
626
+ : "The whole pool is cooling. Capacity returns at the countdown above; queued requests will be served then.",
627
+ disabled === pool.total ? "err" : "warn");
628
+ } else if (disabled > 0) {
629
+ banner(`${disabled} account(s) disabled — usually a rotated key. Open Accounts and press Test.`, "err");
630
+ } else {
631
+ banner("");
632
+ }
633
+ }
634
+
635
+ function meter(label, window) {
636
+ const fraction = window.fraction == null ? 0 : window.fraction;
637
+ const kind = fraction > 0.25 ? "ready" : fraction > 0.05 ? "cool" : "crit";
638
+ const width = Math.max(2, Math.round(fraction * 100));
639
+ // Always show the percentage: it is the one figure comparable across accounts,
640
+ // whether or not the upstream told us an absolute budget.
641
+ const absolute = window.available == null ? "" : " " + tokens(window.available);
642
+ return `<span class="bar"><span class="k">${label}</span>
643
+ <span class="track"><i style="width:${width}%;background:var(--${kind})"></i></span>
644
+ <span class="v">${pct(window.fraction)}${absolute}</span></span>`;
645
+ }
646
+
647
+ /** A rolling window a subscription reports: spent, not remaining.
648
+ *
649
+ * Drawn on the same scale as the others so the two kinds of account can be
650
+ * compared down a column — the bar is headroom either way. */
651
+ function unifiedMeter(label, window) {
652
+ if (!window || window.headroom == null) {
653
+ return `<span class="bar"><span class="k">${label}</span>
654
+ <span class="track"></span><span class="v dm">—</span></span>`;
655
+ }
656
+ const kind = window.headroom > 0.25 ? "ready" : window.headroom > 0.05 ? "cool" : "crit";
657
+ const width = Math.max(2, Math.round(window.headroom * 100));
658
+ return `<span class="bar" title="${esc(label)} window ${pct(window.utilization)} used">
659
+ <span class="k">${label}</span>
660
+ <span class="track"><i style="width:${width}%;background:var(--${kind})"></i></span>
661
+ <span class="v">${pct(window.headroom)}</span></span>`;
662
+ }
663
+
664
+ /** A subscription reports its two rolling windows; an API key reports three
665
+ * budgets. Showing empty req/in/out bars for a subscription used to read as a
666
+ * fault, when in fact the account had told us exactly how much was left. */
667
+ const meters = (limits) => limits.unified_known
668
+ ? `<span class="meter">${unifiedMeter("5h", (limits.unified || {})["5h"])}` +
669
+ `${unifiedMeter("7d", (limits.unified || {})["7d"])}</span>`
670
+ : `<span class="meter">${meter("req", limits.requests)}` +
671
+ `${meter("in", limits.input_tokens)}${meter("out", limits.output_tokens)}</span>`;
672
+
673
+ /** When this account's headroom next moves, whichever kind of window it reports. */
674
+ const resetIn = (limits) => limits.unified_known
675
+ ? ((limits.unified || {})["5h"] || {}).reset_in
676
+ : limits.input_tokens.reset_in;
677
+
678
+ /** Latency sparkline built from request history — no extra endpoint needed. */
679
+ function sparkline(accountId) {
680
+ const points = state.requests
681
+ .filter((r) => r.account_id === accountId && r.latency)
682
+ .slice(0, 14).reverse().map((r) => r.latency);
683
+ if (points.length < 2) return '<span class="dm mono" style="font-size:10px">—</span>';
684
+ const high = Math.max(...points), low = Math.min(...points);
685
+ const span = (high - low) || 1;
686
+ const step = 50 / (points.length - 1);
687
+ const coords = points.map((v, i) =>
688
+ `${(i * step).toFixed(1)},${(16 - ((v - low) / span) * 13).toFixed(1)}`);
689
+ const [lastX, lastY] = coords[coords.length - 1].split(",");
690
+ return `<svg class="spark" width="52" height="18" viewBox="0 0 52 18" fill="none"
691
+ role="img" aria-label="latency trend, ${points.length} requests, latest ${points[points.length-1].toFixed(2)}s">
692
+ <polyline points="${coords.join(" ")}" stroke="var(--ready)" stroke-width="2"
693
+ fill="none" stroke-linejoin="round" stroke-linecap="round"/>
694
+ <circle cx="${lastX}" cy="${lastY}" r="2.5" fill="var(--ready-t)"/></svg>`;
695
+ }
696
+
697
+ /** Name first, id second: the id is the stable handle, the name is what you called it. */
698
+ function identity(account) {
699
+ // "no limits" is only true when nothing was reported at all. A subscription
700
+ // reporting unified utilisation has limits; they are just a different shape.
701
+ const blind = account.limits.observable === false && !account.limits.unified_known
702
+ ? ' <span class="pill muted" title="this upstream reports no rate-limit headers"><i></i>no limits</span>'
703
+ : account.limits.unified_known
704
+ ? ' <span class="pill accent" title="reports rolling 5h and 7d utilisation"><i></i>rolling</span>' : "";
705
+ const named = account.name && account.name !== account.id;
706
+ return `<span class="aid"><span class="nm">${esc(account.name || account.id)}${blind}</span>
707
+ <span class="sub">${named ? esc(account.id) + " · " : ""}${esc(account.type)}</span></span>`;
708
+ }
709
+
710
+ function statePill(account) {
711
+ const cooling = account.state === "cooling" && account.cooling_for != null
712
+ ? " " + clock(account.cooling_for) : "";
713
+ return `<span class="pill ${esc(account.state)}"><i></i>${esc(account.state)}${cooling}</span>`;
714
+ }
715
+
716
+ /** The Enabled column. It works for config accounts too now: the toggle stores an
717
+ * override in the gateway rather than editing the file, which is the difference
718
+ * between turning an account off at 3am and opening an editor on the server. The
719
+ * file still owns the credential, and the hover text says where the change lives. */
720
+ function enabledToggle(account, managed) {
721
+ const button = `<button class="tog ${account.enabled === false ? "" : "on"}"
722
+ data-toggle="${esc(account.id)}"
723
+ aria-label="${account.enabled === false ? "disabled" : "enabled"}"><i></i></button>`;
724
+ if (managed) return button;
725
+ const why = `${account.id} is declared in tokenbiryani.yaml. Turning it off here is `
726
+ + "stored in the gateway, not written to the file — Revert puts the file back in charge";
727
+ return `<span class="held" title="${esc(why)}">${button}</span>`;
728
+ }
729
+
730
+ function renderAccounts(snapshot) {
731
+ const accounts = snapshot.accounts;
732
+ el("accounts-wrap").innerHTML = `<table><thead><tr><th>Account</th><th>State</th>
733
+ <th>Headroom</th><th class="r">Reset</th><th class="r">Inflight</th><th class="r">p95</th>
734
+ <th class="r">Trend</th><th class="r">Cache</th><th class="r">Spend</th></tr></thead>
735
+ <tbody>${accounts.map((a) => `<tr class="click" tabindex="0" data-account="${esc(a.id)}"
736
+ aria-label="account ${esc(a.name || a.id)}, ${esc(a.state)}">
737
+ <td>${identity(a)}</td>
738
+ <td>${statePill(a)}</td>
739
+ <td>${meters(a.limits)}</td>
740
+ <td class="r">${countdown(resetIn(a.limits))}</td>
741
+ <td class="r mono">${a.inflight}</td>
742
+ <td class="r mono">${a.p95_latency == null ? "—" : a.p95_latency.toFixed(2) + "s"}</td>
743
+ <td class="r">${sparkline(a.id)}</td>
744
+ <td class="r mono ca">${pct(a.cache_hit_rate)}</td>
745
+ <td class="r mono">${usd(a.spend_usd)}</td></tr>`).join("")}</tbody></table>`;
746
+ activate(el("accounts-wrap").querySelectorAll("tr[data-account]"),
747
+ (node) => openAccount(node.dataset.account));
748
+ }
749
+
750
+ function renderHorizon(horizon) {
751
+ const series = horizon.series || [];
752
+ if (!series.length || series.every((p) => !p.input_tokens)) {
753
+ el("hz").innerHTML = "";
754
+ el("hzx").innerHTML = "";
755
+ // Distinguish "told us nothing" from "told us a percentage": a subscription
756
+ // reports its budget as utilisation, which routes fine and plots not at all.
757
+ const rolling = ((state.snapshot || {}).accounts || [])
758
+ .some((a) => a.limits && a.limits.unified_known);
759
+ el("hznote").innerHTML = rolling
760
+ ? "<span>This pool's budget is a share of a rolling window, not a token " +
761
+ "count, so there is nothing to project. Headroom still routes — see the " +
762
+ "<b>5h</b> and <b>7d</b> meters below.</span>"
763
+ : "<span>No account reports a budget the gateway can project. " +
764
+ "The horizon needs <b>anthropic-ratelimit-*</b> headers.</span>";
765
+ return;
766
+ }
767
+ // Scale to a little above the peak: a plot filled edge to edge reads as "maxed
768
+ // out" when what it actually shows is "steady".
769
+ const ceiling = Math.max(...series.map((p) => p.input_tokens)) * 1.25;
770
+ el("hz").innerHTML = series.map((point, index) => {
771
+ const grew = index > 0 && point.input_tokens > series[index - 1].input_tokens;
772
+ const height = Math.max(4, (point.input_tokens / ceiling) * 100);
773
+ return `<div class="${grew ? "step" : ""}" style="height:${height}%"
774
+ title="+${Math.round(point.offset_seconds / 60)} min — ${tokens(point.input_tokens)} tokens${grew ? " (a window resets)" : ""}"></div>`;
775
+ }).join("");
776
+ el("hzx").innerHTML = series.map((point, index) =>
777
+ `<span class="mono">${index === 0 ? "now" : "+" + Math.round(point.offset_seconds / 60)}</span>`).join("");
778
+ const start = series[0].input_tokens, end = series[series.length - 1].input_tokens;
779
+ el("hznote").innerHTML =
780
+ `<span><b class="mono">${tokens(start)}</b> now</span>
781
+ <span><b class="mono">${tokens(end)}</b> in ${horizon.minutes} min</span>` +
782
+ (end > start
783
+ ? `<span>capacity returns as windows reset</span>`
784
+ : `<span>flat — nothing resets in this window</span>`);
785
+ }
786
+
787
+ // ---- charts ------------------------------------------------------------------
788
+ // Hand-drawn inline SVG, like the sparkline and the horizon above. No library, no
789
+ // build step, and the whole console stays one file plus one stylesheet.
790
+
791
+ const SERIES_SLOTS = 8;
792
+ const CHART_W = 980, CHART_H = 200, PAD = { t: 12, r: 64, b: 22, l: 52 };
793
+ const PLOT_W = CHART_W - PAD.l - PAD.r, PLOT_H = CHART_H - PAD.t - PAD.b;
794
+
795
+ /**
796
+ * Colour follows the entity, never its rank.
797
+ *
798
+ * The API returns groups ordered largest-first, so painting by array index would
799
+ * repaint every survivor the moment a range change reorders them — the reader who
800
+ * learned "acct-02 is orange" would be misled. Slots are therefore assigned once,
801
+ * on first sight, and kept for the life of the session.
802
+ */
803
+ function colorOf(name) {
804
+ if (name === "—" || name === "Other") return "var(--series-other)";
805
+ if (!(name in state.slots)) {
806
+ const taken = Object.keys(state.slots).length;
807
+ // Past eight, everything folds into one neutral rather than cycling hues that
808
+ // are indistinguishable under colour-vision deficiency.
809
+ state.slots[name] = taken < SERIES_SLOTS ? taken + 1 : 0;
810
+ }
811
+ const slot = state.slots[name];
812
+ return slot === 0 ? "var(--series-other)" : `var(--series-${slot})`;
813
+ }
814
+
815
+ /** Group names capped at eight; the tail becomes one honest "Other". */
816
+ function cappedGroups(groups) {
817
+ groups.forEach(colorOf);
818
+ const kept = groups.filter((g) => state.slots[g] !== 0).slice(0, SERIES_SLOTS);
819
+ return groups.length > kept.length ? kept.concat(["Other"]) : kept;
820
+ }
821
+
822
+ const valueIn = (cell, metric) => (cell && cell[metric] != null ? cell[metric] : 0);
823
+
824
+ /** Round a maximum up to a clean axis ceiling, so ticks read 0 / 50k / 100k. */
825
+ function niceCeiling(value) {
826
+ if (!(value > 0)) return 1;
827
+ const magnitude = Math.pow(10, Math.floor(Math.log10(value)));
828
+ const scaled = value / magnitude;
829
+ const step = scaled <= 1 ? 1 : scaled <= 2 ? 2 : scaled <= 5 ? 5 : 10;
830
+ return step * magnitude;
831
+ }
832
+
833
+ function axisTime(at, windowSeconds) {
834
+ const date = new Date(at * 1000);
835
+ if (windowSeconds <= 86400) return date.toTimeString().slice(0, 5);
836
+ return `${date.getMonth() + 1}/${date.getDate()}`;
837
+ }
838
+
839
+ function fullTime(at) {
840
+ const date = new Date(at * 1000);
841
+ return date.toLocaleString(undefined,
842
+ { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" });
843
+ }
844
+
845
+ /** Y gridlines plus their tick labels. Hairline, solid, one shade off surface. */
846
+ function gridSvg(ceiling, format) {
847
+ let out = "";
848
+ for (let i = 0; i <= 4; i++) {
849
+ const y = PAD.t + PLOT_H - (i / 4) * PLOT_H;
850
+ out += `<line class="grid-line" x1="${PAD.l}" y1="${y}" x2="${PAD.l + PLOT_W}" y2="${y}"/>`;
851
+ out += `<text x="${PAD.l - 8}" y="${y + 3}" text-anchor="end">${esc(format(ceiling * i / 4))}</text>`;
852
+ }
853
+ return out;
854
+ }
855
+
856
+ function xLabelsSvg(buckets, windowSeconds) {
857
+ const every = Math.max(1, Math.ceil(buckets.length / 8));
858
+ return buckets.map((bucket, index) => {
859
+ if (index % every) return "";
860
+ const x = PAD.l + (index + 0.5) * (PLOT_W / buckets.length);
861
+ return `<text x="${x}" y="${CHART_H - 6}" text-anchor="middle">${esc(axisTime(bucket.at, windowSeconds))}</text>`;
862
+ }).join("");
863
+ }
864
+
865
+ const legendSvg = (groups) => `<div class="legend">${groups.map((name) =>
866
+ `<span><i style="background:${colorOf(name)}"></i>${esc(displayName(name))}</span>`).join("")}</div>`;
867
+
868
+ /**
869
+ * Stacked columns: tokens or requests over time, split by group.
870
+ *
871
+ * Segments are separated by a 2px gap in the surface colour rather than a stroke
872
+ * around each one — the gap is the separator, and a border would add ink that is
873
+ * not data.
874
+ */
875
+ function stackedColumns(buckets, groups, metric, format, windowSeconds) {
876
+ const slot = PLOT_W / buckets.length;
877
+ const barWidth = Math.min(24, slot * 0.7);
878
+ const ceiling = niceCeiling(Math.max(...buckets.map((b) =>
879
+ groups.reduce((sum, g) => sum + valueIn(b.by[g], metric), 0)), 0));
880
+
881
+ let marks = "";
882
+ buckets.forEach((bucket, index) => {
883
+ const x = PAD.l + index * slot + (slot - barWidth) / 2;
884
+ let baseline = PAD.t + PLOT_H;
885
+ let drawn = 0;
886
+ groups.forEach((name) => {
887
+ const value = valueIn(bucket.by[name], metric);
888
+ if (!value) return;
889
+ const height = (value / ceiling) * PLOT_H;
890
+ baseline -= height;
891
+ // 4px rounded data-end on the topmost segment only; square at the baseline.
892
+ const radius = drawn === 0 ? 4 : 0;
893
+ marks += `<rect class="seg" x="${x.toFixed(1)}" y="${baseline.toFixed(1)}"
894
+ width="${barWidth.toFixed(1)}" height="${Math.max(1, height).toFixed(1)}"
895
+ rx="${radius}" fill="${colorOf(name)}"/>`;
896
+ drawn++;
897
+ });
898
+ // One hit target per column, the full slot wide: far easier to land on than
899
+ // a 24px bar, and it drives the whole-column tooltip.
900
+ marks += `<rect class="hit" x="${(PAD.l + index * slot).toFixed(1)}" y="${PAD.t}"
901
+ width="${slot.toFixed(1)}" height="${PLOT_H}" data-bucket="${index}"/>`;
902
+ });
903
+
904
+ return `<svg class="chart" viewBox="0 0 ${CHART_W} ${CHART_H}" role="img">
905
+ ${gridSvg(ceiling, format)}
906
+ <line class="axis" x1="${PAD.l}" y1="${PAD.t + PLOT_H}" x2="${PAD.l + PLOT_W}" y2="${PAD.t + PLOT_H}"/>
907
+ ${marks}${xLabelsSvg(buckets, windowSeconds)}</svg>${legendSvg(groups)}`;
908
+ }
909
+
910
+ /**
911
+ * A single measure over time. One series, so no legend box — the card's title
912
+ * already names what is plotted.
913
+ */
914
+ function lineChart(buckets, valueFn, format, windowSeconds, stroke, options) {
915
+ options = options || {};
916
+ const slot = PLOT_W / buckets.length;
917
+ const values = buckets.map(valueFn);
918
+ const present = values.filter((v) => v != null);
919
+ if (!present.length) {
920
+ return `<div class="empty"><b>No data in this range</b>
921
+ Send traffic through the gateway and it appears here.</div>`;
922
+ }
923
+ const ceiling = options.ceiling || niceCeiling(Math.max(...present, 0));
924
+ const at = (index) => PAD.l + (index + 0.5) * slot;
925
+ const yOf = (v) => PAD.t + PLOT_H - (v / ceiling) * PLOT_H;
926
+
927
+ // Break the path across empty buckets: a gap in traffic is a gap in the line,
928
+ // not a straight segment implying values nobody measured.
929
+ const runs = [];
930
+ let run = [];
931
+ values.forEach((value, index) => {
932
+ if (value == null) { if (run.length) runs.push(run); run = []; return; }
933
+ run.push([at(index), yOf(value)]);
934
+ });
935
+ if (run.length) runs.push(run);
936
+
937
+ const paths = runs.map((points) => points.length === 1
938
+ ? `<circle class="dot" cx="${points[0][0].toFixed(1)}" cy="${points[0][1].toFixed(1)}" r="4" fill="${stroke}"/>`
939
+ : `<path class="ln" stroke="${stroke}" d="M${points.map((p) =>
940
+ `${p[0].toFixed(1)},${p[1].toFixed(1)}`).join("L")}"/>`).join("");
941
+
942
+ const area = runs.map((points) => points.length < 2 ? "" :
943
+ `<path fill="${stroke}" opacity="0.10" d="M${points[0][0].toFixed(1)},${(PAD.t + PLOT_H).toFixed(1)}L` +
944
+ points.map((p) => `${p[0].toFixed(1)},${p[1].toFixed(1)}`).join("L") +
945
+ `L${points[points.length - 1][0].toFixed(1)},${(PAD.t + PLOT_H).toFixed(1)}Z"/>`).join("");
946
+
947
+ // Label the endpoint only. A number on every point goes unread.
948
+ let endLabel = "";
949
+ for (let i = values.length - 1; i >= 0; i--) {
950
+ if (values[i] != null) {
951
+ const x = at(i), y = yOf(values[i]);
952
+ // Anchored into the right padding, never over the plot: a label that
953
+ // collides with the axis edge is worse than no label.
954
+ endLabel = `<circle class="dot" cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="4" fill="${stroke}"/>
955
+ <text x="${(x + 9).toFixed(1)}" y="${Math.max(PAD.t + 4, Math.min(y + 3, PAD.t + PLOT_H)).toFixed(1)}"
956
+ text-anchor="start">${esc(format(values[i]))}</text>`;
957
+ break;
958
+ }
959
+ }
960
+
961
+ const hits = buckets.map((bucket, index) =>
962
+ `<rect class="hit" x="${(PAD.l + index * slot).toFixed(1)}" y="${PAD.t}"
963
+ width="${slot.toFixed(1)}" height="${PLOT_H}" data-bucket="${index}"/>`).join("");
964
+
965
+ return `<svg class="chart" viewBox="0 0 ${CHART_W} ${CHART_H}" role="img">
966
+ ${gridSvg(ceiling, options.tickFormat || format)}
967
+ <line class="axis" x1="${PAD.l}" y1="${PAD.t + PLOT_H}" x2="${PAD.l + PLOT_W}" y2="${PAD.t + PLOT_H}"/>
968
+ ${area}${paths}${endLabel}${hits}</svg>`;
969
+ }
970
+
971
+ /** Totals per group as horizontal bars — the same colour each entity has above. */
972
+ function groupBars(totals, groups, metric, format) {
973
+ const ceiling = Math.max(...groups.map((g) => valueIn(totals[g], metric)), 1);
974
+ return `<div class="stack">${groups.map((name) => {
975
+ const value = valueIn(totals[name], metric);
976
+ return `<div style="display:flex;align-items:center;gap:10px">
977
+ <span style="width:34%;min-width:0;overflow:hidden;text-overflow:ellipsis;
978
+ white-space:nowrap;font-size:12px;color:var(--text-2)">${esc(displayName(name))}</span>
979
+ <span style="flex:1;height:10px;background:var(--surface-3);border-radius:99px">
980
+ <i style="display:block;height:100%;border-radius:99px;
981
+ width:${Math.max(2, (value / ceiling) * 100).toFixed(1)}%;
982
+ background:${colorOf(name)}"></i></span>
983
+ <span class="mono" style="width:76px;text-align:right;font-size:11.5px;
984
+ color:var(--text)">${esc(format(value))}</span></div>`;
985
+ }).join("")}</div>`;
986
+ }
987
+
988
+ // ---- usage screen ------------------------------------------------------------
989
+ const displayName = (name) =>
990
+ (state.usage && state.usage.names && state.usage.names[name]) || name;
991
+
992
+ /** Wire hover + keyboard focus on a plot. Tooltips enhance; the table carries all. */
993
+ function attachTips(root, buckets, describe) {
994
+ // A chart that was replaced by an explanation has no plot to hang tips on.
995
+ if (!root) return;
996
+ const tip = document.createElement("div");
997
+ tip.className = "tip hidden";
998
+ root.appendChild(tip);
999
+ const svg = root.querySelector("svg");
1000
+ if (!svg) return;
1001
+
1002
+ root.querySelectorAll("[data-bucket]").forEach((hit) => {
1003
+ const index = Number(hit.dataset.bucket);
1004
+ const enter = () => {
1005
+ const html = describe(buckets[index], index);
1006
+ if (!html) { tip.classList.add("hidden"); return; }
1007
+ tip.innerHTML = html;
1008
+ tip.classList.remove("hidden");
1009
+ const box = svg.getBoundingClientRect(), area = root.getBoundingClientRect();
1010
+ const scale = box.width / CHART_W;
1011
+ const x = (PAD.l + (index + 0.5) * (PLOT_W / buckets.length)) * scale;
1012
+ tip.style.left = Math.max(0, Math.min(x + 12, area.width - tip.offsetWidth - 4)) + "px";
1013
+ tip.style.top = "8px";
1014
+ };
1015
+ hit.addEventListener("mouseenter", enter);
1016
+ hit.addEventListener("focus", enter);
1017
+ hit.setAttribute("tabindex", "0");
1018
+ });
1019
+ root.addEventListener("mouseleave", () => tip.classList.add("hidden"));
1020
+ }
1021
+
1022
+ function bucketTip(bucket, groups, metric, format) {
1023
+ const rows = groups
1024
+ .map((name) => [name, valueIn(bucket.by[name], metric)])
1025
+ .filter(([, value]) => value)
1026
+ .map(([name, value]) => `<div class="tr"><span class="nm">
1027
+ <i style="background:${colorOf(name)}"></i>${esc(displayName(name))}</span>
1028
+ <span class="v">${esc(format(value))}</span></div>`).join("");
1029
+ return `<b>${esc(fullTime(bucket.at))}</b>` +
1030
+ (rows || '<div class="tr"><span class="nm">no traffic</span></div>');
1031
+ }
1032
+
1033
+ async function loadUsage() {
1034
+ const body = el("u-body");
1035
+ if (body.firstChild) body.classList.add("stale");
1036
+ try {
1037
+ const query = `?window=${encodeURIComponent(state.window)}` +
1038
+ `&group_by=${encodeURIComponent(state.groupBy)}`;
1039
+ state.usage = await api("/admin/usage" + query);
1040
+ renderUsage();
1041
+ } catch (error) {
1042
+ if (error.message !== "unauthorized") {
1043
+ body.innerHTML = `<div class="note err">${esc(error.message)}</div>`;
1044
+ }
1045
+ } finally {
1046
+ body.classList.remove("stale");
1047
+ }
1048
+ }
1049
+
1050
+ function renderUsage() {
1051
+ const usage = state.usage;
1052
+ if (!usage) return;
1053
+ const buckets = usage.buckets;
1054
+ // The last bucket is always the one still filling — the aggregator anchors the
1055
+ // grid so it ends in the future. Plotting it draws a cliff every time, which is
1056
+ // an artefact of the clock rather than anything the pool did, so the charts show
1057
+ // complete buckets only. The tiles and the totals table still cover the whole
1058
+ // window, so nothing is hidden; the note under the filters says which is which.
1059
+ //
1060
+ // Unless there is no history to compare it against: on a gateway whose first
1061
+ // requests are minutes old, *everything* is in the bucket still filling, and
1062
+ // hiding it would show an empty chart to exactly the person checking whether
1063
+ // any of this works. A cliff needs something to fall from.
1064
+ const complete = buckets.slice(0, -1);
1065
+ const partialOnly = !complete.some((b) => b.all.requests);
1066
+ const plotted = buckets.length > 1 && !partialOnly ? complete : buckets;
1067
+ const groups = cappedGroups(usage.groups);
1068
+ const overall = usage.overall;
1069
+ const windowSeconds = usage.window_seconds;
1070
+ // An unpriced pool draws an empty cost chart, which reads as "nothing was spent"
1071
+ // rather than "nothing was priced". Say which, and how to fix it.
1072
+ const priced = ((state.snapshot || {}).pricing || {}).models > 0;
1073
+
1074
+ // Fold anything past the eighth series into a single "Other" bucket so the
1075
+ // charts and the table agree about what is on screen.
1076
+ const kept = groups.filter((g) => g !== "Other");
1077
+ if (groups.includes("Other")) {
1078
+ const spare = usage.groups.filter((g) => !kept.includes(g));
1079
+ buckets.forEach((bucket) => {
1080
+ bucket.by["Other"] = spare.reduce((acc, name) => {
1081
+ const cell = bucket.by[name];
1082
+ if (cell) {
1083
+ Object.keys(cell).forEach((k) => {
1084
+ if (typeof cell[k] === "number") acc[k] = (acc[k] || 0) + cell[k];
1085
+ });
1086
+ }
1087
+ return acc;
1088
+ }, {});
1089
+ });
1090
+ usage.totals["Other"] = spare.reduce((acc, name) => {
1091
+ Object.keys(usage.totals[name] || {}).forEach((k) => {
1092
+ if (typeof usage.totals[name][k] === "number") {
1093
+ acc[k] = (acc[k] || 0) + usage.totals[name][k];
1094
+ }
1095
+ });
1096
+ return acc;
1097
+ }, {});
1098
+ }
1099
+
1100
+ el("u-note").textContent =
1101
+ `${plotted.length} buckets of ${Math.round(usage.bucket_seconds / 60)} min` +
1102
+ (partialOnly
1103
+ ? " · the current bucket is still filling"
1104
+ : " · charts show complete buckets; totals cover all " + overall.requests +
1105
+ " requests") +
1106
+ " · history is kept for 90 days";
1107
+
1108
+ if (!overall.requests) {
1109
+ el("u-body").innerHTML = `<div class="card"><div class="empty">
1110
+ <b>Nothing in this range</b>
1111
+ Send a request through the gateway, or pick a longer range.<br>
1112
+ Usage is written to the store, so it survives a restart.</div></div>`;
1113
+ return;
1114
+ }
1115
+
1116
+ el("u-body").innerHTML = `
1117
+ <div class="tiles">
1118
+ <div class="tile"><span class="lb">Requests</span>
1119
+ <span class="vl">${num(overall.requests)}</span>
1120
+ <span class="sub">${overall.errors} errors</span></div>
1121
+ <div class="tile"><span class="lb">Billed input</span>
1122
+ <span class="vl">${tokens(overall.billed_input_tokens)}</span>
1123
+ <span class="sub">${tokens(overall.output_tokens)} output</span></div>
1124
+ <div class="tile cache"><span class="lb">Cache hit</span>
1125
+ <span class="vl">${pct(overall.cache_hit_rate)}</span>
1126
+ <span class="sub">${overall.cache_breaks} affinity breaks</span></div>
1127
+ <div class="tile"><span class="lb">Cost</span>
1128
+ <span class="vl">${usd(overall.cost_usd)}</span>
1129
+ <span class="sub">${overall.latency_avg == null ? "—" : overall.latency_avg.toFixed(2) + "s"} average</span></div>
1130
+ </div>
1131
+
1132
+ <div class="card"><header><h3>Billed input tokens</h3>
1133
+ <span class="note">stacked by ${esc(usage.group_by)}</span></header>
1134
+ <div class="pad plot" id="u-tokens"></div></div>
1135
+
1136
+ <div class="card"><header><h3>Cost</h3>
1137
+ <span class="note">per bucket, in USD</span></header>
1138
+ ${priced ? `<div class="pad plot" id="u-cost"></div>`
1139
+ : `<div class="empty"><b>No prices configured</b>
1140
+ Costs are reported, and spend caps enforced, only for models that have a
1141
+ price. Add <span class="mono">pricing: builtin</span> to
1142
+ <span class="mono">tokenbiryani.yaml</span> for the dated table that ships
1143
+ with this release, then press <b>Reload config file</b> on Settings.</div>`}</div>
1144
+
1145
+ <div class="card"><header><h3>Cache hit rate</h3>
1146
+ <span class="note">the number that decides the bill</span></header>
1147
+ <div class="pad plot" id="u-cache"></div></div>
1148
+
1149
+ <div class="grid g2">
1150
+ <div class="card"><header><h3>Requests</h3>
1151
+ <span class="note">by ${esc(usage.group_by)}</span></header>
1152
+ <div class="pad" id="u-req"></div></div>
1153
+ <div class="card"><header><h3>Cost</h3>
1154
+ <span class="note">by ${esc(usage.group_by)}</span></header>
1155
+ <div class="pad" id="u-spend"></div></div>
1156
+ </div>
1157
+
1158
+ <div class="card"><header><h3>Totals</h3>
1159
+ <span class="note">every value in the charts above, as numbers</span></header>
1160
+ <div class="scroll pad" id="u-table"></div></div>`;
1161
+
1162
+ el("u-tokens").innerHTML =
1163
+ stackedColumns(plotted, groups, "billed_input_tokens", tokens, windowSeconds);
1164
+ attachTips(el("u-tokens"), plotted,
1165
+ (bucket) => bucketTip(bucket, groups, "billed_input_tokens", tokens));
1166
+
1167
+ if (priced) {
1168
+ el("u-cost").innerHTML = lineChart(
1169
+ plotted, (b) => (b.all.requests ? b.all.cost_usd : null), usd,
1170
+ windowSeconds, "var(--series-1)", { tickFormat: usdAxis });
1171
+ }
1172
+ attachTips(el("u-cost"), plotted, (bucket) =>
1173
+ `<b>${esc(fullTime(bucket.at))}</b><div class="tr"><span class="nm">cost</span>
1174
+ <span class="v">${esc(usd(bucket.all.cost_usd))}</span></div>
1175
+ <div class="tr"><span class="nm">requests</span>
1176
+ <span class="v">${bucket.all.requests}</span></div>`);
1177
+
1178
+ // Cache keeps the violet the design system reserves for it: it is a metric,
1179
+ // not a health state, and not one of the categorical series either.
1180
+ el("u-cache").innerHTML = lineChart(
1181
+ plotted, (b) => b.all.cache_hit_rate, (v) => pct(v),
1182
+ windowSeconds, "var(--cache)", { ceiling: 1 });
1183
+ attachTips(el("u-cache"), plotted, (bucket) =>
1184
+ `<b>${esc(fullTime(bucket.at))}</b><div class="tr"><span class="nm">cache hit</span>
1185
+ <span class="v">${esc(pct(bucket.all.cache_hit_rate))}</span></div>
1186
+ <div class="tr"><span class="nm">breaks</span>
1187
+ <span class="v">${bucket.all.cache_breaks}</span></div>`);
1188
+
1189
+ el("u-req").innerHTML = groupBars(usage.totals, groups, "requests", num);
1190
+ el("u-spend").innerHTML = groupBars(usage.totals, groups, "cost_usd", usd);
1191
+
1192
+ // The table view. Not a nicety: three of the light-mode series colours sit
1193
+ // under 3:1 against white, and the rule for that is relief — every value the
1194
+ // charts encode in colour has to be readable as a number somewhere.
1195
+ el("u-table").innerHTML = `<table><thead><tr>
1196
+ <th>${esc(usage.group_by)}</th><th class="r">Requests</th><th class="r">Errors</th>
1197
+ <th class="r">Billed input</th><th class="r">Output</th><th class="r">Cache read</th>
1198
+ <th class="r">Cache hit</th><th class="r">Breaks</th><th class="r">Avg latency</th>
1199
+ <th class="r">Cost</th></tr></thead><tbody>
1200
+ ${groups.map((name) => {
1201
+ const total = usage.totals[name] || {};
1202
+ return `<tr><td><span class="aid"><span class="nm">
1203
+ <i style="width:9px;height:9px;border-radius:3px;display:inline-block;
1204
+ background:${colorOf(name)}"></i> ${esc(displayName(name))}</span></span></td>
1205
+ <td class="r mono">${num(total.requests)}</td>
1206
+ <td class="r mono">${num(total.errors)}</td>
1207
+ <td class="r mono">${num(total.billed_input_tokens)}</td>
1208
+ <td class="r mono">${num(total.output_tokens)}</td>
1209
+ <td class="r mono ca">${num(total.cache_read_tokens)}</td>
1210
+ <td class="r mono ca">${pct(total.cache_hit_rate)}</td>
1211
+ <td class="r mono">${num(total.cache_breaks)}</td>
1212
+ <td class="r mono">${total.latency_avg == null ? "—" : total.latency_avg.toFixed(2) + "s"}</td>
1213
+ <td class="r mono">${usd(total.cost_usd)}</td></tr>`;
1214
+ }).join("")}
1215
+ <tr><td><b>All</b></td>
1216
+ <td class="r mono">${num(overall.requests)}</td>
1217
+ <td class="r mono">${num(overall.errors)}</td>
1218
+ <td class="r mono">${num(overall.billed_input_tokens)}</td>
1219
+ <td class="r mono">${num(overall.output_tokens)}</td>
1220
+ <td class="r mono ca">${num(overall.cache_read_tokens)}</td>
1221
+ <td class="r mono ca">${pct(overall.cache_hit_rate)}</td>
1222
+ <td class="r mono">${num(overall.cache_breaks)}</td>
1223
+ <td class="r mono">${overall.latency_avg == null ? "—" : overall.latency_avg.toFixed(2) + "s"}</td>
1224
+ <td class="r mono">${usd(overall.cost_usd)}</td></tr>
1225
+ </tbody></table>`;
1226
+ }
1227
+
1228
+ // ---- requests ----------------------------------------------------------------
1229
+ // ---- efficiency ---------------------------------------------------------------
1230
+ // Four questions the pool meters cannot answer, on one screen: will the quota last
1231
+ // the week, is the prompt cache even switched on, is one conversation running away,
1232
+ // and is the pool reserving output it never uses.
1233
+
1234
+ /** Seconds as something a person reads at a glance. */
1235
+ const spanOf = (seconds) => {
1236
+ if (seconds == null) return "—";
1237
+ if (seconds < 3600) return Math.round(seconds / 60) + "m";
1238
+ if (seconds < 86400) return (seconds / 3600).toFixed(1) + "h";
1239
+ return (seconds / 86400).toFixed(1) + "d";
1240
+ };
1241
+
1242
+ /** Target against actual on one track. The gap between them *is* the pace, so
1243
+ * drawing them apart would make the reader do the subtraction the bar exists to
1244
+ * do for them. */
1245
+ function paceBar(reading) {
1246
+ const used = Math.max(0, Math.min(1, reading.utilization));
1247
+ const target = Math.max(0, Math.min(1, reading.target));
1248
+ const kind = reading.pace > 0.1 ? "crit" : reading.pace < -0.1 ? "cool" : "ready";
1249
+ return `<span class="bar" title="target ${pct(target)}, actual ${pct(used)}">
1250
+ <span class="k">${esc(reading.scope)}</span>
1251
+ <span class="track">
1252
+ <i style="width:${Math.max(2, Math.round(used * 100))}%;background:var(--${kind})"></i>
1253
+ <b style="left:${Math.round(target * 100)}%;width:2px;background:var(--text-2)"></b>
1254
+ </span>
1255
+ <span class="v">${pct(used)}</span></span>`;
1256
+ }
1257
+
1258
+ function pacingCard(pacing) {
1259
+ if (!pacing.enabled) {
1260
+ return `<div class="card"><header><h3>Quota pace</h3></header>
1261
+ <div class="empty"><b>Pacing is off</b>
1262
+ Set <span class="mono">pacing.enabled: true</span> to see whether this pool is
1263
+ on course to spend its window.</div></div>`;
1264
+ }
1265
+ if (!pacing.readings.length) {
1266
+ return `<div class="card"><header><h3>Quota pace</h3>
1267
+ <span class="note">${esc(pacing.mode)}</span></header>
1268
+ <div class="empty"><b>Nothing to pace against</b>
1269
+ A subscription reports its own 5h and 7d windows and is paced automatically.
1270
+ An API key reports no weekly window at all, so it needs
1271
+ <span class="mono">pacing.weekly_budget_usd</span> — without one it is left out
1272
+ rather than paced against an invented figure.</div></div>`;
1273
+ }
1274
+ const rows = pacing.readings.map((reading) => `<tr>
1275
+ <td class="mono">${esc(reading.scope)}</td>
1276
+ <td>${esc(reading.source)}</td>
1277
+ <td class="r">${pct(reading.elapsed_fraction)}</td>
1278
+ <td class="r">${pct(reading.utilization)}</td>
1279
+ <td class="r">${pct(reading.projected_utilization)}</td>
1280
+ <td class="r">${reading.exhausted_in_seconds == null
1281
+ ? "—" : spanOf(reading.exhausted_in_seconds)}</td>
1282
+ <td>${esc(reading.verdict)}</td></tr>`).join("");
1283
+
1284
+ return `<div class="card"><header><h3>Quota pace</h3>
1285
+ <span class="note">${esc(pacing.mode)} · ${esc(pacing.curve)} curve${
1286
+ pacing.mode === "enforcing"
1287
+ ? " · batch work is being held back; interactive never is" : ""}</span></header>
1288
+ <div class="pad"><span class="meter pace">${
1289
+ pacing.readings.map(paceBar).join("")}</span>
1290
+ <div class="note">The mark on each track is where spending should be by now.
1291
+ Bar past the mark means it runs dry early; short of it means quota goes
1292
+ unused.</div></div>
1293
+ <div class="scroll pad"><table><thead><tr>
1294
+ <th>Scope</th><th>Source</th><th class="r">Window gone</th>
1295
+ <th class="r">Quota used</th><th class="r">Projected</th><th class="r">Dry in</th>
1296
+ <th>Verdict</th></tr></thead><tbody>${rows}</tbody></table></div></div>`;
1297
+ }
1298
+
1299
+ function cacheAdviceCard(advice) {
1300
+ if (!advice.findings.length) {
1301
+ return `<div class="card"><header><h3>Prompt cache</h3></header>
1302
+ <div class="empty"><b>Not enough traffic yet</b>
1303
+ Needs ${num(advice.min_requests)} requests from one key and model before it will
1304
+ say anything.</div></div>`;
1305
+ }
1306
+ const rows = advice.findings.map((f) => {
1307
+ const bad = f.breakpoint_rate < 0.5 || f.cache_hit_rate < 0.2;
1308
+ return `<tr>
1309
+ <td class="mono">${esc(f.key)}</td>
1310
+ <td class="mono">${esc(f.model)}</td>
1311
+ <td class="r">${num(f.requests)}</td>
1312
+ <td class="r">${pct(f.breakpoint_rate)}</td>
1313
+ <td class="r">${tokens(f.mean_prefix_tokens)}</td>
1314
+ <td class="r">${pct(f.cache_hit_rate)}</td>
1315
+ <td class="${bad ? "warn" : ""}">${esc(f.verdict)}</td></tr>`;
1316
+ }).join("");
1317
+
1318
+ return `<div class="card"><header><h3>Prompt cache</h3>
1319
+ <span class="note">a hit rate of zero has two causes and only one is routing</span>
1320
+ </header>
1321
+ <div class="scroll pad"><table><thead><tr>
1322
+ <th>Key</th><th>Model</th><th class="r">Requests</th><th class="r">Marked</th>
1323
+ <th class="r">Prefix</th><th class="r">Hit rate</th><th>Verdict</th>
1324
+ </tr></thead><tbody>${rows}</tbody></table></div></div>`;
1325
+ }
1326
+
1327
+ function sessionsCard(report) {
1328
+ if (!report.tracking) {
1329
+ return `<div class="card"><header><h3>Conversations</h3></header>
1330
+ <div class="empty"><b>Session tracking is off</b>
1331
+ <span class="mono">sessions.track: false</span>, so per-conversation cost and
1332
+ turn counts are not recorded — and the per-key session caps that read them are
1333
+ not enforced either.</div></div>`;
1334
+ }
1335
+ if (!report.sessions.length) {
1336
+ return `<div class="card"><header><h3>Conversations</h3></header>
1337
+ <div class="empty"><b>No conversations in this window</b>
1338
+ They appear here once requests have been served.</div></div>`;
1339
+ }
1340
+ const rows = report.sessions.map((row) => `<tr>
1341
+ <td class="mono">${esc(row.session_key)}</td>
1342
+ <td class="r">${num(row.turns)}</td>
1343
+ <td class="r">${usd(row.spend_usd)}</td>
1344
+ <td class="${row.runaway ? "warn" : ""}">${
1345
+ row.runaway ? "runaway — " + esc(row.why) : ""}</td></tr>`).join("");
1346
+
1347
+ return `<div class="card"><header><h3>Conversations</h3>
1348
+ <span class="note">most expensive first · flagged past
1349
+ ${num(report.runaway_turns)} turns${report.runaway_spend_usd
1350
+ ? " or " + usd(report.runaway_spend_usd) : ""}</span></header>
1351
+ <div class="scroll pad"><table><thead><tr>
1352
+ <th>Session</th><th class="r">Turns</th><th class="r">Spend</th><th></th>
1353
+ </tr></thead><tbody>${rows}</tbody></table></div></div>`;
1354
+ }
1355
+
1356
+ function estimationCard(estimation) {
1357
+ const models = Object.entries(estimation.models || {});
1358
+ if (estimation.mode !== "adaptive") {
1359
+ return `<div class="card"><header><h3>Output leases</h3></header>
1360
+ <div class="empty"><b>Leasing the caller's ceiling</b>
1361
+ <span class="mono">routing.output_estimate: max_tokens</span>. An agent client
1362
+ asking for 32k and returning a few hundred tokens holds 32k of the account's
1363
+ output window for the life of every request.</div></div>`;
1364
+ }
1365
+ if (!models.length) {
1366
+ return `<div class="card"><header><h3>Output leases</h3></header>
1367
+ <div class="empty"><b>No completions observed yet</b>
1368
+ Until ${num(estimation.min_samples)} land for a model, its lease is the
1369
+ caller's <span class="mono">max_tokens</span>.</div></div>`;
1370
+ }
1371
+ const rows = models.map(([model, m]) => `<tr>
1372
+ <td class="mono">${esc(model)}</td>
1373
+ <td class="r">${num(m.samples)}</td>
1374
+ <td class="r">${num(m.median)}</td>
1375
+ <td class="r">${num(m.p95)}</td>
1376
+ <td class="r">${num(m.max)}</td>
1377
+ <td class="r">${pct(m.undershoot_rate)}</td>
1378
+ <td>${m.predicting
1379
+ ? "predicting"
1380
+ : `<span class="warn">leasing the ceiling</span>`}</td></tr>`).join("");
1381
+
1382
+ return `<div class="card"><header><h3>Output leases</h3>
1383
+ <span class="note">what each model really returns, against what callers ask
1384
+ for</span></header>
1385
+ <div class="scroll pad"><table><thead><tr>
1386
+ <th>Model</th><th class="r">Samples</th><th class="r">Median</th>
1387
+ <th class="r">p95</th><th class="r">Max</th><th class="r">Outrun</th><th></th>
1388
+ </tr></thead><tbody>${rows}</tbody></table></div>
1389
+ <div class="pad note">A model reading <b>leasing the ceiling</b> is either still
1390
+ gathering samples or has been outrun too often, and is reserving the caller's
1391
+ full <span class="mono">max_tokens</span> until that ages out.</div></div>`;
1392
+ }
1393
+
1394
+ async function loadEfficiency() {
1395
+ const body = el("eff-body");
1396
+ body.innerHTML = `<div class="card"><div class="empty">Loading…</div></div>`;
1397
+ try {
1398
+ const [pacing, advice, sessions, estimation] = await Promise.all([
1399
+ api("/admin/pacing"), api("/admin/cache-advice"),
1400
+ api("/admin/sessions"), api("/admin/estimation"),
1401
+ ]);
1402
+ body.innerHTML = pacingCard(pacing) + cacheAdviceCard(advice)
1403
+ + sessionsCard(sessions) + estimationCard(estimation);
1404
+ } catch (error) {
1405
+ if (error.offline) { goOffline(); return; }
1406
+ if (error.message !== "unauthorized") banner(error.message);
1407
+ }
1408
+ }
1409
+
1410
+ function requestRow(request) {
1411
+ const status = request.status == null ? "—" : request.status;
1412
+ const cls = request.error ? "er" : request.affinity_broken ? "ca" : "ok";
1413
+ const label = request.affinity_broken ? "break" : status;
1414
+ const via = request.via === "batch" ? " · batch" : "";
1415
+ return `<div class="r ${request.affinity_broken ? "brk" : ""}" tabindex="0"
1416
+ data-request="${esc(request.request_id)}" aria-label="request at ${hhmmss(request.started_at)}">
1417
+ <span>${hhmmss(request.started_at)}</span>
1418
+ <span>${esc(request.account_id || "—")}</span>
1419
+ <span class="m">${esc(request.model)}${via}</span>
1420
+ <span>${tokens(request.input_tokens + request.cache_read_tokens)}</span>
1421
+ <span>${request.latency ? request.latency.toFixed(2) + "s" : "—"}</span>
1422
+ <span class="${cls}">${esc(label)}</span></div>`;
1423
+ }
1424
+
1425
+ const STREAM_HEAD = `<div class="r head">
1426
+ <span>Time</span><span>Account</span><span>Model</span><span>Tokens</span>
1427
+ <span>Latency</span><span>Status</span></div>`;
1428
+
1429
+ function renderRequests(target, list) {
1430
+ const node = el(target);
1431
+ if (!node) return;
1432
+ if (!list.length) {
1433
+ node.innerHTML = `<div class="empty"><b>No requests yet</b>
1434
+ Send one through the gateway and it appears here within a second.</div>`;
1435
+ return;
1436
+ }
1437
+ node.innerHTML = STREAM_HEAD + list.map(requestRow).join("");
1438
+ activate(node.querySelectorAll("[data-request]"), (n) => openRequest(n.dataset.request));
1439
+ }
1440
+
1441
+ function paintRequests() {
1442
+ renderRequests("stream", state.requests.slice(0, 25));
1443
+ renderRequests("requests", state.requests);
1444
+ }
1445
+
1446
+ function renderInspector(request) {
1447
+ const chain = request.attempts.map((attempt, index) => {
1448
+ const won = attempt.kind === "ok" || attempt.kind === "batch";
1449
+ const detail = attempt.status == null
1450
+ ? attempt.kind : `${attempt.status} ${attempt.kind}`;
1451
+ return (index ? `<span class="arrow">── retry ──▶</span>` : "") +
1452
+ `<span class="node ${won ? "win" : "fail"}"><span class="n">${esc(attempt.account_id)}</span>
1453
+ <span class="s">${esc(detail)} · ${attempt.latency.toFixed(3)}s</span></span>`;
1454
+ }).join("") || '<span class="dm">No attempt was made.</span>';
1455
+
1456
+ const decision = request.decision;
1457
+ let table = `<div class="empty">No routing decision was recorded — the request failed
1458
+ before a candidate was chosen.</div>`;
1459
+ if (decision && decision.candidates && decision.candidates.length) {
1460
+ const names = [];
1461
+ decision.candidates.forEach((c) => Object.keys(c.terms || {}).forEach(
1462
+ (n) => names.includes(n) || names.push(n)));
1463
+ table = `<div class="scroll"><table><thead><tr><th>Candidate</th>` +
1464
+ names.map((n) => `<th class="r">${esc(n)}</th>`).join("") +
1465
+ `<th class="r">Score</th><th>Verdict</th></tr></thead><tbody>` +
1466
+ decision.candidates.map((c) => `<tr class="${c.verdict === "chosen" ? "won" : ""}">
1467
+ <td class="mono">${esc(c.account_id)}</td>` +
1468
+ names.map((n) => `<td class="r mono">${c.terms && c.terms[n] != null ? c.terms[n].toFixed(3) : "—"}</td>`).join("") +
1469
+ `<td class="r mono">${c.score == null ? "—" : c.score.toFixed(3)}</td>
1470
+ <td>${esc(c.verdict)}</td></tr>`).join("") + `</tbody></table></div>`;
1471
+ }
1472
+
1473
+ el("inspector").innerHTML = `<div class="card">
1474
+ <header><h3 class="mono">${esc(request.request_id)}</h3>
1475
+ <span class="note">${esc(request.model)} · ${esc(request.priority)}${request.via === "batch" ? " · via batch" : ""} · ${request.attempts.length} attempt(s)</span></header>
1476
+ <div class="pad stack">
1477
+ <div class="chain">${chain}</div>
1478
+ ${request.queued_for ? `<p style="color:var(--text-3);font-size:11px">queued ${request.queued_for.toFixed(2)}s waiting for capacity</p>` : ""}
1479
+ ${request.affinity_broken ? `<div class="note warn">Affinity broke: this
1480
+ conversation moved off the account holding its prompt cache, so its prefix was billed fresh.</div>` : ""}
1481
+ ${request.model_requested ? `<div class="note warn">Downshifted: the caller asked
1482
+ for <span class="mono">${esc(request.model_requested)}</span> and this ran on
1483
+ <span class="mono">${esc(request.model)}</span>, because the pool was ahead of
1484
+ its quota pace. Batch priority only — see pacing.model_downshift.</div>` : ""}
1485
+ ${request.paced_for ? `<p style="color:var(--text-3);font-size:11px">held
1486
+ ${request.paced_for.toFixed(2)}s by pacing, to spend the quota window more
1487
+ slowly</p>` : ""}
1488
+ ${request.cache_breakpoint === false && request.prefix_tokens >= 1024
1489
+ ? `<div class="note warn">No <span class="mono">cache_control</span> breakpoint,
1490
+ on a stable prefix of about ${tokens(request.prefix_tokens)} tokens: the cache
1491
+ never engages for this request, and no routing change alters that.</div>` : ""}
1492
+ <h4>Routing decision${decision ? " · strategy " + esc(decision.strategy) : ""}</h4>
1493
+ ${table}
1494
+ <div class="kv">
1495
+ <div><span class="lb">Input</span><span class="vl">${num(request.input_tokens)}</span></div>
1496
+ <div><span class="lb">Cache read</span><span class="vl ca">${num(request.cache_read_tokens)}</span></div>
1497
+ <div><span class="lb">Cache write</span><span class="vl">${num(request.cache_creation_tokens)}</span></div>
1498
+ <div><span class="lb">Output</span><span class="vl">${num(request.output_tokens)}</span></div>
1499
+ <div><span class="lb">Cost</span><span class="vl">${usd(request.cost_usd)}</span></div>
1500
+ <div><span class="lb">Saved by cache</span><span class="vl ca">${usd(request.saved_usd)}</span></div>
1501
+ </div>
1502
+ </div></div>`;
1503
+ el("inspector").scrollIntoView({ behavior: "smooth", block: "nearest" });
1504
+ }
1505
+
1506
+ // ---- accounts: management ----------------------------------------------------
1507
+ function renderAccountsAdmin(snapshot) {
1508
+ const accounts = snapshot.accounts;
1509
+ if (!accounts.length) {
1510
+ el("accounts-table").innerHTML = `<div class="empty"><b>No accounts</b>
1511
+ Add one and the gateway has something to route to.</div>`;
1512
+ return;
1513
+ }
1514
+ el("accounts-table").innerHTML = `<div class="pad"><table>
1515
+ <thead><tr><th>Account</th><th>State</th><th>Source</th><th class="r">Tier</th>
1516
+ <th class="r">Priority</th><th class="r">Spend</th><th class="r">Enabled</th>
1517
+ <th class="r">Actions</th></tr></thead>
1518
+ <tbody>${accounts.map((a) => {
1519
+ const managed = a.source === "managed";
1520
+ return `<tr data-row="${esc(a.id)}">
1521
+ <td class="click" data-open="${esc(a.id)}">${identity(a)}</td>
1522
+ <td>${statePill(a)}</td>
1523
+ <td>${managed ? '<span class="pill accent"><i></i>managed</span>'
1524
+ : `<span class="pill muted" title="declared in tokenbiryani.yaml"><i></i>in config</span>${
1525
+ (a.overridden || []).length
1526
+ ? ` <span class="pill cooling" title="changed here, not in the file: ${
1527
+ esc((a.overridden || []).join(", "))}"><i></i>edited</span>` : ""}`}</td>
1528
+ <td class="r mono">${a.cost_tier}</td>
1529
+ <td class="r mono">${a.priority}</td>
1530
+ <td class="r mono">${usd(a.spend_usd)}</td>
1531
+ <td class="r">${enabledToggle(a, managed)}</td>
1532
+ <td class="r"><span class="acts">
1533
+ ${a.state === "disabled" && managed
1534
+ ? `<button class="btn sm primary" data-fix="${esc(a.id)}">Fix</button>` : ""}
1535
+ <button class="btn sm" data-test="${esc(a.id)}">Test</button>
1536
+ <button class="btn sm" data-edit="${esc(a.id)}">Edit</button>
1537
+ ${managed ? `<button class="btn sm danger" data-del="${esc(a.id)}">Delete</button>`
1538
+ : (a.overridden || []).length
1539
+ ? `<button class="btn sm" data-revert="${esc(a.id)}">Revert</button>` : ""}
1540
+ </span></td></tr>`;
1541
+ }).join("")}</tbody></table></div>`;
1542
+
1543
+ const wrap = el("accounts-table");
1544
+ wrap.querySelectorAll("[data-open]").forEach((n) =>
1545
+ n.addEventListener("click", () => openAccount(n.dataset.open)));
1546
+ wrap.querySelectorAll("[data-test]").forEach((n) =>
1547
+ n.addEventListener("click", () => testAccount(n.dataset.test, n)));
1548
+ wrap.querySelectorAll("[data-fix]").forEach((n) =>
1549
+ n.addEventListener("click", () => fixAccount(n.dataset.fix)));
1550
+ wrap.querySelectorAll("[data-edit]").forEach((n) =>
1551
+ n.addEventListener("click", () => accountModal(findAccount(n.dataset.edit))));
1552
+ wrap.querySelectorAll("[data-del]").forEach((n) =>
1553
+ n.addEventListener("click", () => deleteAccount(n.dataset.del)));
1554
+ wrap.querySelectorAll("[data-toggle]").forEach((n) =>
1555
+ n.addEventListener("click", () => toggleAccount(n.dataset.toggle, !n.classList.contains("on"))));
1556
+ wrap.querySelectorAll("[data-revert]").forEach((n) =>
1557
+ n.addEventListener("click", () => revertOverride(n.dataset.revert)));
1558
+ }
1559
+
1560
+ /** Test every account in one pass.
1561
+ *
1562
+ * The natural action after restoring a backup or rotating keys, and it used to be
1563
+ * N clicks with no summary at the end. */
1564
+ async function testAllAccounts(button) {
1565
+ const accounts = ((state.snapshot || {}).accounts || []);
1566
+ if (!accounts.length) return;
1567
+ if (button) { button.disabled = true; button.textContent = "Testing…"; }
1568
+ const failures = [];
1569
+ try {
1570
+ for (const account of accounts) {
1571
+ try {
1572
+ const result = await api(
1573
+ "/admin/accounts/" + encodeURIComponent(account.id) + "/test",
1574
+ { method: "POST" });
1575
+ if (!result.ok) failures.push(account.id + ": " + result.detail);
1576
+ } catch (error) {
1577
+ failures.push(account.id + ": " + error.message);
1578
+ }
1579
+ }
1580
+ acctMsg(failures.length
1581
+ ? `<div class="note err"><span><b>${failures.length} of ${accounts.length}</b>
1582
+ could not be used:<br>${failures.map(esc).join("<br>")}</span></div>`
1583
+ : `<div class="note ok">All ${accounts.length} accounts answered.</div>`);
1584
+ refresh();
1585
+ } finally {
1586
+ if (button) { button.disabled = false; button.textContent = "Test all"; }
1587
+ }
1588
+ }
1589
+
1590
+ /** A disabled account is the one state that needs a person. Open its editor on the
1591
+ * credential, with the reason it stopped stated above the field — rather than
1592
+ * making them find the row, read the pill, and guess which field to change. */
1593
+ async function fixAccount(id) {
1594
+ const account = findAccount(id);
1595
+ if (!account) return;
1596
+ // Awaited: the modal is built asynchronously now, so the fields do not exist
1597
+ // until it resolves.
1598
+ await accountModal(account);
1599
+ const field = document.querySelector('#m-fields [data-field="api_key"]');
1600
+ if (field) field.focus();
1601
+ el("m-msg").innerHTML = `<div class="note err"><span><b>${esc(id)}</b> is disabled:
1602
+ <b>${esc(account.disabled_reason || "unknown")}</b>. This does not self-heal.
1603
+ Paste a working credential and save — the account is re-enabled when it is
1604
+ accepted.</span></div>`;
1605
+ }
1606
+
1607
+ const findAccount = (id) =>
1608
+ (state.snapshot && state.snapshot.accounts.find((a) => a.id === id)) || null;
1609
+
1610
+ function acctMsg(html) { el("acct-msg").innerHTML = html; }
1611
+
1612
+ async function testAccount(id, button) {
1613
+ if (button) { button.disabled = true; button.textContent = "Testing…"; }
1614
+ try {
1615
+ const result = await api("/admin/accounts/" + encodeURIComponent(id) + "/test",
1616
+ { method: "POST" });
1617
+ acctMsg(result.ok
1618
+ ? `<div class="note ok"><b>${esc(id)}</b> answered in ${result.latency}s — ${esc(result.detail)}.</div>`
1619
+ : `<div class="note err"><b>${esc(id)}</b> could not be used: ${esc(result.detail)}${
1620
+ result.status ? " (HTTP " + result.status + ")" : ""}.</div>`);
1621
+ refresh();
1622
+ return result;
1623
+ } catch (error) {
1624
+ acctMsg(`<div class="note err">${esc(error.message)}</div>`);
1625
+ return { ok: false, detail: error.message };
1626
+ } finally {
1627
+ if (button) { button.disabled = false; button.textContent = "Test"; }
1628
+ }
1629
+ }
1630
+
1631
+ async function toggleAccount(id, enabled) {
1632
+ try {
1633
+ await api("/admin/accounts/" + encodeURIComponent(id),
1634
+ { method: "PATCH", body: JSON.stringify({ enabled: enabled }) });
1635
+ acctMsg(`<div class="note ok"><b>${esc(id)}</b> ${enabled ? "enabled" : "disabled"}.</div>`);
1636
+ refresh();
1637
+ } catch (error) { acctMsg(`<div class="note err">${esc(error.message)}</div>`); }
1638
+ }
1639
+
1640
+ /** Hand a config account back to the file. */
1641
+ async function revertOverride(id) {
1642
+ if (!window.confirm(`Revert ${id} to what tokenbiryani.yaml says?`)) return;
1643
+ try {
1644
+ await api("/admin/accounts/" + encodeURIComponent(id) + "/override", { method: "DELETE" });
1645
+ acctMsg(`<div class="note ok"><b>${esc(id)}</b> is back to what the config file says.</div>`);
1646
+ refresh();
1647
+ } catch (error) { acctMsg(`<div class="note err">${esc(error.message)}</div>`); }
1648
+ }
1649
+
1650
+ async function deleteAccount(id) {
1651
+ if (!window.confirm(`Delete ${id}? Its credential is removed from the gateway.`)) return;
1652
+ try {
1653
+ await api("/admin/accounts/" + encodeURIComponent(id), { method: "DELETE" });
1654
+ acctMsg(`<div class="note ok"><b>${esc(id)}</b> deleted.</div>`);
1655
+ el("account-detail").innerHTML = "";
1656
+ refresh();
1657
+ } catch (error) { acctMsg(`<div class="note err">${esc(error.message)}</div>`); }
1658
+ }
1659
+
1660
+ // ---- accounts: the add/edit modal --------------------------------------------
1661
+ function closeModal() { el("modal-root").innerHTML = ""; }
1662
+
1663
+ function typeFields(type, account) {
1664
+ const spec = ACCOUNT_TYPES[type] || ACCOUNT_TYPES.anthropic_api;
1665
+ return spec.fields.map((f) => {
1666
+ const key = f.name.startsWith("opt.") ? f.name.slice(4) : f.name;
1667
+ let value = "";
1668
+ if (account && !f.name.startsWith("opt.") && f.type !== "password") {
1669
+ value = account[key] == null ? "" : account[key];
1670
+ }
1671
+ return `<label class="f">
1672
+ <span class="t">${esc(f.label)}${f.required ? "" : "<em>optional</em>"}</span>
1673
+ <input data-field="${esc(f.name)}" type="${f.type || "text"}"
1674
+ placeholder="${esc(f.placeholder || "")}" value="${esc(value)}"
1675
+ autocomplete="off">
1676
+ ${f.hint ? `<span class="hint">${esc(f.hint)}</span>` : ""}</label>`;
1677
+ }).join("");
1678
+ }
1679
+
1680
+ /** Slugify a name into an account id, then make it unique against the pool.
1681
+ *
1682
+ * This exists so the ID field has a value the moment the name does. It used to be
1683
+ * required and empty, which made "An account needs an ID." the most likely first
1684
+ * thing a new user saw — an error for not having invented `acct-02`. `_SAFE_ID`
1685
+ * on the server is what this targets: letters, digits, dashes, underscores. */
1686
+ function idFromName(name) {
1687
+ const base = String(name || "").toLowerCase()
1688
+ .replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40);
1689
+ if (!base) return "";
1690
+ const taken = new Set(((state.snapshot || {}).accounts || []).map((a) => a.id));
1691
+ if (!taken.has(base)) return base;
1692
+ for (let n = 2; n < 100; n++) {
1693
+ if (!taken.has(base + "-" + n)) return base + "-" + n;
1694
+ }
1695
+ return base;
1696
+ }
1697
+
1698
+ /** Recognise a pasted credential and select the type it belongs to.
1699
+ *
1700
+ * One fewer dropdown to understand on a first run: the shape of what you pasted
1701
+ * already says which upstream it is. Returns "" when nothing is recognisable,
1702
+ * which leaves whatever the operator chose alone. */
1703
+ function typeOfCredential(text) {
1704
+ const value = String(text || "").trim();
1705
+ if (!value) return "";
1706
+ if (/^sk-ant-/.test(value)) return "anthropic_api";
1707
+ if (/^(AKIA|ASIA)[A-Z0-9]{12,}/.test(value)) return "bedrock";
1708
+ if (/^arn:aws:/.test(value)) return "bedrock";
1709
+ if (/"type"\s*:\s*"service_account"/.test(value)) return "vertex";
1710
+ return "";
1711
+ }
1712
+
1713
+ /**
1714
+ * One modal for create and edit. `account` present means edit, in which case the
1715
+ * id and the type are fixed — changing either is a different account.
1716
+ *
1717
+ * Laid out as essentials first, everything else folded away. Twelve fields of which
1718
+ * two matter on a first run is an admin form doing an onboarding job; the admin
1719
+ * fields are still one click away, and open by default when editing, because that
1720
+ * is when they are the point.
1721
+ */
1722
+ /**
1723
+ * The account form, with no shell around it.
1724
+ *
1725
+ * Mounted two ways: inside the dialog on the Accounts screen, and inside the
1726
+ * first-run wizard as the whole of step 2. One set of fields, one set of IDs,
1727
+ * one place to fix — a wizard that forked this form would drift from it, and
1728
+ * the field that went missing would be the credential.
1729
+ */
1730
+ function accountFormBody(account, editing, heldByFile) {
1731
+ const type = editing ? account.type : "anthropic_api";
1732
+ return `<div class="pad">
1733
+ <label class="f">Type
1734
+ <select id="m-type" ${editing ? "disabled" : ""}>
1735
+ ${Object.keys(ACCOUNT_TYPES).map((k) =>
1736
+ `<option value="${k}" ${k === type ? "selected" : ""}>${
1737
+ esc(ACCOUNT_TYPES[k].label)}</option>`).join("")}
1738
+ </select>
1739
+ <span class="hint" id="m-blurb"></span></label>
1740
+
1741
+ <label class="f">Name
1742
+ <input id="m-name" placeholder="Work account" value="${esc(account ? (account.name || "") : "")}">
1743
+ <span class="hint">What you call it. Shown everywhere.</span></label>
1744
+
1745
+ <div id="m-fields" class="grid g2"></div>
1746
+ <div id="m-login" class="hidden stack"></div>
1747
+
1748
+ <div id="m-credentials" class="stack">
1749
+ <details class="adv" ${editing ? "open" : ""}>
1750
+ <summary>Advanced</summary>
1751
+ <div class="stack">
1752
+ <label class="f">ID
1753
+ <input id="m-id" placeholder="acct-02" ${editing ? "disabled" : ""}
1754
+ value="${esc(account ? account.id : "")}">
1755
+ <span class="hint">Stable handle used in logs and metrics. Filled in
1756
+ from the name; change it before you save or not at all.</span></label>
1757
+ <div class="grid g3">
1758
+ <label class="f">Cost tier
1759
+ <input id="m-tier" type="number" step="0.1" value="${account ? account.cost_tier : 1}">
1760
+ <span class="hint">Lower drains first under cost_tiered.</span></label>
1761
+ <label class="f">Priority
1762
+ <input id="m-pri" type="number" step="1" value="${account ? account.priority : 0}"></label>
1763
+ <label class="f">Spend cap USD
1764
+ <input id="m-cap" type="number" step="0.01" placeholder="none"
1765
+ value="${account && account.spend_cap_usd != null ? account.spend_cap_usd : ""}"></label>
1766
+ </div>
1767
+ <label class="f">Models
1768
+ <input id="m-models" placeholder="*"
1769
+ value="${esc(account ? [].concat(account.models || ["*"]).join(", ") : "")}">
1770
+ <span class="hint">Comma separated. <code>*</code> or blank means every
1771
+ model.</span></label>
1772
+ </div>
1773
+ </details>
1774
+ </div>
1775
+
1776
+ <div id="m-msg"></div>
1777
+ </div>
1778
+ `;
1779
+ }
1780
+
1781
+ function accountFormFooter(editing) {
1782
+ return `<footer>
1783
+ <button class="btn ghost" id="m-cancel">Cancel</button>
1784
+ <button class="btn" id="m-save">${editing ? "Save without testing" : "Add without testing"}</button>
1785
+ <button class="btn primary" id="m-save-test">${editing ? "Test and save" : "Test and add"}</button>
1786
+ <button class="btn primary hidden" id="m-start-login">Log in with Claude</button>
1787
+ </footer>
1788
+ `;
1789
+ }
1790
+
1791
+ /** Everything the mounted form needs to behave. `onClose` is what Cancel does:
1792
+ * dismiss the dialog, or step back in the wizard. */
1793
+ function wireAccountForm(account, editing, heldByFile, onClose) {
1794
+ const paintType = () => {
1795
+ const chosen = el("m-type").value;
1796
+ const spec = ACCOUNT_TYPES[chosen];
1797
+ const subscription = !!spec.subscription;
1798
+ el("m-fields").innerHTML = subscription || heldByFile ? "" : typeFields(chosen, account);
1799
+ el("m-blurb").textContent = spec.blurb;
1800
+ // A subscription has no API key field: where its token comes from is a choice
1801
+ // between three sources, so that choice replaces the credential form.
1802
+ el("m-login").classList.toggle("hidden", !subscription);
1803
+ // The advanced block is cost tier, models and the like — as useful for a
1804
+ // subscription as for a key, so it stays. Only the credential fields go.
1805
+ if (heldByFile) {
1806
+ el("m-login").classList.remove("hidden");
1807
+ el("m-login").innerHTML = `<div class="note info"><span>
1808
+ <b>${esc(account.id)}</b> is declared in tokenbiryani.yaml. Its credential,
1809
+ type and base URL belong to the file. Changing the name, tier, priority, cap
1810
+ or models here is stored in the gateway and survives a reload — press
1811
+ <b>Revert</b> on the Accounts screen to hand it back to the file.</span></div>`;
1812
+ el("m-save").classList.remove("hidden");
1813
+ el("m-save-test").classList.remove("hidden");
1814
+ el("m-start-login").classList.add("hidden");
1815
+ } else if (subscription) paintOauthMode(editing);
1816
+ else {
1817
+ el("m-save").classList.remove("hidden");
1818
+ el("m-save-test").classList.remove("hidden");
1819
+ el("m-start-login").classList.add("hidden");
1820
+ }
1821
+ };
1822
+ paintType();
1823
+ el("m-type").addEventListener("change", paintType);
1824
+
1825
+ if (!editing) {
1826
+ // The ID follows the name until somebody types an ID of their own, at which
1827
+ // point it stops — a field that overwrites what you typed into it is worse
1828
+ // than one you have to fill in.
1829
+ let idIsOurs = true;
1830
+ el("m-id").addEventListener("input", () => { idIsOurs = false; });
1831
+ el("m-name").addEventListener("input", () => {
1832
+ if (idIsOurs) el("m-id").value = idFromName(el("m-name").value);
1833
+ });
1834
+ }
1835
+
1836
+ el("m-cancel").addEventListener("click", onClose);
1837
+ el("m-save").addEventListener("click", () => saveAccount(account, false));
1838
+ el("m-save-test").addEventListener("click", () => saveAccount(account, true));
1839
+
1840
+ // Paste a credential and the type follows it, unless the operator already chose
1841
+ // one that disagrees — in which case they meant it.
1842
+ const credential = document.querySelector('#m-fields [data-field="api_key"]');
1843
+ if (credential && !editing) {
1844
+ credential.addEventListener("input", () => {
1845
+ const guess = typeOfCredential(credential.value);
1846
+ if (guess && guess !== el("m-type").value) {
1847
+ el("m-type").value = guess;
1848
+ paintType();
1849
+ }
1850
+ });
1851
+ }
1852
+ // onclick, not addEventListener: step two *replaces* this handler with
1853
+ // finishLogin. An added listener would survive and re-run startLogin on the same
1854
+ // click, wiping the code field the operator just pasted into.
1855
+ el("m-start-login").onclick = startLogin;
1856
+ (el("m-name") || {}).focus && el("m-name").focus();
1857
+ }
1858
+
1859
+ /** Shared prep: the dialog and the wizard both need to know whether
1860
+ * subscription login is possible before they draw the choice. */
1861
+ async function prepAccountForm() {
1862
+ state.oauthMode = "session";
1863
+ if (state.oauthConfig === null) {
1864
+ try { state.oauthConfig = await api("/admin/oauth/config"); }
1865
+ catch (error) { state.oauthConfig = { configured: false, missing: [] }; }
1866
+ }
1867
+ }
1868
+
1869
+ async function accountModal(account) {
1870
+ const editing = !!account;
1871
+ //: The file keeps this account's credential, type and base URL. What is left is
1872
+ //: what an operator changes under pressure, so those fields are the whole form.
1873
+ const heldByFile = editing && account.source === "config";
1874
+ await prepAccountForm();
1875
+ el("modal-root").innerHTML = `<div class="scrim" id="scrim">
1876
+ <div class="modal" role="dialog" aria-modal="true" aria-label="${editing ? "Edit" : "Add"} account">
1877
+ <header><h3>${editing ? "Edit " + esc(account.name || account.id) : "Add an account"}</h3>
1878
+ <button class="btn ghost sm" id="m-x">Close</button></header>
1879
+ ${accountFormBody(account, editing, heldByFile)}
1880
+ ${accountFormFooter(editing)}
1881
+ </div></div>`;
1882
+
1883
+ wireAccountForm(account, editing, heldByFile, closeModal);
1884
+ el("m-x").addEventListener("click", closeModal);
1885
+ el("scrim").addEventListener("click", (e) => { if (e.target.id === "scrim") closeModal(); });
1886
+ document.addEventListener("keydown", function onEsc(e) {
1887
+ if (e.key === "Escape") { closeModal(); document.removeEventListener("keydown", onEsc); }
1888
+ });
1889
+ }
1890
+
1891
+ function collectAccount(editing) {
1892
+ const models = el("m-models").value.split(",").map((s) => s.trim()).filter(Boolean);
1893
+ const payload = {
1894
+ name: el("m-name").value.trim(),
1895
+ cost_tier: Number(el("m-tier").value || 1),
1896
+ priority: Number(el("m-pri").value || 0),
1897
+ spend_cap_usd: el("m-cap").value === "" ? null : Number(el("m-cap").value),
1898
+ models: models.length ? models : ["*"],
1899
+ options: {},
1900
+ };
1901
+ if (!editing) {
1902
+ payload.id = el("m-id").value.trim();
1903
+ payload.type = el("m-type").value;
1904
+ }
1905
+ document.querySelectorAll("#m-fields [data-field], #m-login [data-field]").forEach((input) => {
1906
+ const raw = input.value.trim();
1907
+ const name = input.dataset.field;
1908
+ if (name.startsWith("opt.")) {
1909
+ if (raw) payload.options[name.slice(4)] = raw;
1910
+ } else if (raw || !editing) {
1911
+ // On edit, a blank API key means "keep the one you already have".
1912
+ if (raw) payload[name] = raw;
1913
+ }
1914
+ });
1915
+ return payload;
1916
+ }
1917
+
1918
+ /**
1919
+ * Save, optionally testing the credential first.
1920
+ *
1921
+ * The order matters and it used to be the other way round: store, then test. A
1922
+ * typo'd key then became a `disabled` row somebody had to find and delete. Testing
1923
+ * first means a wrong key is a message in this dialog and nothing else, and the
1924
+ * operator can still overrule it — "Add without testing" is right there.
1925
+ */
1926
+ async function saveAccount(account, alsoTest) {
1927
+ const editing = !!account;
1928
+ const payload = collectAccount(editing);
1929
+ if (!editing && !payload.id) {
1930
+ // Only reachable now by clearing a filled-in ID, so it says how to get one back.
1931
+ el("m-msg").innerHTML = `<div class="note err">An account needs an ID. Type a name
1932
+ and one is filled in for you, or set it under Advanced.</div>`;
1933
+ return;
1934
+ }
1935
+ const buttons = ["m-save", "m-save-test"].map(el);
1936
+ buttons.forEach((b) => { if (b) b.disabled = true; });
1937
+ const done = () => buttons.forEach((b) => { if (b) b.disabled = false; });
1938
+
1939
+ try {
1940
+ const type = payload.type || (account || {}).type;
1941
+ if (alsoTest && (payload.api_key || type === "oauth")) {
1942
+ el("m-msg").innerHTML = '<div class="note info"><span>Testing the credential…</span></div>';
1943
+ const probe = await api("/admin/accounts/test", {
1944
+ method: "POST",
1945
+ body: JSON.stringify({
1946
+ api_key: payload.api_key, type: type,
1947
+ base_url: payload.base_url, options: payload.options,
1948
+ }),
1949
+ });
1950
+ if (!probe.ok) {
1951
+ el("m-msg").innerHTML = `<div class="note err"><span>The upstream rejected this
1952
+ credential${probe.status ? " (" + probe.status + ")" : ""}:
1953
+ <b>${esc(probe.detail)}</b><br>Nothing was saved. Fix it, or use
1954
+ <b>${editing ? "Save" : "Add"} without testing</b> if you know better.</span></div>`;
1955
+ done();
1956
+ return;
1957
+ }
1958
+ }
1959
+
1960
+ const id = editing ? account.id : payload.id;
1961
+ if (editing) {
1962
+ await api("/admin/accounts/" + encodeURIComponent(id),
1963
+ { method: "PATCH", body: JSON.stringify(payload) });
1964
+ } else {
1965
+ await api("/admin/accounts", { method: "POST", body: JSON.stringify(payload) });
1966
+ }
1967
+ closeModal();
1968
+ acctMsg(`<div class="note ok"><b>${esc(id)}</b> ${editing ? "saved" : "added"}${
1969
+ alsoTest ? ", credential accepted" : ""}.</div>`);
1970
+ // Claim step 2 before refreshing: the pool is no longer empty, and refresh()
1971
+ // hands the screen back to the console unless the wizard has said it is not done.
1972
+ const onboarding = state.onboardStep > 0;
1973
+ if (onboarding) state.onboardStep = 2;
1974
+ await refresh();
1975
+ if (onboarding) verifyOnboarding(id);
1976
+ } catch (error) {
1977
+ el("m-msg").innerHTML = `<div class="note err">${esc(error.message)}</div>`;
1978
+ } finally {
1979
+ done();
1980
+ }
1981
+ }
1982
+
1983
+ // ---- subscription accounts ---------------------------------------------------
1984
+
1985
+ /** Draw the token-source picker, then the panel for whichever source is chosen.
1986
+ *
1987
+ * Editing never shows the picker: changing where an account's token comes from is
1988
+ * not an edit to that account, it is a different account. Delete and re-add.
1989
+ */
1990
+ function paintOauthMode(editing) {
1991
+ const panel = el("m-login");
1992
+ if (editing) {
1993
+ const detail = (findAccount((el("m-id") || {}).value) || {}).token_source_detail;
1994
+ panel.innerHTML = `<div class="note info"><span>Subscription account${
1995
+ detail ? ", token from " + esc(detail) : ""}. To change where its token comes
1996
+ from, delete it and add it again — everything below still applies.</span></div>`;
1997
+ el("m-save").classList.remove("hidden");
1998
+ el("m-save-test").classList.remove("hidden");
1999
+ el("m-start-login").classList.add("hidden");
2000
+ return;
2001
+ }
2002
+ const mode = state.oauthMode;
2003
+ panel.innerHTML = `<div class="seg" role="tablist" aria-label="where the token comes from">
2004
+ ${Object.keys(OAUTH_MODES).map((key) => `<button type="button" role="tab"
2005
+ class="segb ${key === mode ? "on" : ""}" data-mode="${key}"
2006
+ aria-selected="${key === mode}">${esc(OAUTH_MODES[key].label)}
2007
+ <span class="segt">${esc(OAUTH_MODES[key].tag)}</span></button>`).join("")}
2008
+ </div>
2009
+ <div class="hint">${OAUTH_MODES[mode].blurb}</div>
2010
+ <div id="m-mode-body" class="stack"></div>`;
2011
+
2012
+ panel.querySelectorAll("[data-mode]").forEach((button) =>
2013
+ button.addEventListener("click", () => {
2014
+ state.oauthMode = button.dataset.mode;
2015
+ paintOauthMode(false);
2016
+ }));
2017
+
2018
+ // Only the login flow uses the footer's login button; the other two save an
2019
+ // account like any other, credential probe included.
2020
+ const login = mode === "login";
2021
+ el("m-save").classList.toggle("hidden", login);
2022
+ el("m-save-test").classList.toggle("hidden", login);
2023
+ el("m-start-login").classList.toggle("hidden", !login);
2024
+
2025
+ if (mode === "login") loadOauthConfig();
2026
+ else if (mode === "token") paintTokenMode();
2027
+ else paintSessionMode();
2028
+ }
2029
+
2030
+ function paintTokenMode() {
2031
+ el("m-mode-body").innerHTML = `<label class="f">Access token
2032
+ <input type="password" data-field="api_key" autocomplete="off"
2033
+ placeholder="sk-ant-oat01-…">
2034
+ <span class="hint">From <span class="mono">claude setup-token</span>. Encrypted
2035
+ before it is stored, and never shown again.</span></label>`;
2036
+ }
2037
+
2038
+ /** The scan: which Claude Code logins this machine has, and which one is current.
2039
+ *
2040
+ * Offering a path field alone would be honest and useless — the failure it invites
2041
+ * is pointing at ~/.claude when CLAUDE_CONFIG_DIR says the real login is somewhere
2042
+ * else, and the 401 that follows names neither file.
2043
+ */
2044
+ async function paintSessionMode() {
2045
+ const body = el("m-mode-body");
2046
+ body.innerHTML = '<div class="skel" style="height:76px"></div>';
2047
+ if (state.oauthDetect === null) {
2048
+ try { state.oauthDetect = (await api("/admin/oauth/detect")).candidates || []; }
2049
+ catch (error) { state.oauthDetect = []; }
2050
+ }
2051
+ const found = state.oauthDetect.filter((c) => c.exists);
2052
+ const usable = found.filter((c) => c.has_token && !c.expired);
2053
+ const chosen = (usable[0] || found[0] || {}).path || "";
2054
+
2055
+ body.innerHTML = `${found.length ? found.map((c) => `<label class="pick">
2056
+ <input type="radio" name="m-cred" value="${esc(c.path)}"
2057
+ ${c.path === chosen ? "checked" : ""}>
2058
+ <span class="pickb">
2059
+ <span class="pickt">${esc(c.path)}
2060
+ ${c.subscription ? `<span class="pill accent"><i></i>${esc(c.subscription)}</span>` : ""}
2061
+ ${c.has_token && !c.expired ? '<span class="pill ready"><i></i>current</span>'
2062
+ : '<span class="pill muted"><i></i>stale</span>'}</span>
2063
+ <span class="picks">${esc(c.detail)}${c.expires_at
2064
+ ? " · expires " + esc(new Date(c.expires_at * 1000).toLocaleString()) : ""}</span>
2065
+ </span></label>`).join("")
2066
+ : `<div class="note warn"><span>No Claude Code login found on this machine. Run
2067
+ <span class="mono">claude</span> once and sign in, then reopen this dialog —
2068
+ or paste the path below if it lives somewhere unusual.</span></div>`}
2069
+ <label class="f">Credentials file
2070
+ <input data-field="opt.credentials_path" id="m-cred-path" value="${esc(chosen)}"
2071
+ placeholder="~/.claude/.credentials.json">
2072
+ <span class="hint">What the gateway will read. It re-reads this file whenever it
2073
+ changes, so a token the CLI refreshes is picked up without a restart.</span></label>`;
2074
+
2075
+ body.querySelectorAll('input[name="m-cred"]').forEach((radio) =>
2076
+ radio.addEventListener("change", () => { el("m-cred-path").value = radio.value; }));
2077
+ }
2078
+
2079
+ // ---- subscription login ------------------------------------------------------
2080
+ // Two steps, because the provider redirects a browser that carries no admin key:
2081
+ // start (get the authorize URL) then complete (hand the code back with a key).
2082
+
2083
+ async function loadOauthConfig() {
2084
+ const panel = el("m-mode-body") || el("m-login");
2085
+ if (!panel) return;
2086
+ panel.innerHTML = '<div class="skel" style="height:52px"></div>';
2087
+ try {
2088
+ const config = state.oauthConfig || await api("/admin/oauth/config");
2089
+ state.oauthConfig = config;
2090
+ if (!config.configured) {
2091
+ // Say exactly what is missing rather than failing on the button press.
2092
+ panel.innerHTML = `<div class="note warn"><span>Subscription login is not
2093
+ configured yet. Set <b>${esc(config.missing.join("</b>, <b>"))}</b> in
2094
+ tokenbiryani.yaml, then reload the config on the Settings screen.<br>
2095
+ These are provider endpoints this gateway deliberately does not guess at —
2096
+ see <span class="mono">docs/oauth.md</span>.</span></div>`;
2097
+ el("m-start-login").disabled = true;
2098
+ return;
2099
+ }
2100
+ el("m-start-login").disabled = false;
2101
+ panel.innerHTML = `<div class="note info"><span>You will be sent to Claude to
2102
+ authorize this gateway${config.manual
2103
+ ? ", then paste the code it shows you back here"
2104
+ : ", and returned here automatically"}.</span></div>`;
2105
+ } catch (error) {
2106
+ panel.innerHTML = `<div class="note err">${esc(error.message)}</div>`;
2107
+ }
2108
+ }
2109
+
2110
+ async function startLogin() {
2111
+ const id = el("m-id").value.trim();
2112
+ if (!id) {
2113
+ el("m-msg").innerHTML = '<div class="note err">Give the account an ID first.</div>';
2114
+ return;
2115
+ }
2116
+ const button = el("m-start-login");
2117
+ button.disabled = true;
2118
+ try {
2119
+ const started = await api("/admin/oauth/start", {
2120
+ method: "POST",
2121
+ body: JSON.stringify({ id: id, name: el("m-name").value.trim() }),
2122
+ });
2123
+ // Opened rather than navigated to: navigating away would lose the pending
2124
+ // login, which lives in this page's memory until the code comes back.
2125
+ window.open(started.authorize_url, "_blank", "noopener");
2126
+ (el("m-mode-body") || el("m-login")).innerHTML = `<div class="note info"><span>A Claude tab is open.
2127
+ Authorize the gateway, then paste the code below.<br>
2128
+ If the tab did not open, <a href="${esc(started.authorize_url)}"
2129
+ target="_blank" rel="noopener">use this link</a>.</span></div>
2130
+ <label class="f"><span class="t">Authorization code</span>
2131
+ <input id="m-code" placeholder="paste it here" autocomplete="off">
2132
+ <span class="hint">Shown after you authorize. This login expires in 15 minutes.</span>
2133
+ </label>`;
2134
+ button.textContent = "Finish login";
2135
+ button.onclick = () => finishLogin(started.state);
2136
+ el("m-code").addEventListener("keydown",
2137
+ (event) => { if (event.key === "Enter") finishLogin(started.state); });
2138
+ el("m-code").focus();
2139
+ } catch (error) {
2140
+ el("m-msg").innerHTML = `<div class="note err">${esc(error.message)}</div>`;
2141
+ } finally {
2142
+ button.disabled = false;
2143
+ }
2144
+ }
2145
+
2146
+ async function finishLogin(loginState) {
2147
+ const code = (el("m-code") || {}).value ? el("m-code").value.trim() : "";
2148
+ if (!code) {
2149
+ el("m-msg").innerHTML = '<div class="note err">Paste the authorization code.</div>';
2150
+ return;
2151
+ }
2152
+ const button = el("m-start-login");
2153
+ button.disabled = true;
2154
+ try {
2155
+ const record = await api("/admin/oauth/complete", {
2156
+ method: "POST",
2157
+ body: JSON.stringify({ state: loginState, code: code }),
2158
+ });
2159
+ closeModal();
2160
+ acctMsg(`<div class="note ok"><b>${esc(record.id)}</b> signed in. Its session will
2161
+ be renewed automatically before it expires.</div>`);
2162
+ const onboarding = state.onboardStep > 0;
2163
+ if (onboarding) state.onboardStep = 2;
2164
+ await refresh();
2165
+ if (onboarding) verifyOnboarding(record.id);
2166
+ } catch (error) {
2167
+ el("m-msg").innerHTML = `<div class="note err">${esc(error.message)}</div>`;
2168
+ } finally {
2169
+ button.disabled = false;
2170
+ }
2171
+ }
2172
+
2173
+ /** Where this subscription's token comes from, and what that means for it lapsing.
2174
+ *
2175
+ * Three sources, three different answers. Telling a credentials-file account that
2176
+ * "there is no refresh token, so it cannot be renewed" was true of a session the
2177
+ * gateway holds and false — and alarming — of one the CLI keeps current.
2178
+ */
2179
+ function subscriptionNote(account) {
2180
+ const expiry = account.token_expires_at || account.session_expires_at;
2181
+ const when = expiry ? esc(new Date(expiry * 1000).toLocaleString()) : "";
2182
+ const stale = expiry ? expiry * 1000 <= Date.now() : false;
2183
+
2184
+ if (account.token_source === "external") {
2185
+ return `<div class="note ${stale ? "warn" : "info"}"><span>Token read from
2186
+ ${esc(account.token_source_detail)}${when ? ", valid until " + when : ""}.
2187
+ ${stale
2188
+ ? "It has expired. Run <span class=\"mono\">claude</span> once to renew it — the "
2189
+ + "gateway picks the new one up without a restart."
2190
+ : "The gateway re-reads that file whenever it changes, so a token the CLI "
2191
+ + "refreshes is picked up without a restart."}</span></div>`;
2192
+ }
2193
+ if (account.token_source === "token") {
2194
+ return `<div class="note info"><span>Long-lived token, stored encrypted. It does
2195
+ not depend on any file staying fresh${when ? ", and is valid until " + when : ""}.
2196
+ Replace it by editing this account.</span></div>`;
2197
+ }
2198
+ return `<div class="note ${account.can_refresh ? "info" : "warn"}">
2199
+ <span>Subscription session${when ? ", expires " + when : " with no stated expiry"}.
2200
+ ${account.can_refresh
2201
+ ? "It is renewed automatically before it lapses."
2202
+ : "There is no refresh token, so it cannot be renewed — log in again when it lapses."}
2203
+ </span></div>`;
2204
+ }
2205
+
2206
+ // ---- account detail ----------------------------------------------------------
2207
+ async function openAccount(id) {
2208
+ show("accounts");
2209
+ try {
2210
+ const account = await api("/admin/accounts/" + encodeURIComponent(id));
2211
+ const kinds = Object.entries(account.error_kinds || {});
2212
+ el("account-detail").innerHTML = `<div class="card">
2213
+ <header><h3>${esc(account.name || account.id)}</h3>
2214
+ <span class="note mono">${esc(account.id)} · ${esc(account.type)} · cost tier ${account.cost_tier} · priority ${account.priority}</span></header>
2215
+ <div class="pad stack">
2216
+ ${account.disabled_reason ? `<div class="note err">Disabled: ${esc(account.disabled_reason)}.
2217
+ This does not self-heal — fix the credential, then press Test.</div>` : ""}
2218
+ ${account.limits.unified_known ? `<div class="note info"><span>This account reports
2219
+ rolling-window utilisation (<b>anthropic-ratelimit-unified-*</b>) rather than
2220
+ per-window budgets, so its meters show real headroom but the capacity horizon
2221
+ — which needs absolute token counts — leaves it out.</span></div>`
2222
+ : account.observable_limits === false ? `<div class="note info"><span>This upstream reports no
2223
+ <b>anthropic-ratelimit-*</b> headers, so its meters and the capacity horizon stay
2224
+ empty and routing falls back to reactive backoff.</span></div>` : ""}
2225
+ ${account.type === "oauth" ? subscriptionNote(account) : ""}
2226
+ <div class="kv">
2227
+ <div><span class="lb">State</span><span class="vl">${statePill(account)}</span></div>
2228
+ <div><span class="lb">Requests</span><span class="vl">${num(account.requests_total)}</span></div>
2229
+ <div><span class="lb">Failures</span><span class="vl">${num(account.failures_total)}</span></div>
2230
+ <div><span class="lb">p95</span><span class="vl">${account.p95_latency == null ? "—" : account.p95_latency.toFixed(2) + "s"}</span></div>
2231
+ <div><span class="lb">Cache hit</span><span class="vl ca">${pct(account.cache_hit_rate)}</span></div>
2232
+ <div><span class="lb">Spend</span><span class="vl">${usd(account.spend_usd)}</span></div>
2233
+ <div><span class="lb">Input tok</span><span class="vl">${tokens(account.tokens.input)}</span></div>
2234
+ <div><span class="lb">Output tok</span><span class="vl">${tokens(account.tokens.output)}</span></div>
2235
+ </div>
2236
+ ${kinds.length ? `<h4>Errors by class</h4><div class="kv">${kinds.map(([k, v]) =>
2237
+ `<div><span class="lb">${esc(k)}</span><span class="vl">${v}</span></div>`).join("")}</div>` : ""}
2238
+ <div class="row">
2239
+ <button class="btn" data-test="${esc(account.id)}">Test credential</button>
2240
+ <button class="btn" data-edit="${esc(account.id)}">Edit</button>
2241
+ ${account.source === "managed"
2242
+ ? `<button class="btn danger" data-del="${esc(account.id)}">Delete</button>`
2243
+ : `${(account.overridden || []).length
2244
+ ? `<button class="btn" data-revert="${esc(account.id)}">Revert to file</button>` : ""}
2245
+ <span class="dm" style="font-size:11.5px">Declared in tokenbiryani.yaml —
2246
+ its credential lives there.</span>`}
2247
+ </div>
2248
+ </div></div>
2249
+ <div class="card"><header><h3>Recent requests</h3>
2250
+ <span class="note">including attempts it failed</span></header>
2251
+ <div class="stream" id="account-requests"></div></div>`;
2252
+ renderRequests("account-requests", account.recent_requests || []);
2253
+ const detail = el("account-detail");
2254
+ detail.querySelectorAll("[data-test]").forEach((n) =>
2255
+ n.addEventListener("click", () => testAccount(n.dataset.test, n)));
2256
+ detail.querySelectorAll("[data-edit]").forEach((n) =>
2257
+ n.addEventListener("click", () => accountModal(findAccount(n.dataset.edit))));
2258
+ detail.querySelectorAll("[data-del]").forEach((n) =>
2259
+ n.addEventListener("click", () => deleteAccount(n.dataset.del)));
2260
+ detail.querySelectorAll("[data-revert]").forEach((n) =>
2261
+ n.addEventListener("click", () => revertOverride(n.dataset.revert)));
2262
+ } catch (error) { banner(error.message); }
2263
+ }
2264
+
2265
+ // ---- keys --------------------------------------------------------------------
2266
+ function renderKeys(keys) {
2267
+ el("keys").innerHTML = `<thead><tr><th>Name</th><th>Key</th><th>Source</th><th>Models</th>
2268
+ <th>Pool</th><th class="r">RPM</th><th class="r">Cap</th><th>Admin</th><th></th></tr></thead><tbody>` +
2269
+ keys.map((k) => `<tr>
2270
+ <td style="color:var(--text)">${esc(k.name)}</td>
2271
+ <td class="mono dm">${esc(k.key)}</td>
2272
+ <td>${esc(k.source)}</td>
2273
+ <td class="mono">${esc([].concat(k.models || []).join(", "))}</td>
2274
+ <td class="mono">${esc(Array.isArray(k.pool) ? k.pool.join(", ") : k.pool)}</td>
2275
+ <td class="r mono">${k.rpm == null ? "—" : k.rpm}</td>
2276
+ <td class="r mono">${k.spend_cap_usd == null ? "—" : usd(k.spend_cap_usd)}</td>
2277
+ <td>${k.admin ? '<span class="pill accent"><i></i>admin</span>' : ""}</td>
2278
+ <td class="r">${k.source === "managed"
2279
+ ? `<span class="acts"><button class="btn sm" data-edit-key="${esc(k.name)}">Edit</button>
2280
+ <button class="btn sm danger" data-revoke="${esc(k.name)}">Revoke</button></span>`
2281
+ : '<span class="dm" title="declared in the config file">in config</span>'}</td>
2282
+ </tr>`).join("") + "</tbody>";
2283
+ state.keys = keys;
2284
+ el("keys").querySelectorAll("[data-revoke]").forEach((button) =>
2285
+ button.addEventListener("click", () => revokeKey(button.dataset.revoke)));
2286
+ el("keys").querySelectorAll("[data-edit-key]").forEach((button) =>
2287
+ button.addEventListener("click", () => keyModal(
2288
+ (state.keys || []).filter((k) => k.name === button.dataset.editKey)[0])));
2289
+ }
2290
+
2291
+ /** Edit a key's scope in place.
2292
+ *
2293
+ * The alternative is revoke-and-reissue, which breaks every client already holding
2294
+ * it. The common case is a key scoped at an account that has since been removed:
2295
+ * harmless-looking, and it blocks config reload until somebody fixes it.
2296
+ */
2297
+ function keyModal(key) {
2298
+ if (!key) return;
2299
+ const accounts = ((state.snapshot || {}).accounts || []).map((a) => a.id);
2300
+ const pool = [].concat(key.pool || []);
2301
+ el("modal-root").innerHTML = `<div class="scrim" id="scrim">
2302
+ <div class="modal" role="dialog" aria-modal="true" aria-label="Edit key">
2303
+ <header><h3>Edit ${esc(key.name)}</h3>
2304
+ <button class="btn ghost sm" id="k-x">Close</button></header>
2305
+ <div class="pad stack">
2306
+ <div class="note info"><span>The key itself does not change, so nothing using
2307
+ it has to be updated. To replace the key material, revoke it and mint a
2308
+ new one.</span></div>
2309
+ <div class="f">
2310
+ <span class="t">Pool</span>
2311
+ <div class="stack" id="k-pool-list">${accounts.length
2312
+ ? accounts.map((id) => `<label class="pick"><input type="checkbox"
2313
+ value="${esc(id)}" ${pool.indexOf(id) >= 0 ? "checked" : ""}>
2314
+ <span class="pickb"><span class="pickt">${esc(id)}</span></span></label>`).join("")
2315
+ : '<span class="hint">No accounts yet.</span>'}</div>
2316
+ <span class="hint">None ticked means every account. A key scoped to an
2317
+ account that no longer exists is refused, which is what blocks reload.</span>
2318
+ </div>
2319
+ <div class="grid g3">
2320
+ <label class="f">RPM
2321
+ <input id="k-e-rpm" type="number" step="1" placeholder="none"
2322
+ value="${key.rpm == null ? "" : key.rpm}"></label>
2323
+ <label class="f">Spend cap USD
2324
+ <input id="k-e-cap" type="number" step="0.01" placeholder="none"
2325
+ value="${key.spend_cap_usd == null ? "" : key.spend_cap_usd}"></label>
2326
+ <label class="f">Priority
2327
+ <select id="k-e-pri">${["interactive", "batch", "background"].map((p) =>
2328
+ `<option ${p === key.priority ? "selected" : ""}>${p}</option>`).join("")}</select></label>
2329
+ </div>
2330
+ <label class="f">Models
2331
+ <input id="k-e-models" placeholder="*"
2332
+ value="${esc([].concat(key.models || ["*"]).join(", "))}">
2333
+ <span class="hint">Comma separated. <code>*</code> or blank means every model.</span></label>
2334
+ <div id="k-e-msg"></div>
2335
+ </div>
2336
+ <footer><button class="btn ghost" id="k-cancel">Cancel</button>
2337
+ <button class="btn primary" id="k-save">Save</button></footer>
2338
+ </div></div>`;
2339
+ el("k-x").addEventListener("click", closeModal);
2340
+ el("k-cancel").addEventListener("click", closeModal);
2341
+ el("scrim").addEventListener("click", (e) => { if (e.target.id === "scrim") closeModal(); });
2342
+ el("k-save").addEventListener("click", () => saveKey(key.name));
2343
+ }
2344
+
2345
+ async function saveKey(name) {
2346
+ const models = el("k-e-models").value.split(",").map((s) => s.trim()).filter(Boolean);
2347
+ const pool = [];
2348
+ el("k-pool-list").querySelectorAll("input:checked").forEach((box) => pool.push(box.value));
2349
+ const button = el("k-save");
2350
+ button.disabled = true;
2351
+ try {
2352
+ await api("/admin/keys/" + encodeURIComponent(name), {
2353
+ method: "PATCH",
2354
+ body: JSON.stringify({
2355
+ pool: pool,
2356
+ models: models.length ? models : ["*"],
2357
+ rpm: el("k-e-rpm").value === "" ? null : Number(el("k-e-rpm").value),
2358
+ spend_cap_usd: el("k-e-cap").value === "" ? null : Number(el("k-e-cap").value),
2359
+ priority: el("k-e-pri").value,
2360
+ }),
2361
+ });
2362
+ closeModal();
2363
+ loadKeys();
2364
+ } catch (error) {
2365
+ el("k-e-msg").innerHTML = `<div class="note err">${esc(error.message)}</div>`;
2366
+ } finally {
2367
+ button.disabled = false;
2368
+ }
2369
+ }
2370
+
2371
+ async function loadKeys() {
2372
+ try { renderKeys((await api("/admin/keys")).keys); }
2373
+ catch (error) { banner(error.message); }
2374
+ }
2375
+
2376
+ async function createKey() {
2377
+ const button = el("k-create");
2378
+ const pool = el("k-pool").value.split(",").map((s) => s.trim()).filter(Boolean);
2379
+ const payload = {
2380
+ name: el("k-name").value.trim(),
2381
+ pool: pool,
2382
+ rpm: el("k-rpm").value ? Number(el("k-rpm").value) : null,
2383
+ spend_cap_usd: el("k-cap").value ? Number(el("k-cap").value) : null,
2384
+ priority: el("k-pri").value,
2385
+ admin: el("k-admin").value === "true"
2386
+ };
2387
+ button.disabled = true;
2388
+ try {
2389
+ const created = await api("/admin/keys", { method: "POST", body: JSON.stringify(payload) });
2390
+ el("k-result").innerHTML = `<div class="note ok"><span>Copy this now — it is stored hashed
2391
+ and cannot be shown again.<br><code>${esc(created.key)}</code></span></div>`;
2392
+ ["k-name", "k-pool", "k-rpm", "k-cap"].forEach((id) => { el(id).value = ""; });
2393
+ loadKeys();
2394
+ } catch (error) {
2395
+ el("k-result").innerHTML = `<div class="note err">${esc(error.message)}</div>`;
2396
+ } finally {
2397
+ button.disabled = false;
2398
+ }
2399
+ }
2400
+
2401
+ async function revokeKey(name) {
2402
+ if (!window.confirm("Revoke " + name + "? Anything using it stops working immediately.")) return;
2403
+ try { await api("/admin/keys/" + encodeURIComponent(name), { method: "DELETE" }); loadKeys(); }
2404
+ catch (error) { banner(error.message); }
2405
+ }
2406
+
2407
+ // ---- settings + guide --------------------------------------------------------
2408
+ /** Prices come from a dated table or from the operator; either way the console
2409
+ * says which, because a cost is only trustworthy if you can date it. */
2410
+ function pricingLine(snapshot) {
2411
+ const pricing = snapshot.pricing || {};
2412
+ if (!pricing.models) return "none — costs read \u2014";
2413
+ if (pricing.as_of) return pricing.models + " models · bundled table, " + pricing.as_of;
2414
+ return pricing.models + " models · from your config";
2415
+ }
2416
+
2417
+ function renderSettings(snapshot) {
2418
+ el("settings").innerHTML = [
2419
+ ["Accounts", snapshot.pool.total],
2420
+ ["Ready", snapshot.pool.ready], ["Cooling", snapshot.pool.cooling],
2421
+ ["Disabled", snapshot.pool.disabled], ["Queue depth", snapshot.queue.depth],
2422
+ ["Queue max", snapshot.queue.max_size], ["Pricing", pricingLine(snapshot)]
2423
+ ].map(([label, value]) =>
2424
+ `<div><span class="lb">${label}</span><span class="vl">${esc(value)}</span></div>`).join("");
2425
+ }
2426
+
2427
+ //: Guards against overlapping Settings loads painting out of order.
2428
+ let settingsLoad = 0;
2429
+
2430
+ /** The two settings worth changing without an editor, and where each came from.
2431
+ *
2432
+ * Neither is written back to tokenbiryani.yaml: a process that rewrites its
2433
+ * operator's config file is one that eventually loses a comment somebody needed.
2434
+ * They are stored in the gateway and laid over the file again after every reload,
2435
+ * so an unrelated edit cannot silently revert them — which the origin tag says.
2436
+ */
2437
+ async function loadEditableSettings(saved) {
2438
+ const wrap = el("settings-edit");
2439
+ if (!wrap) return;
2440
+ // Two clicks on Settings start two loads, and they can resolve out of order.
2441
+ // The older one repainting last puts a stale panel on screen and throws away
2442
+ // whatever was chosen in between, so only the newest load is allowed to paint.
2443
+ const token = ++settingsLoad;
2444
+ try { state.settings = await api("/admin/settings"); }
2445
+ catch (error) {
2446
+ if (token !== settingsLoad) return;
2447
+ wrap.innerHTML = `<div class="note err">${esc(error.message)}</div>`;
2448
+ return;
2449
+ }
2450
+ if (token !== settingsLoad) return;
2451
+ const s = state.settings;
2452
+ const origin = (row) => row.source === "console"
2453
+ ? `<span class="pill cooling" title="set here; the file says ${esc(String(row.file_value))}"><i></i>set in console</span>`
2454
+ : '<span class="pill muted"><i></i>from the file</span>';
2455
+
2456
+ // Read at paint time, not before the fetch: this load is asynchronous, and a
2457
+ // choice made while it was in flight is exactly the one a repaint would drop —
2458
+ // leaving a setting that looked changed and saved the old value.
2459
+ const pending = saved ? {} : {
2460
+ strategy: (el("set-strategy") || {}).value,
2461
+ pricing: (el("set-pricing") || {}).value,
2462
+ };
2463
+ wrap.innerHTML = `<div class="grid g2">
2464
+ <label class="f">Routing strategy ${origin(s.strategy)}
2465
+ <select id="set-strategy">${s.strategy.options.map((name) =>
2466
+ `<option ${name === s.strategy.value ? "selected" : ""}>${esc(name)}</option>`).join("")}</select>
2467
+ <span class="hint">Takes effect on the next request. <b>sticky_headroom</b> keeps
2468
+ a conversation on the account holding its prompt cache; the baselines are
2469
+ there to benchmark against, not to run.</span></label>
2470
+ <label class="f">Price table ${origin(s.pricing_source)}
2471
+ <select id="set-pricing">${s.pricing_source.options.map((name) =>
2472
+ `<option value="${name}" ${name === s.pricing_source.value ? "selected" : ""}>${
2473
+ name === "builtin" ? "Bundled table" : "Only what my config names"}</option>`).join("")}</select>
2474
+ <span class="hint">${s.priced_as_of
2475
+ ? "Bundled table dated <b>" + esc(s.priced_as_of) + "</b> — check it against "
2476
+ + "Anthropic's current pricing before trusting a number."
2477
+ : "Your config's own <span class=\"mono\">pricing:</span> block."}
2478
+ Entries in your config always win. ${s.priced_models.length} model
2479
+ pattern(s) priced.</span></label>
2480
+ </div>
2481
+ <div class="row"><button class="btn primary" id="set-save">Save settings</button>
2482
+ <span id="set-msg" class="note"></span></div>`;
2483
+
2484
+ if (pending.strategy) el("set-strategy").value = pending.strategy;
2485
+ if (pending.pricing) el("set-pricing").value = pending.pricing;
2486
+ el("set-save").addEventListener("click", saveSettings);
2487
+ }
2488
+
2489
+ async function saveSettings() {
2490
+ const button = el("set-save");
2491
+ button.disabled = true;
2492
+ el("set-msg").innerHTML = "";
2493
+ try {
2494
+ await api("/admin/settings", {
2495
+ method: "POST",
2496
+ body: JSON.stringify({
2497
+ strategy: el("set-strategy").value,
2498
+ pricing_source: el("set-pricing").value,
2499
+ }),
2500
+ });
2501
+ // Re-render first: it replaces this panel wholesale, so a message written
2502
+ // before it would be wiped by its own success.
2503
+ await loadEditableSettings(true);
2504
+ el("set-msg").innerHTML = '<span class="ok">Saved. This survives a restart.</span>';
2505
+ refresh();
2506
+ } catch (error) {
2507
+ el("set-msg").innerHTML = `<span class="err">${esc(error.message)}</span>`;
2508
+ } finally {
2509
+ button.disabled = false;
2510
+ }
2511
+ }
2512
+
2513
+ async function reloadConfig() {
2514
+ try {
2515
+ const result = await api("/admin/reload", { method: "POST" });
2516
+ el("reload-result").innerHTML = `<div class="note ok"><span>Reloaded.
2517
+ ${result.added.length ? "Added " + esc(result.added.join(", ")) + ". " : ""}
2518
+ ${result.removed.length ? "Removed " + esc(result.removed.join(", ")) + "." : ""}</span></div>`;
2519
+ refresh();
2520
+ } catch (error) {
2521
+ el("reload-result").innerHTML = `<div class="note err">${esc(error.message)}</div>`;
2522
+ }
2523
+ }
2524
+
2525
+ /** The screen that answers "so how do I actually use this thing". */
2526
+ function renderGuide() {
2527
+ const origin = location.origin;
2528
+ const key = state.mintedKey || state.key || "<your key>";
2529
+ el("guide").innerHTML = `
2530
+ <section>
2531
+ <h2><span class="num">1</span>Point your environment at the gateway</h2>
2532
+ <p>Two variables. Nothing else changes — the gateway speaks the Messages API
2533
+ verbatim and only swaps the auth header.</p>
2534
+ <div id="g-connect"></div>
2535
+ </section>
2536
+ <section>
2537
+ <h2><span class="num">2</span>Run Claude Code through it</h2>
2538
+ <p>With those two variables exported, Claude Code needs no configuration at all.</p>
2539
+ <pre class="code">claude</pre>
2540
+ <p>Every turn now goes through the pool. Watch it land on the Overview screen.</p>
2541
+ </section>
2542
+ <section>
2543
+ <h2><span class="num">3</span>Or any Anthropic SDK</h2>
2544
+ <pre class="code">from anthropic import Anthropic
2545
+
2546
+ client = Anthropic(base_url="${esc(origin)}", api_key="${esc(key)}")
2547
+ client.messages.create(model="claude-sonnet-5", max_tokens=256,
2548
+ messages=[{"role": "user", "content": "hello"}])</pre>
2549
+ </section>
2550
+ <section>
2551
+ <h2><span class="num">4</span>Check it with curl</h2>
2552
+ <pre class="code">curl ${esc(origin)}/v1/messages \\
2553
+ -H "x-api-key: ${esc(key)}" \\
2554
+ -H "content-type: application/json" \\
2555
+ -d '{"model":"claude-sonnet-5","max_tokens":64,
2556
+ "messages":[{"role":"user","content":"hello"}]}'</pre>
2557
+ <p>The response carries an <code>x-tokenbiryani-account</code> header naming the
2558
+ account that served it.</p>
2559
+ </section>`;
2560
+ renderKeyHandover(el("g-connect"), "g");
2561
+ }
2562
+
2563
+ // ---- onboarding --------------------------------------------------------------
2564
+ /**
2565
+ * First run: a full page, four steps, and the work done on the page itself.
2566
+ *
2567
+ * It used to be a 560px card whose first step was a button that opened a dialog,
2568
+ * so the screen you landed on did nothing and the form that mattered arrived
2569
+ * stacked on top of it. The four things that have to happen are unchanged; what
2570
+ * changed is that each one is a screen with room to say what it is for, and the
2571
+ * account form is mounted inline instead of in a modal.
2572
+ *
2573
+ * The rail states all four up front on purpose. What is being asked for is a
2574
+ * credential, and a wizard that reveals its own length one screen at a time earns
2575
+ * none of the trust that asking for one requires.
2576
+ */
2577
+ const WIZ_STEPS = [
2578
+ ["Choose a credential", "How this gateway signs in"],
2579
+ ["Point it at the credential", "A login on this machine, or a key you paste"],
2580
+ ["Verify", "Does it work, and can the router read its limits"],
2581
+ ["Connect a client", "The two lines that route through the pool"],
2582
+ ];
2583
+
2584
+ function renderWizSteps(current) {
2585
+ el("ob-addr").textContent = "Serving at " + location.origin;
2586
+ // One button, two exits: on an empty pool it re-reads the file, on a working
2587
+ // pool it simply goes back — and the label has to say which.
2588
+ el("ob-skip").textContent = state.poolEmpty
2589
+ ? "I edited the YAML instead" : "Back to the console";
2590
+ el("ob-steps").innerHTML = WIZ_STEPS.map((step, i) => {
2591
+ const n = i + 1;
2592
+ const cls = n < current ? "done" : n === current ? "on" : "";
2593
+ return `<div class="wiz-step ${cls}"${n === current ? ' aria-current="step"' : ""}>
2594
+ <span class="n">${n < current ? "&#10003;" : n}</span>
2595
+ <span class="t"><b>${esc(step[0])}</b><span>${esc(step[1])}</span></span>
2596
+ </div>`;
2597
+ }).join("");
2598
+ }
2599
+
2600
+ /**
2601
+ * Step 1: where this pool's credential comes from.
2602
+ *
2603
+ * One card per route rather than a select, because the routes are not equivalent
2604
+ * — a subscription already signed in on this machine needs nothing typed, and an
2605
+ * API key needs a secret pasted. A dropdown would present those as the same
2606
+ * decision. When a Claude Code login is found, that route leads and says so.
2607
+ */
2608
+ async function renderOnboarding() {
2609
+ state.onboardStep = 1;
2610
+ renderWizSteps(1);
2611
+ el("ob-body").innerHTML = `<div class="stack">
2612
+ <div class="stack" style="gap:var(--s2)">
2613
+ <h2>Choose a credential</h2>
2614
+ <p class="lede">The gateway holds credentials and lends them out, so a client
2615
+ never carries one. Pick where this pool's first credential comes from — more
2616
+ can be added later, and a pool of one is a valid pool.</p>
2617
+ </div>
2618
+ <div class="skel" style="height:210px"></div></div>`;
2619
+
2620
+ // Detection decides the order, so it has to finish before the cards are drawn.
2621
+ // Cached on state, so stepping back to this screen costs nothing.
2622
+ if (state.oauthDetect === null) {
2623
+ try { state.oauthDetect = (await api("/admin/oauth/detect")).candidates || []; }
2624
+ catch (error) { state.oauthDetect = []; }
2625
+ }
2626
+ if (state.onboardStep !== 1) return; // stepped away while detecting
2627
+
2628
+ const found = (state.oauthDetect || []).filter((c) => c.exists && c.has_token);
2629
+ const fresh = found.filter((c) => !c.expired);
2630
+ const order = found.length
2631
+ ? ["oauth", "anthropic_api", "bedrock", "vertex"]
2632
+ : ["anthropic_api", "oauth", "bedrock", "vertex"];
2633
+
2634
+ const badge = (key, first) => {
2635
+ if (key !== "oauth") return "";
2636
+ if (!found.length) {
2637
+ return '<span class="pill muted"><i></i>none found</span>';
2638
+ }
2639
+ return `${first ? '<span class="pill accent"><i></i>recommended</span>' : ""}
2640
+ <span class="pill ${fresh.length ? "ready" : "cooling"}"><i></i>${found.length}
2641
+ found${fresh.length ? "" : ", stale"}</span>`;
2642
+ };
2643
+
2644
+ const cards = order.map((key, i) => {
2645
+ const spec = ACCOUNT_TYPES[key];
2646
+ return `<label class="route">
2647
+ <input type="radio" name="ob-route" value="${key}"${i === 0 ? " checked" : ""}>
2648
+ <span class="rb">
2649
+ <span class="rt">${esc(spec.label)}${badge(key, i === 0)}</span>
2650
+ <span class="rs">${esc(spec.blurb)}</span>
2651
+ </span></label>`;
2652
+ }).join("");
2653
+
2654
+ // Re-entering the wizard from a working console must not imply the pool is
2655
+ // empty. Saying what is already in it is the whole difference.
2656
+ const pool = (state.snapshot && state.snapshot.pool) || { total: 0 };
2657
+ const existing = pool.total
2658
+ ? `<div class="note info"><span>This pool already has <b>${pool.total}</b>
2659
+ account${pool.total === 1 ? "" : "s"}. Anything added here joins them; nothing
2660
+ is replaced.</span></div>`
2661
+ : "";
2662
+
2663
+ el("ob-body").innerHTML = `<div class="stack">
2664
+ <div class="stack" style="gap:var(--s2)">
2665
+ <h2>Choose a credential</h2>
2666
+ <p class="lede">The gateway holds credentials and lends them out, so a client
2667
+ never carries one. Pick where this pool's first credential comes from — more
2668
+ can be added later, and a pool of one is a valid pool.</p>
2669
+ </div>
2670
+ ${existing}
2671
+ <div class="routes">${cards}</div>
2672
+ <div class="row">
2673
+ <button class="btn primary" id="ob-add">Continue</button>
2674
+ </div></div>`;
2675
+
2676
+ el("ob-add").addEventListener("click", () => {
2677
+ const picked = document.querySelector('input[name="ob-route"]:checked');
2678
+ wizardCredential(picked ? picked.value : "anthropic_api");
2679
+ });
2680
+ }
2681
+
2682
+ /**
2683
+ * Step 2: the credential itself, on the page.
2684
+ *
2685
+ * Mounts the same form the Accounts dialog uses — `accountFormBody` plus
2686
+ * `wireAccountForm` — so the fields, the derived ID, the paste detection and the
2687
+ * test-before-store behaviour are the ones that are already tested, not a second
2688
+ * implementation that will drift from them.
2689
+ */
2690
+ async function wizardCredential(type) {
2691
+ state.onboardStep = 2;
2692
+ renderWizSteps(2);
2693
+ // The heading stays type-independent because the Type select is still on the
2694
+ // form: describing the choice twice would leave one of them stale the moment
2695
+ // somebody changes their mind here rather than stepping back.
2696
+ el("ob-body").innerHTML = `<div class="stack">
2697
+ <div class="stack" style="gap:var(--s2)">
2698
+ <h2>Point it at the credential</h2>
2699
+ <p class="lede">Stored encrypted, and lent out per request — a client never
2700
+ receives it. Everything below except the credential itself can be changed
2701
+ later from the Accounts screen.</p>
2702
+ </div>
2703
+ <div class="card" id="ob-form"></div></div>`;
2704
+
2705
+ await prepAccountForm();
2706
+ if (state.onboardStep !== 2) return;
2707
+ el("ob-form").innerHTML = accountFormBody(null, false, false) + accountFormFooter(false);
2708
+ // Before wiring: paintType() runs inside wireAccountForm and reads this.
2709
+ el("m-type").value = type;
2710
+ wireAccountForm(null, false, false, renderOnboarding);
2711
+ nameFromProfile();
2712
+ }
2713
+
2714
+ /**
2715
+ * Name a subscription account after the profile directory it reads.
2716
+ *
2717
+ * `~/.claude-abhi/.credentials.json` is already the name the operator gave that
2718
+ * login when they made the alias; asking them to invent a second one, and
2719
+ * refusing to save until they do, is the form doing none of the work. Stops the
2720
+ * moment they type — a field that overwrites what you typed is worse than a
2721
+ * blank one.
2722
+ */
2723
+ function nameFromProfile() {
2724
+ const name = el("m-name");
2725
+ if (!name) return;
2726
+ let ours = true;
2727
+ name.addEventListener("input", () => { ours = false; });
2728
+ const fill = () => {
2729
+ const path = el("m-cred-path");
2730
+ if (!ours || !path || !path.value) return;
2731
+ const parts = path.value.split("/").filter(Boolean);
2732
+ const dir = parts[parts.length - 2] || "";
2733
+ const guess = dir.replace(/^\./, "") || "subscription";
2734
+ if (guess && name.value !== guess) {
2735
+ name.value = guess;
2736
+ name.dispatchEvent(new Event("input")); // the ID derives from the name
2737
+ ours = true;
2738
+ }
2739
+ };
2740
+ fill();
2741
+ // The picker is redrawn whenever the token source changes, so listen on the
2742
+ // form rather than on rows that will not survive the next repaint.
2743
+ el("ob-form").addEventListener("change", (e) => {
2744
+ if (e.target && e.target.name === "m-cred") setTimeout(fill, 0);
2745
+ });
2746
+ }
2747
+
2748
+ /**
2749
+ * Step 3: check the account against the real upstream and say what came back.
2750
+ *
2751
+ * This is `tokenbiryani doctor` without the command or the spend. It is here rather
2752
+ * than in a doc because the failure it catches is silent: if the upstream spells a
2753
+ * rate-limit header differently, every meter reads full, routing quietly degrades to
2754
+ * round-robin, and the prompt cache is shredded while the console looks healthy.
2755
+ * Nobody runs a check they have never heard of, so the wizard runs it for them.
2756
+ */
2757
+ async function verifyOnboarding(id, spend) {
2758
+ state.onboardStep = 3;
2759
+ renderWizSteps(3);
2760
+ el("ob-body").innerHTML = `<div class="stack">
2761
+ <div class="stack" style="gap:var(--s2)">
2762
+ <h2>Checking ${esc(id)}</h2>
2763
+ <p class="lede">Does the credential work, and does this upstream report the
2764
+ rate-limit headers the router needs?</p>
2765
+ </div>
2766
+ <div class="skel" style="height:64px"></div></div>`;
2767
+
2768
+ let report;
2769
+ try {
2770
+ report = await api("/admin/accounts/" + encodeURIComponent(id) + "/diagnose",
2771
+ { method: "POST", body: JSON.stringify({ spend: !!spend }) });
2772
+ } catch (error) {
2773
+ report = { ok: false, detail: error.message, limits: null, limits_source: "unchecked" };
2774
+ }
2775
+
2776
+ // Three answers, not two. "Nothing has gone through this account yet" is not a
2777
+ // fault, and reporting it as nine missing headers — which is what checking a
2778
+ // header-free /v1/models probe would do — would be worse than not checking.
2779
+ let verdict = "", offer = "";
2780
+ if (!report.ok) {
2781
+ verdict = `<div class="note err"><span>The upstream rejected this account:
2782
+ <b>${esc(report.detail || "no detail")}</b><br>
2783
+ Fix the credential on the Accounts screen, then press Test.</span></div>`;
2784
+ } else if (report.limits_source === "unobservable") {
2785
+ verdict = `<div class="note info"><span>Credential accepted. This upstream reports
2786
+ no <b>anthropic-ratelimit-*</b> headers by design, so its meters and the capacity
2787
+ horizon stay empty and it routes on reactive backoff — expected, not a fault.</span></div>`;
2788
+ } else if (report.limits_source === "not_observed") {
2789
+ verdict = `<div class="note ok"><span>Credential accepted.</span></div>
2790
+ <div class="note info"><span>The rate-limit headers have not been seen yet —
2791
+ they only arrive on a real completion, and nothing has gone through this account.
2792
+ Until they are checked, a differently spelled header would leave the meters empty
2793
+ and quietly drop routing to round-robin.<br>
2794
+ Send any request through the gateway and it checks itself, or run it now.</span></div>`;
2795
+ offer = `<button class="btn" id="ob-spend">Run the check now</button>`;
2796
+ } else if (report.limits && report.limits.ok) {
2797
+ verdict = `<div class="note ok"><span>Credential accepted, and every rate-limit
2798
+ header the router needs is present and parsed. Headroom routing, the capacity
2799
+ horizon and admission control all work at full strength.</span></div>`;
2800
+ } else if (report.limits) {
2801
+ verdict = `<div class="note err"><span>Credential accepted, but
2802
+ <b>${report.limits.missing.length}</b> rate-limit header${
2803
+ report.limits.missing.length === 1 ? " is" : "s are"} missing or spelled
2804
+ differently:<br>
2805
+ <span class="mono" style="font-size:10.5px">${esc(report.limits.missing.join(", "))}</span>
2806
+ <br>${esc(report.limits.consequence)}</span></div>`;
2807
+ } else {
2808
+ verdict = `<div class="note ok"><span>Credential accepted.</span></div>`;
2809
+ }
2810
+
2811
+ // The verdict prose stays exactly as it was — it is the part that has to be
2812
+ // read. The checklist above it is a summary of the same two facts, so the
2813
+ // answer is legible before the paragraph explaining it.
2814
+ const mark = (state, label) =>
2815
+ `<div class="check ${state}"><i>${state === "ok" ? "&#10003;"
2816
+ : state === "no" ? "&#10007;" : "&#8226;"}</i><span>${label}</span></div>`;
2817
+ const headerState = !report.ok ? "no"
2818
+ : report.limits_source === "unobservable" ? "na"
2819
+ : report.limits_source === "not_observed" ? "na"
2820
+ : report.limits && report.limits.ok ? "ok" : "no";
2821
+ const headerLabel = !report.ok ? "Rate-limit headers not checked"
2822
+ : report.limits_source === "unobservable" ? "Reports no rate-limit headers, by design"
2823
+ : report.limits_source === "not_observed" ? "Rate-limit headers not seen yet"
2824
+ : report.limits && report.limits.ok ? "Every rate-limit header the router reads is present"
2825
+ : "Some rate-limit headers are missing or spelled differently";
2826
+ const checks = mark(report.ok ? "ok" : "no",
2827
+ report.ok ? "Credential accepted by the upstream" : "The upstream rejected this credential")
2828
+ + mark(headerState, headerLabel);
2829
+
2830
+ el("ob-body").innerHTML = `<div class="stack">
2831
+ <div class="stack" style="gap:var(--s2)">
2832
+ <h2>${esc(id)} is in the pool</h2>
2833
+ <p class="lede">Two things were checked: that the upstream accepts this
2834
+ credential, and that it reports what the router reads to route on.</p>
2835
+ </div>
2836
+ <div class="checks">${checks}</div>
2837
+ ${verdict}
2838
+ <div class="row">
2839
+ <button class="btn primary" id="ob-next">Connect a client</button>
2840
+ ${offer}
2841
+ </div>
2842
+ ${offer ? `<p class="lede" style="font-size:11px">One request with
2843
+ <span class="mono">max_tokens=1</span> — a fraction of a cent. It is the only
2844
+ thing here that spends anything.</p>` : ""}</div>`;
2845
+ el("ob-next").addEventListener("click", () => finishOnboarding(id));
2846
+ if (el("ob-spend")) {
2847
+ el("ob-spend").addEventListener("click", () => verifyOnboarding(id, true));
2848
+ }
2849
+ }
2850
+
2851
+ /** Step 4: the two lines that actually use it — with a key that works. */
2852
+ async function finishOnboarding(id) {
2853
+ state.onboardStep = 4;
2854
+ renderWizSteps(4);
2855
+ el("ob-body").innerHTML = `<div class="stack">
2856
+ <div class="stack" style="gap:var(--s2)">
2857
+ <h2>Point a client at the gateway</h2>
2858
+ <p class="lede">Two variables, and every request routes through the pool.
2859
+ Without them a client talks to Anthropic directly and this gateway sees
2860
+ nothing — which is what an empty usage chart usually means.</p>
2861
+ </div>
2862
+ <div id="ob-connect"></div>
2863
+ <div class="row">
2864
+ <button class="btn primary" id="ob-go">Open the console</button>
2865
+ </div></div>`;
2866
+ el("ob-go").addEventListener("click", leaveWizard);
2867
+ await renderKeyHandover(el("ob-connect"), "ob");
2868
+ }
2869
+
2870
+ /** The one way out, whether the wizard was entered on an empty pool or opened
2871
+ * from the sidebar. Both flags have to clear or refresh() draws it again. */
2872
+ function leaveWizard() {
2873
+ state.onboardStep = 0;
2874
+ state.wizardOpen = false;
2875
+ refresh();
2876
+ }
2877
+
2878
+
2879
+ // ---- handing over a key that works -------------------------------------------
2880
+ /**
2881
+ * The export lines, with a real key in them.
2882
+ *
2883
+ * Both this and the Connect screen used to print `<your key>` — while the browser
2884
+ * was holding a working key in localStorage and the Keys screen showed every key
2885
+ * masked. The user was one copy button from being done and was instead sent back to
2886
+ * the file they were told they would not need.
2887
+ *
2888
+ * Minting a client key is offered first and recommended, because the key in this
2889
+ * browser is an admin key, and a client should not be carrying one.
2890
+ */
2891
+ async function renderKeyHandover(target, prefix) {
2892
+ if (!target) return;
2893
+ const origin = location.origin;
2894
+ const paint = (key, note) => {
2895
+ const lines = `export ANTHROPIC_BASE_URL=${origin}\nexport ANTHROPIC_AUTH_TOKEN=${key}`;
2896
+ target.innerHTML = `<div class="stack">
2897
+ <pre class="code">export ANTHROPIC_BASE_URL=<b>${esc(origin)}</b>
2898
+ export ANTHROPIC_AUTH_TOKEN=<b>${esc(key)}</b>
2899
+ claude</pre>
2900
+ ${note}
2901
+ <div class="copyrow">
2902
+ <button class="btn sm" id="${prefix}-copy">Copy the export lines</button>
2903
+ <button class="btn sm ghost" id="${prefix}-mint">Mint a client key</button>
2904
+ </div>
2905
+ <div id="${prefix}-msg"></div></div>`;
2906
+ el(prefix + "-copy").addEventListener("click", () => copy(lines, el(prefix + "-msg")));
2907
+ el(prefix + "-mint").addEventListener("click", () => mintClientKey(target, prefix));
2908
+ };
2909
+
2910
+ if (state.mintedKey) {
2911
+ paint(state.mintedKey, `<div class="note ok"><span>A client key, scoped to no admin
2912
+ access. Copy it now — it is stored hashed and cannot be shown again.</span></div>`);
2913
+ } else if (state.key) {
2914
+ paint(state.key, `<div class="note warn"><span>This is the <b>admin</b> key you
2915
+ signed in with. It works, but a client carrying it can also read your pool and
2916
+ mint more keys — <b>Mint a client key</b> issues one that cannot.</span></div>`);
2917
+ } else {
2918
+ // An open gateway: no keys configured, so no key to send.
2919
+ paint("<not needed>", `<div class="note info"><span>This gateway has no keys
2920
+ configured, so it accepts any request from loopback. Add a <span class="mono">keys:</span>
2921
+ entry before exposing it to anything.</span></div>`);
2922
+ }
2923
+ }
2924
+
2925
+ async function mintClientKey(target, prefix) {
2926
+ const button = el(prefix + "-mint");
2927
+ if (button) button.disabled = true;
2928
+ try {
2929
+ // A name that says where it came from, uniquified so a second press works.
2930
+ const name = "client-" + Math.random().toString(36).slice(2, 7);
2931
+ const created = await api("/admin/keys", {
2932
+ method: "POST",
2933
+ body: JSON.stringify({ name: name, admin: false, priority: "interactive" }),
2934
+ });
2935
+ state.mintedKey = created.key;
2936
+ // The Connect screen prints the key into its SDK and curl snippets too, so it
2937
+ // is redrawn whole rather than just the block that owns the button.
2938
+ if (prefix === "g") renderGuide(); else await renderKeyHandover(target, prefix);
2939
+ } catch (error) {
2940
+ el(prefix + "-msg").innerHTML = `<div class="note err">${esc(error.message)}</div>`;
2941
+ if (button) button.disabled = false;
2942
+ }
2943
+ }
2944
+
2945
+ // ---- data --------------------------------------------------------------------
2946
+ async function refresh() {
2947
+ try {
2948
+ const [snapshot, horizon, recent] = await Promise.all([
2949
+ api("/admin/status"), api("/admin/horizon"), api("/admin/requests?limit=100"),
2950
+ ]);
2951
+ goOnline();
2952
+ state.snapshot = snapshot;
2953
+ // Always seed from history. The live feed only carries *new* events, so a
2954
+ // console that relied on it alone showed an empty list until traffic arrived.
2955
+ state.requests = recent.requests;
2956
+
2957
+ // The wizard keeps the screen past step 1. Steps 2 and 3 verify and hand over
2958
+ // the key, and both happen once the first account exists — so keying the
2959
+ // wizard purely off an empty pool dropped the operator into the console the
2960
+ // instant they saved, and neither step ever ran.
2961
+ // One flag decides the screen. An empty pool always raises it — there is
2962
+ // nothing for the console to show — and the sidebar's Add an account raises it
2963
+ // on a working pool. Keying off `empty` alone was what made steps 3 and 4
2964
+ // unreachable: the pool stops being empty the moment step 2 saves, which is
2965
+ // exactly when the rest of the wizard is supposed to run.
2966
+ state.poolEmpty = snapshot.pool.total === 0;
2967
+ if (state.poolEmpty) state.wizardOpen = true;
2968
+ el("onboarding").classList.toggle("hidden", !state.wizardOpen);
2969
+ el("app").classList.toggle("hidden", state.wizardOpen);
2970
+ if (state.wizardOpen) {
2971
+ if (!state.onboardStep) renderOnboarding();
2972
+ return;
2973
+ }
2974
+ state.onboardStep = 0;
2975
+
2976
+ renderStats(snapshot);
2977
+ renderAccounts(snapshot);
2978
+ renderAccountsAdmin(snapshot);
2979
+ renderHorizon(horizon);
2980
+ renderSettings(snapshot);
2981
+ paintRequests();
2982
+ tick();
2983
+ } catch (error) {
2984
+ if (error.offline) { goOffline(); return; }
2985
+ if (error.message !== "unauthorized") banner(error.message);
2986
+ }
2987
+ }
2988
+
2989
+ async function openRequest(id) {
2990
+ show("requests");
2991
+ try { renderInspector(await api("/admin/requests/" + encodeURIComponent(id))); }
2992
+ catch (error) { banner(error.message); }
2993
+ }
2994
+
2995
+ // ---- live feed ---------------------------------------------------------------
2996
+ // EventSource cannot set headers, and an admin key does not belong in a URL, so the
2997
+ // SSE stream is read through fetch instead.
2998
+ async function connectStream() {
2999
+ const controller = new AbortController();
3000
+ state.stopStream = () => controller.abort();
3001
+ try {
3002
+ const response = await fetch("/admin/events", {
3003
+ headers: { "x-api-key": state.key }, signal: controller.signal
3004
+ });
3005
+ if (response.status === 401 || response.status === 403) {
3006
+ // Not transient. Retrying a key the gateway has refused just repeats the 401
3007
+ // every few seconds against a console that has already been signed out.
3008
+ signOut(response.status === 403
3009
+ ? "That key is not an admin key." : "That key was rejected.");
3010
+ return;
3011
+ }
3012
+ if (!response.ok || !response.body) throw new Error("no stream");
3013
+ state.streamDelay = 2500;
3014
+ setLive(true);
3015
+ const reader = response.body.getReader();
3016
+ const decoder = new TextDecoder();
3017
+ let buffer = "";
3018
+ for (;;) {
3019
+ const { done, value } = await reader.read();
3020
+ if (done) break;
3021
+ buffer += decoder.decode(value, { stream: true });
3022
+ const frames = buffer.split("\n\n");
3023
+ buffer = frames.pop();
3024
+ let changed = false;
3025
+ for (const frame of frames) {
3026
+ const line = frame.split("\n").find((l) => l.startsWith("data:"));
3027
+ if (!line) continue;
3028
+ try {
3029
+ state.requests.unshift(JSON.parse(line.slice(5).trim()));
3030
+ if (state.requests.length > 200) state.requests.pop();
3031
+ changed = true;
3032
+ } catch (e) { /* a malformed frame must not kill the feed */ }
3033
+ }
3034
+ if (changed) paintRequests();
3035
+ }
3036
+ } catch (error) {
3037
+ if (controller.signal.aborted) return;
3038
+ }
3039
+ setLive(false);
3040
+ // Same backoff as the poll: a gateway that is down stays down for a while, and
3041
+ // reconnecting every five seconds only fills the console.
3042
+ state.streamDelay = Math.min(30000, Math.round((state.streamDelay || 2500) * 2));
3043
+ if (state.streamTimer) clearTimeout(state.streamTimer);
3044
+ state.streamTimer = setTimeout(() => {
3045
+ state.streamTimer = null;
3046
+ if (state.key || state.open) connectStream();
3047
+ }, state.streamDelay);
3048
+ }
3049
+
3050
+ function setLive(on) {
3051
+ state.streaming = on;
3052
+ el("live").className = "status " + (on ? "on" : "off");
3053
+ // "polling" used to cover both a missing stream and a missing gateway, which
3054
+ // read as healthy while nothing at all was getting through.
3055
+ el("live-text").textContent = on ? "live" : (state.offline ? "offline" : "polling");
3056
+ }
3057
+
3058
+ // ---- shell -------------------------------------------------------------------
3059
+ /** Click plus Enter/Space, so the whole console is reachable from the keyboard. */
3060
+ function activate(nodes, handler) {
3061
+ nodes.forEach((node) => {
3062
+ node.addEventListener("click", () => handler(node));
3063
+ node.addEventListener("keydown", (event) => {
3064
+ if (event.key === "Enter" || event.key === " ") {
3065
+ event.preventDefault();
3066
+ handler(node);
3067
+ }
3068
+ });
3069
+ });
3070
+ }
3071
+
3072
+ function show(name) {
3073
+ state.tab = name;
3074
+ document.querySelectorAll(".nav button[data-tab]").forEach((b) =>
3075
+ b.classList.toggle("on", b.dataset.tab === name));
3076
+ document.querySelectorAll("[data-panel]").forEach((p) =>
3077
+ p.classList.toggle("hidden", p.dataset.panel !== name));
3078
+ const [title, sub] = TITLES[name] || [name, ""];
3079
+ el("view-title").textContent = title;
3080
+ el("view-sub").textContent = sub;
3081
+ if (name === "keys") loadKeys();
3082
+ if (name === "guide") renderGuide();
3083
+ if (name === "settings") loadEditableSettings();
3084
+ // Fetched on demand, not on the 5s poll: a 30-day window is a real query.
3085
+ if (name === "usage") loadUsage();
3086
+ // Same reasoning: pacing and sessions both read the spend ledger.
3087
+ if (name === "efficiency") loadEfficiency();
3088
+ }
3089
+
3090
+ /**
3091
+ * @param {string} key
3092
+ * @param {boolean} [validated] true when the caller already proved the key works.
3093
+ *
3094
+ * Unvalidated, this checks the key with a single request before starting anything.
3095
+ * It used to launch the poll and the event stream immediately, so a rejected key
3096
+ * produced five 401s — status, horizon, requests, events, and the one that finally
3097
+ * signed you out — before the screen said a word.
3098
+ */
3099
+ async function signIn(key, validated) {
3100
+ state.key = key;
3101
+ if (!validated) {
3102
+ const button = el("gate-go");
3103
+ if (button) { button.disabled = true; button.textContent = "Connecting…"; }
3104
+ try {
3105
+ await api("/admin/status"); // api() signs out on 401 and 403
3106
+ } catch (error) {
3107
+ if (error.offline) {
3108
+ el("gate-msg").innerHTML = `<div class="note err"><span>Cannot reach a gateway
3109
+ at <b class="mono">${esc(location.host)}</b>. Is it running?</span></div>`;
3110
+ }
3111
+ state.key = "";
3112
+ return;
3113
+ } finally {
3114
+ if (button) { button.disabled = false; button.textContent = "Open the console"; }
3115
+ }
3116
+ }
3117
+ state.open = true;
3118
+ try { localStorage.setItem(KEY_STORE, key); } catch (e) { /* private mode */ }
3119
+ el("gate").classList.add("hidden");
3120
+ el("app").classList.remove("hidden");
3121
+ el("addr").textContent = location.host;
3122
+ connectStream();
3123
+ if (state.timer) clearTimeout(state.timer);
3124
+ if (state.ticker) clearInterval(state.ticker);
3125
+ state.pollDelay = 5000;
3126
+ pollNow();
3127
+ state.ticker = setInterval(tick, 1000); // the page's only moving part
3128
+ }
3129
+
3130
+ /** Poll on a self-scheduling timer rather than a fixed interval, so a gateway that
3131
+ * has gone away is retried more slowly instead of hammered forever. */
3132
+ async function pollNow() {
3133
+ if (state.timer) clearTimeout(state.timer);
3134
+ if (!state.key && !state.open) return;
3135
+ await refresh();
3136
+ if (state.key || state.open) {
3137
+ state.timer = setTimeout(pollNow, state.pollDelay || 5000);
3138
+ }
3139
+ }
3140
+
3141
+ function signOut(message) {
3142
+ state.key = "";
3143
+ state.open = false;
3144
+ try { localStorage.removeItem(KEY_STORE); } catch (e) { /* ignore */ }
3145
+ if (state.stopStream) state.stopStream();
3146
+ if (state.streamTimer) { clearTimeout(state.streamTimer); state.streamTimer = null; }
3147
+ if (state.timer) clearTimeout(state.timer);
3148
+ if (state.ticker) clearInterval(state.ticker);
3149
+ closeModal();
3150
+ el("app").classList.add("hidden");
3151
+ el("onboarding").classList.add("hidden");
3152
+ el("gate").classList.remove("hidden");
3153
+ el("gate-msg").innerHTML = message
3154
+ ? `<div class="note err"><span>${esc(message)} It has to be a key this gateway
3155
+ knows — check <span class="mono">keys:</span> in its
3156
+ <span class="mono">tokenbiryani.yaml</span>, or
3157
+ <span class="mono">TOKENBIRYANI_KEY</span> if you started it with
3158
+ <span class="mono">docker compose</span>.</span></div>`
3159
+ : "";
3160
+ }
3161
+
3162
+ function setAttr(name, store, value) {
3163
+ document.documentElement.setAttribute(name, value);
3164
+ try { localStorage.setItem(store, value); } catch (e) { /* ignore */ }
3165
+ }
3166
+
3167
+ document.querySelectorAll(".nav button[data-tab]").forEach((b) =>
3168
+ b.addEventListener("click", () => show(b.dataset.tab)));
3169
+ el("gate-go").addEventListener("click", () => {
3170
+ const value = el("gate-key").value.trim();
3171
+ if (value) signIn(value);
3172
+ });
3173
+ el("gate-key").addEventListener("keydown", (e) => { if (e.key === "Enter") el("gate-go").click(); });
3174
+ el("signout").addEventListener("click", () => signOut(""));
3175
+ const flipTheme = () => setAttr("data-theme", THEME_STORE,
3176
+ document.documentElement.getAttribute("data-theme") === "dark" ? "light" : "dark");
3177
+ el("theme").addEventListener("click", flipTheme);
3178
+ // The wizard hides #app, so it needs its own copy of the control rather than a
3179
+ // second element carrying the same id.
3180
+ el("ob-theme").addEventListener("click", flipTheme);
3181
+ el("ob-skip").addEventListener("click", leaveWizard);
3182
+ // Not a tab — it leaves the console for the wizard, and comes back through
3183
+ // leaveWizard() like every other exit.
3184
+ el("nav-setup").addEventListener("click", () => {
3185
+ state.wizardOpen = true;
3186
+ state.onboardStep = 0;
3187
+ refresh();
3188
+ });
3189
+ el("k-create").addEventListener("click", createKey);
3190
+ el("reload").addEventListener("click", reloadConfig);
3191
+ el("acct-add").addEventListener("click", () => accountModal(null));
3192
+ el("acct-test-all").addEventListener("click", (e) => testAllAccounts(e.currentTarget));
3193
+
3194
+ /** One filter row above the charts; every chart re-renders against the same slice. */
3195
+ function segmented(id, attribute, apply) {
3196
+ el(id).querySelectorAll("button").forEach((button) =>
3197
+ button.addEventListener("click", () => {
3198
+ el(id).querySelectorAll("button").forEach((b) => b.classList.remove("on"));
3199
+ button.classList.add("on");
3200
+ apply(button.dataset[attribute]);
3201
+ loadUsage();
3202
+ }));
3203
+ }
3204
+ segmented("u-range", "window", (value) => { state.window = value; });
3205
+ segmented("u-group", "group", (value) => { state.groupBy = value; });
3206
+
3207
+ /**
3208
+ * `tokenbiryani console` opens us with ?t=<ticket>. Spend it for the admin key and
3209
+ * take it back out of the address bar before anything else runs — a ticket is
3210
+ * single-use, but a URL is forever, and the one in the history bar would be a dead
3211
+ * link that looks like a live one.
3212
+ */
3213
+ async function redeemTicket() {
3214
+ const ticket = new URLSearchParams(location.search).get("t");
3215
+ if (!ticket) return "";
3216
+ history.replaceState(null, "", location.pathname);
3217
+ try {
3218
+ const response = await fetch("/admin/console-session", {
3219
+ method: "POST",
3220
+ headers: { "content-type": "application/json" },
3221
+ body: JSON.stringify({ ticket: ticket }),
3222
+ });
3223
+ if (!response.ok) return "";
3224
+ return (await response.json()).key || "";
3225
+ } catch (e) {
3226
+ return "";
3227
+ }
3228
+ }
3229
+
3230
+ (async function boot() {
3231
+ let stored = "", theme = "dark";
3232
+ try {
3233
+ stored = localStorage.getItem(KEY_STORE) || "";
3234
+ theme = localStorage.getItem(THEME_STORE) || "dark";
3235
+ } catch (e) { /* private mode */ }
3236
+ document.documentElement.setAttribute("data-theme", theme);
3237
+ el("lp-addr").textContent = location.host;
3238
+ el("lp-base").textContent = location.origin;
3239
+ // A handed-over key outranks whatever this browser was holding: the operator just
3240
+ // said which gateway they mean by running the command against it.
3241
+ const handed = await redeemTicket();
3242
+ if (handed) stored = handed;
3243
+ // A gateway with no keys configured is open; try it before asking for one.
3244
+ fetch("/admin/status", { headers: stored ? { "x-api-key": stored } : {} })
3245
+ .then((r) => r.ok ? signIn(stored, true) : signOut(""))
3246
+ .catch(() => signOut(""));
3247
+ })();
3248
+ </script>
3249
+ </body>
3250
+ </html>