cacheverifier 0.4.1__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.1 → cacheverifier-0.5.0}/CHANGELOG.md +17 -0
- {cacheverifier-0.4.1 → cacheverifier-0.5.0}/PKG-INFO +41 -1
- {cacheverifier-0.4.1 → cacheverifier-0.5.0}/README.md +37 -0
- {cacheverifier-0.4.1 → cacheverifier-0.5.0}/cacheverifier/__init__.py +3 -3
- {cacheverifier-0.4.1 → cacheverifier-0.5.0}/cacheverifier/client.py +36 -0
- cacheverifier-0.5.0/cacheverifier/integrations/langchain.py +140 -0
- {cacheverifier-0.4.1 → cacheverifier-0.5.0}/pyproject.toml +3 -2
- {cacheverifier-0.4.1 → cacheverifier-0.5.0}/tests/test_client.py +27 -1
- cacheverifier-0.5.0/tests/test_langchain_integration.py +90 -0
- {cacheverifier-0.4.1 → cacheverifier-0.5.0}/.github/workflows/ci.yml +0 -0
- {cacheverifier-0.4.1 → cacheverifier-0.5.0}/.github/workflows/publish.yml +0 -0
- {cacheverifier-0.4.1 → cacheverifier-0.5.0}/.gitignore +0 -0
- {cacheverifier-0.4.1 → cacheverifier-0.5.0}/LICENSE +0 -0
- {cacheverifier-0.4.1 → cacheverifier-0.5.0}/cacheverifier/__main__.py +0 -0
- {cacheverifier-0.4.1 → cacheverifier-0.5.0}/cacheverifier/_healthcheck/__init__.py +0 -0
- {cacheverifier-0.4.1 → cacheverifier-0.5.0}/cacheverifier/_healthcheck/_base_models.py +0 -0
- {cacheverifier-0.4.1 → cacheverifier-0.5.0}/cacheverifier/_healthcheck/_finetune.py +0 -0
- {cacheverifier-0.4.1 → cacheverifier-0.5.0}/cacheverifier/_healthcheck/cli.py +0 -0
- {cacheverifier-0.4.1 → cacheverifier-0.5.0}/cacheverifier/integrations/__init__.py +0 -0
- {cacheverifier-0.4.1 → cacheverifier-0.5.0}/cacheverifier/integrations/gptcache.py +0 -0
- {cacheverifier-0.4.1 → cacheverifier-0.5.0}/cacheverifier/py.typed +0 -0
- {cacheverifier-0.4.1 → cacheverifier-0.5.0}/examples/gptcache_example.py +0 -0
- {cacheverifier-0.4.1 → cacheverifier-0.5.0}/examples/quickstart.py +0 -0
- {cacheverifier-0.4.1 → cacheverifier-0.5.0}/tests/test_gptcache_integration.py +0 -0
- {cacheverifier-0.4.1 → cacheverifier-0.5.0}/tests/test_healthcheck.py +0 -0
|
@@ -1,5 +1,22 @@
|
|
|
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
|
+
|
|
3
20
|
## 0.4.1
|
|
4
21
|
|
|
5
22
|
`verify()` and `verify_batch()` now treat a **rate-limited 429** (one with a
|
|
@@ -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
|
|
|
@@ -139,6 +155,30 @@ evaluator.report_feedback(query, answer, was_correct=False, similarity_score=0.9
|
|
|
139
155
|
|
|
140
156
|
See [`examples/gptcache_example.py`](examples/gptcache_example.py).
|
|
141
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
|
+
|
|
142
182
|
## Fine-tuning
|
|
143
183
|
|
|
144
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
|
|
|
@@ -107,6 +120,30 @@ evaluator.report_feedback(query, answer, was_correct=False, similarity_score=0.9
|
|
|
107
120
|
|
|
108
121
|
See [`examples/gptcache_example.py`](examples/gptcache_example.py).
|
|
109
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
|
+
|
|
110
147
|
## Fine-tuning
|
|
111
148
|
|
|
112
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__"]
|
|
@@ -57,6 +57,32 @@ class CacheVerifierError(RuntimeError):
|
|
|
57
57
|
self.retry_after = retry_after
|
|
58
58
|
|
|
59
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
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
60
86
|
@dataclass(frozen=True)
|
|
61
87
|
class VerifyResult:
|
|
62
88
|
"""One `/v1/verify` decision.
|
|
@@ -74,6 +100,11 @@ class VerifyResult:
|
|
|
74
100
|
5xx / rate-limited 429) and this result was synthesized by the client
|
|
75
101
|
rather than returned by the API. `approved` then reflects the client's `fail_open` setting
|
|
76
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.
|
|
77
108
|
"""
|
|
78
109
|
|
|
79
110
|
approved: bool
|
|
@@ -82,15 +113,20 @@ class VerifyResult:
|
|
|
82
113
|
model_version: str
|
|
83
114
|
latency_ms: float
|
|
84
115
|
degraded: bool = False
|
|
116
|
+
notice: str | None = None
|
|
117
|
+
shadow: ShadowVerdict | None = None
|
|
85
118
|
|
|
86
119
|
@classmethod
|
|
87
120
|
def _from_json(cls, d: dict[str, Any]) -> VerifyResult:
|
|
121
|
+
shadow = d.get("shadow")
|
|
88
122
|
return cls(
|
|
89
123
|
approved=bool(d["approved"]),
|
|
90
124
|
score=float(d["score"]),
|
|
91
125
|
threshold=float(d["threshold"]),
|
|
92
126
|
model_version=str(d["model_version"]),
|
|
93
127
|
latency_ms=float(d["latency_ms"]),
|
|
128
|
+
notice=d.get("notice"),
|
|
129
|
+
shadow=ShadowVerdict._from_json(shadow) if shadow else None,
|
|
94
130
|
)
|
|
95
131
|
|
|
96
132
|
|
|
@@ -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():
|
|
@@ -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
|