recusal-openai 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,44 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ .eggs/
6
+ build/
7
+ dist/
8
+ .pytest_cache/
9
+ .mypy_cache/
10
+ .ruff_cache/
11
+ .venv/
12
+ venv/
13
+ env/
14
+
15
+ # Secrets, never commit
16
+ .env
17
+ .env.*
18
+ *.key
19
+ *.pem
20
+ *.p12
21
+ *service-account*.json
22
+ secrets/
23
+
24
+ # OS / IDE
25
+ .DS_Store
26
+ Thumbs.db
27
+ .vscode/
28
+ .idea/
29
+
30
+ # Local hook activation, the repo ships .claude/settings.json.example only;
31
+ # copying it to settings.json is a deliberate per-clone step, never committed
32
+ .claude/settings.json
33
+ .claude/settings.local.json
34
+
35
+ # Local working docs, not part of the published project
36
+ linkedin.md
37
+ NOTES.md
38
+ RUNBOOK.local.md
39
+ ESSAY.local.md
40
+ SOURCES.local.md
41
+ *.local.md
42
+
43
+ # Engineering Harness local state, internal planning notes
44
+ .claude/harness/
@@ -0,0 +1,201 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or Derivative
95
+ Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and do
117
+ not modify the License. You may add Your own attribution notices
118
+ within Derivative Works that You distribute, alongside or as an
119
+ addendum to the NOTICE text from the Work, provided that such
120
+ additional attribution notices cannot be construed as modifying
121
+ the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright 2026 Philip Paz
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
@@ -0,0 +1,124 @@
1
+ Metadata-Version: 2.4
2
+ Name: recusal-openai
3
+ Version: 0.1.0
4
+ Summary: Recusal gate for the OpenAI API: judge every function call of a Responses or Chat Completions loop you own, refuse before it runs, and optionally put each decision on a hash-chained audit log.
5
+ Project-URL: Homepage, https://github.com/philpaz/recusal
6
+ Project-URL: Documentation, https://github.com/philpaz/recusal/tree/main/adapters/openai
7
+ Project-URL: Repository, https://github.com/philpaz/recusal
8
+ Project-URL: Issues, https://github.com/philpaz/recusal/issues
9
+ Project-URL: Changelog, https://github.com/philpaz/recusal/blob/main/CHANGELOG.md
10
+ Author-email: Philip Paz <philip.paz@gmail.com>
11
+ License-Expression: Apache-2.0
12
+ License-File: LICENSE
13
+ Keywords: agent-governance,ai-agents,guardrails,openai,recusal,tool-use
14
+ Classifier: Development Status :: 4 - Beta
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3 :: Only
19
+ Classifier: Topic :: Security
20
+ Classifier: Topic :: Software Development :: Quality Assurance
21
+ Classifier: Typing :: Typed
22
+ Requires-Python: >=3.10
23
+ Requires-Dist: openai<4,>=3.34.0
24
+ Requires-Dist: recusal<1,>=0.14.0
25
+ Description-Content-Type: text/markdown
26
+
27
+ # recusal-openai
28
+
29
+ The [Recusal](https://github.com/philpaz/recusal) gate for a loop you own on the
30
+ [OpenAI API](https://platform.openai.com/docs/api-reference): every function call the
31
+ model proposes is judged by your policy before your code runs it, and a refused call
32
+ never runs. It covers the Responses API and Chat Completions with one decision function.
33
+
34
+ This is a separate distribution from `recusal`. The kernel has zero runtime dependencies
35
+ and a frozen compatibility perimeter; this package carries the OpenAI SDK dependency and
36
+ its own version, so an SDK release never forces a release of the kernel.
37
+
38
+ **Status: not yet published to PyPI.** Until it is, install it from the repository:
39
+
40
+ ```bash
41
+ pip install "recusal-openai @ git+https://github.com/philpaz/recusal#subdirectory=adapters/openai"
42
+ ```
43
+
44
+ Requires Python 3.10+ (the SDK's minimum), `recusal>=0.14.0` and `openai>=3.34.0`. It is
45
+ tested on every pull request against exactly `openai==3.34.0`, and weekly against the
46
+ newest release.
47
+
48
+ ## Use
49
+
50
+ ```python
51
+ from openai import OpenAI
52
+ from recusal import Finding
53
+ from recusal_openai import gate_function_calls
54
+
55
+
56
+ def run_sql(sql: str) -> str: ...
57
+
58
+
59
+ def policy(tool_name, tool_input):
60
+ sql = tool_input.get("sql", "")
61
+ if tool_name == "run_sql" and sql.lstrip().upper().startswith("DELETE"):
62
+ return [Finding.fail("no_deletes", severity="CRITICAL", message="DELETE is not allowed")]
63
+ return []
64
+
65
+
66
+ client = OpenAI()
67
+ response = client.responses.create(model=..., input=..., tools=...)
68
+ answers = gate_function_calls(response, {"run_sql": run_sql}, policy)
69
+ # pass ``answers`` as the next request's input
70
+ ```
71
+
72
+ `gate_tool_calls(completion, tools, policy)` does the same for Chat Completions and
73
+ returns `role: "tool"` messages. A completion requested with `n > 1` carries alternative
74
+ messages, so pass `choice=<list position>`; without it the gate refuses the completion
75
+ before anything runs.
76
+
77
+ Each proposed call gets exactly one answer under its own id: the tool's result, or
78
+ Recusal's reason. A policy that raises refuses the call (fail closed).
79
+
80
+ The gate binds the decoded arguments to the tool's signature with its defaults applied,
81
+ judges that invocation, and calls the tool with exactly those arguments. A default the
82
+ model omitted is therefore judged at the value the function would use.
83
+
84
+ ## Put every decision on the record
85
+
86
+ ```python
87
+ from recusal.audit import AuditLog
88
+ from recusal_openai import audited_function_calls
89
+
90
+ log = AuditLog(path="decisions.jsonl")
91
+ answers = audited_function_calls(response, tools, policy, log)
92
+ ```
93
+
94
+ `audited_tool_calls` is the Chat Completions form. Each call the model proposes, run or
95
+ refused, is one hash-chained entry labeled `openai.responses.function_call` or
96
+ `openai.chat_completions.tool_call` with the API's own call id and a SHA-256 fingerprint
97
+ of the arguments (never the arguments). If the log cannot be written the call is refused.
98
+ Check the log with `recusal audit verify decisions.jsonl`.
99
+
100
+ The audited functions call the plain ones, so there is one decision path.
101
+
102
+ ## What the gate refuses, and what it does not see
103
+
104
+ - **Refused with a reason, never run:** arguments that are not valid JSON or not a JSON
105
+ object, a tool the application does not have, arguments that do not bind to the tool,
106
+ and a tool whose signature cannot be judged by name (variadic or positional-only
107
+ parameters, an unsupported annotation, a default that is not JSON data).
108
+ - **Refused by raising, before anything in the turn runs:** an output item or tool call
109
+ of a type the gate does not handle (`custom_tool_call`, a hosted tool call, a type a
110
+ later SDK adds), a call without an id, and a duplicate id. A turn refused this way
111
+ writes no audit entry.
112
+ - **Not seen at all:** hosted tools (web search, file search, code interpreter, image
113
+ generation, remote MCP) run on the provider's side and never reach your loop.
114
+
115
+ ## Compatibility
116
+
117
+ This package is outside the kernel's frozen perimeter (`STABILITY.md`, Integrations). Its
118
+ public names are the ones in `recusal_openai.__all__`. When an SDK release changes the
119
+ response types the gate reads, this package gets a release and the kernel does not.
120
+
121
+ A runnable offline demonstration, with SDK-validated responses and no API key, is
122
+ [`examples/openai_responses_gate.py`](https://github.com/philpaz/recusal/blob/main/examples/openai_responses_gate.py)
123
+ and
124
+ [`examples/openai_responses_audit.py`](https://github.com/philpaz/recusal/blob/main/examples/openai_responses_audit.py).
@@ -0,0 +1,98 @@
1
+ # recusal-openai
2
+
3
+ The [Recusal](https://github.com/philpaz/recusal) gate for a loop you own on the
4
+ [OpenAI API](https://platform.openai.com/docs/api-reference): every function call the
5
+ model proposes is judged by your policy before your code runs it, and a refused call
6
+ never runs. It covers the Responses API and Chat Completions with one decision function.
7
+
8
+ This is a separate distribution from `recusal`. The kernel has zero runtime dependencies
9
+ and a frozen compatibility perimeter; this package carries the OpenAI SDK dependency and
10
+ its own version, so an SDK release never forces a release of the kernel.
11
+
12
+ **Status: not yet published to PyPI.** Until it is, install it from the repository:
13
+
14
+ ```bash
15
+ pip install "recusal-openai @ git+https://github.com/philpaz/recusal#subdirectory=adapters/openai"
16
+ ```
17
+
18
+ Requires Python 3.10+ (the SDK's minimum), `recusal>=0.14.0` and `openai>=3.34.0`. It is
19
+ tested on every pull request against exactly `openai==3.34.0`, and weekly against the
20
+ newest release.
21
+
22
+ ## Use
23
+
24
+ ```python
25
+ from openai import OpenAI
26
+ from recusal import Finding
27
+ from recusal_openai import gate_function_calls
28
+
29
+
30
+ def run_sql(sql: str) -> str: ...
31
+
32
+
33
+ def policy(tool_name, tool_input):
34
+ sql = tool_input.get("sql", "")
35
+ if tool_name == "run_sql" and sql.lstrip().upper().startswith("DELETE"):
36
+ return [Finding.fail("no_deletes", severity="CRITICAL", message="DELETE is not allowed")]
37
+ return []
38
+
39
+
40
+ client = OpenAI()
41
+ response = client.responses.create(model=..., input=..., tools=...)
42
+ answers = gate_function_calls(response, {"run_sql": run_sql}, policy)
43
+ # pass ``answers`` as the next request's input
44
+ ```
45
+
46
+ `gate_tool_calls(completion, tools, policy)` does the same for Chat Completions and
47
+ returns `role: "tool"` messages. A completion requested with `n > 1` carries alternative
48
+ messages, so pass `choice=<list position>`; without it the gate refuses the completion
49
+ before anything runs.
50
+
51
+ Each proposed call gets exactly one answer under its own id: the tool's result, or
52
+ Recusal's reason. A policy that raises refuses the call (fail closed).
53
+
54
+ The gate binds the decoded arguments to the tool's signature with its defaults applied,
55
+ judges that invocation, and calls the tool with exactly those arguments. A default the
56
+ model omitted is therefore judged at the value the function would use.
57
+
58
+ ## Put every decision on the record
59
+
60
+ ```python
61
+ from recusal.audit import AuditLog
62
+ from recusal_openai import audited_function_calls
63
+
64
+ log = AuditLog(path="decisions.jsonl")
65
+ answers = audited_function_calls(response, tools, policy, log)
66
+ ```
67
+
68
+ `audited_tool_calls` is the Chat Completions form. Each call the model proposes, run or
69
+ refused, is one hash-chained entry labeled `openai.responses.function_call` or
70
+ `openai.chat_completions.tool_call` with the API's own call id and a SHA-256 fingerprint
71
+ of the arguments (never the arguments). If the log cannot be written the call is refused.
72
+ Check the log with `recusal audit verify decisions.jsonl`.
73
+
74
+ The audited functions call the plain ones, so there is one decision path.
75
+
76
+ ## What the gate refuses, and what it does not see
77
+
78
+ - **Refused with a reason, never run:** arguments that are not valid JSON or not a JSON
79
+ object, a tool the application does not have, arguments that do not bind to the tool,
80
+ and a tool whose signature cannot be judged by name (variadic or positional-only
81
+ parameters, an unsupported annotation, a default that is not JSON data).
82
+ - **Refused by raising, before anything in the turn runs:** an output item or tool call
83
+ of a type the gate does not handle (`custom_tool_call`, a hosted tool call, a type a
84
+ later SDK adds), a call without an id, and a duplicate id. A turn refused this way
85
+ writes no audit entry.
86
+ - **Not seen at all:** hosted tools (web search, file search, code interpreter, image
87
+ generation, remote MCP) run on the provider's side and never reach your loop.
88
+
89
+ ## Compatibility
90
+
91
+ This package is outside the kernel's frozen perimeter (`STABILITY.md`, Integrations). Its
92
+ public names are the ones in `recusal_openai.__all__`. When an SDK release changes the
93
+ response types the gate reads, this package gets a release and the kernel does not.
94
+
95
+ A runnable offline demonstration, with SDK-validated responses and no API key, is
96
+ [`examples/openai_responses_gate.py`](https://github.com/philpaz/recusal/blob/main/examples/openai_responses_gate.py)
97
+ and
98
+ [`examples/openai_responses_audit.py`](https://github.com/philpaz/recusal/blob/main/examples/openai_responses_audit.py).
@@ -0,0 +1,54 @@
1
+ # The Recusal adapter for the OpenAI API, a separate distribution from `recusal`.
2
+ #
3
+ # Why separate: `recusal` has zero runtime dependencies and a frozen compatibility
4
+ # perimeter (STABILITY.md). An adapter follows a framework that releases on its own
5
+ # schedule, so it carries that dependency and its own version here, and a framework's
6
+ # breaking change never forces a release of the kernel.
7
+ #
8
+ # Build backend pinned exactly, the same pin as the kernel's pyproject.toml: a floating
9
+ # backend is unreviewed code in the release path.
10
+ [build-system]
11
+ requires = ["hatchling==1.27.0"]
12
+ build-backend = "hatchling.build"
13
+
14
+ [project]
15
+ name = "recusal-openai"
16
+ dynamic = ["version"]
17
+ description = "Recusal gate for the OpenAI API: judge every function call of a Responses or Chat Completions loop you own, refuse before it runs, and optionally put each decision on a hash-chained audit log."
18
+ readme = "README.md"
19
+ # The OpenAI SDK's own minimum.
20
+ requires-python = ">=3.10"
21
+ license = "Apache-2.0"
22
+ license-files = ["LICENSE"]
23
+ authors = [{ name = "Philip Paz", email = "philip.paz@gmail.com" }]
24
+ keywords = ["recusal", "openai", "ai-agents", "agent-governance", "guardrails", "tool-use"]
25
+ classifiers = [
26
+ "Development Status :: 4 - Beta",
27
+ "Intended Audience :: Developers",
28
+ "Operating System :: OS Independent",
29
+ "Programming Language :: Python :: 3",
30
+ "Programming Language :: Python :: 3 :: Only",
31
+ "Topic :: Security",
32
+ "Topic :: Software Development :: Quality Assurance",
33
+ "Typing :: Typed",
34
+ ]
35
+ dependencies = [
36
+ # 0.14.0 is the first release where `decide` and `recusal.invocation` are public.
37
+ "recusal>=0.14.0,<1",
38
+ # The lower bound is the version the pinned CI lane tests; the weekly latest lane
39
+ # (.github/workflows/compat.yml) tests the newest release.
40
+ "openai>=3.34.0,<4",
41
+ ]
42
+
43
+ [project.urls]
44
+ Homepage = "https://github.com/philpaz/recusal"
45
+ Documentation = "https://github.com/philpaz/recusal/tree/main/adapters/openai"
46
+ Repository = "https://github.com/philpaz/recusal"
47
+ Issues = "https://github.com/philpaz/recusal/issues"
48
+ Changelog = "https://github.com/philpaz/recusal/blob/main/CHANGELOG.md"
49
+
50
+ [tool.hatch.version]
51
+ path = "src/recusal_openai/__init__.py"
52
+
53
+ [tool.hatch.build.targets.wheel]
54
+ packages = ["src/recusal_openai"]
@@ -0,0 +1,167 @@
1
+ """Prove the INSTALLED recusal-openai wheel gates real OpenAI SDK response objects.
2
+
3
+ Run it from a directory outside the repository, with an interpreter that has the built
4
+ wheels installed:
5
+
6
+ python /path/to/adapters/openai/smoke_test.py
7
+
8
+ It fails if the package it imports is the source tree and not an installed distribution:
9
+ a check run from the checkout would prove nothing about the wheel. The responses are built
10
+ with the SDK's own ``model_validate``; no API key or network is used.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import os
17
+ import sys
18
+ import tempfile
19
+ from importlib import metadata
20
+ from pathlib import Path
21
+
22
+ # Python puts this script's directory first on the import path. Drop it, so nothing in
23
+ # the source tree can stand in for the installed package.
24
+ _HERE = Path(__file__).resolve().parent
25
+ sys.path[:] = [entry for entry in sys.path if Path(entry or ".").resolve() != _HERE]
26
+
27
+ import recusal_openai # noqa: E402
28
+ from openai.types.chat import ChatCompletion # noqa: E402
29
+ from openai.types.responses import Response # noqa: E402
30
+ from recusal_openai import ( # noqa: E402
31
+ SURFACE_CHAT,
32
+ SURFACE_RESPONSES,
33
+ audited_function_calls,
34
+ audited_tool_calls,
35
+ gate_function_calls,
36
+ gate_tool_calls,
37
+ )
38
+
39
+ import recusal # noqa: E402
40
+ from recusal import Finding # noqa: E402
41
+ from recusal.audit import AuditLog, verify # noqa: E402
42
+
43
+ EXECUTED: list[str] = []
44
+ CALLS = [("c1", "SELECT 1"), ("c2", "DELETE FROM t")]
45
+
46
+
47
+ def run_sql(sql: str) -> str:
48
+ """Record SQL text without connecting to a database."""
49
+ EXECUTED.append(sql)
50
+ return f"recorded: {sql}"
51
+
52
+
53
+ TOOLS = {"run_sql": run_sql}
54
+
55
+
56
+ def policy(tool_name, tool_input):
57
+ if str(tool_input.get("sql", "")).upper().startswith("DELETE"):
58
+ return [Finding.fail("no_deletes", severity="CRITICAL", message="DELETE is not allowed")]
59
+ return []
60
+
61
+
62
+ def response() -> Response:
63
+ return Response.model_validate(
64
+ {
65
+ "id": "resp_offline",
66
+ "object": "response",
67
+ "created_at": 0,
68
+ "model": "offline",
69
+ "output": [
70
+ {
71
+ "type": "function_call",
72
+ "id": f"fc_{ident}",
73
+ "call_id": ident,
74
+ "name": "run_sql",
75
+ "arguments": json.dumps({"sql": sql}),
76
+ "status": "completed",
77
+ }
78
+ for ident, sql in CALLS
79
+ ],
80
+ "parallel_tool_calls": True,
81
+ "tool_choice": "auto",
82
+ "tools": [],
83
+ }
84
+ )
85
+
86
+
87
+ def completion() -> ChatCompletion:
88
+ return ChatCompletion.model_validate(
89
+ {
90
+ "id": "chatcmpl_offline",
91
+ "object": "chat.completion",
92
+ "created": 0,
93
+ "model": "offline",
94
+ "choices": [
95
+ {
96
+ "index": 0,
97
+ "finish_reason": "tool_calls",
98
+ "message": {
99
+ "role": "assistant",
100
+ "content": None,
101
+ "tool_calls": [
102
+ {
103
+ "id": ident,
104
+ "type": "function",
105
+ "function": {
106
+ "name": "run_sql",
107
+ "arguments": json.dumps({"sql": sql}),
108
+ },
109
+ }
110
+ for ident, sql in CALLS
111
+ ],
112
+ },
113
+ }
114
+ ],
115
+ }
116
+ )
117
+
118
+
119
+ def main() -> None:
120
+ for module in (recusal, recusal_openai):
121
+ origin = Path(module.__file__).resolve()
122
+ assert "site-packages" in origin.parts, f"{module.__name__} is not installed: {origin}"
123
+ assert metadata.version("recusal-openai") == recusal_openai.__version__
124
+ requires = metadata.requires("recusal-openai") or []
125
+ assert any(r.startswith("recusal") and "openai" not in r for r in requires), requires
126
+
127
+ # Negative control: a policy that refuses nothing runs the DELETE, so the refusals
128
+ # below are the policy's, reached through the gate.
129
+ EXECUTED.clear()
130
+ gate_function_calls(response(), TOOLS, lambda *_: [])
131
+ assert EXECUTED == ["SELECT 1", "DELETE FROM t"], EXECUTED
132
+
133
+ EXECUTED.clear()
134
+ answers = {a["call_id"]: a["output"] for a in gate_function_calls(response(), TOOLS, policy)}
135
+ assert EXECUTED == ["SELECT 1"], EXECUTED
136
+ assert answers["c1"] == "recorded: SELECT 1" and "DELETE is not allowed" in answers["c2"]
137
+
138
+ EXECUTED.clear()
139
+ messages = {
140
+ m["tool_call_id"]: m["content"] for m in gate_tool_calls(completion(), TOOLS, policy)
141
+ }
142
+ assert EXECUTED == ["SELECT 1"], EXECUTED
143
+ assert "DELETE is not allowed" in messages["c2"], messages
144
+
145
+ for audited, proposal, surface in (
146
+ (audited_function_calls, response(), SURFACE_RESPONSES),
147
+ (audited_tool_calls, completion(), SURFACE_CHAT),
148
+ ):
149
+ EXECUTED.clear()
150
+ with tempfile.TemporaryDirectory() as folder:
151
+ log = AuditLog(path=os.path.join(folder, "audit.jsonl"))
152
+ audited(proposal, TOOLS, policy, log)
153
+ assert EXECUTED == ["SELECT 1"], EXECUTED
154
+ by_id = {entry["action"]["tool_use_id"]: entry["action"] for entry in log.entries}
155
+ assert by_id["c2"]["decision"] == "deny" and by_id["c2"]["surface"] == surface, by_id
156
+ assert by_id["c1"]["decision"] != "deny", by_id
157
+ intact, problems = verify(log.entries)
158
+ assert intact, problems
159
+
160
+ print(
161
+ f"recusal-openai {recusal_openai.__version__} with recusal "
162
+ f"{recusal.__version__}: installed wheel gates and records"
163
+ )
164
+
165
+
166
+ if __name__ == "__main__":
167
+ main()
@@ -0,0 +1,43 @@
1
+ """Recusal for the OpenAI API: judge every function call in a loop you own.
2
+
3
+ from recusal_openai import gate_function_calls
4
+
5
+ response = client.responses.create(model=..., input=..., tools=...)
6
+ answers = gate_function_calls(response, {"run_sql": run_sql}, policy)
7
+ # pass ``answers`` as the next request's input
8
+
9
+ ``gate_tool_calls(completion, tools, policy)`` is the same gate for Chat Completions.
10
+ ``policy(tool_name, tool_input)`` is an ordinary Recusal policy returning findings. A
11
+ refused call is answered with Recusal's reason under the call's own id, and its tool does
12
+ not run. ``audited_function_calls`` and ``audited_tool_calls`` are the same loops with
13
+ each decision on a hash-chained audit log.
14
+
15
+ The decision is made by ``recusal.decide``. This package adds only what is the OpenAI
16
+ API's: where the calls are in a response, how each is answered, and its call id.
17
+ """
18
+
19
+ from ._audit import SURFACE_CHAT, SURFACE_RESPONSES, audited_function_calls, audited_tool_calls
20
+ from ._gate import (
21
+ PASSIVE_OUTPUT_TYPES,
22
+ Policy,
23
+ Tools,
24
+ gate_function_calls,
25
+ gate_tool_calls,
26
+ judge_and_run,
27
+ )
28
+
29
+ __version__ = "0.1.0"
30
+
31
+ __all__ = [
32
+ "PASSIVE_OUTPUT_TYPES",
33
+ "SURFACE_CHAT",
34
+ "SURFACE_RESPONSES",
35
+ "Policy",
36
+ "Tools",
37
+ "__version__",
38
+ "audited_function_calls",
39
+ "audited_tool_calls",
40
+ "gate_function_calls",
41
+ "gate_tool_calls",
42
+ "judge_and_run",
43
+ ]
@@ -0,0 +1,79 @@
1
+ """The audited loop: the same decision, with every call on a hash-chained ``AuditLog``.
2
+
3
+ ``audited_function_calls`` and ``audited_tool_calls`` call ``gate_function_calls`` and
4
+ ``gate_tool_calls`` with the recording arguments passed through to ``recusal.decide``,
5
+ which writes the entry before returning. There is no second decision path. If the log
6
+ cannot be written the decision becomes a deny, so nothing runs unrecorded.
7
+
8
+ Each entry carries the surface of the API shape that adjudicated the call and the API's own
9
+ call id, and a SHA-256 fingerprint of the arguments, never the arguments themselves.
10
+
11
+ What each entry is bound to:
12
+
13
+ - a call that is judged: the bound invocation the tool is called with, defaults included;
14
+ - a call refused before judgment (arguments that are not a JSON object, an unknown tool, a
15
+ signature the gate cannot judge, arguments that do not bind): the arguments as proposed.
16
+
17
+ What is not on the log: a turn the gate refuses as a whole by raising (an item type it does
18
+ not handle, a call without an id, a duplicate id). It raises before anything is judged, so
19
+ it runs nothing and writes no entry. A hosted tool never reaches the loop at all.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from typing import Any, Optional
25
+
26
+ from openai.types.chat import ChatCompletion
27
+ from openai.types.responses import Response
28
+
29
+ from recusal.audit import AuditLog
30
+
31
+ from ._gate import Policy, Tools, gate_function_calls, gate_tool_calls
32
+
33
+ #: The surface labels: which API shape's loop adjudicated the call.
34
+ SURFACE_RESPONSES = "openai.responses.function_call"
35
+ SURFACE_CHAT = "openai.chat_completions.tool_call"
36
+
37
+
38
+ def audited_function_calls(
39
+ response: Response,
40
+ tools: Tools,
41
+ policy: Policy,
42
+ log: AuditLog,
43
+ *,
44
+ actor: Optional[str] = None,
45
+ control: Optional[dict[str, Any]] = None,
46
+ ) -> list[dict[str, Any]]:
47
+ """Responses API: ``gate_function_calls`` with every call recorded under its ``call_id``."""
48
+ return gate_function_calls(
49
+ response,
50
+ tools,
51
+ policy,
52
+ audit=log,
53
+ surface=SURFACE_RESPONSES,
54
+ actor=actor,
55
+ control=control,
56
+ )
57
+
58
+
59
+ def audited_tool_calls(
60
+ completion: ChatCompletion,
61
+ tools: Tools,
62
+ policy: Policy,
63
+ log: AuditLog,
64
+ *,
65
+ choice: Optional[int] = None,
66
+ actor: Optional[str] = None,
67
+ control: Optional[dict[str, Any]] = None,
68
+ ) -> list[dict[str, Any]]:
69
+ """Chat Completions: ``gate_tool_calls`` with every call recorded under its ``id``."""
70
+ return gate_tool_calls(
71
+ completion,
72
+ tools,
73
+ policy,
74
+ choice=choice,
75
+ audit=log,
76
+ surface=SURFACE_CHAT,
77
+ actor=actor,
78
+ control=control,
79
+ )
@@ -0,0 +1,274 @@
1
+ """The gate for a loop the application owns: one decision function, two API shapes.
2
+
3
+ An application that calls the OpenAI API directly reads the function calls out of the
4
+ response, runs them, and sends the results back. That loop belongs to the application,
5
+ which makes it the place to put the gate:
6
+
7
+ - the Responses API: ``function_call`` output items, answered with ``function_call_output``
8
+ input items (``gate_function_calls``);
9
+ - Chat Completions: ``tool_calls`` on the assistant message, answered with ``role: "tool"``
10
+ messages (``gate_tool_calls``).
11
+
12
+ For every call the model proposes, the gate decodes the arguments the way the tool would
13
+ receive them, judges the call with ``recusal.decide``, runs the tool only if it is not
14
+ denied, and returns exactly one answer carrying that call's own id. Arguments that are not
15
+ valid JSON or not a JSON object, a tool the application does not have, and a policy that
16
+ raises are all refused with a reason, never run.
17
+
18
+ Default-deny on what the loop does not understand: an output item or tool call of a type
19
+ this gate does not handle (``custom_tool_call``, a hosted tool call, a type added in a later
20
+ SDK release) raises, naming the type, instead of being skipped. The SDK's answer types do
21
+ not require a call id, so the gate refuses a call without one and checks that every call
22
+ got exactly one answer.
23
+
24
+ Hosted tools (web search, file search, code interpreter, image generation, remote MCP) run
25
+ on the provider's side and never reach this loop, so nothing here can gate them.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import copy
31
+ import inspect
32
+ import json
33
+ from typing import Any, Callable, List, Optional
34
+
35
+ from openai.types.chat import ChatCompletion
36
+ from openai.types.responses import Response
37
+
38
+ from recusal import Finding, decide
39
+ from recusal.invocation import unsupported_signature as _unsupported_signature
40
+
41
+ #: A Recusal policy: ``policy(tool_name, tool_input)`` returns a list of findings.
42
+ Policy = Callable[[str, dict], List[Any]]
43
+ #: The application's tools by the name the model calls them.
44
+ Tools = dict[str, Callable[..., Any]]
45
+
46
+ #: Response output items that cannot run anything, so the gate lets them pass. Everything
47
+ #: else (custom_tool_call, hosted tool calls, a type a later SDK adds) raises.
48
+ PASSIVE_OUTPUT_TYPES = frozenset({"message", "reasoning"})
49
+
50
+
51
+ def _unjudgeable(name: str, plain: str, detail: str, proposal: Any, record: dict) -> str:
52
+ """The answer for a call the gate refuses before any policy runs.
53
+
54
+ Without recording arguments it is ``plain``, the text this gate has always returned.
55
+ With them (the audited loop) the refusal goes through ``decide`` like
56
+ every other, so it is one entry on the log, bound to the call as the model proposed it,
57
+ and the text the model reads is the reason on the record.
58
+ """
59
+ if not record:
60
+ return plain
61
+ bound = proposal if isinstance(proposal, dict) else {"input": proposal}
62
+
63
+ def unjudgeable(_name, _input):
64
+ return [Finding.fail("recusal_unjudgeable_input", severity="CRITICAL", message=detail)]
65
+
66
+ try:
67
+ _, reason = decide(name, bound, unjudgeable, safe_errors=True, **record)
68
+ except Exception as exc: # noqa: BLE001, the refusal stands even if the record cannot be written
69
+ return f"Recusal failed closed (policy error): RECUSAL_POLICY_ERROR ({type(exc).__name__})"
70
+ return reason
71
+
72
+
73
+ def judge_and_run(
74
+ name: str, arguments: str | None, tools: Tools, policy: Policy, **record: Any
75
+ ) -> str:
76
+ """Decide one proposed call and run it if allowed; return the text the model gets back.
77
+
78
+ The ONE decision path for both API shapes. The decoded arguments are bound to the
79
+ tool's signature with its defaults applied (``inspect.signature(...).bind`` then
80
+ ``apply_defaults``: argument binding, not type validation; no coercion happens on this
81
+ path), the policy judges a detached copy of that bound invocation, and the tool is
82
+ called with exactly those bound arguments, so a default the model omitted is judged at
83
+ the value the function would use. A tool whose signature cannot be judged by name
84
+ (variadic or positional-only parameters, an unsupported annotation, a default outside
85
+ the JSON domain) is refused, as is a call that does not bind (an unknown or missing
86
+ parameter; the refusal names the parameter, never a value).
87
+
88
+ ``record`` goes straight to ``decide`` (``audit=``, ``surface=``, ``tool_use_id=``, ...),
89
+ so a loop that puts the decision on a log (``audited_function_calls``) decides,
90
+ and refuses, exactly as this one does. The entry for a judged call carries the
91
+ fingerprint of the bound invocation the tool is called with, defaults included; the
92
+ entry for a call refused before judgment is bound to the arguments as proposed.
93
+ """
94
+ try:
95
+ args = json.loads(arguments or "{}")
96
+ except ValueError:
97
+ detail = "arguments are not valid JSON"
98
+ return _unjudgeable(name, f"Recusal refused `{name}`: {detail}", detail, arguments, record)
99
+ if not isinstance(args, dict):
100
+ detail = "arguments are not a JSON object"
101
+ return _unjudgeable(name, f"Recusal refused `{name}`: {detail}", detail, args, record)
102
+ if name not in tools:
103
+ return _unjudgeable(
104
+ name, f"Recusal refused: unknown tool {name!r}", "unknown tool", args, record
105
+ )
106
+ func = tools[name]
107
+ why = _unsupported_signature(func)
108
+ if why is not None:
109
+ detail = f"its signature cannot be judged ({why})"
110
+ return _unjudgeable(name, f"Recusal refused `{name}`: {detail}", detail, args, record)
111
+ try:
112
+ bound = inspect.signature(func).bind(**args)
113
+ except TypeError as exc: # the message names parameters only, never values
114
+ detail = f"arguments do not bind to the tool ({exc})"
115
+ return _unjudgeable(name, f"Recusal refused `{name}`: {detail}", detail, args, record)
116
+ bound.apply_defaults()
117
+ invocation = dict(bound.arguments)
118
+ try:
119
+ decision, reason = decide(
120
+ name, copy.deepcopy(invocation), policy, safe_errors=True, **record
121
+ )
122
+ except Exception as exc: # noqa: BLE001, a buggy policy must not silently disable the gate
123
+ return f"Recusal failed closed (policy error): RECUSAL_POLICY_ERROR ({type(exc).__name__})"
124
+ if decision == "deny":
125
+ return reason
126
+ return str(func(**invocation))
127
+
128
+
129
+ def _require_id(call_id: Any, what: str) -> str:
130
+ """The SDK does not require an id on the answer; the adapter does, or the answer is lost."""
131
+ if not isinstance(call_id, str) or not call_id:
132
+ raise ValueError(f"Recusal refused {what}: it has no call id to answer")
133
+ return call_id
134
+
135
+
136
+ def _for_call(record: dict, call_id: str) -> dict:
137
+ """The recording arguments for one call of a turn: the turn's, plus the call's own id."""
138
+ return {**record, "tool_use_id": call_id} if record else {}
139
+
140
+
141
+ def _check_ids(call_ids: list[str]) -> None:
142
+ """Refuse a turn with a duplicate id BEFORE any call in it runs: the turn's answers are
143
+ keyed by id, so a duplicate could not be answered once, and a refusal raised after
144
+ dispatch would leave a side effect behind for the caller's retry to repeat."""
145
+ if len(set(call_ids)) != len(call_ids):
146
+ raise ValueError(f"Recusal refused the turn: duplicate call ids {call_ids}")
147
+
148
+
149
+ def _check_answered_once(call_ids: list[str], answered: list[str]) -> None:
150
+ """Every proposed call gets exactly one answer, and no answer invents a call."""
151
+ if sorted(call_ids) != sorted(answered):
152
+ raise ValueError(
153
+ f"Recusal refused the turn: calls {sorted(call_ids)} but answers {sorted(answered)}"
154
+ )
155
+ if len(set(call_ids)) != len(call_ids):
156
+ raise ValueError(f"Recusal refused the turn: duplicate call ids {call_ids}")
157
+
158
+
159
+ def gate_function_calls(
160
+ response: Response, tools: Tools, policy: Policy, **record: Any
161
+ ) -> list[dict[str, Any]]:
162
+ """Responses API: judge every ``function_call`` item; return ``function_call_output`` items.
163
+
164
+ Raises TypeError for an output item type this adapter does not handle, and ValueError for
165
+ a call without an id. Pass the returned list as the next request's ``input``.
166
+ ``record`` is the recording arguments for the turn (``audit=``, ``surface=``, ...);
167
+ each call is recorded under its own ``call_id``. A turn refused as a whole raises
168
+ before anything is judged, so it runs nothing and writes no entry.
169
+ """
170
+ # pass 1: every item is one this adapter handles and carries a usable, unique id;
171
+ # nothing runs until the whole turn has passed, so a refused turn has run nothing
172
+ calls: list[tuple[str, str, Any]] = []
173
+ for item in response.output:
174
+ item_type = getattr(item, "type", None)
175
+ if item_type in PASSIVE_OUTPUT_TYPES:
176
+ continue
177
+ if item_type != "function_call":
178
+ raise TypeError(
179
+ f"Recusal refused output item of type {item_type!r}: "
180
+ "this adapter handles function_call items only"
181
+ )
182
+ # the type check above is on the item's ``type`` string, which a type checker cannot
183
+ # use to narrow the SDK's union of output items; from here it is a function call
184
+ call: Any = item
185
+ call_id = _require_id(getattr(call, "call_id", None), f"function_call {call.name!r}")
186
+ calls.append((call_id, call.name, call.arguments))
187
+ call_ids = [call_id for call_id, _, _ in calls]
188
+ _check_ids(call_ids)
189
+ # pass 2: judge and run each call, answering it under its own id
190
+ outputs = [
191
+ {
192
+ "type": "function_call_output",
193
+ "call_id": call_id,
194
+ "output": judge_and_run(name, arguments, tools, policy, **_for_call(record, call_id)),
195
+ }
196
+ for call_id, name, arguments in calls
197
+ ]
198
+ _check_answered_once(call_ids, [o["call_id"] for o in outputs])
199
+ return outputs
200
+
201
+
202
+ def _chosen(completion: ChatCompletion, choice: Any):
203
+ """The one assistant message this turn dispatches: ``completion.choices[choice]``.
204
+
205
+ A completion requested with ``n > 1`` carries alternative messages, not one batch, so
206
+ one must be chosen explicitly; dispatching every alternative would run tool calls the
207
+ application never selected. ``choice`` is the list position. With one choice it may be
208
+ omitted. Every refusal here is raised before any call is judged or run.
209
+ """
210
+ choices = list(completion.choices or [])
211
+ if not choices:
212
+ raise ValueError("Recusal refused the completion: it has no choices")
213
+ if choice is None:
214
+ if len(choices) != 1:
215
+ raise ValueError(
216
+ f"Recusal refused the completion: it has {len(choices)} choices; pass "
217
+ "choice=<list position> to dispatch one assistant message"
218
+ )
219
+ return choices[0]
220
+ if isinstance(choice, bool) or not isinstance(choice, int):
221
+ raise TypeError(
222
+ f"Recusal refused the completion: choice must be an int list position, not "
223
+ f"{type(choice).__name__}"
224
+ )
225
+ if choice < 0 or choice >= len(choices):
226
+ raise ValueError(
227
+ f"Recusal refused the completion: choice {choice} is out of range for "
228
+ f"{len(choices)} choice(s)"
229
+ )
230
+ return choices[choice]
231
+
232
+
233
+ def gate_tool_calls(
234
+ completion: ChatCompletion,
235
+ tools: Tools,
236
+ policy: Policy,
237
+ *,
238
+ choice: Optional[int] = None,
239
+ **record: Any,
240
+ ) -> list[dict[str, Any]]:
241
+ """Chat Completions: judge every ``tool_calls`` entry of ONE chosen assistant message;
242
+ return ``role: "tool"`` messages.
243
+
244
+ ``choice`` is the list position in ``completion.choices``; it may be omitted only when
245
+ the completion has exactly one choice. A completion with several choices (``n > 1``)
246
+ and no ``choice`` is refused before anything is judged: the choices are alternative
247
+ messages, and only the one the application selects is dispatched. Raises TypeError for
248
+ a tool call type this adapter does not handle (``custom``) or a non-int ``choice``, and
249
+ ValueError for a call without an id, no choices, or an out-of-range ``choice``. Append
250
+ the returned messages after the chosen assistant message in the next request.
251
+ ``record`` is the recording arguments for the turn, as in ``gate_function_calls``.
252
+ """
253
+ chosen = _chosen(completion, choice)
254
+ calls: list[tuple[str, str, Any]] = []
255
+ for call in chosen.message.tool_calls or []:
256
+ if call.type != "function":
257
+ raise TypeError(
258
+ f"Recusal refused tool call of type {call.type!r}: "
259
+ "this adapter handles function tool calls only"
260
+ )
261
+ call_id = _require_id(call.id, f"tool call {call.function.name!r}")
262
+ calls.append((call_id, call.function.name, call.function.arguments))
263
+ call_ids = [call_id for call_id, _, _ in calls]
264
+ _check_ids(call_ids)
265
+ outputs = [
266
+ {
267
+ "role": "tool",
268
+ "tool_call_id": call_id,
269
+ "content": judge_and_run(name, arguments, tools, policy, **_for_call(record, call_id)),
270
+ }
271
+ for call_id, name, arguments in calls
272
+ ]
273
+ _check_answered_once(call_ids, [o["tool_call_id"] for o in outputs])
274
+ return outputs
File without changes
@@ -0,0 +1 @@
1
+ openai==3.34.0