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.
Files changed (52) hide show
  1. tierwise/__init__.py +3 -0
  2. tierwise/__main__.py +101 -0
  3. tierwise/affinity.py +137 -0
  4. tierwise/api/__init__.py +0 -0
  5. tierwise/api/app.py +127 -0
  6. tierwise/api/auth.py +22 -0
  7. tierwise/api/errors.py +84 -0
  8. tierwise/api/routes.py +481 -0
  9. tierwise/api/streaming.py +205 -0
  10. tierwise/budgets.py +130 -0
  11. tierwise/cache.py +135 -0
  12. tierwise/config.py +85 -0
  13. tierwise/costs.py +28 -0
  14. tierwise/data/registry.yaml +172 -0
  15. tierwise/data/router/NOTICE +2 -0
  16. tierwise/data/router/classifier.npz +0 -0
  17. tierwise/data/router/router.json +6 -0
  18. tierwise/data/router/tokenizer.json +1 -0
  19. tierwise/fetch.py +69 -0
  20. tierwise/keys.py +51 -0
  21. tierwise/metrics.py +88 -0
  22. tierwise/options.py +69 -0
  23. tierwise/providers/__init__.py +0 -0
  24. tierwise/providers/anthropic.py +297 -0
  25. tierwise/providers/base.py +126 -0
  26. tierwise/providers/gemini.py +263 -0
  27. tierwise/providers/ollama.py +16 -0
  28. tierwise/providers/openai.py +143 -0
  29. tierwise/registry.py +104 -0
  30. tierwise/reliability/__init__.py +0 -0
  31. tierwise/reliability/breaker.py +151 -0
  32. tierwise/reliability/failover.py +100 -0
  33. tierwise/reliability/retry.py +34 -0
  34. tierwise/router/__init__.py +16 -0
  35. tierwise/router/classifier.py +67 -0
  36. tierwise/router/encoder.py +126 -0
  37. tierwise/router/features.py +80 -0
  38. tierwise/router/learned.py +152 -0
  39. tierwise/router/modes.py +96 -0
  40. tierwise/router/router.py +73 -0
  41. tierwise/router/rules.py +66 -0
  42. tierwise/schema.py +178 -0
  43. tierwise/selector.py +161 -0
  44. tierwise/store.py +353 -0
  45. tierwise/telemetry.py +48 -0
  46. tierwise/tokens.py +33 -0
  47. tierwise-0.8.0.dist-info/METADATA +408 -0
  48. tierwise-0.8.0.dist-info/RECORD +52 -0
  49. tierwise-0.8.0.dist-info/WHEEL +4 -0
  50. tierwise-0.8.0.dist-info/entry_points.txt +2 -0
  51. tierwise-0.8.0.dist-info/licenses/LICENSE +202 -0
  52. tierwise-0.8.0.dist-info/licenses/models/router/NOTICE +2 -0
tierwise/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """tierwise: cost-aware, self-hosted LLM gateway with a learned router."""
2
+
3
+ __version__ = "0.1.0"
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
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")