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