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.
- cachellm/__init__.py +13 -0
- cachellm/__main__.py +6 -0
- cachellm/api/__init__.py +5 -0
- cachellm/api/app.py +143 -0
- cachellm/api/auth.py +34 -0
- cachellm/api/deps.py +106 -0
- cachellm/api/routes_admin.py +265 -0
- cachellm/api/routes_chat.py +385 -0
- cachellm/api/sse.py +98 -0
- cachellm/cache/__init__.py +3 -0
- cachellm/cache/analytics.py +150 -0
- cachellm/cache/coalesce.py +63 -0
- cachellm/cache/entry.py +92 -0
- cachellm/cache/exact_store.py +33 -0
- cachellm/cache/keys.py +124 -0
- cachellm/cache/policy.py +134 -0
- cachellm/cache/redis_client.py +22 -0
- cachellm/cache/service.py +332 -0
- cachellm/cache/vector_store.py +217 -0
- cachellm/cli.py +122 -0
- cachellm/embeddings/__init__.py +19 -0
- cachellm/embeddings/base.py +38 -0
- cachellm/embeddings/fastembed_backend.py +75 -0
- cachellm/embeddings/hash_backend.py +42 -0
- cachellm/errors.py +72 -0
- cachellm/logging_setup.py +56 -0
- cachellm/models.py +181 -0
- cachellm/observability/__init__.py +6 -0
- cachellm/observability/metrics.py +147 -0
- cachellm/observability/tracing.py +107 -0
- cachellm/pricing.py +108 -0
- cachellm/providers/__init__.py +7 -0
- cachellm/providers/base.py +84 -0
- cachellm/providers/bedrock.py +238 -0
- cachellm/providers/fake.py +56 -0
- cachellm/providers/openai_compat.py +131 -0
- cachellm/providers/registry.py +96 -0
- cachellm/py.typed +0 -0
- cachellm/settings.py +230 -0
- cachellm_proxy-0.1.0.dist-info/METADATA +550 -0
- cachellm_proxy-0.1.0.dist-info/RECORD +43 -0
- cachellm_proxy-0.1.0.dist-info/WHEEL +4 -0
- 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()
|