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/__init__.py
ADDED
|
File without changes
|
tokenbiryani/api/app.py
ADDED
|
@@ -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("&", "&").replace("<", "<").replace(">", ">")
|
|
32
|
+
.replace('"', """).replace("'", "'")
|
|
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
|
tokenbiryani/api/asgi.py
ADDED
|
@@ -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))
|