|
|
@@ -23,21 +23,21 @@ from typing import Any, Dict, List, Optional, Tuple
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
-def _flatten_messages_to_string(
|
|
|
+def _convert_messages(
|
|
|
messages: List[Dict[str, Any]],
|
|
|
-) -> Tuple[Optional[str], str]:
|
|
|
+) -> Tuple[Optional[str], List[Dict[str, Any]], bool]:
|
|
|
"""
|
|
|
- 把 OpenAI 风格 messages 折叠成 (system_prompt, user_text)。
|
|
|
+ 把 OpenAI 风格 messages 拆为 (system_prompt, anthropic_content_blocks, has_image)。
|
|
|
|
|
|
- role=system 拼接为 system_prompt
|
|
|
- - role=user/assistant 的 content 全部拍平为字符串
|
|
|
- - image_url 类型块降级为 `[图片URL: ...]` 文本占位(模型看到 URL 字符串而非画面)
|
|
|
-
|
|
|
- 使用 string 模式而非 AsyncIterable[dict],是为了走 SDK 中被生产验证的稳定路径。
|
|
|
- 多模态真图传输需要切到 AsyncIterable + Anthropic content block 协议,单独迭代。
|
|
|
+ - role=user/assistant 的 content 转为 Anthropic content blocks (text/image)
|
|
|
+ - OpenAI {"type":"image_url","image_url":{"url":...}} 转为
|
|
|
+ Anthropic {"type":"image","source":{"type":"url","url":...}}
|
|
|
+ - has_image:是否包含图片块,用于决定走 string 还是 AsyncIterable 模式
|
|
|
"""
|
|
|
system_parts: List[str] = []
|
|
|
- user_parts: List[str] = []
|
|
|
+ blocks: List[Dict[str, Any]] = []
|
|
|
+ has_image = False
|
|
|
|
|
|
for msg in messages:
|
|
|
role = msg.get("role")
|
|
|
@@ -49,29 +49,44 @@ def _flatten_messages_to_string(
|
|
|
continue
|
|
|
|
|
|
if isinstance(content, str):
|
|
|
- user_parts.append(content)
|
|
|
+ blocks.append({"type": "text", "text": content})
|
|
|
continue
|
|
|
|
|
|
if isinstance(content, list):
|
|
|
for block in content:
|
|
|
if not isinstance(block, dict):
|
|
|
- user_parts.append(str(block))
|
|
|
+ blocks.append({"type": "text", "text": str(block)})
|
|
|
continue
|
|
|
btype = block.get("type")
|
|
|
if btype == "text":
|
|
|
- user_parts.append(block.get("text", ""))
|
|
|
+ blocks.append({"type": "text", "text": block.get("text", "")})
|
|
|
elif btype == "image_url":
|
|
|
url = (block.get("image_url") or {}).get("url", "")
|
|
|
if url:
|
|
|
- user_parts.append(f"[图片URL: {url}]")
|
|
|
+ blocks.append(
|
|
|
+ {"type": "image", "source": {"type": "url", "url": url}}
|
|
|
+ )
|
|
|
+ has_image = True
|
|
|
elif btype == "image":
|
|
|
- src = block.get("source") or {}
|
|
|
- url = src.get("url") or src.get("data", "")[:60]
|
|
|
- user_parts.append(f"[图片: {url}]")
|
|
|
+ blocks.append(block)
|
|
|
+ has_image = True
|
|
|
|
|
|
system_prompt = "\n\n".join(system_parts).strip() or None
|
|
|
- user_text = "\n\n".join(p for p in user_parts if p).strip()
|
|
|
- return system_prompt, user_text
|
|
|
+ return system_prompt, blocks, has_image
|
|
|
+
|
|
|
+
|
|
|
+def _blocks_to_string(blocks: List[Dict[str, Any]]) -> str:
|
|
|
+ """把 content blocks 拍平成字符串(图片降级为 [图片URL: ...] 占位)— string 模式用"""
|
|
|
+ parts: List[str] = []
|
|
|
+ for block in blocks:
|
|
|
+ btype = block.get("type")
|
|
|
+ if btype == "text":
|
|
|
+ parts.append(block.get("text", ""))
|
|
|
+ elif btype == "image":
|
|
|
+ src = block.get("source") or {}
|
|
|
+ url = src.get("url") or src.get("data", "")[:60]
|
|
|
+ parts.append(f"[图片URL: {url}]")
|
|
|
+ return "\n\n".join(p for p in parts if p).strip()
|
|
|
|
|
|
|
|
|
def create_claude_code_oauth_llm_call(model: str = "claude-sonnet-4-5"):
|
|
|
@@ -94,17 +109,19 @@ def create_claude_code_oauth_llm_call(model: str = "claude-sonnet-4-5"):
|
|
|
TextBlock,
|
|
|
)
|
|
|
|
|
|
- # 从子进程 env 中剥离 API key 相关变量,让 CLI 回落到 OAuth;
|
|
|
- # 父进程 os.environ 不变(其他 LLM provider 仍可用 API key)。
|
|
|
- _stripped_env = {
|
|
|
- k: v
|
|
|
- for k, v in os.environ.items()
|
|
|
- if k not in ("ANTHROPIC_API_KEY", "ANTHROPIC_BASE_URL", "ANTHROPIC_AUTH_TOKEN")
|
|
|
+ # 让 SDK 子进程看不到 API key 相关变量,回落到 OAuth。
|
|
|
+ # SDK 内部把 options.env 当作"覆盖层"叠在父进程 os.environ 之上,
|
|
|
+ # 所以从 dict 里"移除"这些 key 没用 — 必须显式以空串覆盖父值。
|
|
|
+ # 父进程 os.environ 不变(其他 LLM provider 继续可用 API key)。
|
|
|
+ _override_env: Dict[str, str] = {
|
|
|
+ "ANTHROPIC_API_KEY": "",
|
|
|
+ "ANTHROPIC_BASE_URL": "",
|
|
|
+ "ANTHROPIC_AUTH_TOKEN": "",
|
|
|
}
|
|
|
if "ANTHROPIC_API_KEY" in os.environ or "ANTHROPIC_BASE_URL" in os.environ:
|
|
|
logger.info(
|
|
|
- "[claude_code_oauth] Stripping ANTHROPIC_API_KEY/ANTHROPIC_BASE_URL "
|
|
|
- "from SDK subprocess env so CLI falls back to OAuth credentials."
|
|
|
+ "[claude_code_oauth] Overriding ANTHROPIC_API_KEY/ANTHROPIC_BASE_URL "
|
|
|
+ "with empty values in SDK subprocess env so CLI falls back to OAuth."
|
|
|
)
|
|
|
|
|
|
default_model = model
|
|
|
@@ -117,9 +134,9 @@ def create_claude_code_oauth_llm_call(model: str = "claude-sonnet-4-5"):
|
|
|
) -> Dict[str, Any]:
|
|
|
actual_model = (model or default_model).split("/")[-1]
|
|
|
|
|
|
- system_prompt, user_text = _flatten_messages_to_string(messages)
|
|
|
- if not user_text:
|
|
|
- user_text = " "
|
|
|
+ system_prompt, content_blocks, has_image = _convert_messages(messages)
|
|
|
+ if not content_blocks:
|
|
|
+ content_blocks = [{"type": "text", "text": " "}]
|
|
|
|
|
|
stderr_lines: List[str] = []
|
|
|
|
|
|
@@ -132,7 +149,7 @@ def create_claude_code_oauth_llm_call(model: str = "claude-sonnet-4-5"):
|
|
|
system_prompt=system_prompt,
|
|
|
allowed_tools=[],
|
|
|
max_turns=1,
|
|
|
- env=_stripped_env,
|
|
|
+ env=_override_env,
|
|
|
stderr=_capture_stderr,
|
|
|
# 关键:屏蔽 CLI 加载用户级 ~/.claude/ 配置(output_style/skills/plugins 等)
|
|
|
# 否则这些会被注入 system prompt,浪费 token + 影响输出格式
|
|
|
@@ -152,7 +169,20 @@ def create_claude_code_oauth_llm_call(model: str = "claude-sonnet-4-5"):
|
|
|
|
|
|
try:
|
|
|
async with ClaudeSDKClient(options=options) as client:
|
|
|
- await client.query(user_text)
|
|
|
+ if has_image:
|
|
|
+ # 多模态:用 AsyncIterable[dict] 模式发送 Anthropic content blocks
|
|
|
+ async def _input_stream():
|
|
|
+ yield {
|
|
|
+ "type": "user",
|
|
|
+ "message": {"role": "user", "content": content_blocks},
|
|
|
+ "parent_tool_use_id": None,
|
|
|
+ "session_id": "default",
|
|
|
+ }
|
|
|
+ await client.query(_input_stream())
|
|
|
+ else:
|
|
|
+ # 纯文本:走 SDK string 模式(已验证稳定路径)
|
|
|
+ await client.query(_blocks_to_string(content_blocks))
|
|
|
+
|
|
|
async for msg in client.receive_response():
|
|
|
msg_type = type(msg).__name__
|
|
|
|