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)