cacheverifier 0.3.1__tar.gz → 0.4.1__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.3.1 → cacheverifier-0.4.1}/CHANGELOG.md +26 -0
- {cacheverifier-0.3.1 → cacheverifier-0.4.1}/PKG-INFO +30 -6
- {cacheverifier-0.3.1 → cacheverifier-0.4.1}/README.md +29 -5
- {cacheverifier-0.3.1 → cacheverifier-0.4.1}/cacheverifier/__init__.py +1 -1
- cacheverifier-0.4.1/cacheverifier/_healthcheck/_base_models.py +62 -0
- {cacheverifier-0.3.1 → cacheverifier-0.4.1}/cacheverifier/_healthcheck/_finetune.py +43 -9
- {cacheverifier-0.3.1 → cacheverifier-0.4.1}/cacheverifier/_healthcheck/cli.py +40 -1
- {cacheverifier-0.3.1 → cacheverifier-0.4.1}/cacheverifier/client.py +23 -14
- {cacheverifier-0.3.1 → cacheverifier-0.4.1}/pyproject.toml +1 -1
- {cacheverifier-0.3.1 → cacheverifier-0.4.1}/tests/test_client.py +25 -3
- {cacheverifier-0.3.1 → cacheverifier-0.4.1}/tests/test_healthcheck.py +80 -0
- {cacheverifier-0.3.1 → cacheverifier-0.4.1}/.github/workflows/ci.yml +0 -0
- {cacheverifier-0.3.1 → cacheverifier-0.4.1}/.github/workflows/publish.yml +0 -0
- {cacheverifier-0.3.1 → cacheverifier-0.4.1}/.gitignore +0 -0
- {cacheverifier-0.3.1 → cacheverifier-0.4.1}/LICENSE +0 -0
- {cacheverifier-0.3.1 → cacheverifier-0.4.1}/cacheverifier/__main__.py +0 -0
- {cacheverifier-0.3.1 → cacheverifier-0.4.1}/cacheverifier/_healthcheck/__init__.py +0 -0
- {cacheverifier-0.3.1 → cacheverifier-0.4.1}/cacheverifier/integrations/__init__.py +0 -0
- {cacheverifier-0.3.1 → cacheverifier-0.4.1}/cacheverifier/integrations/gptcache.py +0 -0
- {cacheverifier-0.3.1 → cacheverifier-0.4.1}/cacheverifier/py.typed +0 -0
- {cacheverifier-0.3.1 → cacheverifier-0.4.1}/examples/gptcache_example.py +0 -0
- {cacheverifier-0.3.1 → cacheverifier-0.4.1}/examples/quickstart.py +0 -0
- {cacheverifier-0.3.1 → cacheverifier-0.4.1}/tests/test_gptcache_integration.py +0 -0
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.4.1
|
|
4
|
+
|
|
5
|
+
`verify()` and `verify_batch()` now treat a **rate-limited 429** (one with a
|
|
6
|
+
`Retry-After` header) like a 5xx: they log a warning and return a `degraded`
|
|
7
|
+
result (`approved=False` by default, per `fail_open`) instead of raising
|
|
8
|
+
`CacheVerifierError`. A rate limit is transient load on your request path, so
|
|
9
|
+
it should fall through to your LLM like an outage rather than throw. A 429
|
|
10
|
+
*without* `Retry-After` means the monthly verify quota is used up and still
|
|
11
|
+
raises, as do other 4xx responses (bad key, bad request).
|
|
12
|
+
`CacheVerifierError` gains a `retry_after` attribute. Upgrade before the
|
|
13
|
+
hosted service switches its rate-limit response from 503 to the documented
|
|
14
|
+
429.
|
|
15
|
+
|
|
16
|
+
## 0.4.0
|
|
17
|
+
|
|
18
|
+
`cacheverifier healthcheck --base-model` picks the model the local Health
|
|
19
|
+
Check starts from: `ms_marco` (default, unchanged), `nli`, `multilingual` or
|
|
20
|
+
`multilingual_small` — the same choices as the hosted service's `base_model`
|
|
21
|
+
— or any Hugging Face model id / local model directory. Chinese and other
|
|
22
|
+
non-English traffic should use a multilingual base; the CLI now warns when
|
|
23
|
+
most of the input is CJK text and the base is English-only. The NLI base's
|
|
24
|
+
3-class head is scored `P(entailment) - P(contradiction)` for the stock
|
|
25
|
+
baseline and re-initialized to a single logit for fine-tuning, matching the
|
|
26
|
+
hosted service. `--emit-summary` records the base model (a local directory
|
|
27
|
+
is recorded only as "local directory").
|
|
28
|
+
|
|
3
29
|
## 0.3.1
|
|
4
30
|
|
|
5
31
|
`cacheverifier healthcheck` now actually fine-tunes. It passed no
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: cacheverifier
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.1
|
|
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
|
|
@@ -103,11 +103,13 @@ cv.feedback(query, answer, was_correct=True, similarity_score=0.86)
|
|
|
103
103
|
fine-tuning / feedback / monitoring calls. Warm verification is tens of
|
|
104
104
|
milliseconds server-side; 1s covers the network round trip and a cold model
|
|
105
105
|
load after a deploy without letting a stuck verifier stall your request.
|
|
106
|
-
- **Fails closed.** On a timeout, connection error,
|
|
107
|
-
raise — it logs a warning on
|
|
108
|
-
`VerifyResult` with `degraded=True`
|
|
109
|
-
to your LLM exactly as you would on
|
|
110
|
-
|
|
106
|
+
- **Fails closed.** On a timeout, connection error, 5xx, or a rate-limited 429
|
|
107
|
+
(one carrying `Retry-After`), `verify()` does not raise — it logs a warning on
|
|
108
|
+
the `cacheverifier` logger and returns a `VerifyResult` with `degraded=True`
|
|
109
|
+
and `approved=False`, so you fall through to your LLM exactly as you would on
|
|
110
|
+
a cache miss. Other `4xx` responses still raise `CacheVerifierError`: a bad
|
|
111
|
+
key, a bad request, or a 429 without `Retry-After`, which means your monthly
|
|
112
|
+
verify quota is used up.
|
|
111
113
|
|
|
112
114
|
```python
|
|
113
115
|
cv = CacheVerifier(api_key="cv_...", verify_timeout=1.0, fail_open=False) # the defaults
|
|
@@ -177,6 +179,28 @@ Nothing is sent anywhere — the base model downloads once from Hugging Face, th
|
|
|
177
179
|
fully offline. `--emit-summary` writes an aggregate-only JSON file (AUCs, counts, rates —
|
|
178
180
|
no query or answer text) that's safe to share for a human read.
|
|
179
181
|
|
|
182
|
+
**Choosing the base model.** `--base-model` takes the same values as the hosted
|
|
183
|
+
service's `base_model` fine-tune parameter, so local and hosted numbers stay comparable:
|
|
184
|
+
|
|
185
|
+
| `--base-model` | model | use for |
|
|
186
|
+
|---|---|---|
|
|
187
|
+
| `ms_marco` (default) | `cross-encoder/ms-marco-MiniLM-L6-v2` | English traffic |
|
|
188
|
+
| `nli` | `cross-encoder/nli-MiniLM2-L6-H768` | English; entailment pretraining, stronger on negation / entity swaps |
|
|
189
|
+
| `multilingual` | `BAAI/bge-reranker-base` | Chinese and other non-English traffic (~6 GB RAM to fine-tune) |
|
|
190
|
+
| `multilingual_small` | `cross-encoder/mmarco-mMiniLMv2-L12-H384-v1` | same, smaller (~3 GB RAM) |
|
|
191
|
+
|
|
192
|
+
The English bases read most Chinese characters as unknown tokens, so on Chinese
|
|
193
|
+
traffic their result doesn't mean anything — the CLI warns when it sees mostly CJK text
|
|
194
|
+
on an English base. You can also pass any Hugging Face model id or a local model
|
|
195
|
+
directory. If Hugging Face is unreachable from your network, set `HF_ENDPOINT` to a
|
|
196
|
+
mirror, or download the model elsewhere and pass its directory:
|
|
197
|
+
|
|
198
|
+
```bash
|
|
199
|
+
cacheverifier healthcheck traffic.jsonl --base-model multilingual
|
|
200
|
+
HF_ENDPOINT=https://hf-mirror.com cacheverifier healthcheck traffic.jsonl --base-model multilingual_small
|
|
201
|
+
cacheverifier healthcheck traffic.jsonl --base-model ./models/bge-reranker-base
|
|
202
|
+
```
|
|
203
|
+
|
|
180
204
|
```
|
|
181
205
|
results
|
|
182
206
|
------------------------------------------------------------------
|
|
@@ -71,11 +71,13 @@ cv.feedback(query, answer, was_correct=True, similarity_score=0.86)
|
|
|
71
71
|
fine-tuning / feedback / monitoring calls. Warm verification is tens of
|
|
72
72
|
milliseconds server-side; 1s covers the network round trip and a cold model
|
|
73
73
|
load after a deploy without letting a stuck verifier stall your request.
|
|
74
|
-
- **Fails closed.** On a timeout, connection error,
|
|
75
|
-
raise — it logs a warning on
|
|
76
|
-
`VerifyResult` with `degraded=True`
|
|
77
|
-
to your LLM exactly as you would on
|
|
78
|
-
|
|
74
|
+
- **Fails closed.** On a timeout, connection error, 5xx, or a rate-limited 429
|
|
75
|
+
(one carrying `Retry-After`), `verify()` does not raise — it logs a warning on
|
|
76
|
+
the `cacheverifier` logger and returns a `VerifyResult` with `degraded=True`
|
|
77
|
+
and `approved=False`, so you fall through to your LLM exactly as you would on
|
|
78
|
+
a cache miss. Other `4xx` responses still raise `CacheVerifierError`: a bad
|
|
79
|
+
key, a bad request, or a 429 without `Retry-After`, which means your monthly
|
|
80
|
+
verify quota is used up.
|
|
79
81
|
|
|
80
82
|
```python
|
|
81
83
|
cv = CacheVerifier(api_key="cv_...", verify_timeout=1.0, fail_open=False) # the defaults
|
|
@@ -145,6 +147,28 @@ Nothing is sent anywhere — the base model downloads once from Hugging Face, th
|
|
|
145
147
|
fully offline. `--emit-summary` writes an aggregate-only JSON file (AUCs, counts, rates —
|
|
146
148
|
no query or answer text) that's safe to share for a human read.
|
|
147
149
|
|
|
150
|
+
**Choosing the base model.** `--base-model` takes the same values as the hosted
|
|
151
|
+
service's `base_model` fine-tune parameter, so local and hosted numbers stay comparable:
|
|
152
|
+
|
|
153
|
+
| `--base-model` | model | use for |
|
|
154
|
+
|---|---|---|
|
|
155
|
+
| `ms_marco` (default) | `cross-encoder/ms-marco-MiniLM-L6-v2` | English traffic |
|
|
156
|
+
| `nli` | `cross-encoder/nli-MiniLM2-L6-H768` | English; entailment pretraining, stronger on negation / entity swaps |
|
|
157
|
+
| `multilingual` | `BAAI/bge-reranker-base` | Chinese and other non-English traffic (~6 GB RAM to fine-tune) |
|
|
158
|
+
| `multilingual_small` | `cross-encoder/mmarco-mMiniLMv2-L12-H384-v1` | same, smaller (~3 GB RAM) |
|
|
159
|
+
|
|
160
|
+
The English bases read most Chinese characters as unknown tokens, so on Chinese
|
|
161
|
+
traffic their result doesn't mean anything — the CLI warns when it sees mostly CJK text
|
|
162
|
+
on an English base. You can also pass any Hugging Face model id or a local model
|
|
163
|
+
directory. If Hugging Face is unreachable from your network, set `HF_ENDPOINT` to a
|
|
164
|
+
mirror, or download the model elsewhere and pass its directory:
|
|
165
|
+
|
|
166
|
+
```bash
|
|
167
|
+
cacheverifier healthcheck traffic.jsonl --base-model multilingual
|
|
168
|
+
HF_ENDPOINT=https://hf-mirror.com cacheverifier healthcheck traffic.jsonl --base-model multilingual_small
|
|
169
|
+
cacheverifier healthcheck traffic.jsonl --base-model ./models/bge-reranker-base
|
|
170
|
+
```
|
|
171
|
+
|
|
148
172
|
```
|
|
149
173
|
results
|
|
150
174
|
------------------------------------------------------------------
|
|
@@ -18,5 +18,5 @@ Research behind it: https://github.com/imxinchengyou/CacheVerifier
|
|
|
18
18
|
|
|
19
19
|
from cacheverifier.client import CacheVerifier, CacheVerifierError, VerifyResult
|
|
20
20
|
|
|
21
|
-
__version__ = "0.
|
|
21
|
+
__version__ = "0.4.1"
|
|
22
22
|
__all__ = ["CacheVerifier", "CacheVerifierError", "VerifyResult", "__version__"]
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Base-model choices for `cacheverifier healthcheck --base-model`.
|
|
2
|
+
|
|
3
|
+
Kept free of torch / sentence-transformers so `cacheverifier healthcheck
|
|
4
|
+
--help` can list the choices without the `healthcheck` extra installed.
|
|
5
|
+
The aliases and model ids match the hosted service's `base_model` fine-tune
|
|
6
|
+
parameter, so a local run is comparable to `POST /v1/finetune/dry-run`
|
|
7
|
+
with the same value.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
DEFAULT_BASE_MODEL = "cross-encoder/ms-marco-MiniLM-L6-v2"
|
|
16
|
+
"""The stock off-the-shelf verifier every tenant starts on -- the same base
|
|
17
|
+
model the hosted service fine-tunes from. English only."""
|
|
18
|
+
|
|
19
|
+
BASE_MODEL_CHOICES: dict[str, str] = {
|
|
20
|
+
"ms_marco": DEFAULT_BASE_MODEL,
|
|
21
|
+
# SNLI+MultiNLI entailment pretraining, same size as ms_marco. Ships a
|
|
22
|
+
# 3-class head; see _finetune for how it is scored and fine-tuned.
|
|
23
|
+
"nli": "cross-encoder/nli-MiniLM2-L6-H768",
|
|
24
|
+
# Non-English (e.g. Chinese) traffic: the English bases turn most
|
|
25
|
+
# Chinese characters into [UNK] at tokenization.
|
|
26
|
+
"multilingual": "BAAI/bge-reranker-base",
|
|
27
|
+
"multilingual_small": "cross-encoder/mmarco-mMiniLMv2-L12-H384-v1",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
ENGLISH_ONLY_BASE_MODELS = frozenset({BASE_MODEL_CHOICES["ms_marco"], BASE_MODEL_CHOICES["nli"]})
|
|
31
|
+
|
|
32
|
+
_CJK_RE = re.compile(r"[-ヿ㐀-䶿一-鿿가-]")
|
|
33
|
+
|
|
34
|
+
CJK_WARNING_RATIO = 0.3
|
|
35
|
+
"""Share of non-whitespace characters that are CJK above which an
|
|
36
|
+
English-only base model gets a warning."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def resolve_base_model(value: str) -> str:
|
|
40
|
+
"""An alias from `BASE_MODEL_CHOICES`, a Hugging Face model id
|
|
41
|
+
("org/name"), or a local model directory (e.g. one downloaded from a
|
|
42
|
+
mirror or ModelScope). Raises ValueError for anything else, so a typo'd
|
|
43
|
+
alias fails immediately instead of after a failed download."""
|
|
44
|
+
if value in BASE_MODEL_CHOICES:
|
|
45
|
+
return BASE_MODEL_CHOICES[value]
|
|
46
|
+
# `Path("")` is the current directory -- never treat an empty value as
|
|
47
|
+
# a local model.
|
|
48
|
+
if value.strip() and Path(value).expanduser().is_dir():
|
|
49
|
+
return str(Path(value).expanduser())
|
|
50
|
+
if "/" in value and not value.startswith("/"):
|
|
51
|
+
return value
|
|
52
|
+
raise ValueError(
|
|
53
|
+
f"unknown base model {value!r}: use one of {', '.join(BASE_MODEL_CHOICES)}, "
|
|
54
|
+
"a Hugging Face model id (org/name), or a local model directory"
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def cjk_ratio(texts: list[str]) -> float:
|
|
59
|
+
chars = "".join("".join(t.split()) for t in texts)
|
|
60
|
+
if not chars:
|
|
61
|
+
return 0.0
|
|
62
|
+
return len(_CJK_RE.findall(chars)) / len(chars)
|
|
@@ -24,15 +24,17 @@ import torch
|
|
|
24
24
|
from sentence_transformers import CrossEncoder, InputExample
|
|
25
25
|
from torch.utils.data import DataLoader
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
from cacheverifier._healthcheck._base_models import DEFAULT_BASE_MODEL
|
|
28
|
+
|
|
29
|
+
# The four third-party imports above are from the `healthcheck` extra. This module is
|
|
28
30
|
# only ever imported from inside `cacheverifier._healthcheck.cli.run()`,
|
|
29
31
|
# which catches the ImportError and points the user at
|
|
30
32
|
# `pip install "cacheverifier[healthcheck]"` -- so a plain
|
|
31
33
|
# `import cacheverifier` / `cacheverifier --help` never pays this cost.
|
|
32
34
|
|
|
33
|
-
|
|
34
|
-
"""
|
|
35
|
-
|
|
35
|
+
NLI_LABEL_ORDER = ("contradiction", "entailment", "neutral")
|
|
36
|
+
"""`cross-encoder/nli-*` models' class order, used when the loaded model
|
|
37
|
+
doesn't expose an `id2label` naming them."""
|
|
36
38
|
|
|
37
39
|
MAX_SEQUENCE_LENGTH = 128
|
|
38
40
|
"""Token cap applied identically at train and score time. Matches the
|
|
@@ -185,6 +187,28 @@ def _diagnose_ceiling(scores: np.ndarray, labels: np.ndarray) -> str | None:
|
|
|
185
187
|
return "still_improvable"
|
|
186
188
|
|
|
187
189
|
|
|
190
|
+
def _nli_column_indices(model: Any) -> tuple[int, int]:
|
|
191
|
+
"""(entailment_idx, contradiction_idx) for a 3-class NLI cross-encoder."""
|
|
192
|
+
id2label = getattr(getattr(model, "config", None), "id2label", None)
|
|
193
|
+
if isinstance(id2label, dict) and len(id2label) == 3:
|
|
194
|
+
lut = {str(v).lower(): int(k) for k, v in id2label.items()}
|
|
195
|
+
if "entailment" in lut and "contradiction" in lut:
|
|
196
|
+
return lut["entailment"], lut["contradiction"]
|
|
197
|
+
return NLI_LABEL_ORDER.index("entailment"), NLI_LABEL_ORDER.index("contradiction")
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def score_pairs(model: Any, pairs: list[tuple[str, str]], batch_size: int = 32) -> np.ndarray:
|
|
201
|
+
"""Higher means "more likely an acceptable answer". A 1-logit model
|
|
202
|
+
(ms-marco, the multilingual rerankers, anything fine-tuned here) returns
|
|
203
|
+
its raw logit; a 3-class NLI model returns P(entailment) - P(contradiction)
|
|
204
|
+
-- the same scoring the hosted service uses for the stock NLI baseline."""
|
|
205
|
+
if int(model.config.num_labels) == 3:
|
|
206
|
+
ent, contra = _nli_column_indices(model)
|
|
207
|
+
probs = np.asarray(model.predict(pairs, batch_size=batch_size, show_progress_bar=False, apply_softmax=True))
|
|
208
|
+
return probs[:, ent] - probs[:, contra]
|
|
209
|
+
return np.asarray(model.predict(pairs, batch_size=batch_size, show_progress_bar=False))
|
|
210
|
+
|
|
211
|
+
|
|
188
212
|
def run_healthcheck(
|
|
189
213
|
examples: list[GrayZoneExample],
|
|
190
214
|
output_dir: str,
|
|
@@ -221,11 +245,21 @@ def run_healthcheck(
|
|
|
221
245
|
|
|
222
246
|
baseline = CrossEncoder(base_model, device=device, max_length=MAX_SEQUENCE_LENGTH)
|
|
223
247
|
test_pairs_scored = [smart_truncate_pair(q, a, baseline.tokenizer) for q, a in test_pairs]
|
|
224
|
-
baseline_scores =
|
|
248
|
+
baseline_scores = score_pairs(baseline, test_pairs_scored)
|
|
225
249
|
auc_baseline = roc_auc(baseline_scores, test_labels)
|
|
226
250
|
del baseline
|
|
227
251
|
|
|
228
|
-
|
|
252
|
+
# num_labels=1 forces a single-logit head, matching the hosted service: a
|
|
253
|
+
# no-op for 1-logit bases, a deliberate head reinitialization (encoder
|
|
254
|
+
# body kept) for the 3-class NLI base. ignore_mismatched_sizes lets that
|
|
255
|
+
# 3->1 reinit happen instead of raising.
|
|
256
|
+
tuned = CrossEncoder(
|
|
257
|
+
base_model,
|
|
258
|
+
num_labels=1,
|
|
259
|
+
device=device,
|
|
260
|
+
max_length=MAX_SEQUENCE_LENGTH,
|
|
261
|
+
automodel_args={"ignore_mismatched_sizes": True},
|
|
262
|
+
)
|
|
229
263
|
train_examples = [
|
|
230
264
|
InputExample(
|
|
231
265
|
texts=list(smart_truncate_pair(e.query, e.candidate_answer, tuned.tokenizer)),
|
|
@@ -246,10 +280,10 @@ def run_healthcheck(
|
|
|
246
280
|
tuned.save(output_dir)
|
|
247
281
|
|
|
248
282
|
calib_scored = [smart_truncate_pair(q, a, tuned.tokenizer) for q, a in calib_pairs]
|
|
249
|
-
calib_scores =
|
|
283
|
+
calib_scores = score_pairs(tuned, calib_scored)
|
|
250
284
|
threshold = select_threshold(calib_scores, calib_labels)
|
|
251
285
|
|
|
252
|
-
tuned_scores =
|
|
286
|
+
tuned_scores = score_pairs(tuned, test_pairs_scored)
|
|
253
287
|
auc_tuned = roc_auc(tuned_scores, test_labels)
|
|
254
288
|
ceiling_status = _diagnose_ceiling(tuned_scores, test_labels)
|
|
255
289
|
|
|
@@ -259,7 +293,7 @@ def run_healthcheck(
|
|
|
259
293
|
if threshold is not None:
|
|
260
294
|
threshold_hit_rate, threshold_error_rate = _hit_and_error_rate(tuned_scores, test_labels, threshold)
|
|
261
295
|
train_pairs_scored = [smart_truncate_pair(e.query, e.candidate_answer, tuned.tokenizer) for e in train_rows]
|
|
262
|
-
train_scores =
|
|
296
|
+
train_scores = score_pairs(tuned, train_pairs_scored)
|
|
263
297
|
train_labels = np.array([1 if e.was_correct else 0 for e in train_rows])
|
|
264
298
|
train_pred = (train_scores >= threshold).astype(int)
|
|
265
299
|
train_label_disagreement_rate = float(np.mean(train_pred != train_labels))
|
|
@@ -13,6 +13,14 @@ import sys
|
|
|
13
13
|
from pathlib import Path
|
|
14
14
|
from typing import Any
|
|
15
15
|
|
|
16
|
+
from cacheverifier._healthcheck._base_models import (
|
|
17
|
+
BASE_MODEL_CHOICES,
|
|
18
|
+
CJK_WARNING_RATIO,
|
|
19
|
+
ENGLISH_ONLY_BASE_MODELS,
|
|
20
|
+
cjk_ratio,
|
|
21
|
+
resolve_base_model,
|
|
22
|
+
)
|
|
23
|
+
|
|
16
24
|
_MISSING_EXTRA_HINT = (
|
|
17
25
|
"the healthcheck extra is not installed -- run:\n"
|
|
18
26
|
' pip install "cacheverifier[healthcheck]"\n'
|
|
@@ -51,6 +59,17 @@ def add_subparser(subparsers: argparse._SubParsersAction) -> None:
|
|
|
51
59
|
default=3,
|
|
52
60
|
help="fine-tuning epochs (default: 3, matching the hosted service)",
|
|
53
61
|
)
|
|
62
|
+
p.add_argument(
|
|
63
|
+
"--base-model",
|
|
64
|
+
default="ms_marco",
|
|
65
|
+
metavar="MODEL",
|
|
66
|
+
help=(
|
|
67
|
+
f"model to start from: {', '.join(BASE_MODEL_CHOICES)} (same choices as the hosted "
|
|
68
|
+
"service's base_model), a Hugging Face model id, or a local model directory. "
|
|
69
|
+
"Default ms_marco is English-only -- use multilingual or multilingual_small for "
|
|
70
|
+
"Chinese or other non-English traffic (they need several GB of RAM to fine-tune)"
|
|
71
|
+
),
|
|
72
|
+
)
|
|
54
73
|
p.add_argument(
|
|
55
74
|
"--keep-model",
|
|
56
75
|
action="store_true",
|
|
@@ -125,6 +144,12 @@ def run(args: argparse.Namespace) -> int:
|
|
|
125
144
|
print(f"error: {e}", file=sys.stderr)
|
|
126
145
|
return 2
|
|
127
146
|
|
|
147
|
+
try:
|
|
148
|
+
base_model = resolve_base_model(args.base_model)
|
|
149
|
+
except ValueError as e:
|
|
150
|
+
print(f"error: {e}", file=sys.stderr)
|
|
151
|
+
return 2
|
|
152
|
+
|
|
128
153
|
n = len(examples)
|
|
129
154
|
print(f"\nCacheVerifier local Health Check -- {input_path}")
|
|
130
155
|
print("=" * 66)
|
|
@@ -133,6 +158,17 @@ def run(args: argparse.Namespace) -> int:
|
|
|
133
158
|
if n_stale:
|
|
134
159
|
print(f"stale, excluded: {n_stale}")
|
|
135
160
|
|
|
161
|
+
print(f"base model: {base_model}")
|
|
162
|
+
if base_model in ENGLISH_ONLY_BASE_MODELS:
|
|
163
|
+
ratio = cjk_ratio([t for e in examples for t in (e.query, e.candidate_answer)])
|
|
164
|
+
if ratio > CJK_WARNING_RATIO:
|
|
165
|
+
print(
|
|
166
|
+
f"\nWarning: {ratio:.0%} of your text is Chinese/Japanese/Korean, but {base_model} is an "
|
|
167
|
+
"English-only model\n (it reads most CJK characters as unknown tokens), so this "
|
|
168
|
+
"result won't reflect your\n traffic. Re-run with --base-model multilingual "
|
|
169
|
+
"(or multilingual_small)."
|
|
170
|
+
)
|
|
171
|
+
|
|
136
172
|
if n < _finetune.MIN_TRAIN_EXAMPLES:
|
|
137
173
|
print(f"\nNeed at least {_finetune.MIN_TRAIN_EXAMPLES} non-stale rows to run; have {n}.", file=sys.stderr)
|
|
138
174
|
return 2
|
|
@@ -149,7 +185,7 @@ def run(args: argparse.Namespace) -> int:
|
|
|
149
185
|
print(f"\nfine-tuning locally (nothing sent) -> {out_dir}")
|
|
150
186
|
print("this takes a few minutes on CPU; the base model downloads once on first run...\n")
|
|
151
187
|
|
|
152
|
-
result = _finetune.run_healthcheck(examples, out_dir, base_model=
|
|
188
|
+
result = _finetune.run_healthcheck(examples, out_dir, base_model=base_model, epochs=args.epochs)
|
|
153
189
|
|
|
154
190
|
print("results")
|
|
155
191
|
print("-" * 66)
|
|
@@ -175,6 +211,9 @@ def run(args: argparse.Namespace) -> int:
|
|
|
175
211
|
|
|
176
212
|
if args.emit_summary is not None:
|
|
177
213
|
summary = {
|
|
214
|
+
# A local directory's path can carry a username -- this file is
|
|
215
|
+
# meant to be shareable, so record only that it was local.
|
|
216
|
+
"base_model": "local directory" if Path(base_model).is_dir() else base_model,
|
|
178
217
|
"n_rows_read": len(rows),
|
|
179
218
|
"n_usable": n,
|
|
180
219
|
"n_stale_excluded": n_stale,
|
|
@@ -46,13 +46,15 @@ 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
|
|
56
58
|
|
|
57
59
|
|
|
58
60
|
@dataclass(frozen=True)
|
|
@@ -69,8 +71,8 @@ class VerifyResult:
|
|
|
69
71
|
fallback result (see `degraded`).
|
|
70
72
|
- `latency_ms`: server-side model inference time, not round-trip time.
|
|
71
73
|
- `degraded`: True when the verify call failed (timeout / connection error /
|
|
72
|
-
5xx) and this result was synthesized by the client
|
|
73
|
-
the API. `approved` then reflects the client's `fail_open` setting
|
|
74
|
+
5xx / rate-limited 429) and this result was synthesized by the client
|
|
75
|
+
rather than returned by the API. `approved` then reflects the client's `fail_open` setting
|
|
74
76
|
(default `False` -> `approved=False`, i.e. fall through to your LLM).
|
|
75
77
|
"""
|
|
76
78
|
|
|
@@ -112,12 +114,14 @@ class CacheVerifier:
|
|
|
112
114
|
|
|
113
115
|
- `verify_timeout` (default 1s, vs 10s for everything else) bounds how long
|
|
114
116
|
a slow or overloaded verifier can stall your request.
|
|
115
|
-
- On a timeout, connection error,
|
|
116
|
-
|
|
117
|
-
`degraded=True`.
|
|
118
|
-
`
|
|
119
|
-
it `approved=True` so
|
|
120
|
-
|
|
117
|
+
- On a timeout, connection error, 5xx, or a rate-limited 429 (one with a
|
|
118
|
+
`Retry-After` header), `verify()` does not raise -- it logs a warning and
|
|
119
|
+
returns a synthetic `VerifyResult` with `degraded=True`.
|
|
120
|
+
`fail_open=False` (the default) makes that result `approved=False` so you
|
|
121
|
+
fall through to your LLM; `fail_open=True` makes it `approved=True` so
|
|
122
|
+
you serve the cached answer anyway. Other 4xx responses still raise
|
|
123
|
+
`CacheVerifierError`: a bad key, a bad request, or a 429 without
|
|
124
|
+
`Retry-After`, which means your monthly verify quota is used up.
|
|
121
125
|
"""
|
|
122
126
|
|
|
123
127
|
def __init__(
|
|
@@ -157,7 +161,7 @@ class CacheVerifier:
|
|
|
157
161
|
def verify(self, query: str, candidate_answer: str) -> VerifyResult:
|
|
158
162
|
"""Approve or reject one gray-zone cache hit. `POST /v1/verify`.
|
|
159
163
|
|
|
160
|
-
On a timeout / connection error / 5xx this returns a synthetic
|
|
164
|
+
On a timeout / connection error / 5xx / rate-limited 429 this returns a synthetic
|
|
161
165
|
`VerifyResult` (`degraded=True`, `approved` per `fail_open`) instead of
|
|
162
166
|
raising -- the verifier being unreachable should degrade to a normal
|
|
163
167
|
cache miss, not an exception on your request path.
|
|
@@ -189,14 +193,19 @@ class CacheVerifier:
|
|
|
189
193
|
def _verify_call(self, path: str, payload: dict[str, Any]) -> Any | None:
|
|
190
194
|
"""POST a verify request with the request-path timeout. Returns the
|
|
191
195
|
parsed body, or None to signal "unavailable, fall back" on a
|
|
192
|
-
timeout / connection error / 5xx
|
|
196
|
+
timeout / connection error / 5xx / 429 with `Retry-After`. Other 4xx
|
|
197
|
+
still raises: a bad key or request is a bug to fix, and a 429 without
|
|
198
|
+
`Retry-After` is the monthly quota running out, which needs a decision
|
|
199
|
+
(top up or subscribe), not a silent month of fallbacks. A rate limit is
|
|
200
|
+
transient load and should degrade like an outage instead.
|
|
193
201
|
"""
|
|
194
202
|
try:
|
|
195
203
|
return self._post(path, json=payload, timeout=self._verify_timeout)
|
|
196
204
|
except httpx.TransportError as exc:
|
|
197
205
|
self._warn_degraded(path, exc)
|
|
198
206
|
except CacheVerifierError as exc:
|
|
199
|
-
|
|
207
|
+
rate_limited = exc.status_code == 429 and exc.retry_after is not None
|
|
208
|
+
if exc.status_code < 500 and not rate_limited:
|
|
200
209
|
raise
|
|
201
210
|
self._warn_degraded(path, exc)
|
|
202
211
|
return None
|
|
@@ -365,7 +374,7 @@ class CacheVerifier:
|
|
|
365
374
|
detail = body["detail"] if isinstance(body, dict) and "detail" in body else resp.text
|
|
366
375
|
except ValueError:
|
|
367
376
|
detail = resp.text
|
|
368
|
-
raise CacheVerifierError(resp.status_code, str(detail))
|
|
377
|
+
raise CacheVerifierError(resp.status_code, str(detail), resp.headers.get("retry-after"))
|
|
369
378
|
|
|
370
379
|
|
|
371
380
|
def _drop_none(**kwargs: Any) -> dict[str, Any]:
|
|
@@ -135,6 +135,7 @@ def test_error_response_raises_with_detail():
|
|
|
135
135
|
|
|
136
136
|
assert excinfo.value.status_code == 429
|
|
137
137
|
assert excinfo.value.detail == "free tier limit reached"
|
|
138
|
+
assert excinfo.value.retry_after is None
|
|
138
139
|
|
|
139
140
|
|
|
140
141
|
def test_error_response_without_json_body_falls_back_to_text():
|
|
@@ -204,14 +205,35 @@ def test_verify_fails_closed_on_5xx():
|
|
|
204
205
|
assert result.approved is False
|
|
205
206
|
|
|
206
207
|
|
|
207
|
-
def
|
|
208
|
+
def test_verify_fails_closed_on_rate_limit_429():
|
|
208
209
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
209
|
-
return httpx.Response(429, json={"detail": "rate limited"})
|
|
210
|
+
return httpx.Response(429, headers={"Retry-After": "1"}, json={"detail": "rate limited"})
|
|
211
|
+
|
|
212
|
+
with make_client(handler) as cv:
|
|
213
|
+
result = cv.verify("q", "a")
|
|
214
|
+
|
|
215
|
+
assert result.degraded is True
|
|
216
|
+
assert result.approved is False
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def test_verify_batch_degrades_on_rate_limit_429():
|
|
220
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
221
|
+
return httpx.Response(429, headers={"Retry-After": "1"}, json={"detail": "rate limited"})
|
|
222
|
+
|
|
223
|
+
with make_client(handler) as cv:
|
|
224
|
+
results = cv.verify_batch([("q1", "a1"), ("q2", "a2")])
|
|
225
|
+
|
|
226
|
+
assert all(r.degraded and not r.approved for r in results)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def test_verify_still_raises_on_other_4xx():
|
|
230
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
231
|
+
return httpx.Response(401, json={"detail": "invalid API key"})
|
|
210
232
|
|
|
211
233
|
with make_client(handler) as cv, pytest.raises(CacheVerifierError) as excinfo:
|
|
212
234
|
cv.verify("q", "a")
|
|
213
235
|
|
|
214
|
-
assert excinfo.value.status_code ==
|
|
236
|
+
assert excinfo.value.status_code == 401
|
|
215
237
|
|
|
216
238
|
|
|
217
239
|
def test_verify_batch_degrades_every_pair_on_failure():
|
|
@@ -123,3 +123,83 @@ def test_run_healthcheck_warms_up_over_a_tenth_of_its_steps(tmp_path, monkeypatc
|
|
|
123
123
|
assert seen["epochs"] == _finetune.TRAIN_EPOCHS == 3
|
|
124
124
|
expected = max(1, int(_finetune.TRAIN_WARMUP_FRACTION * seen["steps_per_epoch"] * seen["epochs"]))
|
|
125
125
|
assert seen["warmup_steps"] == expected
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class TestBaseModel:
|
|
129
|
+
def test_aliases_match_the_hosted_service(self):
|
|
130
|
+
from cacheverifier._healthcheck._base_models import BASE_MODEL_CHOICES, resolve_base_model
|
|
131
|
+
|
|
132
|
+
assert set(BASE_MODEL_CHOICES) == {"ms_marco", "nli", "multilingual", "multilingual_small"}
|
|
133
|
+
assert resolve_base_model("ms_marco") == "cross-encoder/ms-marco-MiniLM-L6-v2"
|
|
134
|
+
assert resolve_base_model("multilingual") == "BAAI/bge-reranker-base"
|
|
135
|
+
|
|
136
|
+
def test_hf_id_and_local_dir_pass_through(self, tmp_path):
|
|
137
|
+
from cacheverifier._healthcheck._base_models import resolve_base_model
|
|
138
|
+
|
|
139
|
+
assert resolve_base_model("someorg/some-reranker") == "someorg/some-reranker"
|
|
140
|
+
assert resolve_base_model(str(tmp_path)) == str(tmp_path)
|
|
141
|
+
|
|
142
|
+
@pytest.mark.parametrize("bad", ["multilingal", "/no/such/model/dir", ""])
|
|
143
|
+
def test_typos_and_missing_paths_are_rejected(self, bad):
|
|
144
|
+
from cacheverifier._healthcheck._base_models import resolve_base_model
|
|
145
|
+
|
|
146
|
+
with pytest.raises(ValueError, match="unknown base model"):
|
|
147
|
+
resolve_base_model(bad)
|
|
148
|
+
|
|
149
|
+
def test_cjk_ratio(self):
|
|
150
|
+
from cacheverifier._healthcheck._base_models import cjk_ratio
|
|
151
|
+
|
|
152
|
+
assert cjk_ratio(["how do I cancel"]) == 0.0
|
|
153
|
+
assert cjk_ratio(["怎么取消订阅"]) == 1.0
|
|
154
|
+
assert cjk_ratio(["怎么取消 subscription"]) == 4 / 16
|
|
155
|
+
assert cjk_ratio([]) == 0.0
|
|
156
|
+
|
|
157
|
+
def test_help_lists_base_model_choices(self, capsys):
|
|
158
|
+
with pytest.raises(SystemExit):
|
|
159
|
+
main(["healthcheck", "--help"])
|
|
160
|
+
out = capsys.readouterr().out
|
|
161
|
+
assert "--base-model" in out and "multilingual_small" in out
|
|
162
|
+
|
|
163
|
+
@pytest.mark.skipif(not HAS_TORCH, reason="run() imports the healthcheck extra first")
|
|
164
|
+
def test_unknown_base_model_is_an_input_error(self, capsys, tmp_path):
|
|
165
|
+
f = tmp_path / "t.jsonl"
|
|
166
|
+
f.write_text(json.dumps({"query": "q", "candidate_answer": "a", "was_correct": True}) + "\n")
|
|
167
|
+
assert main(["healthcheck", str(f), "--base-model", "multilingal"]) == 2
|
|
168
|
+
assert "unknown base model" in capsys.readouterr().err
|
|
169
|
+
|
|
170
|
+
@pytest.mark.skipif(not HAS_TORCH, reason="run() imports the healthcheck extra first")
|
|
171
|
+
def test_chinese_data_on_english_model_warns(self, capsys, tmp_path):
|
|
172
|
+
# Below MIN_TRAIN_EXAMPLES, so run() stops before any download/training.
|
|
173
|
+
f = tmp_path / "t.jsonl"
|
|
174
|
+
f.write_text(
|
|
175
|
+
"\n".join(
|
|
176
|
+
json.dumps({"query": "怎么取消自动续费", "candidate_answer": "进入设置取消", "was_correct": True}, ensure_ascii=False)
|
|
177
|
+
for _ in range(3)
|
|
178
|
+
)
|
|
179
|
+
)
|
|
180
|
+
assert main(["healthcheck", str(f)]) == 2
|
|
181
|
+
out = capsys.readouterr().out
|
|
182
|
+
assert "English-only" in out and "--base-model multilingual" in out
|
|
183
|
+
|
|
184
|
+
assert main(["healthcheck", str(f), "--base-model", "multilingual_small"]) == 2
|
|
185
|
+
assert "English-only" not in capsys.readouterr().out
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@pytest.mark.skipif(not HAS_TORCH, reason="needs the healthcheck extra (torch, sentence-transformers)")
|
|
189
|
+
def test_run_healthcheck_nli_base_reinitializes_to_single_logit(tmp_path):
|
|
190
|
+
"""The NLI base ships a 3-class head: the stock baseline is scored
|
|
191
|
+
P(entailment) - P(contradiction), and fine-tuning reinitializes it to the
|
|
192
|
+
same 1-logit head as every other base (matching the hosted service)."""
|
|
193
|
+
from sentence_transformers import CrossEncoder
|
|
194
|
+
|
|
195
|
+
from cacheverifier._healthcheck._base_models import BASE_MODEL_CHOICES
|
|
196
|
+
from cacheverifier._healthcheck._finetune import GrayZoneExample, run_healthcheck
|
|
197
|
+
|
|
198
|
+
examples = [
|
|
199
|
+
GrayZoneExample("how do I cancel my plan", "Settings > Billing > Cancel." if i % 2 == 0 else "Settings > Pause.", i % 2 == 0)
|
|
200
|
+
for i in range(24)
|
|
201
|
+
]
|
|
202
|
+
result = run_healthcheck(examples, str(tmp_path / "model"), base_model=BASE_MODEL_CHOICES["nli"], epochs=1)
|
|
203
|
+
assert 0.0 <= result.auc_baseline <= 1.0
|
|
204
|
+
assert 0.0 <= result.auc_tuned <= 1.0
|
|
205
|
+
assert CrossEncoder(str(tmp_path / "model")).config.num_labels == 1
|
|
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
|