codex-sdk-py 0.0.3__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/exec.py ADDED
@@ -0,0 +1,396 @@
1
+ """
2
+ Codex CLI execution management.
3
+
4
+ Corresponds to: src/exec.ts
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import json
11
+ import os
12
+ import platform
13
+ import sys
14
+ from collections.abc import AsyncGenerator
15
+ from dataclasses import dataclass
16
+ from pathlib import Path
17
+ from typing import TYPE_CHECKING, Any
18
+
19
+ if TYPE_CHECKING:
20
+ from .codex_options import CodexConfigObject, CodexConfigValue
21
+ from .thread_options import ApprovalMode, ModelReasoningEffort, SandboxMode, WebSearchMode
22
+
23
+ INTERNAL_ORIGINATOR_ENV = "CODEX_INTERNAL_ORIGINATOR_OVERRIDE"
24
+ PYTHON_SDK_ORIGINATOR = "codex_sdk_py"
25
+
26
+
27
+ @dataclass
28
+ class CodexExecArgs:
29
+ """Arguments for executing the Codex CLI."""
30
+
31
+ input: str
32
+ """The prompt to send to the agent."""
33
+
34
+ base_url: str | None = None
35
+ api_key: str | None = None
36
+ thread_id: str | None = None
37
+ images: list[str] | None = None
38
+
39
+ # --model
40
+ model: str | None = None
41
+ # --sandbox
42
+ sandbox_mode: SandboxMode | None = None
43
+ # --cd
44
+ working_directory: str | None = None
45
+ # --add-dir
46
+ additional_directories: list[str] | None = None
47
+ # --skip-git-repo-check
48
+ skip_git_repo_check: bool | None = None
49
+ # --output-schema
50
+ output_schema_file: str | None = None
51
+ # --config model_reasoning_effort
52
+ model_reasoning_effort: ModelReasoningEffort | None = None
53
+ # Cancel event to cancel the execution
54
+ cancel_event: asyncio.Event | None = None
55
+ # --config sandbox_workspace_write.network_access
56
+ network_access_enabled: bool | None = None
57
+ # --config web_search
58
+ web_search_mode: WebSearchMode | None = None
59
+ # legacy --config features.web_search_request
60
+ web_search_enabled: bool | None = None
61
+ # --config approval_policy
62
+ approval_policy: ApprovalMode | None = None
63
+
64
+
65
+ class CodexExec:
66
+ """
67
+ Internal class that spawns and manages the Codex CLI process.
68
+
69
+ Handles subprocess lifecycle, environment setup, and JSONL streaming.
70
+ """
71
+
72
+ def __init__(
73
+ self,
74
+ executable_path: str | None = None,
75
+ env: dict[str, str] | None = None,
76
+ config_overrides: CodexConfigObject | None = None,
77
+ ) -> None:
78
+ """
79
+ Initialize the CodexExec instance.
80
+
81
+ Args:
82
+ executable_path: Custom path to the Codex CLI binary.
83
+ env: Environment variables to pass to the subprocess.
84
+ config_overrides: Additional --config overrides.
85
+ """
86
+ self._executable_path = executable_path or _find_codex_path()
87
+ self._env_override = env
88
+ self._config_overrides = config_overrides
89
+
90
+ async def run(self, args: CodexExecArgs) -> AsyncGenerator[str, None]:
91
+ """
92
+ Execute the Codex CLI and yield JSONL lines from stdout.
93
+
94
+ Args:
95
+ args: Execution arguments including prompt and options.
96
+
97
+ Yields:
98
+ JSONL lines from the CLI stdout.
99
+
100
+ Raises:
101
+ RuntimeError: If the CLI process fails.
102
+ asyncio.CancelledError: If cancelled via cancel_event.
103
+ """
104
+ command_args = self._build_command_args(args)
105
+ env = self._build_env(args)
106
+
107
+ process = await asyncio.create_subprocess_exec(
108
+ self._executable_path,
109
+ *command_args,
110
+ stdin=asyncio.subprocess.PIPE,
111
+ stdout=asyncio.subprocess.PIPE,
112
+ stderr=asyncio.subprocess.PIPE,
113
+ env=env,
114
+ )
115
+
116
+ stderr_chunks: list[bytes] = []
117
+
118
+ async def read_stderr() -> None:
119
+ """Read stderr in background."""
120
+ if process.stderr:
121
+ while True:
122
+ chunk = await process.stderr.read(4096)
123
+ if not chunk:
124
+ break
125
+ stderr_chunks.append(chunk)
126
+
127
+ stderr_task = asyncio.create_task(read_stderr())
128
+
129
+ try:
130
+ # Write input to stdin and close
131
+ if process.stdin:
132
+ process.stdin.write(args.input.encode("utf-8"))
133
+ await process.stdin.drain()
134
+ process.stdin.close()
135
+ await process.stdin.wait_closed()
136
+
137
+ # Stream stdout line by line
138
+ if process.stdout:
139
+ buffer = b""
140
+ while True:
141
+ # Check for cancellation
142
+ if args.cancel_event and args.cancel_event.is_set():
143
+ process.terminate()
144
+ raise asyncio.CancelledError("Turn cancelled by user")
145
+
146
+ chunk = await process.stdout.read(4096)
147
+ if not chunk:
148
+ # Process remaining buffer
149
+ if buffer:
150
+ yield buffer.decode("utf-8")
151
+ break
152
+
153
+ buffer += chunk
154
+ while b"\n" in buffer:
155
+ line, buffer = buffer.split(b"\n", 1)
156
+ decoded = line.decode("utf-8")
157
+ if decoded:
158
+ yield decoded
159
+
160
+ # Wait for process to complete
161
+ await process.wait()
162
+ await stderr_task
163
+
164
+ # Check for errors
165
+ if process.returncode != 0:
166
+ stderr_output = b"".join(stderr_chunks).decode("utf-8")
167
+ detail = f"code {process.returncode}"
168
+ raise RuntimeError(f"Codex Exec exited with {detail}: {stderr_output}")
169
+
170
+ finally:
171
+ stderr_task.cancel()
172
+ try:
173
+ await stderr_task
174
+ except asyncio.CancelledError:
175
+ pass
176
+
177
+ if process.returncode is None:
178
+ process.terminate()
179
+ try:
180
+ await asyncio.wait_for(process.wait(), timeout=5.0)
181
+ except asyncio.TimeoutError:
182
+ process.kill()
183
+ await process.wait()
184
+
185
+ def _build_command_args(self, args: CodexExecArgs) -> list[str]:
186
+ """Build command line arguments for the CLI."""
187
+ command_args: list[str] = ["exec", "--experimental-json"]
188
+
189
+ # Add config overrides
190
+ if self._config_overrides:
191
+ for override in _serialize_config_overrides(self._config_overrides):
192
+ command_args.extend(["--config", override])
193
+
194
+ if args.model:
195
+ command_args.extend(["--model", args.model])
196
+
197
+ if args.sandbox_mode:
198
+ command_args.extend(["--sandbox", args.sandbox_mode])
199
+
200
+ if args.working_directory:
201
+ command_args.extend(["--cd", args.working_directory])
202
+
203
+ if args.additional_directories:
204
+ for dir_path in args.additional_directories:
205
+ command_args.extend(["--add-dir", dir_path])
206
+
207
+ if args.skip_git_repo_check:
208
+ command_args.append("--skip-git-repo-check")
209
+
210
+ if args.output_schema_file:
211
+ command_args.extend(["--output-schema", args.output_schema_file])
212
+
213
+ if args.model_reasoning_effort:
214
+ command_args.extend(
215
+ ["--config", f'model_reasoning_effort="{args.model_reasoning_effort}"']
216
+ )
217
+
218
+ if args.network_access_enabled is not None:
219
+ value = "true" if args.network_access_enabled else "false"
220
+ command_args.extend(
221
+ ["--config", f"sandbox_workspace_write.network_access={value}"]
222
+ )
223
+
224
+ if args.web_search_mode:
225
+ command_args.extend(["--config", f'web_search="{args.web_search_mode}"'])
226
+ elif args.web_search_enabled is True:
227
+ command_args.extend(["--config", 'web_search="live"'])
228
+ elif args.web_search_enabled is False:
229
+ command_args.extend(["--config", 'web_search="disabled"'])
230
+
231
+ if args.approval_policy:
232
+ command_args.extend(["--config", f'approval_policy="{args.approval_policy}"'])
233
+
234
+ if args.images:
235
+ for image in args.images:
236
+ command_args.extend(["--image", image])
237
+
238
+ if args.thread_id:
239
+ command_args.extend(["resume", args.thread_id])
240
+
241
+ return command_args
242
+
243
+ def _build_env(self, args: CodexExecArgs) -> dict[str, str]:
244
+ """Build environment variables for the subprocess."""
245
+ env: dict[str, str] = {}
246
+
247
+ if self._env_override is not None:
248
+ env.update(self._env_override)
249
+ else:
250
+ for key, value in os.environ.items():
251
+ if value is not None:
252
+ env[key] = value
253
+
254
+ if INTERNAL_ORIGINATOR_ENV not in env:
255
+ env[INTERNAL_ORIGINATOR_ENV] = PYTHON_SDK_ORIGINATOR
256
+
257
+ if args.base_url:
258
+ env["OPENAI_BASE_URL"] = args.base_url
259
+
260
+ if args.api_key:
261
+ env["CODEX_API_KEY"] = args.api_key
262
+
263
+ return env
264
+
265
+
266
+ def _serialize_config_overrides(config_overrides: CodexConfigObject) -> list[str]:
267
+ """Serialize config overrides to CLI format."""
268
+ overrides: list[str] = []
269
+ _flatten_config_overrides(config_overrides, "", overrides)
270
+ return overrides
271
+
272
+
273
+ def _flatten_config_overrides(
274
+ value: CodexConfigValue,
275
+ prefix: str,
276
+ overrides: list[str],
277
+ ) -> None:
278
+ """Flatten nested config dict to dotted paths."""
279
+ if not _is_plain_object(value):
280
+ if prefix:
281
+ overrides.append(f"{prefix}={_to_toml_value(value, prefix)}")
282
+ return
283
+ else:
284
+ raise ValueError("Codex config overrides must be a plain object")
285
+
286
+ entries = list(value.items())
287
+ if not prefix and len(entries) == 0:
288
+ return
289
+
290
+ if prefix and len(entries) == 0:
291
+ overrides.append(f"{prefix}={{}}")
292
+ return
293
+
294
+ for key, child in entries:
295
+ if not key:
296
+ raise ValueError("Codex config override keys must be non-empty strings")
297
+ if child is None:
298
+ continue
299
+ path = f"{prefix}.{key}" if prefix else key
300
+ if _is_plain_object(child):
301
+ _flatten_config_overrides(child, path, overrides)
302
+ else:
303
+ overrides.append(f"{path}={_to_toml_value(child, path)}")
304
+
305
+
306
+ def _to_toml_value(value: CodexConfigValue, path: str) -> str:
307
+ """Serialize a value as a TOML literal."""
308
+ if isinstance(value, str):
309
+ return json.dumps(value)
310
+ elif isinstance(value, bool):
311
+ return "true" if value else "false"
312
+ elif isinstance(value, (int, float)):
313
+ is_finite = isinstance(value, int) or (
314
+ isinstance(value, float)
315
+ and value != float("inf")
316
+ and value != float("-inf")
317
+ and value == value # NaN check
318
+ )
319
+ if not is_finite:
320
+ raise ValueError(f"Codex config override at {path} must be a finite number")
321
+ return str(value)
322
+ elif isinstance(value, list):
323
+ rendered = [_to_toml_value(item, f"{path}[{i}]") for i, item in enumerate(value)]
324
+ return f"[{', '.join(rendered)}]"
325
+ elif _is_plain_object(value):
326
+ parts: list[str] = []
327
+ for key, child in value.items():
328
+ if not key:
329
+ raise ValueError("Codex config override keys must be non-empty strings")
330
+ if child is None:
331
+ continue
332
+ parts.append(f"{_format_toml_key(key)} = {_to_toml_value(child, f'{path}.{key}')}")
333
+ return "{" + ", ".join(parts) + "}"
334
+ elif value is None:
335
+ raise ValueError(f"Codex config override at {path} cannot be null")
336
+ else:
337
+ type_name = type(value).__name__
338
+ raise ValueError(f"Unsupported Codex config override value at {path}: {type_name}")
339
+
340
+
341
+ TOML_BARE_KEY_PATTERN = set("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-")
342
+
343
+
344
+ def _format_toml_key(key: str) -> str:
345
+ """Format a key for TOML syntax."""
346
+ if key and all(c in TOML_BARE_KEY_PATTERN for c in key):
347
+ return key
348
+ return json.dumps(key)
349
+
350
+
351
+ def _is_plain_object(value: Any) -> bool:
352
+ """Check if value is a plain object (dict)."""
353
+ return isinstance(value, dict)
354
+
355
+
356
+ def _find_codex_path() -> str:
357
+ """Find the Codex binary path for the current platform."""
358
+ system = platform.system().lower()
359
+ machine = platform.machine().lower()
360
+
361
+ target_triple: str | None = None
362
+
363
+ if system in ("linux", "android"):
364
+ if machine in ("x86_64", "amd64"):
365
+ target_triple = "x86_64-unknown-linux-musl"
366
+ elif machine in ("arm64", "aarch64"):
367
+ target_triple = "aarch64-unknown-linux-musl"
368
+
369
+ elif system == "darwin":
370
+ if machine in ("x86_64", "amd64"):
371
+ target_triple = "x86_64-apple-darwin"
372
+ elif machine in ("arm64", "aarch64"):
373
+ target_triple = "aarch64-apple-darwin"
374
+
375
+ elif system == "win32" or system == "windows":
376
+ if machine in ("x86_64", "amd64"):
377
+ target_triple = "x86_64-pc-windows-msvc"
378
+ elif machine in ("arm64", "aarch64"):
379
+ target_triple = "aarch64-pc-windows-msvc"
380
+
381
+ if not target_triple:
382
+ raise RuntimeError(f"Unsupported platform: {system} ({machine})")
383
+
384
+ # Try to find bundled binary
385
+ script_dir = Path(__file__).parent
386
+ vendor_root = script_dir / "vendor"
387
+ arch_root = vendor_root / target_triple
388
+
389
+ binary_name = "codex.exe" if sys.platform == "win32" else "codex"
390
+ binary_path = arch_root / "codex" / binary_name
391
+
392
+ if binary_path.exists():
393
+ return str(binary_path)
394
+
395
+ # Fall back to PATH
396
+ return "codex"
codex_sdk/items.py ADDED
@@ -0,0 +1,175 @@
1
+ """
2
+ Thread item type definitions.
3
+
4
+ Based on item types from codex-rs/exec/src/exec_events.rs
5
+
6
+ Corresponds to: src/items.ts
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any, Literal, NotRequired, TypedDict
12
+
13
+ # Status types
14
+ CommandExecutionStatus = Literal["in_progress", "completed", "failed"]
15
+ """The status of a command execution."""
16
+
17
+ PatchChangeKind = Literal["add", "delete", "update"]
18
+ """Indicates the type of the file change."""
19
+
20
+ PatchApplyStatus = Literal["completed", "failed"]
21
+ """The status of a file change."""
22
+
23
+ McpToolCallStatus = Literal["in_progress", "completed", "failed"]
24
+ """The status of an MCP tool call."""
25
+
26
+
27
+ # MCP-related types
28
+ class McpContentBlock(TypedDict, total=False):
29
+ """Content block from MCP tool result."""
30
+
31
+ type: str
32
+ text: str
33
+ data: str
34
+ mime_type: str
35
+
36
+
37
+ class McpToolResult(TypedDict):
38
+ """Result payload returned by the MCP server for successful calls."""
39
+
40
+ content: list[McpContentBlock]
41
+ structured_content: Any
42
+
43
+
44
+ class McpToolError(TypedDict):
45
+ """Error message reported for failed calls."""
46
+
47
+ message: str
48
+
49
+
50
+ # Item types
51
+ class CommandExecutionItem(TypedDict):
52
+ """A command executed by the agent."""
53
+
54
+ id: str
55
+ type: Literal["command_execution"]
56
+ command: str
57
+ """The command line executed by the agent."""
58
+ aggregated_output: str
59
+ """Aggregated stdout and stderr captured while the command was running."""
60
+ status: CommandExecutionStatus
61
+ """Current status of the command execution."""
62
+ exit_code: NotRequired[int]
63
+ """Set when the command exits; omitted while still running."""
64
+
65
+
66
+ class FileUpdateChange(TypedDict):
67
+ """A set of file changes by the agent."""
68
+
69
+ path: str
70
+ kind: PatchChangeKind
71
+
72
+
73
+ class FileChangeItem(TypedDict):
74
+ """A set of file changes by the agent. Emitted once the patch succeeds or fails."""
75
+
76
+ id: str
77
+ type: Literal["file_change"]
78
+ changes: list[FileUpdateChange]
79
+ """Individual file changes that comprise the patch."""
80
+ status: PatchApplyStatus
81
+ """Whether the patch ultimately succeeded or failed."""
82
+
83
+
84
+ class McpToolCallItem(TypedDict):
85
+ """
86
+ Represents a call to an MCP tool.
87
+
88
+ The item starts when the invocation is dispatched and completes
89
+ when the MCP server reports success or failure.
90
+ """
91
+
92
+ id: str
93
+ type: Literal["mcp_tool_call"]
94
+ server: str
95
+ """Name of the MCP server handling the request."""
96
+ tool: str
97
+ """The tool invoked on the MCP server."""
98
+ arguments: Any
99
+ """Arguments forwarded to the tool invocation."""
100
+ status: McpToolCallStatus
101
+ """Current status of the tool invocation."""
102
+ result: NotRequired[McpToolResult]
103
+ """Result payload returned by the MCP server for successful calls."""
104
+ error: NotRequired[McpToolError]
105
+ """Error message reported for failed calls."""
106
+
107
+
108
+ class AgentMessageItem(TypedDict):
109
+ """
110
+ Response from the agent.
111
+
112
+ Either natural-language text or JSON when structured output is requested.
113
+ """
114
+
115
+ id: str
116
+ type: Literal["agent_message"]
117
+ text: str
118
+ """Either natural-language text or JSON when structured output is requested."""
119
+
120
+
121
+ class ReasoningItem(TypedDict):
122
+ """Agent's reasoning summary."""
123
+
124
+ id: str
125
+ type: Literal["reasoning"]
126
+ text: str
127
+
128
+
129
+ class WebSearchItem(TypedDict):
130
+ """Captures a web search request. Completes when results are returned to the agent."""
131
+
132
+ id: str
133
+ type: Literal["web_search"]
134
+ query: str
135
+
136
+
137
+ class ErrorItem(TypedDict):
138
+ """Describes a non-fatal error surfaced as an item."""
139
+
140
+ id: str
141
+ type: Literal["error"]
142
+ message: str
143
+
144
+
145
+ class TodoItem(TypedDict):
146
+ """An item in the agent's to-do list."""
147
+
148
+ text: str
149
+ completed: bool
150
+
151
+
152
+ class TodoListItem(TypedDict):
153
+ """
154
+ Tracks the agent's running to-do list.
155
+
156
+ Starts when the plan is issued, updates as steps change,
157
+ and completes when the turn ends.
158
+ """
159
+
160
+ id: str
161
+ type: Literal["todo_list"]
162
+ items: list[TodoItem]
163
+
164
+
165
+ # Canonical union of thread items and their type-specific payloads
166
+ ThreadItem = (
167
+ AgentMessageItem
168
+ | ReasoningItem
169
+ | CommandExecutionItem
170
+ | FileChangeItem
171
+ | McpToolCallItem
172
+ | WebSearchItem
173
+ | TodoListItem
174
+ | ErrorItem
175
+ )
@@ -0,0 +1,74 @@
1
+ """
2
+ Output schema file utilities.
3
+
4
+ Corresponds to: src/outputSchemaFile.ts
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import shutil
11
+ import tempfile
12
+ from collections.abc import Awaitable, Callable
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+
18
+ @dataclass
19
+ class OutputSchemaFile:
20
+ """Result of creating an output schema file."""
21
+
22
+ schema_path: str | None
23
+ """Path to the temporary schema file, or None if no schema was provided."""
24
+
25
+ cleanup: Callable[[], Awaitable[None]]
26
+ """Async function to clean up the temporary file."""
27
+
28
+
29
+ def _is_json_object(value: Any) -> bool:
30
+ """Check if value is a plain JSON object (dict)."""
31
+ return isinstance(value, dict)
32
+
33
+
34
+ async def create_output_schema_file(schema: Any | None) -> OutputSchemaFile:
35
+ """
36
+ Create a temporary file containing the JSON schema.
37
+
38
+ Args:
39
+ schema: The JSON schema to write, or None if no schema is needed.
40
+
41
+ Returns:
42
+ OutputSchemaFile with the path and cleanup function.
43
+
44
+ Raises:
45
+ ValueError: If schema is not a plain JSON object.
46
+ """
47
+
48
+ async def noop_cleanup() -> None:
49
+ pass
50
+
51
+ if schema is None:
52
+ return OutputSchemaFile(schema_path=None, cleanup=noop_cleanup)
53
+
54
+ if not _is_json_object(schema):
55
+ raise ValueError("output_schema must be a plain JSON object")
56
+
57
+ # Create temporary directory
58
+ schema_dir = Path(tempfile.mkdtemp(prefix="codex-output-schema-"))
59
+ schema_path = schema_dir / "schema.json"
60
+
61
+ async def cleanup() -> None:
62
+ try:
63
+ shutil.rmtree(schema_dir, ignore_errors=True)
64
+ except Exception:
65
+ # suppress errors during cleanup
66
+ pass
67
+
68
+ try:
69
+ # Write schema to file
70
+ schema_path.write_text(json.dumps(schema), encoding="utf-8")
71
+ return OutputSchemaFile(schema_path=str(schema_path), cleanup=cleanup)
72
+ except Exception:
73
+ await cleanup()
74
+ raise
codex_sdk/py.typed ADDED
File without changes