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.
Files changed (205) hide show
  1. crabcode-0.1.6.dist-info/METADATA +63 -0
  2. crabcode-0.1.6.dist-info/RECORD +205 -0
  3. crabcode-0.1.6.dist-info/WHEEL +4 -0
  4. crabcode-0.1.6.dist-info/entry_points.txt +2 -0
  5. crabcode_cli/__init__.py +7 -0
  6. crabcode_cli/__main__.py +4 -0
  7. crabcode_cli/_native/crabcode-computer-use-host.exe +0 -0
  8. crabcode_cli/_native/runtime.json +6 -0
  9. crabcode_cli/app.py +776 -0
  10. crabcode_cli/banner.py +39 -0
  11. crabcode_cli/computer_use.py +286 -0
  12. crabcode_cli/model_manager.py +649 -0
  13. crabcode_cli/model_picker.py +168 -0
  14. crabcode_cli/pipe.py +148 -0
  15. crabcode_cli/render/__init__.py +0 -0
  16. crabcode_cli/render/messages.py +19 -0
  17. crabcode_cli/repl.py +4841 -0
  18. crabcode_cli/session_picker.py +239 -0
  19. crabcode_core/__init__.py +7 -0
  20. crabcode_core/_version.py +3 -0
  21. crabcode_core/_windows_process.py +68 -0
  22. crabcode_core/agent_manager.py +1731 -0
  23. crabcode_core/api/__init__.py +20 -0
  24. crabcode_core/api/anthropic_adapter.py +648 -0
  25. crabcode_core/api/azure_adapter.py +69 -0
  26. crabcode_core/api/base.py +233 -0
  27. crabcode_core/api/codex_adapter.py +1327 -0
  28. crabcode_core/api/error_logging.py +132 -0
  29. crabcode_core/api/gemini_adapter.py +26 -0
  30. crabcode_core/api/model_info.py +257 -0
  31. crabcode_core/api/network.py +62 -0
  32. crabcode_core/api/ollama_adapter.py +27 -0
  33. crabcode_core/api/openai_adapter.py +323 -0
  34. crabcode_core/api/registry.py +58 -0
  35. crabcode_core/command_line.py +18 -0
  36. crabcode_core/compact/__init__.py +17 -0
  37. crabcode_core/compact/compact.py +514 -0
  38. crabcode_core/compact/context_tokens.py +165 -0
  39. crabcode_core/computer_use_mode.py +38 -0
  40. crabcode_core/computer_use_observation.py +144 -0
  41. crabcode_core/config/__init__.py +3 -0
  42. crabcode_core/config/claudemd.py +61 -0
  43. crabcode_core/config/manager.py +237 -0
  44. crabcode_core/config/model_catalog.py +256 -0
  45. crabcode_core/events.py +4896 -0
  46. crabcode_core/file_lock.py +81 -0
  47. crabcode_core/filesystem.py +22 -0
  48. crabcode_core/goal.py +149 -0
  49. crabcode_core/hooks/__init__.py +5 -0
  50. crabcode_core/hooks/manager.py +290 -0
  51. crabcode_core/image_io.py +111 -0
  52. crabcode_core/io_worker.py +211 -0
  53. crabcode_core/logging_utils.py +168 -0
  54. crabcode_core/lsp/__init__.py +6 -0
  55. crabcode_core/lsp/client.py +786 -0
  56. crabcode_core/lsp/diagnostics.py +150 -0
  57. crabcode_core/lsp/manager.py +583 -0
  58. crabcode_core/mcp/__init__.py +4 -0
  59. crabcode_core/mcp/client.py +169 -0
  60. crabcode_core/mcp/config.py +57 -0
  61. crabcode_core/path_validation.py +34 -0
  62. crabcode_core/paths.py +24 -0
  63. crabcode_core/peer/__init__.py +10 -0
  64. crabcode_core/peer/runtime.py +518 -0
  65. crabcode_core/permissions/__init__.py +7 -0
  66. crabcode_core/permissions/ai_reviewer.py +184 -0
  67. crabcode_core/permissions/manager.py +187 -0
  68. crabcode_core/plan/__init__.py +5 -0
  69. crabcode_core/plan/executor.py +270 -0
  70. crabcode_core/plan/types.py +169 -0
  71. crabcode_core/prompts/__init__.py +0 -0
  72. crabcode_core/prompts/blocks.py +10 -0
  73. crabcode_core/prompts/context.py +229 -0
  74. crabcode_core/prompts/library.py +92 -0
  75. crabcode_core/prompts/profile.py +79 -0
  76. crabcode_core/prompts/system.py +356 -0
  77. crabcode_core/prompts/templates.py +104 -0
  78. crabcode_core/query/__init__.py +0 -0
  79. crabcode_core/query/computer_use_history.py +83 -0
  80. crabcode_core/query/loop.py +2446 -0
  81. crabcode_core/query/retry.py +116 -0
  82. crabcode_core/schedule/__init__.py +13 -0
  83. crabcode_core/schedule/manager.py +631 -0
  84. crabcode_core/schedule/models.py +204 -0
  85. crabcode_core/schedule/store.py +558 -0
  86. crabcode_core/schedule/timing.py +287 -0
  87. crabcode_core/session/__init__.py +12 -0
  88. crabcode_core/session/export.py +141 -0
  89. crabcode_core/session/meta_db.py +684 -0
  90. crabcode_core/session/storage.py +2032 -0
  91. crabcode_core/session/title_gen.py +52 -0
  92. crabcode_core/skills/__init__.py +6 -0
  93. crabcode_core/skills/context.py +23 -0
  94. crabcode_core/skills/loader.py +214 -0
  95. crabcode_core/skills/matcher.py +203 -0
  96. crabcode_core/snapshot/__init__.py +21 -0
  97. crabcode_core/snapshot/snapshot.py +436 -0
  98. crabcode_core/snapshot/tracker.py +359 -0
  99. crabcode_core/subprocess_utils.py +260 -0
  100. crabcode_core/team/__init__.py +27 -0
  101. crabcode_core/team/bridge.py +66 -0
  102. crabcode_core/team/inbox.py +237 -0
  103. crabcode_core/team/manager.py +1660 -0
  104. crabcode_core/team/message_bus.py +821 -0
  105. crabcode_core/team/models.py +191 -0
  106. crabcode_core/team/recovery.py +192 -0
  107. crabcode_core/text_io.py +52 -0
  108. crabcode_core/tools/__init__.py +112 -0
  109. crabcode_core/tools/_input_helpers.py +39 -0
  110. crabcode_core/tools/_input_schema.py +130 -0
  111. crabcode_core/tools/agent.py +413 -0
  112. crabcode_core/tools/apply_patch.py +587 -0
  113. crabcode_core/tools/ask_user.py +122 -0
  114. crabcode_core/tools/bash.py +154 -0
  115. crabcode_core/tools/browser.py +678 -0
  116. crabcode_core/tools/checklist.py +241 -0
  117. crabcode_core/tools/checkpoint.py +78 -0
  118. crabcode_core/tools/computer_use.py +599 -0
  119. crabcode_core/tools/diff_utils.py +123 -0
  120. crabcode_core/tools/file_edit.py +240 -0
  121. crabcode_core/tools/file_read.py +187 -0
  122. crabcode_core/tools/file_write.py +154 -0
  123. crabcode_core/tools/glob.py +123 -0
  124. crabcode_core/tools/goal.py +134 -0
  125. crabcode_core/tools/grep.py +247 -0
  126. crabcode_core/tools/image.py +173 -0
  127. crabcode_core/tools/image_generate.py +115 -0
  128. crabcode_core/tools/lint.py +388 -0
  129. crabcode_core/tools/loading.py +187 -0
  130. crabcode_core/tools/memory.py +456 -0
  131. crabcode_core/tools/monitor.py +677 -0
  132. crabcode_core/tools/peer.py +103 -0
  133. crabcode_core/tools/revert.py +90 -0
  134. crabcode_core/tools/schedule.py +683 -0
  135. crabcode_core/tools/skill.py +108 -0
  136. crabcode_core/tools/switch_mode.py +213 -0
  137. crabcode_core/tools/team.py +329 -0
  138. crabcode_core/tools/tool_search.py +64 -0
  139. crabcode_core/tools/web_search.py +444 -0
  140. crabcode_core/turn_details.py +64 -0
  141. crabcode_core/types/__init__.py +95 -0
  142. crabcode_core/types/config.py +434 -0
  143. crabcode_core/types/event.py +334 -0
  144. crabcode_core/types/message.py +263 -0
  145. crabcode_core/types/tool.py +208 -0
  146. crabcode_core/usage.py +289 -0
  147. crabcode_core/utf8_sanitize.py +29 -0
  148. crabcode_debugger/__init__.py +5 -0
  149. crabcode_debugger/adapters.py +332 -0
  150. crabcode_debugger/dap.py +304 -0
  151. crabcode_debugger/memory.py +1806 -0
  152. crabcode_debugger/process.py +707 -0
  153. crabcode_debugger/runtime.py +58 -0
  154. crabcode_debugger/sessions.py +387 -0
  155. crabcode_debugger/tool.py +731 -0
  156. crabcode_gateway/__init__.py +18 -0
  157. crabcode_gateway/acp/__init__.py +13 -0
  158. crabcode_gateway/acp/agent.py +1581 -0
  159. crabcode_gateway/acp/session.py +114 -0
  160. crabcode_gateway/acp/transport.py +39 -0
  161. crabcode_gateway/acp/types.py +90 -0
  162. crabcode_gateway/adapter.py +30 -0
  163. crabcode_gateway/auth.py +139 -0
  164. crabcode_gateway/computer_use.py +364 -0
  165. crabcode_gateway/document_engine.py +637 -0
  166. crabcode_gateway/document_worker.py +301 -0
  167. crabcode_gateway/event_bus.py +655 -0
  168. crabcode_gateway/grpc/__init__.py +0 -0
  169. crabcode_gateway/grpc/crabcode_pb2.py +70 -0
  170. crabcode_gateway/grpc/crabcode_pb2_grpc.py +474 -0
  171. crabcode_gateway/grpc/proto/crabcode.proto +160 -0
  172. crabcode_gateway/grpc/server.py +765 -0
  173. crabcode_gateway/middleware.py +344 -0
  174. crabcode_gateway/protocol.py +17 -0
  175. crabcode_gateway/routes/__init__.py +0 -0
  176. crabcode_gateway/routes/agent.py +392 -0
  177. crabcode_gateway/routes/auth.py +150 -0
  178. crabcode_gateway/routes/computer_use.py +101 -0
  179. crabcode_gateway/routes/config.py +1698 -0
  180. crabcode_gateway/routes/document.py +1810 -0
  181. crabcode_gateway/routes/event.py +3801 -0
  182. crabcode_gateway/routes/git_review.py +232 -0
  183. crabcode_gateway/routes/health.py +14 -0
  184. crabcode_gateway/routes/peer.py +78 -0
  185. crabcode_gateway/routes/permission.py +121 -0
  186. crabcode_gateway/routes/schedule.py +300 -0
  187. crabcode_gateway/routes/session.py +1496 -0
  188. crabcode_gateway/routes/snapshot.py +211 -0
  189. crabcode_gateway/routes/tasks.py +318 -0
  190. crabcode_gateway/routes/team.py +392 -0
  191. crabcode_gateway/routes/usage.py +49 -0
  192. crabcode_gateway/routes/workspace.py +299 -0
  193. crabcode_gateway/safe_file.py +63 -0
  194. crabcode_gateway/schemas.py +1820 -0
  195. crabcode_gateway/server.py +514 -0
  196. crabcode_gateway/session_registry.py +36 -0
  197. crabcode_gateway/task_registry.py +708 -0
  198. crabcode_gateway/turn_details.py +60 -0
  199. crabcode_search/__init__.py +11 -0
  200. crabcode_search/background.py +368 -0
  201. crabcode_search/chunker.py +517 -0
  202. crabcode_search/embedder.py +497 -0
  203. crabcode_search/indexer.py +215 -0
  204. crabcode_search/store.py +222 -0
  205. 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