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.
Files changed (25) hide show
  1. {cacheverifier-0.4.0 → cacheverifier-0.5.0}/CHANGELOG.md +30 -0
  2. {cacheverifier-0.4.0 → cacheverifier-0.5.0}/PKG-INFO +48 -6
  3. {cacheverifier-0.4.0 → cacheverifier-0.5.0}/README.md +44 -5
  4. {cacheverifier-0.4.0 → cacheverifier-0.5.0}/cacheverifier/__init__.py +3 -3
  5. {cacheverifier-0.4.0 → cacheverifier-0.5.0}/cacheverifier/client.py +59 -14
  6. cacheverifier-0.5.0/cacheverifier/integrations/langchain.py +140 -0
  7. {cacheverifier-0.4.0 → cacheverifier-0.5.0}/pyproject.toml +3 -2
  8. {cacheverifier-0.4.0 → cacheverifier-0.5.0}/tests/test_client.py +52 -4
  9. cacheverifier-0.5.0/tests/test_langchain_integration.py +90 -0
  10. {cacheverifier-0.4.0 → cacheverifier-0.5.0}/.github/workflows/ci.yml +0 -0
  11. {cacheverifier-0.4.0 → cacheverifier-0.5.0}/.github/workflows/publish.yml +0 -0
  12. {cacheverifier-0.4.0 → cacheverifier-0.5.0}/.gitignore +0 -0
  13. {cacheverifier-0.4.0 → cacheverifier-0.5.0}/LICENSE +0 -0
  14. {cacheverifier-0.4.0 → cacheverifier-0.5.0}/cacheverifier/__main__.py +0 -0
  15. {cacheverifier-0.4.0 → cacheverifier-0.5.0}/cacheverifier/_healthcheck/__init__.py +0 -0
  16. {cacheverifier-0.4.0 → cacheverifier-0.5.0}/cacheverifier/_healthcheck/_base_models.py +0 -0
  17. {cacheverifier-0.4.0 → cacheverifier-0.5.0}/cacheverifier/_healthcheck/_finetune.py +0 -0
  18. {cacheverifier-0.4.0 → cacheverifier-0.5.0}/cacheverifier/_healthcheck/cli.py +0 -0
  19. {cacheverifier-0.4.0 → cacheverifier-0.5.0}/cacheverifier/integrations/__init__.py +0 -0
  20. {cacheverifier-0.4.0 → cacheverifier-0.5.0}/cacheverifier/integrations/gptcache.py +0 -0
  21. {cacheverifier-0.4.0 → cacheverifier-0.5.0}/cacheverifier/py.typed +0 -0
  22. {cacheverifier-0.4.0 → cacheverifier-0.5.0}/examples/gptcache_example.py +0 -0
  23. {cacheverifier-0.4.0 → cacheverifier-0.5.0}/examples/quickstart.py +0 -0
  24. {cacheverifier-0.4.0 → cacheverifier-0.5.0}/tests/test_gptcache_integration.py +0 -0
  25. {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.4.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, or 5xx, `verify()` does not
107
- raise — it logs a warning on the `cacheverifier` logger and returns a
108
- `VerifyResult` with `degraded=True` and `approved=False`, so you fall through
109
- to your LLM exactly as you would on a cache miss. A `4xx` (bad key, bad
110
- request, rate limit) still raises `CacheVerifierError`.
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, or 5xx, `verify()` does not
75
- raise — it logs a warning on the `cacheverifier` logger and returns a
76
- `VerifyResult` with `degraded=True` and `approved=False`, so you fall through
77
- to your LLM exactly as you would on a cache miss. A `4xx` (bad key, bad
78
- request, rate limit) still raises `CacheVerifierError`.
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.4.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 rather than returned by
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, or 5xx, `verify()` does not raise -- it
116
- logs a warning and returns a synthetic `VerifyResult` with
117
- `degraded=True`. `fail_open=False` (the default) makes that result
118
- `approved=False` so you fall through to your LLM; `fail_open=True` makes
119
- it `approved=True` so you serve the cached answer anyway. 4xx responses
120
- (bad key, bad request, rate limit) still raise `CacheVerifierError`.
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. 4xx still raises.
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
- if exc.status_code < 500:
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.4.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 test_verify_still_raises_on_4xx():
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 == 429
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