cacheverifier 0.2.0__tar.gz → 0.3.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/CHANGELOG.md +42 -0
  2. {cacheverifier-0.2.0 → cacheverifier-0.3.1}/PKG-INFO +28 -2
  3. {cacheverifier-0.2.0 → cacheverifier-0.3.1}/README.md +27 -1
  4. {cacheverifier-0.2.0 → cacheverifier-0.3.1}/cacheverifier/__init__.py +1 -1
  5. {cacheverifier-0.2.0 → cacheverifier-0.3.1}/cacheverifier/_healthcheck/_finetune.py +10 -2
  6. {cacheverifier-0.2.0 → cacheverifier-0.3.1}/cacheverifier/_healthcheck/cli.py +2 -2
  7. {cacheverifier-0.2.0 → cacheverifier-0.3.1}/cacheverifier/client.py +107 -7
  8. {cacheverifier-0.2.0 → cacheverifier-0.3.1}/cacheverifier/integrations/gptcache.py +20 -2
  9. {cacheverifier-0.2.0 → cacheverifier-0.3.1}/examples/quickstart.py +6 -1
  10. {cacheverifier-0.2.0 → cacheverifier-0.3.1}/pyproject.toml +1 -1
  11. {cacheverifier-0.2.0 → cacheverifier-0.3.1}/tests/test_client.py +96 -2
  12. {cacheverifier-0.2.0 → cacheverifier-0.3.1}/tests/test_healthcheck.py +28 -1
  13. cacheverifier-0.2.0/CHANGELOG.md +0 -18
  14. {cacheverifier-0.2.0 → cacheverifier-0.3.1}/.github/workflows/ci.yml +0 -0
  15. {cacheverifier-0.2.0 → cacheverifier-0.3.1}/.github/workflows/publish.yml +0 -0
  16. {cacheverifier-0.2.0 → cacheverifier-0.3.1}/.gitignore +0 -0
  17. {cacheverifier-0.2.0 → cacheverifier-0.3.1}/LICENSE +0 -0
  18. {cacheverifier-0.2.0 → cacheverifier-0.3.1}/cacheverifier/__main__.py +0 -0
  19. {cacheverifier-0.2.0 → cacheverifier-0.3.1}/cacheverifier/_healthcheck/__init__.py +0 -0
  20. {cacheverifier-0.2.0 → cacheverifier-0.3.1}/cacheverifier/integrations/__init__.py +0 -0
  21. {cacheverifier-0.2.0 → cacheverifier-0.3.1}/cacheverifier/py.typed +0 -0
  22. {cacheverifier-0.2.0 → cacheverifier-0.3.1}/examples/gptcache_example.py +0 -0
  23. {cacheverifier-0.2.0 → cacheverifier-0.3.1}/tests/test_gptcache_integration.py +0 -0
@@ -0,0 +1,42 @@
1
+ # Changelog
2
+
3
+ ## 0.3.1
4
+
5
+ `cacheverifier healthcheck` now actually fine-tunes. It passed no
6
+ `warmup_steps` to `CrossEncoder.fit`, whose default of 10000 kept a typical
7
+ run below a few percent of its learning rate, so `auc_tuned` came back equal
8
+ (or nearly equal) to `auc_baseline`. Warmup is now 10% of total steps and the
9
+ default is 3 epochs (`--epochs`), the same as the hosted service since
10
+ verifier-core 0.2.0. On a 500-row later holdout this took LmArena AUC from
11
+ 0.903 to 0.932 and AmazonHelp from 0.551 to 0.700. Runs take about 3x as long.
12
+
13
+ ## 0.3.0
14
+
15
+ Make the request-path defaults safe. `verify()` and `verify_batch()` now use a
16
+ separate **1s timeout** (`verify_timeout=`, vs the unchanged 10s `timeout=` for
17
+ control-plane calls), and on a timeout / connection error / 5xx they **fail
18
+ closed** instead of raising: you get a synthetic `VerifyResult` with
19
+ `degraded=True` and `approved=False` (fall through to your LLM). Pass
20
+ `fail_open=True` to have those cases return `approved=True` instead. 4xx
21
+ responses (bad key, bad request, rate limit) still raise `CacheVerifierError`.
22
+ `VerifyResult` gains a `degraded` field; `model_version` is
23
+ `"verify_unavailable"` on a synthesized result. The GPTCache adapter takes the
24
+ same `verify_timeout` / `fail_open` arguments and returns `0.0` (don't reuse) on
25
+ a verifier outage by default.
26
+
27
+ ## 0.2.0
28
+
29
+ Add a `cacheverifier` console script with a `healthcheck` subcommand:
30
+ `cacheverifier healthcheck traffic.jsonl` runs the hosted Health Check
31
+ Report's stock-vs-fine-tuned held-out AUC evaluation (`POST /v1/finetune/dry-run`)
32
+ entirely offline — no queries or answers leave the machine. Needs the new
33
+ `healthcheck` extra (`pip install "cacheverifier[healthcheck]"`: torch,
34
+ sentence-transformers, numpy); the base client stays `httpx`-only.
35
+ `--emit-summary` writes an aggregate-only JSON file with no text.
36
+
37
+ ## 0.1.0
38
+
39
+ Initial release: thin `httpx`-based client for `/v1/verify`, `/v1/verify/batch`,
40
+ `/v1/feedback`(`/batch`), fine-tune jobs + dry run, drift and gray-zone-threshold
41
+ monitoring, and usage/savings. GPTCache `SimilarityEvaluation` adapter under
42
+ `cacheverifier.integrations.gptcache`.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: cacheverifier
3
- Version: 0.2.0
3
+ Version: 0.3.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
@@ -91,8 +91,34 @@ cv.feedback(query, answer, was_correct=True, similarity_score=0.86)
91
91
  |---|---|
92
92
  | `approved` | serve the cached answer (`True`) or fall through (`False`) |
93
93
  | `score` / `threshold` | `approved` is `score >= threshold` |
94
- | `model_version` | `"stock"`, `"v<id>"` (fine-tuned), or `"cold_start_fail_closed"` |
94
+ | `model_version` | `"stock"`, `"v<id>"` (fine-tuned), `"cold_start_fail_closed"` / `"cold_start_auto_pending"`, or `"verify_unavailable"` |
95
95
  | `latency_ms` | server-side inference time |
96
+ | `degraded` | `True` when the call failed and this result was synthesized client-side |
97
+
98
+ ## On your request path
99
+
100
+ `verify()` runs inline with your traffic, so its defaults are conservative:
101
+
102
+ - **1s timeout** (`verify_timeout=`), separate from the 10s `timeout=` used for
103
+ fine-tuning / feedback / monitoring calls. Warm verification is tens of
104
+ milliseconds server-side; 1s covers the network round trip and a cold model
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`.
111
+
112
+ ```python
113
+ cv = CacheVerifier(api_key="cv_...", verify_timeout=1.0, fail_open=False) # the defaults
114
+
115
+ # fail_open=True instead returns approved=True on an outage — only if a
116
+ # stale-or-near-miss answer is acceptable for that traffic:
117
+ cv = CacheVerifier(api_key="cv_...", fail_open=True)
118
+ ```
119
+
120
+ There is no formal uptime SLA yet, which is the other reason the fallback path
121
+ is a built-in default rather than left to you.
96
122
 
97
123
  ## GPTCache
98
124
 
@@ -59,8 +59,34 @@ cv.feedback(query, answer, was_correct=True, similarity_score=0.86)
59
59
  |---|---|
60
60
  | `approved` | serve the cached answer (`True`) or fall through (`False`) |
61
61
  | `score` / `threshold` | `approved` is `score >= threshold` |
62
- | `model_version` | `"stock"`, `"v<id>"` (fine-tuned), or `"cold_start_fail_closed"` |
62
+ | `model_version` | `"stock"`, `"v<id>"` (fine-tuned), `"cold_start_fail_closed"` / `"cold_start_auto_pending"`, or `"verify_unavailable"` |
63
63
  | `latency_ms` | server-side inference time |
64
+ | `degraded` | `True` when the call failed and this result was synthesized client-side |
65
+
66
+ ## On your request path
67
+
68
+ `verify()` runs inline with your traffic, so its defaults are conservative:
69
+
70
+ - **1s timeout** (`verify_timeout=`), separate from the 10s `timeout=` used for
71
+ fine-tuning / feedback / monitoring calls. Warm verification is tens of
72
+ milliseconds server-side; 1s covers the network round trip and a cold model
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`.
79
+
80
+ ```python
81
+ cv = CacheVerifier(api_key="cv_...", verify_timeout=1.0, fail_open=False) # the defaults
82
+
83
+ # fail_open=True instead returns approved=True on an outage — only if a
84
+ # stale-or-near-miss answer is acceptable for that traffic:
85
+ cv = CacheVerifier(api_key="cv_...", fail_open=True)
86
+ ```
87
+
88
+ There is no formal uptime SLA yet, which is the other reason the fallback path
89
+ is a built-in default rather than left to you.
64
90
 
65
91
  ## GPTCache
66
92
 
@@ -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.2.0"
21
+ __version__ = "0.3.0"
22
22
  __all__ = ["CacheVerifier", "CacheVerifierError", "VerifyResult", "__version__"]
@@ -43,6 +43,13 @@ _CONTENT_TOKEN_BUDGET = MAX_SEQUENCE_LENGTH - 3 # [CLS] + 2x [SEP]
43
43
  _MIN_QUERY_TOKENS = 32
44
44
 
45
45
  TRAIN_BATCH_SIZE = 4
46
+ TRAIN_EPOCHS = 3
47
+ TRAIN_WARMUP_FRACTION = 0.1
48
+ """Same as the hosted service (verifier-core 0.2.0). `CrossEncoder.fit`
49
+ defaults to `warmup_steps=10000`; without an explicit value a typical run
50
+ (a few hundred steps) never got past a few percent of its learning rate and
51
+ the "fine-tuned" model was a near monotone shift of the base model, so
52
+ `auc_tuned` came back equal to `auc_baseline`."""
46
53
  MIN_TRAIN_EXAMPLES = 20
47
54
  """Hard floor -- below this there aren't enough rows for one meaningful
48
55
  epoch plus a held-out split."""
@@ -183,7 +190,7 @@ def run_healthcheck(
183
190
  output_dir: str,
184
191
  *,
185
192
  base_model: str = DEFAULT_BASE_MODEL,
186
- epochs: int = 1,
193
+ epochs: int = TRAIN_EPOCHS,
187
194
  ) -> HealthCheckResult:
188
195
  """Fine-tune `base_model` on a chronological prefix of `examples` and
189
196
  measure held-out AUC for the stock vs. fine-tuned model.
@@ -233,7 +240,8 @@ def run_healthcheck(
233
240
  # whether or not torch's stubs are installed (base CI has no torch).
234
241
  loader: DataLoader[Any] = DataLoader(cast(Any, train_examples), shuffle=True, batch_size=TRAIN_BATCH_SIZE)
235
242
  t0 = time.time()
236
- tuned.fit(train_dataloader=loader, epochs=epochs, show_progress_bar=False)
243
+ warmup_steps = max(1, int(TRAIN_WARMUP_FRACTION * len(loader) * epochs))
244
+ tuned.fit(train_dataloader=loader, epochs=epochs, warmup_steps=warmup_steps, show_progress_bar=False)
237
245
  train_time_seconds = time.time() - t0
238
246
  tuned.save(output_dir)
239
247
 
@@ -48,8 +48,8 @@ def add_subparser(subparsers: argparse._SubParsersAction) -> None:
48
48
  p.add_argument(
49
49
  "--epochs",
50
50
  type=int,
51
- default=1,
52
- help="fine-tuning epochs (default: 1, matching the hosted service)",
51
+ default=3,
52
+ help="fine-tuning epochs (default: 3, matching the hosted service)",
53
53
  )
54
54
  p.add_argument(
55
55
  "--keep-model",
@@ -8,6 +8,7 @@ might be wrong. See https://www.cacheverifier.com/docs for the full API.
8
8
 
9
9
  from __future__ import annotations
10
10
 
11
+ import logging
11
12
  from collections.abc import Iterable, Sequence
12
13
  from dataclasses import dataclass
13
14
  from typing import Any
@@ -15,9 +16,30 @@ from typing import Any
15
16
  import httpx
16
17
 
17
18
  DEFAULT_BASE_URL = "https://www.cacheverifier.com"
19
+ #: Timeout for control-plane calls (feedback, fine-tuning, monitoring, usage).
20
+ #: These are not on your request path, so they get room to breathe.
18
21
  DEFAULT_TIMEOUT = 10.0
22
+ #: Timeout for ``verify()`` / ``verify_batch()`` -- these ARE on your request
23
+ #: path. Warm calls are tens of milliseconds server-side; 1s leaves headroom
24
+ #: for the network round trip and a cold model load after a deploy while still
25
+ #: bounding the damage when the service is actually unreachable.
26
+ DEFAULT_VERIFY_TIMEOUT = 1.0
19
27
 
20
- __all__ = ["CacheVerifier", "CacheVerifierError", "VerifyResult"]
28
+ #: model_version on a synthetic result returned when the verify call itself
29
+ #: failed (timeout / connection error / 5xx) and the client fell back.
30
+ VERIFY_UNAVAILABLE = "verify_unavailable"
31
+
32
+ _log = logging.getLogger(__name__)
33
+
34
+ __all__ = [
35
+ "DEFAULT_BASE_URL",
36
+ "DEFAULT_TIMEOUT",
37
+ "DEFAULT_VERIFY_TIMEOUT",
38
+ "VERIFY_UNAVAILABLE",
39
+ "CacheVerifier",
40
+ "CacheVerifierError",
41
+ "VerifyResult",
42
+ ]
21
43
 
22
44
 
23
45
  class CacheVerifierError(RuntimeError):
@@ -41,9 +63,15 @@ class VerifyResult:
41
63
  - `score`: the verifier's raw score for this pair; `approved` is `score >= threshold`.
42
64
  - `threshold`: the cutoff this call was decided against (tenant-specific once
43
65
  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`).
66
+ - `model_version`: `"stock"`, `"v<id>"` for a fine-tuned model,
67
+ `"cold_start_fail_closed"` / `"cold_start_auto_pending"` when no model ran
68
+ (see `cold_start_mode`), or `"verify_unavailable"` on a synthetic
69
+ fallback result (see `degraded`).
46
70
  - `latency_ms`: server-side model inference time, not round-trip time.
71
+ - `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
+ (default `False` -> `approved=False`, i.e. fall through to your LLM).
47
75
  """
48
76
 
49
77
  approved: bool
@@ -51,6 +79,7 @@ class VerifyResult:
51
79
  threshold: float
52
80
  model_version: str
53
81
  latency_ms: float
82
+ degraded: bool = False
54
83
 
55
84
  @classmethod
56
85
  def _from_json(cls, d: dict[str, Any]) -> VerifyResult:
@@ -77,6 +106,18 @@ class CacheVerifier:
77
106
 
78
107
  Usable as a context manager (`with CacheVerifier(...) as cv:`) to close
79
108
  the underlying HTTP connection pool deterministically.
109
+
110
+ Because `verify()` sits on your request path, it is treated differently
111
+ from every other call:
112
+
113
+ - `verify_timeout` (default 1s, vs 10s for everything else) bounds how long
114
+ 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`.
80
121
  """
81
122
 
82
123
  def __init__(
@@ -85,10 +126,14 @@ class CacheVerifier:
85
126
  *,
86
127
  base_url: str = DEFAULT_BASE_URL,
87
128
  timeout: float = DEFAULT_TIMEOUT,
129
+ verify_timeout: float = DEFAULT_VERIFY_TIMEOUT,
130
+ fail_open: bool = False,
88
131
  transport: httpx.BaseTransport | None = None,
89
132
  ) -> None:
90
133
  if not api_key:
91
134
  raise ValueError("api_key is required -- get one at https://www.cacheverifier.com")
135
+ self._verify_timeout = verify_timeout
136
+ self._fail_open = fail_open
92
137
  self._client = httpx.Client(
93
138
  base_url=base_url.rstrip("/"),
94
139
  headers={"X-API-Key": api_key, "User-Agent": _user_agent()},
@@ -110,8 +155,16 @@ class CacheVerifier:
110
155
  # -- core: verify ----------------------------------------------------
111
156
 
112
157
  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})
158
+ """Approve or reject one gray-zone cache hit. `POST /v1/verify`.
159
+
160
+ On a timeout / connection error / 5xx this returns a synthetic
161
+ `VerifyResult` (`degraded=True`, `approved` per `fail_open`) instead of
162
+ raising -- the verifier being unreachable should degrade to a normal
163
+ cache miss, not an exception on your request path.
164
+ """
165
+ data = self._verify_call("/v1/verify", {"query": query, "candidate_answer": candidate_answer})
166
+ if data is None:
167
+ return self._degraded_result()
115
168
  return VerifyResult._from_json(data)
116
169
 
117
170
  def verify_batch(self, pairs: Sequence[tuple[str, str]]) -> list[VerifyResult]:
@@ -120,11 +173,52 @@ class CacheVerifier:
120
173
 
121
174
  Useful when your own retrieval returns several close candidates:
122
175
  send them in rank order and take the first `approved` one.
176
+
177
+ Degrades the same way as `verify()`: on failure every pair comes back
178
+ as a `degraded` result rather than the call raising. A large batch may
179
+ need `verify_timeout` raised above the 1s default.
123
180
  """
124
181
  items = [{"query": q, "candidate_answer": a} for q, a in pairs]
125
- data = self._post("/v1/verify/batch", json={"items": items})
182
+ data = self._verify_call("/v1/verify/batch", {"items": items})
183
+ if data is None:
184
+ return [self._degraded_result() for _ in items]
126
185
  return [VerifyResult._from_json(r) for r in data["results"]]
127
186
 
187
+ # -- verify plumbing: tight timeout + fail-open/closed fallback --------
188
+
189
+ def _verify_call(self, path: str, payload: dict[str, Any]) -> Any | None:
190
+ """POST a verify request with the request-path timeout. Returns the
191
+ parsed body, or None to signal "unavailable, fall back" on a
192
+ timeout / connection error / 5xx. 4xx still raises.
193
+ """
194
+ try:
195
+ return self._post(path, json=payload, timeout=self._verify_timeout)
196
+ except httpx.TransportError as exc:
197
+ self._warn_degraded(path, exc)
198
+ except CacheVerifierError as exc:
199
+ if exc.status_code < 500:
200
+ raise
201
+ self._warn_degraded(path, exc)
202
+ return None
203
+
204
+ def _warn_degraded(self, path: str, exc: Exception) -> None:
205
+ _log.warning(
206
+ "cacheverifier: %s unavailable (%s) -- failing %s",
207
+ path,
208
+ exc,
209
+ "open" if self._fail_open else "closed",
210
+ )
211
+
212
+ def _degraded_result(self) -> VerifyResult:
213
+ return VerifyResult(
214
+ approved=self._fail_open,
215
+ score=0.0,
216
+ threshold=0.0,
217
+ model_version=VERIFY_UNAVAILABLE,
218
+ latency_ms=0.0,
219
+ degraded=True,
220
+ )
221
+
128
222
  # -- feedback ------------------------------------------------------
129
223
 
130
224
  def feedback(
@@ -252,8 +346,14 @@ class CacheVerifier:
252
346
  json: Any | None = None,
253
347
  params: dict[str, Any] | None = None,
254
348
  headers: dict[str, str] | None = None,
349
+ timeout: float | None = None,
255
350
  ) -> Any:
256
- return self._unwrap(self._client.post(path, json=json, params=params or None, headers=headers))
351
+ # httpx treats timeout=None as "no timeout" -- only pass it through
352
+ # when a caller (verify) explicitly asked for a per-request value.
353
+ extra: dict[str, Any] = {"timeout": timeout} if timeout is not None else {}
354
+ return self._unwrap(
355
+ self._client.post(path, json=json, params=params or None, headers=headers, **extra)
356
+ )
257
357
 
258
358
  @staticmethod
259
359
  def _unwrap(resp: httpx.Response) -> Any:
@@ -22,7 +22,12 @@ from __future__ import annotations
22
22
 
23
23
  from typing import Any
24
24
 
25
- from cacheverifier.client import DEFAULT_BASE_URL, DEFAULT_TIMEOUT, CacheVerifier
25
+ from cacheverifier.client import (
26
+ DEFAULT_BASE_URL,
27
+ DEFAULT_TIMEOUT,
28
+ DEFAULT_VERIFY_TIMEOUT,
29
+ CacheVerifier,
30
+ )
26
31
 
27
32
  try:
28
33
  from gptcache.similarity_evaluation import SimilarityEvaluation as _GPTCacheBase
@@ -37,6 +42,11 @@ class CacheVerifierEvaluation(_GPTCacheBase):
37
42
  hosted verifier already makes a binary approve/reject call per gray-zone
38
43
  hit rather than a softened similarity score. Callers who want GPTCache's
39
44
  own threshold logic on top can wrap this rather than replace it.
45
+
46
+ If the verify call times out or the service is unreachable, `evaluation()`
47
+ returns 0.0 (don't reuse) by default -- a verifier outage falls through to
48
+ a normal GPTCache miss. Pass `fail_open=True` to reuse the cached answer
49
+ instead in that case.
40
50
  """
41
51
 
42
52
  def __init__(
@@ -45,8 +55,16 @@ class CacheVerifierEvaluation(_GPTCacheBase):
45
55
  *,
46
56
  base_url: str = DEFAULT_BASE_URL,
47
57
  timeout: float = DEFAULT_TIMEOUT,
58
+ verify_timeout: float = DEFAULT_VERIFY_TIMEOUT,
59
+ fail_open: bool = False,
48
60
  ) -> None:
49
- self._cv = CacheVerifier(api_key=api_key, base_url=base_url, timeout=timeout)
61
+ self._cv = CacheVerifier(
62
+ api_key=api_key,
63
+ base_url=base_url,
64
+ timeout=timeout,
65
+ verify_timeout=verify_timeout,
66
+ fail_open=fail_open,
67
+ )
50
68
 
51
69
  def evaluation(self, src_dict: dict[str, Any], cache_dict: dict[str, Any], **_kwargs: Any) -> float:
52
70
  query = src_dict.get("question") or src_dict.get("query", "")
@@ -28,10 +28,15 @@ def main() -> None:
28
28
  query = "how do I cancel my subscription"
29
29
  candidate = "Go to Settings > Billing > Pause subscription for a month."
30
30
 
31
+ # verify() sits on your request path: it uses a 1s timeout and, if the
32
+ # service is unreachable, returns a degraded result (approved=False here)
33
+ # instead of raising. Pass fail_open=True to serve the cached answer in
34
+ # that case instead.
31
35
  with CacheVerifier(api_key=api_key) as cv:
32
36
  result = cv.verify(query, candidate)
33
37
  print(f"verify -> approved={result.approved} score={result.score:.3f} "
34
- f"threshold={result.threshold} model={result.model_version}")
38
+ f"threshold={result.threshold} model={result.model_version} "
39
+ f"degraded={result.degraded}")
35
40
 
36
41
  if result.approved:
37
42
  answer = candidate
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "cacheverifier"
7
- version = "0.2.0"
7
+ version = "0.3.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"
@@ -9,8 +9,17 @@ import pytest
9
9
  from cacheverifier import CacheVerifier, CacheVerifierError, VerifyResult
10
10
 
11
11
 
12
- def make_client(handler):
13
- return CacheVerifier(api_key="cv_test", transport=httpx.MockTransport(handler))
12
+ def make_client(handler, **kwargs):
13
+ return CacheVerifier(api_key="cv_test", transport=httpx.MockTransport(handler), **kwargs)
14
+
15
+
16
+ _OK_VERIFY = {
17
+ "approved": True,
18
+ "score": 3.5,
19
+ "latency_ms": 24.1,
20
+ "model_version": "stock",
21
+ "threshold": 0.0,
22
+ }
14
23
 
15
24
 
16
25
  def test_verify_parses_result_and_sends_api_key():
@@ -142,3 +151,88 @@ def test_error_response_without_json_body_falls_back_to_text():
142
151
  def test_empty_api_key_rejected():
143
152
  with pytest.raises(ValueError):
144
153
  CacheVerifier(api_key="")
154
+
155
+
156
+ # -- request-path timeout + fail-open/closed --------------------------------
157
+
158
+
159
+ def test_verify_uses_the_tight_verify_timeout_not_the_control_plane_one():
160
+ seen = {}
161
+
162
+ def handler(request: httpx.Request) -> httpx.Response:
163
+ seen["timeout"] = request.extensions.get("timeout")
164
+ return httpx.Response(200, json=_OK_VERIFY)
165
+
166
+ with make_client(handler, timeout=10.0, verify_timeout=1.0) as cv:
167
+ cv.verify("q", "a")
168
+
169
+ # httpx expands a scalar timeout into per-operation values
170
+ assert set(seen["timeout"].values()) == {1.0}
171
+
172
+
173
+ def test_verify_fails_closed_on_timeout():
174
+ def handler(request: httpx.Request) -> httpx.Response:
175
+ raise httpx.TimeoutException("read timed out", request=request)
176
+
177
+ with make_client(handler) as cv:
178
+ result = cv.verify("q", "a")
179
+
180
+ assert result.degraded is True
181
+ assert result.approved is False
182
+ assert result.model_version == "verify_unavailable"
183
+
184
+
185
+ def test_verify_fails_open_when_configured():
186
+ def handler(request: httpx.Request) -> httpx.Response:
187
+ raise httpx.ConnectError("connection refused", request=request)
188
+
189
+ with make_client(handler, fail_open=True) as cv:
190
+ result = cv.verify("q", "a")
191
+
192
+ assert result.degraded is True
193
+ assert result.approved is True
194
+
195
+
196
+ def test_verify_fails_closed_on_5xx():
197
+ def handler(request: httpx.Request) -> httpx.Response:
198
+ return httpx.Response(503, text="upstream overloaded")
199
+
200
+ with make_client(handler) as cv:
201
+ result = cv.verify("q", "a")
202
+
203
+ assert result.degraded is True
204
+ assert result.approved is False
205
+
206
+
207
+ def test_verify_still_raises_on_4xx():
208
+ def handler(request: httpx.Request) -> httpx.Response:
209
+ return httpx.Response(429, json={"detail": "rate limited"})
210
+
211
+ with make_client(handler) as cv, pytest.raises(CacheVerifierError) as excinfo:
212
+ cv.verify("q", "a")
213
+
214
+ assert excinfo.value.status_code == 429
215
+
216
+
217
+ def test_verify_batch_degrades_every_pair_on_failure():
218
+ def handler(request: httpx.Request) -> httpx.Response:
219
+ return httpx.Response(500, text="boom")
220
+
221
+ with make_client(handler) as cv:
222
+ results = cv.verify_batch([("q1", "a1"), ("q2", "a2"), ("q3", "a3")])
223
+
224
+ assert len(results) == 3
225
+ assert all(r.degraded and not r.approved for r in results)
226
+
227
+
228
+ def test_control_plane_call_still_uses_the_10s_default_and_raises():
229
+ seen = {}
230
+
231
+ def handler(request: httpx.Request) -> httpx.Response:
232
+ seen["timeout"] = request.extensions.get("timeout")
233
+ return httpx.Response(200, json={"status": "ok"})
234
+
235
+ with make_client(handler, timeout=10.0, verify_timeout=1.0) as cv:
236
+ cv.usage()
237
+
238
+ assert set(seen["timeout"].values()) == {10.0}
@@ -32,7 +32,9 @@ class TestDispatch:
32
32
  with pytest.raises(SystemExit) as e:
33
33
  main(["--version"])
34
34
  assert e.value.code == 0
35
- assert "cacheverifier 0.2.0" in capsys.readouterr().out
35
+ from cacheverifier import __version__
36
+
37
+ assert f"cacheverifier {__version__}" in capsys.readouterr().out
36
38
 
37
39
  def test_no_command_prints_help_and_returns_1(self, capsys):
38
40
  assert main([]) == 1
@@ -96,3 +98,28 @@ def test_run_healthcheck_end_to_end(tmp_path):
96
98
  assert result.n_train + result.n_calibrate + result.n_test == 36
97
99
  assert 0.0 <= result.auc_tuned <= 1.0
98
100
  assert (tmp_path / "model").is_dir()
101
+
102
+
103
+ @pytest.mark.skipif(not HAS_TORCH, reason="needs the healthcheck extra (torch)")
104
+ def test_run_healthcheck_warms_up_over_a_tenth_of_its_steps(tmp_path, monkeypatch):
105
+ # CrossEncoder.fit defaults to warmup_steps=10000, which kept a
106
+ # few-hundred-step run from ever reaching its learning rate.
107
+ from sentence_transformers import CrossEncoder
108
+
109
+ from cacheverifier._healthcheck import _finetune
110
+ from cacheverifier._healthcheck._finetune import GrayZoneExample, run_healthcheck
111
+
112
+ seen: dict = {}
113
+ real_fit = CrossEncoder.fit
114
+
115
+ def spy_fit(self, *args, **kwargs):
116
+ seen.update(kwargs, steps_per_epoch=len(kwargs["train_dataloader"]))
117
+ return real_fit(self, *args, **kwargs)
118
+
119
+ monkeypatch.setattr(CrossEncoder, "fit", spy_fit)
120
+ examples = [GrayZoneExample(f"question {i}", f"answer {i % 3}", i % 2 == 0) for i in range(40)]
121
+ run_healthcheck(examples, str(tmp_path / "model"))
122
+
123
+ assert seen["epochs"] == _finetune.TRAIN_EPOCHS == 3
124
+ expected = max(1, int(_finetune.TRAIN_WARMUP_FRACTION * seen["steps_per_epoch"] * seen["epochs"]))
125
+ assert seen["warmup_steps"] == expected
@@ -1,18 +0,0 @@
1
- # Changelog
2
-
3
- ## 0.2.0
4
-
5
- Add a `cacheverifier` console script with a `healthcheck` subcommand:
6
- `cacheverifier healthcheck traffic.jsonl` runs the hosted Health Check
7
- Report's stock-vs-fine-tuned held-out AUC evaluation (`POST /v1/finetune/dry-run`)
8
- entirely offline — no queries or answers leave the machine. Needs the new
9
- `healthcheck` extra (`pip install "cacheverifier[healthcheck]"`: torch,
10
- sentence-transformers, numpy); the base client stays `httpx`-only.
11
- `--emit-summary` writes an aggregate-only JSON file with no text.
12
-
13
- ## 0.1.0
14
-
15
- Initial release: thin `httpx`-based client for `/v1/verify`, `/v1/verify/batch`,
16
- `/v1/feedback`(`/batch`), fine-tune jobs + dry run, drift and gray-zone-threshold
17
- monitoring, and usage/savings. GPTCache `SimilarityEvaluation` adapter under
18
- `cacheverifier.integrations.gptcache`.
File without changes
File without changes