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.
- recusal_openai-0.1.0/.gitignore +44 -0
- recusal_openai-0.1.0/LICENSE +201 -0
- recusal_openai-0.1.0/PKG-INFO +124 -0
- recusal_openai-0.1.0/README.md +98 -0
- recusal_openai-0.1.0/pyproject.toml +54 -0
- recusal_openai-0.1.0/smoke_test.py +167 -0
- recusal_openai-0.1.0/src/recusal_openai/__init__.py +43 -0
- recusal_openai-0.1.0/src/recusal_openai/_audit.py +79 -0
- recusal_openai-0.1.0/src/recusal_openai/_gate.py +274 -0
- recusal_openai-0.1.0/src/recusal_openai/py.typed +0 -0
- recusal_openai-0.1.0/tested-with.txt +1 -0
|
@@ -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
|