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,612 @@
|
|
|
1
|
+
"""
|
|
2
|
+
外部 CLI 管理的订阅凭据集成模块
|
|
3
|
+
==============================
|
|
4
|
+
|
|
5
|
+
本模块提供与外部 CLI 管理的订阅凭据的集成功能。
|
|
6
|
+
|
|
7
|
+
主要功能:
|
|
8
|
+
- 从 Codex CLI 加载外部认证凭据
|
|
9
|
+
- 从 Claude CLI 加载外部认证凭据
|
|
10
|
+
- 处理 OAuth 令牌刷新
|
|
11
|
+
- 检测凭据过期状态
|
|
12
|
+
- 生成 Claude Code 风格的请求头
|
|
13
|
+
|
|
14
|
+
类说明:
|
|
15
|
+
- ExternalAuthBinding: 外部认证绑定数据类
|
|
16
|
+
- ExternalAuthCredential: 外部凭据数据类
|
|
17
|
+
- ExternalAuthState: 外部认证状态数据类
|
|
18
|
+
|
|
19
|
+
使用示例:
|
|
20
|
+
>>> from illusion.auth.external import load_external_credential, default_binding_for_provider
|
|
21
|
+
>>> binding = default_binding_for_provider("openai_codex")
|
|
22
|
+
>>> cred = load_external_credential(binding)
|
|
23
|
+
>>> print(cred.value)
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import base64
|
|
29
|
+
import json
|
|
30
|
+
import os
|
|
31
|
+
import subprocess
|
|
32
|
+
import sys
|
|
33
|
+
import time
|
|
34
|
+
import urllib.parse
|
|
35
|
+
import urllib.request
|
|
36
|
+
import uuid
|
|
37
|
+
from dataclasses import dataclass
|
|
38
|
+
from pathlib import Path
|
|
39
|
+
from typing import Any
|
|
40
|
+
|
|
41
|
+
from illusion.auth.storage import ExternalAuthBinding
|
|
42
|
+
|
|
43
|
+
# 提供商常量定义
|
|
44
|
+
CODEX_PROVIDER = "openai_codex" # Codex 提供商名称
|
|
45
|
+
CLAUDE_PROVIDER = "anthropic_claude" # Claude 提供商名称
|
|
46
|
+
CLAUDE_CODE_VERSION_FALLBACK = "2.1.88" # Claude Code 版本回退值
|
|
47
|
+
CLAUDE_OAUTH_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e" # Claude OAuth 客户端 ID
|
|
48
|
+
CLAUDE_OAUTH_TOKEN_ENDPOINTS = ( # Claude OAuth 令牌端点列表
|
|
49
|
+
"https://platform.claude.com/v1/oauth/token",
|
|
50
|
+
"https://console.anthropic.com/v1/oauth/token",
|
|
51
|
+
)
|
|
52
|
+
CLAUDE_COMMON_BETAS = ( # 通用的 Beta 特性列表
|
|
53
|
+
"interleaved-thinking-2025-05-14",
|
|
54
|
+
"fine-grained-tool-streaming-2025-05-14",
|
|
55
|
+
)
|
|
56
|
+
CLAUDE_OAUTH_ONLY_BETAS = ( # 仅 OAuth 的 Beta 特性列表
|
|
57
|
+
"claude-code-20250219",
|
|
58
|
+
"oauth-2025-04-20",
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
# 模块级缓存变量
|
|
62
|
+
_claude_code_version_cache: str | None = None # Claude Code 版本缓存
|
|
63
|
+
_claude_code_session_id: str | None = None # Claude Code 会话 ID 缓存
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True)
|
|
67
|
+
class ExternalAuthCredential:
|
|
68
|
+
"""运行时使用的规范化外部凭据
|
|
69
|
+
|
|
70
|
+
Attributes:
|
|
71
|
+
provider: 提供商名称
|
|
72
|
+
value: 凭据值(访问令牌)
|
|
73
|
+
auth_kind: 认证类型
|
|
74
|
+
source_path: 源文件路径
|
|
75
|
+
managed_by: 管理程序名称
|
|
76
|
+
profile_label: 配置标签
|
|
77
|
+
refresh_token: 刷新令牌
|
|
78
|
+
expires_at_ms: 过期时间(毫秒时间戳)
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
provider: str
|
|
82
|
+
value: str
|
|
83
|
+
auth_kind: str
|
|
84
|
+
source_path: Path
|
|
85
|
+
managed_by: str
|
|
86
|
+
profile_label: str = ""
|
|
87
|
+
refresh_token: str = ""
|
|
88
|
+
expires_at_ms: int | None = None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass(frozen=True)
|
|
92
|
+
class ExternalAuthState:
|
|
93
|
+
"""人类可读的外部认证源状态
|
|
94
|
+
|
|
95
|
+
Attributes:
|
|
96
|
+
configured: 是否已配置
|
|
97
|
+
state: 状态字符串
|
|
98
|
+
source: 来源字符串
|
|
99
|
+
detail: 详细信息
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
configured: bool
|
|
103
|
+
state: str
|
|
104
|
+
source: str
|
|
105
|
+
detail: str = ""
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def default_binding_for_provider(provider: str) -> ExternalAuthBinding:
|
|
109
|
+
"""获取指定提供商的默认外部认证源
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
provider: 提供商名称
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
ExternalAuthBinding: 外部认证绑定对象
|
|
116
|
+
|
|
117
|
+
Raises:
|
|
118
|
+
ValueError: 不支持的外部认证提供商
|
|
119
|
+
"""
|
|
120
|
+
if provider == CODEX_PROVIDER:
|
|
121
|
+
codex_home = Path(os.environ.get("CODEX_HOME", "~/.codex")).expanduser()
|
|
122
|
+
return ExternalAuthBinding(
|
|
123
|
+
provider=provider,
|
|
124
|
+
source_path=str(codex_home / "auth.json"),
|
|
125
|
+
source_kind="codex_auth_json",
|
|
126
|
+
managed_by="codex-cli",
|
|
127
|
+
profile_label="Codex CLI",
|
|
128
|
+
)
|
|
129
|
+
if provider == CLAUDE_PROVIDER:
|
|
130
|
+
claude_home = Path(os.environ.get("CLAUDE_HOME", "~/.claude")).expanduser()
|
|
131
|
+
return ExternalAuthBinding(
|
|
132
|
+
provider=provider,
|
|
133
|
+
source_path=str(claude_home / ".credentials.json"),
|
|
134
|
+
source_kind="claude_credentials_json",
|
|
135
|
+
managed_by="claude-cli",
|
|
136
|
+
profile_label="Claude CLI",
|
|
137
|
+
)
|
|
138
|
+
raise ValueError(f"Unsupported external auth provider: {provider}")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def load_external_credential(
|
|
142
|
+
binding: ExternalAuthBinding,
|
|
143
|
+
*,
|
|
144
|
+
refresh_if_needed: bool = False,
|
|
145
|
+
) -> ExternalAuthCredential:
|
|
146
|
+
"""从外部认证绑定读取运行时凭据
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
binding: 外部认证绑定对象
|
|
150
|
+
refresh_if_needed: 是否在需要时刷新凭据
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
ExternalAuthCredential: 外部凭据对象
|
|
154
|
+
|
|
155
|
+
Raises:
|
|
156
|
+
ValueError: 外部认证源不存在或无效
|
|
157
|
+
"""
|
|
158
|
+
source_path = Path(binding.source_path).expanduser()
|
|
159
|
+
if not source_path.exists():
|
|
160
|
+
raise ValueError(f"External auth source not found: {source_path}")
|
|
161
|
+
|
|
162
|
+
try:
|
|
163
|
+
payload = json.loads(source_path.read_text(encoding="utf-8"))
|
|
164
|
+
except json.JSONDecodeError as exc:
|
|
165
|
+
raise ValueError(f"Invalid JSON in external auth source: {source_path}") from exc
|
|
166
|
+
|
|
167
|
+
if binding.provider == CODEX_PROVIDER:
|
|
168
|
+
return _load_codex_credential(payload, source_path, binding)
|
|
169
|
+
if binding.provider == CLAUDE_PROVIDER:
|
|
170
|
+
return _load_claude_credential(
|
|
171
|
+
payload,
|
|
172
|
+
source_path,
|
|
173
|
+
binding,
|
|
174
|
+
refresh_if_needed=refresh_if_needed,
|
|
175
|
+
)
|
|
176
|
+
raise ValueError(f"Unsupported external auth provider: {binding.provider}")
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _load_codex_credential(
|
|
180
|
+
payload: dict[str, Any],
|
|
181
|
+
source_path: Path,
|
|
182
|
+
binding: ExternalAuthBinding,
|
|
183
|
+
) -> ExternalAuthCredential:
|
|
184
|
+
"""从 Codex 认证源加载凭据(内部函数)
|
|
185
|
+
|
|
186
|
+
Args:
|
|
187
|
+
payload: 解析后的 JSON 数据
|
|
188
|
+
source_path: 源文件路径
|
|
189
|
+
binding: 外部认证绑定对象
|
|
190
|
+
|
|
191
|
+
Returns:
|
|
192
|
+
ExternalAuthCredential: 外部凭据对象
|
|
193
|
+
|
|
194
|
+
Raises:
|
|
195
|
+
ValueError: 未找到访问令牌
|
|
196
|
+
"""
|
|
197
|
+
tokens = payload.get("tokens")
|
|
198
|
+
access_token = ""
|
|
199
|
+
refresh_token = ""
|
|
200
|
+
if isinstance(tokens, dict):
|
|
201
|
+
access_token = str(tokens.get("access_token", "") or "")
|
|
202
|
+
refresh_token = str(tokens.get("refresh_token", "") or "")
|
|
203
|
+
if not access_token:
|
|
204
|
+
access_token = str(payload.get("OPENAI_API_KEY", "") or "")
|
|
205
|
+
if not access_token:
|
|
206
|
+
raise ValueError("Codex auth source does not contain an access token.")
|
|
207
|
+
|
|
208
|
+
email = _decode_json_web_token_claim(access_token, ["https://api.openai.com/profile", "email"])
|
|
209
|
+
expires_at_ms = _decode_jwt_expiry(access_token)
|
|
210
|
+
return ExternalAuthCredential(
|
|
211
|
+
provider=CODEX_PROVIDER,
|
|
212
|
+
value=access_token,
|
|
213
|
+
auth_kind="api_key",
|
|
214
|
+
source_path=source_path,
|
|
215
|
+
managed_by=binding.managed_by,
|
|
216
|
+
profile_label=email or binding.profile_label,
|
|
217
|
+
refresh_token=refresh_token,
|
|
218
|
+
expires_at_ms=expires_at_ms,
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _load_claude_credential(
|
|
223
|
+
payload: dict[str, Any],
|
|
224
|
+
source_path: Path,
|
|
225
|
+
binding: ExternalAuthBinding,
|
|
226
|
+
*,
|
|
227
|
+
refresh_if_needed: bool,
|
|
228
|
+
) -> ExternalAuthCredential:
|
|
229
|
+
"""从 Claude 认证源加载凭据(内部函数)
|
|
230
|
+
|
|
231
|
+
Args:
|
|
232
|
+
payload: 解析后的 JSON 数据
|
|
233
|
+
source_path: 源文件路径
|
|
234
|
+
binding: 外部认证绑定对象
|
|
235
|
+
refresh_if_needed: 是否在需要时刷新凭据
|
|
236
|
+
|
|
237
|
+
Returns:
|
|
238
|
+
ExternalAuthCredential: 外部凭据对象
|
|
239
|
+
|
|
240
|
+
Raises:
|
|
241
|
+
ValueError: 未找到访问令牌或凭据过期无法刷新
|
|
242
|
+
"""
|
|
243
|
+
claude_oauth = payload.get("claudeAiOauth")
|
|
244
|
+
if not isinstance(claude_oauth, dict):
|
|
245
|
+
raise ValueError("Claude auth source does not contain claudeAiOauth.")
|
|
246
|
+
|
|
247
|
+
access_token = str(claude_oauth.get("accessToken", "") or "")
|
|
248
|
+
refresh_token = str(claude_oauth.get("refreshToken", "") or "")
|
|
249
|
+
expires_at_raw = claude_oauth.get("expiresAt")
|
|
250
|
+
if not access_token:
|
|
251
|
+
raise ValueError("Claude auth source does not contain an access token.")
|
|
252
|
+
|
|
253
|
+
expires_at_ms = _coerce_int(expires_at_raw)
|
|
254
|
+
credential = ExternalAuthCredential(
|
|
255
|
+
provider=CLAUDE_PROVIDER,
|
|
256
|
+
value=access_token,
|
|
257
|
+
auth_kind="auth_token",
|
|
258
|
+
source_path=source_path,
|
|
259
|
+
managed_by=binding.managed_by,
|
|
260
|
+
profile_label=binding.profile_label,
|
|
261
|
+
refresh_token=refresh_token,
|
|
262
|
+
expires_at_ms=expires_at_ms,
|
|
263
|
+
)
|
|
264
|
+
if refresh_if_needed and is_credential_expired(credential):
|
|
265
|
+
if not refresh_token:
|
|
266
|
+
raise ValueError(
|
|
267
|
+
f"Claude credentials at {source_path} are expired and cannot be refreshed."
|
|
268
|
+
)
|
|
269
|
+
refreshed = refresh_claude_oauth_credential(refresh_token)
|
|
270
|
+
write_claude_credentials(
|
|
271
|
+
source_path,
|
|
272
|
+
access_token=refreshed["access_token"],
|
|
273
|
+
refresh_token=refreshed["refresh_token"],
|
|
274
|
+
expires_at_ms=refreshed["expires_at_ms"],
|
|
275
|
+
)
|
|
276
|
+
credential = ExternalAuthCredential(
|
|
277
|
+
provider=CLAUDE_PROVIDER,
|
|
278
|
+
value=str(refreshed["access_token"]),
|
|
279
|
+
auth_kind="auth_token",
|
|
280
|
+
source_path=source_path,
|
|
281
|
+
managed_by=binding.managed_by,
|
|
282
|
+
profile_label=binding.profile_label,
|
|
283
|
+
refresh_token=str(refreshed["refresh_token"]),
|
|
284
|
+
expires_at_ms=int(refreshed["expires_at_ms"]),
|
|
285
|
+
)
|
|
286
|
+
return credential
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def describe_external_binding(binding: ExternalAuthBinding) -> ExternalAuthState:
|
|
290
|
+
"""获取外部认证绑定的人类可读状态
|
|
291
|
+
|
|
292
|
+
Args:
|
|
293
|
+
binding: 外部认证绑定对象
|
|
294
|
+
|
|
295
|
+
Returns:
|
|
296
|
+
ExternalAuthState: 外部认证状态对象
|
|
297
|
+
"""
|
|
298
|
+
source_path = Path(binding.source_path).expanduser()
|
|
299
|
+
if not source_path.exists():
|
|
300
|
+
return ExternalAuthState(
|
|
301
|
+
configured=False,
|
|
302
|
+
state="missing",
|
|
303
|
+
source="missing",
|
|
304
|
+
detail=f"external auth source not found: {source_path}",
|
|
305
|
+
)
|
|
306
|
+
try:
|
|
307
|
+
credential = load_external_credential(binding, refresh_if_needed=False)
|
|
308
|
+
except ValueError as exc:
|
|
309
|
+
return ExternalAuthState(
|
|
310
|
+
configured=False,
|
|
311
|
+
state="invalid",
|
|
312
|
+
source="external",
|
|
313
|
+
detail=str(exc),
|
|
314
|
+
)
|
|
315
|
+
if binding.provider == CLAUDE_PROVIDER and is_credential_expired(credential):
|
|
316
|
+
if credential.refresh_token:
|
|
317
|
+
return ExternalAuthState(
|
|
318
|
+
configured=True,
|
|
319
|
+
state="refreshable",
|
|
320
|
+
source="external",
|
|
321
|
+
detail=f"expired token can be refreshed from {source_path}",
|
|
322
|
+
)
|
|
323
|
+
return ExternalAuthState(
|
|
324
|
+
configured=False,
|
|
325
|
+
state="expired",
|
|
326
|
+
source="external",
|
|
327
|
+
detail=f"expired token at {source_path}",
|
|
328
|
+
)
|
|
329
|
+
return ExternalAuthState(
|
|
330
|
+
configured=True,
|
|
331
|
+
state="configured",
|
|
332
|
+
source="external",
|
|
333
|
+
detail=str(source_path),
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def is_credential_expired(credential: ExternalAuthCredential, *, now_ms: int | None = None) -> bool:
|
|
338
|
+
"""检查外部凭据是否已过期
|
|
339
|
+
|
|
340
|
+
Args:
|
|
341
|
+
credential: 外部凭据对象
|
|
342
|
+
now_ms: 当前时间(毫秒),默认使用当前系统时间
|
|
343
|
+
|
|
344
|
+
Returns:
|
|
345
|
+
bool: 是否已过期
|
|
346
|
+
"""
|
|
347
|
+
if credential.expires_at_ms is None:
|
|
348
|
+
return False
|
|
349
|
+
if now_ms is None:
|
|
350
|
+
import time
|
|
351
|
+
|
|
352
|
+
now_ms = int(time.time() * 1000)
|
|
353
|
+
return credential.expires_at_ms <= now_ms
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def get_claude_code_version() -> str:
|
|
357
|
+
"""获取本地安装的 Claude Code 版本,如果未安装则返回回退版本
|
|
358
|
+
|
|
359
|
+
Returns:
|
|
360
|
+
str: 版本号字符串
|
|
361
|
+
"""
|
|
362
|
+
global _claude_code_version_cache
|
|
363
|
+
if _claude_code_version_cache is not None:
|
|
364
|
+
return _claude_code_version_cache
|
|
365
|
+
for command in ("claude", "claude-code"):
|
|
366
|
+
try:
|
|
367
|
+
run_kwargs: dict = {}
|
|
368
|
+
if sys.platform == "win32":
|
|
369
|
+
run_kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
|
|
370
|
+
result = subprocess.run(
|
|
371
|
+
[command, "--version"],
|
|
372
|
+
capture_output=True,
|
|
373
|
+
text=True,
|
|
374
|
+
timeout=5,
|
|
375
|
+
check=False,
|
|
376
|
+
**run_kwargs,
|
|
377
|
+
)
|
|
378
|
+
except Exception:
|
|
379
|
+
continue
|
|
380
|
+
version = (result.stdout or "").strip().split(" ", 1)[0]
|
|
381
|
+
if result.returncode == 0 and version and version[0].isdigit():
|
|
382
|
+
_claude_code_version_cache = version
|
|
383
|
+
return version
|
|
384
|
+
_claude_code_version_cache = CLAUDE_CODE_VERSION_FALLBACK
|
|
385
|
+
return _claude_code_version_cache
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def get_claude_code_session_id() -> str:
|
|
389
|
+
"""获取此进程的稳定 Claude Code 风格会话标识符
|
|
390
|
+
|
|
391
|
+
Returns:
|
|
392
|
+
str: UUID 格式的会话 ID
|
|
393
|
+
"""
|
|
394
|
+
global _claude_code_session_id
|
|
395
|
+
if _claude_code_session_id is None:
|
|
396
|
+
_claude_code_session_id = str(uuid.uuid4())
|
|
397
|
+
return _claude_code_session_id
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def claude_oauth_betas() -> list[str]:
|
|
401
|
+
"""获取 Claude OAuth Beta 特性列表,用于 SDK Beta 端点
|
|
402
|
+
|
|
403
|
+
Returns:
|
|
404
|
+
list[str]: Beta 特性名称列表
|
|
405
|
+
"""
|
|
406
|
+
return list(CLAUDE_COMMON_BETAS + CLAUDE_OAUTH_ONLY_BETAS)
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def claude_attribution_header() -> str:
|
|
410
|
+
"""获取用于系统提示的 Claude Code 计费归属前缀
|
|
411
|
+
|
|
412
|
+
Returns:
|
|
413
|
+
str: 计费归属头字符串
|
|
414
|
+
"""
|
|
415
|
+
version = get_claude_code_version()
|
|
416
|
+
return (
|
|
417
|
+
"x-anthropic-billing-header: "
|
|
418
|
+
f"cc_version={version}; cc_entrypoint=cli;"
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def claude_oauth_headers() -> dict[str, str]:
|
|
423
|
+
"""获取订阅 OAuth 流量的 Claude Code 风格请求头
|
|
424
|
+
|
|
425
|
+
Returns:
|
|
426
|
+
dict[str, str]: 请求头字典
|
|
427
|
+
"""
|
|
428
|
+
all_betas = ",".join(claude_oauth_betas())
|
|
429
|
+
return {
|
|
430
|
+
"anthropic-beta": all_betas,
|
|
431
|
+
"user-agent": f"claude-cli/{get_claude_code_version()} (external, cli)",
|
|
432
|
+
"x-app": "cli",
|
|
433
|
+
"X-Claude-Code-Session-Id": get_claude_code_session_id(),
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def refresh_claude_oauth_credential(refresh_token: str) -> dict[str, Any]:
|
|
438
|
+
"""刷新 Claude OAuth 令牌,不修改本地文件
|
|
439
|
+
|
|
440
|
+
Args:
|
|
441
|
+
refresh_token: 刷新令牌
|
|
442
|
+
|
|
443
|
+
Returns:
|
|
444
|
+
dict[str, Any]: 包含 access_token、refresh_token、expires_at_ms、scopes 的字典
|
|
445
|
+
|
|
446
|
+
Raises:
|
|
447
|
+
ValueError: 刷新失败
|
|
448
|
+
"""
|
|
449
|
+
if not refresh_token:
|
|
450
|
+
raise ValueError("refresh_token is required")
|
|
451
|
+
|
|
452
|
+
payload = urllib.parse.urlencode(
|
|
453
|
+
{
|
|
454
|
+
"grant_type": "refresh_token",
|
|
455
|
+
"refresh_token": refresh_token,
|
|
456
|
+
"client_id": CLAUDE_OAUTH_CLIENT_ID,
|
|
457
|
+
}
|
|
458
|
+
).encode("utf-8")
|
|
459
|
+
headers = {
|
|
460
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
461
|
+
"User-Agent": f"claude-cli/{get_claude_code_version()} (external, cli)",
|
|
462
|
+
}
|
|
463
|
+
last_error: Exception | None = None
|
|
464
|
+
for endpoint in CLAUDE_OAUTH_TOKEN_ENDPOINTS:
|
|
465
|
+
request = urllib.request.Request(endpoint, data=payload, headers=headers, method="POST")
|
|
466
|
+
try:
|
|
467
|
+
with urllib.request.urlopen(request, timeout=10) as response:
|
|
468
|
+
result = json.loads(response.read().decode("utf-8"))
|
|
469
|
+
except Exception as exc:
|
|
470
|
+
last_error = exc
|
|
471
|
+
continue
|
|
472
|
+
access_token = str(result.get("access_token", "") or "")
|
|
473
|
+
if not access_token:
|
|
474
|
+
raise ValueError("Claude OAuth refresh response missing access_token")
|
|
475
|
+
next_refresh = str(result.get("refresh_token", refresh_token) or refresh_token)
|
|
476
|
+
expires_in = int(result.get("expires_in", 3600) or 3600)
|
|
477
|
+
return {
|
|
478
|
+
"access_token": access_token,
|
|
479
|
+
"refresh_token": next_refresh,
|
|
480
|
+
"expires_at_ms": int(time.time() * 1000) + expires_in * 1000,
|
|
481
|
+
"scopes": result.get("scope"),
|
|
482
|
+
}
|
|
483
|
+
if last_error is not None:
|
|
484
|
+
raise ValueError(f"Claude OAuth refresh failed: {last_error}") from last_error
|
|
485
|
+
raise ValueError("Claude OAuth refresh failed")
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def write_claude_credentials(
|
|
489
|
+
source_path: Path,
|
|
490
|
+
*,
|
|
491
|
+
access_token: str,
|
|
492
|
+
refresh_token: str,
|
|
493
|
+
expires_at_ms: int,
|
|
494
|
+
) -> None:
|
|
495
|
+
"""将刷新的 Claude 凭据写回上游凭据文件
|
|
496
|
+
|
|
497
|
+
Args:
|
|
498
|
+
source_path: 凭据文件路径
|
|
499
|
+
access_token: 访问令牌
|
|
500
|
+
refresh_token: 刷新令牌
|
|
501
|
+
expires_at_ms: 过期时间(毫秒时间戳)
|
|
502
|
+
"""
|
|
503
|
+
existing: dict[str, Any] = {}
|
|
504
|
+
if source_path.exists():
|
|
505
|
+
try:
|
|
506
|
+
existing = json.loads(source_path.read_text(encoding="utf-8"))
|
|
507
|
+
except (json.JSONDecodeError, OSError):
|
|
508
|
+
existing = {}
|
|
509
|
+
previous = existing.get("claudeAiOauth")
|
|
510
|
+
next_oauth: dict[str, Any] = {
|
|
511
|
+
"accessToken": access_token,
|
|
512
|
+
"refreshToken": refresh_token,
|
|
513
|
+
"expiresAt": expires_at_ms,
|
|
514
|
+
}
|
|
515
|
+
if isinstance(previous, dict):
|
|
516
|
+
for key in ("scopes", "rateLimitTier", "subscriptionType"):
|
|
517
|
+
if key in previous:
|
|
518
|
+
next_oauth[key] = previous[key]
|
|
519
|
+
existing["claudeAiOauth"] = next_oauth
|
|
520
|
+
source_path.parent.mkdir(parents=True, exist_ok=True)
|
|
521
|
+
source_path.write_text(json.dumps(existing, indent=2) + "\n", encoding="utf-8")
|
|
522
|
+
try:
|
|
523
|
+
source_path.chmod(0o600)
|
|
524
|
+
except OSError:
|
|
525
|
+
pass
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def is_third_party_anthropic_endpoint(base_url: str | None) -> bool:
|
|
529
|
+
"""检查是否为使用 Anthropic 兼容 API 的非 Anthropic 端点
|
|
530
|
+
|
|
531
|
+
Args:
|
|
532
|
+
base_url: 基础 URL
|
|
533
|
+
|
|
534
|
+
Returns:
|
|
535
|
+
bool: 是否为第三方端点
|
|
536
|
+
"""
|
|
537
|
+
if not base_url:
|
|
538
|
+
return False
|
|
539
|
+
normalized = base_url.rstrip("/").lower()
|
|
540
|
+
return "anthropic.com" not in normalized and "claude.com" not in normalized
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def _coerce_int(value: Any) -> int | None:
|
|
544
|
+
"""将任意值转换为整数(内部函数)
|
|
545
|
+
|
|
546
|
+
Args:
|
|
547
|
+
value: 任意值
|
|
548
|
+
|
|
549
|
+
Returns:
|
|
550
|
+
int | None: 转换后的整数或 None
|
|
551
|
+
"""
|
|
552
|
+
if isinstance(value, bool):
|
|
553
|
+
return None
|
|
554
|
+
if isinstance(value, int):
|
|
555
|
+
return value
|
|
556
|
+
if isinstance(value, float):
|
|
557
|
+
return int(value)
|
|
558
|
+
if isinstance(value, str):
|
|
559
|
+
trimmed = value.strip()
|
|
560
|
+
if trimmed.isdigit():
|
|
561
|
+
return int(trimmed)
|
|
562
|
+
return None
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def _decode_jwt_expiry(token: str) -> int | None:
|
|
566
|
+
"""解码 JWT 令牌的过期时间(内部函数)
|
|
567
|
+
|
|
568
|
+
Args:
|
|
569
|
+
token: JWT 令牌字符串
|
|
570
|
+
|
|
571
|
+
Returns:
|
|
572
|
+
int | None: 过期时间(毫秒时间戳)或 None
|
|
573
|
+
"""
|
|
574
|
+
exp = _decode_json_web_token_claim(token, ["exp"])
|
|
575
|
+
if exp is None:
|
|
576
|
+
return None
|
|
577
|
+
if isinstance(exp, int):
|
|
578
|
+
return exp * 1000
|
|
579
|
+
if isinstance(exp, float):
|
|
580
|
+
return int(exp * 1000)
|
|
581
|
+
if isinstance(exp, str) and exp.strip().isdigit():
|
|
582
|
+
return int(exp.strip()) * 1000
|
|
583
|
+
return None
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def _decode_json_web_token_claim(token: str, path: list[str]) -> Any | None:
|
|
587
|
+
"""解码 JWT 令牌中的指定声明(内部函数)
|
|
588
|
+
|
|
589
|
+
Args:
|
|
590
|
+
token: JWT 令牌字符串
|
|
591
|
+
path: 要获取的声明路径列表
|
|
592
|
+
|
|
593
|
+
Returns:
|
|
594
|
+
Any | None: 声明值或 None
|
|
595
|
+
"""
|
|
596
|
+
parts = token.split(".")
|
|
597
|
+
if len(parts) != 3:
|
|
598
|
+
return None
|
|
599
|
+
try:
|
|
600
|
+
encoded = parts[1]
|
|
601
|
+
padded = encoded + "=" * (-len(encoded) % 4)
|
|
602
|
+
payload = json.loads(base64.urlsafe_b64decode(padded.encode("ascii")).decode("utf-8"))
|
|
603
|
+
except Exception:
|
|
604
|
+
return None
|
|
605
|
+
|
|
606
|
+
current: Any = payload
|
|
607
|
+
for key in path:
|
|
608
|
+
if isinstance(current, dict) and key in current:
|
|
609
|
+
current = current[key]
|
|
610
|
+
else:
|
|
611
|
+
return None
|
|
612
|
+
return current
|
illusion/auth/flows.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""
|
|
2
|
+
认证流程模块
|
|
3
|
+
============
|
|
4
|
+
|
|
5
|
+
本模块提供各种提供商类型的认证流程。
|
|
6
|
+
|
|
7
|
+
每个流程都是一个自包含的类,具有单一的 run() 方法,
|
|
8
|
+
执行交互式认证并返回获取的凭据。
|
|
9
|
+
|
|
10
|
+
类说明:
|
|
11
|
+
- AuthFlow: 认证流程抽象基类
|
|
12
|
+
- ApiKeyFlow: API 密钥认证流程
|
|
13
|
+
|
|
14
|
+
使用示例:
|
|
15
|
+
>>> from illusion.auth.flows import ApiKeyFlow
|
|
16
|
+
>>> flow = ApiKeyFlow(prompt_text="输入 API 密钥")
|
|
17
|
+
>>> key = flow.run()
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import logging
|
|
23
|
+
from abc import ABC, abstractmethod
|
|
24
|
+
|
|
25
|
+
log = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class AuthFlow(ABC):
|
|
29
|
+
"""认证流程抽象基类"""
|
|
30
|
+
|
|
31
|
+
@abstractmethod
|
|
32
|
+
def run(self) -> str:
|
|
33
|
+
"""执行流程并返回获取的凭据值"""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ApiKeyFlow(AuthFlow):
|
|
37
|
+
"""提示用户输入 API 密钥(明文输入)
|
|
38
|
+
|
|
39
|
+
Attributes:
|
|
40
|
+
prompt_text: 提示文本
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(self, prompt_text: str = "API Key") -> None:
|
|
44
|
+
self.prompt_text = prompt_text
|
|
45
|
+
|
|
46
|
+
def run(self) -> str:
|
|
47
|
+
"""提示用户输入 API 密钥
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
str: 输入的 API 密钥
|
|
51
|
+
|
|
52
|
+
Raises:
|
|
53
|
+
ValueError: API 密钥为空
|
|
54
|
+
"""
|
|
55
|
+
key = input(f"{self.prompt_text}: ").strip()
|
|
56
|
+
if not key:
|
|
57
|
+
raise ValueError("API key cannot be empty.")
|
|
58
|
+
return key
|