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.
@@ -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")