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
tokenbiryani/cli.py ADDED
@@ -0,0 +1,1045 @@
1
+ """Command line: init, serve, console, status, strategies, keygen, accounts, doctor.
2
+
3
+ `status` ships at M1, long before the web console — the audience already lives in a
4
+ terminal, and building it first forces the event model into shape.
5
+
6
+ `doctor` is the odd one out: it is the only command that deliberately spends money,
7
+ because it is the only way to check the one assumption no mock can check for us —
8
+ that the real API spells its rate-limit headers the way the limit mirror expects.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import json
15
+ import os
16
+ import sys
17
+ from typing import Any, Dict, List, Optional
18
+
19
+ DEFAULT_CONFIG = "tokenbiryani.yaml"
20
+ DEFAULT_URL = "http://127.0.0.1:8787"
21
+
22
+ #: How long to wait for open connections before closing them anyway.
23
+ #:
24
+ #: Without a bound, uvicorn waits forever, and this gateway always has a connection
25
+ #: that never ends: /admin/events is an SSE stream held open by every console tab.
26
+ #: A reload or a restart would then hang at "Waiting for connections to close" while
27
+ #: the port stayed open and answered nothing — which reads as a wedged gateway, and
28
+ #: fails a container healthcheck.
29
+ GRACEFUL_SHUTDOWN_SECONDS = 5
30
+
31
+ # Approximations of the console palette: ready / cooling / disabled / cache / brand.
32
+ C = {
33
+ "ready": "\033[38;5;42m",
34
+ "cooling": "\033[38;5;75m",
35
+ "disabled": "\033[38;5;203m",
36
+ "cache": "\033[38;5;141m",
37
+ "brand": "\033[38;5;179m",
38
+ "dim": "\033[38;5;244m",
39
+ "bold": "\033[1m",
40
+ "off": "\033[0m",
41
+ }
42
+
43
+
44
+ def _colors_enabled(force: Optional[bool] = None) -> bool:
45
+ if force is not None:
46
+ return force
47
+ if os.environ.get("NO_COLOR"):
48
+ return False
49
+ return sys.stdout.isatty()
50
+
51
+
52
+ def paint(text: str, color: str, enabled: bool) -> str:
53
+ if not enabled or color not in C:
54
+ return text
55
+ return C[color] + text + C["off"]
56
+
57
+
58
+ def _fmt_tokens(value: Optional[int]) -> str:
59
+ if value is None:
60
+ return "—"
61
+ if value >= 1_000_000:
62
+ return f"{value / 1_000_000:.2f}M"
63
+ if value >= 1_000:
64
+ return f"{value / 1_000:.0f}k"
65
+ return str(value)
66
+
67
+
68
+ def _fmt_clock(seconds: Optional[float]) -> str:
69
+ if seconds is None:
70
+ return "—"
71
+ seconds = int(max(0, seconds))
72
+ return f"{seconds // 60:02d}:{seconds % 60:02d}"
73
+
74
+
75
+ def _pct(fraction: Optional[float]) -> str:
76
+ if fraction is None:
77
+ return "—"
78
+ return f"{fraction * 100:.0f}%"
79
+
80
+
81
+ def render_pace(pacing: Dict[str, Any], color: bool = True) -> List[str]:
82
+ """One line per paced scope, or nothing at all.
83
+
84
+ Nothing at all is the common case and the right one: a pool of API keys with no
85
+ stated weekly budget has no pace, and a line reading "—" would imply the gateway
86
+ knows something about a week that it does not.
87
+ """
88
+ readings = (pacing or {}).get("readings") or []
89
+ if not readings:
90
+ return []
91
+ lines = ["", paint(
92
+ " {:<10}{:>9}{:>9}{:>11} {}".format(
93
+ "PACE", "ELAPSED", "USED", "PROJECTED", "VERDICT"
94
+ ), "dim", color)]
95
+ for reading in readings:
96
+ pace = reading.get("pace") or 0.0
97
+ tone = "disabled" if pace > 0.1 else "cache" if pace < -0.1 else "ready"
98
+ lines.append(
99
+ " {:<10}{:>9}{:>9}{:>11} {}".format(
100
+ reading.get("scope", "")[:10],
101
+ _pct(reading.get("elapsed_fraction")),
102
+ _pct(reading.get("utilization")),
103
+ _pct(reading.get("projected_utilization")),
104
+ paint(str(reading.get("verdict") or ""), tone, color),
105
+ )
106
+ )
107
+ return lines
108
+
109
+
110
+ def render_status(
111
+ snapshot: Dict[str, Any],
112
+ color: bool = True,
113
+ pacing: Optional[Dict[str, Any]] = None,
114
+ ) -> str:
115
+ pool = snapshot["pool"]
116
+ stats = snapshot["stats"]
117
+ lines: List[str] = []
118
+
119
+ ready_tokens = _fmt_tokens(pool.get("ready_input_tokens"))
120
+ cache_rate = stats.get("cache_hit_rate")
121
+ lines.append(
122
+ " {} {} tok ready {} next reset {} {} queue {} {} cache {}".format(
123
+ paint("POOL", "dim", color),
124
+ paint(ready_tokens, "bold", color),
125
+ paint("·", "dim", color),
126
+ paint(_fmt_clock(pool.get("next_reset_seconds")), "bold", color),
127
+ paint("·", "dim", color),
128
+ paint(str(snapshot["queue"]["depth"]), "bold", color),
129
+ paint("·", "dim", color),
130
+ paint(_pct(cache_rate), "cache", color),
131
+ )
132
+ )
133
+ lines.append("")
134
+ header = " {:<10}{:<16}{:>6}{:>8}{:>9}{:>8}{:>8}".format(
135
+ "ACCOUNT", "STATE", "REQ", "INPUT", "OUTPUT", "RESET", "CACHE"
136
+ )
137
+ lines.append(paint(header, "dim", color))
138
+
139
+ for account in snapshot["accounts"]:
140
+ state = account["state"]
141
+ limits = account["limits"]
142
+ bullet = paint("●", state if state in C else "dim", color)
143
+ state_text = state
144
+ if state == "cooling" and account.get("cooling_for") is not None:
145
+ state_text = "cooling"
146
+ note = ""
147
+ if account.get("disabled_reason"):
148
+ note = paint(" ← " + account["disabled_reason"], "dim", color)
149
+ lines.append(
150
+ " {:<10}{} {:<14}{:>6}{:>8}{:>9}{:>8}{:>8}{}".format(
151
+ account["id"],
152
+ bullet,
153
+ state_text,
154
+ _pct(limits["requests"]["fraction"]),
155
+ _pct(limits["input_tokens"]["fraction"]),
156
+ _pct(limits["output_tokens"]["fraction"]),
157
+ _fmt_clock(limits["input_tokens"]["reset_in"]),
158
+ paint(_pct(account.get("cache_hit_rate")), "cache", color),
159
+ note,
160
+ )
161
+ )
162
+
163
+ lines.append("")
164
+ lines.append(
165
+ " {} {} requests {} {} failovers {} {} cache breaks {} {} errors {} {}".format(
166
+ paint("1h", "dim", color),
167
+ paint(str(stats["requests"]), "bold", color),
168
+ paint("·", "dim", color),
169
+ paint(str(stats["failovers"]), "ready", color),
170
+ paint("·", "dim", color),
171
+ paint(str(stats["cache_breaks"]), "cache", color),
172
+ paint("·", "dim", color),
173
+ paint(str(stats["errors"]), "disabled", color),
174
+ paint("·", "dim", color),
175
+ paint("${:.2f}".format(stats["spend_usd"]), "bold", color),
176
+ )
177
+ )
178
+ lines.extend(render_pace(pacing or {}, color))
179
+ return "\n".join(lines)
180
+
181
+
182
+ TEMPLATE = """\
183
+ # tokenbiryani — pooling gateway for Claude accounts
184
+ #
185
+ # Point your tools at this gateway instead of api.anthropic.com:
186
+ # export ANTHROPIC_BASE_URL=http://{host}:{port}
187
+ # export ANTHROPIC_AUTH_TOKEN={key}
188
+
189
+ server:
190
+ host: {host}
191
+ port: {port}
192
+
193
+ routing:
194
+ # sticky_headroom keeps a conversation on the account holding its prompt cache.
195
+ # Alternatives: headroom, cost_tiered, priority, least_loaded, round_robin
196
+ strategy: sticky_headroom
197
+
198
+ store:
199
+ # Persist affinity and the spend ledger so they survive a restart.
200
+ backend: sqlite
201
+ path: tokenbiryani.db
202
+
203
+ {accounts}
204
+ keys:
205
+ - key: {key}
206
+ name: default
207
+ # Required to reach /admin/* — the pool snapshot, the request inspector, and
208
+ # key management. Issue tenant keys without it.
209
+ admin: true
210
+
211
+ # Costs are only reported, and spend caps only enforced, for models priced here.
212
+ # `builtin` uses the dated table that ships with this release — see the date on the
213
+ # Settings screen, and override any line by naming the model below it.
214
+ pricing: builtin
215
+ """
216
+
217
+ #: What `init` writes when it has no credential to put in the file.
218
+ #:
219
+ #: Deliberately empty rather than a `${{ANTHROPIC_API_KEY}}` placeholder. Interpolation
220
+ #: raises on an unset variable — correctly, for a real deployment — so a template that
221
+ #: referenced one made `init && serve` fail on any machine that had not already
222
+ #: exported it. The console is where the first account belongs anyway.
223
+ EMPTY_ACCOUNTS = """\
224
+ accounts: []
225
+ # Add accounts from the console at http://{host}:{port}/console — stored encrypted,
226
+ # and renameable, testable and rotatable in place without touching this file.
227
+ #
228
+ # Or declare them here, and `tokenbiryani init --api-key sk-ant-…` will do it for you:
229
+ #
230
+ # - id: acct-01
231
+ # type: anthropic_api
232
+ # api_key: ${{ANTHROPIC_API_KEY}}
233
+ # cost_tier: 1.0
234
+ """
235
+
236
+ #: And what it writes when `--api-key` gave it one.
237
+ SEEDED_ACCOUNTS = """\
238
+ accounts:
239
+ - id: acct-01
240
+ type: anthropic_api
241
+ api_key: {api_key}
242
+ cost_tier: 1.0
243
+ # Add more from the console at http://{host}:{port}/console — no editing this file.
244
+ """
245
+
246
+
247
+ def cmd_init(args: argparse.Namespace) -> int:
248
+ from .core.keys import generate_key
249
+
250
+ path = args.config
251
+ if os.path.exists(path) and not args.force:
252
+ print(f"{path} already exists (use --force to overwrite)", file=sys.stderr)
253
+ return 1
254
+ key = generate_key()
255
+ api_key = (args.api_key or "").strip()
256
+ accounts = (
257
+ SEEDED_ACCOUNTS.format(api_key=api_key, host=args.host, port=args.port)
258
+ if api_key
259
+ else EMPTY_ACCOUNTS.format(host=args.host, port=args.port)
260
+ )
261
+ with open(path, "w", encoding="utf-8") as handle:
262
+ handle.write(
263
+ TEMPLATE.format(host=args.host, port=args.port, key=key, accounts=accounts)
264
+ )
265
+ color = _colors_enabled()
266
+ print("wrote {}".format(paint(path, "brand", color)))
267
+ print()
268
+ print(" tokenbiryani serve")
269
+ print(" tokenbiryani console" + paint(
270
+ " opens the console signed in, and walks you through the first account",
271
+ "dim", color))
272
+ print()
273
+ print(paint(" then point any Anthropic client at it:", "dim", color))
274
+ print(f" export ANTHROPIC_BASE_URL=http://{args.host}:{args.port}")
275
+ print(f" export ANTHROPIC_AUTH_TOKEN={key}")
276
+ return 0
277
+
278
+
279
+ def cmd_strategies(args: argparse.Namespace) -> int:
280
+ from .core.router import CUSTOM_STRATEGIES, available_strategies
281
+
282
+ color = _colors_enabled()
283
+ builtin = {
284
+ "sticky_headroom": "affinity, then most headroom (default)",
285
+ "headroom": "pure most-available; for stateless batch traffic",
286
+ "cost_tiered": "drain cheap accounts first, spill upward",
287
+ "priority": "strict ordered failover: primary, then backup",
288
+ "least_loaded": "baseline",
289
+ "round_robin": "baseline; ignores every signal on purpose",
290
+ }
291
+ for name in available_strategies():
292
+ if name in builtin:
293
+ note = builtin[name]
294
+ origin = ""
295
+ else:
296
+ note = "custom scorer" if name in CUSTOM_STRATEGIES else "custom weights"
297
+ origin = paint(" (plugin)", "brand", color)
298
+ print(" {:<18}{}{}".format(name, paint(note, "dim", color), origin))
299
+ return 0
300
+
301
+
302
+ def cmd_keygen(args: argparse.Namespace) -> int:
303
+ """Two different keys live behind one command, because operators confuse them.
304
+
305
+ A virtual key is what clients authenticate with. A secret key encrypts account
306
+ credentials before they reach the store, and it has to outlive the process that
307
+ made it — lose it and every stored account is unrecoverable.
308
+ """
309
+ if getattr(args, "secret", False):
310
+ from .core.secrets import SecretError
311
+ from .core.secrets import generate_key as generate_secret
312
+
313
+ try:
314
+ print(generate_secret().decode("ascii"))
315
+ except SecretError as exc:
316
+ print(str(exc), file=sys.stderr)
317
+ return 1
318
+ return 0
319
+
320
+ from .core.keys import generate_key
321
+
322
+ print(generate_key())
323
+ return 0
324
+
325
+
326
+ def cmd_serve(args: argparse.Namespace) -> int:
327
+ import uvicorn
328
+
329
+ from .api.app import create_app
330
+ from .api.asgi import CONFIG_ENV
331
+ from .config import Config, ConfigError
332
+
333
+ try:
334
+ config = Config.load(args.config)
335
+ except FileNotFoundError:
336
+ print(
337
+ f"no {args.config} here. Run `tokenbiryani init` first.", file=sys.stderr
338
+ )
339
+ return 1
340
+ except ConfigError as exc:
341
+ print(f"config error: {exc}", file=sys.stderr)
342
+ return 1
343
+
344
+ host = args.host or config.server.host
345
+ port = args.port or config.server.port
346
+
347
+ # A gateway holding real credentials must not be reachable off-box by accident.
348
+ if host not in ("127.0.0.1", "localhost", "::1"):
349
+ if not config.server.allow_remote:
350
+ print(
351
+ f"refusing to bind {host}: set server.allow_remote: true in {args.config} to serve "
352
+ "off-loopback.",
353
+ file=sys.stderr,
354
+ )
355
+ return 1
356
+ if not config.keys:
357
+ print(
358
+ f"refusing to bind {host} with no keys configured — that would expose your "
359
+ "credentials to the network. Add a `keys:` entry.",
360
+ file=sys.stderr,
361
+ )
362
+ return 1
363
+
364
+ # An empty pool used to be fatal, then a warning on stderr. It is neither: it is
365
+ # the expected first run, and the wizard exists to walk through exactly this.
366
+ # Printing it as a fault made the normal path look broken.
367
+ empty_pool = not config.accounts
368
+
369
+ color = _colors_enabled()
370
+ print(
371
+ "{} {} accounts · strategy {} · http://{}:{}".format(
372
+ paint("tokenbiryani", "brand", color),
373
+ len(config.accounts),
374
+ config.routing.strategy,
375
+ host,
376
+ port,
377
+ )
378
+ )
379
+ print(" console http://{}:{}/console{}".format(
380
+ host, port,
381
+ paint(" · `tokenbiryani console` opens it signed in", "dim", color)))
382
+ print(f" api ANTHROPIC_BASE_URL=http://{host}:{port}")
383
+
384
+ # Which keys this gateway will accept, masked. Enough to tell that the key in
385
+ # your browser belongs to a different gateway — the failure mode when two dev
386
+ # stacks with different keys are a `docker compose up` apart — and never enough
387
+ # to use one, so it is safe in a log.
388
+ if config.keys:
389
+ from .core.keys import mask_key
390
+
391
+ admins = [k for k in config.keys if k.admin]
392
+ print(" keys " + " · ".join(
393
+ "{}{} {}".format(
394
+ key.name,
395
+ paint(" (admin)", "brand", color) if key.admin else "",
396
+ paint(mask_key(key.key), "dim", color),
397
+ )
398
+ for key in config.keys
399
+ ))
400
+ if not admins:
401
+ print(paint(" none of them is an admin key, so /console "
402
+ "cannot be used", "disabled", color))
403
+
404
+ if empty_pool:
405
+ print()
406
+ print(" {} no accounts yet. Add your first one:".format(paint("next", "brand", color)))
407
+ print(" tokenbiryani console" + paint(
408
+ " ← opens the console signed in", "dim", color))
409
+
410
+ if args.reload:
411
+ # The reloader re-imports the app in a fresh process, so it needs an import
412
+ # string rather than the object we already built. The config path travels in
413
+ # the environment because that new process does not inherit our argv.
414
+ os.environ[CONFIG_ENV] = args.config
415
+ print(paint(" reload watching src/ for changes", "dim", color))
416
+ uvicorn.run(
417
+ "tokenbiryani.api.asgi:create",
418
+ factory=True,
419
+ host=host,
420
+ port=port,
421
+ log_level=args.log_level,
422
+ reload=True,
423
+ reload_dirs=args.reload_dir or None,
424
+ timeout_graceful_shutdown=GRACEFUL_SHUTDOWN_SECONDS,
425
+ )
426
+ return 0
427
+
428
+ uvicorn.run(
429
+ create_app(config),
430
+ host=host,
431
+ port=port,
432
+ log_level=args.log_level,
433
+ timeout_graceful_shutdown=GRACEFUL_SHUTDOWN_SECONDS,
434
+ )
435
+ return 0
436
+
437
+
438
+ def cmd_doctor(args: argparse.Namespace) -> int:
439
+ """Send one real request upstream and report what it actually said.
440
+
441
+ Everything in this project is tested against a mock that encodes assumptions
442
+ about header spellings. This is the command that checks those assumptions
443
+ against the real thing, which no test can do.
444
+ """
445
+ import httpx
446
+
447
+ from .core.diagnostics import expected_headers, inspect_headers
448
+
449
+ color = _colors_enabled()
450
+ api_key = args.api_key or os.environ.get("ANTHROPIC_API_KEY")
451
+ base_url = args.base_url
452
+
453
+ if not api_key:
454
+ # Fall back to the first API-key account in the config.
455
+ try:
456
+ from .config import Config
457
+
458
+ config = Config.load(args.config)
459
+ except Exception:
460
+ config = None
461
+ if config is not None:
462
+ for account in config.accounts:
463
+ if account.type == "anthropic_api" and account.api_key:
464
+ api_key = account.api_key
465
+ base_url = base_url or account.base_url
466
+ print("using account {} from {}".format(
467
+ paint(account.id, "brand", color), args.config))
468
+ break
469
+
470
+ if not api_key:
471
+ print(
472
+ "doctor needs a real credential. Pass --api-key, set ANTHROPIC_API_KEY, or "
473
+ "point --config at a file with an anthropic_api account.",
474
+ file=sys.stderr,
475
+ )
476
+ return 1
477
+
478
+ base_url = (base_url or "https://api.anthropic.com").rstrip("/")
479
+ payload = {
480
+ "model": args.model,
481
+ "max_tokens": 1,
482
+ "messages": [{"role": "user", "content": "hi"}],
483
+ }
484
+
485
+ print()
486
+ print(" {} {}".format(paint("POST", "dim", color), base_url + "/v1/messages"))
487
+ print(" {} {}".format(paint("model", "dim", color), args.model))
488
+ print(" {}".format(paint("one request, max_tokens=1 — a few cents at most", "dim", color)))
489
+ print()
490
+
491
+ try:
492
+ response = httpx.post(
493
+ base_url + "/v1/messages",
494
+ headers={
495
+ "x-api-key": api_key,
496
+ "anthropic-version": "2023-06-01",
497
+ "content-type": "application/json",
498
+ },
499
+ json=payload,
500
+ timeout=30.0,
501
+ )
502
+ except httpx.HTTPError as exc:
503
+ print(" {} could not reach {}: {}".format(
504
+ paint("FAIL", "disabled", color), base_url, exc), file=sys.stderr)
505
+ return 1
506
+
507
+ status_color = "ready" if response.status_code < 300 else "disabled"
508
+ print(" {} HTTP {}".format(
509
+ paint("status", "dim", color), paint(str(response.status_code), status_color, color)))
510
+ if response.status_code >= 300:
511
+ print(f" {response.text[:400]}")
512
+ print()
513
+ return 1
514
+
515
+ # One implementation of the check, shared with the console's verify step.
516
+ report = inspect_headers(dict(response.headers))
517
+
518
+ print()
519
+ print(paint(" headers the upstream actually returned", "dim", color))
520
+ if not report["returned"]:
521
+ print(" {}".format(paint("none at all", "disabled", color)))
522
+ for name, value in report["returned"].items():
523
+ print(f" {name:<44} {value}")
524
+
525
+ print()
526
+ print(paint(" headers the limit mirror looks for", "dim", color))
527
+ for row in report["checked"]:
528
+ print(" {} {:<44} {}".format(
529
+ paint("✓" if row["present"] else "✗",
530
+ "ready" if row["present"] else "disabled", color),
531
+ row["header"],
532
+ row["value"] if row["present"] else paint("missing", "disabled", color),
533
+ ))
534
+
535
+ print()
536
+ print(paint(" what the router would see", "dim", color))
537
+ unified = report.get("family") == "unified"
538
+ if unified:
539
+ # A subscription session answers a different question: how much of each
540
+ # rolling window is spent. There is no remaining count to print.
541
+ for label, parsed in report["parsed"].items():
542
+ print(" {:<16} {:<10} used {:<10} headroom {:<8} resets {}".format(
543
+ label.replace("unified_", ""),
544
+ parsed["status"] or "—",
545
+ _pct(parsed["utilization"]),
546
+ _pct(parsed["headroom"]),
547
+ _fmt_clock(parsed["reset_in"]),
548
+ ))
549
+ else:
550
+ for label, parsed in report["parsed"].items():
551
+ print(" {:<16} limit {:<10} remaining {:<10} resets {}".format(
552
+ label,
553
+ _fmt_tokens(parsed["limit"]),
554
+ _fmt_tokens(parsed["remaining"]),
555
+ _fmt_clock(parsed["reset_in"]),
556
+ ))
557
+
558
+ print()
559
+ if not report["ok"]:
560
+ print(" {} {} of {} headers are missing or spelled differently.".format(
561
+ paint("PROBLEM", "disabled", color),
562
+ len(report["missing"]), len(expected_headers(unified))))
563
+ if unified:
564
+ print(" This account routes on assumed headroom rather than its real")
565
+ print(" utilisation. Please open an issue with the header list above.")
566
+ else:
567
+ print(" Those windows stay empty, so those accounts read as full and routing")
568
+ print(" degrades to round-robin. Please open an issue with the header list above.")
569
+ print()
570
+ return 1
571
+
572
+ print(" {} every header the router needs is present and parsed{}.".format(
573
+ paint("OK", "ready", color),
574
+ " (subscription session: rolling windows)" if unified else ""))
575
+ print()
576
+ return 0
577
+
578
+
579
+ def _admin_key_from_config(config: Any) -> Optional[str]:
580
+ """The first key in the file that can reach /admin. None if there is no such key."""
581
+ for key in config.keys:
582
+ if key.admin:
583
+ return key.key
584
+ return None
585
+
586
+
587
+ def cmd_console(args: argparse.Namespace) -> int:
588
+ """Open the console in a browser, already signed in.
589
+
590
+ The alternative — and what this replaces — was copying a `bir_…` key out of
591
+ terminal scrollback and pasting it into a password field. That is the first
592
+ thing a new user is asked to do, and it is the first thing that goes wrong.
593
+
594
+ The key is never put in the URL. A single-use ticket is, and it is spent by the
595
+ page before anything else happens.
596
+ """
597
+ import webbrowser
598
+
599
+ import httpx
600
+
601
+ from .config import Config, ConfigError
602
+
603
+ color = _colors_enabled()
604
+ key = args.key
605
+ if not key:
606
+ try:
607
+ config = Config.load(args.config)
608
+ except FileNotFoundError:
609
+ print(
610
+ f"no {args.config} here. Run `tokenbiryani init` first.", file=sys.stderr
611
+ )
612
+ return 1
613
+ except ConfigError as exc:
614
+ print(f"config error: {exc}", file=sys.stderr)
615
+ return 1
616
+ key = _admin_key_from_config(config)
617
+ base = args.url or f"http://{config.server.host}:{config.server.port}"
618
+ else:
619
+ base = args.url or DEFAULT_URL
620
+ base = base.rstrip("/")
621
+
622
+ if not key:
623
+ # An open gateway needs no ticket; anything else needs a key we do not have.
624
+ print(f" opening {base}/console")
625
+ if not args.no_browser:
626
+ webbrowser.open(base + "/console")
627
+ print(paint(
628
+ f" no admin key in {args.config}, so this opens the sign-in page. Add one "
629
+ "with `admin: true` under `keys:`.", "dim", color))
630
+ return 0
631
+
632
+ try:
633
+ response = httpx.post(
634
+ base + "/admin/console-ticket", headers={"x-api-key": key}, timeout=5.0
635
+ )
636
+ except httpx.HTTPError:
637
+ print(
638
+ f"cannot reach {base} — is the gateway running? Start it with "
639
+ "`tokenbiryani serve`.",
640
+ file=sys.stderr,
641
+ )
642
+ return 1
643
+
644
+ if response.status_code == 401:
645
+ print(f"the admin key in {args.config} was rejected by {base}. Two gateways with "
646
+ "different keys is the usual cause.", file=sys.stderr)
647
+ return 1
648
+ if response.status_code != 200:
649
+ # Bound off-loopback, most likely — the message says which.
650
+ detail = ""
651
+ try:
652
+ detail = response.json().get("error", {}).get("message", "")
653
+ except ValueError:
654
+ detail = response.text[:200]
655
+ print(f"{detail or response.status_code}", file=sys.stderr)
656
+ return 1
657
+
658
+ ticket = response.json().get("ticket", "")
659
+ url = f"{base}/console?t={ticket}"
660
+ print()
661
+ print(" {} {}".format(paint("opening", "brand", color), base + "/console"))
662
+ print(paint(" signed in with the admin key from " + args.config, "dim", color))
663
+ if args.no_browser:
664
+ print()
665
+ print(" " + url)
666
+ print(paint(" single use, and it expires in a minute", "dim", color))
667
+ elif not webbrowser.open(url):
668
+ print()
669
+ print(" no browser to open. Use this link within the next minute:")
670
+ print(" " + url)
671
+ print()
672
+ return 0
673
+
674
+
675
+ def cmd_status(args: argparse.Namespace) -> int:
676
+ import httpx
677
+
678
+ headers = {}
679
+ token = args.key or os.environ.get("ANTHROPIC_AUTH_TOKEN") or os.environ.get(
680
+ "TOKENBIRYANI_KEY"
681
+ )
682
+ if token:
683
+ headers["x-api-key"] = token
684
+ url = args.url.rstrip("/") + "/admin/status"
685
+ try:
686
+ response = httpx.get(url, headers=headers, timeout=5.0)
687
+ except httpx.HTTPError as exc:
688
+ print(f"cannot reach {args.url}: {exc}", file=sys.stderr)
689
+ return 1
690
+ if response.status_code == 401:
691
+ print(
692
+ "unauthorized — pass --key or set ANTHROPIC_AUTH_TOKEN", file=sys.stderr
693
+ )
694
+ return 1
695
+ if response.status_code != 200:
696
+ print(f"gateway returned {response.status_code}", file=sys.stderr)
697
+ return 1
698
+ snapshot = response.json()
699
+
700
+ # A separate call because it reads the spend ledger, and /admin/status is what
701
+ # the console polls every five seconds. A gateway too old to have the endpoint,
702
+ # or one that fails it, simply reports no pace rather than failing `status`.
703
+ pacing: Dict[str, Any] = {}
704
+ try:
705
+ paced = httpx.get(
706
+ args.url.rstrip("/") + "/admin/pacing", headers=headers, timeout=5.0
707
+ )
708
+ if paced.status_code == 200:
709
+ pacing = paced.json()
710
+ except httpx.HTTPError:
711
+ pass
712
+
713
+ if args.json:
714
+ print(json.dumps(dict(snapshot, pacing=pacing), indent=2))
715
+ return 0
716
+ print()
717
+ print(render_status(
718
+ snapshot,
719
+ color=_colors_enabled(None if not args.no_color else False),
720
+ pacing=pacing,
721
+ ))
722
+ print()
723
+ return 0
724
+
725
+
726
+ # ---- accounts ------------------------------------------------------------------
727
+ # The console is not the only place a managed account should be reachable from.
728
+ # This audience lives in a terminal; a pool you can only edit in a browser is a
729
+ # worse tool for them, and these are four thin calls over the same admin API the
730
+ # console uses.
731
+
732
+
733
+ def _admin_request(args: argparse.Namespace, method: str, path: str, body=None):
734
+ import httpx
735
+
736
+ token = args.key or os.environ.get("ANTHROPIC_AUTH_TOKEN") or os.environ.get(
737
+ "TOKENBIRYANI_KEY"
738
+ )
739
+ if not token:
740
+ # Fall back to the config, the way `console` does — the key is right there.
741
+ try:
742
+ from .config import Config
743
+
744
+ token = _admin_key_from_config(Config.load(args.config))
745
+ except Exception:
746
+ token = None
747
+ url = args.url.rstrip("/") + path
748
+ try:
749
+ response = httpx.request(
750
+ method, url,
751
+ headers={"x-api-key": token} if token else {},
752
+ json=body,
753
+ timeout=30.0,
754
+ )
755
+ except httpx.HTTPError as exc:
756
+ print(f"cannot reach {args.url}: {exc}", file=sys.stderr)
757
+ return None
758
+ if response.status_code == 401:
759
+ print("unauthorized — pass --key or set ANTHROPIC_AUTH_TOKEN", file=sys.stderr)
760
+ return None
761
+ if response.status_code >= 400:
762
+ try:
763
+ detail = response.json().get("error", {}).get("message", "")
764
+ except ValueError:
765
+ detail = response.text[:200]
766
+ print(detail or f"gateway returned {response.status_code}", file=sys.stderr)
767
+ return None
768
+ return response.json()
769
+
770
+
771
+ def cmd_accounts_list(args: argparse.Namespace) -> int:
772
+ snapshot = _admin_request(args, "GET", "/admin/accounts")
773
+ if snapshot is None:
774
+ return 1
775
+ if args.json:
776
+ print(json.dumps(snapshot["accounts"], indent=2))
777
+ return 0
778
+ color = _colors_enabled()
779
+ print()
780
+ print(paint(" {:<20}{:<16}{:<12}{:>10}".format(
781
+ "ACCOUNT", "TYPE", "STATE", "SPEND"), "dim", color))
782
+ for account in snapshot["accounts"]:
783
+ state = account["state"]
784
+ print(" {:<20}{:<16}{} {:<10}{:>10}".format(
785
+ account["id"],
786
+ account.get("type", ""),
787
+ paint("●", state if state in C else "dim", color),
788
+ state,
789
+ "${:.2f}".format(account.get("spend_usd") or 0.0),
790
+ ))
791
+ print()
792
+ return 0
793
+
794
+
795
+ def cmd_accounts_add(args: argparse.Namespace) -> int:
796
+ payload = {
797
+ "id": args.id,
798
+ "name": args.name or args.id,
799
+ "type": args.type,
800
+ "api_key": args.api_key or "",
801
+ "cost_tier": args.cost_tier,
802
+ "priority": args.priority,
803
+ }
804
+ if args.base_url:
805
+ payload["base_url"] = args.base_url
806
+ if args.spend_cap is not None:
807
+ payload["spend_cap_usd"] = args.spend_cap
808
+
809
+ color = _colors_enabled()
810
+ # Test before storing, exactly as the console does: a wrong key should be a
811
+ # message, not a disabled account somebody has to clean up later.
812
+ if args.api_key and not args.no_test:
813
+ probe = _admin_request(args, "POST", "/admin/accounts/test", {
814
+ "api_key": args.api_key, "type": args.type, "base_url": args.base_url or "",
815
+ })
816
+ if probe is None:
817
+ return 1
818
+ if not probe.get("ok"):
819
+ print(" {} the upstream rejected this credential: {}".format(
820
+ paint("FAIL", "disabled", color), probe.get("detail")), file=sys.stderr)
821
+ print(" nothing was stored. Pass --no-test to add it anyway.", file=sys.stderr)
822
+ return 1
823
+ print(" {} credential accepted in {:.2f}s".format(
824
+ paint("ok", "ready", color), probe.get("latency") or 0.0))
825
+
826
+ record = _admin_request(args, "POST", "/admin/accounts", payload)
827
+ if record is None:
828
+ return 1
829
+ print(" {} added".format(paint(record["id"], "brand", color)))
830
+ return 0
831
+
832
+
833
+ def cmd_accounts_test(args: argparse.Namespace) -> int:
834
+ """Test one account, or every account, and report the header check too."""
835
+ color = _colors_enabled()
836
+ ids = [args.id] if args.id else None
837
+ if ids is None:
838
+ snapshot = _admin_request(args, "GET", "/admin/accounts")
839
+ if snapshot is None:
840
+ return 1
841
+ ids = [a["id"] for a in snapshot["accounts"]]
842
+ if not ids:
843
+ print("no accounts to test", file=sys.stderr)
844
+ return 1
845
+
846
+ failed = 0
847
+ unchecked = 0
848
+ print()
849
+ for account_id in ids:
850
+ report = _admin_request(
851
+ args, "POST", f"/admin/accounts/{account_id}/diagnose",
852
+ {"spend": bool(args.deep)},
853
+ )
854
+ if report is None:
855
+ failed += 1
856
+ continue
857
+ if not report.get("ok"):
858
+ failed += 1
859
+ print(" {} {:<20}{}".format(
860
+ paint("✗", "disabled", color), account_id, report.get("detail")))
861
+ continue
862
+
863
+ source = report.get("limits_source")
864
+ limits = report.get("limits")
865
+ if source == "unobservable":
866
+ print(" {} {:<20}credential ok · reports no limit headers by design".format(
867
+ paint("●", "cooling", color), account_id))
868
+ elif source == "not_observed":
869
+ # Not a failure. The headers only arrive on a real completion, and none
870
+ # has been served — saying "9 missing" here would be a false alarm.
871
+ unchecked += 1
872
+ print(" {} {:<20}credential ok · limit headers not seen yet".format(
873
+ paint("?", "dim", color), account_id))
874
+ elif limits and not limits.get("ok"):
875
+ failed += 1
876
+ print(" {} {:<20}credential ok, {} rate-limit header(s) missing".format(
877
+ paint("!", "disabled", color), account_id, len(limits["missing"])))
878
+ for name in limits["missing"]:
879
+ print(paint(" " + name, "dim", color))
880
+ else:
881
+ print(" {} {:<20}credential ok · every limit header present".format(
882
+ paint("✓", "ready", color), account_id))
883
+
884
+ if unchecked and not args.deep:
885
+ print()
886
+ print(paint(
887
+ " the headers only arrive on a real completion. Send traffic through the "
888
+ "gateway,\n or re-run with --deep to spend one max_tokens=1 request per "
889
+ "account.", "dim", color))
890
+ print()
891
+ return 1 if failed else 0
892
+
893
+
894
+ def cmd_accounts_rm(args: argparse.Namespace) -> int:
895
+ result = _admin_request(
896
+ args, "DELETE", "/admin/accounts/" + args.id
897
+ )
898
+ if result is None:
899
+ return 1
900
+ print(" {} removed".format(paint(args.id, "brand", _colors_enabled())))
901
+ return 0
902
+
903
+
904
+ def cmd_accounts(args: argparse.Namespace) -> int:
905
+ parser = getattr(args, "_parser", None)
906
+ if parser is not None:
907
+ parser.print_help()
908
+ return 1
909
+
910
+
911
+ def build_parser() -> argparse.ArgumentParser:
912
+ parser = argparse.ArgumentParser(
913
+ prog="tokenbiryani",
914
+ description="A pooling gateway for Claude accounts.",
915
+ )
916
+ parser.add_argument("-c", "--config", default=DEFAULT_CONFIG, help="path to config")
917
+ sub = parser.add_subparsers(dest="command")
918
+
919
+ init = sub.add_parser("init", help="write a starter config")
920
+ init.add_argument("--host", default="127.0.0.1")
921
+ init.add_argument("--port", type=int, default=8787)
922
+ init.add_argument("--force", action="store_true")
923
+ init.add_argument(
924
+ "--api-key",
925
+ help="seed the config with this credential. Without it the pool starts empty "
926
+ "and the console's wizard adds the first account.",
927
+ )
928
+ init.set_defaults(func=cmd_init)
929
+
930
+ console = sub.add_parser("console", help="open the console in a browser, signed in")
931
+ console.add_argument("--url", help="defaults to the address in the config")
932
+ console.add_argument("--key", help="defaults to the first admin key in the config")
933
+ console.add_argument(
934
+ "--no-browser", action="store_true", help="print the link instead of opening it"
935
+ )
936
+ console.set_defaults(func=cmd_console)
937
+
938
+ serve = sub.add_parser("serve", help="run the gateway")
939
+ serve.add_argument("--host")
940
+ serve.add_argument("--port", type=int)
941
+ serve.add_argument("--log-level", default="info")
942
+ serve.add_argument(
943
+ "--reload",
944
+ action="store_true",
945
+ help="restart on source changes (development; needs the 'dev' extra)",
946
+ )
947
+ serve.add_argument(
948
+ "--reload-dir",
949
+ action="append",
950
+ metavar="DIR",
951
+ help="directory to watch; repeatable. Defaults to uvicorn's own choice.",
952
+ )
953
+ serve.set_defaults(func=cmd_serve)
954
+
955
+ status = sub.add_parser("status", help="show the pool")
956
+ status.add_argument("--url", default=os.environ.get("TOKENBIRYANI_URL", DEFAULT_URL))
957
+ status.add_argument("--key")
958
+ status.add_argument("--json", action="store_true")
959
+ status.add_argument("--no-color", action="store_true")
960
+ status.set_defaults(func=cmd_status)
961
+
962
+ strategies = sub.add_parser("strategies", help="list routing strategies")
963
+ strategies.set_defaults(func=cmd_strategies)
964
+
965
+ doctor = sub.add_parser(
966
+ "doctor",
967
+ help="send one real request upstream and check the rate-limit headers",
968
+ )
969
+ doctor.add_argument("--api-key", help="defaults to $ANTHROPIC_API_KEY, then the config")
970
+ doctor.add_argument("--base-url", help="defaults to https://api.anthropic.com")
971
+ doctor.add_argument("--model", default="claude-sonnet-5")
972
+ doctor.set_defaults(func=cmd_doctor)
973
+
974
+ # --url and --key belong to every subcommand, not to `accounts` itself: defined
975
+ # on the group, argparse only accepts them *before* the subcommand, which reads
976
+ # as a bug the first time you type `accounts add x --url …`.
977
+ reach = argparse.ArgumentParser(add_help=False)
978
+ reach.add_argument("--url", default=os.environ.get("TOKENBIRYANI_URL", DEFAULT_URL))
979
+ reach.add_argument("--key", help="defaults to the first admin key in the config")
980
+
981
+ accounts = sub.add_parser("accounts", help="add, list, test and remove accounts")
982
+ accounts.set_defaults(func=cmd_accounts, _parser=accounts)
983
+ acct_sub = accounts.add_subparsers(dest="accounts_command")
984
+
985
+ acct_list = acct_sub.add_parser("list", help="show the pool", parents=[reach])
986
+ acct_list.add_argument("--json", action="store_true")
987
+ acct_list.set_defaults(func=cmd_accounts_list)
988
+
989
+ acct_add = acct_sub.add_parser(
990
+ "add", help="add an account, testing it first", parents=[reach]
991
+ )
992
+ acct_add.add_argument("id")
993
+ acct_add.add_argument("--api-key")
994
+ acct_add.add_argument("--name")
995
+ acct_add.add_argument("--type", default="anthropic_api")
996
+ acct_add.add_argument("--base-url")
997
+ acct_add.add_argument("--cost-tier", type=float, default=1.0)
998
+ acct_add.add_argument("--priority", type=float, default=0.0)
999
+ acct_add.add_argument("--spend-cap", type=float)
1000
+ acct_add.add_argument(
1001
+ "--no-test", action="store_true", help="store it without probing the credential"
1002
+ )
1003
+ acct_add.set_defaults(func=cmd_accounts_add)
1004
+
1005
+ acct_test = acct_sub.add_parser(
1006
+ "test", help="probe a credential and check its rate-limit headers",
1007
+ parents=[reach],
1008
+ )
1009
+ acct_test.add_argument("id", nargs="?", help="omit to test every account")
1010
+ acct_test.add_argument(
1011
+ "--deep",
1012
+ action="store_true",
1013
+ help="spend one max_tokens=1 request per account to read its rate-limit "
1014
+ "headers, when no real traffic has done so yet",
1015
+ )
1016
+ acct_test.set_defaults(func=cmd_accounts_test)
1017
+
1018
+ acct_rm = acct_sub.add_parser(
1019
+ "rm", help="remove a managed account", parents=[reach]
1020
+ )
1021
+ acct_rm.add_argument("id")
1022
+ acct_rm.set_defaults(func=cmd_accounts_rm)
1023
+
1024
+ keygen = sub.add_parser("keygen", help="print a new virtual key")
1025
+ keygen.add_argument(
1026
+ "--secret",
1027
+ action="store_true",
1028
+ help="print a credential-encryption key for TOKENBIRYANI_SECRET_KEY instead",
1029
+ )
1030
+ keygen.set_defaults(func=cmd_keygen)
1031
+
1032
+ return parser
1033
+
1034
+
1035
+ def main(argv: Optional[List[str]] = None) -> int:
1036
+ parser = build_parser()
1037
+ args = parser.parse_args(argv)
1038
+ if not getattr(args, "func", None):
1039
+ parser.print_help()
1040
+ return 1
1041
+ return args.func(args)
1042
+
1043
+
1044
+ if __name__ == "__main__":
1045
+ raise SystemExit(main())