illusion-code 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (214) hide show
  1. illusion/__init__.py +24 -0
  2. illusion/__main__.py +15 -0
  3. illusion/_frontend/dist/index.mjs +39208 -0
  4. illusion/_frontend/package.json +27 -0
  5. illusion/_frontend/src/App.tsx +624 -0
  6. illusion/_frontend/src/components/CommandPicker.tsx +98 -0
  7. illusion/_frontend/src/components/Composer.tsx +55 -0
  8. illusion/_frontend/src/components/ComposerController.tsx +128 -0
  9. illusion/_frontend/src/components/ConversationView.tsx +750 -0
  10. illusion/_frontend/src/components/Footer.tsx +25 -0
  11. illusion/_frontend/src/components/MarkdownContent.tsx +537 -0
  12. illusion/_frontend/src/components/MarkdownTable.tsx +245 -0
  13. illusion/_frontend/src/components/ModalHost.tsx +425 -0
  14. illusion/_frontend/src/components/MultilineTextInput.tsx +250 -0
  15. illusion/_frontend/src/components/PromptInput.tsx +64 -0
  16. illusion/_frontend/src/components/SelectModal.tsx +78 -0
  17. illusion/_frontend/src/components/SidePanel.tsx +175 -0
  18. illusion/_frontend/src/components/Spinner.tsx +77 -0
  19. illusion/_frontend/src/components/StatusBar.tsx +142 -0
  20. illusion/_frontend/src/components/SwarmPanel.tsx +141 -0
  21. illusion/_frontend/src/components/TodoPanel.tsx +126 -0
  22. illusion/_frontend/src/components/ToolCallDisplay.tsx +202 -0
  23. illusion/_frontend/src/components/TranscriptPane.tsx +79 -0
  24. illusion/_frontend/src/components/WelcomeBanner.tsx +37 -0
  25. illusion/_frontend/src/hooks/useBackendSession.ts +468 -0
  26. illusion/_frontend/src/hooks/useTerminalSize.ts +9 -0
  27. illusion/_frontend/src/i18n.ts +78 -0
  28. illusion/_frontend/src/index.tsx +42 -0
  29. illusion/_frontend/src/theme/ThemeContext.tsx +19 -0
  30. illusion/_frontend/src/theme/builtinThemes.ts +89 -0
  31. illusion/_frontend/src/types.ts +110 -0
  32. illusion/_frontend/src/utils/markdown.ts +33 -0
  33. illusion/_frontend/src/utils/thinking.ts +191 -0
  34. illusion/_frontend/tsconfig.json +13 -0
  35. illusion/_web_dist/assets/index-BseIw-ik.css +10 -0
  36. illusion/_web_dist/assets/index-C_0ZWMuW.js +82 -0
  37. illusion/_web_dist/index.html +16 -0
  38. illusion/api/__init__.py +36 -0
  39. illusion/api/client.py +568 -0
  40. illusion/api/codex_client.py +563 -0
  41. illusion/api/compat.py +138 -0
  42. illusion/api/effort.py +128 -0
  43. illusion/api/errors.py +57 -0
  44. illusion/api/openai_client.py +819 -0
  45. illusion/api/provider.py +148 -0
  46. illusion/api/registry.py +479 -0
  47. illusion/api/usage.py +45 -0
  48. illusion/auth/__init__.py +50 -0
  49. illusion/auth/copilot.py +419 -0
  50. illusion/auth/external.py +612 -0
  51. illusion/auth/flows.py +58 -0
  52. illusion/auth/manager.py +214 -0
  53. illusion/auth/storage.py +372 -0
  54. illusion/bridge/__init__.py +38 -0
  55. illusion/bridge/manager.py +190 -0
  56. illusion/bridge/session_runner.py +84 -0
  57. illusion/bridge/types.py +113 -0
  58. illusion/bridge/work_secret.py +131 -0
  59. illusion/cli.py +1228 -0
  60. illusion/commands/__init__.py +32 -0
  61. illusion/commands/registry.py +1934 -0
  62. illusion/config/__init__.py +39 -0
  63. illusion/config/i18n.py +522 -0
  64. illusion/config/paths.py +259 -0
  65. illusion/config/settings.py +564 -0
  66. illusion/coordinator/__init__.py +41 -0
  67. illusion/coordinator/agent_definitions.py +1093 -0
  68. illusion/coordinator/coordinator_mode.py +127 -0
  69. illusion/engine/__init__.py +95 -0
  70. illusion/engine/cost_tracker.py +55 -0
  71. illusion/engine/messages.py +369 -0
  72. illusion/engine/query.py +632 -0
  73. illusion/engine/query_engine.py +343 -0
  74. illusion/engine/stream_events.py +169 -0
  75. illusion/hooks/__init__.py +67 -0
  76. illusion/hooks/events.py +43 -0
  77. illusion/hooks/executor.py +397 -0
  78. illusion/hooks/hot_reload.py +74 -0
  79. illusion/hooks/loader.py +133 -0
  80. illusion/hooks/schemas.py +121 -0
  81. illusion/hooks/types.py +86 -0
  82. illusion/mcp/__init__.py +104 -0
  83. illusion/mcp/client.py +377 -0
  84. illusion/mcp/config.py +140 -0
  85. illusion/mcp/types.py +175 -0
  86. illusion/memory/__init__.py +36 -0
  87. illusion/memory/manager.py +94 -0
  88. illusion/memory/memdir.py +58 -0
  89. illusion/memory/paths.py +57 -0
  90. illusion/memory/scan.py +120 -0
  91. illusion/memory/search.py +83 -0
  92. illusion/memory/types.py +43 -0
  93. illusion/output_styles/__init__.py +15 -0
  94. illusion/output_styles/loader.py +64 -0
  95. illusion/permissions/__init__.py +39 -0
  96. illusion/permissions/checker.py +174 -0
  97. illusion/permissions/modes.py +38 -0
  98. illusion/platforms.py +148 -0
  99. illusion/plugins/__init__.py +71 -0
  100. illusion/plugins/bundled/__init__.py +0 -0
  101. illusion/plugins/installer.py +59 -0
  102. illusion/plugins/loader.py +301 -0
  103. illusion/plugins/schemas.py +51 -0
  104. illusion/plugins/types.py +56 -0
  105. illusion/prompts/__init__.py +29 -0
  106. illusion/prompts/claudemd.py +74 -0
  107. illusion/prompts/context.py +187 -0
  108. illusion/prompts/environment.py +189 -0
  109. illusion/prompts/system_prompt.py +155 -0
  110. illusion/py.typed +0 -0
  111. illusion/sandbox/__init__.py +29 -0
  112. illusion/sandbox/adapter.py +174 -0
  113. illusion/services/__init__.py +59 -0
  114. illusion/services/compact/__init__.py +1015 -0
  115. illusion/services/cron.py +338 -0
  116. illusion/services/cron_scheduler.py +715 -0
  117. illusion/services/file_history.py +258 -0
  118. illusion/services/lsp/__init__.py +455 -0
  119. illusion/services/session_storage.py +237 -0
  120. illusion/services/token_estimation.py +72 -0
  121. illusion/skills/__init__.py +60 -0
  122. illusion/skills/bundled/__init__.py +110 -0
  123. illusion/skills/bundled/content/batch.md +86 -0
  124. illusion/skills/bundled/content/coding-guidelines.md +70 -0
  125. illusion/skills/bundled/content/debug.md +38 -0
  126. illusion/skills/bundled/content/loop.md +82 -0
  127. illusion/skills/bundled/content/remember.md +105 -0
  128. illusion/skills/bundled/content/simplify.md +53 -0
  129. illusion/skills/bundled/content/skillify.md +113 -0
  130. illusion/skills/bundled/content/stuck.md +54 -0
  131. illusion/skills/bundled/content/update-config.md +329 -0
  132. illusion/skills/bundled/content/verify.md +74 -0
  133. illusion/skills/loader.py +219 -0
  134. illusion/skills/registry.py +40 -0
  135. illusion/skills/types.py +24 -0
  136. illusion/state/__init__.py +18 -0
  137. illusion/state/app_state.py +67 -0
  138. illusion/state/store.py +93 -0
  139. illusion/swarm/__init__.py +71 -0
  140. illusion/swarm/agent_executor.py +857 -0
  141. illusion/swarm/in_process.py +259 -0
  142. illusion/swarm/subprocess_backend.py +136 -0
  143. illusion/swarm/team_helpers.py +123 -0
  144. illusion/swarm/types.py +159 -0
  145. illusion/swarm/worktree.py +347 -0
  146. illusion/tasks/__init__.py +33 -0
  147. illusion/tasks/local_agent_task.py +42 -0
  148. illusion/tasks/local_shell_task.py +27 -0
  149. illusion/tasks/manager.py +377 -0
  150. illusion/tasks/stop_task.py +21 -0
  151. illusion/tasks/types.py +88 -0
  152. illusion/tools/__init__.py +126 -0
  153. illusion/tools/agent_tool.py +388 -0
  154. illusion/tools/ask_user_question_tool.py +186 -0
  155. illusion/tools/base.py +149 -0
  156. illusion/tools/bash_tool.py +413 -0
  157. illusion/tools/config_tool.py +90 -0
  158. illusion/tools/cron_tool.py +473 -0
  159. illusion/tools/enter_plan_mode_tool.py +147 -0
  160. illusion/tools/enter_worktree_tool.py +188 -0
  161. illusion/tools/exit_plan_mode_tool.py +69 -0
  162. illusion/tools/exit_worktree_tool.py +225 -0
  163. illusion/tools/file_edit_tool.py +283 -0
  164. illusion/tools/file_read_tool.py +294 -0
  165. illusion/tools/file_write_tool.py +184 -0
  166. illusion/tools/glob_tool.py +165 -0
  167. illusion/tools/grep_tool.py +190 -0
  168. illusion/tools/list_mcp_resources_tool.py +80 -0
  169. illusion/tools/lsp_tool.py +333 -0
  170. illusion/tools/mcp_auth_tool.py +100 -0
  171. illusion/tools/mcp_tool.py +75 -0
  172. illusion/tools/notebook_edit_tool.py +242 -0
  173. illusion/tools/powershell_tool.py +334 -0
  174. illusion/tools/read_mcp_resource_tool.py +63 -0
  175. illusion/tools/repl_tool.py +100 -0
  176. illusion/tools/send_message_tool.py +112 -0
  177. illusion/tools/shell_common.py +187 -0
  178. illusion/tools/skill_tool.py +86 -0
  179. illusion/tools/sleep_tool.py +62 -0
  180. illusion/tools/structured_output_tool.py +58 -0
  181. illusion/tools/task_create_tool.py +98 -0
  182. illusion/tools/task_get_tool.py +94 -0
  183. illusion/tools/task_list_tool.py +94 -0
  184. illusion/tools/task_output_tool.py +55 -0
  185. illusion/tools/task_stop_tool.py +52 -0
  186. illusion/tools/task_update_tool.py +224 -0
  187. illusion/tools/team_create_tool.py +236 -0
  188. illusion/tools/team_delete_tool.py +104 -0
  189. illusion/tools/todo_write_tool.py +198 -0
  190. illusion/tools/tool_search_tool.py +156 -0
  191. illusion/tools/web_fetch_tool.py +264 -0
  192. illusion/tools/web_search_tool.py +186 -0
  193. illusion/ui/__init__.py +23 -0
  194. illusion/ui/app.py +258 -0
  195. illusion/ui/backend_host.py +1180 -0
  196. illusion/ui/input.py +86 -0
  197. illusion/ui/output.py +363 -0
  198. illusion/ui/permission_dialog.py +47 -0
  199. illusion/ui/permission_store.py +99 -0
  200. illusion/ui/protocol.py +384 -0
  201. illusion/ui/react_launcher.py +280 -0
  202. illusion/ui/runtime.py +787 -0
  203. illusion/ui/textual_app.py +603 -0
  204. illusion/ui/web/__init__.py +10 -0
  205. illusion/ui/web/server.py +87 -0
  206. illusion/ui/web/ws_host.py +1197 -0
  207. illusion/utils/__init__.py +0 -0
  208. illusion/utils/ripgrep.py +299 -0
  209. illusion/utils/shell.py +248 -0
  210. illusion_code-0.1.0.dist-info/METADATA +1159 -0
  211. illusion_code-0.1.0.dist-info/RECORD +214 -0
  212. illusion_code-0.1.0.dist-info/WHEEL +4 -0
  213. illusion_code-0.1.0.dist-info/entry_points.txt +2 -0
  214. illusion_code-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,819 @@
1
+ """
2
+ OpenAI 兼容 API 客户端模块
3
+ =========================
4
+
5
+ 本模块提供 OpenAI 兼容 API 客户端封装,支持阿里巴巴 DashScope、GitHub Models 等提供商。
6
+
7
+ 主要功能:
8
+ - 流式文本增量生成
9
+ - Anthropic 工具格式到 OpenAI 格式转换
10
+ - 自动重试 transient 错误
11
+ - 支持思维模型(reasoning_content)
12
+
13
+ 类说明:
14
+ - OpenAICompatibleClient: OpenAI 兼容客户端类
15
+
16
+ 使用示例:
17
+ >>> from illusion.api.openai_client import OpenAICompatibleClient
18
+ >>> client = OpenAICompatibleClient(api_key="sk-...")
19
+ >>> request = ApiMessageRequest(model="qwen-plus", messages=[])
20
+ >>> async for event in client.stream_message(request):
21
+ >>> print(event)
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import asyncio
27
+ import json
28
+ import logging
29
+ from typing import Any, AsyncIterator
30
+
31
+ from openai import AsyncOpenAI
32
+
33
+ from illusion.api.client import (
34
+ ApiMessageCompleteEvent,
35
+ ApiMessageRequest,
36
+ ApiStreamEvent,
37
+ ApiTextDeltaEvent,
38
+ ApiToolCallStartedEvent,
39
+ )
40
+ from illusion.api.compat import (
41
+ merge_reasoning_text,
42
+ parse_tool_arguments,
43
+ split_thinking_from_text,
44
+ )
45
+ from illusion.api.errors import (
46
+ AuthenticationFailure,
47
+ IllusionCodeApiError,
48
+ RateLimitFailure,
49
+ RequestFailure,
50
+ )
51
+ from illusion.api.usage import UsageSnapshot
52
+ from illusion.engine.messages import (
53
+ ConversationMessage,
54
+ ContentBlock,
55
+ MediaBlock,
56
+ TextBlock,
57
+ ThinkingBlock,
58
+ ToolResultBlock,
59
+ ToolUseBlock,
60
+ _messages_have_media,
61
+ _strip_media_from_messages,
62
+ )
63
+
64
+ # 模块级日志记录器
65
+ log = logging.getLogger(__name__)
66
+
67
+ # 重试配置常量
68
+ MAX_RETRIES = 3 # 最大重试次数
69
+ BASE_DELAY = 1.0 # 基础延迟(秒)
70
+ MAX_DELAY = 30.0 # 最大延迟(秒)
71
+
72
+
73
+ def _serialize_media_for_openai(block: MediaBlock) -> dict[str, Any]:
74
+ """将图片 MediaBlock 转换为 OpenAI 消息内容部分。"""
75
+ return {
76
+ "type": "image_url",
77
+ "image_url": {"url": f"data:{block.media_type};base64,{block.data}"},
78
+ }
79
+
80
+
81
+ def _convert_tools_to_openai(tools: list[dict[str, Any]]) -> list[dict[str, Any]]:
82
+ """将 Anthropic 工具模式转换为 OpenAI function-calling 格式
83
+
84
+ Anthropic 格式:
85
+ {"name": "...", "description": "...", "input_schema": {...}}
86
+ OpenAI 格式:
87
+ {"type": "function", "function": {"name": "...", "description": "...", "parameters": {...}}}
88
+
89
+ Args:
90
+ tools: Anthropic 格式的工具定义列表
91
+
92
+ Returns:
93
+ list[dict[str, Any]]: OpenAI 格式的工具定义列表
94
+ """
95
+ result = []
96
+ for tool in tools:
97
+ result.append({
98
+ "type": "function",
99
+ "function": {
100
+ "name": tool["name"],
101
+ "description": tool.get("description", ""),
102
+ "parameters": tool.get("input_schema", {}),
103
+ },
104
+ })
105
+ return result
106
+
107
+
108
+ def _convert_messages_to_openai(
109
+ messages: list[ConversationMessage],
110
+ system_prompt: str | None,
111
+ ) -> list[dict[str, Any]]:
112
+ """将 Anthropic 风格消息转换为 OpenAI 聊天格式
113
+
114
+ 主要差异:
115
+ - Anthropic:系统提示词是单独参数
116
+ - OpenAI:系统提示词是 role="system" 的消息
117
+ - Anthropic:tool_use / tool_result 是 content blocks
118
+ - OpenAI:tool_calls 在 assistant 消息上,tool results 是独立消息
119
+
120
+ Args:
121
+ messages: Anthropic 风格的消息列表
122
+ system_prompt: 系统提示词
123
+
124
+ Returns:
125
+ list[dict[str, Any]]: OpenAI 格式的消息列表
126
+ """
127
+ openai_messages: list[dict[str, Any]] = []
128
+
129
+ # 添加系统消息
130
+ if system_prompt:
131
+ openai_messages.append({"role": "system", "content": system_prompt})
132
+
133
+ for msg in messages:
134
+ if msg.role == "assistant":
135
+ openai_msg = _convert_assistant_message(msg)
136
+ openai_messages.append(openai_msg)
137
+ elif msg.role == "user":
138
+ # 用户消息可能包含文本、tool_result 或 media blocks
139
+ tool_results = [b for b in msg.content if isinstance(b, ToolResultBlock)]
140
+ text_blocks = [b for b in msg.content if isinstance(b, TextBlock)]
141
+ media_blocks = [b for b in msg.content if isinstance(b, MediaBlock)]
142
+
143
+ if tool_results:
144
+ # 每个 tool result 成为独立的 role="tool" 消息
145
+ # 注意:OpenAI tool 消息只接受字符串 content,不支持图片
146
+ # 如果 tool result 包含媒体,额外生成一条 user 消息携带媒体
147
+ for tr in tool_results:
148
+ if isinstance(tr.content, list):
149
+ # 提取文本和媒体部分
150
+ tr_media = [b for b in tr.content if isinstance(b, MediaBlock)]
151
+ openai_messages.append({
152
+ "role": "tool",
153
+ "tool_call_id": tr.tool_use_id,
154
+ "content": tr.text_content,
155
+ })
156
+ # 媒体内容通过独立的 user 消息传递
157
+ if tr_media:
158
+ media_parts: list[dict[str, Any]] = []
159
+ for mb in tr_media:
160
+ media_parts.append(_serialize_media_for_openai(mb))
161
+ openai_messages.append({
162
+ "role": "user",
163
+ "content": media_parts,
164
+ })
165
+ else:
166
+ openai_messages.append({
167
+ "role": "tool",
168
+ "tool_call_id": tr.tool_use_id,
169
+ "content": tr.content,
170
+ })
171
+ if text_blocks or media_blocks:
172
+ text = "".join(b.text for b in text_blocks)
173
+ if media_blocks:
174
+ parts: list[dict[str, Any]] = []
175
+ if text.strip():
176
+ parts.append({"type": "text", "text": text})
177
+ for mb in media_blocks:
178
+ parts.append(_serialize_media_for_openai(mb))
179
+ openai_messages.append({"role": "user", "content": parts})
180
+ elif text.strip():
181
+ openai_messages.append({"role": "user", "content": text})
182
+ if not tool_results and not text_blocks and not media_blocks:
183
+ # 空用户消息(不应发生,但需优雅处理)
184
+ openai_messages.append({"role": "user", "content": ""})
185
+
186
+ return openai_messages
187
+
188
+
189
+ def _convert_assistant_message(msg: ConversationMessage) -> dict[str, Any]:
190
+ """将 assistant ConversationMessage 转换为 OpenAI 格式
191
+
192
+ 支持思维模型(如 Kimi k2.5)的 providers 要求每个包含 tool calls 的 assistant
193
+ 消息都有 ``reasoning_content`` 字段。这里统一从 ThinkingBlock 回放 reasoning。
194
+
195
+ Args:
196
+ msg: ConversationMessage 对象
197
+
198
+ Returns:
199
+ dict[str, Any]: OpenAI 格式的消息
200
+ """
201
+ text_parts = [b.text for b in msg.content if isinstance(b, TextBlock)]
202
+ tool_uses = [b for b in msg.content if isinstance(b, ToolUseBlock)]
203
+ thinking_blocks = [b for b in msg.content if isinstance(b, ThinkingBlock)]
204
+
205
+ openai_msg: dict[str, Any] = {"role": "assistant"}
206
+
207
+ content, tagged_reasoning = split_thinking_from_text("".join(text_parts))
208
+ # 确保 content 不为 None,否则 DeepSeek 等 API 会报错
209
+ # "Invalid assistant message: content or tool_calls must be set"
210
+ openai_msg["content"] = content if content else None
211
+ if openai_msg["content"] is None and not tool_uses:
212
+ openai_msg["content"] = content or ""
213
+
214
+ # 为思维模型回放 reasoning_content(统一来源:ThinkingBlock)
215
+ reasoning = merge_reasoning_text(
216
+ *(b.thinking for b in thinking_blocks),
217
+ tagged_reasoning,
218
+ )
219
+ if reasoning:
220
+ openai_msg["reasoning_content"] = reasoning
221
+ elif tool_uses:
222
+ # 思维模型即使为空也需要此字段
223
+ openai_msg["reasoning_content"] = ""
224
+
225
+ if tool_uses:
226
+ openai_msg["tool_calls"] = [
227
+ {
228
+ "id": tu.id,
229
+ "type": "function",
230
+ "function": {
231
+ "name": tu.name,
232
+ "arguments": json.dumps(tu.input),
233
+ },
234
+ }
235
+ for tu in tool_uses
236
+ ]
237
+
238
+ return openai_msg
239
+
240
+
241
+ def _parse_assistant_response(response: Any) -> ConversationMessage:
242
+ """将 OpenAI ChatCompletion 响应解析为 ConversationMessage
243
+
244
+ Args:
245
+ response: OpenAI API 响应对象
246
+
247
+ Returns:
248
+ ConversationMessage: 解析后的消息对象
249
+ """
250
+ choice = response.choices[0]
251
+ message = choice.message
252
+ content: list[ContentBlock] = []
253
+
254
+ if message.content:
255
+ plain_text, tagged_reasoning = split_thinking_from_text(str(message.content))
256
+ if tagged_reasoning:
257
+ content.append(ThinkingBlock(thinking=tagged_reasoning))
258
+ if plain_text:
259
+ content.append(TextBlock(text=plain_text))
260
+
261
+ reasoning_content = getattr(message, "reasoning_content", None)
262
+ if isinstance(reasoning_content, str) and reasoning_content.strip():
263
+ merged = merge_reasoning_text(
264
+ *(b.thinking for b in content if isinstance(b, ThinkingBlock)),
265
+ reasoning_content,
266
+ )
267
+ content = [b for b in content if not isinstance(b, ThinkingBlock)]
268
+ if merged:
269
+ content.insert(0, ThinkingBlock(thinking=merged))
270
+
271
+ if message.tool_calls:
272
+ for tc in message.tool_calls:
273
+ args = parse_tool_arguments(getattr(tc.function, "arguments", ""))
274
+ content.append(ToolUseBlock(
275
+ id=tc.id,
276
+ name=tc.function.name,
277
+ input=args,
278
+ ))
279
+
280
+ return ConversationMessage(role="assistant", content=content)
281
+
282
+
283
+ class OpenAICompatibleClient:
284
+ """OpenAI 兼容 API 客户端
285
+
286
+ 用于 DashScope、GitHub Models 等 OpenAI 兼容 API。
287
+ 实现与 AnthropicApiClient 相同的 SupportsStreamingMessages 协议,
288
+ 因此可以在 agent 循环中作为直接替代品使用。
289
+
290
+ Attributes:
291
+ _client: AsyncOpenAI 客户端实例
292
+ """
293
+
294
+ def __init__(self, api_key: str, *, base_url: str | None = None, extra_headers: dict[str, str] | None = None) -> None:
295
+ kwargs: dict[str, Any] = {"api_key": api_key}
296
+ if base_url:
297
+ kwargs["base_url"] = base_url
298
+ if extra_headers:
299
+ kwargs["default_headers"] = extra_headers
300
+ self._client = AsyncOpenAI(**kwargs)
301
+
302
+ async def stream_message(self, request: ApiMessageRequest) -> AsyncIterator[ApiStreamEvent]:
303
+ """流式生成文本增量和最终消息,匹配 Anthropic 客户端接口
304
+
305
+ 当消息中包含图片但模型不支持时,自动降级为文本描述并重试。
306
+
307
+ Args:
308
+ request: API 消息请求
309
+
310
+ Yields:
311
+ ApiStreamEvent: 流式事件
312
+ """
313
+ last_error: Exception | None = None
314
+ media_stripped = False
315
+
316
+ for attempt in range(MAX_RETRIES + 1):
317
+ try:
318
+ async for event in self._stream_once(request):
319
+ yield event
320
+ return
321
+ except IllusionCodeApiError as exc:
322
+ if (
323
+ not media_stripped
324
+ and _messages_have_media(request.messages)
325
+ and self._is_media_related_error(exc)
326
+ ):
327
+ log.warning(
328
+ "Request failed, possibly due to unsupported image content. "
329
+ "Retrying with text descriptions instead of images.",
330
+ )
331
+ request = ApiMessageRequest(
332
+ model=request.model,
333
+ messages=_strip_media_from_messages(request.messages),
334
+ system_prompt=request.system_prompt,
335
+ tools=request.tools,
336
+ max_tokens=request.max_tokens,
337
+ )
338
+ media_stripped = True
339
+ continue
340
+ raise
341
+ except Exception as exc:
342
+ last_error = exc
343
+ if (
344
+ not media_stripped
345
+ and _messages_have_media(request.messages)
346
+ and self._is_media_related_error(exc)
347
+ ):
348
+ log.warning(
349
+ "Request failed, possibly due to unsupported image content. "
350
+ "Retrying with text descriptions instead of images.",
351
+ )
352
+ request = ApiMessageRequest(
353
+ model=request.model,
354
+ messages=_strip_media_from_messages(request.messages),
355
+ system_prompt=request.system_prompt,
356
+ tools=request.tools,
357
+ max_tokens=request.max_tokens,
358
+ )
359
+ media_stripped = True
360
+ continue
361
+
362
+ if attempt >= MAX_RETRIES or not self._is_retryable(exc):
363
+ raise self._translate_error(exc) from exc
364
+
365
+ delay = min(BASE_DELAY * (2 ** attempt), MAX_DELAY)
366
+ log.warning(
367
+ "OpenAI API request failed (attempt %d/%d), retrying in %.1fs: %s",
368
+ attempt + 1, MAX_RETRIES + 1, delay, exc,
369
+ )
370
+ await asyncio.sleep(delay)
371
+
372
+ if last_error is not None:
373
+ raise self._translate_error(last_error) from last_error
374
+
375
+ async def _stream_once(self, request: ApiMessageRequest) -> AsyncIterator[ApiStreamEvent]:
376
+ """单次尝试:流式 OpenAI 聊天完成
377
+
378
+ Args:
379
+ request: API 消息请求
380
+
381
+ Yields:
382
+ ApiStreamEvent: 流式事件
383
+ """
384
+ openai_messages = _convert_messages_to_openai(request.messages, request.system_prompt)
385
+ openai_tools = _convert_tools_to_openai(request.tools) if request.tools else None
386
+
387
+ params: dict[str, Any] = {
388
+ "model": request.model,
389
+ "messages": openai_messages,
390
+ "max_tokens": request.max_tokens,
391
+ "stream": True,
392
+ "stream_options": {"include_usage": True},
393
+ }
394
+ if openai_tools:
395
+ params["tools"] = openai_tools
396
+ # 某些 providers(如 Kimi)在 tool-call 后续请求中对空的 reasoning_content 报错
397
+ # 如果存在 tools,则移除整个 stream_options 键,避免触发模型端思维模式
398
+ # 该模式要求每个 assistant 消息都有 reasoning_content
399
+ params.pop("stream_options", None)
400
+
401
+ # 添加 effort 字段
402
+ if request.effort is not None:
403
+ params["reasoning_effort"] = request.effort.value
404
+
405
+ # 流式文本增量时收集完整响应
406
+ collected_content = ""
407
+ collected_reasoning = ""
408
+ collected_tool_calls: dict[int, dict[str, Any]] = {}
409
+ finish_reason: str | None = None
410
+ usage_data: dict[str, int] = {}
411
+
412
+ try:
413
+ stream = await self._client.chat.completions.create(**params)
414
+ except Exception as exc:
415
+ # 检查是否为 effort 不支持错误
416
+ if self._is_effort_unsupported_error(exc) and request.effort is not None:
417
+ # 直接向用户反馈错误,不进行降级
418
+ raise RequestFailure(
419
+ f"当前模型不支持推理强度 '{request.effort.value}',请尝试使用其他推理强度级别(如 low/medium/high)"
420
+ ) from exc
421
+ # 某些模型(如 gpt-5.2-codex)不支持 /chat/completions,自动回退到 /responses
422
+ if self._is_chat_endpoint_error(exc):
423
+ log.info("Model %s does not support chat/completions, falling back to responses API", request.model)
424
+ async for event in self._stream_via_responses_api(request, openai_messages, openai_tools):
425
+ yield event
426
+ return
427
+ raise
428
+ async for chunk in stream:
429
+ if not chunk.choices:
430
+ # 仅使用量块(某些 providers 在最后发送)
431
+ if chunk.usage:
432
+ usage_data = {
433
+ "input_tokens": chunk.usage.prompt_tokens or 0,
434
+ "output_tokens": chunk.usage.completion_tokens or 0,
435
+ }
436
+ continue
437
+
438
+ delta = chunk.choices[0].delta
439
+ chunk_finish = chunk.choices[0].finish_reason
440
+
441
+ if chunk_finish:
442
+ finish_reason = chunk_finish
443
+
444
+ # 收集思维模型的 reasoning_content(不向用户显示)
445
+ reasoning_piece = getattr(delta, "reasoning_content", None) or ""
446
+ if reasoning_piece:
447
+ collected_reasoning += reasoning_piece
448
+ yield ApiTextDeltaEvent(text="", reasoning=reasoning_piece)
449
+
450
+ # 向用户流式传输文本内容
451
+ if delta.content:
452
+ collected_content += delta.content
453
+ yield ApiTextDeltaEvent(text=delta.content)
454
+
455
+ # 收集工具调用
456
+ if delta.tool_calls:
457
+ for tc_delta in delta.tool_calls:
458
+ idx = tc_delta.index
459
+ if idx not in collected_tool_calls:
460
+ collected_tool_calls[idx] = {
461
+ "id": tc_delta.id or "",
462
+ "name": "",
463
+ "arguments": "",
464
+ }
465
+ entry = collected_tool_calls[idx]
466
+ if tc_delta.id:
467
+ entry["id"] = tc_delta.id
468
+ if tc_delta.function:
469
+ if tc_delta.function.name:
470
+ # 工具调用开始:模型刚开始生成工具调用时立即通知
471
+ if not entry["name"]:
472
+ yield ApiToolCallStartedEvent(
473
+ tool_name=tc_delta.function.name,
474
+ tool_use_id=tc_delta.id or "",
475
+ )
476
+ entry["name"] = tc_delta.function.name
477
+ if tc_delta.function.arguments:
478
+ entry["arguments"] += tc_delta.function.arguments
479
+
480
+ # chunk 中的使用量(如果 provider 发送)
481
+ if chunk.usage:
482
+ usage_data = {
483
+ "input_tokens": chunk.usage.prompt_tokens or 0,
484
+ "output_tokens": chunk.usage.completion_tokens or 0,
485
+ }
486
+
487
+ # 构建最终 ConversationMessage
488
+ content: list[ContentBlock] = []
489
+ cleaned_text, tagged_reasoning = split_thinking_from_text(collected_content)
490
+ if cleaned_text:
491
+ content.append(TextBlock(text=cleaned_text))
492
+
493
+ for _idx in sorted(collected_tool_calls.keys()):
494
+ tc = collected_tool_calls[_idx]
495
+ # 跳过某些 provider 发送的空/幻影工具调用
496
+ if not tc["name"]:
497
+ continue
498
+ args = parse_tool_arguments(tc["arguments"])
499
+ content.append(ToolUseBlock(
500
+ id=tc["id"],
501
+ name=tc["name"],
502
+ input=args,
503
+ ))
504
+
505
+ merged_reasoning = merge_reasoning_text(collected_reasoning, tagged_reasoning)
506
+ if merged_reasoning:
507
+ content.insert(0, ThinkingBlock(thinking=merged_reasoning))
508
+
509
+ final_message = ConversationMessage(
510
+ role="assistant",
511
+ content=content,
512
+ )
513
+
514
+ yield ApiMessageCompleteEvent(
515
+ message=final_message,
516
+ usage=UsageSnapshot(
517
+ input_tokens=usage_data.get("input_tokens", 0),
518
+ output_tokens=usage_data.get("output_tokens", 0),
519
+ ),
520
+ stop_reason=finish_reason,
521
+ )
522
+
523
+ @staticmethod
524
+ def _is_chat_endpoint_error(exc: Exception) -> bool:
525
+ """检查是否为 chat/completions 端点不支持的错误(需回退到 responses API)"""
526
+ error_msg = str(getattr(exc, "message", "")) or str(exc)
527
+ return (
528
+ getattr(exc, "status_code", None) == 400
529
+ and "chat/completions" in error_msg
530
+ and "not accessible" in error_msg.lower()
531
+ )
532
+
533
+ @staticmethod
534
+ def _is_media_related_error(exc: Exception) -> bool:
535
+ """检查错误是否可能由图片内容导致(用于优雅降级判断)
536
+
537
+ 包括:JSON 解析错误、400/404 错误中与 content/image 相关的消息、
538
+ 空响应(某些模型遇到 image_url 直接返回空内容)。
539
+
540
+ 注意:错误可能已被 _translate_error 转为 IllusionCodeApiError,
541
+ 此时 status_code 属性丢失,需从消息字符串中判断。
542
+ """
543
+ error_msg = str(exc).lower()
544
+ status = getattr(exc, "status_code", None)
545
+
546
+ # 从错误消息字符串中提取状态码(适配已翻译的异常)
547
+ if status is None:
548
+ for code in (404, 400):
549
+ if f"error code: {code}" in error_msg:
550
+ status = code
551
+ break
552
+
553
+ # JSON 解析错误:模型返回空响应(遇到不支持的 image_url)
554
+ if "expecting value" in error_msg:
555
+ return True
556
+
557
+ # 400/404 错误且包含图片/内容相关关键词
558
+ if status in {400, 404}:
559
+ if any(kw in error_msg for kw in ("image", "media", "content", "param", "unsupported")):
560
+ return True
561
+
562
+ return False
563
+
564
+ @staticmethod
565
+ def _is_effort_unsupported_error(exc: Exception) -> bool:
566
+ """检测是否为 effort 字段不支持导致的错误
567
+
568
+ Args:
569
+ exc: 异常对象
570
+
571
+ Returns:
572
+ bool: 是否为 effort 不支持错误
573
+ """
574
+ error_msg = str(exc).lower()
575
+ # 检测常见的 effort 不支持错误消息
576
+ effort_keywords = ["effort", "reasoning_effort", "reasoning effort"]
577
+ unsupported_keywords = ["not supported", "unsupported", "invalid", "unknown"]
578
+
579
+ # 检查是否包含 effort 相关关键词
580
+ has_effort_keyword = any(keyword in error_msg for keyword in effort_keywords)
581
+ # 检查是否包含不支持相关关键词
582
+ has_unsupported_keyword = any(keyword in error_msg for keyword in unsupported_keywords)
583
+
584
+ # 检查特定的错误模式:unknown variant `max`/`xhigh` 等
585
+ has_variant_error = "unknown variant" in error_msg and any(
586
+ level in error_msg for level in ["max", "xhigh", "low", "medium", "high"]
587
+ )
588
+
589
+ return (has_effort_keyword and has_unsupported_keyword) or has_variant_error
590
+
591
+ def _convert_messages_to_responses(
592
+ self,
593
+ messages: list[dict[str, Any]],
594
+ system_prompt: str | None,
595
+ ) -> list[dict[str, Any]]:
596
+ """将 OpenAI 聊天格式消息转换为 Responses API 输入格式"""
597
+ items: list[dict[str, Any]] = []
598
+ for msg in messages:
599
+ role = msg.get("role", "user")
600
+ content = msg.get("content", "")
601
+ if role == "system":
602
+ items.append({"role": "system", "content": content})
603
+ elif role == "assistant" and msg.get("tool_calls"):
604
+ # assistant 消息带 tool_calls:拆分为 message + function_call items
605
+ text_parts = []
606
+ if isinstance(content, str) and content:
607
+ text_parts.append({"type": "output_text", "text": content})
608
+ elif isinstance(content, list):
609
+ for part in content:
610
+ if isinstance(part, dict) and part.get("type") == "text":
611
+ text_parts.append({"type": "output_text", "text": part.get("text", "")})
612
+ if text_parts:
613
+ items.append({"type": "message", "role": "assistant", "content": text_parts})
614
+ for tc in msg["tool_calls"]:
615
+ func = tc.get("function", {})
616
+ items.append({
617
+ "type": "function_call",
618
+ "call_id": tc.get("id", ""),
619
+ "name": func.get("name", ""),
620
+ "arguments": func.get("arguments", "{}"),
621
+ })
622
+ elif role == "tool":
623
+ # tool 结果消息 → function_call_output item
624
+ if isinstance(content, list):
625
+ text_parts = [
626
+ p.get("text", "") for p in content
627
+ if isinstance(p, dict) and p.get("type") == "text"
628
+ ]
629
+ output = " ".join(text_parts) if text_parts else json.dumps(content, ensure_ascii=False)
630
+ else:
631
+ output = content if isinstance(content, str) else json.dumps(content, ensure_ascii=False)
632
+ items.append({
633
+ "type": "function_call_output",
634
+ "call_id": msg.get("tool_call_id", ""),
635
+ "output": output,
636
+ })
637
+ else:
638
+ # user / assistant 纯文本消息
639
+ text = content if isinstance(content, str) else json.dumps(content, ensure_ascii=False)
640
+ items.append({
641
+ "type": "message",
642
+ "role": role,
643
+ "content": [{"type": "input_text" if role == "user" else "output_text", "text": text}],
644
+ })
645
+ return items
646
+
647
+ @staticmethod
648
+ def _convert_tools_to_responses(tools: list[dict[str, Any]] | None) -> list[dict[str, Any]] | None:
649
+ """将 OpenAI function-calling 工具格式转换为 Responses API 格式"""
650
+ if not tools:
651
+ return None
652
+ result = []
653
+ for tool in tools:
654
+ func = tool.get("function", {})
655
+ result.append({
656
+ "type": "function",
657
+ "name": func.get("name", ""),
658
+ "description": func.get("description", ""),
659
+ "parameters": func.get("parameters", {}),
660
+ })
661
+ return result
662
+
663
+ async def _stream_via_responses_api(
664
+ self,
665
+ request: ApiMessageRequest,
666
+ openai_messages: list[dict[str, Any]],
667
+ openai_tools: list[dict[str, Any]] | None,
668
+ ) -> AsyncIterator[ApiStreamEvent]:
669
+ """通过 OpenAI Responses API 流式生成(chat/completions 不可用时的回退方案)"""
670
+ from openai.types.responses import (
671
+ ResponseCompletedEvent,
672
+ ResponseFunctionCallArgumentsDeltaEvent,
673
+ ResponseFunctionCallArgumentsDoneEvent,
674
+ ResponseOutputItemAddedEvent,
675
+ ResponseTextDeltaEvent,
676
+ )
677
+
678
+ input_items = self._convert_messages_to_responses(openai_messages, request.system_prompt)
679
+ resp_tools = self._convert_tools_to_responses(openai_tools)
680
+
681
+ params: dict[str, Any] = {
682
+ "model": request.model,
683
+ "input": input_items,
684
+ }
685
+ if request.system_prompt:
686
+ params["instructions"] = request.system_prompt
687
+ if request.max_tokens:
688
+ params["max_output_tokens"] = request.max_tokens
689
+ if resp_tools:
690
+ params["tools"] = resp_tools
691
+ # 添加 effort 字段
692
+ if request.effort is not None:
693
+ params["reasoning"] = {"effort": request.effort.value}
694
+
695
+ collected_content = ""
696
+ collected_reasoning = ""
697
+ collected_tool_calls: dict[int, dict[str, Any]] = {}
698
+ usage_data: dict[str, int] = {}
699
+
700
+ async with self._client.responses.stream(**params) as stream:
701
+ async for event in stream:
702
+ if isinstance(event, ResponseTextDeltaEvent):
703
+ collected_content += event.delta
704
+ yield ApiTextDeltaEvent(text=event.delta)
705
+ continue
706
+
707
+ event_type = str(getattr(event, "type", "") or "")
708
+ if event_type in {
709
+ "response.reasoning_summary_text.delta",
710
+ "response.reasoning_text.delta",
711
+ "response.output_text.reasoning.delta",
712
+ }:
713
+ delta = getattr(event, "delta", "")
714
+ if isinstance(delta, str) and delta:
715
+ collected_reasoning += delta
716
+ yield ApiTextDeltaEvent(text="", reasoning=delta)
717
+ continue
718
+
719
+ if isinstance(event, ResponseOutputItemAddedEvent):
720
+ item = event.item
721
+ if getattr(item, "type", None) == "function_call":
722
+ idx = event.output_index
723
+ tool_name = getattr(item, "name", "")
724
+ tool_use_id = getattr(item, "call_id", "") or getattr(item, "id", "")
725
+ collected_tool_calls[idx] = {
726
+ "id": tool_use_id,
727
+ "name": tool_name,
728
+ "arguments": "",
729
+ }
730
+ # 工具调用开始:模型刚开始生成工具调用时立即通知
731
+ if tool_name:
732
+ yield ApiToolCallStartedEvent(
733
+ tool_name=tool_name,
734
+ tool_use_id=tool_use_id,
735
+ )
736
+
737
+ elif isinstance(event, ResponseFunctionCallArgumentsDeltaEvent):
738
+ idx = event.output_index
739
+ if idx in collected_tool_calls:
740
+ collected_tool_calls[idx]["arguments"] += event.delta
741
+
742
+ elif isinstance(event, ResponseFunctionCallArgumentsDoneEvent):
743
+ idx = event.output_index
744
+ if idx in collected_tool_calls:
745
+ collected_tool_calls[idx]["arguments"] = event.arguments
746
+
747
+ elif isinstance(event, ResponseCompletedEvent):
748
+ resp = event.response
749
+ if hasattr(resp, "usage") and resp.usage:
750
+ usage_data = {
751
+ "input_tokens": getattr(resp.usage, "input_tokens", 0) or 0,
752
+ "output_tokens": getattr(resp.usage, "output_tokens", 0) or 0,
753
+ }
754
+
755
+ # 构建最终消息
756
+ content: list[ContentBlock] = []
757
+ cleaned_text, tagged_reasoning = split_thinking_from_text(collected_content)
758
+ if cleaned_text:
759
+ content.append(TextBlock(text=cleaned_text))
760
+
761
+ for _idx in sorted(collected_tool_calls.keys()):
762
+ tc = collected_tool_calls[_idx]
763
+ if not tc["name"]:
764
+ continue
765
+ args = parse_tool_arguments(tc["arguments"])
766
+ content.append(ToolUseBlock(
767
+ id=tc["id"],
768
+ name=tc["name"],
769
+ input=args,
770
+ ))
771
+
772
+ merged_reasoning = merge_reasoning_text(collected_reasoning, tagged_reasoning)
773
+ if merged_reasoning:
774
+ content.insert(0, ThinkingBlock(thinking=merged_reasoning))
775
+
776
+ final_message = ConversationMessage(role="assistant", content=content)
777
+ yield ApiMessageCompleteEvent(
778
+ message=final_message,
779
+ usage=UsageSnapshot(
780
+ input_tokens=usage_data.get("input_tokens", 0),
781
+ output_tokens=usage_data.get("output_tokens", 0),
782
+ ),
783
+ stop_reason="stop",
784
+ )
785
+
786
+ @staticmethod
787
+ def _is_retryable(exc: Exception) -> bool:
788
+ """检查异常是否可重试
789
+
790
+ Args:
791
+ exc: 待检查的异常
792
+
793
+ Returns:
794
+ bool: 是否可重试
795
+ """
796
+ status = getattr(exc, "status_code", None)
797
+ if status and status in {429, 500, 502, 503}:
798
+ return True
799
+ if isinstance(exc, (ConnectionError, TimeoutError, OSError)):
800
+ return True
801
+ return False
802
+
803
+ @staticmethod
804
+ def _translate_error(exc: Exception) -> IllusionCodeApiError:
805
+ """转换错误为统一异常类型
806
+
807
+ Args:
808
+ exc: 原始异常
809
+
810
+ Returns:
811
+ IllusionCodeApiError: 统一异常类型
812
+ """
813
+ status = getattr(exc, "status_code", None)
814
+ msg = str(exc)
815
+ if status == 401 or status == 403:
816
+ return AuthenticationFailure(msg)
817
+ if status == 429:
818
+ return RateLimitFailure(msg)
819
+ return RequestFailure(msg)