openai-codex 0.1.0b1__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.
- openai_codex/__init__.py +93 -0
- openai_codex/_approval_mode.py +51 -0
- openai_codex/_initialize_metadata.py +54 -0
- openai_codex/_inputs.py +73 -0
- openai_codex/_login.py +172 -0
- openai_codex/_message_router.py +222 -0
- openai_codex/_run.py +135 -0
- openai_codex/_sandbox.py +78 -0
- openai_codex/_version.py +36 -0
- openai_codex/api.py +807 -0
- openai_codex/async_client.py +285 -0
- openai_codex/client.py +694 -0
- openai_codex/errors.py +121 -0
- openai_codex/generated/__init__.py +1 -0
- openai_codex/generated/notification_registry.py +177 -0
- openai_codex/generated/v2_all.py +8287 -0
- openai_codex/models.py +99 -0
- openai_codex/py.typed +0 -0
- openai_codex/retry.py +41 -0
- openai_codex/types.py +81 -0
- openai_codex-0.1.0b1.dist-info/METADATA +117 -0
- openai_codex-0.1.0b1.dist-info/RECORD +23 -0
- openai_codex-0.1.0b1.dist-info/WHEEL +4 -0
openai_codex/__init__.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Python SDK for running Codex workflows.
|
|
2
|
+
|
|
3
|
+
Start with :class:`Codex` for synchronous applications or
|
|
4
|
+
:class:`AsyncCodex` for async applications. Most programs create a thread and
|
|
5
|
+
run a turn::
|
|
6
|
+
|
|
7
|
+
from openai_codex import Codex, Sandbox
|
|
8
|
+
|
|
9
|
+
with Codex() as codex:
|
|
10
|
+
thread = codex.thread_start(sandbox=Sandbox.workspace_write)
|
|
11
|
+
result = thread.run("Describe this project.")
|
|
12
|
+
print(result.final_response)
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from ._version import __version__
|
|
16
|
+
from .api import (
|
|
17
|
+
ApprovalMode,
|
|
18
|
+
AsyncChatgptLoginHandle,
|
|
19
|
+
AsyncCodex,
|
|
20
|
+
AsyncDeviceCodeLoginHandle,
|
|
21
|
+
AsyncThread,
|
|
22
|
+
AsyncTurnHandle,
|
|
23
|
+
ChatgptLoginHandle,
|
|
24
|
+
Codex,
|
|
25
|
+
DeviceCodeLoginHandle,
|
|
26
|
+
ImageInput,
|
|
27
|
+
Input,
|
|
28
|
+
InputItem,
|
|
29
|
+
LocalImageInput,
|
|
30
|
+
MentionInput,
|
|
31
|
+
RunInput,
|
|
32
|
+
Sandbox,
|
|
33
|
+
SkillInput,
|
|
34
|
+
TextInput,
|
|
35
|
+
Thread,
|
|
36
|
+
TurnHandle,
|
|
37
|
+
TurnResult,
|
|
38
|
+
)
|
|
39
|
+
from .client import CodexConfig
|
|
40
|
+
from .errors import (
|
|
41
|
+
CodexError,
|
|
42
|
+
CodexRpcError,
|
|
43
|
+
InternalRpcError,
|
|
44
|
+
InvalidParamsError,
|
|
45
|
+
InvalidRequestError,
|
|
46
|
+
JsonRpcError,
|
|
47
|
+
MethodNotFoundError,
|
|
48
|
+
ParseError,
|
|
49
|
+
RetryLimitExceededError,
|
|
50
|
+
ServerBusyError,
|
|
51
|
+
TransportClosedError,
|
|
52
|
+
is_retryable_error,
|
|
53
|
+
)
|
|
54
|
+
from .retry import retry_on_overload
|
|
55
|
+
|
|
56
|
+
__all__ = [
|
|
57
|
+
"__version__",
|
|
58
|
+
"CodexConfig",
|
|
59
|
+
"Codex",
|
|
60
|
+
"AsyncCodex",
|
|
61
|
+
"ApprovalMode",
|
|
62
|
+
"Sandbox",
|
|
63
|
+
"ChatgptLoginHandle",
|
|
64
|
+
"DeviceCodeLoginHandle",
|
|
65
|
+
"AsyncChatgptLoginHandle",
|
|
66
|
+
"AsyncDeviceCodeLoginHandle",
|
|
67
|
+
"Thread",
|
|
68
|
+
"AsyncThread",
|
|
69
|
+
"TurnHandle",
|
|
70
|
+
"AsyncTurnHandle",
|
|
71
|
+
"TurnResult",
|
|
72
|
+
"Input",
|
|
73
|
+
"InputItem",
|
|
74
|
+
"RunInput",
|
|
75
|
+
"TextInput",
|
|
76
|
+
"ImageInput",
|
|
77
|
+
"LocalImageInput",
|
|
78
|
+
"SkillInput",
|
|
79
|
+
"MentionInput",
|
|
80
|
+
"retry_on_overload",
|
|
81
|
+
"CodexError",
|
|
82
|
+
"TransportClosedError",
|
|
83
|
+
"JsonRpcError",
|
|
84
|
+
"CodexRpcError",
|
|
85
|
+
"ParseError",
|
|
86
|
+
"InvalidRequestError",
|
|
87
|
+
"MethodNotFoundError",
|
|
88
|
+
"InvalidParamsError",
|
|
89
|
+
"InternalRpcError",
|
|
90
|
+
"ServerBusyError",
|
|
91
|
+
"RetryLimitExceededError",
|
|
92
|
+
"is_retryable_error",
|
|
93
|
+
]
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from enum import Enum
|
|
4
|
+
from typing import NoReturn
|
|
5
|
+
|
|
6
|
+
from .generated.v2_all import (
|
|
7
|
+
ApprovalsReviewer,
|
|
8
|
+
AskForApproval,
|
|
9
|
+
AskForApprovalValue,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ApprovalMode(str, Enum):
|
|
14
|
+
"""High-level approval behavior for escalated permission requests."""
|
|
15
|
+
|
|
16
|
+
deny_all = "deny_all"
|
|
17
|
+
auto_review = "auto_review"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _approval_mode_settings(
|
|
21
|
+
approval_mode: ApprovalMode,
|
|
22
|
+
) -> tuple[AskForApproval, ApprovalsReviewer | None]:
|
|
23
|
+
"""Map the public approval mode to generated app-server start params."""
|
|
24
|
+
if not isinstance(approval_mode, ApprovalMode):
|
|
25
|
+
supported = ", ".join(mode.value for mode in ApprovalMode)
|
|
26
|
+
raise ValueError(f"approval_mode must be one of: {supported}")
|
|
27
|
+
|
|
28
|
+
match approval_mode:
|
|
29
|
+
case ApprovalMode.auto_review:
|
|
30
|
+
return (
|
|
31
|
+
AskForApproval(root=AskForApprovalValue.on_request),
|
|
32
|
+
ApprovalsReviewer.auto_review,
|
|
33
|
+
)
|
|
34
|
+
case ApprovalMode.deny_all:
|
|
35
|
+
return AskForApproval(root=AskForApprovalValue.never), None
|
|
36
|
+
case _:
|
|
37
|
+
return _assert_never_approval_mode(approval_mode)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _assert_never_approval_mode(approval_mode: NoReturn) -> NoReturn:
|
|
41
|
+
"""Make approval mode mapping exhaustive for static type checkers."""
|
|
42
|
+
raise AssertionError(f"Unhandled approval mode: {approval_mode!r}")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _approval_mode_override_settings(
|
|
46
|
+
approval_mode: ApprovalMode | None,
|
|
47
|
+
) -> tuple[AskForApproval | None, ApprovalsReviewer | None]:
|
|
48
|
+
"""Map an optional public approval mode to app-server override params."""
|
|
49
|
+
if approval_mode is None:
|
|
50
|
+
return None, None
|
|
51
|
+
return _approval_mode_settings(approval_mode)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from .models import InitializeResponse, ServerInfo
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _split_user_agent(user_agent: str) -> tuple[str | None, str | None]:
|
|
7
|
+
raw = user_agent.strip()
|
|
8
|
+
if not raw:
|
|
9
|
+
return None, None
|
|
10
|
+
if "/" in raw:
|
|
11
|
+
name, version = raw.split("/", 1)
|
|
12
|
+
return (name or None), (version or None)
|
|
13
|
+
parts = raw.split(maxsplit=1)
|
|
14
|
+
if len(parts) == 2:
|
|
15
|
+
return parts[0], parts[1]
|
|
16
|
+
return raw, None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def validate_initialize_metadata(payload: InitializeResponse) -> InitializeResponse:
|
|
20
|
+
user_agent = (payload.userAgent or "").strip()
|
|
21
|
+
server = payload.serverInfo
|
|
22
|
+
|
|
23
|
+
server_name: str | None = None
|
|
24
|
+
server_version: str | None = None
|
|
25
|
+
|
|
26
|
+
if server is not None:
|
|
27
|
+
server_name = (server.name or "").strip() or None
|
|
28
|
+
server_version = (server.version or "").strip() or None
|
|
29
|
+
|
|
30
|
+
if (server_name is None or server_version is None) and user_agent:
|
|
31
|
+
parsed_name, parsed_version = _split_user_agent(user_agent)
|
|
32
|
+
if server_name is None:
|
|
33
|
+
server_name = parsed_name
|
|
34
|
+
if server_version is None:
|
|
35
|
+
server_version = parsed_version
|
|
36
|
+
|
|
37
|
+
normalized_server_name = (server_name or "").strip()
|
|
38
|
+
normalized_server_version = (server_version or "").strip()
|
|
39
|
+
if not user_agent or not normalized_server_name or not normalized_server_version:
|
|
40
|
+
raise RuntimeError(
|
|
41
|
+
"initialize response missing required metadata "
|
|
42
|
+
f"(user_agent={user_agent!r}, server_name={normalized_server_name!r}, server_version={normalized_server_version!r})"
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
if server is None:
|
|
46
|
+
payload.serverInfo = ServerInfo(
|
|
47
|
+
name=normalized_server_name,
|
|
48
|
+
version=normalized_server_version,
|
|
49
|
+
)
|
|
50
|
+
else:
|
|
51
|
+
server.name = normalized_server_name
|
|
52
|
+
server.version = normalized_server_version
|
|
53
|
+
|
|
54
|
+
return payload
|
openai_codex/_inputs.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
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 supplied to a turn or steering request."""
|
|
11
|
+
|
|
12
|
+
text: str
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(slots=True)
|
|
16
|
+
class ImageInput:
|
|
17
|
+
"""Remote image URL supplied as turn input."""
|
|
18
|
+
|
|
19
|
+
url: str
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(slots=True)
|
|
23
|
+
class LocalImageInput:
|
|
24
|
+
"""Local image path supplied as turn input."""
|
|
25
|
+
|
|
26
|
+
path: str
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(slots=True)
|
|
30
|
+
class SkillInput:
|
|
31
|
+
"""Named skill reference supplied as turn input."""
|
|
32
|
+
|
|
33
|
+
name: str
|
|
34
|
+
path: str
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(slots=True)
|
|
38
|
+
class MentionInput:
|
|
39
|
+
"""Named resource mention supplied as turn input."""
|
|
40
|
+
|
|
41
|
+
name: str
|
|
42
|
+
path: str
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
InputItem = TextInput | ImageInput | LocalImageInput | SkillInput | MentionInput
|
|
46
|
+
Input = list[InputItem] | InputItem
|
|
47
|
+
RunInput = Input | str
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _to_wire_item(item: InputItem) -> JsonObject:
|
|
51
|
+
if isinstance(item, TextInput):
|
|
52
|
+
return {"type": "text", "text": item.text}
|
|
53
|
+
if isinstance(item, ImageInput):
|
|
54
|
+
return {"type": "image", "url": item.url}
|
|
55
|
+
if isinstance(item, LocalImageInput):
|
|
56
|
+
return {"type": "localImage", "path": item.path}
|
|
57
|
+
if isinstance(item, SkillInput):
|
|
58
|
+
return {"type": "skill", "name": item.name, "path": item.path}
|
|
59
|
+
if isinstance(item, MentionInput):
|
|
60
|
+
return {"type": "mention", "name": item.name, "path": item.path}
|
|
61
|
+
raise TypeError(f"unsupported input item: {type(item)!r}")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _to_wire_input(input: Input) -> list[JsonObject]:
|
|
65
|
+
if isinstance(input, list):
|
|
66
|
+
return [_to_wire_item(i) for i in input]
|
|
67
|
+
return [_to_wire_item(input)]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _normalize_run_input(input: RunInput) -> Input:
|
|
71
|
+
if isinstance(input, str):
|
|
72
|
+
return TextInput(input)
|
|
73
|
+
return input
|
openai_codex/_login.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Protocol
|
|
5
|
+
|
|
6
|
+
from .async_client import AsyncCodexClient
|
|
7
|
+
from .client import CodexClient
|
|
8
|
+
from .generated.v2_all import (
|
|
9
|
+
AccountLoginCompletedNotification,
|
|
10
|
+
CancelLoginAccountResponse,
|
|
11
|
+
ChatgptDeviceCodeLoginAccountParams,
|
|
12
|
+
ChatgptDeviceCodeLoginAccountResponse,
|
|
13
|
+
ChatgptLoginAccountParams,
|
|
14
|
+
ChatgptLoginAccountResponse,
|
|
15
|
+
LoginAccountParams,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class _AsyncLoginOwner(Protocol):
|
|
20
|
+
"""Subset of AsyncCodex needed by async login handles."""
|
|
21
|
+
|
|
22
|
+
_client: AsyncCodexClient
|
|
23
|
+
|
|
24
|
+
async def _ensure_initialized(self) -> None:
|
|
25
|
+
"""Ensure the owning SDK client has a live Codex connection."""
|
|
26
|
+
...
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def start_chatgpt_login(client: CodexClient) -> ChatgptLoginHandle:
|
|
30
|
+
"""Start browser ChatGPT login and return the handle for that attempt."""
|
|
31
|
+
response = client.account_login_start(
|
|
32
|
+
LoginAccountParams(
|
|
33
|
+
root=ChatgptLoginAccountParams(type="chatgpt"),
|
|
34
|
+
)
|
|
35
|
+
)
|
|
36
|
+
response_root = response.root
|
|
37
|
+
if not isinstance(response_root, ChatgptLoginAccountResponse):
|
|
38
|
+
raise RuntimeError(f"unexpected ChatGPT login response: {response_root!r}")
|
|
39
|
+
return ChatgptLoginHandle(
|
|
40
|
+
client,
|
|
41
|
+
response_root.login_id,
|
|
42
|
+
response_root.auth_url,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
async def async_start_chatgpt_login(owner: _AsyncLoginOwner) -> AsyncChatgptLoginHandle:
|
|
47
|
+
"""Start async browser ChatGPT login and return that attempt's handle."""
|
|
48
|
+
response = await owner._client.account_login_start(
|
|
49
|
+
LoginAccountParams(
|
|
50
|
+
root=ChatgptLoginAccountParams(type="chatgpt"),
|
|
51
|
+
)
|
|
52
|
+
)
|
|
53
|
+
response_root = response.root
|
|
54
|
+
if not isinstance(response_root, ChatgptLoginAccountResponse):
|
|
55
|
+
raise RuntimeError(f"unexpected ChatGPT login response: {response_root!r}")
|
|
56
|
+
return AsyncChatgptLoginHandle(
|
|
57
|
+
owner,
|
|
58
|
+
response_root.login_id,
|
|
59
|
+
response_root.auth_url,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def start_device_code_login(client: CodexClient) -> DeviceCodeLoginHandle:
|
|
64
|
+
"""Start device-code ChatGPT login and return the handle for that attempt."""
|
|
65
|
+
response = client.account_login_start(
|
|
66
|
+
LoginAccountParams(
|
|
67
|
+
root=ChatgptDeviceCodeLoginAccountParams(type="chatgptDeviceCode"),
|
|
68
|
+
)
|
|
69
|
+
)
|
|
70
|
+
response_root = response.root
|
|
71
|
+
if not isinstance(response_root, ChatgptDeviceCodeLoginAccountResponse):
|
|
72
|
+
raise RuntimeError(f"unexpected device-code login response: {response_root!r}")
|
|
73
|
+
return DeviceCodeLoginHandle(
|
|
74
|
+
client,
|
|
75
|
+
response_root.login_id,
|
|
76
|
+
response_root.verification_url,
|
|
77
|
+
response_root.user_code,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
async def async_start_device_code_login(
|
|
82
|
+
owner: _AsyncLoginOwner,
|
|
83
|
+
) -> AsyncDeviceCodeLoginHandle:
|
|
84
|
+
"""Start async device-code ChatGPT login and return that attempt's handle."""
|
|
85
|
+
response = await owner._client.account_login_start(
|
|
86
|
+
LoginAccountParams(
|
|
87
|
+
root=ChatgptDeviceCodeLoginAccountParams(type="chatgptDeviceCode"),
|
|
88
|
+
)
|
|
89
|
+
)
|
|
90
|
+
response_root = response.root
|
|
91
|
+
if not isinstance(response_root, ChatgptDeviceCodeLoginAccountResponse):
|
|
92
|
+
raise RuntimeError(f"unexpected device-code login response: {response_root!r}")
|
|
93
|
+
return AsyncDeviceCodeLoginHandle(
|
|
94
|
+
owner,
|
|
95
|
+
response_root.login_id,
|
|
96
|
+
response_root.verification_url,
|
|
97
|
+
response_root.user_code,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass(slots=True)
|
|
102
|
+
class ChatgptLoginHandle:
|
|
103
|
+
"""Live browser-login attempt returned by `Codex.login_chatgpt()`."""
|
|
104
|
+
|
|
105
|
+
_client: CodexClient
|
|
106
|
+
login_id: str
|
|
107
|
+
auth_url: str
|
|
108
|
+
|
|
109
|
+
def wait(self) -> AccountLoginCompletedNotification:
|
|
110
|
+
"""Wait for this browser login attempt's completion notification."""
|
|
111
|
+
return self._client.wait_for_login_completed(self.login_id)
|
|
112
|
+
|
|
113
|
+
def cancel(self) -> CancelLoginAccountResponse:
|
|
114
|
+
"""Cancel this browser login attempt."""
|
|
115
|
+
return self._client.account_login_cancel(self.login_id)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclass(slots=True)
|
|
119
|
+
class DeviceCodeLoginHandle:
|
|
120
|
+
"""Live device-code login attempt returned by `Codex.login_chatgpt_device_code()`."""
|
|
121
|
+
|
|
122
|
+
_client: CodexClient
|
|
123
|
+
login_id: str
|
|
124
|
+
verification_url: str
|
|
125
|
+
user_code: str
|
|
126
|
+
|
|
127
|
+
def wait(self) -> AccountLoginCompletedNotification:
|
|
128
|
+
"""Wait for this device-code login attempt's completion notification."""
|
|
129
|
+
return self._client.wait_for_login_completed(self.login_id)
|
|
130
|
+
|
|
131
|
+
def cancel(self) -> CancelLoginAccountResponse:
|
|
132
|
+
"""Cancel this device-code login attempt."""
|
|
133
|
+
return self._client.account_login_cancel(self.login_id)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@dataclass(slots=True)
|
|
137
|
+
class AsyncChatgptLoginHandle:
|
|
138
|
+
"""Live browser-login attempt returned by `AsyncCodex.login_chatgpt()`."""
|
|
139
|
+
|
|
140
|
+
_codex: _AsyncLoginOwner
|
|
141
|
+
login_id: str
|
|
142
|
+
auth_url: str
|
|
143
|
+
|
|
144
|
+
async def wait(self) -> AccountLoginCompletedNotification:
|
|
145
|
+
"""Wait for this browser login attempt's completion notification."""
|
|
146
|
+
await self._codex._ensure_initialized()
|
|
147
|
+
return await self._codex._client.wait_for_login_completed(self.login_id)
|
|
148
|
+
|
|
149
|
+
async def cancel(self) -> CancelLoginAccountResponse:
|
|
150
|
+
"""Cancel this browser login attempt."""
|
|
151
|
+
await self._codex._ensure_initialized()
|
|
152
|
+
return await self._codex._client.account_login_cancel(self.login_id)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@dataclass(slots=True)
|
|
156
|
+
class AsyncDeviceCodeLoginHandle:
|
|
157
|
+
"""Live device-code attempt returned by `AsyncCodex.login_chatgpt_device_code()`."""
|
|
158
|
+
|
|
159
|
+
_codex: _AsyncLoginOwner
|
|
160
|
+
login_id: str
|
|
161
|
+
verification_url: str
|
|
162
|
+
user_code: str
|
|
163
|
+
|
|
164
|
+
async def wait(self) -> AccountLoginCompletedNotification:
|
|
165
|
+
"""Wait for this device-code login attempt's completion notification."""
|
|
166
|
+
await self._codex._ensure_initialized()
|
|
167
|
+
return await self._codex._client.wait_for_login_completed(self.login_id)
|
|
168
|
+
|
|
169
|
+
async def cancel(self) -> CancelLoginAccountResponse:
|
|
170
|
+
"""Cancel this device-code login attempt."""
|
|
171
|
+
await self._codex._ensure_initialized()
|
|
172
|
+
return await self._codex._client.account_login_cancel(self.login_id)
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import queue
|
|
4
|
+
import threading
|
|
5
|
+
from collections import deque
|
|
6
|
+
|
|
7
|
+
from .errors import CodexError, map_jsonrpc_error
|
|
8
|
+
from .generated.notification_registry import notification_turn_id
|
|
9
|
+
from .generated.v2_all import AccountLoginCompletedNotification
|
|
10
|
+
from .models import JsonValue, Notification, UnknownNotification
|
|
11
|
+
|
|
12
|
+
ResponseQueueItem = JsonValue | BaseException
|
|
13
|
+
NotificationQueueItem = Notification | BaseException
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class MessageRouter:
|
|
17
|
+
"""Route reader-thread messages to the SDK operation waiting for them.
|
|
18
|
+
|
|
19
|
+
The app-server stdio transport is a single ordered stream, so only the
|
|
20
|
+
reader thread should consume stdout. This router keeps the rest of the SDK
|
|
21
|
+
from competing for that stream by giving each in-flight JSON-RPC request
|
|
22
|
+
and active turn stream its own queue.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self) -> None:
|
|
26
|
+
"""Create empty response, turn, and global notification queues."""
|
|
27
|
+
self._lock = threading.Lock()
|
|
28
|
+
self._response_waiters: dict[str, queue.Queue[ResponseQueueItem]] = {}
|
|
29
|
+
self._login_notifications: dict[str, queue.Queue[NotificationQueueItem]] = {}
|
|
30
|
+
self._pending_login_notifications: dict[str, deque[Notification]] = {}
|
|
31
|
+
self._turn_notifications: dict[str, queue.Queue[NotificationQueueItem]] = {}
|
|
32
|
+
self._pending_turn_notifications: dict[str, deque[Notification]] = {}
|
|
33
|
+
self._global_notifications: queue.Queue[NotificationQueueItem] = queue.Queue()
|
|
34
|
+
|
|
35
|
+
def create_response_waiter(self, request_id: str) -> queue.Queue[ResponseQueueItem]:
|
|
36
|
+
"""Register a one-shot queue for a JSON-RPC response id."""
|
|
37
|
+
|
|
38
|
+
waiter: queue.Queue[ResponseQueueItem] = queue.Queue(maxsize=1)
|
|
39
|
+
with self._lock:
|
|
40
|
+
self._response_waiters[request_id] = waiter
|
|
41
|
+
return waiter
|
|
42
|
+
|
|
43
|
+
def discard_response_waiter(self, request_id: str) -> None:
|
|
44
|
+
"""Remove a response waiter when the request could not be written."""
|
|
45
|
+
|
|
46
|
+
with self._lock:
|
|
47
|
+
self._response_waiters.pop(request_id, None)
|
|
48
|
+
|
|
49
|
+
def next_global_notification(self) -> Notification:
|
|
50
|
+
"""Block until the next notification that is not scoped to a turn."""
|
|
51
|
+
|
|
52
|
+
item = self._global_notifications.get()
|
|
53
|
+
if isinstance(item, BaseException):
|
|
54
|
+
raise item
|
|
55
|
+
return item
|
|
56
|
+
|
|
57
|
+
def register_login(self, login_id: str) -> None:
|
|
58
|
+
"""Register a queue for one interactive login attempt."""
|
|
59
|
+
|
|
60
|
+
login_queue: queue.Queue[NotificationQueueItem] = queue.Queue()
|
|
61
|
+
with self._lock:
|
|
62
|
+
if login_id in self._login_notifications:
|
|
63
|
+
return
|
|
64
|
+
pending = self._pending_login_notifications.pop(login_id, deque())
|
|
65
|
+
self._login_notifications[login_id] = login_queue
|
|
66
|
+
for notification in pending:
|
|
67
|
+
login_queue.put(notification)
|
|
68
|
+
|
|
69
|
+
def unregister_login(self, login_id: str) -> None:
|
|
70
|
+
"""Stop routing future notifications for one login attempt."""
|
|
71
|
+
|
|
72
|
+
with self._lock:
|
|
73
|
+
self._login_notifications.pop(login_id, None)
|
|
74
|
+
|
|
75
|
+
def next_login_notification(self, login_id: str) -> Notification:
|
|
76
|
+
"""Block until the next notification for a registered login attempt."""
|
|
77
|
+
|
|
78
|
+
with self._lock:
|
|
79
|
+
login_queue = self._login_notifications.get(login_id)
|
|
80
|
+
if login_queue is None:
|
|
81
|
+
raise RuntimeError(f"login {login_id!r} is not registered for waiting")
|
|
82
|
+
item = login_queue.get()
|
|
83
|
+
if isinstance(item, BaseException):
|
|
84
|
+
raise item
|
|
85
|
+
return item
|
|
86
|
+
|
|
87
|
+
def register_turn(self, turn_id: str) -> None:
|
|
88
|
+
"""Register a queue for a turn stream and replay early events."""
|
|
89
|
+
|
|
90
|
+
turn_queue: queue.Queue[NotificationQueueItem] = queue.Queue()
|
|
91
|
+
with self._lock:
|
|
92
|
+
if turn_id in self._turn_notifications:
|
|
93
|
+
return
|
|
94
|
+
# A turn can emit events immediately after turn/start, before the
|
|
95
|
+
# caller receives the TurnHandle and starts streaming.
|
|
96
|
+
pending = self._pending_turn_notifications.pop(turn_id, deque())
|
|
97
|
+
self._turn_notifications[turn_id] = turn_queue
|
|
98
|
+
for notification in pending:
|
|
99
|
+
turn_queue.put(notification)
|
|
100
|
+
|
|
101
|
+
def unregister_turn(self, turn_id: str) -> None:
|
|
102
|
+
"""Stop routing future turn events to the stream queue."""
|
|
103
|
+
|
|
104
|
+
with self._lock:
|
|
105
|
+
self._turn_notifications.pop(turn_id, None)
|
|
106
|
+
|
|
107
|
+
def next_turn_notification(self, turn_id: str) -> Notification:
|
|
108
|
+
"""Block until the next notification for a registered turn."""
|
|
109
|
+
|
|
110
|
+
with self._lock:
|
|
111
|
+
turn_queue = self._turn_notifications.get(turn_id)
|
|
112
|
+
if turn_queue is None:
|
|
113
|
+
raise RuntimeError(f"turn {turn_id!r} is not registered for streaming")
|
|
114
|
+
item = turn_queue.get()
|
|
115
|
+
if isinstance(item, BaseException):
|
|
116
|
+
raise item
|
|
117
|
+
return item
|
|
118
|
+
|
|
119
|
+
def route_response(self, msg: dict[str, JsonValue]) -> None:
|
|
120
|
+
"""Deliver a JSON-RPC response or error to its request waiter."""
|
|
121
|
+
|
|
122
|
+
request_id = msg.get("id")
|
|
123
|
+
with self._lock:
|
|
124
|
+
waiter = self._response_waiters.pop(str(request_id), None)
|
|
125
|
+
if waiter is None:
|
|
126
|
+
return
|
|
127
|
+
|
|
128
|
+
if "error" in msg:
|
|
129
|
+
err = msg["error"]
|
|
130
|
+
if isinstance(err, dict):
|
|
131
|
+
waiter.put(
|
|
132
|
+
map_jsonrpc_error(
|
|
133
|
+
int(err.get("code", -32000)),
|
|
134
|
+
str(err.get("message", "unknown")),
|
|
135
|
+
err.get("data"),
|
|
136
|
+
)
|
|
137
|
+
)
|
|
138
|
+
else:
|
|
139
|
+
waiter.put(CodexError("Malformed JSON-RPC error response"))
|
|
140
|
+
return
|
|
141
|
+
|
|
142
|
+
waiter.put(msg.get("result"))
|
|
143
|
+
|
|
144
|
+
def route_notification(self, notification: Notification) -> None:
|
|
145
|
+
"""Deliver a notification to a turn queue or the global queue."""
|
|
146
|
+
|
|
147
|
+
login_id = self._notification_login_id(notification)
|
|
148
|
+
if login_id is not None:
|
|
149
|
+
with self._lock:
|
|
150
|
+
login_queue = self._login_notifications.get(login_id)
|
|
151
|
+
if login_queue is None:
|
|
152
|
+
self._pending_login_notifications.setdefault(login_id, deque()).append(
|
|
153
|
+
notification
|
|
154
|
+
)
|
|
155
|
+
return
|
|
156
|
+
login_queue.put(notification)
|
|
157
|
+
return
|
|
158
|
+
|
|
159
|
+
turn_id = self._notification_turn_id(notification)
|
|
160
|
+
if turn_id is None:
|
|
161
|
+
self._global_notifications.put(notification)
|
|
162
|
+
return
|
|
163
|
+
|
|
164
|
+
with self._lock:
|
|
165
|
+
turn_queue = self._turn_notifications.get(turn_id)
|
|
166
|
+
if turn_queue is None:
|
|
167
|
+
if notification.method == "turn/completed":
|
|
168
|
+
self._pending_turn_notifications.pop(turn_id, None)
|
|
169
|
+
return
|
|
170
|
+
self._pending_turn_notifications.setdefault(turn_id, deque()).append(notification)
|
|
171
|
+
return
|
|
172
|
+
turn_queue.put(notification)
|
|
173
|
+
|
|
174
|
+
def fail_all(self, exc: BaseException) -> None:
|
|
175
|
+
"""Wake every blocked waiter when the reader thread exits."""
|
|
176
|
+
|
|
177
|
+
with self._lock:
|
|
178
|
+
response_waiters = list(self._response_waiters.values())
|
|
179
|
+
self._response_waiters.clear()
|
|
180
|
+
login_queues = list(self._login_notifications.values())
|
|
181
|
+
self._login_notifications.clear()
|
|
182
|
+
self._pending_login_notifications.clear()
|
|
183
|
+
turn_queues = list(self._turn_notifications.values())
|
|
184
|
+
self._pending_turn_notifications.clear()
|
|
185
|
+
# Put the same transport failure into every queue so no SDK call blocks
|
|
186
|
+
# forever waiting for a response that cannot arrive.
|
|
187
|
+
for waiter in response_waiters:
|
|
188
|
+
waiter.put(exc)
|
|
189
|
+
for login_queue in login_queues:
|
|
190
|
+
login_queue.put(exc)
|
|
191
|
+
for turn_queue in turn_queues:
|
|
192
|
+
turn_queue.put(exc)
|
|
193
|
+
self._global_notifications.put(exc)
|
|
194
|
+
|
|
195
|
+
def _notification_login_id(self, notification: Notification) -> str | None:
|
|
196
|
+
"""Extract the login attempt id from completion notifications."""
|
|
197
|
+
if notification.method != "account/login/completed":
|
|
198
|
+
return None
|
|
199
|
+
|
|
200
|
+
payload = notification.payload
|
|
201
|
+
if isinstance(payload, AccountLoginCompletedNotification):
|
|
202
|
+
return payload.login_id
|
|
203
|
+
if isinstance(payload, UnknownNotification):
|
|
204
|
+
raw_login_id = payload.params.get("loginId")
|
|
205
|
+
if isinstance(raw_login_id, str):
|
|
206
|
+
return raw_login_id
|
|
207
|
+
return None
|
|
208
|
+
|
|
209
|
+
def _notification_turn_id(self, notification: Notification) -> str | None:
|
|
210
|
+
"""Extract routing ids from known generated payloads or raw unknown payloads."""
|
|
211
|
+
payload = notification.payload
|
|
212
|
+
if isinstance(payload, UnknownNotification):
|
|
213
|
+
raw_turn_id = payload.params.get("turnId")
|
|
214
|
+
if isinstance(raw_turn_id, str):
|
|
215
|
+
return raw_turn_id
|
|
216
|
+
raw_turn = payload.params.get("turn")
|
|
217
|
+
if isinstance(raw_turn, dict):
|
|
218
|
+
raw_nested_turn_id = raw_turn.get("id")
|
|
219
|
+
if isinstance(raw_nested_turn_id, str):
|
|
220
|
+
return raw_nested_turn_id
|
|
221
|
+
return None
|
|
222
|
+
return notification_turn_id(payload)
|