crabcode 0.1.6__py3-none-win_amd64.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.
- crabcode-0.1.6.dist-info/METADATA +63 -0
- crabcode-0.1.6.dist-info/RECORD +205 -0
- crabcode-0.1.6.dist-info/WHEEL +4 -0
- crabcode-0.1.6.dist-info/entry_points.txt +2 -0
- crabcode_cli/__init__.py +7 -0
- crabcode_cli/__main__.py +4 -0
- crabcode_cli/_native/crabcode-computer-use-host.exe +0 -0
- crabcode_cli/_native/runtime.json +6 -0
- crabcode_cli/app.py +776 -0
- crabcode_cli/banner.py +39 -0
- crabcode_cli/computer_use.py +286 -0
- crabcode_cli/model_manager.py +649 -0
- crabcode_cli/model_picker.py +168 -0
- crabcode_cli/pipe.py +148 -0
- crabcode_cli/render/__init__.py +0 -0
- crabcode_cli/render/messages.py +19 -0
- crabcode_cli/repl.py +4841 -0
- crabcode_cli/session_picker.py +239 -0
- crabcode_core/__init__.py +7 -0
- crabcode_core/_version.py +3 -0
- crabcode_core/_windows_process.py +68 -0
- crabcode_core/agent_manager.py +1731 -0
- crabcode_core/api/__init__.py +20 -0
- crabcode_core/api/anthropic_adapter.py +648 -0
- crabcode_core/api/azure_adapter.py +69 -0
- crabcode_core/api/base.py +233 -0
- crabcode_core/api/codex_adapter.py +1327 -0
- crabcode_core/api/error_logging.py +132 -0
- crabcode_core/api/gemini_adapter.py +26 -0
- crabcode_core/api/model_info.py +257 -0
- crabcode_core/api/network.py +62 -0
- crabcode_core/api/ollama_adapter.py +27 -0
- crabcode_core/api/openai_adapter.py +323 -0
- crabcode_core/api/registry.py +58 -0
- crabcode_core/command_line.py +18 -0
- crabcode_core/compact/__init__.py +17 -0
- crabcode_core/compact/compact.py +514 -0
- crabcode_core/compact/context_tokens.py +165 -0
- crabcode_core/computer_use_mode.py +38 -0
- crabcode_core/computer_use_observation.py +144 -0
- crabcode_core/config/__init__.py +3 -0
- crabcode_core/config/claudemd.py +61 -0
- crabcode_core/config/manager.py +237 -0
- crabcode_core/config/model_catalog.py +256 -0
- crabcode_core/events.py +4896 -0
- crabcode_core/file_lock.py +81 -0
- crabcode_core/filesystem.py +22 -0
- crabcode_core/goal.py +149 -0
- crabcode_core/hooks/__init__.py +5 -0
- crabcode_core/hooks/manager.py +290 -0
- crabcode_core/image_io.py +111 -0
- crabcode_core/io_worker.py +211 -0
- crabcode_core/logging_utils.py +168 -0
- crabcode_core/lsp/__init__.py +6 -0
- crabcode_core/lsp/client.py +786 -0
- crabcode_core/lsp/diagnostics.py +150 -0
- crabcode_core/lsp/manager.py +583 -0
- crabcode_core/mcp/__init__.py +4 -0
- crabcode_core/mcp/client.py +169 -0
- crabcode_core/mcp/config.py +57 -0
- crabcode_core/path_validation.py +34 -0
- crabcode_core/paths.py +24 -0
- crabcode_core/peer/__init__.py +10 -0
- crabcode_core/peer/runtime.py +518 -0
- crabcode_core/permissions/__init__.py +7 -0
- crabcode_core/permissions/ai_reviewer.py +184 -0
- crabcode_core/permissions/manager.py +187 -0
- crabcode_core/plan/__init__.py +5 -0
- crabcode_core/plan/executor.py +270 -0
- crabcode_core/plan/types.py +169 -0
- crabcode_core/prompts/__init__.py +0 -0
- crabcode_core/prompts/blocks.py +10 -0
- crabcode_core/prompts/context.py +229 -0
- crabcode_core/prompts/library.py +92 -0
- crabcode_core/prompts/profile.py +79 -0
- crabcode_core/prompts/system.py +356 -0
- crabcode_core/prompts/templates.py +104 -0
- crabcode_core/query/__init__.py +0 -0
- crabcode_core/query/computer_use_history.py +83 -0
- crabcode_core/query/loop.py +2446 -0
- crabcode_core/query/retry.py +116 -0
- crabcode_core/schedule/__init__.py +13 -0
- crabcode_core/schedule/manager.py +631 -0
- crabcode_core/schedule/models.py +204 -0
- crabcode_core/schedule/store.py +558 -0
- crabcode_core/schedule/timing.py +287 -0
- crabcode_core/session/__init__.py +12 -0
- crabcode_core/session/export.py +141 -0
- crabcode_core/session/meta_db.py +684 -0
- crabcode_core/session/storage.py +2032 -0
- crabcode_core/session/title_gen.py +52 -0
- crabcode_core/skills/__init__.py +6 -0
- crabcode_core/skills/context.py +23 -0
- crabcode_core/skills/loader.py +214 -0
- crabcode_core/skills/matcher.py +203 -0
- crabcode_core/snapshot/__init__.py +21 -0
- crabcode_core/snapshot/snapshot.py +436 -0
- crabcode_core/snapshot/tracker.py +359 -0
- crabcode_core/subprocess_utils.py +260 -0
- crabcode_core/team/__init__.py +27 -0
- crabcode_core/team/bridge.py +66 -0
- crabcode_core/team/inbox.py +237 -0
- crabcode_core/team/manager.py +1660 -0
- crabcode_core/team/message_bus.py +821 -0
- crabcode_core/team/models.py +191 -0
- crabcode_core/team/recovery.py +192 -0
- crabcode_core/text_io.py +52 -0
- crabcode_core/tools/__init__.py +112 -0
- crabcode_core/tools/_input_helpers.py +39 -0
- crabcode_core/tools/_input_schema.py +130 -0
- crabcode_core/tools/agent.py +413 -0
- crabcode_core/tools/apply_patch.py +587 -0
- crabcode_core/tools/ask_user.py +122 -0
- crabcode_core/tools/bash.py +154 -0
- crabcode_core/tools/browser.py +678 -0
- crabcode_core/tools/checklist.py +241 -0
- crabcode_core/tools/checkpoint.py +78 -0
- crabcode_core/tools/computer_use.py +599 -0
- crabcode_core/tools/diff_utils.py +123 -0
- crabcode_core/tools/file_edit.py +240 -0
- crabcode_core/tools/file_read.py +187 -0
- crabcode_core/tools/file_write.py +154 -0
- crabcode_core/tools/glob.py +123 -0
- crabcode_core/tools/goal.py +134 -0
- crabcode_core/tools/grep.py +247 -0
- crabcode_core/tools/image.py +173 -0
- crabcode_core/tools/image_generate.py +115 -0
- crabcode_core/tools/lint.py +388 -0
- crabcode_core/tools/loading.py +187 -0
- crabcode_core/tools/memory.py +456 -0
- crabcode_core/tools/monitor.py +677 -0
- crabcode_core/tools/peer.py +103 -0
- crabcode_core/tools/revert.py +90 -0
- crabcode_core/tools/schedule.py +683 -0
- crabcode_core/tools/skill.py +108 -0
- crabcode_core/tools/switch_mode.py +213 -0
- crabcode_core/tools/team.py +329 -0
- crabcode_core/tools/tool_search.py +64 -0
- crabcode_core/tools/web_search.py +444 -0
- crabcode_core/turn_details.py +64 -0
- crabcode_core/types/__init__.py +95 -0
- crabcode_core/types/config.py +434 -0
- crabcode_core/types/event.py +334 -0
- crabcode_core/types/message.py +263 -0
- crabcode_core/types/tool.py +208 -0
- crabcode_core/usage.py +289 -0
- crabcode_core/utf8_sanitize.py +29 -0
- crabcode_debugger/__init__.py +5 -0
- crabcode_debugger/adapters.py +332 -0
- crabcode_debugger/dap.py +304 -0
- crabcode_debugger/memory.py +1806 -0
- crabcode_debugger/process.py +707 -0
- crabcode_debugger/runtime.py +58 -0
- crabcode_debugger/sessions.py +387 -0
- crabcode_debugger/tool.py +731 -0
- crabcode_gateway/__init__.py +18 -0
- crabcode_gateway/acp/__init__.py +13 -0
- crabcode_gateway/acp/agent.py +1581 -0
- crabcode_gateway/acp/session.py +114 -0
- crabcode_gateway/acp/transport.py +39 -0
- crabcode_gateway/acp/types.py +90 -0
- crabcode_gateway/adapter.py +30 -0
- crabcode_gateway/auth.py +139 -0
- crabcode_gateway/computer_use.py +364 -0
- crabcode_gateway/document_engine.py +637 -0
- crabcode_gateway/document_worker.py +301 -0
- crabcode_gateway/event_bus.py +655 -0
- crabcode_gateway/grpc/__init__.py +0 -0
- crabcode_gateway/grpc/crabcode_pb2.py +70 -0
- crabcode_gateway/grpc/crabcode_pb2_grpc.py +474 -0
- crabcode_gateway/grpc/proto/crabcode.proto +160 -0
- crabcode_gateway/grpc/server.py +765 -0
- crabcode_gateway/middleware.py +344 -0
- crabcode_gateway/protocol.py +17 -0
- crabcode_gateway/routes/__init__.py +0 -0
- crabcode_gateway/routes/agent.py +392 -0
- crabcode_gateway/routes/auth.py +150 -0
- crabcode_gateway/routes/computer_use.py +101 -0
- crabcode_gateway/routes/config.py +1698 -0
- crabcode_gateway/routes/document.py +1810 -0
- crabcode_gateway/routes/event.py +3801 -0
- crabcode_gateway/routes/git_review.py +232 -0
- crabcode_gateway/routes/health.py +14 -0
- crabcode_gateway/routes/peer.py +78 -0
- crabcode_gateway/routes/permission.py +121 -0
- crabcode_gateway/routes/schedule.py +300 -0
- crabcode_gateway/routes/session.py +1496 -0
- crabcode_gateway/routes/snapshot.py +211 -0
- crabcode_gateway/routes/tasks.py +318 -0
- crabcode_gateway/routes/team.py +392 -0
- crabcode_gateway/routes/usage.py +49 -0
- crabcode_gateway/routes/workspace.py +299 -0
- crabcode_gateway/safe_file.py +63 -0
- crabcode_gateway/schemas.py +1820 -0
- crabcode_gateway/server.py +514 -0
- crabcode_gateway/session_registry.py +36 -0
- crabcode_gateway/task_registry.py +708 -0
- crabcode_gateway/turn_details.py +60 -0
- crabcode_search/__init__.py +11 -0
- crabcode_search/background.py +368 -0
- crabcode_search/chunker.py +517 -0
- crabcode_search/embedder.py +497 -0
- crabcode_search/indexer.py +215 -0
- crabcode_search/store.py +222 -0
- crabcode_search/tool.py +268 -0
|
@@ -0,0 +1,1327 @@
|
|
|
1
|
+
"""OpenAI Responses (Codex) API adapter — uses the newer Responses API endpoint.
|
|
2
|
+
|
|
3
|
+
Supports OpenAI's Responses API which is used by Codex and o-series models.
|
|
4
|
+
Falls back to Chat Completions API for models that don't support the Responses API.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
from contextlib import asynccontextmanager
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, AsyncGenerator
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
|
|
19
|
+
from crabcode_core.api.base import (
|
|
20
|
+
APIAdapter,
|
|
21
|
+
ModelConfig,
|
|
22
|
+
StreamChunk,
|
|
23
|
+
normalize_openai_usage,
|
|
24
|
+
)
|
|
25
|
+
from crabcode_core.api.error_logging import response_request_id
|
|
26
|
+
from crabcode_core.query.retry import MAX_REQUEST_RETRIES, request_retry_backoff
|
|
27
|
+
from crabcode_core.types.config import ApiConfig
|
|
28
|
+
from crabcode_core.utf8_sanitize import safe_utf8_json_tree, safe_utf8_str
|
|
29
|
+
from crabcode_core.types.message import (
|
|
30
|
+
Message,
|
|
31
|
+
MessageRole,
|
|
32
|
+
TextBlock,
|
|
33
|
+
ToolResultBlock,
|
|
34
|
+
ToolUseBlock,
|
|
35
|
+
ThinkingBlock,
|
|
36
|
+
ImageBlock,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
OPENAI_RESPONSES_BASE_URL = "https://api.openai.com/v1"
|
|
41
|
+
CODEX_OAUTH_BASE_URL = "https://chatgpt.com/backend-api/codex"
|
|
42
|
+
CODEX_AUTH_FILENAME = "auth.json"
|
|
43
|
+
CODEX_MODELS_CACHE_FILENAME = "models_cache.json"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _default_codex_auth_path() -> Path:
|
|
47
|
+
codex_home = os.environ.get("CODEX_HOME")
|
|
48
|
+
if codex_home:
|
|
49
|
+
return Path(codex_home).expanduser() / CODEX_AUTH_FILENAME
|
|
50
|
+
return Path.home() / ".codex" / CODEX_AUTH_FILENAME
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _resolve_codex_auth_path(config: ApiConfig) -> Path:
|
|
54
|
+
if config.codex_auth_path:
|
|
55
|
+
return Path(config.codex_auth_path).expanduser()
|
|
56
|
+
return _default_codex_auth_path()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _load_codex_oauth(config: ApiConfig) -> tuple[str | None, str | None]:
|
|
60
|
+
auth_path = _resolve_codex_auth_path(config)
|
|
61
|
+
try:
|
|
62
|
+
payload = json.loads(auth_path.read_text(encoding="utf-8"))
|
|
63
|
+
except (OSError, json.JSONDecodeError):
|
|
64
|
+
return None, None
|
|
65
|
+
|
|
66
|
+
tokens = payload.get("tokens")
|
|
67
|
+
if not isinstance(tokens, dict):
|
|
68
|
+
return None, None
|
|
69
|
+
|
|
70
|
+
access_token = tokens.get("access_token")
|
|
71
|
+
if not isinstance(access_token, str) or not access_token:
|
|
72
|
+
return None, None
|
|
73
|
+
|
|
74
|
+
account_id = tokens.get("account_id")
|
|
75
|
+
if not isinstance(account_id, str) or not account_id:
|
|
76
|
+
account_id = None
|
|
77
|
+
|
|
78
|
+
return access_token, account_id
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _load_codex_context_window(config: ApiConfig, model: str | None) -> int | None:
|
|
82
|
+
"""Read Codex CLI's effective context window for an OAuth model."""
|
|
83
|
+
if not model:
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
cache_path = _resolve_codex_auth_path(config).with_name(
|
|
87
|
+
CODEX_MODELS_CACHE_FILENAME
|
|
88
|
+
)
|
|
89
|
+
try:
|
|
90
|
+
payload = json.loads(cache_path.read_text(encoding="utf-8"))
|
|
91
|
+
except (OSError, json.JSONDecodeError):
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
models = payload.get("models") if isinstance(payload, dict) else None
|
|
95
|
+
if not isinstance(models, list):
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
for item in models:
|
|
99
|
+
if not isinstance(item, dict) or item.get("slug") != model:
|
|
100
|
+
continue
|
|
101
|
+
|
|
102
|
+
context_window = item.get("context_window")
|
|
103
|
+
if (
|
|
104
|
+
not isinstance(context_window, int)
|
|
105
|
+
or isinstance(context_window, bool)
|
|
106
|
+
or context_window <= 0
|
|
107
|
+
):
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
effective_percent = item.get("effective_context_window_percent")
|
|
111
|
+
if (
|
|
112
|
+
isinstance(effective_percent, int)
|
|
113
|
+
and not isinstance(effective_percent, bool)
|
|
114
|
+
and 0 < effective_percent <= 100
|
|
115
|
+
):
|
|
116
|
+
return context_window * effective_percent // 100
|
|
117
|
+
return context_window
|
|
118
|
+
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _has_header(headers: dict[str, str], name: str) -> bool:
|
|
123
|
+
needle = name.lower()
|
|
124
|
+
return any(key.lower() == needle for key in headers)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _messages_to_responses_input(
|
|
128
|
+
messages: list[Message],
|
|
129
|
+
) -> list[dict[str, Any]]:
|
|
130
|
+
"""Convert internal messages to OpenAI Responses API input format.
|
|
131
|
+
|
|
132
|
+
The Responses API uses a flat list of input items rather than
|
|
133
|
+
the Chat Completions 'messages' array. Each item has a 'type' field.
|
|
134
|
+
"""
|
|
135
|
+
result: list[dict[str, Any]] = []
|
|
136
|
+
|
|
137
|
+
for msg in messages:
|
|
138
|
+
if msg.role == MessageRole.SYSTEM:
|
|
139
|
+
continue
|
|
140
|
+
|
|
141
|
+
if isinstance(msg.content, str):
|
|
142
|
+
result.append({
|
|
143
|
+
"type": "message",
|
|
144
|
+
"role": msg.role.value,
|
|
145
|
+
"content": msg.content,
|
|
146
|
+
})
|
|
147
|
+
continue
|
|
148
|
+
|
|
149
|
+
text_parts: list[str] = []
|
|
150
|
+
tool_calls: list[dict[str, Any]] = []
|
|
151
|
+
tool_results: list[dict[str, Any]] = []
|
|
152
|
+
|
|
153
|
+
for block in msg.content:
|
|
154
|
+
if isinstance(block, TextBlock):
|
|
155
|
+
text_parts.append(block.text)
|
|
156
|
+
elif isinstance(block, ToolUseBlock):
|
|
157
|
+
# Responses API uses 'function_call' type
|
|
158
|
+
tool_calls.append({
|
|
159
|
+
"type": "function_call",
|
|
160
|
+
"call_id": block.id,
|
|
161
|
+
"name": block.name,
|
|
162
|
+
"arguments": json.dumps(block.input),
|
|
163
|
+
})
|
|
164
|
+
elif isinstance(block, ToolResultBlock):
|
|
165
|
+
# Responses API uses 'function_call_output' type
|
|
166
|
+
tool_results.append({
|
|
167
|
+
"type": "function_call_output",
|
|
168
|
+
"call_id": block.tool_use_id,
|
|
169
|
+
"output": block.content,
|
|
170
|
+
})
|
|
171
|
+
elif isinstance(block, ThinkingBlock):
|
|
172
|
+
pass
|
|
173
|
+
|
|
174
|
+
if msg.role == MessageRole.ASSISTANT:
|
|
175
|
+
# Add assistant message with text content
|
|
176
|
+
if text_parts:
|
|
177
|
+
result.append({
|
|
178
|
+
"type": "message",
|
|
179
|
+
"role": "assistant",
|
|
180
|
+
"content": "".join(text_parts),
|
|
181
|
+
})
|
|
182
|
+
# Add function_call items (they are separate top-level items)
|
|
183
|
+
for tc in tool_calls:
|
|
184
|
+
result.append(tc)
|
|
185
|
+
elif msg.role == MessageRole.USER:
|
|
186
|
+
# Add function_call_output items (they are separate top-level items)
|
|
187
|
+
for tr in tool_results:
|
|
188
|
+
result.append(tr)
|
|
189
|
+
# Add user message — handle multimodal content with images
|
|
190
|
+
has_images = isinstance(msg.content, list) and any(
|
|
191
|
+
isinstance(b, ImageBlock) for b in msg.content
|
|
192
|
+
)
|
|
193
|
+
if has_images:
|
|
194
|
+
content_parts: list[dict[str, Any]] = []
|
|
195
|
+
for block in msg.content if isinstance(msg.content, list) else []:
|
|
196
|
+
if isinstance(block, TextBlock):
|
|
197
|
+
content_parts.append({"type": "input_text", "text": block.text})
|
|
198
|
+
elif isinstance(block, ImageBlock):
|
|
199
|
+
source = block.source
|
|
200
|
+
if source.get("type") == "base64":
|
|
201
|
+
data_url = f"data:{source.get('media_type', 'image/png')};base64,{source.get('data', '')}"
|
|
202
|
+
else:
|
|
203
|
+
data_url = source.get("url", "")
|
|
204
|
+
content_parts.append({
|
|
205
|
+
"type": "input_image",
|
|
206
|
+
"image_url": data_url,
|
|
207
|
+
})
|
|
208
|
+
if content_parts:
|
|
209
|
+
result.append({
|
|
210
|
+
"type": "message",
|
|
211
|
+
"role": "user",
|
|
212
|
+
"content": content_parts,
|
|
213
|
+
})
|
|
214
|
+
elif text_parts and not tool_results:
|
|
215
|
+
result.append({
|
|
216
|
+
"type": "message",
|
|
217
|
+
"role": "user",
|
|
218
|
+
"content": "".join(text_parts),
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
return safe_utf8_json_tree(result)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _tools_to_responses(tools: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
225
|
+
"""Convert tool schemas to OpenAI Responses API function tool format."""
|
|
226
|
+
result = []
|
|
227
|
+
for tool in tools:
|
|
228
|
+
schema = tool.get("input_schema", {"type": "object", "properties": {}})
|
|
229
|
+
result.append({
|
|
230
|
+
"type": "function",
|
|
231
|
+
"name": tool["name"],
|
|
232
|
+
"description": tool.get("description", ""),
|
|
233
|
+
"parameters": schema,
|
|
234
|
+
"strict": False,
|
|
235
|
+
})
|
|
236
|
+
return safe_utf8_json_tree(result)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _response_error_message(payload: Any) -> str | None:
|
|
240
|
+
"""Extract a useful error string from proxy-specific response shapes."""
|
|
241
|
+
if not isinstance(payload, dict):
|
|
242
|
+
return None
|
|
243
|
+
error = payload.get("error")
|
|
244
|
+
if isinstance(error, dict):
|
|
245
|
+
message = error.get("message") or error.get("detail") or error.get("code")
|
|
246
|
+
if message:
|
|
247
|
+
return safe_utf8_str(str(message))
|
|
248
|
+
elif error:
|
|
249
|
+
return safe_utf8_str(str(error))
|
|
250
|
+
for key in ("message", "detail", "error_message"):
|
|
251
|
+
value = payload.get(key)
|
|
252
|
+
if value:
|
|
253
|
+
return safe_utf8_str(str(value))
|
|
254
|
+
response = payload.get("response")
|
|
255
|
+
if isinstance(response, dict):
|
|
256
|
+
return _response_error_message(response)
|
|
257
|
+
return None
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _response_error_code(payload: Any) -> str:
|
|
261
|
+
if not isinstance(payload, dict):
|
|
262
|
+
return ""
|
|
263
|
+
error = payload.get("error")
|
|
264
|
+
if isinstance(error, dict) and error.get("code"):
|
|
265
|
+
return str(error["code"])
|
|
266
|
+
response = payload.get("response")
|
|
267
|
+
if isinstance(response, dict):
|
|
268
|
+
return _response_error_code(response)
|
|
269
|
+
return ""
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _retry_after_from_error(message: str, code: str) -> float | None:
|
|
273
|
+
if code != "rate_limit_exceeded":
|
|
274
|
+
return None
|
|
275
|
+
match = re.search(
|
|
276
|
+
r"(?:please\s+)?try\s+again\s+in\s+"
|
|
277
|
+
r"(\d+(?:\.\d+)?)\s*(ms|s|seconds?)\b",
|
|
278
|
+
message,
|
|
279
|
+
flags=re.IGNORECASE,
|
|
280
|
+
)
|
|
281
|
+
if not match:
|
|
282
|
+
return None
|
|
283
|
+
value = float(match.group(1))
|
|
284
|
+
return value / 1000.0 if match.group(2).lower() == "ms" else value
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _responses_error_chunk(
|
|
288
|
+
payload: Any, fallback: str, *, error_body: Any = None, response: Any = None,
|
|
289
|
+
) -> StreamChunk:
|
|
290
|
+
message = safe_utf8_str(_response_error_message(payload) or fallback)
|
|
291
|
+
code = _response_error_code(payload)
|
|
292
|
+
terminal_codes = {
|
|
293
|
+
"context_length_exceeded",
|
|
294
|
+
"insufficient_quota",
|
|
295
|
+
"usage_not_included",
|
|
296
|
+
"server_is_overloaded",
|
|
297
|
+
"slow_down",
|
|
298
|
+
"cyber_policy",
|
|
299
|
+
"invalid_prompt",
|
|
300
|
+
"bio_policy",
|
|
301
|
+
"misalignment_policy_violation",
|
|
302
|
+
}
|
|
303
|
+
return StreamChunk(
|
|
304
|
+
type="error",
|
|
305
|
+
error=message,
|
|
306
|
+
error_body=payload if error_body is None else error_body,
|
|
307
|
+
error_status_code=getattr(response, "status_code", None),
|
|
308
|
+
error_request_id=response_request_id(response),
|
|
309
|
+
retryable=False if code in terminal_codes else True,
|
|
310
|
+
retry_after=_retry_after_from_error(message, code),
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _incomplete_response_message(response: Any) -> str:
|
|
315
|
+
reason = "unknown"
|
|
316
|
+
if isinstance(response, dict):
|
|
317
|
+
details = response.get("incomplete_details")
|
|
318
|
+
if isinstance(details, dict) and details.get("reason"):
|
|
319
|
+
reason = str(details["reason"])
|
|
320
|
+
else:
|
|
321
|
+
details = getattr(response, "incomplete_details", None)
|
|
322
|
+
value = getattr(details, "reason", None)
|
|
323
|
+
if value:
|
|
324
|
+
reason = str(value)
|
|
325
|
+
return f"Incomplete response returned, reason: {reason}"
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _response_to_stream_chunks(response: Any) -> list[StreamChunk]:
|
|
329
|
+
"""Convert a non-stream Responses API object into stream chunks.
|
|
330
|
+
|
|
331
|
+
Some OpenAI-compatible proxies support the Responses endpoint but return
|
|
332
|
+
SSE frames that the official SDK cannot parse. In that case we retry
|
|
333
|
+
without streaming and translate the final response back into CrabCode's
|
|
334
|
+
streaming abstraction.
|
|
335
|
+
"""
|
|
336
|
+
chunks: list[StreamChunk] = []
|
|
337
|
+
|
|
338
|
+
output_items = getattr(response, "output", None) or []
|
|
339
|
+
for item in output_items:
|
|
340
|
+
item_type = getattr(item, "type", "")
|
|
341
|
+
|
|
342
|
+
if item_type == "message":
|
|
343
|
+
for part in getattr(item, "content", None) or []:
|
|
344
|
+
part_type = getattr(part, "type", "")
|
|
345
|
+
if part_type == "output_text" and getattr(part, "text", ""):
|
|
346
|
+
chunks.append(
|
|
347
|
+
StreamChunk(type="text", text=safe_utf8_str(part.text))
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
elif item_type == "function_call":
|
|
351
|
+
call_id = getattr(item, "call_id", "") or getattr(item, "id", "")
|
|
352
|
+
name = getattr(item, "name", "")
|
|
353
|
+
arguments = getattr(item, "arguments", "") or ""
|
|
354
|
+
chunks.append(
|
|
355
|
+
StreamChunk(
|
|
356
|
+
type="tool_use_start",
|
|
357
|
+
tool_use_id=call_id,
|
|
358
|
+
tool_name=name,
|
|
359
|
+
)
|
|
360
|
+
)
|
|
361
|
+
chunks.append(
|
|
362
|
+
StreamChunk(
|
|
363
|
+
type="tool_use_end",
|
|
364
|
+
tool_use_id=call_id,
|
|
365
|
+
tool_name=name,
|
|
366
|
+
tool_input_json=arguments,
|
|
367
|
+
)
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
chunks.append(
|
|
371
|
+
StreamChunk(
|
|
372
|
+
type="response_item_done",
|
|
373
|
+
item_id=str(getattr(item, "id", "") or ""),
|
|
374
|
+
item_type=str(item_type),
|
|
375
|
+
)
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
usage = {}
|
|
379
|
+
if hasattr(response, "usage") and response.usage:
|
|
380
|
+
usage = normalize_openai_usage(response.usage)
|
|
381
|
+
|
|
382
|
+
if not chunks and getattr(response, "error", None):
|
|
383
|
+
err = response.error
|
|
384
|
+
error_msg = safe_utf8_str(getattr(err, "message", str(err)))
|
|
385
|
+
chunks.append(StreamChunk(
|
|
386
|
+
type="error", error=error_msg or "Response failed", error_body=err,
|
|
387
|
+
))
|
|
388
|
+
return chunks
|
|
389
|
+
|
|
390
|
+
chunks.append(
|
|
391
|
+
StreamChunk(
|
|
392
|
+
type="message_stop",
|
|
393
|
+
stop_reason="end_turn",
|
|
394
|
+
usage=usage,
|
|
395
|
+
)
|
|
396
|
+
)
|
|
397
|
+
return chunks
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
async def _iter_sse_payloads(
|
|
401
|
+
response: httpx.Response,
|
|
402
|
+
) -> AsyncGenerator[tuple[str, dict[str, Any]], None]:
|
|
403
|
+
"""Yield parsed SSE payloads, tolerating split event/data frames.
|
|
404
|
+
|
|
405
|
+
Some OpenAI-compatible proxies emit:
|
|
406
|
+
|
|
407
|
+
event: response.created
|
|
408
|
+
|
|
409
|
+
data: {...}
|
|
410
|
+
|
|
411
|
+
which inserts an extra blank line between the event name and payload.
|
|
412
|
+
The official OpenAI Python SDK treats that as two separate SSE events and
|
|
413
|
+
fails to decode the empty payload. We keep the pending event name across
|
|
414
|
+
blank lines until a data block arrives.
|
|
415
|
+
"""
|
|
416
|
+
|
|
417
|
+
current_event: str | None = None
|
|
418
|
+
data_lines: list[str] = []
|
|
419
|
+
|
|
420
|
+
def parse_payload(data: str) -> dict[str, Any] | None:
|
|
421
|
+
try:
|
|
422
|
+
payload = json.loads(data)
|
|
423
|
+
except json.JSONDecodeError:
|
|
424
|
+
return None
|
|
425
|
+
if not isinstance(payload, dict):
|
|
426
|
+
return None
|
|
427
|
+
return payload
|
|
428
|
+
|
|
429
|
+
async for raw_line in response.aiter_lines():
|
|
430
|
+
line = raw_line.rstrip("\r")
|
|
431
|
+
|
|
432
|
+
if line.startswith("event:"):
|
|
433
|
+
if data_lines:
|
|
434
|
+
data = "\n".join(data_lines)
|
|
435
|
+
if data and data != "[DONE]":
|
|
436
|
+
payload = parse_payload(data)
|
|
437
|
+
if payload is not None:
|
|
438
|
+
yield current_event or "", payload
|
|
439
|
+
data_lines = []
|
|
440
|
+
current_event = line.split(":", 1)[1].strip()
|
|
441
|
+
continue
|
|
442
|
+
|
|
443
|
+
if line.startswith("data:"):
|
|
444
|
+
data_lines.append(line.split(":", 1)[1].lstrip())
|
|
445
|
+
continue
|
|
446
|
+
|
|
447
|
+
# A few compatible gateways return one JSON object per line while
|
|
448
|
+
# still advertising a streaming response. Treat it as a response
|
|
449
|
+
# payload instead of silently dropping it and reporting an empty turn.
|
|
450
|
+
if line == "[DONE]":
|
|
451
|
+
continue
|
|
452
|
+
if line.startswith(("{", "[")):
|
|
453
|
+
payload = parse_payload(line)
|
|
454
|
+
if payload is not None:
|
|
455
|
+
yield current_event or "", payload
|
|
456
|
+
current_event = None
|
|
457
|
+
continue
|
|
458
|
+
|
|
459
|
+
if line == "":
|
|
460
|
+
if not data_lines:
|
|
461
|
+
continue
|
|
462
|
+
data = "\n".join(data_lines)
|
|
463
|
+
data_lines = []
|
|
464
|
+
if data and data != "[DONE]":
|
|
465
|
+
payload = parse_payload(data)
|
|
466
|
+
if payload is not None:
|
|
467
|
+
yield current_event or "", payload
|
|
468
|
+
current_event = None
|
|
469
|
+
|
|
470
|
+
if data_lines:
|
|
471
|
+
data = "\n".join(data_lines)
|
|
472
|
+
if data and data != "[DONE]":
|
|
473
|
+
payload = parse_payload(data)
|
|
474
|
+
if payload is not None:
|
|
475
|
+
yield current_event or "", payload
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
class CodexAdapter(APIAdapter):
|
|
479
|
+
"""Adapter for OpenAI's Responses API (Codex / o-series models).
|
|
480
|
+
|
|
481
|
+
Uses client.responses.create() with stream=True.
|
|
482
|
+
"""
|
|
483
|
+
|
|
484
|
+
emits_response_item_events = True
|
|
485
|
+
|
|
486
|
+
def __init__(self, config: ApiConfig):
|
|
487
|
+
import openai
|
|
488
|
+
|
|
489
|
+
self.config = config
|
|
490
|
+
api_key = None
|
|
491
|
+
oauth_account_id = None
|
|
492
|
+
using_codex_oauth = False
|
|
493
|
+
if config.api_key_env:
|
|
494
|
+
api_key = os.environ.get(config.api_key_env)
|
|
495
|
+
if not api_key:
|
|
496
|
+
api_key = os.environ.get("OPENAI_API_KEY")
|
|
497
|
+
if not api_key and not config.base_url:
|
|
498
|
+
api_key, oauth_account_id = _load_codex_oauth(config)
|
|
499
|
+
using_codex_oauth = bool(api_key)
|
|
500
|
+
if not api_key:
|
|
501
|
+
auth_path = _resolve_codex_auth_path(config)
|
|
502
|
+
raise RuntimeError(
|
|
503
|
+
"Codex provider requires an API key, a base_url, or a "
|
|
504
|
+
f"Codex OAuth auth file at {auth_path}"
|
|
505
|
+
)
|
|
506
|
+
self._api_key = api_key
|
|
507
|
+
self._codex_oauth_account_id = oauth_account_id
|
|
508
|
+
self._using_codex_oauth = using_codex_oauth
|
|
509
|
+
self._base_url = (
|
|
510
|
+
CODEX_OAUTH_BASE_URL
|
|
511
|
+
if using_codex_oauth
|
|
512
|
+
else config.base_url or OPENAI_RESPONSES_BASE_URL
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
kwargs: dict[str, Any] = {}
|
|
516
|
+
if api_key:
|
|
517
|
+
kwargs["api_key"] = api_key
|
|
518
|
+
if config.base_url:
|
|
519
|
+
kwargs["base_url"] = config.base_url
|
|
520
|
+
if config.http_headers:
|
|
521
|
+
kwargs["default_headers"] = config.http_headers
|
|
522
|
+
|
|
523
|
+
# Keep retry policy above the HTTP transport. Disable the SDK's
|
|
524
|
+
# differing built-in policy (which also retries 429) and apply the
|
|
525
|
+
# configured policy explicitly in _create_sdk_response_stream().
|
|
526
|
+
from crabcode_core.api.network import sdk_options
|
|
527
|
+
kwargs.update(sdk_options(config))
|
|
528
|
+
self.client = openai.AsyncOpenAI(**kwargs)
|
|
529
|
+
|
|
530
|
+
async def resolve_context_window(self) -> int:
|
|
531
|
+
"""Use Codex runtime metadata when authenticated through Codex OAuth."""
|
|
532
|
+
if self.config.context_window:
|
|
533
|
+
return self.config.context_window
|
|
534
|
+
|
|
535
|
+
if self._using_codex_oauth:
|
|
536
|
+
from crabcode_core.api.model_info import DEFAULT_CONTEXT_WINDOW
|
|
537
|
+
|
|
538
|
+
cached_window = _load_codex_context_window(
|
|
539
|
+
self.config,
|
|
540
|
+
self.config.model,
|
|
541
|
+
)
|
|
542
|
+
return cached_window or DEFAULT_CONTEXT_WINDOW
|
|
543
|
+
|
|
544
|
+
return await super().resolve_context_window()
|
|
545
|
+
|
|
546
|
+
def _request_retry_limit(self) -> int:
|
|
547
|
+
return min(
|
|
548
|
+
max(0, int(getattr(self.config, "request_max_retries", 4))),
|
|
549
|
+
MAX_REQUEST_RETRIES,
|
|
550
|
+
)
|
|
551
|
+
|
|
552
|
+
@staticmethod
|
|
553
|
+
def _request_error_is_retryable(exc: BaseException) -> bool:
|
|
554
|
+
"""Retry transport failures and 5xx at this layer, but not 429."""
|
|
555
|
+
from crabcode_core.api.network import certificate_failure
|
|
556
|
+
if certificate_failure(exc):
|
|
557
|
+
return False
|
|
558
|
+
pending: list[BaseException] = [exc]
|
|
559
|
+
seen: set[int] = set()
|
|
560
|
+
while pending:
|
|
561
|
+
current = pending.pop()
|
|
562
|
+
if id(current) in seen:
|
|
563
|
+
continue
|
|
564
|
+
seen.add(id(current))
|
|
565
|
+
if isinstance(current, httpx.TransportError):
|
|
566
|
+
return True
|
|
567
|
+
status = getattr(current, "status_code", None)
|
|
568
|
+
if isinstance(status, int) and status >= 500:
|
|
569
|
+
return True
|
|
570
|
+
for nested in (current.__cause__, current.__context__):
|
|
571
|
+
if nested is not None:
|
|
572
|
+
pending.append(nested)
|
|
573
|
+
return False
|
|
574
|
+
|
|
575
|
+
async def _create_sdk_response_stream(self, params: dict[str, Any]) -> Any:
|
|
576
|
+
"""Create a Responses stream with the request-layer retry policy."""
|
|
577
|
+
max_retries = self._request_retry_limit()
|
|
578
|
+
for attempt in range(max_retries + 1):
|
|
579
|
+
try:
|
|
580
|
+
return await self.client.responses.create(**params)
|
|
581
|
+
except Exception as exc:
|
|
582
|
+
if (
|
|
583
|
+
attempt >= max_retries
|
|
584
|
+
or not self._request_error_is_retryable(exc)
|
|
585
|
+
):
|
|
586
|
+
raise
|
|
587
|
+
await asyncio.sleep(request_retry_backoff(attempt + 1))
|
|
588
|
+
raise RuntimeError("unreachable request retry state")
|
|
589
|
+
|
|
590
|
+
async def _send_httpx_stream_request(
|
|
591
|
+
self,
|
|
592
|
+
client: httpx.AsyncClient,
|
|
593
|
+
*,
|
|
594
|
+
url: str,
|
|
595
|
+
headers: dict[str, str],
|
|
596
|
+
params: dict[str, Any],
|
|
597
|
+
) -> httpx.Response:
|
|
598
|
+
"""Open a stream with the silent request-layer retry policy."""
|
|
599
|
+
max_retries = self._request_retry_limit()
|
|
600
|
+
for attempt in range(max_retries + 1):
|
|
601
|
+
request = client.build_request(
|
|
602
|
+
"POST",
|
|
603
|
+
url,
|
|
604
|
+
headers=headers,
|
|
605
|
+
json=safe_utf8_json_tree(params),
|
|
606
|
+
)
|
|
607
|
+
try:
|
|
608
|
+
response = await client.send(request, stream=True)
|
|
609
|
+
except httpx.TransportError as exc:
|
|
610
|
+
if attempt >= max_retries or not self._request_error_is_retryable(exc):
|
|
611
|
+
raise
|
|
612
|
+
await asyncio.sleep(request_retry_backoff(attempt + 1))
|
|
613
|
+
continue
|
|
614
|
+
if response.status_code >= 500 and attempt < max_retries:
|
|
615
|
+
await response.aclose()
|
|
616
|
+
await asyncio.sleep(request_retry_backoff(attempt + 1))
|
|
617
|
+
continue
|
|
618
|
+
return response
|
|
619
|
+
raise RuntimeError("unreachable request retry state")
|
|
620
|
+
|
|
621
|
+
@asynccontextmanager
|
|
622
|
+
async def _httpx_stream_with_retry(
|
|
623
|
+
self,
|
|
624
|
+
client: httpx.AsyncClient,
|
|
625
|
+
*,
|
|
626
|
+
url: str,
|
|
627
|
+
headers: dict[str, str],
|
|
628
|
+
params: dict[str, Any],
|
|
629
|
+
) -> AsyncGenerator[httpx.Response, None]:
|
|
630
|
+
response = await self._send_httpx_stream_request(
|
|
631
|
+
client,
|
|
632
|
+
url=url,
|
|
633
|
+
headers=headers,
|
|
634
|
+
params=params,
|
|
635
|
+
)
|
|
636
|
+
try:
|
|
637
|
+
yield response
|
|
638
|
+
finally:
|
|
639
|
+
await response.aclose()
|
|
640
|
+
|
|
641
|
+
def _raw_responses_headers(self) -> dict[str, str]:
|
|
642
|
+
headers = dict(self.config.http_headers or {})
|
|
643
|
+
if self._api_key:
|
|
644
|
+
if not _has_header(headers, "Authorization"):
|
|
645
|
+
headers["Authorization"] = f"Bearer {self._api_key}"
|
|
646
|
+
if self._codex_oauth_account_id and not _has_header(
|
|
647
|
+
headers, "ChatGPT-Account-Id"
|
|
648
|
+
):
|
|
649
|
+
headers["ChatGPT-Account-Id"] = self._codex_oauth_account_id
|
|
650
|
+
headers.setdefault("Content-Type", "application/json")
|
|
651
|
+
headers.setdefault("Accept", "text/event-stream")
|
|
652
|
+
return headers
|
|
653
|
+
|
|
654
|
+
def _prompt_cache_key(self) -> str | None:
|
|
655
|
+
if self.config.prompt_cache_key:
|
|
656
|
+
return safe_utf8_str(self.config.prompt_cache_key)
|
|
657
|
+
|
|
658
|
+
session_id = (self.config.http_headers or {}).get("session_id")
|
|
659
|
+
if session_id:
|
|
660
|
+
return safe_utf8_str(session_id)
|
|
661
|
+
|
|
662
|
+
return None
|
|
663
|
+
|
|
664
|
+
@property
|
|
665
|
+
def supports_image_generation(self) -> bool:
|
|
666
|
+
"""OAuth is automatic; compatible endpoints require explicit opt-in."""
|
|
667
|
+
return self._using_codex_oauth or self.config.image_generation_enabled
|
|
668
|
+
|
|
669
|
+
async def generate_images(
|
|
670
|
+
self,
|
|
671
|
+
prompt: str,
|
|
672
|
+
*,
|
|
673
|
+
model: str,
|
|
674
|
+
reference_images: list[dict[str, str]],
|
|
675
|
+
) -> list[dict[str, str]]:
|
|
676
|
+
"""Run the native Responses image tool using this provider's credentials.
|
|
677
|
+
|
|
678
|
+
This deliberately does not call the API-key-only Images API or fall
|
|
679
|
+
back to another provider. Partial previews are not final artifacts.
|
|
680
|
+
"""
|
|
681
|
+
if not self.supports_image_generation:
|
|
682
|
+
raise RuntimeError(
|
|
683
|
+
"Image generation requires Codex auth.json mode or "
|
|
684
|
+
"image_generation_enabled=true on a compatible Codex endpoint"
|
|
685
|
+
)
|
|
686
|
+
|
|
687
|
+
headers = self._raw_responses_headers()
|
|
688
|
+
secrets = [self._api_key]
|
|
689
|
+
if self._using_codex_oauth:
|
|
690
|
+
# Pick up credentials refreshed by `codex login` since startup.
|
|
691
|
+
# Never read or forward Codex credentials to a compatible endpoint.
|
|
692
|
+
token, account_id = _load_codex_oauth(self.config)
|
|
693
|
+
if not token:
|
|
694
|
+
raise RuntimeError("Codex auth.json is unavailable; run `codex login`")
|
|
695
|
+
secrets.extend([token, account_id])
|
|
696
|
+
headers = {
|
|
697
|
+
key: value for key, value in headers.items()
|
|
698
|
+
if key.lower() not in {"authorization", "chatgpt-account-id"}
|
|
699
|
+
}
|
|
700
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
701
|
+
if account_id:
|
|
702
|
+
headers["ChatGPT-Account-Id"] = account_id
|
|
703
|
+
for key, value in headers.items():
|
|
704
|
+
if any(part in key.lower() for part in ("authorization", "token", "key", "account-id")):
|
|
705
|
+
secrets.extend([value, value.removeprefix("Bearer ")])
|
|
706
|
+
|
|
707
|
+
content: list[dict[str, str]] = [{"type": "input_text", "text": prompt}]
|
|
708
|
+
content.extend({
|
|
709
|
+
"type": "input_image",
|
|
710
|
+
"image_url": f"data:{image['media_type']};base64,{image['data']}",
|
|
711
|
+
} for image in reference_images)
|
|
712
|
+
# Preserve compatible-provider routing/options while keeping the native
|
|
713
|
+
# image request contract intact. OAuth retains its validated payload.
|
|
714
|
+
params: dict[str, Any] = (
|
|
715
|
+
{} if self._using_codex_oauth else dict(self.config.extra_body or {})
|
|
716
|
+
)
|
|
717
|
+
params.update({
|
|
718
|
+
"model": model,
|
|
719
|
+
"instructions": "Generate or edit the requested image using the image generation tool.",
|
|
720
|
+
"input": [{"role": "user", "content": content}],
|
|
721
|
+
"tools": [{"type": "image_generation"}],
|
|
722
|
+
"tool_choice": {"type": "image_generation"},
|
|
723
|
+
"stream": True,
|
|
724
|
+
"store": False,
|
|
725
|
+
})
|
|
726
|
+
cache_key = self._prompt_cache_key()
|
|
727
|
+
if cache_key:
|
|
728
|
+
params["prompt_cache_key"] = cache_key
|
|
729
|
+
if not self._using_codex_oauth and self.config.prompt_cache_retention:
|
|
730
|
+
params["prompt_cache_retention"] = self.config.prompt_cache_retention
|
|
731
|
+
|
|
732
|
+
images: dict[str, dict[str, str]] = {}
|
|
733
|
+
completed = False
|
|
734
|
+
|
|
735
|
+
def collect(item: dict[str, Any]) -> None:
|
|
736
|
+
if item.get("type") != "image_generation_call":
|
|
737
|
+
return
|
|
738
|
+
if item.get("status") not in {None, "completed"}:
|
|
739
|
+
return
|
|
740
|
+
result = item.get("result")
|
|
741
|
+
if not isinstance(result, str) or not result:
|
|
742
|
+
return
|
|
743
|
+
output_format = item.get("output_format") or "png"
|
|
744
|
+
media_type = {"png": "image/png", "jpeg": "image/jpeg", "webp": "image/webp"}.get(output_format)
|
|
745
|
+
if not media_type:
|
|
746
|
+
raise RuntimeError(f"Unsupported generated image format: {output_format}")
|
|
747
|
+
images[str(item.get("id") or result)] = {"media_type": media_type, "data": result}
|
|
748
|
+
|
|
749
|
+
from crabcode_core.api.network import http_options, request_timeout
|
|
750
|
+
async with httpx.AsyncClient(timeout=request_timeout(self.config), **http_options(self.config)) as client:
|
|
751
|
+
async with self._httpx_stream_with_retry(
|
|
752
|
+
client,
|
|
753
|
+
url=f"{self._base_url.rstrip('/')}/responses",
|
|
754
|
+
headers=headers,
|
|
755
|
+
params=params,
|
|
756
|
+
) as response:
|
|
757
|
+
if response.is_error:
|
|
758
|
+
# Do not include raw upstream bodies, which can echo credentials.
|
|
759
|
+
if response.status_code == 401:
|
|
760
|
+
if self._using_codex_oauth:
|
|
761
|
+
raise RuntimeError("Codex login expired; run `codex login` and retry")
|
|
762
|
+
raise RuntimeError(
|
|
763
|
+
"Image generation HTTP 401; check api_key_env and "
|
|
764
|
+
"http_headers for the configured compatible endpoint"
|
|
765
|
+
)
|
|
766
|
+
raise RuntimeError(
|
|
767
|
+
f"Codex image generation HTTP {response.status_code}; "
|
|
768
|
+
"the account/model may not support image generation or may have reached its limit"
|
|
769
|
+
)
|
|
770
|
+
async for event, payload in _iter_sse_payloads(response):
|
|
771
|
+
event_type = payload.get("type") or event
|
|
772
|
+
if event_type == "response.output_item.done":
|
|
773
|
+
collect(payload.get("item") or {})
|
|
774
|
+
elif event_type == "response.completed":
|
|
775
|
+
final = payload.get("response") or {}
|
|
776
|
+
if final.get("status") not in {None, "completed"} or final.get("error"):
|
|
777
|
+
raise RuntimeError("Codex image generation did not complete successfully")
|
|
778
|
+
for item in final.get("output") or []:
|
|
779
|
+
collect(item)
|
|
780
|
+
completed = True
|
|
781
|
+
elif event_type in {"response.failed", "response.incomplete", "response.error", "error"}:
|
|
782
|
+
message = _response_error_message(payload) or event_type
|
|
783
|
+
for secret in secrets:
|
|
784
|
+
if secret:
|
|
785
|
+
message = message.replace(secret, "[REDACTED_SECRET]")
|
|
786
|
+
raise RuntimeError(f"Codex image generation failed: {message}")
|
|
787
|
+
if not completed:
|
|
788
|
+
raise RuntimeError("Codex image stream ended without a completed response")
|
|
789
|
+
if not images:
|
|
790
|
+
raise RuntimeError("Codex returned no image; the account/model may not support image generation")
|
|
791
|
+
return list(images.values())
|
|
792
|
+
|
|
793
|
+
async def _stream_via_httpx(
|
|
794
|
+
self,
|
|
795
|
+
params: dict[str, Any],
|
|
796
|
+
) -> AsyncGenerator[StreamChunk, None]:
|
|
797
|
+
url = f"{self._base_url.rstrip('/')}/responses"
|
|
798
|
+
active_calls: dict[str, dict[str, str]] = {}
|
|
799
|
+
finalized_call_items: set[str] = set()
|
|
800
|
+
saw_terminal_event = False
|
|
801
|
+
|
|
802
|
+
from crabcode_core.api.network import http_options, request_timeout
|
|
803
|
+
async with httpx.AsyncClient(timeout=request_timeout(self.config), **http_options(self.config)) as client:
|
|
804
|
+
async with self._httpx_stream_with_retry(
|
|
805
|
+
client,
|
|
806
|
+
url=url,
|
|
807
|
+
headers=self._raw_responses_headers(),
|
|
808
|
+
params=params,
|
|
809
|
+
) as response:
|
|
810
|
+
try:
|
|
811
|
+
response.raise_for_status()
|
|
812
|
+
except httpx.HTTPStatusError:
|
|
813
|
+
body = (await response.aread()).decode("utf-8", "replace").strip()
|
|
814
|
+
error_body = body
|
|
815
|
+
if response.status_code == 401 and self._using_codex_oauth:
|
|
816
|
+
body = (
|
|
817
|
+
"Codex OAuth token was rejected. Run `codex login` "
|
|
818
|
+
"to refresh your Codex auth file, or configure "
|
|
819
|
+
"api_key_env/base_url instead."
|
|
820
|
+
)
|
|
821
|
+
error_message = f"HTTP {response.status_code}"
|
|
822
|
+
if body:
|
|
823
|
+
error_message = f"{error_message}: {body}"
|
|
824
|
+
yield StreamChunk(
|
|
825
|
+
type="error",
|
|
826
|
+
error=safe_utf8_str(error_message),
|
|
827
|
+
error_body=error_body,
|
|
828
|
+
error_status_code=response.status_code,
|
|
829
|
+
error_request_id=response_request_id(response),
|
|
830
|
+
retryable=(
|
|
831
|
+
response.status_code in {408, 409, 429}
|
|
832
|
+
or response.status_code >= 500
|
|
833
|
+
),
|
|
834
|
+
retry_after=(
|
|
835
|
+
float(response.headers["retry-after"])
|
|
836
|
+
if response.headers.get("retry-after", "").replace(".", "", 1).isdigit()
|
|
837
|
+
else None
|
|
838
|
+
),
|
|
839
|
+
)
|
|
840
|
+
return
|
|
841
|
+
|
|
842
|
+
content_type = response.headers.get("content-type", "").lower()
|
|
843
|
+
if "event-stream" not in content_type and "json" in content_type:
|
|
844
|
+
body = (await response.aread()).decode("utf-8", "replace").strip()
|
|
845
|
+
try:
|
|
846
|
+
payload = json.loads(body) if body else {}
|
|
847
|
+
except json.JSONDecodeError:
|
|
848
|
+
payload = {}
|
|
849
|
+
error_message = _response_error_message(payload)
|
|
850
|
+
if error_message:
|
|
851
|
+
yield _responses_error_chunk(payload, error_message, response=response)
|
|
852
|
+
else:
|
|
853
|
+
yield StreamChunk(
|
|
854
|
+
type="error",
|
|
855
|
+
error_body=body,
|
|
856
|
+
error_status_code=response.status_code,
|
|
857
|
+
error_request_id=response_request_id(response),
|
|
858
|
+
error=(
|
|
859
|
+
"Responses endpoint returned JSON without a usable "
|
|
860
|
+
"response or error payload"
|
|
861
|
+
),
|
|
862
|
+
retryable=False,
|
|
863
|
+
)
|
|
864
|
+
return
|
|
865
|
+
|
|
866
|
+
async for sse_event, payload in _iter_sse_payloads(response):
|
|
867
|
+
event_type = payload.get("type") or sse_event
|
|
868
|
+
|
|
869
|
+
if event_type == "response.output_text.delta":
|
|
870
|
+
yield StreamChunk(
|
|
871
|
+
type="text",
|
|
872
|
+
text=safe_utf8_str(str(payload.get("delta", ""))),
|
|
873
|
+
)
|
|
874
|
+
|
|
875
|
+
elif event_type == "response.function_call_arguments.delta":
|
|
876
|
+
item_id = str(payload.get("item_id", ""))
|
|
877
|
+
if item_id not in active_calls:
|
|
878
|
+
active_calls[item_id] = {
|
|
879
|
+
"call_id": "",
|
|
880
|
+
"name": "",
|
|
881
|
+
"arguments": "",
|
|
882
|
+
}
|
|
883
|
+
buf = active_calls[item_id]
|
|
884
|
+
delta = str(payload.get("delta", ""))
|
|
885
|
+
buf["arguments"] += delta
|
|
886
|
+
call_id = buf.get("call_id", "") or item_id
|
|
887
|
+
yield StreamChunk(
|
|
888
|
+
type="tool_use_delta",
|
|
889
|
+
tool_use_id=call_id,
|
|
890
|
+
tool_input_json=delta,
|
|
891
|
+
)
|
|
892
|
+
|
|
893
|
+
elif event_type == "response.output_item.added":
|
|
894
|
+
item = payload.get("item", {}) or {}
|
|
895
|
+
if item.get("type") == "function_call":
|
|
896
|
+
item_id = str(item.get("id", ""))
|
|
897
|
+
call_id = str(item.get("call_id", "") or item_id)
|
|
898
|
+
name = str(item.get("name", ""))
|
|
899
|
+
if item_id:
|
|
900
|
+
active_calls[item_id] = {
|
|
901
|
+
"call_id": call_id,
|
|
902
|
+
"name": name,
|
|
903
|
+
"arguments": "",
|
|
904
|
+
}
|
|
905
|
+
yield StreamChunk(
|
|
906
|
+
type="tool_use_start",
|
|
907
|
+
tool_use_id=call_id,
|
|
908
|
+
tool_name=name,
|
|
909
|
+
)
|
|
910
|
+
|
|
911
|
+
elif event_type == "response.function_call_arguments.done":
|
|
912
|
+
item_id = str(payload.get("item_id", ""))
|
|
913
|
+
buf = active_calls.get(item_id, {})
|
|
914
|
+
yield StreamChunk(
|
|
915
|
+
type="tool_use_end",
|
|
916
|
+
tool_use_id=buf.get("call_id", item_id),
|
|
917
|
+
tool_name=buf.get("name", ""),
|
|
918
|
+
tool_input_json=str(
|
|
919
|
+
payload.get("arguments", buf.get("arguments", ""))
|
|
920
|
+
),
|
|
921
|
+
)
|
|
922
|
+
active_calls.pop(item_id, None)
|
|
923
|
+
finalized_call_items.add(item_id)
|
|
924
|
+
|
|
925
|
+
elif event_type == "response.output_item.done":
|
|
926
|
+
item = payload.get("item", {}) or {}
|
|
927
|
+
if item.get("type") == "function_call":
|
|
928
|
+
item_id = str(item.get("id", ""))
|
|
929
|
+
if item_id and item_id in active_calls:
|
|
930
|
+
buf = active_calls.pop(item_id)
|
|
931
|
+
yield StreamChunk(
|
|
932
|
+
type="tool_use_end",
|
|
933
|
+
tool_use_id=buf.get("call_id", item_id),
|
|
934
|
+
tool_name=buf.get("name", ""),
|
|
935
|
+
tool_input_json=buf.get("arguments", ""),
|
|
936
|
+
)
|
|
937
|
+
elif item_id not in finalized_call_items:
|
|
938
|
+
call_id = str(item.get("call_id", "") or item_id)
|
|
939
|
+
name = str(item.get("name", ""))
|
|
940
|
+
yield StreamChunk(
|
|
941
|
+
type="tool_use_start",
|
|
942
|
+
tool_use_id=call_id,
|
|
943
|
+
tool_name=name,
|
|
944
|
+
)
|
|
945
|
+
yield StreamChunk(
|
|
946
|
+
type="tool_use_end",
|
|
947
|
+
tool_use_id=call_id,
|
|
948
|
+
tool_name=name,
|
|
949
|
+
tool_input_json=str(item.get("arguments", "") or "{}"),
|
|
950
|
+
)
|
|
951
|
+
finalized_call_items.add(item_id)
|
|
952
|
+
yield StreamChunk(
|
|
953
|
+
type="response_item_done",
|
|
954
|
+
item_id=str(item.get("id", "")),
|
|
955
|
+
item_type=str(item.get("type", "")),
|
|
956
|
+
)
|
|
957
|
+
|
|
958
|
+
elif event_type == "response.reasoning_summary_text.delta":
|
|
959
|
+
yield StreamChunk(
|
|
960
|
+
type="thinking",
|
|
961
|
+
text=safe_utf8_str(str(payload.get("delta", ""))),
|
|
962
|
+
)
|
|
963
|
+
|
|
964
|
+
elif event_type == "response.completed":
|
|
965
|
+
saw_terminal_event = True
|
|
966
|
+
response_payload = payload.get("response")
|
|
967
|
+
usage_payload = (
|
|
968
|
+
response_payload.get("usage", {})
|
|
969
|
+
if isinstance(response_payload, dict)
|
|
970
|
+
else {}
|
|
971
|
+
) or {}
|
|
972
|
+
usage = normalize_openai_usage(usage_payload)
|
|
973
|
+
yield StreamChunk(
|
|
974
|
+
type="message_stop",
|
|
975
|
+
stop_reason="end_turn",
|
|
976
|
+
usage=usage,
|
|
977
|
+
)
|
|
978
|
+
|
|
979
|
+
elif event_type == "response.failed":
|
|
980
|
+
saw_terminal_event = True
|
|
981
|
+
response_payload = payload.get("response")
|
|
982
|
+
error_payload = (
|
|
983
|
+
response_payload.get("error", {})
|
|
984
|
+
if isinstance(response_payload, dict)
|
|
985
|
+
else payload.get("error", {})
|
|
986
|
+
) or {}
|
|
987
|
+
error_message = (
|
|
988
|
+
_response_error_message({"error": error_payload})
|
|
989
|
+
or "Response failed"
|
|
990
|
+
)
|
|
991
|
+
yield _responses_error_chunk(payload, error_message, response=response)
|
|
992
|
+
|
|
993
|
+
elif event_type == "response.incomplete":
|
|
994
|
+
saw_terminal_event = True
|
|
995
|
+
yield StreamChunk(
|
|
996
|
+
type="error",
|
|
997
|
+
error_body=payload,
|
|
998
|
+
error_status_code=response.status_code,
|
|
999
|
+
error_request_id=response_request_id(response),
|
|
1000
|
+
error=_incomplete_response_message(
|
|
1001
|
+
payload.get("response") or {}
|
|
1002
|
+
),
|
|
1003
|
+
retryable=True,
|
|
1004
|
+
)
|
|
1005
|
+
|
|
1006
|
+
elif event_type in {"response.error", "error"}:
|
|
1007
|
+
saw_terminal_event = True
|
|
1008
|
+
yield _responses_error_chunk(payload, "Unknown error", response=response)
|
|
1009
|
+
|
|
1010
|
+
elif _response_error_message(payload):
|
|
1011
|
+
# Some proxies wrap an error in a non-standard SSE
|
|
1012
|
+
# event name. Preserve the payload instead of
|
|
1013
|
+
# reducing it to a generic empty-stream failure.
|
|
1014
|
+
saw_terminal_event = True
|
|
1015
|
+
yield _responses_error_chunk(payload, "Unknown error", response=response)
|
|
1016
|
+
|
|
1017
|
+
if not saw_terminal_event:
|
|
1018
|
+
yield StreamChunk(
|
|
1019
|
+
type="error",
|
|
1020
|
+
error="Responses stream ended without a terminal event",
|
|
1021
|
+
)
|
|
1022
|
+
|
|
1023
|
+
def _request_params(
|
|
1024
|
+
self, messages: list[Message], system: list[str],
|
|
1025
|
+
tools: list[dict[str, Any]], config: ModelConfig,
|
|
1026
|
+
) -> dict[str, Any]:
|
|
1027
|
+
"""Share input serialization between generation and server counting."""
|
|
1028
|
+
model = config.model or self.config.model or "codex-mini-latest"
|
|
1029
|
+
|
|
1030
|
+
# Responses API uses 'instructions' for system prompt
|
|
1031
|
+
instructions_raw = "\n\n".join(s for s in system if s) or None
|
|
1032
|
+
instructions = (
|
|
1033
|
+
safe_utf8_str(instructions_raw) if instructions_raw else None
|
|
1034
|
+
)
|
|
1035
|
+
|
|
1036
|
+
params: dict[str, Any] = {
|
|
1037
|
+
"model": model,
|
|
1038
|
+
"input": _messages_to_responses_input(messages),
|
|
1039
|
+
"stream": True,
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
if instructions:
|
|
1043
|
+
params["instructions"] = instructions
|
|
1044
|
+
|
|
1045
|
+
if config.max_tokens:
|
|
1046
|
+
params["max_output_tokens"] = config.max_tokens
|
|
1047
|
+
|
|
1048
|
+
if config.temperature is not None:
|
|
1049
|
+
params["temperature"] = config.temperature
|
|
1050
|
+
|
|
1051
|
+
api_tools = _tools_to_responses(tools)
|
|
1052
|
+
if api_tools:
|
|
1053
|
+
params["tools"] = api_tools
|
|
1054
|
+
|
|
1055
|
+
extra_body = safe_utf8_json_tree(dict(self.config.extra_body or {}))
|
|
1056
|
+
prompt_cache_key = self._prompt_cache_key()
|
|
1057
|
+
if prompt_cache_key:
|
|
1058
|
+
extra_body["prompt_cache_key"] = prompt_cache_key
|
|
1059
|
+
if self.config.prompt_cache_retention:
|
|
1060
|
+
extra_body["prompt_cache_retention"] = self.config.prompt_cache_retention
|
|
1061
|
+
|
|
1062
|
+
# For o-series models, configure reasoning
|
|
1063
|
+
reasoning_effort = (
|
|
1064
|
+
config.reasoning_effort
|
|
1065
|
+
if config.reasoning_effort is not None
|
|
1066
|
+
else self.config.reasoning_effort
|
|
1067
|
+
)
|
|
1068
|
+
if reasoning_effort is not None:
|
|
1069
|
+
if reasoning_effort != "none":
|
|
1070
|
+
params["reasoning"] = {
|
|
1071
|
+
"effort": reasoning_effort,
|
|
1072
|
+
"summary": "auto",
|
|
1073
|
+
}
|
|
1074
|
+
elif config.thinking_enabled:
|
|
1075
|
+
# Backward-compatible mapping from thinking_budget to reasoning effort
|
|
1076
|
+
budget = config.thinking_budget
|
|
1077
|
+
if budget >= 20000:
|
|
1078
|
+
effort = "high"
|
|
1079
|
+
elif budget >= 8000:
|
|
1080
|
+
effort = "medium"
|
|
1081
|
+
else:
|
|
1082
|
+
effort = "low"
|
|
1083
|
+
params["reasoning"] = {"effort": effort, "summary": "auto"}
|
|
1084
|
+
|
|
1085
|
+
if extra_body:
|
|
1086
|
+
params["extra_body"] = extra_body
|
|
1087
|
+
return params
|
|
1088
|
+
|
|
1089
|
+
async def count_input_tokens(
|
|
1090
|
+
self, messages: list[Message], system: list[str],
|
|
1091
|
+
tools: list[dict[str, Any]], config: ModelConfig,
|
|
1092
|
+
) -> int | None:
|
|
1093
|
+
# The OAuth backend is not the public Responses API. Do not forward
|
|
1094
|
+
# its credentials to api.openai.com to obtain a count.
|
|
1095
|
+
if self._using_codex_oauth:
|
|
1096
|
+
return None
|
|
1097
|
+
payload = self._request_params(messages, system, tools, config)
|
|
1098
|
+
payload.update(payload.pop("extra_body", {}))
|
|
1099
|
+
supported = {
|
|
1100
|
+
"model", "input", "instructions", "tools", "tool_choice",
|
|
1101
|
+
"parallel_tool_calls", "previous_response_id", "conversation",
|
|
1102
|
+
"reasoning", "text", "truncation", "personality",
|
|
1103
|
+
}
|
|
1104
|
+
payload = {key: value for key, value in payload.items() if key in supported}
|
|
1105
|
+
headers = {key: value for key, value in self._raw_responses_headers().items()
|
|
1106
|
+
if key.lower() != "accept"}
|
|
1107
|
+
headers["Accept"] = "application/json"
|
|
1108
|
+
return await self._count_tokens_http(
|
|
1109
|
+
f"{self._base_url.rstrip('/')}/responses/input_tokens", payload, headers,
|
|
1110
|
+
)
|
|
1111
|
+
|
|
1112
|
+
async def stream_message(
|
|
1113
|
+
self,
|
|
1114
|
+
messages: list[Message],
|
|
1115
|
+
system: list[str],
|
|
1116
|
+
tools: list[dict[str, Any]],
|
|
1117
|
+
config: ModelConfig,
|
|
1118
|
+
) -> AsyncGenerator[StreamChunk, None]:
|
|
1119
|
+
sdk_params = self._request_params(messages, system, tools, config)
|
|
1120
|
+
raw_params = dict(sdk_params)
|
|
1121
|
+
raw_params.update(raw_params.pop("extra_body", {}))
|
|
1122
|
+
|
|
1123
|
+
if self._using_codex_oauth:
|
|
1124
|
+
# The Codex OAuth endpoint does not support server-side response
|
|
1125
|
+
# storage and rejects requests unless this is explicitly false.
|
|
1126
|
+
# Force the required value even if extra_body contains store=true.
|
|
1127
|
+
raw_params["store"] = False
|
|
1128
|
+
# Output limits are managed by the Codex backend; unlike the public
|
|
1129
|
+
# Responses API, its OAuth endpoint rejects max_output_tokens.
|
|
1130
|
+
raw_params.pop("max_output_tokens", None)
|
|
1131
|
+
async for chunk in self._stream_via_httpx(raw_params):
|
|
1132
|
+
yield chunk
|
|
1133
|
+
return
|
|
1134
|
+
|
|
1135
|
+
# Track active function calls by item_id
|
|
1136
|
+
active_calls: dict[str, dict[str, str]] = {}
|
|
1137
|
+
finalized_call_items: set[str] = set()
|
|
1138
|
+
emitted_stream_event = False
|
|
1139
|
+
saw_sdk_terminal_event = False
|
|
1140
|
+
try:
|
|
1141
|
+
stream = await self._create_sdk_response_stream(sdk_params)
|
|
1142
|
+
async for event in stream:
|
|
1143
|
+
emitted_stream_event = True
|
|
1144
|
+
event_type = getattr(event, "type", "")
|
|
1145
|
+
|
|
1146
|
+
# Text delta
|
|
1147
|
+
if event_type == "response.output_text.delta":
|
|
1148
|
+
yield StreamChunk(type="text", text=safe_utf8_str(event.delta))
|
|
1149
|
+
|
|
1150
|
+
# Function call arguments delta
|
|
1151
|
+
elif event_type == "response.function_call_arguments.delta":
|
|
1152
|
+
item_id = event.item_id
|
|
1153
|
+
if item_id not in active_calls:
|
|
1154
|
+
active_calls[item_id] = {
|
|
1155
|
+
"call_id": "",
|
|
1156
|
+
"name": "",
|
|
1157
|
+
"arguments": "",
|
|
1158
|
+
}
|
|
1159
|
+
buf = active_calls[item_id]
|
|
1160
|
+
buf["arguments"] += event.delta
|
|
1161
|
+
call_id = buf.get("call_id", "") or item_id
|
|
1162
|
+
yield StreamChunk(
|
|
1163
|
+
type="tool_use_delta",
|
|
1164
|
+
tool_use_id=call_id,
|
|
1165
|
+
tool_input_json=event.delta,
|
|
1166
|
+
)
|
|
1167
|
+
|
|
1168
|
+
# Output item added — detect function_call start
|
|
1169
|
+
elif event_type == "response.output_item.added":
|
|
1170
|
+
item = event.item
|
|
1171
|
+
item_type = getattr(item, "type", "")
|
|
1172
|
+
if item_type == "function_call":
|
|
1173
|
+
item_id = getattr(item, "id", "")
|
|
1174
|
+
call_id = getattr(item, "call_id", "") or item_id
|
|
1175
|
+
name = getattr(item, "name", "")
|
|
1176
|
+
if item_id:
|
|
1177
|
+
active_calls[item_id] = {
|
|
1178
|
+
"call_id": call_id,
|
|
1179
|
+
"name": name,
|
|
1180
|
+
"arguments": "",
|
|
1181
|
+
}
|
|
1182
|
+
yield StreamChunk(
|
|
1183
|
+
type="tool_use_start",
|
|
1184
|
+
tool_use_id=call_id,
|
|
1185
|
+
tool_name=name,
|
|
1186
|
+
)
|
|
1187
|
+
|
|
1188
|
+
# Function call arguments done
|
|
1189
|
+
elif event_type == "response.function_call_arguments.done":
|
|
1190
|
+
item_id = event.item_id
|
|
1191
|
+
buf = active_calls.get(item_id, {})
|
|
1192
|
+
call_id = buf.get("call_id", item_id)
|
|
1193
|
+
name = buf.get("name", "")
|
|
1194
|
+
arguments = event.arguments or buf.get("arguments", "")
|
|
1195
|
+
yield StreamChunk(
|
|
1196
|
+
type="tool_use_end",
|
|
1197
|
+
tool_use_id=call_id,
|
|
1198
|
+
tool_name=name,
|
|
1199
|
+
tool_input_json=arguments,
|
|
1200
|
+
)
|
|
1201
|
+
active_calls.pop(item_id, None)
|
|
1202
|
+
finalized_call_items.add(item_id)
|
|
1203
|
+
|
|
1204
|
+
# Output item done — also finalize function calls if not already done
|
|
1205
|
+
elif event_type == "response.output_item.done":
|
|
1206
|
+
item = event.item
|
|
1207
|
+
item_type = getattr(item, "type", "")
|
|
1208
|
+
if item_type == "function_call":
|
|
1209
|
+
item_id = getattr(item, "id", "")
|
|
1210
|
+
# Only yield if not already yielded via arguments.done
|
|
1211
|
+
if item_id and item_id in active_calls:
|
|
1212
|
+
buf = active_calls.pop(item_id)
|
|
1213
|
+
call_id = buf.get("call_id", item_id)
|
|
1214
|
+
yield StreamChunk(
|
|
1215
|
+
type="tool_use_end",
|
|
1216
|
+
tool_use_id=call_id,
|
|
1217
|
+
tool_name=buf.get("name", ""),
|
|
1218
|
+
tool_input_json=buf.get("arguments", ""),
|
|
1219
|
+
)
|
|
1220
|
+
elif item_id not in finalized_call_items:
|
|
1221
|
+
call_id = getattr(item, "call_id", "") or item_id
|
|
1222
|
+
name = getattr(item, "name", "") or ""
|
|
1223
|
+
arguments = getattr(item, "arguments", "") or "{}"
|
|
1224
|
+
yield StreamChunk(
|
|
1225
|
+
type="tool_use_start",
|
|
1226
|
+
tool_use_id=call_id,
|
|
1227
|
+
tool_name=name,
|
|
1228
|
+
)
|
|
1229
|
+
yield StreamChunk(
|
|
1230
|
+
type="tool_use_end",
|
|
1231
|
+
tool_use_id=call_id,
|
|
1232
|
+
tool_name=name,
|
|
1233
|
+
tool_input_json=arguments,
|
|
1234
|
+
)
|
|
1235
|
+
finalized_call_items.add(item_id)
|
|
1236
|
+
yield StreamChunk(
|
|
1237
|
+
type="response_item_done",
|
|
1238
|
+
item_id=str(getattr(item, "id", "") or ""),
|
|
1239
|
+
item_type=str(item_type),
|
|
1240
|
+
)
|
|
1241
|
+
|
|
1242
|
+
# Reasoning summary text delta — treat as thinking
|
|
1243
|
+
elif event_type == "response.reasoning_summary_text.delta":
|
|
1244
|
+
yield StreamChunk(type="thinking", text=safe_utf8_str(event.delta))
|
|
1245
|
+
|
|
1246
|
+
# Response completed
|
|
1247
|
+
elif event_type == "response.completed":
|
|
1248
|
+
saw_sdk_terminal_event = True
|
|
1249
|
+
usage = {}
|
|
1250
|
+
response = event.response
|
|
1251
|
+
if hasattr(response, "usage") and response.usage:
|
|
1252
|
+
usage = normalize_openai_usage(response.usage)
|
|
1253
|
+
yield StreamChunk(
|
|
1254
|
+
type="message_stop",
|
|
1255
|
+
stop_reason="end_turn",
|
|
1256
|
+
usage=usage,
|
|
1257
|
+
model_id=str(getattr(response, "model", "") or ""),
|
|
1258
|
+
)
|
|
1259
|
+
|
|
1260
|
+
# Response failed or incomplete
|
|
1261
|
+
elif event_type == "response.failed":
|
|
1262
|
+
saw_sdk_terminal_event = True
|
|
1263
|
+
error_msg = ""
|
|
1264
|
+
error_code = ""
|
|
1265
|
+
if hasattr(event, "response") and hasattr(event.response, "error"):
|
|
1266
|
+
err = event.response.error
|
|
1267
|
+
if err:
|
|
1268
|
+
error_msg = getattr(err, "message", str(err))
|
|
1269
|
+
error_code = str(getattr(err, "code", "") or "")
|
|
1270
|
+
yield _responses_error_chunk(
|
|
1271
|
+
{"error": {"message": error_msg, "code": error_code}},
|
|
1272
|
+
"Response failed",
|
|
1273
|
+
error_body=event,
|
|
1274
|
+
)
|
|
1275
|
+
|
|
1276
|
+
elif event_type == "response.incomplete":
|
|
1277
|
+
saw_sdk_terminal_event = True
|
|
1278
|
+
yield StreamChunk(
|
|
1279
|
+
type="error",
|
|
1280
|
+
error_body=event,
|
|
1281
|
+
error=_incomplete_response_message(
|
|
1282
|
+
getattr(event, "response", None)
|
|
1283
|
+
),
|
|
1284
|
+
retryable=True,
|
|
1285
|
+
)
|
|
1286
|
+
|
|
1287
|
+
# Error event
|
|
1288
|
+
elif event_type == "response.error":
|
|
1289
|
+
saw_sdk_terminal_event = True
|
|
1290
|
+
error_msg = ""
|
|
1291
|
+
error_code = ""
|
|
1292
|
+
if hasattr(event, "error"):
|
|
1293
|
+
err = event.error
|
|
1294
|
+
error_msg = getattr(err, "message", str(err)) if err else ""
|
|
1295
|
+
error_code = str(getattr(err, "code", "") or "") if err else ""
|
|
1296
|
+
yield _responses_error_chunk(
|
|
1297
|
+
{"error": {"message": error_msg, "code": error_code}},
|
|
1298
|
+
"Unknown error",
|
|
1299
|
+
error_body=event,
|
|
1300
|
+
)
|
|
1301
|
+
if not saw_sdk_terminal_event:
|
|
1302
|
+
yield StreamChunk(
|
|
1303
|
+
type="error",
|
|
1304
|
+
error="Responses stream ended without a terminal event",
|
|
1305
|
+
)
|
|
1306
|
+
except json.JSONDecodeError:
|
|
1307
|
+
if emitted_stream_event:
|
|
1308
|
+
raise
|
|
1309
|
+
|
|
1310
|
+
async for chunk in self._stream_via_httpx(raw_params):
|
|
1311
|
+
yield chunk
|
|
1312
|
+
|
|
1313
|
+
async def count_tokens(
|
|
1314
|
+
self,
|
|
1315
|
+
messages: list[Message],
|
|
1316
|
+
system: list[str],
|
|
1317
|
+
) -> int:
|
|
1318
|
+
# Legacy local estimate; count_input_tokens() uses the server when available
|
|
1319
|
+
total = sum(len(s) for s in system)
|
|
1320
|
+
for msg in messages:
|
|
1321
|
+
if isinstance(msg.content, str):
|
|
1322
|
+
total += len(msg.content)
|
|
1323
|
+
else:
|
|
1324
|
+
for block in msg.content:
|
|
1325
|
+
if isinstance(block, TextBlock):
|
|
1326
|
+
total += len(block.text)
|
|
1327
|
+
return total // 4
|