codex-sdk-python 0.81.0__py3-none-any.whl
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.
- codex_sdk/__init__.py +140 -0
- codex_sdk/abort.py +40 -0
- codex_sdk/app_server.py +918 -0
- codex_sdk/codex.py +147 -0
- codex_sdk/config_overrides.py +70 -0
- codex_sdk/events.py +112 -0
- codex_sdk/exceptions.py +55 -0
- codex_sdk/exec.py +442 -0
- codex_sdk/hooks.py +74 -0
- codex_sdk/integrations/__init__.py +1 -0
- codex_sdk/integrations/pydantic_ai.py +172 -0
- codex_sdk/integrations/pydantic_ai_model.py +381 -0
- codex_sdk/items.py +173 -0
- codex_sdk/options.py +145 -0
- codex_sdk/telemetry.py +36 -0
- codex_sdk/thread.py +606 -0
- codex_sdk_python-0.81.0.dist-info/METADATA +880 -0
- codex_sdk_python-0.81.0.dist-info/RECORD +19 -0
- codex_sdk_python-0.81.0.dist-info/WHEEL +4 -0
codex_sdk/telemetry.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Optional Logfire instrumentation helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from contextlib import contextmanager
|
|
6
|
+
from typing import Any, Iterator, Optional
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _maybe_logfire() -> Optional[Any]:
|
|
10
|
+
try:
|
|
11
|
+
import logfire
|
|
12
|
+
except ImportError:
|
|
13
|
+
return None
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
instance = getattr(logfire, "DEFAULT_LOGFIRE_INSTANCE", None)
|
|
17
|
+
config = getattr(instance, "config", None)
|
|
18
|
+
if config is None:
|
|
19
|
+
return None
|
|
20
|
+
if getattr(config, "_initialized", False):
|
|
21
|
+
return logfire
|
|
22
|
+
except Exception:
|
|
23
|
+
return None
|
|
24
|
+
|
|
25
|
+
return None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@contextmanager
|
|
29
|
+
def span(name: str, **attributes: Any) -> Iterator[None]:
|
|
30
|
+
logfire = _maybe_logfire()
|
|
31
|
+
if logfire is None:
|
|
32
|
+
yield
|
|
33
|
+
return
|
|
34
|
+
|
|
35
|
+
with logfire.span(name, **attributes):
|
|
36
|
+
yield
|
codex_sdk/thread.py
ADDED
|
@@ -0,0 +1,606 @@
|
|
|
1
|
+
"""Thread class for managing conversations with the Codex agent."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import inspect
|
|
7
|
+
import json
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import (
|
|
10
|
+
Any,
|
|
11
|
+
AsyncGenerator,
|
|
12
|
+
Generic,
|
|
13
|
+
List,
|
|
14
|
+
Literal,
|
|
15
|
+
Mapping,
|
|
16
|
+
Optional,
|
|
17
|
+
Sequence,
|
|
18
|
+
TypedDict,
|
|
19
|
+
TypeVar,
|
|
20
|
+
Union,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
from .config_overrides import merge_config_overrides
|
|
24
|
+
from .events import ThreadError, ThreadEvent, Usage
|
|
25
|
+
from .exceptions import CodexError, CodexParseError, TurnFailedError
|
|
26
|
+
from .exec import CodexExec, CodexExecArgs, create_output_schema_file
|
|
27
|
+
from .hooks import ThreadHooks, dispatch_event
|
|
28
|
+
from .items import (
|
|
29
|
+
AgentMessageItem,
|
|
30
|
+
CommandExecutionItem,
|
|
31
|
+
ErrorItem,
|
|
32
|
+
FileChangeItem,
|
|
33
|
+
McpToolCallItem,
|
|
34
|
+
ReasoningItem,
|
|
35
|
+
ThreadItem,
|
|
36
|
+
TodoListItem,
|
|
37
|
+
WebSearchItem,
|
|
38
|
+
)
|
|
39
|
+
from .options import CodexOptions, ThreadOptions, TurnOptions
|
|
40
|
+
from .telemetry import span
|
|
41
|
+
|
|
42
|
+
T = TypeVar("T")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class Turn:
|
|
47
|
+
"""Completed turn."""
|
|
48
|
+
|
|
49
|
+
items: List[ThreadItem]
|
|
50
|
+
final_response: str
|
|
51
|
+
usage: Optional[Usage]
|
|
52
|
+
|
|
53
|
+
def agent_messages(self) -> List[AgentMessageItem]:
|
|
54
|
+
return [item for item in self.items if item.type == "agent_message"]
|
|
55
|
+
|
|
56
|
+
def reasoning(self) -> List[ReasoningItem]:
|
|
57
|
+
return [item for item in self.items if item.type == "reasoning"]
|
|
58
|
+
|
|
59
|
+
def commands(self) -> List[CommandExecutionItem]:
|
|
60
|
+
return [item for item in self.items if item.type == "command_execution"]
|
|
61
|
+
|
|
62
|
+
def file_changes(self) -> List[FileChangeItem]:
|
|
63
|
+
return [item for item in self.items if item.type == "file_change"]
|
|
64
|
+
|
|
65
|
+
def mcp_tool_calls(self) -> List[McpToolCallItem]:
|
|
66
|
+
return [item for item in self.items if item.type == "mcp_tool_call"]
|
|
67
|
+
|
|
68
|
+
def web_searches(self) -> List[WebSearchItem]:
|
|
69
|
+
return [item for item in self.items if item.type == "web_search"]
|
|
70
|
+
|
|
71
|
+
def todo_lists(self) -> List[TodoListItem]:
|
|
72
|
+
return [item for item in self.items if item.type == "todo_list"]
|
|
73
|
+
|
|
74
|
+
def errors(self) -> List[ErrorItem]:
|
|
75
|
+
return [item for item in self.items if item.type == "error"]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# Alias for Turn to describe the result of run()
|
|
79
|
+
RunResult = Turn
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass
|
|
83
|
+
class StreamedTurn:
|
|
84
|
+
"""The result of the run_streamed method."""
|
|
85
|
+
|
|
86
|
+
events: AsyncGenerator[ThreadEvent, None]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# Alias for StreamedTurn to describe the result of run_streamed()
|
|
90
|
+
RunStreamedResult = StreamedTurn
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass
|
|
94
|
+
class ParsedTurn(Generic[T]):
|
|
95
|
+
"""A completed turn plus parsed output."""
|
|
96
|
+
|
|
97
|
+
turn: Turn
|
|
98
|
+
output: T
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class TextInput(TypedDict):
|
|
102
|
+
type: Literal["text"]
|
|
103
|
+
text: str
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class LocalImageInput(TypedDict):
|
|
107
|
+
type: Literal["local_image"]
|
|
108
|
+
path: str
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
UserInput = Union[TextInput, LocalImageInput]
|
|
112
|
+
|
|
113
|
+
# Input alias to mirror the TypeScript SDK
|
|
114
|
+
Input = Union[str, Sequence[UserInput]]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class Thread:
|
|
118
|
+
"""Represents a thread of conversation with the agent.
|
|
119
|
+
|
|
120
|
+
One thread can have multiple consecutive turns.
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
def __init__(
|
|
124
|
+
self,
|
|
125
|
+
exec: CodexExec,
|
|
126
|
+
options: CodexOptions,
|
|
127
|
+
thread_options: ThreadOptions,
|
|
128
|
+
thread_id: Optional[str] = None,
|
|
129
|
+
):
|
|
130
|
+
self._exec = exec
|
|
131
|
+
self._options = options
|
|
132
|
+
self._id = thread_id
|
|
133
|
+
self._thread_options = thread_options
|
|
134
|
+
|
|
135
|
+
@property
|
|
136
|
+
def id(self) -> Optional[str]:
|
|
137
|
+
"""Return the ID of the thread. Populated after the first turn starts."""
|
|
138
|
+
return self._id
|
|
139
|
+
|
|
140
|
+
def run_sync(
|
|
141
|
+
self, input: Input, turn_options: Optional[TurnOptions] = None
|
|
142
|
+
) -> Turn:
|
|
143
|
+
"""
|
|
144
|
+
Synchronous wrapper around `run()`.
|
|
145
|
+
|
|
146
|
+
Raises:
|
|
147
|
+
CodexError: If called from within a running event loop.
|
|
148
|
+
"""
|
|
149
|
+
if turn_options is None:
|
|
150
|
+
turn_options = TurnOptions()
|
|
151
|
+
|
|
152
|
+
try:
|
|
153
|
+
asyncio.get_running_loop()
|
|
154
|
+
except RuntimeError:
|
|
155
|
+
return asyncio.run(self.run(input, turn_options))
|
|
156
|
+
|
|
157
|
+
raise CodexError(
|
|
158
|
+
"run_sync() cannot be used from a running event loop; use await run()."
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
def run_json_sync(
|
|
162
|
+
self,
|
|
163
|
+
input: Input,
|
|
164
|
+
*,
|
|
165
|
+
output_schema: Mapping[str, Any],
|
|
166
|
+
turn_options: Optional[TurnOptions] = None,
|
|
167
|
+
) -> ParsedTurn[Any]:
|
|
168
|
+
"""Synchronous wrapper around `run_json()`."""
|
|
169
|
+
try:
|
|
170
|
+
asyncio.get_running_loop()
|
|
171
|
+
except RuntimeError:
|
|
172
|
+
return asyncio.run(
|
|
173
|
+
self.run_json(
|
|
174
|
+
input, output_schema=output_schema, turn_options=turn_options
|
|
175
|
+
)
|
|
176
|
+
)
|
|
177
|
+
raise CodexError(
|
|
178
|
+
"run_json_sync() cannot be used from a running event loop; use await run_json()."
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
def run_pydantic_sync(
|
|
182
|
+
self,
|
|
183
|
+
input: Input,
|
|
184
|
+
*,
|
|
185
|
+
output_model: Any,
|
|
186
|
+
turn_options: Optional[TurnOptions] = None,
|
|
187
|
+
) -> ParsedTurn[Any]:
|
|
188
|
+
"""Synchronous wrapper around `run_pydantic()`."""
|
|
189
|
+
try:
|
|
190
|
+
asyncio.get_running_loop()
|
|
191
|
+
except RuntimeError:
|
|
192
|
+
return asyncio.run(
|
|
193
|
+
self.run_pydantic(
|
|
194
|
+
input, output_model=output_model, turn_options=turn_options
|
|
195
|
+
)
|
|
196
|
+
)
|
|
197
|
+
raise CodexError(
|
|
198
|
+
"run_pydantic_sync() cannot be used from a running event loop; use await run_pydantic()."
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
async def run_streamed(
|
|
202
|
+
self, input: Input, turn_options: Optional[TurnOptions] = None
|
|
203
|
+
) -> RunStreamedResult:
|
|
204
|
+
"""
|
|
205
|
+
Provide the input to the agent and stream events as they are produced.
|
|
206
|
+
|
|
207
|
+
Args:
|
|
208
|
+
input: Input prompt to send to the agent.
|
|
209
|
+
turn_options: Optional turn configuration.
|
|
210
|
+
|
|
211
|
+
Returns:
|
|
212
|
+
StreamedTurn containing an async generator of thread events.
|
|
213
|
+
|
|
214
|
+
Raises:
|
|
215
|
+
CodexParseError: If a streamed event cannot be parsed.
|
|
216
|
+
CodexError: Propagated errors from the Codex CLI invocation.
|
|
217
|
+
"""
|
|
218
|
+
if turn_options is None:
|
|
219
|
+
turn_options = TurnOptions()
|
|
220
|
+
|
|
221
|
+
return StreamedTurn(events=self._run_streamed_internal(input, turn_options))
|
|
222
|
+
|
|
223
|
+
async def run_streamed_events(
|
|
224
|
+
self, input: Input, turn_options: Optional[TurnOptions] = None
|
|
225
|
+
) -> AsyncGenerator[ThreadEvent, None]:
|
|
226
|
+
"""
|
|
227
|
+
Provide the input to the agent and yield events directly.
|
|
228
|
+
|
|
229
|
+
This helper enables a concise `async for event in thread.run_streamed_events(...)`
|
|
230
|
+
pattern without unpacking the StreamedTurn wrapper.
|
|
231
|
+
|
|
232
|
+
Args:
|
|
233
|
+
input: Input prompt to send to the agent.
|
|
234
|
+
turn_options: Optional turn configuration.
|
|
235
|
+
|
|
236
|
+
Yields:
|
|
237
|
+
Parsed ThreadEvent objects as they arrive.
|
|
238
|
+
|
|
239
|
+
Raises:
|
|
240
|
+
CodexParseError: If a streamed event cannot be parsed.
|
|
241
|
+
CodexError: Propagated errors from the Codex CLI invocation.
|
|
242
|
+
"""
|
|
243
|
+
if turn_options is None:
|
|
244
|
+
turn_options = TurnOptions()
|
|
245
|
+
|
|
246
|
+
async for event in self._run_streamed_internal(input, turn_options):
|
|
247
|
+
yield event
|
|
248
|
+
|
|
249
|
+
async def _run_streamed_internal(
|
|
250
|
+
self, input: Input, turn_options: TurnOptions
|
|
251
|
+
) -> AsyncGenerator[ThreadEvent, None]:
|
|
252
|
+
"""Internal method for streaming events."""
|
|
253
|
+
prompt, images = normalize_input(input)
|
|
254
|
+
schema_path, cleanup = await create_output_schema_file(
|
|
255
|
+
turn_options.output_schema
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
try:
|
|
259
|
+
with span(
|
|
260
|
+
"codex_sdk.thread.turn",
|
|
261
|
+
thread_id=self._id,
|
|
262
|
+
model=self._thread_options.model,
|
|
263
|
+
sandbox_mode=self._thread_options.sandbox_mode,
|
|
264
|
+
working_directory=self._thread_options.working_directory,
|
|
265
|
+
):
|
|
266
|
+
args = CodexExecArgs(
|
|
267
|
+
input=prompt,
|
|
268
|
+
base_url=self._options.base_url,
|
|
269
|
+
api_key=self._options.api_key,
|
|
270
|
+
thread_id=self._id,
|
|
271
|
+
images=images,
|
|
272
|
+
model=self._thread_options.model,
|
|
273
|
+
sandbox_mode=self._thread_options.sandbox_mode,
|
|
274
|
+
working_directory=self._thread_options.working_directory,
|
|
275
|
+
additional_directories=self._thread_options.additional_directories,
|
|
276
|
+
skip_git_repo_check=self._thread_options.skip_git_repo_check,
|
|
277
|
+
output_schema_file=schema_path,
|
|
278
|
+
model_reasoning_effort=self._thread_options.model_reasoning_effort,
|
|
279
|
+
network_access_enabled=self._thread_options.network_access_enabled,
|
|
280
|
+
web_search_enabled=self._thread_options.web_search_enabled,
|
|
281
|
+
web_search_cached_enabled=self._thread_options.web_search_cached_enabled,
|
|
282
|
+
skills_enabled=self._thread_options.skills_enabled,
|
|
283
|
+
shell_snapshot_enabled=self._thread_options.shell_snapshot_enabled,
|
|
284
|
+
background_terminals_enabled=self._thread_options.background_terminals_enabled,
|
|
285
|
+
apply_patch_freeform_enabled=self._thread_options.apply_patch_freeform_enabled,
|
|
286
|
+
exec_policy_enabled=self._thread_options.exec_policy_enabled,
|
|
287
|
+
remote_models_enabled=self._thread_options.remote_models_enabled,
|
|
288
|
+
request_compression_enabled=self._thread_options.request_compression_enabled,
|
|
289
|
+
feature_overrides=self._thread_options.feature_overrides,
|
|
290
|
+
approval_policy=self._thread_options.approval_policy,
|
|
291
|
+
config_overrides=merge_config_overrides(
|
|
292
|
+
self._options.config_overrides,
|
|
293
|
+
self._thread_options.config_overrides,
|
|
294
|
+
),
|
|
295
|
+
signal=turn_options.signal,
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
async for line in self._exec.run(args):
|
|
299
|
+
try:
|
|
300
|
+
parsed = json.loads(line)
|
|
301
|
+
event = self._parse_event(parsed)
|
|
302
|
+
if event.type == "thread.started":
|
|
303
|
+
self._id = event.thread_id
|
|
304
|
+
yield event
|
|
305
|
+
except json.JSONDecodeError as e:
|
|
306
|
+
raise CodexParseError(f"Failed to parse item: {line}") from e
|
|
307
|
+
finally:
|
|
308
|
+
cleanup_result = cleanup()
|
|
309
|
+
if inspect.isawaitable(cleanup_result):
|
|
310
|
+
await cleanup_result
|
|
311
|
+
|
|
312
|
+
def _parse_event(self, data: dict) -> ThreadEvent:
|
|
313
|
+
"""Parse a JSON event into the appropriate ThreadEvent type."""
|
|
314
|
+
from .events import (
|
|
315
|
+
ItemCompletedEvent,
|
|
316
|
+
ItemStartedEvent,
|
|
317
|
+
ItemUpdatedEvent,
|
|
318
|
+
ThreadError,
|
|
319
|
+
ThreadErrorEvent,
|
|
320
|
+
ThreadStartedEvent,
|
|
321
|
+
TurnCompletedEvent,
|
|
322
|
+
TurnFailedEvent,
|
|
323
|
+
TurnStartedEvent,
|
|
324
|
+
Usage,
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
event_type = data.get("type")
|
|
328
|
+
|
|
329
|
+
if event_type == "thread.started":
|
|
330
|
+
return ThreadStartedEvent(
|
|
331
|
+
type="thread.started", thread_id=data["thread_id"]
|
|
332
|
+
)
|
|
333
|
+
elif event_type == "turn.started":
|
|
334
|
+
return TurnStartedEvent(type="turn.started")
|
|
335
|
+
elif event_type == "turn.completed":
|
|
336
|
+
usage_data = data["usage"]
|
|
337
|
+
usage = Usage(
|
|
338
|
+
input_tokens=usage_data["input_tokens"],
|
|
339
|
+
cached_input_tokens=usage_data["cached_input_tokens"],
|
|
340
|
+
output_tokens=usage_data["output_tokens"],
|
|
341
|
+
)
|
|
342
|
+
return TurnCompletedEvent(type="turn.completed", usage=usage)
|
|
343
|
+
elif event_type == "turn.failed":
|
|
344
|
+
error_data = data["error"]
|
|
345
|
+
error = ThreadError(message=error_data["message"])
|
|
346
|
+
return TurnFailedEvent(type="turn.failed", error=error)
|
|
347
|
+
elif event_type == "item.started":
|
|
348
|
+
return ItemStartedEvent(
|
|
349
|
+
type="item.started", item=self._parse_item(data["item"])
|
|
350
|
+
)
|
|
351
|
+
elif event_type == "item.updated":
|
|
352
|
+
return ItemUpdatedEvent(
|
|
353
|
+
type="item.updated", item=self._parse_item(data["item"])
|
|
354
|
+
)
|
|
355
|
+
elif event_type == "item.completed":
|
|
356
|
+
return ItemCompletedEvent(
|
|
357
|
+
type="item.completed", item=self._parse_item(data["item"])
|
|
358
|
+
)
|
|
359
|
+
elif event_type == "error":
|
|
360
|
+
return ThreadErrorEvent(type="error", message=data["message"])
|
|
361
|
+
else:
|
|
362
|
+
raise CodexParseError(f"Unknown event type: {event_type}")
|
|
363
|
+
|
|
364
|
+
def _parse_item(self, data: dict) -> ThreadItem:
|
|
365
|
+
"""Parse a JSON item into the appropriate ThreadItem type."""
|
|
366
|
+
from .items import (
|
|
367
|
+
AgentMessageItem,
|
|
368
|
+
CommandExecutionItem,
|
|
369
|
+
ErrorItem,
|
|
370
|
+
FileChangeItem,
|
|
371
|
+
FileUpdateChange,
|
|
372
|
+
McpToolCallItem,
|
|
373
|
+
McpToolCallItemError,
|
|
374
|
+
McpToolCallItemResult,
|
|
375
|
+
ReasoningItem,
|
|
376
|
+
TodoItem,
|
|
377
|
+
TodoListItem,
|
|
378
|
+
WebSearchItem,
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
item_type = data.get("type")
|
|
382
|
+
|
|
383
|
+
if item_type == "agent_message":
|
|
384
|
+
return AgentMessageItem(
|
|
385
|
+
id=data["id"], type="agent_message", text=data["text"]
|
|
386
|
+
)
|
|
387
|
+
elif item_type == "reasoning":
|
|
388
|
+
return ReasoningItem(id=data["id"], type="reasoning", text=data["text"])
|
|
389
|
+
elif item_type == "command_execution":
|
|
390
|
+
return CommandExecutionItem(
|
|
391
|
+
id=data["id"],
|
|
392
|
+
type="command_execution",
|
|
393
|
+
command=data["command"],
|
|
394
|
+
aggregated_output=data["aggregated_output"],
|
|
395
|
+
exit_code=data.get("exit_code"),
|
|
396
|
+
status=data["status"],
|
|
397
|
+
)
|
|
398
|
+
elif item_type == "file_change":
|
|
399
|
+
changes = [
|
|
400
|
+
FileUpdateChange(path=change["path"], kind=change["kind"])
|
|
401
|
+
for change in data["changes"]
|
|
402
|
+
]
|
|
403
|
+
return FileChangeItem(
|
|
404
|
+
id=data["id"],
|
|
405
|
+
type="file_change",
|
|
406
|
+
changes=changes,
|
|
407
|
+
status=data["status"],
|
|
408
|
+
)
|
|
409
|
+
elif item_type == "mcp_tool_call":
|
|
410
|
+
result_data = data.get("result")
|
|
411
|
+
result = None
|
|
412
|
+
if isinstance(result_data, dict):
|
|
413
|
+
content = result_data.get("content", [])
|
|
414
|
+
structured_content = result_data.get("structured_content")
|
|
415
|
+
result = McpToolCallItemResult(
|
|
416
|
+
content=list(content) if isinstance(content, list) else [],
|
|
417
|
+
structured_content=structured_content,
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
error_data = data.get("error")
|
|
421
|
+
error = None
|
|
422
|
+
if isinstance(error_data, dict) and "message" in error_data:
|
|
423
|
+
error = McpToolCallItemError(message=str(error_data["message"]))
|
|
424
|
+
|
|
425
|
+
return McpToolCallItem(
|
|
426
|
+
id=data["id"],
|
|
427
|
+
type="mcp_tool_call",
|
|
428
|
+
server=data["server"],
|
|
429
|
+
tool=data["tool"],
|
|
430
|
+
status=data["status"],
|
|
431
|
+
arguments=data.get("arguments"),
|
|
432
|
+
result=result,
|
|
433
|
+
error=error,
|
|
434
|
+
)
|
|
435
|
+
elif item_type == "web_search":
|
|
436
|
+
return WebSearchItem(id=data["id"], type="web_search", query=data["query"])
|
|
437
|
+
elif item_type == "todo_list":
|
|
438
|
+
items = [
|
|
439
|
+
TodoItem(text=item["text"], completed=item["completed"])
|
|
440
|
+
for item in data["items"]
|
|
441
|
+
]
|
|
442
|
+
return TodoListItem(id=data["id"], type="todo_list", items=items)
|
|
443
|
+
elif item_type == "error":
|
|
444
|
+
return ErrorItem(id=data["id"], type="error", message=data["message"])
|
|
445
|
+
else:
|
|
446
|
+
raise CodexParseError(f"Unknown item type: {item_type}")
|
|
447
|
+
|
|
448
|
+
async def run(
|
|
449
|
+
self, input: Input, turn_options: Optional[TurnOptions] = None
|
|
450
|
+
) -> Turn:
|
|
451
|
+
"""
|
|
452
|
+
Provide the input to the agent and return the completed turn.
|
|
453
|
+
|
|
454
|
+
Args:
|
|
455
|
+
input: Input prompt to send to the agent.
|
|
456
|
+
turn_options: Optional turn configuration.
|
|
457
|
+
|
|
458
|
+
Returns:
|
|
459
|
+
The completed turn containing items, the final agent message, and usage data.
|
|
460
|
+
|
|
461
|
+
Raises:
|
|
462
|
+
TurnFailedError: If the turn ends with a failure event.
|
|
463
|
+
CodexParseError: If stream output cannot be parsed.
|
|
464
|
+
CodexError: Propagated errors from the Codex CLI invocation.
|
|
465
|
+
"""
|
|
466
|
+
if turn_options is None:
|
|
467
|
+
turn_options = TurnOptions()
|
|
468
|
+
|
|
469
|
+
items: List[ThreadItem] = []
|
|
470
|
+
final_response: str = ""
|
|
471
|
+
usage: Optional[Usage] = None
|
|
472
|
+
turn_failure: Optional[ThreadError] = None
|
|
473
|
+
|
|
474
|
+
async for event in self._run_streamed_internal(input, turn_options):
|
|
475
|
+
if event.type == "item.completed":
|
|
476
|
+
if event.item.type == "agent_message":
|
|
477
|
+
final_response = event.item.text
|
|
478
|
+
items.append(event.item)
|
|
479
|
+
elif event.type == "turn.completed":
|
|
480
|
+
usage = event.usage
|
|
481
|
+
elif event.type == "turn.failed":
|
|
482
|
+
turn_failure = event.error
|
|
483
|
+
break
|
|
484
|
+
|
|
485
|
+
if turn_failure:
|
|
486
|
+
raise TurnFailedError(turn_failure.message, error=turn_failure)
|
|
487
|
+
|
|
488
|
+
return Turn(items=items, final_response=final_response, usage=usage)
|
|
489
|
+
|
|
490
|
+
async def run_with_hooks(
|
|
491
|
+
self,
|
|
492
|
+
input: Input,
|
|
493
|
+
*,
|
|
494
|
+
hooks: ThreadHooks,
|
|
495
|
+
turn_options: Optional[TurnOptions] = None,
|
|
496
|
+
) -> Turn:
|
|
497
|
+
"""
|
|
498
|
+
Run a turn while dispatching streamed events to hooks.
|
|
499
|
+
|
|
500
|
+
Args:
|
|
501
|
+
input: Input prompt to send to the agent.
|
|
502
|
+
hooks: Hook callbacks invoked for streamed events.
|
|
503
|
+
turn_options: Optional turn configuration.
|
|
504
|
+
|
|
505
|
+
Returns:
|
|
506
|
+
The completed turn containing items, the final agent message, and usage data.
|
|
507
|
+
"""
|
|
508
|
+
if turn_options is None:
|
|
509
|
+
turn_options = TurnOptions()
|
|
510
|
+
|
|
511
|
+
items: List[ThreadItem] = []
|
|
512
|
+
final_response: str = ""
|
|
513
|
+
usage: Optional[Usage] = None
|
|
514
|
+
turn_failure: Optional[ThreadError] = None
|
|
515
|
+
|
|
516
|
+
async for event in self._run_streamed_internal(input, turn_options):
|
|
517
|
+
await dispatch_event(hooks, event)
|
|
518
|
+
if event.type == "item.completed":
|
|
519
|
+
if event.item.type == "agent_message":
|
|
520
|
+
final_response = event.item.text
|
|
521
|
+
items.append(event.item)
|
|
522
|
+
elif event.type == "turn.completed":
|
|
523
|
+
usage = event.usage
|
|
524
|
+
elif event.type == "turn.failed":
|
|
525
|
+
turn_failure = event.error
|
|
526
|
+
break
|
|
527
|
+
|
|
528
|
+
if turn_failure:
|
|
529
|
+
raise TurnFailedError(turn_failure.message, error=turn_failure)
|
|
530
|
+
|
|
531
|
+
return Turn(items=items, final_response=final_response, usage=usage)
|
|
532
|
+
|
|
533
|
+
async def run_json(
|
|
534
|
+
self,
|
|
535
|
+
input: Input,
|
|
536
|
+
*,
|
|
537
|
+
output_schema: Mapping[str, Any],
|
|
538
|
+
turn_options: Optional[TurnOptions] = None,
|
|
539
|
+
) -> ParsedTurn[Any]:
|
|
540
|
+
"""
|
|
541
|
+
Run a turn with a JSON schema and parse the final response as JSON.
|
|
542
|
+
"""
|
|
543
|
+
signal = turn_options.signal if turn_options is not None else None
|
|
544
|
+
turn = await self.run(
|
|
545
|
+
input, TurnOptions(output_schema=output_schema, signal=signal)
|
|
546
|
+
)
|
|
547
|
+
try:
|
|
548
|
+
parsed = json.loads(turn.final_response)
|
|
549
|
+
except json.JSONDecodeError as exc:
|
|
550
|
+
raise CodexParseError(
|
|
551
|
+
f"Failed to parse JSON output: {turn.final_response}"
|
|
552
|
+
) from exc
|
|
553
|
+
return ParsedTurn(turn=turn, output=parsed)
|
|
554
|
+
|
|
555
|
+
async def run_pydantic(
|
|
556
|
+
self,
|
|
557
|
+
input: Input,
|
|
558
|
+
*,
|
|
559
|
+
output_model: Any,
|
|
560
|
+
turn_options: Optional[TurnOptions] = None,
|
|
561
|
+
) -> ParsedTurn[Any]:
|
|
562
|
+
"""
|
|
563
|
+
Run a turn with an output schema derived from a Pydantic model and validate the result.
|
|
564
|
+
"""
|
|
565
|
+
try:
|
|
566
|
+
import importlib
|
|
567
|
+
|
|
568
|
+
pydantic = importlib.import_module("pydantic")
|
|
569
|
+
BaseModel = getattr(pydantic, "BaseModel", None)
|
|
570
|
+
if BaseModel is None:
|
|
571
|
+
raise ImportError("pydantic.BaseModel not found")
|
|
572
|
+
except ImportError as exc: # pragma: no cover
|
|
573
|
+
raise CodexError(
|
|
574
|
+
'Pydantic is required for run_pydantic(); install with: uv add "codex-sdk-python[pydantic]"'
|
|
575
|
+
) from exc
|
|
576
|
+
|
|
577
|
+
if not isinstance(output_model, type) or not issubclass(
|
|
578
|
+
output_model, BaseModel
|
|
579
|
+
):
|
|
580
|
+
raise CodexError("output_model must be a Pydantic BaseModel subclass")
|
|
581
|
+
|
|
582
|
+
model_cls: Any = output_model
|
|
583
|
+
schema = model_cls.model_json_schema()
|
|
584
|
+
if isinstance(schema, dict) and "additionalProperties" not in schema:
|
|
585
|
+
schema["additionalProperties"] = False
|
|
586
|
+
|
|
587
|
+
parsed_turn = await self.run_json(
|
|
588
|
+
input, output_schema=schema, turn_options=turn_options
|
|
589
|
+
)
|
|
590
|
+
validated = model_cls.model_validate(parsed_turn.output)
|
|
591
|
+
return ParsedTurn(turn=parsed_turn.turn, output=validated)
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
def normalize_input(input: Input) -> tuple[str, List[str]]:
|
|
595
|
+
if isinstance(input, str):
|
|
596
|
+
return input, []
|
|
597
|
+
|
|
598
|
+
prompt_parts: List[str] = []
|
|
599
|
+
images: List[str] = []
|
|
600
|
+
for item in input:
|
|
601
|
+
if item["type"] == "text":
|
|
602
|
+
prompt_parts.append(item["text"])
|
|
603
|
+
elif item["type"] == "local_image":
|
|
604
|
+
images.append(item["path"])
|
|
605
|
+
|
|
606
|
+
return "\n\n".join(prompt_parts), images
|