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,3 @@
1
+ """tokenbiryani — a pooling gateway for Claude accounts."""
2
+
3
+ __version__ = "0.2.0"
File without changes
@@ -0,0 +1,583 @@
1
+ """HTTP surface. Anthropic-compatible on /v1, gateway-specific on /admin."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ from typing import Any, Dict, Optional
8
+
9
+ from fastapi import FastAPI, Request
10
+ from fastapi.responses import (
11
+ HTMLResponse,
12
+ JSONResponse,
13
+ RedirectResponse,
14
+ Response,
15
+ StreamingResponse,
16
+ )
17
+
18
+ from ..config import Config, KeyConfig
19
+ from ..core.gateway import Gateway, GatewayError
20
+ from ..core.handoff import HandoffError, TicketBook, is_loopback
21
+ from ..dashboard import console_css, console_html
22
+ from ..providers.oauth_credentials import detect_credentials
23
+
24
+ ANTHROPIC_PREFIX = "/v1"
25
+
26
+
27
+ def _escape(value: str) -> str:
28
+ """The OAuth callback echoes provider-supplied text into HTML."""
29
+ return (
30
+ str(value)
31
+ .replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
32
+ .replace('"', "&quot;").replace("'", "&#39;")
33
+ )
34
+
35
+
36
+ def _credential(request: Request) -> Optional[str]:
37
+ presented = request.headers.get("x-api-key")
38
+ if presented:
39
+ return presented
40
+ authorization = request.headers.get("authorization") or ""
41
+ if authorization.lower().startswith("bearer "):
42
+ return authorization[7:].strip()
43
+ return None
44
+
45
+
46
+ def _error(status: int, message: str, kind: str = "api_error", headers=None) -> JSONResponse:
47
+ return JSONResponse(
48
+ status_code=status,
49
+ content={"type": "error", "error": {"type": kind, "message": message}},
50
+ headers=headers or {},
51
+ )
52
+
53
+
54
+ def create_app(config: Config, gateway: Optional[Gateway] = None) -> FastAPI:
55
+ app = FastAPI(title="tokenbiryani", version="0.1.0", docs_url=None, redoc_url=None)
56
+ app.state.gateway = gateway or Gateway(config)
57
+ app.state.config = config
58
+ app.state.tickets = TicketBook()
59
+
60
+ @app.on_event("startup")
61
+ async def _startup() -> None:
62
+ await app.state.gateway.startup()
63
+ app.state.watcher = asyncio.ensure_future(app.state.gateway.watch_config())
64
+ app.state.resync = asyncio.ensure_future(app.state.gateway.resync_spend())
65
+ app.state.sessions = asyncio.ensure_future(app.state.gateway.watch_oauth_sessions())
66
+
67
+ @app.on_event("shutdown")
68
+ async def _shutdown() -> None:
69
+ for name in ("watcher", "resync", "sessions"):
70
+ task = getattr(app.state, name, None)
71
+ if task is not None:
72
+ task.cancel()
73
+ await app.state.gateway.aclose()
74
+
75
+ def authenticate(request: Request) -> KeyConfig:
76
+ result = app.state.gateway.keys.authenticate(_credential(request))
77
+ if not result.ok or result.key is None:
78
+ raise GatewayError(401, result.error, kind="authentication_error")
79
+ return result.key
80
+
81
+ def authenticate_admin(request: Request) -> KeyConfig:
82
+ """/admin exposes account ids, spend and key management. Tenants stay out."""
83
+ key = authenticate(request)
84
+ if not key.admin:
85
+ raise GatewayError(
86
+ 403,
87
+ f"key {key.name!r} is not an admin key",
88
+ kind="permission_error",
89
+ )
90
+ return key
91
+
92
+ async def read_body(request: Request) -> Dict[str, Any]:
93
+ raw = await request.body()
94
+ if not raw:
95
+ return {}
96
+ try:
97
+ parsed = json.loads(raw)
98
+ except ValueError as exc:
99
+ raise GatewayError(
100
+ 400, f"request body is not valid JSON: {exc}", kind="invalid_request_error"
101
+ ) from exc
102
+ if not isinstance(parsed, dict):
103
+ raise GatewayError(400, "request body must be an object", kind="invalid_request_error")
104
+ return parsed
105
+
106
+ @app.exception_handler(GatewayError)
107
+ async def _gateway_error(request: Request, exc: GatewayError) -> JSONResponse:
108
+ return JSONResponse(status_code=exc.status, content=exc.payload(), headers=exc.headers())
109
+
110
+ # ---- Anthropic-compatible surface ---------------------------------------
111
+
112
+ @app.post(ANTHROPIC_PREFIX + "/messages")
113
+ async def messages(request: Request) -> Response:
114
+ key = authenticate(request)
115
+ body = await read_body(request)
116
+ gateway: Gateway = app.state.gateway
117
+
118
+ if body.get("stream"):
119
+ headers, iterator = await gateway.stream(body, request.headers, key)
120
+ return StreamingResponse(iterator, media_type="text/event-stream", headers=headers)
121
+
122
+ completion = await gateway.complete(body, request.headers, key)
123
+ return Response(
124
+ content=completion.content,
125
+ status_code=completion.status,
126
+ headers=completion.headers,
127
+ media_type="application/json",
128
+ )
129
+
130
+ @app.post(ANTHROPIC_PREFIX + "/messages/count_tokens")
131
+ async def count_tokens(request: Request) -> Response:
132
+ key = authenticate(request)
133
+ body = await read_body(request)
134
+ completion = await app.state.gateway.simple_request(
135
+ "POST", "/v1/messages/count_tokens", key, request.headers, body
136
+ )
137
+ return Response(
138
+ content=completion.content,
139
+ status_code=completion.status,
140
+ headers=completion.headers,
141
+ media_type="application/json",
142
+ )
143
+
144
+ @app.get(ANTHROPIC_PREFIX + "/models")
145
+ async def models(request: Request) -> Response:
146
+ key = authenticate(request)
147
+ completion = await app.state.gateway.simple_request(
148
+ "GET", "/v1/models", key, request.headers
149
+ )
150
+ return Response(
151
+ content=completion.content,
152
+ status_code=completion.status,
153
+ headers=completion.headers,
154
+ media_type="application/json",
155
+ )
156
+
157
+ # ---- operational surface -------------------------------------------------
158
+
159
+ @app.get("/", include_in_schema=False)
160
+ async def root() -> Response:
161
+ return RedirectResponse("/console")
162
+
163
+ @app.get("/console", include_in_schema=False)
164
+ async def console() -> Response:
165
+ # The shell carries no data, so it needs no key. Every call it makes is
166
+ # authenticated, and the key it uses never leaves the browser.
167
+ return HTMLResponse(console_html())
168
+
169
+ @app.get("/console.css", include_in_schema=False)
170
+ async def console_stylesheet() -> Response:
171
+ # no-cache, not no-store: the browser may keep it, but must revalidate.
172
+ # The shell is never cached, so a stylesheet the browser held across an
173
+ # upgrade would style the new markup with the old rules.
174
+ return Response(
175
+ console_css(),
176
+ media_type="text/css",
177
+ headers={"cache-control": "no-cache"},
178
+ )
179
+
180
+ @app.get("/healthz")
181
+ async def healthz() -> JSONResponse:
182
+ snapshot = app.state.gateway.snapshot()
183
+ healthy = snapshot["pool"]["ready"] > 0
184
+ return JSONResponse(
185
+ status_code=200 if healthy else 503,
186
+ content={
187
+ "status": "ok" if healthy else "no_capacity",
188
+ "ready": snapshot["pool"]["ready"],
189
+ "total": snapshot["pool"]["total"],
190
+ },
191
+ )
192
+
193
+ @app.get("/admin/status")
194
+ async def status(request: Request) -> JSONResponse:
195
+ authenticate_admin(request)
196
+ # Merge in anything managed that was added elsewhere — another replica, the
197
+ # API, a second console tab. This is the console's polling endpoint, so
198
+ # without it an account you just created stays invisible until a restart.
199
+ await app.state.gateway.refresh_accounts()
200
+ return JSONResponse(app.state.gateway.snapshot())
201
+
202
+ @app.get("/admin/horizon")
203
+ async def horizon(request: Request) -> JSONResponse:
204
+ authenticate_admin(request)
205
+ return JSONResponse(app.state.gateway.capacity_horizon())
206
+
207
+ @app.get("/admin/estimation")
208
+ async def estimation(request: Request) -> JSONResponse:
209
+ """What the output estimator has learned, per model.
210
+
211
+ Worth looking at before trusting it: `predicting: false` on a model means
212
+ every request for it is still leasing the caller's full `max_tokens`.
213
+ """
214
+ authenticate_admin(request)
215
+ return JSONResponse(app.state.gateway.estimator.snapshot())
216
+
217
+ @app.get("/admin/sessions")
218
+ async def sessions(request: Request) -> JSONResponse:
219
+ """The most expensive conversations in the spend window, worst first.
220
+
221
+ A key cap catches a tenant overspending. This is what catches one agent
222
+ loop doing it inside that allowance.
223
+ """
224
+ authenticate_admin(request)
225
+ return JSONResponse(await app.state.gateway.sessions_report())
226
+
227
+ @app.get("/admin/pacing")
228
+ async def pacing(request: Request) -> JSONResponse:
229
+ """Whether this pool is on course to spend its quota window, or to run dry
230
+ early, or to reach the end of the week with quota unused."""
231
+ authenticate_admin(request)
232
+ return JSONResponse(await app.state.gateway.pacing_report())
233
+
234
+ @app.get("/admin/cache-advice")
235
+ async def cache_advice(request: Request) -> JSONResponse:
236
+ """Why the cache hit rate is what it is, per virtual key and model.
237
+
238
+ The distinction the hit rate alone cannot make: a client that never marked a
239
+ breakpoint is not a routing problem and no strategy change will help it.
240
+ """
241
+ authenticate_admin(request)
242
+ return JSONResponse(app.state.gateway.cache_advisor.advice())
243
+
244
+ @app.get("/admin/usage")
245
+ async def usage(
246
+ request: Request,
247
+ window: Optional[str] = None,
248
+ bucket: Optional[str] = None,
249
+ group_by: str = "account",
250
+ account: Optional[str] = None,
251
+ ) -> JSONResponse:
252
+ """Persisted usage, bucketed for charting.
253
+
254
+ `window` accepts a label the console sends (`1h`, `24h`, `7d`, `30d`) or a
255
+ raw second count; `group_by` is account, model or key.
256
+ """
257
+ authenticate_admin(request)
258
+ return JSONResponse(
259
+ await app.state.gateway.usage(
260
+ window=window, bucket=bucket, group_by=group_by, account_id=account
261
+ )
262
+ )
263
+
264
+ @app.get("/admin/accounts/{account_id}")
265
+ async def account_detail(request: Request, account_id: str) -> JSONResponse:
266
+ authenticate_admin(request)
267
+ payload = app.state.gateway.account_detail(account_id)
268
+ if payload is None:
269
+ return _error(404, f"no such account: {account_id}", "not_found_error")
270
+ return JSONResponse(payload)
271
+
272
+ @app.get("/admin/accounts")
273
+ async def list_accounts(request: Request) -> JSONResponse:
274
+ authenticate_admin(request)
275
+ gateway: Gateway = app.state.gateway
276
+ await gateway.refresh_accounts()
277
+ return JSONResponse(gateway.snapshot())
278
+
279
+ @app.post("/admin/accounts")
280
+ async def create_account(request: Request) -> JSONResponse:
281
+ authenticate_admin(request)
282
+ payload = await read_body(request)
283
+ record = await app.state.gateway.create_account(
284
+ str(payload.get("id") or ""),
285
+ str(payload.get("name") or ""),
286
+ str(payload.get("api_key") or ""),
287
+ type=payload.get("type"),
288
+ base_url=payload.get("base_url"),
289
+ cost_tier=payload.get("cost_tier"),
290
+ priority=payload.get("priority"),
291
+ models=payload.get("models"),
292
+ spend_cap_usd=payload.get("spend_cap_usd"),
293
+ observable_limits=payload.get("observable_limits", True),
294
+ options=payload.get("options"),
295
+ )
296
+ return JSONResponse(record, status_code=201)
297
+
298
+ @app.patch("/admin/accounts/{account_id}")
299
+ async def update_account(request: Request, account_id: str) -> JSONResponse:
300
+ authenticate_admin(request)
301
+ payload = await read_body(request)
302
+ return JSONResponse(await app.state.gateway.update_account(account_id, **payload))
303
+
304
+ @app.delete("/admin/accounts/{account_id}")
305
+ async def delete_account(request: Request, account_id: str) -> JSONResponse:
306
+ authenticate_admin(request)
307
+ removed = await app.state.gateway.delete_account(account_id)
308
+ if not removed:
309
+ return _error(404, f"no managed account named {account_id!r}", "not_found_error")
310
+ return JSONResponse({"deleted": account_id})
311
+
312
+ @app.delete("/admin/accounts/{account_id}/override")
313
+ async def clear_account_override(request: Request, account_id: str) -> JSONResponse:
314
+ """Drop console edits to a config account, restoring what the file says."""
315
+ authenticate_admin(request)
316
+ gateway: Gateway = app.state.gateway
317
+ if not gateway.is_config_account(account_id):
318
+ return _error(
319
+ 404,
320
+ f"{account_id!r} is not declared in the config file, so it has no "
321
+ "override to clear",
322
+ "not_found_error",
323
+ )
324
+ cleared = await gateway.clear_override(account_id)
325
+ return JSONResponse({"cleared": cleared, "account": gateway.account_detail(account_id)})
326
+
327
+ @app.post("/admin/accounts/test")
328
+ async def test_credential(request: Request) -> JSONResponse:
329
+ """Probe a credential that has not been stored.
330
+
331
+ The console calls this before POSTing the account, so a typo'd key is a
332
+ message in a dialog rather than a disabled row to clean up afterwards.
333
+ """
334
+ authenticate_admin(request)
335
+ payload = await read_body(request)
336
+ result = await app.state.gateway.probe_credential(
337
+ str(payload.get("api_key") or ""),
338
+ type=payload.get("type"),
339
+ base_url=payload.get("base_url"),
340
+ options=payload.get("options"),
341
+ )
342
+ result.pop("headers", None)
343
+ result.pop("classification", None)
344
+ return JSONResponse(result)
345
+
346
+ @app.post("/admin/accounts/{account_id}/test")
347
+ async def test_account(request: Request, account_id: str) -> JSONResponse:
348
+ authenticate_admin(request)
349
+ return JSONResponse(await app.state.gateway.test_account(account_id))
350
+
351
+ @app.post("/admin/accounts/{account_id}/diagnose")
352
+ async def diagnose_account(request: Request, account_id: str) -> JSONResponse:
353
+ """What `tokenbiryani doctor` reports, for one account, without the spend.
354
+
355
+ The check it performs is the one open risk in the project: if the upstream
356
+ spells a rate-limit header differently, routing degrades to round-robin and
357
+ nothing looks wrong. Putting it behind the console's verify step is what
358
+ makes it run for people who never learn the command exists.
359
+ """
360
+ authenticate_admin(request)
361
+ payload = await read_body(request)
362
+ # `spend: true` is the caller accepting one `max_tokens=1` completion, which
363
+ # is the only way to see the limit headers on an account that has served no
364
+ # traffic yet. Never the default: nothing here spends money unasked.
365
+ return JSONResponse(await app.state.gateway.diagnose_account(
366
+ account_id, spend=bool(payload.get("spend"))
367
+ ))
368
+
369
+ # ---- subscription login --------------------------------------------------
370
+
371
+ @app.get("/admin/oauth/config")
372
+ async def oauth_config(request: Request) -> JSONResponse:
373
+ """Whether "Log in with Claude" is usable, so the console can say why not."""
374
+ authenticate_admin(request)
375
+ gateway: Gateway = app.state.gateway
376
+ settings = config.oauth
377
+ return JSONResponse({
378
+ "configured": gateway.oauth.configured,
379
+ "manual": not settings.redirect_uri,
380
+ "redirect_uri": settings.redirect_uri,
381
+ "missing": [
382
+ name for name, value in (
383
+ ("oauth.client_id", settings.client_id),
384
+ ("oauth.authorize_url", settings.authorize_url),
385
+ ("oauth.token_url", settings.token_url),
386
+ ) if not value
387
+ ],
388
+ })
389
+
390
+ @app.get("/admin/oauth/detect")
391
+ async def oauth_detect(request: Request) -> JSONResponse:
392
+ """Claude Code logins on this machine, so the console can offer one.
393
+
394
+ Never returns a token — only which file, which subscription, and whether it
395
+ is current. Picking the wrong file is the failure this exists to prevent:
396
+ with CLAUDE_CONFIG_DIR set, ~/.claude holds a different account entirely and
397
+ the resulting 401 says nothing about which of the two was read.
398
+ """
399
+ authenticate_admin(request)
400
+ return JSONResponse({"candidates": detect_credentials()})
401
+
402
+ @app.post("/admin/oauth/start")
403
+ async def oauth_start(request: Request) -> JSONResponse:
404
+ authenticate_admin(request)
405
+ payload = await read_body(request)
406
+ return JSONResponse(await app.state.gateway.oauth_start(
407
+ str(payload.get("id") or ""), str(payload.get("name") or "")
408
+ ))
409
+
410
+ @app.post("/admin/oauth/complete")
411
+ async def oauth_complete(request: Request) -> JSONResponse:
412
+ authenticate_admin(request)
413
+ payload = await read_body(request)
414
+ record = await app.state.gateway.oauth_complete(
415
+ str(payload.get("state") or ""), str(payload.get("code") or "")
416
+ )
417
+ return JSONResponse(record, status_code=201)
418
+
419
+ @app.get("/admin/oauth/callback", include_in_schema=False)
420
+ async def oauth_callback(code: str = "", state: str = "", error: str = "") -> Response:
421
+ """Where the provider lands when a redirect_uri is configured.
422
+
423
+ Deliberately keyless and deliberately does not complete the exchange: the
424
+ provider redirects a browser here, and that browser carries no admin key.
425
+ It hands the code back to the console, which completes the login with one.
426
+ """
427
+ return HTMLResponse(
428
+ "<!doctype html><meta charset=utf-8>"
429
+ "<title>tokenbiryani — authorized</title>"
430
+ "<body style='font:14px system-ui;margin:64px auto;max-width:420px'>"
431
+ + (
432
+ f"<h3>Authorization failed</h3><p>{_escape(error)}</p>"
433
+ if error else
434
+ "<h3>Authorized</h3><p>Copy this code into the console to finish "
435
+ f"adding the account.</p><p><code style='word-break:break-all'>"
436
+ f"{_escape(code)}</code></p>"
437
+ f"<p style='color:#666'>state {_escape(state)}</p>"
438
+ )
439
+ + "</body>"
440
+ )
441
+
442
+ # ---- console sign-in handoff ---------------------------------------------
443
+
444
+ @app.post("/admin/console-ticket")
445
+ async def console_ticket(request: Request) -> JSONResponse:
446
+ """Mint a single-use ticket for the key that just authenticated.
447
+
448
+ `tokenbiryani console` calls this with the admin key it read from the config,
449
+ then opens the browser on the ticket. The key never travels in the URL; the
450
+ ticket does, and it is spent the moment the page loads.
451
+ """
452
+ key = authenticate_admin(request)
453
+ if not is_loopback(config.server.host):
454
+ return _error(
455
+ 403,
456
+ f"this gateway is bound to {config.server.host}, not loopback. A sign-in "
457
+ "link is a bearer token in a URL, so it is minted only for a gateway "
458
+ "nothing off-box can reach — sign in with the key instead.",
459
+ "permission_error",
460
+ )
461
+ presented = _credential(request) or key.key
462
+ try:
463
+ ticket, ttl = app.state.tickets.mint(presented)
464
+ except HandoffError as exc:
465
+ return _error(409, str(exc), "invalid_request_error")
466
+ return JSONResponse({"ticket": ticket, "expires_in": ttl})
467
+
468
+ @app.post("/admin/console-session")
469
+ async def console_session(request: Request) -> JSONResponse:
470
+ """Redeem a ticket for the key it stands for.
471
+
472
+ Deliberately keyless: the browser arriving here has nothing else to present,
473
+ and the ticket is the credential. One redemption, then it is gone.
474
+ """
475
+ payload = await read_body(request)
476
+ try:
477
+ key = app.state.tickets.redeem(str(payload.get("ticket") or ""))
478
+ except HandoffError as exc:
479
+ return _error(401, str(exc), "authentication_error")
480
+ return JSONResponse({"key": key})
481
+
482
+ @app.post("/admin/reload")
483
+ async def reload(request: Request) -> JSONResponse:
484
+ authenticate_admin(request)
485
+ return JSONResponse(app.state.gateway.reload_from_path())
486
+
487
+ @app.get("/admin/keys")
488
+ async def keys(request: Request) -> JSONResponse:
489
+ authenticate_admin(request)
490
+ return JSONResponse({"keys": app.state.gateway.keys.redacted()})
491
+
492
+ @app.post("/admin/keys")
493
+ async def create_key(request: Request) -> JSONResponse:
494
+ authenticate_admin(request)
495
+ payload = await read_body(request)
496
+ name = str(payload.get("name") or "")
497
+ plaintext, record = await app.state.gateway.create_key(
498
+ name,
499
+ models=payload.get("models"),
500
+ pool=payload.get("pool"),
501
+ rpm=payload.get("rpm"),
502
+ spend_cap_usd=payload.get("spend_cap_usd"),
503
+ priority=payload.get("priority"),
504
+ max_wait_seconds=payload.get("max_wait_seconds"),
505
+ session_cap_usd=payload.get("session_cap_usd"),
506
+ session_max_turns=payload.get("session_max_turns"),
507
+ admin=payload.get("admin"),
508
+ )
509
+ # The only time the plaintext exists outside the caller's hands.
510
+ return JSONResponse({"key": plaintext, "record": record}, status_code=201)
511
+
512
+ @app.patch("/admin/keys/{name}")
513
+ async def update_key(request: Request, name: str) -> JSONResponse:
514
+ """Edit a managed key's scope without reissuing it.
515
+
516
+ Reissuing breaks every client already holding the key, which is why a key
517
+ scoped at a since-deleted account usually just sits there blocking reload.
518
+ """
519
+ authenticate_admin(request)
520
+ payload = await read_body(request)
521
+ return JSONResponse(await app.state.gateway.update_key(name, **payload))
522
+
523
+ @app.delete("/admin/keys/{name}")
524
+ async def revoke_key(request: Request, name: str) -> JSONResponse:
525
+ authenticate_admin(request)
526
+ removed = await app.state.gateway.revoke_key(name)
527
+ if not removed:
528
+ return _error(404, f"no managed key named {name!r}", "not_found_error")
529
+ return JSONResponse({"revoked": name})
530
+
531
+ @app.get("/admin/settings")
532
+ async def settings(request: Request) -> JSONResponse:
533
+ authenticate_admin(request)
534
+ return JSONResponse(app.state.gateway.settings_view())
535
+
536
+ @app.post("/admin/settings")
537
+ async def update_settings(request: Request) -> JSONResponse:
538
+ """Change a gateway-wide setting from the console.
539
+
540
+ Stored in the gateway, not written back to tokenbiryani.yaml: the file is
541
+ the operator's, and a process that rewrites its operator's config file is
542
+ one that eventually loses a comment somebody needed. The setting is applied
543
+ again after every reload, so an unrelated edit to the file cannot revert it.
544
+ """
545
+ authenticate_admin(request)
546
+ payload = await read_body(request)
547
+ return JSONResponse(await app.state.gateway.update_settings(payload))
548
+
549
+ @app.get("/admin/requests")
550
+ async def requests(request: Request, limit: int = 50) -> JSONResponse:
551
+ authenticate_admin(request)
552
+ return JSONResponse({"requests": app.state.gateway.events.recent(limit)})
553
+
554
+ @app.get("/admin/requests/{request_id}")
555
+ async def request_detail(request: Request, request_id: str) -> JSONResponse:
556
+ authenticate_admin(request)
557
+ found = app.state.gateway.events.get(request_id)
558
+ if found is None:
559
+ return _error(404, f"no such request: {request_id}", "not_found_error")
560
+ return JSONResponse(found)
561
+
562
+ @app.get("/admin/events")
563
+ async def events(request: Request) -> StreamingResponse:
564
+ authenticate_admin(request)
565
+ log = app.state.gateway.events
566
+
567
+ async def feed():
568
+ queue = log.subscribe()
569
+ try:
570
+ yield b": connected\n\n"
571
+ while True:
572
+ try:
573
+ payload = await asyncio.wait_for(queue.get(), timeout=15.0)
574
+ except asyncio.TimeoutError:
575
+ yield b": keepalive\n\n"
576
+ continue
577
+ yield ("data: " + json.dumps(payload) + "\n\n").encode("utf-8")
578
+ finally:
579
+ log.unsubscribe(queue)
580
+
581
+ return StreamingResponse(feed(), media_type="text/event-stream")
582
+
583
+ return app
@@ -0,0 +1,32 @@
1
+ """A stable ASGI entry point: ``tokenbiryani.api.asgi:create``.
2
+
3
+ `tokenbiryani serve` builds the app from a `Config` object it already holds, which
4
+ is fine until you want reloading — uvicorn's reloader re-imports the app in a fresh
5
+ worker process, so it needs an import string rather than an object.
6
+
7
+ This is that import string, and it is useful beyond reloading: it is also how you
8
+ put the gateway behind gunicorn, or any other ASGI runner, without going through
9
+ the CLI at all.
10
+
11
+ TOKENBIRYANI_CONFIG=/etc/tokenbiryani.yaml \\
12
+ uvicorn --factory tokenbiryani.api.asgi:create
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import os
18
+
19
+ from fastapi import FastAPI
20
+
21
+ from ..config import Config
22
+
23
+ #: Which config file to read. The CLI sets this before handing over to the reloader.
24
+ CONFIG_ENV = "TOKENBIRYANI_CONFIG"
25
+ DEFAULT_CONFIG = "tokenbiryani.yaml"
26
+
27
+
28
+ def create() -> FastAPI:
29
+ """Build the app from the config named by $TOKENBIRYANI_CONFIG."""
30
+ from .app import create_app
31
+
32
+ return create_app(Config.load(os.environ.get(CONFIG_ENV) or DEFAULT_CONFIG))