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/app_server.py
ADDED
|
@@ -0,0 +1,918 @@
|
|
|
1
|
+
"""Async client for the Codex app-server (JSON-RPC over stdio)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import contextlib
|
|
7
|
+
import json
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import (
|
|
11
|
+
Any,
|
|
12
|
+
AsyncGenerator,
|
|
13
|
+
Dict,
|
|
14
|
+
List,
|
|
15
|
+
Mapping,
|
|
16
|
+
Optional,
|
|
17
|
+
Sequence,
|
|
18
|
+
TypedDict,
|
|
19
|
+
Union,
|
|
20
|
+
cast,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
from .config_overrides import ConfigOverrides, encode_config_overrides
|
|
24
|
+
from .exceptions import CodexAppServerError, CodexError, CodexParseError
|
|
25
|
+
from .exec import INTERNAL_ORIGINATOR_ENV, PYTHON_SDK_ORIGINATOR
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class AppServerClientInfo:
|
|
30
|
+
"""Metadata identifying the client for app-server initialize."""
|
|
31
|
+
|
|
32
|
+
name: str
|
|
33
|
+
title: str
|
|
34
|
+
version: str
|
|
35
|
+
|
|
36
|
+
def as_dict(self) -> Dict[str, Any]:
|
|
37
|
+
return {
|
|
38
|
+
"name": self.name,
|
|
39
|
+
"title": self.title,
|
|
40
|
+
"version": self.version,
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class AppServerOptions:
|
|
46
|
+
"""Options for configuring the app-server client."""
|
|
47
|
+
|
|
48
|
+
codex_path_override: Optional[str] = None
|
|
49
|
+
base_url: Optional[str] = None
|
|
50
|
+
api_key: Optional[str] = None
|
|
51
|
+
env: Optional[Mapping[str, str]] = None
|
|
52
|
+
config_overrides: Optional[ConfigOverrides] = None
|
|
53
|
+
client_info: Optional[AppServerClientInfo] = None
|
|
54
|
+
auto_initialize: bool = True
|
|
55
|
+
request_timeout: Optional[float] = None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class AppServerNotification:
|
|
60
|
+
"""Represents a JSON-RPC notification from the app-server."""
|
|
61
|
+
|
|
62
|
+
method: str
|
|
63
|
+
params: Optional[Dict[str, Any]]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass
|
|
67
|
+
class AppServerRequest:
|
|
68
|
+
"""Represents a JSON-RPC request sent from the app-server to the client."""
|
|
69
|
+
|
|
70
|
+
id: Any
|
|
71
|
+
method: str
|
|
72
|
+
params: Optional[Dict[str, Any]]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass
|
|
76
|
+
class ApprovalDecisions:
|
|
77
|
+
"""Default decisions for app-server approval requests."""
|
|
78
|
+
|
|
79
|
+
command_execution: Optional[Union[str, Mapping[str, Any]]] = None
|
|
80
|
+
file_change: Optional[Union[str, Mapping[str, Any]]] = None
|
|
81
|
+
execpolicy_amendment: Optional[Mapping[str, Any]] = None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class AppServerTurnSession:
|
|
85
|
+
"""Wrapper around a running turn that streams notifications and handles approvals."""
|
|
86
|
+
|
|
87
|
+
def __init__(
|
|
88
|
+
self,
|
|
89
|
+
client: "AppServerClient",
|
|
90
|
+
*,
|
|
91
|
+
thread_id: str,
|
|
92
|
+
turn_id: str,
|
|
93
|
+
approvals: Optional[ApprovalDecisions] = None,
|
|
94
|
+
initial_turn: Optional[Dict[str, Any]] = None,
|
|
95
|
+
) -> None:
|
|
96
|
+
self._client = client
|
|
97
|
+
self.thread_id = thread_id
|
|
98
|
+
self.turn_id = turn_id
|
|
99
|
+
self.initial_turn = initial_turn
|
|
100
|
+
self.final_turn: Optional[Dict[str, Any]] = None
|
|
101
|
+
self._approvals = approvals
|
|
102
|
+
self._notifications: asyncio.Queue[Optional[AppServerNotification]] = (
|
|
103
|
+
asyncio.Queue()
|
|
104
|
+
)
|
|
105
|
+
self._requests: asyncio.Queue[Optional[AppServerRequest]] = asyncio.Queue()
|
|
106
|
+
self._task: Optional[asyncio.Task[None]] = None
|
|
107
|
+
self._done = asyncio.Event()
|
|
108
|
+
self._closed = False
|
|
109
|
+
|
|
110
|
+
async def __aenter__(self) -> "AppServerTurnSession":
|
|
111
|
+
await self.start()
|
|
112
|
+
return self
|
|
113
|
+
|
|
114
|
+
async def __aexit__(
|
|
115
|
+
self,
|
|
116
|
+
exc_type: Optional[type[BaseException]],
|
|
117
|
+
exc: Optional[BaseException],
|
|
118
|
+
tb: Optional[Any],
|
|
119
|
+
) -> None:
|
|
120
|
+
await self.close()
|
|
121
|
+
|
|
122
|
+
async def start(self) -> None:
|
|
123
|
+
if self._task is not None:
|
|
124
|
+
return
|
|
125
|
+
self._task = asyncio.create_task(self._pump())
|
|
126
|
+
|
|
127
|
+
async def close(self) -> None:
|
|
128
|
+
if self._closed:
|
|
129
|
+
return
|
|
130
|
+
self._closed = True
|
|
131
|
+
if self._task and not self._task.done():
|
|
132
|
+
self._task.cancel()
|
|
133
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
134
|
+
await self._task
|
|
135
|
+
await self._notifications.put(None)
|
|
136
|
+
await self._requests.put(None)
|
|
137
|
+
self._done.set()
|
|
138
|
+
|
|
139
|
+
async def wait(self) -> Optional[Dict[str, Any]]:
|
|
140
|
+
await self.start()
|
|
141
|
+
await self._done.wait()
|
|
142
|
+
return self.final_turn
|
|
143
|
+
|
|
144
|
+
async def notifications(self) -> AsyncGenerator[AppServerNotification, None]:
|
|
145
|
+
await self.start()
|
|
146
|
+
while True:
|
|
147
|
+
item = await self._notifications.get()
|
|
148
|
+
if item is None:
|
|
149
|
+
break
|
|
150
|
+
yield item
|
|
151
|
+
|
|
152
|
+
async def next_notification(self) -> Optional[AppServerNotification]:
|
|
153
|
+
await self.start()
|
|
154
|
+
return await self._notifications.get()
|
|
155
|
+
|
|
156
|
+
async def requests(self) -> AsyncGenerator[AppServerRequest, None]:
|
|
157
|
+
await self.start()
|
|
158
|
+
while True:
|
|
159
|
+
item = await self._requests.get()
|
|
160
|
+
if item is None:
|
|
161
|
+
break
|
|
162
|
+
yield item
|
|
163
|
+
|
|
164
|
+
async def next_request(self) -> Optional[AppServerRequest]:
|
|
165
|
+
await self.start()
|
|
166
|
+
return await self._requests.get()
|
|
167
|
+
|
|
168
|
+
async def _pump(self) -> None:
|
|
169
|
+
try:
|
|
170
|
+
while True:
|
|
171
|
+
notification_task = asyncio.create_task(
|
|
172
|
+
self._client.next_notification()
|
|
173
|
+
)
|
|
174
|
+
request_task = asyncio.create_task(self._client.next_request())
|
|
175
|
+
done, pending = await asyncio.wait(
|
|
176
|
+
{notification_task, request_task},
|
|
177
|
+
return_when=asyncio.FIRST_COMPLETED,
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
for task in pending:
|
|
181
|
+
task.cancel()
|
|
182
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
183
|
+
await task
|
|
184
|
+
|
|
185
|
+
should_exit = False
|
|
186
|
+
for task in done:
|
|
187
|
+
item = task.result()
|
|
188
|
+
if item is None:
|
|
189
|
+
should_exit = True
|
|
190
|
+
continue
|
|
191
|
+
if isinstance(item, AppServerNotification):
|
|
192
|
+
await self._notifications.put(item)
|
|
193
|
+
if self._is_turn_completed(item):
|
|
194
|
+
self.final_turn = _extract_turn(item)
|
|
195
|
+
should_exit = True
|
|
196
|
+
else:
|
|
197
|
+
handled = await self._handle_request(item)
|
|
198
|
+
if not handled:
|
|
199
|
+
await self._requests.put(item)
|
|
200
|
+
|
|
201
|
+
if should_exit:
|
|
202
|
+
await self._drain_pending_requests()
|
|
203
|
+
return
|
|
204
|
+
finally:
|
|
205
|
+
self._done.set()
|
|
206
|
+
await self._notifications.put(None)
|
|
207
|
+
await self._requests.put(None)
|
|
208
|
+
|
|
209
|
+
async def _drain_pending_requests(self) -> None:
|
|
210
|
+
while True:
|
|
211
|
+
try:
|
|
212
|
+
request = await asyncio.wait_for(
|
|
213
|
+
self._client.next_request(), timeout=0.01
|
|
214
|
+
)
|
|
215
|
+
except asyncio.TimeoutError:
|
|
216
|
+
break
|
|
217
|
+
if request is None:
|
|
218
|
+
break
|
|
219
|
+
handled = await self._handle_request(request)
|
|
220
|
+
if not handled:
|
|
221
|
+
await self._requests.put(request)
|
|
222
|
+
|
|
223
|
+
async def _handle_request(self, request: AppServerRequest) -> bool:
|
|
224
|
+
if self._approvals is None:
|
|
225
|
+
return False
|
|
226
|
+
|
|
227
|
+
if not self._matches_turn(request.params):
|
|
228
|
+
return False
|
|
229
|
+
|
|
230
|
+
if request.method == "item/commandExecution/requestApproval":
|
|
231
|
+
decision = self._approvals.command_execution
|
|
232
|
+
if decision is None:
|
|
233
|
+
return False
|
|
234
|
+
payload = {
|
|
235
|
+
"decision": _normalize_decision(
|
|
236
|
+
decision, self._approvals.execpolicy_amendment
|
|
237
|
+
)
|
|
238
|
+
}
|
|
239
|
+
await self._client.respond(request.id, payload)
|
|
240
|
+
return True
|
|
241
|
+
|
|
242
|
+
if request.method == "item/fileChange/requestApproval":
|
|
243
|
+
decision = self._approvals.file_change
|
|
244
|
+
if decision is None:
|
|
245
|
+
return False
|
|
246
|
+
payload = {"decision": _normalize_decision(decision, None)}
|
|
247
|
+
await self._client.respond(request.id, payload)
|
|
248
|
+
return True
|
|
249
|
+
|
|
250
|
+
return False
|
|
251
|
+
|
|
252
|
+
def _matches_turn(self, params: Optional[Dict[str, Any]]) -> bool:
|
|
253
|
+
if params is None:
|
|
254
|
+
return True
|
|
255
|
+
thread_id = params.get("threadId") or params.get("thread_id")
|
|
256
|
+
turn_id = params.get("turnId") or params.get("turn_id")
|
|
257
|
+
if thread_id is not None and thread_id != self.thread_id:
|
|
258
|
+
return False
|
|
259
|
+
if turn_id is not None and turn_id != self.turn_id:
|
|
260
|
+
return False
|
|
261
|
+
return True
|
|
262
|
+
|
|
263
|
+
def _is_turn_completed(self, notification: AppServerNotification) -> bool:
|
|
264
|
+
if notification.method != "turn/completed":
|
|
265
|
+
return False
|
|
266
|
+
turn = _extract_turn(notification)
|
|
267
|
+
if not turn:
|
|
268
|
+
return False
|
|
269
|
+
turn_id = turn.get("id") if isinstance(turn, dict) else None
|
|
270
|
+
return isinstance(turn_id, str) and turn_id == self.turn_id
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
class AppServerClient:
|
|
274
|
+
"""Async client for the Codex app-server."""
|
|
275
|
+
|
|
276
|
+
def __init__(self, options: Optional[AppServerOptions] = None):
|
|
277
|
+
if options is None:
|
|
278
|
+
options = AppServerOptions()
|
|
279
|
+
self._options = options
|
|
280
|
+
self._process: Optional[asyncio.subprocess.Process] = None
|
|
281
|
+
self._reader_task: Optional[asyncio.Task[None]] = None
|
|
282
|
+
self._stderr_task: Optional[asyncio.Task[None]] = None
|
|
283
|
+
self._pending: Dict[int, asyncio.Future[Any]] = {}
|
|
284
|
+
self._notifications: asyncio.Queue[Optional[AppServerNotification]] = (
|
|
285
|
+
asyncio.Queue()
|
|
286
|
+
)
|
|
287
|
+
self._requests: asyncio.Queue[Optional[AppServerRequest]] = asyncio.Queue()
|
|
288
|
+
self._next_id = 1
|
|
289
|
+
self._closed = False
|
|
290
|
+
self._reader_error: Optional[BaseException] = None
|
|
291
|
+
self._stderr_chunks: List[str] = []
|
|
292
|
+
|
|
293
|
+
async def __aenter__(self) -> "AppServerClient":
|
|
294
|
+
await self.start()
|
|
295
|
+
return self
|
|
296
|
+
|
|
297
|
+
async def __aexit__(
|
|
298
|
+
self,
|
|
299
|
+
exc_type: Optional[type[BaseException]],
|
|
300
|
+
exc: Optional[BaseException],
|
|
301
|
+
tb: Optional[Any],
|
|
302
|
+
) -> None:
|
|
303
|
+
await self.close()
|
|
304
|
+
|
|
305
|
+
async def start(self) -> None:
|
|
306
|
+
if self._process is not None:
|
|
307
|
+
return
|
|
308
|
+
|
|
309
|
+
executable = self._resolve_executable()
|
|
310
|
+
command_args = ["app-server"]
|
|
311
|
+
if self._options.config_overrides:
|
|
312
|
+
for override in encode_config_overrides(self._options.config_overrides):
|
|
313
|
+
command_args.extend(["--config", override])
|
|
314
|
+
|
|
315
|
+
env = self._build_env()
|
|
316
|
+
|
|
317
|
+
process = await asyncio.create_subprocess_exec(
|
|
318
|
+
executable,
|
|
319
|
+
*command_args,
|
|
320
|
+
stdin=asyncio.subprocess.PIPE,
|
|
321
|
+
stdout=asyncio.subprocess.PIPE,
|
|
322
|
+
stderr=asyncio.subprocess.PIPE,
|
|
323
|
+
env=env,
|
|
324
|
+
)
|
|
325
|
+
if process.stdin is None or process.stdout is None:
|
|
326
|
+
raise CodexError("Codex app-server did not expose stdin/stdout")
|
|
327
|
+
|
|
328
|
+
self._process = process
|
|
329
|
+
self._reader_task = asyncio.create_task(self._reader_loop())
|
|
330
|
+
if process.stderr is not None:
|
|
331
|
+
self._stderr_task = asyncio.create_task(
|
|
332
|
+
_drain_stream(process.stderr, self._stderr_chunks)
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
if self._options.auto_initialize:
|
|
336
|
+
await self.initialize(self._options.client_info)
|
|
337
|
+
|
|
338
|
+
async def close(self) -> None:
|
|
339
|
+
if self._process is None:
|
|
340
|
+
return
|
|
341
|
+
|
|
342
|
+
self._closed = True
|
|
343
|
+
self._fail_all_pending(CodexError("App-server closed"))
|
|
344
|
+
|
|
345
|
+
if self._reader_task and not self._reader_task.done():
|
|
346
|
+
self._reader_task.cancel()
|
|
347
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
348
|
+
await self._reader_task
|
|
349
|
+
|
|
350
|
+
if self._stderr_task and not self._stderr_task.done():
|
|
351
|
+
self._stderr_task.cancel()
|
|
352
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
353
|
+
await self._stderr_task
|
|
354
|
+
|
|
355
|
+
if self._process.returncode is None:
|
|
356
|
+
self._process.terminate()
|
|
357
|
+
try:
|
|
358
|
+
await asyncio.wait_for(self._process.wait(), timeout=5.0)
|
|
359
|
+
except asyncio.TimeoutError:
|
|
360
|
+
self._process.kill()
|
|
361
|
+
await self._process.wait()
|
|
362
|
+
|
|
363
|
+
await self._notifications.put(None)
|
|
364
|
+
await self._requests.put(None)
|
|
365
|
+
self._process = None
|
|
366
|
+
|
|
367
|
+
async def initialize(
|
|
368
|
+
self, client_info: Optional[AppServerClientInfo] = None
|
|
369
|
+
) -> Dict[str, Any]:
|
|
370
|
+
if self._process is None:
|
|
371
|
+
await self.start()
|
|
372
|
+
|
|
373
|
+
if client_info is None:
|
|
374
|
+
client_info = self._default_client_info()
|
|
375
|
+
|
|
376
|
+
result = await self._request_dict(
|
|
377
|
+
"initialize", {"clientInfo": client_info.as_dict()}
|
|
378
|
+
)
|
|
379
|
+
await self.notify("initialized")
|
|
380
|
+
return result
|
|
381
|
+
|
|
382
|
+
async def request(
|
|
383
|
+
self, method: str, params: Optional[Dict[str, Any]] = None
|
|
384
|
+
) -> Any:
|
|
385
|
+
self._ensure_ready()
|
|
386
|
+
if self._reader_error is not None:
|
|
387
|
+
raise CodexError("App-server reader failed") from self._reader_error
|
|
388
|
+
|
|
389
|
+
req_id = self._next_id
|
|
390
|
+
self._next_id += 1
|
|
391
|
+
loop = asyncio.get_running_loop()
|
|
392
|
+
future: asyncio.Future[Any] = loop.create_future()
|
|
393
|
+
self._pending[req_id] = future
|
|
394
|
+
await self._send({"id": req_id, "method": method, "params": params})
|
|
395
|
+
|
|
396
|
+
timeout = self._options.request_timeout
|
|
397
|
+
if timeout is None:
|
|
398
|
+
result = await future
|
|
399
|
+
else:
|
|
400
|
+
result = await asyncio.wait_for(future, timeout=timeout)
|
|
401
|
+
return result
|
|
402
|
+
|
|
403
|
+
async def _request_dict(
|
|
404
|
+
self, method: str, params: Optional[Dict[str, Any]] = None
|
|
405
|
+
) -> Dict[str, Any]:
|
|
406
|
+
return cast(Dict[str, Any], await self.request(method, params))
|
|
407
|
+
|
|
408
|
+
async def notify(
|
|
409
|
+
self, method: str, params: Optional[Dict[str, Any]] = None
|
|
410
|
+
) -> None:
|
|
411
|
+
self._ensure_ready()
|
|
412
|
+
await self._send({"method": method, "params": params})
|
|
413
|
+
|
|
414
|
+
async def notifications(self) -> AsyncGenerator[AppServerNotification, None]:
|
|
415
|
+
while True:
|
|
416
|
+
item = await self._notifications.get()
|
|
417
|
+
if item is None:
|
|
418
|
+
break
|
|
419
|
+
yield item
|
|
420
|
+
|
|
421
|
+
async def next_notification(self) -> Optional[AppServerNotification]:
|
|
422
|
+
item = await self._notifications.get()
|
|
423
|
+
return item
|
|
424
|
+
|
|
425
|
+
async def requests(self) -> AsyncGenerator[AppServerRequest, None]:
|
|
426
|
+
while True:
|
|
427
|
+
item = await self._requests.get()
|
|
428
|
+
if item is None:
|
|
429
|
+
break
|
|
430
|
+
yield item
|
|
431
|
+
|
|
432
|
+
async def next_request(self) -> Optional[AppServerRequest]:
|
|
433
|
+
return await self._requests.get()
|
|
434
|
+
|
|
435
|
+
async def respond(
|
|
436
|
+
self,
|
|
437
|
+
request_id: Any,
|
|
438
|
+
result: Optional[Any] = None,
|
|
439
|
+
*,
|
|
440
|
+
error: Optional[CodexAppServerError] = None,
|
|
441
|
+
) -> None:
|
|
442
|
+
if error is not None:
|
|
443
|
+
payload = {
|
|
444
|
+
"id": request_id,
|
|
445
|
+
"error": {
|
|
446
|
+
"code": error.code,
|
|
447
|
+
"message": error.message,
|
|
448
|
+
"data": error.data,
|
|
449
|
+
},
|
|
450
|
+
}
|
|
451
|
+
else:
|
|
452
|
+
payload = {"id": request_id, "result": result}
|
|
453
|
+
await self._send(payload)
|
|
454
|
+
|
|
455
|
+
async def thread_start(self, **params: Any) -> Dict[str, Any]:
|
|
456
|
+
return await self._request_dict("thread/start", _coerce_keys(params))
|
|
457
|
+
|
|
458
|
+
async def thread_resume(self, thread_id: str, **params: Any) -> Dict[str, Any]:
|
|
459
|
+
payload = {"threadId": thread_id}
|
|
460
|
+
payload.update(_coerce_keys(params))
|
|
461
|
+
return await self._request_dict("thread/resume", payload)
|
|
462
|
+
|
|
463
|
+
async def thread_fork(self, thread_id: str, **params: Any) -> Dict[str, Any]:
|
|
464
|
+
payload = {"threadId": thread_id}
|
|
465
|
+
payload.update(_coerce_keys(params))
|
|
466
|
+
return await self._request_dict("thread/fork", payload)
|
|
467
|
+
|
|
468
|
+
async def thread_loaded_list(
|
|
469
|
+
self, *, cursor: Optional[str] = None, limit: Optional[int] = None
|
|
470
|
+
) -> Dict[str, Any]:
|
|
471
|
+
params: Dict[str, Any] = {}
|
|
472
|
+
if cursor is not None:
|
|
473
|
+
params["cursor"] = cursor
|
|
474
|
+
if limit is not None:
|
|
475
|
+
params["limit"] = limit
|
|
476
|
+
return await self._request_dict("thread/loaded/list", params or None)
|
|
477
|
+
|
|
478
|
+
async def thread_list(
|
|
479
|
+
self,
|
|
480
|
+
*,
|
|
481
|
+
cursor: Optional[str] = None,
|
|
482
|
+
limit: Optional[int] = None,
|
|
483
|
+
model_providers: Optional[Sequence[str]] = None,
|
|
484
|
+
) -> Dict[str, Any]:
|
|
485
|
+
params: Dict[str, Any] = {}
|
|
486
|
+
if cursor is not None:
|
|
487
|
+
params["cursor"] = cursor
|
|
488
|
+
if limit is not None:
|
|
489
|
+
params["limit"] = limit
|
|
490
|
+
if model_providers is not None:
|
|
491
|
+
params["model_providers"] = list(model_providers)
|
|
492
|
+
return await self._request_dict("thread/list", _coerce_keys(params) or None)
|
|
493
|
+
|
|
494
|
+
async def thread_archive(self, thread_id: str) -> Dict[str, Any]:
|
|
495
|
+
return await self._request_dict("thread/archive", {"threadId": thread_id})
|
|
496
|
+
|
|
497
|
+
async def thread_rollback(
|
|
498
|
+
self, thread_id: str, *, num_turns: int
|
|
499
|
+
) -> Dict[str, Any]:
|
|
500
|
+
return await self._request_dict(
|
|
501
|
+
"thread/rollback", {"threadId": thread_id, "numTurns": num_turns}
|
|
502
|
+
)
|
|
503
|
+
|
|
504
|
+
async def config_requirements_read(self) -> Dict[str, Any]:
|
|
505
|
+
return await self._request_dict("configRequirements/read")
|
|
506
|
+
|
|
507
|
+
async def config_read(self, *, include_layers: bool = False) -> Dict[str, Any]:
|
|
508
|
+
params = {"include_layers": include_layers}
|
|
509
|
+
return await self._request_dict("config/read", _coerce_keys(params))
|
|
510
|
+
|
|
511
|
+
async def config_value_write(
|
|
512
|
+
self,
|
|
513
|
+
*,
|
|
514
|
+
key_path: str,
|
|
515
|
+
value: Any,
|
|
516
|
+
merge_strategy: str,
|
|
517
|
+
file_path: Optional[str] = None,
|
|
518
|
+
expected_version: Optional[str] = None,
|
|
519
|
+
) -> Dict[str, Any]:
|
|
520
|
+
params = {
|
|
521
|
+
"key_path": key_path,
|
|
522
|
+
"value": value,
|
|
523
|
+
"merge_strategy": merge_strategy,
|
|
524
|
+
"file_path": file_path,
|
|
525
|
+
"expected_version": expected_version,
|
|
526
|
+
}
|
|
527
|
+
return await self._request_dict("config/value/write", _coerce_keys(params))
|
|
528
|
+
|
|
529
|
+
async def config_batch_write(
|
|
530
|
+
self,
|
|
531
|
+
*,
|
|
532
|
+
edits: Sequence[Mapping[str, Any]],
|
|
533
|
+
file_path: Optional[str] = None,
|
|
534
|
+
expected_version: Optional[str] = None,
|
|
535
|
+
) -> Dict[str, Any]:
|
|
536
|
+
params = {
|
|
537
|
+
"edits": list(edits),
|
|
538
|
+
"file_path": file_path,
|
|
539
|
+
"expected_version": expected_version,
|
|
540
|
+
}
|
|
541
|
+
return await self._request_dict("config/batchWrite", _coerce_keys(params))
|
|
542
|
+
|
|
543
|
+
async def skills_list(
|
|
544
|
+
self,
|
|
545
|
+
*,
|
|
546
|
+
cwds: Optional[Sequence[Union[str, Path]]] = None,
|
|
547
|
+
force_reload: bool = False,
|
|
548
|
+
) -> Dict[str, Any]:
|
|
549
|
+
payload: Dict[str, Any] = {"force_reload": force_reload}
|
|
550
|
+
if cwds:
|
|
551
|
+
payload["cwds"] = [str(path) for path in cwds]
|
|
552
|
+
return await self._request_dict("skills/list", _coerce_keys(payload))
|
|
553
|
+
|
|
554
|
+
async def turn_start(
|
|
555
|
+
self,
|
|
556
|
+
thread_id: str,
|
|
557
|
+
input: AppServerInput,
|
|
558
|
+
**params: Any,
|
|
559
|
+
) -> Dict[str, Any]:
|
|
560
|
+
payload = {"threadId": thread_id, "input": normalize_app_server_input(input)}
|
|
561
|
+
payload.update(_coerce_keys(params))
|
|
562
|
+
return await self._request_dict("turn/start", payload)
|
|
563
|
+
|
|
564
|
+
async def review_start(
|
|
565
|
+
self,
|
|
566
|
+
thread_id: str,
|
|
567
|
+
*,
|
|
568
|
+
target: Mapping[str, Any],
|
|
569
|
+
delivery: Optional[str] = None,
|
|
570
|
+
) -> Dict[str, Any]:
|
|
571
|
+
payload: Dict[str, Any] = {"thread_id": thread_id, "target": dict(target)}
|
|
572
|
+
if delivery is not None:
|
|
573
|
+
payload["delivery"] = delivery
|
|
574
|
+
return await self._request_dict("review/start", _coerce_keys(payload))
|
|
575
|
+
|
|
576
|
+
async def turn_session(
|
|
577
|
+
self,
|
|
578
|
+
thread_id: str,
|
|
579
|
+
input: AppServerInput,
|
|
580
|
+
*,
|
|
581
|
+
approvals: Optional[ApprovalDecisions] = None,
|
|
582
|
+
**params: Any,
|
|
583
|
+
) -> AppServerTurnSession:
|
|
584
|
+
"""Start a turn and return a session wrapper that streams notifications."""
|
|
585
|
+
result = await self.turn_start(thread_id, input, **params)
|
|
586
|
+
turn = result.get("turn") if isinstance(result, dict) else None
|
|
587
|
+
turn_id = None
|
|
588
|
+
if isinstance(turn, dict):
|
|
589
|
+
turn_id = turn.get("id")
|
|
590
|
+
if not isinstance(turn_id, str) or not turn_id:
|
|
591
|
+
raise CodexError("turn/start response missing turn id")
|
|
592
|
+
session = AppServerTurnSession(
|
|
593
|
+
self,
|
|
594
|
+
thread_id=thread_id,
|
|
595
|
+
turn_id=turn_id,
|
|
596
|
+
approvals=approvals,
|
|
597
|
+
initial_turn=turn,
|
|
598
|
+
)
|
|
599
|
+
await session.start()
|
|
600
|
+
return session
|
|
601
|
+
|
|
602
|
+
async def turn_interrupt(self, thread_id: str, turn_id: str) -> Dict[str, Any]:
|
|
603
|
+
return await self._request_dict(
|
|
604
|
+
"turn/interrupt", {"threadId": thread_id, "turnId": turn_id}
|
|
605
|
+
)
|
|
606
|
+
|
|
607
|
+
async def model_list(
|
|
608
|
+
self, *, cursor: Optional[str] = None, limit: Optional[int] = None
|
|
609
|
+
) -> Dict[str, Any]:
|
|
610
|
+
params: Dict[str, Any] = {}
|
|
611
|
+
if cursor is not None:
|
|
612
|
+
params["cursor"] = cursor
|
|
613
|
+
if limit is not None:
|
|
614
|
+
params["limit"] = limit
|
|
615
|
+
return await self._request_dict("model/list", params or None)
|
|
616
|
+
|
|
617
|
+
async def command_exec(
|
|
618
|
+
self,
|
|
619
|
+
*,
|
|
620
|
+
command: Sequence[str],
|
|
621
|
+
timeout_ms: Optional[int] = None,
|
|
622
|
+
cwd: Optional[Union[str, Path]] = None,
|
|
623
|
+
sandbox_policy: Optional[Mapping[str, Any]] = None,
|
|
624
|
+
) -> Dict[str, Any]:
|
|
625
|
+
params: Dict[str, Any] = {"command": list(command)}
|
|
626
|
+
if timeout_ms is not None:
|
|
627
|
+
params["timeout_ms"] = timeout_ms
|
|
628
|
+
if cwd is not None:
|
|
629
|
+
params["cwd"] = str(cwd)
|
|
630
|
+
if sandbox_policy is not None:
|
|
631
|
+
params["sandbox_policy"] = dict(sandbox_policy)
|
|
632
|
+
return await self._request_dict("command/exec", _coerce_keys(params))
|
|
633
|
+
|
|
634
|
+
async def mcp_server_oauth_login(
|
|
635
|
+
self, *, name: str, scopes: Optional[Sequence[str]] = None
|
|
636
|
+
) -> Dict[str, Any]:
|
|
637
|
+
params: Dict[str, Any] = {"name": name}
|
|
638
|
+
if scopes is not None:
|
|
639
|
+
params["scopes"] = list(scopes)
|
|
640
|
+
return await self._request_dict("mcpServer/oauth/login", _coerce_keys(params))
|
|
641
|
+
|
|
642
|
+
async def mcp_server_refresh(self) -> Dict[str, Any]:
|
|
643
|
+
return await self._request_dict("config/mcpServer/reload")
|
|
644
|
+
|
|
645
|
+
async def mcp_server_status_list(
|
|
646
|
+
self, *, cursor: Optional[str] = None, limit: Optional[int] = None
|
|
647
|
+
) -> Dict[str, Any]:
|
|
648
|
+
params: Dict[str, Any] = {}
|
|
649
|
+
if cursor is not None:
|
|
650
|
+
params["cursor"] = cursor
|
|
651
|
+
if limit is not None:
|
|
652
|
+
params["limit"] = limit
|
|
653
|
+
return await self._request_dict("mcpServerStatus/list", params or None)
|
|
654
|
+
|
|
655
|
+
async def account_login_start(self, *, params: Mapping[str, Any]) -> Dict[str, Any]:
|
|
656
|
+
return await self._request_dict("account/login/start", dict(params))
|
|
657
|
+
|
|
658
|
+
async def account_login_cancel(self, *, login_id: str) -> Dict[str, Any]:
|
|
659
|
+
return await self._request_dict("account/login/cancel", {"loginId": login_id})
|
|
660
|
+
|
|
661
|
+
async def account_logout(self) -> Dict[str, Any]:
|
|
662
|
+
return await self._request_dict("account/logout")
|
|
663
|
+
|
|
664
|
+
async def account_rate_limits_read(self) -> Dict[str, Any]:
|
|
665
|
+
return await self._request_dict("account/rateLimits/read")
|
|
666
|
+
|
|
667
|
+
async def account_read(self, *, refresh_token: bool = False) -> Dict[str, Any]:
|
|
668
|
+
return await self._request_dict(
|
|
669
|
+
"account/read", {"refreshToken": refresh_token} if refresh_token else None
|
|
670
|
+
)
|
|
671
|
+
|
|
672
|
+
async def feedback_upload(
|
|
673
|
+
self,
|
|
674
|
+
*,
|
|
675
|
+
classification: str,
|
|
676
|
+
reason: Optional[str] = None,
|
|
677
|
+
thread_id: Optional[str] = None,
|
|
678
|
+
include_logs: bool = False,
|
|
679
|
+
) -> Dict[str, Any]:
|
|
680
|
+
params = {
|
|
681
|
+
"classification": classification,
|
|
682
|
+
"reason": reason,
|
|
683
|
+
"thread_id": thread_id,
|
|
684
|
+
"include_logs": include_logs,
|
|
685
|
+
}
|
|
686
|
+
return await self._request_dict("feedback/upload", _coerce_keys(params))
|
|
687
|
+
|
|
688
|
+
def _ensure_ready(self) -> None:
|
|
689
|
+
if self._process is None:
|
|
690
|
+
raise CodexError("App-server process is not running")
|
|
691
|
+
|
|
692
|
+
async def _send(self, payload: Dict[str, Any]) -> None:
|
|
693
|
+
if self._process is None or self._process.stdin is None:
|
|
694
|
+
raise CodexError("App-server stdin is not available")
|
|
695
|
+
message = json.dumps({k: v for k, v in payload.items() if v is not None})
|
|
696
|
+
self._process.stdin.write(message.encode("utf-8") + b"\n")
|
|
697
|
+
await self._process.stdin.drain()
|
|
698
|
+
|
|
699
|
+
async def _reader_loop(self) -> None:
|
|
700
|
+
if self._process is None or self._process.stdout is None:
|
|
701
|
+
return
|
|
702
|
+
try:
|
|
703
|
+
async for line in _iter_lines(self._process.stdout):
|
|
704
|
+
if not line:
|
|
705
|
+
continue
|
|
706
|
+
try:
|
|
707
|
+
data = json.loads(line)
|
|
708
|
+
except json.JSONDecodeError as exc:
|
|
709
|
+
raise CodexParseError(
|
|
710
|
+
f"Failed to parse app-server message: {line}"
|
|
711
|
+
) from exc
|
|
712
|
+
|
|
713
|
+
if isinstance(data, dict) and "id" in data and "method" in data:
|
|
714
|
+
await self._requests.put(
|
|
715
|
+
AppServerRequest(
|
|
716
|
+
id=data.get("id"),
|
|
717
|
+
method=str(data.get("method")),
|
|
718
|
+
params=data.get("params"),
|
|
719
|
+
)
|
|
720
|
+
)
|
|
721
|
+
elif isinstance(data, dict) and "id" in data:
|
|
722
|
+
await self._handle_response(data)
|
|
723
|
+
elif isinstance(data, dict) and "method" in data:
|
|
724
|
+
await self._notifications.put(
|
|
725
|
+
AppServerNotification(
|
|
726
|
+
method=str(data.get("method")),
|
|
727
|
+
params=data.get("params"),
|
|
728
|
+
)
|
|
729
|
+
)
|
|
730
|
+
else:
|
|
731
|
+
raise CodexParseError(f"Unknown app-server message: {data}")
|
|
732
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
733
|
+
self._reader_error = exc
|
|
734
|
+
self._fail_all_pending(exc)
|
|
735
|
+
finally:
|
|
736
|
+
self._closed = True
|
|
737
|
+
await self._notifications.put(None)
|
|
738
|
+
await self._requests.put(None)
|
|
739
|
+
|
|
740
|
+
async def _handle_response(self, data: Dict[str, Any]) -> None:
|
|
741
|
+
req_id = data.get("id")
|
|
742
|
+
if not isinstance(req_id, int):
|
|
743
|
+
# String ids are valid, but this client only issues ints.
|
|
744
|
+
return
|
|
745
|
+
future = self._pending.pop(req_id, None)
|
|
746
|
+
if future is None:
|
|
747
|
+
return
|
|
748
|
+
if "error" in data:
|
|
749
|
+
error = data.get("error") or {}
|
|
750
|
+
code = error.get("code", -1)
|
|
751
|
+
message = error.get("message", "Unknown error")
|
|
752
|
+
raise_exc = CodexAppServerError(
|
|
753
|
+
code=code, message=message, data=error.get("data")
|
|
754
|
+
)
|
|
755
|
+
future.set_exception(raise_exc)
|
|
756
|
+
return
|
|
757
|
+
future.set_result(data.get("result"))
|
|
758
|
+
|
|
759
|
+
def _fail_all_pending(self, exc: BaseException) -> None:
|
|
760
|
+
for future in self._pending.values():
|
|
761
|
+
if not future.done():
|
|
762
|
+
future.set_exception(exc)
|
|
763
|
+
self._pending.clear()
|
|
764
|
+
|
|
765
|
+
def _resolve_executable(self) -> str:
|
|
766
|
+
from .exec import CodexExec
|
|
767
|
+
|
|
768
|
+
exec = CodexExec(self._options.codex_path_override, env=self._options.env)
|
|
769
|
+
return exec.executable_path
|
|
770
|
+
|
|
771
|
+
def _build_env(self) -> Dict[str, str]:
|
|
772
|
+
if self._options.env is not None:
|
|
773
|
+
env = dict(self._options.env)
|
|
774
|
+
else:
|
|
775
|
+
import os
|
|
776
|
+
|
|
777
|
+
env = os.environ.copy()
|
|
778
|
+
if INTERNAL_ORIGINATOR_ENV not in env:
|
|
779
|
+
env[INTERNAL_ORIGINATOR_ENV] = PYTHON_SDK_ORIGINATOR
|
|
780
|
+
if self._options.base_url:
|
|
781
|
+
env["OPENAI_BASE_URL"] = self._options.base_url
|
|
782
|
+
if self._options.api_key:
|
|
783
|
+
env["CODEX_API_KEY"] = self._options.api_key
|
|
784
|
+
return env
|
|
785
|
+
|
|
786
|
+
def _default_client_info(self) -> AppServerClientInfo:
|
|
787
|
+
from . import __version__
|
|
788
|
+
|
|
789
|
+
return AppServerClientInfo(
|
|
790
|
+
name="codex_sdk_python",
|
|
791
|
+
title="Codex SDK Python",
|
|
792
|
+
version=__version__,
|
|
793
|
+
)
|
|
794
|
+
|
|
795
|
+
|
|
796
|
+
class AppServerTextInput(TypedDict):
|
|
797
|
+
type: str
|
|
798
|
+
text: str
|
|
799
|
+
|
|
800
|
+
|
|
801
|
+
class AppServerImageInput(TypedDict):
|
|
802
|
+
type: str
|
|
803
|
+
url: str
|
|
804
|
+
|
|
805
|
+
|
|
806
|
+
class AppServerLocalImageInput(TypedDict):
|
|
807
|
+
type: str
|
|
808
|
+
path: str
|
|
809
|
+
|
|
810
|
+
|
|
811
|
+
class AppServerSkillInput(TypedDict):
|
|
812
|
+
type: str
|
|
813
|
+
name: str
|
|
814
|
+
path: str
|
|
815
|
+
|
|
816
|
+
|
|
817
|
+
AppServerUserInput = Union[
|
|
818
|
+
AppServerTextInput,
|
|
819
|
+
AppServerImageInput,
|
|
820
|
+
AppServerLocalImageInput,
|
|
821
|
+
AppServerSkillInput,
|
|
822
|
+
Mapping[str, Any],
|
|
823
|
+
]
|
|
824
|
+
AppServerInput = Union[Sequence[AppServerUserInput], str]
|
|
825
|
+
|
|
826
|
+
|
|
827
|
+
def normalize_app_server_input(input: AppServerInput) -> List[Dict[str, Any]]:
|
|
828
|
+
if isinstance(input, str):
|
|
829
|
+
return [{"type": "text", "text": input}]
|
|
830
|
+
|
|
831
|
+
items: List[Dict[str, Any]] = []
|
|
832
|
+
for raw in input:
|
|
833
|
+
if not isinstance(raw, Mapping):
|
|
834
|
+
raise CodexError("App-server input items must be mappings")
|
|
835
|
+
item = dict(raw)
|
|
836
|
+
item_type = item.get("type")
|
|
837
|
+
if item_type == "local_image":
|
|
838
|
+
item["type"] = "localImage"
|
|
839
|
+
item_type = "localImage"
|
|
840
|
+
if item_type == "localImage" and isinstance(item.get("path"), Path):
|
|
841
|
+
item["path"] = str(item["path"])
|
|
842
|
+
if item_type == "skill" and isinstance(item.get("path"), Path):
|
|
843
|
+
item["path"] = str(item["path"])
|
|
844
|
+
items.append(item)
|
|
845
|
+
|
|
846
|
+
return items
|
|
847
|
+
|
|
848
|
+
|
|
849
|
+
def _coerce_keys(params: Mapping[str, Any]) -> Dict[str, Any]:
|
|
850
|
+
coerced: Dict[str, Any] = {}
|
|
851
|
+
for key, value in params.items():
|
|
852
|
+
if value is None:
|
|
853
|
+
continue
|
|
854
|
+
if "_" in key:
|
|
855
|
+
key = _snake_to_camel(key)
|
|
856
|
+
coerced[key] = value
|
|
857
|
+
return coerced
|
|
858
|
+
|
|
859
|
+
|
|
860
|
+
def _snake_to_camel(value: str) -> str:
|
|
861
|
+
parts = value.split("_")
|
|
862
|
+
return parts[0] + "".join(word.capitalize() for word in parts[1:])
|
|
863
|
+
|
|
864
|
+
|
|
865
|
+
def _normalize_decision(
|
|
866
|
+
decision: Union[str, Mapping[str, Any]],
|
|
867
|
+
execpolicy_amendment: Optional[Mapping[str, Any]],
|
|
868
|
+
) -> Union[str, Dict[str, Any]]:
|
|
869
|
+
if isinstance(decision, Mapping):
|
|
870
|
+
return dict(decision)
|
|
871
|
+
if not isinstance(decision, str):
|
|
872
|
+
raise CodexError("Approval decision must be a string or mapping")
|
|
873
|
+
|
|
874
|
+
normalized = decision.strip()
|
|
875
|
+
if normalized in {
|
|
876
|
+
"accept_with_execpolicy_amendment",
|
|
877
|
+
"acceptWithExecpolicyAmendment",
|
|
878
|
+
}:
|
|
879
|
+
if execpolicy_amendment is None:
|
|
880
|
+
raise CodexError(
|
|
881
|
+
"execpolicy_amendment is required for accept_with_execpolicy_amendment"
|
|
882
|
+
)
|
|
883
|
+
amendment_payload = _coerce_keys(execpolicy_amendment)
|
|
884
|
+
return {
|
|
885
|
+
"acceptWithExecpolicyAmendment": {"execpolicyAmendment": amendment_payload}
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
if "_" in normalized:
|
|
889
|
+
normalized = _snake_to_camel(normalized)
|
|
890
|
+
return normalized
|
|
891
|
+
|
|
892
|
+
|
|
893
|
+
def _extract_turn(notification: AppServerNotification) -> Optional[Dict[str, Any]]:
|
|
894
|
+
params = notification.params
|
|
895
|
+
if not isinstance(params, dict):
|
|
896
|
+
return None
|
|
897
|
+
turn = params.get("turn")
|
|
898
|
+
if isinstance(turn, dict):
|
|
899
|
+
return turn
|
|
900
|
+
if "id" in params:
|
|
901
|
+
return params
|
|
902
|
+
return None
|
|
903
|
+
|
|
904
|
+
|
|
905
|
+
async def _drain_stream(stream: asyncio.StreamReader, sink: list[str]) -> None:
|
|
906
|
+
while True:
|
|
907
|
+
chunk = await stream.readline()
|
|
908
|
+
if not chunk:
|
|
909
|
+
break
|
|
910
|
+
sink.append(chunk.decode("utf-8"))
|
|
911
|
+
|
|
912
|
+
|
|
913
|
+
async def _iter_lines(stream: asyncio.StreamReader) -> AsyncGenerator[str, None]:
|
|
914
|
+
while True:
|
|
915
|
+
line = await stream.readline()
|
|
916
|
+
if not line:
|
|
917
|
+
break
|
|
918
|
+
yield line.decode("utf-8").rstrip("\n\r")
|