python-codex 0.0.1__py3-none-any.whl → 0.1.1__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.
- pycodex/__init__.py +141 -2
- pycodex/agent.py +290 -0
- pycodex/cli.py +705 -0
- pycodex/collaboration.py +21 -0
- pycodex/context.py +580 -0
- pycodex/doctor.py +360 -0
- pycodex/model.py +533 -0
- pycodex/portable.py +390 -0
- pycodex/portable_server.py +205 -0
- pycodex/prompts/collaboration_default.md +11 -0
- pycodex/prompts/collaboration_plan.md +128 -0
- pycodex/prompts/default_base_instructions.md +275 -0
- pycodex/prompts/exec_tools.json +411 -0
- pycodex/prompts/models.json +847 -0
- pycodex/prompts/permissions/approval_policy/never.md +1 -0
- pycodex/prompts/permissions/approval_policy/on_failure.md +1 -0
- pycodex/prompts/permissions/approval_policy/on_request.md +57 -0
- pycodex/prompts/permissions/approval_policy/on_request_rule_request_permission.md +33 -0
- pycodex/prompts/permissions/approval_policy/unless_trusted.md +1 -0
- pycodex/prompts/permissions/sandbox_mode/danger_full_access.md +1 -0
- pycodex/prompts/permissions/sandbox_mode/read_only.md +1 -0
- pycodex/prompts/permissions/sandbox_mode/workspace_write.md +1 -0
- pycodex/prompts/subagent_tools.json +163 -0
- pycodex/protocol.py +347 -0
- pycodex/runtime.py +204 -0
- pycodex/runtime_services.py +409 -0
- pycodex/tools/__init__.py +58 -0
- pycodex/tools/agent_tool_schemas.py +70 -0
- pycodex/tools/apply_patch_tool.py +363 -0
- pycodex/tools/base_tool.py +168 -0
- pycodex/tools/close_agent_tool.py +55 -0
- pycodex/tools/code_mode_manager.py +519 -0
- pycodex/tools/exec_command_tool.py +96 -0
- pycodex/tools/exec_runtime.js +161 -0
- pycodex/tools/exec_tool.py +48 -0
- pycodex/tools/grep_files_tool.py +150 -0
- pycodex/tools/list_dir_tool.py +135 -0
- pycodex/tools/read_file_tool.py +217 -0
- pycodex/tools/request_permissions_tool.py +95 -0
- pycodex/tools/request_user_input_tool.py +167 -0
- pycodex/tools/resume_agent_tool.py +56 -0
- pycodex/tools/send_input_tool.py +106 -0
- pycodex/tools/shell_command_tool.py +107 -0
- pycodex/tools/shell_tool.py +112 -0
- pycodex/tools/spawn_agent_tool.py +97 -0
- pycodex/tools/unified_exec_manager.py +380 -0
- pycodex/tools/update_plan_tool.py +79 -0
- pycodex/tools/view_image_tool.py +111 -0
- pycodex/tools/wait_agent_tool.py +75 -0
- pycodex/tools/wait_tool.py +68 -0
- pycodex/tools/web_search_tool.py +30 -0
- pycodex/tools/write_stdin_tool.py +75 -0
- pycodex/utils/__init__.py +40 -0
- pycodex/utils/dotenv.py +64 -0
- pycodex/utils/get_env.py +218 -0
- pycodex/utils/random_ids.py +19 -0
- pycodex/utils/visualize.py +978 -0
- python_codex-0.1.1.dist-info/METADATA +355 -0
- python_codex-0.1.1.dist-info/RECORD +62 -0
- python_codex-0.1.1.dist-info/entry_points.txt +2 -0
- python_codex-0.1.1.dist-info/licenses/LICENSE +201 -0
- python_codex-0.0.1.dist-info/METADATA +0 -30
- python_codex-0.0.1.dist-info/RECORD +0 -4
- {python_codex-0.0.1.dist-info → python_codex-0.1.1.dist-info}/WHEEL +0 -0
pycodex/model.py
ADDED
|
@@ -0,0 +1,533 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import urllib.parse
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from dataclasses import dataclass, field, replace
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Protocol
|
|
11
|
+
|
|
12
|
+
import requests
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
import tomllib
|
|
16
|
+
except ModuleNotFoundError: # pragma: no cover - Python 3.10 path
|
|
17
|
+
import tomli as tomllib
|
|
18
|
+
|
|
19
|
+
from .protocol import (
|
|
20
|
+
AssistantMessage,
|
|
21
|
+
ModelResponse,
|
|
22
|
+
ModelStreamEvent,
|
|
23
|
+
Prompt,
|
|
24
|
+
ReasoningItem,
|
|
25
|
+
ToolCall,
|
|
26
|
+
)
|
|
27
|
+
from .utils import build_user_agent, uuid7_string
|
|
28
|
+
|
|
29
|
+
DEFAULT_CODEX_CONFIG_PATH = Path.home() / ".codex" / "config.toml"
|
|
30
|
+
DEFAULT_ORIGINATOR = "pycodex"
|
|
31
|
+
ModelStreamEventHandler = Callable[[ModelStreamEvent], None]
|
|
32
|
+
NOOP_MODEL_STREAM_EVENT_HANDLER: ModelStreamEventHandler = lambda _event: None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ModelClient(Protocol):
|
|
36
|
+
async def complete(
|
|
37
|
+
self,
|
|
38
|
+
prompt: Prompt,
|
|
39
|
+
event_handler: ModelStreamEventHandler = NOOP_MODEL_STREAM_EVENT_HANDLER,
|
|
40
|
+
) -> ModelResponse:
|
|
41
|
+
"""Return the next batch of model output items for the current prompt."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True, slots=True)
|
|
45
|
+
class ResponsesProviderConfig:
|
|
46
|
+
model: str
|
|
47
|
+
provider_name: str
|
|
48
|
+
base_url: str
|
|
49
|
+
api_key_env: str
|
|
50
|
+
wire_api: str = "responses"
|
|
51
|
+
query_params: dict[str, str] = field(default_factory=dict)
|
|
52
|
+
reasoning_effort: str | None = None
|
|
53
|
+
reasoning_summary: str | None = None
|
|
54
|
+
verbosity: str | None = None
|
|
55
|
+
sandbox_mode: str | None = None
|
|
56
|
+
beta_features_header: str | None = None
|
|
57
|
+
|
|
58
|
+
@classmethod
|
|
59
|
+
def from_codex_config(
|
|
60
|
+
cls,
|
|
61
|
+
config_path: str | Path = DEFAULT_CODEX_CONFIG_PATH,
|
|
62
|
+
profile: str | None = None,
|
|
63
|
+
) -> ResponsesProviderConfig:
|
|
64
|
+
data = tomllib.loads(Path(config_path).read_text())
|
|
65
|
+
selected = dict(data)
|
|
66
|
+
if profile is not None:
|
|
67
|
+
overrides = data.get("profiles", {}).get(profile)
|
|
68
|
+
if overrides is None:
|
|
69
|
+
raise ValueError(f"unknown Codex profile: {profile}")
|
|
70
|
+
selected.update(overrides)
|
|
71
|
+
|
|
72
|
+
provider_name = selected["model_provider"]
|
|
73
|
+
provider = data["model_providers"][provider_name]
|
|
74
|
+
wire_api = provider.get("wire_api", "responses")
|
|
75
|
+
if wire_api != "responses":
|
|
76
|
+
raise ValueError(f"unsupported wire_api for Python client: {wire_api}")
|
|
77
|
+
|
|
78
|
+
api_key_env = provider.get("env_key")
|
|
79
|
+
if not api_key_env:
|
|
80
|
+
raise ValueError(
|
|
81
|
+
f"provider {provider_name} does not define env_key in Codex config"
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
query_params = {
|
|
85
|
+
str(key): str(value)
|
|
86
|
+
for key, value in provider.get("query_params", {}).items()
|
|
87
|
+
}
|
|
88
|
+
features = selected.get("features", {})
|
|
89
|
+
beta_features: list[str] = []
|
|
90
|
+
if isinstance(features, dict) and features.get("guardian_approval") is True:
|
|
91
|
+
beta_features.append("guardian_approval")
|
|
92
|
+
return cls(
|
|
93
|
+
model=selected["model"],
|
|
94
|
+
provider_name=provider_name,
|
|
95
|
+
base_url=provider["base_url"],
|
|
96
|
+
api_key_env=api_key_env,
|
|
97
|
+
wire_api=wire_api,
|
|
98
|
+
query_params=query_params,
|
|
99
|
+
reasoning_effort=selected.get("model_reasoning_effort"),
|
|
100
|
+
reasoning_summary=selected.get("model_reasoning_summary"),
|
|
101
|
+
verbosity=selected.get("model_verbosity"),
|
|
102
|
+
sandbox_mode=selected.get("sandbox_mode"),
|
|
103
|
+
beta_features_header=",".join(beta_features) or None,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
def api_key(self) -> str:
|
|
107
|
+
value = os.environ.get(self.api_key_env, "")
|
|
108
|
+
if not value:
|
|
109
|
+
raise RuntimeError(
|
|
110
|
+
f"missing API key environment variable: {self.api_key_env}"
|
|
111
|
+
)
|
|
112
|
+
return value
|
|
113
|
+
|
|
114
|
+
def with_overrides(
|
|
115
|
+
self,
|
|
116
|
+
model: str | None = None,
|
|
117
|
+
reasoning_effort: str | None = None,
|
|
118
|
+
) -> ResponsesProviderConfig:
|
|
119
|
+
return replace(
|
|
120
|
+
self,
|
|
121
|
+
model=self.model if model is None else model,
|
|
122
|
+
reasoning_effort=(
|
|
123
|
+
self.reasoning_effort
|
|
124
|
+
if reasoning_effort is None
|
|
125
|
+
else reasoning_effort
|
|
126
|
+
),
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class ResponsesApiError(RuntimeError):
|
|
131
|
+
pass
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class ResponsesModelClient:
|
|
135
|
+
"""Minimal OpenAI-compatible Responses API client.
|
|
136
|
+
|
|
137
|
+
This implementation is intentionally narrow: it supports the subset needed
|
|
138
|
+
by the current AgentLoop abstraction, namely assistant text and function
|
|
139
|
+
tool calls over the streaming `/responses` endpoint.
|
|
140
|
+
"""
|
|
141
|
+
|
|
142
|
+
def __init__(
|
|
143
|
+
self,
|
|
144
|
+
config: ResponsesProviderConfig,
|
|
145
|
+
timeout_seconds: float = 120.0,
|
|
146
|
+
session_id: str | None = None,
|
|
147
|
+
originator: str = DEFAULT_ORIGINATOR,
|
|
148
|
+
user_agent: str | None = None,
|
|
149
|
+
openai_subagent: str | None = None,
|
|
150
|
+
) -> None:
|
|
151
|
+
self._config = config
|
|
152
|
+
self.model = config.model
|
|
153
|
+
self._timeout_seconds = timeout_seconds
|
|
154
|
+
self._session_id = session_id or uuid7_string()
|
|
155
|
+
self._originator = originator
|
|
156
|
+
self._user_agent = user_agent or build_user_agent(originator)
|
|
157
|
+
self._openai_subagent = openai_subagent
|
|
158
|
+
|
|
159
|
+
@classmethod
|
|
160
|
+
def from_codex_config(
|
|
161
|
+
cls,
|
|
162
|
+
config_path: str | Path = DEFAULT_CODEX_CONFIG_PATH,
|
|
163
|
+
profile: str | None = None,
|
|
164
|
+
timeout_seconds: float = 120.0,
|
|
165
|
+
originator: str = DEFAULT_ORIGINATOR,
|
|
166
|
+
user_agent: str | None = None,
|
|
167
|
+
) -> ResponsesModelClient:
|
|
168
|
+
config = ResponsesProviderConfig.from_codex_config(config_path, profile)
|
|
169
|
+
return cls(config, timeout_seconds, originator=originator, user_agent=user_agent)
|
|
170
|
+
|
|
171
|
+
def with_overrides(
|
|
172
|
+
self,
|
|
173
|
+
model: str | None = None,
|
|
174
|
+
reasoning_effort: str | None = None,
|
|
175
|
+
session_id: str | None = None,
|
|
176
|
+
openai_subagent: str | None = None,
|
|
177
|
+
) -> ResponsesModelClient:
|
|
178
|
+
return ResponsesModelClient(
|
|
179
|
+
self._config.with_overrides(
|
|
180
|
+
model or self.model,
|
|
181
|
+
reasoning_effort,
|
|
182
|
+
),
|
|
183
|
+
self._timeout_seconds,
|
|
184
|
+
session_id=self._session_id if session_id is None else session_id,
|
|
185
|
+
originator=self._originator,
|
|
186
|
+
user_agent=self._user_agent,
|
|
187
|
+
openai_subagent=(
|
|
188
|
+
self._openai_subagent
|
|
189
|
+
if openai_subagent is None
|
|
190
|
+
else openai_subagent
|
|
191
|
+
),
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
def responses_url(self) -> str:
|
|
195
|
+
base_url = self._config.base_url.rstrip("/")
|
|
196
|
+
url = f"{base_url}/responses"
|
|
197
|
+
if self._config.query_params:
|
|
198
|
+
return f"{url}?{urllib.parse.urlencode(self._config.query_params)}"
|
|
199
|
+
return url
|
|
200
|
+
|
|
201
|
+
def models_url(self) -> str:
|
|
202
|
+
base_url = self._config.base_url.rstrip("/")
|
|
203
|
+
url = f"{base_url}/models"
|
|
204
|
+
if self._config.query_params:
|
|
205
|
+
return f"{url}?{urllib.parse.urlencode(self._config.query_params)}"
|
|
206
|
+
return url
|
|
207
|
+
|
|
208
|
+
async def list_models(self) -> list[str]:
|
|
209
|
+
return await asyncio.to_thread(self._list_models_sync)
|
|
210
|
+
|
|
211
|
+
async def complete(
|
|
212
|
+
self,
|
|
213
|
+
prompt: Prompt,
|
|
214
|
+
event_handler: ModelStreamEventHandler = NOOP_MODEL_STREAM_EVENT_HANDLER,
|
|
215
|
+
) -> ModelResponse:
|
|
216
|
+
return await asyncio.to_thread(self._complete_sync, prompt, event_handler)
|
|
217
|
+
|
|
218
|
+
def _complete_sync(
|
|
219
|
+
self,
|
|
220
|
+
prompt: Prompt,
|
|
221
|
+
event_handler: ModelStreamEventHandler,
|
|
222
|
+
) -> ModelResponse:
|
|
223
|
+
payload = self._build_payload(prompt)
|
|
224
|
+
body = json.dumps(payload).encode("utf-8")
|
|
225
|
+
url = self.responses_url()
|
|
226
|
+
prepared = requests.PreparedRequest()
|
|
227
|
+
prepared.prepare(
|
|
228
|
+
method="POST",
|
|
229
|
+
url=url,
|
|
230
|
+
headers=self._build_headers(prompt),
|
|
231
|
+
data=body,
|
|
232
|
+
)
|
|
233
|
+
try:
|
|
234
|
+
with requests.Session() as session:
|
|
235
|
+
settings = session.merge_environment_settings(
|
|
236
|
+
prepared.url,
|
|
237
|
+
proxies={},
|
|
238
|
+
stream=True,
|
|
239
|
+
verify=None,
|
|
240
|
+
cert=None,
|
|
241
|
+
)
|
|
242
|
+
verify = _requests_verify_setting()
|
|
243
|
+
if verify is not None:
|
|
244
|
+
settings["verify"] = verify
|
|
245
|
+
response = session.send(
|
|
246
|
+
prepared,
|
|
247
|
+
timeout=self._timeout_seconds,
|
|
248
|
+
allow_redirects=False,
|
|
249
|
+
**settings,
|
|
250
|
+
)
|
|
251
|
+
with response:
|
|
252
|
+
if response.status_code >= 400:
|
|
253
|
+
error_body = response.text
|
|
254
|
+
raise ResponsesApiError(
|
|
255
|
+
f"responses request failed with status {response.status_code}: "
|
|
256
|
+
f"{error_body[:500]}"
|
|
257
|
+
)
|
|
258
|
+
return self._parse_stream(
|
|
259
|
+
response.iter_lines(chunk_size=1, decode_unicode=False),
|
|
260
|
+
event_handler,
|
|
261
|
+
)
|
|
262
|
+
except requests.RequestException as exc:
|
|
263
|
+
raise ResponsesApiError(f"responses request failed: {exc}") from exc
|
|
264
|
+
|
|
265
|
+
def _build_payload(self, prompt: Prompt) -> dict[str, object]:
|
|
266
|
+
payload: dict[str, object] = {
|
|
267
|
+
"model": self.model,
|
|
268
|
+
"instructions": prompt.base_instructions or "",
|
|
269
|
+
"input": [item.serialize() for item in prompt.input],
|
|
270
|
+
"tools": [tool.serialize() for tool in prompt.tools],
|
|
271
|
+
"tool_choice": "auto",
|
|
272
|
+
"parallel_tool_calls": prompt.parallel_tool_calls,
|
|
273
|
+
"store": False,
|
|
274
|
+
"stream": True,
|
|
275
|
+
"include": ["reasoning.encrypted_content"],
|
|
276
|
+
"prompt_cache_key": self._session_id,
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
reasoning: dict[str, str] = {}
|
|
280
|
+
if self._config.reasoning_effort is not None:
|
|
281
|
+
reasoning["effort"] = self._config.reasoning_effort
|
|
282
|
+
if self._config.reasoning_summary is not None:
|
|
283
|
+
reasoning["summary"] = self._config.reasoning_summary
|
|
284
|
+
if reasoning:
|
|
285
|
+
payload["reasoning"] = reasoning
|
|
286
|
+
|
|
287
|
+
text = None
|
|
288
|
+
if self._config.verbosity is not None:
|
|
289
|
+
text = {"verbosity": self._config.verbosity}
|
|
290
|
+
if text is not None:
|
|
291
|
+
payload["text"] = text
|
|
292
|
+
|
|
293
|
+
return payload
|
|
294
|
+
|
|
295
|
+
def _list_models_sync(self) -> list[str]:
|
|
296
|
+
prepared = requests.PreparedRequest()
|
|
297
|
+
prepared.prepare(
|
|
298
|
+
method="GET",
|
|
299
|
+
url=self.models_url(),
|
|
300
|
+
headers=self._build_model_list_headers(),
|
|
301
|
+
)
|
|
302
|
+
try:
|
|
303
|
+
with requests.Session() as session:
|
|
304
|
+
settings = session.merge_environment_settings(
|
|
305
|
+
prepared.url,
|
|
306
|
+
proxies={},
|
|
307
|
+
stream=False,
|
|
308
|
+
verify=None,
|
|
309
|
+
cert=None,
|
|
310
|
+
)
|
|
311
|
+
verify = _requests_verify_setting()
|
|
312
|
+
if verify is not None:
|
|
313
|
+
settings["verify"] = verify
|
|
314
|
+
response = session.send(
|
|
315
|
+
prepared,
|
|
316
|
+
timeout=self._timeout_seconds,
|
|
317
|
+
allow_redirects=False,
|
|
318
|
+
**settings,
|
|
319
|
+
)
|
|
320
|
+
with response:
|
|
321
|
+
if response.status_code >= 400:
|
|
322
|
+
raise ResponsesApiError(
|
|
323
|
+
f"models request failed with status {response.status_code}: "
|
|
324
|
+
f"{response.text[:500]}"
|
|
325
|
+
)
|
|
326
|
+
payload = response.json()
|
|
327
|
+
except requests.RequestException as exc:
|
|
328
|
+
raise ResponsesApiError(f"models request failed: {exc}") from exc
|
|
329
|
+
|
|
330
|
+
data = payload.get("data")
|
|
331
|
+
if not isinstance(data, list):
|
|
332
|
+
raise ResponsesApiError("models response is missing `data` list")
|
|
333
|
+
models: list[str] = []
|
|
334
|
+
for item in data:
|
|
335
|
+
if not isinstance(item, dict):
|
|
336
|
+
continue
|
|
337
|
+
model_id = str(item.get("id", "")).strip()
|
|
338
|
+
if model_id:
|
|
339
|
+
models.append(model_id)
|
|
340
|
+
return models
|
|
341
|
+
|
|
342
|
+
def _build_headers(self, prompt: Prompt) -> dict[str, str]:
|
|
343
|
+
headers = {
|
|
344
|
+
"content-type": "application/json",
|
|
345
|
+
"accept": "text/event-stream",
|
|
346
|
+
"authorization": f"Bearer {self._config.api_key()}",
|
|
347
|
+
"x-client-request-id": self._session_id,
|
|
348
|
+
"session_id": self._session_id,
|
|
349
|
+
"originator": self._originator,
|
|
350
|
+
"user-agent": self._user_agent,
|
|
351
|
+
}
|
|
352
|
+
if self._config.beta_features_header is not None:
|
|
353
|
+
headers["x-codex-beta-features"] = self._config.beta_features_header
|
|
354
|
+
if self._openai_subagent is not None:
|
|
355
|
+
headers["x-openai-subagent"] = self._openai_subagent
|
|
356
|
+
if prompt.turn_metadata is not None:
|
|
357
|
+
headers["x-codex-turn-metadata"] = json.dumps(
|
|
358
|
+
prompt.turn_metadata,
|
|
359
|
+
separators=(",", ":"),
|
|
360
|
+
)
|
|
361
|
+
return headers
|
|
362
|
+
|
|
363
|
+
def _build_model_list_headers(self) -> dict[str, str]:
|
|
364
|
+
headers = {
|
|
365
|
+
"accept": "application/json",
|
|
366
|
+
"authorization": f"Bearer {self._config.api_key()}",
|
|
367
|
+
"originator": self._originator,
|
|
368
|
+
"user-agent": self._user_agent,
|
|
369
|
+
}
|
|
370
|
+
if self._config.beta_features_header is not None:
|
|
371
|
+
headers["x-codex-beta-features"] = self._config.beta_features_header
|
|
372
|
+
if self._openai_subagent is not None:
|
|
373
|
+
headers["x-openai-subagent"] = self._openai_subagent
|
|
374
|
+
return headers
|
|
375
|
+
|
|
376
|
+
def _parse_stream(
|
|
377
|
+
self,
|
|
378
|
+
response,
|
|
379
|
+
event_handler: ModelStreamEventHandler,
|
|
380
|
+
) -> ModelResponse:
|
|
381
|
+
items: list[AssistantMessage | ToolCall | ReasoningItem] = []
|
|
382
|
+
saw_completed = False
|
|
383
|
+
|
|
384
|
+
for event_name, data in self._iter_sse_events(response):
|
|
385
|
+
if not data:
|
|
386
|
+
continue
|
|
387
|
+
payload = json.loads(data)
|
|
388
|
+
event_type = payload.get("type", event_name)
|
|
389
|
+
|
|
390
|
+
if event_type == "response.output_text.delta":
|
|
391
|
+
event_handler(
|
|
392
|
+
ModelStreamEvent(
|
|
393
|
+
kind="assistant_delta",
|
|
394
|
+
payload={"delta": str(payload.get("delta", ""))},
|
|
395
|
+
)
|
|
396
|
+
)
|
|
397
|
+
continue
|
|
398
|
+
|
|
399
|
+
if event_type == "response.output_item.done":
|
|
400
|
+
item_payload = payload.get("item", {})
|
|
401
|
+
if (
|
|
402
|
+
isinstance(item_payload, dict)
|
|
403
|
+
and item_payload.get("type") == "web_search_call"
|
|
404
|
+
):
|
|
405
|
+
action_payload = item_payload.get("action")
|
|
406
|
+
event_payload = {
|
|
407
|
+
"call_id": str(item_payload.get("id", "web_search")),
|
|
408
|
+
"tool_name": "web_search",
|
|
409
|
+
}
|
|
410
|
+
if isinstance(action_payload, dict):
|
|
411
|
+
event_payload["action_type"] = str(
|
|
412
|
+
action_payload.get("type", "")
|
|
413
|
+
)
|
|
414
|
+
if "query" in action_payload:
|
|
415
|
+
event_payload["query"] = str(action_payload.get("query", ""))
|
|
416
|
+
queries = action_payload.get("queries")
|
|
417
|
+
if isinstance(queries, list):
|
|
418
|
+
event_payload["queries"] = [
|
|
419
|
+
str(query) for query in queries if str(query).strip()
|
|
420
|
+
]
|
|
421
|
+
if "url" in action_payload:
|
|
422
|
+
event_payload["url"] = str(action_payload.get("url", ""))
|
|
423
|
+
if "pattern" in action_payload:
|
|
424
|
+
event_payload["pattern"] = str(
|
|
425
|
+
action_payload.get("pattern", "")
|
|
426
|
+
)
|
|
427
|
+
event_handler(
|
|
428
|
+
ModelStreamEvent(
|
|
429
|
+
kind="tool_call",
|
|
430
|
+
payload=event_payload,
|
|
431
|
+
)
|
|
432
|
+
)
|
|
433
|
+
continue
|
|
434
|
+
|
|
435
|
+
parsed = self._parse_output_item(item_payload)
|
|
436
|
+
if parsed is not None:
|
|
437
|
+
if isinstance(parsed, ToolCall):
|
|
438
|
+
event_handler(
|
|
439
|
+
ModelStreamEvent(
|
|
440
|
+
kind="tool_call",
|
|
441
|
+
payload={
|
|
442
|
+
"call_id": parsed.call_id,
|
|
443
|
+
"tool_name": parsed.name,
|
|
444
|
+
},
|
|
445
|
+
)
|
|
446
|
+
)
|
|
447
|
+
items.append(parsed)
|
|
448
|
+
continue
|
|
449
|
+
|
|
450
|
+
if event_type == "response.completed":
|
|
451
|
+
saw_completed = True
|
|
452
|
+
break
|
|
453
|
+
|
|
454
|
+
if event_type == "response.failed":
|
|
455
|
+
error = payload.get("response", {}).get("error") or {}
|
|
456
|
+
message = error.get("message") or "responses stream failed"
|
|
457
|
+
raise ResponsesApiError(message)
|
|
458
|
+
|
|
459
|
+
if not saw_completed:
|
|
460
|
+
raise ResponsesApiError("responses stream ended before response.completed")
|
|
461
|
+
|
|
462
|
+
return ModelResponse(items=items)
|
|
463
|
+
|
|
464
|
+
def _parse_output_item(
|
|
465
|
+
self,
|
|
466
|
+
item: dict[str, object],
|
|
467
|
+
) -> AssistantMessage | ToolCall | ReasoningItem | None:
|
|
468
|
+
item_type = item.get("type")
|
|
469
|
+
if item_type == "reasoning":
|
|
470
|
+
return ReasoningItem(payload=dict(item))
|
|
471
|
+
|
|
472
|
+
if item_type == "message" and item.get("role") == "assistant":
|
|
473
|
+
content = item.get("content", [])
|
|
474
|
+
text_parts = []
|
|
475
|
+
for part in content:
|
|
476
|
+
if isinstance(part, dict) and part.get("type") == "output_text":
|
|
477
|
+
text_parts.append(str(part.get("text", "")))
|
|
478
|
+
return AssistantMessage(text="".join(text_parts))
|
|
479
|
+
|
|
480
|
+
if item_type == "function_call":
|
|
481
|
+
raw_arguments = str(item.get("arguments", "") or "{}")
|
|
482
|
+
arguments = json.loads(raw_arguments)
|
|
483
|
+
if not isinstance(arguments, dict):
|
|
484
|
+
raise ResponsesApiError(
|
|
485
|
+
f"function call arguments must decode to an object, got {type(arguments).__name__}"
|
|
486
|
+
)
|
|
487
|
+
return ToolCall(
|
|
488
|
+
call_id=str(item["call_id"]),
|
|
489
|
+
name=str(item["name"]),
|
|
490
|
+
arguments=arguments,
|
|
491
|
+
)
|
|
492
|
+
|
|
493
|
+
if item_type == "custom_tool_call":
|
|
494
|
+
return ToolCall(
|
|
495
|
+
call_id=str(item["call_id"]),
|
|
496
|
+
name=str(item["name"]),
|
|
497
|
+
arguments=str(item.get("input", "")),
|
|
498
|
+
tool_type="custom",
|
|
499
|
+
)
|
|
500
|
+
|
|
501
|
+
return None
|
|
502
|
+
|
|
503
|
+
def _iter_sse_events(self, response):
|
|
504
|
+
event_name: str | None = None
|
|
505
|
+
data_lines: list[str] = []
|
|
506
|
+
|
|
507
|
+
for raw_line in response:
|
|
508
|
+
line = raw_line.decode("utf-8", errors="replace").rstrip("\r\n")
|
|
509
|
+
if line == "":
|
|
510
|
+
if data_lines:
|
|
511
|
+
yield event_name or "message", "\n".join(data_lines)
|
|
512
|
+
event_name = None
|
|
513
|
+
data_lines = []
|
|
514
|
+
continue
|
|
515
|
+
|
|
516
|
+
if line.startswith(":"):
|
|
517
|
+
continue
|
|
518
|
+
if line.startswith("event:"):
|
|
519
|
+
event_name = line.split(":", 1)[1].lstrip()
|
|
520
|
+
continue
|
|
521
|
+
if line.startswith("data:"):
|
|
522
|
+
data_lines.append(line.split(":", 1)[1].lstrip())
|
|
523
|
+
|
|
524
|
+
if data_lines:
|
|
525
|
+
yield event_name or "message", "\n".join(data_lines)
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def _requests_verify_setting() -> str | bool | None:
|
|
529
|
+
for env_name in ("REQUESTS_CA_BUNDLE", "CURL_CA_BUNDLE", "SSL_CERT_FILE"):
|
|
530
|
+
value = os.environ.get(env_name, "").strip()
|
|
531
|
+
if value:
|
|
532
|
+
return value
|
|
533
|
+
return None
|