hypercache-kv 0.1.0__py3-none-any.whl
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.
hypercache/__init__.py
ADDED
|
@@ -0,0 +1,1310 @@
|
|
|
1
|
+
"""
|
|
2
|
+
hypercache — Python client for the Hyper Cache fingerprint API.
|
|
3
|
+
|
|
4
|
+
Quickstart:
|
|
5
|
+
import hypercache
|
|
6
|
+
|
|
7
|
+
# Reads HYPERCACHE_KEY from environment by default
|
|
8
|
+
result = hypercache.fingerprint(my_bytes_or_array)
|
|
9
|
+
print(result.record_hex) # 180-char hex string
|
|
10
|
+
print(result.ops_remaining) # ops left in your pass
|
|
11
|
+
|
|
12
|
+
Accepts bytes, bytearray, memoryview, numpy.ndarray, torch.Tensor, or any
|
|
13
|
+
buffer-protocol object.
|
|
14
|
+
|
|
15
|
+
Audit chain (records cryptographically linked to a prior record):
|
|
16
|
+
r1 = hypercache.fingerprint(batch1)
|
|
17
|
+
r2 = hypercache.fingerprint(batch2, prev=r1.record)
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import urllib.error
|
|
25
|
+
import urllib.request
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from typing import Any, Optional, Union
|
|
28
|
+
|
|
29
|
+
__version__ = "0.1.0"
|
|
30
|
+
__all__ = [
|
|
31
|
+
"Client",
|
|
32
|
+
"Session",
|
|
33
|
+
"FingerprintResult",
|
|
34
|
+
"CachePutResult",
|
|
35
|
+
"CacheLookupResult",
|
|
36
|
+
"BatchLookupItem",
|
|
37
|
+
"EmbeddingResult",
|
|
38
|
+
"CacheListEntry",
|
|
39
|
+
"CacheListRun",
|
|
40
|
+
"CacheListResponse",
|
|
41
|
+
"RelabelResult",
|
|
42
|
+
"BulkDeleteResult",
|
|
43
|
+
"HypercacheError",
|
|
44
|
+
"AuthError",
|
|
45
|
+
"QuotaError",
|
|
46
|
+
"RateLimitError",
|
|
47
|
+
"ClientError",
|
|
48
|
+
"ServerError",
|
|
49
|
+
"fingerprint",
|
|
50
|
+
"cache_put",
|
|
51
|
+
"cache_get",
|
|
52
|
+
"cache_delete",
|
|
53
|
+
"cache_lookup",
|
|
54
|
+
"cache_lookup_batch",
|
|
55
|
+
"cached_embedding",
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
DEFAULT_BASE_URL = "https://api.hypercache.ai"
|
|
59
|
+
DEFAULT_LAYERS = 32
|
|
60
|
+
DEFAULT_N_TOK = 64
|
|
61
|
+
DEFAULT_TIMEOUT = 30.0
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# ---------- Errors ----------
|
|
65
|
+
|
|
66
|
+
class HypercacheError(Exception):
|
|
67
|
+
"""Base class for all Hyper Cache errors."""
|
|
68
|
+
|
|
69
|
+
def __init__(self, message: str, status: Optional[int] = None):
|
|
70
|
+
super().__init__(message)
|
|
71
|
+
self.status = status
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class AuthError(HypercacheError):
|
|
75
|
+
"""401 — missing or invalid API key."""
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class QuotaError(HypercacheError):
|
|
79
|
+
"""402 — pass expired or operation cap reached."""
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class RateLimitError(HypercacheError):
|
|
83
|
+
"""429 — too many requests (1000/min limit)."""
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class ClientError(HypercacheError):
|
|
87
|
+
"""400-499 (other) — malformed request."""
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class ServerError(HypercacheError):
|
|
91
|
+
"""5xx or network failure — server-side issue."""
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# ---------- Result ----------
|
|
95
|
+
|
|
96
|
+
@dataclass
|
|
97
|
+
class FingerprintResult:
|
|
98
|
+
"""Returned by Client.fingerprint(). The 90-byte record + quota metadata."""
|
|
99
|
+
|
|
100
|
+
record: bytes
|
|
101
|
+
record_hex: str
|
|
102
|
+
version: int
|
|
103
|
+
ops_used: Optional[int] = None
|
|
104
|
+
ops_cap: Optional[int] = None
|
|
105
|
+
ops_remaining: Optional[int] = None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass
|
|
109
|
+
class CachePutResult:
|
|
110
|
+
"""Returned by Client.cache_put(). Storage receipt + quota metadata."""
|
|
111
|
+
|
|
112
|
+
size_bytes: int
|
|
113
|
+
expires_at: Optional[int] # unix epoch seconds, or None if stored with no expiry
|
|
114
|
+
ops_used: Optional[int] = None
|
|
115
|
+
ops_cap: Optional[int] = None
|
|
116
|
+
ops_remaining: Optional[int] = None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@dataclass
|
|
120
|
+
class CacheLookupResult:
|
|
121
|
+
"""Returned by Client.cache_lookup(). Combined fingerprint + cache check in 1 op.
|
|
122
|
+
|
|
123
|
+
On a hit, ``value`` holds the cached bytes. On a miss, ``value`` is None and
|
|
124
|
+
you should compute the result locally and call ``cache_put(fingerprint_hex, ...)``
|
|
125
|
+
to store it for next time.
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
hit: bool
|
|
129
|
+
fingerprint_hex: str
|
|
130
|
+
value: Optional[bytes]
|
|
131
|
+
expired: bool = False # True if the miss was due to TTL expiration (diagnostic)
|
|
132
|
+
ops_used: Optional[int] = None
|
|
133
|
+
ops_cap: Optional[int] = None
|
|
134
|
+
ops_remaining: Optional[int] = None
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@dataclass
|
|
138
|
+
class EmbeddingResult:
|
|
139
|
+
"""Returned by cached_embedding(). The embedding vector plus diagnostics."""
|
|
140
|
+
|
|
141
|
+
embedding: list # list[float], but kept untyped to avoid numpy entanglement
|
|
142
|
+
hit: bool
|
|
143
|
+
fingerprint_hex: str
|
|
144
|
+
ops_used: Optional[int] = None
|
|
145
|
+
ops_remaining: Optional[int] = None
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@dataclass
|
|
149
|
+
class BatchLookupItem:
|
|
150
|
+
"""One item in a batch lookup result. Mirrors the JSON shape returned by
|
|
151
|
+
POST /v1/cache/lookup/batch.
|
|
152
|
+
"""
|
|
153
|
+
|
|
154
|
+
hit: bool
|
|
155
|
+
fingerprint_hex: str
|
|
156
|
+
value: Optional[bytes] = None # decoded from value_b64 on hit
|
|
157
|
+
expired: bool = False # True if miss was due to TTL expiration
|
|
158
|
+
size_bytes: Optional[int] = None # cached object size, if hit
|
|
159
|
+
stored_at: Optional[int] = None # unix epoch seconds, if hit
|
|
160
|
+
expires_at: Optional[int] = None # unix epoch seconds or None, if hit
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@dataclass
|
|
164
|
+
class CacheListEntry:
|
|
165
|
+
"""One cache entry returned by GET /v1/cache/list.
|
|
166
|
+
|
|
167
|
+
Lightweight metadata only — fetch the actual bytes with cache_get(fingerprint_hex).
|
|
168
|
+
"""
|
|
169
|
+
|
|
170
|
+
fingerprint_hex: str
|
|
171
|
+
label: Optional[str]
|
|
172
|
+
run: Optional[str]
|
|
173
|
+
size_bytes: int
|
|
174
|
+
stored_at: int # unix epoch seconds
|
|
175
|
+
expires_at: Optional[int] = None # unix epoch seconds, or None if no TTL
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
@dataclass
|
|
179
|
+
class CacheListRun:
|
|
180
|
+
"""A grouping of cache entries by run name within a bucket window."""
|
|
181
|
+
|
|
182
|
+
run: Optional[str] # None = entries without a run tag
|
|
183
|
+
count: int
|
|
184
|
+
total_bytes: int
|
|
185
|
+
entries: list # list[CacheListEntry]
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@dataclass
|
|
189
|
+
class CacheListResponse:
|
|
190
|
+
"""Response from Client.cache_list().
|
|
191
|
+
|
|
192
|
+
Entries are grouped by run inside the bucket window. Use next_cursor to
|
|
193
|
+
paginate; pass it as cursor= on the next call. None = no more pages.
|
|
194
|
+
"""
|
|
195
|
+
|
|
196
|
+
bucket: str # friendly label like "today (2026-05-28)"
|
|
197
|
+
part: str # "AM" | "PM" | "ALL"
|
|
198
|
+
total_count: int
|
|
199
|
+
total_bytes: int
|
|
200
|
+
runs: list # list[CacheListRun]
|
|
201
|
+
next_cursor: Optional[int] = None
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@dataclass
|
|
205
|
+
class RelabelResult:
|
|
206
|
+
"""Response from Client.cache_relabel()."""
|
|
207
|
+
|
|
208
|
+
relabeled: bool
|
|
209
|
+
fingerprint_hex: str
|
|
210
|
+
label: Optional[str] = None
|
|
211
|
+
run: Optional[str] = None
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
@dataclass
|
|
215
|
+
class BulkDeleteResult:
|
|
216
|
+
"""Response from cache_bulk_delete_by_label() and cache_bulk_delete_by_age()."""
|
|
217
|
+
|
|
218
|
+
deleted: int # number of entries removed
|
|
219
|
+
bytes_freed: int # total payload bytes reclaimed
|
|
220
|
+
cutoff_unix: Optional[int] = None # only set on by-age delete
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
# ---------- Coercion helpers ----------
|
|
224
|
+
|
|
225
|
+
def _coerce_to_bytes(data: Any) -> bytes:
|
|
226
|
+
"""Accept bytes, numpy arrays, torch tensors, buffer-protocol objects, etc."""
|
|
227
|
+
if isinstance(data, (bytes, bytearray)):
|
|
228
|
+
return bytes(data)
|
|
229
|
+
if isinstance(data, memoryview):
|
|
230
|
+
return data.tobytes()
|
|
231
|
+
# torch.Tensor: has detach() and cpu()
|
|
232
|
+
if hasattr(data, "detach") and hasattr(data, "cpu") and hasattr(data, "numpy"):
|
|
233
|
+
return data.detach().cpu().numpy().tobytes()
|
|
234
|
+
# numpy.ndarray and other buffer-protocol objects
|
|
235
|
+
if hasattr(data, "tobytes"):
|
|
236
|
+
return data.tobytes()
|
|
237
|
+
raise TypeError(
|
|
238
|
+
f"hypercache: unsupported data type {type(data).__name__}. "
|
|
239
|
+
"Pass bytes, numpy.ndarray, torch.Tensor, or any buffer-protocol object."
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _coerce_prev(prev: Optional[Union[bytes, bytearray, str]]) -> str:
|
|
244
|
+
if prev is None or prev == "":
|
|
245
|
+
return ""
|
|
246
|
+
if isinstance(prev, (bytes, bytearray)):
|
|
247
|
+
return bytes(prev).hex()
|
|
248
|
+
if isinstance(prev, str):
|
|
249
|
+
return prev
|
|
250
|
+
raise TypeError(
|
|
251
|
+
f"hypercache: prev must be bytes or hex string, got {type(prev).__name__}"
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _raise_for_status(status: int, body: str) -> None:
|
|
256
|
+
message = body.strip() or f"HTTP {status}"
|
|
257
|
+
if status == 401:
|
|
258
|
+
raise AuthError(message, status=status)
|
|
259
|
+
if status == 402:
|
|
260
|
+
raise QuotaError(message, status=status)
|
|
261
|
+
if status == 429:
|
|
262
|
+
raise RateLimitError(message, status=status)
|
|
263
|
+
if 400 <= status < 500:
|
|
264
|
+
raise ClientError(message, status=status)
|
|
265
|
+
raise ServerError(message, status=status)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
# ---------- Client ----------
|
|
269
|
+
|
|
270
|
+
class Client:
|
|
271
|
+
"""Hyper Cache API client.
|
|
272
|
+
|
|
273
|
+
Args:
|
|
274
|
+
api_key: API key. Falls back to HYPERCACHE_KEY environment variable.
|
|
275
|
+
base_url: API base URL. Falls back to HYPERCACHE_BASE_URL env var or
|
|
276
|
+
the production default (https://api.hypercache.ai).
|
|
277
|
+
timeout: Request timeout in seconds (default: 30).
|
|
278
|
+
"""
|
|
279
|
+
|
|
280
|
+
def __init__(
|
|
281
|
+
self,
|
|
282
|
+
api_key: Optional[str] = None,
|
|
283
|
+
base_url: Optional[str] = None,
|
|
284
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
285
|
+
):
|
|
286
|
+
self.api_key = api_key or os.environ.get("HYPERCACHE_KEY", "")
|
|
287
|
+
if not self.api_key:
|
|
288
|
+
raise AuthError(
|
|
289
|
+
"No API key. Pass api_key= or set HYPERCACHE_KEY in your environment."
|
|
290
|
+
)
|
|
291
|
+
self.base_url = (
|
|
292
|
+
base_url or os.environ.get("HYPERCACHE_BASE_URL") or DEFAULT_BASE_URL
|
|
293
|
+
).rstrip("/")
|
|
294
|
+
self.timeout = timeout
|
|
295
|
+
|
|
296
|
+
def fingerprint(
|
|
297
|
+
self,
|
|
298
|
+
data: Any,
|
|
299
|
+
layers: int = DEFAULT_LAYERS,
|
|
300
|
+
n_tok: int = DEFAULT_N_TOK,
|
|
301
|
+
prev: Optional[Union[bytes, bytearray, str]] = None,
|
|
302
|
+
) -> FingerprintResult:
|
|
303
|
+
"""Compute a 90-byte fingerprint record for the given data.
|
|
304
|
+
|
|
305
|
+
Args:
|
|
306
|
+
data: bytes, numpy.ndarray, torch.Tensor, or any buffer-protocol object.
|
|
307
|
+
layers: model layer count hint (default 32).
|
|
308
|
+
n_tok: token count hint (default 64).
|
|
309
|
+
prev: optional prior 90-byte record (bytes or hex string) to chain to.
|
|
310
|
+
|
|
311
|
+
Returns:
|
|
312
|
+
FingerprintResult with .record (90 bytes), .record_hex, .version,
|
|
313
|
+
and quota metadata (.ops_used, .ops_cap, .ops_remaining).
|
|
314
|
+
"""
|
|
315
|
+
body = _coerce_to_bytes(data)
|
|
316
|
+
prev_hex = _coerce_prev(prev)
|
|
317
|
+
|
|
318
|
+
headers = {
|
|
319
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
320
|
+
"content-type": "application/octet-stream",
|
|
321
|
+
"x-hc-layers": str(layers),
|
|
322
|
+
"x-hc-n-tok": str(n_tok),
|
|
323
|
+
"user-agent": f"hypercache-python/{__version__}",
|
|
324
|
+
}
|
|
325
|
+
if prev_hex:
|
|
326
|
+
headers["x-hc-prev"] = prev_hex
|
|
327
|
+
|
|
328
|
+
req = urllib.request.Request(
|
|
329
|
+
f"{self.base_url}/v1/fingerprint",
|
|
330
|
+
data=body,
|
|
331
|
+
method="POST",
|
|
332
|
+
headers=headers,
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
try:
|
|
336
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
|
337
|
+
payload = json.loads(resp.read())
|
|
338
|
+
return FingerprintResult(
|
|
339
|
+
record=bytes.fromhex(payload["fingerprint_hex"]),
|
|
340
|
+
record_hex=payload["fingerprint_hex"],
|
|
341
|
+
version=payload["version"],
|
|
342
|
+
ops_used=_maybe_int(resp.headers.get("x-hc-ops-used")),
|
|
343
|
+
ops_cap=_maybe_int(resp.headers.get("x-hc-ops-cap")),
|
|
344
|
+
ops_remaining=_maybe_int(resp.headers.get("x-hc-ops-remaining")),
|
|
345
|
+
)
|
|
346
|
+
except urllib.error.HTTPError as e:
|
|
347
|
+
error_body = ""
|
|
348
|
+
try:
|
|
349
|
+
error_body = e.read().decode("utf-8", errors="replace")
|
|
350
|
+
except Exception:
|
|
351
|
+
pass
|
|
352
|
+
_raise_for_status(e.code, error_body)
|
|
353
|
+
raise # _raise_for_status always raises; this satisfies the type checker
|
|
354
|
+
except urllib.error.URLError as e:
|
|
355
|
+
raise ServerError(f"Network error: {e.reason}")
|
|
356
|
+
|
|
357
|
+
# ---------- Cache methods ----------
|
|
358
|
+
|
|
359
|
+
def cache_put(
|
|
360
|
+
self,
|
|
361
|
+
fingerprint: str,
|
|
362
|
+
data: Any,
|
|
363
|
+
ttl: Optional[int] = None,
|
|
364
|
+
label: Optional[str] = None,
|
|
365
|
+
run: Optional[str] = None,
|
|
366
|
+
) -> CachePutResult:
|
|
367
|
+
"""Store data under the given fingerprint.
|
|
368
|
+
|
|
369
|
+
Args:
|
|
370
|
+
fingerprint: 180-character hex string from a FingerprintResult.record_hex.
|
|
371
|
+
data: bytes-like or buffer-protocol object to cache.
|
|
372
|
+
ttl: seconds until expiry. None = tier default.
|
|
373
|
+
Pass 0 for no expiry (object persists until quota pressure or manual delete).
|
|
374
|
+
label: optional ≤256-char ASCII organizer string (e.g., "prod/song1.v1.3").
|
|
375
|
+
Stored as plaintext metadata — DO NOT put PHI or secrets in labels.
|
|
376
|
+
run: optional ≤256-char run/session identifier (e.g., "agent-abc123").
|
|
377
|
+
Use for grouping related entries; query via cache_list(run=...).
|
|
378
|
+
|
|
379
|
+
Returns:
|
|
380
|
+
CachePutResult with size_bytes, expires_at, and updated quota counters.
|
|
381
|
+
|
|
382
|
+
Raises:
|
|
383
|
+
QuotaError on 402 (op cap reached or cache quota exceeded).
|
|
384
|
+
ClientError on 400/413 (bad fingerprint, empty body, object too large).
|
|
385
|
+
"""
|
|
386
|
+
body = _coerce_to_bytes(data)
|
|
387
|
+
headers = {
|
|
388
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
389
|
+
"content-type": "application/octet-stream",
|
|
390
|
+
"user-agent": f"hypercache-python/{__version__}",
|
|
391
|
+
}
|
|
392
|
+
if ttl is not None:
|
|
393
|
+
headers["x-hc-ttl"] = str(ttl)
|
|
394
|
+
if label is not None:
|
|
395
|
+
headers["x-hc-label"] = label
|
|
396
|
+
if run is not None:
|
|
397
|
+
headers["x-hc-run"] = run
|
|
398
|
+
|
|
399
|
+
req = urllib.request.Request(
|
|
400
|
+
f"{self.base_url}/v1/cache/{fingerprint}",
|
|
401
|
+
data=body,
|
|
402
|
+
method="PUT",
|
|
403
|
+
headers=headers,
|
|
404
|
+
)
|
|
405
|
+
try:
|
|
406
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
|
407
|
+
payload = json.loads(resp.read())
|
|
408
|
+
return CachePutResult(
|
|
409
|
+
size_bytes=int(payload["size_bytes"]),
|
|
410
|
+
expires_at=payload.get("expires_at"),
|
|
411
|
+
ops_used=_maybe_int(resp.headers.get("x-hc-ops-used")),
|
|
412
|
+
ops_cap=_maybe_int(resp.headers.get("x-hc-ops-cap")),
|
|
413
|
+
ops_remaining=_maybe_int(resp.headers.get("x-hc-ops-remaining")),
|
|
414
|
+
)
|
|
415
|
+
except urllib.error.HTTPError as e:
|
|
416
|
+
error_body = ""
|
|
417
|
+
try:
|
|
418
|
+
error_body = e.read().decode("utf-8", errors="replace")
|
|
419
|
+
except Exception:
|
|
420
|
+
pass
|
|
421
|
+
_raise_for_status(e.code, error_body)
|
|
422
|
+
raise
|
|
423
|
+
except urllib.error.URLError as e:
|
|
424
|
+
raise ServerError(f"Network error: {e.reason}")
|
|
425
|
+
|
|
426
|
+
def cache_get(self, fingerprint: str) -> Optional[bytes]:
|
|
427
|
+
"""Retrieve cached bytes for the given fingerprint.
|
|
428
|
+
|
|
429
|
+
Returns:
|
|
430
|
+
bytes on cache hit, None on cache miss (404 is the expected miss case).
|
|
431
|
+
|
|
432
|
+
Raises:
|
|
433
|
+
On other HTTP errors (401, 402, 429, 5xx) — typed exceptions.
|
|
434
|
+
"""
|
|
435
|
+
req = urllib.request.Request(
|
|
436
|
+
f"{self.base_url}/v1/cache/{fingerprint}",
|
|
437
|
+
method="GET",
|
|
438
|
+
headers={
|
|
439
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
440
|
+
"user-agent": f"hypercache-python/{__version__}",
|
|
441
|
+
},
|
|
442
|
+
)
|
|
443
|
+
try:
|
|
444
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
|
445
|
+
return resp.read()
|
|
446
|
+
except urllib.error.HTTPError as e:
|
|
447
|
+
if e.code == 404:
|
|
448
|
+
return None # cache miss is not an error
|
|
449
|
+
error_body = ""
|
|
450
|
+
try:
|
|
451
|
+
error_body = e.read().decode("utf-8", errors="replace")
|
|
452
|
+
except Exception:
|
|
453
|
+
pass
|
|
454
|
+
_raise_for_status(e.code, error_body)
|
|
455
|
+
raise
|
|
456
|
+
except urllib.error.URLError as e:
|
|
457
|
+
raise ServerError(f"Network error: {e.reason}")
|
|
458
|
+
|
|
459
|
+
def cache_lookup(
|
|
460
|
+
self,
|
|
461
|
+
data: Any,
|
|
462
|
+
layers: int = DEFAULT_LAYERS,
|
|
463
|
+
n_tok: int = DEFAULT_N_TOK,
|
|
464
|
+
) -> CacheLookupResult:
|
|
465
|
+
"""Compute the fingerprint of ``data`` AND check the cache in a single op.
|
|
466
|
+
|
|
467
|
+
Saves a round trip versus calling ``fingerprint()`` then ``cache_get()``.
|
|
468
|
+
|
|
469
|
+
Returns:
|
|
470
|
+
CacheLookupResult. On hit, ``.value`` is the cached bytes. On miss,
|
|
471
|
+
``.value`` is None and ``.fingerprint_hex`` is the key you'd use
|
|
472
|
+
with ``cache_put()`` to populate the cache.
|
|
473
|
+
"""
|
|
474
|
+
body = _coerce_to_bytes(data)
|
|
475
|
+
headers = {
|
|
476
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
477
|
+
"content-type": "application/octet-stream",
|
|
478
|
+
"x-hc-layers": str(layers),
|
|
479
|
+
"x-hc-n-tok": str(n_tok),
|
|
480
|
+
"user-agent": f"hypercache-python/{__version__}",
|
|
481
|
+
}
|
|
482
|
+
req = urllib.request.Request(
|
|
483
|
+
f"{self.base_url}/v1/cache/lookup",
|
|
484
|
+
data=body,
|
|
485
|
+
method="POST",
|
|
486
|
+
headers=headers,
|
|
487
|
+
)
|
|
488
|
+
try:
|
|
489
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
|
490
|
+
hit_header = resp.headers.get("x-hc-cache-hit", "0")
|
|
491
|
+
fingerprint_hex = resp.headers.get("x-hc-fingerprint", "")
|
|
492
|
+
ops_used = _maybe_int(resp.headers.get("x-hc-ops-used"))
|
|
493
|
+
ops_cap = _maybe_int(resp.headers.get("x-hc-ops-cap"))
|
|
494
|
+
ops_remaining = _maybe_int(resp.headers.get("x-hc-ops-remaining"))
|
|
495
|
+
if hit_header == "1":
|
|
496
|
+
return CacheLookupResult(
|
|
497
|
+
hit=True,
|
|
498
|
+
fingerprint_hex=fingerprint_hex,
|
|
499
|
+
value=resp.read(),
|
|
500
|
+
expired=False,
|
|
501
|
+
ops_used=ops_used,
|
|
502
|
+
ops_cap=ops_cap,
|
|
503
|
+
ops_remaining=ops_remaining,
|
|
504
|
+
)
|
|
505
|
+
# Miss path is JSON.
|
|
506
|
+
payload = json.loads(resp.read())
|
|
507
|
+
return CacheLookupResult(
|
|
508
|
+
hit=False,
|
|
509
|
+
fingerprint_hex=payload.get("fingerprint_hex", fingerprint_hex),
|
|
510
|
+
value=None,
|
|
511
|
+
expired=bool(payload.get("expired", False)),
|
|
512
|
+
ops_used=ops_used,
|
|
513
|
+
ops_cap=ops_cap,
|
|
514
|
+
ops_remaining=ops_remaining,
|
|
515
|
+
)
|
|
516
|
+
except urllib.error.HTTPError as e:
|
|
517
|
+
error_body = ""
|
|
518
|
+
try:
|
|
519
|
+
error_body = e.read().decode("utf-8", errors="replace")
|
|
520
|
+
except Exception:
|
|
521
|
+
pass
|
|
522
|
+
_raise_for_status(e.code, error_body)
|
|
523
|
+
raise
|
|
524
|
+
except urllib.error.URLError as e:
|
|
525
|
+
raise ServerError(f"Network error: {e.reason}")
|
|
526
|
+
|
|
527
|
+
def cache_lookup_batch(
|
|
528
|
+
self,
|
|
529
|
+
inputs: list, # list of bytes-like OR list of dicts with "data" and optional "prev"
|
|
530
|
+
layers: int = DEFAULT_LAYERS,
|
|
531
|
+
n_tok: int = DEFAULT_N_TOK,
|
|
532
|
+
) -> list:
|
|
533
|
+
"""Look up many records in a single round trip.
|
|
534
|
+
|
|
535
|
+
Each item is fingerprinted and cache-checked atomically. Strict
|
|
536
|
+
all-or-nothing on op accounting: if the batch would exceed your cap,
|
|
537
|
+
nothing is charged and a QuotaError is raised with the current quota.
|
|
538
|
+
|
|
539
|
+
Args:
|
|
540
|
+
inputs: list of items. Each item is either:
|
|
541
|
+
- raw bytes / numpy array / torch tensor / buffer-protocol object, OR
|
|
542
|
+
- dict with keys "data" (the input), optionally "prev" (bytes or
|
|
543
|
+
hex string), optionally "layers", optionally "n_tok".
|
|
544
|
+
layers: default layer count if item dict doesn't specify.
|
|
545
|
+
n_tok: default token count if item dict doesn't specify.
|
|
546
|
+
|
|
547
|
+
Returns:
|
|
548
|
+
list[BatchLookupItem] in the same order as inputs.
|
|
549
|
+
|
|
550
|
+
Raises:
|
|
551
|
+
QuotaError on 402 (op cap would be exceeded; message includes current quota).
|
|
552
|
+
"""
|
|
553
|
+
if not isinstance(inputs, list) or not inputs:
|
|
554
|
+
raise ClientError("cache_lookup_batch: inputs must be a non-empty list")
|
|
555
|
+
|
|
556
|
+
items_payload = []
|
|
557
|
+
for i, item in enumerate(inputs):
|
|
558
|
+
if isinstance(item, dict):
|
|
559
|
+
data = item.get("data")
|
|
560
|
+
if data is None:
|
|
561
|
+
raise ClientError(
|
|
562
|
+
f"cache_lookup_batch: inputs[{i}] dict missing 'data' key"
|
|
563
|
+
)
|
|
564
|
+
data_bytes = _coerce_to_bytes(data)
|
|
565
|
+
payload: dict = {
|
|
566
|
+
"data_b64": _b64encode(data_bytes),
|
|
567
|
+
"layers": int(item.get("layers", layers)),
|
|
568
|
+
"n_tok": int(item.get("n_tok", n_tok)),
|
|
569
|
+
}
|
|
570
|
+
prev = item.get("prev")
|
|
571
|
+
if prev:
|
|
572
|
+
payload["prev_hex"] = _coerce_prev(prev)
|
|
573
|
+
items_payload.append(payload)
|
|
574
|
+
else:
|
|
575
|
+
data_bytes = _coerce_to_bytes(item)
|
|
576
|
+
items_payload.append({
|
|
577
|
+
"data_b64": _b64encode(data_bytes),
|
|
578
|
+
"layers": layers,
|
|
579
|
+
"n_tok": n_tok,
|
|
580
|
+
})
|
|
581
|
+
|
|
582
|
+
body = json.dumps({"items": items_payload}).encode("utf-8")
|
|
583
|
+
headers = {
|
|
584
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
585
|
+
"content-type": "application/json",
|
|
586
|
+
"user-agent": f"hypercache-python/{__version__}",
|
|
587
|
+
}
|
|
588
|
+
req = urllib.request.Request(
|
|
589
|
+
f"{self.base_url}/v1/cache/lookup/batch",
|
|
590
|
+
data=body,
|
|
591
|
+
method="POST",
|
|
592
|
+
headers=headers,
|
|
593
|
+
)
|
|
594
|
+
try:
|
|
595
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
|
596
|
+
payload = json.loads(resp.read())
|
|
597
|
+
results = []
|
|
598
|
+
for r in payload.get("items", []):
|
|
599
|
+
value: Optional[bytes] = None
|
|
600
|
+
if r.get("hit") and "value_b64" in r:
|
|
601
|
+
value = _b64decode(r["value_b64"])
|
|
602
|
+
results.append(BatchLookupItem(
|
|
603
|
+
hit=bool(r.get("hit", False)),
|
|
604
|
+
fingerprint_hex=r.get("fingerprint_hex", ""),
|
|
605
|
+
value=value,
|
|
606
|
+
expired=bool(r.get("expired", False)),
|
|
607
|
+
size_bytes=r.get("size_bytes"),
|
|
608
|
+
stored_at=r.get("stored_at"),
|
|
609
|
+
expires_at=r.get("expires_at"),
|
|
610
|
+
))
|
|
611
|
+
return results
|
|
612
|
+
except urllib.error.HTTPError as e:
|
|
613
|
+
error_body = ""
|
|
614
|
+
try:
|
|
615
|
+
error_body = e.read().decode("utf-8", errors="replace")
|
|
616
|
+
except Exception:
|
|
617
|
+
pass
|
|
618
|
+
_raise_for_status(e.code, error_body)
|
|
619
|
+
raise
|
|
620
|
+
except urllib.error.URLError as e:
|
|
621
|
+
raise ServerError(f"Network error: {e.reason}")
|
|
622
|
+
|
|
623
|
+
def cached_embedding(
|
|
624
|
+
self,
|
|
625
|
+
model: str,
|
|
626
|
+
text: str,
|
|
627
|
+
compute: Any, # Callable[[str], list[float]]
|
|
628
|
+
ttl: Optional[int] = 86400,
|
|
629
|
+
) -> EmbeddingResult:
|
|
630
|
+
"""Wrap an embedding function with caching keyed by (model, text).
|
|
631
|
+
|
|
632
|
+
Args:
|
|
633
|
+
model: model identifier (e.g., "text-embedding-3-small"). Becomes
|
|
634
|
+
part of the cache key, so different models do not collide.
|
|
635
|
+
text: input text whose embedding you want.
|
|
636
|
+
compute: callable that takes the text and returns a list of floats.
|
|
637
|
+
Called only on cache miss.
|
|
638
|
+
ttl: seconds to keep the cached embedding (default 24h).
|
|
639
|
+
|
|
640
|
+
Returns:
|
|
641
|
+
EmbeddingResult with the embedding vector and a ``hit`` flag.
|
|
642
|
+
|
|
643
|
+
Cost model:
|
|
644
|
+
- Hit: 1 op (the lookup).
|
|
645
|
+
- Miss: 2 ops (lookup + put) + 1 call to your ``compute`` function.
|
|
646
|
+
Caching pays off when the same (model, text) pair would be re-embedded
|
|
647
|
+
often enough that 1 op << avoided provider cost × hit rate.
|
|
648
|
+
"""
|
|
649
|
+
# Cache key derivation: model + \n + text, encoded as UTF-8.
|
|
650
|
+
# The \n separator prevents accidental collisions between, e.g.,
|
|
651
|
+
# model="a" text="b\nc" and model="a\nb" text="c".
|
|
652
|
+
key_bytes = f"{model}\n{text}".encode("utf-8")
|
|
653
|
+
|
|
654
|
+
lookup = self.cache_lookup(key_bytes)
|
|
655
|
+
if lookup.hit and lookup.value is not None:
|
|
656
|
+
try:
|
|
657
|
+
embedding = json.loads(lookup.value.decode("utf-8"))
|
|
658
|
+
except Exception as e:
|
|
659
|
+
# Corrupt cache entry — fall through to recompute.
|
|
660
|
+
embedding = None
|
|
661
|
+
if isinstance(embedding, list):
|
|
662
|
+
return EmbeddingResult(
|
|
663
|
+
embedding=embedding,
|
|
664
|
+
hit=True,
|
|
665
|
+
fingerprint_hex=lookup.fingerprint_hex,
|
|
666
|
+
ops_used=lookup.ops_used,
|
|
667
|
+
ops_remaining=lookup.ops_remaining,
|
|
668
|
+
)
|
|
669
|
+
|
|
670
|
+
# Miss (or corrupt hit) — call the user's compute function.
|
|
671
|
+
embedding = compute(text)
|
|
672
|
+
if not isinstance(embedding, list):
|
|
673
|
+
# Coerce numpy arrays / tuples to plain list.
|
|
674
|
+
try:
|
|
675
|
+
embedding = list(embedding)
|
|
676
|
+
except TypeError:
|
|
677
|
+
raise ClientError(
|
|
678
|
+
"cached_embedding: compute() must return a list of floats "
|
|
679
|
+
f"(or convertible), got {type(embedding).__name__}"
|
|
680
|
+
)
|
|
681
|
+
|
|
682
|
+
payload = json.dumps(embedding).encode("utf-8")
|
|
683
|
+
put_result = self.cache_put(lookup.fingerprint_hex, payload, ttl=ttl)
|
|
684
|
+
return EmbeddingResult(
|
|
685
|
+
embedding=embedding,
|
|
686
|
+
hit=False,
|
|
687
|
+
fingerprint_hex=lookup.fingerprint_hex,
|
|
688
|
+
ops_used=put_result.ops_used,
|
|
689
|
+
ops_remaining=put_result.ops_remaining,
|
|
690
|
+
)
|
|
691
|
+
|
|
692
|
+
def cache_delete(self, fingerprint: str) -> None:
|
|
693
|
+
"""Delete cached entry. Idempotent — never errors on already-deleted."""
|
|
694
|
+
req = urllib.request.Request(
|
|
695
|
+
f"{self.base_url}/v1/cache/{fingerprint}",
|
|
696
|
+
method="DELETE",
|
|
697
|
+
headers={
|
|
698
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
699
|
+
"user-agent": f"hypercache-python/{__version__}",
|
|
700
|
+
},
|
|
701
|
+
)
|
|
702
|
+
try:
|
|
703
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
|
704
|
+
resp.read()
|
|
705
|
+
except urllib.error.HTTPError as e:
|
|
706
|
+
error_body = ""
|
|
707
|
+
try:
|
|
708
|
+
error_body = e.read().decode("utf-8", errors="replace")
|
|
709
|
+
except Exception:
|
|
710
|
+
pass
|
|
711
|
+
_raise_for_status(e.code, error_body)
|
|
712
|
+
raise
|
|
713
|
+
except urllib.error.URLError as e:
|
|
714
|
+
raise ServerError(f"Network error: {e.reason}")
|
|
715
|
+
|
|
716
|
+
# ---------- Organizational methods: list, relabel, bulk delete ----------
|
|
717
|
+
|
|
718
|
+
def cache_list(
|
|
719
|
+
self,
|
|
720
|
+
bucket: str = "today",
|
|
721
|
+
part: str = "ALL",
|
|
722
|
+
run: Optional[str] = None,
|
|
723
|
+
label_prefix: Optional[str] = None,
|
|
724
|
+
limit: int = 100,
|
|
725
|
+
cursor: Optional[int] = None,
|
|
726
|
+
) -> CacheListResponse:
|
|
727
|
+
"""List your cache entries filtered by time bucket + run + label prefix.
|
|
728
|
+
|
|
729
|
+
Args:
|
|
730
|
+
bucket: time window. One of: "today", "yesterday", "this-week",
|
|
731
|
+
"this-month", "this-year", "YYYY", "YYYY-MM", or "YYYY-MM-DD".
|
|
732
|
+
part: time-of-day filter within the bucket. "AM" (00:00–11:59),
|
|
733
|
+
"PM" (12:00–23:59), or "ALL" (default).
|
|
734
|
+
run: optional exact match on the run identifier.
|
|
735
|
+
label_prefix: optional case-sensitive prefix match on the label.
|
|
736
|
+
limit: max entries per response (default 100, max 500).
|
|
737
|
+
cursor: pagination cursor returned from a previous call.
|
|
738
|
+
|
|
739
|
+
Returns:
|
|
740
|
+
CacheListResponse with entries grouped by run inside the bucket
|
|
741
|
+
window. Use .next_cursor for the next page; None = no more pages.
|
|
742
|
+
|
|
743
|
+
Cost: 0.25 weighted ops per call (D1 query, no R2 reads).
|
|
744
|
+
"""
|
|
745
|
+
from urllib.parse import urlencode
|
|
746
|
+
|
|
747
|
+
params: list = [("bucket", bucket), ("part", part), ("limit", str(limit))]
|
|
748
|
+
if run is not None:
|
|
749
|
+
params.append(("run", run))
|
|
750
|
+
if label_prefix is not None:
|
|
751
|
+
params.append(("label_prefix", label_prefix))
|
|
752
|
+
if cursor is not None:
|
|
753
|
+
params.append(("cursor", str(cursor)))
|
|
754
|
+
qs = urlencode(params)
|
|
755
|
+
|
|
756
|
+
req = urllib.request.Request(
|
|
757
|
+
f"{self.base_url}/v1/cache/list?{qs}",
|
|
758
|
+
method="GET",
|
|
759
|
+
headers={
|
|
760
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
761
|
+
"user-agent": f"hypercache-python/{__version__}",
|
|
762
|
+
},
|
|
763
|
+
)
|
|
764
|
+
try:
|
|
765
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
|
766
|
+
payload = json.loads(resp.read())
|
|
767
|
+
runs = []
|
|
768
|
+
for r in payload.get("runs", []):
|
|
769
|
+
entries = [
|
|
770
|
+
CacheListEntry(
|
|
771
|
+
fingerprint_hex=e["fingerprint_hex"],
|
|
772
|
+
label=e.get("label"),
|
|
773
|
+
run=e.get("run"),
|
|
774
|
+
size_bytes=int(e.get("size_bytes", 0)),
|
|
775
|
+
stored_at=int(e.get("stored_at", 0)),
|
|
776
|
+
expires_at=e.get("expires_at"),
|
|
777
|
+
)
|
|
778
|
+
for e in r.get("entries", [])
|
|
779
|
+
]
|
|
780
|
+
runs.append(CacheListRun(
|
|
781
|
+
run=r.get("run"),
|
|
782
|
+
count=int(r.get("count", 0)),
|
|
783
|
+
total_bytes=int(r.get("total_bytes", 0)),
|
|
784
|
+
entries=entries,
|
|
785
|
+
))
|
|
786
|
+
return CacheListResponse(
|
|
787
|
+
bucket=payload.get("bucket", bucket),
|
|
788
|
+
part=payload.get("part", part),
|
|
789
|
+
total_count=int(payload.get("total_count", 0)),
|
|
790
|
+
total_bytes=int(payload.get("total_bytes", 0)),
|
|
791
|
+
runs=runs,
|
|
792
|
+
next_cursor=payload.get("next_cursor"),
|
|
793
|
+
)
|
|
794
|
+
except urllib.error.HTTPError as e:
|
|
795
|
+
error_body = ""
|
|
796
|
+
try:
|
|
797
|
+
error_body = e.read().decode("utf-8", errors="replace")
|
|
798
|
+
except Exception:
|
|
799
|
+
pass
|
|
800
|
+
_raise_for_status(e.code, error_body)
|
|
801
|
+
raise
|
|
802
|
+
except urllib.error.URLError as e:
|
|
803
|
+
raise ServerError(f"Network error: {e.reason}")
|
|
804
|
+
|
|
805
|
+
def cache_relabel(
|
|
806
|
+
self,
|
|
807
|
+
fingerprint: str,
|
|
808
|
+
label: Optional[str] = None,
|
|
809
|
+
run: Optional[str] = None,
|
|
810
|
+
) -> RelabelResult:
|
|
811
|
+
"""Update the label and/or run of an existing cache entry without touching data.
|
|
812
|
+
|
|
813
|
+
Pass an empty string or None to clear that field. At least one of
|
|
814
|
+
label or run must be provided.
|
|
815
|
+
|
|
816
|
+
Args:
|
|
817
|
+
fingerprint: 180-char hex of the entry to relabel.
|
|
818
|
+
label: new label, or None to leave unchanged. Pass "" or `None` and
|
|
819
|
+
set the parameter explicitly to clear.
|
|
820
|
+
run: new run, same semantics as label.
|
|
821
|
+
|
|
822
|
+
Returns:
|
|
823
|
+
RelabelResult with .relabeled=True on success, plus the new values.
|
|
824
|
+
|
|
825
|
+
Raises:
|
|
826
|
+
ClientError on 404 (no such entry) or 400 (invalid label/run).
|
|
827
|
+
"""
|
|
828
|
+
if label is None and run is None:
|
|
829
|
+
raise ClientError("cache_relabel: must provide label= or run=")
|
|
830
|
+
body_dict: dict = {}
|
|
831
|
+
if label is not None:
|
|
832
|
+
body_dict["label"] = label if label != "" else None
|
|
833
|
+
if run is not None:
|
|
834
|
+
body_dict["run"] = run if run != "" else None
|
|
835
|
+
body = json.dumps(body_dict).encode("utf-8")
|
|
836
|
+
|
|
837
|
+
req = urllib.request.Request(
|
|
838
|
+
f"{self.base_url}/v1/cache/{fingerprint}/relabel",
|
|
839
|
+
data=body,
|
|
840
|
+
method="POST",
|
|
841
|
+
headers={
|
|
842
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
843
|
+
"content-type": "application/json",
|
|
844
|
+
"user-agent": f"hypercache-python/{__version__}",
|
|
845
|
+
},
|
|
846
|
+
)
|
|
847
|
+
try:
|
|
848
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
|
849
|
+
payload = json.loads(resp.read())
|
|
850
|
+
return RelabelResult(
|
|
851
|
+
relabeled=bool(payload.get("relabeled", False)),
|
|
852
|
+
fingerprint_hex=payload.get("fingerprint_hex", fingerprint),
|
|
853
|
+
label=payload.get("label"),
|
|
854
|
+
run=payload.get("run"),
|
|
855
|
+
)
|
|
856
|
+
except urllib.error.HTTPError as e:
|
|
857
|
+
error_body = ""
|
|
858
|
+
try:
|
|
859
|
+
error_body = e.read().decode("utf-8", errors="replace")
|
|
860
|
+
except Exception:
|
|
861
|
+
pass
|
|
862
|
+
_raise_for_status(e.code, error_body)
|
|
863
|
+
raise
|
|
864
|
+
except urllib.error.URLError as e:
|
|
865
|
+
raise ServerError(f"Network error: {e.reason}")
|
|
866
|
+
|
|
867
|
+
def cache_bulk_delete_by_label(
|
|
868
|
+
self,
|
|
869
|
+
label_prefix: str,
|
|
870
|
+
confirm_count: int,
|
|
871
|
+
) -> BulkDeleteResult:
|
|
872
|
+
"""Delete every cache entry whose label starts with the given prefix.
|
|
873
|
+
|
|
874
|
+
Two-step safety: you MUST first call cache_list(label_prefix=...) to
|
|
875
|
+
learn the count of matching entries, then pass that exact integer as
|
|
876
|
+
confirm_count. Mismatch returns 409 — no data is touched.
|
|
877
|
+
|
|
878
|
+
Requires Starter tier or higher; lower tiers raise QuotaError (403).
|
|
879
|
+
|
|
880
|
+
Args:
|
|
881
|
+
label_prefix: prefix to match (e.g., "prod/song1/").
|
|
882
|
+
confirm_count: the exact total_count from a prior cache_list call.
|
|
883
|
+
|
|
884
|
+
Returns:
|
|
885
|
+
BulkDeleteResult with deleted count and bytes_freed.
|
|
886
|
+
|
|
887
|
+
Raises:
|
|
888
|
+
ClientError on 409 if confirm_count doesn't match server's count.
|
|
889
|
+
QuotaError on 403 if the tier doesn't have bulk-delete enabled.
|
|
890
|
+
"""
|
|
891
|
+
from urllib.parse import urlencode
|
|
892
|
+
qs = urlencode([("label_prefix", label_prefix), ("confirm", str(confirm_count))])
|
|
893
|
+
req = urllib.request.Request(
|
|
894
|
+
f"{self.base_url}/v1/cache/by-label?{qs}",
|
|
895
|
+
method="DELETE",
|
|
896
|
+
headers={
|
|
897
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
898
|
+
"user-agent": f"hypercache-python/{__version__}",
|
|
899
|
+
},
|
|
900
|
+
)
|
|
901
|
+
try:
|
|
902
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
|
903
|
+
payload = json.loads(resp.read())
|
|
904
|
+
return BulkDeleteResult(
|
|
905
|
+
deleted=int(payload.get("deleted", 0)),
|
|
906
|
+
bytes_freed=int(payload.get("bytes_freed", 0)),
|
|
907
|
+
)
|
|
908
|
+
except urllib.error.HTTPError as e:
|
|
909
|
+
error_body = ""
|
|
910
|
+
try:
|
|
911
|
+
error_body = e.read().decode("utf-8", errors="replace")
|
|
912
|
+
except Exception:
|
|
913
|
+
pass
|
|
914
|
+
_raise_for_status(e.code, error_body)
|
|
915
|
+
raise
|
|
916
|
+
except urllib.error.URLError as e:
|
|
917
|
+
raise ServerError(f"Network error: {e.reason}")
|
|
918
|
+
|
|
919
|
+
def cache_bulk_delete_by_age(
|
|
920
|
+
self,
|
|
921
|
+
older_than: str,
|
|
922
|
+
confirm_count: int,
|
|
923
|
+
) -> BulkDeleteResult:
|
|
924
|
+
"""Delete every cache entry older than the given relative time.
|
|
925
|
+
|
|
926
|
+
Two-step safety: first call cache_list(bucket="...") to learn the count
|
|
927
|
+
of matching entries, then pass that exact integer as confirm_count.
|
|
928
|
+
|
|
929
|
+
Requires Starter tier or higher.
|
|
930
|
+
|
|
931
|
+
Args:
|
|
932
|
+
older_than: relative-time shorthand. Examples: "30d" (days),
|
|
933
|
+
"12h" (hours), "2w" (weeks), "1m" (months), "1y" (years).
|
|
934
|
+
confirm_count: the exact count to delete from a prior list.
|
|
935
|
+
|
|
936
|
+
Returns:
|
|
937
|
+
BulkDeleteResult with deleted, bytes_freed, and cutoff_unix.
|
|
938
|
+
|
|
939
|
+
Raises:
|
|
940
|
+
ClientError on 409 if confirm_count doesn't match.
|
|
941
|
+
QuotaError on 403 if the tier doesn't have bulk-delete enabled.
|
|
942
|
+
"""
|
|
943
|
+
from urllib.parse import urlencode
|
|
944
|
+
qs = urlencode([("older_than", older_than), ("confirm", str(confirm_count))])
|
|
945
|
+
req = urllib.request.Request(
|
|
946
|
+
f"{self.base_url}/v1/cache/by-age?{qs}",
|
|
947
|
+
method="DELETE",
|
|
948
|
+
headers={
|
|
949
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
950
|
+
"user-agent": f"hypercache-python/{__version__}",
|
|
951
|
+
},
|
|
952
|
+
)
|
|
953
|
+
try:
|
|
954
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
|
955
|
+
payload = json.loads(resp.read())
|
|
956
|
+
return BulkDeleteResult(
|
|
957
|
+
deleted=int(payload.get("deleted", 0)),
|
|
958
|
+
bytes_freed=int(payload.get("bytes_freed", 0)),
|
|
959
|
+
cutoff_unix=payload.get("cutoff_unix"),
|
|
960
|
+
)
|
|
961
|
+
except urllib.error.HTTPError as e:
|
|
962
|
+
error_body = ""
|
|
963
|
+
try:
|
|
964
|
+
error_body = e.read().decode("utf-8", errors="replace")
|
|
965
|
+
except Exception:
|
|
966
|
+
pass
|
|
967
|
+
_raise_for_status(e.code, error_body)
|
|
968
|
+
raise
|
|
969
|
+
except urllib.error.URLError as e:
|
|
970
|
+
raise ServerError(f"Network error: {e.reason}")
|
|
971
|
+
|
|
972
|
+
|
|
973
|
+
def _b64encode(b: bytes) -> str:
|
|
974
|
+
"""base64 a bytes object, returning the str representation."""
|
|
975
|
+
import base64
|
|
976
|
+
return base64.b64encode(b).decode("ascii")
|
|
977
|
+
|
|
978
|
+
|
|
979
|
+
def _b64decode(s: str) -> bytes:
|
|
980
|
+
"""Decode a base64 str into bytes."""
|
|
981
|
+
import base64
|
|
982
|
+
return base64.b64decode(s)
|
|
983
|
+
|
|
984
|
+
|
|
985
|
+
# ---------- Session: chain-aware wrapper for agent loops ----------
|
|
986
|
+
|
|
987
|
+
class Session:
|
|
988
|
+
"""A chain-aware wrapper around a Client that auto-passes the previous
|
|
989
|
+
record as ``prev`` on every subsequent fingerprint or cache_lookup call.
|
|
990
|
+
|
|
991
|
+
Use this in agent loops where each step's record should link to the
|
|
992
|
+
prior step's, building a verifiable lineage of computation.
|
|
993
|
+
|
|
994
|
+
Example:
|
|
995
|
+
session = hypercache.Session()
|
|
996
|
+
r1 = session.fingerprint(b"user query: where is the gate?")
|
|
997
|
+
r2 = session.fingerprint(b"retrieval: gate is at concourse C")
|
|
998
|
+
r3 = session.fingerprint(b"reply: your gate is at concourse C")
|
|
999
|
+
# r1, r2, r3 are linked: r3's record encodes that r2 came before it,
|
|
1000
|
+
# r2's that r1 came before it.
|
|
1001
|
+
|
|
1002
|
+
Reset the chain (start a new lineage) with .reset().
|
|
1003
|
+
"""
|
|
1004
|
+
|
|
1005
|
+
def __init__(
|
|
1006
|
+
self,
|
|
1007
|
+
client: Optional[Client] = None,
|
|
1008
|
+
api_key: Optional[str] = None,
|
|
1009
|
+
run: Optional[str] = None,
|
|
1010
|
+
):
|
|
1011
|
+
if client is None:
|
|
1012
|
+
client = Client(api_key=api_key)
|
|
1013
|
+
self.client = client
|
|
1014
|
+
self._prev: Optional[bytes] = None
|
|
1015
|
+
self._run: Optional[str] = run
|
|
1016
|
+
|
|
1017
|
+
@property
|
|
1018
|
+
def prev(self) -> Optional[bytes]:
|
|
1019
|
+
"""The most recent 90-byte record, or None if no calls have been made."""
|
|
1020
|
+
return self._prev
|
|
1021
|
+
|
|
1022
|
+
@property
|
|
1023
|
+
def run(self) -> Optional[str]:
|
|
1024
|
+
"""The run name currently attached to PUTs in this session."""
|
|
1025
|
+
return self._run
|
|
1026
|
+
|
|
1027
|
+
def reset(self) -> None:
|
|
1028
|
+
"""Reset the chain. Subsequent calls start a fresh lineage."""
|
|
1029
|
+
self._prev = None
|
|
1030
|
+
|
|
1031
|
+
def with_run(self, run_name: str):
|
|
1032
|
+
"""Context manager: auto-attach x-hc-run to every PUT inside the block.
|
|
1033
|
+
|
|
1034
|
+
Example:
|
|
1035
|
+
session = hypercache.Session()
|
|
1036
|
+
with session.with_run("agent-abc/turn-5"):
|
|
1037
|
+
session.cache_put(fp, payload)
|
|
1038
|
+
session.cache_put(fp2, payload2)
|
|
1039
|
+
# Outside the with block, run is restored to whatever it was before.
|
|
1040
|
+
|
|
1041
|
+
Nests cleanly; inner with_run() overrides outer for the inner scope.
|
|
1042
|
+
"""
|
|
1043
|
+
from contextlib import contextmanager
|
|
1044
|
+
|
|
1045
|
+
@contextmanager
|
|
1046
|
+
def _scope():
|
|
1047
|
+
old_run = self._run
|
|
1048
|
+
self._run = run_name
|
|
1049
|
+
try:
|
|
1050
|
+
yield self
|
|
1051
|
+
finally:
|
|
1052
|
+
self._run = old_run
|
|
1053
|
+
|
|
1054
|
+
return _scope()
|
|
1055
|
+
|
|
1056
|
+
def fingerprint(
|
|
1057
|
+
self,
|
|
1058
|
+
data: Any,
|
|
1059
|
+
layers: int = DEFAULT_LAYERS,
|
|
1060
|
+
n_tok: int = DEFAULT_N_TOK,
|
|
1061
|
+
) -> FingerprintResult:
|
|
1062
|
+
"""Fingerprint ``data``, auto-chained to the previous record in this session."""
|
|
1063
|
+
result = self.client.fingerprint(data, layers=layers, n_tok=n_tok, prev=self._prev)
|
|
1064
|
+
self._prev = result.record
|
|
1065
|
+
return result
|
|
1066
|
+
|
|
1067
|
+
def cache_lookup(
|
|
1068
|
+
self,
|
|
1069
|
+
data: Any,
|
|
1070
|
+
layers: int = DEFAULT_LAYERS,
|
|
1071
|
+
n_tok: int = DEFAULT_N_TOK,
|
|
1072
|
+
) -> CacheLookupResult:
|
|
1073
|
+
"""Combined fingerprint + cache check, auto-chained to the previous record.
|
|
1074
|
+
|
|
1075
|
+
Note: the chain advances even on cache hits (the fingerprint is still
|
|
1076
|
+
computed by the server using the chained ``prev``).
|
|
1077
|
+
"""
|
|
1078
|
+
# cache_lookup doesn't currently take a prev argument in the Client API.
|
|
1079
|
+
# For chain advancement, we need the server to use prev — which the
|
|
1080
|
+
# /v1/cache/lookup endpoint accepts via the x-hc-prev header. Right now
|
|
1081
|
+
# the Client.cache_lookup method doesn't forward that. For v1 of Session,
|
|
1082
|
+
# we approximate: do a fingerprint with prev (updates chain), then look
|
|
1083
|
+
# up that fingerprint directly.
|
|
1084
|
+
fp = self.client.fingerprint(data, layers=layers, n_tok=n_tok, prev=self._prev)
|
|
1085
|
+
self._prev = fp.record
|
|
1086
|
+
cached = self.client.cache_get(fp.record_hex)
|
|
1087
|
+
return CacheLookupResult(
|
|
1088
|
+
hit=cached is not None,
|
|
1089
|
+
fingerprint_hex=fp.record_hex,
|
|
1090
|
+
value=cached,
|
|
1091
|
+
ops_used=fp.ops_used,
|
|
1092
|
+
ops_cap=fp.ops_cap,
|
|
1093
|
+
ops_remaining=fp.ops_remaining,
|
|
1094
|
+
)
|
|
1095
|
+
|
|
1096
|
+
def cache_put(
|
|
1097
|
+
self,
|
|
1098
|
+
fingerprint_hex: str,
|
|
1099
|
+
data: Any,
|
|
1100
|
+
ttl: Optional[int] = None,
|
|
1101
|
+
label: Optional[str] = None,
|
|
1102
|
+
run: Optional[str] = None,
|
|
1103
|
+
) -> CachePutResult:
|
|
1104
|
+
"""Store data under the given fingerprint. Doesn't advance the chain.
|
|
1105
|
+
|
|
1106
|
+
If ``run`` is None and the session has a run set (e.g., inside
|
|
1107
|
+
``with session.with_run(...)``), that run is auto-attached.
|
|
1108
|
+
"""
|
|
1109
|
+
effective_run = run if run is not None else self._run
|
|
1110
|
+
return self.client.cache_put(
|
|
1111
|
+
fingerprint_hex, data, ttl=ttl, label=label, run=effective_run
|
|
1112
|
+
)
|
|
1113
|
+
|
|
1114
|
+
def cache_list(
|
|
1115
|
+
self,
|
|
1116
|
+
bucket: str = "today",
|
|
1117
|
+
part: str = "ALL",
|
|
1118
|
+
run: Optional[str] = None,
|
|
1119
|
+
label_prefix: Optional[str] = None,
|
|
1120
|
+
limit: int = 100,
|
|
1121
|
+
cursor: Optional[int] = None,
|
|
1122
|
+
) -> CacheListResponse:
|
|
1123
|
+
"""List cache entries. Forwards to Client.cache_list().
|
|
1124
|
+
|
|
1125
|
+
If ``run`` is None and the session has a run set, filters by that run.
|
|
1126
|
+
Pass run="" to explicitly query unscoped entries.
|
|
1127
|
+
"""
|
|
1128
|
+
effective_run = run if run is not None else self._run
|
|
1129
|
+
return self.client.cache_list(
|
|
1130
|
+
bucket=bucket, part=part, run=effective_run,
|
|
1131
|
+
label_prefix=label_prefix, limit=limit, cursor=cursor,
|
|
1132
|
+
)
|
|
1133
|
+
|
|
1134
|
+
def cache_relabel(
|
|
1135
|
+
self,
|
|
1136
|
+
fingerprint_hex: str,
|
|
1137
|
+
label: Optional[str] = None,
|
|
1138
|
+
run: Optional[str] = None,
|
|
1139
|
+
) -> RelabelResult:
|
|
1140
|
+
"""Forward to Client.cache_relabel()."""
|
|
1141
|
+
return self.client.cache_relabel(fingerprint_hex, label=label, run=run)
|
|
1142
|
+
|
|
1143
|
+
def cache_bulk_delete_by_label(
|
|
1144
|
+
self, label_prefix: str, confirm_count: int
|
|
1145
|
+
) -> BulkDeleteResult:
|
|
1146
|
+
"""Forward to Client.cache_bulk_delete_by_label()."""
|
|
1147
|
+
return self.client.cache_bulk_delete_by_label(label_prefix, confirm_count)
|
|
1148
|
+
|
|
1149
|
+
def cache_bulk_delete_by_age(
|
|
1150
|
+
self, older_than: str, confirm_count: int
|
|
1151
|
+
) -> BulkDeleteResult:
|
|
1152
|
+
"""Forward to Client.cache_bulk_delete_by_age()."""
|
|
1153
|
+
return self.client.cache_bulk_delete_by_age(older_than, confirm_count)
|
|
1154
|
+
|
|
1155
|
+
def cached_embedding(
|
|
1156
|
+
self,
|
|
1157
|
+
model: str,
|
|
1158
|
+
text: str,
|
|
1159
|
+
compute: Any,
|
|
1160
|
+
ttl: Optional[int] = 86400,
|
|
1161
|
+
) -> EmbeddingResult:
|
|
1162
|
+
"""Cached embedding via the wrapped client. Does NOT advance the chain —
|
|
1163
|
+
embedding caching has its own keying (model + text) that's independent
|
|
1164
|
+
of the session's lineage.
|
|
1165
|
+
"""
|
|
1166
|
+
return self.client.cached_embedding(model, text, compute, ttl=ttl)
|
|
1167
|
+
|
|
1168
|
+
|
|
1169
|
+
def _maybe_int(s: Optional[str]) -> Optional[int]:
|
|
1170
|
+
if s is None:
|
|
1171
|
+
return None
|
|
1172
|
+
try:
|
|
1173
|
+
return int(s)
|
|
1174
|
+
except (TypeError, ValueError):
|
|
1175
|
+
return None
|
|
1176
|
+
|
|
1177
|
+
|
|
1178
|
+
# ---------- Module-level convenience ----------
|
|
1179
|
+
|
|
1180
|
+
_default_client: Optional[Client] = None
|
|
1181
|
+
|
|
1182
|
+
|
|
1183
|
+
def fingerprint(
|
|
1184
|
+
data: Any,
|
|
1185
|
+
layers: int = DEFAULT_LAYERS,
|
|
1186
|
+
n_tok: int = DEFAULT_N_TOK,
|
|
1187
|
+
prev: Optional[Union[bytes, bytearray, str]] = None,
|
|
1188
|
+
api_key: Optional[str] = None,
|
|
1189
|
+
) -> FingerprintResult:
|
|
1190
|
+
"""Module-level shortcut for the common case.
|
|
1191
|
+
|
|
1192
|
+
Lazily constructs a default Client using HYPERCACHE_KEY from the environment
|
|
1193
|
+
on first call. Pass api_key= to use a one-off key without affecting the default.
|
|
1194
|
+
"""
|
|
1195
|
+
global _default_client
|
|
1196
|
+
if api_key is not None:
|
|
1197
|
+
# Fresh client per call when api_key is explicit (no shared state)
|
|
1198
|
+
return Client(api_key=api_key).fingerprint(
|
|
1199
|
+
data, layers=layers, n_tok=n_tok, prev=prev
|
|
1200
|
+
)
|
|
1201
|
+
if _default_client is None:
|
|
1202
|
+
_default_client = Client()
|
|
1203
|
+
return _default_client.fingerprint(data, layers=layers, n_tok=n_tok, prev=prev)
|
|
1204
|
+
|
|
1205
|
+
|
|
1206
|
+
def cache_put(
|
|
1207
|
+
fingerprint: str,
|
|
1208
|
+
data: Any,
|
|
1209
|
+
ttl: Optional[int] = None,
|
|
1210
|
+
api_key: Optional[str] = None,
|
|
1211
|
+
) -> CachePutResult:
|
|
1212
|
+
"""Module-level cache_put shortcut."""
|
|
1213
|
+
global _default_client
|
|
1214
|
+
if api_key is not None:
|
|
1215
|
+
return Client(api_key=api_key).cache_put(fingerprint, data, ttl=ttl)
|
|
1216
|
+
if _default_client is None:
|
|
1217
|
+
_default_client = Client()
|
|
1218
|
+
return _default_client.cache_put(fingerprint, data, ttl=ttl)
|
|
1219
|
+
|
|
1220
|
+
|
|
1221
|
+
def cache_get(fingerprint: str, api_key: Optional[str] = None) -> Optional[bytes]:
|
|
1222
|
+
"""Module-level cache_get shortcut. Returns None on miss."""
|
|
1223
|
+
global _default_client
|
|
1224
|
+
if api_key is not None:
|
|
1225
|
+
return Client(api_key=api_key).cache_get(fingerprint)
|
|
1226
|
+
if _default_client is None:
|
|
1227
|
+
_default_client = Client()
|
|
1228
|
+
return _default_client.cache_get(fingerprint)
|
|
1229
|
+
|
|
1230
|
+
|
|
1231
|
+
def cache_delete(fingerprint: str, api_key: Optional[str] = None) -> None:
|
|
1232
|
+
"""Module-level cache_delete shortcut."""
|
|
1233
|
+
global _default_client
|
|
1234
|
+
if api_key is not None:
|
|
1235
|
+
Client(api_key=api_key).cache_delete(fingerprint)
|
|
1236
|
+
return
|
|
1237
|
+
if _default_client is None:
|
|
1238
|
+
_default_client = Client()
|
|
1239
|
+
_default_client.cache_delete(fingerprint)
|
|
1240
|
+
|
|
1241
|
+
|
|
1242
|
+
def cache_lookup(
|
|
1243
|
+
data: Any,
|
|
1244
|
+
layers: int = DEFAULT_LAYERS,
|
|
1245
|
+
n_tok: int = DEFAULT_N_TOK,
|
|
1246
|
+
api_key: Optional[str] = None,
|
|
1247
|
+
) -> CacheLookupResult:
|
|
1248
|
+
"""Module-level cache_lookup shortcut. Combined fingerprint + cache check (1 op)."""
|
|
1249
|
+
global _default_client
|
|
1250
|
+
if api_key is not None:
|
|
1251
|
+
return Client(api_key=api_key).cache_lookup(data, layers=layers, n_tok=n_tok)
|
|
1252
|
+
if _default_client is None:
|
|
1253
|
+
_default_client = Client()
|
|
1254
|
+
return _default_client.cache_lookup(data, layers=layers, n_tok=n_tok)
|
|
1255
|
+
|
|
1256
|
+
|
|
1257
|
+
def cache_lookup_batch(
|
|
1258
|
+
inputs: list,
|
|
1259
|
+
layers: int = DEFAULT_LAYERS,
|
|
1260
|
+
n_tok: int = DEFAULT_N_TOK,
|
|
1261
|
+
api_key: Optional[str] = None,
|
|
1262
|
+
) -> list:
|
|
1263
|
+
"""Module-level batch lookup shortcut. Many records, one round trip.
|
|
1264
|
+
|
|
1265
|
+
Example:
|
|
1266
|
+
results = hypercache.cache_lookup_batch([b"item one", b"item two", b"item three"])
|
|
1267
|
+
for r in results:
|
|
1268
|
+
print("hit" if r.hit else "miss", r.fingerprint_hex)
|
|
1269
|
+
"""
|
|
1270
|
+
global _default_client
|
|
1271
|
+
if api_key is not None:
|
|
1272
|
+
return Client(api_key=api_key).cache_lookup_batch(inputs, layers=layers, n_tok=n_tok)
|
|
1273
|
+
if _default_client is None:
|
|
1274
|
+
_default_client = Client()
|
|
1275
|
+
return _default_client.cache_lookup_batch(inputs, layers=layers, n_tok=n_tok)
|
|
1276
|
+
|
|
1277
|
+
|
|
1278
|
+
def cached_embedding(
|
|
1279
|
+
model: str,
|
|
1280
|
+
text: str,
|
|
1281
|
+
compute: Any, # Callable[[str], list[float]]
|
|
1282
|
+
ttl: Optional[int] = 86400,
|
|
1283
|
+
api_key: Optional[str] = None,
|
|
1284
|
+
) -> EmbeddingResult:
|
|
1285
|
+
"""Module-level cached_embedding shortcut.
|
|
1286
|
+
|
|
1287
|
+
Example:
|
|
1288
|
+
from openai import OpenAI
|
|
1289
|
+
import hypercache
|
|
1290
|
+
|
|
1291
|
+
client = OpenAI()
|
|
1292
|
+
|
|
1293
|
+
def embed(text: str) -> list[float]:
|
|
1294
|
+
return client.embeddings.create(
|
|
1295
|
+
model="text-embedding-3-small", input=text
|
|
1296
|
+
).data[0].embedding
|
|
1297
|
+
|
|
1298
|
+
result = hypercache.cached_embedding(
|
|
1299
|
+
model="text-embedding-3-small",
|
|
1300
|
+
text="The quick brown fox",
|
|
1301
|
+
compute=embed,
|
|
1302
|
+
)
|
|
1303
|
+
print(result.embedding[:4], "hit=", result.hit)
|
|
1304
|
+
"""
|
|
1305
|
+
global _default_client
|
|
1306
|
+
if api_key is not None:
|
|
1307
|
+
return Client(api_key=api_key).cached_embedding(model, text, compute, ttl=ttl)
|
|
1308
|
+
if _default_client is None:
|
|
1309
|
+
_default_client = Client()
|
|
1310
|
+
return _default_client.cached_embedding(model, text, compute, ttl=ttl)
|