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,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
|