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,563 @@
1
+ """
2
+ OpenAI Codex 订阅客户端模块
3
+ ==========================
4
+
5
+ 本模块提供基于 chatgpt.com Codex Responses 的 OpenAI Codex 订阅客户端。
6
+
7
+ 主要功能:
8
+ - 使用 OAuth 令牌进行认证
9
+ - 流式文本增量生成
10
+ - 支持工具调用
11
+ - 自动重试 transient 错误
12
+
13
+ 类说明:
14
+ - CodexApiClient: Codex API 客户端类
15
+
16
+ 使用示例:
17
+ >>> from illusion.api.codex_client import CodexApiClient
18
+ >>> client = CodexApiClient(auth_token="gho_...")
19
+ >>> request = ApiMessageRequest(model="gpt-4o", messages=[])
20
+ >>> async for event in client.stream_message(request):
21
+ >>> print(event)
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import base64
27
+ import json
28
+ import logging
29
+ import platform
30
+ from typing import Any, AsyncIterator
31
+
32
+ import httpx
33
+
34
+ from illusion.api.client import (
35
+ ApiMessageCompleteEvent,
36
+ ApiMessageRequest,
37
+ ApiRetryEvent,
38
+ ApiStreamEvent,
39
+ ApiTextDeltaEvent,
40
+ ApiToolCallStartedEvent,
41
+ )
42
+ from illusion.api.compat import merge_reasoning_text, parse_tool_arguments, split_thinking_from_text
43
+ from illusion.api.errors import AuthenticationFailure, IllusionCodeApiError, RateLimitFailure, RequestFailure
44
+ from illusion.api.usage import UsageSnapshot
45
+ from illusion.engine.messages import (
46
+ ContentBlock,
47
+ ConversationMessage,
48
+ MediaBlock,
49
+ ThinkingBlock,
50
+ TextBlock,
51
+ ToolResultBlock,
52
+ ToolUseBlock,
53
+ )
54
+
55
+ # 模块级日志记录器
56
+ log = logging.getLogger(__name__)
57
+
58
+ # 常量定义
59
+ DEFAULT_CODEX_BASE_URL = "https://chatgpt.com/backend-api" # 默认 Codex 基础 URL
60
+ JWT_CLAIM_PATH = "https://api.openai.com/auth" # JWT 声明路径
61
+ MAX_RETRIES = 3 # 最大重试次数
62
+ BASE_DELAY_SECONDS = 1.0 # 基础延迟(秒)
63
+ MAX_DELAY_SECONDS = 30.0 # 最大延迟(秒)
64
+
65
+
66
+ def _extract_account_id(token: str) -> str:
67
+ """从 JWT token 中提取 chatgpt_account_id
68
+
69
+ Args:
70
+ token: JWT 访问令牌
71
+
72
+ Returns:
73
+ str: 账户 ID,提取失败时返回空字符串
74
+ """
75
+ parts = token.split(".")
76
+ if len(parts) != 3:
77
+ return ""
78
+ try:
79
+ encoded = parts[1]
80
+ padded = encoded + "=" * (-len(encoded) % 4)
81
+ payload = json.loads(base64.urlsafe_b64decode(padded.encode("ascii")).decode("utf-8"))
82
+ except Exception:
83
+ return ""
84
+ auth = payload.get(JWT_CLAIM_PATH)
85
+ if isinstance(auth, dict):
86
+ return str(auth.get("chatgpt_account_id", "") or "")
87
+ return ""
88
+
89
+
90
+ def _resolve_codex_url(base_url: str | None) -> str:
91
+ """解析并返回 Codex API URL
92
+
93
+ Args:
94
+ base_url: 可选的基础 URL
95
+
96
+ Returns:
97
+ str: 完整的 Codex API URL
98
+ """
99
+ trimmed = (base_url or "").strip()
100
+ if trimmed and "chatgpt.com/backend-api" not in trimmed:
101
+ trimmed = ""
102
+ raw = (trimmed or DEFAULT_CODEX_BASE_URL).rstrip("/")
103
+ if raw.endswith("/codex/responses"):
104
+ return raw
105
+ if raw.endswith("/codex"):
106
+ return f"{raw}/responses"
107
+ return f"{raw}/codex/responses"
108
+
109
+
110
+ def _build_codex_headers(token: str, *, session_id: str | None = None) -> dict[str, str]:
111
+ """构建 Codex API 请求头
112
+
113
+ Args:
114
+ token: Codex 访问令牌
115
+ session_id: 可选的会话 ID
116
+
117
+ Returns:
118
+ dict[str, str]: 请求头字典
119
+ """
120
+ account_id = _extract_account_id(token)
121
+ headers = {
122
+ "Authorization": f"Bearer {token}",
123
+ "chatgpt-account-id": account_id,
124
+ "originator": "illusion",
125
+ "User-Agent": f"illusion ({platform.system().lower()} {platform.machine() or 'unknown'})",
126
+ "OpenAI-Beta": "responses=experimental",
127
+ "accept": "text/event-stream",
128
+ "content-type": "application/json",
129
+ }
130
+ if session_id:
131
+ headers["session_id"] = session_id
132
+ return headers
133
+
134
+
135
+ def _convert_messages_to_codex(messages: list[ConversationMessage]) -> list[dict[str, Any]]:
136
+ """将消息转换为 Codex 格式
137
+
138
+ Args:
139
+ messages: ConversationMessage 列表
140
+
141
+ Returns:
142
+ list[dict[str, Any]]: Codex 格式的消息列表
143
+ """
144
+ result: list[dict[str, Any]] = []
145
+ for msg in messages:
146
+ if msg.role == "user":
147
+ text = "".join(block.text for block in msg.content if isinstance(block, TextBlock))
148
+ media_blocks = [b for b in msg.content if isinstance(b, MediaBlock)]
149
+ if text.strip() or media_blocks:
150
+ parts = []
151
+ if text.strip():
152
+ parts.append({"type": "input_text", "text": text})
153
+ # Codex 上下文窗口有限(272K token),input_image 的 base64
154
+ # 数据会被计算为海量 token,因此统一用文本描述替代
155
+ for mb in media_blocks:
156
+ size_str = f" ({mb.metadata['size']} bytes)" if "size" in mb.metadata else ""
157
+ parts.append({
158
+ "type": "input_text",
159
+ "text": f"[image file: {mb.file_path}{size_str}, {mb.media_type}] This model does not support image input",
160
+ })
161
+ result.append({
162
+ "role": "user",
163
+ "content": parts,
164
+ })
165
+ for block in msg.content:
166
+ if isinstance(block, ToolResultBlock):
167
+ # Codex function_call_output 只接受字符串,不支持媒体
168
+ # 始终使用 text_content,不传 base64 数据
169
+ result.append({
170
+ "type": "function_call_output",
171
+ "call_id": block.tool_use_id,
172
+ "output": block.text_content,
173
+ })
174
+ continue
175
+
176
+ assistant_text = "".join(block.text for block in msg.content if isinstance(block, TextBlock))
177
+ assistant_text, _ = split_thinking_from_text(assistant_text)
178
+ if assistant_text:
179
+ result.append({
180
+ "type": "message",
181
+ "role": "assistant",
182
+ "content": [{"type": "output_text", "text": assistant_text, "annotations": []}],
183
+ })
184
+ for block in msg.content:
185
+ if isinstance(block, ToolUseBlock):
186
+ result.append({
187
+ "type": "function_call",
188
+ "id": f"fc_{block.id[:58]}",
189
+ "call_id": block.id,
190
+ "name": block.name,
191
+ "arguments": json.dumps(block.input, separators=(",", ":")),
192
+ })
193
+ return result
194
+
195
+
196
+ def _convert_tools_to_codex(tools: list[dict[str, Any]]) -> list[dict[str, Any]]:
197
+ """将工具转换为 Codex 格式
198
+
199
+ Args:
200
+ tools: 工具定义列表
201
+
202
+ Returns:
203
+ list[dict[str, Any]]: Codex 格式的工具列表
204
+ """
205
+ return [
206
+ {
207
+ "type": "function",
208
+ "name": tool["name"],
209
+ "description": tool.get("description", ""),
210
+ "parameters": tool.get("input_schema", {}),
211
+ }
212
+ for tool in tools
213
+ ]
214
+
215
+
216
+ def _usage_from_response(response: dict[str, Any]) -> UsageSnapshot:
217
+ """从响应中提取使用量信息
218
+
219
+ Args:
220
+ response: API 响应字典
221
+
222
+ Returns:
223
+ UsageSnapshot: 使用量快照
224
+ """
225
+ usage = response.get("usage")
226
+ if not isinstance(usage, dict):
227
+ return UsageSnapshot()
228
+ return UsageSnapshot(
229
+ input_tokens=int(usage.get("input_tokens") or 0),
230
+ output_tokens=int(usage.get("output_tokens") or 0),
231
+ )
232
+
233
+
234
+ def _stop_reason_from_response(response: dict[str, Any], *, has_tool_calls: bool) -> str | None:
235
+ """从响应中提取停止原因
236
+
237
+ Args:
238
+ response: API 响应字典
239
+ has_tool_calls: 是否有工具调用
240
+
241
+ Returns:
242
+ str | None: 停止原因
243
+ """
244
+ status = response.get("status")
245
+ if has_tool_calls and status == "completed":
246
+ return "tool_use"
247
+ if status == "completed":
248
+ return "stop"
249
+ if status == "incomplete":
250
+ return "length"
251
+ if status in {"failed", "cancelled"}:
252
+ return "error"
253
+ return None
254
+
255
+
256
+ def _format_error_message(status_code: int, payload: str) -> str:
257
+ """格式化错误消息
258
+
259
+ Args:
260
+ status_code: HTTP 状态码
261
+ payload: 响应负载
262
+
263
+ Returns:
264
+ str: 格式化的错误消息
265
+ """
266
+ try:
267
+ parsed = json.loads(payload)
268
+ except json.JSONDecodeError:
269
+ parsed = None
270
+ if isinstance(parsed, dict):
271
+ error = parsed.get("error")
272
+ if isinstance(error, dict):
273
+ message = error.get("message")
274
+ if isinstance(message, str) and message.strip():
275
+ return message
276
+ detail = parsed.get("detail")
277
+ if isinstance(detail, str) and detail.strip():
278
+ return detail
279
+ text = payload.strip()
280
+ if text:
281
+ return text
282
+ return f"Codex request failed with status {status_code}"
283
+
284
+
285
+ def _translate_status_error(status_code: int, message: str) -> IllusionCodeApiError:
286
+ """转换状态码错误为统一异常类型
287
+
288
+ Args:
289
+ status_code: HTTP 状态码
290
+ message: 错误消息
291
+
292
+ Returns:
293
+ IllusionCodeApiError: 统一异常类型
294
+ """
295
+ if status_code in {401, 403}:
296
+ return AuthenticationFailure(message)
297
+ if status_code == 429:
298
+ return RateLimitFailure(message)
299
+ return RequestFailure(message)
300
+
301
+
302
+ def _is_effort_unsupported_error(exc: Exception) -> bool:
303
+ """检测是否为 effort 字段不支持导致的错误
304
+
305
+ Args:
306
+ exc: 异常对象
307
+
308
+ Returns:
309
+ bool: 是否为 effort 不支持错误
310
+ """
311
+ error_msg = str(exc).lower()
312
+ # 检测常见的 effort 不支持错误消息
313
+ effort_keywords = ["effort", "reasoning_effort", "reasoning effort"]
314
+ unsupported_keywords = ["not supported", "unsupported", "invalid", "unknown"]
315
+
316
+ # 检查是否包含 effort 相关关键词
317
+ has_effort_keyword = any(keyword in error_msg for keyword in effort_keywords)
318
+ # 检查是否包含不支持相关关键词
319
+ has_unsupported_keyword = any(keyword in error_msg for keyword in unsupported_keywords)
320
+
321
+ # 检查特定的错误模式:unknown variant `max`/`xhigh` 等
322
+ has_variant_error = "unknown variant" in error_msg and any(
323
+ level in error_msg for level in ["max", "xhigh", "low", "medium", "high"]
324
+ )
325
+
326
+ return (has_effort_keyword and has_unsupported_keyword) or has_variant_error
327
+
328
+
329
+ class CodexApiClient:
330
+ """ChatGPT/Codex 订阅支持的 Codex Responses 客户端
331
+
332
+ Attributes:
333
+ _auth_token: 认证令牌
334
+ _base_url: 基础 URL
335
+ _url: 解析后的 API URL
336
+ """
337
+
338
+ def __init__(self, auth_token: str, *, base_url: str | None = None) -> None:
339
+ self._auth_token = auth_token
340
+ self._base_url = base_url
341
+ self._url = _resolve_codex_url(base_url)
342
+
343
+ async def stream_message(self, request: ApiMessageRequest) -> AsyncIterator[ApiStreamEvent]:
344
+ """流式生成文本增量
345
+
346
+ Args:
347
+ request: API 消息请求
348
+
349
+ Yields:
350
+ ApiStreamEvent: 流式事件
351
+ """
352
+ last_error: Exception | None = None
353
+ for attempt in range(MAX_RETRIES + 1):
354
+ try:
355
+ async for event in self._stream_once(request):
356
+ yield event
357
+ return
358
+ except Exception as exc:
359
+ last_error = exc
360
+ if attempt >= MAX_RETRIES or not self._is_retryable(exc):
361
+ raise self._translate_error(exc) from exc
362
+ delay = min(BASE_DELAY_SECONDS * (2 ** attempt), MAX_DELAY_SECONDS)
363
+ import asyncio
364
+
365
+ yield ApiRetryEvent(
366
+ message=str(exc),
367
+ attempt=attempt + 1,
368
+ max_attempts=MAX_RETRIES + 1,
369
+ delay_seconds=delay,
370
+ )
371
+ await asyncio.sleep(delay)
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
+ body: dict[str, Any] = {
377
+ "model": request.model,
378
+ "store": False,
379
+ "stream": True,
380
+ "instructions": request.system_prompt or "You are illusion.",
381
+ "input": _convert_messages_to_codex(request.messages),
382
+ "text": {"verbosity": "medium"},
383
+ "include": ["reasoning.encrypted_content"],
384
+ "tool_choice": "auto",
385
+ "parallel_tool_calls": True,
386
+ }
387
+ if request.tools:
388
+ body["tools"] = _convert_tools_to_codex(request.tools)
389
+
390
+ # 添加 effort 字段
391
+ if request.effort is not None:
392
+ body["reasoning"] = {"effort": request.effort.value}
393
+
394
+ content: list[ContentBlock] = []
395
+ current_text_parts: list[str] = []
396
+ collected_reasoning = ""
397
+ completed_response: dict[str, Any] | None = None
398
+
399
+ headers = _build_codex_headers(self._auth_token)
400
+ try:
401
+ async with httpx.AsyncClient(timeout=60.0, follow_redirects=True) as client:
402
+ async with client.stream("POST", self._url, headers=headers, json=body) as response:
403
+ if response.status_code >= 400:
404
+ payload = await response.aread()
405
+ message = _format_error_message(response.status_code, payload.decode("utf-8", "replace"))
406
+ raise httpx.HTTPStatusError(message, request=response.request, response=response)
407
+
408
+ async for event in self._iter_sse_events(response):
409
+ event_type = event.get("type")
410
+ if event_type == "response.output_text.delta":
411
+ delta = event.get("delta")
412
+ if isinstance(delta, str) and delta:
413
+ current_text_parts.append(delta)
414
+ yield ApiTextDeltaEvent(text=delta)
415
+ elif event_type in {
416
+ "response.reasoning_summary_text.delta",
417
+ "response.reasoning_text.delta",
418
+ "response.output_text.reasoning.delta",
419
+ }:
420
+ delta = event.get("delta")
421
+ if isinstance(delta, str) and delta:
422
+ collected_reasoning = merge_reasoning_text(collected_reasoning, delta)
423
+ yield ApiTextDeltaEvent(text="", reasoning=delta)
424
+ elif event_type == "response.output_item.added":
425
+ # 工具调用开始:模型刚开始生成工具调用时立即通知
426
+ item = event.get("item")
427
+ if isinstance(item, dict) and item.get("type") == "function_call":
428
+ tool_name = item.get("name", "")
429
+ tool_use_id = item.get("call_id", "") or item.get("id", "")
430
+ if tool_name:
431
+ yield ApiToolCallStartedEvent(
432
+ tool_name=tool_name,
433
+ tool_use_id=tool_use_id,
434
+ )
435
+ elif event_type == "response.output_item.done":
436
+ item = event.get("item")
437
+ if not isinstance(item, dict):
438
+ continue
439
+ item_type = item.get("type")
440
+ if item_type == "message":
441
+ text = ""
442
+ raw_content = item.get("content")
443
+ if isinstance(raw_content, list):
444
+ parts = []
445
+ for block in raw_content:
446
+ if isinstance(block, dict):
447
+ if block.get("type") == "output_text":
448
+ parts.append(str(block.get("text", "")))
449
+ elif block.get("type") == "refusal":
450
+ parts.append(str(block.get("refusal", "")))
451
+ text = "".join(parts)
452
+ if text:
453
+ plain_text, tagged_reasoning = split_thinking_from_text(text)
454
+ if plain_text:
455
+ content.append(TextBlock(text=plain_text))
456
+ if tagged_reasoning:
457
+ collected_reasoning = merge_reasoning_text(
458
+ collected_reasoning,
459
+ tagged_reasoning,
460
+ )
461
+ elif item_type == "function_call":
462
+ arguments = item.get("arguments")
463
+ parsed_arguments = parse_tool_arguments(arguments)
464
+ call_id = item.get("call_id")
465
+ name = item.get("name")
466
+ if isinstance(call_id, str) and call_id and isinstance(name, str) and name:
467
+ content.append(ToolUseBlock(id=call_id, name=name, input=parsed_arguments))
468
+ elif event_type == "response.completed":
469
+ response_payload = event.get("response")
470
+ if isinstance(response_payload, dict):
471
+ completed_response = response_payload
472
+ elif event_type == "response.failed":
473
+ response_payload = event.get("response")
474
+ if isinstance(response_payload, dict):
475
+ error = response_payload.get("error")
476
+ if isinstance(error, dict):
477
+ message = str(error.get("message") or error.get("code") or "Codex response failed")
478
+ raise RequestFailure(message)
479
+ raise RequestFailure("Codex response failed")
480
+ elif event_type == "error":
481
+ message = str(event.get("message") or event.get("code") or "Codex error")
482
+ raise RequestFailure(message)
483
+ except httpx.HTTPStatusError as exc:
484
+ # 检查是否为 effort 不支持错误
485
+ if _is_effort_unsupported_error(exc) and request.effort is not None:
486
+ # 直接向用户反馈错误,不进行降级
487
+ raise RequestFailure(
488
+ f"当前模型不支持推理强度 '{request.effort.value}',请尝试使用其他推理强度级别(如 low/medium/high)"
489
+ ) from exc
490
+ raise
491
+
492
+ if current_text_parts and not any(isinstance(block, TextBlock) for block in content):
493
+ plain_text, tagged_reasoning = split_thinking_from_text("".join(current_text_parts))
494
+ if plain_text:
495
+ content.insert(0, TextBlock(text=plain_text))
496
+ if tagged_reasoning:
497
+ collected_reasoning = merge_reasoning_text(collected_reasoning, tagged_reasoning)
498
+
499
+ if collected_reasoning:
500
+ content.insert(0, ThinkingBlock(thinking=collected_reasoning))
501
+
502
+ final_message = ConversationMessage(role="assistant", content=content)
503
+ usage = _usage_from_response(completed_response or {})
504
+ stop_reason = _stop_reason_from_response(
505
+ completed_response or {},
506
+ has_tool_calls=bool(final_message.tool_uses),
507
+ )
508
+ yield ApiMessageCompleteEvent(
509
+ message=final_message,
510
+ usage=usage,
511
+ stop_reason=stop_reason,
512
+ )
513
+
514
+ async def _iter_sse_events(self, response: httpx.Response) -> AsyncIterator[dict[str, Any]]:
515
+ data_lines: list[str] = []
516
+ async for line in response.aiter_lines():
517
+ if line == "":
518
+ if data_lines:
519
+ payload = "\n".join(data_lines).strip()
520
+ data_lines = []
521
+ if payload and payload != "[DONE]":
522
+ try:
523
+ event = json.loads(payload)
524
+ except json.JSONDecodeError:
525
+ continue
526
+ if isinstance(event, dict):
527
+ yield event
528
+ continue
529
+ if line.startswith("data:"):
530
+ data_lines.append(line[5:].strip())
531
+ if data_lines:
532
+ payload = "\n".join(data_lines).strip()
533
+ if payload and payload != "[DONE]":
534
+ try:
535
+ event = json.loads(payload)
536
+ except json.JSONDecodeError:
537
+ return
538
+ if isinstance(event, dict):
539
+ yield event
540
+
541
+ @staticmethod
542
+ def _is_retryable(exc: Exception) -> bool:
543
+ if isinstance(exc, httpx.HTTPStatusError):
544
+ return exc.response.status_code in {429, 500, 502, 503, 504}
545
+ if isinstance(exc, RateLimitFailure):
546
+ return True
547
+ if isinstance(exc, RequestFailure):
548
+ message = str(exc).lower()
549
+ return any(term in message for term in ["timeout", "connect", "network", "rate", "overloaded"])
550
+ if isinstance(exc, (httpx.TimeoutException, httpx.NetworkError)):
551
+ return True
552
+ return False
553
+
554
+ @staticmethod
555
+ def _translate_error(exc: Exception) -> IllusionCodeApiError:
556
+ if isinstance(exc, IllusionCodeApiError):
557
+ return exc
558
+ if isinstance(exc, httpx.HTTPStatusError):
559
+ status = exc.response.status_code
560
+ return _translate_status_error(status, str(exc))
561
+ if isinstance(exc, httpx.HTTPError):
562
+ return RequestFailure(str(exc))
563
+ return RequestFailure(str(exc))
illusion/api/compat.py ADDED
@@ -0,0 +1,138 @@
1
+ """
2
+ API 兼容辅助模块
3
+ ================
4
+
5
+ 本模块提供不同模型供应商之间的兼容处理辅助函数。
6
+
7
+ 主要功能:
8
+ - 解析非标准工具参数字符串
9
+ - 清理模型输出中的工具调用残留标签
10
+ - 提取并拆分 `<think>` 思考内容
11
+ - 合并去重多来源推理文本
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import ast
17
+ import json
18
+ import re
19
+ from typing import Any
20
+
21
+ _THINK_BLOCK_RE = re.compile(r"<think\b[^>]*>([\s\S]*?)</think\b[^>]*>", re.IGNORECASE)
22
+ _THINK_OPEN_TAG_RE = re.compile(r"<think\b[^>]*>", re.IGNORECASE)
23
+ _THINK_CLOSE_TAG_RE = re.compile(r"</think\b[^>]*>", re.IGNORECASE)
24
+ _JSON_FENCE_RE = re.compile(r"^\s*```(?:json)?\s*([\s\S]*?)\s*```\s*$", re.IGNORECASE)
25
+ _DSML_TOOL_CALL_PREFIX_RE = re.compile(
26
+ r"<\s*[||]\s*DSML\s*[||]\s*tool_calls[^\n>]*>?",
27
+ re.IGNORECASE,
28
+ )
29
+ _TOOL_CALL_XML_BLOCK_RE = re.compile(r"<tool_call\b[^>]*>[\s\S]*?</tool_call\b[^>]*>", re.IGNORECASE)
30
+ _TOOL_CALL_XML_TAG_RE = re.compile(r"</?(?:tool_call|arg_key|arg_value)\b[^>]*>", re.IGNORECASE)
31
+ _WHITESPACE_RE = re.compile(r"\s+")
32
+
33
+
34
+ def sanitize_tool_artifacts(raw: str) -> str:
35
+ """清理模型输出中的工具调用残留标签。"""
36
+ if not raw:
37
+ return ""
38
+ return (
39
+ raw.replace("\r\n", "\n")
40
+ .replace("\r", "\n")
41
+ .strip()
42
+ .replace("\u0000", "")
43
+ ).replace("\t", " ")
44
+
45
+
46
+ def strip_tool_call_artifacts(raw: str) -> str:
47
+ """移除 DeepSeek/类 XML 工具调用残留,避免污染用户可见文本。"""
48
+ if not raw:
49
+ return ""
50
+ cleaned = _DSML_TOOL_CALL_PREFIX_RE.sub("", raw)
51
+ cleaned = _TOOL_CALL_XML_BLOCK_RE.sub("", cleaned)
52
+ cleaned = _TOOL_CALL_XML_TAG_RE.sub("", cleaned)
53
+ return cleaned
54
+
55
+
56
+ def split_thinking_from_text(raw: str) -> tuple[str, str]:
57
+ """从文本中提取 `<think>` 内容,并返回正文与思考文本。"""
58
+ if not raw:
59
+ return "", ""
60
+ source = strip_tool_call_artifacts(sanitize_tool_artifacts(raw))
61
+ thinking_parts = [m.group(1).strip() for m in _THINK_BLOCK_RE.finditer(source) if m.group(1).strip()]
62
+ without_full_blocks = _THINK_BLOCK_RE.sub("", source)
63
+
64
+ dangling_open = _THINK_OPEN_TAG_RE.search(without_full_blocks)
65
+ if dangling_open:
66
+ tail = without_full_blocks[dangling_open.end():].strip()
67
+ if tail:
68
+ thinking_parts.append(tail)
69
+ without_full_blocks = without_full_blocks[:dangling_open.start()]
70
+
71
+ plain = _THINK_OPEN_TAG_RE.sub("", without_full_blocks)
72
+ plain = _THINK_CLOSE_TAG_RE.sub("", plain).strip()
73
+ thinking = merge_reasoning_text(*thinking_parts)
74
+ return plain, thinking
75
+
76
+
77
+ def parse_tool_arguments(raw: Any) -> dict[str, Any]:
78
+ """将工具参数解析为字典,兼容常见非标准格式。"""
79
+ if isinstance(raw, dict):
80
+ return raw
81
+ if not isinstance(raw, str):
82
+ return {}
83
+
84
+ text = raw.strip()
85
+ if not text:
86
+ return {}
87
+
88
+ fenced = _JSON_FENCE_RE.match(text)
89
+ if fenced:
90
+ text = fenced.group(1).strip()
91
+
92
+ parsed = _parse_json_dict(text)
93
+ if parsed:
94
+ return parsed
95
+
96
+ first_brace = text.find("{")
97
+ last_brace = text.rfind("}")
98
+ if first_brace != -1 and last_brace > first_brace:
99
+ parsed = _parse_json_dict(text[first_brace : last_brace + 1].strip())
100
+ if parsed:
101
+ return parsed
102
+
103
+ try:
104
+ literal = ast.literal_eval(text)
105
+ except (ValueError, SyntaxError):
106
+ return {}
107
+ return literal if isinstance(literal, dict) else {}
108
+
109
+
110
+ def merge_reasoning_text(*parts: str) -> str:
111
+ """合并多个推理文本片段并去重。"""
112
+ merged: list[str] = []
113
+ for part in parts:
114
+ cleaned = strip_tool_call_artifacts(sanitize_tool_artifacts(part)).strip()
115
+ if not cleaned:
116
+ continue
117
+ candidate = _normalize_compare_text(cleaned)
118
+ if not candidate:
119
+ continue
120
+ normalized_existing = [_normalize_compare_text(value) for value in merged]
121
+ if any(existing == candidate or candidate in existing for existing in normalized_existing):
122
+ continue
123
+ merged = [value for value in merged if _normalize_compare_text(value) not in candidate]
124
+ merged.append(cleaned)
125
+ return "\n\n".join(merged).strip()
126
+
127
+
128
+ def _parse_json_dict(text: str) -> dict[str, Any]:
129
+ try:
130
+ loaded = json.loads(text)
131
+ except json.JSONDecodeError:
132
+ return {}
133
+ return loaded if isinstance(loaded, dict) else {}
134
+
135
+
136
+ def _normalize_compare_text(raw: str) -> str:
137
+ return _WHITESPACE_RE.sub(" ", raw).strip()
138
+