codex-ipc 0.1.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,112 @@
1
+ from .async_client import AsyncAppServerClient
2
+ from .client import AppServerClient, AppServerConfig
3
+ from .errors import (
4
+ AppServerError,
5
+ AppServerRpcError,
6
+ InternalRpcError,
7
+ InvalidParamsError,
8
+ InvalidRequestError,
9
+ JsonRpcError,
10
+ MethodNotFoundError,
11
+ ParseError,
12
+ RetryLimitExceededError,
13
+ ServerBusyError,
14
+ TransportClosedError,
15
+ is_retryable_error,
16
+ )
17
+ from .generated.v2_all import (
18
+ AskForApproval,
19
+ Personality,
20
+ PlanType,
21
+ ReasoningEffort,
22
+ ReasoningSummary,
23
+ SandboxMode,
24
+ SandboxPolicy,
25
+ ServiceTier,
26
+ ThreadItem,
27
+ ThreadForkParams,
28
+ ThreadListParams,
29
+ ThreadResumeParams,
30
+ ThreadSortKey,
31
+ ThreadSourceKind,
32
+ ThreadStartParams,
33
+ ThreadTokenUsageUpdatedNotification,
34
+ TurnCompletedNotification,
35
+ TurnStartParams,
36
+ TurnStatus,
37
+ TurnSteerParams,
38
+ )
39
+ from .models import InitializeResponse
40
+ from .api import (
41
+ AsyncCodex,
42
+ AsyncThread,
43
+ AsyncTurnHandle,
44
+ Codex,
45
+ ImageInput,
46
+ Input,
47
+ InputItem,
48
+ LocalImageInput,
49
+ MentionInput,
50
+ RunResult,
51
+ SkillInput,
52
+ TextInput,
53
+ Thread,
54
+ TurnHandle,
55
+ )
56
+ from .retry import retry_on_overload
57
+ from ._version import __version__
58
+
59
+ __all__ = [
60
+ "__version__",
61
+ "AppServerClient",
62
+ "AsyncAppServerClient",
63
+ "AppServerConfig",
64
+ "Codex",
65
+ "AsyncCodex",
66
+ "Thread",
67
+ "AsyncThread",
68
+ "TurnHandle",
69
+ "AsyncTurnHandle",
70
+ "InitializeResponse",
71
+ "RunResult",
72
+ "Input",
73
+ "InputItem",
74
+ "TextInput",
75
+ "ImageInput",
76
+ "LocalImageInput",
77
+ "SkillInput",
78
+ "MentionInput",
79
+ "ThreadItem",
80
+ "ThreadTokenUsageUpdatedNotification",
81
+ "TurnCompletedNotification",
82
+ "AskForApproval",
83
+ "Personality",
84
+ "PlanType",
85
+ "ReasoningEffort",
86
+ "ReasoningSummary",
87
+ "SandboxMode",
88
+ "SandboxPolicy",
89
+ "ServiceTier",
90
+ "ThreadStartParams",
91
+ "ThreadResumeParams",
92
+ "ThreadListParams",
93
+ "ThreadSortKey",
94
+ "ThreadSourceKind",
95
+ "ThreadForkParams",
96
+ "TurnStatus",
97
+ "TurnStartParams",
98
+ "TurnSteerParams",
99
+ "retry_on_overload",
100
+ "AppServerError",
101
+ "TransportClosedError",
102
+ "JsonRpcError",
103
+ "AppServerRpcError",
104
+ "ParseError",
105
+ "InvalidRequestError",
106
+ "MethodNotFoundError",
107
+ "InvalidParamsError",
108
+ "InternalRpcError",
109
+ "ServerBusyError",
110
+ "RetryLimitExceededError",
111
+ "is_retryable_error",
112
+ ]
@@ -0,0 +1,63 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from .models import JsonObject
6
+
7
+
8
+ @dataclass(slots=True)
9
+ class TextInput:
10
+ text: str
11
+
12
+
13
+ @dataclass(slots=True)
14
+ class ImageInput:
15
+ url: str
16
+
17
+
18
+ @dataclass(slots=True)
19
+ class LocalImageInput:
20
+ path: str
21
+
22
+
23
+ @dataclass(slots=True)
24
+ class SkillInput:
25
+ name: str
26
+ path: str
27
+
28
+
29
+ @dataclass(slots=True)
30
+ class MentionInput:
31
+ name: str
32
+ path: str
33
+
34
+
35
+ InputItem = TextInput | ImageInput | LocalImageInput | SkillInput | MentionInput
36
+ Input = list[InputItem] | InputItem
37
+ RunInput = Input | str
38
+
39
+
40
+ def _to_wire_item(item: InputItem) -> JsonObject:
41
+ if isinstance(item, TextInput):
42
+ return {"type": "text", "text": item.text}
43
+ if isinstance(item, ImageInput):
44
+ return {"type": "image", "url": item.url}
45
+ if isinstance(item, LocalImageInput):
46
+ return {"type": "localImage", "path": item.path}
47
+ if isinstance(item, SkillInput):
48
+ return {"type": "skill", "name": item.name, "path": item.path}
49
+ if isinstance(item, MentionInput):
50
+ return {"type": "mention", "name": item.name, "path": item.path}
51
+ raise TypeError(f"unsupported input item: {type(item)!r}")
52
+
53
+
54
+ def _to_wire_input(input: Input) -> list[JsonObject]:
55
+ if isinstance(input, list):
56
+ return [_to_wire_item(i) for i in input]
57
+ return [_to_wire_item(input)]
58
+
59
+
60
+ def _normalize_run_input(input: RunInput) -> Input:
61
+ if isinstance(input, str):
62
+ return TextInput(input)
63
+ return input
@@ -0,0 +1,158 @@
1
+ from __future__ import annotations
2
+
3
+ import queue
4
+ import threading
5
+ from collections import deque
6
+
7
+ from .errors import AppServerError, map_jsonrpc_error
8
+ from .generated.notification_registry import notification_turn_id
9
+ from .models import JsonValue, Notification, UnknownNotification
10
+
11
+ ResponseQueueItem = JsonValue | BaseException
12
+ NotificationQueueItem = Notification | BaseException
13
+
14
+
15
+ class MessageRouter:
16
+ """Route reader-thread messages to the SDK operation waiting for them.
17
+
18
+ The app-server stdio transport is a single ordered stream, so only the
19
+ reader thread should consume stdout. This router keeps the rest of the SDK
20
+ from competing for that stream by giving each in-flight JSON-RPC request
21
+ and active turn stream its own queue.
22
+ """
23
+
24
+ def __init__(self) -> None:
25
+ self._lock = threading.Lock()
26
+ self._response_waiters: dict[str, queue.Queue[ResponseQueueItem]] = {}
27
+ self._turn_notifications: dict[str, queue.Queue[NotificationQueueItem]] = {}
28
+ self._pending_turn_notifications: dict[str, deque[Notification]] = {}
29
+ self._global_notifications: queue.Queue[NotificationQueueItem] = queue.Queue()
30
+
31
+ def create_response_waiter(self, request_id: str) -> queue.Queue[ResponseQueueItem]:
32
+ """Register a one-shot queue for a JSON-RPC response id."""
33
+
34
+ waiter: queue.Queue[ResponseQueueItem] = queue.Queue(maxsize=1)
35
+ with self._lock:
36
+ self._response_waiters[request_id] = waiter
37
+ return waiter
38
+
39
+ def discard_response_waiter(self, request_id: str) -> None:
40
+ """Remove a response waiter when the request could not be written."""
41
+
42
+ with self._lock:
43
+ self._response_waiters.pop(request_id, None)
44
+
45
+ def next_global_notification(self) -> Notification:
46
+ """Block until the next notification that is not scoped to a turn."""
47
+
48
+ item = self._global_notifications.get()
49
+ if isinstance(item, BaseException):
50
+ raise item
51
+ return item
52
+
53
+ def register_turn(self, turn_id: str) -> None:
54
+ """Register a queue for a turn stream and replay early events."""
55
+
56
+ turn_queue: queue.Queue[NotificationQueueItem] = queue.Queue()
57
+ with self._lock:
58
+ if turn_id in self._turn_notifications:
59
+ return
60
+ # A turn can emit events immediately after turn/start, before the
61
+ # caller receives the TurnHandle and starts streaming.
62
+ pending = self._pending_turn_notifications.pop(turn_id, deque())
63
+ self._turn_notifications[turn_id] = turn_queue
64
+ for notification in pending:
65
+ turn_queue.put(notification)
66
+
67
+ def unregister_turn(self, turn_id: str) -> None:
68
+ """Stop routing future turn events to the stream queue."""
69
+
70
+ with self._lock:
71
+ self._turn_notifications.pop(turn_id, None)
72
+
73
+ def next_turn_notification(self, turn_id: str) -> Notification:
74
+ """Block until the next notification for a registered turn."""
75
+
76
+ with self._lock:
77
+ turn_queue = self._turn_notifications.get(turn_id)
78
+ if turn_queue is None:
79
+ raise RuntimeError(f"turn {turn_id!r} is not registered for streaming")
80
+ item = turn_queue.get()
81
+ if isinstance(item, BaseException):
82
+ raise item
83
+ return item
84
+
85
+ def route_response(self, msg: dict[str, JsonValue]) -> None:
86
+ """Deliver a JSON-RPC response or error to its request waiter."""
87
+
88
+ request_id = msg.get("id")
89
+ with self._lock:
90
+ waiter = self._response_waiters.pop(str(request_id), None)
91
+ if waiter is None:
92
+ return
93
+
94
+ if "error" in msg:
95
+ err = msg["error"]
96
+ if isinstance(err, dict):
97
+ waiter.put(
98
+ map_jsonrpc_error(
99
+ int(err.get("code", -32000)),
100
+ str(err.get("message", "unknown")),
101
+ err.get("data"),
102
+ )
103
+ )
104
+ else:
105
+ waiter.put(AppServerError("Malformed JSON-RPC error response"))
106
+ return
107
+
108
+ waiter.put(msg.get("result"))
109
+
110
+ def route_notification(self, notification: Notification) -> None:
111
+ """Deliver a notification to a turn queue or the global queue."""
112
+
113
+ turn_id = self._notification_turn_id(notification)
114
+ if turn_id is None:
115
+ self._global_notifications.put(notification)
116
+ return
117
+
118
+ with self._lock:
119
+ turn_queue = self._turn_notifications.get(turn_id)
120
+ if turn_queue is None:
121
+ if notification.method == "turn/completed":
122
+ self._pending_turn_notifications.pop(turn_id, None)
123
+ return
124
+ self._pending_turn_notifications.setdefault(turn_id, deque()).append(
125
+ notification
126
+ )
127
+ return
128
+ turn_queue.put(notification)
129
+
130
+ def fail_all(self, exc: BaseException) -> None:
131
+ """Wake every blocked waiter when the reader thread exits."""
132
+
133
+ with self._lock:
134
+ response_waiters = list(self._response_waiters.values())
135
+ self._response_waiters.clear()
136
+ turn_queues = list(self._turn_notifications.values())
137
+ self._pending_turn_notifications.clear()
138
+ # Put the same transport failure into every queue so no SDK call blocks
139
+ # forever waiting for a response that cannot arrive.
140
+ for waiter in response_waiters:
141
+ waiter.put(exc)
142
+ for turn_queue in turn_queues:
143
+ turn_queue.put(exc)
144
+ self._global_notifications.put(exc)
145
+
146
+ def _notification_turn_id(self, notification: Notification) -> str | None:
147
+ payload = notification.payload
148
+ if isinstance(payload, UnknownNotification):
149
+ raw_turn_id = payload.params.get("turnId")
150
+ if isinstance(raw_turn_id, str):
151
+ return raw_turn_id
152
+ raw_turn = payload.params.get("turn")
153
+ if isinstance(raw_turn, dict):
154
+ raw_nested_turn_id = raw_turn.get("id")
155
+ if isinstance(raw_nested_turn_id, str):
156
+ return raw_nested_turn_id
157
+ return None
158
+ return notification_turn_id(payload)
@@ -0,0 +1,112 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import AsyncIterator, Iterator
5
+
6
+ from .generated.v2_all import (
7
+ AgentMessageThreadItem,
8
+ ItemCompletedNotification,
9
+ MessagePhase,
10
+ ThreadItem,
11
+ ThreadTokenUsage,
12
+ ThreadTokenUsageUpdatedNotification,
13
+ Turn as AppServerTurn,
14
+ TurnCompletedNotification,
15
+ TurnStatus,
16
+ )
17
+ from .models import Notification
18
+
19
+
20
+ @dataclass(slots=True)
21
+ class RunResult:
22
+ final_response: str | None
23
+ items: list[ThreadItem]
24
+ usage: ThreadTokenUsage | None
25
+
26
+
27
+ def _agent_message_item_from_thread_item(
28
+ item: ThreadItem,
29
+ ) -> AgentMessageThreadItem | None:
30
+ thread_item = item.root if hasattr(item, "root") else item
31
+ if isinstance(thread_item, AgentMessageThreadItem):
32
+ return thread_item
33
+ return None
34
+
35
+
36
+ def _final_assistant_response_from_items(items: list[ThreadItem]) -> str | None:
37
+ last_unknown_phase_response: str | None = None
38
+
39
+ for item in reversed(items):
40
+ agent_message = _agent_message_item_from_thread_item(item)
41
+ if agent_message is None:
42
+ continue
43
+ if agent_message.phase == MessagePhase.final_answer:
44
+ return agent_message.text
45
+ if agent_message.phase is None and last_unknown_phase_response is None:
46
+ last_unknown_phase_response = agent_message.text
47
+
48
+ return last_unknown_phase_response
49
+
50
+
51
+ def _raise_for_failed_turn(turn: AppServerTurn) -> None:
52
+ if turn.status != TurnStatus.failed:
53
+ return
54
+ if turn.error is not None and turn.error.message:
55
+ raise RuntimeError(turn.error.message)
56
+ raise RuntimeError(f"turn failed with status {turn.status.value}")
57
+
58
+
59
+ def _collect_run_result(stream: Iterator[Notification], *, turn_id: str) -> RunResult:
60
+ completed: TurnCompletedNotification | None = None
61
+ items: list[ThreadItem] = []
62
+ usage: ThreadTokenUsage | None = None
63
+
64
+ for event in stream:
65
+ payload = event.payload
66
+ if isinstance(payload, ItemCompletedNotification) and payload.turn_id == turn_id:
67
+ items.append(payload.item)
68
+ continue
69
+ if isinstance(payload, ThreadTokenUsageUpdatedNotification) and payload.turn_id == turn_id:
70
+ usage = payload.token_usage
71
+ continue
72
+ if isinstance(payload, TurnCompletedNotification) and payload.turn.id == turn_id:
73
+ completed = payload
74
+
75
+ if completed is None:
76
+ raise RuntimeError("turn completed event not received")
77
+
78
+ _raise_for_failed_turn(completed.turn)
79
+ return RunResult(
80
+ final_response=_final_assistant_response_from_items(items),
81
+ items=items,
82
+ usage=usage,
83
+ )
84
+
85
+
86
+ async def _collect_async_run_result(
87
+ stream: AsyncIterator[Notification], *, turn_id: str
88
+ ) -> RunResult:
89
+ completed: TurnCompletedNotification | None = None
90
+ items: list[ThreadItem] = []
91
+ usage: ThreadTokenUsage | None = None
92
+
93
+ async for event in stream:
94
+ payload = event.payload
95
+ if isinstance(payload, ItemCompletedNotification) and payload.turn_id == turn_id:
96
+ items.append(payload.item)
97
+ continue
98
+ if isinstance(payload, ThreadTokenUsageUpdatedNotification) and payload.turn_id == turn_id:
99
+ usage = payload.token_usage
100
+ continue
101
+ if isinstance(payload, TurnCompletedNotification) and payload.turn.id == turn_id:
102
+ completed = payload
103
+
104
+ if completed is None:
105
+ raise RuntimeError("turn completed event not received")
106
+
107
+ _raise_for_failed_turn(completed.turn)
108
+ return RunResult(
109
+ final_response=_final_assistant_response_from_items(items),
110
+ items=items,
111
+ usage=usage,
112
+ )
@@ -0,0 +1,37 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from importlib.metadata import PackageNotFoundError
5
+ from importlib.metadata import version as distribution_version
6
+ from pathlib import Path
7
+
8
+ DISTRIBUTION_NAME = "openai-codex-app-server-sdk"
9
+ UNKNOWN_VERSION = "0+unknown"
10
+
11
+
12
+ def package_version() -> str:
13
+ source_version = _source_tree_project_version()
14
+ if source_version is not None:
15
+ return source_version
16
+
17
+ try:
18
+ return distribution_version(DISTRIBUTION_NAME)
19
+ except PackageNotFoundError:
20
+ return UNKNOWN_VERSION
21
+
22
+
23
+ def _source_tree_project_version() -> str | None:
24
+ pyproject_path = Path(__file__).resolve().parents[2] / "pyproject.toml"
25
+ if not pyproject_path.exists():
26
+ return None
27
+
28
+ match = re.search(
29
+ r'(?m)^version = "([^"]+)"$',
30
+ pyproject_path.read_text(encoding="utf-8"),
31
+ )
32
+ if match is None:
33
+ return None
34
+ return match.group(1)
35
+
36
+
37
+ __version__ = package_version()