tokenbiryani 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. tokenbiryani/__init__.py +3 -0
  2. tokenbiryani/api/__init__.py +0 -0
  3. tokenbiryani/api/app.py +583 -0
  4. tokenbiryani/api/asgi.py +32 -0
  5. tokenbiryani/cli.py +1045 -0
  6. tokenbiryani/config.py +532 -0
  7. tokenbiryani/core/__init__.py +0 -0
  8. tokenbiryani/core/account.py +258 -0
  9. tokenbiryani/core/batch.py +135 -0
  10. tokenbiryani/core/breaker.py +53 -0
  11. tokenbiryani/core/cacheadvice.py +239 -0
  12. tokenbiryani/core/diagnostics.py +131 -0
  13. tokenbiryani/core/estimator.py +180 -0
  14. tokenbiryani/core/gateway.py +2395 -0
  15. tokenbiryani/core/handoff.py +87 -0
  16. tokenbiryani/core/keys.py +199 -0
  17. tokenbiryani/core/limits.py +440 -0
  18. tokenbiryani/core/oauth.py +222 -0
  19. tokenbiryani/core/pacing.py +320 -0
  20. tokenbiryani/core/queue.py +132 -0
  21. tokenbiryani/core/router.py +323 -0
  22. tokenbiryani/core/secrets.py +114 -0
  23. tokenbiryani/core/session.py +117 -0
  24. tokenbiryani/dashboard/__init__.py +56 -0
  25. tokenbiryani/dashboard/console.css +610 -0
  26. tokenbiryani/dashboard/console.html +3250 -0
  27. tokenbiryani/observability/__init__.py +0 -0
  28. tokenbiryani/observability/events.py +171 -0
  29. tokenbiryani/observability/usage.py +226 -0
  30. tokenbiryani/prices.yaml +77 -0
  31. tokenbiryani/providers/__init__.py +0 -0
  32. tokenbiryani/providers/anthropic_api.py +118 -0
  33. tokenbiryani/providers/base.py +173 -0
  34. tokenbiryani/providers/bedrock.py +182 -0
  35. tokenbiryani/providers/oauth.py +165 -0
  36. tokenbiryani/providers/oauth_credentials.py +293 -0
  37. tokenbiryani/providers/translate.py +35 -0
  38. tokenbiryani/providers/vertex.py +144 -0
  39. tokenbiryani/proxy/__init__.py +0 -0
  40. tokenbiryani/proxy/errors.py +169 -0
  41. tokenbiryani/proxy/sse.py +98 -0
  42. tokenbiryani/store/__init__.py +0 -0
  43. tokenbiryani/store/base.py +150 -0
  44. tokenbiryani/store/memory.py +149 -0
  45. tokenbiryani/store/redis_store.py +222 -0
  46. tokenbiryani/store/sqlite.py +336 -0
  47. tokenbiryani-0.2.0.dist-info/METADATA +697 -0
  48. tokenbiryani-0.2.0.dist-info/RECORD +51 -0
  49. tokenbiryani-0.2.0.dist-info/WHEEL +4 -0
  50. tokenbiryani-0.2.0.dist-info/entry_points.txt +2 -0
  51. tokenbiryani-0.2.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,98 @@
1
+ """Just enough SSE to account for a streamed response without altering it.
2
+
3
+ The gateway never rewrites the stream. It watches it go past, so it can tell when
4
+ the first content byte has been committed to the client and what the response cost.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from typing import Any, Dict, Iterator, Optional
11
+
12
+ #: The event that marks the point of no return: after this, the client has content
13
+ #: on screen and a failure can no longer be hidden by retrying elsewhere.
14
+ FIRST_CONTENT_EVENT = b"content_block_delta"
15
+
16
+
17
+ def iter_data_objects(text: str) -> Iterator[Dict[str, Any]]:
18
+ for line in text.splitlines():
19
+ line = line.strip()
20
+ if not line.startswith("data:"):
21
+ continue
22
+ payload = line[5:].strip()
23
+ if not payload or payload == "[DONE]":
24
+ continue
25
+ try:
26
+ parsed = json.loads(payload)
27
+ except ValueError:
28
+ continue
29
+ if isinstance(parsed, dict):
30
+ yield parsed
31
+
32
+
33
+ class UsageCollector:
34
+ """Accumulates token usage from a Messages stream as it passes through."""
35
+
36
+ def __init__(self) -> None:
37
+ self.input_tokens = 0
38
+ self.output_tokens = 0
39
+ self.cache_read_tokens = 0
40
+ self.cache_creation_tokens = 0
41
+ self.stop_reason: Optional[str] = None
42
+ self._buffer = ""
43
+
44
+ def feed(self, chunk: bytes) -> None:
45
+ try:
46
+ self._buffer += chunk.decode("utf-8")
47
+ except UnicodeDecodeError:
48
+ self._buffer += chunk.decode("utf-8", "ignore")
49
+ # Only whole lines are parseable; keep the tail for the next chunk.
50
+ head, newline, tail = self._buffer.rpartition("\n")
51
+ if not newline:
52
+ return
53
+ self._buffer = tail
54
+ self._absorb(head)
55
+
56
+ def finish(self) -> None:
57
+ if self._buffer:
58
+ self._absorb(self._buffer)
59
+ self._buffer = ""
60
+
61
+ def _absorb(self, text: str) -> None:
62
+ for obj in iter_data_objects(text):
63
+ kind = obj.get("type")
64
+ if kind == "message_start":
65
+ usage = (obj.get("message") or {}).get("usage") or {}
66
+ self.input_tokens += _int(usage.get("input_tokens"))
67
+ self.output_tokens += _int(usage.get("output_tokens"))
68
+ self.cache_read_tokens += _int(usage.get("cache_read_input_tokens"))
69
+ self.cache_creation_tokens += _int(usage.get("cache_creation_input_tokens"))
70
+ elif kind == "message_delta":
71
+ usage = obj.get("usage") or {}
72
+ self.output_tokens += _int(usage.get("output_tokens"))
73
+ delta = obj.get("delta") or {}
74
+ if delta.get("stop_reason"):
75
+ self.stop_reason = str(delta["stop_reason"])
76
+
77
+ def as_body(self) -> Dict[str, Any]:
78
+ return {
79
+ "usage": {
80
+ "input_tokens": self.input_tokens,
81
+ "output_tokens": self.output_tokens,
82
+ "cache_read_input_tokens": self.cache_read_tokens,
83
+ "cache_creation_input_tokens": self.cache_creation_tokens,
84
+ }
85
+ }
86
+
87
+
88
+ def error_event(message: str, kind: str = "api_error") -> bytes:
89
+ """An SSE error frame, for a failure that lands after the stream is committed."""
90
+ payload = json.dumps({"type": "error", "error": {"type": kind, "message": message}})
91
+ return ("event: error\ndata: " + payload + "\n\n").encode("utf-8")
92
+
93
+
94
+ def _int(value: Any) -> int:
95
+ try:
96
+ return int(value or 0)
97
+ except (TypeError, ValueError):
98
+ return 0
File without changes
@@ -0,0 +1,150 @@
1
+ """State store interface.
2
+
3
+ Everything shared between requests goes through here — affinity, windowed spend,
4
+ per-key request counts. Keeping it behind one interface is what makes the SQLite and
5
+ Redis backends implementations rather than rewrites.
6
+
7
+ Spend is a *ledger*, not a running total: entries carry a timestamp and callers ask
8
+ for a window. A lifetime total on a persistent store would eventually wedge the
9
+ gateway shut and stay that way.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import abc
15
+ from typing import Dict, List, Optional
16
+
17
+ #: Spend scopes. Keys are billed to whoever presented them; accounts to the credential.
18
+ SCOPE_KEY = "key"
19
+ SCOPE_ACCOUNT = "account"
20
+
21
+ #: One conversation's cost. A key cap catches a tenant overspending; nothing caught
22
+ #: a single agent loop doing it, and one loop resending a 100k-token prefix a few
23
+ #: hundred times is a whole week of quota with one virtual key's name on it.
24
+ SCOPE_SESSION = "session"
25
+
26
+ #: Turn *count* per conversation, carried on the same ledger with an amount of 1.0
27
+ #: rather than a table of its own. The ledger is already (scope, name, at, amount)
28
+ #: with a window query over it, which is exactly what counting turns in a window
29
+ #: needs — and it means turn caps hold across instances on Redis for free, where a
30
+ #: counter kept in the process would not.
31
+ SCOPE_SESSION_TURNS = "session_turns"
32
+
33
+
34
+ class StateStore(abc.ABC):
35
+ @abc.abstractmethod
36
+ async def get_affinity(self, session_key: str) -> Optional[str]:
37
+ """Which account currently owns this session's prompt cache, if any."""
38
+
39
+ @abc.abstractmethod
40
+ async def set_affinity(self, session_key: str, account_id: str, ttl: float) -> None:
41
+ ...
42
+
43
+ @abc.abstractmethod
44
+ async def clear_affinity(self, session_key: str) -> None:
45
+ ...
46
+
47
+ @abc.abstractmethod
48
+ async def clear_affinity_for_account(self, account_id: str) -> int:
49
+ """Drop every session pinned to this account; return how many.
50
+
51
+ Called when an account is deleted. Without it those sessions keep naming a
52
+ credential that no longer exists for the rest of the affinity TTL: the
53
+ router cannot match the owner, so it scores every candidate at zero
54
+ affinity and reports a cache break on each one — noise attributed to
55
+ routing for something routing did not do.
56
+
57
+ Not called on *disable*, which is usually temporary. A disabled account
58
+ that comes back should find its conversations still pinned to it.
59
+ """
60
+
61
+ @abc.abstractmethod
62
+ async def record_key_request(self, key_name: str, now: float) -> int:
63
+ """Record a request against a virtual key; return its count in the last 60s."""
64
+
65
+ @abc.abstractmethod
66
+ async def add_spend(self, scope: str, name: str, amount: float) -> None:
67
+ """Append to the spend ledger."""
68
+
69
+ @abc.abstractmethod
70
+ async def get_spend(self, scope: str, name: str, window_seconds: float) -> float:
71
+ """Total spend for one name over the trailing window."""
72
+
73
+ @abc.abstractmethod
74
+ async def spend_by_scope(self, scope: str, window_seconds: float) -> Dict[str, float]:
75
+ """Every name in a scope, for hydrating in-memory counters at startup."""
76
+
77
+ @abc.abstractmethod
78
+ async def record_usage(self, sample: Dict[str, object]) -> None:
79
+ """Append one request's accounting to the usage history.
80
+
81
+ Telemetry, not billing — `add_spend` remains the record a cap is enforced
82
+ against. Losing a row here costs a notch on a chart, nothing more.
83
+ """
84
+
85
+ @abc.abstractmethod
86
+ async def usage_rows(
87
+ self, since: float, until: float, account_id: Optional[str] = None
88
+ ) -> List[Dict[str, object]]:
89
+ """Raw usage rows in a window. Bucketing happens in `observability.usage`."""
90
+
91
+ @abc.abstractmethod
92
+ async def put_key(self, record: Dict[str, object]) -> None:
93
+ """Store a managed key record. Records hold a hash, never the key itself."""
94
+
95
+ @abc.abstractmethod
96
+ async def delete_key(self, name: str) -> bool:
97
+ ...
98
+
99
+ @abc.abstractmethod
100
+ async def list_keys(self) -> List[Dict[str, object]]:
101
+ ...
102
+
103
+ @abc.abstractmethod
104
+ async def put_account(self, record: Dict[str, object]) -> None:
105
+ """Store an account added through the API. Credentials arrive encrypted."""
106
+
107
+ @abc.abstractmethod
108
+ async def delete_account(self, account_id: str) -> bool:
109
+ ...
110
+
111
+ @abc.abstractmethod
112
+ async def list_accounts(self) -> List[Dict[str, object]]:
113
+ ...
114
+
115
+ async def put_setting(self, name: str, value: object) -> None:
116
+ """Persist one operator setting made from the console.
117
+
118
+ Not abstract: a store predating this method keeps working, it just forgets
119
+ the setting at restart, which is a degradation and not a crash.
120
+ """
121
+ return None
122
+
123
+ async def get_settings(self) -> Dict[str, object]:
124
+ return {}
125
+
126
+ async def startup(self) -> None:
127
+ return None
128
+
129
+ async def close(self) -> None:
130
+ return None
131
+
132
+
133
+ def build_store(config) -> StateStore:
134
+ """Construct the store named by `store.backend`."""
135
+ backend = (config.store.backend or "memory").lower()
136
+ if backend == "memory":
137
+ from .memory import MemoryStateStore
138
+
139
+ return MemoryStateStore()
140
+ if backend == "sqlite":
141
+ from .sqlite import SqliteStateStore
142
+
143
+ return SqliteStateStore(config.store.path)
144
+ if backend == "redis":
145
+ from .redis_store import RedisStateStore
146
+
147
+ return RedisStateStore(config.store.url, config.store.namespace)
148
+ raise ValueError(
149
+ f"unknown store backend {backend!r}; known: memory, sqlite, redis"
150
+ )
@@ -0,0 +1,149 @@
1
+ """In-process state store. Zero dependencies; loses everything on restart."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import time
7
+ from collections import defaultdict, deque
8
+ from typing import Deque, Dict, List, Optional, Tuple
9
+
10
+ from .base import StateStore
11
+
12
+ #: How many usage rows this store keeps. Roughly a day of steady traffic, and a
13
+ #: few megabytes. The persistent backends keep 90 days instead.
14
+ USAGE_CAPACITY = 50_000
15
+
16
+
17
+ class MemoryStateStore(StateStore):
18
+ def __init__(self) -> None:
19
+ self._affinity: Dict[str, Tuple[str, float]] = {}
20
+ self._requests: Dict[str, Deque[float]] = defaultdict(deque)
21
+ self._ledger: Dict[str, List[Tuple[float, float]]] = defaultdict(list)
22
+ self._keys: Dict[str, Dict[str, object]] = {}
23
+ self._accounts: Dict[str, Dict[str, object]] = {}
24
+ self._settings: Dict[str, object] = {}
25
+ self._usage: List[Dict[str, object]] = []
26
+ self._lock = asyncio.Lock()
27
+
28
+ async def get_affinity(self, session_key: str) -> Optional[str]:
29
+ async with self._lock:
30
+ entry = self._affinity.get(session_key)
31
+ if entry is None:
32
+ return None
33
+ account_id, expires_at = entry
34
+ if expires_at and expires_at < time.time():
35
+ del self._affinity[session_key]
36
+ return None
37
+ return account_id
38
+
39
+ async def set_affinity(self, session_key: str, account_id: str, ttl: float) -> None:
40
+ async with self._lock:
41
+ self._affinity[session_key] = (account_id, time.time() + ttl)
42
+
43
+ async def clear_affinity(self, session_key: str) -> None:
44
+ async with self._lock:
45
+ self._affinity.pop(session_key, None)
46
+
47
+ async def clear_affinity_for_account(self, account_id: str) -> int:
48
+ async with self._lock:
49
+ doomed = [
50
+ session
51
+ for session, (owner, _) in self._affinity.items()
52
+ if owner == account_id
53
+ ]
54
+ for session in doomed:
55
+ del self._affinity[session]
56
+ return len(doomed)
57
+
58
+ async def record_key_request(self, key_name: str, now: float) -> int:
59
+ async with self._lock:
60
+ bucket = self._requests[key_name]
61
+ bucket.append(now)
62
+ cutoff = now - 60.0
63
+ while bucket and bucket[0] < cutoff:
64
+ bucket.popleft()
65
+ return len(bucket)
66
+
67
+ async def add_spend(self, scope: str, name: str, amount: float) -> None:
68
+ async with self._lock:
69
+ self._ledger[f"{scope}:{name}"].append((time.time(), amount))
70
+
71
+ async def get_spend(self, scope: str, name: str, window_seconds: float) -> float:
72
+ async with self._lock:
73
+ return self._sum(f"{scope}:{name}", window_seconds)
74
+
75
+ async def spend_by_scope(self, scope: str, window_seconds: float) -> Dict[str, float]:
76
+ prefix = scope + ":"
77
+ async with self._lock:
78
+ return {
79
+ key[len(prefix):]: self._sum(key, window_seconds)
80
+ for key in list(self._ledger)
81
+ if key.startswith(prefix)
82
+ }
83
+
84
+ async def record_usage(self, sample: Dict[str, object]) -> None:
85
+ async with self._lock:
86
+ self._usage.append(dict(sample))
87
+ # Bounded, unlike the persistent backends: this store is already
88
+ # "everything disappears on restart", so the ceiling is memory, not days.
89
+ if len(self._usage) > USAGE_CAPACITY:
90
+ del self._usage[: len(self._usage) - USAGE_CAPACITY]
91
+
92
+ async def usage_rows(
93
+ self, since: float, until: float, account_id: Optional[str] = None
94
+ ) -> List[Dict[str, object]]:
95
+ async with self._lock:
96
+ return [
97
+ dict(row)
98
+ for row in self._usage
99
+ if since <= _at(row) < until
100
+ and (account_id is None or row.get("account_id") == account_id)
101
+ ]
102
+
103
+ async def put_key(self, record: Dict[str, object]) -> None:
104
+ async with self._lock:
105
+ self._keys[str(record["name"])] = dict(record)
106
+
107
+ async def delete_key(self, name: str) -> bool:
108
+ async with self._lock:
109
+ return self._keys.pop(name, None) is not None
110
+
111
+ async def list_keys(self) -> List[Dict[str, object]]:
112
+ async with self._lock:
113
+ return [dict(record) for record in self._keys.values()]
114
+
115
+ async def put_account(self, record: Dict[str, object]) -> None:
116
+ async with self._lock:
117
+ self._accounts[str(record["id"])] = dict(record)
118
+
119
+ async def delete_account(self, account_id: str) -> bool:
120
+ async with self._lock:
121
+ return self._accounts.pop(account_id, None) is not None
122
+
123
+ async def list_accounts(self) -> List[Dict[str, object]]:
124
+ async with self._lock:
125
+ return [dict(record) for record in self._accounts.values()]
126
+
127
+ async def put_setting(self, name: str, value: object) -> None:
128
+ async with self._lock:
129
+ self._settings[str(name)] = value
130
+
131
+ async def get_settings(self) -> Dict[str, object]:
132
+ async with self._lock:
133
+ return dict(self._settings)
134
+
135
+ def _sum(self, key: str, window_seconds: float) -> float:
136
+ cutoff = time.time() - window_seconds
137
+ entries = self._ledger.get(key)
138
+ if not entries:
139
+ return 0.0
140
+ # Prune while we are here; the ledger is append-only otherwise.
141
+ kept = [entry for entry in entries if entry[0] >= cutoff]
142
+ self._ledger[key] = kept
143
+ return sum(amount for _, amount in kept)
144
+
145
+
146
+ def _at(row: Dict[str, object]) -> float:
147
+ """Rows are `Dict[str, object]` at this boundary; read the timestamp safely."""
148
+ value = row.get("at")
149
+ return float(value) if isinstance(value, (int, float)) else 0.0
@@ -0,0 +1,222 @@
1
+ """Redis state store: shared affinity, spend and rate counters across instances.
2
+
3
+ This is what makes more than one gateway process safe to run. Without it, two
4
+ instances each keep their own affinity map and each enforce half a spend cap.
5
+
6
+ Named `redis_store` rather than `redis` so nothing in this package can shadow the
7
+ `redis` distribution for a reader skimming imports.
8
+
9
+ Requires the optional dependency: pip install "tokenbiryani[redis]"
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import time
16
+ import uuid
17
+ from typing import Any, Dict, List, Optional
18
+
19
+ from .base import StateStore
20
+
21
+ #: Ledger entries are stored as "<amount>|<nonce>" so two identical charges in the
22
+ #: same millisecond remain distinct members of the sorted set. Usage rows use the
23
+ #: same trick, as "<json>|<nonce>".
24
+ _SEPARATOR = "|"
25
+
26
+ #: Matches the SQLite backend's retention, so a chart looks the same on either.
27
+ USAGE_RETENTION_SECONDS = 90 * 24 * 3600
28
+
29
+
30
+ def _connect(url: str) -> Any:
31
+ try:
32
+ import redis.asyncio as redis_asyncio
33
+ except ImportError as exc: # pragma: no cover - exercised by the error path only
34
+ raise RuntimeError(
35
+ "the redis store needs the redis package: pip install 'tokenbiryani[redis]'"
36
+ ) from exc
37
+ return redis_asyncio.from_url(url, decode_responses=True)
38
+
39
+
40
+ class RedisStateStore(StateStore):
41
+ def __init__(
42
+ self,
43
+ url: str = "redis://127.0.0.1:6379/0",
44
+ namespace: str = "tokenbiryani",
45
+ client: Optional[Any] = None,
46
+ ) -> None:
47
+ self.url = url
48
+ self.namespace = namespace
49
+ self._client = client
50
+ self._owns_client = client is None
51
+
52
+ @property
53
+ def client(self) -> Any:
54
+ if self._client is None:
55
+ self._client = _connect(self.url)
56
+ return self._client
57
+
58
+ def _key(self, *parts: str) -> str:
59
+ return ":".join((self.namespace,) + parts)
60
+
61
+ async def startup(self) -> None:
62
+ await self.client.ping()
63
+
64
+ async def close(self) -> None:
65
+ if self._client is not None and self._owns_client:
66
+ closer = getattr(self._client, "aclose", None) or getattr(
67
+ self._client, "close", None
68
+ )
69
+ if closer is not None:
70
+ await closer()
71
+ self._client = None
72
+
73
+ # ---- affinity ------------------------------------------------------------
74
+
75
+ async def get_affinity(self, session_key: str) -> Optional[str]:
76
+ return await self.client.get(self._key("affinity", session_key))
77
+
78
+ async def set_affinity(self, session_key: str, account_id: str, ttl: float) -> None:
79
+ # px, not ex: a sub-second TTL must not round down to "no expiry".
80
+ await self.client.set(
81
+ self._key("affinity", session_key), account_id, px=max(1, int(ttl * 1000))
82
+ )
83
+
84
+ async def clear_affinity(self, session_key: str) -> None:
85
+ await self.client.delete(self._key("affinity", session_key))
86
+
87
+ async def clear_affinity_for_account(self, account_id: str) -> int:
88
+ """Scan the affinity keyspace and drop the ones this account owns.
89
+
90
+ A scan rather than a reverse index: deleting an account is rare and
91
+ operator-driven, while `set_affinity` runs on every successful request.
92
+ Paying a little on the rare path keeps the hot one a single SET.
93
+ """
94
+ pattern = self._key("affinity", "*")
95
+ removed = 0
96
+ async for key in self.client.scan_iter(match=pattern, count=500):
97
+ if await self.client.get(key) == account_id:
98
+ removed += int(await self.client.delete(key) or 0)
99
+ return removed
100
+
101
+ # ---- per-key request rate ------------------------------------------------
102
+
103
+ async def record_key_request(self, key_name: str, now: float) -> int:
104
+ key = self._key("requests", key_name)
105
+ await self.client.zadd(key, {uuid.uuid4().hex: now})
106
+ # "(" makes the upper bound exclusive. Redis is inclusive by default, which
107
+ # would evict one entry more than the memory and SQLite backends and quietly
108
+ # under-count the rate. The window keeps entries with score >= cutoff.
109
+ await self.client.zremrangebyscore(key, "-inf", f"({now - 60.0}")
110
+ # Expire the whole set if the key goes quiet, so idle keys cost nothing.
111
+ await self.client.expire(key, 120)
112
+ return int(await self.client.zcard(key))
113
+
114
+ # ---- spend ledger --------------------------------------------------------
115
+
116
+ async def add_spend(self, scope: str, name: str, amount: float) -> None:
117
+ key = self._key("spend", scope, name)
118
+ member = f"{amount}{_SEPARATOR}{uuid.uuid4().hex}"
119
+ await self.client.zadd(key, {member: time.time()})
120
+ await self.client.sadd(self._key("spend-names", scope), name)
121
+
122
+ async def get_spend(self, scope: str, name: str, window_seconds: float) -> float:
123
+ key = self._key("spend", scope, name)
124
+ cutoff = time.time() - window_seconds
125
+ await self.client.zremrangebyscore(key, "-inf", f"({cutoff}")
126
+ members = await self.client.zrangebyscore(key, cutoff, "+inf")
127
+ return sum(_amount(member) for member in members)
128
+
129
+ # ---- usage history -------------------------------------------------------
130
+
131
+ async def record_usage(self, sample: Dict[str, Any]) -> None:
132
+ # A sorted set scored by timestamp, exactly like the spend ledger. The
133
+ # nonce keeps two identical requests in the same instant distinct members.
134
+ member = json.dumps(sample, separators=(",", ":")) + _SEPARATOR + uuid.uuid4().hex
135
+ await self.client.zadd(self._key("usage"), {member: float(sample.get("at") or 0.0)})
136
+
137
+ async def usage_rows(
138
+ self, since: float, until: float, account_id: Optional[str] = None
139
+ ) -> List[Dict[str, Any]]:
140
+ key = self._key("usage")
141
+ # Trim on read rather than on write: the write is on the request path.
142
+ await self.client.zremrangebyscore(
143
+ key, "-inf", f"({time.time() - USAGE_RETENTION_SECONDS}"
144
+ )
145
+ # "(" makes the upper bound exclusive so a row exactly on `until` belongs to
146
+ # the next window, matching the other two backends.
147
+ members = await self.client.zrangebyscore(key, since, f"({until}")
148
+ rows: List[Dict[str, Any]] = []
149
+ for member in members:
150
+ try:
151
+ row = json.loads(member.rsplit(_SEPARATOR, 1)[0])
152
+ except ValueError:
153
+ continue
154
+ if account_id is None or row.get("account_id") == account_id:
155
+ rows.append(row)
156
+ return rows
157
+
158
+ # ---- managed keys --------------------------------------------------------
159
+
160
+ async def put_key(self, record: Dict[str, Any]) -> None:
161
+ await self.client.hset(
162
+ self._key("keys"), str(record["name"]), json.dumps(record)
163
+ )
164
+
165
+ async def delete_key(self, name: str) -> bool:
166
+ return bool(await self.client.hdel(self._key("keys"), name))
167
+
168
+ async def list_keys(self) -> List[Dict[str, Any]]:
169
+ raw = await self.client.hgetall(self._key("keys"))
170
+ records = []
171
+ for value in (raw or {}).values():
172
+ try:
173
+ records.append(json.loads(value))
174
+ except ValueError:
175
+ continue
176
+ return records
177
+
178
+ async def put_account(self, record: Dict[str, Any]) -> None:
179
+ await self.client.hset(self._key("accounts"), str(record["id"]), json.dumps(record))
180
+
181
+ async def delete_account(self, account_id: str) -> bool:
182
+ return bool(await self.client.hdel(self._key("accounts"), account_id))
183
+
184
+ async def list_accounts(self) -> List[Dict[str, Any]]:
185
+ raw = await self.client.hgetall(self._key("accounts"))
186
+ records = []
187
+ for value in (raw or {}).values():
188
+ try:
189
+ records.append(json.loads(value))
190
+ except ValueError:
191
+ continue
192
+ return sorted(records, key=lambda r: str(r.get("id", "")))
193
+
194
+ async def put_setting(self, name: str, value: Any) -> None:
195
+ await self.client.hset(self._key("settings"), str(name), json.dumps(value))
196
+
197
+ async def get_settings(self) -> Dict[str, Any]:
198
+ raw = await self.client.hgetall(self._key("settings"))
199
+ out: Dict[str, Any] = {}
200
+ for name, value in (raw or {}).items():
201
+ try:
202
+ out[str(name)] = json.loads(value)
203
+ except ValueError:
204
+ continue
205
+ return out
206
+
207
+ async def spend_by_scope(self, scope: str, window_seconds: float) -> Dict[str, float]:
208
+ names = await self.client.smembers(self._key("spend-names", scope))
209
+ totals: Dict[str, float] = {}
210
+ for name in names:
211
+ total = await self.get_spend(scope, name, window_seconds)
212
+ if total:
213
+ totals[name] = total
214
+ return totals
215
+
216
+
217
+ def _amount(member: str) -> float:
218
+ head = member.split(_SEPARATOR, 1)[0]
219
+ try:
220
+ return float(head)
221
+ except ValueError:
222
+ return 0.0