oauth-codex 2.0.3__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.0.3 → oauth_codex-2.2.0}/PKG-INFO +39 -1
  2. {oauth_codex-2.0.3 → oauth_codex-2.2.0}/README.md +38 -0
  3. {oauth_codex-2.0.3 → oauth_codex-2.2.0}/pyproject.toml +1 -1
  4. {oauth_codex-2.0.3 → oauth_codex-2.2.0}/src/oauth_codex/_client.py +118 -10
  5. {oauth_codex-2.0.3 → oauth_codex-2.2.0}/src/oauth_codex/_engine.py +48 -5
  6. {oauth_codex-2.0.3 → oauth_codex-2.2.0}/src/oauth_codex/_version.py +1 -1
  7. {oauth_codex-2.0.3 → oauth_codex-2.2.0}/src/oauth_codex/tooling.py +196 -0
  8. {oauth_codex-2.0.3 → oauth_codex-2.2.0}/src/oauth_codex.egg-info/PKG-INFO +39 -1
  9. {oauth_codex-2.0.3 → oauth_codex-2.2.0}/src/oauth_codex.egg-info/SOURCES.txt +2 -1
  10. {oauth_codex-2.0.3 → oauth_codex-2.2.0}/tests/test_engine_stream_and_continuity.py +45 -1
  11. {oauth_codex-2.0.3 → oauth_codex-2.2.0}/tests/test_generate_async.py +113 -1
  12. {oauth_codex-2.0.3 → oauth_codex-2.2.0}/tests/test_generate_sync.py +167 -1
  13. oauth_codex-2.2.0/tests/test_tooling_strict_schema.py +130 -0
  14. {oauth_codex-2.0.3 → oauth_codex-2.2.0}/setup.cfg +0 -0
  15. {oauth_codex-2.0.3 → oauth_codex-2.2.0}/src/oauth_codex/__init__.py +0 -0
  16. {oauth_codex-2.0.3 → oauth_codex-2.2.0}/src/oauth_codex/_base_client.py +0 -0
  17. {oauth_codex-2.0.3 → oauth_codex-2.2.0}/src/oauth_codex/_exceptions.py +0 -0
  18. {oauth_codex-2.0.3 → oauth_codex-2.2.0}/src/oauth_codex/_models.py +0 -0
  19. {oauth_codex-2.0.3 → oauth_codex-2.2.0}/src/oauth_codex/_module_client.py +0 -0
  20. {oauth_codex-2.0.3 → oauth_codex-2.2.0}/src/oauth_codex/_resource.py +0 -0
  21. {oauth_codex-2.0.3 → oauth_codex-2.2.0}/src/oauth_codex/_types.py +0 -0
  22. {oauth_codex-2.0.3 → oauth_codex-2.2.0}/src/oauth_codex/auth/__init__.py +0 -0
  23. {oauth_codex-2.0.3 → oauth_codex-2.2.0}/src/oauth_codex/auth/config.py +0 -0
  24. {oauth_codex-2.0.3 → oauth_codex-2.2.0}/src/oauth_codex/auth/pkce.py +0 -0
  25. {oauth_codex-2.0.3 → oauth_codex-2.2.0}/src/oauth_codex/auth/store.py +0 -0
  26. {oauth_codex-2.0.3 → oauth_codex-2.2.0}/src/oauth_codex/auth/token_manager.py +0 -0
  27. {oauth_codex-2.0.3 → oauth_codex-2.2.0}/src/oauth_codex/compat_store.py +0 -0
  28. {oauth_codex-2.0.3 → oauth_codex-2.2.0}/src/oauth_codex/core_types.py +0 -0
  29. {oauth_codex-2.0.3 → oauth_codex-2.2.0}/src/oauth_codex/errors.py +0 -0
  30. {oauth_codex-2.0.3 → oauth_codex-2.2.0}/src/oauth_codex/py.typed +0 -0
  31. {oauth_codex-2.0.3 → oauth_codex-2.2.0}/src/oauth_codex/resources/__init__.py +0 -0
  32. {oauth_codex-2.0.3 → oauth_codex-2.2.0}/src/oauth_codex/resources/_wrappers.py +0 -0
  33. {oauth_codex-2.0.3 → oauth_codex-2.2.0}/src/oauth_codex/resources/files.py +0 -0
  34. {oauth_codex-2.0.3 → oauth_codex-2.2.0}/src/oauth_codex/resources/models.py +0 -0
  35. {oauth_codex-2.0.3 → oauth_codex-2.2.0}/src/oauth_codex/resources/responses/__init__.py +0 -0
  36. {oauth_codex-2.0.3 → oauth_codex-2.2.0}/src/oauth_codex/resources/responses/_helpers.py +0 -0
  37. {oauth_codex-2.0.3 → oauth_codex-2.2.0}/src/oauth_codex/resources/responses/input_tokens.py +0 -0
  38. {oauth_codex-2.0.3 → oauth_codex-2.2.0}/src/oauth_codex/resources/responses/responses.py +0 -0
  39. {oauth_codex-2.0.3 → oauth_codex-2.2.0}/src/oauth_codex/resources/vector_stores/__init__.py +0 -0
  40. {oauth_codex-2.0.3 → oauth_codex-2.2.0}/src/oauth_codex/resources/vector_stores/file_batches.py +0 -0
  41. {oauth_codex-2.0.3 → oauth_codex-2.2.0}/src/oauth_codex/resources/vector_stores/files.py +0 -0
  42. {oauth_codex-2.0.3 → oauth_codex-2.2.0}/src/oauth_codex/resources/vector_stores/vector_stores.py +0 -0
  43. {oauth_codex-2.0.3 → oauth_codex-2.2.0}/src/oauth_codex/store.py +0 -0
  44. {oauth_codex-2.0.3 → oauth_codex-2.2.0}/src/oauth_codex/types/__init__.py +0 -0
  45. {oauth_codex-2.0.3 → oauth_codex-2.2.0}/src/oauth_codex/types/file_deleted.py +0 -0
  46. {oauth_codex-2.0.3 → oauth_codex-2.2.0}/src/oauth_codex/types/file_object.py +0 -0
  47. {oauth_codex-2.0.3 → oauth_codex-2.2.0}/src/oauth_codex/types/responses/__init__.py +0 -0
  48. {oauth_codex-2.0.3 → oauth_codex-2.2.0}/src/oauth_codex/types/responses/input_token_count_response.py +0 -0
  49. {oauth_codex-2.0.3 → oauth_codex-2.2.0}/src/oauth_codex/types/responses/response.py +0 -0
  50. {oauth_codex-2.0.3 → oauth_codex-2.2.0}/src/oauth_codex/types/responses/response_stream_event.py +0 -0
  51. {oauth_codex-2.0.3 → oauth_codex-2.2.0}/src/oauth_codex/types/shared/__init__.py +0 -0
  52. {oauth_codex-2.0.3 → oauth_codex-2.2.0}/src/oauth_codex/types/shared/model_capabilities.py +0 -0
  53. {oauth_codex-2.0.3 → oauth_codex-2.2.0}/src/oauth_codex/types/shared/usage.py +0 -0
  54. {oauth_codex-2.0.3 → oauth_codex-2.2.0}/src/oauth_codex/types/vector_stores/__init__.py +0 -0
  55. {oauth_codex-2.0.3 → oauth_codex-2.2.0}/src/oauth_codex/types/vector_stores/vector_store.py +0 -0
  56. {oauth_codex-2.0.3 → oauth_codex-2.2.0}/src/oauth_codex/types/vector_stores/vector_store_deleted.py +0 -0
  57. {oauth_codex-2.0.3 → oauth_codex-2.2.0}/src/oauth_codex/types/vector_stores/vector_store_file.py +0 -0
  58. {oauth_codex-2.0.3 → oauth_codex-2.2.0}/src/oauth_codex/types/vector_stores/vector_store_file_batch.py +0 -0
  59. {oauth_codex-2.0.3 → oauth_codex-2.2.0}/src/oauth_codex/types/vector_stores/vector_store_search_response.py +0 -0
  60. {oauth_codex-2.0.3 → oauth_codex-2.2.0}/src/oauth_codex/version.py +0 -0
  61. {oauth_codex-2.0.3 → oauth_codex-2.2.0}/src/oauth_codex.egg-info/dependency_links.txt +0 -0
  62. {oauth_codex-2.0.3 → oauth_codex-2.2.0}/src/oauth_codex.egg-info/requires.txt +0 -0
  63. {oauth_codex-2.0.3 → oauth_codex-2.2.0}/src/oauth_codex.egg-info/top_level.txt +0 -0
  64. {oauth_codex-2.0.3 → 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.0.3
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.0.3"
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
- DEFAULT_MAX_TOOL_ROUNDS = 8
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
@@ -107,9 +114,15 @@ class OAuthCodexClient(SyncAPIClient):
107
114
  for _ in range(self.max_tool_rounds):
108
115
  result = self._engine.generate(
109
116
  model=self._resolve_model(model),
110
- messages=messages,
117
+ messages=self._messages_for_round(
118
+ messages=messages,
119
+ previous_response_id=previous_response_id,
120
+ tool_results=tool_results,
121
+ ),
111
122
  tools=normalized_tools,
112
123
  tool_results=tool_results,
124
+ response_format=response_format,
125
+ strict_output=effective_strict_output,
113
126
  reasoning={"effort": reasoning_effort},
114
127
  previous_response_id=previous_response_id,
115
128
  temperature=temperature,
@@ -124,7 +137,10 @@ class OAuthCodexClient(SyncAPIClient):
124
137
  output_parts.append(result.text)
125
138
 
126
139
  if not result.tool_calls:
127
- 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)
128
144
 
129
145
  tool_results = self._execute_tool_calls_sync(result.tool_calls, tools_by_name)
130
146
  previous_response_id = result.response_id
@@ -142,9 +158,15 @@ class OAuthCodexClient(SyncAPIClient):
142
158
  temperature: float | None = None,
143
159
  top_p: float | None = None,
144
160
  max_output_tokens: int | None = None,
145
- ) -> str:
161
+ output_schema: StructuredOutputSchema | None = None,
162
+ strict_output: bool | None = None,
163
+ ) -> str | dict[str, Any]:
146
164
  messages = self._build_messages(prompt=prompt, images=images)
147
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
+ )
148
170
 
149
171
  previous_response_id: str | None = None
150
172
  tool_results: list[ToolResult] | None = None
@@ -153,9 +175,15 @@ class OAuthCodexClient(SyncAPIClient):
153
175
  for _ in range(self.max_tool_rounds):
154
176
  result = await self._engine.agenerate(
155
177
  model=self._resolve_model(model),
156
- messages=messages,
178
+ messages=self._messages_for_round(
179
+ messages=messages,
180
+ previous_response_id=previous_response_id,
181
+ tool_results=tool_results,
182
+ ),
157
183
  tools=normalized_tools,
158
184
  tool_results=tool_results,
185
+ response_format=response_format,
186
+ strict_output=effective_strict_output,
159
187
  reasoning={"effort": reasoning_effort},
160
188
  previous_response_id=previous_response_id,
161
189
  temperature=temperature,
@@ -170,7 +198,10 @@ class OAuthCodexClient(SyncAPIClient):
170
198
  output_parts.append(result.text)
171
199
 
172
200
  if not result.tool_calls:
173
- 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)
174
205
 
175
206
  tool_results = await self._execute_tool_calls_async(result.tool_calls, tools_by_name)
176
207
  previous_response_id = result.response_id
@@ -188,9 +219,15 @@ class OAuthCodexClient(SyncAPIClient):
188
219
  temperature: float | None = None,
189
220
  top_p: float | None = None,
190
221
  max_output_tokens: int | None = None,
222
+ output_schema: StructuredOutputSchema | None = None,
223
+ strict_output: bool | None = None,
191
224
  ) -> Iterator[str]:
192
225
  messages = self._build_messages(prompt=prompt, images=images)
193
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
+ )
194
231
 
195
232
  previous_response_id: str | None = None
196
233
  tool_results: list[ToolResult] | None = None
@@ -200,9 +237,15 @@ class OAuthCodexClient(SyncAPIClient):
200
237
  round_response_id: str | None = previous_response_id
201
238
  events = self._engine.generate_stream(
202
239
  model=self._resolve_model(model),
203
- messages=messages,
240
+ messages=self._messages_for_round(
241
+ messages=messages,
242
+ previous_response_id=previous_response_id,
243
+ tool_results=tool_results,
244
+ ),
204
245
  tools=normalized_tools,
205
246
  tool_results=tool_results,
247
+ response_format=response_format,
248
+ strict_output=effective_strict_output,
206
249
  reasoning={"effort": reasoning_effort},
207
250
  previous_response_id=previous_response_id,
208
251
  temperature=temperature,
@@ -237,9 +280,15 @@ class OAuthCodexClient(SyncAPIClient):
237
280
  temperature: float | None = None,
238
281
  top_p: float | None = None,
239
282
  max_output_tokens: int | None = None,
283
+ output_schema: StructuredOutputSchema | None = None,
284
+ strict_output: bool | None = None,
240
285
  ) -> AsyncIterator[str]:
241
286
  messages = self._build_messages(prompt=prompt, images=images)
242
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
+ )
243
292
 
244
293
  previous_response_id: str | None = None
245
294
  tool_results: list[ToolResult] | None = None
@@ -249,9 +298,15 @@ class OAuthCodexClient(SyncAPIClient):
249
298
  round_response_id: str | None = previous_response_id
250
299
  events = await self._engine.agenerate_stream(
251
300
  model=self._resolve_model(model),
252
- messages=messages,
301
+ messages=self._messages_for_round(
302
+ messages=messages,
303
+ previous_response_id=previous_response_id,
304
+ tool_results=tool_results,
305
+ ),
253
306
  tools=normalized_tools,
254
307
  tool_results=tool_results,
308
+ response_format=response_format,
309
+ strict_output=effective_strict_output,
255
310
  reasoning={"effort": reasoning_effort},
256
311
  previous_response_id=previous_response_id,
257
312
  temperature=temperature,
@@ -278,6 +333,59 @@ class OAuthCodexClient(SyncAPIClient):
278
333
  def _resolve_model(self, model: str | None) -> str:
279
334
  return model or self.default_model
280
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
+
367
+ def _is_tool_continuation_round(
368
+ self,
369
+ *,
370
+ previous_response_id: str | None,
371
+ tool_results: list[ToolResult] | None,
372
+ ) -> bool:
373
+ return bool(previous_response_id) and bool(tool_results)
374
+
375
+ def _messages_for_round(
376
+ self,
377
+ *,
378
+ messages: list[Message],
379
+ previous_response_id: str | None,
380
+ tool_results: list[ToolResult] | None,
381
+ ) -> list[Message]:
382
+ if self._is_tool_continuation_round(
383
+ previous_response_id=previous_response_id,
384
+ tool_results=tool_results,
385
+ ):
386
+ return []
387
+ return messages
388
+
281
389
  def _build_messages(
282
390
  self,
283
391
  *,
@@ -199,7 +199,15 @@ class OAuthCodexClient:
199
199
  ) -> str | GenerateResult:
200
200
  self._require_responses_mode(api_mode)
201
201
 
202
- normalized_messages = self._normalize_messages(prompt=prompt, messages=messages)
202
+ allows_empty_messages = self._is_tool_continuation_request(
203
+ previous_response_id=previous_response_id,
204
+ tool_results=tool_results,
205
+ )
206
+ normalized_messages = self._normalize_messages(
207
+ prompt=prompt,
208
+ messages=messages,
209
+ allow_empty_messages=allows_empty_messages,
210
+ )
203
211
  normalized_tools = normalize_tool_inputs(tools)
204
212
  normalized_tool_results = self._normalize_tool_results(tool_results)
205
213
 
@@ -276,7 +284,15 @@ class OAuthCodexClient:
276
284
  ) -> str | GenerateResult:
277
285
  self._require_responses_mode(api_mode)
278
286
 
279
- normalized_messages = self._normalize_messages(prompt=prompt, messages=messages)
287
+ allows_empty_messages = self._is_tool_continuation_request(
288
+ previous_response_id=previous_response_id,
289
+ tool_results=tool_results,
290
+ )
291
+ normalized_messages = self._normalize_messages(
292
+ prompt=prompt,
293
+ messages=messages,
294
+ allow_empty_messages=allows_empty_messages,
295
+ )
280
296
  normalized_tools = normalize_tool_inputs(tools)
281
297
  normalized_tool_results = self._normalize_tool_results(tool_results)
282
298
 
@@ -353,7 +369,15 @@ class OAuthCodexClient:
353
369
  ) -> Iterator[str] | Iterator[StreamEvent]:
354
370
  self._require_responses_mode(api_mode)
355
371
 
356
- normalized_messages = self._normalize_messages(prompt=prompt, messages=messages)
372
+ allows_empty_messages = self._is_tool_continuation_request(
373
+ previous_response_id=previous_response_id,
374
+ tool_results=tool_results,
375
+ )
376
+ normalized_messages = self._normalize_messages(
377
+ prompt=prompt,
378
+ messages=messages,
379
+ allow_empty_messages=allows_empty_messages,
380
+ )
357
381
  normalized_tools = normalize_tool_inputs(tools)
358
382
  normalized_tool_results = self._normalize_tool_results(tool_results)
359
383
 
@@ -426,7 +450,15 @@ class OAuthCodexClient:
426
450
  ) -> AsyncIterator[str] | AsyncIterator[StreamEvent]:
427
451
  self._require_responses_mode(api_mode)
428
452
 
429
- normalized_messages = self._normalize_messages(prompt=prompt, messages=messages)
453
+ allows_empty_messages = self._is_tool_continuation_request(
454
+ previous_response_id=previous_response_id,
455
+ tool_results=tool_results,
456
+ )
457
+ normalized_messages = self._normalize_messages(
458
+ prompt=prompt,
459
+ messages=messages,
460
+ allow_empty_messages=allows_empty_messages,
461
+ )
430
462
  normalized_tools = normalize_tool_inputs(tools)
431
463
  normalized_tool_results = self._normalize_tool_results(tool_results)
432
464
 
@@ -1392,6 +1424,7 @@ class OAuthCodexClient:
1392
1424
  *,
1393
1425
  prompt: str | None,
1394
1426
  messages: list[Message] | None,
1427
+ allow_empty_messages: bool = False,
1395
1428
  ) -> list[Message]:
1396
1429
  if (prompt is None and messages is None) or (prompt is not None and messages is not None):
1397
1430
  raise ValueError("Provide exactly one of `prompt` or `messages`")
@@ -1399,10 +1432,20 @@ class OAuthCodexClient:
1399
1432
  if prompt is not None:
1400
1433
  return [{"role": "user", "content": prompt}]
1401
1434
 
1402
- if not isinstance(messages, list) or not messages:
1435
+ if not isinstance(messages, list):
1436
+ raise ValueError("`messages` must be a non-empty list")
1437
+ if not messages and not allow_empty_messages:
1403
1438
  raise ValueError("`messages` must be a non-empty list")
1404
1439
  return [dict(item) for item in messages]
1405
1440
 
1441
+ def _is_tool_continuation_request(
1442
+ self,
1443
+ *,
1444
+ previous_response_id: str | None,
1445
+ tool_results: list[ToolResult] | None,
1446
+ ) -> bool:
1447
+ return bool(previous_response_id) and bool(tool_results)
1448
+
1406
1449
  def _normalize_tool_results(
1407
1450
  self,
1408
1451
  tool_results: list[ToolResult] | None,
@@ -1,2 +1,2 @@
1
1
  __title__ = "oauth-codex"
2
- __version__ = "2.0.3"
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