oauth-codex 2.1.0__tar.gz → 2.2.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.
Files changed (64) hide show
  1. {oauth_codex-2.1.0 → oauth_codex-2.2.0}/PKG-INFO +39 -1
  2. {oauth_codex-2.1.0 → oauth_codex-2.2.0}/README.md +38 -0
  3. {oauth_codex-2.1.0 → oauth_codex-2.2.0}/pyproject.toml +1 -1
  4. {oauth_codex-2.1.0 → oauth_codex-2.2.0}/src/oauth_codex/_client.py +75 -5
  5. {oauth_codex-2.1.0 → oauth_codex-2.2.0}/src/oauth_codex/_version.py +1 -1
  6. {oauth_codex-2.1.0 → oauth_codex-2.2.0}/src/oauth_codex/tooling.py +196 -0
  7. {oauth_codex-2.1.0 → oauth_codex-2.2.0}/src/oauth_codex.egg-info/PKG-INFO +39 -1
  8. {oauth_codex-2.1.0 → oauth_codex-2.2.0}/src/oauth_codex.egg-info/SOURCES.txt +2 -1
  9. {oauth_codex-2.1.0 → oauth_codex-2.2.0}/tests/test_generate_async.py +111 -1
  10. {oauth_codex-2.1.0 → oauth_codex-2.2.0}/tests/test_generate_sync.py +136 -1
  11. oauth_codex-2.2.0/tests/test_tooling_strict_schema.py +130 -0
  12. {oauth_codex-2.1.0 → oauth_codex-2.2.0}/setup.cfg +0 -0
  13. {oauth_codex-2.1.0 → oauth_codex-2.2.0}/src/oauth_codex/__init__.py +0 -0
  14. {oauth_codex-2.1.0 → oauth_codex-2.2.0}/src/oauth_codex/_base_client.py +0 -0
  15. {oauth_codex-2.1.0 → oauth_codex-2.2.0}/src/oauth_codex/_engine.py +0 -0
  16. {oauth_codex-2.1.0 → oauth_codex-2.2.0}/src/oauth_codex/_exceptions.py +0 -0
  17. {oauth_codex-2.1.0 → oauth_codex-2.2.0}/src/oauth_codex/_models.py +0 -0
  18. {oauth_codex-2.1.0 → oauth_codex-2.2.0}/src/oauth_codex/_module_client.py +0 -0
  19. {oauth_codex-2.1.0 → oauth_codex-2.2.0}/src/oauth_codex/_resource.py +0 -0
  20. {oauth_codex-2.1.0 → oauth_codex-2.2.0}/src/oauth_codex/_types.py +0 -0
  21. {oauth_codex-2.1.0 → oauth_codex-2.2.0}/src/oauth_codex/auth/__init__.py +0 -0
  22. {oauth_codex-2.1.0 → oauth_codex-2.2.0}/src/oauth_codex/auth/config.py +0 -0
  23. {oauth_codex-2.1.0 → oauth_codex-2.2.0}/src/oauth_codex/auth/pkce.py +0 -0
  24. {oauth_codex-2.1.0 → oauth_codex-2.2.0}/src/oauth_codex/auth/store.py +0 -0
  25. {oauth_codex-2.1.0 → oauth_codex-2.2.0}/src/oauth_codex/auth/token_manager.py +0 -0
  26. {oauth_codex-2.1.0 → oauth_codex-2.2.0}/src/oauth_codex/compat_store.py +0 -0
  27. {oauth_codex-2.1.0 → oauth_codex-2.2.0}/src/oauth_codex/core_types.py +0 -0
  28. {oauth_codex-2.1.0 → oauth_codex-2.2.0}/src/oauth_codex/errors.py +0 -0
  29. {oauth_codex-2.1.0 → oauth_codex-2.2.0}/src/oauth_codex/py.typed +0 -0
  30. {oauth_codex-2.1.0 → oauth_codex-2.2.0}/src/oauth_codex/resources/__init__.py +0 -0
  31. {oauth_codex-2.1.0 → oauth_codex-2.2.0}/src/oauth_codex/resources/_wrappers.py +0 -0
  32. {oauth_codex-2.1.0 → oauth_codex-2.2.0}/src/oauth_codex/resources/files.py +0 -0
  33. {oauth_codex-2.1.0 → oauth_codex-2.2.0}/src/oauth_codex/resources/models.py +0 -0
  34. {oauth_codex-2.1.0 → oauth_codex-2.2.0}/src/oauth_codex/resources/responses/__init__.py +0 -0
  35. {oauth_codex-2.1.0 → oauth_codex-2.2.0}/src/oauth_codex/resources/responses/_helpers.py +0 -0
  36. {oauth_codex-2.1.0 → oauth_codex-2.2.0}/src/oauth_codex/resources/responses/input_tokens.py +0 -0
  37. {oauth_codex-2.1.0 → oauth_codex-2.2.0}/src/oauth_codex/resources/responses/responses.py +0 -0
  38. {oauth_codex-2.1.0 → oauth_codex-2.2.0}/src/oauth_codex/resources/vector_stores/__init__.py +0 -0
  39. {oauth_codex-2.1.0 → oauth_codex-2.2.0}/src/oauth_codex/resources/vector_stores/file_batches.py +0 -0
  40. {oauth_codex-2.1.0 → oauth_codex-2.2.0}/src/oauth_codex/resources/vector_stores/files.py +0 -0
  41. {oauth_codex-2.1.0 → oauth_codex-2.2.0}/src/oauth_codex/resources/vector_stores/vector_stores.py +0 -0
  42. {oauth_codex-2.1.0 → oauth_codex-2.2.0}/src/oauth_codex/store.py +0 -0
  43. {oauth_codex-2.1.0 → oauth_codex-2.2.0}/src/oauth_codex/types/__init__.py +0 -0
  44. {oauth_codex-2.1.0 → oauth_codex-2.2.0}/src/oauth_codex/types/file_deleted.py +0 -0
  45. {oauth_codex-2.1.0 → oauth_codex-2.2.0}/src/oauth_codex/types/file_object.py +0 -0
  46. {oauth_codex-2.1.0 → oauth_codex-2.2.0}/src/oauth_codex/types/responses/__init__.py +0 -0
  47. {oauth_codex-2.1.0 → oauth_codex-2.2.0}/src/oauth_codex/types/responses/input_token_count_response.py +0 -0
  48. {oauth_codex-2.1.0 → oauth_codex-2.2.0}/src/oauth_codex/types/responses/response.py +0 -0
  49. {oauth_codex-2.1.0 → oauth_codex-2.2.0}/src/oauth_codex/types/responses/response_stream_event.py +0 -0
  50. {oauth_codex-2.1.0 → oauth_codex-2.2.0}/src/oauth_codex/types/shared/__init__.py +0 -0
  51. {oauth_codex-2.1.0 → oauth_codex-2.2.0}/src/oauth_codex/types/shared/model_capabilities.py +0 -0
  52. {oauth_codex-2.1.0 → oauth_codex-2.2.0}/src/oauth_codex/types/shared/usage.py +0 -0
  53. {oauth_codex-2.1.0 → oauth_codex-2.2.0}/src/oauth_codex/types/vector_stores/__init__.py +0 -0
  54. {oauth_codex-2.1.0 → oauth_codex-2.2.0}/src/oauth_codex/types/vector_stores/vector_store.py +0 -0
  55. {oauth_codex-2.1.0 → oauth_codex-2.2.0}/src/oauth_codex/types/vector_stores/vector_store_deleted.py +0 -0
  56. {oauth_codex-2.1.0 → oauth_codex-2.2.0}/src/oauth_codex/types/vector_stores/vector_store_file.py +0 -0
  57. {oauth_codex-2.1.0 → oauth_codex-2.2.0}/src/oauth_codex/types/vector_stores/vector_store_file_batch.py +0 -0
  58. {oauth_codex-2.1.0 → oauth_codex-2.2.0}/src/oauth_codex/types/vector_stores/vector_store_search_response.py +0 -0
  59. {oauth_codex-2.1.0 → oauth_codex-2.2.0}/src/oauth_codex/version.py +0 -0
  60. {oauth_codex-2.1.0 → oauth_codex-2.2.0}/src/oauth_codex.egg-info/dependency_links.txt +0 -0
  61. {oauth_codex-2.1.0 → oauth_codex-2.2.0}/src/oauth_codex.egg-info/requires.txt +0 -0
  62. {oauth_codex-2.1.0 → oauth_codex-2.2.0}/src/oauth_codex.egg-info/top_level.txt +0 -0
  63. {oauth_codex-2.1.0 → oauth_codex-2.2.0}/tests/test_engine_stream_and_continuity.py +0 -0
  64. {oauth_codex-2.1.0 → oauth_codex-2.2.0}/tests/test_public_surface.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: oauth-codex
3
- Version: 2.1.0
3
+ Version: 2.2.0
4
4
  Summary: Codex OAuth-based Python SDK with a single Client and generate-first API
5
5
  Author: Codex
6
6
  Requires-Python: >=3.11
@@ -84,6 +84,44 @@ print(text)
84
84
 
85
85
  If a tool raises an exception, the SDK forwards it to the model as `{\"error\": ...}` and continues the loop.
86
86
 
87
+ ## Structured Output (Strict JSON Schema / Pydantic)
88
+
89
+ `generate` / `agenerate` can produce validated JSON object output via `output_schema`.
90
+
91
+ ```python
92
+ from pydantic import BaseModel
93
+ from oauth_codex import Client
94
+
95
+
96
+ class Summary(BaseModel):
97
+ title: str
98
+ score: int
99
+
100
+
101
+ client = Client()
102
+ out = client.generate(
103
+ "Return JSON with title and score",
104
+ output_schema=Summary,
105
+ )
106
+ print(out) # {"title": "...", "score": 1}
107
+ ```
108
+
109
+ You can also pass a raw JSON schema object.
110
+
111
+ ```python
112
+ out = client.generate(
113
+ "Return {\"ok\": true}",
114
+ output_schema={
115
+ "type": "object",
116
+ "properties": {"ok": {"type": "boolean"}},
117
+ },
118
+ )
119
+ print(out) # {"ok": True}
120
+ ```
121
+
122
+ When `output_schema` is set, strict mode is enabled by default unless `strict_output` is explicitly provided.
123
+ `stream` / `astream` still yield text deltas only; they do not parse final JSON objects.
124
+
87
125
  ## Async
88
126
 
89
127
  ```python
@@ -70,6 +70,44 @@ print(text)
70
70
 
71
71
  If a tool raises an exception, the SDK forwards it to the model as `{\"error\": ...}` and continues the loop.
72
72
 
73
+ ## Structured Output (Strict JSON Schema / Pydantic)
74
+
75
+ `generate` / `agenerate` can produce validated JSON object output via `output_schema`.
76
+
77
+ ```python
78
+ from pydantic import BaseModel
79
+ from oauth_codex import Client
80
+
81
+
82
+ class Summary(BaseModel):
83
+ title: str
84
+ score: int
85
+
86
+
87
+ client = Client()
88
+ out = client.generate(
89
+ "Return JSON with title and score",
90
+ output_schema=Summary,
91
+ )
92
+ print(out) # {"title": "...", "score": 1}
93
+ ```
94
+
95
+ You can also pass a raw JSON schema object.
96
+
97
+ ```python
98
+ out = client.generate(
99
+ "Return {\"ok\": true}",
100
+ output_schema={
101
+ "type": "object",
102
+ "properties": {"ok": {"type": "boolean"}},
103
+ },
104
+ )
105
+ print(out) # {"ok": True}
106
+ ```
107
+
108
+ When `output_schema` is set, strict mode is enabled by default unless `strict_output` is explicitly provided.
109
+ `stream` / `astream` still yield text deltas only; they do not parse final JSON objects.
110
+
73
111
  ## Async
74
112
 
75
113
  ```python
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "oauth-codex"
7
- version = "2.1.0"
7
+ version = "2.2.0"
8
8
  description = "Codex OAuth-based Python SDK with a single Client and generate-first API"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -22,10 +22,11 @@ from .core_types import (
22
22
  ToolResult,
23
23
  )
24
24
  from .store import FallbackTokenStore
25
- from .tooling import callable_to_tool_schema, normalize_tool_output
25
+ from .tooling import build_strict_response_format, callable_to_tool_schema, normalize_tool_output
26
26
 
27
27
  DEFAULT_MODEL = "gpt-5.3-codex"
28
28
  DEFAULT_MAX_TOOL_ROUNDS = 16
29
+ StructuredOutputSchema = type[BaseModel] | dict[str, Any]
29
30
 
30
31
 
31
32
  class OAuthCodexClient(SyncAPIClient):
@@ -96,9 +97,15 @@ class OAuthCodexClient(SyncAPIClient):
96
97
  temperature: float | None = None,
97
98
  top_p: float | None = None,
98
99
  max_output_tokens: int | None = None,
99
- ) -> str:
100
+ output_schema: StructuredOutputSchema | None = None,
101
+ strict_output: bool | None = None,
102
+ ) -> str | dict[str, Any]:
100
103
  messages = self._build_messages(prompt=prompt, images=images)
101
104
  normalized_tools, tools_by_name = self._normalize_tools(tools)
105
+ response_format, effective_strict_output = self._resolve_structured_output_options(
106
+ output_schema=output_schema,
107
+ strict_output=strict_output,
108
+ )
102
109
 
103
110
  previous_response_id: str | None = None
104
111
  tool_results: list[ToolResult] | None = None
@@ -114,6 +121,8 @@ class OAuthCodexClient(SyncAPIClient):
114
121
  ),
115
122
  tools=normalized_tools,
116
123
  tool_results=tool_results,
124
+ response_format=response_format,
125
+ strict_output=effective_strict_output,
117
126
  reasoning={"effort": reasoning_effort},
118
127
  previous_response_id=previous_response_id,
119
128
  temperature=temperature,
@@ -128,7 +137,10 @@ class OAuthCodexClient(SyncAPIClient):
128
137
  output_parts.append(result.text)
129
138
 
130
139
  if not result.tool_calls:
131
- return "".join(output_parts)
140
+ text = "".join(output_parts)
141
+ if output_schema is None:
142
+ return text
143
+ return self._parse_structured_output_text(text=text, output_schema=output_schema)
132
144
 
133
145
  tool_results = self._execute_tool_calls_sync(result.tool_calls, tools_by_name)
134
146
  previous_response_id = result.response_id
@@ -146,9 +158,15 @@ class OAuthCodexClient(SyncAPIClient):
146
158
  temperature: float | None = None,
147
159
  top_p: float | None = None,
148
160
  max_output_tokens: int | None = None,
149
- ) -> str:
161
+ output_schema: StructuredOutputSchema | None = None,
162
+ strict_output: bool | None = None,
163
+ ) -> str | dict[str, Any]:
150
164
  messages = self._build_messages(prompt=prompt, images=images)
151
165
  normalized_tools, tools_by_name = self._normalize_tools(tools)
166
+ response_format, effective_strict_output = self._resolve_structured_output_options(
167
+ output_schema=output_schema,
168
+ strict_output=strict_output,
169
+ )
152
170
 
153
171
  previous_response_id: str | None = None
154
172
  tool_results: list[ToolResult] | None = None
@@ -164,6 +182,8 @@ class OAuthCodexClient(SyncAPIClient):
164
182
  ),
165
183
  tools=normalized_tools,
166
184
  tool_results=tool_results,
185
+ response_format=response_format,
186
+ strict_output=effective_strict_output,
167
187
  reasoning={"effort": reasoning_effort},
168
188
  previous_response_id=previous_response_id,
169
189
  temperature=temperature,
@@ -178,7 +198,10 @@ class OAuthCodexClient(SyncAPIClient):
178
198
  output_parts.append(result.text)
179
199
 
180
200
  if not result.tool_calls:
181
- return "".join(output_parts)
201
+ text = "".join(output_parts)
202
+ if output_schema is None:
203
+ return text
204
+ return self._parse_structured_output_text(text=text, output_schema=output_schema)
182
205
 
183
206
  tool_results = await self._execute_tool_calls_async(result.tool_calls, tools_by_name)
184
207
  previous_response_id = result.response_id
@@ -196,9 +219,15 @@ class OAuthCodexClient(SyncAPIClient):
196
219
  temperature: float | None = None,
197
220
  top_p: float | None = None,
198
221
  max_output_tokens: int | None = None,
222
+ output_schema: StructuredOutputSchema | None = None,
223
+ strict_output: bool | None = None,
199
224
  ) -> Iterator[str]:
200
225
  messages = self._build_messages(prompt=prompt, images=images)
201
226
  normalized_tools, tools_by_name = self._normalize_tools(tools)
227
+ response_format, effective_strict_output = self._resolve_structured_output_options(
228
+ output_schema=output_schema,
229
+ strict_output=strict_output,
230
+ )
202
231
 
203
232
  previous_response_id: str | None = None
204
233
  tool_results: list[ToolResult] | None = None
@@ -215,6 +244,8 @@ class OAuthCodexClient(SyncAPIClient):
215
244
  ),
216
245
  tools=normalized_tools,
217
246
  tool_results=tool_results,
247
+ response_format=response_format,
248
+ strict_output=effective_strict_output,
218
249
  reasoning={"effort": reasoning_effort},
219
250
  previous_response_id=previous_response_id,
220
251
  temperature=temperature,
@@ -249,9 +280,15 @@ class OAuthCodexClient(SyncAPIClient):
249
280
  temperature: float | None = None,
250
281
  top_p: float | None = None,
251
282
  max_output_tokens: int | None = None,
283
+ output_schema: StructuredOutputSchema | None = None,
284
+ strict_output: bool | None = None,
252
285
  ) -> AsyncIterator[str]:
253
286
  messages = self._build_messages(prompt=prompt, images=images)
254
287
  normalized_tools, tools_by_name = self._normalize_tools(tools)
288
+ response_format, effective_strict_output = self._resolve_structured_output_options(
289
+ output_schema=output_schema,
290
+ strict_output=strict_output,
291
+ )
255
292
 
256
293
  previous_response_id: str | None = None
257
294
  tool_results: list[ToolResult] | None = None
@@ -268,6 +305,8 @@ class OAuthCodexClient(SyncAPIClient):
268
305
  ),
269
306
  tools=normalized_tools,
270
307
  tool_results=tool_results,
308
+ response_format=response_format,
309
+ strict_output=effective_strict_output,
271
310
  reasoning={"effort": reasoning_effort},
272
311
  previous_response_id=previous_response_id,
273
312
  temperature=temperature,
@@ -294,6 +333,37 @@ class OAuthCodexClient(SyncAPIClient):
294
333
  def _resolve_model(self, model: str | None) -> str:
295
334
  return model or self.default_model
296
335
 
336
+ def _resolve_structured_output_options(
337
+ self,
338
+ *,
339
+ output_schema: StructuredOutputSchema | None,
340
+ strict_output: bool | None,
341
+ ) -> tuple[dict[str, Any] | None, bool]:
342
+ effective_strict_output = strict_output if strict_output is not None else bool(output_schema)
343
+ if output_schema is None:
344
+ return None, effective_strict_output
345
+ return build_strict_response_format(output_schema), effective_strict_output
346
+
347
+ def _parse_structured_output_text(
348
+ self,
349
+ *,
350
+ text: str,
351
+ output_schema: StructuredOutputSchema,
352
+ ) -> dict[str, Any]:
353
+ try:
354
+ parsed = json.loads(text)
355
+ except json.JSONDecodeError as exc:
356
+ raise ValueError("structured output is not valid JSON") from exc
357
+
358
+ if not isinstance(parsed, dict):
359
+ raise TypeError("structured output must be a JSON object")
360
+
361
+ model_type = self._resolve_pydantic_model_type(output_schema)
362
+ if model_type is None:
363
+ return parsed
364
+ validated = model_type.model_validate(parsed, strict=True)
365
+ return validated.model_dump(mode="json")
366
+
297
367
  def _is_tool_continuation_round(
298
368
  self,
299
369
  *,
@@ -1,2 +1,2 @@
1
1
  __title__ = "oauth-codex"
2
- __version__ = "2.1.0"
2
+ __version__ = "2.2.0"
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import copy
3
4
  import inspect
4
5
  import json
5
6
  from types import UnionType
@@ -30,6 +31,200 @@ def _pydantic_model_to_schema(model_type: type[Any]) -> dict[str, Any]:
30
31
  return {"type": "object"}
31
32
 
32
33
 
34
+ def _resolve_json_pointer_ref(*, root: dict[str, Any], ref: str) -> dict[str, Any]:
35
+ if not ref.startswith("#/"):
36
+ raise ValueError(f"Only local JSON pointer refs are supported, got: {ref}")
37
+
38
+ target: Any = root
39
+ for raw_part in ref[2:].split("/"):
40
+ part = raw_part.replace("~1", "/").replace("~0", "~")
41
+ if not isinstance(target, dict) or part not in target:
42
+ raise ValueError(f"Unresolvable JSON pointer ref: {ref}")
43
+ target = target[part]
44
+
45
+ if not isinstance(target, dict):
46
+ raise ValueError(f"JSON pointer ref must resolve to an object schema: {ref}")
47
+ return copy.deepcopy(target)
48
+
49
+
50
+ def _ensure_strict_json_schema(
51
+ json_schema: dict[str, Any],
52
+ *,
53
+ path: tuple[str, ...] = (),
54
+ root: dict[str, Any] | None = None,
55
+ ) -> dict[str, Any]:
56
+ if not isinstance(json_schema, dict):
57
+ raise TypeError(f"Expected dict at path={path!r}, got {type(json_schema).__name__}")
58
+
59
+ if root is None:
60
+ root = json_schema
61
+
62
+ defs = json_schema.get("$defs")
63
+ if defs is not None:
64
+ if not isinstance(defs, dict):
65
+ raise TypeError(f"Expected $defs to be a dict at path={path!r}")
66
+ for def_name, def_schema in defs.items():
67
+ if not isinstance(def_schema, dict):
68
+ raise TypeError(f"Expected object schema in $defs[{def_name!r}]")
69
+ _ensure_strict_json_schema(
70
+ def_schema,
71
+ path=(*path, "$defs", str(def_name)),
72
+ root=root,
73
+ )
74
+
75
+ definitions = json_schema.get("definitions")
76
+ if definitions is not None:
77
+ if not isinstance(definitions, dict):
78
+ raise TypeError(f"Expected definitions to be a dict at path={path!r}")
79
+ for definition_name, definition_schema in definitions.items():
80
+ if not isinstance(definition_schema, dict):
81
+ raise TypeError(f"Expected object schema in definitions[{definition_name!r}]")
82
+ _ensure_strict_json_schema(
83
+ definition_schema,
84
+ path=(*path, "definitions", str(definition_name)),
85
+ root=root,
86
+ )
87
+
88
+ if json_schema.get("type") == "object" and "additionalProperties" not in json_schema:
89
+ json_schema["additionalProperties"] = False
90
+
91
+ properties = json_schema.get("properties")
92
+ if properties is not None:
93
+ if not isinstance(properties, dict):
94
+ raise TypeError(f"Expected properties to be a dict at path={path!r}")
95
+ json_schema["required"] = list(properties.keys())
96
+ json_schema["properties"] = {
97
+ key: _ensure_strict_json_schema(
98
+ prop_schema,
99
+ path=(*path, "properties", str(key)),
100
+ root=root,
101
+ )
102
+ for key, prop_schema in properties.items()
103
+ }
104
+
105
+ items = json_schema.get("items")
106
+ if items is not None:
107
+ if isinstance(items, dict):
108
+ json_schema["items"] = _ensure_strict_json_schema(
109
+ items,
110
+ path=(*path, "items"),
111
+ root=root,
112
+ )
113
+ elif isinstance(items, list):
114
+ json_schema["items"] = [
115
+ _ensure_strict_json_schema(
116
+ entry,
117
+ path=(*path, "items", str(i)),
118
+ root=root,
119
+ )
120
+ for i, entry in enumerate(items)
121
+ ]
122
+ else:
123
+ raise TypeError(f"Expected items to be a dict or list at path={path!r}")
124
+
125
+ any_of = json_schema.get("anyOf")
126
+ if any_of is not None:
127
+ if not isinstance(any_of, list):
128
+ raise TypeError(f"Expected anyOf to be a list at path={path!r}")
129
+ json_schema["anyOf"] = [
130
+ _ensure_strict_json_schema(
131
+ variant,
132
+ path=(*path, "anyOf", str(i)),
133
+ root=root,
134
+ )
135
+ for i, variant in enumerate(any_of)
136
+ ]
137
+
138
+ all_of = json_schema.get("allOf")
139
+ if all_of is not None:
140
+ if not isinstance(all_of, list):
141
+ raise TypeError(f"Expected allOf to be a list at path={path!r}")
142
+ if len(all_of) == 1:
143
+ entry = _ensure_strict_json_schema(
144
+ all_of[0],
145
+ path=(*path, "allOf", "0"),
146
+ root=root,
147
+ )
148
+ json_schema.update(entry)
149
+ json_schema.pop("allOf", None)
150
+ else:
151
+ json_schema["allOf"] = [
152
+ _ensure_strict_json_schema(
153
+ entry,
154
+ path=(*path, "allOf", str(i)),
155
+ root=root,
156
+ )
157
+ for i, entry in enumerate(all_of)
158
+ ]
159
+
160
+ if json_schema.get("default", object()) is None:
161
+ json_schema.pop("default", None)
162
+
163
+ ref = json_schema.get("$ref")
164
+ if ref is not None and len(json_schema) > 1:
165
+ if not isinstance(ref, str):
166
+ raise TypeError(f"Expected $ref to be a string at path={path!r}")
167
+ resolved = _resolve_json_pointer_ref(root=root, ref=ref)
168
+ json_schema.update({**resolved, **json_schema})
169
+ json_schema.pop("$ref", None)
170
+ return _ensure_strict_json_schema(json_schema, path=path, root=root)
171
+
172
+ return json_schema
173
+
174
+
175
+ def build_strict_response_format(output_schema: type[Any] | dict[str, Any]) -> dict[str, Any]:
176
+ name = "output"
177
+ description: str | None = None
178
+
179
+ if _is_pydantic_model_type(output_schema):
180
+ model_type = output_schema
181
+ schema = _pydantic_model_to_schema(model_type)
182
+ name = getattr(model_type, "__name__", "output") or "output"
183
+ elif isinstance(output_schema, dict):
184
+ schema: dict[str, Any]
185
+ if output_schema.get("type") == "json_schema":
186
+ nested = output_schema.get("json_schema")
187
+ if nested is not None:
188
+ if not isinstance(nested, dict):
189
+ raise TypeError("response format json_schema payload must be a dictionary")
190
+ schema = nested.get("schema")
191
+ if not isinstance(schema, dict):
192
+ raise TypeError("response format json_schema.schema must be a dictionary")
193
+ name = str(nested.get("name") or output_schema.get("name") or "output")
194
+ nested_description = nested.get("description")
195
+ root_description = output_schema.get("description")
196
+ if isinstance(nested_description, str):
197
+ description = nested_description
198
+ elif isinstance(root_description, str):
199
+ description = root_description
200
+ else:
201
+ schema = output_schema.get("schema")
202
+ if not isinstance(schema, dict):
203
+ raise TypeError("response format schema must be a dictionary")
204
+ name = str(output_schema.get("name") or "output")
205
+ root_description = output_schema.get("description")
206
+ if isinstance(root_description, str):
207
+ description = root_description
208
+ else:
209
+ schema = output_schema
210
+ else:
211
+ raise TypeError("output_schema must be a pydantic model type or a dictionary")
212
+
213
+ strict_schema = _ensure_strict_json_schema(copy.deepcopy(schema))
214
+ if strict_schema.get("type") != "object":
215
+ raise ValueError("structured output schema root must be a JSON object")
216
+
217
+ response_format: dict[str, Any] = {
218
+ "type": "json_schema",
219
+ "name": name,
220
+ "schema": strict_schema,
221
+ "strict": True,
222
+ }
223
+ if description:
224
+ response_format["description"] = description
225
+ return response_format
226
+
227
+
33
228
  def _python_type_to_schema(annotation: Any) -> dict[str, Any]:
34
229
  if annotation is inspect._empty:
35
230
  return {"type": "string"}
@@ -199,6 +394,7 @@ def to_responses_tools(
199
394
  "parameters": dict(tool.get("parameters", {"type": "object", "properties": {}})),
200
395
  }
201
396
  if strict_output:
397
+ item["parameters"] = _ensure_strict_json_schema(copy.deepcopy(item["parameters"]))
202
398
  item["strict"] = True
203
399
  normalized.append(item)
204
400
  return normalized
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: oauth-codex
3
- Version: 2.1.0
3
+ Version: 2.2.0
4
4
  Summary: Codex OAuth-based Python SDK with a single Client and generate-first API
5
5
  Author: Codex
6
6
  Requires-Python: >=3.11
@@ -84,6 +84,44 @@ print(text)
84
84
 
85
85
  If a tool raises an exception, the SDK forwards it to the model as `{\"error\": ...}` and continues the loop.
86
86
 
87
+ ## Structured Output (Strict JSON Schema / Pydantic)
88
+
89
+ `generate` / `agenerate` can produce validated JSON object output via `output_schema`.
90
+
91
+ ```python
92
+ from pydantic import BaseModel
93
+ from oauth_codex import Client
94
+
95
+
96
+ class Summary(BaseModel):
97
+ title: str
98
+ score: int
99
+
100
+
101
+ client = Client()
102
+ out = client.generate(
103
+ "Return JSON with title and score",
104
+ output_schema=Summary,
105
+ )
106
+ print(out) # {"title": "...", "score": 1}
107
+ ```
108
+
109
+ You can also pass a raw JSON schema object.
110
+
111
+ ```python
112
+ out = client.generate(
113
+ "Return {\"ok\": true}",
114
+ output_schema={
115
+ "type": "object",
116
+ "properties": {"ok": {"type": "boolean"}},
117
+ },
118
+ )
119
+ print(out) # {"ok": True}
120
+ ```
121
+
122
+ When `output_schema` is set, strict mode is enabled by default unless `strict_output` is explicitly provided.
123
+ `stream` / `astream` still yield text deltas only; they do not parse final JSON objects.
124
+
87
125
  ## Async
88
126
 
89
127
  ```python
@@ -58,4 +58,5 @@ src/oauth_codex/types/vector_stores/vector_store_search_response.py
58
58
  tests/test_engine_stream_and_continuity.py
59
59
  tests/test_generate_async.py
60
60
  tests/test_generate_sync.py
61
- tests/test_public_surface.py
61
+ tests/test_public_surface.py
62
+ tests/test_tooling_strict_schema.py
@@ -1,7 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import pytest
4
- from pydantic import BaseModel
4
+ from pydantic import BaseModel, ValidationError
5
5
 
6
6
  from conftest import InMemoryTokenStore
7
7
  from oauth_codex import Client
@@ -12,6 +12,11 @@ class ToolInput(BaseModel):
12
12
  query: str
13
13
 
14
14
 
15
+ class StructuredOutput(BaseModel):
16
+ answer: str
17
+ count: int
18
+
19
+
15
20
  def _client() -> Client:
16
21
  return Client(
17
22
  token_store=InMemoryTokenStore(
@@ -117,3 +122,108 @@ async def test_agenerate_supports_single_pydantic_tool_input(monkeypatch: pytest
117
122
  assert out == "done"
118
123
  tool_results = calls[1]["tool_results"]
119
124
  assert tool_results[0].output == {"output": "Tool received query: hello"}
125
+
126
+
127
+ @pytest.mark.asyncio
128
+ async def test_agenerate_supports_structured_output_with_pydantic_schema(
129
+ monkeypatch: pytest.MonkeyPatch,
130
+ ) -> None:
131
+ client = _client()
132
+ captured: dict[str, object] = {}
133
+
134
+ async def fake_agenerate(**kwargs):
135
+ captured.update(kwargs)
136
+ return GenerateResult(
137
+ text='{"answer":"ok","count":1}',
138
+ tool_calls=[],
139
+ finish_reason="stop",
140
+ response_id="resp_1",
141
+ )
142
+
143
+ monkeypatch.setattr(client._engine, "agenerate", fake_agenerate)
144
+
145
+ out = await client.agenerate("return json", output_schema=StructuredOutput)
146
+
147
+ assert out == {"answer": "ok", "count": 1}
148
+ response_format = captured["response_format"]
149
+ assert response_format["type"] == "json_schema"
150
+ assert response_format["name"] == "StructuredOutput"
151
+ assert response_format["strict"] is True
152
+ assert response_format["schema"]["required"] == ["answer", "count"]
153
+ assert captured["strict_output"] is True
154
+
155
+
156
+ @pytest.mark.asyncio
157
+ async def test_agenerate_structured_output_rejects_invalid_json(
158
+ monkeypatch: pytest.MonkeyPatch,
159
+ ) -> None:
160
+ client = _client()
161
+
162
+ async def fake_agenerate(**kwargs):
163
+ _ = kwargs
164
+ return GenerateResult(
165
+ text="not-json",
166
+ tool_calls=[],
167
+ finish_reason="stop",
168
+ )
169
+
170
+ monkeypatch.setattr(client._engine, "agenerate", fake_agenerate)
171
+
172
+ with pytest.raises(ValueError, match="valid JSON"):
173
+ await client.agenerate(
174
+ "return json",
175
+ output_schema={"type": "object", "properties": {"ok": {"type": "boolean"}}},
176
+ )
177
+
178
+
179
+ @pytest.mark.asyncio
180
+ async def test_agenerate_structured_output_enforces_pydantic_strict_validation(
181
+ monkeypatch: pytest.MonkeyPatch,
182
+ ) -> None:
183
+ client = _client()
184
+
185
+ async def fake_agenerate(**kwargs):
186
+ _ = kwargs
187
+ return GenerateResult(
188
+ text='{"answer":"ok","count":"1"}',
189
+ tool_calls=[],
190
+ finish_reason="stop",
191
+ )
192
+
193
+ monkeypatch.setattr(client._engine, "agenerate", fake_agenerate)
194
+
195
+ with pytest.raises(ValidationError):
196
+ await client.agenerate("return json", output_schema=StructuredOutput)
197
+
198
+
199
+ @pytest.mark.asyncio
200
+ async def test_astream_accepts_output_schema_and_keeps_text_stream(
201
+ monkeypatch: pytest.MonkeyPatch,
202
+ ) -> None:
203
+ client = _client()
204
+ calls: list[dict[str, object]] = []
205
+
206
+ async def fake_astream(**kwargs):
207
+ calls.append(kwargs)
208
+
209
+ async def events():
210
+ yield StreamEvent(type="text_delta", delta="{", response_id="resp_1")
211
+ yield StreamEvent(type="text_delta", delta='"ok":true}', response_id="resp_1")
212
+ yield StreamEvent(type="done", response_id="resp_1")
213
+
214
+ return events()
215
+
216
+ monkeypatch.setattr(client._engine, "agenerate_stream", fake_astream)
217
+
218
+ out: list[str] = []
219
+ async for delta in client.astream(
220
+ "return json",
221
+ output_schema={"type": "object", "properties": {"ok": {"type": "boolean"}}},
222
+ ):
223
+ out.append(delta)
224
+
225
+ assert out == ["{", '"ok":true}']
226
+ assert calls[0]["strict_output"] is True
227
+ response_format = calls[0]["response_format"]
228
+ assert response_format["type"] == "json_schema"
229
+ assert response_format["strict"] is True
@@ -1,7 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import pytest
4
- from pydantic import BaseModel, Field
4
+ from pydantic import BaseModel, Field, ValidationError
5
5
 
6
6
  from conftest import InMemoryTokenStore
7
7
  from oauth_codex import Client
@@ -16,6 +16,11 @@ class ToolInput(BaseModel):
16
16
  query: str
17
17
 
18
18
 
19
+ class StructuredOutput(BaseModel):
20
+ answer: str
21
+ count: int
22
+
23
+
19
24
  def _client() -> Client:
20
25
  return Client(
21
26
  token_store=InMemoryTokenStore(
@@ -277,6 +282,108 @@ def test_generate_raises_when_tool_round_limit_exceeded(monkeypatch: pytest.Monk
277
282
  client.generate("loop", tools=[loop])
278
283
 
279
284
 
285
+ def test_generate_supports_structured_output_with_pydantic_schema(
286
+ monkeypatch: pytest.MonkeyPatch,
287
+ ) -> None:
288
+ client = _client()
289
+ captured: dict[str, object] = {}
290
+
291
+ def fake_generate(**kwargs):
292
+ captured.update(kwargs)
293
+ return GenerateResult(
294
+ text='{"answer":"ok","count":1}',
295
+ tool_calls=[],
296
+ finish_reason="stop",
297
+ response_id="resp_1",
298
+ )
299
+
300
+ monkeypatch.setattr(client._engine, "generate", fake_generate)
301
+
302
+ out = client.generate("return json", output_schema=StructuredOutput)
303
+
304
+ assert out == {"answer": "ok", "count": 1}
305
+ response_format = captured["response_format"]
306
+ assert response_format["type"] == "json_schema"
307
+ assert response_format["name"] == "StructuredOutput"
308
+ assert response_format["strict"] is True
309
+ assert response_format["schema"]["type"] == "object"
310
+ assert response_format["schema"]["additionalProperties"] is False
311
+ assert response_format["schema"]["required"] == ["answer", "count"]
312
+ assert captured["strict_output"] is True
313
+
314
+
315
+ def test_generate_supports_structured_output_with_raw_schema_dict(
316
+ monkeypatch: pytest.MonkeyPatch,
317
+ ) -> None:
318
+ client = _client()
319
+ captured: dict[str, object] = {}
320
+
321
+ def fake_generate(**kwargs):
322
+ captured.update(kwargs)
323
+ return GenerateResult(
324
+ text='{"ok":true}',
325
+ tool_calls=[],
326
+ finish_reason="stop",
327
+ )
328
+
329
+ monkeypatch.setattr(client._engine, "generate", fake_generate)
330
+
331
+ out = client.generate(
332
+ "return json",
333
+ output_schema={
334
+ "type": "object",
335
+ "properties": {"ok": {"type": "boolean"}},
336
+ },
337
+ )
338
+
339
+ assert out == {"ok": True}
340
+ response_format = captured["response_format"]
341
+ assert response_format["type"] == "json_schema"
342
+ assert response_format["name"] == "output"
343
+ assert response_format["strict"] is True
344
+ assert response_format["schema"]["required"] == ["ok"]
345
+ assert response_format["schema"]["additionalProperties"] is False
346
+
347
+
348
+ def test_generate_structured_output_rejects_invalid_json(monkeypatch: pytest.MonkeyPatch) -> None:
349
+ client = _client()
350
+
351
+ def fake_generate(**kwargs):
352
+ _ = kwargs
353
+ return GenerateResult(
354
+ text="not-json",
355
+ tool_calls=[],
356
+ finish_reason="stop",
357
+ )
358
+
359
+ monkeypatch.setattr(client._engine, "generate", fake_generate)
360
+
361
+ with pytest.raises(ValueError, match="valid JSON"):
362
+ client.generate(
363
+ "return json",
364
+ output_schema={"type": "object", "properties": {"ok": {"type": "boolean"}}},
365
+ )
366
+
367
+
368
+ def test_generate_structured_output_enforces_pydantic_strict_validation(
369
+ monkeypatch: pytest.MonkeyPatch,
370
+ ) -> None:
371
+ client = _client()
372
+
373
+ def fake_generate(**kwargs):
374
+ _ = kwargs
375
+ return GenerateResult(
376
+ text='{"answer":"ok","count":"1"}',
377
+ tool_calls=[],
378
+ finish_reason="stop",
379
+ )
380
+
381
+ monkeypatch.setattr(client._engine, "generate", fake_generate)
382
+
383
+ with pytest.raises(ValidationError):
384
+ client.generate("return json", output_schema=StructuredOutput)
385
+
386
+
280
387
  def test_stream_supports_tool_calls(monkeypatch: pytest.MonkeyPatch) -> None:
281
388
  client = _client()
282
389
  calls: list[dict[str, object]] = []
@@ -308,3 +415,31 @@ def test_stream_supports_tool_calls(monkeypatch: pytest.MonkeyPatch) -> None:
308
415
  assert calls[1]["messages"] == []
309
416
  tool_results = calls[1]["tool_results"]
310
417
  assert tool_results[0].output == {"sum": 3}
418
+
419
+
420
+ def test_stream_accepts_output_schema_and_keeps_text_stream(
421
+ monkeypatch: pytest.MonkeyPatch,
422
+ ) -> None:
423
+ client = _client()
424
+ calls: list[dict[str, object]] = []
425
+
426
+ def fake_stream(**kwargs):
427
+ calls.append(kwargs)
428
+ yield StreamEvent(type="text_delta", delta="{", response_id="resp_1")
429
+ yield StreamEvent(type="text_delta", delta='"ok":true}', response_id="resp_1")
430
+ yield StreamEvent(type="done", response_id="resp_1")
431
+
432
+ monkeypatch.setattr(client._engine, "generate_stream", fake_stream)
433
+
434
+ out = list(
435
+ client.stream(
436
+ "return json",
437
+ output_schema={"type": "object", "properties": {"ok": {"type": "boolean"}}},
438
+ )
439
+ )
440
+
441
+ assert out == ["{", '"ok":true}']
442
+ assert calls[0]["strict_output"] is True
443
+ response_format = calls[0]["response_format"]
444
+ assert response_format["type"] == "json_schema"
445
+ assert response_format["strict"] is True
@@ -0,0 +1,130 @@
1
+ from __future__ import annotations
2
+
3
+ import pytest
4
+
5
+ from oauth_codex.tooling import _ensure_strict_json_schema, build_strict_response_format, to_responses_tools
6
+
7
+
8
+ def test_build_strict_response_format_normalizes_recursive_object_schema() -> None:
9
+ response_format = build_strict_response_format(
10
+ {
11
+ "type": "object",
12
+ "properties": {
13
+ "nested": {
14
+ "type": "object",
15
+ "properties": {
16
+ "value": {"type": "string"},
17
+ },
18
+ },
19
+ "items": {
20
+ "type": "array",
21
+ "items": {
22
+ "type": "object",
23
+ "properties": {
24
+ "name": {"type": "string"},
25
+ },
26
+ },
27
+ },
28
+ "choice": {
29
+ "anyOf": [
30
+ {
31
+ "type": "object",
32
+ "properties": {
33
+ "a": {"type": "string"},
34
+ },
35
+ },
36
+ {
37
+ "type": "object",
38
+ "properties": {
39
+ "b": {"type": "string"},
40
+ },
41
+ },
42
+ ],
43
+ },
44
+ },
45
+ }
46
+ )
47
+
48
+ schema = response_format["schema"]
49
+ assert schema["type"] == "object"
50
+ assert schema["additionalProperties"] is False
51
+ assert schema["required"] == ["nested", "items", "choice"]
52
+ assert schema["properties"]["nested"]["additionalProperties"] is False
53
+ assert schema["properties"]["items"]["items"]["additionalProperties"] is False
54
+ assert schema["properties"]["choice"]["anyOf"][0]["additionalProperties"] is False
55
+ assert schema["properties"]["choice"]["anyOf"][1]["additionalProperties"] is False
56
+
57
+
58
+ def test_build_strict_response_format_inlines_ref_with_sibling_keys() -> None:
59
+ response_format = build_strict_response_format(
60
+ {
61
+ "type": "object",
62
+ "properties": {
63
+ "item": {
64
+ "$ref": "#/$defs/Item",
65
+ "description": "Inline ref",
66
+ }
67
+ },
68
+ "$defs": {
69
+ "Item": {
70
+ "type": "object",
71
+ "properties": {
72
+ "name": {"type": "string", "default": None},
73
+ },
74
+ }
75
+ },
76
+ }
77
+ )
78
+
79
+ item_schema = response_format["schema"]["properties"]["item"]
80
+ assert "$ref" not in item_schema
81
+ assert item_schema["description"] == "Inline ref"
82
+ assert item_schema["additionalProperties"] is False
83
+ assert "default" not in item_schema["properties"]["name"]
84
+
85
+
86
+ def test_build_strict_response_format_rejects_invalid_schema_input() -> None:
87
+ with pytest.raises(TypeError):
88
+ build_strict_response_format({"type": "json_schema", "schema": "bad"})
89
+
90
+ with pytest.raises(ValueError, match="root must be a JSON object"):
91
+ build_strict_response_format({"type": "array", "items": {"type": "string"}})
92
+
93
+
94
+ def test_ensure_strict_json_schema_fails_fast_on_unresolvable_ref() -> None:
95
+ with pytest.raises(ValueError, match="Unresolvable JSON pointer ref"):
96
+ _ensure_strict_json_schema(
97
+ {
98
+ "type": "object",
99
+ "properties": {
100
+ "item": {
101
+ "$ref": "#/$defs/Missing",
102
+ "description": "broken",
103
+ }
104
+ },
105
+ }
106
+ )
107
+
108
+
109
+ def test_to_responses_tools_applies_strict_schema_to_parameters() -> None:
110
+ tools = [
111
+ {
112
+ "type": "function",
113
+ "name": "demo",
114
+ "description": "Demo tool",
115
+ "parameters": {
116
+ "type": "object",
117
+ "properties": {
118
+ "payload": {
119
+ "type": "object",
120
+ "properties": {"value": {"type": "string"}},
121
+ }
122
+ },
123
+ },
124
+ }
125
+ ]
126
+
127
+ normalized = to_responses_tools(tools, strict_output=True)
128
+ assert normalized[0]["strict"] is True
129
+ assert normalized[0]["parameters"]["additionalProperties"] is False
130
+ assert normalized[0]["parameters"]["properties"]["payload"]["additionalProperties"] is False
File without changes