tokenbiryani 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tokenbiryani/__init__.py +3 -0
- tokenbiryani/api/__init__.py +0 -0
- tokenbiryani/api/app.py +583 -0
- tokenbiryani/api/asgi.py +32 -0
- tokenbiryani/cli.py +1045 -0
- tokenbiryani/config.py +532 -0
- tokenbiryani/core/__init__.py +0 -0
- tokenbiryani/core/account.py +258 -0
- tokenbiryani/core/batch.py +135 -0
- tokenbiryani/core/breaker.py +53 -0
- tokenbiryani/core/cacheadvice.py +239 -0
- tokenbiryani/core/diagnostics.py +131 -0
- tokenbiryani/core/estimator.py +180 -0
- tokenbiryani/core/gateway.py +2395 -0
- tokenbiryani/core/handoff.py +87 -0
- tokenbiryani/core/keys.py +199 -0
- tokenbiryani/core/limits.py +440 -0
- tokenbiryani/core/oauth.py +222 -0
- tokenbiryani/core/pacing.py +320 -0
- tokenbiryani/core/queue.py +132 -0
- tokenbiryani/core/router.py +323 -0
- tokenbiryani/core/secrets.py +114 -0
- tokenbiryani/core/session.py +117 -0
- tokenbiryani/dashboard/__init__.py +56 -0
- tokenbiryani/dashboard/console.css +610 -0
- tokenbiryani/dashboard/console.html +3250 -0
- tokenbiryani/observability/__init__.py +0 -0
- tokenbiryani/observability/events.py +171 -0
- tokenbiryani/observability/usage.py +226 -0
- tokenbiryani/prices.yaml +77 -0
- tokenbiryani/providers/__init__.py +0 -0
- tokenbiryani/providers/anthropic_api.py +118 -0
- tokenbiryani/providers/base.py +173 -0
- tokenbiryani/providers/bedrock.py +182 -0
- tokenbiryani/providers/oauth.py +165 -0
- tokenbiryani/providers/oauth_credentials.py +293 -0
- tokenbiryani/providers/translate.py +35 -0
- tokenbiryani/providers/vertex.py +144 -0
- tokenbiryani/proxy/__init__.py +0 -0
- tokenbiryani/proxy/errors.py +169 -0
- tokenbiryani/proxy/sse.py +98 -0
- tokenbiryani/store/__init__.py +0 -0
- tokenbiryani/store/base.py +150 -0
- tokenbiryani/store/memory.py +149 -0
- tokenbiryani/store/redis_store.py +222 -0
- tokenbiryani/store/sqlite.py +336 -0
- tokenbiryani-0.2.0.dist-info/METADATA +697 -0
- tokenbiryani-0.2.0.dist-info/RECORD +51 -0
- tokenbiryani-0.2.0.dist-info/WHEEL +4 -0
- tokenbiryani-0.2.0.dist-info/entry_points.txt +2 -0
- tokenbiryani-0.2.0.dist-info/licenses/LICENSE +202 -0
|
@@ -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
|