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/__init__.py +175 -0
- cached_openai/_cache.py +145 -0
- cached_openai/_client.py +166 -0
- cached_openai/_fingerprint.py +186 -0
- cached_openai/_http.py +515 -0
- cached_openai/_module_alias.py +86 -0
- cached_openai/_settings.py +122 -0
- cached_openai/_version.py +3 -0
- cached_openai/py.typed +1 -0
- openai_sqlite_cache-0.0.1.dist-info/METADATA +151 -0
- openai_sqlite_cache-0.0.1.dist-info/RECORD +14 -0
- openai_sqlite_cache-0.0.1.dist-info/WHEEL +5 -0
- openai_sqlite_cache-0.0.1.dist-info/licenses/LICENSE +21 -0
- openai_sqlite_cache-0.0.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Drop-in OpenAI Python SDK with a persistent local SQLite cache.
|
|
2
|
+
|
|
3
|
+
Use ``import cached_openai as openai`` and keep the rest of an existing OpenAI
|
|
4
|
+
SDK integration unchanged.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import sys
|
|
10
|
+
import types as _stdlib_types
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, Optional
|
|
13
|
+
|
|
14
|
+
import openai as _openai
|
|
15
|
+
from openai import * # noqa: F403
|
|
16
|
+
|
|
17
|
+
from . import _client as _cached_client
|
|
18
|
+
from ._cache import SQLiteCache
|
|
19
|
+
from ._http import CachingSyncClient, wrap_sync_client
|
|
20
|
+
from ._module_alias import install_module_alias
|
|
21
|
+
from ._settings import _UNSET, configure_settings, get_settings
|
|
22
|
+
from ._version import PACKAGE_VERSION
|
|
23
|
+
|
|
24
|
+
OpenAI = _cached_client.OpenAI
|
|
25
|
+
AsyncOpenAI = _cached_client.AsyncOpenAI
|
|
26
|
+
Client = _cached_client.Client
|
|
27
|
+
AsyncClient = _cached_client.AsyncClient
|
|
28
|
+
AzureOpenAI = _cached_client.AzureOpenAI
|
|
29
|
+
AsyncAzureOpenAI = _cached_client.AsyncAzureOpenAI
|
|
30
|
+
CachedOpenAI = _cached_client.CachedOpenAI
|
|
31
|
+
AsyncCachedOpenAI = _cached_client.AsyncCachedOpenAI
|
|
32
|
+
|
|
33
|
+
__version__ = PACKAGE_VERSION
|
|
34
|
+
openai_version = _openai.__version__
|
|
35
|
+
|
|
36
|
+
_MODULE_CONFIG_NAMES = {
|
|
37
|
+
"api_key",
|
|
38
|
+
"organization",
|
|
39
|
+
"project",
|
|
40
|
+
"webhook_secret",
|
|
41
|
+
"base_url",
|
|
42
|
+
"timeout",
|
|
43
|
+
"max_retries",
|
|
44
|
+
"default_headers",
|
|
45
|
+
"default_query",
|
|
46
|
+
"http_client",
|
|
47
|
+
"api_type",
|
|
48
|
+
"api_version",
|
|
49
|
+
"azure_endpoint",
|
|
50
|
+
"azure_ad_token",
|
|
51
|
+
"azure_ad_token_provider",
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
_MODULE_RESOURCE_NAMES = {
|
|
55
|
+
"audio",
|
|
56
|
+
"batches",
|
|
57
|
+
"beta",
|
|
58
|
+
"chat",
|
|
59
|
+
"completions",
|
|
60
|
+
"containers",
|
|
61
|
+
"conversations",
|
|
62
|
+
"embeddings",
|
|
63
|
+
"evals",
|
|
64
|
+
"files",
|
|
65
|
+
"fine_tuning",
|
|
66
|
+
"images",
|
|
67
|
+
"models",
|
|
68
|
+
"moderations",
|
|
69
|
+
"realtime",
|
|
70
|
+
"responses",
|
|
71
|
+
"skills",
|
|
72
|
+
"uploads",
|
|
73
|
+
"vector_stores",
|
|
74
|
+
"videos",
|
|
75
|
+
"webhooks",
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _reset_upstream_module_client() -> None:
|
|
80
|
+
reset = getattr(_openai, "_reset_client", None)
|
|
81
|
+
if reset is not None:
|
|
82
|
+
reset()
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _prepare_module_client() -> None:
|
|
86
|
+
settings = get_settings()
|
|
87
|
+
if not settings.enabled:
|
|
88
|
+
return
|
|
89
|
+
current = getattr(_openai, "http_client", None)
|
|
90
|
+
if current is None:
|
|
91
|
+
current = _openai.DefaultHttpxClient()
|
|
92
|
+
wrapped = wrap_sync_client(current, settings)
|
|
93
|
+
if wrapped is not getattr(_openai, "http_client", None):
|
|
94
|
+
_openai.http_client = wrapped
|
|
95
|
+
_reset_upstream_module_client()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def configure_cache(
|
|
99
|
+
*,
|
|
100
|
+
path: Any = _UNSET,
|
|
101
|
+
ttl_seconds: Any = _UNSET,
|
|
102
|
+
enabled: Any = _UNSET,
|
|
103
|
+
) -> dict:
|
|
104
|
+
"""Configure defaults used by new clients and the module-level client."""
|
|
105
|
+
|
|
106
|
+
settings = configure_settings(path=path, ttl_seconds=ttl_seconds, enabled=enabled)
|
|
107
|
+
current = getattr(_openai, "http_client", None)
|
|
108
|
+
if isinstance(current, CachingSyncClient):
|
|
109
|
+
_openai.http_client = current._cached_openai_inner
|
|
110
|
+
_reset_upstream_module_client()
|
|
111
|
+
return {
|
|
112
|
+
"path": str(settings.path.expanduser().resolve()),
|
|
113
|
+
"ttl_seconds": settings.ttl_seconds,
|
|
114
|
+
"enabled": settings.enabled,
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def clear_cache(path: Optional[Any] = None) -> int:
|
|
119
|
+
"""Delete all cached response rows and return the number removed."""
|
|
120
|
+
|
|
121
|
+
cache_path = get_settings().path if path is None else Path(path).expanduser()
|
|
122
|
+
return SQLiteCache(cache_path).clear()
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def cache_info(path: Optional[Any] = None) -> dict:
|
|
126
|
+
"""Return cache path, row count, stored bytes, and cumulative hit count."""
|
|
127
|
+
|
|
128
|
+
cache_path = get_settings().path if path is None else Path(path).expanduser()
|
|
129
|
+
return SQLiteCache(cache_path).stats()
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def __getattr__(name: str) -> Any:
|
|
133
|
+
if name in _MODULE_RESOURCE_NAMES:
|
|
134
|
+
_prepare_module_client()
|
|
135
|
+
return getattr(_openai, name)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def __dir__() -> list:
|
|
139
|
+
return sorted(set(globals()) | set(dir(_openai)))
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class _CachedOpenAIModule(_stdlib_types.ModuleType):
|
|
143
|
+
def __setattr__(self, name: str, value: Any) -> None:
|
|
144
|
+
if name in _MODULE_CONFIG_NAMES:
|
|
145
|
+
if name == "http_client" and isinstance(value, CachingSyncClient):
|
|
146
|
+
value = value._cached_openai_inner
|
|
147
|
+
setattr(_openai, name, value)
|
|
148
|
+
_reset_upstream_module_client()
|
|
149
|
+
return
|
|
150
|
+
super().__setattr__(name, value)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
sys.modules[__name__].__class__ = _CachedOpenAIModule
|
|
154
|
+
|
|
155
|
+
# Preserve class identity for imports such as
|
|
156
|
+
# ``from cached_openai.types.chat import ChatCompletion``.
|
|
157
|
+
types = install_module_alias(__name__ + ".types", "openai.types")
|
|
158
|
+
|
|
159
|
+
__all__ = sorted(
|
|
160
|
+
set(getattr(_openai, "__all__", []))
|
|
161
|
+
| {
|
|
162
|
+
"AsyncAzureOpenAI",
|
|
163
|
+
"AsyncCachedOpenAI",
|
|
164
|
+
"AsyncClient",
|
|
165
|
+
"AsyncOpenAI",
|
|
166
|
+
"AzureOpenAI",
|
|
167
|
+
"CachedOpenAI",
|
|
168
|
+
"Client",
|
|
169
|
+
"OpenAI",
|
|
170
|
+
"cache_info",
|
|
171
|
+
"clear_cache",
|
|
172
|
+
"configure_cache",
|
|
173
|
+
"openai_version",
|
|
174
|
+
}
|
|
175
|
+
)
|
cached_openai/_cache.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""A small, process-safe SQLite store for HTTP response payloads."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import sqlite3
|
|
8
|
+
import time
|
|
9
|
+
from contextlib import closing
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from threading import RLock
|
|
13
|
+
from typing import List, Optional, Sequence, Tuple
|
|
14
|
+
|
|
15
|
+
HeaderList = List[Tuple[str, str]]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class CachedResponse:
|
|
20
|
+
status_code: int
|
|
21
|
+
headers: HeaderList
|
|
22
|
+
body: bytes
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class SQLiteCache:
|
|
26
|
+
"""Persistent cache whose rows contain responses, never request bodies."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, path: Path):
|
|
29
|
+
self.path = path.expanduser().resolve()
|
|
30
|
+
self._lock = RLock()
|
|
31
|
+
self._initialize()
|
|
32
|
+
|
|
33
|
+
def _connect(self) -> sqlite3.Connection:
|
|
34
|
+
connection = sqlite3.connect(str(self.path), timeout=10.0)
|
|
35
|
+
connection.execute("PRAGMA busy_timeout = 10000")
|
|
36
|
+
return connection
|
|
37
|
+
|
|
38
|
+
def _initialize(self) -> None:
|
|
39
|
+
self.path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
40
|
+
with self._lock, closing(self._connect()) as connection, connection:
|
|
41
|
+
connection.execute("PRAGMA journal_mode = WAL")
|
|
42
|
+
connection.execute(
|
|
43
|
+
"""
|
|
44
|
+
CREATE TABLE IF NOT EXISTS responses (
|
|
45
|
+
cache_key TEXT PRIMARY KEY,
|
|
46
|
+
status_code INTEGER NOT NULL,
|
|
47
|
+
headers_json TEXT NOT NULL,
|
|
48
|
+
body BLOB NOT NULL,
|
|
49
|
+
created_at REAL NOT NULL,
|
|
50
|
+
last_accessed_at REAL NOT NULL,
|
|
51
|
+
hit_count INTEGER NOT NULL DEFAULT 0
|
|
52
|
+
)
|
|
53
|
+
"""
|
|
54
|
+
)
|
|
55
|
+
try:
|
|
56
|
+
os.chmod(self.path, 0o600)
|
|
57
|
+
except OSError:
|
|
58
|
+
pass
|
|
59
|
+
|
|
60
|
+
def get(
|
|
61
|
+
self, cache_key: str, ttl_seconds: Optional[float]
|
|
62
|
+
) -> Optional[CachedResponse]:
|
|
63
|
+
now = time.time()
|
|
64
|
+
with self._lock, closing(self._connect()) as connection, connection:
|
|
65
|
+
row = connection.execute(
|
|
66
|
+
"""
|
|
67
|
+
SELECT status_code, headers_json, body, created_at
|
|
68
|
+
FROM responses
|
|
69
|
+
WHERE cache_key = ?
|
|
70
|
+
""",
|
|
71
|
+
(cache_key,),
|
|
72
|
+
).fetchone()
|
|
73
|
+
if row is None:
|
|
74
|
+
return None
|
|
75
|
+
if ttl_seconds is not None and now - float(row[3]) >= ttl_seconds:
|
|
76
|
+
connection.execute(
|
|
77
|
+
"DELETE FROM responses WHERE cache_key = ?", (cache_key,)
|
|
78
|
+
)
|
|
79
|
+
return None
|
|
80
|
+
connection.execute(
|
|
81
|
+
"""
|
|
82
|
+
UPDATE responses
|
|
83
|
+
SET last_accessed_at = ?, hit_count = hit_count + 1
|
|
84
|
+
WHERE cache_key = ?
|
|
85
|
+
""",
|
|
86
|
+
(now, cache_key),
|
|
87
|
+
)
|
|
88
|
+
raw_headers = json.loads(str(row[1]))
|
|
89
|
+
headers = [(str(name), str(value)) for name, value in raw_headers]
|
|
90
|
+
return CachedResponse(
|
|
91
|
+
status_code=int(row[0]), headers=headers, body=bytes(row[2])
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
def put(
|
|
95
|
+
self,
|
|
96
|
+
cache_key: str,
|
|
97
|
+
status_code: int,
|
|
98
|
+
headers: Sequence[Tuple[str, str]],
|
|
99
|
+
body: bytes,
|
|
100
|
+
) -> None:
|
|
101
|
+
now = time.time()
|
|
102
|
+
headers_json = json.dumps(
|
|
103
|
+
list(headers), ensure_ascii=False, separators=(",", ":")
|
|
104
|
+
)
|
|
105
|
+
with self._lock, closing(self._connect()) as connection, connection:
|
|
106
|
+
connection.execute(
|
|
107
|
+
"""
|
|
108
|
+
INSERT INTO responses (
|
|
109
|
+
cache_key, status_code, headers_json, body,
|
|
110
|
+
created_at, last_accessed_at, hit_count
|
|
111
|
+
) VALUES (?, ?, ?, ?, ?, ?, 0)
|
|
112
|
+
ON CONFLICT(cache_key) DO UPDATE SET
|
|
113
|
+
status_code = excluded.status_code,
|
|
114
|
+
headers_json = excluded.headers_json,
|
|
115
|
+
body = excluded.body,
|
|
116
|
+
created_at = excluded.created_at,
|
|
117
|
+
last_accessed_at = excluded.last_accessed_at,
|
|
118
|
+
hit_count = 0
|
|
119
|
+
""",
|
|
120
|
+
(cache_key, status_code, headers_json, sqlite3.Binary(body), now, now),
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
def clear(self) -> int:
|
|
124
|
+
with self._lock, closing(self._connect()) as connection, connection:
|
|
125
|
+
count = int(
|
|
126
|
+
connection.execute("SELECT COUNT(*) FROM responses").fetchone()[0]
|
|
127
|
+
)
|
|
128
|
+
connection.execute("DELETE FROM responses")
|
|
129
|
+
return count
|
|
130
|
+
|
|
131
|
+
def stats(self) -> dict:
|
|
132
|
+
with self._lock, closing(self._connect()) as connection, connection:
|
|
133
|
+
row = connection.execute(
|
|
134
|
+
"""
|
|
135
|
+
SELECT COUNT(*), COALESCE(SUM(LENGTH(body)), 0),
|
|
136
|
+
COALESCE(SUM(hit_count), 0)
|
|
137
|
+
FROM responses
|
|
138
|
+
"""
|
|
139
|
+
).fetchone()
|
|
140
|
+
return {
|
|
141
|
+
"path": str(self.path),
|
|
142
|
+
"entries": int(row[0]),
|
|
143
|
+
"response_bytes": int(row[1]),
|
|
144
|
+
"hits": int(row[2]),
|
|
145
|
+
}
|
cached_openai/_client.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Drop-in cached variants of OpenAI's explicit clients."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import openai as _openai
|
|
9
|
+
|
|
10
|
+
from ._http import (
|
|
11
|
+
CachingAsyncClient,
|
|
12
|
+
CachingSyncClient,
|
|
13
|
+
wrap_async_client,
|
|
14
|
+
wrap_sync_client,
|
|
15
|
+
)
|
|
16
|
+
from ._settings import _UNSET, CacheSettings, settings_for_client
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _settings_from_existing(
|
|
20
|
+
existing: CacheSettings,
|
|
21
|
+
cache_path: Any,
|
|
22
|
+
cache_ttl: Any,
|
|
23
|
+
cache_enabled: Any,
|
|
24
|
+
) -> CacheSettings:
|
|
25
|
+
path = (
|
|
26
|
+
existing.path
|
|
27
|
+
if cache_path is _UNSET or cache_path is None
|
|
28
|
+
else Path(cache_path).expanduser()
|
|
29
|
+
)
|
|
30
|
+
ttl = existing.ttl_seconds if cache_ttl is _UNSET else cache_ttl
|
|
31
|
+
enabled = (
|
|
32
|
+
existing.enabled
|
|
33
|
+
if cache_enabled is _UNSET or cache_enabled is None
|
|
34
|
+
else cache_enabled
|
|
35
|
+
)
|
|
36
|
+
if ttl is not None and float(ttl) < 0:
|
|
37
|
+
raise ValueError("cache_ttl must be zero or greater")
|
|
38
|
+
return CacheSettings(
|
|
39
|
+
path=path,
|
|
40
|
+
ttl_seconds=None if ttl is None else float(ttl),
|
|
41
|
+
enabled=bool(enabled),
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _sync_kwargs(
|
|
46
|
+
kwargs: dict, cache_path: Any, cache_ttl: Any, cache_enabled: Any
|
|
47
|
+
) -> dict:
|
|
48
|
+
original = kwargs.get("http_client")
|
|
49
|
+
if isinstance(original, CachingSyncClient):
|
|
50
|
+
settings = _settings_from_existing(
|
|
51
|
+
original._cached_openai_settings,
|
|
52
|
+
cache_path,
|
|
53
|
+
cache_ttl,
|
|
54
|
+
cache_enabled,
|
|
55
|
+
)
|
|
56
|
+
else:
|
|
57
|
+
settings = settings_for_client(
|
|
58
|
+
cache_path=cache_path,
|
|
59
|
+
cache_ttl=cache_ttl,
|
|
60
|
+
cache_enabled=cache_enabled,
|
|
61
|
+
)
|
|
62
|
+
if not settings.enabled:
|
|
63
|
+
if isinstance(original, CachingSyncClient):
|
|
64
|
+
kwargs["http_client"] = original._cached_openai_inner
|
|
65
|
+
return kwargs
|
|
66
|
+
if original is None:
|
|
67
|
+
original = _openai.DefaultHttpxClient()
|
|
68
|
+
kwargs["http_client"] = wrap_sync_client(original, settings)
|
|
69
|
+
return kwargs
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _async_kwargs(
|
|
73
|
+
kwargs: dict, cache_path: Any, cache_ttl: Any, cache_enabled: Any
|
|
74
|
+
) -> dict:
|
|
75
|
+
original = kwargs.get("http_client")
|
|
76
|
+
if isinstance(original, CachingAsyncClient):
|
|
77
|
+
settings = _settings_from_existing(
|
|
78
|
+
original._cached_openai_settings,
|
|
79
|
+
cache_path,
|
|
80
|
+
cache_ttl,
|
|
81
|
+
cache_enabled,
|
|
82
|
+
)
|
|
83
|
+
else:
|
|
84
|
+
settings = settings_for_client(
|
|
85
|
+
cache_path=cache_path,
|
|
86
|
+
cache_ttl=cache_ttl,
|
|
87
|
+
cache_enabled=cache_enabled,
|
|
88
|
+
)
|
|
89
|
+
if not settings.enabled:
|
|
90
|
+
if isinstance(original, CachingAsyncClient):
|
|
91
|
+
kwargs["http_client"] = original._cached_openai_inner
|
|
92
|
+
return kwargs
|
|
93
|
+
if original is None:
|
|
94
|
+
original = _openai.DefaultAsyncHttpxClient()
|
|
95
|
+
kwargs["http_client"] = wrap_async_client(original, settings)
|
|
96
|
+
return kwargs
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class OpenAI(_openai.OpenAI):
|
|
100
|
+
"""The official synchronous OpenAI client with an SQLite cache."""
|
|
101
|
+
|
|
102
|
+
def __init__(
|
|
103
|
+
self,
|
|
104
|
+
*args: Any,
|
|
105
|
+
cache_path: Any = _UNSET,
|
|
106
|
+
cache_ttl: Any = _UNSET,
|
|
107
|
+
cache_enabled: Any = _UNSET,
|
|
108
|
+
**kwargs: Any,
|
|
109
|
+
) -> None:
|
|
110
|
+
super().__init__(
|
|
111
|
+
*args, **_sync_kwargs(kwargs, cache_path, cache_ttl, cache_enabled)
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class AsyncOpenAI(_openai.AsyncOpenAI):
|
|
116
|
+
"""The official asynchronous OpenAI client with an SQLite cache."""
|
|
117
|
+
|
|
118
|
+
def __init__(
|
|
119
|
+
self,
|
|
120
|
+
*args: Any,
|
|
121
|
+
cache_path: Any = _UNSET,
|
|
122
|
+
cache_ttl: Any = _UNSET,
|
|
123
|
+
cache_enabled: Any = _UNSET,
|
|
124
|
+
**kwargs: Any,
|
|
125
|
+
) -> None:
|
|
126
|
+
super().__init__(
|
|
127
|
+
*args, **_async_kwargs(kwargs, cache_path, cache_ttl, cache_enabled)
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class AzureOpenAI(_openai.AzureOpenAI):
|
|
132
|
+
"""The official synchronous Azure OpenAI client with an SQLite cache."""
|
|
133
|
+
|
|
134
|
+
def __init__(
|
|
135
|
+
self,
|
|
136
|
+
*args: Any,
|
|
137
|
+
cache_path: Any = _UNSET,
|
|
138
|
+
cache_ttl: Any = _UNSET,
|
|
139
|
+
cache_enabled: Any = _UNSET,
|
|
140
|
+
**kwargs: Any,
|
|
141
|
+
) -> None:
|
|
142
|
+
super().__init__(
|
|
143
|
+
*args, **_sync_kwargs(kwargs, cache_path, cache_ttl, cache_enabled)
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class AsyncAzureOpenAI(_openai.AsyncAzureOpenAI):
|
|
148
|
+
"""The official asynchronous Azure OpenAI client with an SQLite cache."""
|
|
149
|
+
|
|
150
|
+
def __init__(
|
|
151
|
+
self,
|
|
152
|
+
*args: Any,
|
|
153
|
+
cache_path: Any = _UNSET,
|
|
154
|
+
cache_ttl: Any = _UNSET,
|
|
155
|
+
cache_enabled: Any = _UNSET,
|
|
156
|
+
**kwargs: Any,
|
|
157
|
+
) -> None:
|
|
158
|
+
super().__init__(
|
|
159
|
+
*args, **_async_kwargs(kwargs, cache_path, cache_ttl, cache_enabled)
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
Client = OpenAI
|
|
164
|
+
AsyncClient = AsyncOpenAI
|
|
165
|
+
CachedOpenAI = OpenAI
|
|
166
|
+
AsyncCachedOpenAI = AsyncOpenAI
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""Stable, privacy-conscious request fingerprints for cacheable OpenAI APIs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
from email.parser import BytesParser
|
|
8
|
+
from email.policy import default as email_policy
|
|
9
|
+
from typing import Any, Dict, List, Mapping, Optional, Tuple
|
|
10
|
+
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
|
11
|
+
|
|
12
|
+
_CACHEABLE_SUFFIXES = (
|
|
13
|
+
"/responses",
|
|
14
|
+
"/responses/compact",
|
|
15
|
+
"/chat/completions",
|
|
16
|
+
"/completions",
|
|
17
|
+
"/embeddings",
|
|
18
|
+
"/moderations",
|
|
19
|
+
"/images/generations",
|
|
20
|
+
"/images/edits",
|
|
21
|
+
"/images/variations",
|
|
22
|
+
"/audio/speech",
|
|
23
|
+
"/audio/transcriptions",
|
|
24
|
+
"/audio/translations",
|
|
25
|
+
"/videos",
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
_CREDENTIAL_HEADERS = {
|
|
29
|
+
"authorization",
|
|
30
|
+
"api-key",
|
|
31
|
+
"cookie",
|
|
32
|
+
"x-api-key",
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
_IGNORED_HEADERS = {
|
|
36
|
+
"accept-encoding",
|
|
37
|
+
"connection",
|
|
38
|
+
"content-length",
|
|
39
|
+
"host",
|
|
40
|
+
"idempotency-key",
|
|
41
|
+
"traceparent",
|
|
42
|
+
"tracestate",
|
|
43
|
+
"transfer-encoding",
|
|
44
|
+
"user-agent",
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _canonical_json(value: Any) -> bytes:
|
|
49
|
+
return json.dumps(
|
|
50
|
+
value,
|
|
51
|
+
ensure_ascii=False,
|
|
52
|
+
sort_keys=True,
|
|
53
|
+
separators=(",", ":"),
|
|
54
|
+
allow_nan=False,
|
|
55
|
+
).encode("utf-8")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _normalized_url(url: str) -> str:
|
|
59
|
+
parts = urlsplit(url)
|
|
60
|
+
hostname = (parts.hostname or "").lower()
|
|
61
|
+
port = parts.port
|
|
62
|
+
default_port = (parts.scheme.lower() == "https" and port == 443) or (
|
|
63
|
+
parts.scheme.lower() == "http" and port == 80
|
|
64
|
+
)
|
|
65
|
+
authority = hostname if port is None or default_port else f"{hostname}:{port}"
|
|
66
|
+
query = urlencode(
|
|
67
|
+
sorted(parse_qsl(parts.query, keep_blank_values=True)), doseq=True
|
|
68
|
+
)
|
|
69
|
+
return urlunsplit((parts.scheme.lower(), authority, parts.path, query, ""))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _is_cacheable_path(path: str) -> bool:
|
|
73
|
+
normalized = path.rstrip("/").lower()
|
|
74
|
+
return any(normalized.endswith(suffix) for suffix in _CACHEABLE_SUFFIXES)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _header_items(headers: Any) -> List[Tuple[str, str]]:
|
|
78
|
+
if hasattr(headers, "multi_items"):
|
|
79
|
+
return [(str(name), str(value)) for name, value in headers.multi_items()]
|
|
80
|
+
if hasattr(headers, "items"):
|
|
81
|
+
return [(str(name), str(value)) for name, value in headers.items()]
|
|
82
|
+
return [(str(name), str(value)) for name, value in headers]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _multipart_value(content_type: str, body: bytes) -> Tuple[Any, Optional[str]]:
|
|
86
|
+
message = BytesParser(policy=email_policy).parsebytes(
|
|
87
|
+
b"Content-Type: "
|
|
88
|
+
+ content_type.encode("latin-1")
|
|
89
|
+
+ b"\r\nMIME-Version: 1.0\r\n\r\n"
|
|
90
|
+
+ body
|
|
91
|
+
)
|
|
92
|
+
parts: List[Dict[str, Any]] = []
|
|
93
|
+
model: Optional[str] = None
|
|
94
|
+
for part in message.iter_parts():
|
|
95
|
+
name = part.get_param("name", header="content-disposition") or ""
|
|
96
|
+
filename = part.get_filename()
|
|
97
|
+
payload = part.get_payload(decode=True) or b""
|
|
98
|
+
item: Dict[str, Any] = {
|
|
99
|
+
"name": name,
|
|
100
|
+
"filename": filename,
|
|
101
|
+
"content_type": part.get_content_type(),
|
|
102
|
+
"sha256": hashlib.sha256(payload).hexdigest(),
|
|
103
|
+
}
|
|
104
|
+
if filename is None:
|
|
105
|
+
charset = part.get_content_charset() or "utf-8"
|
|
106
|
+
try:
|
|
107
|
+
value = payload.decode(charset)
|
|
108
|
+
except (LookupError, UnicodeDecodeError):
|
|
109
|
+
value = payload.decode("utf-8", errors="replace")
|
|
110
|
+
item["value"] = value
|
|
111
|
+
if name == "model":
|
|
112
|
+
model = value
|
|
113
|
+
parts.append(item)
|
|
114
|
+
return {"kind": "multipart", "parts": parts}, model
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def request_fingerprint(
|
|
118
|
+
method: str, url: str, headers: Any, body: bytes
|
|
119
|
+
) -> Optional[str]:
|
|
120
|
+
"""Return a versioned SHA-256 key, or ``None`` for non-inference APIs."""
|
|
121
|
+
|
|
122
|
+
if method.upper() != "POST" or not _is_cacheable_path(urlsplit(url).path):
|
|
123
|
+
return None
|
|
124
|
+
|
|
125
|
+
items = _header_items(headers)
|
|
126
|
+
lowered: Dict[str, List[str]] = {}
|
|
127
|
+
for name, value in items:
|
|
128
|
+
lowered.setdefault(name.lower(), []).append(value)
|
|
129
|
+
content_type = (lowered.get("content-type") or [""])[0]
|
|
130
|
+
media_type = content_type.split(";", 1)[0].strip().lower()
|
|
131
|
+
|
|
132
|
+
model: Optional[str] = None
|
|
133
|
+
body_value: Any
|
|
134
|
+
if media_type == "application/json" or (
|
|
135
|
+
not media_type and body.lstrip().startswith((b"{", b"["))
|
|
136
|
+
):
|
|
137
|
+
try:
|
|
138
|
+
body_value = json.loads(body.decode("utf-8"))
|
|
139
|
+
if isinstance(body_value, Mapping) and body_value.get("model") is not None:
|
|
140
|
+
model = str(body_value["model"])
|
|
141
|
+
body_value = {"kind": "json", "value": body_value}
|
|
142
|
+
except (UnicodeDecodeError, json.JSONDecodeError, ValueError):
|
|
143
|
+
body_value = {"kind": "bytes", "sha256": hashlib.sha256(body).hexdigest()}
|
|
144
|
+
elif media_type == "multipart/form-data":
|
|
145
|
+
try:
|
|
146
|
+
body_value, model = _multipart_value(content_type, body)
|
|
147
|
+
except Exception:
|
|
148
|
+
body_value = {"kind": "bytes", "sha256": hashlib.sha256(body).hexdigest()}
|
|
149
|
+
elif media_type == "application/x-www-form-urlencoded":
|
|
150
|
+
fields = sorted(parse_qsl(body.decode("utf-8"), keep_blank_values=True))
|
|
151
|
+
body_value = {"kind": "form", "fields": fields}
|
|
152
|
+
for name, value in fields:
|
|
153
|
+
if name == "model":
|
|
154
|
+
model = value
|
|
155
|
+
break
|
|
156
|
+
else:
|
|
157
|
+
body_value = {"kind": "bytes", "sha256": hashlib.sha256(body).hexdigest()}
|
|
158
|
+
|
|
159
|
+
scoped_headers = {}
|
|
160
|
+
for name, values in lowered.items():
|
|
161
|
+
if (
|
|
162
|
+
name in _CREDENTIAL_HEADERS
|
|
163
|
+
or name in _IGNORED_HEADERS
|
|
164
|
+
or name == "content-type"
|
|
165
|
+
or name.startswith("x-stainless-")
|
|
166
|
+
):
|
|
167
|
+
continue
|
|
168
|
+
scoped_headers[name] = sorted(values)
|
|
169
|
+
scoped_headers["content-type"] = [media_type]
|
|
170
|
+
credential_material = "\0".join(
|
|
171
|
+
f"{name}:{value}"
|
|
172
|
+
for name in sorted(_CREDENTIAL_HEADERS)
|
|
173
|
+
for value in lowered.get(name, [])
|
|
174
|
+
)
|
|
175
|
+
credential_scope = hashlib.sha256(credential_material.encode("utf-8")).hexdigest()
|
|
176
|
+
|
|
177
|
+
material = {
|
|
178
|
+
"version": 1,
|
|
179
|
+
"method": method.upper(),
|
|
180
|
+
"url": _normalized_url(url),
|
|
181
|
+
"model": model or "<default>",
|
|
182
|
+
"headers": scoped_headers,
|
|
183
|
+
"credential_scope": credential_scope,
|
|
184
|
+
"body": body_value,
|
|
185
|
+
}
|
|
186
|
+
return "v1:" + hashlib.sha256(_canonical_json(material)).hexdigest()
|