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.
Files changed (23) hide show
  1. {cacheverifier-0.3.1 → cacheverifier-0.4.1}/CHANGELOG.md +26 -0
  2. {cacheverifier-0.3.1 → cacheverifier-0.4.1}/PKG-INFO +30 -6
  3. {cacheverifier-0.3.1 → cacheverifier-0.4.1}/README.md +29 -5
  4. {cacheverifier-0.3.1 → cacheverifier-0.4.1}/cacheverifier/__init__.py +1 -1
  5. cacheverifier-0.4.1/cacheverifier/_healthcheck/_base_models.py +62 -0
  6. {cacheverifier-0.3.1 → cacheverifier-0.4.1}/cacheverifier/_healthcheck/_finetune.py +43 -9
  7. {cacheverifier-0.3.1 → cacheverifier-0.4.1}/cacheverifier/_healthcheck/cli.py +40 -1
  8. {cacheverifier-0.3.1 → cacheverifier-0.4.1}/cacheverifier/client.py +23 -14
  9. {cacheverifier-0.3.1 → cacheverifier-0.4.1}/pyproject.toml +1 -1
  10. {cacheverifier-0.3.1 → cacheverifier-0.4.1}/tests/test_client.py +25 -3
  11. {cacheverifier-0.3.1 → cacheverifier-0.4.1}/tests/test_healthcheck.py +80 -0
  12. {cacheverifier-0.3.1 → cacheverifier-0.4.1}/.github/workflows/ci.yml +0 -0
  13. {cacheverifier-0.3.1 → cacheverifier-0.4.1}/.github/workflows/publish.yml +0 -0
  14. {cacheverifier-0.3.1 → cacheverifier-0.4.1}/.gitignore +0 -0
  15. {cacheverifier-0.3.1 → cacheverifier-0.4.1}/LICENSE +0 -0
  16. {cacheverifier-0.3.1 → cacheverifier-0.4.1}/cacheverifier/__main__.py +0 -0
  17. {cacheverifier-0.3.1 → cacheverifier-0.4.1}/cacheverifier/_healthcheck/__init__.py +0 -0
  18. {cacheverifier-0.3.1 → cacheverifier-0.4.1}/cacheverifier/integrations/__init__.py +0 -0
  19. {cacheverifier-0.3.1 → cacheverifier-0.4.1}/cacheverifier/integrations/gptcache.py +0 -0
  20. {cacheverifier-0.3.1 → cacheverifier-0.4.1}/cacheverifier/py.typed +0 -0
  21. {cacheverifier-0.3.1 → cacheverifier-0.4.1}/examples/gptcache_example.py +0 -0
  22. {cacheverifier-0.3.1 → cacheverifier-0.4.1}/examples/quickstart.py +0 -0
  23. {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.1
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, or 5xx, `verify()` does not
107
- raise — it logs a warning on the `cacheverifier` logger and returns a
108
- `VerifyResult` with `degraded=True` and `approved=False`, so you fall through
109
- to your LLM exactly as you would on a cache miss. A `4xx` (bad key, bad
110
- request, rate limit) still raises `CacheVerifierError`.
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, or 5xx, `verify()` does not
75
- raise — it logs a warning on the `cacheverifier` logger and returns a
76
- `VerifyResult` with `degraded=True` and `approved=False`, so you fall through
77
- to your LLM exactly as you would on a cache miss. A `4xx` (bad key, bad
78
- request, rate limit) still raises `CacheVerifierError`.
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.3.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
- # All four imports above are from the `healthcheck` extra. This module is
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
- DEFAULT_BASE_MODEL = "cross-encoder/ms-marco-MiniLM-L6-v2"
34
- """The stock off-the-shelf verifier every tenant starts on -- the same base
35
- model the hosted service fine-tunes from."""
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 = np.array(baseline.predict(test_pairs_scored, batch_size=32, show_progress_bar=False))
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
- tuned = CrossEncoder(base_model, device=device, max_length=MAX_SEQUENCE_LENGTH)
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 = np.array(tuned.predict(calib_scored, batch_size=32, show_progress_bar=False))
283
+ calib_scores = score_pairs(tuned, calib_scored)
250
284
  threshold = select_threshold(calib_scores, calib_labels)
251
285
 
252
- tuned_scores = np.array(tuned.predict(test_pairs_scored, batch_size=32, show_progress_bar=False))
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 = np.array(tuned.predict(train_pairs_scored, batch_size=32, show_progress_bar=False))
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=_finetune.DEFAULT_BASE_MODEL, epochs=args.epochs)
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 rather than returned by
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, or 5xx, `verify()` does not raise -- it
116
- logs a warning and returns a synthetic `VerifyResult` with
117
- `degraded=True`. `fail_open=False` (the default) makes that result
118
- `approved=False` so you fall through to your LLM; `fail_open=True` makes
119
- it `approved=True` so you serve the cached answer anyway. 4xx responses
120
- (bad key, bad request, rate limit) still raise `CacheVerifierError`.
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. 4xx still raises.
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
- if exc.status_code < 500:
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]:
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "cacheverifier"
7
- version = "0.3.1"
7
+ version = "0.4.1"
8
8
  description = "Python client for the hosted CacheVerifier semantic-cache verification API"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -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 test_verify_still_raises_on_4xx():
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 == 429
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