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,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
|
+
|