tierwise 0.8.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.
- tierwise/__init__.py +3 -0
- tierwise/__main__.py +101 -0
- tierwise/affinity.py +137 -0
- tierwise/api/__init__.py +0 -0
- tierwise/api/app.py +127 -0
- tierwise/api/auth.py +22 -0
- tierwise/api/errors.py +84 -0
- tierwise/api/routes.py +481 -0
- tierwise/api/streaming.py +205 -0
- tierwise/budgets.py +130 -0
- tierwise/cache.py +135 -0
- tierwise/config.py +85 -0
- tierwise/costs.py +28 -0
- tierwise/data/registry.yaml +172 -0
- tierwise/data/router/NOTICE +2 -0
- tierwise/data/router/classifier.npz +0 -0
- tierwise/data/router/router.json +6 -0
- tierwise/data/router/tokenizer.json +1 -0
- tierwise/fetch.py +69 -0
- tierwise/keys.py +51 -0
- tierwise/metrics.py +88 -0
- tierwise/options.py +69 -0
- tierwise/providers/__init__.py +0 -0
- tierwise/providers/anthropic.py +297 -0
- tierwise/providers/base.py +126 -0
- tierwise/providers/gemini.py +263 -0
- tierwise/providers/ollama.py +16 -0
- tierwise/providers/openai.py +143 -0
- tierwise/registry.py +104 -0
- tierwise/reliability/__init__.py +0 -0
- tierwise/reliability/breaker.py +151 -0
- tierwise/reliability/failover.py +100 -0
- tierwise/reliability/retry.py +34 -0
- tierwise/router/__init__.py +16 -0
- tierwise/router/classifier.py +67 -0
- tierwise/router/encoder.py +126 -0
- tierwise/router/features.py +80 -0
- tierwise/router/learned.py +152 -0
- tierwise/router/modes.py +96 -0
- tierwise/router/router.py +73 -0
- tierwise/router/rules.py +66 -0
- tierwise/schema.py +178 -0
- tierwise/selector.py +161 -0
- tierwise/store.py +353 -0
- tierwise/telemetry.py +48 -0
- tierwise/tokens.py +33 -0
- tierwise-0.8.0.dist-info/METADATA +408 -0
- tierwise-0.8.0.dist-info/RECORD +52 -0
- tierwise-0.8.0.dist-info/WHEEL +4 -0
- tierwise-0.8.0.dist-info/entry_points.txt +2 -0
- tierwise-0.8.0.dist-info/licenses/LICENSE +202 -0
- tierwise-0.8.0.dist-info/licenses/models/router/NOTICE +2 -0
tierwise/__init__.py
ADDED
tierwise/__main__.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Run the gateway (`tierwise`, `python -m tierwise`), manage API keys (`tierwise keys`) or
|
|
2
|
+
download the learned router's encoder (`tierwise fetch-model`)."""
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import asyncio
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import uvicorn
|
|
11
|
+
|
|
12
|
+
from tierwise import fetch
|
|
13
|
+
from tierwise.api.app import create_app
|
|
14
|
+
from tierwise.budgets import month_of
|
|
15
|
+
from tierwise.config import CACHE_ROUTER_DIR, Settings
|
|
16
|
+
from tierwise.keys import generate_key, hash_key
|
|
17
|
+
from tierwise.store import RequestLog
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _rpm(value: str) -> int:
|
|
21
|
+
rpm = int(value)
|
|
22
|
+
if rpm < 1:
|
|
23
|
+
raise argparse.ArgumentTypeError("must be at least 1 (omit --rpm for no limit)")
|
|
24
|
+
return rpm
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _budget(value: str) -> float:
|
|
28
|
+
budget = float(value)
|
|
29
|
+
if budget < 0:
|
|
30
|
+
raise argparse.ArgumentTypeError("must be zero or more (omit --budget for no limit)")
|
|
31
|
+
return budget
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _parser() -> argparse.ArgumentParser:
|
|
35
|
+
parser = argparse.ArgumentParser(prog="tierwise")
|
|
36
|
+
commands = parser.add_subparsers(dest="command")
|
|
37
|
+
commands.add_parser("serve", help="run the gateway (the default)")
|
|
38
|
+
keys = commands.add_parser("keys", help="manage gateway API keys").add_subparsers(
|
|
39
|
+
dest="action", required=True
|
|
40
|
+
)
|
|
41
|
+
create = keys.add_parser("create", help="create a key (printed once)")
|
|
42
|
+
create.add_argument("--name", required=True)
|
|
43
|
+
create.add_argument("--budget", type=_budget, help="monthly budget in USD")
|
|
44
|
+
create.add_argument("--rpm", type=_rpm, help="requests per minute")
|
|
45
|
+
keys.add_parser("list", help="list keys (no secrets)")
|
|
46
|
+
revoke = keys.add_parser("revoke", help="revoke a key by id")
|
|
47
|
+
revoke.add_argument("id")
|
|
48
|
+
fetch_model = commands.add_parser(
|
|
49
|
+
"fetch-model", help="download the learned router's encoder (90 MB, sha256-checked)"
|
|
50
|
+
)
|
|
51
|
+
fetch_model.add_argument("--out", type=Path, default=CACHE_ROUTER_DIR)
|
|
52
|
+
return parser
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
async def _keys(args: argparse.Namespace, db_url: str) -> int:
|
|
56
|
+
log = RequestLog(db_url)
|
|
57
|
+
await log.start()
|
|
58
|
+
try:
|
|
59
|
+
if args.action == "create":
|
|
60
|
+
key = generate_key()
|
|
61
|
+
row = await log.create_key(args.name, hash_key(key), key[:10], args.budget, args.rpm)
|
|
62
|
+
print(f"id: {row.id}\nkey: {key}\nStore the key now: it is not shown again.")
|
|
63
|
+
return 0
|
|
64
|
+
if args.action == "revoke":
|
|
65
|
+
if await log.revoke_key(args.id):
|
|
66
|
+
print(f"revoked {args.id}")
|
|
67
|
+
return 0
|
|
68
|
+
print(f"no active key with id {args.id}", file=sys.stderr)
|
|
69
|
+
return 1
|
|
70
|
+
spend = await log.load_month_spend(month_of(time.time()))
|
|
71
|
+
print(f"{'id':<14}{'name':<16}{'prefix':<12}{'budget':>10}{'spent':>10}{'rpm':>6} status")
|
|
72
|
+
for row in await log.list_keys():
|
|
73
|
+
budget = "-" if row.monthly_budget_usd is None else f"{row.monthly_budget_usd:.2f}"
|
|
74
|
+
rpm = "-" if row.rpm is None else str(row.rpm)
|
|
75
|
+
status = "active" if row.revoked_ts is None else "revoked"
|
|
76
|
+
print(f"{row.id:<14}{row.name:<16}{row.prefix:<12}{budget:>10}"
|
|
77
|
+
f"{spend.get(row.id, 0.0):>10.2f}{rpm:>6} {status}") # fmt: skip
|
|
78
|
+
return 0
|
|
79
|
+
finally:
|
|
80
|
+
await log.stop()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def main(argv: list[str] | None = None) -> int:
|
|
84
|
+
args = _parser().parse_args(argv)
|
|
85
|
+
if args.command == "fetch-model":
|
|
86
|
+
try:
|
|
87
|
+
path = fetch.fetch_model(args.out)
|
|
88
|
+
except fetch.FetchError as exc:
|
|
89
|
+
print(f"fetch-model failed: {exc}", file=sys.stderr)
|
|
90
|
+
return 1
|
|
91
|
+
print(f"wrote {path}")
|
|
92
|
+
return 0
|
|
93
|
+
settings = Settings()
|
|
94
|
+
if args.command == "keys":
|
|
95
|
+
return asyncio.run(_keys(args, settings.db_url))
|
|
96
|
+
uvicorn.run(create_app(settings), host=settings.host, port=settings.port)
|
|
97
|
+
return 0
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
if __name__ == "__main__":
|
|
101
|
+
sys.exit(main())
|
tierwise/affinity.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Conversation memory: which model served which prompt prefix, and when.
|
|
2
|
+
|
|
3
|
+
Providers cache the start of a prompt, so the model that served a conversation's earlier
|
|
4
|
+
turns can be cheaper for the next turn than a "cheaper" model whose cache is cold.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import hashlib
|
|
8
|
+
import json
|
|
9
|
+
import time
|
|
10
|
+
from collections import OrderedDict
|
|
11
|
+
from collections.abc import Callable, Sequence
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from tierwise.schema import ChatRequest
|
|
15
|
+
from tierwise.tokens import estimate_text, text_of
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _items(req: ChatRequest) -> list[tuple[str, int]]:
|
|
19
|
+
"""The prompt as cache-able items, in the order providers see them: tools, then messages."""
|
|
20
|
+
items: list[tuple[str, int]] = []
|
|
21
|
+
if req.tools:
|
|
22
|
+
tools = [t.model_dump(exclude_none=True) for t in req.tools]
|
|
23
|
+
items.append((_canonical({"tools": tools}), estimate_text(json.dumps(tools))))
|
|
24
|
+
for m in req.messages:
|
|
25
|
+
items.append(
|
|
26
|
+
(_canonical(m.model_dump(exclude_none=True)), estimate_text(text_of(m.content)))
|
|
27
|
+
)
|
|
28
|
+
return items
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _canonical(value: Any) -> str:
|
|
32
|
+
return json.dumps(value, sort_keys=True, separators=(",", ":"))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def prefix_boundaries(req: ChatRequest) -> list[tuple[str, int]]:
|
|
36
|
+
"""(rolling hash, cumulative estimated tokens) after each item of the prompt.
|
|
37
|
+
|
|
38
|
+
Providers cache the *beginning* of a prompt, so turn N+1 of a conversation can reuse the
|
|
39
|
+
cache written for turn N: the whole turn-N prompt is one of turn N+1's boundaries.
|
|
40
|
+
The last entry covers the full prompt.
|
|
41
|
+
"""
|
|
42
|
+
boundaries: list[tuple[str, int]] = []
|
|
43
|
+
digest, tokens = b"", 0
|
|
44
|
+
for canonical, item_tokens in _items(req):
|
|
45
|
+
digest = hashlib.sha256(digest + canonical.encode()).digest()
|
|
46
|
+
tokens += item_tokens
|
|
47
|
+
boundaries.append((digest.hex(), tokens))
|
|
48
|
+
return boundaries
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def split_prefix(req: ChatRequest) -> tuple[str | None, int]:
|
|
52
|
+
"""Hash and size of everything before the last user message (the conversation so far)."""
|
|
53
|
+
last_user = max((i for i, m in enumerate(req.messages) if m.role == "user"), default=None)
|
|
54
|
+
count = (1 if req.tools else 0) + (len(req.messages) if last_user is None else last_user)
|
|
55
|
+
if count == 0:
|
|
56
|
+
return None, 0
|
|
57
|
+
return prefix_boundaries(req)[count - 1]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class AffinityTable:
|
|
61
|
+
"""In-memory LRU of prefix hash -> {(model id, effort sent): when it last served it}."""
|
|
62
|
+
|
|
63
|
+
def __init__(
|
|
64
|
+
self, max_entries: int = 10_000, clock: Callable[[], float] = time.monotonic
|
|
65
|
+
) -> None:
|
|
66
|
+
self._entries: OrderedDict[str, dict[tuple[str, str | None], float]] = OrderedDict()
|
|
67
|
+
self._max_entries = max_entries
|
|
68
|
+
self._clock = clock
|
|
69
|
+
|
|
70
|
+
def record(self, prefix_hash: str, model_id: str, effort: str | None = None) -> None:
|
|
71
|
+
served = self._entries.pop(prefix_hash, {})
|
|
72
|
+
served[(model_id, effort)] = self._clock()
|
|
73
|
+
self._entries[prefix_hash] = served
|
|
74
|
+
while len(self._entries) > self._max_entries:
|
|
75
|
+
self._entries.popitem(last=False)
|
|
76
|
+
|
|
77
|
+
def warm_models(self, prefix_hash: str | None) -> dict[str, float]:
|
|
78
|
+
"""Model id -> most recent time it served this prefix, at any effort."""
|
|
79
|
+
latest: dict[str, float] = {}
|
|
80
|
+
for (model_id, _), served_at in self._entries.get(prefix_hash or "", {}).items():
|
|
81
|
+
latest[model_id] = max(served_at, latest.get(model_id, served_at))
|
|
82
|
+
return latest
|
|
83
|
+
|
|
84
|
+
def is_warm(
|
|
85
|
+
self,
|
|
86
|
+
prefix_hash: str | None,
|
|
87
|
+
model_id: str,
|
|
88
|
+
ttl: int | None,
|
|
89
|
+
effort: str | None = None,
|
|
90
|
+
effort_matters: bool = False,
|
|
91
|
+
) -> bool:
|
|
92
|
+
"""Whether ``model_id`` still has this prefix cached.
|
|
93
|
+
|
|
94
|
+
Where effort is part of the provider's cache key, only the same effort counts.
|
|
95
|
+
"""
|
|
96
|
+
if ttl is None:
|
|
97
|
+
return False
|
|
98
|
+
now = self._clock()
|
|
99
|
+
return any(
|
|
100
|
+
mid == model_id and (not effort_matters or used == effort) and now - at <= ttl
|
|
101
|
+
for (mid, used), at in self._entries.get(prefix_hash or "", {}).items()
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
def longest_warm(
|
|
105
|
+
self,
|
|
106
|
+
boundaries: Sequence[tuple[str, int]],
|
|
107
|
+
model_id: str,
|
|
108
|
+
ttl: int | None,
|
|
109
|
+
effort: str | None = None,
|
|
110
|
+
effort_matters: bool = False,
|
|
111
|
+
) -> int:
|
|
112
|
+
"""Estimated tokens of the longest boundary ``model_id`` has warm in its cache."""
|
|
113
|
+
for prefix_hash, tokens in reversed(boundaries):
|
|
114
|
+
if self.is_warm(prefix_hash, model_id, ttl, effort, effort_matters):
|
|
115
|
+
return tokens
|
|
116
|
+
return 0
|
|
117
|
+
|
|
118
|
+
def chain_incumbent(
|
|
119
|
+
self, boundaries: Sequence[tuple[str, int]], ttl_for: Callable[[str], int | None]
|
|
120
|
+
) -> str | None:
|
|
121
|
+
"""The warm model for the longest boundary that has one."""
|
|
122
|
+
for prefix_hash, _ in reversed(boundaries):
|
|
123
|
+
if (model_id := self.incumbent(prefix_hash, ttl_for)) is not None:
|
|
124
|
+
return model_id
|
|
125
|
+
return None
|
|
126
|
+
|
|
127
|
+
def incumbent(
|
|
128
|
+
self, prefix_hash: str | None, ttl_for: Callable[[str], int | None]
|
|
129
|
+
) -> str | None:
|
|
130
|
+
"""The most recent model whose cache for this prefix should still be warm."""
|
|
131
|
+
now = self._clock()
|
|
132
|
+
warm = [
|
|
133
|
+
(served_at, model_id)
|
|
134
|
+
for model_id, served_at in self.warm_models(prefix_hash).items()
|
|
135
|
+
if (ttl := ttl_for(model_id)) is not None and now - served_at <= ttl
|
|
136
|
+
]
|
|
137
|
+
return max(warm)[1] if warm else None
|
tierwise/api/__init__.py
ADDED
|
File without changes
|
tierwise/api/app.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Application factory: registry, provider clients, learned router and the request log."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import contextlib
|
|
5
|
+
import time
|
|
6
|
+
from collections.abc import AsyncIterator
|
|
7
|
+
from contextlib import asynccontextmanager
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
from fastapi import FastAPI
|
|
11
|
+
|
|
12
|
+
from tierwise import __version__
|
|
13
|
+
from tierwise.affinity import AffinityTable
|
|
14
|
+
from tierwise.api.errors import install_error_handlers
|
|
15
|
+
from tierwise.api.routes import router
|
|
16
|
+
from tierwise.budgets import Budgets, RateLimiter, month_of
|
|
17
|
+
from tierwise.cache import SemanticCache
|
|
18
|
+
from tierwise.config import Settings, registry_file
|
|
19
|
+
from tierwise.keys import KeyStore
|
|
20
|
+
from tierwise.metrics import Metrics
|
|
21
|
+
from tierwise.providers.anthropic import AnthropicProvider
|
|
22
|
+
from tierwise.providers.base import Provider
|
|
23
|
+
from tierwise.providers.gemini import GeminiProvider
|
|
24
|
+
from tierwise.providers.ollama import OllamaProvider
|
|
25
|
+
from tierwise.providers.openai import OpenAIProvider
|
|
26
|
+
from tierwise.registry import Registry, load_registry
|
|
27
|
+
from tierwise.reliability.breaker import Breakers
|
|
28
|
+
from tierwise.reliability.retry import RetryPolicy
|
|
29
|
+
from tierwise.router.learned import load_learned
|
|
30
|
+
from tierwise.store import RequestLog
|
|
31
|
+
from tierwise.telemetry import configure_tracing
|
|
32
|
+
|
|
33
|
+
LIMITS = httpx.Limits(max_keepalive_connections=100, max_connections=250, keepalive_expiry=30)
|
|
34
|
+
TIMEOUT = httpx.Timeout(connect=5.0, read=120.0, write=30.0, pool=2.0)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def create_app(
|
|
38
|
+
settings: Settings | None = None,
|
|
39
|
+
transport: httpx.AsyncBaseTransport | None = None,
|
|
40
|
+
registry: Registry | None = None,
|
|
41
|
+
) -> FastAPI:
|
|
42
|
+
config = settings or Settings()
|
|
43
|
+
|
|
44
|
+
@asynccontextmanager
|
|
45
|
+
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
|
46
|
+
tracing = configure_tracing(config.otel_exporter_otlp_endpoint)
|
|
47
|
+
# Fail fast: an invalid registry stops startup instead of breaking requests later.
|
|
48
|
+
app.state.registry = registry or load_registry(registry_file(config))
|
|
49
|
+
|
|
50
|
+
# One pooled client per provider for the app's whole life: no per-request handshakes.
|
|
51
|
+
clients: dict[str, httpx.AsyncClient] = {}
|
|
52
|
+
providers: dict[str, Provider] = {}
|
|
53
|
+
|
|
54
|
+
def client_for(name: str) -> httpx.AsyncClient:
|
|
55
|
+
clients[name] = httpx.AsyncClient(limits=LIMITS, timeout=TIMEOUT, transport=transport)
|
|
56
|
+
return clients[name]
|
|
57
|
+
|
|
58
|
+
if config.anthropic_api_key:
|
|
59
|
+
providers["anthropic"] = AnthropicProvider(
|
|
60
|
+
client_for("anthropic"), config.anthropic_api_key
|
|
61
|
+
)
|
|
62
|
+
if config.openai_api_key:
|
|
63
|
+
providers["openai"] = OpenAIProvider(
|
|
64
|
+
client_for("openai"), config.openai_api_key, config.openai_base_url
|
|
65
|
+
)
|
|
66
|
+
if config.gemini_api_key:
|
|
67
|
+
providers["gemini"] = GeminiProvider(client_for("gemini"), config.gemini_api_key)
|
|
68
|
+
if config.ollama_base_url:
|
|
69
|
+
providers["ollama"] = OllamaProvider(client_for("ollama"), config.ollama_base_url)
|
|
70
|
+
app.state.clients = clients
|
|
71
|
+
app.state.providers = providers
|
|
72
|
+
app.state.affinity = AffinityTable()
|
|
73
|
+
app.state.breakers = Breakers(
|
|
74
|
+
config.breaker_failures, config.breaker_window_s, config.breaker_cooldown_s
|
|
75
|
+
)
|
|
76
|
+
app.state.retry = RetryPolicy(config.retries, config.retry_base_s, config.retry_max_s)
|
|
77
|
+
app.state.stream_hold = (config.stream_hold_s, config.stream_hold_bytes)
|
|
78
|
+
|
|
79
|
+
# Loading and warming the ONNX session blocks; keep it off the event loop.
|
|
80
|
+
learned = await asyncio.to_thread(load_learned, config)
|
|
81
|
+
app.state.learned = learned
|
|
82
|
+
app.state.cache = (
|
|
83
|
+
SemanticCache(
|
|
84
|
+
learned.embed_text,
|
|
85
|
+
config.cache_similarity,
|
|
86
|
+
config.cache_ttl_s,
|
|
87
|
+
config.cache_max_entries,
|
|
88
|
+
)
|
|
89
|
+
if learned is not None and config.cache_enabled
|
|
90
|
+
else None
|
|
91
|
+
)
|
|
92
|
+
app.state.cache_max_temperature = config.cache_max_temperature
|
|
93
|
+
|
|
94
|
+
log = RequestLog(config.db_url)
|
|
95
|
+
await log.start()
|
|
96
|
+
app.state.log = log
|
|
97
|
+
keys = KeyStore()
|
|
98
|
+
keys.replace(await log.list_keys())
|
|
99
|
+
app.state.keys = keys
|
|
100
|
+
app.state.require_api_key = config.require_api_key
|
|
101
|
+
app.state.limiter = RateLimiter()
|
|
102
|
+
app.state.metrics = Metrics(
|
|
103
|
+
app.state.breakers, log, [m.id for m in app.state.registry.models]
|
|
104
|
+
)
|
|
105
|
+
budgets = Budgets(log)
|
|
106
|
+
month = month_of(time.time())
|
|
107
|
+
budgets.load(month, await log.load_month_spend(month))
|
|
108
|
+
app.state.budgets = budgets
|
|
109
|
+
refresh = asyncio.create_task(keys.refresh_forever(log, config.keys_refresh_s))
|
|
110
|
+
try:
|
|
111
|
+
yield
|
|
112
|
+
finally:
|
|
113
|
+
refresh.cancel()
|
|
114
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
115
|
+
await refresh
|
|
116
|
+
for client in clients.values():
|
|
117
|
+
await client.aclose()
|
|
118
|
+
await log.stop()
|
|
119
|
+
if learned is not None:
|
|
120
|
+
learned.close()
|
|
121
|
+
if tracing is not None: # last: a slow collector must not delay the debit flush
|
|
122
|
+
tracing.shutdown()
|
|
123
|
+
|
|
124
|
+
app = FastAPI(title="tierwise", version=__version__, lifespan=lifespan)
|
|
125
|
+
install_error_handlers(app)
|
|
126
|
+
app.include_router(router)
|
|
127
|
+
return app
|
tierwise/api/auth.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Bearer-token authentication against the gateway's API keys."""
|
|
2
|
+
|
|
3
|
+
from tierwise.api.errors import InvalidApiKey
|
|
4
|
+
from tierwise.keys import KeyStore
|
|
5
|
+
from tierwise.store import KeyRow
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def authenticate(header: str | None, store: KeyStore, required: bool) -> KeyRow | None:
|
|
9
|
+
"""The key named by ``Authorization: Bearer <key>``.
|
|
10
|
+
|
|
11
|
+
When keys are required, a missing, malformed, unknown or revoked key is a 401. When they
|
|
12
|
+
aren't, a valid key is still resolved so its spend is tracked, and anything else is
|
|
13
|
+
anonymous (None).
|
|
14
|
+
"""
|
|
15
|
+
parts = (header or "").split()
|
|
16
|
+
if len(parts) == 2 and parts[0].lower() == "bearer":
|
|
17
|
+
row = store.find(parts[1])
|
|
18
|
+
if row is not None:
|
|
19
|
+
return row
|
|
20
|
+
if required:
|
|
21
|
+
raise InvalidApiKey()
|
|
22
|
+
return None
|
tierwise/api/errors.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""OpenAI-shaped error responses for everything the gateway can raise."""
|
|
2
|
+
|
|
3
|
+
from fastapi import FastAPI, Request
|
|
4
|
+
from fastapi.exceptions import RequestValidationError
|
|
5
|
+
from fastapi.responses import JSONResponse
|
|
6
|
+
|
|
7
|
+
from tierwise.providers.base import AuthError, FatalError, ProviderError, RetryableError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class GatewayError(Exception):
|
|
11
|
+
def __init__(
|
|
12
|
+
self, status: int, code: str, message: str, type_: str = "invalid_request_error"
|
|
13
|
+
) -> None:
|
|
14
|
+
super().__init__(message)
|
|
15
|
+
self.status = status
|
|
16
|
+
self.code = code
|
|
17
|
+
self.message = message
|
|
18
|
+
self.type = type_
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class InvalidApiKey(GatewayError):
|
|
22
|
+
def __init__(self) -> None:
|
|
23
|
+
super().__init__(401, "invalid_api_key", "Invalid or missing API key.")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class RateLimited(GatewayError):
|
|
27
|
+
def __init__(self, retry_after: int) -> None:
|
|
28
|
+
message = f"Rate limit exceeded for this API key; retry in {retry_after} s."
|
|
29
|
+
super().__init__(429, "rate_limited", message, "rate_limit_error")
|
|
30
|
+
self.retry_after = retry_after
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class BudgetExceeded(GatewayError):
|
|
34
|
+
def __init__(self, message: str) -> None:
|
|
35
|
+
super().__init__(429, "budget_exceeded", message, "insufficient_quota")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class AllCircuitsOpen(GatewayError):
|
|
39
|
+
"""Every candidate's circuit breaker is open, so nothing was attempted."""
|
|
40
|
+
|
|
41
|
+
def __init__(self, skipped: list[str]) -> None:
|
|
42
|
+
names = ", ".join(skipped) or "none"
|
|
43
|
+
message = f"All candidate models are temporarily unavailable (circuit open: {names})."
|
|
44
|
+
super().__init__(503, "upstream_unavailable", message, "api_error")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def error_response(status: int, code: str, message: str, type_: str) -> JSONResponse:
|
|
48
|
+
return JSONResponse(
|
|
49
|
+
status_code=status, content={"error": {"message": message, "type": type_, "code": code}}
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def gateway_error_from(exc: ProviderError) -> GatewayError:
|
|
54
|
+
if isinstance(exc, AuthError):
|
|
55
|
+
# Never echo the upstream body: it can contain (part of) our key.
|
|
56
|
+
message = f"Upstream provider '{exc.provider}' rejected the gateway's credentials."
|
|
57
|
+
return GatewayError(502, "upstream_auth_error", message, "api_error")
|
|
58
|
+
if isinstance(exc, FatalError):
|
|
59
|
+
return GatewayError(exc.status or 400, "invalid_request_error", exc.message)
|
|
60
|
+
if isinstance(exc, RetryableError):
|
|
61
|
+
message = f"Upstream provider '{exc.provider}' is unavailable: {exc.message}"
|
|
62
|
+
return GatewayError(503, "upstream_unavailable", message, "api_error")
|
|
63
|
+
return GatewayError(502, "upstream_error", exc.message, "api_error")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def install_error_handlers(app: FastAPI) -> None:
|
|
67
|
+
@app.exception_handler(GatewayError)
|
|
68
|
+
async def _gateway(_: Request, exc: GatewayError) -> JSONResponse:
|
|
69
|
+
response = error_response(exc.status, exc.code, exc.message, exc.type)
|
|
70
|
+
if isinstance(exc, RateLimited):
|
|
71
|
+
response.headers["Retry-After"] = str(exc.retry_after)
|
|
72
|
+
return response
|
|
73
|
+
|
|
74
|
+
@app.exception_handler(ProviderError)
|
|
75
|
+
async def _provider(_: Request, exc: ProviderError) -> JSONResponse:
|
|
76
|
+
err = gateway_error_from(exc)
|
|
77
|
+
return error_response(err.status, err.code, err.message, err.type)
|
|
78
|
+
|
|
79
|
+
@app.exception_handler(RequestValidationError)
|
|
80
|
+
async def _validation(_: Request, exc: RequestValidationError) -> JSONResponse:
|
|
81
|
+
problems = "; ".join(
|
|
82
|
+
f"{'.'.join(str(p) for p in e['loc'])}: {e['msg']}" for e in exc.errors()
|
|
83
|
+
)
|
|
84
|
+
return error_response(400, "invalid_request_error", problems, "invalid_request_error")
|