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