cachellm-proxy 0.1.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 (43) hide show
  1. cachellm/__init__.py +13 -0
  2. cachellm/__main__.py +6 -0
  3. cachellm/api/__init__.py +5 -0
  4. cachellm/api/app.py +143 -0
  5. cachellm/api/auth.py +34 -0
  6. cachellm/api/deps.py +106 -0
  7. cachellm/api/routes_admin.py +265 -0
  8. cachellm/api/routes_chat.py +385 -0
  9. cachellm/api/sse.py +98 -0
  10. cachellm/cache/__init__.py +3 -0
  11. cachellm/cache/analytics.py +150 -0
  12. cachellm/cache/coalesce.py +63 -0
  13. cachellm/cache/entry.py +92 -0
  14. cachellm/cache/exact_store.py +33 -0
  15. cachellm/cache/keys.py +124 -0
  16. cachellm/cache/policy.py +134 -0
  17. cachellm/cache/redis_client.py +22 -0
  18. cachellm/cache/service.py +332 -0
  19. cachellm/cache/vector_store.py +217 -0
  20. cachellm/cli.py +122 -0
  21. cachellm/embeddings/__init__.py +19 -0
  22. cachellm/embeddings/base.py +38 -0
  23. cachellm/embeddings/fastembed_backend.py +75 -0
  24. cachellm/embeddings/hash_backend.py +42 -0
  25. cachellm/errors.py +72 -0
  26. cachellm/logging_setup.py +56 -0
  27. cachellm/models.py +181 -0
  28. cachellm/observability/__init__.py +6 -0
  29. cachellm/observability/metrics.py +147 -0
  30. cachellm/observability/tracing.py +107 -0
  31. cachellm/pricing.py +108 -0
  32. cachellm/providers/__init__.py +7 -0
  33. cachellm/providers/base.py +84 -0
  34. cachellm/providers/bedrock.py +238 -0
  35. cachellm/providers/fake.py +56 -0
  36. cachellm/providers/openai_compat.py +131 -0
  37. cachellm/providers/registry.py +96 -0
  38. cachellm/py.typed +0 -0
  39. cachellm/settings.py +230 -0
  40. cachellm_proxy-0.1.0.dist-info/METADATA +550 -0
  41. cachellm_proxy-0.1.0.dist-info/RECORD +43 -0
  42. cachellm_proxy-0.1.0.dist-info/WHEEL +4 -0
  43. cachellm_proxy-0.1.0.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,131 @@
1
+ """Any OpenAI-compatible upstream: OpenAI itself, Groq, Together, OpenRouter,
2
+ vLLM, LM Studio, llama.cpp server.
3
+
4
+ One adapter covers all of them because they all speak the same
5
+ ``POST /chat/completions``. That is the whole reason the OpenAI shape became
6
+ the industry contract, and it is why CacheLLM speaks it on the front door too.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ from collections.abc import AsyncIterator
13
+ from typing import Any
14
+
15
+ import httpx
16
+ import structlog
17
+
18
+ from cachellm.errors import UpstreamError
19
+ from cachellm.models import ChatCompletionRequest
20
+ from cachellm.providers.base import (
21
+ Provider,
22
+ ProviderResult,
23
+ StreamEvent,
24
+ normalise_finish_reason,
25
+ )
26
+ from cachellm.settings import Settings
27
+
28
+ log = structlog.get_logger(__name__)
29
+
30
+
31
+ class OpenAICompatProvider(Provider):
32
+ name = "openai"
33
+
34
+ def __init__(
35
+ self, settings: Settings, base_url: str | None = None, api_key: str | None = None
36
+ ) -> None:
37
+ self._settings = settings
38
+ self._base_url = (base_url or settings.openai_base_url).rstrip("/")
39
+ self._api_key = api_key if api_key is not None else settings.openai_api_key
40
+ self._client: httpx.AsyncClient | None = None
41
+
42
+ def _http(self) -> httpx.AsyncClient:
43
+ if self._client is None:
44
+ headers = {"Content-Type": "application/json"}
45
+ if self._api_key:
46
+ headers["Authorization"] = f"Bearer {self._api_key}"
47
+ self._client = httpx.AsyncClient(
48
+ base_url=self._base_url,
49
+ headers=headers,
50
+ timeout=httpx.Timeout(self._settings.request_timeout, connect=10.0),
51
+ )
52
+ return self._client
53
+
54
+ def _payload(self, request: ChatCompletionRequest, stream: bool) -> dict[str, Any]:
55
+ body = request.model_dump(exclude_none=True, by_alias=True)
56
+ body["model"] = self.resolve_model(request.model)
57
+ body["stream"] = stream
58
+ body.pop("stream_options", None)
59
+ if stream:
60
+ body["stream_options"] = {"include_usage": True}
61
+ return body
62
+
63
+ async def complete(self, request: ChatCompletionRequest) -> ProviderResult:
64
+ try:
65
+ response = await self._http().post(
66
+ "/chat/completions", json=self._payload(request, False)
67
+ )
68
+ response.raise_for_status()
69
+ except httpx.HTTPStatusError as exc:
70
+ detail = exc.response.text[:400]
71
+ log.warning("openai_http_error", status=exc.response.status_code, detail=detail)
72
+ raise UpstreamError(
73
+ f"Upstream returned {exc.response.status_code}: {detail}",
74
+ status_code=exc.response.status_code,
75
+ ) from exc
76
+ except httpx.HTTPError as exc:
77
+ raise UpstreamError(f"Upstream request failed: {exc}") from exc
78
+
79
+ data = response.json()
80
+ choice = (data.get("choices") or [{}])[0]
81
+ message = choice.get("message", {}) or {}
82
+ usage = data.get("usage", {}) or {}
83
+ return ProviderResult(
84
+ text=message.get("content") or "",
85
+ model=data.get("model", self.resolve_model(request.model)),
86
+ prompt_tokens=int(usage.get("prompt_tokens", 0) or 0),
87
+ completion_tokens=int(usage.get("completion_tokens", 0) or 0),
88
+ finish_reason=normalise_finish_reason(choice.get("finish_reason")),
89
+ tool_calls=message.get("tool_calls"),
90
+ )
91
+
92
+ async def stream(self, request: ChatCompletionRequest) -> AsyncIterator[StreamEvent]:
93
+ payload = self._payload(request, True)
94
+ try:
95
+ async with self._http().stream("POST", "/chat/completions", json=payload) as response:
96
+ if response.status_code >= 400:
97
+ body = (await response.aread()).decode("utf-8", "replace")[:400]
98
+ raise UpstreamError(
99
+ f"Upstream returned {response.status_code}: {body}",
100
+ status_code=response.status_code,
101
+ )
102
+ async for line in response.aiter_lines():
103
+ if not line.startswith("data:"):
104
+ continue
105
+ data = line[5:].strip()
106
+ if data == "[DONE]":
107
+ break
108
+ try:
109
+ chunk = json.loads(data)
110
+ except json.JSONDecodeError:
111
+ continue
112
+ usage = chunk.get("usage") or {}
113
+ if usage:
114
+ yield StreamEvent(
115
+ prompt_tokens=int(usage.get("prompt_tokens", 0) or 0),
116
+ completion_tokens=int(usage.get("completion_tokens", 0) or 0),
117
+ )
118
+ for choice in chunk.get("choices", []) or []:
119
+ delta = (choice.get("delta") or {}).get("content") or ""
120
+ finish = choice.get("finish_reason")
121
+ if delta:
122
+ yield StreamEvent(delta=delta)
123
+ if finish:
124
+ yield StreamEvent(finish_reason=normalise_finish_reason(finish))
125
+ except httpx.HTTPError as exc:
126
+ raise UpstreamError(f"Upstream stream failed: {exc}") from exc
127
+
128
+ async def close(self) -> None:
129
+ if self._client is not None:
130
+ await self._client.aclose()
131
+ self._client = None
@@ -0,0 +1,96 @@
1
+ """Model to provider routing.
2
+
3
+ Two ways to reach a provider:
4
+
5
+ * explicit prefix, ``bedrock/us.amazon.nova-lite-v1:0`` or ``openai/gpt-4o-mini``,
6
+ which is unambiguous and is what the docs recommend;
7
+ * bare model id, which is what a drop-in client actually sends, routed by
8
+ recognising vendor prefixes and otherwise falling back to the configured
9
+ default provider.
10
+
11
+ The provider name is part of the cache namespace, so a Bedrock answer can never
12
+ be served to an OpenAI request even when the model strings look alike.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import structlog
18
+
19
+ from cachellm.providers.base import Provider
20
+ from cachellm.providers.bedrock import BedrockProvider, looks_like_bedrock_model
21
+ from cachellm.providers.fake import FakeProvider
22
+ from cachellm.providers.openai_compat import OpenAICompatProvider
23
+ from cachellm.settings import Settings
24
+
25
+ log = structlog.get_logger(__name__)
26
+
27
+ # Model families we can attribute to a vendor with confidence. A bare
28
+ # "gpt-4o-mini" from a drop-in client means OpenAI, whatever the configured
29
+ # default provider happens to be.
30
+ OPENAI_MODEL_PREFIXES = (
31
+ "gpt-",
32
+ "o1",
33
+ "o3",
34
+ "o4",
35
+ "chatgpt-",
36
+ "text-embedding-",
37
+ "davinci",
38
+ "babbage",
39
+ )
40
+
41
+ # Shown by GET /v1/models so a client can discover what this proxy will route.
42
+ SUGGESTED_MODELS = [
43
+ "bedrock/us.amazon.nova-micro-v1:0",
44
+ "bedrock/us.amazon.nova-lite-v1:0",
45
+ "bedrock/us.amazon.nova-pro-v1:0",
46
+ "bedrock/anthropic.claude-3-haiku-20240307-v1:0",
47
+ "openai/gpt-4o-mini",
48
+ "fake/echo",
49
+ ]
50
+
51
+
52
+ class ProviderRegistry:
53
+ def __init__(self, settings: Settings, providers: dict[str, Provider] | None = None) -> None:
54
+ self._settings = settings
55
+ self._providers: dict[str, Provider] = providers or {}
56
+
57
+ def _get_or_create(self, name: str) -> Provider:
58
+ if name not in self._providers:
59
+ if name == "bedrock":
60
+ self._providers[name] = BedrockProvider(self._settings)
61
+ elif name == "openai":
62
+ self._providers[name] = OpenAICompatProvider(self._settings)
63
+ elif name == "fake":
64
+ self._providers[name] = FakeProvider(latency_ms=self._settings.fake_latency_ms)
65
+ else: # pragma: no cover - guarded by resolve()
66
+ raise ValueError(f"unknown provider {name!r}")
67
+ return self._providers[name]
68
+
69
+ def provider_name_for(self, model: str) -> str:
70
+ model = model.strip()
71
+ if "/" in model:
72
+ prefix = model.split("/", 1)[0].lower()
73
+ if prefix in ("bedrock", "openai", "fake"):
74
+ return prefix
75
+ if model.startswith("fake-"):
76
+ return "fake"
77
+ if looks_like_bedrock_model(model):
78
+ return "bedrock"
79
+ if model.lower().startswith(OPENAI_MODEL_PREFIXES):
80
+ return "openai"
81
+ return self._settings.default_provider
82
+
83
+ def resolve(self, model: str) -> tuple[Provider, str]:
84
+ name = self.provider_name_for(model)
85
+ return self._get_or_create(name), name
86
+
87
+ def register(self, name: str, provider: Provider) -> None:
88
+ self._providers[name] = provider
89
+
90
+ def models(self) -> list[str]:
91
+ return list(SUGGESTED_MODELS)
92
+
93
+ async def close(self) -> None:
94
+ for provider in self._providers.values():
95
+ await provider.close()
96
+ self._providers.clear()
cachellm/py.typed ADDED
File without changes
cachellm/settings.py ADDED
@@ -0,0 +1,230 @@
1
+ """Typed configuration for CacheLLM.
2
+
3
+ Every value can be set through an environment variable prefixed with
4
+ ``CACHELLM_`` (for example ``CACHELLM_THRESHOLD_DEFAULT=0.94``) or through a
5
+ ``.env`` file sitting next to the process working directory. Defaults are
6
+ deliberately conservative: it is far worse to serve a confidently wrong cached
7
+ answer than to miss the cache and pay for a fresh call.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from functools import lru_cache
13
+ from typing import Literal
14
+
15
+ from pydantic import field_validator
16
+ from pydantic_settings import BaseSettings, SettingsConfigDict
17
+
18
+ # Embedding dimensions for the models we ship presets for. Used to catch the
19
+ # classic "changed the model but not the index" mistake at startup.
20
+ KNOWN_EMBEDDING_DIMS: dict[str, int] = {
21
+ "BAAI/bge-small-en-v1.5": 384,
22
+ "BAAI/bge-base-en-v1.5": 768,
23
+ "sentence-transformers/all-MiniLM-L6-v2": 384,
24
+ "snowflake/snowflake-arctic-embed-s": 384,
25
+ "jinaai/jina-embeddings-v2-small-en": 512,
26
+ "thenlper/gte-base": 768,
27
+ "hash-embedder": 384,
28
+ }
29
+
30
+ # Similarity thresholds DO NOT transfer between embedding models. Measured on
31
+ # the project's own labelled corpus (see `bench/compare_models.py` and
32
+ # docs/evaluation.md), these are the highest thresholds at which each model
33
+ # served zero hard negatives: pairs one word apart with opposite meaning, like
34
+ # "undo the last git commit" versus "undo the last git merge".
35
+ #
36
+ # The spread is the point. bge-small needs 0.96 where MiniLM needs 0.89, so a
37
+ # threshold copied from a blog post is meaningless without naming the model it
38
+ # was measured on.
39
+ CALIBRATED_THRESHOLDS: dict[str, float] = {
40
+ "sentence-transformers/all-MiniLM-L6-v2": 0.89,
41
+ "BAAI/bge-small-en-v1.5": 0.96,
42
+ "BAAI/bge-base-en-v1.5": 0.94,
43
+ "snowflake/snowflake-arctic-embed-s": 0.98,
44
+ "jinaai/jina-embeddings-v2-small-en": 0.96,
45
+ "thenlper/gte-base": 0.96,
46
+ }
47
+
48
+ Category = Literal[
49
+ "factual",
50
+ "classification",
51
+ "creative",
52
+ "volatile",
53
+ "conversational",
54
+ "default",
55
+ ]
56
+
57
+
58
+ class Settings(BaseSettings):
59
+ model_config = SettingsConfigDict(
60
+ env_file=".env",
61
+ env_file_encoding="utf-8",
62
+ env_prefix="CACHELLM_",
63
+ extra="ignore",
64
+ protected_namespaces=(),
65
+ )
66
+
67
+ # ------------------------------------------------------------------ server
68
+ host: str = "127.0.0.1"
69
+ port: int = 8080
70
+ log_level: str = "INFO"
71
+ log_json: bool = True
72
+ #: Prompt and completion text is NEVER written to logs unless this is on.
73
+ log_prompts: bool = False
74
+
75
+ # -------------------------------------------------------------------- auth
76
+ #: Comma-separated client keys. Empty means auth is disabled (local dev only).
77
+ api_keys: str = ""
78
+ require_auth_for_admin: bool = True
79
+
80
+ # ------------------------------------------------------------------- redis
81
+ redis_url: str = "redis://localhost:6379/0"
82
+ index_name: str = "cachellm_idx"
83
+ entry_prefix: str = "cachellm:e:"
84
+ exact_prefix: str = "cachellm:x:"
85
+ stats_prefix: str = "cachellm:s:"
86
+
87
+ # -------------------------------------------------------------- embeddings
88
+ embedding_backend: Literal["fastembed", "hash"] = "fastembed"
89
+ #: MiniLM is the default because it gave four times the safe recall of
90
+ #: bge-small on the evaluation corpus, at a third of the download size.
91
+ embedding_model: str = "sentence-transformers/all-MiniLM-L6-v2"
92
+ embedding_dim: int = 384
93
+ embedding_cache_size: int = 2048
94
+
95
+ # ---------------------------------------------------------------- caching
96
+ enabled: bool = True
97
+ #: Observe-only. Looks up, records what it *would* have served, then still
98
+ #: calls the provider. The safe way to roll out onto real traffic.
99
+ shadow_mode: bool = False
100
+ cache_streaming: bool = True
101
+ #: Store responses that stopped because they hit max_tokens. Off by default:
102
+ #: a truncated answer served from cache forever is a silent quality bug. Turn
103
+ #: it on if you cap max_tokens deliberately and want those answers cached.
104
+ cache_truncated: bool = False
105
+ top_k: int = 3
106
+ #: A miss within this margin of the threshold is recorded as a near miss.
107
+ near_miss_margin: float = 0.06
108
+ near_miss_log_size: int = 500
109
+
110
+ #: 0.0 means "use the calibrated value for the configured embedding model".
111
+ #: Set any of these explicitly to override the calibration.
112
+ threshold_default: float = 0.0
113
+ threshold_factual: float = 0.0
114
+ threshold_classification: float = 0.0
115
+ threshold_creative: float = 0.0
116
+ threshold_volatile: float = 0.0
117
+ threshold_conversational: float = 0.0
118
+
119
+ #: Offsets applied to the calibrated threshold, per category. Classification
120
+ #: has a small, constrained answer space so it tolerates looser matching;
121
+ #: creative generation tolerates almost none.
122
+ category_threshold_offsets: dict[str, float] = {
123
+ "classification": -0.02,
124
+ "factual": 0.0,
125
+ "conversational": +0.03,
126
+ "volatile": +0.03,
127
+ "creative": +0.06,
128
+ "default": 0.0,
129
+ }
130
+
131
+ ttl_default: int = 86_400 # 1 day
132
+ ttl_factual: int = 604_800 # 7 days
133
+ ttl_classification: int = 604_800
134
+ ttl_creative: int = 3_600
135
+ ttl_volatile: int = 900 # 15 minutes
136
+ ttl_conversational: int = 3_600
137
+
138
+ # ------------------------------------------------------- cacheability rules
139
+ max_cacheable_temperature: float = 0.3
140
+ max_prompt_chars: int = 8_000
141
+ cache_multi_turn: bool = False
142
+ cache_json_mode: bool = False
143
+ cache_tool_calls: bool = False
144
+ #: Skip caching prompts that look like they carry personal data.
145
+ pii_guard: bool = True
146
+ #: Strip filler words before embedding. Raises hit rate, slightly raises risk.
147
+ strip_filler_words: bool = False
148
+
149
+ # --------------------------------------------------------------- providers
150
+ default_provider: Literal["bedrock", "openai", "fake"] = "bedrock"
151
+ aws_region: str = "us-east-1"
152
+ aws_profile: str = ""
153
+ #: Simulated upstream latency for the `fake` provider, so a reproducible
154
+ #: benchmark can show a realistic hit-versus-miss gap without spending money.
155
+ fake_latency_ms: float = 0.0
156
+ openai_base_url: str = "https://api.openai.com/v1"
157
+ openai_api_key: str = ""
158
+ request_timeout: float = 120.0
159
+ provider_max_retries: int = 2
160
+
161
+ # ----------------------------------------------------------- observability
162
+ metrics_enabled: bool = True
163
+ tracing_enabled: bool = False
164
+ otlp_endpoint: str = ""
165
+ service_name: str = "cachellm"
166
+
167
+ @field_validator(
168
+ "threshold_default",
169
+ "threshold_factual",
170
+ "threshold_classification",
171
+ "threshold_creative",
172
+ "threshold_volatile",
173
+ "threshold_conversational",
174
+ )
175
+ @classmethod
176
+ def _valid_threshold(cls, v: float) -> float:
177
+ if not 0.0 <= v <= 1.0:
178
+ raise ValueError("similarity thresholds must be between 0 and 1")
179
+ return v
180
+
181
+ # ------------------------------------------------------------------ helpers
182
+ @property
183
+ def client_keys(self) -> set[str]:
184
+ return {k.strip() for k in self.api_keys.split(",") if k.strip()}
185
+
186
+ @property
187
+ def auth_enabled(self) -> bool:
188
+ return bool(self.client_keys)
189
+
190
+ @property
191
+ def calibrated_threshold(self) -> float:
192
+ """The safe threshold for the configured embedding model.
193
+
194
+ 0.92 is the fallback for an uncalibrated model. It is a guess, and the
195
+ proxy says so at startup, because the measured safe points across six
196
+ models span 0.89 to 0.98.
197
+ """
198
+ if self.embedding_backend == "hash":
199
+ return 0.92
200
+ return CALIBRATED_THRESHOLDS.get(self.embedding_model, 0.92)
201
+
202
+ @property
203
+ def is_calibrated(self) -> bool:
204
+ return self.embedding_backend != "hash" and self.embedding_model in CALIBRATED_THRESHOLDS
205
+
206
+ def threshold_for(self, category: str) -> float:
207
+ explicit = float(getattr(self, f"threshold_{category}", 0.0) or 0.0)
208
+ if explicit > 0.0:
209
+ return explicit
210
+ base = self.threshold_default if self.threshold_default > 0.0 else self.calibrated_threshold
211
+ offset = self.category_threshold_offsets.get(category, 0.0)
212
+ # Cap below 1.0: an exact-similarity requirement would make the semantic
213
+ # tier dead weight, and the exact tier already covers literal repeats.
214
+ return round(min(0.995, max(0.0, base + offset)), 4)
215
+
216
+ def ttl_for(self, category: str) -> int:
217
+ return int(getattr(self, f"ttl_{category}", self.ttl_default))
218
+
219
+ def expected_dim(self) -> int:
220
+ return KNOWN_EMBEDDING_DIMS.get(self.embedding_model, self.embedding_dim)
221
+
222
+
223
+ @lru_cache(maxsize=1)
224
+ def get_settings() -> Settings:
225
+ return Settings()
226
+
227
+
228
+ def reset_settings_cache() -> None:
229
+ """Test helper: drop the memoised Settings instance."""
230
+ get_settings.cache_clear()