claude_code_oauth.py 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234
  1. """
  2. Claude Code OAuth Provider
  3. 通过 claude-agent-sdk 复用 `claude` CLI 的 OAuth 登录态调用 Claude(Max 订阅额度)。
  4. 实现方式:使用 `ClaudeSDKClient`(双向 session)+ AsyncIterable[dict] 形式发送
  5. 用户消息。这种模式同时满足:
  6. 1. 协议正确(client 内部管 stdin 生命周期,不会卡死)
  7. 2. 支持多模态(content blocks 可带 image 节点)
  8. Auth:依赖 `~/.claude/.credentials.json` 的 OAuth token;如父进程有
  9. ANTHROPIC_API_KEY / ANTHROPIC_BASE_URL,会从子进程 env 中剥离,让 CLI
  10. 回落到 OAuth。父进程 os.environ 不变。
  11. 输出契约(与现有 llm_call 一致):
  12. {"content": str, "usage": {"input_tokens": int, "output_tokens": int}}
  13. """
  14. import logging
  15. import os
  16. from typing import Any, Dict, List, Optional, Tuple
  17. logger = logging.getLogger(__name__)
  18. def _flatten_messages_to_string(
  19. messages: List[Dict[str, Any]],
  20. ) -> Tuple[Optional[str], str]:
  21. """
  22. 把 OpenAI 风格 messages 折叠成 (system_prompt, user_text)。
  23. - role=system 拼接为 system_prompt
  24. - role=user/assistant 的 content 全部拍平为字符串
  25. - image_url 类型块降级为 `[图片URL: ...]` 文本占位(模型看到 URL 字符串而非画面)
  26. 使用 string 模式而非 AsyncIterable[dict],是为了走 SDK 中被生产验证的稳定路径。
  27. 多模态真图传输需要切到 AsyncIterable + Anthropic content block 协议,单独迭代。
  28. """
  29. system_parts: List[str] = []
  30. user_parts: List[str] = []
  31. for msg in messages:
  32. role = msg.get("role")
  33. content = msg.get("content")
  34. if role == "system":
  35. if isinstance(content, str):
  36. system_parts.append(content)
  37. continue
  38. if isinstance(content, str):
  39. user_parts.append(content)
  40. continue
  41. if isinstance(content, list):
  42. for block in content:
  43. if not isinstance(block, dict):
  44. user_parts.append(str(block))
  45. continue
  46. btype = block.get("type")
  47. if btype == "text":
  48. user_parts.append(block.get("text", ""))
  49. elif btype == "image_url":
  50. url = (block.get("image_url") or {}).get("url", "")
  51. if url:
  52. user_parts.append(f"[图片URL: {url}]")
  53. elif btype == "image":
  54. src = block.get("source") or {}
  55. url = src.get("url") or src.get("data", "")[:60]
  56. user_parts.append(f"[图片: {url}]")
  57. system_prompt = "\n\n".join(system_parts).strip() or None
  58. user_text = "\n\n".join(p for p in user_parts if p).strip()
  59. return system_prompt, user_text
  60. def create_claude_code_oauth_llm_call(model: str = "claude-sonnet-4-5"):
  61. """
  62. 工厂:返回兼容 pipeline llm_call 契约的异步函数(基于 ClaudeSDKClient)。
  63. 返回函数签名:
  64. async (messages, model=..., temperature=..., max_tokens=...,
  65. response_schema=None, tools=None, **kwargs) -> dict
  66. 其中 temperature / max_tokens / response_schema / tools 静默忽略
  67. (SDK 不透传这些参数,CLI 用自己的默认值)。
  68. """
  69. from claude_agent_sdk import (
  70. AssistantMessage,
  71. ClaudeAgentOptions,
  72. ClaudeSDKClient,
  73. ClaudeSDKError,
  74. RateLimitEvent,
  75. ResultMessage,
  76. TextBlock,
  77. )
  78. # 从子进程 env 中剥离 API key 相关变量,让 CLI 回落到 OAuth;
  79. # 父进程 os.environ 不变(其他 LLM provider 仍可用 API key)。
  80. _stripped_env = {
  81. k: v
  82. for k, v in os.environ.items()
  83. if k not in ("ANTHROPIC_API_KEY", "ANTHROPIC_BASE_URL", "ANTHROPIC_AUTH_TOKEN")
  84. }
  85. if "ANTHROPIC_API_KEY" in os.environ or "ANTHROPIC_BASE_URL" in os.environ:
  86. logger.info(
  87. "[claude_code_oauth] Stripping ANTHROPIC_API_KEY/ANTHROPIC_BASE_URL "
  88. "from SDK subprocess env so CLI falls back to OAuth credentials."
  89. )
  90. default_model = model
  91. async def llm_call(
  92. messages: List[Dict[str, Any]],
  93. model: Optional[str] = None,
  94. tools: Optional[List[Dict]] = None,
  95. **kwargs: Any,
  96. ) -> Dict[str, Any]:
  97. actual_model = (model or default_model).split("/")[-1]
  98. system_prompt, user_text = _flatten_messages_to_string(messages)
  99. if not user_text:
  100. user_text = " "
  101. stderr_lines: List[str] = []
  102. def _capture_stderr(line: str) -> None:
  103. if line:
  104. stderr_lines.append(line)
  105. options = ClaudeAgentOptions(
  106. model=actual_model,
  107. system_prompt=system_prompt,
  108. allowed_tools=[],
  109. max_turns=1,
  110. env=_stripped_env,
  111. stderr=_capture_stderr,
  112. # 关键:屏蔽 CLI 加载用户级 ~/.claude/ 配置(output_style/skills/plugins 等)
  113. # 否则这些会被注入 system prompt,浪费 token + 影响输出格式
  114. setting_sources=[],
  115. )
  116. text_parts: List[str] = []
  117. usage: Dict[str, Any] = {}
  118. is_error = False
  119. api_error_status: Optional[int] = None
  120. result_subtype: Optional[str] = None
  121. result_errors: List[str] = []
  122. rate_limit_signal: Optional[str] = None
  123. def _emit(line: str) -> None:
  124. print(f"[claude] {line}", flush=True)
  125. try:
  126. async with ClaudeSDKClient(options=options) as client:
  127. await client.query(user_text)
  128. async for msg in client.receive_response():
  129. msg_type = type(msg).__name__
  130. if isinstance(msg, AssistantMessage):
  131. for block in msg.content:
  132. if hasattr(block, "thinking"):
  133. _emit(f"[think] {block.thinking}")
  134. elif isinstance(block, TextBlock):
  135. _emit(f"[text] {block.text}")
  136. text_parts.append(block.text)
  137. elif hasattr(block, "name") and hasattr(block, "input"):
  138. _emit(f"[tool_use] {block.name}({block.input})")
  139. else:
  140. _emit(f"[{type(block).__name__}] {block!r}")
  141. if msg.usage and not usage:
  142. usage = dict(msg.usage)
  143. elif isinstance(msg, ResultMessage):
  144. if msg.usage:
  145. usage = dict(msg.usage)
  146. _emit(
  147. f"[result] subtype={msg.subtype} "
  148. f"is_error={msg.is_error} turns={msg.num_turns} "
  149. f"duration={msg.duration_ms}ms "
  150. f"in={msg.usage.get('input_tokens', 0) if msg.usage else 0} "
  151. f"out={msg.usage.get('output_tokens', 0) if msg.usage else 0}"
  152. )
  153. if msg.is_error:
  154. is_error = True
  155. api_error_status = msg.api_error_status
  156. result_subtype = msg.subtype
  157. result_errors = list(msg.errors or [])
  158. elif isinstance(msg, RateLimitEvent):
  159. # RateLimitEvent 是 SDK 定期播报 quota 状态,不等于被限流。
  160. # 只有 rate_limit_info.status != 'allowed' 才算真限流。
  161. info = getattr(msg, "rate_limit_info", None)
  162. info_status = getattr(info, "status", None) if info else None
  163. _emit(f"[rate_limit] status={info_status!r} type={getattr(info, 'rate_limit_type', None)!r}")
  164. if info_status and info_status != "allowed":
  165. rate_limit_signal = f"status={info_status!r}"
  166. else:
  167. # SystemMessage 或其他未知类型
  168. _emit(f"[{msg_type}] {msg!r}")
  169. except ClaudeSDKError as e:
  170. stderr_tail = "\n".join(stderr_lines[-20:])
  171. raise RuntimeError(
  172. f"claude_agent_sdk error: {type(e).__name__}: {e}\n"
  173. f"--- CLI stderr (last 20 lines) ---\n{stderr_tail}"
  174. ) from e
  175. if rate_limit_signal or api_error_status == 429:
  176. raise RuntimeError(
  177. "Claude Code OAuth rate-limited (429). "
  178. "Max subscription quota may be exhausted in current 5-hour window. "
  179. "Run `claude /status` to check remaining."
  180. )
  181. if is_error:
  182. stderr_tail = "\n".join(stderr_lines[-20:])
  183. errors_str = "; ".join(result_errors) or "(empty errors[])"
  184. raise RuntimeError(
  185. f"claude_agent_sdk is_error=True "
  186. f"subtype={result_subtype!r} status={api_error_status} "
  187. f"errors={errors_str}\n"
  188. f"--- CLI stderr (last 20 lines) ---\n{stderr_tail}"
  189. )
  190. content = "".join(text_parts)
  191. normalized_usage = {
  192. "input_tokens": int(usage.get("input_tokens", 0) or 0),
  193. "output_tokens": int(usage.get("output_tokens", 0) or 0),
  194. }
  195. for k in ("cache_creation_input_tokens", "cache_read_input_tokens"):
  196. if k in usage:
  197. normalized_usage[k] = int(usage[k] or 0)
  198. return {"content": content, "usage": normalized_usage}
  199. return llm_call