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,323 @@
|
|
|
1
|
+
"""Routing: filter, score, pick — and record why every loser lost.
|
|
2
|
+
|
|
3
|
+
The decision object is deliberately verbose. An operator who cannot see why a request
|
|
4
|
+
went where it went has no reason to trust the router, so every candidate carries its
|
|
5
|
+
weighted terms and a verdict, and that travels straight through to the inspector.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import logging
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import Any, Dict, Iterable, List, Optional, Protocol, Sequence
|
|
14
|
+
|
|
15
|
+
from ..config import RoutingWeights
|
|
16
|
+
from .account import AccountRuntime
|
|
17
|
+
from .limits import TokenEstimate
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger("tokenbiryani")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
#: Entry point group third-party packages publish strategies under.
|
|
23
|
+
PLUGIN_GROUP = "tokenbiryani.strategies"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Scorer(Protocol):
|
|
27
|
+
"""A strategy that computes its own terms instead of reweighting the built-ins.
|
|
28
|
+
|
|
29
|
+
Terms are summed to produce the score, and each one is shown per candidate in the
|
|
30
|
+
request inspector — so name them for a reader, not for the arithmetic.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def terms(
|
|
34
|
+
self,
|
|
35
|
+
account: AccountRuntime,
|
|
36
|
+
ctx: RoutingContext,
|
|
37
|
+
weights: RoutingWeights,
|
|
38
|
+
index: int,
|
|
39
|
+
count: int,
|
|
40
|
+
) -> Dict[str, float]:
|
|
41
|
+
... # pragma: no cover - protocol
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class StrategySpec:
|
|
46
|
+
"""Per-strategy multipliers over the operator's base weights."""
|
|
47
|
+
|
|
48
|
+
affinity: float = 1.0
|
|
49
|
+
headroom: float = 1.0
|
|
50
|
+
priority: float = 1.0
|
|
51
|
+
load: float = 1.0
|
|
52
|
+
errors: float = 1.0
|
|
53
|
+
cost: float = 1.0
|
|
54
|
+
rotate: bool = False
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
STRATEGIES: Dict[str, StrategySpec] = {
|
|
58
|
+
# Affinity first, then headroom. Keeps prompt caches warm; the default.
|
|
59
|
+
"sticky_headroom": StrategySpec(),
|
|
60
|
+
# Pure most-available. Correct for stateless batch traffic, wasteful for chat.
|
|
61
|
+
"headroom": StrategySpec(affinity=0.0),
|
|
62
|
+
# Drain the cheap accounts, spill upward.
|
|
63
|
+
"cost_tiered": StrategySpec(affinity=0.5, headroom=0.25, cost=8.0),
|
|
64
|
+
# Strict ordered failover: primary, then backup.
|
|
65
|
+
"priority": StrategySpec(affinity=0.25, headroom=0.25, priority=8.0),
|
|
66
|
+
# Baseline. Shipped so the default can be benchmarked against it.
|
|
67
|
+
"least_loaded": StrategySpec(affinity=0.0, headroom=0.2, load=8.0),
|
|
68
|
+
# Baseline. Ignores every signal on purpose.
|
|
69
|
+
"round_robin": StrategySpec(
|
|
70
|
+
affinity=0.0, headroom=0.0, priority=0.0, load=0.0, errors=0.0, cost=0.0, rotate=True
|
|
71
|
+
),
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
#: Strategies registered at runtime, by this process or by a plugin.
|
|
76
|
+
CUSTOM_STRATEGIES: Dict[str, Scorer] = {}
|
|
77
|
+
|
|
78
|
+
_plugins_loaded = False
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def register_strategy(name: str, strategy) -> None:
|
|
82
|
+
"""Register a strategy under `name`.
|
|
83
|
+
|
|
84
|
+
Accepts a StrategySpec (reweights the built-in terms) or any object with a
|
|
85
|
+
`terms(...)` method (computes its own).
|
|
86
|
+
"""
|
|
87
|
+
if isinstance(strategy, StrategySpec):
|
|
88
|
+
STRATEGIES[name] = strategy
|
|
89
|
+
CUSTOM_STRATEGIES.pop(name, None)
|
|
90
|
+
return
|
|
91
|
+
if not hasattr(strategy, "terms"):
|
|
92
|
+
raise TypeError(
|
|
93
|
+
f"strategy {name!r} must be a StrategySpec or expose a terms() method"
|
|
94
|
+
)
|
|
95
|
+
CUSTOM_STRATEGIES[name] = strategy
|
|
96
|
+
STRATEGIES.pop(name, None)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def load_plugin_strategies(force: bool = False) -> List[str]:
|
|
100
|
+
"""Import strategies published under the `tokenbiryani.strategies` entry point.
|
|
101
|
+
|
|
102
|
+
A plugin that fails to import is logged and skipped: a broken third-party
|
|
103
|
+
package must not stop the gateway from starting on its built-in strategies.
|
|
104
|
+
"""
|
|
105
|
+
global _plugins_loaded
|
|
106
|
+
if _plugins_loaded and not force:
|
|
107
|
+
return []
|
|
108
|
+
_plugins_loaded = True
|
|
109
|
+
loaded: List[str] = []
|
|
110
|
+
for entry in _entry_points(PLUGIN_GROUP):
|
|
111
|
+
try:
|
|
112
|
+
resolved = entry.load()
|
|
113
|
+
if callable(resolved) and not isinstance(resolved, StrategySpec):
|
|
114
|
+
candidate = resolved() if not hasattr(resolved, "terms") else resolved
|
|
115
|
+
else:
|
|
116
|
+
candidate = resolved
|
|
117
|
+
register_strategy(entry.name, candidate)
|
|
118
|
+
loaded.append(entry.name)
|
|
119
|
+
except Exception as exc: # noqa: BLE001 - a bad plugin must not be fatal
|
|
120
|
+
logger.warning("could not load routing strategy %r: %s", entry.name, exc)
|
|
121
|
+
return loaded
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _entry_points(group: str):
|
|
125
|
+
"""importlib.metadata changed shape in 3.10; support both."""
|
|
126
|
+
try:
|
|
127
|
+
from importlib.metadata import entry_points
|
|
128
|
+
except ImportError: # pragma: no cover - Python < 3.8 only
|
|
129
|
+
return []
|
|
130
|
+
found = entry_points()
|
|
131
|
+
selector = getattr(found, "select", None)
|
|
132
|
+
if selector is not None:
|
|
133
|
+
return list(selector(group=group))
|
|
134
|
+
return list(found.get(group, []))
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def available_strategies() -> List[str]:
|
|
138
|
+
load_plugin_strategies()
|
|
139
|
+
return sorted(set(STRATEGIES) | set(CUSTOM_STRATEGIES))
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@dataclass
|
|
143
|
+
class RoutingContext:
|
|
144
|
+
model: str
|
|
145
|
+
estimate: TokenEstimate
|
|
146
|
+
session_key: str
|
|
147
|
+
now: float
|
|
148
|
+
sticky_owner: Optional[str] = None
|
|
149
|
+
pool: Sequence[str] = ()
|
|
150
|
+
exclude: Sequence[str] = ()
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@dataclass
|
|
154
|
+
class ScoredCandidate:
|
|
155
|
+
account_id: str
|
|
156
|
+
verdict: str
|
|
157
|
+
score: Optional[float] = None
|
|
158
|
+
terms: Dict[str, float] = field(default_factory=dict)
|
|
159
|
+
|
|
160
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
161
|
+
return {
|
|
162
|
+
"account_id": self.account_id,
|
|
163
|
+
"verdict": self.verdict,
|
|
164
|
+
"score": None if self.score is None else round(self.score, 4),
|
|
165
|
+
"terms": {k: round(v, 4) for k, v in self.terms.items()},
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@dataclass
|
|
170
|
+
class Decision:
|
|
171
|
+
chosen: Optional[AccountRuntime]
|
|
172
|
+
candidates: List[ScoredCandidate]
|
|
173
|
+
strategy: str
|
|
174
|
+
affinity_honored: bool = False
|
|
175
|
+
affinity_broken: bool = False
|
|
176
|
+
#: True when nothing is eligible *now* but something will be after a reset
|
|
177
|
+
retryable_later: bool = False
|
|
178
|
+
soonest_available: Optional[float] = None
|
|
179
|
+
|
|
180
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
181
|
+
return {
|
|
182
|
+
"strategy": self.strategy,
|
|
183
|
+
"chosen": self.chosen.id if self.chosen else None,
|
|
184
|
+
"affinity_honored": self.affinity_honored,
|
|
185
|
+
"affinity_broken": self.affinity_broken,
|
|
186
|
+
"soonest_available": (
|
|
187
|
+
round(self.soonest_available, 1) if self.soonest_available is not None else None
|
|
188
|
+
),
|
|
189
|
+
"candidates": [c.to_dict() for c in self.candidates],
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _jitter(session_key: str, account_id: str) -> float:
|
|
194
|
+
"""Deterministic tie-break: stable under replay, spread across sessions."""
|
|
195
|
+
digest = hashlib.sha256((session_key + "|" + account_id).encode("utf-8")).digest()
|
|
196
|
+
return int.from_bytes(digest[:4], "big") / float(1 << 32) * 1e-6
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
class Router:
|
|
200
|
+
def __init__(self, strategy: str, weights: RoutingWeights) -> None:
|
|
201
|
+
load_plugin_strategies()
|
|
202
|
+
if strategy not in STRATEGIES and strategy not in CUSTOM_STRATEGIES:
|
|
203
|
+
raise ValueError(
|
|
204
|
+
"unknown routing strategy {!r}; known: {}".format(
|
|
205
|
+
strategy, ", ".join(available_strategies())
|
|
206
|
+
)
|
|
207
|
+
)
|
|
208
|
+
self.strategy = strategy
|
|
209
|
+
self.scorer: Optional[Scorer] = CUSTOM_STRATEGIES.get(strategy)
|
|
210
|
+
self.spec = STRATEGIES.get(strategy, StrategySpec())
|
|
211
|
+
self.weights = weights
|
|
212
|
+
self._cursor = 0
|
|
213
|
+
|
|
214
|
+
def select(
|
|
215
|
+
self, accounts: Iterable[AccountRuntime], ctx: RoutingContext
|
|
216
|
+
) -> Decision:
|
|
217
|
+
accounts = list(accounts)
|
|
218
|
+
excluded = set(ctx.exclude)
|
|
219
|
+
pool = set(ctx.pool) if ctx.pool else None
|
|
220
|
+
|
|
221
|
+
eligible: List[AccountRuntime] = []
|
|
222
|
+
candidates: List[ScoredCandidate] = []
|
|
223
|
+
soonest: Optional[float] = None
|
|
224
|
+
|
|
225
|
+
for account in accounts:
|
|
226
|
+
if pool is not None and account.id not in pool:
|
|
227
|
+
candidates.append(
|
|
228
|
+
ScoredCandidate(account.id, "filtered — outside this key's pool")
|
|
229
|
+
)
|
|
230
|
+
continue
|
|
231
|
+
if account.id in excluded:
|
|
232
|
+
candidates.append(
|
|
233
|
+
ScoredCandidate(account.id, "filtered — already attempted")
|
|
234
|
+
)
|
|
235
|
+
continue
|
|
236
|
+
if account.mirror.exceeds_capacity(ctx.estimate):
|
|
237
|
+
candidates.append(
|
|
238
|
+
ScoredCandidate(account.id, "filtered — request exceeds account limit")
|
|
239
|
+
)
|
|
240
|
+
continue
|
|
241
|
+
reason = account.eligible(ctx.model, ctx.estimate, ctx.now)
|
|
242
|
+
if reason is not None:
|
|
243
|
+
candidates.append(ScoredCandidate(account.id, "filtered — " + reason))
|
|
244
|
+
wait = account.cooling_for(ctx.now)
|
|
245
|
+
if wait is None and "headroom" in reason:
|
|
246
|
+
wait = account.mirror.next_reset(ctx.now)
|
|
247
|
+
if wait is not None:
|
|
248
|
+
soonest = wait if soonest is None else min(soonest, wait)
|
|
249
|
+
continue
|
|
250
|
+
eligible.append(account)
|
|
251
|
+
|
|
252
|
+
if not eligible:
|
|
253
|
+
return Decision(
|
|
254
|
+
None,
|
|
255
|
+
candidates,
|
|
256
|
+
self.strategy,
|
|
257
|
+
retryable_later=soonest is not None,
|
|
258
|
+
soonest_available=soonest,
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
tiers = [a.config.cost_tier for a in eligible]
|
|
262
|
+
low, high = min(tiers), max(tiers)
|
|
263
|
+
spread = (high - low) or 1.0
|
|
264
|
+
|
|
265
|
+
scored: List[Any] = []
|
|
266
|
+
for index, account in enumerate(eligible):
|
|
267
|
+
terms = self._terms(account, ctx, index, len(eligible), low, spread)
|
|
268
|
+
total = sum(terms.values()) + _jitter(ctx.session_key, account.id)
|
|
269
|
+
scored.append((total, account, terms))
|
|
270
|
+
|
|
271
|
+
scored.sort(key=lambda item: item[0], reverse=True)
|
|
272
|
+
best_score, best, best_terms = scored[0]
|
|
273
|
+
|
|
274
|
+
for total, account, terms in scored:
|
|
275
|
+
candidates.append(
|
|
276
|
+
ScoredCandidate(
|
|
277
|
+
account.id,
|
|
278
|
+
"chosen" if account is best else "eligible",
|
|
279
|
+
score=total,
|
|
280
|
+
terms=terms,
|
|
281
|
+
)
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
if self.spec.rotate:
|
|
285
|
+
self._cursor = (self._cursor + 1) % max(1, len(eligible))
|
|
286
|
+
|
|
287
|
+
honored = bool(ctx.sticky_owner) and ctx.sticky_owner == best.id
|
|
288
|
+
broken = bool(ctx.sticky_owner) and ctx.sticky_owner != best.id
|
|
289
|
+
return Decision(
|
|
290
|
+
best,
|
|
291
|
+
candidates,
|
|
292
|
+
self.strategy,
|
|
293
|
+
affinity_honored=honored,
|
|
294
|
+
affinity_broken=broken,
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
def _terms(
|
|
298
|
+
self,
|
|
299
|
+
account: AccountRuntime,
|
|
300
|
+
ctx: RoutingContext,
|
|
301
|
+
index: int,
|
|
302
|
+
count: int,
|
|
303
|
+
low_tier: float,
|
|
304
|
+
tier_spread: float,
|
|
305
|
+
) -> Dict[str, float]:
|
|
306
|
+
if self.scorer is not None:
|
|
307
|
+
return self.scorer.terms(account, ctx, self.weights, index, count)
|
|
308
|
+
w, s = self.weights, self.spec
|
|
309
|
+
if s.rotate:
|
|
310
|
+
# Distance forward from the cursor; the account at the cursor scores highest.
|
|
311
|
+
offset = (index - self._cursor) % max(1, count)
|
|
312
|
+
return {"rotation": -float(offset)}
|
|
313
|
+
|
|
314
|
+
is_owner = 1.0 if ctx.sticky_owner == account.id else 0.0
|
|
315
|
+
normalized_cost = (account.config.cost_tier - low_tier) / tier_spread
|
|
316
|
+
return {
|
|
317
|
+
"affinity": w.affinity * s.affinity * is_owner,
|
|
318
|
+
"headroom": w.headroom * s.headroom * account.mirror.headroom(ctx.now),
|
|
319
|
+
"priority": w.priority * s.priority * account.config.priority,
|
|
320
|
+
"load": -w.load * s.load * account.load(),
|
|
321
|
+
"errors": -w.errors * s.errors * account.error_rate(),
|
|
322
|
+
"cost": -w.cost * s.cost * normalized_cost,
|
|
323
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Encryption for credentials the gateway stores itself.
|
|
2
|
+
|
|
3
|
+
Config-file accounts keep their credentials in the operator's own file, protected by
|
|
4
|
+
file permissions. Accounts added through the API are different: the gateway writes
|
|
5
|
+
them to a database that may be a shared Redis or a file that ends up in a backup. So
|
|
6
|
+
those are encrypted before they leave the process, and the key lives somewhere the
|
|
7
|
+
database does not.
|
|
8
|
+
|
|
9
|
+
Key resolution, in order:
|
|
10
|
+
1. ``TOKENBIRYANI_SECRET_KEY`` — a urlsafe-base64 Fernet key. Right for containers.
|
|
11
|
+
2. A key file beside the store, created 0600 on first use. Right for a laptop.
|
|
12
|
+
|
|
13
|
+
Losing the key means the stored credentials are unrecoverable, which is the point.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import os
|
|
19
|
+
import stat
|
|
20
|
+
from typing import Optional
|
|
21
|
+
|
|
22
|
+
ENV_VAR = "TOKENBIRYANI_SECRET_KEY"
|
|
23
|
+
DEFAULT_KEY_FILE = "tokenbiryani.key"
|
|
24
|
+
|
|
25
|
+
#: Ciphertext is prefixed so a future scheme can be told apart from this one.
|
|
26
|
+
PREFIX = "v1:"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class SecretError(RuntimeError):
|
|
30
|
+
"""Raised when a secret cannot be encrypted or read back."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _fernet(key: bytes):
|
|
34
|
+
try:
|
|
35
|
+
from cryptography.fernet import Fernet
|
|
36
|
+
except ImportError as exc: # pragma: no cover - error path only
|
|
37
|
+
raise SecretError(
|
|
38
|
+
"storing account credentials needs the cryptography package, which is a "
|
|
39
|
+
"base dependency — reinstall with: pip install --upgrade tokenbiryani"
|
|
40
|
+
) from exc
|
|
41
|
+
return Fernet(key)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def generate_key() -> bytes:
|
|
45
|
+
# Guarded like _fernet: this is reached on the first attempt to store an
|
|
46
|
+
# account, and a bare ModuleNotFoundError there tells the operator nothing
|
|
47
|
+
# about which extra is missing.
|
|
48
|
+
try:
|
|
49
|
+
from cryptography.fernet import Fernet
|
|
50
|
+
except ImportError as exc: # pragma: no cover - error path only
|
|
51
|
+
raise SecretError(
|
|
52
|
+
"storing account credentials needs the cryptography package, which is a "
|
|
53
|
+
"base dependency — reinstall with: pip install --upgrade tokenbiryani"
|
|
54
|
+
) from exc
|
|
55
|
+
|
|
56
|
+
return Fernet.generate_key()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def load_key(key_path: Optional[str] = None) -> bytes:
|
|
60
|
+
"""Find the key, or create one. Never returns a key from a world-readable file."""
|
|
61
|
+
from_env = os.environ.get(ENV_VAR, "").strip()
|
|
62
|
+
if from_env:
|
|
63
|
+
return from_env.encode("utf-8")
|
|
64
|
+
|
|
65
|
+
path = os.path.abspath(os.path.expanduser(key_path or DEFAULT_KEY_FILE))
|
|
66
|
+
if os.path.exists(path):
|
|
67
|
+
mode = stat.S_IMODE(os.stat(path).st_mode)
|
|
68
|
+
if mode & 0o077:
|
|
69
|
+
raise SecretError(
|
|
70
|
+
f"{path} is readable by other users (mode {mode:o}). "
|
|
71
|
+
"Run: chmod 600 " + path
|
|
72
|
+
)
|
|
73
|
+
with open(path, "rb") as handle:
|
|
74
|
+
return handle.read().strip()
|
|
75
|
+
|
|
76
|
+
directory = os.path.dirname(path)
|
|
77
|
+
if directory and not os.path.isdir(directory):
|
|
78
|
+
os.makedirs(directory, exist_ok=True)
|
|
79
|
+
key = generate_key()
|
|
80
|
+
# Create with the right mode from the start; do not write then chmod.
|
|
81
|
+
descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
82
|
+
with os.fdopen(descriptor, "wb") as handle:
|
|
83
|
+
handle.write(key)
|
|
84
|
+
return key
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class SecretBox:
|
|
88
|
+
"""Encrypts and decrypts one kind of thing: a credential going into the store."""
|
|
89
|
+
|
|
90
|
+
def __init__(self, key: Optional[bytes] = None, key_path: Optional[str] = None) -> None:
|
|
91
|
+
self._key = key or load_key(key_path)
|
|
92
|
+
self._cipher = _fernet(self._key)
|
|
93
|
+
|
|
94
|
+
def encrypt(self, plaintext: str) -> str:
|
|
95
|
+
if not plaintext:
|
|
96
|
+
return ""
|
|
97
|
+
return PREFIX + self._cipher.encrypt(plaintext.encode("utf-8")).decode("ascii")
|
|
98
|
+
|
|
99
|
+
def decrypt(self, ciphertext: str) -> str:
|
|
100
|
+
if not ciphertext:
|
|
101
|
+
return ""
|
|
102
|
+
if not ciphertext.startswith(PREFIX):
|
|
103
|
+
# A value written before encryption existed, or hand-edited into the
|
|
104
|
+
# store. Accept it rather than lock the operator out of their own pool.
|
|
105
|
+
return ciphertext
|
|
106
|
+
from cryptography.fernet import InvalidToken
|
|
107
|
+
|
|
108
|
+
try:
|
|
109
|
+
return self._cipher.decrypt(ciphertext[len(PREFIX):].encode("ascii")).decode("utf-8")
|
|
110
|
+
except (InvalidToken, ValueError) as exc:
|
|
111
|
+
raise SecretError(
|
|
112
|
+
"could not decrypt a stored credential — the secret key has changed. "
|
|
113
|
+
"Restore the original key, or delete and re-add the account."
|
|
114
|
+
) from exc
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Session affinity.
|
|
2
|
+
|
|
3
|
+
Anthropic's prompt cache is scoped per credential. A conversation that hops accounts
|
|
4
|
+
pays full price for a prefix it already cached somewhere else, so the router needs a
|
|
5
|
+
stable key for "this conversation" without the client having to supply one.
|
|
6
|
+
|
|
7
|
+
The fingerprint deliberately covers only the *stable* head of a request — system
|
|
8
|
+
prompt, tool definitions, and the opening messages — so it stays constant as a
|
|
9
|
+
conversation grows.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import hashlib
|
|
15
|
+
import json
|
|
16
|
+
from typing import Any, List, Mapping, Optional
|
|
17
|
+
|
|
18
|
+
SESSION_HEADER = "x-tokenbiryani-session"
|
|
19
|
+
|
|
20
|
+
#: How many leading messages take part in the fingerprint. Enough to distinguish two
|
|
21
|
+
#: conversations that share a system prompt; few enough to survive history growth.
|
|
22
|
+
LEADING_MESSAGES = 2
|
|
23
|
+
|
|
24
|
+
#: Per-message character budget, so a long opening message can't dominate the hash.
|
|
25
|
+
MESSAGE_BUDGET = 512
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _flatten(content: Any, budget: int = MESSAGE_BUDGET) -> str:
|
|
29
|
+
"""Content is a string or a list of typed blocks. Reduce it to stable text."""
|
|
30
|
+
if content is None:
|
|
31
|
+
return ""
|
|
32
|
+
if isinstance(content, str):
|
|
33
|
+
return content[:budget]
|
|
34
|
+
if isinstance(content, Mapping):
|
|
35
|
+
kind = content.get("type", "")
|
|
36
|
+
if kind == "text":
|
|
37
|
+
return "text:" + str(content.get("text", ""))[:budget]
|
|
38
|
+
if kind == "tool_use":
|
|
39
|
+
return "tool_use:" + str(content.get("name", ""))
|
|
40
|
+
if kind == "tool_result":
|
|
41
|
+
return "tool_result:" + str(content.get("tool_use_id", ""))
|
|
42
|
+
return str(kind)
|
|
43
|
+
if isinstance(content, list):
|
|
44
|
+
parts: List[str] = []
|
|
45
|
+
remaining = budget
|
|
46
|
+
for block in content:
|
|
47
|
+
if remaining <= 0:
|
|
48
|
+
break
|
|
49
|
+
piece = _flatten(block, remaining)
|
|
50
|
+
remaining -= len(piece)
|
|
51
|
+
parts.append(piece)
|
|
52
|
+
return "|".join(parts)
|
|
53
|
+
return str(content)[:budget]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def fingerprint(body: Mapping[str, Any]) -> str:
|
|
57
|
+
"""Hash the stable prefix of a Messages request."""
|
|
58
|
+
parts: List[str] = ["model=" + str(body.get("model", ""))]
|
|
59
|
+
parts.append("system=" + _flatten(body.get("system"), budget=4096))
|
|
60
|
+
|
|
61
|
+
tools = body.get("tools")
|
|
62
|
+
if isinstance(tools, list):
|
|
63
|
+
names = []
|
|
64
|
+
for tool in tools:
|
|
65
|
+
if isinstance(tool, Mapping):
|
|
66
|
+
names.append(str(tool.get("name", "")))
|
|
67
|
+
parts.append("tools=" + ",".join(sorted(names)))
|
|
68
|
+
|
|
69
|
+
messages = body.get("messages")
|
|
70
|
+
if isinstance(messages, list):
|
|
71
|
+
for message in messages[:LEADING_MESSAGES]:
|
|
72
|
+
if isinstance(message, Mapping):
|
|
73
|
+
parts.append(
|
|
74
|
+
"{}:{}".format(message.get("role", ""), _flatten(message.get("content")))
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
digest = hashlib.sha256("\x1f".join(parts).encode("utf-8", "replace")).hexdigest()
|
|
78
|
+
return digest[:32]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def session_key(
|
|
82
|
+
body: Mapping[str, Any],
|
|
83
|
+
headers: Optional[Mapping[str, str]] = None,
|
|
84
|
+
scope: str = "",
|
|
85
|
+
) -> str:
|
|
86
|
+
"""An explicit header always wins; otherwise fall back to the fingerprint.
|
|
87
|
+
|
|
88
|
+
``scope`` namespaces the result to one tenant. Without it the key is global, and
|
|
89
|
+
two tenants collide in two ways that both matter: either can steer the other's
|
|
90
|
+
affinity by choosing the same ``X-TokenBiryani-Session`` value, and two callers
|
|
91
|
+
running the same agent share a fingerprint whenever their system prompt, tool
|
|
92
|
+
names and opening messages match — which is the normal case for one popular
|
|
93
|
+
client, not a contrived one. Pass the virtual key's name and neither happens.
|
|
94
|
+
"""
|
|
95
|
+
if headers:
|
|
96
|
+
for name, value in headers.items():
|
|
97
|
+
if name.lower() == SESSION_HEADER and value.strip():
|
|
98
|
+
return _scoped(scope, "hdr:" + value.strip()[:128])
|
|
99
|
+
return _scoped(scope, "fp:" + fingerprint(body))
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _scoped(scope: str, key: str) -> str:
|
|
103
|
+
"""Prefix a session key with its tenant, when there is one."""
|
|
104
|
+
if not scope:
|
|
105
|
+
return key
|
|
106
|
+
return "k:" + str(scope)[:64] + "|" + key
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def cache_prefix_size(body: Mapping[str, Any]) -> int:
|
|
110
|
+
"""Rough size of the cacheable prefix — how much is at stake in a cache break."""
|
|
111
|
+
try:
|
|
112
|
+
return len(json.dumps(
|
|
113
|
+
{k: body.get(k) for k in ("system", "tools") if body.get(k) is not None},
|
|
114
|
+
ensure_ascii=False,
|
|
115
|
+
))
|
|
116
|
+
except (TypeError, ValueError):
|
|
117
|
+
return 0
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""The operator console: a server-rendered page and its stylesheet, no build step.
|
|
2
|
+
|
|
3
|
+
It ships inside the package deliberately. Adding a Node toolchain to a `pipx install`
|
|
4
|
+
to get a dashboard would be a bad trade for a tool whose whole appeal is that it runs
|
|
5
|
+
from one command.
|
|
6
|
+
|
|
7
|
+
The CSS is a separate file rather than a `<style>` block because it is a design system
|
|
8
|
+
— tokens, primitives, components — that is read and edited on its own terms. Splitting
|
|
9
|
+
it costs one cached request and no build step at all.
|
|
10
|
+
|
|
11
|
+
Both are re-read when they change on disk, so editing the console and refreshing the
|
|
12
|
+
browser is the whole loop — no restart, and no build.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
|
|
19
|
+
_HERE = os.path.dirname(os.path.abspath(__file__))
|
|
20
|
+
CONSOLE_PATH = os.path.join(_HERE, "console.html")
|
|
21
|
+
CONSOLE_CSS_PATH = os.path.join(_HERE, "console.css")
|
|
22
|
+
|
|
23
|
+
#: path -> (mtime, contents)
|
|
24
|
+
_cache: dict = {}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _read(path: str) -> str:
|
|
28
|
+
"""Read once, and again whenever the file changes underneath us.
|
|
29
|
+
|
|
30
|
+
Caching outright was wrong. In a container with the source bind-mounted, and
|
|
31
|
+
under `serve --reload` (which watches Python files, not these), editing the
|
|
32
|
+
console meant refreshing the browser and seeing the old page until something
|
|
33
|
+
else happened to restart the process.
|
|
34
|
+
|
|
35
|
+
A stat per request is nothing next to proxying a model call, and it makes
|
|
36
|
+
"edit the console, refresh" true rather than nearly true.
|
|
37
|
+
"""
|
|
38
|
+
try:
|
|
39
|
+
mtime = os.path.getmtime(path)
|
|
40
|
+
except OSError:
|
|
41
|
+
mtime = None
|
|
42
|
+
cached = _cache.get(path)
|
|
43
|
+
if cached is not None and cached[0] == mtime:
|
|
44
|
+
return cached[1]
|
|
45
|
+
with open(path, encoding="utf-8") as handle:
|
|
46
|
+
contents = handle.read()
|
|
47
|
+
_cache[path] = (mtime, contents)
|
|
48
|
+
return contents
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def console_html() -> str:
|
|
52
|
+
return _read(CONSOLE_PATH)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def console_css() -> str:
|
|
56
|
+
return _read(CONSOLE_CSS_PATH)
|