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
|
File without changes
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Request events: the one stream the console, the CLI and the logs all read from."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import time
|
|
9
|
+
from collections import deque
|
|
10
|
+
from dataclasses import asdict, dataclass, field
|
|
11
|
+
from typing import Any, Deque, Dict, List, Optional, Set
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger("tokenbiryani")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class Attempt:
|
|
18
|
+
account_id: str
|
|
19
|
+
status: Optional[int] = None
|
|
20
|
+
kind: str = ""
|
|
21
|
+
latency: float = 0.0
|
|
22
|
+
detail: str = ""
|
|
23
|
+
|
|
24
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
25
|
+
return asdict(self)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class RequestEvent:
|
|
30
|
+
request_id: str
|
|
31
|
+
started_at: float
|
|
32
|
+
model: str = ""
|
|
33
|
+
key_name: str = ""
|
|
34
|
+
session_key: str = ""
|
|
35
|
+
streamed: bool = False
|
|
36
|
+
priority: str = "interactive"
|
|
37
|
+
#: The model the caller asked for, when pacing substituted a different one.
|
|
38
|
+
#: Empty when nothing was substituted, which is the normal case.
|
|
39
|
+
model_requested: str = ""
|
|
40
|
+
#: "messages" (the normal path) or "batch" (spilled to the Batches API)
|
|
41
|
+
via: str = "messages"
|
|
42
|
+
batch_id: Optional[str] = None
|
|
43
|
+
attempts: List[Attempt] = field(default_factory=list)
|
|
44
|
+
account_id: Optional[str] = None
|
|
45
|
+
status: Optional[int] = None
|
|
46
|
+
latency: float = 0.0
|
|
47
|
+
ttft: Optional[float] = None
|
|
48
|
+
queued_for: float = 0.0
|
|
49
|
+
#: Seconds this request was deliberately held back to protect a quota window.
|
|
50
|
+
#: Batch priority only, and only when pacing is enforcing.
|
|
51
|
+
paced_for: float = 0.0
|
|
52
|
+
input_tokens: int = 0
|
|
53
|
+
output_tokens: int = 0
|
|
54
|
+
cache_read_tokens: int = 0
|
|
55
|
+
cache_creation_tokens: int = 0
|
|
56
|
+
cost_usd: Optional[float] = None
|
|
57
|
+
saved_usd: Optional[float] = None
|
|
58
|
+
affinity_broken: bool = False
|
|
59
|
+
affinity_honored: bool = False
|
|
60
|
+
#: Whether the caller marked anything with `cache_control`. None when the
|
|
61
|
+
#: request never got far enough to be inspected.
|
|
62
|
+
cache_breakpoint: Optional[bool] = None
|
|
63
|
+
#: Rough size of the stable head — the part a breakpoint would cover.
|
|
64
|
+
prefix_tokens: int = 0
|
|
65
|
+
decision: Optional[Dict[str, Any]] = None
|
|
66
|
+
error: Optional[str] = None
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def cache_hit(self) -> bool:
|
|
70
|
+
return self.cache_read_tokens > 0
|
|
71
|
+
|
|
72
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
73
|
+
data = asdict(self)
|
|
74
|
+
data["attempts"] = [a.to_dict() for a in self.attempts]
|
|
75
|
+
data["cache_hit"] = self.cache_hit
|
|
76
|
+
return data
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class EventLog:
|
|
80
|
+
"""A bounded ring buffer plus fan-out to live subscribers (the console's SSE feed)."""
|
|
81
|
+
|
|
82
|
+
def __init__(self, capacity: int = 500) -> None:
|
|
83
|
+
self._events: Deque[RequestEvent] = deque(maxlen=capacity)
|
|
84
|
+
self._subscribers: Set[asyncio.Queue[Dict[str, Any]]] = set()
|
|
85
|
+
|
|
86
|
+
def record(self, event: RequestEvent) -> None:
|
|
87
|
+
self._events.append(event)
|
|
88
|
+
payload = event.to_dict()
|
|
89
|
+
for queue in list(self._subscribers):
|
|
90
|
+
try:
|
|
91
|
+
queue.put_nowait(payload)
|
|
92
|
+
except asyncio.QueueFull:
|
|
93
|
+
# A slow console must never apply backpressure to a request.
|
|
94
|
+
pass
|
|
95
|
+
logger.info("request %s", json.dumps(_log_view(payload), separators=(",", ":")))
|
|
96
|
+
|
|
97
|
+
def recent(self, limit: int = 50) -> List[Dict[str, Any]]:
|
|
98
|
+
events = list(self._events)[-limit:]
|
|
99
|
+
return [e.to_dict() for e in reversed(events)]
|
|
100
|
+
|
|
101
|
+
def recent_for(self, account_id: str, limit: int = 50) -> List[Dict[str, Any]]:
|
|
102
|
+
"""Requests this account took part in, including attempts it failed."""
|
|
103
|
+
matched = [
|
|
104
|
+
event
|
|
105
|
+
for event in self._events
|
|
106
|
+
if event.account_id == account_id
|
|
107
|
+
or any(attempt.account_id == account_id for attempt in event.attempts)
|
|
108
|
+
]
|
|
109
|
+
return [e.to_dict() for e in reversed(matched[-limit:])]
|
|
110
|
+
|
|
111
|
+
def get(self, request_id: str) -> Optional[Dict[str, Any]]:
|
|
112
|
+
for event in reversed(self._events):
|
|
113
|
+
if event.request_id == request_id:
|
|
114
|
+
return event.to_dict()
|
|
115
|
+
return None
|
|
116
|
+
|
|
117
|
+
def stats(self, window_seconds: float = 3600.0) -> Dict[str, Any]:
|
|
118
|
+
cutoff = time.time() - window_seconds
|
|
119
|
+
recent = [e for e in self._events if e.started_at >= cutoff]
|
|
120
|
+
failovers = sum(1 for e in recent if len(e.attempts) > 1)
|
|
121
|
+
breaks = sum(1 for e in recent if e.affinity_broken)
|
|
122
|
+
errors = sum(1 for e in recent if e.status is None or e.status >= 400)
|
|
123
|
+
billed = sum(
|
|
124
|
+
e.input_tokens + e.cache_read_tokens + e.cache_creation_tokens for e in recent
|
|
125
|
+
)
|
|
126
|
+
cached = sum(e.cache_read_tokens for e in recent)
|
|
127
|
+
spend = sum(e.cost_usd or 0.0 for e in recent)
|
|
128
|
+
return {
|
|
129
|
+
"window_seconds": window_seconds,
|
|
130
|
+
"requests": len(recent),
|
|
131
|
+
"failovers": failovers,
|
|
132
|
+
"cache_breaks": breaks,
|
|
133
|
+
"errors": errors,
|
|
134
|
+
"cache_hit_rate": round(cached / billed, 4) if billed else None,
|
|
135
|
+
"spend_usd": round(spend, 4),
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
def subscribe(self) -> asyncio.Queue[Dict[str, Any]]:
|
|
139
|
+
queue: asyncio.Queue[Dict[str, Any]] = asyncio.Queue(maxsize=64)
|
|
140
|
+
self._subscribers.add(queue)
|
|
141
|
+
return queue
|
|
142
|
+
|
|
143
|
+
def unsubscribe(self, queue: asyncio.Queue[Dict[str, Any]]) -> None:
|
|
144
|
+
self._subscribers.discard(queue)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _log_view(payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
148
|
+
"""Structured log line. Prompts are never in here — only accounting."""
|
|
149
|
+
return {
|
|
150
|
+
"request_id": payload["request_id"],
|
|
151
|
+
"model": payload["model"],
|
|
152
|
+
"key": payload["key_name"],
|
|
153
|
+
"account": payload["account_id"],
|
|
154
|
+
"status": payload["status"],
|
|
155
|
+
"latency": round(payload["latency"], 3),
|
|
156
|
+
"attempts": [
|
|
157
|
+
{"account": a["account_id"], "status": a["status"], "kind": a["kind"]}
|
|
158
|
+
for a in payload["attempts"]
|
|
159
|
+
],
|
|
160
|
+
"tokens": {
|
|
161
|
+
"in": payload["input_tokens"],
|
|
162
|
+
"out": payload["output_tokens"],
|
|
163
|
+
"cache_read": payload["cache_read_tokens"],
|
|
164
|
+
},
|
|
165
|
+
"cache_hit": payload["cache_hit"],
|
|
166
|
+
"priority": payload["priority"],
|
|
167
|
+
"via": payload["via"],
|
|
168
|
+
"queued_for": payload["queued_for"],
|
|
169
|
+
"affinity_broken": payload["affinity_broken"],
|
|
170
|
+
"cost_usd": payload["cost_usd"],
|
|
171
|
+
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""Persisted usage history, and the bucketing that turns it into charts.
|
|
2
|
+
|
|
3
|
+
The event log (`events.py`) is a 500-entry ring in memory: perfect for "what is
|
|
4
|
+
happening right now", useless for "what did last Tuesday cost". Charts need the
|
|
5
|
+
second question answered, and answering it means writing a row per request and
|
|
6
|
+
keeping it.
|
|
7
|
+
|
|
8
|
+
Two decisions worth stating.
|
|
9
|
+
|
|
10
|
+
**One row per request, like the spend ledger.** Not a pre-aggregated rollup. A
|
|
11
|
+
rollup has to choose its buckets at write time, and every question you did not
|
|
12
|
+
anticipate — cost by model, cache rate for one account, errors in a five minute
|
|
13
|
+
window — becomes unanswerable. Rows are cheap and the retention window is bounded.
|
|
14
|
+
|
|
15
|
+
**Aggregation lives here, not in the backends.** Each store only has to append a
|
|
16
|
+
row and hand back the rows in a window; the bucketing, the grouping and every
|
|
17
|
+
derived rate are computed once, in `aggregate`. Three backends implementing the
|
|
18
|
+
same arithmetic three times is three chances to disagree, and a chart that differs
|
|
19
|
+
by backend is worse than no chart.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from typing import Any, Dict, Iterable, List, Optional
|
|
25
|
+
|
|
26
|
+
#: What a stored row holds. Deliberately flat and JSON-safe: SQLite gets columns,
|
|
27
|
+
#: Redis and memory get the dict verbatim.
|
|
28
|
+
USAGE_FIELDS = (
|
|
29
|
+
"at",
|
|
30
|
+
"account_id",
|
|
31
|
+
"key_name",
|
|
32
|
+
"model",
|
|
33
|
+
"status",
|
|
34
|
+
"input_tokens",
|
|
35
|
+
"output_tokens",
|
|
36
|
+
"cache_read_tokens",
|
|
37
|
+
"cache_creation_tokens",
|
|
38
|
+
"cost_usd",
|
|
39
|
+
"latency",
|
|
40
|
+
"affinity_broken",
|
|
41
|
+
"via",
|
|
42
|
+
"error",
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
#: How a `group_by` name maps onto a row field.
|
|
46
|
+
GROUP_FIELDS = {
|
|
47
|
+
"account": "account_id",
|
|
48
|
+
"model": "model",
|
|
49
|
+
"key": "key_name",
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
#: Windows the console offers, in seconds.
|
|
53
|
+
WINDOWS = {"1h": 3600, "24h": 86400, "7d": 604800, "30d": 2592000}
|
|
54
|
+
|
|
55
|
+
#: A readable bucket for each, chosen so a chart lands between 12 and 60 marks.
|
|
56
|
+
DEFAULT_BUCKETS = {3600: 300, 86400: 3600, 604800: 21600, 2592000: 86400}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def sample_from_event(event: Any) -> Dict[str, Any]:
|
|
60
|
+
"""Flatten a `RequestEvent` into a storable row.
|
|
61
|
+
|
|
62
|
+
Failed requests are recorded too — a chart that only shows successes hides the
|
|
63
|
+
outage, which is the thing you opened the chart to see.
|
|
64
|
+
"""
|
|
65
|
+
return {
|
|
66
|
+
"at": float(event.started_at),
|
|
67
|
+
"account_id": event.account_id or "",
|
|
68
|
+
"key_name": event.key_name or "",
|
|
69
|
+
"model": event.model or "",
|
|
70
|
+
"status": int(event.status) if event.status is not None else 0,
|
|
71
|
+
"input_tokens": int(event.input_tokens or 0),
|
|
72
|
+
"output_tokens": int(event.output_tokens or 0),
|
|
73
|
+
"cache_read_tokens": int(event.cache_read_tokens or 0),
|
|
74
|
+
"cache_creation_tokens": int(event.cache_creation_tokens or 0),
|
|
75
|
+
"cost_usd": float(event.cost_usd) if event.cost_usd is not None else 0.0,
|
|
76
|
+
"latency": float(event.latency or 0.0),
|
|
77
|
+
"affinity_broken": 1 if event.affinity_broken else 0,
|
|
78
|
+
"via": event.via or "messages",
|
|
79
|
+
"error": event.error or "",
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def resolve_window(raw: Optional[str], fallback: int = 86400) -> int:
|
|
84
|
+
"""Accept either a label the console sends (`24h`) or a raw second count."""
|
|
85
|
+
if raw is None:
|
|
86
|
+
return fallback
|
|
87
|
+
text = str(raw).strip().lower()
|
|
88
|
+
if text in WINDOWS:
|
|
89
|
+
return WINDOWS[text]
|
|
90
|
+
try:
|
|
91
|
+
seconds = int(float(text))
|
|
92
|
+
except ValueError:
|
|
93
|
+
return fallback
|
|
94
|
+
# A window of a year of per-request rows is a denial of service on yourself.
|
|
95
|
+
return max(60, min(seconds, WINDOWS["30d"]))
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def resolve_bucket(window_seconds: int, raw: Optional[str]) -> int:
|
|
99
|
+
if raw is not None:
|
|
100
|
+
try:
|
|
101
|
+
requested = int(float(raw))
|
|
102
|
+
except ValueError:
|
|
103
|
+
requested = 0
|
|
104
|
+
if requested > 0:
|
|
105
|
+
# Never fewer than 2 or more than 240 marks: past that a line chart is
|
|
106
|
+
# either a single dot or an unreadable comb.
|
|
107
|
+
return max(window_seconds // 240, min(requested, window_seconds // 2))
|
|
108
|
+
for size, bucket in sorted(DEFAULT_BUCKETS.items()):
|
|
109
|
+
if window_seconds <= size:
|
|
110
|
+
return bucket
|
|
111
|
+
return DEFAULT_BUCKETS[2592000]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _blank() -> Dict[str, Any]:
|
|
115
|
+
return {
|
|
116
|
+
"requests": 0,
|
|
117
|
+
"errors": 0,
|
|
118
|
+
"input_tokens": 0,
|
|
119
|
+
"output_tokens": 0,
|
|
120
|
+
"cache_read_tokens": 0,
|
|
121
|
+
"cache_creation_tokens": 0,
|
|
122
|
+
"cost_usd": 0.0,
|
|
123
|
+
"cache_breaks": 0,
|
|
124
|
+
"_latency_sum": 0.0,
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _add(cell: Dict[str, Any], row: Dict[str, Any]) -> None:
|
|
129
|
+
cell["requests"] += 1
|
|
130
|
+
status = int(row.get("status") or 0)
|
|
131
|
+
if row.get("error") or status == 0 or status >= 400:
|
|
132
|
+
cell["errors"] += 1
|
|
133
|
+
cell["input_tokens"] += int(row.get("input_tokens") or 0)
|
|
134
|
+
cell["output_tokens"] += int(row.get("output_tokens") or 0)
|
|
135
|
+
cell["cache_read_tokens"] += int(row.get("cache_read_tokens") or 0)
|
|
136
|
+
cell["cache_creation_tokens"] += int(row.get("cache_creation_tokens") or 0)
|
|
137
|
+
cell["cost_usd"] += float(row.get("cost_usd") or 0.0)
|
|
138
|
+
cell["cache_breaks"] += 1 if row.get("affinity_broken") else 0
|
|
139
|
+
cell["_latency_sum"] += float(row.get("latency") or 0.0)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _finalise(cell: Dict[str, Any]) -> Dict[str, Any]:
|
|
143
|
+
"""Turn running sums into the derived rates a chart actually plots."""
|
|
144
|
+
billed = (
|
|
145
|
+
cell["input_tokens"] + cell["cache_read_tokens"] + cell["cache_creation_tokens"]
|
|
146
|
+
)
|
|
147
|
+
requests = cell["requests"]
|
|
148
|
+
out = {k: v for k, v in cell.items() if not k.startswith("_")}
|
|
149
|
+
out["billed_input_tokens"] = billed
|
|
150
|
+
out["total_tokens"] = billed + cell["output_tokens"]
|
|
151
|
+
out["cost_usd"] = round(cell["cost_usd"], 6)
|
|
152
|
+
# None, not 0.0: "no traffic" and "every request missed the cache" are different
|
|
153
|
+
# answers, and a chart that draws them the same is lying about the quiet hours.
|
|
154
|
+
out["cache_hit_rate"] = (
|
|
155
|
+
round(cell["cache_read_tokens"] / billed, 4) if billed else None
|
|
156
|
+
)
|
|
157
|
+
out["latency_avg"] = round(cell["_latency_sum"] / requests, 4) if requests else None
|
|
158
|
+
return out
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def aggregate(
|
|
162
|
+
rows: Iterable[Dict[str, Any]],
|
|
163
|
+
now: float,
|
|
164
|
+
window_seconds: int,
|
|
165
|
+
bucket_seconds: int,
|
|
166
|
+
group_by: str = "account",
|
|
167
|
+
) -> Dict[str, Any]:
|
|
168
|
+
"""Bucket raw usage rows into a time series plus totals.
|
|
169
|
+
|
|
170
|
+
Buckets are emitted for the whole window even when empty, so a gap in traffic
|
|
171
|
+
draws as a gap rather than silently compressing the time axis.
|
|
172
|
+
"""
|
|
173
|
+
field = GROUP_FIELDS.get(group_by, "account_id")
|
|
174
|
+
count = max(1, int(window_seconds // bucket_seconds))
|
|
175
|
+
# Anchor to the bucket grid so the last bucket is the live, partial one and
|
|
176
|
+
# every earlier bucket covers a full period.
|
|
177
|
+
end = (int(now) // bucket_seconds) * bucket_seconds + bucket_seconds
|
|
178
|
+
start = end - count * bucket_seconds
|
|
179
|
+
|
|
180
|
+
buckets: List[Dict[str, Any]] = [
|
|
181
|
+
{"at": start + index * bucket_seconds, "_all": _blank(), "_by": {}}
|
|
182
|
+
for index in range(count)
|
|
183
|
+
]
|
|
184
|
+
totals: Dict[str, Dict[str, Any]] = {}
|
|
185
|
+
overall = _blank()
|
|
186
|
+
|
|
187
|
+
for row in rows:
|
|
188
|
+
at = float(row.get("at") or 0.0)
|
|
189
|
+
if at < start or at >= end:
|
|
190
|
+
continue
|
|
191
|
+
index = int((at - start) // bucket_seconds)
|
|
192
|
+
if index < 0 or index >= count:
|
|
193
|
+
continue
|
|
194
|
+
name = str(row.get(field) or "—")
|
|
195
|
+
bucket = buckets[index]
|
|
196
|
+
_add(bucket["_all"], row)
|
|
197
|
+
if name not in bucket["_by"]:
|
|
198
|
+
bucket["_by"][name] = _blank()
|
|
199
|
+
_add(bucket["_by"][name], row)
|
|
200
|
+
if name not in totals:
|
|
201
|
+
totals[name] = _blank()
|
|
202
|
+
_add(totals[name], row)
|
|
203
|
+
_add(overall, row)
|
|
204
|
+
|
|
205
|
+
# Largest first: the legend order and the stacking order should agree, and the
|
|
206
|
+
# series worth reading should be the one nearest the axis.
|
|
207
|
+
order = sorted(totals, key=lambda n: -totals[n]["input_tokens"] - totals[n]["output_tokens"])
|
|
208
|
+
|
|
209
|
+
return {
|
|
210
|
+
"window_seconds": window_seconds,
|
|
211
|
+
"bucket_seconds": bucket_seconds,
|
|
212
|
+
"group_by": group_by,
|
|
213
|
+
"start": start,
|
|
214
|
+
"end": end,
|
|
215
|
+
"groups": order,
|
|
216
|
+
"buckets": [
|
|
217
|
+
{
|
|
218
|
+
"at": bucket["at"],
|
|
219
|
+
"all": _finalise(bucket["_all"]),
|
|
220
|
+
"by": {name: _finalise(cell) for name, cell in bucket["_by"].items()},
|
|
221
|
+
}
|
|
222
|
+
for bucket in buckets
|
|
223
|
+
],
|
|
224
|
+
"totals": {name: _finalise(cell) for name, cell in totals.items()},
|
|
225
|
+
"overall": _finalise(overall),
|
|
226
|
+
}
|
tokenbiryani/prices.yaml
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# Bundled price table — USD per million tokens.
|
|
2
|
+
#
|
|
3
|
+
# This file is DATA, not code, and it is DATED. That distinction is the whole
|
|
4
|
+
# argument for shipping it: the project's standing rule is that the gateway must
|
|
5
|
+
# never bill against a number nobody can attribute, and a dated table that the
|
|
6
|
+
# console displays the date of satisfies that rule. A hard-coded dict in
|
|
7
|
+
# config.py would not.
|
|
8
|
+
#
|
|
9
|
+
# Opt in with `pricing: builtin` in tokenbiryani.yaml. Name a model under
|
|
10
|
+
# `pricing:` to override any line here — your entry always wins.
|
|
11
|
+
#
|
|
12
|
+
# Check the current numbers at https://anthropic.com/pricing and either update
|
|
13
|
+
# this file or override in your own config. The console shows `as_of` beside
|
|
14
|
+
# every cost so a stale table is visible rather than silent.
|
|
15
|
+
#
|
|
16
|
+
# cache_read and cache_write are the standard multiples of the input rate:
|
|
17
|
+
# a cache read bills at ~0.1x input, a 5-minute cache write at ~1.25x input.
|
|
18
|
+
# Claude Fable 5.1 is the exception — its cache reads are priced at a flat
|
|
19
|
+
# $0.25/MTok rather than 0.1x — so it is written out rather than derived.
|
|
20
|
+
|
|
21
|
+
as_of: 2026-06-24
|
|
22
|
+
source: https://anthropic.com/pricing
|
|
23
|
+
|
|
24
|
+
models:
|
|
25
|
+
claude-fable-5-1*:
|
|
26
|
+
input: 10.0
|
|
27
|
+
output: 50.0
|
|
28
|
+
cache_read: 0.25
|
|
29
|
+
cache_write: 12.5
|
|
30
|
+
|
|
31
|
+
claude-fable-5*:
|
|
32
|
+
input: 10.0
|
|
33
|
+
output: 50.0
|
|
34
|
+
cache_read: 1.0
|
|
35
|
+
cache_write: 12.5
|
|
36
|
+
|
|
37
|
+
claude-opus-5*:
|
|
38
|
+
input: 5.0
|
|
39
|
+
output: 25.0
|
|
40
|
+
cache_read: 0.5
|
|
41
|
+
cache_write: 6.25
|
|
42
|
+
|
|
43
|
+
claude-opus-4-8*:
|
|
44
|
+
input: 5.0
|
|
45
|
+
output: 25.0
|
|
46
|
+
cache_read: 0.5
|
|
47
|
+
cache_write: 6.25
|
|
48
|
+
|
|
49
|
+
claude-opus-4-7*:
|
|
50
|
+
input: 5.0
|
|
51
|
+
output: 25.0
|
|
52
|
+
cache_read: 0.5
|
|
53
|
+
cache_write: 6.25
|
|
54
|
+
|
|
55
|
+
claude-opus-4-6*:
|
|
56
|
+
input: 5.0
|
|
57
|
+
output: 25.0
|
|
58
|
+
cache_read: 0.5
|
|
59
|
+
cache_write: 6.25
|
|
60
|
+
|
|
61
|
+
claude-sonnet-5*:
|
|
62
|
+
input: 2.0
|
|
63
|
+
output: 10.0
|
|
64
|
+
cache_read: 0.2
|
|
65
|
+
cache_write: 2.5
|
|
66
|
+
|
|
67
|
+
claude-sonnet-4-6*:
|
|
68
|
+
input: 3.0
|
|
69
|
+
output: 15.0
|
|
70
|
+
cache_read: 0.3
|
|
71
|
+
cache_write: 3.75
|
|
72
|
+
|
|
73
|
+
claude-haiku-4-5*:
|
|
74
|
+
input: 1.0
|
|
75
|
+
output: 5.0
|
|
76
|
+
cache_read: 0.1
|
|
77
|
+
cache_write: 1.25
|
|
File without changes
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Anthropic API key upstream — the supported path."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Callable, Dict, List, Optional
|
|
7
|
+
|
|
8
|
+
from ..config import AccountConfig
|
|
9
|
+
from .base import Upstream
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger("tokenbiryani")
|
|
12
|
+
|
|
13
|
+
_plugins: Dict[str, Callable[[AccountConfig], Upstream]] = {}
|
|
14
|
+
_plugins_loaded = False
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def register_upstream(name: str, factory: Callable[[AccountConfig], Upstream]) -> None:
|
|
18
|
+
"""Register an account type at runtime. Built-in names cannot be shadowed."""
|
|
19
|
+
if name in BUILTIN_TYPES:
|
|
20
|
+
raise ValueError(f"{name!r} is a built-in account type and cannot be replaced")
|
|
21
|
+
_plugins[name] = factory
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def plugin_upstreams() -> Dict[str, Callable[[AccountConfig], Upstream]]:
|
|
25
|
+
global _plugins_loaded
|
|
26
|
+
if _plugins_loaded:
|
|
27
|
+
return _plugins
|
|
28
|
+
_plugins_loaded = True
|
|
29
|
+
for entry in _entry_points(PLUGIN_GROUP):
|
|
30
|
+
try:
|
|
31
|
+
register_upstream(entry.name, entry.load())
|
|
32
|
+
except Exception as exc: # noqa: BLE001 - a bad plugin must not be fatal
|
|
33
|
+
logger.warning("could not load account type %r: %s", entry.name, exc)
|
|
34
|
+
return _plugins
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _entry_points(group: str):
|
|
38
|
+
"""importlib.metadata changed shape in 3.10; support both."""
|
|
39
|
+
from importlib.metadata import entry_points
|
|
40
|
+
|
|
41
|
+
found = entry_points()
|
|
42
|
+
selector = getattr(found, "select", None)
|
|
43
|
+
if selector is not None:
|
|
44
|
+
return list(selector(group=group))
|
|
45
|
+
return list(found.get(group, []))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def available_types() -> List[str]:
|
|
49
|
+
return sorted(set(BUILTIN_TYPES) | set(plugin_upstreams()))
|
|
50
|
+
|
|
51
|
+
#: Entry point group third-party packages publish account types under.
|
|
52
|
+
PLUGIN_GROUP = "tokenbiryani.providers"
|
|
53
|
+
|
|
54
|
+
BUILTIN_TYPES = ("anthropic_api", "bedrock", "vertex", "oauth")
|
|
55
|
+
|
|
56
|
+
ANTHROPIC_VERSION = "2023-06-01"
|
|
57
|
+
DEFAULT_BASE_URL = "https://api.anthropic.com"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class AnthropicUpstream(Upstream):
|
|
61
|
+
def __init__(self, config: AccountConfig) -> None:
|
|
62
|
+
super().__init__(config.id)
|
|
63
|
+
self.config = config
|
|
64
|
+
self.base_url = (config.base_url or DEFAULT_BASE_URL).rstrip("/")
|
|
65
|
+
|
|
66
|
+
def url(self, path: str) -> str:
|
|
67
|
+
return self.base_url + "/" + path.lstrip("/")
|
|
68
|
+
|
|
69
|
+
def auth_headers(self) -> Dict[str, str]:
|
|
70
|
+
headers = {
|
|
71
|
+
"x-api-key": self.config.api_key,
|
|
72
|
+
"content-type": "application/json",
|
|
73
|
+
}
|
|
74
|
+
headers.update(self.config.headers)
|
|
75
|
+
headers.setdefault("anthropic-version", ANTHROPIC_VERSION)
|
|
76
|
+
return headers
|
|
77
|
+
|
|
78
|
+
def request_headers(self, client_headers):
|
|
79
|
+
headers = super().request_headers(client_headers)
|
|
80
|
+
# The client's own anthropic-version wins if it sent one; ours is only a default.
|
|
81
|
+
for name in list(headers):
|
|
82
|
+
if name.lower() == "anthropic-version" and name != "anthropic-version":
|
|
83
|
+
headers["anthropic-version"] = headers.pop(name)
|
|
84
|
+
return headers
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def build_upstream(
|
|
88
|
+
config: AccountConfig, token_provider: Optional[Callable[[], str]] = None
|
|
89
|
+
) -> Upstream:
|
|
90
|
+
"""Construct the upstream for an account type, built-in or installed.
|
|
91
|
+
|
|
92
|
+
`token_provider` is only meaningful for `oauth` accounts, whose credential is a
|
|
93
|
+
session the gateway refreshes rather than a value sitting in the config.
|
|
94
|
+
"""
|
|
95
|
+
if config.type in ("anthropic_api", "anthropic"):
|
|
96
|
+
return AnthropicUpstream(config)
|
|
97
|
+
if config.type == "oauth":
|
|
98
|
+
from .oauth import OAuthUpstream
|
|
99
|
+
|
|
100
|
+
return OAuthUpstream(config, token_provider)
|
|
101
|
+
if config.type == "bedrock":
|
|
102
|
+
from .bedrock import BedrockUpstream
|
|
103
|
+
|
|
104
|
+
return BedrockUpstream(config)
|
|
105
|
+
if config.type == "vertex":
|
|
106
|
+
from .vertex import VertexUpstream
|
|
107
|
+
|
|
108
|
+
return VertexUpstream(config)
|
|
109
|
+
|
|
110
|
+
factory = plugin_upstreams().get(config.type)
|
|
111
|
+
if factory is not None:
|
|
112
|
+
return factory(config)
|
|
113
|
+
|
|
114
|
+
raise ValueError(
|
|
115
|
+
f"unsupported account type {config.type!r}; known: "
|
|
116
|
+
f"{', '.join(available_types())}. Implement providers.base.Upstream and "
|
|
117
|
+
f"publish it under the {PLUGIN_GROUP!r} entry point to add another."
|
|
118
|
+
)
|