cacheverifier 0.4.0__tar.gz → 0.5.0__tar.gz
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.
- {cacheverifier-0.4.0 → cacheverifier-0.5.0}/CHANGELOG.md +30 -0
- {cacheverifier-0.4.0 → cacheverifier-0.5.0}/PKG-INFO +48 -6
- {cacheverifier-0.4.0 → cacheverifier-0.5.0}/README.md +44 -5
- {cacheverifier-0.4.0 → cacheverifier-0.5.0}/cacheverifier/__init__.py +3 -3
- {cacheverifier-0.4.0 → cacheverifier-0.5.0}/cacheverifier/client.py +59 -14
- cacheverifier-0.5.0/cacheverifier/integrations/langchain.py +140 -0
- {cacheverifier-0.4.0 → cacheverifier-0.5.0}/pyproject.toml +3 -2
- {cacheverifier-0.4.0 → cacheverifier-0.5.0}/tests/test_client.py +52 -4
- cacheverifier-0.5.0/tests/test_langchain_integration.py +90 -0
- {cacheverifier-0.4.0 → cacheverifier-0.5.0}/.github/workflows/ci.yml +0 -0
- {cacheverifier-0.4.0 → cacheverifier-0.5.0}/.github/workflows/publish.yml +0 -0
- {cacheverifier-0.4.0 → cacheverifier-0.5.0}/.gitignore +0 -0
- {cacheverifier-0.4.0 → cacheverifier-0.5.0}/LICENSE +0 -0
- {cacheverifier-0.4.0 → cacheverifier-0.5.0}/cacheverifier/__main__.py +0 -0
- {cacheverifier-0.4.0 → cacheverifier-0.5.0}/cacheverifier/_healthcheck/__init__.py +0 -0
- {cacheverifier-0.4.0 → cacheverifier-0.5.0}/cacheverifier/_healthcheck/_base_models.py +0 -0
- {cacheverifier-0.4.0 → cacheverifier-0.5.0}/cacheverifier/_healthcheck/_finetune.py +0 -0
- {cacheverifier-0.4.0 → cacheverifier-0.5.0}/cacheverifier/_healthcheck/cli.py +0 -0
- {cacheverifier-0.4.0 → cacheverifier-0.5.0}/cacheverifier/integrations/__init__.py +0 -0
- {cacheverifier-0.4.0 → cacheverifier-0.5.0}/cacheverifier/integrations/gptcache.py +0 -0
- {cacheverifier-0.4.0 → cacheverifier-0.5.0}/cacheverifier/py.typed +0 -0
- {cacheverifier-0.4.0 → cacheverifier-0.5.0}/examples/gptcache_example.py +0 -0
- {cacheverifier-0.4.0 → cacheverifier-0.5.0}/examples/quickstart.py +0 -0
- {cacheverifier-0.4.0 → cacheverifier-0.5.0}/tests/test_gptcache_integration.py +0 -0
- {cacheverifier-0.4.0 → cacheverifier-0.5.0}/tests/test_healthcheck.py +0 -0
|
@@ -1,5 +1,35 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.0
|
|
4
|
+
|
|
5
|
+
New LangChain integration: `cacheverifier.integrations.langchain.VerifiedCache`
|
|
6
|
+
wraps any LangChain cache (e.g. `RedisSemanticCache`) and verifies each hit
|
|
7
|
+
before it is served; a rejected hit becomes a miss. Install with
|
|
8
|
+
`pip install "cacheverifier[langchain]"`.
|
|
9
|
+
|
|
10
|
+
`VerifyResult` gains two optional fields, both None unless the server sends
|
|
11
|
+
them:
|
|
12
|
+
|
|
13
|
+
- `notice`: the plain-language reason a call wasn't decided by a model (cold
|
|
14
|
+
start), which the API already returned but the client dropped.
|
|
15
|
+
- `shadow`: on a cold-start call, a `ShadowVerdict` with what the shared stock
|
|
16
|
+
model would have decided (`approved`, `score`, `threshold`, `latency_ms`).
|
|
17
|
+
The call itself is still rejected, so this is a preview: your first calls
|
|
18
|
+
on a new account show what the model thinks before anything is served.
|
|
19
|
+
|
|
20
|
+
## 0.4.1
|
|
21
|
+
|
|
22
|
+
`verify()` and `verify_batch()` now treat a **rate-limited 429** (one with a
|
|
23
|
+
`Retry-After` header) like a 5xx: they log a warning and return a `degraded`
|
|
24
|
+
result (`approved=False` by default, per `fail_open`) instead of raising
|
|
25
|
+
`CacheVerifierError`. A rate limit is transient load on your request path, so
|
|
26
|
+
it should fall through to your LLM like an outage rather than throw. A 429
|
|
27
|
+
*without* `Retry-After` means the monthly verify quota is used up and still
|
|
28
|
+
raises, as do other 4xx responses (bad key, bad request).
|
|
29
|
+
`CacheVerifierError` gains a `retry_after` attribute. Upgrade before the
|
|
30
|
+
hosted service switches its rate-limit response from 503 to the documented
|
|
31
|
+
429.
|
|
32
|
+
|
|
3
33
|
## 0.4.0
|
|
4
34
|
|
|
5
35
|
`cacheverifier healthcheck --base-model` picks the model the local Health
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: cacheverifier
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: Python client for the hosted CacheVerifier semantic-cache verification API
|
|
5
5
|
Project-URL: Homepage, https://www.cacheverifier.com
|
|
6
6
|
Project-URL: Documentation, https://www.cacheverifier.com/docs
|
|
@@ -19,6 +19,7 @@ Classifier: Typing :: Typed
|
|
|
19
19
|
Requires-Python: >=3.9
|
|
20
20
|
Requires-Dist: httpx>=0.24
|
|
21
21
|
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: langchain-core>=0.3; extra == 'dev'
|
|
22
23
|
Requires-Dist: mypy>=1.5; extra == 'dev'
|
|
23
24
|
Requires-Dist: pytest>=7; extra == 'dev'
|
|
24
25
|
Requires-Dist: ruff>=0.4; extra == 'dev'
|
|
@@ -28,6 +29,8 @@ Provides-Extra: healthcheck
|
|
|
28
29
|
Requires-Dist: numpy<2,>=1.26; extra == 'healthcheck'
|
|
29
30
|
Requires-Dist: sentence-transformers<4,>=3.0; extra == 'healthcheck'
|
|
30
31
|
Requires-Dist: torch>=2.2; extra == 'healthcheck'
|
|
32
|
+
Provides-Extra: langchain
|
|
33
|
+
Requires-Dist: langchain-core>=0.3; extra == 'langchain'
|
|
31
34
|
Description-Content-Type: text/markdown
|
|
32
35
|
|
|
33
36
|
# cacheverifier
|
|
@@ -94,6 +97,19 @@ cv.feedback(query, answer, was_correct=True, similarity_score=0.86)
|
|
|
94
97
|
| `model_version` | `"stock"`, `"v<id>"` (fine-tuned), `"cold_start_fail_closed"` / `"cold_start_auto_pending"`, or `"verify_unavailable"` |
|
|
95
98
|
| `latency_ms` | server-side inference time |
|
|
96
99
|
| `degraded` | `True` when the call failed and this result was synthesized client-side |
|
|
100
|
+
| `notice` | why no model decided the call (cold start); `None` otherwise |
|
|
101
|
+
| `shadow` | cold start only: a `ShadowVerdict` with what the shared stock model would have decided. The call is still rejected, so treat it as a preview |
|
|
102
|
+
|
|
103
|
+
A new account starts in cold start: every call returns `approved=False` until a
|
|
104
|
+
Health Check shows the shared model is accurate enough on your data. Look at
|
|
105
|
+
`result.shadow` to see what it would have decided in the meantime:
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
result = cv.verify("when does my flight board", "Boarding starts 40 minutes before departure.")
|
|
109
|
+
if result.shadow:
|
|
110
|
+
print(result.notice)
|
|
111
|
+
print("stock model would approve:", result.shadow.approved, result.shadow.score)
|
|
112
|
+
```
|
|
97
113
|
|
|
98
114
|
## On your request path
|
|
99
115
|
|
|
@@ -103,11 +119,13 @@ cv.feedback(query, answer, was_correct=True, similarity_score=0.86)
|
|
|
103
119
|
fine-tuning / feedback / monitoring calls. Warm verification is tens of
|
|
104
120
|
milliseconds server-side; 1s covers the network round trip and a cold model
|
|
105
121
|
load after a deploy without letting a stuck verifier stall your request.
|
|
106
|
-
- **Fails closed.** On a timeout, connection error,
|
|
107
|
-
raise — it logs a warning on
|
|
108
|
-
`VerifyResult` with `degraded=True`
|
|
109
|
-
to your LLM exactly as you would on
|
|
110
|
-
|
|
122
|
+
- **Fails closed.** On a timeout, connection error, 5xx, or a rate-limited 429
|
|
123
|
+
(one carrying `Retry-After`), `verify()` does not raise — it logs a warning on
|
|
124
|
+
the `cacheverifier` logger and returns a `VerifyResult` with `degraded=True`
|
|
125
|
+
and `approved=False`, so you fall through to your LLM exactly as you would on
|
|
126
|
+
a cache miss. Other `4xx` responses still raise `CacheVerifierError`: a bad
|
|
127
|
+
key, a bad request, or a 429 without `Retry-After`, which means your monthly
|
|
128
|
+
verify quota is used up.
|
|
111
129
|
|
|
112
130
|
```python
|
|
113
131
|
cv = CacheVerifier(api_key="cv_...", verify_timeout=1.0, fail_open=False) # the defaults
|
|
@@ -137,6 +155,30 @@ evaluator.report_feedback(query, answer, was_correct=False, similarity_score=0.9
|
|
|
137
155
|
|
|
138
156
|
See [`examples/gptcache_example.py`](examples/gptcache_example.py).
|
|
139
157
|
|
|
158
|
+
## LangChain
|
|
159
|
+
|
|
160
|
+
`VerifiedCache` wraps any LangChain cache, typically a semantic one, and only
|
|
161
|
+
serves a hit after CacheVerifier approves it; a rejected hit is a miss, so
|
|
162
|
+
your model is called as usual. For chat models it verifies against the last
|
|
163
|
+
user message.
|
|
164
|
+
|
|
165
|
+
```bash
|
|
166
|
+
pip install "cacheverifier[langchain]"
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
```python
|
|
170
|
+
from langchain_core.globals import set_llm_cache
|
|
171
|
+
from langchain_redis import RedisSemanticCache
|
|
172
|
+
from cacheverifier.integrations.langchain import VerifiedCache
|
|
173
|
+
|
|
174
|
+
set_llm_cache(
|
|
175
|
+
VerifiedCache(
|
|
176
|
+
RedisSemanticCache(redis_url="redis://localhost:6379", embeddings=embeddings),
|
|
177
|
+
api_key="cv_...",
|
|
178
|
+
)
|
|
179
|
+
)
|
|
180
|
+
```
|
|
181
|
+
|
|
140
182
|
## Fine-tuning
|
|
141
183
|
|
|
142
184
|
Once you have ~20+ feedback rows (the service found fine-tuning is often a net negative
|
|
@@ -62,6 +62,19 @@ cv.feedback(query, answer, was_correct=True, similarity_score=0.86)
|
|
|
62
62
|
| `model_version` | `"stock"`, `"v<id>"` (fine-tuned), `"cold_start_fail_closed"` / `"cold_start_auto_pending"`, or `"verify_unavailable"` |
|
|
63
63
|
| `latency_ms` | server-side inference time |
|
|
64
64
|
| `degraded` | `True` when the call failed and this result was synthesized client-side |
|
|
65
|
+
| `notice` | why no model decided the call (cold start); `None` otherwise |
|
|
66
|
+
| `shadow` | cold start only: a `ShadowVerdict` with what the shared stock model would have decided. The call is still rejected, so treat it as a preview |
|
|
67
|
+
|
|
68
|
+
A new account starts in cold start: every call returns `approved=False` until a
|
|
69
|
+
Health Check shows the shared model is accurate enough on your data. Look at
|
|
70
|
+
`result.shadow` to see what it would have decided in the meantime:
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
result = cv.verify("when does my flight board", "Boarding starts 40 minutes before departure.")
|
|
74
|
+
if result.shadow:
|
|
75
|
+
print(result.notice)
|
|
76
|
+
print("stock model would approve:", result.shadow.approved, result.shadow.score)
|
|
77
|
+
```
|
|
65
78
|
|
|
66
79
|
## On your request path
|
|
67
80
|
|
|
@@ -71,11 +84,13 @@ cv.feedback(query, answer, was_correct=True, similarity_score=0.86)
|
|
|
71
84
|
fine-tuning / feedback / monitoring calls. Warm verification is tens of
|
|
72
85
|
milliseconds server-side; 1s covers the network round trip and a cold model
|
|
73
86
|
load after a deploy without letting a stuck verifier stall your request.
|
|
74
|
-
- **Fails closed.** On a timeout, connection error,
|
|
75
|
-
raise — it logs a warning on
|
|
76
|
-
`VerifyResult` with `degraded=True`
|
|
77
|
-
to your LLM exactly as you would on
|
|
78
|
-
|
|
87
|
+
- **Fails closed.** On a timeout, connection error, 5xx, or a rate-limited 429
|
|
88
|
+
(one carrying `Retry-After`), `verify()` does not raise — it logs a warning on
|
|
89
|
+
the `cacheverifier` logger and returns a `VerifyResult` with `degraded=True`
|
|
90
|
+
and `approved=False`, so you fall through to your LLM exactly as you would on
|
|
91
|
+
a cache miss. Other `4xx` responses still raise `CacheVerifierError`: a bad
|
|
92
|
+
key, a bad request, or a 429 without `Retry-After`, which means your monthly
|
|
93
|
+
verify quota is used up.
|
|
79
94
|
|
|
80
95
|
```python
|
|
81
96
|
cv = CacheVerifier(api_key="cv_...", verify_timeout=1.0, fail_open=False) # the defaults
|
|
@@ -105,6 +120,30 @@ evaluator.report_feedback(query, answer, was_correct=False, similarity_score=0.9
|
|
|
105
120
|
|
|
106
121
|
See [`examples/gptcache_example.py`](examples/gptcache_example.py).
|
|
107
122
|
|
|
123
|
+
## LangChain
|
|
124
|
+
|
|
125
|
+
`VerifiedCache` wraps any LangChain cache, typically a semantic one, and only
|
|
126
|
+
serves a hit after CacheVerifier approves it; a rejected hit is a miss, so
|
|
127
|
+
your model is called as usual. For chat models it verifies against the last
|
|
128
|
+
user message.
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
pip install "cacheverifier[langchain]"
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
```python
|
|
135
|
+
from langchain_core.globals import set_llm_cache
|
|
136
|
+
from langchain_redis import RedisSemanticCache
|
|
137
|
+
from cacheverifier.integrations.langchain import VerifiedCache
|
|
138
|
+
|
|
139
|
+
set_llm_cache(
|
|
140
|
+
VerifiedCache(
|
|
141
|
+
RedisSemanticCache(redis_url="redis://localhost:6379", embeddings=embeddings),
|
|
142
|
+
api_key="cv_...",
|
|
143
|
+
)
|
|
144
|
+
)
|
|
145
|
+
```
|
|
146
|
+
|
|
108
147
|
## Fine-tuning
|
|
109
148
|
|
|
110
149
|
Once you have ~20+ feedback rows (the service found fine-tuning is often a net negative
|
|
@@ -16,7 +16,7 @@ Docs: https://www.cacheverifier.com/docs
|
|
|
16
16
|
Research behind it: https://github.com/imxinchengyou/CacheVerifier
|
|
17
17
|
"""
|
|
18
18
|
|
|
19
|
-
from cacheverifier.client import CacheVerifier, CacheVerifierError, VerifyResult
|
|
19
|
+
from cacheverifier.client import CacheVerifier, CacheVerifierError, ShadowVerdict, VerifyResult
|
|
20
20
|
|
|
21
|
-
__version__ = "0.
|
|
22
|
-
__all__ = ["CacheVerifier", "CacheVerifierError", "VerifyResult", "__version__"]
|
|
21
|
+
__version__ = "0.5.0"
|
|
22
|
+
__all__ = ["CacheVerifier", "CacheVerifierError", "ShadowVerdict", "VerifyResult", "__version__"]
|
|
@@ -46,13 +46,41 @@ class CacheVerifierError(RuntimeError):
|
|
|
46
46
|
"""Raised for any non-2xx response from the API.
|
|
47
47
|
|
|
48
48
|
`status_code` is the HTTP status; `detail` is the server's error message
|
|
49
|
-
(the JSON body's `detail` field when present, otherwise the raw text)
|
|
49
|
+
(the JSON body's `detail` field when present, otherwise the raw text);
|
|
50
|
+
`retry_after` is the response's `Retry-After` header, if it sent one.
|
|
50
51
|
"""
|
|
51
52
|
|
|
52
|
-
def __init__(self, status_code: int, detail: str) -> None:
|
|
53
|
+
def __init__(self, status_code: int, detail: str, retry_after: str | None = None) -> None:
|
|
53
54
|
super().__init__(f"CacheVerifier API error {status_code}: {detail}")
|
|
54
55
|
self.status_code = status_code
|
|
55
56
|
self.detail = detail
|
|
57
|
+
self.retry_after = retry_after
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True)
|
|
61
|
+
class ShadowVerdict:
|
|
62
|
+
"""What the shared stock model would have decided for a cold-start call.
|
|
63
|
+
|
|
64
|
+
Only a preview: during cold start the call itself is always rejected
|
|
65
|
+
(`VerifyResult.approved` is False). The stock model hasn't been checked
|
|
66
|
+
against your data, so run a Health Check before relying on it.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
approved: bool
|
|
70
|
+
score: float
|
|
71
|
+
threshold: float
|
|
72
|
+
latency_ms: float
|
|
73
|
+
model_version: str = "stock"
|
|
74
|
+
|
|
75
|
+
@classmethod
|
|
76
|
+
def _from_json(cls, d: dict[str, Any]) -> ShadowVerdict:
|
|
77
|
+
return cls(
|
|
78
|
+
approved=bool(d["approved"]),
|
|
79
|
+
score=float(d["score"]),
|
|
80
|
+
threshold=float(d["threshold"]),
|
|
81
|
+
latency_ms=float(d["latency_ms"]),
|
|
82
|
+
model_version=str(d.get("model_version", "stock")),
|
|
83
|
+
)
|
|
56
84
|
|
|
57
85
|
|
|
58
86
|
@dataclass(frozen=True)
|
|
@@ -69,9 +97,14 @@ class VerifyResult:
|
|
|
69
97
|
fallback result (see `degraded`).
|
|
70
98
|
- `latency_ms`: server-side model inference time, not round-trip time.
|
|
71
99
|
- `degraded`: True when the verify call failed (timeout / connection error /
|
|
72
|
-
5xx) and this result was synthesized by the client
|
|
73
|
-
the API. `approved` then reflects the client's `fail_open` setting
|
|
100
|
+
5xx / rate-limited 429) and this result was synthesized by the client
|
|
101
|
+
rather than returned by the API. `approved` then reflects the client's `fail_open` setting
|
|
74
102
|
(default `False` -> `approved=False`, i.e. fall through to your LLM).
|
|
103
|
+
- `notice`: a plain-language reason when no model decided the call (cold
|
|
104
|
+
start); None otherwise.
|
|
105
|
+
- `shadow`: on a cold-start call, what the shared stock model would have
|
|
106
|
+
decided (a `ShadowVerdict`); None otherwise, and on servers that don't
|
|
107
|
+
send it.
|
|
75
108
|
"""
|
|
76
109
|
|
|
77
110
|
approved: bool
|
|
@@ -80,15 +113,20 @@ class VerifyResult:
|
|
|
80
113
|
model_version: str
|
|
81
114
|
latency_ms: float
|
|
82
115
|
degraded: bool = False
|
|
116
|
+
notice: str | None = None
|
|
117
|
+
shadow: ShadowVerdict | None = None
|
|
83
118
|
|
|
84
119
|
@classmethod
|
|
85
120
|
def _from_json(cls, d: dict[str, Any]) -> VerifyResult:
|
|
121
|
+
shadow = d.get("shadow")
|
|
86
122
|
return cls(
|
|
87
123
|
approved=bool(d["approved"]),
|
|
88
124
|
score=float(d["score"]),
|
|
89
125
|
threshold=float(d["threshold"]),
|
|
90
126
|
model_version=str(d["model_version"]),
|
|
91
127
|
latency_ms=float(d["latency_ms"]),
|
|
128
|
+
notice=d.get("notice"),
|
|
129
|
+
shadow=ShadowVerdict._from_json(shadow) if shadow else None,
|
|
92
130
|
)
|
|
93
131
|
|
|
94
132
|
|
|
@@ -112,12 +150,14 @@ class CacheVerifier:
|
|
|
112
150
|
|
|
113
151
|
- `verify_timeout` (default 1s, vs 10s for everything else) bounds how long
|
|
114
152
|
a slow or overloaded verifier can stall your request.
|
|
115
|
-
- On a timeout, connection error,
|
|
116
|
-
|
|
117
|
-
`degraded=True`.
|
|
118
|
-
`
|
|
119
|
-
it `approved=True` so
|
|
120
|
-
|
|
153
|
+
- On a timeout, connection error, 5xx, or a rate-limited 429 (one with a
|
|
154
|
+
`Retry-After` header), `verify()` does not raise -- it logs a warning and
|
|
155
|
+
returns a synthetic `VerifyResult` with `degraded=True`.
|
|
156
|
+
`fail_open=False` (the default) makes that result `approved=False` so you
|
|
157
|
+
fall through to your LLM; `fail_open=True` makes it `approved=True` so
|
|
158
|
+
you serve the cached answer anyway. Other 4xx responses still raise
|
|
159
|
+
`CacheVerifierError`: a bad key, a bad request, or a 429 without
|
|
160
|
+
`Retry-After`, which means your monthly verify quota is used up.
|
|
121
161
|
"""
|
|
122
162
|
|
|
123
163
|
def __init__(
|
|
@@ -157,7 +197,7 @@ class CacheVerifier:
|
|
|
157
197
|
def verify(self, query: str, candidate_answer: str) -> VerifyResult:
|
|
158
198
|
"""Approve or reject one gray-zone cache hit. `POST /v1/verify`.
|
|
159
199
|
|
|
160
|
-
On a timeout / connection error / 5xx this returns a synthetic
|
|
200
|
+
On a timeout / connection error / 5xx / rate-limited 429 this returns a synthetic
|
|
161
201
|
`VerifyResult` (`degraded=True`, `approved` per `fail_open`) instead of
|
|
162
202
|
raising -- the verifier being unreachable should degrade to a normal
|
|
163
203
|
cache miss, not an exception on your request path.
|
|
@@ -189,14 +229,19 @@ class CacheVerifier:
|
|
|
189
229
|
def _verify_call(self, path: str, payload: dict[str, Any]) -> Any | None:
|
|
190
230
|
"""POST a verify request with the request-path timeout. Returns the
|
|
191
231
|
parsed body, or None to signal "unavailable, fall back" on a
|
|
192
|
-
timeout / connection error / 5xx
|
|
232
|
+
timeout / connection error / 5xx / 429 with `Retry-After`. Other 4xx
|
|
233
|
+
still raises: a bad key or request is a bug to fix, and a 429 without
|
|
234
|
+
`Retry-After` is the monthly quota running out, which needs a decision
|
|
235
|
+
(top up or subscribe), not a silent month of fallbacks. A rate limit is
|
|
236
|
+
transient load and should degrade like an outage instead.
|
|
193
237
|
"""
|
|
194
238
|
try:
|
|
195
239
|
return self._post(path, json=payload, timeout=self._verify_timeout)
|
|
196
240
|
except httpx.TransportError as exc:
|
|
197
241
|
self._warn_degraded(path, exc)
|
|
198
242
|
except CacheVerifierError as exc:
|
|
199
|
-
|
|
243
|
+
rate_limited = exc.status_code == 429 and exc.retry_after is not None
|
|
244
|
+
if exc.status_code < 500 and not rate_limited:
|
|
200
245
|
raise
|
|
201
246
|
self._warn_degraded(path, exc)
|
|
202
247
|
return None
|
|
@@ -365,7 +410,7 @@ class CacheVerifier:
|
|
|
365
410
|
detail = body["detail"] if isinstance(body, dict) and "detail" in body else resp.text
|
|
366
411
|
except ValueError:
|
|
367
412
|
detail = resp.text
|
|
368
|
-
raise CacheVerifierError(resp.status_code, str(detail))
|
|
413
|
+
raise CacheVerifierError(resp.status_code, str(detail), resp.headers.get("retry-after"))
|
|
369
414
|
|
|
370
415
|
|
|
371
416
|
def _drop_none(**kwargs: Any) -> dict[str, Any]:
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""LangChain integration: wrap any LangChain semantic cache so a hit is only
|
|
2
|
+
served after CacheVerifier approves it.
|
|
3
|
+
|
|
4
|
+
A semantic cache (`RedisSemanticCache`, `MongoDBAtlasSemanticCache`, ...)
|
|
5
|
+
returns a stored answer whenever a past prompt is similar enough to the new
|
|
6
|
+
one, including when the wording differs in a way that changes the answer.
|
|
7
|
+
`VerifiedCache` sits in front of it: on a hit it asks `/v1/verify` whether
|
|
8
|
+
the cached answer fits the new question, and turns a rejected hit into a
|
|
9
|
+
miss, so your model is called as usual.
|
|
10
|
+
|
|
11
|
+
from langchain_core.globals import set_llm_cache
|
|
12
|
+
from langchain_redis import RedisSemanticCache
|
|
13
|
+
from cacheverifier.integrations.langchain import VerifiedCache
|
|
14
|
+
|
|
15
|
+
set_llm_cache(
|
|
16
|
+
VerifiedCache(
|
|
17
|
+
RedisSemanticCache(redis_url="redis://localhost:6379", embeddings=embeddings),
|
|
18
|
+
api_key="cv_...",
|
|
19
|
+
)
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
Writes, clears and exact-match caches pass straight through. If the verify
|
|
23
|
+
call times out or the service is unreachable, the hit is rejected by
|
|
24
|
+
default (a verifier outage becomes a normal cache miss); pass
|
|
25
|
+
`fail_open=True` to serve it instead.
|
|
26
|
+
|
|
27
|
+
`langchain-core` is an optional dependency -- `pip install "cacheverifier[langchain]"`.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import asyncio
|
|
33
|
+
import json
|
|
34
|
+
from typing import Any
|
|
35
|
+
|
|
36
|
+
from cacheverifier.client import (
|
|
37
|
+
DEFAULT_BASE_URL,
|
|
38
|
+
DEFAULT_TIMEOUT,
|
|
39
|
+
DEFAULT_VERIFY_TIMEOUT,
|
|
40
|
+
CacheVerifier,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
try:
|
|
44
|
+
from langchain_core.caches import RETURN_VAL_TYPE, BaseCache
|
|
45
|
+
except ImportError as exc: # pragma: no cover - exercised only without the extra
|
|
46
|
+
raise ImportError(
|
|
47
|
+
'cacheverifier.integrations.langchain needs langchain-core: pip install "cacheverifier[langchain]"'
|
|
48
|
+
) from exc
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _message_text(content: Any) -> str:
|
|
52
|
+
if isinstance(content, str):
|
|
53
|
+
return content
|
|
54
|
+
if isinstance(content, list): # multimodal content blocks
|
|
55
|
+
return "\n".join(
|
|
56
|
+
block if isinstance(block, str) else str(block.get("text", ""))
|
|
57
|
+
for block in content
|
|
58
|
+
if isinstance(block, str) or (isinstance(block, dict) and block.get("type") == "text")
|
|
59
|
+
)
|
|
60
|
+
return ""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def user_question(prompt: str) -> str:
|
|
64
|
+
"""The text to verify against for a LangChain cache key.
|
|
65
|
+
|
|
66
|
+
Plain LLMs use the prompt string itself as the key. Chat models use a
|
|
67
|
+
JSON dump of the whole message list, system prompt and history
|
|
68
|
+
included; the question being asked is the last human message, so that
|
|
69
|
+
is what the verifier sees. Anything unrecognized falls back to the raw
|
|
70
|
+
prompt."""
|
|
71
|
+
if not prompt.startswith("["):
|
|
72
|
+
return prompt
|
|
73
|
+
try:
|
|
74
|
+
messages = json.loads(prompt)
|
|
75
|
+
except ValueError:
|
|
76
|
+
return prompt
|
|
77
|
+
for message in reversed(messages if isinstance(messages, list) else []):
|
|
78
|
+
kwargs = message.get("kwargs", {}) if isinstance(message, dict) else {}
|
|
79
|
+
if kwargs.get("type") == "human":
|
|
80
|
+
text = _message_text(kwargs.get("content"))
|
|
81
|
+
if text:
|
|
82
|
+
return text
|
|
83
|
+
return prompt
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class VerifiedCache(BaseCache):
|
|
87
|
+
"""A LangChain cache that verifies another cache's hits before serving them."""
|
|
88
|
+
|
|
89
|
+
def __init__(
|
|
90
|
+
self,
|
|
91
|
+
cache: BaseCache,
|
|
92
|
+
api_key: str | None = None,
|
|
93
|
+
*,
|
|
94
|
+
client: CacheVerifier | None = None,
|
|
95
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
96
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
97
|
+
verify_timeout: float = DEFAULT_VERIFY_TIMEOUT,
|
|
98
|
+
fail_open: bool = False,
|
|
99
|
+
) -> None:
|
|
100
|
+
if client is None:
|
|
101
|
+
if not api_key:
|
|
102
|
+
raise ValueError("pass api_key or client")
|
|
103
|
+
client = CacheVerifier(
|
|
104
|
+
api_key=api_key,
|
|
105
|
+
base_url=base_url,
|
|
106
|
+
timeout=timeout,
|
|
107
|
+
verify_timeout=verify_timeout,
|
|
108
|
+
fail_open=fail_open,
|
|
109
|
+
)
|
|
110
|
+
self.cache = cache
|
|
111
|
+
self.client = client
|
|
112
|
+
|
|
113
|
+
def _approved(self, prompt: str, hit: RETURN_VAL_TYPE) -> bool:
|
|
114
|
+
answer = "\n".join(generation.text for generation in hit)
|
|
115
|
+
return self.client.verify(user_question(prompt), answer).approved
|
|
116
|
+
|
|
117
|
+
def lookup(self, prompt: str, llm_string: str) -> RETURN_VAL_TYPE | None:
|
|
118
|
+
hit = self.cache.lookup(prompt, llm_string)
|
|
119
|
+
if not hit:
|
|
120
|
+
return hit
|
|
121
|
+
return hit if self._approved(prompt, hit) else None
|
|
122
|
+
|
|
123
|
+
async def alookup(self, prompt: str, llm_string: str) -> RETURN_VAL_TYPE | None:
|
|
124
|
+
hit = await self.cache.alookup(prompt, llm_string)
|
|
125
|
+
if not hit:
|
|
126
|
+
return hit
|
|
127
|
+
approved = await asyncio.get_running_loop().run_in_executor(None, self._approved, prompt, hit)
|
|
128
|
+
return hit if approved else None
|
|
129
|
+
|
|
130
|
+
def update(self, prompt: str, llm_string: str, return_val: RETURN_VAL_TYPE) -> None:
|
|
131
|
+
self.cache.update(prompt, llm_string, return_val)
|
|
132
|
+
|
|
133
|
+
async def aupdate(self, prompt: str, llm_string: str, return_val: RETURN_VAL_TYPE) -> None:
|
|
134
|
+
await self.cache.aupdate(prompt, llm_string, return_val)
|
|
135
|
+
|
|
136
|
+
def clear(self, **kwargs: Any) -> None:
|
|
137
|
+
self.cache.clear(**kwargs)
|
|
138
|
+
|
|
139
|
+
async def aclear(self, **kwargs: Any) -> None:
|
|
140
|
+
await self.cache.aclear(**kwargs)
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "cacheverifier"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.5.0"
|
|
8
8
|
description = "Python client for the hosted CacheVerifier semantic-cache verification API"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.9"
|
|
@@ -40,6 +40,7 @@ Research = "https://github.com/imxinchengyou/CacheVerifier"
|
|
|
40
40
|
|
|
41
41
|
[project.optional-dependencies]
|
|
42
42
|
gptcache = ["gptcache>=0.1.30"]
|
|
43
|
+
langchain = ["langchain-core>=0.3"]
|
|
43
44
|
# `cacheverifier healthcheck` -- runs the hosted Health Check Report's
|
|
44
45
|
# stock-vs-fine-tuned evaluation locally. Deliberately NOT a base
|
|
45
46
|
# dependency: torch + sentence-transformers dwarf the httpx-only client.
|
|
@@ -54,7 +55,7 @@ healthcheck = [
|
|
|
54
55
|
"torch>=2.2",
|
|
55
56
|
"sentence-transformers>=3.0,<4",
|
|
56
57
|
]
|
|
57
|
-
dev = ["pytest>=7", "mypy>=1.5", "ruff>=0.4"]
|
|
58
|
+
dev = ["pytest>=7", "mypy>=1.5", "ruff>=0.4", "langchain-core>=0.3"]
|
|
58
59
|
|
|
59
60
|
[tool.hatch.build.targets.wheel]
|
|
60
61
|
packages = ["cacheverifier"]
|
|
@@ -6,7 +6,7 @@ import json
|
|
|
6
6
|
import httpx
|
|
7
7
|
import pytest
|
|
8
8
|
|
|
9
|
-
from cacheverifier import CacheVerifier, CacheVerifierError, VerifyResult
|
|
9
|
+
from cacheverifier import CacheVerifier, CacheVerifierError, ShadowVerdict, VerifyResult
|
|
10
10
|
|
|
11
11
|
|
|
12
12
|
def make_client(handler, **kwargs):
|
|
@@ -51,6 +51,32 @@ def test_verify_parses_result_and_sends_api_key():
|
|
|
51
51
|
assert seen["path"] == "/v1/verify"
|
|
52
52
|
assert seen["api_key"] == "cv_test"
|
|
53
53
|
assert seen["body"] == {"query": "how do I cancel", "candidate_answer": "Go to Settings > Billing."}
|
|
54
|
+
assert result.notice is None
|
|
55
|
+
assert result.shadow is None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_verify_parses_cold_start_notice_and_shadow_verdict():
|
|
59
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
60
|
+
return httpx.Response(
|
|
61
|
+
200,
|
|
62
|
+
json={
|
|
63
|
+
"approved": False,
|
|
64
|
+
"score": 0.0,
|
|
65
|
+
"latency_ms": 0.0,
|
|
66
|
+
"model_version": "cold_start_auto_pending",
|
|
67
|
+
"threshold": 0.0,
|
|
68
|
+
"decision_rule": "cold_start",
|
|
69
|
+
"notice": "No verifier has been checked against your data yet.",
|
|
70
|
+
"shadow": {"approved": True, "score": 2.1, "threshold": 0.0, "latency_ms": 18.0, "model_version": "stock"},
|
|
71
|
+
},
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
with make_client(handler) as cv:
|
|
75
|
+
result = cv.verify("when does my flight board", "Boarding starts 40 minutes before departure.")
|
|
76
|
+
|
|
77
|
+
assert result.approved is False
|
|
78
|
+
assert result.notice.startswith("No verifier")
|
|
79
|
+
assert result.shadow == ShadowVerdict(approved=True, score=2.1, threshold=0.0, latency_ms=18.0)
|
|
54
80
|
|
|
55
81
|
|
|
56
82
|
def test_verify_batch_returns_ordered_results():
|
|
@@ -135,6 +161,7 @@ def test_error_response_raises_with_detail():
|
|
|
135
161
|
|
|
136
162
|
assert excinfo.value.status_code == 429
|
|
137
163
|
assert excinfo.value.detail == "free tier limit reached"
|
|
164
|
+
assert excinfo.value.retry_after is None
|
|
138
165
|
|
|
139
166
|
|
|
140
167
|
def test_error_response_without_json_body_falls_back_to_text():
|
|
@@ -204,14 +231,35 @@ def test_verify_fails_closed_on_5xx():
|
|
|
204
231
|
assert result.approved is False
|
|
205
232
|
|
|
206
233
|
|
|
207
|
-
def
|
|
234
|
+
def test_verify_fails_closed_on_rate_limit_429():
|
|
208
235
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
209
|
-
return httpx.Response(429, json={"detail": "rate limited"})
|
|
236
|
+
return httpx.Response(429, headers={"Retry-After": "1"}, json={"detail": "rate limited"})
|
|
237
|
+
|
|
238
|
+
with make_client(handler) as cv:
|
|
239
|
+
result = cv.verify("q", "a")
|
|
240
|
+
|
|
241
|
+
assert result.degraded is True
|
|
242
|
+
assert result.approved is False
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def test_verify_batch_degrades_on_rate_limit_429():
|
|
246
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
247
|
+
return httpx.Response(429, headers={"Retry-After": "1"}, json={"detail": "rate limited"})
|
|
248
|
+
|
|
249
|
+
with make_client(handler) as cv:
|
|
250
|
+
results = cv.verify_batch([("q1", "a1"), ("q2", "a2")])
|
|
251
|
+
|
|
252
|
+
assert all(r.degraded and not r.approved for r in results)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def test_verify_still_raises_on_other_4xx():
|
|
256
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
257
|
+
return httpx.Response(401, json={"detail": "invalid API key"})
|
|
210
258
|
|
|
211
259
|
with make_client(handler) as cv, pytest.raises(CacheVerifierError) as excinfo:
|
|
212
260
|
cv.verify("q", "a")
|
|
213
261
|
|
|
214
|
-
assert excinfo.value.status_code ==
|
|
262
|
+
assert excinfo.value.status_code == 401
|
|
215
263
|
|
|
216
264
|
|
|
217
265
|
def test_verify_batch_degrades_every_pair_on_failure():
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""VerifiedCache against a real langchain-core cache and chat model, with
|
|
2
|
+
every verify call served by an httpx.MockTransport."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
7
|
+
import json
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
pytest.importorskip("langchain_core")
|
|
13
|
+
|
|
14
|
+
from langchain_core.caches import InMemoryCache
|
|
15
|
+
from langchain_core.language_models.fake_chat_models import FakeListChatModel
|
|
16
|
+
from langchain_core.messages import HumanMessage, SystemMessage
|
|
17
|
+
from langchain_core.outputs import Generation
|
|
18
|
+
|
|
19
|
+
from cacheverifier import CacheVerifier
|
|
20
|
+
from cacheverifier.integrations.langchain import VerifiedCache, user_question
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _verified(approve: bool, seen: list | None = None, inner=None) -> VerifiedCache:
|
|
24
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
25
|
+
if seen is not None:
|
|
26
|
+
seen.append(json.loads(request.content))
|
|
27
|
+
return httpx.Response(
|
|
28
|
+
200,
|
|
29
|
+
json={"approved": approve, "score": 1.0, "latency_ms": 5.0, "model_version": "stock", "threshold": 0.0},
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
client = CacheVerifier(api_key="cv_test", transport=httpx.MockTransport(handler))
|
|
33
|
+
return VerifiedCache(inner or InMemoryCache(), client=client)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_approved_hit_is_served_and_rejected_hit_becomes_a_miss():
|
|
37
|
+
seen: list = []
|
|
38
|
+
cache = _verified(True, seen)
|
|
39
|
+
cache.update("when does my flight board", "llm", [Generation(text="40 minutes before departure.")])
|
|
40
|
+
assert cache.lookup("when does my flight board", "llm")[0].text == "40 minutes before departure."
|
|
41
|
+
assert seen == [{"query": "when does my flight board", "candidate_answer": "40 minutes before departure."}]
|
|
42
|
+
|
|
43
|
+
rejecting = _verified(False, inner=cache.cache)
|
|
44
|
+
assert rejecting.lookup("when does my flight board", "llm") is None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_a_miss_never_calls_the_verifier():
|
|
48
|
+
seen: list = []
|
|
49
|
+
cache = _verified(True, seen)
|
|
50
|
+
assert cache.lookup("never cached", "llm") is None
|
|
51
|
+
assert seen == []
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_chat_model_verifies_against_the_last_human_message():
|
|
55
|
+
seen: list = []
|
|
56
|
+
cache = _verified(True, seen)
|
|
57
|
+
model = FakeListChatModel(responses=["Gate B12, boarding at 9:20.", "should not be called"], cache=cache)
|
|
58
|
+
messages = [SystemMessage("You are an airline assistant."), HumanMessage("which gate is my flight")]
|
|
59
|
+
|
|
60
|
+
assert model.invoke(messages).content == "Gate B12, boarding at 9:20." # miss: model called, then cached
|
|
61
|
+
assert model.invoke(messages).content == "Gate B12, boarding at 9:20." # hit: verified, served
|
|
62
|
+
assert seen == [{"query": "which gate is my flight", "candidate_answer": "Gate B12, boarding at 9:20."}]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_rejected_chat_hit_falls_through_to_the_model():
|
|
66
|
+
cache = _verified(False)
|
|
67
|
+
model = FakeListChatModel(responses=["first answer", "fresh answer"], cache=cache)
|
|
68
|
+
assert model.invoke("what is the baggage allowance").content == "first answer"
|
|
69
|
+
assert model.invoke("what is the baggage allowance").content == "fresh answer"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_async_lookup_verifies_too():
|
|
73
|
+
cache = _verified(False)
|
|
74
|
+
asyncio.run(cache.aupdate("q", "llm", [Generation(text="a")]))
|
|
75
|
+
assert asyncio.run(cache.alookup("q", "llm")) is None
|
|
76
|
+
assert cache.cache.lookup("q", "llm")[0].text == "a" # the inner cache still has it
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_user_question_falls_back_to_the_raw_prompt():
|
|
80
|
+
assert user_question("plain prompt") == "plain prompt"
|
|
81
|
+
assert user_question("[not json") == "[not json"
|
|
82
|
+
multimodal = json.dumps(
|
|
83
|
+
[{"kwargs": {"type": "human", "content": [{"type": "text", "text": "what's in this photo"}, {"type": "image_url"}]}}]
|
|
84
|
+
)
|
|
85
|
+
assert user_question(multimodal) == "what's in this photo"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_needs_a_key_or_a_client():
|
|
89
|
+
with pytest.raises(ValueError):
|
|
90
|
+
VerifiedCache(InMemoryCache())
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|