codex-backend-sdk 0.3.2__tar.gz → 0.3.3__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 (37) hide show
  1. {codex_backend_sdk-0.3.2 → codex_backend_sdk-0.3.3}/PKG-INFO +25 -1
  2. {codex_backend_sdk-0.3.2 → codex_backend_sdk-0.3.3}/README.md +24 -0
  3. {codex_backend_sdk-0.3.2 → codex_backend_sdk-0.3.3}/codex_backend_sdk/__init__.py +5 -1
  4. {codex_backend_sdk-0.3.2 → codex_backend_sdk-0.3.3}/codex_backend_sdk/_client.py +10 -40
  5. {codex_backend_sdk-0.3.2 → codex_backend_sdk-0.3.3}/codex_backend_sdk/_models.py +53 -1
  6. codex_backend_sdk-0.3.3/codex_backend_sdk/_transport.py +71 -0
  7. {codex_backend_sdk-0.3.2 → codex_backend_sdk-0.3.3}/codex_backend_sdk/_utils.py +3 -0
  8. {codex_backend_sdk-0.3.2 → codex_backend_sdk-0.3.3}/codex_backend_sdk/codex_client.py +4 -0
  9. codex_backend_sdk-0.3.2/codex_backend_sdk/resources/responses.py → codex_backend_sdk-0.3.3/codex_backend_sdk/resources/_responses_payloads.py +114 -176
  10. codex_backend_sdk-0.3.3/codex_backend_sdk/resources/responses.py +224 -0
  11. {codex_backend_sdk-0.3.2 → codex_backend_sdk-0.3.3}/docs/backend-api.md +4 -0
  12. {codex_backend_sdk-0.3.2 → codex_backend_sdk-0.3.3}/pyproject.toml +1 -1
  13. {codex_backend_sdk-0.3.2 → codex_backend_sdk-0.3.3}/tests/test_client_retry.py +5 -5
  14. {codex_backend_sdk-0.3.2 → codex_backend_sdk-0.3.3}/tests/test_responses_resource.py +102 -1
  15. {codex_backend_sdk-0.3.2 → codex_backend_sdk-0.3.3}/.gitignore +0 -0
  16. {codex_backend_sdk-0.3.2 → codex_backend_sdk-0.3.3}/LICENSE +0 -0
  17. {codex_backend_sdk-0.3.2 → codex_backend_sdk-0.3.3}/codex_backend_sdk/_streaming.py +0 -0
  18. {codex_backend_sdk-0.3.2 → codex_backend_sdk-0.3.3}/codex_backend_sdk/oauth.py +0 -0
  19. {codex_backend_sdk-0.3.2 → codex_backend_sdk-0.3.3}/codex_backend_sdk/pkce.py +0 -0
  20. {codex_backend_sdk-0.3.2 → codex_backend_sdk-0.3.3}/codex_backend_sdk/resources/__init__.py +0 -0
  21. {codex_backend_sdk-0.3.2 → codex_backend_sdk-0.3.3}/codex_backend_sdk/resources/codex.py +0 -0
  22. {codex_backend_sdk-0.3.2 → codex_backend_sdk-0.3.3}/codex_backend_sdk/resources/files.py +0 -0
  23. {codex_backend_sdk-0.3.2 → codex_backend_sdk-0.3.3}/codex_backend_sdk/resources/models.py +0 -0
  24. {codex_backend_sdk-0.3.2 → codex_backend_sdk-0.3.3}/codex_backend_sdk/resources/openai_oauth.py +0 -0
  25. {codex_backend_sdk-0.3.2 → codex_backend_sdk-0.3.3}/codex_backend_sdk/resources/realtime.py +0 -0
  26. {codex_backend_sdk-0.3.2 → codex_backend_sdk-0.3.3}/codex_backend_sdk/storage.py +0 -0
  27. {codex_backend_sdk-0.3.2 → codex_backend_sdk-0.3.3}/examples/agent.py +0 -0
  28. {codex_backend_sdk-0.3.2 → codex_backend_sdk-0.3.3}/tests/conftest.py +0 -0
  29. {codex_backend_sdk-0.3.2 → codex_backend_sdk-0.3.3}/tests/test_basic.py +0 -0
  30. {codex_backend_sdk-0.3.2 → codex_backend_sdk-0.3.3}/tests/test_codex_resources.py +0 -0
  31. {codex_backend_sdk-0.3.2 → codex_backend_sdk-0.3.3}/tests/test_conversation.py +0 -0
  32. {codex_backend_sdk-0.3.2 → codex_backend_sdk-0.3.3}/tests/test_files_resource.py +0 -0
  33. {codex_backend_sdk-0.3.2 → codex_backend_sdk-0.3.3}/tests/test_openai_oauth_resources.py +0 -0
  34. {codex_backend_sdk-0.3.2 → codex_backend_sdk-0.3.3}/tests/test_realtime_resource.py +0 -0
  35. {codex_backend_sdk-0.3.2 → codex_backend_sdk-0.3.3}/tests/test_reasoning.py +0 -0
  36. {codex_backend_sdk-0.3.2 → codex_backend_sdk-0.3.3}/tests/test_structured_output.py +0 -0
  37. {codex_backend_sdk-0.3.2 → codex_backend_sdk-0.3.3}/tests/test_tools.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codex-backend-sdk
3
- Version: 0.3.2
3
+ Version: 0.3.3
4
4
  Summary: Unofficial Python SDK for the ChatGPT Codex backend API
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -240,6 +240,30 @@ response = client.responses.create(
240
240
  )
241
241
  ```
242
242
 
243
+ For structured output, `client.responses.parse(...)` accepts a Pydantic model,
244
+ sends it as a strict JSON schema, and returns `ParsedResponse`:
245
+
246
+ ```python
247
+ from pydantic import BaseModel
248
+
249
+
250
+ class Person(BaseModel):
251
+ name: str
252
+ age: int
253
+
254
+
255
+ parsed = client.responses.parse(
256
+ model="gpt-5.4",
257
+ input="Extract: Ada is 37 years old.",
258
+ text_format=Person,
259
+ )
260
+ print(parsed.output_parsed.name)
261
+ ```
262
+
263
+ Collected responses expose convenience properties for common output items:
264
+ `response.output_text`, `response.reasoning_summary`, and
265
+ `response.tool_calls`.
266
+
243
267
  Unsupported official Responses parameters are rejected explicitly with
244
268
  `CodexBackendUnsupportedParameterError`, including `temperature`, `top_p`,
245
269
  `max_output_tokens`, `metadata`, `user`, `safety_identifier`, `truncation`,
@@ -214,6 +214,30 @@ response = client.responses.create(
214
214
  )
215
215
  ```
216
216
 
217
+ For structured output, `client.responses.parse(...)` accepts a Pydantic model,
218
+ sends it as a strict JSON schema, and returns `ParsedResponse`:
219
+
220
+ ```python
221
+ from pydantic import BaseModel
222
+
223
+
224
+ class Person(BaseModel):
225
+ name: str
226
+ age: int
227
+
228
+
229
+ parsed = client.responses.parse(
230
+ model="gpt-5.4",
231
+ input="Extract: Ada is 37 years old.",
232
+ text_format=Person,
233
+ )
234
+ print(parsed.output_parsed.name)
235
+ ```
236
+
237
+ Collected responses expose convenience properties for common output items:
238
+ `response.output_text`, `response.reasoning_summary`, and
239
+ `response.tool_calls`.
240
+
217
241
  Unsupported official Responses parameters are rejected explicitly with
218
242
  `CodexBackendUnsupportedParameterError`, including `temperature`, `top_p`,
219
243
  `max_output_tokens`, `metadata`, `user`, `safety_identifier`, `truncation`,
@@ -17,7 +17,7 @@ Quickstart:
17
17
  print(response.output_text)
18
18
  """
19
19
 
20
- __version__ = "0.3.2"
20
+ __version__ = "0.3.3"
21
21
 
22
22
  from .oauth import run_oauth_flow, refresh_access_token, obtain_api_key
23
23
  from .storage import load_tokens, save_tokens, TokenStore
@@ -38,7 +38,9 @@ from .codex_client import (
38
38
  Model,
39
39
  RawMemory,
40
40
  RawMemoryMetadata,
41
+ ParsedResponse,
41
42
  Response,
43
+ ResponseFormatJsonSchema,
42
44
  ResponseStreamEvent,
43
45
  ResponseUsage,
44
46
  RealtimeCallResponse,
@@ -61,9 +63,11 @@ __all__ = [
61
63
  "MemorySummarizeOutput",
62
64
  "MemorySummarizeResponse",
63
65
  "Model",
66
+ "ParsedResponse",
64
67
  "RawMemory",
65
68
  "RawMemoryMetadata",
66
69
  "Response",
70
+ "ResponseFormatJsonSchema",
67
71
  "ResponseStreamEvent",
68
72
  "ResponseUsage",
69
73
  "RealtimeCallResponse",
@@ -3,11 +3,11 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import urllib.parse
6
- import time
7
6
  from typing import Any, Optional
8
7
 
9
8
  import requests
10
9
 
10
+ from ._transport import request_with_retries
11
11
  from ._utils import _UNSET, _is_given
12
12
  from .storage import TokenStore, load_tokens, save_tokens, token_needs_refresh
13
13
 
@@ -271,37 +271,16 @@ class CodexClient:
271
271
  return response.json()
272
272
 
273
273
  def _request_with_retries(self, method: str, url: str, **kwargs: Any) -> requests.Response:
274
- last_error: requests.RequestException | None = None
275
274
  use_session = kwargs.pop("_use_session", True)
276
- for attempt in range(self._max_retries + 1):
277
- try:
278
- request = self._session.request if use_session else requests.request
279
- response = request(method, url, **kwargs)
280
- if self._should_retry_response(response, attempt):
281
- self._sleep_before_retry(response, attempt)
282
- continue
283
- response.raise_for_status()
284
- return response
285
- except (requests.Timeout, requests.ConnectionError) as exc:
286
- last_error = exc
287
- if attempt >= self._max_retries:
288
- raise
289
- self._sleep_before_retry(None, attempt)
290
- if last_error is not None:
291
- raise last_error
292
- raise RuntimeError("Request retry loop exhausted")
293
-
294
- def _should_retry_response(self, response: requests.Response, attempt: int) -> bool:
295
- if attempt >= self._max_retries:
296
- return False
297
- return response.status_code == 429 or 500 <= response.status_code < 600
298
-
299
- def _sleep_before_retry(self, response: requests.Response | None, attempt: int) -> None:
300
- retry_after = response.headers.get("Retry-After") if response is not None else None
301
- delay = _parse_retry_after(retry_after)
302
- if delay is None:
303
- delay = self._retry_base_delay * (2 ** attempt)
304
- time.sleep(delay)
275
+ return request_with_retries(
276
+ self._session,
277
+ method,
278
+ url,
279
+ max_retries=self._max_retries,
280
+ retry_base_delay=self._retry_base_delay,
281
+ use_session=use_session,
282
+ **kwargs,
283
+ )
305
284
 
306
285
  def realtime_websocket_url(self, *, model: str) -> str:
307
286
  """Return the official OpenAI Realtime WebSocket URL for Codex plugins."""
@@ -324,12 +303,3 @@ class CodexClient:
324
303
 
325
304
 
326
305
  OpenAI = CodexClient
327
-
328
-
329
- def _parse_retry_after(value: str | None) -> float | None:
330
- if not value:
331
- return None
332
- try:
333
- return max(0.0, float(value))
334
- except ValueError:
335
- return None
@@ -4,7 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import time
6
6
  from collections.abc import Iterator
7
- from typing import Any, Literal, Optional
7
+ from typing import Any, Generic, Literal, Optional, TypeVar
8
8
 
9
9
  import requests
10
10
  from pydantic import BaseModel, ConfigDict, Field
@@ -13,6 +13,7 @@ ReasoningEffort = Literal["minimal", "low", "medium", "high", "xhigh"]
13
13
  ReasoningSummary = Literal["concise", "detailed", "auto"]
14
14
  Verbosity = Literal["low", "medium", "high"]
15
15
  ServiceTier = Literal["flex", "priority"]
16
+ ParsedT = TypeVar("ParsedT")
16
17
 
17
18
 
18
19
  class CodexBaseModel(BaseModel):
@@ -73,6 +74,14 @@ class ResponseUsage(CodexBaseModel):
73
74
  output_tokens_details: TokenDetails = Field(default_factory=TokenDetails)
74
75
 
75
76
 
77
+ class ResponseFormatJsonSchema(CodexBaseModel):
78
+ type: Literal["json_schema"] = "json_schema"
79
+ name: str
80
+ schema_: dict[str, Any] = Field(alias="schema")
81
+ strict: Optional[bool] = None
82
+ description: Optional[str] = None
83
+
84
+
76
85
  class Response(CodexBaseModel):
77
86
  id: str
78
87
  created_at: float = Field(default_factory=time.time)
@@ -117,6 +126,49 @@ class Response(CodexBaseModel):
117
126
  texts.append(content.get("text", ""))
118
127
  return "".join(texts)
119
128
 
129
+ @property
130
+ def reasoning_summary(self) -> str | None:
131
+ texts: list[str] = []
132
+ for output in self.output:
133
+ if output.get("type") == "reasoning":
134
+ for summary in output.get("summary", []):
135
+ if isinstance(summary, dict):
136
+ texts.append(summary.get("text", ""))
137
+ return "\n".join(text for text in texts if text) or None
138
+
139
+ @property
140
+ def tool_calls(self) -> list[dict[str, Any]]:
141
+ return [output for output in self.output if output.get("type") == "function_call"]
142
+
143
+
144
+ class ParsedResponse(CodexBaseModel, Generic[ParsedT]):
145
+ response: Response
146
+ output_parsed: ParsedT
147
+
148
+ @property
149
+ def id(self) -> str:
150
+ return self.response.id
151
+
152
+ @property
153
+ def model(self) -> Optional[str]:
154
+ return self.response.model
155
+
156
+ @property
157
+ def output(self) -> list[dict[str, Any]]:
158
+ return self.response.output
159
+
160
+ @property
161
+ def output_text(self) -> str:
162
+ return self.response.output_text
163
+
164
+ @property
165
+ def status(self) -> Optional[str]:
166
+ return self.response.status
167
+
168
+ @property
169
+ def usage(self) -> Optional[ResponseUsage]:
170
+ return self.response.usage
171
+
120
172
 
121
173
  class ResponseStreamEvent(CodexBaseModel):
122
174
  type: str
@@ -0,0 +1,71 @@
1
+ """HTTP transport helpers shared by client resources."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from typing import Any
7
+
8
+ import requests
9
+
10
+
11
+ def request_with_retries(
12
+ session: requests.Session,
13
+ method: str,
14
+ url: str,
15
+ *,
16
+ max_retries: int,
17
+ retry_base_delay: float,
18
+ use_session: bool = True,
19
+ **kwargs: Any,
20
+ ) -> requests.Response:
21
+ last_error: requests.RequestException | None = None
22
+ for attempt in range(max_retries + 1):
23
+ try:
24
+ request = session.request if use_session else requests.request
25
+ response = request(method, url, **kwargs)
26
+ if should_retry_response(response, attempt, max_retries=max_retries):
27
+ sleep_before_retry(response, attempt, retry_base_delay=retry_base_delay)
28
+ continue
29
+ response.raise_for_status()
30
+ return response
31
+ except (requests.Timeout, requests.ConnectionError) as exc:
32
+ last_error = exc
33
+ if attempt >= max_retries:
34
+ raise
35
+ sleep_before_retry(None, attempt, retry_base_delay=retry_base_delay)
36
+ if last_error is not None:
37
+ raise last_error
38
+ raise RuntimeError("Request retry loop exhausted")
39
+
40
+
41
+ def should_retry_response(
42
+ response: requests.Response,
43
+ attempt: int,
44
+ *,
45
+ max_retries: int,
46
+ ) -> bool:
47
+ if attempt >= max_retries:
48
+ return False
49
+ return response.status_code == 429 or 500 <= response.status_code < 600
50
+
51
+
52
+ def sleep_before_retry(
53
+ response: requests.Response | None,
54
+ attempt: int,
55
+ *,
56
+ retry_base_delay: float,
57
+ ) -> None:
58
+ retry_after = response.headers.get("Retry-After") if response is not None else None
59
+ delay = parse_retry_after(retry_after)
60
+ if delay is None:
61
+ delay = retry_base_delay * (2 ** attempt)
62
+ time.sleep(delay)
63
+
64
+
65
+ def parse_retry_after(value: str | None) -> float | None:
66
+ if not value:
67
+ return None
68
+ try:
69
+ return max(0.0, float(value))
70
+ except ValueError:
71
+ return None
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import base64
6
6
  import json
7
+ from dataclasses import asdict, is_dataclass
7
8
  from typing import Any
8
9
 
9
10
  from pydantic import BaseModel
@@ -26,6 +27,8 @@ def image_b64(data: str, media_type: str = "image/jpeg") -> dict[str, str]:
26
27
  def _jsonable(value: Any) -> Any:
27
28
  if isinstance(value, BaseModel):
28
29
  return value.model_dump(mode="json", by_alias=True, exclude_unset=True)
30
+ if is_dataclass(value) and not isinstance(value, type):
31
+ return _jsonable(asdict(value))
29
32
  if isinstance(value, dict):
30
33
  return {key: _jsonable(item) for key, item in value.items()}
31
34
  if isinstance(value, list):
@@ -16,12 +16,14 @@ from ._models import (
16
16
  Model,
17
17
  MemorySummarizeOutput,
18
18
  MemorySummarizeResponse,
19
+ ParsedResponse,
19
20
  RawMemory,
20
21
  RawMemoryMetadata,
21
22
  ReasoningEffort,
22
23
  ReasoningSummary,
23
24
  RealtimeCallResponse,
24
25
  Response,
26
+ ResponseFormatJsonSchema,
25
27
  ResponseStreamEvent,
26
28
  ResponseUsage,
27
29
  ServiceTier,
@@ -45,11 +47,13 @@ __all__ = [
45
47
  "MemorySummarizeResponse",
46
48
  "Model",
47
49
  "OpenAI",
50
+ "ParsedResponse",
48
51
  "RawMemory",
49
52
  "RawMemoryMetadata",
50
53
  "ReasoningEffort",
51
54
  "ReasoningSummary",
52
55
  "Response",
56
+ "ResponseFormatJsonSchema",
53
57
  "ResponseStreamEvent",
54
58
  "ResponseUsage",
55
59
  "RealtimeCallResponse",
@@ -1,150 +1,25 @@
1
- """Responses resource."""
1
+ """Internal builders and collectors for the Codex Responses resource."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
5
  import time
6
- from collections.abc import Iterable, Iterator
7
- from typing import Any, Optional, TYPE_CHECKING
6
+ from collections.abc import Iterable
7
+ from typing import Any, Optional
8
8
 
9
9
  from pydantic import BaseModel
10
10
 
11
11
  from .._models import (
12
12
  CodexBaseModel,
13
- CompactedResponse,
14
13
  Response,
14
+ ResponseFormatJsonSchema,
15
15
  ResponseStreamEvent,
16
16
  ResponseUsage,
17
17
  TokenDetails,
18
18
  )
19
- from .._streaming import stream_response_events
20
- from .._utils import _UNSET, _default, _is_given, _reject_backend_unsupported
19
+ from .._utils import _UNSET, _default, _is_given, _jsonable
21
20
 
22
- if TYPE_CHECKING:
23
- from .._client import CodexClient
24
21
 
25
-
26
- class Responses:
27
- def __init__(self, client: CodexClient) -> None:
28
- self._client = client
29
-
30
- def create(
31
- self,
32
- *,
33
- background: Any = _UNSET,
34
- context_management: Any = _UNSET,
35
- conversation: Any = _UNSET,
36
- include: Any = _UNSET,
37
- input: Any = _UNSET,
38
- instructions: Any = _UNSET,
39
- max_output_tokens: Any = _UNSET,
40
- max_tool_calls: Any = _UNSET,
41
- metadata: Any = _UNSET,
42
- model: Any = _UNSET,
43
- parallel_tool_calls: Any = _UNSET,
44
- previous_response_id: Any = _UNSET,
45
- prompt: Any = _UNSET,
46
- prompt_cache_key: Any = _UNSET,
47
- prompt_cache_retention: Any = _UNSET,
48
- reasoning: Any = _UNSET,
49
- safety_identifier: Any = _UNSET,
50
- service_tier: Any = _UNSET,
51
- store: Any = _UNSET,
52
- stream: Any = _UNSET,
53
- stream_options: Any = _UNSET,
54
- temperature: Any = _UNSET,
55
- text: Any = _UNSET,
56
- tool_choice: Any = _UNSET,
57
- tools: Any = _UNSET,
58
- top_logprobs: Any = _UNSET,
59
- top_p: Any = _UNSET,
60
- truncation: Any = _UNSET,
61
- user: Any = _UNSET,
62
- extra_headers: Any = None,
63
- extra_query: Any = None,
64
- extra_body: Any = None,
65
- timeout: Any = _UNSET,
66
- ) -> Response | Iterator[ResponseStreamEvent]:
67
- _reject_backend_unsupported(
68
- background=background,
69
- context_management=context_management,
70
- conversation=conversation,
71
- max_output_tokens=max_output_tokens,
72
- max_tool_calls=max_tool_calls,
73
- metadata=metadata,
74
- previous_response_id=previous_response_id,
75
- prompt=prompt,
76
- prompt_cache_retention=prompt_cache_retention,
77
- safety_identifier=safety_identifier,
78
- stream_options=stream_options,
79
- temperature=temperature,
80
- top_logprobs=top_logprobs,
81
- top_p=top_p,
82
- truncation=truncation,
83
- user=user,
84
- extra_body=extra_body,
85
- )
86
-
87
- if _is_given(store) and store is not False:
88
- raise NotImplementedError("The Codex backend only accepts store=False.")
89
-
90
- request = _ResponsesCreateRequest.from_openai_params(
91
- client_defaults=self._client._defaults,
92
- input=input,
93
- include=include,
94
- instructions=instructions,
95
- model=model,
96
- parallel_tool_calls=parallel_tool_calls,
97
- prompt_cache_key=prompt_cache_key,
98
- reasoning=reasoning,
99
- service_tier=service_tier,
100
- text=text,
101
- tool_choice=tool_choice,
102
- tools=tools,
103
- )
104
- response = self._client._post("/responses", body=request.payload, stream=True)
105
- events = stream_response_events(response)
106
- stream_enabled = bool(stream) if _is_given(stream) else False
107
-
108
- if stream_enabled:
109
- return events
110
- return _collect_response(events, request=request)
111
-
112
- def compact(
113
- self,
114
- *,
115
- input: list[dict[str, Any]],
116
- model: Any = _UNSET,
117
- instructions: Any = _UNSET,
118
- tools: Any = _UNSET,
119
- parallel_tool_calls: Any = _UNSET,
120
- reasoning: Any = _UNSET,
121
- service_tier: Any = _UNSET,
122
- prompt_cache_key: Any = _UNSET,
123
- text: Any = _UNSET,
124
- ) -> CompactedResponse:
125
- normalized_tools = _normalize_tools(tools)
126
- payload = {
127
- "model": _default(model, self._client._defaults["model"]),
128
- "instructions": _default(instructions, self._client._defaults["instructions"]) or "",
129
- "input": [_normalize_input_item(item) for item in input],
130
- "tools": normalized_tools,
131
- "parallel_tool_calls": (
132
- bool(_default(parallel_tool_calls, False)) if normalized_tools else False
133
- ),
134
- }
135
- if _is_given(reasoning) and reasoning is not None:
136
- payload["reasoning"] = _normalize_reasoning(reasoning)
137
- if _is_given(service_tier) and service_tier is not None:
138
- payload["service_tier"] = service_tier
139
- if _is_given(prompt_cache_key) and prompt_cache_key is not None:
140
- payload["prompt_cache_key"] = prompt_cache_key
141
- if _is_given(text) and text is not None:
142
- payload["text"] = _normalize_text(text)
143
- data = self._client._post("/responses/compact", body=payload).json()
144
- return CompactedResponse(id=data.get("id", ""), output=data.get("output", []))
145
-
146
-
147
- class _ResponsesCreateRequest(CodexBaseModel):
22
+ class ResponsesCreateRequest(CodexBaseModel):
148
23
  model: str
149
24
  instructions: Optional[str]
150
25
  input: list[dict[str, Any]]
@@ -164,9 +39,9 @@ class _ResponsesCreateRequest(CodexBaseModel):
164
39
  *,
165
40
  client_defaults: dict[str, Any],
166
41
  **params: Any,
167
- ) -> "_ResponsesCreateRequest":
168
- input_items = _normalize_input(params["input"])
169
- tools = _normalize_tools(params["tools"])
42
+ ) -> "ResponsesCreateRequest":
43
+ input_items = normalize_input(params["input"])
44
+ tools = normalize_tools(params["tools"])
170
45
  include = (
171
46
  []
172
47
  if not _is_given(params["include"]) or params["include"] is None
@@ -198,9 +73,9 @@ class _ResponsesCreateRequest(CodexBaseModel):
198
73
  if service_tier is not None:
199
74
  payload["service_tier"] = service_tier
200
75
  if reasoning is not None:
201
- payload["reasoning"] = _normalize_reasoning(reasoning)
76
+ payload["reasoning"] = normalize_reasoning(reasoning)
202
77
  if text is not None:
203
- payload["text"] = _normalize_text(text)
78
+ payload["text"] = normalize_text(text)
204
79
 
205
80
  return cls(
206
81
  model=payload["model"],
@@ -218,10 +93,10 @@ class _ResponsesCreateRequest(CodexBaseModel):
218
93
  )
219
94
 
220
95
 
221
- def _collect_response(
96
+ def collect_response(
222
97
  events: Iterable[ResponseStreamEvent],
223
98
  *,
224
- request: _ResponsesCreateRequest,
99
+ request: ResponsesCreateRequest,
225
100
  ) -> Response:
226
101
  output: list[dict[str, Any]] = []
227
102
  text_parts: list[str] = []
@@ -267,42 +142,18 @@ def _collect_response(
267
142
  )
268
143
 
269
144
 
270
- def _event_delta_text(event: ResponseStreamEvent) -> str:
271
- delta = getattr(event, "delta", None)
272
- if isinstance(delta, str):
273
- return delta
274
- if isinstance(delta, dict):
275
- return delta.get("text", "")
276
- return ""
277
-
278
-
279
- def _event_response_dict(event: ResponseStreamEvent) -> dict[str, Any]:
280
- response = getattr(event, "response", None)
281
- if isinstance(response, BaseModel):
282
- return response.model_dump()
283
- return response if isinstance(response, dict) else {}
284
-
285
-
286
- def _event_error_message(event: ResponseStreamEvent) -> str:
287
- response = _event_response_dict(event)
288
- error = response.get("error") or getattr(event, "error", None) or {}
289
- if isinstance(error, dict):
290
- return error.get("message", "Response failed")
291
- return str(error)
292
-
293
-
294
- def _normalize_input(input_value: Any) -> list[dict[str, Any]]:
145
+ def normalize_input(input_value: Any) -> list[dict[str, Any]]:
295
146
  if not _is_given(input_value) or input_value is None:
296
147
  return []
297
148
  if isinstance(input_value, str):
298
149
  return [_message("user", [{"type": "input_text", "text": input_value}])]
299
150
  if isinstance(input_value, list):
300
- return [_normalize_input_item(item) for item in input_value]
301
- return [_normalize_input_item(input_value)]
151
+ return [normalize_input_item(item) for item in input_value]
152
+ return [normalize_input_item(input_value)]
302
153
 
303
154
 
304
- def _normalize_input_item(item: Any) -> dict[str, Any]:
305
- raw = dict(item)
155
+ def normalize_input_item(item: Any) -> dict[str, Any]:
156
+ raw = _as_dict(item)
306
157
  if raw.get("type") and raw.get("type") != "message":
307
158
  return raw
308
159
  if "role" not in raw:
@@ -321,23 +172,20 @@ def _normalize_input_item(item: Any) -> dict[str, Any]:
321
172
  return _message(role, content)
322
173
 
323
174
 
324
- def _message(role: str, content: list[dict[str, Any]]) -> dict[str, Any]:
325
- return {"type": "message", "role": role, "content": content}
326
-
327
-
328
- def _normalize_tools(tools: Any) -> list[dict[str, Any]]:
175
+ def normalize_tools(tools: Any) -> list[dict[str, Any]]:
329
176
  if not _is_given(tools) or tools is None:
330
177
  return []
331
178
  normalized = []
332
179
  for tool in tools:
333
- item = dict(tool)
180
+ item = _as_dict(tool)
334
181
  if item.get("type") == "web_search":
335
182
  item["type"] = "web_search_preview"
336
183
  normalized.append(item)
337
184
  return normalized
338
185
 
339
186
 
340
- def _normalize_reasoning(reasoning: Any) -> dict[str, Any]:
187
+ def normalize_reasoning(reasoning: Any) -> dict[str, Any]:
188
+ reasoning = _jsonable(reasoning)
341
189
  if isinstance(reasoning, dict):
342
190
  return {key: value for key, value in reasoning.items() if value is not None}
343
191
  return {
@@ -347,8 +195,8 @@ def _normalize_reasoning(reasoning: Any) -> dict[str, Any]:
347
195
  }
348
196
 
349
197
 
350
- def _normalize_text(text: Any) -> dict[str, Any]:
351
- text_dict = dict(text)
198
+ def normalize_text(text: Any) -> dict[str, Any]:
199
+ text_dict = _as_dict(text)
352
200
  fmt = text_dict.get("format")
353
201
  if isinstance(fmt, dict) and fmt.get("type") == "json_schema":
354
202
  schema = fmt.get("schema")
@@ -363,6 +211,96 @@ def _normalize_text(text: Any) -> dict[str, Any]:
363
211
  return text_dict
364
212
 
365
213
 
214
+ def merge_text_format(text: Any, fmt: ResponseFormatJsonSchema) -> dict[str, Any]:
215
+ if not _is_given(text) or text is None:
216
+ text_dict: dict[str, Any] = {}
217
+ else:
218
+ text_dict = normalize_text(text)
219
+ if text_dict.get("format") is not None:
220
+ raise TypeError("Cannot pass both text_format and text.format.")
221
+ text_dict["format"] = fmt.model_dump(mode="json", by_alias=True, exclude_none=True)
222
+ return text_dict
223
+
224
+
225
+ def pydantic_to_format(model_class: type[Any]) -> ResponseFormatJsonSchema:
226
+ try:
227
+ schema = model_class.model_json_schema()
228
+ model_class.model_validate_json
229
+ except AttributeError:
230
+ raise TypeError("responses.parse() requires a Pydantic BaseModel class.") from None
231
+
232
+ schema = _ensure_strict_schema(schema)
233
+ return ResponseFormatJsonSchema(
234
+ name=getattr(model_class, "__name__", "output"),
235
+ schema=schema,
236
+ strict=True,
237
+ )
238
+
239
+
240
+ def _event_delta_text(event: ResponseStreamEvent) -> str:
241
+ delta = getattr(event, "delta", None)
242
+ if isinstance(delta, str):
243
+ return delta
244
+ if isinstance(delta, dict):
245
+ return delta.get("text", "")
246
+ return ""
247
+
248
+
249
+ def _event_response_dict(event: ResponseStreamEvent) -> dict[str, Any]:
250
+ response = getattr(event, "response", None)
251
+ if isinstance(response, BaseModel):
252
+ return response.model_dump()
253
+ return response if isinstance(response, dict) else {}
254
+
255
+
256
+ def _event_error_message(event: ResponseStreamEvent) -> str:
257
+ response = _event_response_dict(event)
258
+ error = response.get("error") or getattr(event, "error", None) or {}
259
+ if isinstance(error, dict):
260
+ return error.get("message", "Response failed")
261
+ return str(error)
262
+
263
+
264
+ def _message(role: str, content: list[dict[str, Any]]) -> dict[str, Any]:
265
+ return {"type": "message", "role": role, "content": content}
266
+
267
+
268
+ def _as_dict(value: Any) -> dict[str, Any]:
269
+ value = _jsonable(value)
270
+ if isinstance(value, dict):
271
+ return value
272
+ return dict(value)
273
+
274
+
275
+ def _ensure_strict_schema(schema: dict[str, Any]) -> dict[str, Any]:
276
+ schema = dict(schema)
277
+ if schema.get("type") == "object":
278
+ schema.setdefault("additionalProperties", False)
279
+ props = schema.get("properties")
280
+ if isinstance(props, dict):
281
+ schema["properties"] = {
282
+ key: _ensure_strict_schema(value) if isinstance(value, dict) else value
283
+ for key, value in props.items()
284
+ }
285
+ items = schema.get("items")
286
+ if isinstance(items, dict):
287
+ schema["items"] = _ensure_strict_schema(items)
288
+ for key in ("anyOf", "oneOf", "allOf"):
289
+ variants = schema.get(key)
290
+ if isinstance(variants, list):
291
+ schema[key] = [
292
+ _ensure_strict_schema(value) if isinstance(value, dict) else value
293
+ for value in variants
294
+ ]
295
+ defs = schema.get("$defs")
296
+ if isinstance(defs, dict):
297
+ schema["$defs"] = {
298
+ key: _ensure_strict_schema(value) if isinstance(value, dict) else value
299
+ for key, value in defs.items()
300
+ }
301
+ return schema
302
+
303
+
366
304
  def _usage_from_backend(raw: Any) -> ResponseUsage:
367
305
  raw = raw or {}
368
306
  return ResponseUsage(
@@ -0,0 +1,224 @@
1
+ """Responses resource."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterator
6
+ from typing import Any, TYPE_CHECKING
7
+
8
+ from .._models import CompactedResponse, ParsedResponse, Response, ResponseStreamEvent
9
+ from .._streaming import stream_response_events
10
+ from .._utils import _UNSET, _default, _is_given, _reject_backend_unsupported
11
+ from ._responses_payloads import (
12
+ ResponsesCreateRequest,
13
+ collect_response,
14
+ merge_text_format,
15
+ normalize_input_item,
16
+ normalize_reasoning,
17
+ normalize_text,
18
+ normalize_tools,
19
+ pydantic_to_format,
20
+ )
21
+
22
+ if TYPE_CHECKING:
23
+ from .._client import CodexClient
24
+
25
+
26
+ class Responses:
27
+ def __init__(self, client: CodexClient) -> None:
28
+ self._client = client
29
+
30
+ def create(
31
+ self,
32
+ *,
33
+ background: Any = _UNSET,
34
+ context_management: Any = _UNSET,
35
+ conversation: Any = _UNSET,
36
+ include: Any = _UNSET,
37
+ input: Any = _UNSET,
38
+ instructions: Any = _UNSET,
39
+ max_output_tokens: Any = _UNSET,
40
+ max_tool_calls: Any = _UNSET,
41
+ metadata: Any = _UNSET,
42
+ model: Any = _UNSET,
43
+ parallel_tool_calls: Any = _UNSET,
44
+ previous_response_id: Any = _UNSET,
45
+ prompt: Any = _UNSET,
46
+ prompt_cache_key: Any = _UNSET,
47
+ prompt_cache_retention: Any = _UNSET,
48
+ reasoning: Any = _UNSET,
49
+ safety_identifier: Any = _UNSET,
50
+ service_tier: Any = _UNSET,
51
+ store: Any = _UNSET,
52
+ stream: Any = _UNSET,
53
+ stream_options: Any = _UNSET,
54
+ temperature: Any = _UNSET,
55
+ text: Any = _UNSET,
56
+ tool_choice: Any = _UNSET,
57
+ tools: Any = _UNSET,
58
+ top_logprobs: Any = _UNSET,
59
+ top_p: Any = _UNSET,
60
+ truncation: Any = _UNSET,
61
+ user: Any = _UNSET,
62
+ extra_headers: Any = None,
63
+ extra_query: Any = None,
64
+ extra_body: Any = None,
65
+ timeout: Any = _UNSET,
66
+ ) -> Response | Iterator[ResponseStreamEvent]:
67
+ _reject_backend_unsupported(
68
+ background=background,
69
+ context_management=context_management,
70
+ conversation=conversation,
71
+ max_output_tokens=max_output_tokens,
72
+ max_tool_calls=max_tool_calls,
73
+ metadata=metadata,
74
+ previous_response_id=previous_response_id,
75
+ prompt=prompt,
76
+ prompt_cache_retention=prompt_cache_retention,
77
+ safety_identifier=safety_identifier,
78
+ stream_options=stream_options,
79
+ temperature=temperature,
80
+ top_logprobs=top_logprobs,
81
+ top_p=top_p,
82
+ truncation=truncation,
83
+ user=user,
84
+ extra_body=extra_body,
85
+ )
86
+
87
+ if _is_given(store) and store is not False:
88
+ raise NotImplementedError("The Codex backend only accepts store=False.")
89
+
90
+ request = ResponsesCreateRequest.from_openai_params(
91
+ client_defaults=self._client._defaults,
92
+ input=input,
93
+ include=include,
94
+ instructions=instructions,
95
+ model=model,
96
+ parallel_tool_calls=parallel_tool_calls,
97
+ prompt_cache_key=prompt_cache_key,
98
+ reasoning=reasoning,
99
+ service_tier=service_tier,
100
+ text=text,
101
+ tool_choice=tool_choice,
102
+ tools=tools,
103
+ )
104
+ response = self._client._post("/responses", body=request.payload, stream=True)
105
+ events = stream_response_events(response)
106
+ stream_enabled = bool(stream) if _is_given(stream) else False
107
+
108
+ if stream_enabled:
109
+ return events
110
+ return collect_response(events, request=request)
111
+
112
+ def parse(
113
+ self,
114
+ *,
115
+ text_format: type[Any],
116
+ background: Any = _UNSET,
117
+ context_management: Any = _UNSET,
118
+ conversation: Any = _UNSET,
119
+ include: Any = _UNSET,
120
+ input: Any = _UNSET,
121
+ instructions: Any = _UNSET,
122
+ max_output_tokens: Any = _UNSET,
123
+ max_tool_calls: Any = _UNSET,
124
+ metadata: Any = _UNSET,
125
+ model: Any = _UNSET,
126
+ parallel_tool_calls: Any = _UNSET,
127
+ previous_response_id: Any = _UNSET,
128
+ prompt: Any = _UNSET,
129
+ prompt_cache_key: Any = _UNSET,
130
+ prompt_cache_retention: Any = _UNSET,
131
+ reasoning: Any = _UNSET,
132
+ safety_identifier: Any = _UNSET,
133
+ service_tier: Any = _UNSET,
134
+ store: Any = _UNSET,
135
+ stream_options: Any = _UNSET,
136
+ temperature: Any = _UNSET,
137
+ text: Any = _UNSET,
138
+ tool_choice: Any = _UNSET,
139
+ tools: Any = _UNSET,
140
+ top_logprobs: Any = _UNSET,
141
+ top_p: Any = _UNSET,
142
+ truncation: Any = _UNSET,
143
+ user: Any = _UNSET,
144
+ extra_headers: Any = None,
145
+ extra_query: Any = None,
146
+ extra_body: Any = None,
147
+ timeout: Any = _UNSET,
148
+ ) -> ParsedResponse[Any]:
149
+ fmt = pydantic_to_format(text_format)
150
+ response = self.create(
151
+ background=background,
152
+ context_management=context_management,
153
+ conversation=conversation,
154
+ include=include,
155
+ input=input,
156
+ instructions=instructions,
157
+ max_output_tokens=max_output_tokens,
158
+ max_tool_calls=max_tool_calls,
159
+ metadata=metadata,
160
+ model=model,
161
+ parallel_tool_calls=parallel_tool_calls,
162
+ previous_response_id=previous_response_id,
163
+ prompt=prompt,
164
+ prompt_cache_key=prompt_cache_key,
165
+ prompt_cache_retention=prompt_cache_retention,
166
+ reasoning=reasoning,
167
+ safety_identifier=safety_identifier,
168
+ service_tier=service_tier,
169
+ store=store,
170
+ stream=False,
171
+ stream_options=stream_options,
172
+ temperature=temperature,
173
+ text=merge_text_format(text, fmt),
174
+ tool_choice=tool_choice,
175
+ tools=tools,
176
+ top_logprobs=top_logprobs,
177
+ top_p=top_p,
178
+ truncation=truncation,
179
+ user=user,
180
+ extra_headers=extra_headers,
181
+ extra_query=extra_query,
182
+ extra_body=extra_body,
183
+ timeout=timeout,
184
+ )
185
+ if not isinstance(response, Response):
186
+ raise TypeError("responses.parse() expected a non-streaming Response")
187
+ return ParsedResponse(
188
+ response=response,
189
+ output_parsed=text_format.model_validate_json(response.output_text),
190
+ )
191
+
192
+ def compact(
193
+ self,
194
+ *,
195
+ input: list[dict[str, Any]],
196
+ model: Any = _UNSET,
197
+ instructions: Any = _UNSET,
198
+ tools: Any = _UNSET,
199
+ parallel_tool_calls: Any = _UNSET,
200
+ reasoning: Any = _UNSET,
201
+ service_tier: Any = _UNSET,
202
+ prompt_cache_key: Any = _UNSET,
203
+ text: Any = _UNSET,
204
+ ) -> CompactedResponse:
205
+ normalized_tools = normalize_tools(tools)
206
+ payload = {
207
+ "model": _default(model, self._client._defaults["model"]),
208
+ "instructions": _default(instructions, self._client._defaults["instructions"]) or "",
209
+ "input": [normalize_input_item(item) for item in input],
210
+ "tools": normalized_tools,
211
+ "parallel_tool_calls": (
212
+ bool(_default(parallel_tool_calls, False)) if normalized_tools else False
213
+ ),
214
+ }
215
+ if _is_given(reasoning) and reasoning is not None:
216
+ payload["reasoning"] = normalize_reasoning(reasoning)
217
+ if _is_given(service_tier) and service_tier is not None:
218
+ payload["service_tier"] = service_tier
219
+ if _is_given(prompt_cache_key) and prompt_cache_key is not None:
220
+ payload["prompt_cache_key"] = prompt_cache_key
221
+ if _is_given(text) and text is not None:
222
+ payload["text"] = normalize_text(text)
223
+ data = self._client._post("/responses/compact", body=payload).json()
224
+ return CompactedResponse(id=data.get("id", ""), output=data.get("output", []))
@@ -84,6 +84,10 @@ backend never returns a non-streaming HTTP response. In SDK calls,
84
84
 
85
85
  **SDK method**: `client.responses.create(...)`
86
86
 
87
+ `client.responses.parse(..., text_format=MyPydanticModel)` is a convenience
88
+ wrapper over the same endpoint. It populates `text.format` with a strict JSON
89
+ schema and returns `ParsedResponse`.
90
+
87
91
  **Request body** (JSON):
88
92
 
89
93
  ```json
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "codex-backend-sdk"
7
- version = "0.3.2"
7
+ version = "0.3.3"
8
8
  description = "Unofficial Python SDK for the ChatGPT Codex backend API"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -1,6 +1,6 @@
1
1
  import requests
2
2
 
3
- import codex_backend_sdk._client as client_module
3
+ import codex_backend_sdk._transport as transport_module
4
4
  from codex_backend_sdk import OpenAI
5
5
 
6
6
 
@@ -37,7 +37,7 @@ def _response(status_code, *, body=b"{}", headers=None):
37
37
 
38
38
  def test_retry_retries_5xx_then_returns_success(monkeypatch):
39
39
  sleeps = []
40
- monkeypatch.setattr(client_module.time, "sleep", sleeps.append)
40
+ monkeypatch.setattr(transport_module.time, "sleep", sleeps.append)
41
41
  client = RetryClient([
42
42
  _response(503),
43
43
  _response(200, body=b'{"ok": true}'),
@@ -52,7 +52,7 @@ def test_retry_retries_5xx_then_returns_success(monkeypatch):
52
52
 
53
53
  def test_retry_honors_retry_after_header(monkeypatch):
54
54
  sleeps = []
55
- monkeypatch.setattr(client_module.time, "sleep", sleeps.append)
55
+ monkeypatch.setattr(transport_module.time, "sleep", sleeps.append)
56
56
  client = RetryClient([
57
57
  _response(429, headers={"Retry-After": "1.5"}),
58
58
  _response(200),
@@ -66,7 +66,7 @@ def test_retry_honors_retry_after_header(monkeypatch):
66
66
 
67
67
  def test_retry_does_not_retry_client_errors(monkeypatch):
68
68
  sleeps = []
69
- monkeypatch.setattr(client_module.time, "sleep", sleeps.append)
69
+ monkeypatch.setattr(transport_module.time, "sleep", sleeps.append)
70
70
  client = RetryClient([
71
71
  _response(400, body=b'{"error": "bad request"}'),
72
72
  ])
@@ -84,7 +84,7 @@ def test_retry_does_not_retry_client_errors(monkeypatch):
84
84
 
85
85
  def test_retry_retries_transport_timeout(monkeypatch):
86
86
  sleeps = []
87
- monkeypatch.setattr(client_module.time, "sleep", sleeps.append)
87
+ monkeypatch.setattr(transport_module.time, "sleep", sleeps.append)
88
88
  client = RetryClient([
89
89
  requests.Timeout("temporary timeout"),
90
90
  _response(200, body=b'{"ok": true}'),
@@ -1,4 +1,8 @@
1
- from codex_backend_sdk import OpenAI, Response
1
+ from dataclasses import dataclass
2
+
3
+ from pydantic import BaseModel
4
+
5
+ from codex_backend_sdk import OpenAI, ParsedResponse, Response
2
6
 
3
7
 
4
8
  class FakeSSE:
@@ -75,6 +79,34 @@ class FakeClient(OpenAI):
75
79
  )
76
80
 
77
81
 
82
+ class ParsedPerson(BaseModel):
83
+ name: str
84
+ age: int
85
+
86
+
87
+ @dataclass
88
+ class TextOptions:
89
+ verbosity: str
90
+
91
+
92
+ class ParseFakeClient(FakeClient):
93
+ def _post(self, path, *, body, stream=False):
94
+ self.posts.append((path, body, stream))
95
+ return FakeSSE([
96
+ (
97
+ 'data: {"type":"response.output_text.delta","delta":'
98
+ '"{\\"name\\":\\"Ada\\",\\"age\\":37}"}'
99
+ ),
100
+ "",
101
+ (
102
+ 'data: {"type":"response.completed","response":'
103
+ '{"id":"resp_parse","model":"gpt-test","output":[{"type":"message",'
104
+ '"role":"assistant","content":[{"type":"output_text",'
105
+ '"text":"{\\"name\\":\\"Ada\\",\\"age\\":37}"}]}]}}'
106
+ ),
107
+ ])
108
+
109
+
78
110
  def test_responses_create_collects_to_pydantic_response():
79
111
  client = FakeClient()
80
112
 
@@ -107,6 +139,75 @@ def test_responses_create_collects_to_pydantic_response():
107
139
  assert payload["text"] == {"verbosity": "low"}
108
140
 
109
141
 
142
+ def test_response_exposes_tool_calls_and_reasoning_summary():
143
+ response = Response(
144
+ id="resp_helpers",
145
+ output=[
146
+ {
147
+ "type": "reasoning",
148
+ "summary": [
149
+ {"type": "summary_text", "text": "First"},
150
+ {"type": "summary_text", "text": "Second"},
151
+ ],
152
+ },
153
+ {
154
+ "type": "function_call",
155
+ "call_id": "call_123",
156
+ "name": "lookup",
157
+ "arguments": "{}",
158
+ },
159
+ ],
160
+ )
161
+
162
+ assert response.reasoning_summary == "First\nSecond"
163
+ assert response.tool_calls == [
164
+ {
165
+ "type": "function_call",
166
+ "call_id": "call_123",
167
+ "name": "lookup",
168
+ "arguments": "{}",
169
+ }
170
+ ]
171
+
172
+
173
+ def test_responses_parse_sends_strict_schema_and_returns_parsed_response():
174
+ client = ParseFakeClient()
175
+
176
+ parsed = client.responses.parse(
177
+ model="gpt-test",
178
+ input="Extract the person",
179
+ text_format=ParsedPerson,
180
+ text=TextOptions(verbosity="low"),
181
+ )
182
+
183
+ assert isinstance(parsed, ParsedResponse)
184
+ assert parsed.id == "resp_parse"
185
+ assert parsed.output_parsed == ParsedPerson(name="Ada", age=37)
186
+ path, payload, stream = client.posts[0]
187
+ assert path == "/responses"
188
+ assert stream is True
189
+ assert payload["text"]["verbosity"] == "low"
190
+ assert payload["text"]["format"]["type"] == "json_schema"
191
+ assert payload["text"]["format"]["name"] == "ParsedPerson"
192
+ assert payload["text"]["format"]["strict"] is True
193
+ assert payload["text"]["format"]["schema"]["additionalProperties"] is False
194
+
195
+
196
+ def test_responses_parse_rejects_existing_text_format():
197
+ client = ParseFakeClient()
198
+
199
+ try:
200
+ client.responses.parse(
201
+ input="Extract",
202
+ text_format=ParsedPerson,
203
+ text={"format": {"type": "json_object"}},
204
+ )
205
+ except TypeError as exc:
206
+ assert "text_format" in str(exc)
207
+ else:
208
+ raise AssertionError("Expected TypeError")
209
+
210
+
110
211
  def test_responses_create_normalizes_official_input_items():
111
212
  client = FakeClient()
112
213