openai-sqlite-cache 0.0.1__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.
cached_openai/_http.py ADDED
@@ -0,0 +1,515 @@
1
+ """Caching HTTP-client adapters used by the OpenAI SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import importlib
7
+ import threading
8
+ import warnings
9
+ import weakref
10
+ import zlib
11
+ from typing import Any, Awaitable, Callable, Iterable, List, Optional, Tuple
12
+
13
+ from ._cache import CachedResponse, SQLiteCache
14
+ from ._fingerprint import request_fingerprint
15
+ from ._settings import CacheSettings
16
+
17
+
18
+ def _sdk_http_module() -> Any:
19
+ """Resolve the HTTP module used by this installed OpenAI SDK.
20
+
21
+ OpenAI 1.x/early 2.x use ``httpx``. Newer SDK releases can use the
22
+ API-compatible ``httpx2`` package, so importing the SDK's own dependency is
23
+ more robust than pinning this package to either implementation.
24
+ """
25
+
26
+ base_client = importlib.import_module("openai._base_client")
27
+ for name in ("httpx2", "httpx"):
28
+ module = getattr(base_client, name, None)
29
+ if module is not None:
30
+ return module
31
+ for name in ("httpx2", "httpx"):
32
+ try:
33
+ return importlib.import_module(name)
34
+ except ImportError:
35
+ continue
36
+ raise ImportError(
37
+ "openai-sqlite-cache could not locate the HTTP library used by openai"
38
+ )
39
+
40
+
41
+ _http = _sdk_http_module()
42
+ _SYNC_LOCKS = [threading.Lock() for _ in range(127)]
43
+ _ASYNC_LOCKS: "weakref.WeakKeyDictionary[Any, List[asyncio.Lock]]" = (
44
+ weakref.WeakKeyDictionary()
45
+ )
46
+ _ASYNC_LOCKS_GUARD = threading.Lock()
47
+ _warning_lock = threading.Lock()
48
+ _warned_messages = set()
49
+
50
+
51
+ def _warn_once(message: str) -> None:
52
+ with _warning_lock:
53
+ if message in _warned_messages:
54
+ return
55
+ _warned_messages.add(message)
56
+ warnings.warn(message, RuntimeWarning, stacklevel=3)
57
+
58
+
59
+ def _sync_lock(cache_key: str) -> Any:
60
+ return _SYNC_LOCKS[int(cache_key[-8:], 16) % len(_SYNC_LOCKS)]
61
+
62
+
63
+ def _async_lock(cache_key: str) -> asyncio.Lock:
64
+ loop = asyncio.get_running_loop()
65
+ with _ASYNC_LOCKS_GUARD:
66
+ locks = _ASYNC_LOCKS.get(loop)
67
+ if locks is None:
68
+ locks = [asyncio.Lock() for _ in range(127)]
69
+ _ASYNC_LOCKS[loop] = locks
70
+ return locks[int(cache_key[-8:], 16) % len(locks)]
71
+
72
+
73
+ _DROPPED_RESPONSE_HEADERS = {
74
+ "connection",
75
+ "content-encoding",
76
+ "content-length",
77
+ "keep-alive",
78
+ "proxy-authenticate",
79
+ "proxy-authorization",
80
+ "te",
81
+ "trailer",
82
+ "transfer-encoding",
83
+ "upgrade",
84
+ }
85
+
86
+
87
+ def _storable_headers(headers: Any, *, raw_body: bool) -> List[Tuple[str, str]]:
88
+ items = (
89
+ headers.multi_items() if hasattr(headers, "multi_items") else headers.items()
90
+ )
91
+ return [
92
+ (str(name), str(value))
93
+ for name, value in items
94
+ if str(name).lower() not in _DROPPED_RESPONSE_HEADERS
95
+ or (raw_body and str(name).lower() == "content-encoding")
96
+ ]
97
+
98
+
99
+ class _SSETerminalDetector:
100
+ """Find an SSE done frame incrementally, including gzip/deflate streams."""
101
+
102
+ _markers = (b"data: [DONE]", b"data:[DONE]")
103
+
104
+ def __init__(self, content_encoding: str):
105
+ self.complete = False
106
+ self._tail = b""
107
+ self._encoding = content_encoding.strip().lower()
108
+ self._decoder: Any = None
109
+ if self._encoding in {"gzip", "x-gzip"}:
110
+ self._decoder = zlib.decompressobj(zlib.MAX_WBITS | 16)
111
+ elif self._encoding == "deflate":
112
+ self._decoder = zlib.decompressobj()
113
+
114
+ def feed(self, chunk: bytes) -> bool:
115
+ if self.complete:
116
+ return True
117
+ if self._encoding and self._decoder is None:
118
+ return False
119
+ decoded = chunk
120
+ if self._decoder is not None:
121
+ try:
122
+ decoded = self._decoder.decompress(chunk)
123
+ except zlib.error:
124
+ return False
125
+ candidate = self._tail + decoded
126
+ self.complete = any(marker in candidate for marker in self._markers)
127
+ longest_marker = max(len(marker) for marker in self._markers)
128
+ self._tail = candidate[-(longest_marker - 1) :]
129
+ return self.complete
130
+
131
+
132
+ class _CacheAccess:
133
+ def __init__(self, settings: CacheSettings):
134
+ self.settings = settings
135
+ self.backend: Optional[SQLiteCache]
136
+ try:
137
+ self.backend = SQLiteCache(settings.path)
138
+ except Exception as error:
139
+ self.backend = None
140
+ _warn_once(
141
+ "openai-sqlite-cache cache could not be opened; "
142
+ f"caching is disabled: {error}"
143
+ )
144
+
145
+ def get(self, key: str) -> Optional[CachedResponse]:
146
+ if self.backend is None:
147
+ return None
148
+ try:
149
+ return self.backend.get(key, self.settings.ttl_seconds)
150
+ except Exception as error:
151
+ _warn_once(
152
+ "openai-sqlite-cache cache read failed; "
153
+ f"requesting upstream instead: {error}"
154
+ )
155
+ return None
156
+
157
+ def put(
158
+ self, key: str, response: Any, body: bytes, *, raw_body: bool = False
159
+ ) -> None:
160
+ if self.backend is None or not 200 <= int(response.status_code) < 300:
161
+ return
162
+ try:
163
+ self.backend.put(
164
+ key,
165
+ int(response.status_code),
166
+ _storable_headers(response.headers, raw_body=raw_body),
167
+ body,
168
+ )
169
+ except Exception as error:
170
+ _warn_once(
171
+ "openai-sqlite-cache cache write failed; "
172
+ f"response was not cached: {error}"
173
+ )
174
+
175
+ @staticmethod
176
+ def response(record: CachedResponse, request: Any) -> Any:
177
+ return _http.Response(
178
+ status_code=record.status_code,
179
+ headers=record.headers,
180
+ content=record.body,
181
+ request=request,
182
+ )
183
+
184
+
185
+ class _CachingSyncStream(_http.SyncByteStream):
186
+ def __init__(
187
+ self,
188
+ stream: Any,
189
+ on_complete: Callable[[bytes], None],
190
+ release: Callable[[], None],
191
+ content_encoding: str,
192
+ ):
193
+ self._stream = stream
194
+ self._on_complete = on_complete
195
+ self._release = release
196
+ self._content_encoding = content_encoding
197
+ self._terminal = _SSETerminalDetector(content_encoding)
198
+ self._chunks = bytearray()
199
+ self._stored = False
200
+ self._released = False
201
+
202
+ def __iter__(self) -> Iterable[bytes]:
203
+ completed = False
204
+ try:
205
+ for chunk in self._stream:
206
+ self._chunks.extend(chunk)
207
+ if self._terminal.feed(chunk):
208
+ self._store_once()
209
+ yield chunk
210
+ completed = True
211
+ self._store_once()
212
+ finally:
213
+ if not completed:
214
+ if self._terminal.complete:
215
+ self._store_once()
216
+ if not self._stored:
217
+ self._chunks.clear()
218
+ self._release_once()
219
+
220
+ def _store_once(self) -> None:
221
+ if not self._stored:
222
+ self._stored = True
223
+ self._on_complete(bytes(self._chunks))
224
+
225
+ def _release_once(self) -> None:
226
+ if not self._released:
227
+ self._released = True
228
+ self._release()
229
+
230
+ def close(self) -> None:
231
+ try:
232
+ self._stream.close()
233
+ finally:
234
+ self._release_once()
235
+
236
+
237
+ class _CachingAsyncStream(_http.AsyncByteStream):
238
+ def __init__(
239
+ self,
240
+ stream: Any,
241
+ on_complete: Callable[[bytes], Awaitable[None]],
242
+ release: Callable[[], None],
243
+ content_encoding: str,
244
+ ):
245
+ self._stream = stream
246
+ self._on_complete = on_complete
247
+ self._release = release
248
+ self._content_encoding = content_encoding
249
+ self._terminal = _SSETerminalDetector(content_encoding)
250
+ self._chunks = bytearray()
251
+ self._stored = False
252
+ self._released = False
253
+
254
+ async def __aiter__(self) -> Any:
255
+ completed = False
256
+ try:
257
+ async for chunk in self._stream:
258
+ self._chunks.extend(chunk)
259
+ if self._terminal.feed(chunk):
260
+ await self._store_once()
261
+ yield chunk
262
+ completed = True
263
+ await self._store_once()
264
+ finally:
265
+ if not completed:
266
+ if self._terminal.complete:
267
+ await self._store_once()
268
+ if not self._stored:
269
+ self._chunks.clear()
270
+ self._release_once()
271
+
272
+ async def _store_once(self) -> None:
273
+ if not self._stored:
274
+ self._stored = True
275
+ await self._on_complete(bytes(self._chunks))
276
+
277
+ def _release_once(self) -> None:
278
+ if not self._released:
279
+ self._released = True
280
+ self._release()
281
+
282
+ async def aclose(self) -> None:
283
+ try:
284
+ if self._terminal.complete:
285
+ await self._store_once()
286
+ await self._stream.aclose()
287
+ finally:
288
+ self._release_once()
289
+
290
+
291
+ class CachingSyncClient(_http.Client):
292
+ """An ``httpx.Client``-compatible proxy around any SDK HTTP client."""
293
+
294
+ def __init__(self, inner: Any, settings: CacheSettings):
295
+ self._cached_openai_inner = inner
296
+ self._cached_openai_cache = _CacheAccess(settings)
297
+ self._cached_openai_settings = settings
298
+
299
+ @property
300
+ def timeout(self) -> Any:
301
+ return self._cached_openai_inner.timeout
302
+
303
+ @timeout.setter
304
+ def timeout(self, value: Any) -> None:
305
+ self._cached_openai_inner.timeout = value
306
+
307
+ @property
308
+ def is_closed(self) -> bool:
309
+ return bool(self._cached_openai_inner.is_closed)
310
+
311
+ def build_request(self, *args: Any, **kwargs: Any) -> Any:
312
+ return self._cached_openai_inner.build_request(*args, **kwargs)
313
+
314
+ def send(self, request: Any, *args: Any, **kwargs: Any) -> Any:
315
+ if not self._cached_openai_settings.enabled:
316
+ return self._cached_openai_inner.send(request, *args, **kwargs)
317
+ try:
318
+ body = request.read()
319
+ cache_key = request_fingerprint(
320
+ str(request.method), str(request.url), request.headers, body
321
+ )
322
+ except Exception as error:
323
+ _warn_once(
324
+ "openai-sqlite-cache could not fingerprint a request; "
325
+ f"bypassing cache: {error}"
326
+ )
327
+ return self._cached_openai_inner.send(request, *args, **kwargs)
328
+ if cache_key is None:
329
+ return self._cached_openai_inner.send(request, *args, **kwargs)
330
+
331
+ lock = _sync_lock(cache_key)
332
+ lock.acquire()
333
+ release_here = True
334
+ try:
335
+ cached = self._cached_openai_cache.get(cache_key)
336
+ if cached is not None:
337
+ try:
338
+ return self._cached_openai_cache.response(cached, request)
339
+ except Exception as error:
340
+ _warn_once(
341
+ "openai-sqlite-cache could not reconstruct a cached response; "
342
+ f"requesting upstream instead: {error}"
343
+ )
344
+
345
+ response = self._cached_openai_inner.send(request, *args, **kwargs)
346
+ if not 200 <= int(response.status_code) < 300:
347
+ return response
348
+ if kwargs.get("stream", False):
349
+ try:
350
+ buffered_content = bytes(response.content)
351
+ except Exception:
352
+ release_here = False
353
+ response.stream = _CachingSyncStream(
354
+ response.stream,
355
+ lambda content: self._cached_openai_cache.put(
356
+ cache_key, response, content, raw_body=True
357
+ ),
358
+ lock.release,
359
+ str(response.headers.get("content-encoding", "")),
360
+ )
361
+ else:
362
+ self._cached_openai_cache.put(cache_key, response, buffered_content)
363
+ else:
364
+ self._cached_openai_cache.put(
365
+ cache_key, response, bytes(response.content)
366
+ )
367
+ return response
368
+ finally:
369
+ if release_here:
370
+ lock.release()
371
+
372
+ def close(self) -> None:
373
+ self._cached_openai_inner.close()
374
+
375
+ def __enter__(self) -> "CachingSyncClient":
376
+ self._cached_openai_inner.__enter__()
377
+ return self
378
+
379
+ def __exit__(self, *args: Any) -> Any:
380
+ return self._cached_openai_inner.__exit__(*args)
381
+
382
+ def __getattr__(self, name: str) -> Any:
383
+ return getattr(self._cached_openai_inner, name)
384
+
385
+
386
+ class CachingAsyncClient(_http.AsyncClient):
387
+ """An ``httpx.AsyncClient``-compatible proxy around any SDK HTTP client."""
388
+
389
+ def __init__(self, inner: Any, settings: CacheSettings):
390
+ self._cached_openai_inner = inner
391
+ self._cached_openai_cache = _CacheAccess(settings)
392
+ self._cached_openai_settings = settings
393
+
394
+ @property
395
+ def timeout(self) -> Any:
396
+ return self._cached_openai_inner.timeout
397
+
398
+ @timeout.setter
399
+ def timeout(self, value: Any) -> None:
400
+ self._cached_openai_inner.timeout = value
401
+
402
+ @property
403
+ def is_closed(self) -> bool:
404
+ return bool(self._cached_openai_inner.is_closed)
405
+
406
+ def build_request(self, *args: Any, **kwargs: Any) -> Any:
407
+ return self._cached_openai_inner.build_request(*args, **kwargs)
408
+
409
+ async def send(self, request: Any, *args: Any, **kwargs: Any) -> Any:
410
+ if not self._cached_openai_settings.enabled:
411
+ return await self._cached_openai_inner.send(request, *args, **kwargs)
412
+ try:
413
+ body = await request.aread()
414
+ cache_key = request_fingerprint(
415
+ str(request.method), str(request.url), request.headers, body
416
+ )
417
+ except Exception as error:
418
+ _warn_once(
419
+ "openai-sqlite-cache could not fingerprint a request; "
420
+ f"bypassing cache: {error}"
421
+ )
422
+ return await self._cached_openai_inner.send(request, *args, **kwargs)
423
+ if cache_key is None:
424
+ return await self._cached_openai_inner.send(request, *args, **kwargs)
425
+
426
+ lock = _async_lock(cache_key)
427
+ await lock.acquire()
428
+ release_here = True
429
+ try:
430
+ cached = await asyncio.to_thread(self._cached_openai_cache.get, cache_key)
431
+ if cached is not None:
432
+ try:
433
+ return self._cached_openai_cache.response(cached, request)
434
+ except Exception as error:
435
+ _warn_once(
436
+ "openai-sqlite-cache could not reconstruct a cached response; "
437
+ f"requesting upstream instead: {error}"
438
+ )
439
+
440
+ response = await self._cached_openai_inner.send(request, *args, **kwargs)
441
+ if not 200 <= int(response.status_code) < 300:
442
+ return response
443
+ if kwargs.get("stream", False):
444
+ try:
445
+ buffered_content = bytes(response.content)
446
+ except Exception:
447
+ release_here = False
448
+
449
+ async def store(content: bytes) -> None:
450
+ try:
451
+ await asyncio.to_thread(
452
+ self._cached_openai_cache.put,
453
+ cache_key,
454
+ response,
455
+ content,
456
+ raw_body=True,
457
+ )
458
+ except Exception as error:
459
+ _warn_once(
460
+ f"openai-sqlite-cache cache write failed: {error}"
461
+ )
462
+
463
+ response.stream = _CachingAsyncStream(
464
+ response.stream,
465
+ store,
466
+ lock.release,
467
+ str(response.headers.get("content-encoding", "")),
468
+ )
469
+ else:
470
+ await asyncio.to_thread(
471
+ self._cached_openai_cache.put,
472
+ cache_key,
473
+ response,
474
+ buffered_content,
475
+ )
476
+ else:
477
+ await asyncio.to_thread(
478
+ self._cached_openai_cache.put,
479
+ cache_key,
480
+ response,
481
+ bytes(response.content),
482
+ )
483
+ return response
484
+ finally:
485
+ if release_here:
486
+ lock.release()
487
+
488
+ async def aclose(self) -> None:
489
+ await self._cached_openai_inner.aclose()
490
+
491
+ async def __aenter__(self) -> "CachingAsyncClient":
492
+ await self._cached_openai_inner.__aenter__()
493
+ return self
494
+
495
+ async def __aexit__(self, *args: Any) -> Any:
496
+ return await self._cached_openai_inner.__aexit__(*args)
497
+
498
+ def __getattr__(self, name: str) -> Any:
499
+ return getattr(self._cached_openai_inner, name)
500
+
501
+
502
+ def wrap_sync_client(client: Any, settings: CacheSettings) -> Any:
503
+ if isinstance(client, CachingSyncClient):
504
+ if client._cached_openai_settings == settings:
505
+ return client
506
+ client = client._cached_openai_inner
507
+ return CachingSyncClient(client, settings)
508
+
509
+
510
+ def wrap_async_client(client: Any, settings: CacheSettings) -> Any:
511
+ if isinstance(client, CachingAsyncClient):
512
+ if client._cached_openai_settings == settings:
513
+ return client
514
+ client = client._cached_openai_inner
515
+ return CachingAsyncClient(client, settings)
@@ -0,0 +1,86 @@
1
+ """Lazy aliases for public submodules from the upstream ``openai`` package."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib
6
+ import importlib.abc
7
+ import importlib.util
8
+ import sys
9
+ from types import ModuleType
10
+ from typing import Any, Optional
11
+
12
+
13
+ class _AliasModule(ModuleType):
14
+ def __init__(self, alias: str, target_name: str):
15
+ super().__init__(alias)
16
+ target = importlib.import_module(target_name)
17
+ self.__dict__["_alias_target"] = target
18
+ self.__dict__["__doc__"] = getattr(target, "__doc__", None)
19
+ exported = getattr(target, "__all__", None)
20
+ self.__dict__["__all__"] = (
21
+ list(exported)
22
+ if exported is not None
23
+ else [name for name in dir(target) if not name.startswith("_")]
24
+ )
25
+ self.__dict__["__file__"] = getattr(target, "__file__", None)
26
+ if hasattr(target, "__path__"):
27
+ self.__dict__["__path__"] = list(target.__path__)
28
+
29
+ def __getattr__(self, name: str) -> Any:
30
+ return getattr(self.__dict__["_alias_target"], name)
31
+
32
+ def __dir__(self) -> list:
33
+ return sorted(set(self.__dict__) | set(dir(self.__dict__["_alias_target"])))
34
+
35
+
36
+ class _AliasLoader(importlib.abc.Loader):
37
+ def __init__(self, alias: str, target: str):
38
+ self.alias = alias
39
+ self.target = target
40
+
41
+ def create_module(self, spec: Any) -> ModuleType:
42
+ return _AliasModule(self.alias, self.target)
43
+
44
+ def exec_module(self, module: ModuleType) -> None:
45
+ return None
46
+
47
+
48
+ class _AliasFinder(importlib.abc.MetaPathFinder):
49
+ def __init__(self, alias_root: str, target_root: str):
50
+ self.alias_root = alias_root
51
+ self.target_root = target_root
52
+
53
+ def find_spec(
54
+ self, fullname: str, path: Any = None, target: Any = None
55
+ ) -> Optional[Any]:
56
+ prefix = self.alias_root + "."
57
+ if not fullname.startswith(prefix):
58
+ return None
59
+ target_name = self.target_root + fullname[len(self.alias_root) :]
60
+ target_spec = importlib.util.find_spec(target_name)
61
+ if target_spec is None:
62
+ return None
63
+ is_package = target_spec.submodule_search_locations is not None
64
+ return importlib.util.spec_from_loader(
65
+ fullname,
66
+ _AliasLoader(fullname, target_name),
67
+ is_package=is_package,
68
+ )
69
+
70
+
71
+ def install_module_alias(alias_root: str, target_root: str) -> ModuleType:
72
+ """Install a lazy module tree alias while preserving exported class identity."""
73
+
74
+ existing = sys.modules.get(alias_root)
75
+ if isinstance(existing, _AliasModule):
76
+ return existing
77
+ proxy = _AliasModule(alias_root, target_root)
78
+ sys.modules[alias_root] = proxy
79
+ if not any(
80
+ isinstance(finder, _AliasFinder)
81
+ and finder.alias_root == alias_root
82
+ and finder.target_root == target_root
83
+ for finder in sys.meta_path
84
+ ):
85
+ sys.meta_path.insert(0, _AliasFinder(alias_root, target_root))
86
+ return proxy