cacheverifier 0.1.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.1.0/.github/workflows/ci.yml +36 -0
- cacheverifier-0.1.0/.github/workflows/publish.yml +43 -0
- cacheverifier-0.1.0/.gitignore +10 -0
- cacheverifier-0.1.0/CHANGELOG.md +8 -0
- cacheverifier-0.1.0/LICENSE +21 -0
- cacheverifier-0.1.0/PKG-INFO +145 -0
- cacheverifier-0.1.0/README.md +117 -0
- cacheverifier-0.1.0/cacheverifier/__init__.py +22 -0
- cacheverifier-0.1.0/cacheverifier/client.py +278 -0
- cacheverifier-0.1.0/cacheverifier/integrations/__init__.py +7 -0
- cacheverifier-0.1.0/cacheverifier/integrations/gptcache.py +84 -0
- cacheverifier-0.1.0/cacheverifier/py.typed +0 -0
- cacheverifier-0.1.0/examples/gptcache_example.py +46 -0
- cacheverifier-0.1.0/examples/quickstart.py +51 -0
- cacheverifier-0.1.0/pyproject.toml +62 -0
- cacheverifier-0.1.0/tests/test_client.py +144 -0
- cacheverifier-0.1.0/tests/test_gptcache_integration.py +55 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
test:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
strategy:
|
|
12
|
+
fail-fast: false
|
|
13
|
+
matrix:
|
|
14
|
+
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v4
|
|
17
|
+
- uses: actions/setup-python@v5
|
|
18
|
+
with:
|
|
19
|
+
python-version: ${{ matrix.python-version }}
|
|
20
|
+
- run: python -m pip install --upgrade pip
|
|
21
|
+
- run: pip install -e ".[dev]"
|
|
22
|
+
- run: ruff check .
|
|
23
|
+
- run: pytest -q
|
|
24
|
+
- run: mypy cacheverifier
|
|
25
|
+
if: matrix.python-version != '3.9' # mypy config targets 3.10+
|
|
26
|
+
|
|
27
|
+
build:
|
|
28
|
+
runs-on: ubuntu-latest
|
|
29
|
+
steps:
|
|
30
|
+
- uses: actions/checkout@v4
|
|
31
|
+
- uses: actions/setup-python@v5
|
|
32
|
+
with:
|
|
33
|
+
python-version: "3.12"
|
|
34
|
+
- run: pip install build twine
|
|
35
|
+
- run: python -m build
|
|
36
|
+
- run: twine check dist/*
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
# Publishes to PyPI via Trusted Publishing (OIDC) -- no API token stored
|
|
4
|
+
# anywhere. Setup, done once on pypi.org: Account -> Publishing -> "Add a
|
|
5
|
+
# pending publisher" with
|
|
6
|
+
# PyPI project name: cacheverifier
|
|
7
|
+
# Owner: imxinchengyou
|
|
8
|
+
# Repository: cacheverifier-python
|
|
9
|
+
# Workflow name: publish.yml
|
|
10
|
+
# Environment: pypi
|
|
11
|
+
# Then publishing a GitHub Release runs this workflow and uploads the build.
|
|
12
|
+
|
|
13
|
+
on:
|
|
14
|
+
release:
|
|
15
|
+
types: [published]
|
|
16
|
+
|
|
17
|
+
jobs:
|
|
18
|
+
build:
|
|
19
|
+
runs-on: ubuntu-latest
|
|
20
|
+
steps:
|
|
21
|
+
- uses: actions/checkout@v4
|
|
22
|
+
- uses: actions/setup-python@v5
|
|
23
|
+
with:
|
|
24
|
+
python-version: "3.12"
|
|
25
|
+
- run: pip install build
|
|
26
|
+
- run: python -m build
|
|
27
|
+
- uses: actions/upload-artifact@v4
|
|
28
|
+
with:
|
|
29
|
+
name: dist
|
|
30
|
+
path: dist/
|
|
31
|
+
|
|
32
|
+
publish:
|
|
33
|
+
needs: build
|
|
34
|
+
runs-on: ubuntu-latest
|
|
35
|
+
environment: pypi
|
|
36
|
+
permissions:
|
|
37
|
+
id-token: write # required for Trusted Publishing
|
|
38
|
+
steps:
|
|
39
|
+
- uses: actions/download-artifact@v4
|
|
40
|
+
with:
|
|
41
|
+
name: dist
|
|
42
|
+
path: dist/
|
|
43
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
Initial release: thin `httpx`-based client for `/v1/verify`, `/v1/verify/batch`,
|
|
6
|
+
`/v1/feedback`(`/batch`), fine-tune jobs + dry run, drift and gray-zone-threshold
|
|
7
|
+
monitoring, and usage/savings. GPTCache `SimilarityEvaluation` adapter under
|
|
8
|
+
`cacheverifier.integrations.gptcache`.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Chengyou Xin
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: cacheverifier
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python client for the hosted CacheVerifier semantic-cache verification API
|
|
5
|
+
Project-URL: Homepage, https://www.cacheverifier.com
|
|
6
|
+
Project-URL: Documentation, https://www.cacheverifier.com/docs
|
|
7
|
+
Project-URL: Source, https://github.com/imxinchengyou/cacheverifier-python
|
|
8
|
+
Project-URL: Research, https://github.com/imxinchengyou/CacheVerifier
|
|
9
|
+
Author: Chengyou Xin
|
|
10
|
+
License: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: cache-verification,cross-encoder,gptcache,llm,llm-caching,semantic-cache,semantic-cache-verification
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
18
|
+
Classifier: Typing :: Typed
|
|
19
|
+
Requires-Python: >=3.9
|
|
20
|
+
Requires-Dist: httpx>=0.24
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: mypy>=1.5; extra == 'dev'
|
|
23
|
+
Requires-Dist: pytest>=7; extra == 'dev'
|
|
24
|
+
Requires-Dist: ruff>=0.4; extra == 'dev'
|
|
25
|
+
Provides-Extra: gptcache
|
|
26
|
+
Requires-Dist: gptcache>=0.1.30; extra == 'gptcache'
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# cacheverifier
|
|
30
|
+
|
|
31
|
+
[](https://github.com/imxinchengyou/cacheverifier-python/actions/workflows/ci.yml)
|
|
32
|
+
[](https://pypi.org/project/cacheverifier/)
|
|
33
|
+
[](https://pypi.org/project/cacheverifier/)
|
|
34
|
+
|
|
35
|
+
Python client for **[CacheVerifier](https://www.cacheverifier.com)** — a hosted API that
|
|
36
|
+
verifies semantic-cache hits. Given a query and a candidate cached answer, it approves or
|
|
37
|
+
rejects serving that answer from cache, so a similarity match that is *close but wrong*
|
|
38
|
+
doesn't become a silent error in your app.
|
|
39
|
+
|
|
40
|
+
CacheVerifier does **not** run your cache or do similarity search. Your cache backend does
|
|
41
|
+
its own lookup first; you call `verify()` only on the candidates in the similarity "gray
|
|
42
|
+
zone", where a plain threshold match might be wrong.
|
|
43
|
+
|
|
44
|
+
- Docs / API reference: <https://www.cacheverifier.com/docs>
|
|
45
|
+
- Why similarity ≠ correctness: <https://www.cacheverifier.com/why-similarity-fails>
|
|
46
|
+
- The research behind it (paper + benchmarks): <https://github.com/imxinchengyou/CacheVerifier>
|
|
47
|
+
|
|
48
|
+
## Install
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
pip install cacheverifier
|
|
52
|
+
# with the GPTCache adapter:
|
|
53
|
+
pip install "cacheverifier[gptcache]"
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Requires Python 3.9+. The only runtime dependency is `httpx`.
|
|
57
|
+
|
|
58
|
+
## Quickstart
|
|
59
|
+
|
|
60
|
+
Get a free API key at <https://www.cacheverifier.com> (self-serve verify and fine-tuning
|
|
61
|
+
are free forever, no card).
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
from cacheverifier import CacheVerifier
|
|
65
|
+
|
|
66
|
+
cv = CacheVerifier(api_key="cv_...")
|
|
67
|
+
|
|
68
|
+
query = "how do I cancel my subscription"
|
|
69
|
+
candidate = "Go to Settings > Billing > Pause subscription for a month." # from your cache
|
|
70
|
+
|
|
71
|
+
result = cv.verify(query, candidate)
|
|
72
|
+
if result.approved:
|
|
73
|
+
answer = candidate # verified hit — skip the LLM call
|
|
74
|
+
else:
|
|
75
|
+
answer = call_your_llm(query) # not trustworthy — fall through
|
|
76
|
+
|
|
77
|
+
# Later, once you know if it was actually right (thumbs-down, reopened ticket, ...):
|
|
78
|
+
cv.feedback(query, answer, was_correct=True, similarity_score=0.86)
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
`verify()` returns a `VerifyResult`:
|
|
82
|
+
|
|
83
|
+
| field | meaning |
|
|
84
|
+
|---|---|
|
|
85
|
+
| `approved` | serve the cached answer (`True`) or fall through (`False`) |
|
|
86
|
+
| `score` / `threshold` | `approved` is `score >= threshold` |
|
|
87
|
+
| `model_version` | `"stock"`, `"v<id>"` (fine-tuned), or `"cold_start_fail_closed"` |
|
|
88
|
+
| `latency_ms` | server-side inference time |
|
|
89
|
+
|
|
90
|
+
## GPTCache
|
|
91
|
+
|
|
92
|
+
Drop the verifier into a GPTCache pipeline as its similarity evaluator — no fork required:
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
from gptcache import cache
|
|
96
|
+
from cacheverifier.integrations.gptcache import CacheVerifierEvaluation
|
|
97
|
+
|
|
98
|
+
evaluator = CacheVerifierEvaluation(api_key="cv_...")
|
|
99
|
+
cache.init(similarity_evaluation=evaluator, ...)
|
|
100
|
+
|
|
101
|
+
# when you learn a served hit's real outcome:
|
|
102
|
+
evaluator.report_feedback(query, answer, was_correct=False, similarity_score=0.9)
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
See [`examples/gptcache_example.py`](examples/gptcache_example.py).
|
|
106
|
+
|
|
107
|
+
## Fine-tuning
|
|
108
|
+
|
|
109
|
+
Once you have ~20+ feedback rows (the service found fine-tuning is often a net negative
|
|
110
|
+
below ~1,000 on the hardest data — see the [research](https://github.com/imxinchengyou/CacheVerifier)),
|
|
111
|
+
train a verifier on your own gray-zone labels:
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
job = cv.finetune() # or cv.finetune(target_risk=0.01, cost_ratio=5.0)
|
|
115
|
+
job = cv.get_finetune_job(job["id"]) # poll until status == "done"
|
|
116
|
+
print(job["auc_baseline"], job["auc_tuned"])
|
|
117
|
+
|
|
118
|
+
# a model can finish as "held_for_review" — promote it explicitly:
|
|
119
|
+
if job.get("result_model_version"):
|
|
120
|
+
cv.activate_model_version(job["result_model_version"])
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
`cv.dry_run([...])` reports the same baseline-vs-tuned AUC on examples you pass directly,
|
|
124
|
+
without writing anything or deploying a model.
|
|
125
|
+
|
|
126
|
+
## API surface
|
|
127
|
+
|
|
128
|
+
| method | endpoint |
|
|
129
|
+
|---|---|
|
|
130
|
+
| `verify(query, candidate_answer)` | `POST /v1/verify` |
|
|
131
|
+
| `verify_batch(pairs)` | `POST /v1/verify/batch` |
|
|
132
|
+
| `feedback(...)` / `feedback_batch(items)` | `POST /v1/feedback` / `/batch` |
|
|
133
|
+
| `finetune(...)` / `dry_run(examples, ...)` | `POST /v1/finetune/jobs` / `/dry-run` |
|
|
134
|
+
| `get_finetune_job(id)` / `list_finetune_jobs()` | `GET /v1/finetune/jobs[/id]` |
|
|
135
|
+
| `activate_model_version(id)` | `POST /v1/finetune/model-versions/{id}/activate` |
|
|
136
|
+
| `drift_status()` | `GET /v1/monitor/drift-status` |
|
|
137
|
+
| `gray_zone_threshold()` | `GET /v1/monitor/gray-zone-threshold` |
|
|
138
|
+
| `usage()` / `savings()` | `GET /v1/usage/status` / `/savings` |
|
|
139
|
+
|
|
140
|
+
Non-2xx responses raise `CacheVerifierError` (`.status_code`, `.detail`).
|
|
141
|
+
|
|
142
|
+
## License
|
|
143
|
+
|
|
144
|
+
MIT — see [`LICENSE`](LICENSE). (The [research repository](https://github.com/imxinchengyou/CacheVerifier)
|
|
145
|
+
is separately licensed; this client is not.)
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# cacheverifier
|
|
2
|
+
|
|
3
|
+
[](https://github.com/imxinchengyou/cacheverifier-python/actions/workflows/ci.yml)
|
|
4
|
+
[](https://pypi.org/project/cacheverifier/)
|
|
5
|
+
[](https://pypi.org/project/cacheverifier/)
|
|
6
|
+
|
|
7
|
+
Python client for **[CacheVerifier](https://www.cacheverifier.com)** — a hosted API that
|
|
8
|
+
verifies semantic-cache hits. Given a query and a candidate cached answer, it approves or
|
|
9
|
+
rejects serving that answer from cache, so a similarity match that is *close but wrong*
|
|
10
|
+
doesn't become a silent error in your app.
|
|
11
|
+
|
|
12
|
+
CacheVerifier does **not** run your cache or do similarity search. Your cache backend does
|
|
13
|
+
its own lookup first; you call `verify()` only on the candidates in the similarity "gray
|
|
14
|
+
zone", where a plain threshold match might be wrong.
|
|
15
|
+
|
|
16
|
+
- Docs / API reference: <https://www.cacheverifier.com/docs>
|
|
17
|
+
- Why similarity ≠ correctness: <https://www.cacheverifier.com/why-similarity-fails>
|
|
18
|
+
- The research behind it (paper + benchmarks): <https://github.com/imxinchengyou/CacheVerifier>
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pip install cacheverifier
|
|
24
|
+
# with the GPTCache adapter:
|
|
25
|
+
pip install "cacheverifier[gptcache]"
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Requires Python 3.9+. The only runtime dependency is `httpx`.
|
|
29
|
+
|
|
30
|
+
## Quickstart
|
|
31
|
+
|
|
32
|
+
Get a free API key at <https://www.cacheverifier.com> (self-serve verify and fine-tuning
|
|
33
|
+
are free forever, no card).
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
from cacheverifier import CacheVerifier
|
|
37
|
+
|
|
38
|
+
cv = CacheVerifier(api_key="cv_...")
|
|
39
|
+
|
|
40
|
+
query = "how do I cancel my subscription"
|
|
41
|
+
candidate = "Go to Settings > Billing > Pause subscription for a month." # from your cache
|
|
42
|
+
|
|
43
|
+
result = cv.verify(query, candidate)
|
|
44
|
+
if result.approved:
|
|
45
|
+
answer = candidate # verified hit — skip the LLM call
|
|
46
|
+
else:
|
|
47
|
+
answer = call_your_llm(query) # not trustworthy — fall through
|
|
48
|
+
|
|
49
|
+
# Later, once you know if it was actually right (thumbs-down, reopened ticket, ...):
|
|
50
|
+
cv.feedback(query, answer, was_correct=True, similarity_score=0.86)
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
`verify()` returns a `VerifyResult`:
|
|
54
|
+
|
|
55
|
+
| field | meaning |
|
|
56
|
+
|---|---|
|
|
57
|
+
| `approved` | serve the cached answer (`True`) or fall through (`False`) |
|
|
58
|
+
| `score` / `threshold` | `approved` is `score >= threshold` |
|
|
59
|
+
| `model_version` | `"stock"`, `"v<id>"` (fine-tuned), or `"cold_start_fail_closed"` |
|
|
60
|
+
| `latency_ms` | server-side inference time |
|
|
61
|
+
|
|
62
|
+
## GPTCache
|
|
63
|
+
|
|
64
|
+
Drop the verifier into a GPTCache pipeline as its similarity evaluator — no fork required:
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
from gptcache import cache
|
|
68
|
+
from cacheverifier.integrations.gptcache import CacheVerifierEvaluation
|
|
69
|
+
|
|
70
|
+
evaluator = CacheVerifierEvaluation(api_key="cv_...")
|
|
71
|
+
cache.init(similarity_evaluation=evaluator, ...)
|
|
72
|
+
|
|
73
|
+
# when you learn a served hit's real outcome:
|
|
74
|
+
evaluator.report_feedback(query, answer, was_correct=False, similarity_score=0.9)
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
See [`examples/gptcache_example.py`](examples/gptcache_example.py).
|
|
78
|
+
|
|
79
|
+
## Fine-tuning
|
|
80
|
+
|
|
81
|
+
Once you have ~20+ feedback rows (the service found fine-tuning is often a net negative
|
|
82
|
+
below ~1,000 on the hardest data — see the [research](https://github.com/imxinchengyou/CacheVerifier)),
|
|
83
|
+
train a verifier on your own gray-zone labels:
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
job = cv.finetune() # or cv.finetune(target_risk=0.01, cost_ratio=5.0)
|
|
87
|
+
job = cv.get_finetune_job(job["id"]) # poll until status == "done"
|
|
88
|
+
print(job["auc_baseline"], job["auc_tuned"])
|
|
89
|
+
|
|
90
|
+
# a model can finish as "held_for_review" — promote it explicitly:
|
|
91
|
+
if job.get("result_model_version"):
|
|
92
|
+
cv.activate_model_version(job["result_model_version"])
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
`cv.dry_run([...])` reports the same baseline-vs-tuned AUC on examples you pass directly,
|
|
96
|
+
without writing anything or deploying a model.
|
|
97
|
+
|
|
98
|
+
## API surface
|
|
99
|
+
|
|
100
|
+
| method | endpoint |
|
|
101
|
+
|---|---|
|
|
102
|
+
| `verify(query, candidate_answer)` | `POST /v1/verify` |
|
|
103
|
+
| `verify_batch(pairs)` | `POST /v1/verify/batch` |
|
|
104
|
+
| `feedback(...)` / `feedback_batch(items)` | `POST /v1/feedback` / `/batch` |
|
|
105
|
+
| `finetune(...)` / `dry_run(examples, ...)` | `POST /v1/finetune/jobs` / `/dry-run` |
|
|
106
|
+
| `get_finetune_job(id)` / `list_finetune_jobs()` | `GET /v1/finetune/jobs[/id]` |
|
|
107
|
+
| `activate_model_version(id)` | `POST /v1/finetune/model-versions/{id}/activate` |
|
|
108
|
+
| `drift_status()` | `GET /v1/monitor/drift-status` |
|
|
109
|
+
| `gray_zone_threshold()` | `GET /v1/monitor/gray-zone-threshold` |
|
|
110
|
+
| `usage()` / `savings()` | `GET /v1/usage/status` / `/savings` |
|
|
111
|
+
|
|
112
|
+
Non-2xx responses raise `CacheVerifierError` (`.status_code`, `.detail`).
|
|
113
|
+
|
|
114
|
+
## License
|
|
115
|
+
|
|
116
|
+
MIT — see [`LICENSE`](LICENSE). (The [research repository](https://github.com/imxinchengyou/CacheVerifier)
|
|
117
|
+
is separately licensed; this client is not.)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""cacheverifier -- Python client for the hosted CacheVerifier API.
|
|
2
|
+
|
|
3
|
+
CacheVerifier verifies semantic-cache hits: given a query and a candidate
|
|
4
|
+
cached answer, it approves or rejects serving that answer from cache, so a
|
|
5
|
+
similarity match that is close but wrong doesn't become a silent error.
|
|
6
|
+
|
|
7
|
+
from cacheverifier import CacheVerifier
|
|
8
|
+
|
|
9
|
+
cv = CacheVerifier(api_key="cv_...")
|
|
10
|
+
if cv.verify(query, candidate_answer).approved:
|
|
11
|
+
... # serve from cache
|
|
12
|
+
else:
|
|
13
|
+
... # fall through to your LLM
|
|
14
|
+
|
|
15
|
+
Docs: https://www.cacheverifier.com/docs
|
|
16
|
+
Research behind it: https://github.com/imxinchengyou/CacheVerifier
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from cacheverifier.client import CacheVerifier, CacheVerifierError, VerifyResult
|
|
20
|
+
|
|
21
|
+
__version__ = "0.1.0"
|
|
22
|
+
__all__ = ["CacheVerifier", "CacheVerifierError", "VerifyResult", "__version__"]
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
"""Thin Python client for the hosted CacheVerifier API (https://www.cacheverifier.com).
|
|
2
|
+
|
|
3
|
+
CacheVerifier does not run your semantic cache or do similarity search. Your
|
|
4
|
+
cache backend does its own lookup first; you call `verify()` only on the
|
|
5
|
+
candidates in the similarity "gray zone", where a plain threshold match
|
|
6
|
+
might be wrong. See https://www.cacheverifier.com/docs for the full API.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Iterable, Sequence
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
DEFAULT_BASE_URL = "https://www.cacheverifier.com"
|
|
18
|
+
DEFAULT_TIMEOUT = 10.0
|
|
19
|
+
|
|
20
|
+
__all__ = ["CacheVerifier", "CacheVerifierError", "VerifyResult"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class CacheVerifierError(RuntimeError):
|
|
24
|
+
"""Raised for any non-2xx response from the API.
|
|
25
|
+
|
|
26
|
+
`status_code` is the HTTP status; `detail` is the server's error message
|
|
27
|
+
(the JSON body's `detail` field when present, otherwise the raw text).
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, status_code: int, detail: str) -> None:
|
|
31
|
+
super().__init__(f"CacheVerifier API error {status_code}: {detail}")
|
|
32
|
+
self.status_code = status_code
|
|
33
|
+
self.detail = detail
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class VerifyResult:
|
|
38
|
+
"""One `/v1/verify` decision.
|
|
39
|
+
|
|
40
|
+
- `approved`: serve the cached answer (True) or fall through to your LLM (False).
|
|
41
|
+
- `score`: the verifier's raw score for this pair; `approved` is `score >= threshold`.
|
|
42
|
+
- `threshold`: the cutoff this call was decided against (tenant-specific once
|
|
43
|
+
you've fine-tuned; 0.0 on the shared stock model).
|
|
44
|
+
- `model_version`: `"stock"`, `"v<id>"` for a fine-tuned model, or
|
|
45
|
+
`"cold_start_fail_closed"` when no model ran (see `cold_start_mode`).
|
|
46
|
+
- `latency_ms`: server-side model inference time, not round-trip time.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
approved: bool
|
|
50
|
+
score: float
|
|
51
|
+
threshold: float
|
|
52
|
+
model_version: str
|
|
53
|
+
latency_ms: float
|
|
54
|
+
|
|
55
|
+
@classmethod
|
|
56
|
+
def _from_json(cls, d: dict[str, Any]) -> VerifyResult:
|
|
57
|
+
return cls(
|
|
58
|
+
approved=bool(d["approved"]),
|
|
59
|
+
score=float(d["score"]),
|
|
60
|
+
threshold=float(d["threshold"]),
|
|
61
|
+
model_version=str(d["model_version"]),
|
|
62
|
+
latency_ms=float(d["latency_ms"]),
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class CacheVerifier:
|
|
67
|
+
"""Client for the hosted CacheVerifier API.
|
|
68
|
+
|
|
69
|
+
from cacheverifier import CacheVerifier
|
|
70
|
+
|
|
71
|
+
cv = CacheVerifier(api_key="cv_...")
|
|
72
|
+
result = cv.verify("how do I cancel", "Go to Settings > Billing > Pause.")
|
|
73
|
+
if result.approved:
|
|
74
|
+
... # serve the cached answer
|
|
75
|
+
else:
|
|
76
|
+
... # fall through to your LLM
|
|
77
|
+
|
|
78
|
+
Usable as a context manager (`with CacheVerifier(...) as cv:`) to close
|
|
79
|
+
the underlying HTTP connection pool deterministically.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
def __init__(
|
|
83
|
+
self,
|
|
84
|
+
api_key: str,
|
|
85
|
+
*,
|
|
86
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
87
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
88
|
+
transport: httpx.BaseTransport | None = None,
|
|
89
|
+
) -> None:
|
|
90
|
+
if not api_key:
|
|
91
|
+
raise ValueError("api_key is required -- get one at https://www.cacheverifier.com")
|
|
92
|
+
self._client = httpx.Client(
|
|
93
|
+
base_url=base_url.rstrip("/"),
|
|
94
|
+
headers={"X-API-Key": api_key, "User-Agent": _user_agent()},
|
|
95
|
+
timeout=timeout,
|
|
96
|
+
transport=transport,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
# -- lifecycle ---------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
def close(self) -> None:
|
|
102
|
+
self._client.close()
|
|
103
|
+
|
|
104
|
+
def __enter__(self) -> CacheVerifier:
|
|
105
|
+
return self
|
|
106
|
+
|
|
107
|
+
def __exit__(self, *_exc: object) -> None:
|
|
108
|
+
self.close()
|
|
109
|
+
|
|
110
|
+
# -- core: verify ----------------------------------------------------
|
|
111
|
+
|
|
112
|
+
def verify(self, query: str, candidate_answer: str) -> VerifyResult:
|
|
113
|
+
"""Approve or reject one gray-zone cache hit. `POST /v1/verify`."""
|
|
114
|
+
data = self._post("/v1/verify", json={"query": query, "candidate_answer": candidate_answer})
|
|
115
|
+
return VerifyResult._from_json(data)
|
|
116
|
+
|
|
117
|
+
def verify_batch(self, pairs: Sequence[tuple[str, str]]) -> list[VerifyResult]:
|
|
118
|
+
"""Verify many `(query, candidate_answer)` pairs in one request and
|
|
119
|
+
one batched forward pass. `POST /v1/verify/batch` (max 100 items).
|
|
120
|
+
|
|
121
|
+
Useful when your own retrieval returns several close candidates:
|
|
122
|
+
send them in rank order and take the first `approved` one.
|
|
123
|
+
"""
|
|
124
|
+
items = [{"query": q, "candidate_answer": a} for q, a in pairs]
|
|
125
|
+
data = self._post("/v1/verify/batch", json={"items": items})
|
|
126
|
+
return [VerifyResult._from_json(r) for r in data["results"]]
|
|
127
|
+
|
|
128
|
+
# -- feedback ------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
def feedback(
|
|
131
|
+
self,
|
|
132
|
+
query: str,
|
|
133
|
+
candidate_answer: str,
|
|
134
|
+
was_correct: bool,
|
|
135
|
+
*,
|
|
136
|
+
similarity_score: float | None = None,
|
|
137
|
+
stale: bool = False,
|
|
138
|
+
idempotency_key: str | None = None,
|
|
139
|
+
) -> int:
|
|
140
|
+
"""Record whether a served (or considered) hit was actually correct.
|
|
141
|
+
Returns the created row id. `POST /v1/feedback`.
|
|
142
|
+
|
|
143
|
+
This is the ground-truth signal fine-tuning and drift monitoring
|
|
144
|
+
learn from -- you supply it from a thumbs-down, a reopened ticket,
|
|
145
|
+
manual review, etc.
|
|
146
|
+
|
|
147
|
+
- `similarity_score`: your cache backend's own score for this
|
|
148
|
+
candidate, if you still have it. Only used by
|
|
149
|
+
`gray_zone_threshold()`; safe to omit.
|
|
150
|
+
- `stale`: set instead of a bare `was_correct=False` when the answer
|
|
151
|
+
is wrong ONLY because a fact changed (price, date, ...), not a
|
|
152
|
+
semantic mismatch. Stale rows are excluded from training and drift.
|
|
153
|
+
- `idempotency_key`: pass a stable key to make retries safe.
|
|
154
|
+
"""
|
|
155
|
+
payload: dict[str, Any] = {
|
|
156
|
+
"query": query,
|
|
157
|
+
"candidate_answer": candidate_answer,
|
|
158
|
+
"was_correct": was_correct,
|
|
159
|
+
"stale": stale,
|
|
160
|
+
}
|
|
161
|
+
if similarity_score is not None:
|
|
162
|
+
payload["similarity_score"] = similarity_score
|
|
163
|
+
headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
|
|
164
|
+
return int(self._post("/v1/feedback", json=payload, headers=headers)["id"])
|
|
165
|
+
|
|
166
|
+
def feedback_batch(self, items: Iterable[dict[str, Any]]) -> list[int]:
|
|
167
|
+
"""Bulk-upload feedback rows (max 500). Each item is a dict with
|
|
168
|
+
`query`, `candidate_answer`, `was_correct`, and optionally
|
|
169
|
+
`similarity_score` / `stale`. Returns the created row ids.
|
|
170
|
+
`POST /v1/feedback/batch`.
|
|
171
|
+
"""
|
|
172
|
+
data = self._post("/v1/feedback/batch", json={"items": list(items)})
|
|
173
|
+
return [int(i) for i in data["ids"]]
|
|
174
|
+
|
|
175
|
+
# -- fine-tuning -------------------------------------------------
|
|
176
|
+
|
|
177
|
+
def finetune(self, *, target_risk: float | None = None, cost_ratio: float | None = None) -> dict[str, Any]:
|
|
178
|
+
"""Kick off fine-tuning on all feedback submitted so far. Returns the
|
|
179
|
+
job (poll `get_finetune_job(job['id'])` for completion + AUC).
|
|
180
|
+
`POST /v1/finetune/jobs`.
|
|
181
|
+
|
|
182
|
+
- `target_risk`: e.g. `0.01` to also certify a threshold at 1%
|
|
183
|
+
false-reuse risk via Conformal Risk Control.
|
|
184
|
+
- `cost_ratio`: pick the live threshold by cost (how many times more
|
|
185
|
+
a wrong reuse costs than a miss) instead of the default Youden's J.
|
|
186
|
+
"""
|
|
187
|
+
return self._post("/v1/finetune/jobs", params=_drop_none(target_risk=target_risk, cost_ratio=cost_ratio))
|
|
188
|
+
|
|
189
|
+
def dry_run(
|
|
190
|
+
self,
|
|
191
|
+
examples: Sequence[dict[str, Any]],
|
|
192
|
+
*,
|
|
193
|
+
target_risk: float | None = None,
|
|
194
|
+
cost_ratio: float | None = None,
|
|
195
|
+
) -> dict[str, Any]:
|
|
196
|
+
"""Baseline-vs-fine-tuned AUC on `examples` given directly here --
|
|
197
|
+
nothing is written to your feedback history and no model is
|
|
198
|
+
deployed. Each example is a dict with `query`, `candidate_answer`,
|
|
199
|
+
`was_correct` (and optional `stale`). `POST /v1/finetune/dry-run`.
|
|
200
|
+
"""
|
|
201
|
+
return self._post(
|
|
202
|
+
"/v1/finetune/dry-run",
|
|
203
|
+
json={"examples": list(examples)},
|
|
204
|
+
params=_drop_none(target_risk=target_risk, cost_ratio=cost_ratio),
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
def get_finetune_job(self, job_id: int) -> dict[str, Any]:
|
|
208
|
+
"""`GET /v1/finetune/jobs/{id}`."""
|
|
209
|
+
return self._get(f"/v1/finetune/jobs/{job_id}")
|
|
210
|
+
|
|
211
|
+
def list_finetune_jobs(self) -> list[dict[str, Any]]:
|
|
212
|
+
"""`GET /v1/finetune/jobs`."""
|
|
213
|
+
return self._get("/v1/finetune/jobs")
|
|
214
|
+
|
|
215
|
+
def activate_model_version(self, model_version_id: int) -> dict[str, Any]:
|
|
216
|
+
"""Promote a model that finished as `held_for_review`.
|
|
217
|
+
`POST /v1/finetune/model-versions/{id}/activate`.
|
|
218
|
+
"""
|
|
219
|
+
return self._post(f"/v1/finetune/model-versions/{model_version_id}/activate")
|
|
220
|
+
|
|
221
|
+
# -- monitoring / usage ----------------------------------------
|
|
222
|
+
|
|
223
|
+
def drift_status(self) -> dict[str, Any]:
|
|
224
|
+
"""`GET /v1/monitor/drift-status` (needs an active fine-tuned model)."""
|
|
225
|
+
return self._get("/v1/monitor/drift-status")
|
|
226
|
+
|
|
227
|
+
def gray_zone_threshold(self) -> dict[str, Any]:
|
|
228
|
+
"""Recommend a tau_high for YOUR cache backend, replaying feedback
|
|
229
|
+
rows that included `similarity_score`. `GET /v1/monitor/gray-zone-threshold`.
|
|
230
|
+
"""
|
|
231
|
+
return self._get("/v1/monitor/gray-zone-threshold")
|
|
232
|
+
|
|
233
|
+
def usage(self) -> dict[str, Any]:
|
|
234
|
+
"""`GET /v1/usage/status`."""
|
|
235
|
+
return self._get("/v1/usage/status")
|
|
236
|
+
|
|
237
|
+
def savings(self) -> dict[str, Any]:
|
|
238
|
+
"""This period's estimated LLM calls and wrong hits avoided.
|
|
239
|
+
`GET /v1/usage/savings`.
|
|
240
|
+
"""
|
|
241
|
+
return self._get("/v1/usage/savings")
|
|
242
|
+
|
|
243
|
+
# -- plumbing ----------------------------------------------------
|
|
244
|
+
|
|
245
|
+
def _get(self, path: str) -> Any:
|
|
246
|
+
return self._unwrap(self._client.get(path))
|
|
247
|
+
|
|
248
|
+
def _post(
|
|
249
|
+
self,
|
|
250
|
+
path: str,
|
|
251
|
+
*,
|
|
252
|
+
json: Any | None = None,
|
|
253
|
+
params: dict[str, Any] | None = None,
|
|
254
|
+
headers: dict[str, str] | None = None,
|
|
255
|
+
) -> Any:
|
|
256
|
+
return self._unwrap(self._client.post(path, json=json, params=params or None, headers=headers))
|
|
257
|
+
|
|
258
|
+
@staticmethod
|
|
259
|
+
def _unwrap(resp: httpx.Response) -> Any:
|
|
260
|
+
if resp.is_success:
|
|
261
|
+
return resp.json()
|
|
262
|
+
detail: str
|
|
263
|
+
try:
|
|
264
|
+
body = resp.json()
|
|
265
|
+
detail = body["detail"] if isinstance(body, dict) and "detail" in body else resp.text
|
|
266
|
+
except ValueError:
|
|
267
|
+
detail = resp.text
|
|
268
|
+
raise CacheVerifierError(resp.status_code, str(detail))
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _drop_none(**kwargs: Any) -> dict[str, Any]:
|
|
272
|
+
return {k: v for k, v in kwargs.items() if v is not None}
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _user_agent() -> str:
|
|
276
|
+
from cacheverifier import __version__
|
|
277
|
+
|
|
278
|
+
return f"cacheverifier-python/{__version__} httpx/{httpx.__version__}"
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""Cache-backend integrations for CacheVerifier.
|
|
2
|
+
|
|
3
|
+
Each integration wraps `cacheverifier.CacheVerifier` in the extension point
|
|
4
|
+
a given cache library already exposes, so verification is a drop-in change
|
|
5
|
+
with no upstream fork required. GPTCache is the first; the same pattern
|
|
6
|
+
applies to any backend with a "should I trust this candidate" hook.
|
|
7
|
+
"""
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""GPTCache integration: a `SimilarityEvaluation` that calls the hosted
|
|
2
|
+
CacheVerifier `/v1/verify` endpoint instead of relying on cosine/dot-product
|
|
3
|
+
similarity alone.
|
|
4
|
+
|
|
5
|
+
GPTCache's `SimilarityEvaluation` interface (`evaluation()` + `range()`) is
|
|
6
|
+
a zero-friction integration point: any subclass drops straight into a
|
|
7
|
+
GPTCache pipeline's `similarity_evaluation=` argument, no upstream PR
|
|
8
|
+
required.
|
|
9
|
+
|
|
10
|
+
from gptcache import cache
|
|
11
|
+
from cacheverifier.integrations.gptcache import CacheVerifierEvaluation
|
|
12
|
+
|
|
13
|
+
cache.init(
|
|
14
|
+
similarity_evaluation=CacheVerifierEvaluation(api_key="cv_..."),
|
|
15
|
+
...
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
`gptcache` is an optional dependency -- `pip install "cacheverifier[gptcache]"`.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from cacheverifier.client import DEFAULT_BASE_URL, DEFAULT_TIMEOUT, CacheVerifier
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
from gptcache.similarity_evaluation import SimilarityEvaluation as _GPTCacheBase
|
|
29
|
+
except ImportError: # keep importable without gptcache installed
|
|
30
|
+
_GPTCacheBase = object
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class CacheVerifierEvaluation(_GPTCacheBase):
|
|
34
|
+
"""Drop-in replacement for GPTCache's built-in similarity evaluators.
|
|
35
|
+
|
|
36
|
+
`evaluation()` returns 1.0 (reuse) or 0.0 (don't) -- binary, because the
|
|
37
|
+
hosted verifier already makes a binary approve/reject call per gray-zone
|
|
38
|
+
hit rather than a softened similarity score. Callers who want GPTCache's
|
|
39
|
+
own threshold logic on top can wrap this rather than replace it.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
api_key: str,
|
|
45
|
+
*,
|
|
46
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
47
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
48
|
+
) -> None:
|
|
49
|
+
self._cv = CacheVerifier(api_key=api_key, base_url=base_url, timeout=timeout)
|
|
50
|
+
|
|
51
|
+
def evaluation(self, src_dict: dict[str, Any], cache_dict: dict[str, Any], **_kwargs: Any) -> float:
|
|
52
|
+
query = src_dict.get("question") or src_dict.get("query", "")
|
|
53
|
+
candidate_answer = cache_dict.get("answer", "")
|
|
54
|
+
return 1.0 if self._cv.verify(query, candidate_answer).approved else 0.0
|
|
55
|
+
|
|
56
|
+
def range(self) -> tuple[float, float]:
|
|
57
|
+
return 0.0, 1.0
|
|
58
|
+
|
|
59
|
+
def report_feedback(
|
|
60
|
+
self,
|
|
61
|
+
query: str,
|
|
62
|
+
candidate_answer: str,
|
|
63
|
+
was_correct: bool,
|
|
64
|
+
similarity_score: float | None = None,
|
|
65
|
+
) -> None:
|
|
66
|
+
"""Not part of GPTCache's interface -- call it once you know whether
|
|
67
|
+
a served hit was actually correct (a thumbs-down, a reopened
|
|
68
|
+
ticket, ...). Feeds `POST /v1/feedback`, which fine-tuning and drift
|
|
69
|
+
monitoring both consume.
|
|
70
|
+
|
|
71
|
+
`similarity_score` is optional: pass GPTCache's own vector-search
|
|
72
|
+
score for this candidate if you still have it when you learn
|
|
73
|
+
`was_correct` -- that is what lets the service later recommend a
|
|
74
|
+
tau_high for your GPTCache config (`gray_zone_threshold()`).
|
|
75
|
+
"""
|
|
76
|
+
self._cv.feedback(
|
|
77
|
+
query,
|
|
78
|
+
candidate_answer,
|
|
79
|
+
was_correct,
|
|
80
|
+
similarity_score=similarity_score,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
def close(self) -> None:
|
|
84
|
+
self._cv.close()
|
|
File without changes
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Wire CacheVerifier into a GPTCache pipeline as the similarity evaluator.
|
|
2
|
+
|
|
3
|
+
pip install "cacheverifier[gptcache]"
|
|
4
|
+
CACHEVERIFIER_API_KEY=cv_... python gptcache_example.py
|
|
5
|
+
|
|
6
|
+
The only CacheVerifier-specific line is `similarity_evaluation=...`. Every
|
|
7
|
+
gray-zone lookup GPTCache would otherwise decide on cosine similarity alone
|
|
8
|
+
now gets one synchronous verifier call instead.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
|
|
13
|
+
from gptcache import cache
|
|
14
|
+
from gptcache.adapter.api import get, put
|
|
15
|
+
from gptcache.embedding import Onnx
|
|
16
|
+
from gptcache.manager import CacheBase, VectorBase, get_data_manager
|
|
17
|
+
|
|
18
|
+
from cacheverifier.integrations.gptcache import CacheVerifierEvaluation
|
|
19
|
+
|
|
20
|
+
embedding = Onnx()
|
|
21
|
+
data_manager = get_data_manager(
|
|
22
|
+
CacheBase("sqlite"),
|
|
23
|
+
VectorBase("faiss", dimension=embedding.dimension),
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
evaluator = CacheVerifierEvaluation(api_key=os.environ["CACHEVERIFIER_API_KEY"])
|
|
27
|
+
|
|
28
|
+
cache.init(
|
|
29
|
+
embedding_func=embedding.to_embeddings,
|
|
30
|
+
data_manager=data_manager,
|
|
31
|
+
similarity_evaluation=evaluator,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
put("how do I cancel my subscription?", "Go to Settings > Billing > Cancel subscription.")
|
|
35
|
+
|
|
36
|
+
# Close-but-different intent. Cosine similarity would likely reuse the
|
|
37
|
+
# cached answer; the verifier call decides whether that is actually safe.
|
|
38
|
+
hit = get("how do I pause my subscription for a month?")
|
|
39
|
+
print("cache returned:", hit)
|
|
40
|
+
|
|
41
|
+
# When you later learn whether a served hit was right:
|
|
42
|
+
evaluator.report_feedback(
|
|
43
|
+
"how do I pause my subscription for a month?",
|
|
44
|
+
hit or "",
|
|
45
|
+
was_correct=False,
|
|
46
|
+
)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""CacheVerifier quickstart -- verify one gray-zone cache hit, then report
|
|
2
|
+
the outcome back. Runs against the live API.
|
|
3
|
+
|
|
4
|
+
pip install cacheverifier
|
|
5
|
+
CACHEVERIFIER_API_KEY=cv_... python quickstart.py
|
|
6
|
+
|
|
7
|
+
Get a free key at https://www.cacheverifier.com (self-serve verify and
|
|
8
|
+
fine-tuning are free forever).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
|
|
13
|
+
from cacheverifier import CacheVerifier
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def call_your_llm(query: str) -> str:
|
|
17
|
+
"""Stand-in for your own LLM call. CacheVerifier doesn't do this part --
|
|
18
|
+
your cache backend already has one."""
|
|
19
|
+
return "Go to Settings > Billing > Cancel subscription."
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def main() -> None:
|
|
23
|
+
api_key = os.environ["CACHEVERIFIER_API_KEY"]
|
|
24
|
+
|
|
25
|
+
# A gray-zone example: your cache's similarity search found a candidate
|
|
26
|
+
# close enough to look like a match, but "cancel" vs "pause" is a real
|
|
27
|
+
# difference a plain similarity threshold can't tell apart.
|
|
28
|
+
query = "how do I cancel my subscription"
|
|
29
|
+
candidate = "Go to Settings > Billing > Pause subscription for a month."
|
|
30
|
+
|
|
31
|
+
with CacheVerifier(api_key=api_key) as cv:
|
|
32
|
+
result = cv.verify(query, candidate)
|
|
33
|
+
print(f"verify -> approved={result.approved} score={result.score:.3f} "
|
|
34
|
+
f"threshold={result.threshold} model={result.model_version}")
|
|
35
|
+
|
|
36
|
+
if result.approved:
|
|
37
|
+
answer = candidate
|
|
38
|
+
print("approved: serving the cached answer, no LLM call made.")
|
|
39
|
+
else:
|
|
40
|
+
answer = call_your_llm(query)
|
|
41
|
+
print(f"rejected: fell through to your LLM -> {answer!r}")
|
|
42
|
+
|
|
43
|
+
# Once you know the real outcome (thumbs-down, reopened ticket,
|
|
44
|
+
# manual review), report it. This is what fine-tuning and drift
|
|
45
|
+
# monitoring learn from.
|
|
46
|
+
row_id = cv.feedback(query, answer, was_correct=True, similarity_score=0.86)
|
|
47
|
+
print(f"feedback recorded (row {row_id})")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
if __name__ == "__main__":
|
|
51
|
+
main()
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "cacheverifier"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Python client for the hosted CacheVerifier semantic-cache verification API"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Chengyou Xin" }]
|
|
13
|
+
keywords = [
|
|
14
|
+
"semantic-cache",
|
|
15
|
+
"semantic-cache-verification",
|
|
16
|
+
"llm",
|
|
17
|
+
"llm-caching",
|
|
18
|
+
"gptcache",
|
|
19
|
+
"cache-verification",
|
|
20
|
+
"cross-encoder",
|
|
21
|
+
]
|
|
22
|
+
classifiers = [
|
|
23
|
+
"Development Status :: 4 - Beta",
|
|
24
|
+
"Intended Audience :: Developers",
|
|
25
|
+
"License :: OSI Approved :: MIT License",
|
|
26
|
+
"Programming Language :: Python :: 3",
|
|
27
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
28
|
+
"Typing :: Typed",
|
|
29
|
+
]
|
|
30
|
+
dependencies = ["httpx>=0.24"]
|
|
31
|
+
|
|
32
|
+
[project.urls]
|
|
33
|
+
Homepage = "https://www.cacheverifier.com"
|
|
34
|
+
Documentation = "https://www.cacheverifier.com/docs"
|
|
35
|
+
Source = "https://github.com/imxinchengyou/cacheverifier-python"
|
|
36
|
+
Research = "https://github.com/imxinchengyou/CacheVerifier"
|
|
37
|
+
|
|
38
|
+
[project.optional-dependencies]
|
|
39
|
+
gptcache = ["gptcache>=0.1.30"]
|
|
40
|
+
dev = ["pytest>=7", "mypy>=1.5", "ruff>=0.4"]
|
|
41
|
+
|
|
42
|
+
[tool.hatch.build.targets.wheel]
|
|
43
|
+
packages = ["cacheverifier"]
|
|
44
|
+
|
|
45
|
+
[tool.ruff]
|
|
46
|
+
line-length = 110
|
|
47
|
+
target-version = "py39"
|
|
48
|
+
|
|
49
|
+
[tool.ruff.lint]
|
|
50
|
+
# PYI034 wants `Self` as the __enter__ return type; not available on the
|
|
51
|
+
# 3.9 floor without a typing_extensions dependency this client doesn't need.
|
|
52
|
+
ignore = ["PYI034"]
|
|
53
|
+
|
|
54
|
+
[tool.mypy]
|
|
55
|
+
python_version = "3.10"
|
|
56
|
+
disallow_untyped_defs = true
|
|
57
|
+
warn_unused_ignores = true
|
|
58
|
+
warn_redundant_casts = true
|
|
59
|
+
|
|
60
|
+
[[tool.mypy.overrides]]
|
|
61
|
+
module = "gptcache.*"
|
|
62
|
+
ignore_missing_imports = true
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Offline tests: every request is served by an httpx.MockTransport, so
|
|
2
|
+
nothing here touches the network."""
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from cacheverifier import CacheVerifier, CacheVerifierError, VerifyResult
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def make_client(handler):
|
|
13
|
+
return CacheVerifier(api_key="cv_test", transport=httpx.MockTransport(handler))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_verify_parses_result_and_sends_api_key():
|
|
17
|
+
seen = {}
|
|
18
|
+
|
|
19
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
20
|
+
seen["path"] = request.url.path
|
|
21
|
+
seen["api_key"] = request.headers.get("X-API-Key")
|
|
22
|
+
seen["body"] = json.loads(request.content)
|
|
23
|
+
return httpx.Response(
|
|
24
|
+
200,
|
|
25
|
+
json={
|
|
26
|
+
"approved": True,
|
|
27
|
+
"score": 3.5,
|
|
28
|
+
"latency_ms": 24.1,
|
|
29
|
+
"model_version": "stock",
|
|
30
|
+
"threshold": 0.0,
|
|
31
|
+
},
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
with make_client(handler) as cv:
|
|
35
|
+
result = cv.verify("how do I cancel", "Go to Settings > Billing.")
|
|
36
|
+
|
|
37
|
+
assert isinstance(result, VerifyResult)
|
|
38
|
+
assert result.approved is True
|
|
39
|
+
assert result.score == 3.5
|
|
40
|
+
assert result.threshold == 0.0
|
|
41
|
+
assert result.model_version == "stock"
|
|
42
|
+
assert seen["path"] == "/v1/verify"
|
|
43
|
+
assert seen["api_key"] == "cv_test"
|
|
44
|
+
assert seen["body"] == {"query": "how do I cancel", "candidate_answer": "Go to Settings > Billing."}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_verify_batch_returns_ordered_results():
|
|
48
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
49
|
+
items = json.loads(request.content)["items"]
|
|
50
|
+
assert request.url.path == "/v1/verify/batch"
|
|
51
|
+
return httpx.Response(
|
|
52
|
+
200,
|
|
53
|
+
json={
|
|
54
|
+
"results": [
|
|
55
|
+
{
|
|
56
|
+
"approved": i == 0,
|
|
57
|
+
"score": 5.0 - i,
|
|
58
|
+
"latency_ms": 10.0,
|
|
59
|
+
"model_version": "v7",
|
|
60
|
+
"threshold": 1.0,
|
|
61
|
+
}
|
|
62
|
+
for i, _ in enumerate(items)
|
|
63
|
+
]
|
|
64
|
+
},
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
with make_client(handler) as cv:
|
|
68
|
+
results = cv.verify_batch([("q1", "a1"), ("q2", "a2")])
|
|
69
|
+
|
|
70
|
+
assert [r.approved for r in results] == [True, False]
|
|
71
|
+
assert results[0].model_version == "v7"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_feedback_returns_id_and_forwards_idempotency_key():
|
|
75
|
+
seen = {}
|
|
76
|
+
|
|
77
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
78
|
+
seen["idem"] = request.headers.get("Idempotency-Key")
|
|
79
|
+
seen["body"] = json.loads(request.content)
|
|
80
|
+
return httpx.Response(200, json={"id": 42})
|
|
81
|
+
|
|
82
|
+
with make_client(handler) as cv:
|
|
83
|
+
row_id = cv.feedback(
|
|
84
|
+
"q", "a", was_correct=False, similarity_score=0.86, stale=True, idempotency_key="abc-123"
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
assert row_id == 42
|
|
88
|
+
assert seen["idem"] == "abc-123"
|
|
89
|
+
assert seen["body"] == {
|
|
90
|
+
"query": "q",
|
|
91
|
+
"candidate_answer": "a",
|
|
92
|
+
"was_correct": False,
|
|
93
|
+
"stale": True,
|
|
94
|
+
"similarity_score": 0.86,
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_feedback_omits_optional_fields_when_unset():
|
|
99
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
100
|
+
assert "similarity_score" not in json.loads(request.content)
|
|
101
|
+
assert request.headers.get("Idempotency-Key") is None
|
|
102
|
+
return httpx.Response(200, json={"id": 1})
|
|
103
|
+
|
|
104
|
+
with make_client(handler) as cv:
|
|
105
|
+
assert cv.feedback("q", "a", was_correct=True) == 1
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_finetune_passes_query_params():
|
|
109
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
110
|
+
assert request.url.path == "/v1/finetune/jobs"
|
|
111
|
+
assert dict(request.url.params) == {"target_risk": "0.01", "cost_ratio": "5.0"}
|
|
112
|
+
return httpx.Response(200, json={"id": 3, "status": "queued"})
|
|
113
|
+
|
|
114
|
+
with make_client(handler) as cv:
|
|
115
|
+
job = cv.finetune(target_risk=0.01, cost_ratio=5.0)
|
|
116
|
+
|
|
117
|
+
assert job["status"] == "queued"
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def test_error_response_raises_with_detail():
|
|
121
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
122
|
+
return httpx.Response(429, json={"detail": "free tier limit reached"})
|
|
123
|
+
|
|
124
|
+
with make_client(handler) as cv, pytest.raises(CacheVerifierError) as excinfo:
|
|
125
|
+
cv.verify("q", "a")
|
|
126
|
+
|
|
127
|
+
assert excinfo.value.status_code == 429
|
|
128
|
+
assert excinfo.value.detail == "free tier limit reached"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def test_error_response_without_json_body_falls_back_to_text():
|
|
132
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
133
|
+
return httpx.Response(502, text="Bad Gateway")
|
|
134
|
+
|
|
135
|
+
with make_client(handler) as cv, pytest.raises(CacheVerifierError) as excinfo:
|
|
136
|
+
cv.usage()
|
|
137
|
+
|
|
138
|
+
assert excinfo.value.status_code == 502
|
|
139
|
+
assert "Bad Gateway" in excinfo.value.detail
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def test_empty_api_key_rejected():
|
|
143
|
+
with pytest.raises(ValueError):
|
|
144
|
+
CacheVerifier(api_key="")
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""The GPTCache adapter must stay importable and usable even when gptcache
|
|
2
|
+
itself isn't installed (it's an optional dependency)."""
|
|
3
|
+
|
|
4
|
+
import httpx
|
|
5
|
+
|
|
6
|
+
from cacheverifier.integrations.gptcache import CacheVerifierEvaluation
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _eval(handler) -> CacheVerifierEvaluation:
|
|
10
|
+
ev = CacheVerifierEvaluation(api_key="cv_test")
|
|
11
|
+
ev._cv._client = httpx.Client( # swap in a mock transport
|
|
12
|
+
base_url="https://www.cacheverifier.com",
|
|
13
|
+
headers={"X-API-Key": "cv_test"},
|
|
14
|
+
transport=httpx.MockTransport(handler),
|
|
15
|
+
)
|
|
16
|
+
return ev
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_evaluation_maps_approved_to_binary_score():
|
|
20
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
21
|
+
assert request.url.path == "/v1/verify"
|
|
22
|
+
return httpx.Response(
|
|
23
|
+
200,
|
|
24
|
+
json={"approved": True, "score": 2.0, "latency_ms": 5.0, "model_version": "stock", "threshold": 0.0},
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
ev = _eval(handler)
|
|
28
|
+
assert ev.evaluation({"question": "q"}, {"answer": "a"}) == 1.0
|
|
29
|
+
assert ev.range() == (0.0, 1.0)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_evaluation_rejects_to_zero():
|
|
33
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
34
|
+
return httpx.Response(
|
|
35
|
+
200,
|
|
36
|
+
json={"approved": False, "score": -1.0, "latency_ms": 5.0, "model_version": "stock", "threshold": 0.0},
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
assert _eval(handler).evaluation({"query": "q"}, {"answer": "a"}) == 0.0
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_report_feedback_posts_similarity_score():
|
|
43
|
+
seen = {}
|
|
44
|
+
|
|
45
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
46
|
+
import json
|
|
47
|
+
|
|
48
|
+
seen["path"] = request.url.path
|
|
49
|
+
seen["body"] = json.loads(request.content)
|
|
50
|
+
return httpx.Response(200, json={"id": 9})
|
|
51
|
+
|
|
52
|
+
_eval(handler).report_feedback("q", "a", was_correct=False, similarity_score=0.9)
|
|
53
|
+
assert seen["path"] == "/v1/feedback"
|
|
54
|
+
assert seen["body"]["similarity_score"] == 0.9
|
|
55
|
+
assert seen["body"]["was_correct"] is False
|