oauth-codex 2.0.2__tar.gz → 2.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. {oauth_codex-2.0.2 → oauth_codex-2.1.0}/PKG-INFO +19 -1
  2. {oauth_codex-2.0.2 → oauth_codex-2.1.0}/README.md +18 -0
  3. {oauth_codex-2.0.2 → oauth_codex-2.1.0}/pyproject.toml +1 -1
  4. {oauth_codex-2.0.2 → oauth_codex-2.1.0}/src/oauth_codex/_client.py +118 -8
  5. {oauth_codex-2.0.2 → oauth_codex-2.1.0}/src/oauth_codex/_engine.py +48 -5
  6. {oauth_codex-2.0.2 → oauth_codex-2.1.0}/src/oauth_codex/_version.py +1 -1
  7. {oauth_codex-2.0.2 → oauth_codex-2.1.0}/src/oauth_codex/tooling.py +52 -5
  8. {oauth_codex-2.0.2 → oauth_codex-2.1.0}/src/oauth_codex.egg-info/PKG-INFO +19 -1
  9. {oauth_codex-2.0.2 → oauth_codex-2.1.0}/tests/test_engine_stream_and_continuity.py +45 -1
  10. {oauth_codex-2.0.2 → oauth_codex-2.1.0}/tests/test_generate_async.py +35 -0
  11. {oauth_codex-2.0.2 → oauth_codex-2.1.0}/tests/test_generate_sync.py +109 -0
  12. {oauth_codex-2.0.2 → oauth_codex-2.1.0}/setup.cfg +0 -0
  13. {oauth_codex-2.0.2 → oauth_codex-2.1.0}/src/oauth_codex/__init__.py +0 -0
  14. {oauth_codex-2.0.2 → oauth_codex-2.1.0}/src/oauth_codex/_base_client.py +0 -0
  15. {oauth_codex-2.0.2 → oauth_codex-2.1.0}/src/oauth_codex/_exceptions.py +0 -0
  16. {oauth_codex-2.0.2 → oauth_codex-2.1.0}/src/oauth_codex/_models.py +0 -0
  17. {oauth_codex-2.0.2 → oauth_codex-2.1.0}/src/oauth_codex/_module_client.py +0 -0
  18. {oauth_codex-2.0.2 → oauth_codex-2.1.0}/src/oauth_codex/_resource.py +0 -0
  19. {oauth_codex-2.0.2 → oauth_codex-2.1.0}/src/oauth_codex/_types.py +0 -0
  20. {oauth_codex-2.0.2 → oauth_codex-2.1.0}/src/oauth_codex/auth/__init__.py +0 -0
  21. {oauth_codex-2.0.2 → oauth_codex-2.1.0}/src/oauth_codex/auth/config.py +0 -0
  22. {oauth_codex-2.0.2 → oauth_codex-2.1.0}/src/oauth_codex/auth/pkce.py +0 -0
  23. {oauth_codex-2.0.2 → oauth_codex-2.1.0}/src/oauth_codex/auth/store.py +0 -0
  24. {oauth_codex-2.0.2 → oauth_codex-2.1.0}/src/oauth_codex/auth/token_manager.py +0 -0
  25. {oauth_codex-2.0.2 → oauth_codex-2.1.0}/src/oauth_codex/compat_store.py +0 -0
  26. {oauth_codex-2.0.2 → oauth_codex-2.1.0}/src/oauth_codex/core_types.py +0 -0
  27. {oauth_codex-2.0.2 → oauth_codex-2.1.0}/src/oauth_codex/errors.py +0 -0
  28. {oauth_codex-2.0.2 → oauth_codex-2.1.0}/src/oauth_codex/py.typed +0 -0
  29. {oauth_codex-2.0.2 → oauth_codex-2.1.0}/src/oauth_codex/resources/__init__.py +0 -0
  30. {oauth_codex-2.0.2 → oauth_codex-2.1.0}/src/oauth_codex/resources/_wrappers.py +0 -0
  31. {oauth_codex-2.0.2 → oauth_codex-2.1.0}/src/oauth_codex/resources/files.py +0 -0
  32. {oauth_codex-2.0.2 → oauth_codex-2.1.0}/src/oauth_codex/resources/models.py +0 -0
  33. {oauth_codex-2.0.2 → oauth_codex-2.1.0}/src/oauth_codex/resources/responses/__init__.py +0 -0
  34. {oauth_codex-2.0.2 → oauth_codex-2.1.0}/src/oauth_codex/resources/responses/_helpers.py +0 -0
  35. {oauth_codex-2.0.2 → oauth_codex-2.1.0}/src/oauth_codex/resources/responses/input_tokens.py +0 -0
  36. {oauth_codex-2.0.2 → oauth_codex-2.1.0}/src/oauth_codex/resources/responses/responses.py +0 -0
  37. {oauth_codex-2.0.2 → oauth_codex-2.1.0}/src/oauth_codex/resources/vector_stores/__init__.py +0 -0
  38. {oauth_codex-2.0.2 → oauth_codex-2.1.0}/src/oauth_codex/resources/vector_stores/file_batches.py +0 -0
  39. {oauth_codex-2.0.2 → oauth_codex-2.1.0}/src/oauth_codex/resources/vector_stores/files.py +0 -0
  40. {oauth_codex-2.0.2 → oauth_codex-2.1.0}/src/oauth_codex/resources/vector_stores/vector_stores.py +0 -0
  41. {oauth_codex-2.0.2 → oauth_codex-2.1.0}/src/oauth_codex/store.py +0 -0
  42. {oauth_codex-2.0.2 → oauth_codex-2.1.0}/src/oauth_codex/types/__init__.py +0 -0
  43. {oauth_codex-2.0.2 → oauth_codex-2.1.0}/src/oauth_codex/types/file_deleted.py +0 -0
  44. {oauth_codex-2.0.2 → oauth_codex-2.1.0}/src/oauth_codex/types/file_object.py +0 -0
  45. {oauth_codex-2.0.2 → oauth_codex-2.1.0}/src/oauth_codex/types/responses/__init__.py +0 -0
  46. {oauth_codex-2.0.2 → oauth_codex-2.1.0}/src/oauth_codex/types/responses/input_token_count_response.py +0 -0
  47. {oauth_codex-2.0.2 → oauth_codex-2.1.0}/src/oauth_codex/types/responses/response.py +0 -0
  48. {oauth_codex-2.0.2 → oauth_codex-2.1.0}/src/oauth_codex/types/responses/response_stream_event.py +0 -0
  49. {oauth_codex-2.0.2 → oauth_codex-2.1.0}/src/oauth_codex/types/shared/__init__.py +0 -0
  50. {oauth_codex-2.0.2 → oauth_codex-2.1.0}/src/oauth_codex/types/shared/model_capabilities.py +0 -0
  51. {oauth_codex-2.0.2 → oauth_codex-2.1.0}/src/oauth_codex/types/shared/usage.py +0 -0
  52. {oauth_codex-2.0.2 → oauth_codex-2.1.0}/src/oauth_codex/types/vector_stores/__init__.py +0 -0
  53. {oauth_codex-2.0.2 → oauth_codex-2.1.0}/src/oauth_codex/types/vector_stores/vector_store.py +0 -0
  54. {oauth_codex-2.0.2 → oauth_codex-2.1.0}/src/oauth_codex/types/vector_stores/vector_store_deleted.py +0 -0
  55. {oauth_codex-2.0.2 → oauth_codex-2.1.0}/src/oauth_codex/types/vector_stores/vector_store_file.py +0 -0
  56. {oauth_codex-2.0.2 → oauth_codex-2.1.0}/src/oauth_codex/types/vector_stores/vector_store_file_batch.py +0 -0
  57. {oauth_codex-2.0.2 → oauth_codex-2.1.0}/src/oauth_codex/types/vector_stores/vector_store_search_response.py +0 -0
  58. {oauth_codex-2.0.2 → oauth_codex-2.1.0}/src/oauth_codex/version.py +0 -0
  59. {oauth_codex-2.0.2 → oauth_codex-2.1.0}/src/oauth_codex.egg-info/SOURCES.txt +0 -0
  60. {oauth_codex-2.0.2 → oauth_codex-2.1.0}/src/oauth_codex.egg-info/dependency_links.txt +0 -0
  61. {oauth_codex-2.0.2 → oauth_codex-2.1.0}/src/oauth_codex.egg-info/requires.txt +0 -0
  62. {oauth_codex-2.0.2 → oauth_codex-2.1.0}/src/oauth_codex.egg-info/top_level.txt +0 -0
  63. {oauth_codex-2.0.2 → oauth_codex-2.1.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.2
3
+ Version: 2.1.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
@@ -64,6 +64,24 @@ text = client.generate(
64
64
  print(text)
65
65
  ```
66
66
 
67
+ Single-parameter Pydantic tool inputs are also supported.
68
+
69
+ ```python
70
+ from pydantic import BaseModel
71
+
72
+
73
+ class ToolInput(BaseModel):
74
+ query: str
75
+
76
+
77
+ def tool(input: ToolInput) -> str:
78
+ return f"Tool received query: {input.query}"
79
+
80
+
81
+ text = client.generate("Use the tool", tools=[tool])
82
+ print(text)
83
+ ```
84
+
67
85
  If a tool raises an exception, the SDK forwards it to the model as `{\"error\": ...}` and continues the loop.
68
86
 
69
87
  ## Async
@@ -50,6 +50,24 @@ text = client.generate(
50
50
  print(text)
51
51
  ```
52
52
 
53
+ Single-parameter Pydantic tool inputs are also supported.
54
+
55
+ ```python
56
+ from pydantic import BaseModel
57
+
58
+
59
+ class ToolInput(BaseModel):
60
+ query: str
61
+
62
+
63
+ def tool(input: ToolInput) -> str:
64
+ return f"Tool received query: {input.query}"
65
+
66
+
67
+ text = client.generate("Use the tool", tools=[tool])
68
+ print(text)
69
+ ```
70
+
53
71
  If a tool raises an exception, the SDK forwards it to the model as `{\"error\": ...}` and continues the loop.
54
72
 
55
73
  ## Async
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "oauth-codex"
7
- version = "2.0.2"
7
+ version = "2.1.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"
@@ -6,7 +6,9 @@ import inspect
6
6
  import json
7
7
  import mimetypes
8
8
  from pathlib import Path
9
- from typing import Any, AsyncIterator, Callable, Iterator
9
+ from typing import Any, AsyncIterator, Callable, Iterator, get_type_hints
10
+
11
+ from pydantic import BaseModel
10
12
 
11
13
  from ._base_client import SyncAPIClient
12
14
  from ._engine import OAuthCodexClient as _EngineClient
@@ -23,7 +25,7 @@ from .store import FallbackTokenStore
23
25
  from .tooling import callable_to_tool_schema, normalize_tool_output
24
26
 
25
27
  DEFAULT_MODEL = "gpt-5.3-codex"
26
- DEFAULT_MAX_TOOL_ROUNDS = 8
28
+ DEFAULT_MAX_TOOL_ROUNDS = 16
27
29
 
28
30
 
29
31
  class OAuthCodexClient(SyncAPIClient):
@@ -105,7 +107,11 @@ class OAuthCodexClient(SyncAPIClient):
105
107
  for _ in range(self.max_tool_rounds):
106
108
  result = self._engine.generate(
107
109
  model=self._resolve_model(model),
108
- messages=messages,
110
+ messages=self._messages_for_round(
111
+ messages=messages,
112
+ previous_response_id=previous_response_id,
113
+ tool_results=tool_results,
114
+ ),
109
115
  tools=normalized_tools,
110
116
  tool_results=tool_results,
111
117
  reasoning={"effort": reasoning_effort},
@@ -151,7 +157,11 @@ class OAuthCodexClient(SyncAPIClient):
151
157
  for _ in range(self.max_tool_rounds):
152
158
  result = await self._engine.agenerate(
153
159
  model=self._resolve_model(model),
154
- messages=messages,
160
+ messages=self._messages_for_round(
161
+ messages=messages,
162
+ previous_response_id=previous_response_id,
163
+ tool_results=tool_results,
164
+ ),
155
165
  tools=normalized_tools,
156
166
  tool_results=tool_results,
157
167
  reasoning={"effort": reasoning_effort},
@@ -198,7 +208,11 @@ class OAuthCodexClient(SyncAPIClient):
198
208
  round_response_id: str | None = previous_response_id
199
209
  events = self._engine.generate_stream(
200
210
  model=self._resolve_model(model),
201
- messages=messages,
211
+ messages=self._messages_for_round(
212
+ messages=messages,
213
+ previous_response_id=previous_response_id,
214
+ tool_results=tool_results,
215
+ ),
202
216
  tools=normalized_tools,
203
217
  tool_results=tool_results,
204
218
  reasoning={"effort": reasoning_effort},
@@ -247,7 +261,11 @@ class OAuthCodexClient(SyncAPIClient):
247
261
  round_response_id: str | None = previous_response_id
248
262
  events = await self._engine.agenerate_stream(
249
263
  model=self._resolve_model(model),
250
- messages=messages,
264
+ messages=self._messages_for_round(
265
+ messages=messages,
266
+ previous_response_id=previous_response_id,
267
+ tool_results=tool_results,
268
+ ),
251
269
  tools=normalized_tools,
252
270
  tool_results=tool_results,
253
271
  reasoning={"effort": reasoning_effort},
@@ -276,6 +294,28 @@ class OAuthCodexClient(SyncAPIClient):
276
294
  def _resolve_model(self, model: str | None) -> str:
277
295
  return model or self.default_model
278
296
 
297
+ def _is_tool_continuation_round(
298
+ self,
299
+ *,
300
+ previous_response_id: str | None,
301
+ tool_results: list[ToolResult] | None,
302
+ ) -> bool:
303
+ return bool(previous_response_id) and bool(tool_results)
304
+
305
+ def _messages_for_round(
306
+ self,
307
+ *,
308
+ messages: list[Message],
309
+ previous_response_id: str | None,
310
+ tool_results: list[ToolResult] | None,
311
+ ) -> list[Message]:
312
+ if self._is_tool_continuation_round(
313
+ previous_response_id=previous_response_id,
314
+ tool_results=tool_results,
315
+ ):
316
+ return []
317
+ return messages
318
+
279
319
  def _build_messages(
280
320
  self,
281
321
  *,
@@ -371,7 +411,8 @@ class OAuthCodexClient(SyncAPIClient):
371
411
  else:
372
412
  try:
373
413
  kwargs = self._parse_tool_kwargs(call.arguments_json)
374
- value = tool(**kwargs)
414
+ normalized_kwargs = self._normalize_tool_kwargs(tool, kwargs)
415
+ value = tool(**normalized_kwargs)
375
416
  if inspect.isawaitable(value):
376
417
  raise TypeError("async tool is not supported in generate(); use agenerate()")
377
418
  output = self._normalize_tool_output(value)
@@ -394,7 +435,8 @@ class OAuthCodexClient(SyncAPIClient):
394
435
  else:
395
436
  try:
396
437
  kwargs = self._parse_tool_kwargs(call.arguments_json)
397
- value = tool(**kwargs)
438
+ normalized_kwargs = self._normalize_tool_kwargs(tool, kwargs)
439
+ value = tool(**normalized_kwargs)
398
440
  if inspect.isawaitable(value):
399
441
  value = await value
400
442
  output = self._normalize_tool_output(value)
@@ -411,6 +453,74 @@ class OAuthCodexClient(SyncAPIClient):
411
453
  raise TypeError("tool arguments must be a JSON object")
412
454
  return parsed
413
455
 
456
+ def _normalize_tool_kwargs(
457
+ self,
458
+ tool: Callable[..., Any],
459
+ kwargs: dict[str, Any],
460
+ ) -> dict[str, Any]:
461
+ signature = inspect.signature(tool)
462
+ resolved_hints = self._resolve_tool_type_hints(tool)
463
+ params = [
464
+ param
465
+ for param in signature.parameters.values()
466
+ if param.kind not in (param.VAR_POSITIONAL, param.VAR_KEYWORD)
467
+ ]
468
+ if not params:
469
+ return kwargs
470
+
471
+ if len(params) == 1:
472
+ param = params[0]
473
+ model_type = self._resolve_pydantic_model_type(
474
+ resolved_hints.get(param.name, param.annotation)
475
+ )
476
+ if model_type is not None:
477
+ if param.name in kwargs:
478
+ payload = kwargs[param.name]
479
+ if isinstance(payload, model_type):
480
+ return kwargs
481
+ if not isinstance(payload, dict):
482
+ raise TypeError(f"tool argument `{param.name}` must be a JSON object")
483
+ normalized = dict(kwargs)
484
+ normalized[param.name] = model_type.model_validate(payload)
485
+ return normalized
486
+
487
+ if not kwargs and param.default is not inspect._empty:
488
+ return kwargs
489
+
490
+ payload = kwargs
491
+ normalized_payload = (
492
+ payload
493
+ if isinstance(payload, model_type)
494
+ else model_type.model_validate(payload)
495
+ )
496
+ return {param.name: normalized_payload}
497
+
498
+ normalized = dict(kwargs)
499
+ for param in params:
500
+ model_type = self._resolve_pydantic_model_type(
501
+ resolved_hints.get(param.name, param.annotation)
502
+ )
503
+ if model_type is None or param.name not in normalized:
504
+ continue
505
+ payload = normalized[param.name]
506
+ if isinstance(payload, model_type):
507
+ continue
508
+ if not isinstance(payload, dict):
509
+ raise TypeError(f"tool argument `{param.name}` must be a JSON object")
510
+ normalized[param.name] = model_type.model_validate(payload)
511
+ return normalized
512
+
513
+ def _resolve_pydantic_model_type(self, annotation: Any) -> type[BaseModel] | None:
514
+ if isinstance(annotation, type) and issubclass(annotation, BaseModel):
515
+ return annotation
516
+ return None
517
+
518
+ def _resolve_tool_type_hints(self, tool: Callable[..., Any]) -> dict[str, Any]:
519
+ try:
520
+ return get_type_hints(tool)
521
+ except Exception:
522
+ return {}
523
+
414
524
  def _normalize_tool_output(self, output: Any) -> dict[str, Any]:
415
525
  return normalize_tool_output(output)
416
526
 
@@ -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.2"
2
+ __version__ = "2.1.0"
@@ -3,11 +3,32 @@ from __future__ import annotations
3
3
  import inspect
4
4
  import json
5
5
  from types import UnionType
6
- from typing import Any, get_args, get_origin
6
+ from typing import Any, get_args, get_origin, get_type_hints
7
7
 
8
8
  from .core_types import ToolInput, ToolResult, ToolSchema
9
9
  from .errors import SDKRequestError
10
10
 
11
+ try:
12
+ from pydantic import BaseModel
13
+ except Exception: # pragma: no cover - pydantic is a runtime dependency
14
+ BaseModel = None # type: ignore[assignment]
15
+
16
+
17
+ def _is_pydantic_model_type(annotation: Any) -> bool:
18
+ return bool(
19
+ BaseModel is not None
20
+ and isinstance(annotation, type)
21
+ and issubclass(annotation, BaseModel)
22
+ )
23
+
24
+
25
+ def _pydantic_model_to_schema(model_type: type[Any]) -> dict[str, Any]:
26
+ if hasattr(model_type, "model_json_schema"):
27
+ schema = model_type.model_json_schema()
28
+ if isinstance(schema, dict):
29
+ return schema
30
+ return {"type": "object"}
31
+
11
32
 
12
33
  def _python_type_to_schema(annotation: Any) -> dict[str, Any]:
13
34
  if annotation is inspect._empty:
@@ -33,6 +54,8 @@ def _python_type_to_schema(annotation: Any) -> dict[str, Any]:
33
54
  args = get_args(annotation)
34
55
 
35
56
  if origin is None:
57
+ if _is_pydantic_model_type(annotation):
58
+ return _pydantic_model_to_schema(annotation)
36
59
  if annotation is str:
37
60
  return {"type": "string"}
38
61
  if annotation is int:
@@ -67,16 +90,39 @@ def _python_type_to_schema(annotation: Any) -> dict[str, Any]:
67
90
 
68
91
  def callable_to_tool_schema(func: Any) -> ToolSchema:
69
92
  signature = inspect.signature(func)
93
+ try:
94
+ resolved_hints = get_type_hints(func)
95
+ except Exception:
96
+ resolved_hints = {}
97
+
70
98
  doc = inspect.getdoc(func) or ""
71
99
  description = doc.splitlines()[0] if doc else f"Tool `{getattr(func, '__name__', 'tool')}`"
72
100
 
101
+ params = [
102
+ param
103
+ for param in signature.parameters.values()
104
+ if param.kind not in (param.VAR_POSITIONAL, param.VAR_KEYWORD)
105
+ ]
106
+ if len(params) == 1:
107
+ single = params[0]
108
+ single_annotation = resolved_hints.get(single.name, single.annotation)
109
+ if _is_pydantic_model_type(single_annotation):
110
+ model_schema = _python_type_to_schema(single_annotation)
111
+ if model_schema.get("type") == "object":
112
+ return {
113
+ "type": "function",
114
+ "name": getattr(func, "__name__", "tool"),
115
+ "description": description,
116
+ "parameters": model_schema,
117
+ }
118
+
73
119
  properties: dict[str, Any] = {}
74
120
  required: list[str] = []
75
121
 
76
- for name, param in signature.parameters.items():
77
- if param.kind in (param.VAR_POSITIONAL, param.VAR_KEYWORD):
78
- continue
79
- properties[name] = _python_type_to_schema(param.annotation)
122
+ for param in params:
123
+ name = param.name
124
+ annotation = resolved_hints.get(name, param.annotation)
125
+ properties[name] = _python_type_to_schema(annotation)
80
126
  if param.default is inspect._empty:
81
127
  required.append(name)
82
128
 
@@ -93,6 +139,7 @@ def callable_to_tool_schema(func: Any) -> ToolSchema:
93
139
  }
94
140
 
95
141
 
142
+
96
143
  def _normalize_dict_tool(tool: dict[str, Any]) -> ToolSchema:
97
144
  if tool.get("type") == "function" and "function" in tool and isinstance(tool["function"], dict):
98
145
  fn = tool["function"]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: oauth-codex
3
- Version: 2.0.2
3
+ Version: 2.1.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
@@ -64,6 +64,24 @@ text = client.generate(
64
64
  print(text)
65
65
  ```
66
66
 
67
+ Single-parameter Pydantic tool inputs are also supported.
68
+
69
+ ```python
70
+ from pydantic import BaseModel
71
+
72
+
73
+ class ToolInput(BaseModel):
74
+ query: str
75
+
76
+
77
+ def tool(input: ToolInput) -> str:
78
+ return f"Tool received query: {input.query}"
79
+
80
+
81
+ text = client.generate("Use the tool", tools=[tool])
82
+ print(text)
83
+ ```
84
+
67
85
  If a tool raises an exception, the SDK forwards it to the model as `{\"error\": ...}` and continues the loop.
68
86
 
69
87
  ## Async
@@ -4,7 +4,7 @@ import pytest
4
4
 
5
5
  from conftest import InMemoryTokenStore
6
6
  from oauth_codex._engine import OAuthCodexClient as EngineClient
7
- from oauth_codex.core_types import OAuthTokens, ToolResult
7
+ from oauth_codex.core_types import GenerateResult, OAuthTokens, ToolResult
8
8
  from oauth_codex.errors import SDKRequestError
9
9
  from oauth_codex.tooling import tool_results_to_response_items
10
10
 
@@ -168,3 +168,47 @@ def test_extract_output_items_for_continuation_sanitizes_response_output() -> No
168
168
  assert [item.get("type") for item in items] == ["message", "function_call"]
169
169
  assert all("id" not in item for item in items)
170
170
  assert all("status" not in item for item in items)
171
+
172
+
173
+ def test_generate_allows_empty_messages_for_tool_continuation(
174
+ monkeypatch: pytest.MonkeyPatch,
175
+ ) -> None:
176
+ engine = _engine()
177
+ captured: dict[str, object] = {}
178
+
179
+ def fake_generate_responses_sync(**kwargs):
180
+ captured.update(kwargs)
181
+ return GenerateResult(text="ok", tool_calls=[], finish_reason="stop")
182
+
183
+ monkeypatch.setattr(engine, "_generate_responses_sync", fake_generate_responses_sync)
184
+
185
+ out = engine.generate(
186
+ model="gpt-5.3-codex",
187
+ messages=[],
188
+ tool_results=[ToolResult(tool_call_id="call_1", name="tool", output={"ok": True})],
189
+ previous_response_id="resp_1",
190
+ return_details=True,
191
+ )
192
+
193
+ assert isinstance(out, GenerateResult)
194
+ assert captured["messages"] == []
195
+
196
+
197
+ def test_generate_rejects_empty_messages_without_tool_continuation() -> None:
198
+ engine = _engine()
199
+
200
+ with pytest.raises(ValueError, match="non-empty"):
201
+ engine.generate(
202
+ model="gpt-5.3-codex",
203
+ messages=[],
204
+ tool_results=[ToolResult(tool_call_id="call_1", name="tool", output={"ok": True})],
205
+ previous_response_id=None,
206
+ )
207
+
208
+ with pytest.raises(ValueError, match="non-empty"):
209
+ engine.generate(
210
+ model="gpt-5.3-codex",
211
+ messages=[],
212
+ tool_results=[],
213
+ previous_response_id="resp_1",
214
+ )
@@ -1,12 +1,17 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import pytest
4
+ from pydantic import BaseModel
4
5
 
5
6
  from conftest import InMemoryTokenStore
6
7
  from oauth_codex import Client
7
8
  from oauth_codex.core_types import GenerateResult, OAuthTokens, StreamEvent, ToolCall
8
9
 
9
10
 
11
+ class ToolInput(BaseModel):
12
+ query: str
13
+
14
+
10
15
  def _client() -> Client:
11
16
  return Client(
12
17
  token_store=InMemoryTokenStore(
@@ -40,6 +45,7 @@ async def test_agenerate_auto_function_calling(monkeypatch: pytest.MonkeyPatch)
40
45
 
41
46
  assert out == "12"
42
47
  assert calls[1]["previous_response_id"] == "resp_1"
48
+ assert calls[1]["messages"] == []
43
49
  tool_results = calls[1]["tool_results"]
44
50
  assert tool_results[0].output == {"sum": 12}
45
51
 
@@ -80,5 +86,34 @@ async def test_astream_supports_tool_calls(monkeypatch: pytest.MonkeyPatch) -> N
80
86
 
81
87
  assert out == ["X", "Y"]
82
88
  assert calls[1]["previous_response_id"] == "resp_1"
89
+ assert calls[1]["messages"] == []
83
90
  tool_results = calls[1]["tool_results"]
84
91
  assert tool_results[0].output == {"product": 12}
92
+
93
+
94
+ @pytest.mark.asyncio
95
+ async def test_agenerate_supports_single_pydantic_tool_input(monkeypatch: pytest.MonkeyPatch) -> None:
96
+ client = _client()
97
+ calls: list[dict[str, object]] = []
98
+
99
+ async def fake_agenerate(**kwargs):
100
+ calls.append(kwargs)
101
+ if len(calls) == 1:
102
+ return GenerateResult(
103
+ text="",
104
+ tool_calls=[ToolCall(id="call_1", name="tool", arguments_json='{"query":"hello"}')],
105
+ finish_reason="tool_calls",
106
+ response_id="resp_1",
107
+ )
108
+ return GenerateResult(text="done", tool_calls=[], finish_reason="stop", response_id="resp_2")
109
+
110
+ monkeypatch.setattr(client._engine, "agenerate", fake_agenerate)
111
+
112
+ def tool(input: ToolInput) -> str:
113
+ return f"Tool received query: {input.query}"
114
+
115
+ out = await client.agenerate("run", tools=[tool])
116
+
117
+ assert out == "done"
118
+ tool_results = calls[1]["tool_results"]
119
+ assert tool_results[0].output == {"output": "Tool received query: hello"}
@@ -1,12 +1,21 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import pytest
4
+ from pydantic import BaseModel, Field
4
5
 
5
6
  from conftest import InMemoryTokenStore
6
7
  from oauth_codex import Client
7
8
  from oauth_codex.core_types import GenerateResult, OAuthTokens, StreamEvent, ToolCall
8
9
 
9
10
 
11
+ class ToolInputWithDescription(BaseModel):
12
+ query: str = Field(..., description="The query to be processed by the tool.")
13
+
14
+
15
+ class ToolInput(BaseModel):
16
+ query: str
17
+
18
+
10
19
  def _client() -> Client:
11
20
  return Client(
12
21
  token_store=InMemoryTokenStore(
@@ -86,12 +95,42 @@ def test_generate_auto_function_calling(monkeypatch: pytest.MonkeyPatch) -> None
86
95
 
87
96
  assert out == "5"
88
97
  assert calls[1]["previous_response_id"] == "resp_1"
98
+ assert calls[1]["messages"] == []
89
99
  tool_results = calls[1]["tool_results"]
90
100
  assert len(tool_results) == 1
91
101
  assert tool_results[0].name == "add"
92
102
  assert tool_results[0].output == {"sum": 5}
93
103
 
94
104
 
105
+ def test_generate_replays_messages_when_tool_round_has_no_response_id(
106
+ monkeypatch: pytest.MonkeyPatch,
107
+ ) -> None:
108
+ client = _client()
109
+ calls: list[dict[str, object]] = []
110
+
111
+ def fake_generate(**kwargs):
112
+ calls.append(kwargs)
113
+ if len(calls) == 1:
114
+ return GenerateResult(
115
+ text="",
116
+ tool_calls=[ToolCall(id="call_1", name="add", arguments_json='{"a":1,"b":2}')],
117
+ finish_reason="tool_calls",
118
+ response_id=None,
119
+ )
120
+ return GenerateResult(text="3", tool_calls=[], finish_reason="stop", response_id="resp_2")
121
+
122
+ monkeypatch.setattr(client._engine, "generate", fake_generate)
123
+
124
+ def add(a: int, b: int) -> dict[str, int]:
125
+ return {"sum": a + b}
126
+
127
+ out = client.generate("1+2", tools=[add])
128
+
129
+ assert out == "3"
130
+ assert calls[1]["previous_response_id"] is None
131
+ assert calls[1]["messages"] == calls[0]["messages"]
132
+
133
+
95
134
  def test_generate_tool_failure_is_forwarded_to_model(monkeypatch: pytest.MonkeyPatch) -> None:
96
135
  client = _client()
97
136
  calls: list[dict[str, object]] = []
@@ -147,6 +186,75 @@ def test_generate_wraps_string_tool_output_as_dict(monkeypatch: pytest.MonkeyPat
147
186
  assert tool_results[0].output == {"output": "hello"}
148
187
 
149
188
 
189
+ def test_generate_supports_single_pydantic_tool_input_with_flat_payload(
190
+ monkeypatch: pytest.MonkeyPatch,
191
+ ) -> None:
192
+ client = _client()
193
+ calls: list[dict[str, object]] = []
194
+
195
+ def fake_generate(**kwargs):
196
+ calls.append(kwargs)
197
+ if len(calls) == 1:
198
+ return GenerateResult(
199
+ text="",
200
+ tool_calls=[ToolCall(id="call_1", name="tool", arguments_json='{"query":"hello"}')],
201
+ finish_reason="tool_calls",
202
+ response_id="resp_1",
203
+ )
204
+ return GenerateResult(text="done", tool_calls=[], finish_reason="stop", response_id="resp_2")
205
+
206
+ monkeypatch.setattr(client._engine, "generate", fake_generate)
207
+
208
+ def tool(input: ToolInputWithDescription) -> str:
209
+ return f"Tool received query: {input.query}"
210
+
211
+ out = client.generate("run", tools=[tool])
212
+
213
+ assert out == "done"
214
+ first_round_tools = calls[0]["tools"]
215
+ assert isinstance(first_round_tools, list)
216
+ assert first_round_tools[0]["parameters"]["type"] == "object"
217
+ assert "query" in first_round_tools[0]["parameters"]["properties"]
218
+ assert "input" not in first_round_tools[0]["parameters"]["properties"]
219
+ tool_results = calls[1]["tool_results"]
220
+ assert tool_results[0].output == {"output": "Tool received query: hello"}
221
+
222
+
223
+ def test_generate_supports_single_pydantic_tool_input_with_nested_payload(
224
+ monkeypatch: pytest.MonkeyPatch,
225
+ ) -> None:
226
+ client = _client()
227
+ calls: list[dict[str, object]] = []
228
+
229
+ def fake_generate(**kwargs):
230
+ calls.append(kwargs)
231
+ if len(calls) == 1:
232
+ return GenerateResult(
233
+ text="",
234
+ tool_calls=[
235
+ ToolCall(
236
+ id="call_1",
237
+ name="tool",
238
+ arguments_json='{"input":{"query":"hello"}}',
239
+ )
240
+ ],
241
+ finish_reason="tool_calls",
242
+ response_id="resp_1",
243
+ )
244
+ return GenerateResult(text="done", tool_calls=[], finish_reason="stop", response_id="resp_2")
245
+
246
+ monkeypatch.setattr(client._engine, "generate", fake_generate)
247
+
248
+ def tool(input: ToolInput) -> str:
249
+ return f"Tool received query: {input.query}"
250
+
251
+ out = client.generate("run", tools=[tool])
252
+
253
+ assert out == "done"
254
+ tool_results = calls[1]["tool_results"]
255
+ assert tool_results[0].output == {"output": "Tool received query: hello"}
256
+
257
+
150
258
  def test_generate_raises_when_tool_round_limit_exceeded(monkeypatch: pytest.MonkeyPatch) -> None:
151
259
  client = _client()
152
260
  client.max_tool_rounds = 2
@@ -197,5 +305,6 @@ def test_stream_supports_tool_calls(monkeypatch: pytest.MonkeyPatch) -> None:
197
305
 
198
306
  assert out == ["A", "B"]
199
307
  assert calls[1]["previous_response_id"] == "resp_1"
308
+ assert calls[1]["messages"] == []
200
309
  tool_results = calls[1]["tool_results"]
201
310
  assert tool_results[0].output == {"sum": 3}
File without changes