coco-framework 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.
Files changed (400) hide show
  1. coco/__init__.py +3 -0
  2. coco/agents/__init__.py +9 -0
  3. coco/agents/_subagent_transformer.py +307 -0
  4. coco/agents/factory.py +2164 -0
  5. coco/agents/middleware/__init__.py +100 -0
  6. coco/agents/middleware/_execution.py +385 -0
  7. coco/agents/middleware/_redaction.py +454 -0
  8. coco/agents/middleware/_retry.py +134 -0
  9. coco/agents/middleware/_trace_policy.py +61 -0
  10. coco/agents/middleware/context_editing.py +306 -0
  11. coco/agents/middleware/file_search.py +433 -0
  12. coco/agents/middleware/human_in_the_loop.py +680 -0
  13. coco/agents/middleware/internal_call_transformer.py +108 -0
  14. coco/agents/middleware/model_call_limit.py +267 -0
  15. coco/agents/middleware/model_fallback.py +417 -0
  16. coco/agents/middleware/model_retry.py +327 -0
  17. coco/agents/middleware/pii.py +878 -0
  18. coco/agents/middleware/provider_tool_search.py +307 -0
  19. coco/agents/middleware/shell_tool.py +949 -0
  20. coco/agents/middleware/summarization.py +910 -0
  21. coco/agents/middleware/todo.py +357 -0
  22. coco/agents/middleware/tool_call_limit.py +495 -0
  23. coco/agents/middleware/tool_emulator.py +251 -0
  24. coco/agents/middleware/tool_error.py +209 -0
  25. coco/agents/middleware/tool_retry.py +421 -0
  26. coco/agents/middleware/tool_selection.py +515 -0
  27. coco/agents/middleware/types.py +2201 -0
  28. coco/agents/structured_output.py +472 -0
  29. coco/chat_models/__init__.py +7 -0
  30. coco/chat_models/base.py +1082 -0
  31. coco/embeddings/__init__.py +18 -0
  32. coco/embeddings/base.py +279 -0
  33. coco/mcp/__init__.py +36 -0
  34. coco/mcp/adapter.py +245 -0
  35. coco/mcp/elicitation.py +394 -0
  36. coco/mcp/tools.py +307 -0
  37. coco/messages/__init__.py +73 -0
  38. coco/py.typed +0 -0
  39. coco/rate_limiters/__init__.py +13 -0
  40. coco/tools/__init__.py +22 -0
  41. coco/tools/tool_node.py +20 -0
  42. coco_anthropic/__init__.py +15 -0
  43. coco_anthropic/_client_utils.py +81 -0
  44. coco_anthropic/_compat.py +228 -0
  45. coco_anthropic/_sdk_compat.py +124 -0
  46. coco_anthropic/_version.py +3 -0
  47. coco_anthropic/chat_models.py +3349 -0
  48. coco_anthropic/data/__init__.py +1 -0
  49. coco_anthropic/data/_profiles.py +491 -0
  50. coco_anthropic/data/profile_augmentations.toml +69 -0
  51. coco_anthropic/experimental.py +140 -0
  52. coco_anthropic/llms.py +428 -0
  53. coco_anthropic/middleware/__init__.py +25 -0
  54. coco_anthropic/middleware/anthropic_tools.py +1197 -0
  55. coco_anthropic/middleware/bash.py +84 -0
  56. coco_anthropic/middleware/file_search.py +360 -0
  57. coco_anthropic/middleware/prompt_caching.py +276 -0
  58. coco_anthropic/output_parsers.py +93 -0
  59. coco_anthropic/py.typed +0 -0
  60. coco_core/__init__.py +20 -0
  61. coco_core/_api/__init__.py +87 -0
  62. coco_core/_api/beta_decorator.py +266 -0
  63. coco_core/_api/deprecation.py +637 -0
  64. coco_core/_api/internal.py +23 -0
  65. coco_core/_api/path.py +50 -0
  66. coco_core/_import_utils.py +41 -0
  67. coco_core/_protocol.py +625 -0
  68. coco_core/_security/__init__.py +36 -0
  69. coco_core/_security/_exceptions.py +9 -0
  70. coco_core/_security/_policy.py +313 -0
  71. coco_core/_security/_ssrf_protection.py +155 -0
  72. coco_core/_security/_transport.py +254 -0
  73. coco_core/agents.py +258 -0
  74. coco_core/caches.py +272 -0
  75. coco_core/callbacks/__init__.py +132 -0
  76. coco_core/callbacks/base.py +1229 -0
  77. coco_core/callbacks/file.py +266 -0
  78. coco_core/callbacks/manager.py +2826 -0
  79. coco_core/callbacks/stdout.py +123 -0
  80. coco_core/callbacks/streaming_stdout.py +154 -0
  81. coco_core/callbacks/usage.py +124 -0
  82. coco_core/chat_history.py +267 -0
  83. coco_core/chat_loaders.py +26 -0
  84. coco_core/chat_sessions.py +19 -0
  85. coco_core/cross_encoders.py +18 -0
  86. coco_core/document_loaders/__init__.py +36 -0
  87. coco_core/document_loaders/base.py +155 -0
  88. coco_core/document_loaders/blob_loaders.py +38 -0
  89. coco_core/documents/__init__.py +55 -0
  90. coco_core/documents/base.py +347 -0
  91. coco_core/documents/compressor.py +74 -0
  92. coco_core/documents/transformers.py +79 -0
  93. coco_core/embeddings/__init__.py +31 -0
  94. coco_core/embeddings/embeddings.py +78 -0
  95. coco_core/embeddings/fake.py +129 -0
  96. coco_core/env.py +22 -0
  97. coco_core/example_selectors/__init__.py +47 -0
  98. coco_core/example_selectors/base.py +60 -0
  99. coco_core/example_selectors/length_based.py +131 -0
  100. coco_core/example_selectors/semantic_similarity.py +366 -0
  101. coco_core/exceptions.py +166 -0
  102. coco_core/globals.py +72 -0
  103. coco_core/indexing/__init__.py +53 -0
  104. coco_core/indexing/api.py +954 -0
  105. coco_core/indexing/base.py +661 -0
  106. coco_core/indexing/in_memory.py +104 -0
  107. coco_core/language_models/__init__.py +116 -0
  108. coco_core/language_models/_compat_bridge.py +844 -0
  109. coco_core/language_models/_utils.py +343 -0
  110. coco_core/language_models/base.py +500 -0
  111. coco_core/language_models/chat_model_stream.py +1525 -0
  112. coco_core/language_models/chat_models.py +2748 -0
  113. coco_core/language_models/fake.py +137 -0
  114. coco_core/language_models/fake_chat_models.py +396 -0
  115. coco_core/language_models/llms.py +1569 -0
  116. coco_core/language_models/model_profile.py +183 -0
  117. coco_core/load/__init__.py +44 -0
  118. coco_core/load/_validation.py +191 -0
  119. coco_core/load/dump.py +120 -0
  120. coco_core/load/load.py +828 -0
  121. coco_core/load/mapping.py +1097 -0
  122. coco_core/load/serializable.py +406 -0
  123. coco_core/messages/__init__.py +198 -0
  124. coco_core/messages/ai.py +854 -0
  125. coco_core/messages/base.py +518 -0
  126. coco_core/messages/block_translators/__init__.py +112 -0
  127. coco_core/messages/block_translators/anthropic.py +534 -0
  128. coco_core/messages/block_translators/bedrock.py +108 -0
  129. coco_core/messages/block_translators/bedrock_converse.py +329 -0
  130. coco_core/messages/block_translators/coco_v0.py +303 -0
  131. coco_core/messages/block_translators/google_genai.py +610 -0
  132. coco_core/messages/block_translators/google_vertexai.py +21 -0
  133. coco_core/messages/block_translators/groq.py +157 -0
  134. coco_core/messages/block_translators/openai.py +1089 -0
  135. coco_core/messages/chat.py +64 -0
  136. coco_core/messages/content.py +1488 -0
  137. coco_core/messages/function.py +62 -0
  138. coco_core/messages/human.py +70 -0
  139. coco_core/messages/modifier.py +33 -0
  140. coco_core/messages/system.py +70 -0
  141. coco_core/messages/tool.py +418 -0
  142. coco_core/messages/utils.py +2442 -0
  143. coco_core/output_parsers/__init__.py +101 -0
  144. coco_core/output_parsers/base.py +361 -0
  145. coco_core/output_parsers/format_instructions.py +16 -0
  146. coco_core/output_parsers/json.py +139 -0
  147. coco_core/output_parsers/list.py +249 -0
  148. coco_core/output_parsers/openai_functions.py +324 -0
  149. coco_core/output_parsers/openai_tools.py +383 -0
  150. coco_core/output_parsers/pydantic.py +141 -0
  151. coco_core/output_parsers/string.py +63 -0
  152. coco_core/output_parsers/transform.py +175 -0
  153. coco_core/output_parsers/xml.py +301 -0
  154. coco_core/outputs/__init__.py +66 -0
  155. coco_core/outputs/chat_generation.py +147 -0
  156. coco_core/outputs/chat_result.py +39 -0
  157. coco_core/outputs/generation.py +80 -0
  158. coco_core/outputs/llm_result.py +112 -0
  159. coco_core/outputs/run_info.py +22 -0
  160. coco_core/prompt_values.py +161 -0
  161. coco_core/prompts/__init__.py +101 -0
  162. coco_core/prompts/base.py +508 -0
  163. coco_core/prompts/chat.py +1495 -0
  164. coco_core/prompts/dict.py +177 -0
  165. coco_core/prompts/few_shot.py +483 -0
  166. coco_core/prompts/few_shot_with_templates.py +239 -0
  167. coco_core/prompts/image.py +177 -0
  168. coco_core/prompts/loading.py +296 -0
  169. coco_core/prompts/message.py +97 -0
  170. coco_core/prompts/prompt.py +312 -0
  171. coco_core/prompts/string.py +399 -0
  172. coco_core/prompts/structured.py +214 -0
  173. coco_core/py.typed +0 -0
  174. coco_core/rate_limiters.py +254 -0
  175. coco_core/retrievers.py +328 -0
  176. coco_core/runnables/__init__.py +136 -0
  177. coco_core/runnables/base.py +6725 -0
  178. coco_core/runnables/branch.py +457 -0
  179. coco_core/runnables/config.py +712 -0
  180. coco_core/runnables/configurable.py +710 -0
  181. coco_core/runnables/fallbacks.py +663 -0
  182. coco_core/runnables/graph.py +741 -0
  183. coco_core/runnables/graph_ascii.py +366 -0
  184. coco_core/runnables/graph_mermaid.py +503 -0
  185. coco_core/runnables/graph_png.py +215 -0
  186. coco_core/runnables/history.py +631 -0
  187. coco_core/runnables/passthrough.py +849 -0
  188. coco_core/runnables/retry.py +379 -0
  189. coco_core/runnables/router.py +239 -0
  190. coco_core/runnables/schema.py +188 -0
  191. coco_core/runnables/utils.py +798 -0
  192. coco_core/stores.py +291 -0
  193. coco_core/structured_query.py +203 -0
  194. coco_core/sys_info.py +154 -0
  195. coco_core/tools/__init__.py +95 -0
  196. coco_core/tools/base.py +1948 -0
  197. coco_core/tools/convert.py +497 -0
  198. coco_core/tools/render.py +67 -0
  199. coco_core/tools/retriever.py +94 -0
  200. coco_core/tools/simple.py +208 -0
  201. coco_core/tools/structured.py +322 -0
  202. coco_core/tracers/__init__.py +47 -0
  203. coco_core/tracers/_compat.py +95 -0
  204. coco_core/tracers/_context.py +337 -0
  205. coco_core/tracers/_streaming.py +53 -0
  206. coco_core/tracers/base.py +955 -0
  207. coco_core/tracers/coco.py +279 -0
  208. coco_core/tracers/context.py +205 -0
  209. coco_core/tracers/core.py +766 -0
  210. coco_core/tracers/event_stream.py +1101 -0
  211. coco_core/tracers/log_stream.py +765 -0
  212. coco_core/tracers/memory_stream.py +200 -0
  213. coco_core/tracers/root_listeners.py +130 -0
  214. coco_core/tracers/run_collector.py +40 -0
  215. coco_core/tracers/schemas.py +14 -0
  216. coco_core/tracers/stdout.py +205 -0
  217. coco_core/utils/__init__.py +111 -0
  218. coco_core/utils/_gateway.py +274 -0
  219. coco_core/utils/_merge.py +218 -0
  220. coco_core/utils/aiter.py +357 -0
  221. coco_core/utils/env.py +86 -0
  222. coco_core/utils/formatting.py +80 -0
  223. coco_core/utils/function_calling.py +869 -0
  224. coco_core/utils/html.py +132 -0
  225. coco_core/utils/image.py +15 -0
  226. coco_core/utils/input.py +82 -0
  227. coco_core/utils/interactive_env.py +12 -0
  228. coco_core/utils/iter.py +229 -0
  229. coco_core/utils/json.py +230 -0
  230. coco_core/utils/json_schema.py +273 -0
  231. coco_core/utils/mustache.py +706 -0
  232. coco_core/utils/pydantic.py +631 -0
  233. coco_core/utils/strings.py +69 -0
  234. coco_core/utils/usage.py +61 -0
  235. coco_core/utils/utils.py +528 -0
  236. coco_core/utils/uuid.py +57 -0
  237. coco_core/vectorstores/__init__.py +53 -0
  238. coco_core/vectorstores/base.py +1111 -0
  239. coco_core/vectorstores/in_memory.py +546 -0
  240. coco_core/vectorstores/utils.py +163 -0
  241. coco_core/version.py +3 -0
  242. coco_framework-0.1.0.dist-info/METADATA +184 -0
  243. coco_framework-0.1.0.dist-info/RECORD +400 -0
  244. coco_framework-0.1.0.dist-info/WHEEL +4 -0
  245. coco_framework-0.1.0.dist-info/licenses/LICENSE +24 -0
  246. coco_framework-0.1.0.dist-info/licenses/NOTICE +15 -0
  247. coco_graph/_internal/__init__.py +4 -0
  248. coco_graph/_internal/_cache.py +31 -0
  249. coco_graph/_internal/_config.py +474 -0
  250. coco_graph/_internal/_constants.py +140 -0
  251. coco_graph/_internal/_fields.py +213 -0
  252. coco_graph/_internal/_future.py +220 -0
  253. coco_graph/_internal/_pydantic.py +275 -0
  254. coco_graph/_internal/_queue.py +124 -0
  255. coco_graph/_internal/_replay.py +90 -0
  256. coco_graph/_internal/_retry.py +29 -0
  257. coco_graph/_internal/_runnable.py +969 -0
  258. coco_graph/_internal/_scratchpad.py +19 -0
  259. coco_graph/_internal/_serde.py +253 -0
  260. coco_graph/_internal/_timeout.py +25 -0
  261. coco_graph/_internal/_typing.py +54 -0
  262. coco_graph/cache/base/__init__.py +48 -0
  263. coco_graph/cache/base/py.typed +0 -0
  264. coco_graph/cache/memory/__init__.py +73 -0
  265. coco_graph/cache/redis/__init__.py +144 -0
  266. coco_graph/cache/sqlite/__init__.py +120 -0
  267. coco_graph/callbacks.py +394 -0
  268. coco_graph/channels/__init__.py +29 -0
  269. coco_graph/channels/any_value.py +72 -0
  270. coco_graph/channels/base.py +121 -0
  271. coco_graph/channels/binop.py +155 -0
  272. coco_graph/channels/delta.py +202 -0
  273. coco_graph/channels/ephemeral_value.py +79 -0
  274. coco_graph/channels/last_value.py +151 -0
  275. coco_graph/channels/named_barrier_value.py +167 -0
  276. coco_graph/channels/topic.py +94 -0
  277. coco_graph/channels/untracked_value.py +73 -0
  278. coco_graph/checkpoint/base/__init__.py +861 -0
  279. coco_graph/checkpoint/base/id.py +109 -0
  280. coco_graph/checkpoint/base/py.typed +0 -0
  281. coco_graph/checkpoint/memory/__init__.py +698 -0
  282. coco_graph/checkpoint/memory/py.typed +0 -0
  283. coco_graph/checkpoint/postgres/__init__.py +602 -0
  284. coco_graph/checkpoint/postgres/_ainternal.py +23 -0
  285. coco_graph/checkpoint/postgres/_internal.py +21 -0
  286. coco_graph/checkpoint/postgres/aio.py +691 -0
  287. coco_graph/checkpoint/postgres/base.py +666 -0
  288. coco_graph/checkpoint/postgres/py.typed +0 -0
  289. coco_graph/checkpoint/postgres/shallow.py +967 -0
  290. coco_graph/checkpoint/serde/__init__.py +0 -0
  291. coco_graph/checkpoint/serde/_msgpack.py +97 -0
  292. coco_graph/checkpoint/serde/base.py +64 -0
  293. coco_graph/checkpoint/serde/encrypted.py +80 -0
  294. coco_graph/checkpoint/serde/event_hooks.py +52 -0
  295. coco_graph/checkpoint/serde/jsonplus.py +883 -0
  296. coco_graph/checkpoint/serde/py.typed +0 -0
  297. coco_graph/checkpoint/serde/types.py +68 -0
  298. coco_graph/checkpoint/sqlite/__init__.py +645 -0
  299. coco_graph/checkpoint/sqlite/_delta.py +172 -0
  300. coco_graph/checkpoint/sqlite/aio.py +738 -0
  301. coco_graph/checkpoint/sqlite/py.typed +0 -0
  302. coco_graph/checkpoint/sqlite/utils.py +116 -0
  303. coco_graph/config.py +196 -0
  304. coco_graph/constants.py +64 -0
  305. coco_graph/errors.py +241 -0
  306. coco_graph/func/__init__.py +620 -0
  307. coco_graph/graph/__init__.py +12 -0
  308. coco_graph/graph/_branch.py +225 -0
  309. coco_graph/graph/_node.py +103 -0
  310. coco_graph/graph/message.py +437 -0
  311. coco_graph/graph/state.py +1978 -0
  312. coco_graph/graph/ui.py +227 -0
  313. coco_graph/managed/__init__.py +3 -0
  314. coco_graph/managed/base.py +31 -0
  315. coco_graph/managed/is_last_step.py +24 -0
  316. coco_graph/prebuilt/__init__.py +23 -0
  317. coco_graph/prebuilt/_tool_call_stream.py +117 -0
  318. coco_graph/prebuilt/_tool_call_transformer.py +165 -0
  319. coco_graph/prebuilt/chat_agent_executor.py +1015 -0
  320. coco_graph/prebuilt/interrupt.py +105 -0
  321. coco_graph/prebuilt/py.typed +0 -0
  322. coco_graph/prebuilt/tool_node.py +2030 -0
  323. coco_graph/prebuilt/tool_validator.py +221 -0
  324. coco_graph/pregel/__init__.py +3 -0
  325. coco_graph/pregel/_algo.py +1460 -0
  326. coco_graph/pregel/_call.py +298 -0
  327. coco_graph/pregel/_checkpoint.py +331 -0
  328. coco_graph/pregel/_config.py +0 -0
  329. coco_graph/pregel/_draw.py +294 -0
  330. coco_graph/pregel/_executor.py +223 -0
  331. coco_graph/pregel/_io.py +174 -0
  332. coco_graph/pregel/_log.py +3 -0
  333. coco_graph/pregel/_loop.py +1988 -0
  334. coco_graph/pregel/_messages.py +461 -0
  335. coco_graph/pregel/_read.py +312 -0
  336. coco_graph/pregel/_retry.py +854 -0
  337. coco_graph/pregel/_runner.py +941 -0
  338. coco_graph/pregel/_tools.py +268 -0
  339. coco_graph/pregel/_utils.py +221 -0
  340. coco_graph/pregel/_validate.py +120 -0
  341. coco_graph/pregel/_write.py +192 -0
  342. coco_graph/pregel/debug.py +302 -0
  343. coco_graph/pregel/main.py +4364 -0
  344. coco_graph/pregel/protocol.py +288 -0
  345. coco_graph/pregel/types.py +38 -0
  346. coco_graph/py.typed +0 -0
  347. coco_graph/runtime.py +350 -0
  348. coco_graph/store/base/__init__.py +1322 -0
  349. coco_graph/store/base/batch.py +371 -0
  350. coco_graph/store/base/embed.py +436 -0
  351. coco_graph/store/base/py.typed +0 -0
  352. coco_graph/store/memory/__init__.py +592 -0
  353. coco_graph/store/memory/py.typed +0 -0
  354. coco_graph/store/postgres/__init__.py +4 -0
  355. coco_graph/store/postgres/aio.py +592 -0
  356. coco_graph/store/postgres/base.py +1480 -0
  357. coco_graph/store/postgres/py.typed +0 -0
  358. coco_graph/store/sqlite/__init__.py +4 -0
  359. coco_graph/store/sqlite/aio.py +632 -0
  360. coco_graph/store/sqlite/base.py +1514 -0
  361. coco_graph/stream/__init__.py +45 -0
  362. coco_graph/stream/_convert.py +32 -0
  363. coco_graph/stream/_mux.py +523 -0
  364. coco_graph/stream/_types.py +330 -0
  365. coco_graph/stream/run_stream.py +735 -0
  366. coco_graph/stream/stream_channel.py +341 -0
  367. coco_graph/stream/transformers.py +1039 -0
  368. coco_graph/types.py +1079 -0
  369. coco_graph/typing.py +48 -0
  370. coco_graph/utils/__init__.py +1 -0
  371. coco_graph/utils/config.py +4 -0
  372. coco_graph/utils/runnable.py +3 -0
  373. coco_graph/version.py +13 -0
  374. coco_graph/warnings.py +69 -0
  375. coco_openai/__init__.py +20 -0
  376. coco_openai/_compat.py +45 -0
  377. coco_openai/_version.py +3 -0
  378. coco_openai/chat_models/__init__.py +6 -0
  379. coco_openai/chat_models/_client_utils.py +684 -0
  380. coco_openai/chat_models/_compat.py +525 -0
  381. coco_openai/chat_models/azure.py +1227 -0
  382. coco_openai/chat_models/base.py +5654 -0
  383. coco_openai/chat_models/codex.py +557 -0
  384. coco_openai/chatgpt_oauth.py +1063 -0
  385. coco_openai/data/__init__.py +1 -0
  386. coco_openai/data/_profiles.py +1655 -0
  387. coco_openai/data/profile_augmentations.toml +127 -0
  388. coco_openai/embeddings/__init__.py +6 -0
  389. coco_openai/embeddings/azure.py +240 -0
  390. coco_openai/embeddings/base.py +820 -0
  391. coco_openai/llms/__init__.py +6 -0
  392. coco_openai/llms/azure.py +249 -0
  393. coco_openai/llms/base.py +919 -0
  394. coco_openai/middleware/__init__.py +11 -0
  395. coco_openai/middleware/openai_moderation.py +484 -0
  396. coco_openai/output_parsers/__init__.py +9 -0
  397. coco_openai/output_parsers/tools.py +9 -0
  398. coco_openai/py.typed +0 -0
  399. coco_openai/tools/__init__.py +5 -0
  400. coco_openai/tools/custom_tool.py +111 -0
@@ -0,0 +1,3349 @@
1
+ """Anthropic chat models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import copy
6
+ import datetime
7
+ import hashlib
8
+ import json
9
+ import re
10
+ import warnings
11
+ from collections.abc import AsyncIterator, Callable, Iterator, Mapping, Sequence
12
+ from functools import cached_property
13
+ from operator import itemgetter
14
+ from typing import Any, Final, Literal, TypeGuard, cast
15
+
16
+ import anthropic
17
+ from coco_core.callbacks import (
18
+ AsyncCallbackManagerForLLMRun,
19
+ CallbackManagerForLLMRun,
20
+ )
21
+ from coco_core.exceptions import (
22
+ ContextOverflowError,
23
+ ModelAPIError,
24
+ ModelAuthenticationError,
25
+ ModelConnectionError,
26
+ ModelInvalidRequestError,
27
+ ModelNotFoundError,
28
+ ModelPermissionDeniedError,
29
+ ModelRateLimitError,
30
+ ModelTimeoutError,
31
+ OutputParserException,
32
+ )
33
+ from coco_core.language_models import (
34
+ LanguageModelInput,
35
+ ModelProfile,
36
+ ModelProfileRegistry,
37
+ )
38
+ from coco_core.language_models.chat_models import BaseChatModel, LangSmithParams
39
+ from coco_core.messages import (
40
+ AIMessage,
41
+ AIMessageChunk,
42
+ BaseMessage,
43
+ HumanMessage,
44
+ SystemMessage,
45
+ ToolCall,
46
+ ToolMessage,
47
+ is_data_content_block,
48
+ )
49
+ from coco_core.messages import content as types
50
+ from coco_core.messages.ai import (
51
+ InputTokenDetails,
52
+ OutputTokenDetails,
53
+ UsageMetadata,
54
+ )
55
+ from coco_core.messages.tool import tool_call_chunk as create_tool_call_chunk
56
+ from coco_core.output_parsers import (
57
+ JsonOutputKeyToolsParser,
58
+ JsonOutputParser,
59
+ PydanticOutputParser,
60
+ PydanticToolsParser,
61
+ )
62
+ from coco_core.output_parsers.base import OutputParserLike
63
+ from coco_core.outputs import ChatGeneration, ChatGenerationChunk, ChatResult
64
+ from coco_core.runnables import Runnable, RunnableMap, RunnablePassthrough
65
+ from coco_core.tools import BaseTool
66
+ from coco_core.utils import from_env, get_pydantic_field_names
67
+ from coco_core.utils._gateway import (
68
+ GATEWAY_METADATA_RESPONSE_KEY,
69
+ _apply_gateway_config,
70
+ _parse_gateway_metadata,
71
+ )
72
+ from coco_core.utils.function_calling import (
73
+ convert_to_json_schema,
74
+ convert_to_openai_tool,
75
+ )
76
+ from coco_core.utils.pydantic import is_basemodel_subclass
77
+ from coco_core.utils.utils import _build_model_kwargs
78
+ from pydantic import BaseModel, ConfigDict, Field, SecretStr, model_validator
79
+ from typing_extensions import NotRequired, Self, TypedDict
80
+
81
+ from coco_anthropic import __version__
82
+ from coco_anthropic._client_utils import (
83
+ _get_default_async_httpx_client,
84
+ _get_default_httpx_client,
85
+ )
86
+ from coco_anthropic._compat import (
87
+ _convert_from_v1_to_anthropic,
88
+ _unwrap_non_standard,
89
+ )
90
+ from coco_anthropic._sdk_compat import (
91
+ _aparse,
92
+ _route_unsupported_sampling_params,
93
+ )
94
+ from coco_anthropic.data._profiles import _PROFILES
95
+ from coco_anthropic.output_parsers import extract_tool_calls
96
+
97
+ _message_type_lookups = {
98
+ "human": "user",
99
+ "ai": "assistant",
100
+ "AIMessageChunk": "assistant",
101
+ "HumanMessageChunk": "user",
102
+ }
103
+
104
+ _MODEL_PROFILES = cast(ModelProfileRegistry, _PROFILES)
105
+
106
+ _USER_AGENT: Final[str] = f"trustcoco-anthropic/{__version__}"
107
+
108
+
109
+ def _add_gateway_metadata(generation_info: dict[str, Any], raw_response: Any) -> None:
110
+ """Add parsed LangSmith gateway metadata to `generation_info`, if present.
111
+
112
+ Args:
113
+ generation_info: Generation info to mutate in place.
114
+ raw_response: The raw provider response, or None.
115
+ """
116
+ headers = getattr(raw_response, "headers", None)
117
+ if headers is None:
118
+ return
119
+ gateway_metadata = _parse_gateway_metadata(headers)
120
+ if gateway_metadata is not None:
121
+ generation_info[GATEWAY_METADATA_RESPONSE_KEY] = gateway_metadata
122
+
123
+
124
+ def _get_default_model_profile(model_name: str) -> ModelProfile:
125
+ """Get the default profile for a model.
126
+
127
+ Args:
128
+ model_name: The model identifier.
129
+
130
+ Returns:
131
+ The model profile dictionary, or an empty dict if not found.
132
+ """
133
+ default = _MODEL_PROFILES.get(model_name)
134
+ if default:
135
+ return default.copy()
136
+ return {}
137
+
138
+
139
+ _FALLBACK_MAX_OUTPUT_TOKENS: Final[int] = 4096
140
+
141
+
142
+ class AnthropicTool(TypedDict):
143
+ """Anthropic tool definition for custom (user-defined) tools.
144
+
145
+ Custom tools use `name` and `input_schema` fields to define the tool's
146
+ interface. These are converted from Coco tool formats (functions, Pydantic
147
+ models, `BaseTool` objects) via `convert_to_anthropic_tool`.
148
+ """
149
+
150
+ name: str
151
+
152
+ input_schema: dict[str, Any]
153
+
154
+ description: NotRequired[str]
155
+
156
+ strict: NotRequired[bool]
157
+
158
+ cache_control: NotRequired[dict[str, str]]
159
+
160
+ defer_loading: NotRequired[bool]
161
+
162
+ input_examples: NotRequired[list[dict[str, Any]]]
163
+
164
+ allowed_callers: NotRequired[list[str]]
165
+
166
+
167
+ # ---------------------------------------------------------------------------
168
+ # Built-in Tool Support
169
+ # ---------------------------------------------------------------------------
170
+ # When Anthropic releases new built-in tools, two places may need updating:
171
+ #
172
+ # 1. _TOOL_TYPE_TO_BETA (below) - Add mapping if the tool requires a beta header.
173
+ # Not all tools need this; only add if the API requires a beta header.
174
+ #
175
+ # 2. _is_builtin_tool() - Add the tool type prefix to _BUILTIN_TOOL_PREFIXES.
176
+ # This ensures the tool dict is passed through to the API unchanged (instead
177
+ # of being converted via convert_to_anthropic_tool, which may fail).
178
+ # ---------------------------------------------------------------------------
179
+
180
+ _TOOL_TYPE_TO_BETA: dict[str, str] = {
181
+ "web_fetch_20250910": "web-fetch-2025-09-10",
182
+ "code_execution_20250522": "code-execution-2025-05-22",
183
+ "mcp_toolset": "mcp-client-2025-11-20",
184
+ "memory_20250818": "context-management-2025-06-27",
185
+ "computer_20250124": "computer-use-2025-01-24",
186
+ "computer_20251124": "computer-use-2025-11-24",
187
+ "tool_search_tool_regex_20251119": "advanced-tool-use-2025-11-20",
188
+ "tool_search_tool_bm25_20251119": "advanced-tool-use-2025-11-20",
189
+ "advisor_20260301": "advisor-tool-2026-03-01",
190
+ }
191
+ """Mapping of tool type to required beta header.
192
+
193
+ Some tool types require specific beta headers to be enabled.
194
+ """
195
+
196
+ _BUILTIN_TOOL_PREFIXES = [
197
+ "text_editor_",
198
+ "computer_",
199
+ "bash_",
200
+ "web_search_",
201
+ "web_fetch_",
202
+ "code_execution_",
203
+ "mcp_toolset",
204
+ "memory_",
205
+ "tool_search_",
206
+ "advisor_",
207
+ ]
208
+
209
+ _ANTHROPIC_EXTRA_FIELDS: set[str] = {
210
+ "allowed_callers",
211
+ "cache_control",
212
+ "defer_loading",
213
+ "eager_input_streaming",
214
+ "input_examples",
215
+ }
216
+ """Valid Anthropic-specific extra fields"""
217
+
218
+
219
+ def _is_builtin_tool(tool: Any) -> TypeGuard[dict[str, Any]]:
220
+ """Check if a tool is a built-in (server-side) Anthropic tool.
221
+
222
+ `tool` must be a `dict` and have a `type` key starting with one of the known
223
+ built-in tool prefixes.
224
+
225
+ [Claude docs](https://platform.claude.com/docs/en/agents-and-tools/tool-use/overview)
226
+ """
227
+ if not isinstance(tool, dict):
228
+ return False
229
+
230
+ tool_type = tool.get("type")
231
+ if not tool_type or not isinstance(tool_type, str):
232
+ return False
233
+
234
+ return any(tool_type.startswith(prefix) for prefix in _BUILTIN_TOOL_PREFIXES)
235
+
236
+
237
+ def _format_image(url: str) -> dict:
238
+ """Convert part["image_url"]["url"] strings (OpenAI format) to Anthropic format.
239
+
240
+ {
241
+ "type": "base64",
242
+ "media_type": "image/jpeg",
243
+ "data": "/9j/4AAQSkZJRg...",
244
+ }
245
+
246
+ Or
247
+
248
+ {
249
+ "type": "url",
250
+ "url": "https://example.com/image.jpg",
251
+ }
252
+ """
253
+ # Base64 encoded image
254
+ base64_regex = r"^data:(?P<media_type>image/.+);base64,(?P<data>.+)$"
255
+ base64_match = re.match(base64_regex, url)
256
+
257
+ if base64_match:
258
+ return {
259
+ "type": "base64",
260
+ "media_type": base64_match.group("media_type"),
261
+ "data": base64_match.group("data"),
262
+ }
263
+
264
+ # Url
265
+ url_regex = r"^https?://.*$"
266
+ url_match = re.match(url_regex, url)
267
+
268
+ if url_match:
269
+ return {
270
+ "type": "url",
271
+ "url": url,
272
+ }
273
+
274
+ msg = (
275
+ "Malformed url parameter."
276
+ " Must be either an image URL (https://example.com/image.jpg)"
277
+ " or base64 encoded string (data:image/png;base64,'/9j/4AAQSk'...)"
278
+ )
279
+ raise ValueError(
280
+ msg,
281
+ )
282
+
283
+
284
+ _TOOL_CALL_ID_PATTERN = re.compile(r"^[a-zA-Z0-9_-]+$")
285
+ """Anthropic requires `tool_use`/`tool_result` IDs to match this pattern."""
286
+
287
+
288
+ def _normalize_tool_call_id(tool_call_id: str | None) -> str | None:
289
+ """Map a tool-call ID to an Anthropic-compatible form if needed.
290
+
291
+ Anthropic rejects `tool_use`/`tool_result` IDs that don't match
292
+ `^[a-zA-Z0-9_-]+$`. IDs minted by other providers can violate this when a
293
+ thread is replayed across providers (e.g. Fireworks/Kimi emits
294
+ `functions.write_todos:0`, whose `.` and `:` are invalid). Valid IDs are
295
+ returned unchanged; invalid ones are hashed deterministically so that a
296
+ rewritten `tool_use.id` and its paired `tool_use_id` resolve to the same
297
+ value, both within a request and across turns.
298
+
299
+ Empty and `None` IDs are passed through unchanged so that a genuinely
300
+ malformed request surfaces as a clear error from Anthropic rather than
301
+ being masked by a synthesized ID.
302
+
303
+ Args:
304
+ tool_call_id: The tool-call ID to normalize.
305
+
306
+ Returns:
307
+ The original ID if it is empty, `None`, or already valid; otherwise a
308
+ deterministic Anthropic-compatible replacement.
309
+ """
310
+ if not tool_call_id or _TOOL_CALL_ID_PATTERN.match(tool_call_id):
311
+ return tool_call_id
312
+ digest = hashlib.sha256(tool_call_id.encode()).hexdigest()
313
+ return f"toolu_{digest[:24]}"
314
+
315
+
316
+ def _normalize_block_tool_use_id(block: dict) -> dict:
317
+ """Return `block` with its `tool_use_id` normalized, if it carries one.
318
+
319
+ Mirrors `_normalize_tool_call_id` for `tool_result`-style content blocks so
320
+ that a `tool_use_id` arriving pre-structured (e.g. on a `ToolMessage` whose
321
+ content is already a list of `tool_result` blocks) stays consistent with its
322
+ paired, normalized `tool_use.id`. A no-op for already-valid IDs.
323
+ """
324
+ if "tool_use_id" in block:
325
+ return {**block, "tool_use_id": _normalize_tool_call_id(block["tool_use_id"])}
326
+ return block
327
+
328
+
329
+ def _merge_messages(
330
+ messages: Sequence[BaseMessage],
331
+ ) -> list[SystemMessage | AIMessage | HumanMessage]:
332
+ """Merge runs of human/tool messages into single human messages with content blocks.""" # noqa: E501
333
+ merged: list = []
334
+ for curr in messages:
335
+ if isinstance(curr, ToolMessage):
336
+ if (
337
+ isinstance(curr.content, list)
338
+ and curr.content
339
+ and all(
340
+ isinstance(block, dict) and block.get("type") == "tool_result"
341
+ for block in curr.content
342
+ )
343
+ ):
344
+ curr = HumanMessage(curr.content) # type: ignore[misc]
345
+ else:
346
+ tool_content = curr.content
347
+ cache_ctrl = None
348
+ # Extract cache_control from content blocks and hoist it
349
+ # to the tool_result level. Anthropic's API does not
350
+ # support cache_control on tool_result content sub-blocks.
351
+ if isinstance(tool_content, list):
352
+ cleaned = []
353
+ for block in tool_content:
354
+ if isinstance(block, dict) and "cache_control" in block:
355
+ cache_ctrl = block["cache_control"]
356
+ block = {
357
+ k: v for k, v in block.items() if k != "cache_control"
358
+ }
359
+ cleaned.append(block)
360
+ tool_content = cleaned
361
+ tool_result: dict = {
362
+ "type": "tool_result",
363
+ "content": tool_content,
364
+ "tool_use_id": _normalize_tool_call_id(curr.tool_call_id),
365
+ "is_error": curr.status == "error",
366
+ }
367
+ if cache_ctrl:
368
+ tool_result["cache_control"] = cache_ctrl
369
+ curr = HumanMessage( # type: ignore[misc]
370
+ [tool_result],
371
+ )
372
+ last = merged[-1] if merged else None
373
+ if any(
374
+ all(isinstance(m, c) for m in (curr, last))
375
+ for c in (SystemMessage, HumanMessage)
376
+ ):
377
+ if isinstance(cast("BaseMessage", last).content, str):
378
+ new_content: list = [
379
+ {"type": "text", "text": cast("BaseMessage", last).content},
380
+ ]
381
+ else:
382
+ new_content = copy.copy(cast("list", cast("BaseMessage", last).content))
383
+ if isinstance(curr.content, str):
384
+ new_content.append({"type": "text", "text": curr.content})
385
+ else:
386
+ new_content.extend(curr.content)
387
+ merged[-1] = curr.model_copy(update={"content": new_content})
388
+ else:
389
+ merged.append(curr)
390
+ return merged
391
+
392
+
393
+ def _format_data_content_block(block: dict) -> dict:
394
+ """Format standard data content block to format expected by Anthropic."""
395
+ if block["type"] == "image":
396
+ if "url" in block:
397
+ if block["url"].startswith("data:"):
398
+ # Data URI
399
+ formatted_block = {
400
+ "type": "image",
401
+ "source": _format_image(block["url"]),
402
+ }
403
+ else:
404
+ formatted_block = {
405
+ "type": "image",
406
+ "source": {"type": "url", "url": block["url"]},
407
+ }
408
+ elif "base64" in block or block.get("source_type") == "base64":
409
+ formatted_block = {
410
+ "type": "image",
411
+ "source": {
412
+ "type": "base64",
413
+ "media_type": block["mime_type"],
414
+ "data": block.get("base64") or block.get("data", ""),
415
+ },
416
+ }
417
+ elif "file_id" in block:
418
+ formatted_block = {
419
+ "type": "image",
420
+ "source": {
421
+ "type": "file",
422
+ "file_id": block["file_id"],
423
+ },
424
+ }
425
+ elif block.get("source_type") == "id":
426
+ formatted_block = {
427
+ "type": "image",
428
+ "source": {
429
+ "type": "file",
430
+ "file_id": block["id"],
431
+ },
432
+ }
433
+ else:
434
+ msg = (
435
+ "Anthropic only supports 'url', 'base64', or 'id' keys for image "
436
+ "content blocks."
437
+ )
438
+ raise ValueError(
439
+ msg,
440
+ )
441
+
442
+ elif block["type"] == "file":
443
+ if "url" in block:
444
+ formatted_block = {
445
+ "type": "document",
446
+ "source": {
447
+ "type": "url",
448
+ "url": block["url"],
449
+ },
450
+ }
451
+ elif "base64" in block or block.get("source_type") == "base64":
452
+ formatted_block = {
453
+ "type": "document",
454
+ "source": {
455
+ "type": "base64",
456
+ "media_type": block.get("mime_type") or "application/pdf",
457
+ "data": block.get("base64") or block.get("data", ""),
458
+ },
459
+ }
460
+ elif block.get("source_type") == "text":
461
+ formatted_block = {
462
+ "type": "document",
463
+ "source": {
464
+ "type": "text",
465
+ "media_type": block.get("mime_type") or "text/plain",
466
+ "data": block["text"],
467
+ },
468
+ }
469
+ elif "file_id" in block:
470
+ formatted_block = {
471
+ "type": "document",
472
+ "source": {
473
+ "type": "file",
474
+ "file_id": block["file_id"],
475
+ },
476
+ }
477
+ elif block.get("source_type") == "id":
478
+ formatted_block = {
479
+ "type": "document",
480
+ "source": {
481
+ "type": "file",
482
+ "file_id": block["id"],
483
+ },
484
+ }
485
+ else:
486
+ msg = (
487
+ "Anthropic only supports 'url', 'base64', or 'id' keys for file "
488
+ "content blocks."
489
+ )
490
+ raise ValueError(msg)
491
+
492
+ elif block["type"] == "text-plain":
493
+ formatted_block = {
494
+ "type": "document",
495
+ "source": {
496
+ "type": "text",
497
+ "media_type": block.get("mime_type") or "text/plain",
498
+ "data": block["text"],
499
+ },
500
+ }
501
+
502
+ else:
503
+ msg = f"Block of type {block['type']} is not supported."
504
+ raise ValueError(msg)
505
+
506
+ if formatted_block:
507
+ for key in ["cache_control", "citations", "title", "context"]:
508
+ if key in block:
509
+ formatted_block[key] = block[key]
510
+ elif (metadata := block.get("extras")) and key in metadata:
511
+ formatted_block[key] = metadata[key]
512
+ elif (metadata := block.get("metadata")) and key in metadata:
513
+ # Backward compat
514
+ formatted_block[key] = metadata[key]
515
+
516
+ return formatted_block
517
+
518
+
519
+ def _format_text_block(block: dict) -> dict:
520
+ """Narrow a text content block to fields supported by Anthropic's API.
521
+
522
+ Drops Coco-internal fields (e.g. the ``id`` minted by
523
+ ``create_text_block``) that Anthropic rejects as extra inputs.
524
+ """
525
+ formatted_block = {
526
+ k: v
527
+ for k, v in block.items()
528
+ if k in ("type", "text", "cache_control", "citations")
529
+ }
530
+ # Clean up citations to remove null file_id fields
531
+ if formatted_block.get("citations"):
532
+ cleaned_citations = []
533
+ for citation in formatted_block["citations"]:
534
+ cleaned_citation = {
535
+ k: v for k, v in citation.items() if not (k == "file_id" and v is None)
536
+ }
537
+ cleaned_citations.append(cleaned_citation)
538
+ formatted_block["citations"] = cleaned_citations
539
+ return formatted_block
540
+
541
+
542
+ _TOOL_CHANGE_BLOCK_TYPES = ("tool_addition", "tool_removal")
543
+ """Anthropic-native system content blocks that change the tool set mid-conversation."""
544
+
545
+ _MID_CONVERSATION_TOOL_CHANGES_BETA = "mid-conversation-tool-changes-2026-07-01"
546
+ """Beta header required to send `tool_addition` / `tool_removal` blocks."""
547
+
548
+ _INLINE_TOOLS_BETA = "inline-tools-2026-09-15"
549
+ """Beta header required to define a tool in a `tool_addition` block."""
550
+
551
+
552
+ def _is_tool_change_block(block: object) -> bool:
553
+ """Return whether a content block changes the tool set, in either spelling."""
554
+ return (
555
+ isinstance(block, dict)
556
+ and _unwrap_non_standard(block).get("type") in _TOOL_CHANGE_BLOCK_TYPES
557
+ )
558
+
559
+
560
+ def _has_tool_change_block(content: object) -> bool:
561
+ """Return whether any block in `content` changes the tool set."""
562
+ return isinstance(content, list) and any(_is_tool_change_block(b) for b in content)
563
+
564
+
565
+ def _has_inline_tool_definition(content: object) -> bool:
566
+ """Return whether `content` defines a tool in a `tool_addition` block."""
567
+ if not isinstance(content, list):
568
+ return False
569
+ return any(
570
+ isinstance(block, dict)
571
+ and block.get("type") == "tool_addition"
572
+ and isinstance(tool := block.get("tool"), dict)
573
+ and tool.get("type") == "tool_definition"
574
+ for block in content
575
+ )
576
+
577
+
578
+ def _format_system_content(
579
+ content: str | list[Any],
580
+ *,
581
+ model: str | None = None,
582
+ preserve_tool_changes: bool = False,
583
+ stacklevel: int = 3,
584
+ ) -> str | list[dict]:
585
+ """Narrow system message content to what Anthropic accepts.
586
+
587
+ String content is passed through unchanged; promoting it to a single-element
588
+ block array would invalidate existing callers' prompt caches.
589
+
590
+ Anthropic documents a closed set of system content blocks: `text` anywhere, plus
591
+ `tool_addition` / `tool_removal` on a mid-conversation `system` turn. Anything
592
+ else is rejected by the API, so it is dropped with a warning rather than
593
+ forwarded. Blocks wrapped in core's `non_standard` escape hatch are unwrapped
594
+ first, so both spellings behave identically.
595
+
596
+ Args:
597
+ content: The system message's content.
598
+ model: The model the request targets, used only in warning text.
599
+ preserve_tool_changes: Whether tool-change blocks should be forwarded instead
600
+ of dropped.
601
+ stacklevel: Frames to skip when attributing a warning, so it points at the
602
+ caller of `_format_messages` rather than at this module. The default
603
+ suits a direct call; a caller reached through a helper adds a frame.
604
+
605
+ Returns:
606
+ Content narrowed to the blocks Anthropic accepts in this position.
607
+ """
608
+ if not isinstance(content, list):
609
+ return content
610
+
611
+ formatted: list[dict] = []
612
+ for raw_block in content:
613
+ if not isinstance(raw_block, dict):
614
+ formatted.append({"type": "text", "text": raw_block})
615
+ continue
616
+
617
+ block = _unwrap_non_standard(raw_block)
618
+ block_type = block.get("type")
619
+ if block_type == "text":
620
+ formatted.append(_format_text_block(block))
621
+ elif block_type in _TOOL_CHANGE_BLOCK_TYPES:
622
+ if preserve_tool_changes:
623
+ formatted.append(block)
624
+ else:
625
+ warnings.warn(
626
+ f"Tool-change block {block_type!r} was dropped: it is only "
627
+ "valid on a `SystemMessage` sent in place, and this one was "
628
+ "hoisted into the top-level `system` field. Use a model that "
629
+ "supports mid-conversation system messages and place the "
630
+ "message after a human or tool message, either last or before "
631
+ f"an AI message (model: {model!r}).",
632
+ UserWarning,
633
+ stacklevel=stacklevel,
634
+ )
635
+ else:
636
+ warnings.warn(
637
+ f"Unrecognized system content block {block_type!r} was dropped. "
638
+ "Anthropic accepts `text` in any system message, plus "
639
+ "`tool_addition` and `tool_removal` on a mid-conversation one.",
640
+ UserWarning,
641
+ stacklevel=stacklevel,
642
+ )
643
+ return formatted
644
+
645
+
646
+ def _warn_system_message_hoisted(model: str | None) -> None:
647
+ """Warn that a non-leading system message was hoisted."""
648
+ warnings.warn(
649
+ "A non-leading `SystemMessage` was moved to the top-level `system` field "
650
+ "and now applies to the entire conversation. To send it in place, use a "
651
+ "supported model and place it after a human or tool message, either last "
652
+ f"or before an AI message (model: {model!r}).",
653
+ UserWarning,
654
+ stacklevel=3,
655
+ )
656
+
657
+
658
+ def _is_server_tool_result_block(block: object) -> bool:
659
+ """Return whether a content block is a server-side tool result."""
660
+ return (
661
+ isinstance(block, dict)
662
+ and isinstance(block.get("type"), str)
663
+ and block["type"].endswith("_tool_result")
664
+ )
665
+
666
+
667
+ def _previous_turn_allows_system(previous_turn: dict | None) -> bool:
668
+ """Return whether a system message may follow the formatted turn."""
669
+ if previous_turn is None:
670
+ return False
671
+
672
+ previous_role = previous_turn.get("role")
673
+ previous_content = previous_turn.get("content")
674
+ return previous_role == "user" or (
675
+ previous_role == "assistant"
676
+ and isinstance(previous_content, list)
677
+ and bool(previous_content)
678
+ and _is_server_tool_result_block(previous_content[-1])
679
+ )
680
+
681
+
682
+ def _format_in_place_system_messages(
683
+ pending_system: Sequence[BaseMessage],
684
+ *,
685
+ model: str | None,
686
+ ) -> list[dict]:
687
+ """Format system messages that keep their position in the message array.
688
+
689
+ A message whose content is narrowed away entirely is omitted: Anthropic
690
+ rejects a `system` turn with empty content, and the dropped blocks have
691
+ already been warned about.
692
+
693
+ Args:
694
+ pending_system: System messages awaiting emission, in order.
695
+ model: The model the request targets, used only in warning text.
696
+
697
+ Returns:
698
+ Formatted `system`-role turns.
699
+ """
700
+ turns: list[dict] = []
701
+ for pending in pending_system:
702
+ content = _format_system_content(
703
+ pending.content,
704
+ model=model,
705
+ preserve_tool_changes=True,
706
+ # This helper sits between `_format_messages` and the warning site.
707
+ stacklevel=4,
708
+ )
709
+ if content == []:
710
+ continue
711
+ turns.append({"role": "system", "content": content})
712
+ return turns
713
+
714
+
715
+ def _format_messages(
716
+ messages: Sequence[BaseMessage],
717
+ *,
718
+ model: str | None,
719
+ ) -> tuple[str | list[dict] | None, list[dict]]:
720
+ """Format messages for Anthropic's API."""
721
+ system: str | list[dict] | None = None
722
+ formatted_messages: list[dict] = []
723
+ merged_messages = _merge_messages(messages)
724
+ last_non_system_index = max(
725
+ (i for i, m in enumerate(merged_messages) if m.type != "system"),
726
+ default=-1,
727
+ )
728
+ pending_system: list[BaseMessage] = []
729
+ for _i, message in enumerate(merged_messages):
730
+ if message.type == "system":
731
+ if _i == 0:
732
+ system = _format_system_content(
733
+ message.content,
734
+ model=model,
735
+ preserve_tool_changes=True,
736
+ )
737
+ continue
738
+ if _supports_mid_conversation_system_messages(model) and (
739
+ pending_system
740
+ or _previous_turn_allows_system(
741
+ formatted_messages[-1] if formatted_messages else None
742
+ )
743
+ ):
744
+ pending_system.append(message)
745
+ continue
746
+ if system is not None:
747
+ msg = "Received multiple non-consecutive system messages."
748
+ raise ValueError(msg)
749
+ system = _format_system_content(message.content, model=model)
750
+ _warn_system_message_hoisted(model)
751
+ continue
752
+
753
+ role = _message_type_lookups[message.type]
754
+ content: str | list
755
+
756
+ if not isinstance(message.content, str):
757
+ # parse as dict
758
+ if not isinstance(message.content, list):
759
+ msg = "Anthropic message content must be str or list of dicts"
760
+ raise ValueError(
761
+ msg,
762
+ )
763
+
764
+ # populate content
765
+ content = []
766
+ for block in message.content:
767
+ if isinstance(block, str):
768
+ content.append({"type": "text", "text": block})
769
+ elif isinstance(block, dict):
770
+ if "type" not in block:
771
+ msg = "Dict content block must have a type key"
772
+ raise ValueError(msg)
773
+ if block["type"] in ("reasoning", "function_call") and (
774
+ not isinstance(message, AIMessage)
775
+ or message.response_metadata.get("model_provider")
776
+ != "anthropic"
777
+ ):
778
+ continue
779
+ if block["type"] == "image_url":
780
+ # convert format
781
+ source = _format_image(block["image_url"]["url"])
782
+ content.append({"type": "image", "source": source})
783
+ elif is_data_content_block(block):
784
+ content.append(_format_data_content_block(block))
785
+ elif block["type"] == "tool_use":
786
+ # If a tool_call with the same id as a tool_use content block
787
+ # exists, the tool_call is preferred.
788
+ if (
789
+ isinstance(message, AIMessage)
790
+ and (block["id"] in [tc["id"] for tc in message.tool_calls])
791
+ and not block.get("caller")
792
+ ):
793
+ overlapping = [
794
+ tc
795
+ for tc in message.tool_calls
796
+ if tc["id"] == block["id"]
797
+ ]
798
+ content.extend(
799
+ _lc_tool_calls_to_anthropic_tool_use_blocks(
800
+ overlapping,
801
+ ),
802
+ )
803
+ else:
804
+ if tool_input := block.get("input"):
805
+ args = tool_input
806
+ elif "partial_json" in block:
807
+ try:
808
+ args = json.loads(block["partial_json"] or "{}")
809
+ except json.JSONDecodeError:
810
+ args = {}
811
+ else:
812
+ args = {}
813
+ tool_use_block = _AnthropicToolUse(
814
+ type="tool_use",
815
+ name=block["name"],
816
+ input=args,
817
+ id=cast("str", _normalize_tool_call_id(block["id"])),
818
+ )
819
+ if caller := block.get("caller"):
820
+ tool_use_block["caller"] = caller
821
+ content.append(tool_use_block)
822
+ elif block["type"] in ("server_tool_use", "mcp_tool_use"):
823
+ formatted_block = {
824
+ k: v
825
+ for k, v in block.items()
826
+ if k
827
+ in (
828
+ "type",
829
+ "id",
830
+ "input",
831
+ "name",
832
+ "server_name", # for mcp_tool_use
833
+ "cache_control",
834
+ )
835
+ }
836
+ # Attempt to parse streamed output
837
+ if block.get("input") == {} and "partial_json" in block:
838
+ try:
839
+ input_ = json.loads(block["partial_json"])
840
+ if input_:
841
+ formatted_block["input"] = input_
842
+ except json.JSONDecodeError:
843
+ pass
844
+ content.append(formatted_block)
845
+ elif block["type"] == "text":
846
+ text = block.get("text", "")
847
+ # Only add non-empty strings for now as empty ones are not
848
+ # accepted.
849
+ # https://github.com/anthropics/anthropic-sdk-python/issues/461
850
+ if text.strip():
851
+ content.append(_format_text_block(block))
852
+ elif block["type"] == "thinking":
853
+ formatted_thinking = {
854
+ k: v
855
+ for k, v in block.items()
856
+ if k in ("type", "thinking", "cache_control", "signature")
857
+ }
858
+ if "signature" in formatted_thinking:
859
+ formatted_thinking.setdefault("thinking", "")
860
+ content.append(formatted_thinking)
861
+ elif block["type"] == "redacted_thinking":
862
+ content.append(
863
+ {
864
+ k: v
865
+ for k, v in block.items()
866
+ if k in ("type", "cache_control", "data")
867
+ },
868
+ )
869
+ elif (
870
+ block["type"] == "tool_result"
871
+ and isinstance(block.get("content"), list)
872
+ and any(
873
+ isinstance(item, dict)
874
+ and item.get("type") == "tool_reference"
875
+ for item in block["content"]
876
+ )
877
+ ):
878
+ # Tool search results with tool_reference blocks
879
+ content.append(
880
+ _normalize_block_tool_use_id(
881
+ {
882
+ k: v
883
+ for k, v in block.items()
884
+ if k
885
+ in (
886
+ "type",
887
+ "content",
888
+ "tool_use_id",
889
+ "cache_control",
890
+ )
891
+ },
892
+ ),
893
+ )
894
+ elif block["type"] == "tool_search_tool_result":
895
+ # Omit streaming-only fields, such as `index`, from results.
896
+ content.append(
897
+ _normalize_block_tool_use_id(
898
+ {
899
+ k: v
900
+ for k, v in block.items()
901
+ if k
902
+ in (
903
+ "type",
904
+ "content",
905
+ "tool_use_id",
906
+ "cache_control",
907
+ )
908
+ },
909
+ ),
910
+ )
911
+ elif block["type"] == "tool_result":
912
+ # Regular tool results that need content formatting
913
+ tool_content = _format_messages(
914
+ [HumanMessage(block["content"])],
915
+ model=None,
916
+ )[1][0]["content"]
917
+ content.append(
918
+ _normalize_block_tool_use_id(
919
+ {**block, "content": tool_content},
920
+ ),
921
+ )
922
+ elif block["type"] in (
923
+ "code_execution_tool_result",
924
+ "bash_code_execution_tool_result",
925
+ "text_editor_code_execution_tool_result",
926
+ "mcp_tool_result",
927
+ "web_search_tool_result",
928
+ "web_fetch_tool_result",
929
+ ):
930
+ content.append(
931
+ _normalize_block_tool_use_id(
932
+ {
933
+ k: v
934
+ for k, v in block.items()
935
+ if k
936
+ in (
937
+ "type",
938
+ "content",
939
+ "tool_use_id",
940
+ "is_error", # for mcp_tool_result
941
+ "cache_control",
942
+ "retrieved_at", # for web_fetch_tool_result
943
+ )
944
+ },
945
+ ),
946
+ )
947
+ else:
948
+ content.append(block)
949
+ else:
950
+ msg = (
951
+ f"Content blocks must be str or dict, instead was: "
952
+ f"{type(block)}"
953
+ )
954
+ raise ValueError(
955
+ msg,
956
+ )
957
+ else:
958
+ content = message.content
959
+
960
+ if isinstance(message, AIMessage) and (
961
+ message.tool_calls or message.invalid_tool_calls
962
+ ):
963
+ content = content or []
964
+ content = (
965
+ [{"type": "text", "text": message.content}]
966
+ if isinstance(content, str) and content
967
+ else content
968
+ )
969
+ tool_use_ids = [
970
+ cast("dict", block)["id"]
971
+ for block in content
972
+ if cast("dict", block)["type"] == "tool_use"
973
+ ]
974
+ # `tool_use_ids` are already normalized via the branches above, so
975
+ # compare against the normalized tool-call ID to avoid emitting a
976
+ # duplicate `tool_use` block when the original ID was rewritten.
977
+ missing_tool_calls = [
978
+ tc
979
+ for tc in message.tool_calls
980
+ if _normalize_tool_call_id(tc["id"]) not in tool_use_ids
981
+ ]
982
+ cast("list", content).extend(
983
+ _lc_tool_calls_to_anthropic_tool_use_blocks(missing_tool_calls),
984
+ )
985
+ tool_use_ids.extend(
986
+ cast("str", _normalize_tool_call_id(tc["id"]))
987
+ for tc in missing_tool_calls
988
+ )
989
+ for invalid_call in message.invalid_tool_calls:
990
+ tool_call_id = invalid_call.get("id")
991
+ tool_name = invalid_call.get("name")
992
+ if not tool_call_id or not tool_name:
993
+ continue
994
+ normalized_id = _normalize_tool_call_id(tool_call_id)
995
+ if normalized_id in tool_use_ids:
996
+ continue
997
+ try:
998
+ args = json.loads(invalid_call.get("args") or "{}")
999
+ except json.JSONDecodeError:
1000
+ args = {}
1001
+ cast("list", content).append(
1002
+ _AnthropicToolUse(
1003
+ type="tool_use",
1004
+ name=tool_name,
1005
+ input=args if isinstance(args, dict) else {},
1006
+ id=cast("str", normalized_id),
1007
+ )
1008
+ )
1009
+ tool_use_ids.append(normalized_id)
1010
+
1011
+ if role == "assistant" and _i == last_non_system_index:
1012
+ if isinstance(content, str):
1013
+ content = content.rstrip()
1014
+ elif (
1015
+ isinstance(content, list)
1016
+ and content
1017
+ and isinstance(content[-1], dict)
1018
+ and content[-1].get("type") == "text"
1019
+ ):
1020
+ content[-1]["text"] = content[-1]["text"].rstrip()
1021
+
1022
+ if not content and role == "assistant" and _i < last_non_system_index:
1023
+ # anthropic.BadRequestError: Error code: 400: all messages must have
1024
+ # non-empty content except for the optional final assistant message
1025
+ continue
1026
+ if pending_system:
1027
+ if role == "assistant":
1028
+ formatted_messages.extend(
1029
+ _format_in_place_system_messages(pending_system, model=model)
1030
+ )
1031
+ else:
1032
+ for pending in pending_system:
1033
+ if system is not None:
1034
+ msg = "Received multiple non-consecutive system messages."
1035
+ raise ValueError(msg)
1036
+ system = _format_system_content(pending.content, model=model)
1037
+ _warn_system_message_hoisted(model)
1038
+ pending_system = []
1039
+ formatted_messages.append({"role": role, "content": content})
1040
+
1041
+ formatted_messages.extend(
1042
+ _format_in_place_system_messages(pending_system, model=model)
1043
+ )
1044
+ return system, formatted_messages
1045
+
1046
+
1047
+ def _container_id(container: Any) -> str | None:
1048
+ """Return the container ID from either accepted `container` shape."""
1049
+ if isinstance(container, str):
1050
+ return container
1051
+ if isinstance(container, dict):
1052
+ return container.get("id")
1053
+ return None
1054
+
1055
+
1056
+ def _collect_code_execution_tool_ids(formatted_messages: list[dict]) -> set[str]:
1057
+ """Collect `tool_use` IDs that were called by `code_execution`.
1058
+
1059
+ These blocks cannot have `cache_control` applied per Anthropic API
1060
+ requirements.
1061
+ """
1062
+ code_execution_tool_ids: set[str] = set()
1063
+
1064
+ for message in formatted_messages:
1065
+ if message.get("role") != "assistant":
1066
+ continue
1067
+ content = message.get("content", [])
1068
+ if not isinstance(content, list):
1069
+ continue
1070
+ for block in content:
1071
+ if not isinstance(block, dict):
1072
+ continue
1073
+ if block.get("type") != "tool_use":
1074
+ continue
1075
+ caller = block.get("caller")
1076
+ if isinstance(caller, dict):
1077
+ caller_type = caller.get("type", "")
1078
+ if caller_type.startswith("code_execution"):
1079
+ tool_id = block.get("id")
1080
+ if tool_id:
1081
+ code_execution_tool_ids.add(tool_id)
1082
+
1083
+ return code_execution_tool_ids
1084
+
1085
+
1086
+ def _is_code_execution_related_block(
1087
+ block: dict,
1088
+ code_execution_tool_ids: set[str],
1089
+ ) -> bool:
1090
+ """Return whether a content block is related to `code_execution`.
1091
+
1092
+ Returns `True` for blocks that should NOT have `cache_control` applied.
1093
+ """
1094
+ if not isinstance(block, dict):
1095
+ return False
1096
+
1097
+ block_type = block.get("type")
1098
+
1099
+ if block_type == "tool_use":
1100
+ caller = block.get("caller")
1101
+ if isinstance(caller, dict):
1102
+ caller_type = caller.get("type", "")
1103
+ if caller_type.startswith("code_execution"):
1104
+ return True
1105
+
1106
+ if block_type == "tool_result":
1107
+ tool_use_id = block.get("tool_use_id")
1108
+ if tool_use_id and tool_use_id in code_execution_tool_ids:
1109
+ return True
1110
+
1111
+ return False
1112
+
1113
+
1114
+ def _reasoning_effort_levels(profile: object) -> tuple[str, ...]:
1115
+ """Return the reasoning-effort levels declared in a model's profile, if any.
1116
+
1117
+ Defensive against a missing/malformed profile: an absent `profile`, a
1118
+ non-mapping value, or a missing/non-list `reasoning_effort_levels` value is
1119
+ treated as "no levels declared" rather than raising.
1120
+ """
1121
+ if not isinstance(profile, Mapping):
1122
+ return ()
1123
+ levels = profile.get("reasoning_effort_levels")
1124
+ if not isinstance(levels, (list, tuple)):
1125
+ return ()
1126
+ return tuple(levels)
1127
+
1128
+
1129
+ def _supports_mid_conversation_system_messages(model: object) -> bool:
1130
+ """Return whether the model supports mid-conversation system messages."""
1131
+ if not isinstance(model, str):
1132
+ return False
1133
+ return model.startswith(
1134
+ (
1135
+ "claude-fable-5",
1136
+ "claude-mythos-5",
1137
+ "claude-opus-4-8",
1138
+ "claude-opus-5",
1139
+ )
1140
+ )
1141
+
1142
+
1143
+ def _supports_forced_tool_choice(model: str) -> bool:
1144
+ """Return whether the model accepts `tool_choice` types `any` and `tool`."""
1145
+ return not model.startswith(("claude-fable-5-1", "claude-opus-5-5"))
1146
+
1147
+
1148
+ def _is_direct_anthropic_llm_type(llm_type: object) -> bool:
1149
+ """Return whether an `_llm_type` reaches Claude via the direct Anthropic API.
1150
+
1151
+ Only the direct API accepts the top-level `cache_control` request param.
1152
+ Subclasses that route through other transports (Bedrock, future backends)
1153
+ override `_llm_type` and must expand `cache_control` kwargs into
1154
+ block-level breakpoints instead.
1155
+
1156
+ Non-string `_llm_type` values return `False` rather than raising, so a
1157
+ misbehaving subclass falls through to the safer non-direct branch.
1158
+ """
1159
+ return llm_type == "anthropic-chat"
1160
+
1161
+
1162
+ def _apply_cache_control_to_last_eligible_block(
1163
+ formatted_messages: list[dict],
1164
+ cache_control: Any,
1165
+ code_execution_tool_ids: set[str],
1166
+ ) -> bool:
1167
+ """Place `cache_control` on the last block eligible for a breakpoint.
1168
+
1169
+ Walks messages newest-to-oldest and, within each, blocks newest-to-oldest,
1170
+ skipping `code_execution`-related blocks (Anthropic rejects breakpoints
1171
+ there). String message content is promoted to a single text block so the
1172
+ breakpoint can be attached.
1173
+
1174
+ Returns:
1175
+ `True` if a breakpoint was applied, `False` if every candidate was
1176
+ `code_execution`-related (caller should warn and drop the kwarg).
1177
+ """
1178
+ for formatted_message in reversed(formatted_messages):
1179
+ content = formatted_message.get("content")
1180
+ if isinstance(content, list) and content:
1181
+ for block in reversed(content):
1182
+ if not isinstance(block, dict):
1183
+ continue
1184
+ if _is_code_execution_related_block(block, code_execution_tool_ids):
1185
+ continue
1186
+ block["cache_control"] = cache_control
1187
+ return True
1188
+ elif isinstance(content, str):
1189
+ formatted_message["content"] = [
1190
+ {
1191
+ "type": "text",
1192
+ "text": content,
1193
+ "cache_control": cache_control,
1194
+ }
1195
+ ]
1196
+ return True
1197
+ return False
1198
+
1199
+
1200
+ class AnthropicContextOverflowError(anthropic.BadRequestError, ContextOverflowError):
1201
+ """BadRequestError raised when input exceeds Anthropic's context limit."""
1202
+
1203
+
1204
+ class AnthropicAuthenticationError(
1205
+ anthropic.AuthenticationError, ModelAuthenticationError
1206
+ ):
1207
+ """Anthropic authentication error classified as a Coco model error."""
1208
+
1209
+
1210
+ class AnthropicPermissionDeniedError(
1211
+ anthropic.PermissionDeniedError, ModelPermissionDeniedError
1212
+ ):
1213
+ """Anthropic permission error classified as a Coco model error."""
1214
+
1215
+
1216
+ class AnthropicInvalidRequestError(anthropic.BadRequestError, ModelInvalidRequestError):
1217
+ """Anthropic bad-request error classified as a Coco model error."""
1218
+
1219
+
1220
+ class AnthropicModelNotFoundError(anthropic.NotFoundError, ModelNotFoundError):
1221
+ """Anthropic not-found error classified as a Coco model error."""
1222
+
1223
+
1224
+ class AnthropicRateLimitError(anthropic.RateLimitError, ModelRateLimitError):
1225
+ """Anthropic rate-limit error classified as a Coco model error."""
1226
+
1227
+
1228
+ class AnthropicAPIError(anthropic.InternalServerError, ModelAPIError):
1229
+ """Anthropic server error classified as a Coco model error."""
1230
+
1231
+
1232
+ class AnthropicOverloadedError(anthropic.OverloadedError, ModelAPIError):
1233
+ """Anthropic overloaded error (HTTP 529) classified as a Coco model error."""
1234
+
1235
+
1236
+ class AnthropicConnectionError(anthropic.APIConnectionError, ModelConnectionError):
1237
+ """Anthropic connection error classified as a Coco model error."""
1238
+
1239
+
1240
+ class AnthropicTimeoutError(anthropic.APITimeoutError, ModelTimeoutError):
1241
+ """Anthropic timeout error classified as a Coco model error."""
1242
+
1243
+
1244
+ def _raise_if_authentication_error(e: TypeError) -> None:
1245
+ """Re-raise anthropic SDK's missing-credentials `TypeError` with guidance."""
1246
+ if "Could not resolve authentication method" in str(e):
1247
+ msg = (
1248
+ "Anthropic authentication failed: no API key or authorization "
1249
+ "credentials were provided. Set the ANTHROPIC_API_KEY environment "
1250
+ "variable, pass api_key=... to ChatAnthropic, or provide "
1251
+ 'credentials via default_headers={"Authorization": ...}. If you '
1252
+ "are routing through the LangSmith gateway, set LANGSMITH_GATEWAY "
1253
+ "and LANGSMITH_GATEWAY_API_KEY."
1254
+ )
1255
+ raise TypeError(msg) from e
1256
+
1257
+
1258
+ def _handle_anthropic_bad_request(e: anthropic.BadRequestError) -> None:
1259
+ """Handle Anthropic BadRequestError."""
1260
+ if "prompt is too long" in e.message:
1261
+ raise AnthropicContextOverflowError(
1262
+ message=e.message, response=e.response, body=e.body
1263
+ ) from e
1264
+ if ("messages: at least one message is required") in e.message:
1265
+ message = "Received only system message(s). "
1266
+ warnings.warn(message, stacklevel=2)
1267
+ raise AnthropicInvalidRequestError(
1268
+ message=e.message, response=e.response, body=e.body
1269
+ ) from e
1270
+
1271
+
1272
+ def _handle_anthropic_api_error(e: anthropic.APIError) -> None:
1273
+ """Re-raise an Anthropic SDK error as its Coco-classified equivalent."""
1274
+ if isinstance(e, anthropic.AuthenticationError):
1275
+ raise AnthropicAuthenticationError(
1276
+ message=e.message, response=e.response, body=e.body
1277
+ ) from e
1278
+ if isinstance(e, anthropic.PermissionDeniedError):
1279
+ raise AnthropicPermissionDeniedError(
1280
+ message=e.message, response=e.response, body=e.body
1281
+ ) from e
1282
+ if isinstance(e, anthropic.NotFoundError):
1283
+ raise AnthropicModelNotFoundError(
1284
+ message=e.message, response=e.response, body=e.body
1285
+ ) from e
1286
+ if isinstance(e, anthropic.RateLimitError):
1287
+ raise AnthropicRateLimitError(
1288
+ message=e.message, response=e.response, body=e.body
1289
+ ) from e
1290
+ if isinstance(e, anthropic.OverloadedError):
1291
+ raise AnthropicOverloadedError(
1292
+ message=e.message, response=e.response, body=e.body
1293
+ ) from e
1294
+ if isinstance(e, anthropic.InternalServerError):
1295
+ raise AnthropicAPIError(
1296
+ message=e.message, response=e.response, body=e.body
1297
+ ) from e
1298
+ # `APITimeoutError` subclasses `APIConnectionError`, so check it first.
1299
+ if isinstance(e, anthropic.APITimeoutError):
1300
+ raise AnthropicTimeoutError(e.request) from e
1301
+ if isinstance(e, anthropic.APIConnectionError):
1302
+ raise AnthropicConnectionError(message=e.message, request=e.request) from e
1303
+ raise
1304
+
1305
+
1306
+ class ChatAnthropic(BaseChatModel):
1307
+ """Anthropic (Claude) chat models.
1308
+
1309
+ See the [Coco docs for `ChatAnthropic`](https://docs.langchain.com/oss/python/integrations/chat/anthropic)
1310
+ for tutorials, feature walkthroughs, and examples.
1311
+
1312
+ See the [Claude Platform docs](https://platform.claude.com/docs/en/about-claude/models/overview)
1313
+ for a list of the latest models, their capabilities, and pricing.
1314
+
1315
+ Example:
1316
+ ```python
1317
+ # pip install -U trustcoco-anthropic
1318
+ # export ANTHROPIC_API_KEY="your-api-key"
1319
+
1320
+ from coco_anthropic import ChatAnthropic
1321
+
1322
+ model = ChatAnthropic(
1323
+ model="claude-sonnet-4-5-20250929",
1324
+ # temperature=,
1325
+ # max_tokens=,
1326
+ # timeout=,
1327
+ # max_retries=,
1328
+ # base_url="...",
1329
+ # Refer to API reference for full list of parameters
1330
+ )
1331
+ ```
1332
+
1333
+ Example: Add a tool mid-conversation
1334
+ ```python
1335
+ from coco_core.messages import HumanMessage, SystemMessage
1336
+ from coco_anthropic import ChatAnthropic
1337
+
1338
+ model = ChatAnthropic(model="claude-opus-5-5")
1339
+ model.invoke(
1340
+ [
1341
+ HumanMessage("What time is it?"),
1342
+ SystemMessage(
1343
+ [
1344
+ {
1345
+ "type": "tool_addition",
1346
+ "tool": {
1347
+ "type": "tool_definition",
1348
+ "definition": {
1349
+ "name": "get_time",
1350
+ "description": "Get the current time.",
1351
+ "input_schema": {
1352
+ "type": "object",
1353
+ "properties": {},
1354
+ },
1355
+ },
1356
+ },
1357
+ }
1358
+ ]
1359
+ ),
1360
+ ]
1361
+ )
1362
+ ```
1363
+
1364
+ Note:
1365
+ Any param which is not explicitly supported will be passed directly to
1366
+ [`Anthropic.messages.create(...)`](https://platform.claude.com/docs/en/api/python/messages/create)
1367
+ each time to the model is invoked.
1368
+ """
1369
+
1370
+ model_config = ConfigDict(
1371
+ populate_by_name=True,
1372
+ )
1373
+
1374
+ model: str = Field(alias="model_name")
1375
+ """Model name to use."""
1376
+
1377
+ max_tokens: int | None = Field(default=None, alias="max_tokens_to_sample")
1378
+ """Denotes the number of tokens to predict per generation.
1379
+
1380
+ If not specified, this is set dynamically using the model's `max_output_tokens`
1381
+ from its model profile.
1382
+
1383
+ See docs on [model profiles](https://docs.langchain.com/oss/python/langchain/models#model-profiles)
1384
+ for more information.
1385
+ """
1386
+
1387
+ temperature: float | None = None
1388
+ """A non-negative float that tunes the degree of randomness in generation."""
1389
+
1390
+ top_k: int | None = None
1391
+ """Number of most likely tokens to consider at each step."""
1392
+
1393
+ top_p: float | None = None
1394
+ """Total probability mass of tokens to consider at each step."""
1395
+
1396
+ default_request_timeout: float | None = Field(None, alias="timeout")
1397
+ """Timeout for requests to Claude API."""
1398
+
1399
+ # sdk default = 2: https://github.com/anthropics/anthropic-sdk-python?tab=readme-ov-file#retries
1400
+ max_retries: int = 2
1401
+ """Number of retries allowed for requests sent to the Claude API."""
1402
+
1403
+ stop_sequences: list[str] | None = Field(None, alias="stop")
1404
+ """Default stop sequences."""
1405
+
1406
+ anthropic_api_url: str | None = Field(default=None, alias="base_url")
1407
+ """Base URL for API requests. Only specify if using a proxy or service emulator.
1408
+
1409
+ If a value isn't passed in, will attempt to read the value first from
1410
+ `ANTHROPIC_API_URL` and if that is not set, `ANTHROPIC_BASE_URL`.
1411
+
1412
+ If `LANGSMITH_GATEWAY` is set, it is used as a fallback after those env vars.
1413
+ """
1414
+
1415
+ anthropic_api_key: SecretStr = Field(default=SecretStr(""), alias="api_key")
1416
+ """Automatically read from env var `ANTHROPIC_API_KEY` if not provided.
1417
+
1418
+ If `LANGSMITH_GATEWAY` is enabled and the base URL points at the gateway,
1419
+ `LANGSMITH_GATEWAY_API_KEY` is used instead.
1420
+ """
1421
+
1422
+ anthropic_proxy: str | None = Field(
1423
+ default_factory=from_env("ANTHROPIC_PROXY", default=None)
1424
+ )
1425
+ """Proxy to use for the Anthropic clients, will be used for every API call.
1426
+
1427
+ If not provided, will attempt to read from the `ANTHROPIC_PROXY` environment
1428
+ variable.
1429
+ """
1430
+
1431
+ default_headers: Mapping[str, str] | None = None
1432
+ """Headers to pass to the Anthropic clients, will be used for every API call."""
1433
+
1434
+ betas: list[str] | None = None
1435
+ """List of beta features to enable. If specified, invocations will be routed
1436
+ through `client.beta.messages.create`.
1437
+
1438
+ Example: `#!python betas=["token-efficient-tools-2025-02-19"]`
1439
+ """
1440
+ # Can also be passed in w/ model_kwargs, but having it as a param makes better devx
1441
+ #
1442
+ # Precedence order:
1443
+ # 1. Call-time kwargs (e.g., llm.invoke(..., betas=[...]))
1444
+ # 2. model_kwargs (e.g., ChatAnthropic(model_kwargs={"betas": [...]}))
1445
+ # 3. Direct parameter (e.g., ChatAnthropic(betas=[...]))
1446
+
1447
+ model_kwargs: dict[str, Any] = Field(default_factory=dict)
1448
+
1449
+ streaming: bool = False
1450
+ """Whether to use streaming or not."""
1451
+
1452
+ stream_usage: bool = True
1453
+ """Whether to include usage metadata in streaming output.
1454
+
1455
+ If `True`, additional message chunks will be generated during the stream including
1456
+ usage metadata.
1457
+ """
1458
+
1459
+ thinking: dict[str, Any] | None = Field(default=None)
1460
+ """Parameters for Claude reasoning.
1461
+
1462
+ Examples:
1463
+
1464
+ - `#!python {"type": "enabled", "budget_tokens": 10_000}` (pre-4.7 models)
1465
+ - `#!python {"type": "adaptive"}` (Opus 4.6+, Opus 5, Opus 5.5, Sonnet 5)
1466
+ - `#!python {"type": "adaptive", "display": "summarized"}` (Opus 4.7+,
1467
+ Opus 5, Opus 5.5, Sonnet 5)
1468
+ - `#!python {"type": "disabled"}` (Opus 5 and Sonnet 5, where adaptive
1469
+ thinking is on by default)
1470
+
1471
+ !!! note "Claude Opus 4.7+, Opus 5, Opus 5.5, and Sonnet 5"
1472
+
1473
+ `budget_tokens` is removed on these models — use `{"type": "adaptive"}`
1474
+ with `output_config.effort` to control reasoning effort. The default
1475
+ `display` is `"omitted"`; set it to `"summarized"` to receive
1476
+ summarized reasoning in the response. On Opus 5, disabled thinking is
1477
+ supported only at `"high"` effort or below. On Opus 5.5, thinking
1478
+ can't be disabled; omit `thinking` and use `output_config.effort`.
1479
+ """
1480
+
1481
+ output_config: dict[str, Any] | None = None
1482
+ """Configuration options for the model's output.
1483
+
1484
+ Supports the following keys:
1485
+
1486
+ - `effort`: Controls how many tokens Claude uses when responding.
1487
+ One of `"max"`, `"xhigh"`, `"high"`, `"medium"`, or `"low"`.
1488
+ - `format`: Structured output format configuration (typically set via
1489
+ `with_structured_output`).
1490
+ - `task_budget`: Advisory token budget for an agentic loop (beta).
1491
+ E.g., `#!python {"type": "tokens", "total": 128_000}`.
1492
+
1493
+ Example:
1494
+
1495
+ .. code-block:: python
1496
+
1497
+ ChatAnthropic(
1498
+ model="claude-opus-4-7",
1499
+ output_config={
1500
+ "effort": "xhigh",
1501
+ "task_budget": {"type": "tokens", "total": 128_000},
1502
+ },
1503
+ )
1504
+
1505
+ See Anthropic docs on
1506
+ [extended output](https://platform.claude.com/docs/en/api/go/beta/messages/create).
1507
+ """
1508
+
1509
+ reasoning_effort: Literal["max", "xhigh", "high", "medium", "low"] | None = Field(
1510
+ default=None,
1511
+ alias="effort",
1512
+ )
1513
+ """Reasoning effort.
1514
+
1515
+ Configures `output_config.effort`. If `thinking` isn't set explicitly,
1516
+ defaults it to `{"type": "adaptive", "display": "summarized"}`. Can also
1517
+ be passed at call time (for example,
1518
+ `model.invoke(..., reasoning_effort="high")`).
1519
+
1520
+ !!! note "`effort` alias"
1521
+
1522
+ `effort` is also accepted as an alias for this field, at both
1523
+ construction and call time. If both `effort` and `reasoning_effort` are
1524
+ set, `effort` wins (Pydantic's alias-resolution precedence).
1525
+
1526
+ !!! note
1527
+
1528
+ On most models, setting `reasoning_effort` to `'high'` produces exactly
1529
+ the same behavior as omitting the parameter altogether. On Opus 5.5 the
1530
+ default is `'medium'`.
1531
+
1532
+ Example: `reasoning_effort="medium"`
1533
+ """
1534
+
1535
+ mcp_servers: list[dict[str, Any]] | None = None
1536
+ """List of MCP servers to use for the request.
1537
+
1538
+ Example: `#!python mcp_servers=[{"type": "url", "url": "https://mcp.example.com/mcp",
1539
+ "name": "example-mcp"}]`
1540
+ """
1541
+
1542
+ context_management: dict[str, Any] | None = None
1543
+ """Configuration for
1544
+ [context management](https://platform.claude.com/docs/en/build-with-claude/context-editing).
1545
+ """
1546
+
1547
+ container: dict[str, Any] | str | None = None
1548
+ """Code execution container for the request.
1549
+
1550
+ Either a container ID from a previous response, or a dict of container
1551
+ parameters — notably
1552
+ [skills](https://platform.claude.com/docs/en/build-with-claude/skills-guide)
1553
+ to load into the container. Skills require a
1554
+ [code execution](https://docs.langchain.com/oss/python/integrations/chat/anthropic#code-execution)
1555
+ tool to be bound.
1556
+
1557
+ ```python
1558
+ model = ChatAnthropic(
1559
+ model="claude-opus-5",
1560
+ container={
1561
+ "skills": [{"type": "anthropic", "skill_id": "pptx", "version": "latest"}]
1562
+ },
1563
+ ).bind_tools([{"type": "code_execution_20260521", "name": "code_execution"}])
1564
+ ```
1565
+
1566
+ Can also be passed at call time, which overrides the value set here.
1567
+ """
1568
+
1569
+ reuse_last_container: bool | None = None
1570
+ """Automatically reuse container from most recent response (code execution).
1571
+
1572
+ When using the built-in
1573
+ [code execution tool](https://docs.langchain.com/oss/python/integrations/chat/anthropic#code-execution),
1574
+ model responses will include container metadata. Set `reuse_last_container=True`
1575
+ to automatically reuse the container from the most recent response for subsequent
1576
+ invocations.
1577
+ """
1578
+
1579
+ inference_geo: str | None = None
1580
+ """Controls where model inference runs. See Anthropic's
1581
+ [data residency](https://platform.claude.com/docs/en/build-with-claude/data-residency)
1582
+ docs for more information.
1583
+ """
1584
+
1585
+ user_profile_id: str | None = None
1586
+ """User profile ID to attribute the request to.
1587
+
1588
+ Use when acting on behalf of a party other than your organization. Setting this
1589
+ automatically enables the required `user-profiles` beta, routing the request
1590
+ through `client.beta.messages.create`.
1591
+
1592
+ Can also be passed at call time, which overrides the value set here (for example,
1593
+ `model.invoke(..., user_profile_id="uprof_...")`).
1594
+ """
1595
+
1596
+ @property
1597
+ def effort(self) -> Literal["max", "xhigh", "high", "medium", "low"] | None:
1598
+ """Alias for `reasoning_effort`."""
1599
+ return self.reasoning_effort
1600
+
1601
+ @property
1602
+ def _llm_type(self) -> str:
1603
+ """Return type of chat model."""
1604
+ return "anthropic-chat"
1605
+
1606
+ @property
1607
+ def _uses_gateway(self) -> bool:
1608
+ """Whether requests are routed through the LangSmith gateway."""
1609
+ return self.anthropic_api_key.get_secret_value().startswith("lsv2_")
1610
+
1611
+ @property
1612
+ def lc_secrets(self) -> dict[str, str]:
1613
+ """Return a mapping of secret keys to environment variables."""
1614
+ return {
1615
+ "anthropic_api_key": "ANTHROPIC_API_KEY",
1616
+ "mcp_servers": "ANTHROPIC_MCP_SERVERS",
1617
+ }
1618
+
1619
+ @classmethod
1620
+ def is_lc_serializable(cls) -> bool:
1621
+ """Whether the class is serializable in coco."""
1622
+ return True
1623
+
1624
+ @classmethod
1625
+ def get_lc_namespace(cls) -> list[str]:
1626
+ """Get the namespace of the Coco object.
1627
+
1628
+ Returns:
1629
+ `["coco", "chat_models", "anthropic"]`
1630
+ """
1631
+ return ["coco", "chat_models", "anthropic"]
1632
+
1633
+ @property
1634
+ def _identifying_params(self) -> dict[str, Any]:
1635
+ """Get the identifying parameters."""
1636
+ return {
1637
+ "model": self.model,
1638
+ "max_tokens": self.max_tokens,
1639
+ "temperature": self.temperature,
1640
+ "top_k": self.top_k,
1641
+ "top_p": self.top_p,
1642
+ "model_kwargs": self.model_kwargs,
1643
+ "streaming": self.streaming,
1644
+ "max_retries": self.max_retries,
1645
+ "default_request_timeout": self.default_request_timeout,
1646
+ "thinking": self.thinking,
1647
+ "output_config": self.output_config,
1648
+ }
1649
+
1650
+ def _get_ls_params(
1651
+ self,
1652
+ stop: list[str] | None = None,
1653
+ **kwargs: Any,
1654
+ ) -> LangSmithParams:
1655
+ """Get standard params for tracing."""
1656
+ params = self._get_invocation_params(stop=stop, **kwargs)
1657
+ ls_params = LangSmithParams(
1658
+ ls_provider="anthropic",
1659
+ ls_model_name=params.get("model", self.model),
1660
+ ls_model_type="chat",
1661
+ ls_temperature=params.get("temperature", self.temperature),
1662
+ )
1663
+ if ls_max_tokens := params.get("max_tokens", self.max_tokens):
1664
+ ls_params["ls_max_tokens"] = ls_max_tokens
1665
+ if ls_stop := stop or params.get("stop", None):
1666
+ ls_params["ls_stop"] = ls_stop
1667
+ return ls_params
1668
+
1669
+ @model_validator(mode="before")
1670
+ @classmethod
1671
+ def set_default_max_tokens(cls, values: dict[str, Any]) -> Any:
1672
+ """Set default `max_tokens` from model profile with fallback."""
1673
+ if values.get("max_tokens") is None:
1674
+ model = values.get("model") or values.get("model_name")
1675
+ profile = _get_default_model_profile(model) if model else {}
1676
+ values["max_tokens"] = profile.get(
1677
+ "max_output_tokens", _FALLBACK_MAX_OUTPUT_TOKENS
1678
+ )
1679
+ return values
1680
+
1681
+ @model_validator(mode="before")
1682
+ @classmethod
1683
+ def build_extra(cls, values: dict) -> Any:
1684
+ """Build model kwargs."""
1685
+ all_required_field_names = get_pydantic_field_names(cls)
1686
+ return _build_model_kwargs(values, all_required_field_names)
1687
+
1688
+ @model_validator(mode="after")
1689
+ def _set_anthropic_version(self) -> Self:
1690
+ """Set package version in metadata."""
1691
+ self._add_version("trustcoco-anthropic", __version__)
1692
+ return self
1693
+
1694
+ @model_validator(mode="before")
1695
+ @classmethod
1696
+ def _resolve_gateway(cls, values: Any) -> Any:
1697
+ """Resolve the base URL and API key, applying LangSmith gateway settings.
1698
+
1699
+ An explicit ``base_url``/``api_key`` always wins. Otherwise the base URL
1700
+ falls back to ``ANTHROPIC_API_URL``/``ANTHROPIC_BASE_URL``, then the
1701
+ LangSmith gateway, then the Anthropic default. The gateway key is
1702
+ preferred only when the base URL came from the gateway; for any other
1703
+ endpoint the provider key wins, and the gateway key is a candidate only
1704
+ when the gateway is enabled.
1705
+ """
1706
+ if isinstance(values, dict):
1707
+ _apply_gateway_config(
1708
+ values,
1709
+ cls,
1710
+ base_url_field="anthropic_api_url",
1711
+ api_key_field="anthropic_api_key",
1712
+ provider_path="anthropic",
1713
+ base_url_env=["ANTHROPIC_API_URL", "ANTHROPIC_BASE_URL"],
1714
+ api_key_env="ANTHROPIC_API_KEY",
1715
+ default_base_url="https://api.anthropic.com",
1716
+ )
1717
+ return values
1718
+
1719
+ def _resolve_model_profile(self) -> ModelProfile | None:
1720
+ profile = _get_default_model_profile(self.model) or None
1721
+ if profile is not None and self.betas and "context-1m-2025-08-07" in self.betas:
1722
+ profile["max_input_tokens"] = 1_000_000
1723
+ return profile
1724
+
1725
+ @cached_property
1726
+ def _client_params(self) -> dict[str, Any]:
1727
+ # Merge User-Agent with user-provided headers (user headers take precedence)
1728
+ default_headers = {"User-Agent": _USER_AGENT}
1729
+ if self.default_headers:
1730
+ default_headers.update(self.default_headers)
1731
+
1732
+ client_params: dict[str, Any] = {
1733
+ "api_key": self.anthropic_api_key.get_secret_value(),
1734
+ "base_url": self.anthropic_api_url,
1735
+ "max_retries": self.max_retries,
1736
+ "default_headers": default_headers,
1737
+ }
1738
+ # value <= 0 indicates the param should be ignored. None is a meaningful value
1739
+ # for Anthropic client and treated differently than not specifying the param at
1740
+ # all.
1741
+ if self.default_request_timeout is None or self.default_request_timeout > 0:
1742
+ client_params["timeout"] = self.default_request_timeout
1743
+
1744
+ return client_params
1745
+
1746
+ @cached_property
1747
+ def _client(self) -> anthropic.Client:
1748
+ client_params = self._client_params
1749
+ http_client_params = {"base_url": client_params["base_url"]}
1750
+ if "timeout" in client_params:
1751
+ http_client_params["timeout"] = client_params["timeout"]
1752
+ if self.anthropic_proxy:
1753
+ http_client_params["anthropic_proxy"] = self.anthropic_proxy
1754
+ http_client = _get_default_httpx_client(**http_client_params)
1755
+ params = {
1756
+ **client_params,
1757
+ "http_client": http_client,
1758
+ }
1759
+ return anthropic.Client(**params)
1760
+
1761
+ @cached_property
1762
+ def _async_client(self) -> anthropic.AsyncClient:
1763
+ client_params = self._client_params
1764
+ http_client_params = {"base_url": client_params["base_url"]}
1765
+ if "timeout" in client_params:
1766
+ http_client_params["timeout"] = client_params["timeout"]
1767
+ if self.anthropic_proxy:
1768
+ http_client_params["anthropic_proxy"] = self.anthropic_proxy
1769
+ http_client = _get_default_async_httpx_client(**http_client_params)
1770
+ params = {
1771
+ **client_params,
1772
+ "http_client": http_client,
1773
+ }
1774
+ return anthropic.AsyncClient(**params)
1775
+
1776
+ def _assert_valid_model_configuration(self, kwargs: Mapping[str, Any]) -> None:
1777
+ """Validate resolved request configuration against model-specific invariants."""
1778
+ request_config = {**self.model_kwargs, **kwargs}
1779
+ thinking = request_config.get("thinking")
1780
+ if self.thinking is not None:
1781
+ thinking = self.thinking
1782
+
1783
+ output_config = dict(self.output_config or {})
1784
+ if self.reasoning_effort:
1785
+ output_config["effort"] = self.reasoning_effort
1786
+ request_output_config = request_config.get("output_config")
1787
+ if isinstance(request_output_config, dict):
1788
+ output_config.update(request_output_config)
1789
+ effort = request_config.get("effort")
1790
+ if effort is None:
1791
+ effort = request_config.get("reasoning_effort")
1792
+ if effort:
1793
+ output_config["effort"] = effort
1794
+
1795
+ is_fable_model = self.model.startswith("claude-fable-5")
1796
+ if is_fable_model:
1797
+ top_k = request_config.get("top_k", self.top_k)
1798
+ top_p = request_config.get("top_p", self.top_p)
1799
+ temperature = request_config.get("temperature", self.temperature)
1800
+ if top_k is not None:
1801
+ msg = f"`top_k` is not supported for {self.model}."
1802
+ raise ValueError(msg)
1803
+ if top_p is not None and top_p != 1:
1804
+ msg = (
1805
+ f"`top_p` is not supported for {self.model} at non-default values."
1806
+ )
1807
+ raise ValueError(msg)
1808
+ if temperature is not None and temperature != 1:
1809
+ msg = (
1810
+ f"`temperature` is not supported for {self.model} at "
1811
+ "non-default values."
1812
+ )
1813
+ raise ValueError(msg)
1814
+ if isinstance(thinking, Mapping) and thinking.get("type") == "disabled":
1815
+ msg = (
1816
+ '`thinking={"type": "disabled"}` is not supported for '
1817
+ f"{self.model}; omit `thinking` to use adaptive thinking."
1818
+ )
1819
+ raise ValueError(msg)
1820
+
1821
+ if (
1822
+ (self.model.startswith("claude-opus-5") or is_fable_model)
1823
+ and isinstance(thinking, Mapping)
1824
+ and thinking.get("type") == "enabled"
1825
+ ):
1826
+ msg = (
1827
+ '`thinking={"type": "enabled", "budget_tokens": ...}` is not '
1828
+ f"supported for {self.model}; use adaptive thinking and "
1829
+ "`output_config.effort` instead."
1830
+ )
1831
+ raise ValueError(msg)
1832
+
1833
+ if (
1834
+ self.model.startswith("claude-opus-5")
1835
+ and isinstance(thinking, Mapping)
1836
+ and thinking.get("type") == "disabled"
1837
+ and output_config.get("effort") in {"xhigh", "max"}
1838
+ ):
1839
+ msg = (
1840
+ '`thinking={"type": "disabled"}` is not supported for '
1841
+ f"{self.model} with "
1842
+ f"`output_config.effort={output_config['effort']!r}`; use adaptive "
1843
+ "thinking, omit `thinking`, or set effort to `high` or below."
1844
+ )
1845
+ raise ValueError(msg)
1846
+
1847
+ def _get_request_payload(
1848
+ self,
1849
+ input_: LanguageModelInput,
1850
+ *,
1851
+ stop: list[str] | None = None,
1852
+ **kwargs: Any,
1853
+ ) -> dict:
1854
+ """Get the request payload for the Anthropic API."""
1855
+ self._assert_valid_model_configuration(kwargs)
1856
+ messages = self._convert_input(input_).to_messages()
1857
+
1858
+ for idx, message in enumerate(messages):
1859
+ # Translate v1 content
1860
+ if (
1861
+ isinstance(message, AIMessage)
1862
+ and message.response_metadata.get("output_version") == "v1"
1863
+ ):
1864
+ tcs: list[types.ToolCall] = [
1865
+ {
1866
+ "type": "tool_call",
1867
+ "name": tool_call["name"],
1868
+ "args": tool_call["args"],
1869
+ "id": tool_call.get("id"),
1870
+ }
1871
+ for tool_call in message.tool_calls
1872
+ ]
1873
+ messages[idx] = message.model_copy(
1874
+ update={
1875
+ "content": _convert_from_v1_to_anthropic(
1876
+ cast(list[types.ContentBlock], message.content),
1877
+ tcs,
1878
+ message.response_metadata.get("model_provider"),
1879
+ )
1880
+ }
1881
+ )
1882
+
1883
+ system, formatted_messages = _format_messages(messages, model=self.model)
1884
+ if isinstance(system, list) and not system:
1885
+ # Every block was narrowed away (or the message was empty to begin
1886
+ # with). An empty block array carries no instructions, so drop the
1887
+ # field rather than sending it. `_format_messages` has already
1888
+ # claimed the slot, so a second hoisted system message still errors.
1889
+ system = None
1890
+
1891
+ # Only the direct Anthropic API accepts top-level `cache_control`.
1892
+ # Subclasses that route through other transports (e.g. Bedrock) expand
1893
+ # `cache_control` kwargs into block-level breakpoints, the only form
1894
+ # those transports accept.
1895
+ if not _is_direct_anthropic_llm_type(getattr(self, "_llm_type", None)):
1896
+ cache_control = kwargs.pop("cache_control", None)
1897
+ # Empty `formatted_messages` has nothing to attach a breakpoint to;
1898
+ # skip silently. The warning below is reserved for the surprising
1899
+ # case where messages exist but every candidate block is ineligible.
1900
+ if cache_control and formatted_messages:
1901
+ code_execution_tool_ids = _collect_code_execution_tool_ids(
1902
+ formatted_messages
1903
+ )
1904
+ applied = _apply_cache_control_to_last_eligible_block(
1905
+ formatted_messages, cache_control, code_execution_tool_ids
1906
+ )
1907
+ if not applied:
1908
+ warnings.warn(
1909
+ "`cache_control` kwarg was dropped: no eligible "
1910
+ "content block found (all candidates are "
1911
+ "`code_execution`-related, which Anthropic forbids "
1912
+ "breakpoints on).",
1913
+ UserWarning,
1914
+ stacklevel=2,
1915
+ )
1916
+
1917
+ payload = {
1918
+ "model": self.model,
1919
+ "max_tokens": self.max_tokens,
1920
+ "messages": formatted_messages,
1921
+ "temperature": self.temperature,
1922
+ "top_k": self.top_k,
1923
+ "top_p": self.top_p,
1924
+ "stop_sequences": stop or self.stop_sequences,
1925
+ "betas": self.betas,
1926
+ "context_management": self.context_management,
1927
+ "mcp_servers": self.mcp_servers,
1928
+ "container": self.container,
1929
+ "user_profile_id": self.user_profile_id,
1930
+ "system": system,
1931
+ **self.model_kwargs,
1932
+ **kwargs,
1933
+ }
1934
+ # Captured before `self.thinking` is applied below, so a call-time
1935
+ # `thinking` kwarg counts as "explicitly set" too.
1936
+ thinking_explicitly_set = "thinking" in payload or self.thinking is not None
1937
+ if self.thinking is not None:
1938
+ payload["thinking"] = self.thinking
1939
+ if self.inference_geo is not None:
1940
+ payload["inference_geo"] = self.inference_geo
1941
+ if self.model.startswith("claude-fable-5"):
1942
+ payload.pop("temperature", None)
1943
+ payload.pop("top_k", None)
1944
+ payload.pop("top_p", None)
1945
+
1946
+ # Handle output_config and effort parameter
1947
+ # Priority: kwarg `effort`/`reasoning_effort` > kwarg `output_config`
1948
+ # > self.reasoning_effort > self.output_config
1949
+ output_config: dict[str, Any] = {}
1950
+ if self.output_config:
1951
+ output_config.update(self.output_config)
1952
+ reasoning_effort_applied = False
1953
+ if self.reasoning_effort:
1954
+ output_config["effort"] = self.reasoning_effort
1955
+ reasoning_effort_applied = True
1956
+ payload_oc = payload.get("output_config")
1957
+ if isinstance(payload_oc, dict):
1958
+ output_config.update(payload_oc)
1959
+
1960
+ # Neither `reasoning_effort` nor its `effort` alias are Anthropic API
1961
+ # fields. Pop them so they never leak through as top-level keys.
1962
+ effort_kwarg = payload.pop("effort", None)
1963
+ reasoning_effort_kwarg = payload.pop("reasoning_effort", None)
1964
+ # `effort` wins if both are set at call time, matching the
1965
+ # construction-time alias-resolution precedence (`Field(alias="effort")`).
1966
+ reasoning_effort_override = (
1967
+ effort_kwarg if effort_kwarg is not None else reasoning_effort_kwarg
1968
+ )
1969
+ if reasoning_effort_override:
1970
+ output_config["effort"] = reasoning_effort_override
1971
+ reasoning_effort_applied = True
1972
+
1973
+ if output_config:
1974
+ payload["output_config"] = output_config
1975
+
1976
+ # Default adaptive thinking when `reasoning_effort` is set, unless the
1977
+ # caller explicitly provided `thinking`. Gated on `xhigh` support: only
1978
+ # Opus 4.7+/Sonnet 5 accept the adaptive+summarized `thinking` shape —
1979
+ # sending it to an older model (e.g. Opus 4.5, 4.6) is rejected by the
1980
+ # API with "adaptive thinking is not supported on this model".
1981
+ if (
1982
+ reasoning_effort_applied
1983
+ and not thinking_explicitly_set
1984
+ and "xhigh" in _reasoning_effort_levels(self.profile)
1985
+ ):
1986
+ payload["thinking"] = {"type": "adaptive", "display": "summarized"}
1987
+
1988
+ if "response_format" in payload:
1989
+ # response_format present when using agents.create_agent's ProviderStrategy
1990
+ # ---
1991
+ # ProviderStrategy converts to OpenAI-style format, which passes kwargs to
1992
+ # ChatAnthropic, ending up in our payload
1993
+ response_format = payload.pop("response_format")
1994
+ if (
1995
+ isinstance(response_format, dict)
1996
+ and response_format.get("type") == "json_schema"
1997
+ and "schema" in response_format.get("json_schema", {})
1998
+ ):
1999
+ response_format = cast(dict, response_format["json_schema"]["schema"])
2000
+ # Convert OpenAI-style response_format to Anthropic's output_config.format
2001
+ output_config = payload.setdefault("output_config", {})
2002
+ output_config["format"] = _convert_to_anthropic_output_config_format(
2003
+ response_format
2004
+ )
2005
+
2006
+ # Handle deprecated output_format parameter for backward compatibility
2007
+ if "output_format" in payload:
2008
+ warnings.warn(
2009
+ "The 'output_format' parameter is deprecated and will be removed in "
2010
+ "trustcoco-anthropic 2.0.0. Use 'output_config={\"format\": ...}' "
2011
+ "instead.",
2012
+ DeprecationWarning,
2013
+ stacklevel=2,
2014
+ )
2015
+ output_config = payload.setdefault("output_config", {})
2016
+ output_config["format"] = payload.pop("output_format")
2017
+
2018
+ container = payload.get("container")
2019
+
2020
+ if self.reuse_last_container and not _container_id(container):
2021
+ # Reuse the container from the most recent response (code execution)
2022
+ for message in reversed(messages):
2023
+ if (
2024
+ isinstance(message, AIMessage)
2025
+ and isinstance(
2026
+ last_container := message.response_metadata.get("container"),
2027
+ dict,
2028
+ )
2029
+ and (container_id := last_container.get("id"))
2030
+ ):
2031
+ payload["container"] = (
2032
+ {**container, "id": container_id}
2033
+ if isinstance(container, dict)
2034
+ else container_id
2035
+ )
2036
+ break
2037
+
2038
+ if (
2039
+ isinstance(container, dict)
2040
+ and container.get("skills")
2041
+ and not any(
2042
+ isinstance(tool, dict)
2043
+ and str(tool.get("type", "")).startswith("code_execution")
2044
+ for tool in (payload.get("tools") or [])
2045
+ )
2046
+ ):
2047
+ warnings.warn(
2048
+ "Skills require a code execution tool to be bound, e.g. "
2049
+ '`bind_tools([{"type": "code_execution_20260521", '
2050
+ '"name": "code_execution"}])`.',
2051
+ UserWarning,
2052
+ stacklevel=2,
2053
+ )
2054
+
2055
+ # Note: Beta headers are no longer required for structured outputs
2056
+ # (output_config.format or strict tool use) as they are now generally available
2057
+ if "tools" in payload and isinstance(payload["tools"], list):
2058
+ # Auto-append required betas for specific tool types and input_examples
2059
+ has_input_examples = False
2060
+ for tool in payload["tools"]:
2061
+ if isinstance(tool, dict):
2062
+ tool_type = tool.get("type")
2063
+ if tool_type and tool_type in _TOOL_TYPE_TO_BETA:
2064
+ required_beta = _TOOL_TYPE_TO_BETA[tool_type]
2065
+ if payload["betas"]:
2066
+ if required_beta not in payload["betas"]:
2067
+ payload["betas"] = [
2068
+ *payload["betas"],
2069
+ required_beta,
2070
+ ]
2071
+ else:
2072
+ payload["betas"] = [required_beta]
2073
+ # Check for input_examples
2074
+ if tool.get("input_examples"):
2075
+ has_input_examples = True
2076
+
2077
+ # Auto-append header for input_examples
2078
+ if has_input_examples:
2079
+ required_beta = "advanced-tool-use-2025-11-20"
2080
+ if payload["betas"]:
2081
+ if required_beta not in payload["betas"]:
2082
+ payload["betas"] = [*payload["betas"], required_beta]
2083
+ else:
2084
+ payload["betas"] = [required_beta]
2085
+
2086
+ # Auto-append required beta for mcp_servers
2087
+ if payload.get("mcp_servers"):
2088
+ required_beta = "mcp-client-2025-11-20"
2089
+ if payload["betas"]:
2090
+ # Append to existing betas if not already present
2091
+ if required_beta not in payload["betas"]:
2092
+ payload["betas"] = [*payload["betas"], required_beta]
2093
+ else:
2094
+ payload["betas"] = [required_beta]
2095
+
2096
+ # Auto-append required beta for task_budget
2097
+ resolved_oc = payload.get("output_config")
2098
+ if isinstance(resolved_oc, dict) and resolved_oc.get("task_budget"):
2099
+ required_beta = "task-budgets-2026-03-13"
2100
+ if payload.get("betas"):
2101
+ if required_beta not in payload["betas"]:
2102
+ payload["betas"] = [*payload["betas"], required_beta]
2103
+ else:
2104
+ payload["betas"] = [required_beta]
2105
+
2106
+ # Auto-append required beta for the `updates` thinking display mode
2107
+ thinking = payload.get("thinking")
2108
+ if isinstance(thinking, dict) and thinking.get("display") == "updates":
2109
+ required_beta = "thinking-display-updates-2026-08-18"
2110
+ if payload.get("betas"):
2111
+ if required_beta not in payload["betas"]:
2112
+ payload["betas"] = [*payload["betas"], required_beta]
2113
+ else:
2114
+ payload["betas"] = [required_beta]
2115
+
2116
+ system_contents = [
2117
+ message.get("content")
2118
+ for message in (payload.get("messages") or [])
2119
+ if message.get("role") == "system"
2120
+ ]
2121
+ has_tool_change = any(_has_tool_change_block(c) for c in system_contents)
2122
+ has_inline_definition = any(
2123
+ _has_inline_tool_definition(c) for c in system_contents
2124
+ )
2125
+ explicit_betas = payload.get("betas") or []
2126
+ tool_change_beta = (
2127
+ _INLINE_TOOLS_BETA
2128
+ if has_inline_definition
2129
+ else (
2130
+ _MID_CONVERSATION_TOOL_CHANGES_BETA
2131
+ if has_tool_change and _INLINE_TOOLS_BETA not in explicit_betas
2132
+ else None
2133
+ )
2134
+ )
2135
+ if tool_change_beta and tool_change_beta not in explicit_betas:
2136
+ payload["betas"] = [*explicit_betas, tool_change_beta]
2137
+
2138
+ # Auto-append required beta for user_profile_id
2139
+ if payload.get("user_profile_id"):
2140
+ required_beta = "user-profiles-2026-03-24"
2141
+ if payload.get("betas"):
2142
+ if required_beta not in payload["betas"]:
2143
+ payload["betas"] = [*payload["betas"], required_beta]
2144
+ else:
2145
+ payload["betas"] = [required_beta]
2146
+
2147
+ return _route_unsupported_sampling_params(
2148
+ {k: v for k, v in payload.items() if v is not None}
2149
+ )
2150
+
2151
+ def _create(self, payload: dict) -> Any:
2152
+ try:
2153
+ if "betas" in payload:
2154
+ return self._client.beta.messages.with_raw_response.create(**payload)
2155
+ return self._client.messages.with_raw_response.create(**payload)
2156
+ except TypeError as e:
2157
+ _raise_if_authentication_error(e)
2158
+ raise
2159
+
2160
+ async def _acreate(self, payload: dict) -> Any:
2161
+ try:
2162
+ if "betas" in payload:
2163
+ return await self._async_client.beta.messages.with_raw_response.create(
2164
+ **payload
2165
+ )
2166
+ return await self._async_client.messages.with_raw_response.create(**payload)
2167
+ except TypeError as e:
2168
+ _raise_if_authentication_error(e)
2169
+ raise
2170
+
2171
+ def _stream(
2172
+ self,
2173
+ messages: list[BaseMessage],
2174
+ stop: list[str] | None = None,
2175
+ run_manager: CallbackManagerForLLMRun | None = None,
2176
+ *,
2177
+ stream_usage: bool | None = None,
2178
+ **kwargs: Any,
2179
+ ) -> Iterator[ChatGenerationChunk]:
2180
+ if stream_usage is None:
2181
+ stream_usage = self.stream_usage
2182
+ kwargs["stream"] = True
2183
+ payload = self._get_request_payload(messages, stop=stop, **kwargs)
2184
+ try:
2185
+ raw_response = self._create(payload)
2186
+ base_generation_info: dict[str, Any] = {}
2187
+ if self._uses_gateway:
2188
+ _add_gateway_metadata(base_generation_info, raw_response)
2189
+ stream = raw_response.parse()
2190
+ coerce_content_to_string = (
2191
+ not _tools_in_params(payload)
2192
+ and not _documents_in_params(payload)
2193
+ and not _thinking_in_params(payload)
2194
+ and not _compact_in_params(payload)
2195
+ )
2196
+ block_start_event = None
2197
+ is_first_chunk = True
2198
+ for event in stream:
2199
+ msg, block_start_event = self._make_message_chunk_from_anthropic_event(
2200
+ event,
2201
+ stream_usage=stream_usage,
2202
+ coerce_content_to_string=coerce_content_to_string,
2203
+ block_start_event=block_start_event,
2204
+ )
2205
+ if msg is not None:
2206
+ chunk = ChatGenerationChunk(
2207
+ message=msg,
2208
+ generation_info=base_generation_info
2209
+ if is_first_chunk
2210
+ else None,
2211
+ )
2212
+ is_first_chunk = False
2213
+ if run_manager and isinstance(msg.content, str):
2214
+ run_manager.on_llm_new_token(msg.content, chunk=chunk)
2215
+ yield chunk
2216
+ except anthropic.BadRequestError as e:
2217
+ _handle_anthropic_bad_request(e)
2218
+ except anthropic.APIError as e:
2219
+ _handle_anthropic_api_error(e)
2220
+
2221
+ async def _astream(
2222
+ self,
2223
+ messages: list[BaseMessage],
2224
+ stop: list[str] | None = None,
2225
+ run_manager: AsyncCallbackManagerForLLMRun | None = None,
2226
+ *,
2227
+ stream_usage: bool | None = None,
2228
+ **kwargs: Any,
2229
+ ) -> AsyncIterator[ChatGenerationChunk]:
2230
+ if stream_usage is None:
2231
+ stream_usage = self.stream_usage
2232
+ kwargs["stream"] = True
2233
+ payload = self._get_request_payload(messages, stop=stop, **kwargs)
2234
+ try:
2235
+ raw_response = await self._acreate(payload)
2236
+ base_generation_info: dict[str, Any] = {}
2237
+ if self._uses_gateway:
2238
+ _add_gateway_metadata(base_generation_info, raw_response)
2239
+ stream = await _aparse(raw_response)
2240
+ coerce_content_to_string = (
2241
+ not _tools_in_params(payload)
2242
+ and not _documents_in_params(payload)
2243
+ and not _thinking_in_params(payload)
2244
+ and not _compact_in_params(payload)
2245
+ )
2246
+ block_start_event = None
2247
+ is_first_chunk = True
2248
+ async for event in stream:
2249
+ msg, block_start_event = self._make_message_chunk_from_anthropic_event(
2250
+ event,
2251
+ stream_usage=stream_usage,
2252
+ coerce_content_to_string=coerce_content_to_string,
2253
+ block_start_event=block_start_event,
2254
+ )
2255
+ if msg is not None:
2256
+ chunk = ChatGenerationChunk(
2257
+ message=msg,
2258
+ generation_info=base_generation_info
2259
+ if is_first_chunk
2260
+ else None,
2261
+ )
2262
+ is_first_chunk = False
2263
+ if run_manager and isinstance(msg.content, str):
2264
+ await run_manager.on_llm_new_token(msg.content, chunk=chunk)
2265
+ yield chunk
2266
+ except anthropic.BadRequestError as e:
2267
+ _handle_anthropic_bad_request(e)
2268
+ except anthropic.APIError as e:
2269
+ _handle_anthropic_api_error(e)
2270
+
2271
+ def _make_message_chunk_from_anthropic_event(
2272
+ self,
2273
+ event: anthropic.types.RawMessageStreamEvent,
2274
+ *,
2275
+ stream_usage: bool = True,
2276
+ coerce_content_to_string: bool,
2277
+ block_start_event: anthropic.types.RawMessageStreamEvent | None = None,
2278
+ ) -> tuple[AIMessageChunk | None, anthropic.types.RawMessageStreamEvent | None]:
2279
+ """Convert Anthropic streaming event to `AIMessageChunk`.
2280
+
2281
+ Args:
2282
+ event: Raw streaming event from Anthropic SDK
2283
+ stream_usage: Whether to include usage metadata in the output chunks.
2284
+ coerce_content_to_string: Whether to convert structured content to plain
2285
+ text strings.
2286
+
2287
+ When `True`, only text content is preserved; when `False`, structured
2288
+ content like tool calls and citations are maintained.
2289
+ block_start_event: Previous content block start event, used for tracking
2290
+ tool use blocks and maintaining context across related events.
2291
+
2292
+ Returns:
2293
+ Tuple with
2294
+ - `AIMessageChunk`: Converted message chunk with appropriate content and
2295
+ metadata, or `None` if the event doesn't produce a chunk
2296
+ - `RawMessageStreamEvent`: Updated `block_start_event` for tracking
2297
+ content blocks across sequential events, or `None` if not applicable
2298
+
2299
+ Note:
2300
+ Not all Anthropic events result in message chunks. Events like internal
2301
+ state changes return `None` for the message chunk while potentially
2302
+ updating the `block_start_event` for context tracking.
2303
+ """
2304
+ message_chunk: AIMessageChunk | None = None
2305
+ # Reference: Anthropic SDK streaming implementation
2306
+ # https://github.com/anthropics/anthropic-sdk-python/blob/main/src/anthropic/lib/streaming/_messages.py # noqa: E501
2307
+ if event.type == "message_start" and stream_usage:
2308
+ # Capture model name, but don't include usage_metadata yet
2309
+ # as it will be properly reported in message_delta with complete info
2310
+ if hasattr(event.message, "model"):
2311
+ response_metadata: dict[str, Any] = {"model_name": event.message.model}
2312
+ else:
2313
+ response_metadata = {}
2314
+
2315
+ message_chunk = AIMessageChunk(
2316
+ content="" if coerce_content_to_string else [],
2317
+ response_metadata=response_metadata,
2318
+ )
2319
+
2320
+ elif (
2321
+ event.type == "content_block_start"
2322
+ and event.content_block is not None
2323
+ and (
2324
+ "tool_result" in event.content_block.type
2325
+ or "tool_use" in event.content_block.type
2326
+ or "document" in event.content_block.type
2327
+ or "redacted_thinking" in event.content_block.type
2328
+ )
2329
+ ):
2330
+ if coerce_content_to_string:
2331
+ warnings.warn("Received unexpected tool content block.", stacklevel=2)
2332
+
2333
+ content_block = event.content_block.model_dump()
2334
+ if "caller" in content_block and content_block["caller"] is None:
2335
+ content_block.pop("caller")
2336
+ content_block["index"] = event.index
2337
+ if event.content_block.type == "tool_use":
2338
+ if (
2339
+ parsed_args := getattr(event.content_block, "input", None)
2340
+ ) and isinstance(parsed_args, dict):
2341
+ # In some cases parsed args are represented in start event, with no
2342
+ # following input_json_delta events
2343
+ args = json.dumps(parsed_args)
2344
+ else:
2345
+ args = ""
2346
+ tool_call_chunk = create_tool_call_chunk(
2347
+ index=event.index,
2348
+ id=event.content_block.id,
2349
+ name=event.content_block.name,
2350
+ args=args,
2351
+ )
2352
+ tool_call_chunks = [tool_call_chunk]
2353
+ else:
2354
+ tool_call_chunks = []
2355
+ message_chunk = AIMessageChunk(
2356
+ content=[content_block],
2357
+ tool_call_chunks=tool_call_chunks,
2358
+ )
2359
+ block_start_event = event
2360
+
2361
+ elif (
2362
+ event.type == "content_block_start"
2363
+ and event.content_block is not None
2364
+ and event.content_block.type in ("text", "thinking")
2365
+ ):
2366
+ # Anthropic can place the opening content of a text or thinking block
2367
+ # directly on the `content_block_start` event instead of in a
2368
+ # following delta. This is common for the assistant turn that follows
2369
+ # a tool result. Emit that initial content here so it is not dropped
2370
+ # from the aggregated message. The deltas that follow are emitted as
2371
+ # separate chunks sharing this block's `index`; chunk addition
2372
+ # (`AIMessageChunk.__add__`) later coalesces them into one block.
2373
+ block_start_event = event
2374
+ if event.content_block.type == "text":
2375
+ text = getattr(event.content_block, "text", "") or ""
2376
+ if text:
2377
+ if coerce_content_to_string:
2378
+ message_chunk = AIMessageChunk(content=text)
2379
+ else:
2380
+ content_block = event.content_block.model_dump()
2381
+ content_block["index"] = event.index
2382
+ if content_block.get("citations") is None:
2383
+ content_block.pop("citations", None)
2384
+ message_chunk = AIMessageChunk(content=[content_block])
2385
+ else: # thinking
2386
+ thinking = getattr(event.content_block, "thinking", "") or ""
2387
+ signature = getattr(event.content_block, "signature", "") or ""
2388
+ if thinking or signature:
2389
+ content_block = event.content_block.model_dump()
2390
+ content_block["index"] = event.index
2391
+ content_block["type"] = "thinking"
2392
+ message_chunk = AIMessageChunk(content=[content_block])
2393
+
2394
+ # Process incremental content updates
2395
+ elif event.type == "content_block_delta":
2396
+ # Text and citation deltas (incremental text content)
2397
+ if event.delta.type in ("text_delta", "citations_delta"):
2398
+ if coerce_content_to_string and hasattr(event.delta, "text"):
2399
+ text = getattr(event.delta, "text", "")
2400
+ message_chunk = AIMessageChunk(content=text)
2401
+ else:
2402
+ content_block = event.delta.model_dump()
2403
+ content_block["index"] = event.index
2404
+
2405
+ # All citation deltas are part of a text block
2406
+ content_block["type"] = "text"
2407
+ if "citation" in content_block:
2408
+ # Assign citations to a list if present
2409
+ content_block["citations"] = [content_block.pop("citation")]
2410
+ message_chunk = AIMessageChunk(content=[content_block])
2411
+
2412
+ # Reasoning
2413
+ elif event.delta.type in {"thinking_delta", "signature_delta"}:
2414
+ content_block = event.delta.model_dump()
2415
+ content_block["index"] = event.index
2416
+ content_block["type"] = "thinking"
2417
+ if event.delta.type == "signature_delta":
2418
+ content_block.setdefault("thinking", "")
2419
+ message_chunk = AIMessageChunk(content=[content_block])
2420
+
2421
+ # Tool input JSON (streaming tool arguments)
2422
+ elif event.delta.type == "input_json_delta":
2423
+ content_block = event.delta.model_dump()
2424
+ content_block["index"] = event.index
2425
+ start_event_block = (
2426
+ getattr(block_start_event, "content_block", None)
2427
+ if block_start_event
2428
+ else None
2429
+ )
2430
+ if (
2431
+ start_event_block is not None
2432
+ and getattr(start_event_block, "type", None) == "tool_use"
2433
+ ):
2434
+ tool_call_chunk = create_tool_call_chunk(
2435
+ index=event.index,
2436
+ id=None,
2437
+ name=None,
2438
+ args=event.delta.partial_json,
2439
+ )
2440
+ tool_call_chunks = [tool_call_chunk]
2441
+ else:
2442
+ tool_call_chunks = []
2443
+ message_chunk = AIMessageChunk(
2444
+ content=[content_block],
2445
+ tool_call_chunks=tool_call_chunks,
2446
+ )
2447
+
2448
+ # Compaction block
2449
+ elif event.delta.type == "compaction_delta":
2450
+ content_block = event.delta.model_dump()
2451
+ content_block["index"] = event.index
2452
+ content_block["type"] = "compaction"
2453
+ if (
2454
+ "encrypted_content" in content_block
2455
+ and content_block["encrypted_content"] is None
2456
+ ):
2457
+ content_block.pop("encrypted_content")
2458
+ message_chunk = AIMessageChunk(content=[content_block])
2459
+
2460
+ # Process final usage metadata and completion info
2461
+ elif event.type == "message_delta" and stream_usage:
2462
+ usage_metadata = _create_usage_metadata(event.usage)
2463
+ response_metadata = {
2464
+ "stop_reason": event.delta.stop_reason,
2465
+ "stop_sequence": event.delta.stop_sequence,
2466
+ }
2467
+ if context_management := getattr(event, "context_management", None):
2468
+ response_metadata["context_management"] = (
2469
+ context_management.model_dump()
2470
+ )
2471
+ message_delta = getattr(event, "delta", None)
2472
+ if message_delta and (
2473
+ container := getattr(message_delta, "container", None)
2474
+ ):
2475
+ response_metadata["container"] = container.model_dump(mode="json")
2476
+ message_chunk = AIMessageChunk(
2477
+ content="" if coerce_content_to_string else [],
2478
+ usage_metadata=usage_metadata,
2479
+ response_metadata=response_metadata,
2480
+ )
2481
+ if message_chunk.response_metadata.get("stop_reason"):
2482
+ # Mark final Anthropic stream chunk
2483
+ message_chunk.chunk_position = "last"
2484
+ # Unhandled event types (e.g., `content_block_stop`, `ping` events)
2485
+ # https://platform.claude.com/docs/en/build-with-claude/streaming#other-events
2486
+ else:
2487
+ pass
2488
+
2489
+ if message_chunk:
2490
+ message_chunk.response_metadata["model_provider"] = "anthropic"
2491
+ return message_chunk, block_start_event
2492
+
2493
+ def _format_output(
2494
+ self,
2495
+ data: Any,
2496
+ *,
2497
+ generation_info: dict[str, Any] | None = None,
2498
+ **kwargs: Any,
2499
+ ) -> ChatResult:
2500
+ """Format the output from the Anthropic API to LC."""
2501
+ data_dict = data.model_dump()
2502
+ content = data_dict["content"]
2503
+
2504
+ # Remove citations if they are None - introduced in anthropic sdk 0.45
2505
+ for block in content:
2506
+ if isinstance(block, dict):
2507
+ if "citations" in block and block["citations"] is None:
2508
+ block.pop("citations")
2509
+ if "caller" in block and block["caller"] is None:
2510
+ block.pop("caller")
2511
+ if "encrypted_content" in block and block["encrypted_content"] is None:
2512
+ block.pop("encrypted_content")
2513
+ if (
2514
+ block.get("type") == "thinking"
2515
+ and "text" in block
2516
+ and block["text"] is None
2517
+ ):
2518
+ block.pop("text")
2519
+
2520
+ llm_output = {
2521
+ k: v for k, v in data_dict.items() if k not in ("content", "role", "type")
2522
+ }
2523
+ if (
2524
+ (container := llm_output.get("container"))
2525
+ and isinstance(container, dict)
2526
+ and (expires_at := container.get("expires_at"))
2527
+ and isinstance(expires_at, datetime.datetime)
2528
+ ):
2529
+ # TODO: dump all `data` with `mode="json"`
2530
+ llm_output["container"]["expires_at"] = expires_at.isoformat()
2531
+ response_metadata = {"model_provider": "anthropic"}
2532
+ if "model" in llm_output and "model_name" not in llm_output:
2533
+ llm_output["model_name"] = llm_output["model"]
2534
+ if (
2535
+ len(content) == 1
2536
+ and content[0]["type"] == "text"
2537
+ and not content[0].get("citations")
2538
+ ):
2539
+ msg = AIMessage(
2540
+ content=content[0]["text"], response_metadata=response_metadata
2541
+ )
2542
+ elif any(block["type"] == "tool_use" for block in content):
2543
+ tool_calls = extract_tool_calls(content)
2544
+ msg = AIMessage(
2545
+ content=content,
2546
+ tool_calls=tool_calls,
2547
+ response_metadata=response_metadata,
2548
+ )
2549
+ else:
2550
+ msg = AIMessage(content=content, response_metadata=response_metadata)
2551
+ msg.usage_metadata = _create_usage_metadata(data.usage)
2552
+ return ChatResult(
2553
+ generations=[
2554
+ ChatGeneration(message=msg, generation_info=generation_info or None)
2555
+ ],
2556
+ llm_output=llm_output,
2557
+ )
2558
+
2559
+ def _generate(
2560
+ self,
2561
+ messages: list[BaseMessage],
2562
+ stop: list[str] | None = None,
2563
+ run_manager: CallbackManagerForLLMRun | None = None,
2564
+ **kwargs: Any,
2565
+ ) -> ChatResult:
2566
+ payload = self._get_request_payload(messages, stop=stop, **kwargs)
2567
+ try:
2568
+ raw_response = self._create(payload)
2569
+ except anthropic.BadRequestError as e:
2570
+ _handle_anthropic_bad_request(e)
2571
+ except anthropic.APIError as e:
2572
+ _handle_anthropic_api_error(e)
2573
+ generation_info: dict[str, Any] = {}
2574
+ if self._uses_gateway:
2575
+ _add_gateway_metadata(generation_info, raw_response)
2576
+ return self._format_output(
2577
+ raw_response.parse(), generation_info=generation_info, **kwargs
2578
+ )
2579
+
2580
+ async def _agenerate(
2581
+ self,
2582
+ messages: list[BaseMessage],
2583
+ stop: list[str] | None = None,
2584
+ run_manager: AsyncCallbackManagerForLLMRun | None = None,
2585
+ **kwargs: Any,
2586
+ ) -> ChatResult:
2587
+ payload = self._get_request_payload(messages, stop=stop, **kwargs)
2588
+ try:
2589
+ raw_response = await self._acreate(payload)
2590
+ except anthropic.BadRequestError as e:
2591
+ _handle_anthropic_bad_request(e)
2592
+ except anthropic.APIError as e:
2593
+ _handle_anthropic_api_error(e)
2594
+ generation_info: dict[str, Any] = {}
2595
+ if self._uses_gateway:
2596
+ _add_gateway_metadata(generation_info, raw_response)
2597
+ return self._format_output(
2598
+ await _aparse(raw_response), generation_info=generation_info, **kwargs
2599
+ )
2600
+
2601
+ def _get_llm_for_structured_output_when_thinking_is_enabled(
2602
+ self,
2603
+ schema: dict | type,
2604
+ formatted_tool: AnthropicTool,
2605
+ ) -> Runnable[LanguageModelInput, BaseMessage]:
2606
+ thinking_admonition = (
2607
+ "You are attempting to use structured output via forced tool calling, "
2608
+ "which is not supported when `thinking` is enabled or on "
2609
+ f"{self.model}. This method will raise an OutputParserException if tool "
2610
+ "calls are not generated. Consider `method='json_schema'`, disabling "
2611
+ "`thinking` where supported, or adjusting your prompt to ensure the "
2612
+ "tool is called."
2613
+ )
2614
+ warnings.warn(thinking_admonition, stacklevel=2)
2615
+ llm = self.bind_tools(
2616
+ [schema],
2617
+ # We don't specify tool_choice here since the API will reject attempts to
2618
+ # force tool calls when thinking=true
2619
+ ls_structured_output_format={
2620
+ "kwargs": {"method": "function_calling"},
2621
+ "schema": formatted_tool,
2622
+ },
2623
+ )
2624
+
2625
+ def _raise_if_no_tool_calls(message: AIMessage) -> AIMessage:
2626
+ if not message.tool_calls:
2627
+ raise OutputParserException(thinking_admonition)
2628
+ return message
2629
+
2630
+ return llm | _raise_if_no_tool_calls
2631
+
2632
+ def bind_tools(
2633
+ self,
2634
+ tools: Sequence[Mapping[str, Any] | type | Callable | BaseTool],
2635
+ *,
2636
+ tool_choice: dict[str, str] | str | None = None,
2637
+ parallel_tool_calls: bool | None = None,
2638
+ strict: bool | None = None,
2639
+ **kwargs: Any,
2640
+ ) -> Runnable[LanguageModelInput, AIMessage]:
2641
+ r"""Bind tool-like objects to `ChatAnthropic`.
2642
+
2643
+ Args:
2644
+ tools: A list of tool definitions to bind to this chat model.
2645
+
2646
+ Supports Anthropic format tool schemas and any tool definition handled
2647
+ by [`convert_to_openai_tool`][coco_core.utils.function_calling.convert_to_openai_tool].
2648
+ tool_choice: Which tool to require the model to call. Options are:
2649
+
2650
+ - Name of the tool as a string or as dict `{"type": "tool", "name": "<<tool_name>>"}`: calls corresponding tool
2651
+ - `'auto'`, `{"type: "auto"}`, or `None`: automatically selects a tool (including no tool)
2652
+ - `'any'` or `{"type: "any"}`: force at least one tool to be called
2653
+ parallel_tool_calls: Set to `False` to disable parallel tool use.
2654
+
2655
+ Defaults to `None` (no specification, which allows parallel tool use).
2656
+
2657
+ !!! version-added "Added in `trustcoco-anthropic` 0.3.2"
2658
+ strict: If `True`, Claude's schema adherence is applied to tool calls.
2659
+
2660
+ See the [docs](https://docs.langchain.com/oss/python/integrations/chat/anthropic#strict-tool-use) for more info.
2661
+ kwargs: Any additional parameters are passed directly to `bind`.
2662
+
2663
+ Raises:
2664
+ ValueError: If every tool in `tools` was dropped for using top-level
2665
+ schema composition, leaving the model with no callable tool.
2666
+ ValueError: If `tool_choice` forces tool use (a specific tool, or
2667
+ `'any'`) while any tool was dropped, since the forced tool may be
2668
+ unreachable. Does not apply when `thinking` is enabled, as the
2669
+ forced choice is discarded before the request is sent.
2670
+ ValueError: If `tool_choice` is neither a `dict`, a `str`, nor `None`.
2671
+
2672
+ Example:
2673
+ ```python
2674
+ from coco_anthropic import ChatAnthropic
2675
+ from pydantic import BaseModel, Field
2676
+
2677
+
2678
+ class GetWeather(BaseModel):
2679
+ '''Get the current weather in a given location'''
2680
+
2681
+ location: str = Field(..., description="The city and state, e.g. San Francisco, CA")
2682
+
2683
+
2684
+ class GetPrice(BaseModel):
2685
+ '''Get the price of a specific product.'''
2686
+
2687
+ product: str = Field(..., description="The product to look up.")
2688
+
2689
+
2690
+ model = ChatAnthropic(model="claude-sonnet-4-5-20250929", temperature=0)
2691
+ model_with_tools = model.bind_tools([GetWeather, GetPrice])
2692
+ model_with_tools.invoke(
2693
+ "What is the weather like in San Francisco",
2694
+ )
2695
+ # -> AIMessage(
2696
+ # content=[
2697
+ # {'text': '<thinking>\nBased on the user\'s question, the relevant function to call is GetWeather, which requires the "location" parameter.\n\nThe user has directly specified the location as "San Francisco". Since San Francisco is a well known city, I can reasonably infer they mean San Francisco, CA without needing the state specified.\n\nAll the required parameters are provided, so I can proceed with the API call.\n</thinking>', 'type': 'text'},
2698
+ # {'text': None, 'type': 'tool_use', 'id': 'toolu_01SCgExKzQ7eqSkMHfygvYuu', 'name': 'GetWeather', 'input': {'location': 'San Francisco, CA'}}
2699
+ # ],
2700
+ # response_metadata={'id': 'msg_01GM3zQtoFv8jGQMW7abLnhi', 'model': 'claude-sonnet-4-5-20250929', 'stop_reason': 'tool_use', 'stop_sequence': None, 'usage': {'input_tokens': 487, 'output_tokens': 145}},
2701
+ # id='run-87b1331e-9251-4a68-acef-f0a018b639cc-0'
2702
+ # )
2703
+ ```
2704
+ """ # noqa: E501
2705
+ # Allows built-in tools either by their:
2706
+ # - Raw `dict` format
2707
+ # - Extracting extras["provider_tool_definition"] if provided on a BaseTool
2708
+ formatted_tools: list[Mapping[str, Any]] = [
2709
+ tool
2710
+ if _is_builtin_tool(tool)
2711
+ else convert_to_anthropic_tool(tool, strict=strict)
2712
+ for tool in tools
2713
+ ]
2714
+ formatted_tools, dropped_tool_names = _drop_unsupported_root_composition_tools(
2715
+ formatted_tools
2716
+ )
2717
+
2718
+ # Dropping salvages a request when usable tools remain. If every tool was
2719
+ # dropped there is nothing to salvage: the caller asked for a model with
2720
+ # tools and would get one that cannot call any, so fail loudly instead of
2721
+ # letting it surface later as a tool call that never happens.
2722
+ if tools and not formatted_tools:
2723
+ msg = (
2724
+ f"All {len(tools)} bound tool(s) use a top-level "
2725
+ f"{'/'.join(_TOP_LEVEL_SCHEMA_COMPOSITION_KEYS)} in their "
2726
+ f"input_schema, which the Anthropic API rejects: "
2727
+ f"{sorted(dropped_tool_names)}. No tool is left for the model to "
2728
+ "call. If you control the schema, move the combinator under "
2729
+ "`properties`; for structured output, `with_structured_output(..., "
2730
+ "method='json_schema')` accepts these schemas directly. Otherwise "
2731
+ "these tools cannot be used with Anthropic -- bind a subset that "
2732
+ "excludes them, or raise it with the tool's author."
2733
+ )
2734
+ raise ValueError(msg)
2735
+
2736
+ # Reconcile tool_choice with the filtered list: forcing a tool that was
2737
+ # dropped, or forcing tool use at all when the set the caller depends on
2738
+ # has silently shrunk, still produces a 400 or a stuck agent loop.
2739
+ if tool_choice and dropped_tool_names:
2740
+ choice_type: str | None = None
2741
+ choice_name: str | None = None
2742
+ if isinstance(tool_choice, dict):
2743
+ choice_type = tool_choice.get("type")
2744
+ choice_name = tool_choice.get("name")
2745
+ elif isinstance(tool_choice, str):
2746
+ if tool_choice in ("any", "auto"):
2747
+ choice_type = tool_choice
2748
+ else:
2749
+ choice_type, choice_name = "tool", tool_choice
2750
+ # Thinking discards forced choices before the request is sent, so
2751
+ # they need not refer to a tool that remains after filtering.
2752
+ thinking_discards_forced_choice = (
2753
+ self.thinking is not None
2754
+ and self.thinking.get("type") in ("enabled", "adaptive")
2755
+ and choice_type in ("any", "tool")
2756
+ )
2757
+ if not thinking_discards_forced_choice:
2758
+ if choice_type == "tool" and choice_name in dropped_tool_names:
2759
+ msg = (
2760
+ f"tool_choice forces {choice_name!r}, but that tool was "
2761
+ "dropped because its input_schema uses a top-level "
2762
+ f"{'/'.join(_TOP_LEVEL_SCHEMA_COMPOSITION_KEYS)}, which "
2763
+ "the Anthropic API rejects. Stop forcing it, or -- if you "
2764
+ "control the schema -- move the combinator under "
2765
+ "`properties`. For structured output, "
2766
+ "`with_structured_output(..., method='json_schema')` "
2767
+ "accepts these schemas directly."
2768
+ )
2769
+ raise ValueError(msg)
2770
+ if choice_type == "any":
2771
+ # Forcing tool use means the caller depends on a specific
2772
+ # reachable tool set, so losing any member of it is fatal --
2773
+ # not just losing all of them.
2774
+ msg = (
2775
+ "tool_choice='any' forces the model to call a tool, but "
2776
+ f"{sorted(dropped_tool_names)} were dropped because their "
2777
+ "input_schema uses a top-level "
2778
+ f"{'/'.join(_TOP_LEVEL_SCHEMA_COMPOSITION_KEYS)}, which "
2779
+ "the Anthropic API rejects, so the model can no longer "
2780
+ "call them. Use tool_choice='auto' to proceed with the "
2781
+ "remaining tools, or -- if you control the schema -- move "
2782
+ "the combinator under `properties`."
2783
+ )
2784
+ raise ValueError(msg)
2785
+
2786
+ if not tool_choice:
2787
+ pass
2788
+ elif isinstance(tool_choice, dict):
2789
+ kwargs["tool_choice"] = tool_choice.copy()
2790
+ elif isinstance(tool_choice, str) and tool_choice in ("any", "auto"):
2791
+ kwargs["tool_choice"] = {"type": tool_choice}
2792
+ elif isinstance(tool_choice, str):
2793
+ kwargs["tool_choice"] = {"type": "tool", "name": tool_choice}
2794
+ else:
2795
+ msg = (
2796
+ f"Unrecognized 'tool_choice' type {tool_choice=}. Expected dict, "
2797
+ f"str, or None."
2798
+ )
2799
+ raise ValueError(
2800
+ msg,
2801
+ )
2802
+
2803
+ # Anthropic API rejects forced tool use when thinking is enabled:
2804
+ # "Thinking may not be enabled when tool_choice forces tool use."
2805
+ # Drop forced tool_choice and warn, matching the behavior in
2806
+ # _get_llm_for_structured_output_when_thinking_is_enabled.
2807
+ if (
2808
+ self.thinking is not None
2809
+ and self.thinking.get("type") in ("enabled", "adaptive")
2810
+ and "tool_choice" in kwargs
2811
+ and kwargs["tool_choice"].get("type") in ("any", "tool")
2812
+ ):
2813
+ warnings.warn(
2814
+ "tool_choice is forced but thinking is enabled. The Anthropic "
2815
+ "API does not support forced tool use with thinking. "
2816
+ "Dropping tool_choice to avoid an API error. Tool calls are "
2817
+ "not guaranteed. Consider disabling thinking or adjusting "
2818
+ "your prompt to ensure the tool is called.",
2819
+ stacklevel=2,
2820
+ )
2821
+ del kwargs["tool_choice"]
2822
+
2823
+ if parallel_tool_calls is not None:
2824
+ disable_parallel_tool_use = not parallel_tool_calls
2825
+ if "tool_choice" in kwargs:
2826
+ kwargs["tool_choice"]["disable_parallel_tool_use"] = (
2827
+ disable_parallel_tool_use
2828
+ )
2829
+ else:
2830
+ kwargs["tool_choice"] = {
2831
+ "type": "auto",
2832
+ "disable_parallel_tool_use": disable_parallel_tool_use,
2833
+ }
2834
+
2835
+ return self.bind(tools=formatted_tools, **kwargs)
2836
+
2837
+ def with_structured_output(
2838
+ self,
2839
+ schema: dict | type,
2840
+ *,
2841
+ include_raw: bool = False,
2842
+ method: Literal["function_calling", "json_schema"] = "function_calling",
2843
+ **kwargs: Any,
2844
+ ) -> Runnable[LanguageModelInput, dict | BaseModel]:
2845
+ """Model wrapper that returns outputs formatted to match the given schema.
2846
+
2847
+ See the [Coco docs](https://docs.langchain.com/oss/python/integrations/chat/anthropic#structured-output)
2848
+ for more details and examples.
2849
+
2850
+ Args:
2851
+ schema: The output schema. Can be passed in as:
2852
+
2853
+ - An Anthropic tool schema,
2854
+ - An OpenAI function/tool schema,
2855
+ - A JSON Schema,
2856
+ - A `TypedDict` class,
2857
+ - Or a Pydantic class.
2858
+
2859
+ If `schema` is a Pydantic class then the model output will be a
2860
+ Pydantic instance of that class, and the model-generated fields will be
2861
+ validated by the Pydantic class. Otherwise the model output will be a
2862
+ dict and will not be validated.
2863
+
2864
+ See `coco_core.utils.function_calling.convert_to_openai_tool` for
2865
+ more on how to properly specify types and descriptions of schema fields
2866
+ when specifying a Pydantic or `TypedDict` class.
2867
+ include_raw:
2868
+ If `False` then only the parsed structured output is returned.
2869
+
2870
+ If an error occurs during model output parsing it will be raised.
2871
+
2872
+ If `True` then both the raw model response (a `BaseMessage`) and the
2873
+ parsed model response will be returned.
2874
+
2875
+ If an error occurs during output parsing it will be caught and returned
2876
+ as well.
2877
+
2878
+ The final output is always a `dict` with keys `'raw'`, `'parsed'`, and
2879
+ `'parsing_error'`.
2880
+ method: The structured output method to use. Options are:
2881
+
2882
+ - `'function_calling'` (default): Use forced tool calling to get
2883
+ structured output. When `thinking` is enabled, or on models
2884
+ that don't support forced tool use (Claude Opus 5.5, Claude
2885
+ Fable 5.1), the tool call isn't forced, and a missing tool
2886
+ call raises `OutputParserException`.
2887
+ - `'json_schema'`: Use Claude's dedicated
2888
+ [structured output](https://platform.claude.com/docs/en/build-with-claude/structured-outputs)
2889
+ feature.
2890
+
2891
+ kwargs: Additional keyword arguments are ignored.
2892
+
2893
+ Returns:
2894
+ A `Runnable` that takes same inputs as a
2895
+ `coco_core.language_models.chat.BaseChatModel`.
2896
+
2897
+ If `include_raw` is `False` and `schema` is a Pydantic class, `Runnable`
2898
+ outputs an instance of `schema` (i.e., a Pydantic object). Otherwise, if
2899
+ `include_raw` is `False` then `Runnable` outputs a `dict`.
2900
+
2901
+ If `include_raw` is `True`, then `Runnable` outputs a `dict` with keys:
2902
+
2903
+ - `'raw'`: `BaseMessage`
2904
+ - `'parsed'`: `None` if there was a parsing error, otherwise the type
2905
+ depends on the `schema` as described above.
2906
+ - `'parsing_error'`: `BaseException | None`
2907
+
2908
+ Example:
2909
+ ```python hl_lines="13"
2910
+ from coco_anthropic import ChatAnthropic
2911
+ from pydantic import BaseModel, Field
2912
+
2913
+ model = ChatAnthropic(model="claude-sonnet-4-5")
2914
+
2915
+ class Movie(BaseModel):
2916
+ \"\"\"A movie with details.\"\"\"
2917
+ title: str = Field(..., description="The title of the movie")
2918
+ year: int = Field(..., description="The year the movie was released")
2919
+ director: str = Field(..., description="The director of the movie")
2920
+ rating: float = Field(..., description="The movie's rating out of 10")
2921
+
2922
+ model_with_structure = model.with_structured_output(Movie, method="json_schema")
2923
+ response = model_with_structure.invoke("Provide details about the movie Inception")
2924
+ print(response)
2925
+ # -> Movie(title="Inception", year=2010, director="Christopher Nolan", rating=8.8)
2926
+ ```
2927
+ """ # noqa: E501
2928
+ if method == "json_mode":
2929
+ warning_message = (
2930
+ "Unrecognized structured output method 'json_mode'. Defaulting to "
2931
+ "'json_schema' method."
2932
+ )
2933
+ warnings.warn(warning_message, stacklevel=2)
2934
+ method = "json_schema"
2935
+
2936
+ # TODO: make `method="json_schema"` the default in a future release.
2937
+ if method == "function_calling":
2938
+ formatted_tool = cast(AnthropicTool, convert_to_anthropic_tool(schema))
2939
+ # The result of convert_to_anthropic_tool for 'method=function_calling' will
2940
+ # always be an AnthropicTool
2941
+ tool_name = formatted_tool["name"]
2942
+ if (
2943
+ self.thinking is not None
2944
+ and self.thinking.get("type") in ("enabled", "adaptive")
2945
+ ) or not _supports_forced_tool_choice(self.model):
2946
+ llm = self._get_llm_for_structured_output_when_thinking_is_enabled(
2947
+ schema,
2948
+ formatted_tool,
2949
+ )
2950
+ else:
2951
+ llm = self.bind_tools(
2952
+ [schema],
2953
+ tool_choice=tool_name, # Force tool call
2954
+ ls_structured_output_format={
2955
+ "kwargs": {"method": "function_calling"},
2956
+ "schema": formatted_tool,
2957
+ },
2958
+ )
2959
+
2960
+ if isinstance(schema, type) and is_basemodel_subclass(schema):
2961
+ output_parser: OutputParserLike = PydanticToolsParser(
2962
+ tools=[schema],
2963
+ first_tool_only=True,
2964
+ )
2965
+ else:
2966
+ output_parser = JsonOutputKeyToolsParser(
2967
+ key_name=tool_name,
2968
+ first_tool_only=True,
2969
+ )
2970
+ elif method == "json_schema":
2971
+ llm = self.bind(
2972
+ output_config={
2973
+ "format": _convert_to_anthropic_output_config_format(schema)
2974
+ },
2975
+ ls_structured_output_format={
2976
+ "kwargs": {"method": "json_schema"},
2977
+ "schema": convert_to_openai_tool(schema),
2978
+ },
2979
+ )
2980
+ if isinstance(schema, type) and is_basemodel_subclass(schema):
2981
+ output_parser = PydanticOutputParser(pydantic_object=schema)
2982
+ else:
2983
+ output_parser = JsonOutputParser()
2984
+ else:
2985
+ error_message = (
2986
+ f"Unrecognized structured output method '{method}'. "
2987
+ f"Expected 'function_calling' or 'json_schema'."
2988
+ )
2989
+ raise ValueError(error_message)
2990
+
2991
+ if include_raw:
2992
+ parser_assign = RunnablePassthrough.assign(
2993
+ parsed=itemgetter("raw") | output_parser,
2994
+ parsing_error=lambda _: None,
2995
+ )
2996
+ parser_none = RunnablePassthrough.assign(parsed=lambda _: None)
2997
+ parser_with_fallback = parser_assign.with_fallbacks(
2998
+ [parser_none],
2999
+ exception_key="parsing_error",
3000
+ )
3001
+ return RunnableMap(raw=llm) | parser_with_fallback
3002
+ return llm | output_parser
3003
+
3004
+ def get_num_tokens_from_messages(
3005
+ self,
3006
+ messages: list[BaseMessage],
3007
+ tools: Sequence[dict[str, Any] | type | Callable | BaseTool] | None = None,
3008
+ **kwargs: Any,
3009
+ ) -> int:
3010
+ """Count tokens in a sequence of input messages.
3011
+
3012
+ This uses Anthropic's official [token counting API](https://platform.claude.com/docs/en/build-with-claude/token-counting).
3013
+
3014
+ Args:
3015
+ messages: The message inputs to tokenize.
3016
+ tools: If provided, sequence of `dict`, `BaseModel`, function, or `BaseTool`
3017
+ objects to be converted to tool schemas.
3018
+ kwargs: Additional keyword arguments are passed to the Anthropic
3019
+ `messages.count_tokens` method.
3020
+
3021
+ ???+ example "Basic usage"
3022
+
3023
+ ```python
3024
+ from coco_anthropic import ChatAnthropic
3025
+ from coco_core.messages import HumanMessage, SystemMessage
3026
+
3027
+ model = ChatAnthropic(model="claude-sonnet-4-5-20250929")
3028
+
3029
+ messages = [
3030
+ SystemMessage(content="You are a scientist"),
3031
+ HumanMessage(content="Hello, Claude"),
3032
+ ]
3033
+ model.get_num_tokens_from_messages(messages)
3034
+ ```
3035
+
3036
+ ```txt
3037
+ 14
3038
+ ```
3039
+
3040
+ ??? example "Pass tool schemas"
3041
+
3042
+ ```python
3043
+ from coco_anthropic import ChatAnthropic
3044
+ from coco_core.messages import HumanMessage
3045
+ from coco_core.tools import tool
3046
+
3047
+ model = ChatAnthropic(model="claude-sonnet-4-5-20250929")
3048
+
3049
+ @tool(parse_docstring=True)
3050
+ def get_weather(location: str) -> str:
3051
+ \"\"\"Get the current weather in a given location
3052
+
3053
+ Args:
3054
+ location: The city and state, e.g. San Francisco, CA
3055
+ \"\"\"
3056
+ return "Sunny"
3057
+
3058
+ messages = [
3059
+ HumanMessage(content="What's the weather like in San Francisco?"),
3060
+ ]
3061
+ model.get_num_tokens_from_messages(messages, tools=[get_weather])
3062
+ ```
3063
+
3064
+ ```txt
3065
+ 403
3066
+ ```
3067
+ """ # noqa: D214
3068
+ formatted_system, formatted_messages = _format_messages(
3069
+ messages, model=self.model
3070
+ )
3071
+ if formatted_system is not None:
3072
+ kwargs["system"] = formatted_system
3073
+ if tools:
3074
+ # Filter the same schemas `bind_tools` drops, so counting tokens and
3075
+ # sending a request agree on which tools the API will accept.
3076
+ kwargs["tools"], _ = _drop_unsupported_root_composition_tools(
3077
+ [convert_to_anthropic_tool(tool) for tool in tools]
3078
+ )
3079
+ if self.context_management is not None:
3080
+ kwargs["context_management"] = self.context_management
3081
+
3082
+ if self.betas is not None:
3083
+ beta_response = self._client.beta.messages.count_tokens(
3084
+ betas=self.betas,
3085
+ model=self.model,
3086
+ messages=formatted_messages, # type: ignore[arg-type]
3087
+ **kwargs,
3088
+ )
3089
+ return beta_response.input_tokens
3090
+ response = self._client.messages.count_tokens(
3091
+ model=self.model,
3092
+ messages=formatted_messages, # type: ignore[arg-type]
3093
+ **kwargs,
3094
+ )
3095
+ return response.input_tokens
3096
+
3097
+
3098
+ _TOP_LEVEL_SCHEMA_COMPOSITION_KEYS = ("oneOf", "anyOf")
3099
+
3100
+
3101
+ def _drop_unsupported_root_composition_tools(
3102
+ tools: Sequence[Mapping[str, Any]],
3103
+ ) -> tuple[list[Mapping[str, Any]], set[str]]:
3104
+ """Drop tools whose root `input_schema` uses `oneOf`/`anyOf`.
3105
+
3106
+ The Anthropic API rejects these at request validation, failing the entire
3107
+ request. A tool is dropped only if its `input_schema` is a mapping carrying
3108
+ a root combinator, so built-in (server-side) tools -- which have no
3109
+ `input_schema` -- and tools whose combinators are nested under `properties`
3110
+ are passed through as the same objects, unmodified.
3111
+
3112
+ A `UserWarning` is emitted per dropped tool.
3113
+
3114
+ Args:
3115
+ tools: Already-formatted tool definitions, as built by `bind_tools`.
3116
+
3117
+ Returns:
3118
+ The retained tools, and the names of the dropped tools. A dropped tool
3119
+ with no string `name` contributes no entry to the name set.
3120
+ """
3121
+ kept: list[Mapping[str, Any]] = []
3122
+ dropped_tool_names: set[str] = set()
3123
+ for tool in tools:
3124
+ input_schema = tool.get("input_schema")
3125
+ offending_keys = (
3126
+ [k for k in _TOP_LEVEL_SCHEMA_COMPOSITION_KEYS if k in input_schema]
3127
+ if isinstance(input_schema, Mapping)
3128
+ else []
3129
+ )
3130
+ if not offending_keys:
3131
+ kept.append(tool)
3132
+ continue
3133
+ tool_name = tool.get("name")
3134
+ if isinstance(tool_name, str):
3135
+ dropped_tool_names.add(tool_name)
3136
+ described = repr(tool_name)
3137
+ else:
3138
+ described = "with no name"
3139
+ warnings.warn(
3140
+ f"Dropping tool {described}: its input_schema has a "
3141
+ f"top-level {'/'.join(offending_keys)}, which the Anthropic API does "
3142
+ "not support. The tool will not be available to the model.",
3143
+ stacklevel=3,
3144
+ )
3145
+ return kept, dropped_tool_names
3146
+
3147
+
3148
+ def convert_to_anthropic_tool(
3149
+ tool: Mapping[str, Any] | type | Callable | BaseTool,
3150
+ *,
3151
+ strict: bool | None = None,
3152
+ ) -> AnthropicTool:
3153
+ """Convert a tool-like object to an Anthropic tool definition.
3154
+
3155
+ Args:
3156
+ tool: A tool-like object to convert. Can be an Anthropic tool dict,
3157
+ a Pydantic model, a function, or a `BaseTool`.
3158
+ strict: If `True`, enables strict schema adherence for the tool.
3159
+
3160
+ !!! note
3161
+
3162
+ Requires Claude Sonnet 4.5 or Opus 4.1.
3163
+
3164
+ Returns:
3165
+ `AnthropicTool` for custom/user-defined tools
3166
+ """
3167
+ if (
3168
+ isinstance(tool, BaseTool)
3169
+ and hasattr(tool, "extras")
3170
+ and isinstance(tool.extras, dict)
3171
+ and "provider_tool_definition" in tool.extras
3172
+ ):
3173
+ # Pass through built-in tool definitions
3174
+ return tool.extras["provider_tool_definition"] # type: ignore[return-value]
3175
+
3176
+ if isinstance(tool, dict) and all(
3177
+ k in tool for k in ("name", "description", "input_schema")
3178
+ ):
3179
+ # Anthropic tool format
3180
+ anthropic_formatted = AnthropicTool(tool) # type: ignore[misc]
3181
+ else:
3182
+ oai_formatted = convert_to_openai_tool(tool, strict=strict)["function"]
3183
+ anthropic_formatted = AnthropicTool(
3184
+ name=oai_formatted["name"],
3185
+ input_schema=oai_formatted["parameters"],
3186
+ )
3187
+ if "description" in oai_formatted:
3188
+ anthropic_formatted["description"] = oai_formatted["description"]
3189
+ if "strict" in oai_formatted and isinstance(strict, bool):
3190
+ anthropic_formatted["strict"] = oai_formatted["strict"]
3191
+ # Select params from tool.extras
3192
+ if (
3193
+ isinstance(tool, BaseTool)
3194
+ and hasattr(tool, "extras")
3195
+ and isinstance(tool.extras, dict)
3196
+ ):
3197
+ for key, value in tool.extras.items():
3198
+ if key in _ANTHROPIC_EXTRA_FIELDS:
3199
+ # all are populated top-level
3200
+ anthropic_formatted[key] = value # type: ignore[literal-required]
3201
+ return anthropic_formatted
3202
+
3203
+
3204
+ def _tools_in_params(params: dict) -> bool:
3205
+ return (
3206
+ "tools" in params
3207
+ or ("extra_body" in params and params["extra_body"].get("tools"))
3208
+ or "mcp_servers" in params
3209
+ )
3210
+
3211
+
3212
+ def _thinking_in_params(params: dict) -> bool:
3213
+ return params.get("thinking", {}).get("type") in ("enabled", "adaptive")
3214
+
3215
+
3216
+ def _documents_in_params(params: dict) -> bool:
3217
+ for message in params.get("messages", []):
3218
+ if isinstance(message.get("content"), list):
3219
+ for block in message["content"]:
3220
+ if (
3221
+ isinstance(block, dict)
3222
+ and block.get("type") == "document"
3223
+ and block.get("citations", {}).get("enabled")
3224
+ ):
3225
+ return True
3226
+ return False
3227
+
3228
+
3229
+ def _compact_in_params(params: dict) -> bool:
3230
+ edits = params.get("context_management", {}).get("edits") or []
3231
+
3232
+ return any("compact" in (edit.get("type") or "") for edit in edits)
3233
+
3234
+
3235
+ class _AnthropicToolUse(TypedDict):
3236
+ type: Literal["tool_use"]
3237
+ name: str
3238
+ input: dict
3239
+ id: str
3240
+ caller: NotRequired[dict[str, Any]]
3241
+
3242
+
3243
+ def _lc_tool_calls_to_anthropic_tool_use_blocks(
3244
+ tool_calls: list[ToolCall],
3245
+ ) -> list[_AnthropicToolUse]:
3246
+ return [
3247
+ _AnthropicToolUse(
3248
+ type="tool_use",
3249
+ name=tool_call["name"],
3250
+ input=tool_call["args"],
3251
+ id=cast("str", _normalize_tool_call_id(tool_call["id"])),
3252
+ )
3253
+ for tool_call in tool_calls
3254
+ ]
3255
+
3256
+
3257
+ def _convert_to_anthropic_output_config_format(schema: dict | type) -> dict[str, Any]:
3258
+ """Convert JSON schema, Pydantic model, or `TypedDict` into `output_config.format`.
3259
+
3260
+ See Claude docs on [structured outputs](https://platform.claude.com/docs/en/build-with-claude/structured-outputs).
3261
+
3262
+ Args:
3263
+ schema: A JSON schema dict, Pydantic model class, or TypedDict.
3264
+
3265
+ Returns:
3266
+ A dict with `type` and `schema` keys suitable for `output_config.format`.
3267
+ """
3268
+ from anthropic import transform_schema
3269
+
3270
+ is_pydantic_class = isinstance(schema, type) and is_basemodel_subclass(schema)
3271
+ if is_pydantic_class or isinstance(schema, dict):
3272
+ json_schema = transform_schema(schema)
3273
+ else:
3274
+ # TypedDict
3275
+ json_schema = transform_schema(convert_to_json_schema(schema))
3276
+ return {"type": "json_schema", "schema": json_schema}
3277
+
3278
+
3279
+ def _create_usage_metadata(anthropic_usage: BaseModel) -> UsageMetadata:
3280
+ """Create Coco `UsageMetadata` from Anthropic `Usage` data.
3281
+
3282
+ Note:
3283
+ Anthropic's `input_tokens` excludes cached tokens, so we manually add
3284
+ `cache_read` and `cache_creation` tokens to get the true total.
3285
+ """
3286
+ input_token_details: dict = {
3287
+ "cache_read": getattr(anthropic_usage, "cache_read_input_tokens", None),
3288
+ "cache_creation": getattr(anthropic_usage, "cache_creation_input_tokens", None),
3289
+ }
3290
+
3291
+ # Add cache TTL information if provided (5-minute and 1-hour ephemeral cache)
3292
+ cache_creation = getattr(anthropic_usage, "cache_creation", None)
3293
+
3294
+ # Currently just copying over the 5m and 1h keys, but if more are added in the
3295
+ # future we'll need to expand this tuple
3296
+ cache_creation_keys = ("ephemeral_5m_input_tokens", "ephemeral_1h_input_tokens")
3297
+ specific_cache_creation_tokens = 0
3298
+ if cache_creation:
3299
+ if isinstance(cache_creation, BaseModel):
3300
+ cache_creation = cache_creation.model_dump()
3301
+ for k in cache_creation_keys:
3302
+ specific_cache_creation_tokens += cache_creation.get(k, 0)
3303
+ input_token_details[k] = cache_creation.get(k)
3304
+ if not isinstance(specific_cache_creation_tokens, int):
3305
+ specific_cache_creation_tokens = 0
3306
+ if specific_cache_creation_tokens > 0:
3307
+ # Remove generic key to avoid double counting cache creation tokens
3308
+ input_token_details["cache_creation"] = 0
3309
+
3310
+ # Calculate total input tokens: Anthropic's `input_tokens` excludes cached tokens,
3311
+ # so we need to add them back to get the true total input token count
3312
+ input_tokens = (
3313
+ (getattr(anthropic_usage, "input_tokens", 0) or 0) # Base input tokens
3314
+ + (input_token_details["cache_read"] or 0) # Tokens read from cache
3315
+ + (
3316
+ specific_cache_creation_tokens or input_token_details["cache_creation"] or 0
3317
+ ) # Tokens used to create cache
3318
+ )
3319
+ output_tokens = getattr(anthropic_usage, "output_tokens", 0) or 0
3320
+
3321
+ # Reasoning (thinking) tokens are a decomposition of `output_tokens` (not
3322
+ # additive), reported by Anthropic via `output_tokens_details.thinking_tokens`.
3323
+ # Older models omit `output_tokens_details` entirely, so guard with `getattr`.
3324
+ output_token_details: dict = {
3325
+ "reasoning": getattr(
3326
+ getattr(anthropic_usage, "output_tokens_details", None),
3327
+ "thinking_tokens",
3328
+ None,
3329
+ ),
3330
+ }
3331
+
3332
+ usage_metadata = UsageMetadata(
3333
+ input_tokens=input_tokens,
3334
+ output_tokens=output_tokens,
3335
+ total_tokens=input_tokens + output_tokens,
3336
+ input_token_details=InputTokenDetails(
3337
+ **{k: v for k, v in input_token_details.items() if v is not None},
3338
+ ),
3339
+ )
3340
+
3341
+ filtered_output_token_details = {
3342
+ k: v for k, v in output_token_details.items() if v is not None
3343
+ }
3344
+ if filtered_output_token_details:
3345
+ usage_metadata["output_token_details"] = OutputTokenDetails(
3346
+ **filtered_output_token_details,
3347
+ )
3348
+
3349
+ return usage_metadata