openai-fast-transform 0.1.0__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_fast_transform-0.1.0/.gitignore +8 -0
- openai_fast_transform-0.1.0/LICENSE +21 -0
- openai_fast_transform-0.1.0/PKG-INFO +182 -0
- openai_fast_transform-0.1.0/README.md +159 -0
- openai_fast_transform-0.1.0/pyproject.toml +77 -0
- openai_fast_transform-0.1.0/src/openai_fast_transform/__init__.py +8 -0
- openai_fast_transform-0.1.0/src/openai_fast_transform/__main__.py +5 -0
- openai_fast_transform-0.1.0/src/openai_fast_transform/_transform.py +790 -0
- openai_fast_transform-0.1.0/src/openai_fast_transform/py.typed +0 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Rasmus Faber-Espensen
|
|
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,182 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: openai-fast-transform
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: An exact, compiled replacement for the openai SDK's request transform.
|
|
5
|
+
Project-URL: Repository, https://github.com/rasmusfaber/openai-fast-transform
|
|
6
|
+
Project-URL: Issues, https://github.com/rasmusfaber/openai-fast-transform/issues
|
|
7
|
+
Author: Rasmus Faber-Espensen
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
18
|
+
Classifier: Typing :: Typed
|
|
19
|
+
Requires-Python: >=3.12
|
|
20
|
+
Requires-Dist: openai>=1.108.1
|
|
21
|
+
Requires-Dist: typing-extensions>=4.11
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# openai-fast-transform
|
|
25
|
+
|
|
26
|
+
An exact, compiled replacement for the [openai](https://github.com/openai/openai-python)
|
|
27
|
+
SDK's request transform.
|
|
28
|
+
|
|
29
|
+
Before sending a request, openai-python passes the body through
|
|
30
|
+
`openai._utils._transform`, which walks it node by node and re-derives the parameter
|
|
31
|
+
type's structure with typing introspection for every value on every call. That costs about
|
|
32
|
+
0.25 ms per Responses input item, so an agent with a long transcript spends hundreds of
|
|
33
|
+
milliseconds of event-loop time on every model call before any I/O happens.
|
|
34
|
+
`openai_fast_transform.install()` swaps in a version that compiles each parameter type once
|
|
35
|
+
and gives the same result as stock: hundreds of times faster on Responses requests and over
|
|
36
|
+
100 times faster on Chat Completions requests (see the benchmarks below).
|
|
37
|
+
|
|
38
|
+
## Usage
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
import openai_fast_transform
|
|
42
|
+
|
|
43
|
+
openai_fast_transform.install() # once, before the first request
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
`install()` returns `True` when the compiled transform is in place. It does nothing until
|
|
47
|
+
you call it (no import-time side effects, no environment variables) and never raises. If the
|
|
48
|
+
installed openai is not a verified release, or anything goes wrong, it logs a WARNING on the
|
|
49
|
+
`openai_fast_transform` logger, keeps the stock transform and returns `False`. On success it
|
|
50
|
+
logs the openai version and profile at INFO. Only the first call checks openai and logs;
|
|
51
|
+
later calls return its result.
|
|
52
|
+
|
|
53
|
+
## Guarantees
|
|
54
|
+
|
|
55
|
+
For every value and every annotation the SDK passes, the patched functions return a value
|
|
56
|
+
strictly equal to stock's (same container classes, dict key order, key objects and leaf
|
|
57
|
+
objects), raise the same exception at the same point, and have the same side effects
|
|
58
|
+
(generator consumption, file reads and whether they are sync or async, `model_dump` calls
|
|
59
|
+
and arguments, `__api_exclude__` reads). They may copy more than stock, but never return a
|
|
60
|
+
caller-owned container where stock returns a fresh one. Carve-outs:
|
|
61
|
+
|
|
62
|
+
1. Union arm order that depends on the history of openai's equality-keyed `lru_cache`s
|
|
63
|
+
(`Union[A, B] == Union[B, A]`). A test asserts that no order-sensitive pair is reachable
|
|
64
|
+
from openai's own param types.
|
|
65
|
+
2. The depth at which `RecursionError` or `MemoryError` occurs.
|
|
66
|
+
3. Third parties re-patching openai helpers or mutating `PropertyInfo` objects after
|
|
67
|
+
`install()`.
|
|
68
|
+
4. User dunders run by type checks (`__class__`, `__instancecheck__`, ABC hooks) may run a
|
|
69
|
+
different number of times.
|
|
70
|
+
|
|
71
|
+
The test suite checks this differentially against stock, sync and async: quirk cases,
|
|
72
|
+
synthetic fuzzing over aliases, formats, unions, recursive and unresolvable types, and
|
|
73
|
+
fuzzing over every TypedDict in `openai.types`. The full argument is in the header comment
|
|
74
|
+
of [`_transform.py`](https://github.com/rasmusfaber/openai-fast-transform/blob/main/src/openai_fast_transform/_transform.py).
|
|
75
|
+
|
|
76
|
+
## Supported openai releases
|
|
77
|
+
|
|
78
|
+
`install()` only patches an openai whose normalised `_utils/_transform.py` (docstrings,
|
|
79
|
+
annotations and `cast` calls dropped) and the `is_given` and `is_base64_file_input` helpers
|
|
80
|
+
have known fingerprints, and whose loaded functions are the ones compiled from that source.
|
|
81
|
+
Anything else keeps the stock transform, so the dependency has no upper bound.
|
|
82
|
+
|
|
83
|
+
| openai releases | profile |
|
|
84
|
+
| ---------------- | ------- |
|
|
85
|
+
| 1.108.1 – 3.11.0 | A |
|
|
86
|
+
| 3.12.0 – 3.18.0 | B |
|
|
87
|
+
| 3.19.0 – 3.43.0 | C |
|
|
88
|
+
|
|
89
|
+
The ranges come from fingerprinting every release from 1.0.0 to 3.43.0; the fingerprints
|
|
90
|
+
are identical on Python 3.12, 3.13 and 3.14. Releases before 1.108.1 are refused. CI tests
|
|
91
|
+
both ends of every range, and a weekly job tests the newest openai on PyPI.
|
|
92
|
+
|
|
93
|
+
`python -m openai_fast_transform` prints what the gate sees (it exits 1 if the release is
|
|
94
|
+
refused):
|
|
95
|
+
|
|
96
|
+
```
|
|
97
|
+
openai 3.43.0
|
|
98
|
+
_transform.py 972f4e003507b0ccfced9a9a08e4fafb55b55f6a28c5e14b8b14f75f75014412
|
|
99
|
+
is_given a5e2169fcf23b5b0e42ee616ee372dddb848f1f14db2a4706bed313e94caa62b
|
|
100
|
+
is_base64_file_input 7a7155747eff3c1445c585d97f6dbe7e83a72ac99ba3bfa870f72f3ab13c7be6
|
|
101
|
+
profile C
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Benchmarks
|
|
105
|
+
|
|
106
|
+
`openai._utils._transform.transform` / `async_transform` on two synthetic request bodies:
|
|
107
|
+
|
|
108
|
+
- Responses (`ResponseCreateParamsNonStreaming`): a developer and a user message, then
|
|
109
|
+
repeated reasoning (6,000-character `encrypted_content`), `function_call` and
|
|
110
|
+
`function_call_output` items up to the given number of input items, two function tools
|
|
111
|
+
and the usual omitted fields.
|
|
112
|
+
- Chat Completions (`CompletionCreateParamsNonStreaming`), 1 MB of JSON: a system and a
|
|
113
|
+
user message (2,000 characters each), then 311 assistant messages (300 characters of
|
|
114
|
+
text and one function tool call) each followed by a tool message with a 2,700-character
|
|
115
|
+
result, 624 messages in all, two function tools and the usual omitted fields.
|
|
116
|
+
|
|
117
|
+
These are medians of 21 runs on Python 3.13.12, openai 3.43.0 and an AMD Ryzen 7 9800X3D.
|
|
118
|
+
|
|
119
|
+
| request | mode | stock (ms) | patched (ms) | speed-up |
|
|
120
|
+
| ---------------------- | ----- | ---------: | -----------: | -------: |
|
|
121
|
+
| Responses, 50 items | sync | 13.9 | 0.02 | 601x |
|
|
122
|
+
| Responses, 50 items | async | 14.2 | 0.03 | 444x |
|
|
123
|
+
| Responses, 300 items | sync | 84.8 | 0.13 | 666x |
|
|
124
|
+
| Responses, 300 items | async | 84.1 | 0.16 | 536x |
|
|
125
|
+
| Responses, 1000 items | sync | 284.6 | 0.43 | 662x |
|
|
126
|
+
| Responses, 1000 items | async | 299.3 | 0.45 | 661x |
|
|
127
|
+
| Chat Completions, 1 MB | sync | 50.2 | 0.36 | 138x |
|
|
128
|
+
| Chat Completions, 1 MB | async | 54.6 | 0.37 | 147x |
|
|
129
|
+
|
|
130
|
+
`install()` takes about 10 ms. The first call for a parameter type compiles its plans, once
|
|
131
|
+
per process for sync and once for async: 14 ms and 8 ms for the Responses create params.
|
|
132
|
+
|
|
133
|
+
## Limitations
|
|
134
|
+
|
|
135
|
+
- Plans are compiled only for openai's own types. Anything else, such as a user-defined
|
|
136
|
+
TypedDict or `Annotated` metadata that is not openai's `PropertyInfo`, runs the stock
|
|
137
|
+
transform for that part of the request.
|
|
138
|
+
- openai only. anthropic 1.8 and later already replaced this transform upstream.
|
|
139
|
+
- After 20,000 compiled nodes (all of `openai.types` takes about 4,800), new annotations
|
|
140
|
+
use the stock transform, so callers that build a new annotation per call cannot grow
|
|
141
|
+
memory without bound.
|
|
142
|
+
|
|
143
|
+
## Maintenance
|
|
144
|
+
|
|
145
|
+
When a new openai release changes the transform, `install()` refuses it and the weekly
|
|
146
|
+
**Newest openai** workflow fails. GitHub disables scheduled workflows in a public repository
|
|
147
|
+
after 60 days without activity; if **Newest openai** is disabled, re-enable it under
|
|
148
|
+
Actions. To support the release:
|
|
149
|
+
|
|
150
|
+
1. Install it and run `python -m openai_fast_transform` to get its fingerprints.
|
|
151
|
+
2. Diff `src/openai/_utils/_transform.py`, `is_given` and `is_base64_file_input` against the
|
|
152
|
+
last verified release in openai-python's git history. Decide whether the change only
|
|
153
|
+
reaches helpers the plans call live (the same profile) or changes a branch the compiler
|
|
154
|
+
mirrors (a new profile, with a matching change in `_Compiler` and `_Node.apply`).
|
|
155
|
+
3. Add the fingerprint and its release range to `_PROFILES` (or `_HELPERS`) in
|
|
156
|
+
[`_transform.py`](https://github.com/rasmusfaber/openai-fast-transform/blob/main/src/openai_fast_transform/_transform.py).
|
|
157
|
+
For a new profile, also add its first release to `_FIRST_RELEASES` in
|
|
158
|
+
`tests/test_transform.py`.
|
|
159
|
+
4. Run the tests against the new release and add it to the `test-openai` matrix in
|
|
160
|
+
`.github/workflows/pr-and-main.yaml`. Update the table above.
|
|
161
|
+
|
|
162
|
+
## Development
|
|
163
|
+
|
|
164
|
+
```bash
|
|
165
|
+
uv sync --all-groups
|
|
166
|
+
uv run pytest -n auto
|
|
167
|
+
uv run ruff check && uv run ruff format --check && uv run basedpyright
|
|
168
|
+
|
|
169
|
+
# Against another openai release (uv sync restores the locked one):
|
|
170
|
+
uv pip install openai==3.12.0 && uv run --no-sync pytest -n auto
|
|
171
|
+
|
|
172
|
+
# On another Python:
|
|
173
|
+
UV_PROJECT_ENVIRONMENT=.venv-3.12 uv run --python 3.12 --all-groups pytest -n auto
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
To release, publish a GitHub release tagged `vX.Y.Z`. The **Release** workflow builds the
|
|
177
|
+
sdist and wheel and uploads them with PyPI Trusted Publishing (the PyPI project trusts
|
|
178
|
+
`release.yaml` in the `pypi` environment).
|
|
179
|
+
|
|
180
|
+
## License
|
|
181
|
+
|
|
182
|
+
MIT. See [LICENSE](https://github.com/rasmusfaber/openai-fast-transform/blob/main/LICENSE).
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# openai-fast-transform
|
|
2
|
+
|
|
3
|
+
An exact, compiled replacement for the [openai](https://github.com/openai/openai-python)
|
|
4
|
+
SDK's request transform.
|
|
5
|
+
|
|
6
|
+
Before sending a request, openai-python passes the body through
|
|
7
|
+
`openai._utils._transform`, which walks it node by node and re-derives the parameter
|
|
8
|
+
type's structure with typing introspection for every value on every call. That costs about
|
|
9
|
+
0.25 ms per Responses input item, so an agent with a long transcript spends hundreds of
|
|
10
|
+
milliseconds of event-loop time on every model call before any I/O happens.
|
|
11
|
+
`openai_fast_transform.install()` swaps in a version that compiles each parameter type once
|
|
12
|
+
and gives the same result as stock: hundreds of times faster on Responses requests and over
|
|
13
|
+
100 times faster on Chat Completions requests (see the benchmarks below).
|
|
14
|
+
|
|
15
|
+
## Usage
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
import openai_fast_transform
|
|
19
|
+
|
|
20
|
+
openai_fast_transform.install() # once, before the first request
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
`install()` returns `True` when the compiled transform is in place. It does nothing until
|
|
24
|
+
you call it (no import-time side effects, no environment variables) and never raises. If the
|
|
25
|
+
installed openai is not a verified release, or anything goes wrong, it logs a WARNING on the
|
|
26
|
+
`openai_fast_transform` logger, keeps the stock transform and returns `False`. On success it
|
|
27
|
+
logs the openai version and profile at INFO. Only the first call checks openai and logs;
|
|
28
|
+
later calls return its result.
|
|
29
|
+
|
|
30
|
+
## Guarantees
|
|
31
|
+
|
|
32
|
+
For every value and every annotation the SDK passes, the patched functions return a value
|
|
33
|
+
strictly equal to stock's (same container classes, dict key order, key objects and leaf
|
|
34
|
+
objects), raise the same exception at the same point, and have the same side effects
|
|
35
|
+
(generator consumption, file reads and whether they are sync or async, `model_dump` calls
|
|
36
|
+
and arguments, `__api_exclude__` reads). They may copy more than stock, but never return a
|
|
37
|
+
caller-owned container where stock returns a fresh one. Carve-outs:
|
|
38
|
+
|
|
39
|
+
1. Union arm order that depends on the history of openai's equality-keyed `lru_cache`s
|
|
40
|
+
(`Union[A, B] == Union[B, A]`). A test asserts that no order-sensitive pair is reachable
|
|
41
|
+
from openai's own param types.
|
|
42
|
+
2. The depth at which `RecursionError` or `MemoryError` occurs.
|
|
43
|
+
3. Third parties re-patching openai helpers or mutating `PropertyInfo` objects after
|
|
44
|
+
`install()`.
|
|
45
|
+
4. User dunders run by type checks (`__class__`, `__instancecheck__`, ABC hooks) may run a
|
|
46
|
+
different number of times.
|
|
47
|
+
|
|
48
|
+
The test suite checks this differentially against stock, sync and async: quirk cases,
|
|
49
|
+
synthetic fuzzing over aliases, formats, unions, recursive and unresolvable types, and
|
|
50
|
+
fuzzing over every TypedDict in `openai.types`. The full argument is in the header comment
|
|
51
|
+
of [`_transform.py`](https://github.com/rasmusfaber/openai-fast-transform/blob/main/src/openai_fast_transform/_transform.py).
|
|
52
|
+
|
|
53
|
+
## Supported openai releases
|
|
54
|
+
|
|
55
|
+
`install()` only patches an openai whose normalised `_utils/_transform.py` (docstrings,
|
|
56
|
+
annotations and `cast` calls dropped) and the `is_given` and `is_base64_file_input` helpers
|
|
57
|
+
have known fingerprints, and whose loaded functions are the ones compiled from that source.
|
|
58
|
+
Anything else keeps the stock transform, so the dependency has no upper bound.
|
|
59
|
+
|
|
60
|
+
| openai releases | profile |
|
|
61
|
+
| ---------------- | ------- |
|
|
62
|
+
| 1.108.1 – 3.11.0 | A |
|
|
63
|
+
| 3.12.0 – 3.18.0 | B |
|
|
64
|
+
| 3.19.0 – 3.43.0 | C |
|
|
65
|
+
|
|
66
|
+
The ranges come from fingerprinting every release from 1.0.0 to 3.43.0; the fingerprints
|
|
67
|
+
are identical on Python 3.12, 3.13 and 3.14. Releases before 1.108.1 are refused. CI tests
|
|
68
|
+
both ends of every range, and a weekly job tests the newest openai on PyPI.
|
|
69
|
+
|
|
70
|
+
`python -m openai_fast_transform` prints what the gate sees (it exits 1 if the release is
|
|
71
|
+
refused):
|
|
72
|
+
|
|
73
|
+
```
|
|
74
|
+
openai 3.43.0
|
|
75
|
+
_transform.py 972f4e003507b0ccfced9a9a08e4fafb55b55f6a28c5e14b8b14f75f75014412
|
|
76
|
+
is_given a5e2169fcf23b5b0e42ee616ee372dddb848f1f14db2a4706bed313e94caa62b
|
|
77
|
+
is_base64_file_input 7a7155747eff3c1445c585d97f6dbe7e83a72ac99ba3bfa870f72f3ab13c7be6
|
|
78
|
+
profile C
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Benchmarks
|
|
82
|
+
|
|
83
|
+
`openai._utils._transform.transform` / `async_transform` on two synthetic request bodies:
|
|
84
|
+
|
|
85
|
+
- Responses (`ResponseCreateParamsNonStreaming`): a developer and a user message, then
|
|
86
|
+
repeated reasoning (6,000-character `encrypted_content`), `function_call` and
|
|
87
|
+
`function_call_output` items up to the given number of input items, two function tools
|
|
88
|
+
and the usual omitted fields.
|
|
89
|
+
- Chat Completions (`CompletionCreateParamsNonStreaming`), 1 MB of JSON: a system and a
|
|
90
|
+
user message (2,000 characters each), then 311 assistant messages (300 characters of
|
|
91
|
+
text and one function tool call) each followed by a tool message with a 2,700-character
|
|
92
|
+
result, 624 messages in all, two function tools and the usual omitted fields.
|
|
93
|
+
|
|
94
|
+
These are medians of 21 runs on Python 3.13.12, openai 3.43.0 and an AMD Ryzen 7 9800X3D.
|
|
95
|
+
|
|
96
|
+
| request | mode | stock (ms) | patched (ms) | speed-up |
|
|
97
|
+
| ---------------------- | ----- | ---------: | -----------: | -------: |
|
|
98
|
+
| Responses, 50 items | sync | 13.9 | 0.02 | 601x |
|
|
99
|
+
| Responses, 50 items | async | 14.2 | 0.03 | 444x |
|
|
100
|
+
| Responses, 300 items | sync | 84.8 | 0.13 | 666x |
|
|
101
|
+
| Responses, 300 items | async | 84.1 | 0.16 | 536x |
|
|
102
|
+
| Responses, 1000 items | sync | 284.6 | 0.43 | 662x |
|
|
103
|
+
| Responses, 1000 items | async | 299.3 | 0.45 | 661x |
|
|
104
|
+
| Chat Completions, 1 MB | sync | 50.2 | 0.36 | 138x |
|
|
105
|
+
| Chat Completions, 1 MB | async | 54.6 | 0.37 | 147x |
|
|
106
|
+
|
|
107
|
+
`install()` takes about 10 ms. The first call for a parameter type compiles its plans, once
|
|
108
|
+
per process for sync and once for async: 14 ms and 8 ms for the Responses create params.
|
|
109
|
+
|
|
110
|
+
## Limitations
|
|
111
|
+
|
|
112
|
+
- Plans are compiled only for openai's own types. Anything else, such as a user-defined
|
|
113
|
+
TypedDict or `Annotated` metadata that is not openai's `PropertyInfo`, runs the stock
|
|
114
|
+
transform for that part of the request.
|
|
115
|
+
- openai only. anthropic 1.8 and later already replaced this transform upstream.
|
|
116
|
+
- After 20,000 compiled nodes (all of `openai.types` takes about 4,800), new annotations
|
|
117
|
+
use the stock transform, so callers that build a new annotation per call cannot grow
|
|
118
|
+
memory without bound.
|
|
119
|
+
|
|
120
|
+
## Maintenance
|
|
121
|
+
|
|
122
|
+
When a new openai release changes the transform, `install()` refuses it and the weekly
|
|
123
|
+
**Newest openai** workflow fails. GitHub disables scheduled workflows in a public repository
|
|
124
|
+
after 60 days without activity; if **Newest openai** is disabled, re-enable it under
|
|
125
|
+
Actions. To support the release:
|
|
126
|
+
|
|
127
|
+
1. Install it and run `python -m openai_fast_transform` to get its fingerprints.
|
|
128
|
+
2. Diff `src/openai/_utils/_transform.py`, `is_given` and `is_base64_file_input` against the
|
|
129
|
+
last verified release in openai-python's git history. Decide whether the change only
|
|
130
|
+
reaches helpers the plans call live (the same profile) or changes a branch the compiler
|
|
131
|
+
mirrors (a new profile, with a matching change in `_Compiler` and `_Node.apply`).
|
|
132
|
+
3. Add the fingerprint and its release range to `_PROFILES` (or `_HELPERS`) in
|
|
133
|
+
[`_transform.py`](https://github.com/rasmusfaber/openai-fast-transform/blob/main/src/openai_fast_transform/_transform.py).
|
|
134
|
+
For a new profile, also add its first release to `_FIRST_RELEASES` in
|
|
135
|
+
`tests/test_transform.py`.
|
|
136
|
+
4. Run the tests against the new release and add it to the `test-openai` matrix in
|
|
137
|
+
`.github/workflows/pr-and-main.yaml`. Update the table above.
|
|
138
|
+
|
|
139
|
+
## Development
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
uv sync --all-groups
|
|
143
|
+
uv run pytest -n auto
|
|
144
|
+
uv run ruff check && uv run ruff format --check && uv run basedpyright
|
|
145
|
+
|
|
146
|
+
# Against another openai release (uv sync restores the locked one):
|
|
147
|
+
uv pip install openai==3.12.0 && uv run --no-sync pytest -n auto
|
|
148
|
+
|
|
149
|
+
# On another Python:
|
|
150
|
+
UV_PROJECT_ENVIRONMENT=.venv-3.12 uv run --python 3.12 --all-groups pytest -n auto
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
To release, publish a GitHub release tagged `vX.Y.Z`. The **Release** workflow builds the
|
|
154
|
+
sdist and wheel and uploads them with PyPI Trusted Publishing (the PyPI project trusts
|
|
155
|
+
`release.yaml` in the `pypi` environment).
|
|
156
|
+
|
|
157
|
+
## License
|
|
158
|
+
|
|
159
|
+
MIT. See [LICENSE](https://github.com/rasmusfaber/openai-fast-transform/blob/main/LICENSE).
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "openai-fast-transform"
|
|
3
|
+
dynamic = ["version"]
|
|
4
|
+
description = "An exact, compiled replacement for the openai SDK's request transform."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.12"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
license-files = ["LICENSE"]
|
|
9
|
+
authors = [
|
|
10
|
+
{ name = "Rasmus Faber-Espensen" },
|
|
11
|
+
]
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Development Status :: 4 - Beta",
|
|
14
|
+
"Intended Audience :: Developers",
|
|
15
|
+
"Operating System :: OS Independent",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
18
|
+
"Programming Language :: Python :: 3.12",
|
|
19
|
+
"Programming Language :: Python :: 3.13",
|
|
20
|
+
"Programming Language :: Python :: 3.14",
|
|
21
|
+
"Typing :: Typed",
|
|
22
|
+
]
|
|
23
|
+
# No upper bound: install() refuses (and keeps stock) on any openai it does not recognise.
|
|
24
|
+
dependencies = [
|
|
25
|
+
"openai>=1.108.1",
|
|
26
|
+
"typing-extensions>=4.11",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[project.urls]
|
|
30
|
+
Repository = "https://github.com/rasmusfaber/openai-fast-transform"
|
|
31
|
+
Issues = "https://github.com/rasmusfaber/openai-fast-transform/issues"
|
|
32
|
+
|
|
33
|
+
[build-system]
|
|
34
|
+
requires = ["hatchling", "hatch-vcs"]
|
|
35
|
+
build-backend = "hatchling.build"
|
|
36
|
+
|
|
37
|
+
[tool.hatch.version]
|
|
38
|
+
source = "vcs"
|
|
39
|
+
|
|
40
|
+
[tool.hatch.build.targets.wheel]
|
|
41
|
+
packages = ["src/openai_fast_transform"]
|
|
42
|
+
|
|
43
|
+
[tool.hatch.build.targets.sdist]
|
|
44
|
+
only-include = [
|
|
45
|
+
"src/openai_fast_transform",
|
|
46
|
+
"LICENSE",
|
|
47
|
+
"README.md",
|
|
48
|
+
"pyproject.toml",
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
[dependency-groups]
|
|
52
|
+
dev = [
|
|
53
|
+
"basedpyright>=1.37",
|
|
54
|
+
"pytest>=8.0",
|
|
55
|
+
"pytest-xdist>=3.6",
|
|
56
|
+
"ruff>=0.11",
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
[tool.ruff]
|
|
60
|
+
line-length = 100
|
|
61
|
+
|
|
62
|
+
[tool.ruff.lint]
|
|
63
|
+
# Pinned: newer ruff defaults add pyupgrade fixes that would rewrite the probe's deliberate
|
|
64
|
+
# typing.List/Dict/Union/Optional annotations (the spelling openai's own types use).
|
|
65
|
+
select = ["E4", "E7", "E9", "F"]
|
|
66
|
+
extend-select = ["B006", "E701", "E702", "FA102", "I"]
|
|
67
|
+
|
|
68
|
+
[tool.basedpyright]
|
|
69
|
+
pythonVersion = "3.12"
|
|
70
|
+
typeCheckingMode = "standard"
|
|
71
|
+
include = [
|
|
72
|
+
"src",
|
|
73
|
+
"tests",
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
[tool.pytest.ini_options]
|
|
77
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,790 @@
|
|
|
1
|
+
"""Exact, faster replacement for the openai SDK's request transform.
|
|
2
|
+
|
|
3
|
+
openai-python passes every request body through ``openai._utils._transform``'s
|
|
4
|
+
``_transform_recursive`` (or its async twin), which re-derives the parameter type's
|
|
5
|
+
structure for every value on every call: about 0.25 ms per Responses input item, so a
|
|
6
|
+
long agent transcript spends hundreds of milliseconds of event-loop time per model call
|
|
7
|
+
in it. :func:`install` replaces both functions with versions that compile each
|
|
8
|
+
parameter type once and return results strictly equal to stock's.
|
|
9
|
+
|
|
10
|
+
Nothing happens until :func:`install` is called. It only patches an openai whose transform
|
|
11
|
+
source matches a release it was verified against; any other version, or any failure while
|
|
12
|
+
installing, logs a WARNING and leaves the stock transform in place.
|
|
13
|
+
``python -m openai_fast_transform`` prints the installed openai's fingerprints and the
|
|
14
|
+
profile it would use.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
# Contract. For every value and every annotation the SDK passes, the patched functions
|
|
18
|
+
# return a value strictly equal to stock's (same container classes, dict key order, key
|
|
19
|
+
# objects and leaf objects), raise the same exception at the same point, and have the same
|
|
20
|
+
# side effects (generator consumption, file reads and whether they are sync or async,
|
|
21
|
+
# model_dump calls and arguments, __api_exclude__ reads). They may copy more than stock but
|
|
22
|
+
# never return a caller-owned container where stock returns a fresh one. Carve-outs:
|
|
23
|
+
# 1. Union arm order that depends on the history of openai's equality-keyed lru_caches:
|
|
24
|
+
# Union[A, B] == Union[B, A], so stock's strip_annotated_type can return either order
|
|
25
|
+
# for either. A test asserts no order-sensitive pair is reachable from openai's params.
|
|
26
|
+
# 2. The depth at which RecursionError or MemoryError occurs.
|
|
27
|
+
# 3. Third parties re-patching openai helpers or mutating PropertyInfo objects after the
|
|
28
|
+
# compiled transform is installed.
|
|
29
|
+
# 4. User dunders run by type checks (__class__, __instancecheck__, ABC hooks) may run a
|
|
30
|
+
# different number of times.
|
|
31
|
+
#
|
|
32
|
+
# Scope. Plans are compiled only for SDK-defined types (see _in_scope). Anything else may
|
|
33
|
+
# run user code from __eq__/__hash__/forward references, so its node calls the original
|
|
34
|
+
# function, which re-enters the patched one for its children.
|
|
35
|
+
#
|
|
36
|
+
# Plans. Each (annotation, inner_type) pair compiles, through the same helper bindings the
|
|
37
|
+
# fingerprinted source uses (vars(openai._utils._transform)), to a _Node that records the
|
|
38
|
+
# branch stock would take and its children. A node whose compilation raises calls the
|
|
39
|
+
# original function instead, so stock raises (or not) exactly where the data leads it.
|
|
40
|
+
# _Node.apply mirrors stock branch by branch (the "faithful" path).
|
|
41
|
+
#
|
|
42
|
+
# Copiers. A node is eligible when nothing reachable from it delegates, renames a key or
|
|
43
|
+
# has a format that is not a str. Every stock branch of an eligible node is the identity
|
|
44
|
+
# on the value of plain data (exact str/int/float/bool/None/dict/list): TypedDict and dict
|
|
45
|
+
# branches rebuild the dict with the same keys in the same order, dropping only
|
|
46
|
+
# Omit/NotGiven; list branches rebuild the list; union arms run in sequence, each identity
|
|
47
|
+
# by value; and a str format leaves plain values unchanged (none is a date or a file
|
|
48
|
+
# input; is_given and is_base64_file_input are fingerprinted). Stock's result is then the
|
|
49
|
+
# data with a fresh container exactly where at least one of the nodes applied at that
|
|
50
|
+
# position copies, which a copier computes from the node structure alone. A copier for a
|
|
51
|
+
# set of nodes (a union's arms are applied at once) answers _NOT_PLAIN instead whenever a
|
|
52
|
+
# node could do anything but copy: a BaseModel, a Mapping that is not a dict, an iterable
|
|
53
|
+
# a list branch would materialise, a non-str dict key, or a value a format might change.
|
|
54
|
+
# The caller then takes the faithful path. Copiers recurse only as deep as the types do,
|
|
55
|
+
# so opaque values (object, Dict[str, object] values, unknown keys) are returned by
|
|
56
|
+
# reference without being walked, as stock does.
|
|
57
|
+
#
|
|
58
|
+
from __future__ import annotations
|
|
59
|
+
|
|
60
|
+
import ast
|
|
61
|
+
import functools
|
|
62
|
+
import hashlib
|
|
63
|
+
import inspect
|
|
64
|
+
import logging
|
|
65
|
+
import sys
|
|
66
|
+
import threading
|
|
67
|
+
import types
|
|
68
|
+
import typing
|
|
69
|
+
from collections.abc import Callable, Coroutine, Iterable, Mapping
|
|
70
|
+
from typing import Any, Final, cast, override
|
|
71
|
+
|
|
72
|
+
import typing_extensions
|
|
73
|
+
|
|
74
|
+
logger = logging.getLogger("openai_fast_transform")
|
|
75
|
+
|
|
76
|
+
# sha256 of the normalised openai/_utils/_transform.py (see _digest) at every release in
|
|
77
|
+
# each range (identical on Python 3.12-3.14). A raises for bare dict/list annotations; A and
|
|
78
|
+
# B dump BaseModels without `exclude` in the async transform. The NotRequired change of
|
|
79
|
+
# v3.22.1 lives in helpers the plans call, so C covers both. A's file fingerprint starts at
|
|
80
|
+
# v1.106.0, but is_given (below) only matches from v1.108.1, so earlier releases are
|
|
81
|
+
# refused.
|
|
82
|
+
_PROFILES: Final = {
|
|
83
|
+
"f4fac2b3efba380ab112888bcb8dc448579450af0c6050ad48e26d4334c81ddf": "A", # 1.106.0-3.11.0
|
|
84
|
+
"80066a82b76a2e009a83692f11237f016aff07d723097ab8a229171035e1ebcc": "B", # 3.12.0-3.18.0
|
|
85
|
+
"f57c110db4046086daec67e77596aa034d5bbf4541bd36a0e1c062ecf1c09d08": "C", # 3.19.0-3.22.0
|
|
86
|
+
"972f4e003507b0ccfced9a9a08e4fafb55b55f6a28c5e14b8b14f75f75014412": "C", # 3.22.1-3.43.0
|
|
87
|
+
}
|
|
88
|
+
# Function-level fingerprints of the helpers the copiers' identity argument relies on,
|
|
89
|
+
# unchanged from v1.108.1 through the newest release above.
|
|
90
|
+
_HELPERS: Final = {
|
|
91
|
+
"is_given": "a5e2169fcf23b5b0e42ee616ee372dddb848f1f14db2a4706bed313e94caa62b",
|
|
92
|
+
"is_base64_file_input": "7a7155747eff3c1445c585d97f6dbe7e83a72ac99ba3bfa870f72f3ab13c7be6",
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
_SCOPE_PREFIXES: tuple[str, ...] = ("openai.",)
|
|
96
|
+
_STD_MODULES: Final = frozenset(
|
|
97
|
+
{
|
|
98
|
+
"builtins",
|
|
99
|
+
"collections.abc",
|
|
100
|
+
"datetime",
|
|
101
|
+
"io",
|
|
102
|
+
"os",
|
|
103
|
+
"pathlib",
|
|
104
|
+
"pathlib._local",
|
|
105
|
+
"types",
|
|
106
|
+
"typing",
|
|
107
|
+
"typing_extensions",
|
|
108
|
+
}
|
|
109
|
+
)
|
|
110
|
+
_SCALARS: Final = frozenset({str, int, float, bool, type(None)})
|
|
111
|
+
# What @functools.lru_cache returns; functools keeps the name private.
|
|
112
|
+
_LRU_CACHE_WRAPPER: Final = type(functools.lru_cache(len))
|
|
113
|
+
# At this many nodes, annotations not yet compiled run stock, so callers that build a new
|
|
114
|
+
# annotation per call cannot grow the tables without bound. Compiling every TypedDict in
|
|
115
|
+
# openai.types takes about 4,800 nodes (openai 3.43.0).
|
|
116
|
+
_MAX_NODES: Final = 20_000
|
|
117
|
+
|
|
118
|
+
# Node kinds: the branch stock's _transform_recursive takes for an annotation.
|
|
119
|
+
_TD = "typeddict"
|
|
120
|
+
_DICT = "dict"
|
|
121
|
+
_LIST = "list"
|
|
122
|
+
_UNION = "union"
|
|
123
|
+
_LEAF = "leaf"
|
|
124
|
+
_DELEGATE = "delegate"
|
|
125
|
+
_NOT_PLAIN: Final = object()
|
|
126
|
+
# _maybe_transform_key(_KEY_PROBE, hint) is _KEY_PROBE unless the field has an alias.
|
|
127
|
+
_KEY_PROBE: Final = object()
|
|
128
|
+
|
|
129
|
+
_result: bool | None = None # install()'s outcome, once it has run
|
|
130
|
+
_install_lock = threading.Lock()
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class _Refused(Exception):
|
|
134
|
+
"""The loaded openai is not a verified release."""
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class _Normaliser(ast.NodeTransformer):
|
|
138
|
+
"""Drops what cannot change behaviour: docstrings, annotations and ``cast`` calls."""
|
|
139
|
+
|
|
140
|
+
@override
|
|
141
|
+
def generic_visit(self, node: ast.AST) -> ast.AST:
|
|
142
|
+
if isinstance(node, (ast.Module, ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
143
|
+
first = node.body[0] if node.body else None
|
|
144
|
+
if (
|
|
145
|
+
isinstance(first, ast.Expr)
|
|
146
|
+
and isinstance(first.value, ast.Constant)
|
|
147
|
+
and isinstance(first.value.value, str)
|
|
148
|
+
):
|
|
149
|
+
node.body = node.body[1:] or [ast.Pass()]
|
|
150
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
151
|
+
node.returns = None
|
|
152
|
+
elif isinstance(node, ast.arg):
|
|
153
|
+
node.annotation = None
|
|
154
|
+
elif isinstance(node, ast.AnnAssign):
|
|
155
|
+
# Keep the statement: a bare `x: T` still makes `x` a local name.
|
|
156
|
+
node.annotation = ast.Constant(...)
|
|
157
|
+
node = super().generic_visit(node)
|
|
158
|
+
if isinstance(node, ast.Call) and len(node.args) == 2 and not node.keywords:
|
|
159
|
+
func = node.func
|
|
160
|
+
if (isinstance(func, ast.Name) and func.id == "cast") or (
|
|
161
|
+
isinstance(func, ast.Attribute)
|
|
162
|
+
and func.attr == "cast"
|
|
163
|
+
and isinstance(func.value, ast.Name)
|
|
164
|
+
and func.value.id == "typing"
|
|
165
|
+
):
|
|
166
|
+
return node.args[1]
|
|
167
|
+
return node
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _digest(node: ast.AST) -> str:
|
|
171
|
+
"""Fingerprint of a parsed module or function; the same on Python 3.12-3.14."""
|
|
172
|
+
return hashlib.sha256(ast.unparse(_Normaliser().visit(node)).encode()).hexdigest()
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _functions(tree: ast.Module) -> dict[str, ast.FunctionDef | ast.AsyncFunctionDef]:
|
|
176
|
+
return {
|
|
177
|
+
node.name: node
|
|
178
|
+
for node in tree.body
|
|
179
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _source(module: types.ModuleType) -> str:
|
|
184
|
+
loader = getattr(module, "__loader__", None)
|
|
185
|
+
source = None if loader is None else loader.get_source(module.__name__)
|
|
186
|
+
if source is None:
|
|
187
|
+
raise _Refused(f"no source for {module.__name__}")
|
|
188
|
+
return source
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _check_code(
|
|
192
|
+
namespace: Mapping[str, Any],
|
|
193
|
+
source: str,
|
|
194
|
+
functions: Mapping[str, ast.FunctionDef | ast.AsyncFunctionDef],
|
|
195
|
+
) -> None:
|
|
196
|
+
"""Refuse unless the loaded functions are the ones compiled from ``source``.
|
|
197
|
+
|
|
198
|
+
This catches openai being reinstalled after import (the file no longer matches the
|
|
199
|
+
code that runs) and anything that wrapped these functions before us.
|
|
200
|
+
"""
|
|
201
|
+
# Code objects compare equal across filenames.
|
|
202
|
+
compiled = compile(source, "<openai>", "exec", dont_inherit=True)
|
|
203
|
+
codes = {
|
|
204
|
+
const.co_name: const for const in compiled.co_consts if isinstance(const, types.CodeType)
|
|
205
|
+
}
|
|
206
|
+
for name, function in functions.items():
|
|
207
|
+
live = namespace[name]
|
|
208
|
+
# Exact types: a proxy can fake __class__ and forward __code__ and __wrapped__.
|
|
209
|
+
if function.decorator_list: # @lru_cache: compare the function it wraps
|
|
210
|
+
live = live.__wrapped__ if type(live) is _LRU_CACHE_WRAPPER else None
|
|
211
|
+
if type(live) is not types.FunctionType or live.__code__ != codes.get(name):
|
|
212
|
+
raise _Refused(f"the loaded {name} does not match its source")
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _helper(
|
|
216
|
+
namespace: Mapping[str, Any], name: str
|
|
217
|
+
) -> tuple[str, ast.FunctionDef | ast.AsyncFunctionDef | None, str]:
|
|
218
|
+
"""The source of the module defining helper ``name``, its function and fingerprint."""
|
|
219
|
+
if name not in namespace:
|
|
220
|
+
raise _Refused(f"{name} is missing")
|
|
221
|
+
source = _source(sys.modules[namespace[name].__module__])
|
|
222
|
+
function = _functions(ast.parse(source)).get(name)
|
|
223
|
+
return source, function, "missing" if function is None else _digest(function)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _check(transform_module: types.ModuleType) -> str:
|
|
227
|
+
"""Return the profile for the loaded openai transform, or raise _Refused."""
|
|
228
|
+
source = _source(transform_module)
|
|
229
|
+
tree = ast.parse(source)
|
|
230
|
+
functions = _functions(tree)
|
|
231
|
+
digest = _digest(tree)
|
|
232
|
+
profile = _PROFILES.get(digest)
|
|
233
|
+
if profile is None:
|
|
234
|
+
raise _Refused(f"unrecognised _transform.py fingerprint {digest}")
|
|
235
|
+
namespace = vars(transform_module)
|
|
236
|
+
_check_code(namespace, source, functions)
|
|
237
|
+
for name, expected in _HELPERS.items():
|
|
238
|
+
helper_source, function, digest = _helper(namespace, name)
|
|
239
|
+
if function is None or digest != expected:
|
|
240
|
+
raise _Refused(f"unrecognised {name} fingerprint {digest}")
|
|
241
|
+
_check_code(namespace, helper_source, {name: function})
|
|
242
|
+
return profile
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
class _Node:
|
|
246
|
+
"""One (annotation, inner_type) pair, compiled the way stock's transform reads it."""
|
|
247
|
+
|
|
248
|
+
def __init__(self, compiler: _Compiler, annotation: Any, inner_type: Any) -> None:
|
|
249
|
+
self.compiler: _Compiler = compiler
|
|
250
|
+
self.annotation: Any = annotation
|
|
251
|
+
self.inner_type: Any = inner_type
|
|
252
|
+
self.kind: str = _DELEGATE
|
|
253
|
+
# TD: key -> (alias or None, child)
|
|
254
|
+
self.fields: dict[str, tuple[Any, _Node]] = {}
|
|
255
|
+
self.items: _Node | None = None # DICT
|
|
256
|
+
self.list_flags: tuple[bool, bool, bool] = (False, False, False) # LIST
|
|
257
|
+
self.elem: _Node | None = None # LIST, unless _no_transform_needed
|
|
258
|
+
self.members: list[_Node] = [] # UNION, nested unions flattened
|
|
259
|
+
self.fmt: Any = None
|
|
260
|
+
self.fmt_template: Any = None
|
|
261
|
+
self.eligible: bool = False
|
|
262
|
+
self.impure: bool = False # async: the whole call must run the original
|
|
263
|
+
self.fn: Callable[[object], object] = self.apply
|
|
264
|
+
|
|
265
|
+
def children(self) -> list[_Node]:
|
|
266
|
+
children = [child for _alias, child in self.fields.values()] + self.members
|
|
267
|
+
return children + [node for node in (self.items, self.elem) if node is not None]
|
|
268
|
+
|
|
269
|
+
def apply(self, data: object) -> object:
|
|
270
|
+
compiler = self.compiler
|
|
271
|
+
ns = compiler.ns
|
|
272
|
+
kind = self.kind
|
|
273
|
+
if kind is _DELEGATE:
|
|
274
|
+
return compiler.original(data, annotation=self.annotation, inner_type=self.inner_type)
|
|
275
|
+
if kind is _TD:
|
|
276
|
+
if type(data) is dict or ns["is_mapping"](data):
|
|
277
|
+
is_given = ns["is_given"]
|
|
278
|
+
fields = self.fields
|
|
279
|
+
result: dict[Any, object] = {}
|
|
280
|
+
for key, value in cast("Mapping[Any, object]", data).items():
|
|
281
|
+
if not is_given(value):
|
|
282
|
+
continue
|
|
283
|
+
field = fields.get(key)
|
|
284
|
+
if field is None:
|
|
285
|
+
result[key] = value
|
|
286
|
+
else:
|
|
287
|
+
alias, child = field
|
|
288
|
+
result[key if alias is None else alias] = child.fn(value)
|
|
289
|
+
return result
|
|
290
|
+
elif kind is _DICT:
|
|
291
|
+
if type(data) is dict or ns["is_mapping"](data):
|
|
292
|
+
items = cast(_Node, self.items).fn
|
|
293
|
+
return {
|
|
294
|
+
key: items(value) for key, value in cast("Mapping[Any, object]", data).items()
|
|
295
|
+
}
|
|
296
|
+
elif kind is _LIST:
|
|
297
|
+
listy, iterably, seqy = self.list_flags
|
|
298
|
+
if (
|
|
299
|
+
(listy and ns["is_list"](data))
|
|
300
|
+
or (iterably and ns["is_iterable"](data) and not isinstance(data, str))
|
|
301
|
+
or (seqy and ns["is_sequence"](data) and not isinstance(data, str))
|
|
302
|
+
):
|
|
303
|
+
if isinstance(data, dict):
|
|
304
|
+
return cast(object, data)
|
|
305
|
+
if self.elem is None:
|
|
306
|
+
return data if ns["is_list"](data) else list(cast(Iterable[Any], data))
|
|
307
|
+
elem = self.elem.fn
|
|
308
|
+
return [elem(item) for item in cast(Iterable[Any], data)]
|
|
309
|
+
elif kind is _UNION:
|
|
310
|
+
for member in self.members:
|
|
311
|
+
data = member.fn(data)
|
|
312
|
+
return data
|
|
313
|
+
if isinstance(data, ns["pydantic"].BaseModel):
|
|
314
|
+
model_dump = compiler.compat.model_dump
|
|
315
|
+
if compiler.exclude:
|
|
316
|
+
return model_dump(
|
|
317
|
+
data,
|
|
318
|
+
exclude_unset=True,
|
|
319
|
+
mode="json",
|
|
320
|
+
exclude=getattr(data, "__api_exclude__", None),
|
|
321
|
+
)
|
|
322
|
+
return model_dump(data, exclude_unset=True, mode="json")
|
|
323
|
+
if self.fmt is not None:
|
|
324
|
+
return ns["_format_data"](data, self.fmt, self.fmt_template)
|
|
325
|
+
return data
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
class _Compiler:
|
|
329
|
+
"""Plans for the sync transform, or (with ``sync``) for the async one."""
|
|
330
|
+
|
|
331
|
+
def __init__(
|
|
332
|
+
self,
|
|
333
|
+
transform_module: types.ModuleType,
|
|
334
|
+
compat: types.ModuleType,
|
|
335
|
+
profile: str,
|
|
336
|
+
sync: _Compiler | None = None,
|
|
337
|
+
) -> None:
|
|
338
|
+
self.ns: dict[str, Any] = vars(transform_module)
|
|
339
|
+
self.compat: Any = compat
|
|
340
|
+
self.profile: str = profile
|
|
341
|
+
self.sync: _Compiler = sync or self
|
|
342
|
+
self.is_async: bool = sync is not None
|
|
343
|
+
name = "_async_transform_recursive" if self.is_async else "_transform_recursive"
|
|
344
|
+
self.original: Callable[..., Any] = self.ns[name]
|
|
345
|
+
# Async A and B neither pass `exclude` nor read __api_exclude__.
|
|
346
|
+
self.exclude: bool = not self.is_async or profile == "C"
|
|
347
|
+
self.nodes: dict[tuple[object, object], _Node] = {}
|
|
348
|
+
self.copiers: dict[frozenset[_Node], Callable[[object], object]] = {}
|
|
349
|
+
# Filled only while compiling, so it stops growing once _MAX_NODES is reached.
|
|
350
|
+
self.scope: dict[int, tuple[object, bool]] = {}
|
|
351
|
+
self.warned_full: bool = False
|
|
352
|
+
self.warned_fallback: bool = False
|
|
353
|
+
|
|
354
|
+
def plan(self, annotation: object, inner_type: object) -> Callable[[object], object] | None:
|
|
355
|
+
"""The compiled transform for this call, or None to run the original instead."""
|
|
356
|
+
try:
|
|
357
|
+
node = self.nodes.get((annotation, inner_type))
|
|
358
|
+
except TypeError: # unhashable: stock's lru_caches raise (or not) the same way
|
|
359
|
+
return None
|
|
360
|
+
if node is None:
|
|
361
|
+
# The async compiler also fills the sync table (dict values), so check both.
|
|
362
|
+
size = max(len(self.nodes), len(self.sync.nodes))
|
|
363
|
+
if size >= _MAX_NODES:
|
|
364
|
+
if not self.warned_full:
|
|
365
|
+
self.warned_full = True
|
|
366
|
+
logger.warning(
|
|
367
|
+
"The openai transform holds %d plans; new annotations use the stock transform",
|
|
368
|
+
size,
|
|
369
|
+
)
|
|
370
|
+
return None
|
|
371
|
+
try:
|
|
372
|
+
node = self._compile(annotation, inner_type)
|
|
373
|
+
except Exception as error: # the original is always exact
|
|
374
|
+
if isinstance(error, MemoryError) or (
|
|
375
|
+
isinstance(error, RecursionError) and _deep_stack()
|
|
376
|
+
):
|
|
377
|
+
return None # may come from the caller's state, so retry next call
|
|
378
|
+
logger.warning(
|
|
379
|
+
"Could not compile the openai transform for %.200r; using the stock transform",
|
|
380
|
+
self._shown(annotation),
|
|
381
|
+
exc_info=True,
|
|
382
|
+
)
|
|
383
|
+
node = _Node(self, annotation, inner_type)
|
|
384
|
+
node.impure = True
|
|
385
|
+
node = self.nodes.setdefault((annotation, inner_type), node)
|
|
386
|
+
return None if node.impure else node.fn
|
|
387
|
+
|
|
388
|
+
def _compile(self, annotation: object, inner_type: object) -> _Node:
|
|
389
|
+
# Build into a private table and publish finished nodes only, so other threads
|
|
390
|
+
# never see a half-built plan and a duplicate compile is harmless.
|
|
391
|
+
building: dict[tuple[object, object], _Node] = {}
|
|
392
|
+
top = self._node(annotation, inner_type, building)
|
|
393
|
+
nodes = list(building.values())
|
|
394
|
+
for node in nodes:
|
|
395
|
+
if node.kind is _UNION:
|
|
396
|
+
node.members = _flatten(node.members)
|
|
397
|
+
plain_fmt = node.fmt is None or type(node.fmt) is str
|
|
398
|
+
node.eligible = (
|
|
399
|
+
node.kind is not _DELEGATE
|
|
400
|
+
and plain_fmt
|
|
401
|
+
and all(alias is None for alias, _child in node.fields.values())
|
|
402
|
+
)
|
|
403
|
+
# Only the async base64 path awaits (an anyio file read).
|
|
404
|
+
node.impure = self.is_async and (
|
|
405
|
+
node.kind is _DELEGATE or not plain_fmt or node.fmt == "base64"
|
|
406
|
+
)
|
|
407
|
+
changed = True
|
|
408
|
+
while changed: # fixpoint over recursive types
|
|
409
|
+
changed = False
|
|
410
|
+
for node in nodes:
|
|
411
|
+
children = node.children()
|
|
412
|
+
if node.eligible and not all(child.eligible for child in children):
|
|
413
|
+
node.eligible = False
|
|
414
|
+
changed = True
|
|
415
|
+
if not node.impure and any(child.impure for child in children):
|
|
416
|
+
node.impure = True
|
|
417
|
+
changed = True
|
|
418
|
+
copiers: dict[frozenset[_Node], Callable[[object], object]] = {}
|
|
419
|
+
for node in nodes:
|
|
420
|
+
if node.eligible:
|
|
421
|
+
node.fn = _with_copier(self._copier([node], copiers), node.apply)
|
|
422
|
+
for nodes_key, copier in copiers.items():
|
|
423
|
+
self.copiers.setdefault(nodes_key, copier)
|
|
424
|
+
for key, node in building.items():
|
|
425
|
+
self.nodes.setdefault(key, node)
|
|
426
|
+
if not self.warned_fallback and (
|
|
427
|
+
top.impure or any(node.kind is _DELEGATE for node in nodes)
|
|
428
|
+
):
|
|
429
|
+
self.warned_fallback = True
|
|
430
|
+
logger.warning(
|
|
431
|
+
"The openai transform falls back to stock for parts of %.200r; later fallbacks are not logged",
|
|
432
|
+
self._shown(annotation),
|
|
433
|
+
)
|
|
434
|
+
return self.nodes[annotation, inner_type]
|
|
435
|
+
|
|
436
|
+
def _node(
|
|
437
|
+
self,
|
|
438
|
+
annotation: object,
|
|
439
|
+
inner_type: object,
|
|
440
|
+
building: dict[tuple[object, object], _Node],
|
|
441
|
+
) -> _Node:
|
|
442
|
+
key = (annotation, inner_type)
|
|
443
|
+
node = self.nodes.get(key) or building.get(key)
|
|
444
|
+
if node is None:
|
|
445
|
+
node = building[key] = _Node(self, annotation, inner_type)
|
|
446
|
+
try:
|
|
447
|
+
self._fill(node, building)
|
|
448
|
+
except (RecursionError, MemoryError):
|
|
449
|
+
raise # may not be a property of the type: plan() decides
|
|
450
|
+
except Exception: # noqa: BLE001 - delegate: stock raises when data gets here
|
|
451
|
+
node.kind = _DELEGATE
|
|
452
|
+
return node
|
|
453
|
+
|
|
454
|
+
def _fill(self, node: _Node, building: dict[tuple[object, object], _Node]) -> None:
|
|
455
|
+
"""Set ``node``'s kind and children; it stays a DELEGATE when out of scope."""
|
|
456
|
+
ns = self.ns
|
|
457
|
+
if not (self._in_scope(node.annotation) and self._in_scope(node.inner_type)):
|
|
458
|
+
return
|
|
459
|
+
stripped = ns["strip_annotated_type"](node.inner_type)
|
|
460
|
+
if not self._in_scope(stripped):
|
|
461
|
+
return
|
|
462
|
+
origin = ns["get_origin"](stripped) or stripped
|
|
463
|
+
annotated = ns["_get_annotated_type"](node.annotation)
|
|
464
|
+
if annotated is not None:
|
|
465
|
+
for meta in ns["get_args"](annotated)[1:]:
|
|
466
|
+
if isinstance(meta, ns["PropertyInfo"]) and meta.format is not None:
|
|
467
|
+
node.fmt, node.fmt_template = meta.format, meta.format_template
|
|
468
|
+
break
|
|
469
|
+
listy = ns["is_list_type"](stripped)
|
|
470
|
+
iterably = ns["is_iterable_type"](stripped)
|
|
471
|
+
seqy = ns["is_sequence_type"](stripped)
|
|
472
|
+
if ns["is_typeddict"](stripped):
|
|
473
|
+
hints = ns["get_type_hints"](stripped, include_extras=True)
|
|
474
|
+
if not all(self._in_scope(hint) for hint in hints.values()):
|
|
475
|
+
return
|
|
476
|
+
for name, hint in hints.items():
|
|
477
|
+
alias = ns["_maybe_transform_key"](_KEY_PROBE, hint)
|
|
478
|
+
child = self._node(hint, hint, building)
|
|
479
|
+
node.fields[name] = (None if alias is _KEY_PROBE else alias, child)
|
|
480
|
+
node.kind = _TD
|
|
481
|
+
elif origin == dict: # noqa: E721 - stock's comparison, not `is`
|
|
482
|
+
args = ns["get_args"](stripped)
|
|
483
|
+
if self.profile == "A":
|
|
484
|
+
items = args[1] # raises for a bare dict, as stock A does at runtime
|
|
485
|
+
else:
|
|
486
|
+
items = args[1] if len(args) > 1 else object
|
|
487
|
+
# The async dict branch calls the sync transform for its values.
|
|
488
|
+
if self.is_async: # returns the published node if there is one
|
|
489
|
+
node.items = self.sync._compile(items, items)
|
|
490
|
+
else:
|
|
491
|
+
node.items = self._node(items, items, building)
|
|
492
|
+
node.kind = _DICT
|
|
493
|
+
elif listy or iterably or seqy:
|
|
494
|
+
if self.profile == "A":
|
|
495
|
+
elem = ns["extract_type_arg"](stripped, 0) # raises for a bare list
|
|
496
|
+
else:
|
|
497
|
+
args = ns["get_args"](stripped)
|
|
498
|
+
elem = args[0] if args else object
|
|
499
|
+
if not ns["_no_transform_needed"](elem):
|
|
500
|
+
node.elem = self._node(node.annotation, elem, building)
|
|
501
|
+
node.list_flags = (listy, iterably, seqy)
|
|
502
|
+
node.kind = _LIST
|
|
503
|
+
elif ns["is_union_type"](stripped):
|
|
504
|
+
node.members = [
|
|
505
|
+
self._node(node.annotation, member, building) for member in ns["get_args"](stripped)
|
|
506
|
+
]
|
|
507
|
+
node.kind = _UNION
|
|
508
|
+
else:
|
|
509
|
+
node.kind = _LEAF
|
|
510
|
+
|
|
511
|
+
def _shown(self, annotation: object) -> object:
|
|
512
|
+
# repr() of an annotation outside the scope could run its code.
|
|
513
|
+
hit = self.scope.get(id(annotation))
|
|
514
|
+
in_scope = hit is not None and hit[0] is annotation and hit[1]
|
|
515
|
+
return annotation if in_scope else type(annotation)
|
|
516
|
+
|
|
517
|
+
def _in_scope(self, tp: object) -> bool:
|
|
518
|
+
hit = self.scope.get(id(tp))
|
|
519
|
+
if hit is not None and hit[0] is tp:
|
|
520
|
+
return hit[1]
|
|
521
|
+
if issubclass(type(tp), type):
|
|
522
|
+
module = cast(type, tp).__module__
|
|
523
|
+
if self.ns["is_typeddict"](tp):
|
|
524
|
+
ok = module.startswith(_SCOPE_PREFIXES)
|
|
525
|
+
else:
|
|
526
|
+
ok = module in _STD_MODULES or module.startswith(_SCOPE_PREFIXES)
|
|
527
|
+
elif type(tp).__module__ not in _STD_MODULES:
|
|
528
|
+
ok = False
|
|
529
|
+
else:
|
|
530
|
+
origin = typing_extensions.get_origin(tp)
|
|
531
|
+
args = typing_extensions.get_args(tp)
|
|
532
|
+
if origin is typing.Literal or origin is typing_extensions.Literal:
|
|
533
|
+
ok = True
|
|
534
|
+
elif origin is typing.Annotated or origin is typing_extensions.Annotated:
|
|
535
|
+
ok = self._in_scope(args[0]) and all(
|
|
536
|
+
type(meta) is self.ns["PropertyInfo"] for meta in args[1:]
|
|
537
|
+
)
|
|
538
|
+
else:
|
|
539
|
+
ok = (origin is None or self._in_scope(origin)) and all(
|
|
540
|
+
self._in_scope(arg) for arg in args
|
|
541
|
+
)
|
|
542
|
+
self.scope[id(tp)] = (tp, ok)
|
|
543
|
+
return ok
|
|
544
|
+
|
|
545
|
+
def _copier(
|
|
546
|
+
self,
|
|
547
|
+
nodes: Iterable[_Node],
|
|
548
|
+
building: dict[frozenset[_Node], Callable[[object], object]],
|
|
549
|
+
) -> Callable[[object], object]:
|
|
550
|
+
flat = frozenset(_flatten(nodes)) # a union stands for its arms
|
|
551
|
+
copier = self.copiers.get(flat) or building.get(flat)
|
|
552
|
+
if copier is not None:
|
|
553
|
+
return copier
|
|
554
|
+
box: list[Callable[[object], object]] = []
|
|
555
|
+
# Recursive types reach this set again while it is being built.
|
|
556
|
+
building[flat] = lambda data: box[0](data)
|
|
557
|
+
tds = [node for node in flat if node.kind is _TD]
|
|
558
|
+
items = [cast(_Node, node.items) for node in flat if node.kind is _DICT]
|
|
559
|
+
per_key: dict[str, list[_Node]] = {}
|
|
560
|
+
for td in tds:
|
|
561
|
+
for name, (_alias, child) in td.fields.items():
|
|
562
|
+
per_key.setdefault(name, list(items)).append(child)
|
|
563
|
+
elems = [node.elem for node in flat if node.kind is _LIST and node.elem is not None]
|
|
564
|
+
copier = _make_copier(
|
|
565
|
+
self.ns,
|
|
566
|
+
key_copiers={
|
|
567
|
+
name: self._copier(children, building) for name, children in per_key.items()
|
|
568
|
+
},
|
|
569
|
+
default=self._copier(items, building) if items else None,
|
|
570
|
+
elem_copier=self._copier(elems, building) if elems else None,
|
|
571
|
+
has_td=bool(tds),
|
|
572
|
+
has_list=any(node.kind is _LIST for node in flat),
|
|
573
|
+
has_fmt=any(node.fmt is not None for node in flat),
|
|
574
|
+
)
|
|
575
|
+
box.append(copier)
|
|
576
|
+
building[flat] = copier
|
|
577
|
+
return copier
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def _deep_stack() -> bool:
|
|
581
|
+
"""Whether the caller uses over half the recursion limit, so a RecursionError may be its."""
|
|
582
|
+
depth = 0
|
|
583
|
+
frame = inspect.currentframe()
|
|
584
|
+
while frame is not None:
|
|
585
|
+
depth += 1
|
|
586
|
+
frame = frame.f_back
|
|
587
|
+
return depth > sys.getrecursionlimit() // 2
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def _flatten(nodes: Iterable[_Node]) -> list[_Node]:
|
|
591
|
+
return [
|
|
592
|
+
arm
|
|
593
|
+
for node in nodes
|
|
594
|
+
for arm in (_flatten(node.members) if node.kind is _UNION else (node,))
|
|
595
|
+
]
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def _with_copier(
|
|
599
|
+
copier: Callable[[object], object], apply: Callable[[object], object]
|
|
600
|
+
) -> Callable[[object], object]:
|
|
601
|
+
def transform(data: object) -> object:
|
|
602
|
+
copied = copier(data)
|
|
603
|
+
return apply(data) if copied is _NOT_PLAIN else copied
|
|
604
|
+
|
|
605
|
+
return transform
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def _make_copier(
|
|
609
|
+
ns: dict[str, Any],
|
|
610
|
+
*,
|
|
611
|
+
key_copiers: dict[str, Callable[[object], object]],
|
|
612
|
+
default: Callable[[object], object] | None,
|
|
613
|
+
elem_copier: Callable[[object], object] | None,
|
|
614
|
+
has_td: bool,
|
|
615
|
+
has_list: bool,
|
|
616
|
+
has_fmt: bool,
|
|
617
|
+
) -> Callable[[object], object]:
|
|
618
|
+
"""The copy stock makes of plain data under a set of eligible nodes, or _NOT_PLAIN.
|
|
619
|
+
|
|
620
|
+
``key_copiers`` and ``default`` apply to a dict's values (TypedDict fields, then
|
|
621
|
+
dict items; ``default`` is set exactly when a dict node is in the set);
|
|
622
|
+
``elem_copier`` to a list's elements.
|
|
623
|
+
"""
|
|
624
|
+
is_given = ns["is_given"]
|
|
625
|
+
is_mapping = ns["is_mapping"]
|
|
626
|
+
is_list = ns["is_list"]
|
|
627
|
+
is_iterable = ns["is_iterable"]
|
|
628
|
+
base_model = ns["pydantic"].BaseModel
|
|
629
|
+
has_dict = default is not None
|
|
630
|
+
has_map = has_td or has_dict
|
|
631
|
+
copier_for = key_copiers.get
|
|
632
|
+
scalars = _SCALARS
|
|
633
|
+
not_plain = _NOT_PLAIN
|
|
634
|
+
|
|
635
|
+
def copy(data: object) -> object:
|
|
636
|
+
cls = type(data)
|
|
637
|
+
if cls in scalars:
|
|
638
|
+
return data
|
|
639
|
+
if cls is dict:
|
|
640
|
+
if not has_map:
|
|
641
|
+
return data
|
|
642
|
+
result: dict[str, object] = {}
|
|
643
|
+
for key, value in cast("dict[Any, object]", data).items():
|
|
644
|
+
# Before hashing it: a non-str key's __hash__/__eq__ may be user code,
|
|
645
|
+
# which the faithful path would then run again.
|
|
646
|
+
if type(key) is not str:
|
|
647
|
+
return not_plain
|
|
648
|
+
value_cls = type(value)
|
|
649
|
+
if value_cls in scalars:
|
|
650
|
+
result[key] = value
|
|
651
|
+
continue
|
|
652
|
+
if (
|
|
653
|
+
has_td
|
|
654
|
+
and value_cls is not dict
|
|
655
|
+
and value_cls is not list
|
|
656
|
+
and not is_given(value)
|
|
657
|
+
):
|
|
658
|
+
# A TypedDict drops it; a dict arm may first have seen it.
|
|
659
|
+
if has_dict:
|
|
660
|
+
return not_plain
|
|
661
|
+
continue
|
|
662
|
+
copier = copier_for(key, default)
|
|
663
|
+
if copier is None:
|
|
664
|
+
result[key] = value
|
|
665
|
+
continue
|
|
666
|
+
copied = copier(value)
|
|
667
|
+
if copied is not_plain:
|
|
668
|
+
return not_plain
|
|
669
|
+
result[key] = copied
|
|
670
|
+
return result
|
|
671
|
+
if cls is list:
|
|
672
|
+
if elem_copier is None:
|
|
673
|
+
return data
|
|
674
|
+
copies: list[object] = []
|
|
675
|
+
for item in cast("list[object]", data):
|
|
676
|
+
if type(item) in scalars:
|
|
677
|
+
copies.append(item)
|
|
678
|
+
continue
|
|
679
|
+
copied = elem_copier(item)
|
|
680
|
+
if copied is not_plain:
|
|
681
|
+
return not_plain
|
|
682
|
+
copies.append(copied)
|
|
683
|
+
return copies
|
|
684
|
+
# A list branch returns a dict (subclass) as is; is_list honours __class__.
|
|
685
|
+
if (
|
|
686
|
+
has_fmt
|
|
687
|
+
or isinstance(data, base_model)
|
|
688
|
+
or (has_map and is_mapping(data))
|
|
689
|
+
or (
|
|
690
|
+
has_list
|
|
691
|
+
and not issubclass(cls, dict)
|
|
692
|
+
and (is_list(data) or (not issubclass(cls, str) and is_iterable(data)))
|
|
693
|
+
)
|
|
694
|
+
):
|
|
695
|
+
return not_plain
|
|
696
|
+
return data
|
|
697
|
+
|
|
698
|
+
return copy
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
def _entry_points(
|
|
702
|
+
sync: _Compiler, async_: _Compiler
|
|
703
|
+
) -> tuple[Callable[..., object], Callable[..., Coroutine[Any, Any, object]]]:
|
|
704
|
+
# Same signatures as stock: callers pass annotation/inner_type by keyword.
|
|
705
|
+
def _patched_transform_recursive(
|
|
706
|
+
data: object,
|
|
707
|
+
*,
|
|
708
|
+
annotation: type,
|
|
709
|
+
inner_type: type | None = None,
|
|
710
|
+
) -> object:
|
|
711
|
+
plan = sync.plan(annotation, annotation if inner_type is None else inner_type)
|
|
712
|
+
if plan is None:
|
|
713
|
+
return sync.original(data, annotation=annotation, inner_type=inner_type)
|
|
714
|
+
return plan(data)
|
|
715
|
+
|
|
716
|
+
async def _patched_async_transform_recursive(
|
|
717
|
+
data: object,
|
|
718
|
+
*,
|
|
719
|
+
annotation: type,
|
|
720
|
+
inner_type: type | None = None,
|
|
721
|
+
) -> object:
|
|
722
|
+
plan = async_.plan(annotation, annotation if inner_type is None else inner_type)
|
|
723
|
+
if plan is None:
|
|
724
|
+
return await async_.original(data, annotation=annotation, inner_type=inner_type)
|
|
725
|
+
return plan(data)
|
|
726
|
+
|
|
727
|
+
return _patched_transform_recursive, _patched_async_transform_recursive
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
def install() -> bool:
|
|
731
|
+
"""Replace openai's request transform with the compiled one if openai is verified.
|
|
732
|
+
|
|
733
|
+
Call it once, before the first request. Returns whether the compiled transform is in
|
|
734
|
+
place. Only the first call checks openai and logs; later calls return its result. Never
|
|
735
|
+
raises: an unverified openai or any failure while installing logs a WARNING and keeps
|
|
736
|
+
the stock transform.
|
|
737
|
+
"""
|
|
738
|
+
global _result
|
|
739
|
+
|
|
740
|
+
with _install_lock:
|
|
741
|
+
if _result is not None:
|
|
742
|
+
return _result
|
|
743
|
+
_result = False
|
|
744
|
+
try:
|
|
745
|
+
import openai
|
|
746
|
+
import openai._compat
|
|
747
|
+
import openai._utils._transform as transform_module
|
|
748
|
+
|
|
749
|
+
version = openai.__version__
|
|
750
|
+
try:
|
|
751
|
+
profile = _check(transform_module)
|
|
752
|
+
except _Refused as refused:
|
|
753
|
+
logger.warning(
|
|
754
|
+
"Fast openai transform not installed: openai %s is not verified (%s); keeping the stock transform",
|
|
755
|
+
version,
|
|
756
|
+
refused,
|
|
757
|
+
)
|
|
758
|
+
return False
|
|
759
|
+
sync = _Compiler(transform_module, openai._compat, profile)
|
|
760
|
+
async_ = _Compiler(transform_module, openai._compat, profile, sync=sync)
|
|
761
|
+
patched, patched_async = _entry_points(sync, async_)
|
|
762
|
+
transform_module.__dict__.update(
|
|
763
|
+
_transform_recursive=patched, _async_transform_recursive=patched_async
|
|
764
|
+
)
|
|
765
|
+
_result = True
|
|
766
|
+
except Exception: # never raise: stock stays in place
|
|
767
|
+
logger.warning(
|
|
768
|
+
"Fast openai transform failed to install; keeping the stock transform",
|
|
769
|
+
exc_info=True,
|
|
770
|
+
)
|
|
771
|
+
return False
|
|
772
|
+
logger.info("Fast openai transform installed (openai %s, profile %s)", version, profile)
|
|
773
|
+
return True
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
def main() -> int:
|
|
777
|
+
"""``python -m openai_fast_transform``: fingerprints of the installed openai; 1 if refused."""
|
|
778
|
+
import openai
|
|
779
|
+
import openai._utils._transform as transform_module
|
|
780
|
+
|
|
781
|
+
print(f"openai {openai.__version__}")
|
|
782
|
+
try:
|
|
783
|
+
print(f"_transform.py {_digest(ast.parse(_source(transform_module)))}")
|
|
784
|
+
for name in _HELPERS:
|
|
785
|
+
print(f"{name} {_helper(vars(transform_module), name)[2]}")
|
|
786
|
+
print(f"profile {_check(transform_module)}")
|
|
787
|
+
except _Refused as refused:
|
|
788
|
+
print(f"not verified: {refused}")
|
|
789
|
+
return 1
|
|
790
|
+
return 0
|
|
File without changes
|