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.
- illusion/__init__.py +24 -0
- illusion/__main__.py +15 -0
- illusion/_frontend/dist/index.mjs +39208 -0
- illusion/_frontend/package.json +27 -0
- illusion/_frontend/src/App.tsx +624 -0
- illusion/_frontend/src/components/CommandPicker.tsx +98 -0
- illusion/_frontend/src/components/Composer.tsx +55 -0
- illusion/_frontend/src/components/ComposerController.tsx +128 -0
- illusion/_frontend/src/components/ConversationView.tsx +750 -0
- illusion/_frontend/src/components/Footer.tsx +25 -0
- illusion/_frontend/src/components/MarkdownContent.tsx +537 -0
- illusion/_frontend/src/components/MarkdownTable.tsx +245 -0
- illusion/_frontend/src/components/ModalHost.tsx +425 -0
- illusion/_frontend/src/components/MultilineTextInput.tsx +250 -0
- illusion/_frontend/src/components/PromptInput.tsx +64 -0
- illusion/_frontend/src/components/SelectModal.tsx +78 -0
- illusion/_frontend/src/components/SidePanel.tsx +175 -0
- illusion/_frontend/src/components/Spinner.tsx +77 -0
- illusion/_frontend/src/components/StatusBar.tsx +142 -0
- illusion/_frontend/src/components/SwarmPanel.tsx +141 -0
- illusion/_frontend/src/components/TodoPanel.tsx +126 -0
- illusion/_frontend/src/components/ToolCallDisplay.tsx +202 -0
- illusion/_frontend/src/components/TranscriptPane.tsx +79 -0
- illusion/_frontend/src/components/WelcomeBanner.tsx +37 -0
- illusion/_frontend/src/hooks/useBackendSession.ts +468 -0
- illusion/_frontend/src/hooks/useTerminalSize.ts +9 -0
- illusion/_frontend/src/i18n.ts +78 -0
- illusion/_frontend/src/index.tsx +42 -0
- illusion/_frontend/src/theme/ThemeContext.tsx +19 -0
- illusion/_frontend/src/theme/builtinThemes.ts +89 -0
- illusion/_frontend/src/types.ts +110 -0
- illusion/_frontend/src/utils/markdown.ts +33 -0
- illusion/_frontend/src/utils/thinking.ts +191 -0
- illusion/_frontend/tsconfig.json +13 -0
- illusion/_web_dist/assets/index-BseIw-ik.css +10 -0
- illusion/_web_dist/assets/index-C_0ZWMuW.js +82 -0
- illusion/_web_dist/index.html +16 -0
- illusion/api/__init__.py +36 -0
- illusion/api/client.py +568 -0
- illusion/api/codex_client.py +563 -0
- illusion/api/compat.py +138 -0
- illusion/api/effort.py +128 -0
- illusion/api/errors.py +57 -0
- illusion/api/openai_client.py +819 -0
- illusion/api/provider.py +148 -0
- illusion/api/registry.py +479 -0
- illusion/api/usage.py +45 -0
- illusion/auth/__init__.py +50 -0
- illusion/auth/copilot.py +419 -0
- illusion/auth/external.py +612 -0
- illusion/auth/flows.py +58 -0
- illusion/auth/manager.py +214 -0
- illusion/auth/storage.py +372 -0
- illusion/bridge/__init__.py +38 -0
- illusion/bridge/manager.py +190 -0
- illusion/bridge/session_runner.py +84 -0
- illusion/bridge/types.py +113 -0
- illusion/bridge/work_secret.py +131 -0
- illusion/cli.py +1228 -0
- illusion/commands/__init__.py +32 -0
- illusion/commands/registry.py +1934 -0
- illusion/config/__init__.py +39 -0
- illusion/config/i18n.py +522 -0
- illusion/config/paths.py +259 -0
- illusion/config/settings.py +564 -0
- illusion/coordinator/__init__.py +41 -0
- illusion/coordinator/agent_definitions.py +1093 -0
- illusion/coordinator/coordinator_mode.py +127 -0
- illusion/engine/__init__.py +95 -0
- illusion/engine/cost_tracker.py +55 -0
- illusion/engine/messages.py +369 -0
- illusion/engine/query.py +632 -0
- illusion/engine/query_engine.py +343 -0
- illusion/engine/stream_events.py +169 -0
- illusion/hooks/__init__.py +67 -0
- illusion/hooks/events.py +43 -0
- illusion/hooks/executor.py +397 -0
- illusion/hooks/hot_reload.py +74 -0
- illusion/hooks/loader.py +133 -0
- illusion/hooks/schemas.py +121 -0
- illusion/hooks/types.py +86 -0
- illusion/mcp/__init__.py +104 -0
- illusion/mcp/client.py +377 -0
- illusion/mcp/config.py +140 -0
- illusion/mcp/types.py +175 -0
- illusion/memory/__init__.py +36 -0
- illusion/memory/manager.py +94 -0
- illusion/memory/memdir.py +58 -0
- illusion/memory/paths.py +57 -0
- illusion/memory/scan.py +120 -0
- illusion/memory/search.py +83 -0
- illusion/memory/types.py +43 -0
- illusion/output_styles/__init__.py +15 -0
- illusion/output_styles/loader.py +64 -0
- illusion/permissions/__init__.py +39 -0
- illusion/permissions/checker.py +174 -0
- illusion/permissions/modes.py +38 -0
- illusion/platforms.py +148 -0
- illusion/plugins/__init__.py +71 -0
- illusion/plugins/bundled/__init__.py +0 -0
- illusion/plugins/installer.py +59 -0
- illusion/plugins/loader.py +301 -0
- illusion/plugins/schemas.py +51 -0
- illusion/plugins/types.py +56 -0
- illusion/prompts/__init__.py +29 -0
- illusion/prompts/claudemd.py +74 -0
- illusion/prompts/context.py +187 -0
- illusion/prompts/environment.py +189 -0
- illusion/prompts/system_prompt.py +155 -0
- illusion/py.typed +0 -0
- illusion/sandbox/__init__.py +29 -0
- illusion/sandbox/adapter.py +174 -0
- illusion/services/__init__.py +59 -0
- illusion/services/compact/__init__.py +1015 -0
- illusion/services/cron.py +338 -0
- illusion/services/cron_scheduler.py +715 -0
- illusion/services/file_history.py +258 -0
- illusion/services/lsp/__init__.py +455 -0
- illusion/services/session_storage.py +237 -0
- illusion/services/token_estimation.py +72 -0
- illusion/skills/__init__.py +60 -0
- illusion/skills/bundled/__init__.py +110 -0
- illusion/skills/bundled/content/batch.md +86 -0
- illusion/skills/bundled/content/coding-guidelines.md +70 -0
- illusion/skills/bundled/content/debug.md +38 -0
- illusion/skills/bundled/content/loop.md +82 -0
- illusion/skills/bundled/content/remember.md +105 -0
- illusion/skills/bundled/content/simplify.md +53 -0
- illusion/skills/bundled/content/skillify.md +113 -0
- illusion/skills/bundled/content/stuck.md +54 -0
- illusion/skills/bundled/content/update-config.md +329 -0
- illusion/skills/bundled/content/verify.md +74 -0
- illusion/skills/loader.py +219 -0
- illusion/skills/registry.py +40 -0
- illusion/skills/types.py +24 -0
- illusion/state/__init__.py +18 -0
- illusion/state/app_state.py +67 -0
- illusion/state/store.py +93 -0
- illusion/swarm/__init__.py +71 -0
- illusion/swarm/agent_executor.py +857 -0
- illusion/swarm/in_process.py +259 -0
- illusion/swarm/subprocess_backend.py +136 -0
- illusion/swarm/team_helpers.py +123 -0
- illusion/swarm/types.py +159 -0
- illusion/swarm/worktree.py +347 -0
- illusion/tasks/__init__.py +33 -0
- illusion/tasks/local_agent_task.py +42 -0
- illusion/tasks/local_shell_task.py +27 -0
- illusion/tasks/manager.py +377 -0
- illusion/tasks/stop_task.py +21 -0
- illusion/tasks/types.py +88 -0
- illusion/tools/__init__.py +126 -0
- illusion/tools/agent_tool.py +388 -0
- illusion/tools/ask_user_question_tool.py +186 -0
- illusion/tools/base.py +149 -0
- illusion/tools/bash_tool.py +413 -0
- illusion/tools/config_tool.py +90 -0
- illusion/tools/cron_tool.py +473 -0
- illusion/tools/enter_plan_mode_tool.py +147 -0
- illusion/tools/enter_worktree_tool.py +188 -0
- illusion/tools/exit_plan_mode_tool.py +69 -0
- illusion/tools/exit_worktree_tool.py +225 -0
- illusion/tools/file_edit_tool.py +283 -0
- illusion/tools/file_read_tool.py +294 -0
- illusion/tools/file_write_tool.py +184 -0
- illusion/tools/glob_tool.py +165 -0
- illusion/tools/grep_tool.py +190 -0
- illusion/tools/list_mcp_resources_tool.py +80 -0
- illusion/tools/lsp_tool.py +333 -0
- illusion/tools/mcp_auth_tool.py +100 -0
- illusion/tools/mcp_tool.py +75 -0
- illusion/tools/notebook_edit_tool.py +242 -0
- illusion/tools/powershell_tool.py +334 -0
- illusion/tools/read_mcp_resource_tool.py +63 -0
- illusion/tools/repl_tool.py +100 -0
- illusion/tools/send_message_tool.py +112 -0
- illusion/tools/shell_common.py +187 -0
- illusion/tools/skill_tool.py +86 -0
- illusion/tools/sleep_tool.py +62 -0
- illusion/tools/structured_output_tool.py +58 -0
- illusion/tools/task_create_tool.py +98 -0
- illusion/tools/task_get_tool.py +94 -0
- illusion/tools/task_list_tool.py +94 -0
- illusion/tools/task_output_tool.py +55 -0
- illusion/tools/task_stop_tool.py +52 -0
- illusion/tools/task_update_tool.py +224 -0
- illusion/tools/team_create_tool.py +236 -0
- illusion/tools/team_delete_tool.py +104 -0
- illusion/tools/todo_write_tool.py +198 -0
- illusion/tools/tool_search_tool.py +156 -0
- illusion/tools/web_fetch_tool.py +264 -0
- illusion/tools/web_search_tool.py +186 -0
- illusion/ui/__init__.py +23 -0
- illusion/ui/app.py +258 -0
- illusion/ui/backend_host.py +1180 -0
- illusion/ui/input.py +86 -0
- illusion/ui/output.py +363 -0
- illusion/ui/permission_dialog.py +47 -0
- illusion/ui/permission_store.py +99 -0
- illusion/ui/protocol.py +384 -0
- illusion/ui/react_launcher.py +280 -0
- illusion/ui/runtime.py +787 -0
- illusion/ui/textual_app.py +603 -0
- illusion/ui/web/__init__.py +10 -0
- illusion/ui/web/server.py +87 -0
- illusion/ui/web/ws_host.py +1197 -0
- illusion/utils/__init__.py +0 -0
- illusion/utils/ripgrep.py +299 -0
- illusion/utils/shell.py +248 -0
- illusion_code-0.1.0.dist-info/METADATA +1159 -0
- illusion_code-0.1.0.dist-info/RECORD +214 -0
- illusion_code-0.1.0.dist-info/WHEEL +4 -0
- illusion_code-0.1.0.dist-info/entry_points.txt +2 -0
- 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)
|