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.
- tokenbiryani/__init__.py +3 -0
- tokenbiryani/api/__init__.py +0 -0
- tokenbiryani/api/app.py +583 -0
- tokenbiryani/api/asgi.py +32 -0
- tokenbiryani/cli.py +1045 -0
- tokenbiryani/config.py +532 -0
- tokenbiryani/core/__init__.py +0 -0
- tokenbiryani/core/account.py +258 -0
- tokenbiryani/core/batch.py +135 -0
- tokenbiryani/core/breaker.py +53 -0
- tokenbiryani/core/cacheadvice.py +239 -0
- tokenbiryani/core/diagnostics.py +131 -0
- tokenbiryani/core/estimator.py +180 -0
- tokenbiryani/core/gateway.py +2395 -0
- tokenbiryani/core/handoff.py +87 -0
- tokenbiryani/core/keys.py +199 -0
- tokenbiryani/core/limits.py +440 -0
- tokenbiryani/core/oauth.py +222 -0
- tokenbiryani/core/pacing.py +320 -0
- tokenbiryani/core/queue.py +132 -0
- tokenbiryani/core/router.py +323 -0
- tokenbiryani/core/secrets.py +114 -0
- tokenbiryani/core/session.py +117 -0
- tokenbiryani/dashboard/__init__.py +56 -0
- tokenbiryani/dashboard/console.css +610 -0
- tokenbiryani/dashboard/console.html +3250 -0
- tokenbiryani/observability/__init__.py +0 -0
- tokenbiryani/observability/events.py +171 -0
- tokenbiryani/observability/usage.py +226 -0
- tokenbiryani/prices.yaml +77 -0
- tokenbiryani/providers/__init__.py +0 -0
- tokenbiryani/providers/anthropic_api.py +118 -0
- tokenbiryani/providers/base.py +173 -0
- tokenbiryani/providers/bedrock.py +182 -0
- tokenbiryani/providers/oauth.py +165 -0
- tokenbiryani/providers/oauth_credentials.py +293 -0
- tokenbiryani/providers/translate.py +35 -0
- tokenbiryani/providers/vertex.py +144 -0
- tokenbiryani/proxy/__init__.py +0 -0
- tokenbiryani/proxy/errors.py +169 -0
- tokenbiryani/proxy/sse.py +98 -0
- tokenbiryani/store/__init__.py +0 -0
- tokenbiryani/store/base.py +150 -0
- tokenbiryani/store/memory.py +149 -0
- tokenbiryani/store/redis_store.py +222 -0
- tokenbiryani/store/sqlite.py +336 -0
- tokenbiryani-0.2.0.dist-info/METADATA +697 -0
- tokenbiryani-0.2.0.dist-info/RECORD +51 -0
- tokenbiryani-0.2.0.dist-info/WHEEL +4 -0
- tokenbiryani-0.2.0.dist-info/entry_points.txt +2 -0
- 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())
|