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/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