tokenbiryani 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. tokenbiryani/__init__.py +3 -0
  2. tokenbiryani/api/__init__.py +0 -0
  3. tokenbiryani/api/app.py +583 -0
  4. tokenbiryani/api/asgi.py +32 -0
  5. tokenbiryani/cli.py +1045 -0
  6. tokenbiryani/config.py +532 -0
  7. tokenbiryani/core/__init__.py +0 -0
  8. tokenbiryani/core/account.py +258 -0
  9. tokenbiryani/core/batch.py +135 -0
  10. tokenbiryani/core/breaker.py +53 -0
  11. tokenbiryani/core/cacheadvice.py +239 -0
  12. tokenbiryani/core/diagnostics.py +131 -0
  13. tokenbiryani/core/estimator.py +180 -0
  14. tokenbiryani/core/gateway.py +2395 -0
  15. tokenbiryani/core/handoff.py +87 -0
  16. tokenbiryani/core/keys.py +199 -0
  17. tokenbiryani/core/limits.py +440 -0
  18. tokenbiryani/core/oauth.py +222 -0
  19. tokenbiryani/core/pacing.py +320 -0
  20. tokenbiryani/core/queue.py +132 -0
  21. tokenbiryani/core/router.py +323 -0
  22. tokenbiryani/core/secrets.py +114 -0
  23. tokenbiryani/core/session.py +117 -0
  24. tokenbiryani/dashboard/__init__.py +56 -0
  25. tokenbiryani/dashboard/console.css +610 -0
  26. tokenbiryani/dashboard/console.html +3250 -0
  27. tokenbiryani/observability/__init__.py +0 -0
  28. tokenbiryani/observability/events.py +171 -0
  29. tokenbiryani/observability/usage.py +226 -0
  30. tokenbiryani/prices.yaml +77 -0
  31. tokenbiryani/providers/__init__.py +0 -0
  32. tokenbiryani/providers/anthropic_api.py +118 -0
  33. tokenbiryani/providers/base.py +173 -0
  34. tokenbiryani/providers/bedrock.py +182 -0
  35. tokenbiryani/providers/oauth.py +165 -0
  36. tokenbiryani/providers/oauth_credentials.py +293 -0
  37. tokenbiryani/providers/translate.py +35 -0
  38. tokenbiryani/providers/vertex.py +144 -0
  39. tokenbiryani/proxy/__init__.py +0 -0
  40. tokenbiryani/proxy/errors.py +169 -0
  41. tokenbiryani/proxy/sse.py +98 -0
  42. tokenbiryani/store/__init__.py +0 -0
  43. tokenbiryani/store/base.py +150 -0
  44. tokenbiryani/store/memory.py +149 -0
  45. tokenbiryani/store/redis_store.py +222 -0
  46. tokenbiryani/store/sqlite.py +336 -0
  47. tokenbiryani-0.2.0.dist-info/METADATA +697 -0
  48. tokenbiryani-0.2.0.dist-info/RECORD +51 -0
  49. tokenbiryani-0.2.0.dist-info/WHEEL +4 -0
  50. tokenbiryani-0.2.0.dist-info/entry_points.txt +2 -0
  51. tokenbiryani-0.2.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,2395 @@
1
+ """The gateway: admit, route, lease, proxy, reconcile, recover.
2
+
3
+ This is the seven-stage pipeline from the plan. The two subtleties worth reading for
4
+ are the lease lifecycle (reserve before dispatch, reconcile against real usage) and
5
+ the streaming commit boundary in ``stream`` — the single point where a failure stops
6
+ being hideable.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import json
13
+ import logging
14
+ import os
15
+ import random
16
+ import re
17
+ import time
18
+ import uuid
19
+ from dataclasses import dataclass, replace
20
+ from typing import Any, AsyncIterator, Dict, List, Mapping, Optional, Tuple
21
+
22
+ import httpx
23
+
24
+ from ..config import AccountConfig, Config, ConfigError, KeyConfig
25
+ from ..observability.events import Attempt, EventLog, RequestEvent
26
+ from ..observability.usage import (
27
+ aggregate,
28
+ resolve_bucket,
29
+ resolve_window,
30
+ sample_from_event,
31
+ )
32
+ from ..providers.anthropic_api import build_upstream
33
+ from ..providers.base import BLOCKED_RESPONSE_HEADERS, Upstream
34
+ from ..providers.translate import map_model
35
+ from ..proxy import sse
36
+ from ..proxy.errors import (
37
+ AccountAction,
38
+ Classification,
39
+ RequestAction,
40
+ classify,
41
+ classify_exception,
42
+ )
43
+ from ..store.base import (
44
+ SCOPE_ACCOUNT,
45
+ SCOPE_KEY,
46
+ SCOPE_SESSION,
47
+ SCOPE_SESSION_TURNS,
48
+ StateStore,
49
+ build_store,
50
+ )
51
+ from . import batch as batch_lane
52
+ from .account import AccountRuntime, AccountState, Usage
53
+ from .breaker import CircuitBreaker
54
+ from .cacheadvice import (
55
+ CacheAdvisor,
56
+ cacheable_prefix_tokens,
57
+ has_cache_breakpoint,
58
+ insert_cache_breakpoint,
59
+ )
60
+ from .diagnostics import inspect_headers, looks_unified
61
+ from .estimator import OutputEstimator
62
+ from .keys import KeyRegistry, generate_key, record_from_config
63
+ from .limits import LimitMirror, TokenEstimate, estimate_request
64
+ from .oauth import OAuthClient, OAuthError, OAuthTokens, PendingLogin, make_verifier
65
+ from .pacing import PaceReading, PacingGovernor
66
+ from .pacing import week_start as _pacing_week_start
67
+ from .queue import (
68
+ MAX_WAIT_HEADER,
69
+ PRIORITY_BATCH,
70
+ PRIORITY_HEADER,
71
+ CapacityGate,
72
+ QueueFull,
73
+ admission_check,
74
+ parse_priority,
75
+ priority_name,
76
+ )
77
+ from .router import Decision, Router, RoutingContext, available_strategies
78
+ from .secrets import SecretBox, SecretError
79
+ from .session import session_key as compute_session_key
80
+
81
+ MESSAGES_PATH = "/v1/messages"
82
+
83
+ logger = logging.getLogger("tokenbiryani")
84
+
85
+ #: Account ids appear in URLs, metric labels and log lines; keep them boring.
86
+ _SAFE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$")
87
+
88
+ #: Never leaves the process. Stripped from every account payload the API returns.
89
+ _CREDENTIAL_FIELDS = ("api_key", "oauth_access", "oauth_refresh")
90
+
91
+
92
+ class GatewayError(Exception):
93
+ """A failure the gateway itself is reporting, in Anthropic's error shape."""
94
+
95
+ def __init__(
96
+ self,
97
+ status: int,
98
+ message: str,
99
+ kind: str = "api_error",
100
+ retry_after: Optional[float] = None,
101
+ extra_headers: Optional[Dict[str, str]] = None,
102
+ ) -> None:
103
+ super().__init__(message)
104
+ self.status = status
105
+ self.message = message
106
+ self.kind = kind
107
+ self.retry_after = retry_after
108
+ self.extra_headers = extra_headers or {}
109
+
110
+ def payload(self) -> Dict[str, Any]:
111
+ return {"type": "error", "error": {"type": self.kind, "message": self.message}}
112
+
113
+ def headers(self) -> Dict[str, str]:
114
+ headers = dict(self.extra_headers)
115
+ if self.retry_after is not None:
116
+ headers["retry-after"] = str(int(max(1, round(self.retry_after))))
117
+ return headers
118
+
119
+
120
+ class _SpillToBatch(Exception):
121
+ """Internal: the pool is saturated and this request is allowed to spill."""
122
+
123
+
124
+ @dataclass
125
+ class Completion:
126
+ status: int
127
+ headers: Dict[str, str]
128
+ content: bytes
129
+ event: RequestEvent
130
+
131
+
132
+ def _response_headers(upstream_headers: Mapping[str, str], account_id: str) -> Dict[str, str]:
133
+ out = {
134
+ k: v for k, v in upstream_headers.items() if k.lower() not in BLOCKED_RESPONSE_HEADERS
135
+ }
136
+ out["x-tokenbiryani-account"] = account_id
137
+ return out
138
+
139
+
140
+ #: How long a computed pace is reused for. A quota window is days long; a request
141
+ #: does not need a fresher number than this, and pacing reads the spend ledger.
142
+ PACE_CACHE_SECONDS = 5.0
143
+
144
+
145
+ def _pacing_settings(config: Config) -> Dict[str, Any]:
146
+ pacing = config.pacing
147
+ return {
148
+ "enabled": pacing.enabled,
149
+ "mode": pacing.mode,
150
+ "window": pacing.window,
151
+ "curve": pacing.curve,
152
+ "ahead_threshold": pacing.ahead_threshold,
153
+ "behind_threshold": pacing.behind_threshold,
154
+ "max_batch_delay_seconds": pacing.max_batch_delay_seconds,
155
+ "weekly_budget_usd": pacing.weekly_budget_usd,
156
+ }
157
+
158
+
159
+ def _build_pacing(config: Config) -> PacingGovernor:
160
+ return PacingGovernor(**_pacing_settings(config))
161
+
162
+
163
+ def _build_estimator(config: Config) -> OutputEstimator:
164
+ """One estimator per gateway, configured from `routing.*`."""
165
+ routing = config.routing
166
+ return OutputEstimator(
167
+ mode=routing.output_estimate,
168
+ quantile=routing.output_estimate_quantile,
169
+ min_samples=routing.output_estimate_min_samples,
170
+ window=routing.output_estimate_window,
171
+ floor=routing.output_estimate_floor,
172
+ max_undershoot=routing.output_estimate_max_undershoot,
173
+ )
174
+
175
+
176
+ class Gateway:
177
+ def __init__(
178
+ self,
179
+ config: Config,
180
+ store: Optional[StateStore] = None,
181
+ client: Optional[httpx.AsyncClient] = None,
182
+ ) -> None:
183
+ self.config = config
184
+ self.store = store or build_store(config)
185
+ self.keys = KeyRegistry(config.keys)
186
+ self.events = EventLog(config.observability.event_buffer)
187
+ self.gate = CapacityGate(config.queue.max_size, config.queue.default_max_wait_seconds)
188
+ self.router = Router(config.routing.strategy, config.routing.weights)
189
+ self.estimator = _build_estimator(config)
190
+ self.cache_advisor = CacheAdvisor(
191
+ min_requests=config.cache.advice_min_requests
192
+ )
193
+ self.pacing = _build_pacing(config)
194
+ #: Pacing reads the spend ledger, which is a store round trip. A request
195
+ #: does not need a fresher answer than this — a quota window is days long.
196
+ self._pace_value: Optional[float] = None
197
+ self._pace_cached_at: float = 0.0
198
+
199
+ self.accounts: Dict[str, AccountRuntime] = {}
200
+ self.upstreams: Dict[str, Upstream] = {}
201
+ for account_config in config.accounts:
202
+ self.accounts[account_config.id] = AccountRuntime(
203
+ config=account_config,
204
+ mirror=_mirror_for(account_config),
205
+ breaker=CircuitBreaker(
206
+ failure_threshold=config.breaker.failure_threshold,
207
+ cooldown_seconds=config.breaker.cooldown_seconds,
208
+ ),
209
+ )
210
+ self.upstreams[account_config.id] = self._make_upstream(account_config)
211
+
212
+ #: Accounts declared in the config file. They belong to the file: the API
213
+ #: can neither edit nor delete them, only accounts it created itself.
214
+ self._config_account_ids = {a.id for a in config.accounts}
215
+ self._secrets: Optional[SecretBox] = None
216
+ self._oauth: Optional[OAuthClient] = None
217
+ #: Decrypted access tokens, held in memory only. The request path reads
218
+ #: these synchronously; the refresh task is what keeps them current.
219
+ self._oauth_tokens: Dict[str, str] = {}
220
+ #: Session facts the console shows — expiry, whether it can be renewed.
221
+ #: Kept beside the token so a snapshot needs no store round-trip.
222
+ self._oauth_meta: Dict[str, Dict[str, Any]] = {}
223
+ self._logins: Dict[str, PendingLogin] = {}
224
+ #: Console edits to accounts the config file declares. The file keeps the
225
+ #: credential and the identity; these are the operational fields an operator
226
+ #: needs to change at 3am without opening an editor on the server.
227
+ self._account_overrides: Dict[str, Dict[str, Any]] = {}
228
+ #: Console edits to gateway-wide settings, applied over the file's values.
229
+ self._settings: Dict[str, Any] = {}
230
+ #: What the file asked for, kept because a console override replaces the
231
+ #: live value and the Settings screen shows both.
232
+ self._file_pricing_table = config.pricing_table
233
+
234
+ self._client = client
235
+ self._owns_client = client is None
236
+
237
+ # ---- lifecycle -----------------------------------------------------------
238
+
239
+ async def startup(self) -> None:
240
+ if self._client is None:
241
+ self._client = httpx.AsyncClient(
242
+ timeout=httpx.Timeout(self.config.server.request_timeout_seconds, connect=10.0)
243
+ )
244
+ await self.store.startup()
245
+ await self.refresh_settings()
246
+ await self.refresh_accounts()
247
+ await self.hydrate_spend()
248
+ await self.refresh_keys()
249
+
250
+ async def hydrate_spend(self) -> None:
251
+ """Load each account's windowed spend from the store.
252
+
253
+ Account caps are checked on the synchronous routing path, so the figure is
254
+ cached in memory. Without this, a restart would hand every capped account a
255
+ clean slate — which is the whole reason the ledger is persistent.
256
+ """
257
+ totals = await self.store.spend_by_scope(SCOPE_ACCOUNT, self.config.spend.window_seconds)
258
+ for account_id, total in totals.items():
259
+ account = self.accounts.get(account_id)
260
+ if account is not None:
261
+ account.spend_usd = total
262
+
263
+ async def resync_spend(self, interval: float = 60.0) -> None:
264
+ """Re-read spend and managed keys so this instance cannot drift from the store."""
265
+ while True:
266
+ await asyncio.sleep(interval)
267
+ try:
268
+ await self.hydrate_spend()
269
+ await self.refresh_keys()
270
+ await self.refresh_accounts()
271
+ except Exception as exc: # noqa: BLE001 - a store blip must not kill routing
272
+ logger.warning("state resync failed: %s", exc)
273
+
274
+ async def refresh_keys(self) -> None:
275
+ """Load managed keys from the store into the registry."""
276
+ self.keys.set_managed(await self.store.list_keys())
277
+
278
+ # ---- managed accounts ----------------------------------------------------
279
+
280
+ @property
281
+ def secrets(self) -> SecretBox:
282
+ if self._secrets is None:
283
+ try:
284
+ self._secrets = SecretBox(key_path=self.config.store.secret_key_path)
285
+ except (SecretError, OSError) as exc:
286
+ # Reached the first time an account is stored, which is a request the
287
+ # operator is making from the console. A missing extra or an
288
+ # unwritable key path is their problem to fix, so say which it is
289
+ # rather than returning an opaque 500.
290
+ raise GatewayError(
291
+ 503,
292
+ f"cannot store credentials: {exc}",
293
+ kind="api_error",
294
+ ) from exc
295
+ return self._secrets
296
+
297
+ def is_config_account(self, account_id: str) -> bool:
298
+ return account_id in self._config_account_ids
299
+
300
+ # ---- overrides on config accounts ----------------------------------------
301
+
302
+ #: What the console may change on an account the file declares. Credentials,
303
+ #: type and base_url are absent on purpose: those are the file's business, and
304
+ #: an override that could repoint an account at another host would make the
305
+ #: config file a lie rather than an authority.
306
+ OVERRIDABLE = (
307
+ "name", "enabled", "cost_tier", "priority", "spend_cap_usd",
308
+ "models", "max_concurrency",
309
+ )
310
+
311
+ @staticmethod
312
+ def is_override(record: Mapping[str, Any]) -> bool:
313
+ return str(record.get("kind") or "") == "override"
314
+
315
+ def file_config(self, account_id: str) -> Optional[AccountConfig]:
316
+ """The account exactly as the config file declares it."""
317
+ return next((a for a in self.config.accounts if a.id == account_id), None)
318
+
319
+ def _apply_override(self, account_id: str) -> None:
320
+ """Point the runtime at the file's account, or at a copy carrying the override.
321
+
322
+ A copy, never an edit in place: `AccountRuntime.config` starts out as the
323
+ very object the file was parsed into, and mutating it would destroy the
324
+ value an override has to be revertible to. With a copy, reverting is just
325
+ pointing back at the original.
326
+ """
327
+ runtime = self.accounts.get(account_id)
328
+ base = self.file_config(account_id)
329
+ if runtime is None or base is None:
330
+ return
331
+ fields = {
332
+ name: value
333
+ for name, value in (self._account_overrides.get(account_id) or {}).items()
334
+ if name in self.OVERRIDABLE and value is not None
335
+ }
336
+ runtime.config = replace(base, **fields) if fields else base
337
+ runtime.mirror.observable = runtime.config.observable_limits
338
+
339
+ def _apply_all_overrides(self) -> None:
340
+ for account_id in self._config_account_ids:
341
+ self._apply_override(account_id)
342
+
343
+ def override_record(self, account_id: str, fields: Mapping[str, Any]) -> Dict[str, Any]:
344
+ return {
345
+ "id": account_id,
346
+ "kind": "override",
347
+ "fields": {k: v for k, v in fields.items() if k in self.OVERRIDABLE},
348
+ "updated_at": time.time(),
349
+ }
350
+
351
+ # ---- gateway-wide settings -----------------------------------------------
352
+
353
+ #: Settings the console may change without an editor, and what each is checked
354
+ #: against. Everything else stays a config-file decision.
355
+ SETTABLE = ("strategy", "pricing_source")
356
+
357
+ async def refresh_settings(self) -> None:
358
+ """Load console-set settings from the store and apply them over the file."""
359
+ try:
360
+ stored = await self.store.get_settings()
361
+ except Exception as exc: # noqa: BLE001 - a store blip must not stop startup
362
+ logger.warning("could not read settings: %s", exc)
363
+ return
364
+ self._settings = {k: v for k, v in (stored or {}).items() if k in self.SETTABLE}
365
+ self.apply_settings()
366
+
367
+ def apply_settings(self) -> None:
368
+ """Re-apply stored settings. Called after anything that rebuilds from config.
369
+
370
+ A reload reads the file, so without this a console-set strategy would
371
+ silently revert the next time anyone touched tokenbiryani.yaml.
372
+ """
373
+ strategy = self._settings.get("strategy")
374
+ if strategy and strategy != self.router.strategy:
375
+ # A whole Router, not just its name: the scorer and the weight spec are
376
+ # chosen by the strategy, so renaming the field in place would leave the
377
+ # gateway claiming round_robin while still scoring like sticky_headroom.
378
+ self.router = Router(str(strategy), self.config.routing.weights)
379
+ source = self._settings.get("pricing_source")
380
+ if source:
381
+ self.config.use_pricing(str(source))
382
+
383
+ async def update_settings(self, changes: Mapping[str, Any]) -> Dict[str, Any]:
384
+ """Change a setting from the console. Persisted, so a restart keeps it."""
385
+ unknown = [k for k in changes if k not in self.SETTABLE]
386
+ if unknown:
387
+ raise GatewayError(
388
+ 400,
389
+ "unknown setting(s): {}. Settable: {}".format(
390
+ ", ".join(sorted(unknown)), ", ".join(self.SETTABLE)
391
+ ),
392
+ kind="invalid_request_error",
393
+ )
394
+ if "strategy" in changes:
395
+ strategy = str(changes["strategy"] or "")
396
+ if strategy not in available_strategies():
397
+ raise GatewayError(
398
+ 400,
399
+ "unknown strategy {!r}. Available: {}".format(
400
+ strategy, ", ".join(sorted(available_strategies()))
401
+ ),
402
+ kind="invalid_request_error",
403
+ )
404
+ if "pricing_source" in changes:
405
+ source = str(changes["pricing_source"] or "")
406
+ if source not in ("builtin", "config"):
407
+ raise GatewayError(
408
+ 400,
409
+ "pricing_source must be 'builtin' (the dated table shipped with "
410
+ "this release) or 'config' (only what tokenbiryani.yaml names)",
411
+ kind="invalid_request_error",
412
+ )
413
+ for name, value in changes.items():
414
+ self._settings[name] = value
415
+ await self.store.put_setting(name, value)
416
+ self.apply_settings()
417
+ return self.settings_view()
418
+
419
+ def settings_view(self) -> Dict[str, Any]:
420
+ """What the Settings screen edits, and where each value came from."""
421
+ return {
422
+ "strategy": {
423
+ "value": self.router.strategy,
424
+ "source": "console" if "strategy" in self._settings else "config",
425
+ "file_value": self.config.routing.strategy,
426
+ "options": sorted(available_strategies()),
427
+ },
428
+ "pricing_source": {
429
+ "value": self.config.pricing_table,
430
+ "source": "console" if "pricing_source" in self._settings else "config",
431
+ "file_value": self._file_pricing_table,
432
+ "options": ["builtin", "config"],
433
+ },
434
+ "priced_models": sorted(self.config.pricing),
435
+ "priced_as_of": self.config.pricing_as_of,
436
+ "priced_source": self.config.pricing_source,
437
+ }
438
+
439
+ def _make_upstream(self, account_config: AccountConfig) -> Upstream:
440
+ """One construction point, so an oauth account always gets its token source."""
441
+ if account_config.type == "oauth":
442
+ account_id = account_config.id
443
+ return build_upstream(account_config, lambda: self.oauth_token(account_id))
444
+ return build_upstream(account_config)
445
+
446
+ def _config_from_record(self, record: Dict[str, Any]) -> AccountConfig:
447
+ return AccountConfig(
448
+ id=str(record["id"]),
449
+ name=str(record.get("name") or ""),
450
+ type=str(record.get("type") or "anthropic_api"),
451
+ api_key=self.secrets.decrypt(str(record.get("api_key") or "")),
452
+ base_url=str(record.get("base_url") or ""),
453
+ priority=float(record.get("priority") or 0.0),
454
+ cost_tier=float(record.get("cost_tier") or 1.0),
455
+ models=list(record.get("models") or ["*"]),
456
+ max_concurrency=int(record.get("max_concurrency") or 16),
457
+ spend_cap_usd=record.get("spend_cap_usd"),
458
+ enabled=bool(record.get("enabled", True)),
459
+ headers=dict(record.get("headers") or {}),
460
+ options=dict(record.get("options") or {}),
461
+ observable_limits=bool(record.get("observable_limits", True)),
462
+ assumed_headroom=float(record.get("assumed_headroom") or 0.5),
463
+ )
464
+
465
+ def _cache_session(self, record: Dict[str, Any]) -> None:
466
+ """Decrypt a stored subscription session into the in-memory token cache."""
467
+ if record.get("type") != "oauth":
468
+ return
469
+ account_id = str(record.get("id") or "")
470
+ self._oauth_meta[account_id] = {
471
+ "session_expires_at": record.get("oauth_expires_at"),
472
+ "can_refresh": bool(record.get("oauth_refresh")),
473
+ }
474
+ try:
475
+ self._oauth_tokens[account_id] = self.secrets.decrypt(
476
+ str(record.get("oauth_access") or "")
477
+ )
478
+ except SecretError as exc:
479
+ logger.warning("cannot read the session for %r: %s", account_id, exc)
480
+ self._oauth_tokens.pop(account_id, None)
481
+
482
+ async def refresh_accounts(self) -> None:
483
+ """Merge accounts stored through the API into the running pool.
484
+
485
+ Config accounts always win a name clash: the file is the operator's, and a
486
+ record in a shared store must not be able to redirect it.
487
+ """
488
+ records = await self.store.list_accounts()
489
+ seen = set()
490
+ overrides: Dict[str, Dict[str, Any]] = {}
491
+ for record in records:
492
+ account_id = str(record.get("id") or "")
493
+ if not account_id:
494
+ continue
495
+ if self.is_override(record):
496
+ # An override belongs to a config account and is not one itself.
497
+ fields = record.get("fields")
498
+ overrides[account_id] = dict(fields) if isinstance(fields, dict) else {}
499
+ continue
500
+ if account_id in self._config_account_ids:
501
+ continue
502
+ seen.add(account_id)
503
+ try:
504
+ account_config = self._config_from_record(record)
505
+ except SecretError as exc:
506
+ logger.warning("account %r is unreadable: %s", account_id, exc)
507
+ continue
508
+ # Decrypt the session now, not on the request path.
509
+ self._cache_session(record)
510
+ existing = self.accounts.get(account_id)
511
+ if existing is None:
512
+ self.accounts[account_id] = AccountRuntime(
513
+ config=account_config,
514
+ mirror=_mirror_for(account_config),
515
+ breaker=CircuitBreaker(
516
+ failure_threshold=self.config.breaker.failure_threshold,
517
+ cooldown_seconds=self.config.breaker.cooldown_seconds,
518
+ ),
519
+ )
520
+ self.upstreams[account_id] = self._make_upstream(account_config)
521
+ continue
522
+ rotated = (
523
+ existing.config.api_key != account_config.api_key
524
+ or existing.config.base_url != account_config.base_url
525
+ or existing.config.type != account_config.type
526
+ )
527
+ existing.config = account_config
528
+ existing.mirror.observable = account_config.observable_limits
529
+ if rotated:
530
+ self.upstreams[account_id] = self._make_upstream(account_config)
531
+ existing.disabled_reason = None
532
+ existing.breaker.record_success()
533
+
534
+ # Anything managed that has gone from the store has been deleted elsewhere.
535
+ for account_id in list(self.accounts):
536
+ if account_id not in self._config_account_ids and account_id not in seen:
537
+ self.accounts.pop(account_id, None)
538
+ self.upstreams.pop(account_id, None)
539
+
540
+ # An override that has been dropped must put the file's own value back, so
541
+ # reverting is a delete rather than a second edit guessing what the file said.
542
+ self._account_overrides = overrides
543
+ self._apply_all_overrides()
544
+
545
+ def account_record(
546
+ self, account_id: str, name: str, api_key: str, **options: Any
547
+ ) -> Dict[str, Any]:
548
+ return {
549
+ "id": account_id,
550
+ "name": name or account_id,
551
+ "type": str(options.get("type") or "anthropic_api"),
552
+ "api_key": self.secrets.encrypt(api_key),
553
+ "base_url": str(options.get("base_url") or ""),
554
+ "cost_tier": float(options.get("cost_tier") or 1.0),
555
+ "priority": float(options.get("priority") or 0.0),
556
+ "models": list(options.get("models") or ["*"]),
557
+ "max_concurrency": int(options.get("max_concurrency") or 16),
558
+ "spend_cap_usd": options.get("spend_cap_usd"),
559
+ "enabled": bool(options.get("enabled", True)),
560
+ # Forced, not defaulted, for a subscription: an account whose classic
561
+ # windows never populate would read as permanently full and win every
562
+ # comparison. Its real budget arrives as unified utilisation instead.
563
+ "observable_limits": (
564
+ False
565
+ if str(options.get("type") or "anthropic_api") == "oauth"
566
+ else bool(options.get("observable_limits", True))
567
+ ),
568
+ "options": dict(options.get("options") or {}),
569
+ "created_at": time.time(),
570
+ }
571
+
572
+ async def create_account(
573
+ self, account_id: str, name: str, api_key: str, **options: Any
574
+ ) -> Dict[str, Any]:
575
+ account_id = (account_id or "").strip()
576
+ if not account_id:
577
+ raise GatewayError(400, "an account needs an id", kind="invalid_request_error")
578
+ if not _SAFE_ID.match(account_id):
579
+ raise GatewayError(
580
+ 400,
581
+ "an account id may contain letters, digits, dashes and underscores only",
582
+ kind="invalid_request_error",
583
+ )
584
+ await self.refresh_accounts()
585
+ if account_id in self.accounts:
586
+ raise GatewayError(
587
+ 409, f"an account named {account_id!r} already exists",
588
+ kind="invalid_request_error",
589
+ )
590
+ if not api_key and str(options.get("type") or "anthropic_api") == "anthropic_api":
591
+ raise GatewayError(400, "an API key is required", kind="invalid_request_error")
592
+
593
+ record = self.account_record(account_id, name, api_key, **options)
594
+ await self.store.put_account(record)
595
+ await self.refresh_accounts()
596
+ return self.redacted_account(record)
597
+
598
+ async def update_account(self, account_id: str, **changes: Any) -> Dict[str, Any]:
599
+ if self.is_config_account(account_id):
600
+ return await self.override_account(account_id, **changes)
601
+ records = {str(r.get("id")): r for r in await self.store.list_accounts()}
602
+ record = records.get(account_id)
603
+ if record is None:
604
+ raise GatewayError(404, f"no managed account {account_id!r}", kind="not_found_error")
605
+
606
+ for field_name in (
607
+ "name", "base_url", "cost_tier", "priority", "models",
608
+ "max_concurrency", "spend_cap_usd", "enabled", "observable_limits",
609
+ ):
610
+ if field_name in changes and changes[field_name] is not None:
611
+ record[field_name] = changes[field_name]
612
+ if changes.get("api_key"):
613
+ record["api_key"] = self.secrets.encrypt(str(changes["api_key"]))
614
+
615
+ await self.store.put_account(record)
616
+ await self.refresh_accounts()
617
+
618
+ # Re-enabling is an operator saying "I fixed it". A disabled_reason from the
619
+ # last 401, or a breaker still open from the failures that followed, would
620
+ # otherwise keep the account out of the pool with the toggle reading "on".
621
+ if changes.get("enabled") is True:
622
+ runtime = self.accounts.get(account_id)
623
+ if runtime is not None:
624
+ runtime.disabled_reason = None
625
+ runtime.breaker.record_success()
626
+
627
+ return self.redacted_account(record)
628
+
629
+ async def override_account(self, account_id: str, **changes: Any) -> Dict[str, Any]:
630
+ """Change an operational field on an account the config file declares.
631
+
632
+ The file keeps the credential, the type and the base URL — an override that
633
+ could repoint an account at another host would make the file a lie. What is
634
+ left is exactly what an operator needs at 3am: turn it off, rename it,
635
+ re-tier it, cap it. The change is stored in the gateway, not written back to
636
+ the file, and the console says so; deleting the override puts the file's own
637
+ values back.
638
+ """
639
+ blank: Tuple[Any, ...] = (None, "", {}, ())
640
+ rejected = [
641
+ name for name in changes
642
+ if name not in self.OVERRIDABLE and changes[name] not in blank
643
+ ]
644
+ if rejected:
645
+ raise GatewayError(
646
+ 409,
647
+ "{!r} is declared in tokenbiryani.yaml. The console can change {} on "
648
+ "it; {} must be edited in the file.".format(
649
+ account_id,
650
+ ", ".join(self.OVERRIDABLE),
651
+ ", ".join(sorted(rejected)),
652
+ ),
653
+ kind="invalid_request_error",
654
+ )
655
+ existing = dict(self._account_overrides.get(account_id) or {})
656
+ existing.update({
657
+ name: value for name, value in changes.items()
658
+ if name in self.OVERRIDABLE and value is not None
659
+ })
660
+ await self.store.put_account(self.override_record(account_id, existing))
661
+ await self.refresh_accounts()
662
+
663
+ runtime = self.accounts.get(account_id)
664
+ if runtime is not None and changes.get("enabled") is True:
665
+ runtime.disabled_reason = None
666
+ runtime.breaker.record_success()
667
+ return self.account_detail(account_id) or {"id": account_id}
668
+
669
+ async def clear_override(self, account_id: str) -> bool:
670
+ """Drop a config account's override, restoring what the file says."""
671
+ removed = await self.store.delete_account(account_id)
672
+ await self.refresh_accounts()
673
+ return removed
674
+
675
+ async def delete_account(self, account_id: str) -> bool:
676
+ if self.is_config_account(account_id):
677
+ raise GatewayError(
678
+ 409,
679
+ f"{account_id!r} is declared in the config file; remove it there and reload",
680
+ kind="invalid_request_error",
681
+ )
682
+ removed = await self.store.delete_account(account_id)
683
+ # Before the pool is rebuilt: every session pinned here now names a
684
+ # credential that no longer exists, and leaving them to expire would
685
+ # report a cache break on each one for the rest of the affinity TTL.
686
+ # `clear_override` deliberately does not do this — that account survives,
687
+ # it only stops being overridden.
688
+ try:
689
+ dropped = await self.store.clear_affinity_for_account(account_id)
690
+ if dropped:
691
+ logger.info("released %d session(s) pinned to %s", dropped, account_id)
692
+ except Exception: # noqa: BLE001 - a store blip must not fail the delete
693
+ logger.warning("could not release affinity for %s", account_id, exc_info=True)
694
+ await self.refresh_accounts()
695
+ return removed
696
+
697
+ async def test_account(self, account_id: str) -> Dict[str, Any]:
698
+ """Prove a credential works, before anyone routes real traffic to it."""
699
+ account = self.accounts.get(account_id)
700
+ if account is None:
701
+ raise GatewayError(404, f"no account {account_id!r}", kind="not_found_error")
702
+ result = await self._probe(self.upstreams[account_id])
703
+ headers = result.get("headers") or {}
704
+ if headers:
705
+ account.mirror.update_from_headers(dict(headers), time.time())
706
+ classification = result.pop("classification", None)
707
+ if classification is not None and classification.account_action is AccountAction.DISABLE:
708
+ account.disabled_reason = classification.kind
709
+ return result
710
+
711
+ async def probe_credential(
712
+ self, api_key: str, **options: Any
713
+ ) -> Dict[str, Any]:
714
+ """Test a credential that has not been stored yet.
715
+
716
+ `test_account` is the same probe against an account already in the pool. The
717
+ reason this second entry point exists is the order of operations: storing
718
+ first and testing second turns a typo into a `disabled` row somebody has to
719
+ find and clean up later. Testing first means a wrong key is just a wrong key.
720
+ """
721
+ config = AccountConfig(
722
+ id="probe",
723
+ type=str(options.get("type") or "anthropic_api"),
724
+ api_key=api_key,
725
+ base_url=str(options.get("base_url") or ""),
726
+ options=dict(options.get("options") or {}),
727
+ )
728
+ try:
729
+ upstream = build_upstream(config)
730
+ except Exception as exc: # noqa: BLE001 - a missing adapter extra lands here
731
+ return {"ok": False, "status": None, "latency": 0.0,
732
+ "detail": f"{type(exc).__name__}: {exc}"}
733
+ return await self._probe(upstream)
734
+
735
+ async def _probe(self, upstream: Upstream) -> Dict[str, Any]:
736
+ """One GET /v1/models, and what it says. Shared by both test entry points."""
737
+ started = time.time()
738
+ try:
739
+ response = await self.client.get(
740
+ upstream.url("/v1/models"), headers=upstream.request_headers({}), timeout=15.0
741
+ )
742
+ except Exception as exc: # noqa: BLE001 - the reason is the whole answer
743
+ return {
744
+ "ok": False, "status": None,
745
+ "detail": f"{type(exc).__name__}: {exc}",
746
+ "latency": round(time.time() - started, 3),
747
+ }
748
+ latency = round(time.time() - started, 3)
749
+ result: Dict[str, Any] = {
750
+ "ok": response.status_code < 300,
751
+ "status": response.status_code,
752
+ "latency": latency,
753
+ "headers": dict(response.headers),
754
+ }
755
+ if result["ok"]:
756
+ result["detail"] = "credential accepted"
757
+ else:
758
+ classification = classify(
759
+ response.status_code, dict(response.headers), _safe_json(response)
760
+ )
761
+ result["detail"] = classification.detail or classification.kind
762
+ result["classification"] = classification
763
+ return result
764
+
765
+ async def diagnose_account(self, account_id: str, spend: bool = False) -> Dict[str, Any]:
766
+ """Test the credential, and check the rate-limit headers honestly.
767
+
768
+ The check itself is the project's largest open risk: if the upstream spells a
769
+ header differently, the window stays empty, the account reads as full, and
770
+ routing degrades to round-robin while every meter looks healthy.
771
+
772
+ The subtlety is where the headers come from. `GET /v1/models` is free, which
773
+ makes it the right probe for "does this credential work" — but it carries no
774
+ `anthropic-ratelimit-*` headers at all, so checking against it reports all
775
+ nine missing for a perfectly healthy account. Reporting that to someone who
776
+ has just added their first account would be worse than not checking.
777
+
778
+ So there are three answers, not two:
779
+
780
+ * ``checked`` — headers from a real ``/v1/messages`` response, either one this
781
+ account has already served or one this call paid for.
782
+ * ``not_observed`` — no request has been through this account yet. Nothing is
783
+ wrong; there is simply nothing to check. Send traffic, or ask for ``spend``.
784
+ * ``unobservable`` — this upstream reports no limit headers by design.
785
+ """
786
+ result = await self.test_account(account_id)
787
+ result.pop("headers", None)
788
+ account = self.accounts.get(account_id)
789
+ result["limits"] = None
790
+
791
+ if not result["ok"] or account is None:
792
+ result["limits_source"] = "unchecked"
793
+ return result
794
+
795
+ observable = bool(account.config.observable_limits)
796
+ result["observable"] = observable
797
+ headers: Dict[str, str] = dict(account.last_limit_headers)
798
+ if not headers and spend:
799
+ paid = await self._spend_one_request(account_id)
800
+ if paid is None:
801
+ result["limits_source"] = "unchecked"
802
+ result["detail"] = "the check request failed; the credential is fine"
803
+ return result
804
+ headers = paid
805
+ if not headers:
806
+ # Nothing seen yet. For an unobservable account that is the permanent
807
+ # answer, not a "come back later".
808
+ result["limits_source"] = "not_observed" if observable else "unobservable"
809
+ return result
810
+ if not observable and not looks_unified(headers):
811
+ # A subscription session that reports nothing we can read. Holding it to
812
+ # the API-key checklist would report a fault where there is none.
813
+ result["limits_source"] = "unobservable"
814
+ return result
815
+
816
+ result["limits"] = inspect_headers(headers, time.time())
817
+ result["limits_source"] = "checked"
818
+ return result
819
+
820
+ async def _spend_one_request(self, account_id: str) -> Optional[Dict[str, str]]:
821
+ """One `max_tokens=1` completion, purely to read its headers back.
822
+
823
+ The only place this gateway deliberately spends money, and it happens only
824
+ when a caller has asked for it. `tokenbiryani doctor` is the same trade made
825
+ from the terminal.
826
+ """
827
+ upstream = self.upstreams[account_id]
828
+ payload = {
829
+ "model": self._probe_model(),
830
+ "max_tokens": 1,
831
+ "messages": [{"role": "user", "content": "hi"}],
832
+ }
833
+ try:
834
+ # Through the adapter, not a raw post: Bedrock and Vertex address the
835
+ # model in the URL, and a hand-rolled request would miss that.
836
+ result = await upstream.send(
837
+ self.client, MESSAGES_PATH, payload,
838
+ {"content-type": "application/json"}, timeout=30.0,
839
+ )
840
+ except Exception as exc: # noqa: BLE001 - reported, never raised at a console
841
+ logger.warning("limit-header check failed for %r: %s", account_id, exc)
842
+ return None
843
+ if result.status >= 300:
844
+ return None
845
+ account = self.accounts.get(account_id)
846
+ if account is not None:
847
+ account.observe_headers(dict(result.headers), time.time())
848
+ return {
849
+ str(k).lower(): str(v)
850
+ for k, v in result.headers.items()
851
+ if str(k).lower().startswith("anthropic-ratelimit-")
852
+ }
853
+
854
+ def _probe_model(self) -> str:
855
+ """A model this pool is configured for, so the check is not rejected outright."""
856
+ for name in self.config.pricing:
857
+ # The bundled table is keyed by prefix (`claude-sonnet-5*`) so that dated
858
+ # model ids price correctly; the prefix itself is a valid model id.
859
+ stripped = name.rstrip("*")
860
+ if stripped and not stripped.endswith("-"):
861
+ return stripped
862
+ return "claude-sonnet-5"
863
+
864
+ def redacted_account(self, record: Dict[str, Any]) -> Dict[str, Any]:
865
+ # Every field holding a credential is stripped by name. A subscription's
866
+ # refresh token is a longer-lived secret than the access token it mints, so
867
+ # forgetting it here would be the worse of the two leaks.
868
+ out = {k: v for k, v in record.items() if k not in _CREDENTIAL_FIELDS}
869
+ out["source"] = "managed"
870
+ out["has_credential"] = bool(record.get("api_key") or record.get("oauth_access"))
871
+ if record.get("type") == "oauth":
872
+ out["session_expires_at"] = record.get("oauth_expires_at")
873
+ out["can_refresh"] = bool(record.get("oauth_refresh"))
874
+ return out
875
+
876
+ # ---- subscription sessions ----------------------------------------------
877
+
878
+ @property
879
+ def oauth(self) -> OAuthClient:
880
+ if self._oauth is None:
881
+ settings = self.config.oauth
882
+ self._oauth = OAuthClient(
883
+ client_id=settings.client_id,
884
+ authorize_url=settings.authorize_url,
885
+ token_url=settings.token_url,
886
+ redirect_uri=settings.redirect_uri,
887
+ scopes=settings.scopes,
888
+ client=self.client,
889
+ )
890
+ return self._oauth
891
+
892
+ def oauth_token(self, account_id: str) -> str:
893
+ """The current access token for a subscription account.
894
+
895
+ Synchronous on purpose: it is called from `auth_headers` on the request
896
+ path. Refreshing happens in the background task, never here — a request
897
+ must not block on a token endpoint.
898
+ """
899
+ token = self._oauth_tokens.get(account_id)
900
+ if not token:
901
+ raise GatewayError(
902
+ 401,
903
+ f"account {account_id!r} has no active session. Log in again from the "
904
+ "Accounts screen.",
905
+ kind="authentication_error",
906
+ )
907
+ return token
908
+
909
+ async def oauth_start(self, account_id: str, name: str = "") -> Dict[str, Any]:
910
+ """Begin a login. Returns the URL to open and the handle to complete with."""
911
+ self.oauth.require_configured()
912
+ account_id = (account_id or "").strip()
913
+ if not _SAFE_ID.match(account_id):
914
+ raise GatewayError(
915
+ 400,
916
+ "an account id may contain letters, digits, dashes and underscores only",
917
+ kind="invalid_request_error",
918
+ )
919
+ await self.refresh_accounts()
920
+ if account_id in self.accounts:
921
+ raise GatewayError(
922
+ 409, f"an account named {account_id!r} already exists",
923
+ kind="invalid_request_error",
924
+ )
925
+ login = PendingLogin(
926
+ state=uuid.uuid4().hex, verifier=make_verifier(),
927
+ account_id=account_id, name=name or account_id,
928
+ )
929
+ now = time.time()
930
+ # Drop anything abandoned, so a browser closed mid-login cannot accumulate.
931
+ self._logins = {k: v for k, v in self._logins.items() if not v.expired(now)}
932
+ self._logins[login.state] = login
933
+ return {
934
+ "state": login.state,
935
+ "authorize_url": self.oauth.authorize_url_for(login),
936
+ "manual": not self.config.oauth.redirect_uri,
937
+ }
938
+
939
+ async def oauth_complete(self, state: str, code: str) -> Dict[str, Any]:
940
+ """Finish a login: exchange the code and store the session as an account."""
941
+ login = self._logins.pop(state, None)
942
+ if login is None:
943
+ raise GatewayError(
944
+ 400,
945
+ "that login is not in progress — it may have expired, or already been "
946
+ "completed. Start again from the Accounts screen.",
947
+ kind="invalid_request_error",
948
+ )
949
+ if login.expired(time.time()):
950
+ raise GatewayError(400, "that login expired; start again",
951
+ kind="invalid_request_error")
952
+ code = (code or "").strip()
953
+ if not code:
954
+ raise GatewayError(400, "no authorization code was supplied",
955
+ kind="invalid_request_error")
956
+ try:
957
+ tokens = await self.oauth.exchange(code, login.verifier)
958
+ except OAuthError as exc:
959
+ raise GatewayError(400, str(exc), kind="invalid_request_error") from exc
960
+
961
+ record = self.account_record(
962
+ login.account_id, login.name, "",
963
+ type="oauth",
964
+ # Nothing to reserve against and nothing to read: a subscription session
965
+ # sends no rate-limit headers.
966
+ observable_limits=False,
967
+ )
968
+ self._store_session(record, tokens)
969
+ await self.store.put_account(record)
970
+ await self.refresh_accounts()
971
+ return self.redacted_account(record)
972
+
973
+ def _store_session(self, record: Dict[str, Any], tokens: OAuthTokens) -> None:
974
+ record["oauth_access"] = self.secrets.encrypt(tokens.access_token)
975
+ record["oauth_refresh"] = self.secrets.encrypt(tokens.refresh_token)
976
+ record["oauth_expires_at"] = tokens.expires_at
977
+ record["oauth_scope"] = tokens.scope
978
+
979
+ async def refresh_oauth_sessions(self) -> None:
980
+ """Renew subscription tokens before they expire.
981
+
982
+ Reading a file the Claude CLI keeps fresh covers a single session and nothing
983
+ else. A gateway holding several has no CLI to lean on, so it renews them
984
+ itself.
985
+ """
986
+ settings = self.config.oauth
987
+ now = time.time()
988
+ for record in await self.store.list_accounts():
989
+ if record.get("type") != "oauth":
990
+ continue
991
+ account_id = str(record.get("id") or "")
992
+ expires_at = record.get("oauth_expires_at")
993
+ if not isinstance(expires_at, (int, float)):
994
+ # Unknown expiry: nothing to renew against. The token is used until
995
+ # the upstream rejects it, which the error taxonomy already handles.
996
+ continue
997
+ if float(expires_at) - settings.refresh_skew_seconds > now:
998
+ continue
999
+ try:
1000
+ refresh_token = self.secrets.decrypt(str(record.get("oauth_refresh") or ""))
1001
+ tokens = await self.oauth.refresh(refresh_token)
1002
+ except (OAuthError, SecretError) as exc:
1003
+ # Disable rather than retry forever: an expired session that cannot
1004
+ # be renewed needs a human, and silently failing every request while
1005
+ # reading "ready" is the outcome worth avoiding.
1006
+ logger.warning("could not refresh session for %r: %s", account_id, exc)
1007
+ runtime = self.accounts.get(account_id)
1008
+ if runtime is not None:
1009
+ runtime.disabled_reason = "session expired — log in again"
1010
+ continue
1011
+ # Some providers do not reissue a refresh token; keep the one we have.
1012
+ if not tokens.refresh_token:
1013
+ tokens.refresh_token = refresh_token
1014
+ self._store_session(record, tokens)
1015
+ await self.store.put_account(record)
1016
+ self._cache_session(record)
1017
+ runtime = self.accounts.get(account_id)
1018
+ if runtime is not None:
1019
+ runtime.disabled_reason = None
1020
+ logger.info("renewed subscription session for %r", account_id)
1021
+
1022
+ async def watch_oauth_sessions(self) -> None:
1023
+ interval = max(5.0, self.config.oauth.refresh_interval_seconds)
1024
+ while True:
1025
+ await asyncio.sleep(interval)
1026
+ try:
1027
+ await self.refresh_oauth_sessions()
1028
+ except Exception as exc: # noqa: BLE001 - a blip must not kill routing
1029
+ logger.warning("session refresh pass failed: %s", exc)
1030
+
1031
+ async def create_key(self, name: str, **options: Any) -> Tuple[str, Dict[str, Any]]:
1032
+ """Mint a managed key. The plaintext is returned once and never stored."""
1033
+ name = (name or "").strip()
1034
+ if not name:
1035
+ raise GatewayError(400, "a key needs a name", kind="invalid_request_error")
1036
+ await self.refresh_keys()
1037
+ if self.keys.by_name(name) is not None:
1038
+ raise GatewayError(
1039
+ 409, f"a key named {name!r} already exists", kind="invalid_request_error"
1040
+ )
1041
+
1042
+ pool = list(options.get("pool") or [])
1043
+ unknown = [account_id for account_id in pool if account_id not in self.accounts]
1044
+ if unknown:
1045
+ raise GatewayError(
1046
+ 400,
1047
+ "unknown account(s) in pool: {}".format(", ".join(sorted(unknown))),
1048
+ kind="invalid_request_error",
1049
+ )
1050
+
1051
+ plaintext = generate_key()
1052
+ template = KeyConfig(
1053
+ key="",
1054
+ name=name,
1055
+ models=list(options.get("models") or ["*"]),
1056
+ pool=pool,
1057
+ rpm=options.get("rpm"),
1058
+ spend_cap_usd=options.get("spend_cap_usd"),
1059
+ priority=str(options.get("priority") or "interactive"),
1060
+ max_wait_seconds=options.get("max_wait_seconds"),
1061
+ session_cap_usd=options.get("session_cap_usd"),
1062
+ session_max_turns=options.get("session_max_turns"),
1063
+ admin=bool(options.get("admin")),
1064
+ )
1065
+ record = record_from_config(template, plaintext)
1066
+ await self.store.put_key(record)
1067
+ await self.refresh_keys()
1068
+ redacted = {k: v for k, v in record.items() if k != "key_hash"}
1069
+ return plaintext, redacted
1070
+
1071
+ #: What a key's record may have changed after it is minted. The key material
1072
+ #: itself is absent: a key is rotated by issuing a new one, not by editing a
1073
+ #: hash in place, and `admin` is absent because quietly promoting a tenant key
1074
+ #: is not an edit anybody should be able to make by typo.
1075
+ KEY_EDITABLE = (
1076
+ "models", "pool", "rpm", "spend_cap_usd", "priority", "max_wait_seconds",
1077
+ "session_cap_usd", "session_max_turns",
1078
+ )
1079
+
1080
+ async def update_key(self, name: str, **changes: Any) -> Dict[str, Any]:
1081
+ """Edit a managed key's scope in place.
1082
+
1083
+ Without this, narrowing a key's pool means revoking it and reissuing — which
1084
+ means every client holding it breaks. A key scoped to an account that has
1085
+ since been removed is the common case, and it blocks config reload until
1086
+ somebody fixes it.
1087
+ """
1088
+ if self.keys.is_config_key(name):
1089
+ raise GatewayError(
1090
+ 409,
1091
+ f"{name!r} is declared in the config file; edit it there and reload",
1092
+ kind="invalid_request_error",
1093
+ )
1094
+ records = {str(r.get("name")): r for r in await self.store.list_keys()}
1095
+ record = records.get(name)
1096
+ if record is None:
1097
+ raise GatewayError(404, f"no managed key {name!r}", kind="not_found_error")
1098
+
1099
+ rejected = [k for k in changes if k not in self.KEY_EDITABLE]
1100
+ if rejected:
1101
+ raise GatewayError(
1102
+ 400,
1103
+ "cannot change {} on a key. Editable: {}".format(
1104
+ ", ".join(sorted(rejected)), ", ".join(self.KEY_EDITABLE)
1105
+ ),
1106
+ kind="invalid_request_error",
1107
+ )
1108
+ if changes.get("pool") is not None:
1109
+ pool = list(changes["pool"])
1110
+ unknown = [a for a in pool if a not in self.accounts]
1111
+ if unknown:
1112
+ raise GatewayError(
1113
+ 400,
1114
+ "unknown account(s) in pool: {}".format(", ".join(sorted(unknown))),
1115
+ kind="invalid_request_error",
1116
+ )
1117
+ changes["pool"] = pool
1118
+ if changes.get("priority") is not None:
1119
+ priority = str(changes["priority"])
1120
+ if priority not in ("interactive", "batch", "background"):
1121
+ raise GatewayError(
1122
+ 400,
1123
+ "priority must be interactive, batch or background",
1124
+ kind="invalid_request_error",
1125
+ )
1126
+ changes["priority"] = priority
1127
+
1128
+ # rpm, cap and wait are nullable: "no limit" has to be expressible, or a
1129
+ # cap set once could never be lifted without reissuing the key.
1130
+ nullable = ("rpm", "spend_cap_usd", "max_wait_seconds")
1131
+ for field_name, value in changes.items():
1132
+ if value is None and field_name not in nullable:
1133
+ continue
1134
+ record[field_name] = value
1135
+ await self.store.put_key(record)
1136
+ await self.refresh_keys()
1137
+ return {k: v for k, v in record.items() if k != "key_hash"}
1138
+
1139
+ async def revoke_key(self, name: str) -> bool:
1140
+ """Revoke a managed key. Config keys belong to the file, not to the API."""
1141
+ if self.keys.is_config_key(name):
1142
+ raise GatewayError(
1143
+ 409,
1144
+ f"{name!r} is declared in the config file; remove it there and reload",
1145
+ kind="invalid_request_error",
1146
+ )
1147
+ removed = await self.store.delete_key(name)
1148
+ await self.refresh_keys()
1149
+ return removed
1150
+
1151
+ def reload(self, config: Config) -> Dict[str, Any]:
1152
+ """Swap in a new config without dropping in-flight state.
1153
+
1154
+ Accounts that survive the reload keep their limit mirror, breaker and
1155
+ counters — a config edit must not hand every account a clean bill of health
1156
+ and re-stampede an upstream that was cooling for a good reason.
1157
+ """
1158
+ before = set(self.accounts)
1159
+ after = [a.id for a in config.accounts]
1160
+
1161
+ for account_config in config.accounts:
1162
+ existing = self.accounts.get(account_config.id)
1163
+ if existing is None:
1164
+ self.accounts[account_config.id] = AccountRuntime(
1165
+ config=account_config,
1166
+ mirror=_mirror_for(account_config),
1167
+ breaker=CircuitBreaker(
1168
+ failure_threshold=config.breaker.failure_threshold,
1169
+ cooldown_seconds=config.breaker.cooldown_seconds,
1170
+ ),
1171
+ )
1172
+ self.upstreams[account_config.id] = self._make_upstream(account_config)
1173
+ continue
1174
+ credentials_changed = (
1175
+ existing.config.api_key != account_config.api_key
1176
+ or existing.config.base_url != account_config.base_url
1177
+ or existing.config.type != account_config.type
1178
+ )
1179
+ existing.config = account_config
1180
+ existing.mirror.observable = account_config.observable_limits
1181
+ existing.mirror.assumed_headroom = account_config.assumed_headroom
1182
+ existing.breaker.failure_threshold = config.breaker.failure_threshold
1183
+ existing.breaker.cooldown_seconds = config.breaker.cooldown_seconds
1184
+ if credentials_changed:
1185
+ self.upstreams[account_config.id] = self._make_upstream(account_config)
1186
+ # New credentials deserve a fresh chance: a rotated key is the usual
1187
+ # reason an account was disabled in the first place.
1188
+ existing.disabled_reason = None
1189
+ existing.breaker.record_success()
1190
+
1191
+ # Only config accounts are governed by the file; managed ones live in the
1192
+ # store and must survive a reload of it.
1193
+ removed = [
1194
+ account_id for account_id in before
1195
+ if account_id not in after and account_id in self._config_account_ids
1196
+ ]
1197
+ for account_id in removed:
1198
+ self.accounts.pop(account_id, None)
1199
+ self.upstreams.pop(account_id, None)
1200
+ self._config_account_ids = set(after)
1201
+
1202
+ managed = list(self.keys._managed) # noqa: SLF001 - same object's own state
1203
+ self.config = config
1204
+ self.keys = KeyRegistry(config.keys)
1205
+ self.keys.set_managed(managed)
1206
+ self.router = Router(config.routing.strategy, config.routing.weights)
1207
+ # Reconfigured, not rebuilt: what the estimator has learned about each model
1208
+ # and what the advisor has counted are observed state, and a reload must not
1209
+ # send the pool back to leasing ceilings for twenty requests of every model.
1210
+ # Managed keys are preserved a few lines above for the same reason.
1211
+ self.estimator.reconfigure(
1212
+ mode=config.routing.output_estimate,
1213
+ quantile=config.routing.output_estimate_quantile,
1214
+ min_samples=config.routing.output_estimate_min_samples,
1215
+ window=config.routing.output_estimate_window,
1216
+ floor=config.routing.output_estimate_floor,
1217
+ max_undershoot=config.routing.output_estimate_max_undershoot,
1218
+ )
1219
+ self.cache_advisor.reconfigure(config.cache.advice_min_requests)
1220
+ self.pacing.reconfigure(**_pacing_settings(config))
1221
+ self.gate.max_size = config.queue.max_size
1222
+ self.gate.default_max_wait = config.queue.default_max_wait_seconds
1223
+ self._file_pricing_table = config.pricing_table
1224
+ # The file has just replaced every account's settings and the router. A
1225
+ # console setting is an overlay on the file, not a one-time edit to it, so
1226
+ # both go back on top — otherwise saving an unrelated line in the YAML
1227
+ # would quietly revert them.
1228
+ self._apply_all_overrides()
1229
+ self.apply_settings()
1230
+
1231
+ added = [account_id for account_id in after if account_id not in before]
1232
+ return {
1233
+ "accounts": after,
1234
+ "added": added,
1235
+ "removed": removed,
1236
+ "strategy": self.router.strategy,
1237
+ }
1238
+
1239
+ def reload_from_path(self) -> Dict[str, Any]:
1240
+ """Re-read the config file. A broken file leaves the running config alone."""
1241
+ if not self.config.path:
1242
+ raise GatewayError(
1243
+ 400, "gateway was not started from a config file", kind="invalid_request_error"
1244
+ )
1245
+ try:
1246
+ fresh = Config.load(self.config.path)
1247
+ except (OSError, ConfigError) as exc:
1248
+ raise GatewayError(
1249
+ 400, f"config reload failed, keeping the running config: {exc}",
1250
+ kind="invalid_request_error",
1251
+ ) from exc
1252
+ return self.reload(fresh)
1253
+
1254
+ async def watch_config(self) -> None:
1255
+ """Poll the config file's mtime and reload when it moves."""
1256
+ interval = self.config.server.hot_reload_seconds
1257
+ if not self.config.path or interval <= 0:
1258
+ return
1259
+ try:
1260
+ last = os.path.getmtime(self.config.path)
1261
+ except OSError:
1262
+ return
1263
+ while True:
1264
+ await asyncio.sleep(interval)
1265
+ try:
1266
+ current = os.path.getmtime(self.config.path)
1267
+ except OSError:
1268
+ continue
1269
+ if current == last:
1270
+ continue
1271
+ last = current
1272
+ try:
1273
+ result = self.reload_from_path()
1274
+ except GatewayError as exc:
1275
+ logger.warning("config reload rejected: %s", exc.message)
1276
+ continue
1277
+ logger.info("config reloaded: %s", json.dumps(result))
1278
+
1279
+ async def aclose(self) -> None:
1280
+ if self._client is not None and self._owns_client:
1281
+ await self._client.aclose()
1282
+ self._client = None
1283
+ await self.store.close()
1284
+
1285
+ @property
1286
+ def client(self) -> httpx.AsyncClient:
1287
+ if self._client is None:
1288
+ raise RuntimeError("gateway.startup() has not run")
1289
+ return self._client
1290
+
1291
+ # ---- public API ----------------------------------------------------------
1292
+
1293
+ async def complete(
1294
+ self,
1295
+ body: Dict[str, Any],
1296
+ headers: Mapping[str, str],
1297
+ key: KeyConfig,
1298
+ path: str = MESSAGES_PATH,
1299
+ ) -> Completion:
1300
+ """Non-streaming Messages request, with transparent failover."""
1301
+ plan = await self._prepare(body, headers, key)
1302
+ last_error: Optional[GatewayError] = None
1303
+
1304
+ while True:
1305
+ try:
1306
+ account, decision = await self._acquire(plan)
1307
+ except _SpillToBatch:
1308
+ return await self._spill(plan)
1309
+ result, classification, latency = await self._dispatch(account, plan, path)
1310
+ plan.event.attempts.append(
1311
+ Attempt(
1312
+ account_id=account.id,
1313
+ status=result.status if result else None,
1314
+ kind=classification.kind,
1315
+ latency=round(latency, 4),
1316
+ detail=classification.detail[:200],
1317
+ )
1318
+ )
1319
+ plan.event.decision = decision.to_dict()
1320
+
1321
+ if classification.kind == "ok" and result is not None:
1322
+ usage = Usage.from_body(result.body)
1323
+ await self._settle(account, plan, decision, usage, latency, result.status)
1324
+ return Completion(
1325
+ status=result.status,
1326
+ headers=_response_headers(result.headers, account.id),
1327
+ content=result.raw,
1328
+ event=plan.event,
1329
+ )
1330
+
1331
+ account.apply(classification, time.time(), plan.model)
1332
+ if classification.request_action is RequestAction.RETURN and result is not None:
1333
+ plan.event.status = result.status
1334
+ plan.event.error = classification.kind
1335
+ plan.event.latency = time.time() - plan.started
1336
+ self.events.record(plan.event)
1337
+ return Completion(
1338
+ status=result.status,
1339
+ headers=_response_headers(result.headers, account.id),
1340
+ content=result.raw,
1341
+ event=plan.event,
1342
+ )
1343
+
1344
+ last_error = GatewayError(
1345
+ 502 if result is None else result.status,
1346
+ classification.detail or classification.kind,
1347
+ kind=classification.kind,
1348
+ )
1349
+ if not self._may_retry(plan, account, classification):
1350
+ await self._fail(plan, last_error)
1351
+ raise last_error
1352
+ await self._backoff(plan)
1353
+
1354
+ async def stream(
1355
+ self,
1356
+ body: Dict[str, Any],
1357
+ headers: Mapping[str, str],
1358
+ key: KeyConfig,
1359
+ path: str = MESSAGES_PATH,
1360
+ ) -> Tuple[Dict[str, str], AsyncIterator[bytes]]:
1361
+ """Streaming Messages request.
1362
+
1363
+ Failover is transparent right up until the first ``content_block_delta``
1364
+ reaches the client. After that the client is already rendering, so a failure
1365
+ can only be reported as an SSE error frame — that boundary is the one thing
1366
+ the gateway cannot hide, and it is documented rather than papered over.
1367
+ """
1368
+ plan = await self._prepare(body, headers, key, streamed=True)
1369
+ response_headers = {
1370
+ "content-type": "text/event-stream",
1371
+ "cache-control": "no-cache",
1372
+ "connection": "keep-alive",
1373
+ }
1374
+ return response_headers, self._stream_iter(plan, path)
1375
+
1376
+ # ---- internals -----------------------------------------------------------
1377
+
1378
+ async def _prepare(
1379
+ self,
1380
+ body: Dict[str, Any],
1381
+ headers: Mapping[str, str],
1382
+ key: KeyConfig,
1383
+ streamed: bool = False,
1384
+ ) -> _Plan:
1385
+ model = str(body.get("model") or "")
1386
+ if not model:
1387
+ raise GatewayError(400, "request is missing 'model'", kind="invalid_request_error")
1388
+ if not key.supports_model(model):
1389
+ raise GatewayError(
1390
+ 403,
1391
+ f"key {key.name!r} is not allowed to use model {model!r}",
1392
+ kind="permission_error",
1393
+ )
1394
+
1395
+ now = time.time()
1396
+ if key.rpm is not None:
1397
+ count = await self.store.record_key_request(key.name, now)
1398
+ if count > key.rpm:
1399
+ raise GatewayError(
1400
+ 429,
1401
+ f"key {key.name!r} is over its {key.rpm} rpm limit",
1402
+ kind="rate_limit_error",
1403
+ retry_after=60.0,
1404
+ )
1405
+ if key.spend_cap_usd is not None:
1406
+ spent = await self.store.get_spend(
1407
+ SCOPE_KEY, key.name, self.config.spend.window_seconds
1408
+ )
1409
+ if spent >= key.spend_cap_usd:
1410
+ raise GatewayError(
1411
+ 429,
1412
+ f"key {key.name!r} has spent ${spent:.2f} of its "
1413
+ f"${key.spend_cap_usd:.2f} cap in the last "
1414
+ f"{self.config.spend.window_hours:.0f}h",
1415
+ kind="rate_limit_error",
1416
+ )
1417
+
1418
+ lowered = {k.lower(): v for k, v in headers.items()}
1419
+ priority = parse_priority(
1420
+ lowered.get(PRIORITY_HEADER), parse_priority(key.priority)
1421
+ )
1422
+ max_wait = _positive_float(
1423
+ lowered.get(MAX_WAIT_HEADER),
1424
+ key.max_wait_seconds
1425
+ if key.max_wait_seconds is not None
1426
+ else self.config.queue.default_max_wait_seconds,
1427
+ )
1428
+
1429
+ requested_model = model
1430
+ model, body = await self._downshift(model, body, key, priority)
1431
+
1432
+ # Diagnosis before dispatch: whether the caller asked for caching at all,
1433
+ # and how much stable head a breakpoint would have covered. Booleans and
1434
+ # token counts only — the body itself is never kept.
1435
+ marked = has_cache_breakpoint(body)
1436
+ prefix_tokens = cacheable_prefix_tokens(body)
1437
+ if not marked and self.config.cache.auto_breakpoint:
1438
+ body, inserted = insert_cache_breakpoint(body)
1439
+ marked = inserted
1440
+
1441
+ session = compute_session_key(body, headers, scope=key.name)
1442
+ await self._check_session_caps(key, session)
1443
+ owner = await self.store.get_affinity(session)
1444
+ estimate = estimate_request(
1445
+ body,
1446
+ self.config.routing.estimate_safety_margin,
1447
+ predictor=self.estimator.predict,
1448
+ )
1449
+ request_id = "req_" + uuid.uuid4().hex[:20]
1450
+
1451
+ event = RequestEvent(
1452
+ request_id=request_id,
1453
+ started_at=now,
1454
+ model=model,
1455
+ key_name=key.name,
1456
+ session_key=session,
1457
+ streamed=streamed,
1458
+ )
1459
+ event.priority = priority_name(priority)
1460
+ event.model_requested = requested_model if requested_model != model else ""
1461
+ event.cache_breakpoint = marked
1462
+ event.prefix_tokens = prefix_tokens
1463
+ return _Plan(
1464
+ body=body,
1465
+ headers=dict(headers),
1466
+ key=key,
1467
+ model=model,
1468
+ estimate=estimate,
1469
+ session=session,
1470
+ owner=owner,
1471
+ event=event,
1472
+ started=now,
1473
+ deadline=now + self.config.retry.deadline_seconds,
1474
+ priority=priority,
1475
+ max_wait=max_wait,
1476
+ )
1477
+
1478
+ async def _cached_pace(self) -> Optional[float]:
1479
+ now = time.time()
1480
+ if self._pace_cached_at + PACE_CACHE_SECONDS > now:
1481
+ return self._pace_value
1482
+ self._pace_value = await self._pace()
1483
+ self._pace_cached_at = now
1484
+ return self._pace_value
1485
+
1486
+ async def _ahead_of_pace(self) -> bool:
1487
+ """True when enforcement should act. False whenever it cannot be sure."""
1488
+ if not self.pacing.enforcing:
1489
+ return False
1490
+ pace = await self._cached_pace()
1491
+ return pace is not None and pace > self.pacing.ahead_threshold
1492
+
1493
+ async def _downshift(
1494
+ self, model: str, body: Dict[str, Any], key: KeyConfig, priority: int
1495
+ ) -> Tuple[str, Dict[str, Any]]:
1496
+ """Substitute a cheaper model for batch work while ahead of pace.
1497
+
1498
+ The one lever here that changes *what the caller gets* rather than when or
1499
+ where they get it, so it is off unless configured, batch-priority only, and
1500
+ recorded on the request. It also refuses to substitute a model the key is
1501
+ not allowed to use: a pacing policy must not widen what a tenant can reach.
1502
+ """
1503
+ downshift = self.config.pacing.model_downshift
1504
+ if not downshift or priority < PRIORITY_BATCH:
1505
+ return model, body
1506
+ if not await self._ahead_of_pace():
1507
+ return model, body
1508
+ substituted = map_model(model, downshift)
1509
+ if substituted == model or not key.supports_model(substituted):
1510
+ return model, body
1511
+ updated = dict(body)
1512
+ updated["model"] = substituted
1513
+ return substituted, updated
1514
+
1515
+ async def _apply_pacing(self, plan: _Plan) -> None:
1516
+ """Hold a batch request back while the pool is ahead of its quota pace.
1517
+
1518
+ Deliberately a wait rather than a rejection. Waiting is what spends a window
1519
+ more slowly; rejecting just moves the same work to whenever the client
1520
+ retries, which is usually immediately.
1521
+ """
1522
+ if not self.pacing.enforcing or plan.priority < PRIORITY_BATCH:
1523
+ return
1524
+ if not await self._ahead_of_pace():
1525
+ return
1526
+
1527
+ # Cheaper than waiting, so it is tried first: the Batches API is priced
1528
+ # below standard and spends a different upstream limit, which is the whole
1529
+ # reason being ahead of pace is worth acting on at all.
1530
+ if self.config.pacing.prefer_batch_lane_when_ahead and self._may_spill(plan):
1531
+ raise _SpillToBatch()
1532
+
1533
+ delay = self.pacing.delay_for(
1534
+ plan.priority, PRIORITY_BATCH, await self._cached_pace()
1535
+ )
1536
+ budget = min(
1537
+ delay,
1538
+ max(0.0, plan.deadline - time.time()),
1539
+ max(0.0, plan.max_wait - plan.queued_seconds),
1540
+ )
1541
+ if budget <= 0:
1542
+ return
1543
+ started = time.time()
1544
+ try:
1545
+ await asyncio.sleep(budget)
1546
+ finally:
1547
+ waited = time.time() - started
1548
+ plan.queued_seconds += waited
1549
+ plan.event.paced_for = round(waited, 3)
1550
+
1551
+ async def _check_session_caps(self, key: KeyConfig, session: str) -> None:
1552
+ """Bound one conversation, where `spend_cap_usd` bounds the whole key.
1553
+
1554
+ A key cap catches a tenant overspending across everything they do. Nothing
1555
+ caught a single agent loop doing it inside that allowance, and one loop
1556
+ resending a large prefix a few hundred times can be the whole cap with one
1557
+ key's name on it — visible only afterwards, in a chart.
1558
+ """
1559
+ if not self.config.sessions.track:
1560
+ return
1561
+ window = self.config.spend.window_seconds
1562
+
1563
+ if key.session_max_turns is not None:
1564
+ turns = await self.store.get_spend(SCOPE_SESSION_TURNS, session, window)
1565
+ if turns >= key.session_max_turns:
1566
+ raise GatewayError(
1567
+ 429,
1568
+ f"this conversation has run {int(turns)} turns, at the "
1569
+ f"{key.session_max_turns}-turn limit for key {key.name!r}. Start "
1570
+ "a new conversation, or raise session_max_turns",
1571
+ kind="rate_limit_error",
1572
+ )
1573
+
1574
+ if key.session_cap_usd is not None:
1575
+ spent = await self.store.get_spend(SCOPE_SESSION, session, window)
1576
+ if spent >= key.session_cap_usd:
1577
+ raise GatewayError(
1578
+ 429,
1579
+ f"this conversation has spent ${spent:.2f} of its "
1580
+ f"${key.session_cap_usd:.2f} limit for key {key.name!r} in the "
1581
+ f"last {self.config.spend.window_hours:.0f}h",
1582
+ kind="rate_limit_error",
1583
+ )
1584
+
1585
+ async def sessions_report(self) -> Dict[str, Any]:
1586
+ """The most expensive conversations in the spend window, worst first.
1587
+
1588
+ Sessions are unbounded in number, so this reports the tail that matters
1589
+ rather than all of them: what a runaway looks like is one session far above
1590
+ every other, and that is visible in twenty rows.
1591
+ """
1592
+ settings = self.config.sessions
1593
+ if not settings.track:
1594
+ return {"tracking": False, "sessions": []}
1595
+
1596
+ window = self.config.spend.window_seconds
1597
+ spend = await self.store.spend_by_scope(SCOPE_SESSION, window)
1598
+ turns = await self.store.spend_by_scope(SCOPE_SESSION_TURNS, window)
1599
+
1600
+ rows: List[Dict[str, Any]] = []
1601
+ for session in set(spend) | set(turns):
1602
+ session_turns = int(turns.get(session, 0.0))
1603
+ session_spend = float(spend.get(session, 0.0))
1604
+ reasons = []
1605
+ if settings.runaway_turns and session_turns >= settings.runaway_turns:
1606
+ reasons.append(f"{session_turns} turns")
1607
+ if (
1608
+ settings.runaway_spend_usd
1609
+ and session_spend >= settings.runaway_spend_usd
1610
+ ):
1611
+ reasons.append(f"${session_spend:.2f}")
1612
+ rows.append({
1613
+ "session_key": session,
1614
+ "turns": session_turns,
1615
+ "spend_usd": round(session_spend, 6),
1616
+ "runaway": bool(reasons),
1617
+ "why": ", ".join(reasons),
1618
+ })
1619
+
1620
+ rows.sort(key=lambda row: (row["spend_usd"], row["turns"]), reverse=True)
1621
+ return {
1622
+ "tracking": True,
1623
+ "window_seconds": window,
1624
+ "runaway_turns": settings.runaway_turns,
1625
+ "runaway_spend_usd": settings.runaway_spend_usd,
1626
+ "sessions": rows[: max(1, settings.report_limit)],
1627
+ }
1628
+
1629
+ async def _acquire(self, plan: _Plan) -> Tuple[AccountRuntime, Decision]:
1630
+ """Route, or wait on the queue for capacity that is known to be coming."""
1631
+ await self._apply_pacing(plan)
1632
+ while True:
1633
+ now = time.time()
1634
+ if now >= plan.deadline:
1635
+ waited = self.config.retry.deadline_seconds
1636
+ raise GatewayError(
1637
+ 504,
1638
+ f"no capacity became available within {waited:.0f}s",
1639
+ kind="timeout_error",
1640
+ )
1641
+ ctx = RoutingContext(
1642
+ model=plan.model,
1643
+ estimate=plan.estimate,
1644
+ session_key=plan.session,
1645
+ now=now,
1646
+ sticky_owner=plan.owner,
1647
+ pool=plan.key.pool,
1648
+ exclude=plan.excluded,
1649
+ )
1650
+ decision = self.router.select(self.accounts.values(), ctx)
1651
+ if decision.chosen is not None:
1652
+ plan.event.queued_for = round(plan.queued_seconds, 3)
1653
+ return decision.chosen, decision
1654
+
1655
+ admission = admission_check(False, decision.soonest_available)
1656
+ if admission.retry_after is None:
1657
+ plan.event.decision = decision.to_dict()
1658
+ error = GatewayError(
1659
+ 503,
1660
+ admission.reason or "no eligible account",
1661
+ kind="overloaded_error",
1662
+ )
1663
+ await self._fail(plan, error)
1664
+ raise error
1665
+
1666
+ if self._may_spill(plan):
1667
+ raise _SpillToBatch()
1668
+
1669
+ remaining_wait = plan.max_wait - plan.queued_seconds
1670
+ if remaining_wait <= 0:
1671
+ error = GatewayError(
1672
+ 429,
1673
+ f"waited {plan.max_wait:.0f}s for pool capacity without any becoming "
1674
+ "available",
1675
+ kind="rate_limit_error",
1676
+ retry_after=decision.soonest_available,
1677
+ )
1678
+ await self._fail(plan, error)
1679
+ raise error
1680
+
1681
+ budget = min(
1682
+ plan.deadline - now,
1683
+ remaining_wait,
1684
+ admission.retry_after + 0.25,
1685
+ )
1686
+ waited_from = time.time()
1687
+ try:
1688
+ await self.gate.wait(plan.priority, budget)
1689
+ except QueueFull as exc:
1690
+ error = GatewayError(
1691
+ 429,
1692
+ str(exc),
1693
+ kind="rate_limit_error",
1694
+ retry_after=decision.soonest_available,
1695
+ )
1696
+ await self._fail(plan, error)
1697
+ raise error from exc
1698
+ finally:
1699
+ plan.queued_seconds += time.time() - waited_from
1700
+
1701
+ def _may_spill(self, plan: _Plan) -> bool:
1702
+ return (
1703
+ self.config.batch.enabled
1704
+ and plan.priority >= PRIORITY_BATCH
1705
+ and not plan.event.streamed
1706
+ and not plan.spilled
1707
+ and self._batch_account(plan) is not None
1708
+ )
1709
+
1710
+ def _batch_account(self, plan: _Plan) -> Optional[AccountRuntime]:
1711
+ """Batches have their own upstream rate-limit pool, so a messages-saturated
1712
+ account is still a fine place to send one. Prefer the cache owner."""
1713
+ pool = set(plan.key.pool) if plan.key.pool else None
1714
+ candidates = [
1715
+ account
1716
+ for account in self.accounts.values()
1717
+ if (pool is None or account.id in pool)
1718
+ and account.state(time.time()) is not AccountState.DISABLED
1719
+ and account.config.supports_model(plan.model)
1720
+ and plan.model not in account.unsupported_models
1721
+ ]
1722
+ if not candidates:
1723
+ return None
1724
+ for account in candidates:
1725
+ if account.id == plan.owner:
1726
+ return account
1727
+ return candidates[0]
1728
+
1729
+ async def _spill(self, plan: _Plan) -> Completion:
1730
+ """Run a saturated batch-priority request through the Batches API."""
1731
+ plan.spilled = True
1732
+ account = self._batch_account(plan)
1733
+ if account is None:
1734
+ raise GatewayError(503, "no account can accept a batch", kind="overloaded_error")
1735
+
1736
+ deadline = min(
1737
+ plan.deadline, time.time() + max(0.0, plan.max_wait - plan.queued_seconds)
1738
+ )
1739
+ started = time.time()
1740
+ try:
1741
+ outcome = await batch_lane.run_single(
1742
+ self.client,
1743
+ self.upstreams[account.id],
1744
+ plan.body,
1745
+ plan.headers,
1746
+ self.config.batch.poll_interval_seconds,
1747
+ deadline,
1748
+ )
1749
+ except batch_lane.BatchTimeout as exc:
1750
+ error = GatewayError(
1751
+ 429,
1752
+ "batch did not finish within this request's wait budget; it was "
1753
+ "cancelled upstream",
1754
+ kind="rate_limit_error",
1755
+ extra_headers=(
1756
+ {"x-tokenbiryani-batch-id": exc.batch_id} if exc.batch_id else None
1757
+ ),
1758
+ )
1759
+ await self._fail(plan, error)
1760
+ raise error from exc
1761
+ except (batch_lane.BatchUnavailable, httpx.HTTPError) as exc:
1762
+ # The spill lane is an optimisation, never a dependency: fall back to
1763
+ # the normal queue rather than failing the request.
1764
+ logger.info("batch spill unavailable, falling back to queue: %s", exc)
1765
+ plan.event.attempts.append(
1766
+ Attempt(account_id=account.id, kind="batch_unavailable", detail=str(exc)[:200])
1767
+ )
1768
+ return await self._resume_after_spill(plan)
1769
+
1770
+ latency = time.time() - started
1771
+ usage = Usage.from_body(outcome.message)
1772
+ plan.event.via = "batch"
1773
+ plan.event.batch_id = outcome.batch_id
1774
+ plan.event.attempts.append(
1775
+ Attempt(
1776
+ account_id=account.id,
1777
+ status=200,
1778
+ kind="batch",
1779
+ latency=round(latency, 4),
1780
+ detail=f"{outcome.polls} polls",
1781
+ )
1782
+ )
1783
+ decision = Decision(account, [], "batch_spill")
1784
+ await self._settle(
1785
+ account, plan, decision, usage, latency, 200,
1786
+ cost_multiplier=self.config.batch.cost_multiplier,
1787
+ )
1788
+ return Completion(
1789
+ status=200,
1790
+ headers={
1791
+ "content-type": "application/json",
1792
+ "x-tokenbiryani-account": account.id,
1793
+ "x-tokenbiryani-via": "batch",
1794
+ "x-tokenbiryani-batch-id": outcome.batch_id,
1795
+ },
1796
+ content=json.dumps(outcome.message).encode("utf-8"),
1797
+ event=plan.event,
1798
+ )
1799
+
1800
+ async def _resume_after_spill(self, plan: _Plan) -> Completion:
1801
+ """Re-enter the normal path once, with spilling disabled for this request."""
1802
+ account, decision = await self._acquire(plan)
1803
+ result, classification, latency = await self._dispatch(account, plan, MESSAGES_PATH)
1804
+ plan.event.attempts.append(
1805
+ Attempt(
1806
+ account_id=account.id,
1807
+ status=result.status if result else None,
1808
+ kind=classification.kind,
1809
+ latency=round(latency, 4),
1810
+ detail=classification.detail[:200],
1811
+ )
1812
+ )
1813
+ plan.event.decision = decision.to_dict()
1814
+ if classification.kind == "ok" and result is not None:
1815
+ usage = Usage.from_body(result.body)
1816
+ await self._settle(account, plan, decision, usage, latency, result.status)
1817
+ return Completion(
1818
+ status=result.status,
1819
+ headers=_response_headers(result.headers, account.id),
1820
+ content=result.raw,
1821
+ event=plan.event,
1822
+ )
1823
+ account.apply(classification, time.time(), plan.model)
1824
+ error = GatewayError(
1825
+ 502 if result is None else result.status,
1826
+ classification.detail or classification.kind,
1827
+ kind=classification.kind,
1828
+ )
1829
+ await self._fail(plan, error)
1830
+ raise error
1831
+
1832
+ async def _dispatch(
1833
+ self, account: AccountRuntime, plan: _Plan, path: str
1834
+ ) -> Tuple[Any, Classification, float]:
1835
+ """One non-streaming attempt, with the lease held for its duration."""
1836
+ lease = account.mirror.reserve(plan.estimate, account.id)
1837
+ account.inflight += 1
1838
+ started = time.time()
1839
+ result = None
1840
+ try:
1841
+ result = await self.upstreams[account.id].send(
1842
+ self.client, path, plan.body, plan.headers
1843
+ )
1844
+ classification = classify(result.status, result.headers, result.body)
1845
+ except Exception as exc: # noqa: BLE001 - transport failures are data here
1846
+ classification = classify_exception(exc)
1847
+ latency = time.time() - started
1848
+ now = time.time()
1849
+ if result is not None:
1850
+ account.observe_headers(dict(result.headers), now)
1851
+ usage = Usage.from_body(result.body if result else None)
1852
+ account.mirror.release(
1853
+ lease,
1854
+ actual_input=usage.billed_input or None,
1855
+ actual_output=usage.output_tokens or None,
1856
+ now=now,
1857
+ )
1858
+ account.inflight -= 1
1859
+ await self.gate.wake_one()
1860
+ return result, classification, latency
1861
+
1862
+ async def _stream_iter(self, plan: _Plan, path: str) -> AsyncIterator[bytes]:
1863
+ while True:
1864
+ try:
1865
+ account, decision = await self._acquire(plan)
1866
+ except GatewayError as exc:
1867
+ yield sse.error_event(exc.message, exc.kind)
1868
+ return
1869
+
1870
+ plan.event.decision = decision.to_dict()
1871
+ upstream = self.upstreams[account.id]
1872
+ lease = account.mirror.reserve(plan.estimate, account.id)
1873
+ account.inflight += 1
1874
+ started = time.time()
1875
+ collector = sse.UsageCollector()
1876
+ committed = False
1877
+ classification: Optional[Classification] = None
1878
+ status: Optional[int] = None
1879
+
1880
+ try:
1881
+ context = upstream.open_stream(self.client, path, plan.body, plan.headers)
1882
+ response = await context.__aenter__()
1883
+ try:
1884
+ status = int(response.status_code)
1885
+ if status != 200:
1886
+ await response.aread()
1887
+ body = _safe_json(response)
1888
+ classification = classify(status, dict(response.headers), body)
1889
+ account.observe_headers(dict(response.headers), time.time())
1890
+ else:
1891
+ account.observe_headers(dict(response.headers), time.time())
1892
+ buffered: List[bytes] = []
1893
+ grace = started + self.config.retry.first_token_grace_seconds
1894
+ async for chunk in upstream.iter_sse(response):
1895
+ collector.feed(chunk)
1896
+ if not committed:
1897
+ buffered.append(chunk)
1898
+ seen_content = sse.FIRST_CONTENT_EVENT in b"".join(buffered)
1899
+ if seen_content or time.time() >= grace:
1900
+ # Point of no return: flush and commit.
1901
+ committed = True
1902
+ if plan.event.ttft is None:
1903
+ plan.event.ttft = round(time.time() - started, 4)
1904
+ for pending in buffered:
1905
+ yield pending
1906
+ buffered = []
1907
+ else:
1908
+ yield chunk
1909
+ if not committed:
1910
+ # Stream ended before any delta — a complete, short response.
1911
+ committed = True
1912
+ if plan.event.ttft is None:
1913
+ plan.event.ttft = round(time.time() - started, 4)
1914
+ for pending in buffered:
1915
+ yield pending
1916
+ classification = classify(200)
1917
+ finally:
1918
+ await context.__aexit__(None, None, None)
1919
+ except Exception as exc: # noqa: BLE001
1920
+ classification = classify_exception(exc)
1921
+
1922
+ collector.finish()
1923
+ latency = time.time() - started
1924
+ now = time.time()
1925
+ usage = Usage.from_body(collector.as_body())
1926
+ account.mirror.release(
1927
+ lease,
1928
+ actual_input=usage.billed_input or None,
1929
+ actual_output=usage.output_tokens or None,
1930
+ now=now,
1931
+ )
1932
+ account.inflight -= 1
1933
+ await self.gate.wake_one()
1934
+
1935
+ assert classification is not None
1936
+ plan.event.attempts.append(
1937
+ Attempt(
1938
+ account_id=account.id,
1939
+ status=status,
1940
+ kind=classification.kind,
1941
+ latency=round(latency, 4),
1942
+ detail=classification.detail[:200],
1943
+ )
1944
+ )
1945
+
1946
+ if classification.kind == "ok":
1947
+ await self._settle(account, plan, decision, usage, latency, status or 200)
1948
+ return
1949
+
1950
+ account.apply(classification, now, plan.model)
1951
+ if committed:
1952
+ # Past the commit boundary: the client has content. Report, don't retry.
1953
+ plan.event.error = classification.kind
1954
+ plan.event.status = status
1955
+ plan.event.latency = time.time() - plan.started
1956
+ self.events.record(plan.event)
1957
+ yield sse.error_event(
1958
+ classification.detail or classification.kind, classification.kind
1959
+ )
1960
+ return
1961
+
1962
+ if (
1963
+ classification.request_action is RequestAction.RETURN
1964
+ or not self._may_retry(plan, account, classification)
1965
+ ):
1966
+ message = classification.detail or classification.kind
1967
+ await self._fail(plan, GatewayError(status or 502, message, classification.kind))
1968
+ yield sse.error_event(message, classification.kind)
1969
+ return
1970
+
1971
+ await self._backoff(plan)
1972
+
1973
+ def _may_retry(
1974
+ self, plan: _Plan, account: AccountRuntime, classification: Classification
1975
+ ) -> bool:
1976
+ if not classification.retryable:
1977
+ return False
1978
+ if classification.request_action is RequestAction.RETRY_OTHER:
1979
+ if account.id not in plan.excluded:
1980
+ plan.excluded.append(account.id)
1981
+ plan.attempts += 1
1982
+ retry = self.config.retry
1983
+ if plan.attempts >= retry.max_attempts:
1984
+ return False
1985
+ if len(plan.excluded) >= min(retry.max_accounts, len(self.accounts)):
1986
+ return False
1987
+ if time.time() >= plan.deadline:
1988
+ return False
1989
+ return True
1990
+
1991
+ async def _backoff(self, plan: _Plan) -> None:
1992
+ retry = self.config.retry
1993
+ delay = min(
1994
+ retry.backoff_max_seconds,
1995
+ retry.backoff_base_seconds * (2 ** max(0, plan.attempts - 1)),
1996
+ )
1997
+ await asyncio.sleep(random.uniform(0, delay))
1998
+
1999
+ async def _settle(
2000
+ self,
2001
+ account: AccountRuntime,
2002
+ plan: _Plan,
2003
+ decision: Decision,
2004
+ usage: Usage,
2005
+ latency: float,
2006
+ status: int,
2007
+ cost_multiplier: float = 1.0,
2008
+ ) -> None:
2009
+ """Record a success: stats, affinity, spend, and the event."""
2010
+ now = time.time()
2011
+ # What the model really returned, against what its lease reserved. The lease
2012
+ # is the number that decided whether this account looked full.
2013
+ self.estimator.observe(plan.model, plan.estimate.output_tokens, usage.output_tokens)
2014
+ self.cache_advisor.observe(
2015
+ plan.key.name,
2016
+ plan.model,
2017
+ plan.event.cache_breakpoint,
2018
+ plan.event.prefix_tokens,
2019
+ usage.input_tokens,
2020
+ usage.cache_read_tokens,
2021
+ usage.cache_creation_tokens,
2022
+ )
2023
+ price = self.config.price_for(plan.model)
2024
+ cost = account.record_success(latency, usage, price, now, cost_multiplier)
2025
+
2026
+ await self.store.set_affinity(
2027
+ plan.session, account.id, self.config.routing.affinity_ttl_seconds
2028
+ )
2029
+
2030
+ if cost is not None:
2031
+ await self.store.add_spend(SCOPE_KEY, plan.key.name, cost)
2032
+ await self.store.add_spend(SCOPE_ACCOUNT, account.id, cost)
2033
+
2034
+ if self.config.sessions.track:
2035
+ # A turn is recorded whether or not the model was priced: a runaway loop
2036
+ # on an unpriced model is still a runaway loop, and a turn cap that only
2037
+ # worked for priced models would fail exactly where the cost is unknown.
2038
+ await self.store.add_spend(SCOPE_SESSION_TURNS, plan.session, 1.0)
2039
+ if cost is not None:
2040
+ await self.store.add_spend(SCOPE_SESSION, plan.session, cost)
2041
+
2042
+ saved = None
2043
+ if price is not None and usage.cache_read_tokens:
2044
+ saved = usage.cache_read_tokens * (price.input - price.cache_read) / 1_000_000.0
2045
+
2046
+ event = plan.event
2047
+ event.account_id = account.id
2048
+ event.status = status
2049
+ event.latency = round(now - plan.started, 4)
2050
+ event.input_tokens = usage.input_tokens
2051
+ event.output_tokens = usage.output_tokens
2052
+ event.cache_read_tokens = usage.cache_read_tokens
2053
+ event.cache_creation_tokens = usage.cache_creation_tokens
2054
+ event.cost_usd = None if cost is None else round(cost, 6)
2055
+ event.saved_usd = None if saved is None else round(saved, 6)
2056
+ event.affinity_broken = decision.affinity_broken
2057
+ event.affinity_honored = decision.affinity_honored
2058
+ self.events.record(event)
2059
+ await self._record_usage(event)
2060
+
2061
+
2062
+ async def _record_usage(self, event: RequestEvent) -> None:
2063
+ """Persist one request's accounting so the charts survive a restart.
2064
+
2065
+ Never allowed to fail a request. The spend ledger is the billing record and
2066
+ is written separately; this is the history a chart is drawn from, and a
2067
+ dropped row costs one notch on a graph.
2068
+ """
2069
+ try:
2070
+ await self.store.record_usage(sample_from_event(event))
2071
+ except Exception: # noqa: BLE001 - telemetry must not break the response
2072
+ logger.warning("could not record usage for %s", event.request_id, exc_info=True)
2073
+
2074
+ async def _fail(self, plan: _Plan, error: GatewayError) -> None:
2075
+ plan.event.status = error.status
2076
+ plan.event.error = error.kind
2077
+ plan.event.latency = round(time.time() - plan.started, 4)
2078
+ self.events.record(plan.event)
2079
+ # Failures are history too. A usage chart that shows only successes hides
2080
+ # the outage, which is exactly what someone opens it to find.
2081
+ await self._record_usage(plan.event)
2082
+
2083
+ async def simple_request(
2084
+ self,
2085
+ method: str,
2086
+ path: str,
2087
+ key: KeyConfig,
2088
+ headers: Mapping[str, str],
2089
+ body: Optional[Dict[str, Any]] = None,
2090
+ ) -> Completion:
2091
+ """Auxiliary endpoints (models, count_tokens): no leases, one failover hop.
2092
+
2093
+ These cost no quota, so they skip admission and scoring entirely — running
2094
+ them through the router would be ceremony without a purpose.
2095
+ """
2096
+ now = time.time()
2097
+ pool = set(key.pool) if key.pool else None
2098
+ candidates = [
2099
+ a
2100
+ for a in self.accounts.values()
2101
+ if (pool is None or a.id in pool) and a.state(now) is not AccountState.DISABLED
2102
+ ]
2103
+ if not candidates:
2104
+ raise GatewayError(503, "no account available", kind="overloaded_error")
2105
+
2106
+ last: Optional[GatewayError] = None
2107
+ for account in candidates[: self.config.retry.max_accounts]:
2108
+ upstream = self.upstreams[account.id]
2109
+ try:
2110
+ if method.upper() == "GET":
2111
+ response = await self.client.get(
2112
+ upstream.url(path), headers=upstream.request_headers(headers)
2113
+ )
2114
+ result = type(
2115
+ "R", (), {"status": response.status_code, "headers": dict(response.headers),
2116
+ "raw": response.content}
2117
+ )()
2118
+ else:
2119
+ sent = await upstream.send(self.client, path, body or {}, headers)
2120
+ result = sent
2121
+ account.observe_headers(dict(result.headers), time.time())
2122
+ if result.status < 500 and result.status != 429:
2123
+ return Completion(
2124
+ status=result.status,
2125
+ headers=_response_headers(result.headers, account.id),
2126
+ content=result.raw,
2127
+ event=RequestEvent(request_id="aux", started_at=now),
2128
+ )
2129
+ last = GatewayError(result.status, f"upstream returned {result.status}")
2130
+ except Exception as exc: # noqa: BLE001
2131
+ last = GatewayError(502, f"{type(exc).__name__}: {exc}")
2132
+ raise last or GatewayError(502, "all accounts failed")
2133
+
2134
+ # ---- introspection -------------------------------------------------------
2135
+
2136
+ async def pace_readings(self) -> List[PaceReading]:
2137
+ """One reading per account that reports a window, plus the pool's budget.
2138
+
2139
+ An account that reports unified utilisation is paced on what it says about
2140
+ itself. An API-key account says nothing about a week, so it can only be
2141
+ paced against a budget the operator stated — and if none is stated it is
2142
+ left out rather than guessed at.
2143
+ """
2144
+ now = time.time()
2145
+ readings: List[PaceReading] = []
2146
+ if not self.pacing.enabled:
2147
+ return readings
2148
+
2149
+ for account in self.accounts.values():
2150
+ window = account.mirror.unified.get(self.pacing.window)
2151
+ if window is None or window.utilization is None:
2152
+ continue
2153
+ reading = self.pacing.from_unified(
2154
+ account.id, window.utilization, window.seconds_to_reset(now), now
2155
+ )
2156
+ if reading is not None:
2157
+ readings.append(reading)
2158
+
2159
+ if self.pacing.weekly_budget_usd:
2160
+ since = max(1.0, now - _pacing_week_start(now))
2161
+ spent = sum(
2162
+ (await self.store.spend_by_scope(SCOPE_ACCOUNT, since)).values()
2163
+ )
2164
+ reading = self.pacing.from_budget("pool", spent, now)
2165
+ if reading is not None:
2166
+ readings.append(reading)
2167
+ return readings
2168
+
2169
+ async def pacing_report(self) -> Dict[str, Any]:
2170
+ return self.pacing.report(await self.pace_readings(), time.time())
2171
+
2172
+ async def _pace(self) -> Optional[float]:
2173
+ """The tightest pace across every scope, or None when nothing is paced.
2174
+
2175
+ The tightest and not the mean: one account about to run dry on Wednesday is
2176
+ the fact worth acting on, and averaging it against three healthy ones is how
2177
+ that fact gets lost.
2178
+ """
2179
+ readings = await self.pace_readings()
2180
+ if not readings:
2181
+ return None
2182
+ return max(reading.pace for reading in readings)
2183
+
2184
+ def capacity_horizon(self, minutes: int = 60, buckets: int = 12) -> Dict[str, Any]:
2185
+ """Projected input-token capacity over the next hour.
2186
+
2187
+ Drawable only because we mirror per-account reset timestamps; a round-robin
2188
+ proxy has no idea when anything refills.
2189
+ """
2190
+ now = time.time()
2191
+ step = (minutes * 60.0) / buckets
2192
+ series: List[Dict[str, Any]] = []
2193
+ for index in range(buckets):
2194
+ at = now + index * step
2195
+ total = 0
2196
+ for account in self.accounts.values():
2197
+ if account.state(now) is AccountState.DISABLED:
2198
+ continue
2199
+ if not account.mirror.observable:
2200
+ # Nothing is known about this account's future capacity, and a
2201
+ # horizon that quietly includes a guess is worse than one that
2202
+ # leaves it out.
2203
+ continue
2204
+ window = account.mirror.input_tokens
2205
+ if window.limit is None:
2206
+ continue
2207
+ if window.reset_at is not None and at >= window.reset_at:
2208
+ total += window.limit
2209
+ elif window.remaining is not None:
2210
+ total += max(0, window.remaining - account.mirror.reserved_input)
2211
+ series.append({"offset_seconds": round(index * step), "input_tokens": total})
2212
+ return {"minutes": minutes, "series": series}
2213
+
2214
+ def _identify(self, runtime: AccountRuntime, entry: Dict[str, Any]) -> Dict[str, Any]:
2215
+ """Stamp the fields the console needs to *manage* an account, not just watch it.
2216
+
2217
+ `AccountRuntime.snapshot` is the live picture — states, meters, counters. None
2218
+ of it says what the account is called, whether it is editable, or what it was
2219
+ configured with, and a UI that offers a Rename button needs all three.
2220
+ """
2221
+ config = runtime.config
2222
+ entry["name"] = getattr(config, "name", None) or runtime.id
2223
+ entry["source"] = "config" if self.is_config_account(runtime.id) else "managed"
2224
+ entry["enabled"] = bool(getattr(config, "enabled", True))
2225
+ entry["base_url"] = getattr(config, "base_url", "") or ""
2226
+ entry["models"] = list(getattr(config, "models", ["*"]))
2227
+ entry["max_concurrency"] = getattr(config, "max_concurrency", None)
2228
+ entry["spend_cap_usd"] = getattr(config, "spend_cap_usd", None)
2229
+ entry["observable_limits"] = bool(getattr(config, "observable_limits", True))
2230
+ override = self._account_overrides.get(runtime.id) or {}
2231
+ entry["overridden"] = sorted(override) if override else []
2232
+ if config.type == "oauth":
2233
+ upstream = self.upstreams.get(runtime.id)
2234
+ describe = getattr(upstream, "describe_source", None)
2235
+ expiry = getattr(upstream, "source_expiry", None)
2236
+ entry["token_source"] = getattr(upstream, "source_kind", "login")
2237
+ entry["token_source_detail"] = describe() if describe is not None else ""
2238
+ entry["token_expires_at"] = expiry() if expiry is not None else None
2239
+ if config.type == "oauth":
2240
+ entry.update(self._oauth_meta.get(runtime.id, {
2241
+ "session_expires_at": None, "can_refresh": False,
2242
+ }))
2243
+ return entry
2244
+
2245
+ def account_detail(self, account_id: str) -> Optional[Dict[str, Any]]:
2246
+ """One account, live picture plus the config the console can edit."""
2247
+ runtime = self.accounts.get(account_id)
2248
+ if runtime is None:
2249
+ return None
2250
+ payload = self._identify(runtime, runtime.snapshot(time.time()))
2251
+ payload["recent_requests"] = self.events.recent_for(account_id, 25)
2252
+ return payload
2253
+
2254
+ async def usage(
2255
+ self,
2256
+ window: Optional[str] = None,
2257
+ bucket: Optional[str] = None,
2258
+ group_by: str = "account",
2259
+ account_id: Optional[str] = None,
2260
+ ) -> Dict[str, Any]:
2261
+ """Bucketed usage history for the console's charts.
2262
+
2263
+ Unlike `snapshot`, this reads the store rather than memory, so it answers
2264
+ for time the process was not running.
2265
+ """
2266
+ window_seconds = resolve_window(window)
2267
+ bucket_seconds = resolve_bucket(window_seconds, bucket)
2268
+ now = time.time()
2269
+ # Ask for the whole bucket grid the aggregator will emit, not just the
2270
+ # trailing window, or the oldest bucket comes back half empty.
2271
+ end = (int(now) // bucket_seconds) * bucket_seconds + bucket_seconds
2272
+ count = max(1, window_seconds // bucket_seconds)
2273
+ rows = await self.store.usage_rows(end - count * bucket_seconds, end, account_id)
2274
+ result = aggregate(rows, now, window_seconds, bucket_seconds, group_by)
2275
+ result["account_id"] = account_id
2276
+ # Names, so a chart legend can read "Work account" rather than "acct-02".
2277
+ result["names"] = {
2278
+ runtime.id: getattr(runtime.config, "name", None) or runtime.id
2279
+ for runtime in self.accounts.values()
2280
+ }
2281
+ return result
2282
+
2283
+ def snapshot(self) -> Dict[str, Any]:
2284
+ now = time.time()
2285
+ accounts = [
2286
+ self._identify(runtime, runtime.snapshot(now))
2287
+ for runtime in self.accounts.values()
2288
+ ]
2289
+ ready = [
2290
+ a
2291
+ for a in self.accounts.values()
2292
+ if a.state(now) is AccountState.READY
2293
+ ]
2294
+ ready_tokens = 0
2295
+ # Accounts whose budget is a percentage cannot be added to a token count.
2296
+ # Counting them as zero would read as "nothing available" on a pool that is
2297
+ # in fact half full, so they are reported separately as headroom.
2298
+ ready_headroom = None
2299
+ for account in ready:
2300
+ available = account.mirror.input_tokens.available(
2301
+ account.mirror.reserved_input, now
2302
+ )
2303
+ if available is not None:
2304
+ ready_tokens += available
2305
+ elif account.mirror.unified_known:
2306
+ headroom = account.mirror.headroom(now)
2307
+ if ready_headroom is None or headroom > ready_headroom:
2308
+ ready_headroom = headroom
2309
+ resets = []
2310
+ for account in self.accounts.values():
2311
+ reset = account.mirror.next_reset(now)
2312
+ if reset is not None:
2313
+ resets.append(reset)
2314
+ return {
2315
+ "strategy": self.router.strategy,
2316
+ # What the console needs to explain an empty cost column: whether any
2317
+ # model is priced at all, and — if the bundled table is in use — how old
2318
+ # it is. A stale price should be visible, not silent.
2319
+ "pricing": {
2320
+ "models": len(self.config.pricing),
2321
+ "as_of": self.config.pricing_as_of,
2322
+ "source": self.config.pricing_source,
2323
+ },
2324
+ "accounts": accounts,
2325
+ "pool": {
2326
+ "total": len(self.accounts),
2327
+ "ready": len(ready),
2328
+ "cooling": sum(
2329
+ 1 for a in self.accounts.values() if a.state(now) is AccountState.COOLING
2330
+ ),
2331
+ "disabled": sum(
2332
+ 1 for a in self.accounts.values() if a.state(now) is AccountState.DISABLED
2333
+ ),
2334
+ "ready_input_tokens": ready_tokens,
2335
+ #: Best headroom among ready accounts that report a fraction rather
2336
+ #: than a count. None when every ready account reports a count.
2337
+ "ready_headroom": (
2338
+ None if ready_headroom is None else round(ready_headroom, 4)
2339
+ ),
2340
+ "next_reset_seconds": round(min(resets), 1) if resets else None,
2341
+ },
2342
+ "queue": self.gate.snapshot(),
2343
+ "stats": self.events.stats(),
2344
+ }
2345
+
2346
+
2347
+ @dataclass
2348
+ class _Plan:
2349
+ body: Dict[str, Any]
2350
+ headers: Dict[str, str]
2351
+ key: KeyConfig
2352
+ model: str
2353
+ estimate: TokenEstimate
2354
+ session: str
2355
+ owner: Optional[str]
2356
+ event: RequestEvent
2357
+ started: float
2358
+ deadline: float
2359
+ priority: int = 0
2360
+ max_wait: float = 60.0
2361
+ spilled: bool = False
2362
+ queued_seconds: float = 0.0
2363
+ excluded: List[str] = None # type: ignore[assignment]
2364
+ attempts: int = 0
2365
+
2366
+ def __post_init__(self) -> None:
2367
+ if self.excluded is None:
2368
+ self.excluded = []
2369
+
2370
+
2371
+ def _mirror_for(config: AccountConfig) -> LimitMirror:
2372
+ return LimitMirror(
2373
+ observable=config.observable_limits, assumed_headroom=config.assumed_headroom
2374
+ )
2375
+
2376
+
2377
+ def _positive_float(raw: Optional[str], fallback: float) -> float:
2378
+ """A client may shorten its own wait budget, never extend it past the operator's."""
2379
+ if raw is None:
2380
+ return fallback
2381
+ try:
2382
+ requested = float(raw)
2383
+ except (TypeError, ValueError):
2384
+ return fallback
2385
+ if requested <= 0:
2386
+ return 0.0
2387
+ return min(requested, fallback)
2388
+
2389
+
2390
+ def _safe_json(response: httpx.Response) -> Optional[Dict[str, Any]]:
2391
+ try:
2392
+ parsed = response.json()
2393
+ except ValueError:
2394
+ return None
2395
+ return parsed if isinstance(parsed, dict) else None