1"""Relay reservation and quota admission.
2
3Implements in-memory admission control for relay requests: an async
4``RelayReservationManager`` holds sliding token/charge windows per
5configured scope dimension (tenant, account, user, model, provider,
6channel) and reserves capacity before upstream admission. The reserve
7operation is atomic across every applicable scope under a single async
8lock, so concurrent requests cannot oversubscribe a configured limit.
9
10Prompt estimation for the reservation uses an injected token counter
11when available; otherwise it falls back to the explicit character
12estimate used by the LLM pricing conventions (~4 chars per token).
13Requested max output tokens are included only in the admission
14reservation and are never copied into actual usage.
15"""
16
17from __future__ import annotations
18
19import asyncio
20from dataclasses import dataclass
21from datetime import datetime, timedelta
22from decimal import Decimal
23from typing import TYPE_CHECKING
24
25from lexigram.ai.governance.budget import SlidingWindowCounter
26from lexigram.contracts.ai.governance import (
27 RelayBillingError,
28 RelayUsageReservation,
29 RelayUsageScope,
30 invalid_usage,
31 quota_exhausted,
32 reservation_expired,
33)
34from lexigram.contracts.ai.relay import (
35 ClaudeRequest,
36 GeminiRequest,
37 OpenAIChatRequest,
38 RelayRequestPayload,
39 ResponsesRequest,
40)
41from lexigram.contracts.core.result import Err, Ok, Result
42from lexigram.identity import ambient as identity
43from lexigram.logging import get_logger
44from lexigram.primitives import clock
45from lexigram.serialization import dumps_str
46
47if TYPE_CHECKING:
48 from lexigram.contracts.ai.llm import TokenCounterProtocol
49
50logger = get_logger(__name__)
51
52__all__ = [
53 "DEFAULT_RESERVATION_TTL",
54 "DEFAULT_WINDOW_SECONDS",
55 "RelayQuotaEntry",
56 "RelayQuotaSnapshot",
57 "RelayReservationLimits",
58 "RelayReservationManager",
59 "RelayScopeLimit",
60 "estimate_prompt_tokens",
61 "requested_max_output_tokens",
62]
63
64RELAY_DIMENSIONS = ("tenant", "account", "user", "model", "provider", "channel")
65DEFAULT_WINDOW_SECONDS = 60.0
66DEFAULT_RESERVATION_TTL = 60.0
67_CHARS_PER_TOKEN = 4
68
69
70@dataclass(frozen=True, slots=True)
71class RelayScopeLimit:
72 """Admission quota for one scope dimension.
73
74 Attributes:
75 max_tokens: Maximum tokens admitted in the sliding window.
76 max_charge: Maximum charge admitted in the sliding window.
77 window_seconds: Sliding window size in seconds.
78 """
79
80 max_tokens: int
81 max_charge: Decimal
82 window_seconds: float = DEFAULT_WINDOW_SECONDS
83
84 def __post_init__(self) -> None:
85 """Reject negative token/charge limits or a non-positive window."""
86 if self.max_tokens < 0:
87 raise ValueError("max_tokens must be non-negative")
88 if self.max_charge < 0:
89 raise ValueError("max_charge must be non-negative")
90 if self.window_seconds <= 0:
91 raise ValueError("window_seconds must be positive")
92
93
94@dataclass(frozen=True, slots=True)
95class RelayReservationLimits:
96 """Configured per-dimension admission limits.
97
98 Attributes:
99 tenant: Tenant-scope limit, when enforced.
100 account: Account-scope limit, when enforced.
101 user: User-scope limit, when enforced.
102 model: Model-scope limit, when enforced.
103 provider: Provider-scope limit, when enforced.
104 channel: Channel-scope limit, when enforced.
105 """
106
107 tenant: RelayScopeLimit | None = None
108 account: RelayScopeLimit | None = None
109 user: RelayScopeLimit | None = None
110 model: RelayScopeLimit | None = None
111 provider: RelayScopeLimit | None = None
112 channel: RelayScopeLimit | None = None
113
114
115@dataclass(frozen=True, slots=True)
116class _ReservationState:
117 """In-memory reservation bookkeeping.
118
119 Attributes:
120 reservation: The public reservation value.
121 window_keys: Sliding-window keys amounts were reserved on.
122 expires_at: Reservation expiry instant (timezone-aware).
123 """
124
125 reservation: RelayUsageReservation
126 window_keys: tuple[str, ...]
127 expires_at: datetime
128
129
130@dataclass(frozen=True, slots=True)
131class RelayQuotaEntry:
132 """One dimension's quota configuration and current usage.
133
134 Attributes:
135 dimension: Scope dimension the entry applies to.
136 value: Scope values currently tracked on the dimension.
137 max_tokens: Configured token limit for the window.
138 max_charge: Configured charge limit for the window.
139 window_seconds: Sliding window size in seconds.
140 used_tokens: Tokens currently held by live reservations.
141 used_charge: Charge currently held by live reservations.
142 """
143
144 dimension: str
145 value: str
146 max_tokens: int
147 max_charge: Decimal
148 window_seconds: float
149 used_tokens: int
150 used_charge: Decimal
151
152 def remaining_tokens(self) -> int:
153 """Return tokens still available in the window (never negative)."""
154 return max(0, self.max_tokens - self.used_tokens)
155
156 def remaining_charge(self) -> Decimal:
157 """Return charge still available in the window (never negative)."""
158 return max(Decimal("0"), self.max_charge - self.used_charge)
159
160
161@dataclass(frozen=True, slots=True)
162class RelayQuotaSnapshot:
163 """Read-only quota usage per configured scope dimension.
164
165 Attributes:
166 tenant: Tenant-dimension quota entry, when limits are configured.
167 account: Account-dimension quota entry, when limits are configured.
168 user: User-dimension quota entry, when limits are configured.
169 model: Model-dimension quota entry, when limits are configured.
170 provider: Provider-dimension quota entry, when limits are configured.
171 channel: Channel-dimension quota entry, when limits are configured.
172 """
173
174 tenant: RelayQuotaEntry | None = None
175 account: RelayQuotaEntry | None = None
176 user: RelayQuotaEntry | None = None
177 model: RelayQuotaEntry | None = None
178 provider: RelayQuotaEntry | None = None
179 channel: RelayQuotaEntry | None = None
180
181
182def _payload_text(payload: RelayRequestPayload) -> str:
183 """Serialize a request payload to a JSON text for estimation."""
184 return dumps_str(payload.to_dict())
185
186
187def estimate_prompt_tokens(
188 payload: RelayRequestPayload,
189 token_counter: TokenCounterProtocol | None = None,
190) -> int:
191 """Estimate the prompt token count for a request payload.
192
193 Args:
194 payload: Relay request payload to estimate.
195 token_counter: Optional model-aware token counter; when provided
196 it counts the serialized payload, otherwise the character
197 estimate (~4 chars per token) is used.
198
199 Returns:
200 A non-negative, admission-only token estimate.
201 """
202 text = _payload_text(payload)
203 if token_counter is not None:
204 return max(0, int(token_counter.count(text)))
205 return max(1, len(text) // _CHARS_PER_TOKEN)
206
207
208def requested_max_output_tokens(payload: RelayRequestPayload) -> int:
209 """Return the requested max output tokens, or 0 when not set.
210
211 Args:
212 payload: Relay request payload to inspect.
213
214 Returns:
215 The requested output budget, or 0 when the request does not
216 carry one.
217 """
218 if isinstance(payload, OpenAIChatRequest):
219 value = payload.max_completion_tokens or payload.max_tokens
220 return value or 0
221 if isinstance(payload, ResponsesRequest):
222 return payload.max_output_tokens or 0
223 if isinstance(payload, ClaudeRequest):
224 return payload.max_tokens
225 if isinstance(payload, GeminiRequest):
226 value = payload.generation_config.get("maxOutputTokens")
227 if value is None:
228 value = payload.generation_config.get("max_output_tokens")
229 return int(value) if value is not None else 0
230 return 0
231
232
233class RelayReservationManager:
234 """In-memory admission reservations across configured scope quotas.
235
236 Reservations hold estimated token and charge amounts in sliding
237 windows so concurrent requests cannot oversubscribe a configured
238 limit. The :meth:`reserve` operation is atomic across every
239 applicable scope under one async lock, so concurrent requests cannot
240 double-spend capacity. Expired reservations are released before a
241 new admission check; releasing is idempotent and harmless.
242
243 Args:
244 limits: Per-dimension quota configuration.
245 ttl_seconds: Default reservation lifetime in seconds.
246 """
247
248 def __init__(
249 self,
250 limits: RelayReservationLimits | None = None,
251 *,
252 ttl_seconds: float = DEFAULT_RESERVATION_TTL,
253 ) -> None:
254 self._limits = limits or RelayReservationLimits()
255 self._ttl_seconds = ttl_seconds
256 self._token_windows: dict[str, SlidingWindowCounter] = {}
257 self._charge_windows: dict[str, SlidingWindowCounter] = {}
258 self._reservations: dict[str, _ReservationState] = {}
259 self._started: set[str] = set()
260 self._lock = asyncio.Lock()
261
262 def _token_window(self, key: str) -> SlidingWindowCounter:
263 """Return the token window for *key*, creating it lazily."""
264 window = self._token_windows.get(key)
265 if window is None:
266 window = SlidingWindowCounter()
267 self._token_windows[key] = window
268 return window
269
270 def _charge_window(self, key: str) -> SlidingWindowCounter:
271 """Return the charge window for *key*, creating it lazily."""
272 window = self._charge_windows.get(key)
273 if window is None:
274 window = SlidingWindowCounter()
275 self._charge_windows[key] = window
276 return window
277
278 def _scope_value(self, scope: RelayUsageScope, dimension: str) -> str:
279 """Return the value of a dimension in *scope* (``""`` if unset)."""
280 if dimension == "tenant":
281 return scope.tenant_id
282 if dimension == "model":
283 return scope.model
284 if dimension == "provider":
285 return scope.provider
286 if dimension == "channel":
287 return scope.channel
288 if dimension == "account":
289 return scope.account_id or ""
290 return scope.user_id or ""
291
292 def _scope_keys(self, scope: RelayUsageScope) -> list[str]:
293 """Collect internal window keys for the configured scope.
294
295 Args:
296 scope: The scope to map onto configured dimensions.
297
298 Returns:
299 Window keys ``"<dim>:<value>"`` for every configured
300 dimension the scope carries a value for.
301 """
302 keys: list[str] = []
303 for dimension in RELAY_DIMENSIONS:
304 limit = getattr(self._limits, dimension)
305 if limit is None:
306 continue
307 value = self._scope_value(scope, dimension)
308 if not value:
309 logger.debug(
310 "relay_reservation_scope_empty",
311 dimension=dimension,
312 tenant_id=scope.tenant_id,
313 )
314 continue
315 keys.append(f"{dimension}:{value}")
316 return keys
317
318 async def reserve(
319 self,
320 request_id: str,
321 scope: RelayUsageScope,
322 estimated_tokens: int,
323 estimated_charge: Decimal,
324 *,
325 ttl_seconds: float | None = None,
326 ) -> Result[RelayUsageReservation, RelayBillingError]:
327 """Atomically reserve capacity across every applicable scope.
328
329 Args:
330 request_id: Gateway request identifier for the reservation.
331 scope: Accounting scope of the request.
332 estimated_tokens: Prompt estimate used for admission only,
333 including requested max output tokens folded in by the
334 caller.
335 estimated_charge: Maximum charge the reservation covers.
336 ttl_seconds: Reservation lifetime override; defaults to the
337 manager's configured TTL.
338
339 Returns:
340 ``Ok(reservation)`` when every applicable scope has room,
341 otherwise ``Err`` with code ``invalid_usage`` or
342 ``quota_exhausted``.
343 """
344 if estimated_tokens < 0:
345 return Err(
346 invalid_usage(
347 message="estimated_tokens must be non-negative",
348 request_id=request_id,
349 tenant_id=scope.tenant_id,
350 )
351 )
352 if estimated_charge < 0:
353 return Err(
354 invalid_usage(
355 message="estimated_charge must be non-negative",
356 request_id=request_id,
357 tenant_id=scope.tenant_id,
358 )
359 )
360 ttl = ttl_seconds if ttl_seconds is not None else self._ttl_seconds
361 if ttl <= 0:
362 return Err(
363 invalid_usage(
364 message="reservation TTL must be positive",
365 request_id=request_id,
366 tenant_id=scope.tenant_id,
367 )
368 )
369
370 async with self._lock:
371 now = clock.now()
372 await self._release_expired(now)
373
374 window_keys: list[str] = []
375 for key in self._scope_keys(scope):
376 dimension, _, _ = key.partition(":")
377 limit = getattr(self._limits, dimension)
378 if limit is None:
379 continue
380 token_total = await self._token_window(key).total()
381 if token_total + estimated_tokens > limit.max_tokens:
382 return Err(
383 quota_exhausted(
384 message=(
385 f"{dimension} token quota {limit.max_tokens} "
386 f"exceeded ({token_total} used + {estimated_tokens})"
387 ),
388 request_id=request_id,
389 tenant_id=scope.tenant_id,
390 )
391 )
392 charge_total = await self._charge_window(key).total()
393 if charge_total + float(estimated_charge) > float(limit.max_charge):
394 return Err(
395 quota_exhausted(
396 message=(
397 f"{dimension} credit quota {limit.max_charge} "
398 f"exceeded ({charge_total} used)"
399 ),
400 request_id=request_id,
401 tenant_id=scope.tenant_id,
402 )
403 )
404 window_keys.append(key)
405
406 reservation_id = identity.new_uuid()
407 expires_at = now + timedelta(seconds=ttl)
408 reservation = RelayUsageReservation(
409 reservation_id=reservation_id,
410 request_id=request_id,
411 estimated_tokens=estimated_tokens,
412 estimated_charge=estimated_charge,
413 expires_at=expires_at,
414 )
415 for key in window_keys:
416 await self._token_window(key).reserve(
417 reservation_id, float(estimated_tokens)
418 )
419 await self._charge_window(key).reserve(
420 reservation_id, float(estimated_charge)
421 )
422 self._reservations[reservation_id] = _ReservationState(
423 reservation=reservation,
424 window_keys=tuple(window_keys),
425 expires_at=expires_at,
426 )
427 logger.info(
428 "relay_reservation_created",
429 reservation_id=reservation_id,
430 request_id=request_id,
431 estimated_tokens=estimated_tokens,
432 estimated_charge=str(estimated_charge),
433 )
434 return Ok(reservation)
435
436 async def mark_started(self, reservation_id: str) -> None:
437 """Record that the upstream attempt started for a reservation.
438
439 A reservation marked started may be settled even after expiry.
440
441 Args:
442 reservation_id: Reservation identifier.
443 """
444 async with self._lock:
445 self._started.add(reservation_id)
446
447 async def release(self, reservation_id: str) -> None:
448 """Release a reservation and its reserved capacity.
449
450 Idempotent and harmless for unknown reservation IDs.
451
452 Args:
453 reservation_id: Reservation identifier.
454 """
455 async with self._lock:
456 await self._release_locked(reservation_id)
457
458 async def settle(self, reservation_id: str) -> Result[None, RelayBillingError]:
459 """Settle a reservation exactly once, freeing its capacity.
460
461 A reservation cannot be settled after expiry unless the upstream
462 attempt was marked started via :meth:`mark_started`. Actual
463 usage is recorded separately by the billing service.
464
465 Args:
466 reservation_id: Reservation identifier.
467
468 Returns:
469 Ok(None) on settlement, or an Err of code
470 ``reservation_expired`` when it expired before starting.
471 """
472 async with self._lock:
473 state = self._reservations.get(reservation_id)
474 if state is None:
475 return Ok(None)
476 now = clock.now()
477 if state.expires_at <= now and reservation_id not in self._started:
478 await self._release_locked(reservation_id)
479 return Err(
480 reservation_expired(
481 message="reservation expired before starting",
482 request_id=state.reservation.request_id,
483 )
484 )
485 for key in state.window_keys:
486 await self._token_window(key).release_reservation(reservation_id)
487 await self._charge_window(key).release_reservation(reservation_id)
488 self._reservations.pop(reservation_id, None)
489 self._started.discard(reservation_id)
490 logger.info("relay_reservation_settled", reservation_id=reservation_id)
491 return Ok(None)
492
493 async def quota_snapshot(self) -> RelayQuotaSnapshot:
494 """Report configured limits and current usage per dimension.
495
496 Expired reservations are released first so the snapshot reflects
497 live capacity only. Dimensions without a configured limit never
498 appear in the snapshot.
499
500 Returns:
501 Per-dimension quota entries aggregating every tracked window
502 value; ``None`` for unconfigured dimensions.
503 """
504 async with self._lock:
505 await self._release_expired(clock.now())
506 entries: dict[str, RelayQuotaEntry] = {}
507 for dimension in RELAY_DIMENSIONS:
508 limit = getattr(self._limits, dimension)
509 if limit is None:
510 continue
511 prefix = f"{dimension}:"
512 keys = sorted(
513 key for key in self._token_windows if key.startswith(prefix)
514 )
515 if not keys:
516 continue
517 used_tokens = 0
518 used_charge = Decimal("0")
519 for key in keys:
520 used_tokens += int(await self._token_window(key).total())
521 used_charge += Decimal(str(await self._charge_window(key).total()))
522 entries[dimension] = RelayQuotaEntry(
523 dimension=dimension,
524 value=", ".join(key.partition(":")[2] for key in keys),
525 max_tokens=limit.max_tokens,
526 max_charge=limit.max_charge,
527 window_seconds=limit.window_seconds,
528 used_tokens=used_tokens,
529 used_charge=used_charge,
530 )
531 return RelayQuotaSnapshot(
532 tenant=entries.get("tenant"),
533 account=entries.get("account"),
534 user=entries.get("user"),
535 model=entries.get("model"),
536 provider=entries.get("provider"),
537 channel=entries.get("channel"),
538 )
539
540 async def _release_expired(self, now: datetime) -> None:
541 """Release every reservation that expired at or before *now*."""
542 for reservation_id, state in list(self._reservations.items()):
543 if state.expires_at <= now:
544 await self._release_locked(reservation_id)
545
546 async def _release_locked(self, reservation_id: str) -> None:
547 """Release a reservation assuming the manager lock is held."""
548 state = self._reservations.pop(reservation_id, None)
549 if state is None:
550 return
551 for key in state.window_keys:
552 await self._token_window(key).release_reservation(reservation_id)
553 await self._charge_window(key).release_reservation(reservation_id)
554 self._started.discard(reservation_id)
555 logger.debug("relay_reservation_released", reservation_id=reservation_id)