yescode.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488
  1. """
  2. Yescode Provider
  3. 使用 Yescode 代理 API 调用 Claude 等模型
  4. 使用 Anthropic Messages API 格式(/v1/messages)
  5. 环境变量:
  6. - YESCODE_BASE_URL: API 基础地址(如 https://co.yes.vg)
  7. - YESCODE_API_KEY: API 密钥
  8. 注意:
  9. - Yescode 代理要求 User-Agent 包含 "claude-code"
  10. - 使用 Anthropic 原生 Messages API 格式
  11. - 响应格式转换为框架统一的 OpenAI 兼容格式
  12. """
  13. import os
  14. import json
  15. import asyncio
  16. import logging
  17. import httpx
  18. from typing import List, Dict, Any, Optional
  19. from .usage import TokenUsage
  20. from .pricing import calculate_cost
  21. logger = logging.getLogger(__name__)
  22. # 可重试的异常类型
  23. _RETRYABLE_EXCEPTIONS = (
  24. httpx.RemoteProtocolError,
  25. httpx.ConnectError,
  26. httpx.ReadTimeout,
  27. httpx.WriteTimeout,
  28. httpx.ConnectTimeout,
  29. httpx.PoolTimeout,
  30. ConnectionError,
  31. )
  32. # 模糊匹配规则:(关键词, 目标模型名),从精确到宽泛排序
  33. # 精确匹配走 MODEL_EXACT,不命中则按顺序尝试关键词匹配
  34. MODEL_EXACT = {
  35. "claude-sonnet-4-6": "claude-sonnet-4-6",
  36. "claude-sonnet-4.6": "claude-sonnet-4-6",
  37. "claude-sonnet-4-5-20250929": "claude-sonnet-4-5-20250929",
  38. "claude-sonnet-4-5": "claude-sonnet-4-5-20250929",
  39. "claude-sonnet-4.5": "claude-sonnet-4-5-20250929",
  40. "claude-opus-4-6": "claude-opus-4-6",
  41. "claude-opus-4-5-20251101": "claude-opus-4-5-20251101",
  42. "claude-opus-4-5": "claude-opus-4-5-20251101",
  43. "claude-opus-4-1-20250805": "claude-opus-4-1-20250805",
  44. "claude-opus-4-1": "claude-opus-4-1-20250805",
  45. "claude-haiku-4-5-20251001": "claude-haiku-4-5-20251001",
  46. "claude-haiku-4-5": "claude-haiku-4-5-20251001",
  47. }
  48. MODEL_FUZZY = [
  49. # 版本+家族(精确)
  50. ("sonnet-4-6", "claude-sonnet-4-6"),
  51. ("sonnet-4.6", "claude-sonnet-4-6"),
  52. ("sonnet-4-5", "claude-sonnet-4-5-20250929"),
  53. ("sonnet-4.5", "claude-sonnet-4-5-20250929"),
  54. ("opus-4-6", "claude-opus-4-6"),
  55. ("opus-4.6", "claude-opus-4-6"),
  56. ("opus-4-5", "claude-opus-4-5-20251101"),
  57. ("opus-4.5", "claude-opus-4-5-20251101"),
  58. ("opus-4-1", "claude-opus-4-1-20250805"),
  59. ("opus-4.1", "claude-opus-4-1-20250805"),
  60. ("haiku-4-5", "claude-haiku-4-5-20251001"),
  61. ("haiku-4.5", "claude-haiku-4-5-20251001"),
  62. # 仅家族名 → 最新版本
  63. ("sonnet", "claude-sonnet-4-6"),
  64. ("opus", "claude-opus-4-6"),
  65. ("haiku", "claude-haiku-4-5-20251001"),
  66. ]
  67. def _resolve_model(model: str) -> str:
  68. """将任意格式的模型名映射为 Yescode API 接受的模型名。
  69. 支持:OpenRouter 前缀(anthropic/xxx)、带点号(4.5)、纯家族名(sonnet)等。
  70. """
  71. # 1. 剥离 provider 前缀
  72. if "/" in model:
  73. model = model.split("/", 1)[1]
  74. # 2. 精确匹配
  75. if model in MODEL_EXACT:
  76. return MODEL_EXACT[model]
  77. # 3. 模糊匹配(大小写不敏感)
  78. model_lower = model.lower()
  79. for keyword, target in MODEL_FUZZY:
  80. if keyword in model_lower:
  81. logger.info("模型名模糊匹配: %s → %s", model, target)
  82. return target
  83. # 4. 兜底:原样返回,让 API 报错
  84. logger.warning("未能匹配模型名: %s, 原样传递", model)
  85. return model
  86. def _normalize_tool_call_ids(messages: List[Dict[str, Any]], target_prefix: str) -> List[Dict[str, Any]]:
  87. """
  88. 将消息历史中的 tool_call_id 统一重写为目标 Provider 的格式。
  89. 跨 Provider 续跑时,历史中的 tool_call_id 可能不兼容目标 API
  90. (如 Anthropic 的 toolu_xxx 发给 OpenAI,或 OpenAI 的 call_xxx 发给 Anthropic)。
  91. 仅在检测到异格式 ID 时才重写,同格式直接跳过。
  92. """
  93. # 第一遍:收集需要重写的 ID
  94. id_map: Dict[str, str] = {}
  95. counter = 0
  96. for msg in messages:
  97. if msg.get("role") == "assistant" and msg.get("tool_calls"):
  98. for tc in msg["tool_calls"]:
  99. old_id = tc.get("id", "")
  100. if old_id and not old_id.startswith(target_prefix + "_"):
  101. if old_id not in id_map:
  102. id_map[old_id] = f"{target_prefix}_{counter:06x}"
  103. counter += 1
  104. if not id_map:
  105. return messages # 无需重写
  106. logger.info("重写 %d 个 tool_call_id (target_prefix=%s)", len(id_map), target_prefix)
  107. # 第二遍:重写(浅拷贝避免修改原始数据)
  108. result = []
  109. for msg in messages:
  110. if msg.get("role") == "assistant" and msg.get("tool_calls"):
  111. new_tcs = []
  112. for tc in msg["tool_calls"]:
  113. old_id = tc.get("id", "")
  114. if old_id in id_map:
  115. new_tcs.append({**tc, "id": id_map[old_id]})
  116. else:
  117. new_tcs.append(tc)
  118. result.append({**msg, "tool_calls": new_tcs})
  119. elif msg.get("role") == "tool" and msg.get("tool_call_id") in id_map:
  120. result.append({**msg, "tool_call_id": id_map[msg["tool_call_id"]]})
  121. else:
  122. result.append(msg)
  123. return result
  124. def _convert_content_to_anthropic(content: Any) -> Any:
  125. """
  126. 将 OpenAI 格式的 content(字符串或列表)转换为 Anthropic 格式。
  127. 主要处理 image_url 类型块 → Anthropic image 块。
  128. """
  129. if not isinstance(content, list):
  130. return content
  131. result = []
  132. for block in content:
  133. if not isinstance(block, dict):
  134. result.append(block)
  135. continue
  136. block_type = block.get("type", "")
  137. if block_type == "image_url":
  138. image_url_obj = block.get("image_url", {})
  139. url = image_url_obj.get("url", "") if isinstance(image_url_obj, dict) else str(image_url_obj)
  140. if url.startswith("data:"):
  141. # base64 编码图片:data:<media_type>;base64,<data>
  142. header, _, data = url.partition(",")
  143. media_type = header.split(":")[1].split(";")[0] if ":" in header else "image/png"
  144. result.append({
  145. "type": "image",
  146. "source": {
  147. "type": "base64",
  148. "media_type": media_type,
  149. "data": data,
  150. },
  151. })
  152. else:
  153. result.append({
  154. "type": "image",
  155. "source": {
  156. "type": "url",
  157. "url": url,
  158. },
  159. })
  160. else:
  161. result.append(block)
  162. return result
  163. def _convert_messages_to_anthropic(messages: List[Dict[str, Any]]) -> tuple:
  164. """
  165. 将 OpenAI 格式消息转换为 Anthropic Messages API 格式
  166. Returns:
  167. (system_prompt, anthropic_messages)
  168. """
  169. system_prompt = None
  170. anthropic_messages = []
  171. for msg in messages:
  172. role = msg.get("role", "")
  173. content = msg.get("content", "")
  174. if role == "system":
  175. # Anthropic 把 system 消息放在顶层参数中
  176. system_prompt = content
  177. elif role == "user":
  178. anthropic_messages.append({"role": "user", "content": _convert_content_to_anthropic(content)})
  179. elif role == "assistant":
  180. assistant_msg = {"role": "assistant"}
  181. # 处理 tool_calls(assistant 发起工具调用)
  182. tool_calls = msg.get("tool_calls")
  183. if tool_calls:
  184. content_blocks = []
  185. if content:
  186. # content 可能已被 _add_cache_control 转成 list(含 cache_control),
  187. # 也可能是普通字符串。两者都需要正确处理,避免产生 {"type":"text","text":[...]}
  188. converted = _convert_content_to_anthropic(content)
  189. if isinstance(converted, list):
  190. content_blocks.extend(converted)
  191. elif isinstance(converted, str) and converted.strip():
  192. content_blocks.append({"type": "text", "text": converted})
  193. for tc in tool_calls:
  194. func = tc.get("function", {})
  195. args_str = func.get("arguments", "{}")
  196. try:
  197. args = json.loads(args_str) if isinstance(args_str, str) else args_str
  198. except json.JSONDecodeError:
  199. args = {}
  200. content_blocks.append({
  201. "type": "tool_use",
  202. "id": tc.get("id", ""),
  203. "name": func.get("name", ""),
  204. "input": args,
  205. })
  206. assistant_msg["content"] = content_blocks
  207. else:
  208. assistant_msg["content"] = content
  209. anthropic_messages.append(assistant_msg)
  210. elif role == "tool":
  211. # OpenAI tool 结果 -> Anthropic tool_result
  212. # Anthropic 要求同一个 assistant 的所有 tool_results 合并到一个 user message 中
  213. tool_result_block = {
  214. "type": "tool_result",
  215. "tool_use_id": msg.get("tool_call_id", ""),
  216. "content": _convert_content_to_anthropic(content),
  217. }
  218. # 如果上一条已经是 tool_result user message,合并进去
  219. if (anthropic_messages
  220. and anthropic_messages[-1].get("role") == "user"
  221. and isinstance(anthropic_messages[-1].get("content"), list)
  222. and anthropic_messages[-1]["content"]
  223. and anthropic_messages[-1]["content"][0].get("type") == "tool_result"):
  224. anthropic_messages[-1]["content"].append(tool_result_block)
  225. else:
  226. anthropic_messages.append({
  227. "role": "user",
  228. "content": [tool_result_block],
  229. })
  230. return system_prompt, anthropic_messages
  231. def _convert_tools_to_anthropic(tools: List[Dict]) -> List[Dict]:
  232. """将 OpenAI 工具定义转换为 Anthropic 格式"""
  233. anthropic_tools = []
  234. for tool in tools:
  235. if tool.get("type") == "function":
  236. func = tool["function"]
  237. anthropic_tools.append({
  238. "name": func.get("name", ""),
  239. "description": func.get("description", ""),
  240. "input_schema": func.get("parameters", {"type": "object", "properties": {}}),
  241. })
  242. return anthropic_tools
  243. def _parse_anthropic_response(result: Dict[str, Any]) -> Dict[str, Any]:
  244. """
  245. 将 Anthropic Messages API 响应转换为框架统一格式
  246. Anthropic 响应格式:
  247. {
  248. "id": "msg_...",
  249. "type": "message",
  250. "role": "assistant",
  251. "content": [{"type": "text", "text": "..."}, {"type": "tool_use", ...}],
  252. "usage": {"input_tokens": ..., "output_tokens": ...},
  253. "stop_reason": "end_turn" | "tool_use" | "max_tokens"
  254. }
  255. """
  256. content_blocks = result.get("content", [])
  257. # 提取文本内容
  258. text_parts = []
  259. tool_calls = []
  260. for block in content_blocks:
  261. if block.get("type") == "text":
  262. text_parts.append(block.get("text", ""))
  263. elif block.get("type") == "tool_use":
  264. # 转换为 OpenAI tool_calls 格式
  265. tool_calls.append({
  266. "id": block.get("id", ""),
  267. "type": "function",
  268. "function": {
  269. "name": block.get("name", ""),
  270. "arguments": json.dumps(block.get("input", {}), ensure_ascii=False),
  271. },
  272. })
  273. content = "\n".join(text_parts)
  274. # 映射 stop_reason
  275. stop_reason = result.get("stop_reason", "end_turn")
  276. finish_reason_map = {
  277. "end_turn": "stop",
  278. "tool_use": "tool_calls",
  279. "max_tokens": "length",
  280. "stop_sequence": "stop",
  281. }
  282. finish_reason = finish_reason_map.get(stop_reason, stop_reason)
  283. # 提取 usage(Anthropic 原生格式)
  284. raw_usage = result.get("usage", {})
  285. usage = TokenUsage(
  286. input_tokens=raw_usage.get("input_tokens", 0),
  287. output_tokens=raw_usage.get("output_tokens", 0),
  288. cache_creation_tokens=raw_usage.get("cache_creation_input_tokens", 0),
  289. cache_read_tokens=raw_usage.get("cache_read_input_tokens", 0),
  290. )
  291. return {
  292. "content": content,
  293. "tool_calls": tool_calls if tool_calls else None,
  294. "finish_reason": finish_reason,
  295. "usage": usage,
  296. }
  297. async def yescode_llm_call(
  298. messages: List[Dict[str, Any]],
  299. model: str = "claude-sonnet-4.5",
  300. tools: Optional[List[Dict]] = None,
  301. **kwargs
  302. ) -> Dict[str, Any]:
  303. """
  304. Yescode LLM 调用函数
  305. Args:
  306. messages: OpenAI 格式消息列表
  307. model: 模型名称(如 "claude-sonnet-4.5")
  308. tools: OpenAI 格式工具定义
  309. **kwargs: 其他参数(temperature, max_tokens 等)
  310. Returns:
  311. 统一格式的响应字典
  312. """
  313. base_url = os.getenv("YESCODE_BASE_URL")
  314. api_key = os.getenv("YESCODE_API_KEY")
  315. if not base_url:
  316. raise ValueError("YESCODE_BASE_URL environment variable not set")
  317. if not api_key:
  318. raise ValueError("YESCODE_API_KEY environment variable not set")
  319. base_url = base_url.rstrip("/")
  320. endpoint = f"{base_url}/v1/messages"
  321. # 解析模型名
  322. api_model = _resolve_model(model)
  323. # 跨 Provider 续跑时,重写不兼容的 tool_call_id
  324. messages = _normalize_tool_call_ids(messages, "toolu")
  325. # 转换消息格式
  326. system_prompt, anthropic_messages = _convert_messages_to_anthropic(messages)
  327. # 构建 Anthropic 格式请求
  328. payload = {
  329. "model": api_model,
  330. "messages": anthropic_messages,
  331. "max_tokens": kwargs.get("max_tokens", 16384),
  332. }
  333. if system_prompt:
  334. payload["system"] = system_prompt
  335. if tools:
  336. payload["tools"] = _convert_tools_to_anthropic(tools)
  337. if "temperature" in kwargs:
  338. payload["temperature"] = kwargs["temperature"]
  339. headers = {
  340. "x-api-key": api_key,
  341. "content-type": "application/json",
  342. "anthropic-version": "2023-06-01",
  343. "user-agent": "claude-code/1.0.0",
  344. }
  345. # 调用 API(带重试)
  346. max_retries = 5
  347. last_exception = None
  348. for attempt in range(max_retries):
  349. async with httpx.AsyncClient(timeout=300.0) as client:
  350. try:
  351. response = await client.post(endpoint, json=payload, headers=headers)
  352. response.raise_for_status()
  353. result = response.json()
  354. break
  355. except httpx.HTTPStatusError as e:
  356. error_body = e.response.text
  357. status = e.response.status_code
  358. if status in (429, 500, 502, 503, 504, 524, 529) and attempt < max_retries - 1:
  359. wait = 2 ** attempt * 2
  360. logger.warning(
  361. "[Yescode] HTTP %d (attempt %d/%d), retrying in %ds: %s",
  362. status, attempt + 1, max_retries, wait, error_body[:200],
  363. )
  364. await asyncio.sleep(wait)
  365. last_exception = e
  366. continue
  367. logger.error("[Yescode] Error %d: %s", status, error_body)
  368. print(f"[Yescode] API Error {status}: {error_body[:500]}")
  369. raise
  370. except _RETRYABLE_EXCEPTIONS as e:
  371. last_exception = e
  372. if attempt < max_retries - 1:
  373. wait = 2 ** attempt * 2
  374. logger.warning(
  375. "[Yescode] %s (attempt %d/%d), retrying in %ds",
  376. type(e).__name__, attempt + 1, max_retries, wait,
  377. )
  378. await asyncio.sleep(wait)
  379. continue
  380. logger.error("[Yescode] Request failed after %d attempts: %s", max_retries, e)
  381. raise
  382. except Exception as e:
  383. logger.error("[Yescode] Request failed: %s", e)
  384. raise
  385. else:
  386. raise last_exception # type: ignore[misc]
  387. # 解析 Anthropic 响应并转换为统一格式
  388. parsed = _parse_anthropic_response(result)
  389. usage = parsed["usage"]
  390. # 计算费用
  391. cost = calculate_cost(model, usage)
  392. return {
  393. "content": parsed["content"],
  394. "tool_calls": parsed["tool_calls"],
  395. "prompt_tokens": usage.input_tokens,
  396. "completion_tokens": usage.output_tokens,
  397. "reasoning_tokens": usage.reasoning_tokens,
  398. "cache_creation_tokens": usage.cache_creation_tokens,
  399. "cache_read_tokens": usage.cache_read_tokens,
  400. "finish_reason": parsed["finish_reason"],
  401. "cost": cost,
  402. "usage": usage,
  403. }
  404. def create_yescode_llm_call(
  405. model: str = "claude-sonnet-4.5"
  406. ):
  407. """
  408. 创建 Yescode LLM 调用函数
  409. Args:
  410. model: 模型名称
  411. - "claude-sonnet-4.5"
  412. Returns:
  413. 异步 LLM 调用函数
  414. """
  415. async def llm_call(
  416. messages: List[Dict[str, Any]],
  417. model: str = model,
  418. tools: Optional[List[Dict]] = None,
  419. **kwargs
  420. ) -> Dict[str, Any]:
  421. return await yescode_llm_call(messages, model, tools, **kwargs)
  422. return llm_call