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,87 @@
1
+ """Single-use tickets that hand an admin key from the terminal to the browser.
2
+
3
+ The key itself must never travel in a URL — it lands in shell history, in the
4
+ browser's own history, in a referrer, and in any proxy log on the way. That rule is
5
+ what the console's sign-in copy promises, and it is not negotiable.
6
+
7
+ A ticket can travel there, because it is worth nothing after one redemption:
8
+
9
+ tokenbiryani console reads the admin key from the config file
10
+ mints a ticket, authenticated with that key
11
+ opens /console?t=<ticket>
12
+ the page redeems it once for the key, then rewrites the URL
13
+
14
+ The properties that make that safe are all here rather than spread across the
15
+ gateway: one redemption, a short life, and loopback only. The last one matters most
16
+ — a ticket is a bearer credential with no second factor, so it is minted only for a
17
+ gateway that nothing off-box can reach in the first place.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import secrets
23
+ import time
24
+ from typing import Dict, Optional, Tuple
25
+
26
+ #: Long enough that guessing is hopeless inside the lifetime below.
27
+ TICKET_BYTES = 24
28
+
29
+ #: A ticket exists to survive the trip from an exec() to a page load. Sixty seconds
30
+ #: is generous for that and short enough that a shell history full of stale tickets
31
+ #: is a history full of nothing.
32
+ TTL_SECONDS = 60.0
33
+
34
+ #: Hosts a ticket may be minted for. A gateway reachable off-box needs a real login,
35
+ #: not a bearer token in a query string.
36
+ LOOPBACK_HOSTS = frozenset({"127.0.0.1", "localhost", "::1", "0:0:0:0:0:0:0:1"})
37
+
38
+
39
+ class HandoffError(RuntimeError):
40
+ """Raised when a ticket cannot be minted, or cannot be redeemed."""
41
+
42
+
43
+ def is_loopback(host: str) -> bool:
44
+ return (host or "").strip().strip("[]").lower() in LOOPBACK_HOSTS
45
+
46
+
47
+ class TicketBook:
48
+ """In-memory, process-local, and deliberately not persisted.
49
+
50
+ A ticket that survived a restart would be a credential in a file, which is the
51
+ thing this exists to avoid. Losing them on restart costs one re-run of the
52
+ command that mints them.
53
+ """
54
+
55
+ def __init__(self, ttl_seconds: float = TTL_SECONDS) -> None:
56
+ self.ttl_seconds = ttl_seconds
57
+ #: ticket -> (key, expires_at)
58
+ self._tickets: Dict[str, Tuple[str, float]] = {}
59
+
60
+ def _sweep(self, now: float) -> None:
61
+ expired = [t for t, (_, expires) in self._tickets.items() if expires <= now]
62
+ for ticket in expired:
63
+ self._tickets.pop(ticket, None)
64
+
65
+ def mint(self, key: str, now: Optional[float] = None) -> Tuple[str, float]:
66
+ """Return (ticket, ttl_seconds) for a key that has already been authenticated."""
67
+ if not key:
68
+ # An open gateway has no key to hand over; the console needs none either.
69
+ raise HandoffError("this gateway has no keys configured, so it needs no sign-in")
70
+ now = time.time() if now is None else now
71
+ self._sweep(now)
72
+ ticket = secrets.token_urlsafe(TICKET_BYTES)
73
+ self._tickets[ticket] = (key, now + self.ttl_seconds)
74
+ return ticket, self.ttl_seconds
75
+
76
+ def redeem(self, ticket: str, now: Optional[float] = None) -> str:
77
+ """Exchange a ticket for its key, exactly once."""
78
+ now = time.time() if now is None else now
79
+ self._sweep(now)
80
+ # pop, not get: a replayed ticket must fail even if the first use raced.
81
+ found = self._tickets.pop(str(ticket or ""), None)
82
+ if found is None:
83
+ raise HandoffError("that sign-in link has already been used, or has expired")
84
+ key, expires = found
85
+ if expires <= now:
86
+ raise HandoffError("that sign-in link has expired")
87
+ return key
@@ -0,0 +1,199 @@
1
+ """Virtual keys.
2
+
3
+ Clients present a gateway key, never an upstream credential. Real keys stay in the
4
+ gateway process; a leaked virtual key costs you one revocation, not a rotation.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import hmac
11
+ import secrets
12
+ import time
13
+ from dataclasses import dataclass
14
+ from typing import Any, Dict, List, Mapping, Optional
15
+
16
+ from ..config import KeyConfig
17
+
18
+ PREFIX = "bir_"
19
+
20
+
21
+ #: Below this length a key has too little entropy to reveal any of it and stay secret.
22
+ MIN_MASKABLE = 16
23
+
24
+
25
+ def generate_key() -> str:
26
+ return PREFIX + secrets.token_urlsafe(24)
27
+
28
+
29
+ def mask_key(key: str) -> str:
30
+ """Show enough to tell two keys apart, never enough to use one.
31
+
32
+ Short keys are masked completely rather than partially: revealing the first
33
+ eight characters of an eight-character key reveals the key.
34
+ """
35
+ if not key:
36
+ return ""
37
+ if len(key) < MIN_MASKABLE:
38
+ return "\u2022" * 8
39
+ return key[: len(PREFIX) + 4] + "\u2026"
40
+
41
+
42
+ @dataclass
43
+ class AuthResult:
44
+ ok: bool
45
+ key: Optional[KeyConfig] = None
46
+ error: str = ""
47
+ status: int = 401
48
+
49
+
50
+ def hash_key(key: str) -> str:
51
+ """Managed keys are stored hashed. A leaked store is not a leaked key."""
52
+ return hashlib.sha256(key.encode("utf-8")).hexdigest()
53
+
54
+
55
+ def record_from_config(key: KeyConfig, plaintext: str) -> Dict[str, Any]:
56
+ return {
57
+ "name": key.name,
58
+ "key_hash": hash_key(plaintext),
59
+ "models": list(key.models),
60
+ "pool": list(key.pool),
61
+ "rpm": key.rpm,
62
+ "spend_cap_usd": key.spend_cap_usd,
63
+ "priority": key.priority,
64
+ "max_wait_seconds": key.max_wait_seconds,
65
+ "session_cap_usd": key.session_cap_usd,
66
+ "session_max_turns": key.session_max_turns,
67
+ "admin": key.admin,
68
+ "created_at": time.time(),
69
+ }
70
+
71
+
72
+ def _string_list(value: Any, default: List[str]) -> List[str]:
73
+ """Records come back from a store as loose JSON; coerce rather than trust."""
74
+ if isinstance(value, (list, tuple)):
75
+ return [str(item) for item in value]
76
+ return list(default)
77
+
78
+
79
+ def _optional_number(value: Any) -> Optional[float]:
80
+ if value is None:
81
+ return None
82
+ try:
83
+ return float(value)
84
+ except (TypeError, ValueError):
85
+ return None
86
+
87
+
88
+ def _optional_int(value: Any) -> Optional[int]:
89
+ number = _optional_number(value)
90
+ return None if number is None else int(number)
91
+
92
+
93
+ def config_from_record(record: Mapping[str, Any]) -> KeyConfig:
94
+ rpm = _optional_number(record.get("rpm"))
95
+ return KeyConfig(
96
+ key="",
97
+ name=str(record.get("name") or ""),
98
+ models=_string_list(record.get("models"), ["*"]),
99
+ pool=_string_list(record.get("pool"), []),
100
+ rpm=None if rpm is None else int(rpm),
101
+ spend_cap_usd=_optional_number(record.get("spend_cap_usd")),
102
+ priority=str(record.get("priority") or "interactive"),
103
+ max_wait_seconds=_optional_number(record.get("max_wait_seconds")),
104
+ session_cap_usd=_optional_number(record.get("session_cap_usd")),
105
+ session_max_turns=_optional_int(record.get("session_max_turns")),
106
+ admin=bool(record.get("admin")),
107
+ )
108
+
109
+
110
+ class KeyRegistry:
111
+ """Config keys are declared by the operator; managed keys are minted at runtime.
112
+
113
+ Config keys are compared in plaintext (they live in the operator's own file).
114
+ Managed keys are only ever stored as a hash, so the plaintext exists exactly once,
115
+ in the response that created it.
116
+ """
117
+
118
+ def __init__(self, keys: List[KeyConfig]) -> None:
119
+ self._keys = list(keys)
120
+ self._managed: List[Dict[str, Any]] = []
121
+
122
+ @property
123
+ def keys(self) -> List[KeyConfig]:
124
+ return list(self._keys)
125
+
126
+ def set_managed(self, records: List[Dict[str, Any]]) -> None:
127
+ self._managed = list(records)
128
+
129
+ @property
130
+ def open_access(self) -> bool:
131
+ """No keys at all means loopback-only, unauthenticated. Refused remotely."""
132
+ return not self._keys and not self._managed
133
+
134
+ def authenticate(self, presented: Optional[str]) -> AuthResult:
135
+ if self.open_access:
136
+ # Unauthenticated loopback development: full access, including admin.
137
+ return AuthResult(True, KeyConfig(key="", name="anonymous", admin=True))
138
+ if not presented:
139
+ return AuthResult(False, error="missing credentials")
140
+ for candidate in self._keys:
141
+ # Constant-time compare: key checking must not leak length or prefix.
142
+ if hmac.compare_digest(candidate.key, presented):
143
+ return AuthResult(True, candidate)
144
+ digest = hash_key(presented)
145
+ for record in self._managed:
146
+ stored = str(record.get("key_hash") or "")
147
+ if stored and hmac.compare_digest(stored, digest):
148
+ return AuthResult(True, config_from_record(record))
149
+ return AuthResult(False, error="invalid key")
150
+
151
+ def by_name(self, name: str) -> Optional[KeyConfig]:
152
+ for candidate in self._keys:
153
+ if candidate.name == name:
154
+ return candidate
155
+ for record in self._managed:
156
+ if record.get("name") == name:
157
+ return config_from_record(record)
158
+ return None
159
+
160
+ def is_config_key(self, name: str) -> bool:
161
+ return any(candidate.name == name for candidate in self._keys)
162
+
163
+ def redacted(self) -> List[Dict[str, Any]]:
164
+ out: List[Dict[str, Any]] = []
165
+ for key in self._keys:
166
+ out.append(
167
+ {
168
+ "name": key.name,
169
+ "key": mask_key(key.key),
170
+ "source": "config",
171
+ "models": key.models,
172
+ "pool": key.pool or "all",
173
+ "rpm": key.rpm,
174
+ "spend_cap_usd": key.spend_cap_usd,
175
+ "session_cap_usd": key.session_cap_usd,
176
+ "session_max_turns": key.session_max_turns,
177
+ "priority": key.priority,
178
+ "admin": key.admin,
179
+ }
180
+ )
181
+ for record in self._managed:
182
+ key = config_from_record(record)
183
+ out.append(
184
+ {
185
+ "name": key.name,
186
+ "key": "(hashed)",
187
+ "source": "managed",
188
+ "models": key.models,
189
+ "pool": key.pool or "all",
190
+ "rpm": key.rpm,
191
+ "spend_cap_usd": key.spend_cap_usd,
192
+ "session_cap_usd": key.session_cap_usd,
193
+ "session_max_turns": key.session_max_turns,
194
+ "priority": key.priority,
195
+ "admin": key.admin,
196
+ "created_at": record.get("created_at"),
197
+ }
198
+ )
199
+ return out