openai-sqlite-cache 0.0.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- openai_sqlite_cache-0.0.1/LICENSE +21 -0
- openai_sqlite_cache-0.0.1/PKG-INFO +151 -0
- openai_sqlite_cache-0.0.1/README.md +128 -0
- openai_sqlite_cache-0.0.1/pyproject.toml +47 -0
- openai_sqlite_cache-0.0.1/setup.cfg +4 -0
- openai_sqlite_cache-0.0.1/src/cached_openai/__init__.py +175 -0
- openai_sqlite_cache-0.0.1/src/cached_openai/_cache.py +145 -0
- openai_sqlite_cache-0.0.1/src/cached_openai/_client.py +166 -0
- openai_sqlite_cache-0.0.1/src/cached_openai/_fingerprint.py +186 -0
- openai_sqlite_cache-0.0.1/src/cached_openai/_http.py +515 -0
- openai_sqlite_cache-0.0.1/src/cached_openai/_module_alias.py +86 -0
- openai_sqlite_cache-0.0.1/src/cached_openai/_settings.py +122 -0
- openai_sqlite_cache-0.0.1/src/cached_openai/_version.py +3 -0
- openai_sqlite_cache-0.0.1/src/cached_openai/py.typed +1 -0
- openai_sqlite_cache-0.0.1/src/openai_sqlite_cache.egg-info/PKG-INFO +151 -0
- openai_sqlite_cache-0.0.1/src/openai_sqlite_cache.egg-info/SOURCES.txt +19 -0
- openai_sqlite_cache-0.0.1/src/openai_sqlite_cache.egg-info/dependency_links.txt +1 -0
- openai_sqlite_cache-0.0.1/src/openai_sqlite_cache.egg-info/requires.txt +1 -0
- openai_sqlite_cache-0.0.1/src/openai_sqlite_cache.egg-info/top_level.txt +1 -0
- openai_sqlite_cache-0.0.1/tests/test_cached_openai.py +418 -0
- openai_sqlite_cache-0.0.1/tests/test_fingerprint.py +89 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 openai_sqlite_cache contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: openai_sqlite_cache
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: An unofficial drop-in OpenAI Python SDK with a local SQLite response cache
|
|
5
|
+
Author: openai_sqlite_cache contributors
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Keywords: openai,cache,sqlite,llm,api
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
18
|
+
Requires-Python: >=3.9
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Requires-Dist: openai<3,>=1.66.2
|
|
22
|
+
Dynamic: license-file
|
|
23
|
+
|
|
24
|
+
# openai_sqlite_cache
|
|
25
|
+
|
|
26
|
+
`openai_sqlite_cache` 是非官方的 OpenAI Python SDK 透明 SQLite 缓存层。同一个 API、同一个模型、完全相同的输入再次调用时,会直接返回本机缓存的响应,不再请求上游。
|
|
27
|
+
|
|
28
|
+
PyPI 分发名是 `openai_sqlite_cache`(规范化名称为 `openai-sqlite-cache`);为满足只替换 import 的用法,Python 导入名仍然是 `cached_openai`。
|
|
29
|
+
|
|
30
|
+
## 安装
|
|
31
|
+
|
|
32
|
+
在当前仓库中安装:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install ./openai_sqlite_cache
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
发布到你有权限的 Python 包索引后,安装形式为:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install openai_sqlite_cache
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
> 注意:本项目与公共 PyPI 上的 `cached-openai` 分发项目都提供 `cached_openai` 导入包,二者不能安全地安装在同一个 Python 环境中。请只安装其中一个。
|
|
45
|
+
|
|
46
|
+
## 用法
|
|
47
|
+
|
|
48
|
+
现有代码只需替换 import,并保留 `openai` 这个本地别名:
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
# 原来:import openai
|
|
52
|
+
import cached_openai as openai
|
|
53
|
+
|
|
54
|
+
client = openai.OpenAI()
|
|
55
|
+
response = client.responses.create(
|
|
56
|
+
model="gpt-5",
|
|
57
|
+
input="Explain SQLite in one sentence.",
|
|
58
|
+
)
|
|
59
|
+
print(response.output_text)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
也可以直接使用包名:
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
import cached_openai
|
|
66
|
+
|
|
67
|
+
client = cached_openai.OpenAI()
|
|
68
|
+
completion = client.chat.completions.create(
|
|
69
|
+
model="gpt-4.1-mini",
|
|
70
|
+
messages=[{"role": "user", "content": "Hello"}],
|
|
71
|
+
)
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
以下官方 SDK 用法均保持不变:
|
|
75
|
+
|
|
76
|
+
- `OpenAI`、`AsyncOpenAI`、`AzureOpenAI` 和 `AsyncAzureOpenAI`;
|
|
77
|
+
- 模块级调用,如 `openai.chat.completions.create(...)`;
|
|
78
|
+
- `with_options()`、`with_raw_response`、`with_streaming_response`;
|
|
79
|
+
- 自定义 `base_url` 和自定义同步/异步 `http_client`;
|
|
80
|
+
- 官方 SDK 的响应模型和异常类型。
|
|
81
|
+
|
|
82
|
+
流式响应会在第一次被完整消费时写入缓存;如果流被提前关闭,则不会缓存不完整内容。Realtime/WebSocket 不经过普通 HTTP 请求,因此不会缓存。
|
|
83
|
+
|
|
84
|
+
## 缓存规则
|
|
85
|
+
|
|
86
|
+
缓存键使用 SHA-256 计算,包含:
|
|
87
|
+
|
|
88
|
+
- HTTP 方法和规范化后的完整 API URL;
|
|
89
|
+
- 请求体(JSON 会按键排序,multipart 会忽略随机 boundary 并对文件内容取 hash);
|
|
90
|
+
- 模型名;
|
|
91
|
+
- organization、project、beta 等会影响语义的请求头;
|
|
92
|
+
- API 凭据的不可逆作用域 hash,防止不同 API key 之间共享响应。
|
|
93
|
+
|
|
94
|
+
请求原文和 API key 都不会写入数据库。数据库只保存 hash、成功响应体、必要响应头和命中统计。默认永久保留,只缓存 2xx 响应。
|
|
95
|
+
|
|
96
|
+
为避免有副作用的操作被错误去重,目前只缓存这些推理/生成端点:Responses、Chat Completions、Completions、Embeddings、Moderations、Images、Audio 和 Videos。Files、Uploads、Batches、Fine-tuning、Vector Stores 等管理类 API 会原样请求上游。
|
|
97
|
+
|
|
98
|
+
## 配置
|
|
99
|
+
|
|
100
|
+
默认数据库位置:
|
|
101
|
+
|
|
102
|
+
- macOS:`~/Library/Caches/openai-sqlite-cache/cache.sqlite3`
|
|
103
|
+
- Linux:`$XDG_CACHE_HOME/openai-sqlite-cache/cache.sqlite3`,未设置时使用 `~/.cache/...`
|
|
104
|
+
- Windows:`%LOCALAPPDATA%/openai-sqlite-cache/cache.sqlite3`
|
|
105
|
+
|
|
106
|
+
环境变量:
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
export CACHED_OPENAI_CACHE_PATH=/path/to/cache.sqlite3
|
|
110
|
+
export CACHED_OPENAI_TTL_SECONDS=86400
|
|
111
|
+
export CACHED_OPENAI_DISABLE=0
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
也可以在创建客户端前配置:
|
|
115
|
+
|
|
116
|
+
```python
|
|
117
|
+
import cached_openai as openai
|
|
118
|
+
|
|
119
|
+
openai.configure_cache(
|
|
120
|
+
path="./.cache/openai.sqlite3",
|
|
121
|
+
ttl_seconds=24 * 60 * 60,
|
|
122
|
+
)
|
|
123
|
+
client = openai.OpenAI()
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
单个客户端可以覆盖全局配置:
|
|
127
|
+
|
|
128
|
+
```python
|
|
129
|
+
client = openai.OpenAI(
|
|
130
|
+
cache_path="./project-cache.sqlite3",
|
|
131
|
+
cache_ttl=3600,
|
|
132
|
+
cache_enabled=True,
|
|
133
|
+
)
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
维护接口:
|
|
137
|
+
|
|
138
|
+
```python
|
|
139
|
+
print(openai.cache_info())
|
|
140
|
+
removed = openai.clear_cache()
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
缓存文件可能包含模型响应中的敏感内容,应像其他本地应用数据一样保护。包会尽量把目录和数据库权限分别设为 `0700` 和 `0600`。
|
|
144
|
+
|
|
145
|
+
## 开发与测试
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
cd openai_sqlite_cache
|
|
149
|
+
python -m unittest discover -s tests -v
|
|
150
|
+
python -m pip wheel --no-deps . -w dist
|
|
151
|
+
```
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# openai_sqlite_cache
|
|
2
|
+
|
|
3
|
+
`openai_sqlite_cache` 是非官方的 OpenAI Python SDK 透明 SQLite 缓存层。同一个 API、同一个模型、完全相同的输入再次调用时,会直接返回本机缓存的响应,不再请求上游。
|
|
4
|
+
|
|
5
|
+
PyPI 分发名是 `openai_sqlite_cache`(规范化名称为 `openai-sqlite-cache`);为满足只替换 import 的用法,Python 导入名仍然是 `cached_openai`。
|
|
6
|
+
|
|
7
|
+
## 安装
|
|
8
|
+
|
|
9
|
+
在当前仓库中安装:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install ./openai_sqlite_cache
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
发布到你有权限的 Python 包索引后,安装形式为:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pip install openai_sqlite_cache
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
> 注意:本项目与公共 PyPI 上的 `cached-openai` 分发项目都提供 `cached_openai` 导入包,二者不能安全地安装在同一个 Python 环境中。请只安装其中一个。
|
|
22
|
+
|
|
23
|
+
## 用法
|
|
24
|
+
|
|
25
|
+
现有代码只需替换 import,并保留 `openai` 这个本地别名:
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
# 原来:import openai
|
|
29
|
+
import cached_openai as openai
|
|
30
|
+
|
|
31
|
+
client = openai.OpenAI()
|
|
32
|
+
response = client.responses.create(
|
|
33
|
+
model="gpt-5",
|
|
34
|
+
input="Explain SQLite in one sentence.",
|
|
35
|
+
)
|
|
36
|
+
print(response.output_text)
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
也可以直接使用包名:
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
import cached_openai
|
|
43
|
+
|
|
44
|
+
client = cached_openai.OpenAI()
|
|
45
|
+
completion = client.chat.completions.create(
|
|
46
|
+
model="gpt-4.1-mini",
|
|
47
|
+
messages=[{"role": "user", "content": "Hello"}],
|
|
48
|
+
)
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
以下官方 SDK 用法均保持不变:
|
|
52
|
+
|
|
53
|
+
- `OpenAI`、`AsyncOpenAI`、`AzureOpenAI` 和 `AsyncAzureOpenAI`;
|
|
54
|
+
- 模块级调用,如 `openai.chat.completions.create(...)`;
|
|
55
|
+
- `with_options()`、`with_raw_response`、`with_streaming_response`;
|
|
56
|
+
- 自定义 `base_url` 和自定义同步/异步 `http_client`;
|
|
57
|
+
- 官方 SDK 的响应模型和异常类型。
|
|
58
|
+
|
|
59
|
+
流式响应会在第一次被完整消费时写入缓存;如果流被提前关闭,则不会缓存不完整内容。Realtime/WebSocket 不经过普通 HTTP 请求,因此不会缓存。
|
|
60
|
+
|
|
61
|
+
## 缓存规则
|
|
62
|
+
|
|
63
|
+
缓存键使用 SHA-256 计算,包含:
|
|
64
|
+
|
|
65
|
+
- HTTP 方法和规范化后的完整 API URL;
|
|
66
|
+
- 请求体(JSON 会按键排序,multipart 会忽略随机 boundary 并对文件内容取 hash);
|
|
67
|
+
- 模型名;
|
|
68
|
+
- organization、project、beta 等会影响语义的请求头;
|
|
69
|
+
- API 凭据的不可逆作用域 hash,防止不同 API key 之间共享响应。
|
|
70
|
+
|
|
71
|
+
请求原文和 API key 都不会写入数据库。数据库只保存 hash、成功响应体、必要响应头和命中统计。默认永久保留,只缓存 2xx 响应。
|
|
72
|
+
|
|
73
|
+
为避免有副作用的操作被错误去重,目前只缓存这些推理/生成端点:Responses、Chat Completions、Completions、Embeddings、Moderations、Images、Audio 和 Videos。Files、Uploads、Batches、Fine-tuning、Vector Stores 等管理类 API 会原样请求上游。
|
|
74
|
+
|
|
75
|
+
## 配置
|
|
76
|
+
|
|
77
|
+
默认数据库位置:
|
|
78
|
+
|
|
79
|
+
- macOS:`~/Library/Caches/openai-sqlite-cache/cache.sqlite3`
|
|
80
|
+
- Linux:`$XDG_CACHE_HOME/openai-sqlite-cache/cache.sqlite3`,未设置时使用 `~/.cache/...`
|
|
81
|
+
- Windows:`%LOCALAPPDATA%/openai-sqlite-cache/cache.sqlite3`
|
|
82
|
+
|
|
83
|
+
环境变量:
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
export CACHED_OPENAI_CACHE_PATH=/path/to/cache.sqlite3
|
|
87
|
+
export CACHED_OPENAI_TTL_SECONDS=86400
|
|
88
|
+
export CACHED_OPENAI_DISABLE=0
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
也可以在创建客户端前配置:
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
import cached_openai as openai
|
|
95
|
+
|
|
96
|
+
openai.configure_cache(
|
|
97
|
+
path="./.cache/openai.sqlite3",
|
|
98
|
+
ttl_seconds=24 * 60 * 60,
|
|
99
|
+
)
|
|
100
|
+
client = openai.OpenAI()
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
单个客户端可以覆盖全局配置:
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
client = openai.OpenAI(
|
|
107
|
+
cache_path="./project-cache.sqlite3",
|
|
108
|
+
cache_ttl=3600,
|
|
109
|
+
cache_enabled=True,
|
|
110
|
+
)
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
维护接口:
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
print(openai.cache_info())
|
|
117
|
+
removed = openai.clear_cache()
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
缓存文件可能包含模型响应中的敏感内容,应像其他本地应用数据一样保护。包会尽量把目录和数据库权限分别设为 `0700` 和 `0600`。
|
|
121
|
+
|
|
122
|
+
## 开发与测试
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
cd openai_sqlite_cache
|
|
126
|
+
python -m unittest discover -s tests -v
|
|
127
|
+
python -m pip wheel --no-deps . -w dist
|
|
128
|
+
```
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "openai_sqlite_cache"
|
|
7
|
+
version = "0.0.1"
|
|
8
|
+
description = "An unofficial drop-in OpenAI Python SDK with a local SQLite response cache"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [
|
|
14
|
+
{ name = "openai_sqlite_cache contributors" },
|
|
15
|
+
]
|
|
16
|
+
keywords = ["openai", "cache", "sqlite", "llm", "api"]
|
|
17
|
+
classifiers = [
|
|
18
|
+
"Development Status :: 3 - Alpha",
|
|
19
|
+
"Intended Audience :: Developers",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3.9",
|
|
22
|
+
"Programming Language :: Python :: 3.10",
|
|
23
|
+
"Programming Language :: Python :: 3.11",
|
|
24
|
+
"Programming Language :: Python :: 3.12",
|
|
25
|
+
"Programming Language :: Python :: 3.13",
|
|
26
|
+
"Programming Language :: Python :: 3.14",
|
|
27
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
28
|
+
]
|
|
29
|
+
dependencies = [
|
|
30
|
+
"openai>=1.66.2,<3",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
[tool.setuptools]
|
|
34
|
+
package-dir = { "" = "src" }
|
|
35
|
+
|
|
36
|
+
[tool.setuptools.packages.find]
|
|
37
|
+
where = ["src"]
|
|
38
|
+
|
|
39
|
+
[tool.setuptools.package-data]
|
|
40
|
+
cached_openai = ["py.typed"]
|
|
41
|
+
|
|
42
|
+
[tool.ruff]
|
|
43
|
+
target-version = "py39"
|
|
44
|
+
line-length = 88
|
|
45
|
+
|
|
46
|
+
[tool.ruff.lint]
|
|
47
|
+
select = ["E", "F", "I"]
|
|
@@ -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
|
+
)
|
|
@@ -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
|
+
}
|