anthropic_protocol.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416
  1. """Provider-neutral helpers for Anthropic Messages API adapters."""
  2. from __future__ import annotations
  3. import base64
  4. import json
  5. import logging
  6. import mimetypes
  7. import struct
  8. from pathlib import Path
  9. from typing import Any, Dict, List, Optional, Tuple
  10. import httpx
  11. from .pricing import calculate_cost
  12. from .usage import TokenUsage
  13. ANTHROPIC_RETRYABLE_EXCEPTIONS = (
  14. httpx.RemoteProtocolError,
  15. httpx.ConnectError,
  16. httpx.ReadTimeout,
  17. httpx.WriteTimeout,
  18. httpx.ConnectTimeout,
  19. httpx.PoolTimeout,
  20. ConnectionError,
  21. )
  22. ANTHROPIC_MODEL_EXACT = {
  23. "claude-sonnet-4-6": "claude-sonnet-4-6",
  24. "claude-sonnet-4.6": "claude-sonnet-4-6",
  25. "claude-sonnet-4-5-20250929": "claude-sonnet-4-5-20250929",
  26. "claude-sonnet-4-5": "claude-sonnet-4-5-20250929",
  27. "claude-sonnet-4.5": "claude-sonnet-4-5-20250929",
  28. "claude-opus-4-6": "claude-opus-4-6",
  29. "claude-opus-4-5-20251101": "claude-opus-4-5-20251101",
  30. "claude-opus-4-5": "claude-opus-4-5-20251101",
  31. "claude-opus-4-1-20250805": "claude-opus-4-1-20250805",
  32. "claude-opus-4-1": "claude-opus-4-1-20250805",
  33. "claude-haiku-4-5-20251001": "claude-haiku-4-5-20251001",
  34. "claude-haiku-4-5": "claude-haiku-4-5-20251001",
  35. }
  36. ANTHROPIC_MODEL_FUZZY: List[Tuple[str, str]] = [
  37. ("sonnet-4-6", "claude-sonnet-4-6"),
  38. ("sonnet-4.6", "claude-sonnet-4-6"),
  39. ("sonnet-4-5", "claude-sonnet-4-5-20250929"),
  40. ("sonnet-4.5", "claude-sonnet-4-5-20250929"),
  41. ("opus-4-6", "claude-opus-4-6"),
  42. ("opus-4.6", "claude-opus-4-6"),
  43. ("opus-4-5", "claude-opus-4-5-20251101"),
  44. ("opus-4.5", "claude-opus-4-5-20251101"),
  45. ("opus-4-1", "claude-opus-4-1-20250805"),
  46. ("opus-4.1", "claude-opus-4-1-20250805"),
  47. ("haiku-4-5", "claude-haiku-4-5-20251001"),
  48. ("haiku-4.5", "claude-haiku-4-5-20251001"),
  49. ("sonnet", "claude-sonnet-4-6"),
  50. ("opus", "claude-opus-4-6"),
  51. ("haiku", "claude-haiku-4-5-20251001"),
  52. ]
  53. def resolve_anthropic_model(
  54. model: str,
  55. *,
  56. provider_prefix: str = "",
  57. preserve_unknown_prefix: bool = False,
  58. logger: Optional[logging.Logger] = None,
  59. ) -> str:
  60. """Resolve framework aliases while preserving each Provider's fallback."""
  61. original = model
  62. bare = model.split("/", 1)[1] if "/" in model else model
  63. target = ANTHROPIC_MODEL_EXACT.get(bare)
  64. if target is None:
  65. bare_lower = bare.lower()
  66. target = next(
  67. (
  68. candidate
  69. for keyword, candidate in ANTHROPIC_MODEL_FUZZY
  70. if keyword in bare_lower
  71. ),
  72. None,
  73. )
  74. if target is not None:
  75. resolved = f"{provider_prefix}{target}"
  76. if logger and bare != target:
  77. logger.info("Anthropic model alias resolved: %s -> %s", model, resolved)
  78. return resolved
  79. fallback = original if preserve_unknown_prefix else bare
  80. if logger:
  81. logger.warning("Could not resolve Anthropic model alias: %s", model)
  82. return fallback
  83. def normalize_tool_call_ids(
  84. messages: List[Dict[str, Any]], target_prefix: str
  85. ) -> List[Dict[str, Any]]:
  86. """Rewrite cross-Provider tool call IDs without mutating input messages."""
  87. id_map: Dict[str, str] = {}
  88. for message in messages:
  89. if message.get("role") != "assistant":
  90. continue
  91. for tool_call in message.get("tool_calls") or ():
  92. old_id = tool_call.get("id", "")
  93. if old_id and not old_id.startswith(f"{target_prefix}_"):
  94. id_map.setdefault(old_id, f"{target_prefix}_{len(id_map):06x}")
  95. if not id_map:
  96. return messages
  97. normalized = []
  98. for message in messages:
  99. if message.get("role") == "assistant" and message.get("tool_calls"):
  100. tool_calls = [
  101. {**tool_call, "id": id_map[tool_call["id"]]}
  102. if tool_call.get("id") in id_map
  103. else tool_call
  104. for tool_call in message["tool_calls"]
  105. ]
  106. normalized.append({**message, "tool_calls": tool_calls})
  107. elif message.get("role") == "tool" and message.get("tool_call_id") in id_map:
  108. normalized.append(
  109. {**message, "tool_call_id": id_map[message["tool_call_id"]]}
  110. )
  111. else:
  112. normalized.append(message)
  113. return normalized
  114. def _image_dimensions(data: bytes) -> Optional[Tuple[int, int]]:
  115. """Read PNG/JPEG dimensions from headers without a Pillow dependency."""
  116. try:
  117. if data[:8] == b"\x89PNG\r\n\x1a\n" and len(data) >= 24:
  118. return struct.unpack(">II", data[16:24])
  119. if data[:2] == b"\xff\xd8":
  120. offset = 2
  121. while offset < len(data) - 9:
  122. if data[offset] != 0xFF:
  123. break
  124. marker = data[offset + 1]
  125. if marker in (0xC0, 0xC2):
  126. height, width = struct.unpack(">HH", data[offset + 5 : offset + 9])
  127. return width, height
  128. length = struct.unpack(">H", data[offset + 2 : offset + 4])[0]
  129. offset += 2 + length
  130. except (IndexError, struct.error):
  131. return None
  132. return None
  133. def to_anthropic_content(
  134. content: Any,
  135. *,
  136. resolve_local_files: bool = True,
  137. include_image_metadata: bool = True,
  138. logger: Optional[logging.Logger] = None,
  139. ) -> Any:
  140. """Convert OpenAI content blocks to Anthropic content blocks."""
  141. if not isinstance(content, list):
  142. return content
  143. converted = []
  144. for block in content:
  145. if not isinstance(block, dict) or block.get("type") != "image_url":
  146. converted.append(block)
  147. continue
  148. image_url = block.get("image_url", {})
  149. url = image_url.get("url", "") if isinstance(image_url, dict) else str(image_url)
  150. image_data: Optional[bytes] = None
  151. if url.startswith("data:"):
  152. header, _, encoded = url.partition(",")
  153. media_type = (
  154. header.split(":", 1)[1].split(";", 1)[0]
  155. if ":" in header
  156. else "image/png"
  157. )
  158. source = {"type": "base64", "media_type": media_type, "data": encoded}
  159. if include_image_metadata:
  160. image_data = base64.b64decode(encoded)
  161. elif resolve_local_files and Path(url).is_file():
  162. path = Path(url)
  163. image_data = path.read_bytes()
  164. media_type = mimetypes.guess_type(str(path))[0] or "image/png"
  165. source = {
  166. "type": "base64",
  167. "media_type": media_type,
  168. "data": base64.b64encode(image_data).decode("ascii"),
  169. }
  170. if logger:
  171. logger.info("Converted local image to base64: %s (%d bytes)", url, len(image_data))
  172. else:
  173. source = {"type": "url", "url": url}
  174. image_block: Dict[str, Any] = {"type": "image", "source": source}
  175. if include_image_metadata and image_data:
  176. dimensions = _image_dimensions(image_data)
  177. if dimensions:
  178. image_block["_image_meta"] = {
  179. "width": dimensions[0],
  180. "height": dimensions[1],
  181. }
  182. converted.append(image_block)
  183. return converted
  184. def to_anthropic_messages(
  185. messages: List[Dict[str, Any]],
  186. *,
  187. resolve_local_files: bool = True,
  188. include_image_metadata: bool = True,
  189. split_tool_result_images: bool = True,
  190. logger: Optional[logging.Logger] = None,
  191. ) -> Tuple[Any, List[Dict[str, Any]]]:
  192. """Convert OpenAI message history to the Anthropic Messages format."""
  193. def convert(content: Any) -> Any:
  194. return to_anthropic_content(
  195. content,
  196. resolve_local_files=resolve_local_files,
  197. include_image_metadata=include_image_metadata,
  198. logger=logger,
  199. )
  200. system_prompt = None
  201. anthropic_messages: List[Dict[str, Any]] = []
  202. for message in messages:
  203. role = message.get("role", "")
  204. content = message.get("content", "")
  205. if role == "system":
  206. system_prompt = content
  207. elif role == "user":
  208. anthropic_messages.append({"role": "user", "content": convert(content)})
  209. elif role == "assistant":
  210. tool_calls = message.get("tool_calls")
  211. if not tool_calls:
  212. anthropic_messages.append({"role": "assistant", "content": content})
  213. continue
  214. content_blocks: List[Dict[str, Any]] = []
  215. if content:
  216. assistant_content = convert(content)
  217. if isinstance(assistant_content, list):
  218. content_blocks.extend(assistant_content)
  219. elif isinstance(assistant_content, str) and assistant_content.strip():
  220. content_blocks.append({"type": "text", "text": assistant_content})
  221. for tool_call in tool_calls:
  222. function = tool_call.get("function", {})
  223. arguments = function.get("arguments", "{}")
  224. try:
  225. parsed_arguments = (
  226. json.loads(arguments) if isinstance(arguments, str) else arguments
  227. )
  228. except json.JSONDecodeError:
  229. parsed_arguments = {}
  230. content_blocks.append(
  231. {
  232. "type": "tool_use",
  233. "id": tool_call.get("id", ""),
  234. "name": function.get("name", ""),
  235. "input": parsed_arguments,
  236. }
  237. )
  238. anthropic_messages.append(
  239. {"role": "assistant", "content": content_blocks}
  240. )
  241. elif role == "tool":
  242. tool_content = convert(content)
  243. if not split_tool_result_images:
  244. blocks = [
  245. {
  246. "type": "tool_result",
  247. "tool_use_id": message.get("tool_call_id", ""),
  248. "content": tool_content,
  249. }
  250. ]
  251. else:
  252. text_parts: List[Dict[str, Any]] = []
  253. image_parts: List[Dict[str, Any]] = []
  254. if isinstance(tool_content, list):
  255. for block in tool_content:
  256. if isinstance(block, dict) and block.get("type") == "image":
  257. image_parts.append(block)
  258. else:
  259. text_parts.append(block)
  260. elif isinstance(tool_content, str) and tool_content:
  261. text_parts.append({"type": "text", "text": tool_content})
  262. tool_result: Dict[str, Any] = {
  263. "type": "tool_result",
  264. "tool_use_id": message.get("tool_call_id", ""),
  265. }
  266. if len(text_parts) == 1 and text_parts[0].get("type") == "text":
  267. tool_result["content"] = text_parts[0]["text"]
  268. elif text_parts:
  269. tool_result["content"] = text_parts
  270. blocks = [tool_result, *image_parts]
  271. if (
  272. anthropic_messages
  273. and anthropic_messages[-1].get("role") == "user"
  274. and isinstance(anthropic_messages[-1].get("content"), list)
  275. and anthropic_messages[-1]["content"]
  276. and anthropic_messages[-1]["content"][0].get("type") == "tool_result"
  277. ):
  278. anthropic_messages[-1]["content"].extend(blocks)
  279. else:
  280. anthropic_messages.append({"role": "user", "content": blocks})
  281. return system_prompt, anthropic_messages
  282. def to_anthropic_tools(tools: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
  283. """Convert OpenAI function definitions to Anthropic tool definitions."""
  284. return [
  285. {
  286. "name": function.get("name", ""),
  287. "description": function.get("description", ""),
  288. "input_schema": function.get(
  289. "parameters", {"type": "object", "properties": {}}
  290. ),
  291. }
  292. for tool in tools
  293. if tool.get("type") == "function"
  294. for function in (tool["function"],)
  295. ]
  296. def parse_anthropic_response(result: Dict[str, Any]) -> Dict[str, Any]:
  297. """Convert an Anthropic response to the framework's unified shape."""
  298. text_parts = []
  299. tool_calls = []
  300. for block in result.get("content", []):
  301. if block.get("type") == "text":
  302. text_parts.append(block.get("text", ""))
  303. elif block.get("type") == "tool_use":
  304. tool_calls.append(
  305. {
  306. "id": block.get("id", ""),
  307. "type": "function",
  308. "function": {
  309. "name": block.get("name", ""),
  310. "arguments": json.dumps(
  311. block.get("input", {}), ensure_ascii=False
  312. ),
  313. },
  314. }
  315. )
  316. stop_reason = result.get("stop_reason", "end_turn")
  317. finish_reason = {
  318. "end_turn": "stop",
  319. "tool_use": "tool_calls",
  320. "max_tokens": "length",
  321. "stop_sequence": "stop",
  322. }.get(stop_reason, stop_reason)
  323. raw_usage = result.get("usage", {})
  324. return {
  325. "content": "\n".join(text_parts),
  326. "tool_calls": tool_calls or None,
  327. "finish_reason": finish_reason,
  328. "usage": TokenUsage(
  329. input_tokens=raw_usage.get("input_tokens", 0),
  330. output_tokens=raw_usage.get("output_tokens", 0),
  331. cache_creation_tokens=raw_usage.get("cache_creation_input_tokens", 0),
  332. cache_read_tokens=raw_usage.get("cache_read_input_tokens", 0),
  333. ),
  334. }
  335. def count_cache_controls(system_prompt: Any, messages: List[Dict[str, Any]]) -> int:
  336. """Count cache-control blocks for optional Provider diagnostics."""
  337. blocks = list(system_prompt) if isinstance(system_prompt, list) else []
  338. for message in messages:
  339. if isinstance(message.get("content"), list):
  340. blocks.extend(message["content"])
  341. return sum(
  342. 1 for block in blocks if isinstance(block, dict) and "cache_control" in block
  343. )
  344. def build_anthropic_result(parsed: Dict[str, Any], model: str) -> Dict[str, Any]:
  345. """Attach cost and token compatibility fields to a parsed response."""
  346. usage = parsed["usage"]
  347. return {
  348. "content": parsed["content"],
  349. "tool_calls": parsed["tool_calls"],
  350. "prompt_tokens": usage.input_tokens,
  351. "completion_tokens": usage.output_tokens,
  352. "reasoning_tokens": usage.reasoning_tokens,
  353. "cache_creation_tokens": usage.cache_creation_tokens,
  354. "cache_read_tokens": usage.cache_read_tokens,
  355. "finish_reason": parsed["finish_reason"],
  356. "cost": calculate_cost(model, usage),
  357. "usage": usage,
  358. }
  359. __all__ = [
  360. "ANTHROPIC_MODEL_EXACT",
  361. "ANTHROPIC_MODEL_FUZZY",
  362. "ANTHROPIC_RETRYABLE_EXCEPTIONS",
  363. "build_anthropic_result",
  364. "count_cache_controls",
  365. "normalize_tool_call_ids",
  366. "parse_anthropic_response",
  367. "resolve_anthropic_model",
  368. "to_anthropic_content",
  369. "to_anthropic_messages",
  370. "to_anthropic_tools",
  371. ]