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.
@@ -0,0 +1,8 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .venv*/
4
+ .ruff_cache/
5
+ .pytest_cache/
6
+ dist/
7
+ build/
8
+ *.egg-info/
@@ -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,8 @@
1
+ """An exact, compiled replacement for the openai SDK's request transform.
2
+
3
+ Call :func:`install` once, before the first request.
4
+ """
5
+
6
+ from openai_fast_transform._transform import install
7
+
8
+ __all__ = ["install"]
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from openai_fast_transform._transform import main
4
+
5
+ sys.exit(main())
@@ -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