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,844 @@
1
+ """Compat bridge: convert `AIMessageChunk` streams to protocol events.
2
+
3
+ The bridge trusts `AIMessageChunk.content_blocks` as the single
4
+ protocol view of any chunk. That property runs the three-tier lookup
5
+ (`output_version == "v1"` short-circuit, registered translator, or
6
+ best-effort parsing) and returns a `list[ContentBlock]` for every
7
+ well-formed message — whether the provider is a registered partner, an
8
+ unregistered community model, or not tagged at all.
9
+
10
+ Per-chunk `content_blocks` output is a **delta slice**, not accumulated
11
+ state: providers in this ecosystem emit SSE-style chunks that each carry
12
+ their own increment. The bridge therefore forwards each slice straight
13
+ through as a `content-block-delta` event, and accumulates per-index
14
+ state only so the final `content-block-finish` event can report a
15
+ finalized block (e.g. `tool_call_chunk` args parsed to a dict).
16
+
17
+ Lifecycle::
18
+
19
+ message-start
20
+ -> content-block-start (first time each index is observed)
21
+ -> content-block-delta* (per chunk, carrying the slice)
22
+ -> content-block-finish (finalized block)
23
+ -> message-finish
24
+
25
+ Public API:
26
+
27
+ - `chunks_to_events` / `achunks_to_events` — for live streams where
28
+ chunks arrive over time.
29
+ - `message_to_events` / `amessage_to_events` — for replaying a finalized
30
+ `AIMessage` (cache hit, checkpoint restore, graph-node return value)
31
+ as a synthetic event lifecycle.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import json
37
+ from typing import TYPE_CHECKING, Any, cast
38
+
39
+ from coco_core._protocol import (
40
+ ContentBlock,
41
+ ContentBlockDeltaData,
42
+ ContentBlockFinishData,
43
+ ContentBlockStartData,
44
+ FinalizedContentBlock,
45
+ InvalidToolCall,
46
+ MessageFinishData,
47
+ MessageMetadata,
48
+ MessagesData,
49
+ MessageStartData,
50
+ ReasoningContentBlock,
51
+ ServerToolCall,
52
+ ServerToolCallChunk,
53
+ TextContentBlock,
54
+ ToolCall,
55
+ ToolCallChunk,
56
+ UsageInfo,
57
+ )
58
+
59
+ from coco_core.messages import AIMessageChunk, BaseMessage
60
+ from coco_core.utils._merge import merge_dicts
61
+
62
+ if TYPE_CHECKING:
63
+ from collections.abc import AsyncIterator, Iterator
64
+
65
+ from coco_core._protocol import (
66
+ BlockDelta,
67
+ BlockDeltaFields,
68
+ ContentBlockDelta,
69
+ DataDelta,
70
+ ReasoningDelta,
71
+ TextDelta,
72
+ )
73
+
74
+ from coco_core.messages.ai import UsageMetadata
75
+ from coco_core.outputs import ChatGenerationChunk
76
+
77
+
78
+ CompatBlock = dict[str, Any]
79
+ """Internal working type for a content block.
80
+
81
+ The bridge works with plain dicts internally because two separate but
82
+ structurally similar `ContentBlock` Unions exist — one in
83
+ `coco_core.messages.content` (returned by `msg.content_blocks`),
84
+ one in `coco_core._protocol` (the wire/event shape). They are
85
+ not mypy-compatible despite being near-isomorphic. Passing through
86
+ `dict[str, Any]` launders between them. See `_to_protocol_block` for
87
+ the single seam where the laundering cast lives.
88
+ """
89
+
90
+
91
+ # ---------------------------------------------------------------------------
92
+ # Type laundering between core and protocol `ContentBlock` unions
93
+ # ---------------------------------------------------------------------------
94
+
95
+
96
+ def _to_protocol_block(block: CompatBlock) -> ContentBlock:
97
+ """Narrow an internal working dict to a protocol `ContentBlock`.
98
+
99
+ Single seam between the two `ContentBlock` type systems:
100
+ `coco_core.messages.content` (what `msg.content_blocks`
101
+ returns) and `coco_core._protocol` (what event payloads
102
+ require). The two Unions overlap structurally but are nominally
103
+ distinct to mypy, so we launder through `dict[str, Any]`. When the
104
+ Unions are unified, this helper and its finalized counterpart can be
105
+ deleted.
106
+ """
107
+ return cast("ContentBlock", block)
108
+
109
+
110
+ def _to_finalized_block(block: CompatBlock) -> FinalizedContentBlock:
111
+ """Counterpart of `_to_protocol_block` for finalized blocks."""
112
+ return cast("FinalizedContentBlock", block)
113
+
114
+
115
+ def _to_block_delta_fields(block: CompatBlock) -> BlockDeltaFields:
116
+ """Narrow an internal working dict to protocol block-delta fields."""
117
+ return cast("BlockDeltaFields", block)
118
+
119
+
120
+ def _to_content_delta(block: CompatBlock) -> ContentBlockDelta:
121
+ """Convert a content-block slice/snapshot to an explicit protocol delta."""
122
+ btype = block.get("type")
123
+ if btype == "text":
124
+ return cast("TextDelta", {"type": "text-delta", "text": block.get("text", "")})
125
+ if btype == "reasoning":
126
+ return cast(
127
+ "ReasoningDelta",
128
+ {
129
+ "type": "reasoning-delta",
130
+ "reasoning": block.get("reasoning", ""),
131
+ },
132
+ )
133
+ if "data" in block:
134
+ delta = cast("DataDelta", {"type": "data-delta", "data": block.get("data", "")})
135
+ if block.get("encoding") == "base64":
136
+ delta["encoding"] = "base64"
137
+ return delta
138
+ return cast(
139
+ "BlockDelta",
140
+ {
141
+ "type": "block-delta",
142
+ "fields": _to_block_delta_fields(block),
143
+ },
144
+ )
145
+
146
+
147
+ # ---------------------------------------------------------------------------
148
+ # Block iteration
149
+ # ---------------------------------------------------------------------------
150
+
151
+
152
+ def _iter_protocol_blocks(msg: BaseMessage) -> list[tuple[Any, CompatBlock]]:
153
+ """Read per-chunk protocol blocks from `msg.content_blocks`.
154
+
155
+ Returns `(key, block)` pairs. The key is the block's stable identifier
156
+ across the stream: the block's `index` field when present (can be an
157
+ int or a string — some providers use string identifiers like
158
+ `"lc_rs_305f30"`), or the positional index within the message as a
159
+ fallback. Callers are responsible for allocating wire-level `uint`
160
+ indices; this helper only surfaces the source-side identity.
161
+
162
+ For finalized `AIMessage`, also surfaces `invalid_tool_calls`
163
+ — which `AIMessage.content_blocks` currently omits from its return
164
+ value even though they are a defined protocol block type.
165
+
166
+ The positional fallback is a known fragility: when a provider emits
167
+ blocks without an `index` field (e.g. Anthropic's `_stream` with
168
+ `coerce_content_to_string=True`, where text chunks lose their
169
+ source-side index), every such chunk gets positional key 0 and
170
+ successive chunks merge into one block. This works correctly for
171
+ single-type streams (pure-text responses merge cleanly) because all
172
+ chunks share the same key and the open-block logic collapses them.
173
+ It would miscategorise a stream that mixed indexed structured
174
+ blocks with non-indexed coerced-text blocks, since an indexed
175
+ block with `index == 0` would collide with the anonymous text
176
+ block's positional-0 key. In the anthropic integration this
177
+ cannot currently occur: coerce-to-string mode is only selected
178
+ when no tools, thinking, or documents are present, and any of
179
+ those flips the stream to structured mode where every block
180
+ carries an integer index. A native `_stream_chat_model_events`
181
+ hook per provider (or a bridge-level "continue the open block when
182
+ the source has no identity" rule) would close the gap if another
183
+ integration ever emits mixed content.
184
+ """
185
+ try:
186
+ raw = msg.content_blocks
187
+ except Exception:
188
+ return []
189
+
190
+ result: list[tuple[Any, CompatBlock]] = []
191
+ for i, block in enumerate(raw):
192
+ if not isinstance(block, dict):
193
+ continue # type: ignore[unreachable]
194
+ explicit_idx = block.get("index")
195
+ if explicit_idx is None:
196
+ # No source-side identity. Bucket by (sentinel, block type,
197
+ # positional `i`) so two blocks of different types at the
198
+ # same position across chunks (e.g. Gemini emitting a
199
+ # reasoning block in one chunk and a `tool_call` in the
200
+ # next, both at positional 0 because each chunk carries one
201
+ # block) get distinct wire blocks. Without this, the second
202
+ # type's incoming block hits `_accumulate`'s self-contained
203
+ # `else` branch and clobbers the first. Same-type chunks
204
+ # still share the bucket and merge cleanly, which is what
205
+ # streaming text / reasoning relies on.
206
+ key: Any = ("__lc_no_index__", block.get("type"), i)
207
+ else:
208
+ key = explicit_idx
209
+ result.append((key, dict(block)))
210
+
211
+ if not isinstance(msg, AIMessageChunk):
212
+ # Finalized AIMessage: pull invalid_tool_calls from the dedicated
213
+ # field — AIMessage.content_blocks does not currently include them.
214
+ for itc in getattr(msg, "invalid_tool_calls", None) or []:
215
+ itc_block: CompatBlock = {"type": "invalid_tool_call"}
216
+ for key_name in ("id", "name", "args", "error"):
217
+ if itc.get(key_name) is not None:
218
+ itc_block[key_name] = itc[key_name]
219
+ result.append((len(result), itc_block))
220
+
221
+ return result
222
+
223
+
224
+ # ---------------------------------------------------------------------------
225
+ # Per-block helpers
226
+ # ---------------------------------------------------------------------------
227
+
228
+
229
+ # Fields that can carry large payloads (inline base64 media, parsed args,
230
+ # arbitrary dicts). Stripped from `content-block-start` for self-contained
231
+ # block types so the payload rides on `content-block-finish` alone instead
232
+ # of being serialized twice on the wire.
233
+ _HEAVY_FIELDS = frozenset({"args", "data", "output", "transcript", "value"})
234
+
235
+
236
+ def _start_skeleton(block: CompatBlock) -> ContentBlock:
237
+ """Empty-content placeholder for the `content-block-start` event.
238
+
239
+ Deltaable block types (text, reasoning, the `_chunk` tool variants)
240
+ get an empty payload so the lifecycle's "start" signal is distinct
241
+ from the first incremental delta. Self-contained types (image,
242
+ audio, video, file, non_standard, finalized tool calls) drop their
243
+ heavy payload fields; those are carried by `content-block-finish`.
244
+ Correlation fields (id, name, toolCallId) and small metadata
245
+ (mime_type, url, status, …) are preserved on the start event.
246
+ """
247
+ btype = block.get("type", "text")
248
+ if btype == "text":
249
+ return TextContentBlock(type="text", text="")
250
+ if btype == "reasoning":
251
+ return ReasoningContentBlock(type="reasoning", reasoning="")
252
+ if btype == "tool_call_chunk":
253
+ return ToolCallChunk(
254
+ type="tool_call_chunk",
255
+ id=block.get("id"),
256
+ name=block.get("name"),
257
+ args="",
258
+ )
259
+ if btype == "server_tool_call_chunk":
260
+ s_skel = ServerToolCallChunk(
261
+ type="server_tool_call_chunk",
262
+ args="",
263
+ )
264
+ if block.get("id") is not None:
265
+ s_skel["id"] = block["id"]
266
+ if block.get("name") is not None:
267
+ s_skel["name"] = block["name"]
268
+ return s_skel
269
+
270
+ stripped: CompatBlock = {k: v for k, v in block.items() if k not in _HEAVY_FIELDS}
271
+ # Restore required-but-heavy fields with minimal placeholders so the
272
+ # start event still validates against the CDDL shape of the block type.
273
+ if btype in {"tool_call", "server_tool_call"}:
274
+ stripped["args"] = {}
275
+ elif btype == "non_standard":
276
+ stripped["value"] = {}
277
+ return _to_protocol_block(stripped)
278
+
279
+
280
+ def _should_emit_delta(block: CompatBlock) -> bool:
281
+ """Whether a per-chunk block carries content worth a delta event.
282
+
283
+ Deltaable types emit only when they have fresh content. Self-contained
284
+ / already-finalized types skip the delta entirely — the `finish`
285
+ event carries them.
286
+ """
287
+ btype = block.get("type")
288
+ if btype == "text":
289
+ return bool(block.get("text"))
290
+ if btype == "reasoning":
291
+ return bool(block.get("reasoning"))
292
+ if btype in {"tool_call_chunk", "server_tool_call_chunk"}:
293
+ return bool(
294
+ block.get("args") or block.get("id") or block.get("name"),
295
+ )
296
+ if "data" in block:
297
+ return bool(block.get("data"))
298
+ return False
299
+
300
+
301
+ def _accumulate(state: CompatBlock | None, delta: CompatBlock) -> CompatBlock:
302
+ """Merge a per-chunk delta slice into accumulated per-index state.
303
+
304
+ Used only for the finalization pass — live delta events are emitted
305
+ directly from the per-chunk block, without round-tripping through
306
+ accumulated state.
307
+ """
308
+ if state is None:
309
+ return dict(delta)
310
+ btype = state.get("type")
311
+ dtype = delta.get("type")
312
+ if btype == "text" and dtype == "text":
313
+ state["text"] = state.get("text", "") + delta.get("text", "")
314
+ # Providers may send non-text fields (like `id`, or annotations)
315
+ # on later deltas. Merging (not replacing) keeps earlier keys
316
+ # intact while picking up these late-arriving fields.
317
+ for key, value in delta.items():
318
+ if key in {"type", "text"} or value is None:
319
+ continue
320
+ if key == "extras" and isinstance(value, dict):
321
+ state["extras"] = {**(state.get("extras") or {}), **value}
322
+ else:
323
+ state[key] = value
324
+ elif btype == "reasoning" and dtype == "reasoning":
325
+ state["reasoning"] = state.get("reasoning", "") + delta.get("reasoning", "")
326
+ # Providers may ship non-text fields on later deltas. Claude's
327
+ # `signature_delta` arrives after the reasoning text, surfaced
328
+ # as `extras.signature`; merging (not replacing) keeps earlier
329
+ # keys intact.
330
+ for key, value in delta.items():
331
+ if key in {"type", "reasoning"} or value is None:
332
+ continue
333
+ if key == "extras" and isinstance(value, dict):
334
+ state["extras"] = {**(state.get("extras") or {}), **value}
335
+ else:
336
+ state[key] = value
337
+ elif btype in {"tool_call_chunk", "server_tool_call_chunk"} and dtype == btype:
338
+ state["args"] = (state.get("args", "") or "") + (delta.get("args") or "")
339
+ if delta.get("id") is not None:
340
+ state["id"] = delta["id"]
341
+ if delta.get("name") is not None:
342
+ state["name"] = delta["name"]
343
+ elif btype == dtype and "data" in delta:
344
+ state["data"] = (state.get("data", "") or "") + (delta.get("data") or "")
345
+ for key, value in delta.items():
346
+ if key in {"type", "data"} or value is None:
347
+ continue
348
+ if key == "extras" and isinstance(value, dict):
349
+ state["extras"] = {**(state.get("extras") or {}), **value}
350
+ else:
351
+ state[key] = value
352
+ else:
353
+ # Self-contained or already-finalized types: replace wholesale.
354
+ state.clear()
355
+ state.update(delta)
356
+ return state
357
+
358
+
359
+ def finalize_tool_call_chunk(
360
+ *,
361
+ raw_args: str | None,
362
+ id_: str | None,
363
+ name: str | None,
364
+ extras: dict[str, Any],
365
+ finalized_type: str,
366
+ ) -> FinalizedContentBlock:
367
+ """Parse accumulated tool-chunk args into a finalized block.
368
+
369
+ Shared between the compat bridge's `_finalize_block` and the
370
+ `ChatModelStream` end-of-stream sweep. Parses `raw_args` as JSON:
371
+ on success builds the requested finalized type (`tool_call` or
372
+ `server_tool_call`) with provider-specific fields (`extras`)
373
+ preserved; on failure falls back to `invalid_tool_call` carrying
374
+ the raw string so downstream consumers can still introspect the
375
+ malformed payload.
376
+
377
+ Args:
378
+ raw_args: Accumulated partial-JSON string; `None` or empty
379
+ treated as `{}`.
380
+ id_: Tool-call id collected across chunks.
381
+ name: Tool name collected across chunks.
382
+ extras: Provider-specific fields to carry onto the finalized
383
+ block. Callers are responsible for having already dropped
384
+ keys they don't want propagated (notably `type`, `id`,
385
+ `name`, `args`, and `index` on client-side `tool_call`).
386
+ finalized_type: `"tool_call"` or `"server_tool_call"`.
387
+
388
+ Returns:
389
+ A `ToolCall`, `ServerToolCall`, or `InvalidToolCall` — the
390
+ latter when `raw_args` is non-empty but not valid JSON.
391
+ """
392
+ raw = raw_args or "{}"
393
+ try:
394
+ parsed = json.loads(raw) if raw else {}
395
+ except (json.JSONDecodeError, TypeError):
396
+ invalid = InvalidToolCall(
397
+ type="invalid_tool_call",
398
+ id=id_,
399
+ name=name,
400
+ args=raw,
401
+ error="Failed to parse tool call arguments as JSON",
402
+ )
403
+ invalid.update(extras) # type: ignore[typeddict-item]
404
+ return invalid
405
+ if finalized_type == "tool_call":
406
+ finalized_tc = ToolCall(
407
+ type="tool_call",
408
+ id=id_ or "",
409
+ name=name or "",
410
+ args=parsed,
411
+ )
412
+ finalized_tc.update(extras) # type: ignore[typeddict-item]
413
+ return finalized_tc
414
+ finalized_stc = ServerToolCall(
415
+ type="server_tool_call",
416
+ id=id_ or "",
417
+ name=name or "",
418
+ args=parsed,
419
+ )
420
+ finalized_stc.update(extras) # type: ignore[typeddict-item]
421
+ return finalized_stc
422
+
423
+
424
+ def _finalize_block(block: CompatBlock) -> FinalizedContentBlock:
425
+ """Promote chunk variants to their finalized form.
426
+
427
+ `tool_call_chunk` becomes `tool_call` — or `invalid_tool_call`
428
+ if the accumulated `args` don't parse as JSON.
429
+ `server_tool_call_chunk` becomes `server_tool_call` under the same
430
+ rule. Everything else passes through: text/reasoning blocks carry
431
+ their accumulated snapshot, and self-contained types are already in
432
+ their terminal shape.
433
+ """
434
+ btype = block.get("type")
435
+ if btype in {"tool_call_chunk", "server_tool_call_chunk"}:
436
+ # Carry provider-specific fields from the accumulated chunk onto
437
+ # the finalized block. Drop the chunk-only keys we rewrite
438
+ # explicitly. `index` is stripped on client-side
439
+ # `tool_call` / `invalid_tool_call` finalizations to match v1
440
+ # (`AIMessage.init_tool_calls` rebuilds tool_call blocks without
441
+ # `index`), preventing `merge_lists` from re-merging further
442
+ # chunks into an already-parsed args dict. `server_tool_call`
443
+ # retains `index` because v1's `init_server_tool_calls`
444
+ # finalizes in-place and preserves it.
445
+ client_tool_call = btype == "tool_call_chunk"
446
+ extras_drop = {"type", "id", "name", "args"}
447
+ if client_tool_call:
448
+ extras_drop |= {"index"}
449
+ extras = {
450
+ k: v for k, v in block.items() if k not in extras_drop and v is not None
451
+ }
452
+ return finalize_tool_call_chunk(
453
+ raw_args=block.get("args"),
454
+ id_=block.get("id"),
455
+ name=block.get("name"),
456
+ extras=extras,
457
+ finalized_type="tool_call" if client_tool_call else "server_tool_call",
458
+ )
459
+ return _to_finalized_block(block)
460
+
461
+
462
+ # ---------------------------------------------------------------------------
463
+ # Metadata, usage, finish-reason
464
+ # ---------------------------------------------------------------------------
465
+
466
+
467
+ def _extract_start_metadata(response_metadata: dict[str, Any]) -> MessageMetadata:
468
+ """Pull provider/model hints for the `message-start` event."""
469
+ metadata: MessageMetadata = {}
470
+ if "model_provider" in response_metadata:
471
+ metadata["provider"] = response_metadata["model_provider"]
472
+ if "model_name" in response_metadata:
473
+ metadata["model"] = response_metadata["model_name"]
474
+ return metadata
475
+
476
+
477
+ def _accumulate_usage(current: UsageInfo | None, delta: UsageMetadata) -> UsageInfo:
478
+ """Sum usage counts and merge detail dicts across chunks.
479
+
480
+ `delta` is a chunk's `usage_metadata`; `current` is the running total.
481
+ Both sides are read and written by literal key so the typed shape is
482
+ preserved end to end — no `dict[str, Any]` detour.
483
+ """
484
+ new: UsageInfo = current if current is not None else {}
485
+ if "input_tokens" in delta:
486
+ new["input_tokens"] = new.get("input_tokens", 0) + delta["input_tokens"]
487
+ if "output_tokens" in delta:
488
+ new["output_tokens"] = new.get("output_tokens", 0) + delta["output_tokens"]
489
+ if "total_tokens" in delta:
490
+ new["total_tokens"] = new.get("total_tokens", 0) + delta["total_tokens"]
491
+ input_details = delta.get("input_token_details")
492
+ if input_details:
493
+ merged_input = new.get("input_token_details", {})
494
+ merged_input.update(input_details)
495
+ new["input_token_details"] = merged_input
496
+ output_details = delta.get("output_token_details")
497
+ if output_details:
498
+ merged_output = new.get("output_token_details", {})
499
+ merged_output.update(output_details)
500
+ new["output_token_details"] = merged_output
501
+ return new
502
+
503
+
504
+ def _isolate_usage(usage: UsageInfo | None) -> UsageInfo | None:
505
+ """Copy usage for the event so consumers can't mutate the source message.
506
+
507
+ The replay path (`message_to_events`) feeds the live `msg.usage_metadata`,
508
+ so the emitted event must not share its dicts: copy the top level plus the
509
+ nested `input_token_details` / `output_token_details` to de-alias it. The
510
+ streaming accumulator already owns the dicts it builds, so the copy is a
511
+ harmless no-op on that path.
512
+ """
513
+ if not usage:
514
+ return None
515
+ result: UsageInfo = usage.copy()
516
+ input_details = result.get("input_token_details")
517
+ if input_details is not None:
518
+ result["input_token_details"] = input_details.copy()
519
+ output_details = result.get("output_token_details")
520
+ if output_details is not None:
521
+ result["output_token_details"] = output_details.copy()
522
+ return result
523
+
524
+
525
+ # ---------------------------------------------------------------------------
526
+ # Event builders
527
+ # ---------------------------------------------------------------------------
528
+
529
+
530
+ def _build_message_start(
531
+ msg: BaseMessage,
532
+ message_id: str | None,
533
+ ) -> MessageStartData:
534
+ start_data = MessageStartData(event="message-start", role="ai", id="")
535
+ resolved_id = message_id if message_id is not None else getattr(msg, "id", None)
536
+ if resolved_id:
537
+ start_data["id"] = resolved_id
538
+ start_metadata = _extract_start_metadata(msg.response_metadata or {})
539
+ if start_metadata:
540
+ start_data["metadata"] = start_metadata
541
+ return start_data
542
+
543
+
544
+ def _build_message_finish(
545
+ *,
546
+ usage: UsageInfo | None,
547
+ response_metadata: dict[str, Any] | None,
548
+ additional_kwargs: dict[str, Any] | None = None,
549
+ ) -> MessageFinishData:
550
+ # Protocol 0.0.9 removed the top-level `reason` field from
551
+ # `MessageFinishData`; the provider's raw `finish_reason` /
552
+ # `stop_reason` now rides inside `metadata` alongside other
553
+ # response metadata. Pass it through unchanged.
554
+ finish_data: dict[str, Any] = {"event": "message-finish"}
555
+ usage_info = _isolate_usage(usage)
556
+ if usage_info is not None:
557
+ finish_data["usage"] = usage_info
558
+ if response_metadata:
559
+ finish_data["metadata"] = dict(response_metadata)
560
+ # `additional_kwargs` is an off-spec extension on the message-finish
561
+ # event (parallel to `metadata`, which `MessageFinishData` also doesn't
562
+ # formally declare but the consumer reads). It carries provider-side
563
+ # kwargs that don't map onto a typed protocol field — notably Gemini's
564
+ # `__gemini_function_call_thought_signatures__`, which the model
565
+ # requires on follow-up turns to replay prior thinking. Without this,
566
+ # streaming-assembled messages would silently drop data that
567
+ # `ainvoke` preserves, breaking multi-turn streaming flows.
568
+ if additional_kwargs:
569
+ finish_data["additional_kwargs"] = dict(additional_kwargs)
570
+ return cast("MessageFinishData", finish_data)
571
+
572
+
573
+ def _finalize_and_build_finish(
574
+ wire_idx: int,
575
+ block: CompatBlock,
576
+ ) -> MessagesData:
577
+ """Finalize a block and wrap it in a `content-block-finish` event."""
578
+ return ContentBlockFinishData(
579
+ event="content-block-finish",
580
+ index=wire_idx,
581
+ content=_finalize_block(block),
582
+ )
583
+
584
+
585
+ # ---------------------------------------------------------------------------
586
+ # Main generators
587
+ # ---------------------------------------------------------------------------
588
+
589
+
590
+ def chunks_to_events(
591
+ chunks: Iterator[ChatGenerationChunk],
592
+ *,
593
+ message_id: str | None = None,
594
+ ) -> Iterator[MessagesData]:
595
+ """Convert a stream of `ChatGenerationChunk` to protocol events.
596
+
597
+ Blocks are tracked independently by source-side identifier. Providers
598
+ such as Anthropic can interleave parallel tool-call chunks by index, so
599
+ each first-seen block gets a `content-block-start`, deltas keep their
600
+ stable wire index, and all open blocks are finalized at message end.
601
+ Source-side identifiers (from the block's `index` field, which may be
602
+ int or string) are translated to sequential `uint` wire indices.
603
+
604
+ Args:
605
+ chunks: Iterator of `ChatGenerationChunk` from `_stream()`.
606
+ message_id: Optional stable message ID.
607
+
608
+ Yields:
609
+ `MessagesData` lifecycle events.
610
+ """
611
+ started = False
612
+ blocks: dict[Any, tuple[int, CompatBlock]] = {}
613
+ next_wire_idx = 0
614
+ usage: UsageInfo | None = None
615
+ response_metadata: dict[str, Any] = {}
616
+ additional_kwargs: dict[str, Any] = {}
617
+
618
+ for chunk in chunks:
619
+ msg = chunk.message
620
+ if not isinstance(msg, AIMessageChunk):
621
+ continue
622
+
623
+ # The v1 `stream()` wrapper merges `generation_info` into
624
+ # `response_metadata` before yielding (`chat_models.py` via
625
+ # `_gen_info_and_msg_metadata`). We bypass that wrapper by reading
626
+ # `_stream` directly, so reproduce the merge here with the same
627
+ # priority: `generation_info` first, then `message.response_metadata`
628
+ # overlays. This is how provider fields like `model_name`,
629
+ # `system_fingerprint`, and `finish_reason` reach the bridge when
630
+ # a provider emits them via `generation_info` instead of the
631
+ # message's `response_metadata`.
632
+ merged_rm: dict[str, Any] = {
633
+ **(chunk.generation_info or {}),
634
+ **(msg.response_metadata or {}),
635
+ }
636
+ if merged_rm:
637
+ response_metadata.update(merged_rm)
638
+
639
+ # Carry chunks' `additional_kwargs` through to the assembled
640
+ # message. Provider-side fields that don't map onto a typed
641
+ # protocol block (e.g. Gemini's per-tool-call thought signatures)
642
+ # live here on non-streaming `ainvoke` results; dropping them on
643
+ # the streaming path silently diverges multi-turn behavior. Use
644
+ # `merge_dicts` because the same key can arrive in pieces across
645
+ # chunks (e.g. an accumulating `function_call`), matching how
646
+ # `AIMessageChunk` merges itself.
647
+ if msg.additional_kwargs:
648
+ additional_kwargs = merge_dicts(additional_kwargs, msg.additional_kwargs)
649
+
650
+ if not started:
651
+ started = True
652
+ yield _build_message_start(msg, message_id)
653
+
654
+ for key, block in _iter_protocol_blocks(msg):
655
+ if key not in blocks:
656
+ wire_idx = next_wire_idx
657
+ next_wire_idx += 1
658
+ blocks[key] = (wire_idx, dict(block))
659
+ yield ContentBlockStartData(
660
+ event="content-block-start",
661
+ index=wire_idx,
662
+ content=_start_skeleton(block),
663
+ )
664
+ else:
665
+ wire_idx, existing = blocks[key]
666
+ blocks[key] = (wire_idx, _accumulate(existing, block))
667
+ if _should_emit_delta(block):
668
+ wire_idx, current = blocks[key]
669
+ is_block_delta = block.get("type") in {
670
+ "tool_call_chunk",
671
+ "server_tool_call_chunk",
672
+ }
673
+ delta_source = current if is_block_delta else block
674
+ yield ContentBlockDeltaData(
675
+ event="content-block-delta",
676
+ index=wire_idx,
677
+ delta=_to_content_delta(delta_source or block),
678
+ )
679
+
680
+ if msg.usage_metadata:
681
+ usage = _accumulate_usage(usage, msg.usage_metadata)
682
+
683
+ if not started:
684
+ return
685
+
686
+ for wire_idx, block in blocks.values():
687
+ yield _finalize_and_build_finish(wire_idx, block)
688
+
689
+ yield _build_message_finish(
690
+ usage=usage,
691
+ response_metadata=response_metadata,
692
+ additional_kwargs=additional_kwargs,
693
+ )
694
+
695
+
696
+ async def achunks_to_events(
697
+ chunks: AsyncIterator[ChatGenerationChunk],
698
+ *,
699
+ message_id: str | None = None,
700
+ ) -> AsyncIterator[MessagesData]:
701
+ """Async variant of `chunks_to_events`."""
702
+ started = False
703
+ blocks: dict[Any, tuple[int, CompatBlock]] = {}
704
+ next_wire_idx = 0
705
+ usage: UsageInfo | None = None
706
+ response_metadata: dict[str, Any] = {}
707
+ additional_kwargs: dict[str, Any] = {}
708
+
709
+ async for chunk in chunks:
710
+ msg = chunk.message
711
+ if not isinstance(msg, AIMessageChunk):
712
+ continue
713
+
714
+ # See sync twin for rationale: merge `generation_info` into the
715
+ # accumulated `response_metadata` with the same priority as the
716
+ # v1 `stream()` wrapper.
717
+ merged_rm: dict[str, Any] = {
718
+ **(chunk.generation_info or {}),
719
+ **(msg.response_metadata or {}),
720
+ }
721
+ if merged_rm:
722
+ response_metadata.update(merged_rm)
723
+
724
+ # See sync twin: carry chunk `additional_kwargs` through so
725
+ # provider-specific data (e.g. Gemini thought signatures) reaches
726
+ # the assembled message instead of being dropped.
727
+ if msg.additional_kwargs:
728
+ additional_kwargs = merge_dicts(additional_kwargs, msg.additional_kwargs)
729
+
730
+ if not started:
731
+ started = True
732
+ yield _build_message_start(msg, message_id)
733
+
734
+ for key, block in _iter_protocol_blocks(msg):
735
+ if key not in blocks:
736
+ wire_idx = next_wire_idx
737
+ next_wire_idx += 1
738
+ blocks[key] = (wire_idx, dict(block))
739
+ yield ContentBlockStartData(
740
+ event="content-block-start",
741
+ index=wire_idx,
742
+ content=_start_skeleton(block),
743
+ )
744
+ else:
745
+ wire_idx, existing = blocks[key]
746
+ blocks[key] = (wire_idx, _accumulate(existing, block))
747
+ if _should_emit_delta(block):
748
+ wire_idx, current = blocks[key]
749
+ is_block_delta = block.get("type") in {
750
+ "tool_call_chunk",
751
+ "server_tool_call_chunk",
752
+ }
753
+ delta_source = current if is_block_delta else block
754
+ yield ContentBlockDeltaData(
755
+ event="content-block-delta",
756
+ index=wire_idx,
757
+ delta=_to_content_delta(delta_source or block),
758
+ )
759
+
760
+ if msg.usage_metadata:
761
+ usage = _accumulate_usage(usage, msg.usage_metadata)
762
+
763
+ if not started:
764
+ return
765
+
766
+ for wire_idx, block in blocks.values():
767
+ yield _finalize_and_build_finish(wire_idx, block)
768
+
769
+ yield _build_message_finish(
770
+ usage=usage,
771
+ response_metadata=response_metadata,
772
+ additional_kwargs=additional_kwargs,
773
+ )
774
+
775
+
776
+ def message_to_events(
777
+ msg: BaseMessage,
778
+ *,
779
+ message_id: str | None = None,
780
+ ) -> Iterator[MessagesData]:
781
+ """Replay a finalized message as a synthetic event lifecycle.
782
+
783
+ For a message returned whole (from a graph node, checkpoint, or
784
+ cache), produce the same `message-start` / per-block /
785
+ `message-finish` event stream a live call would produce. Consumers
786
+ downstream see a uniform event shape regardless of source.
787
+
788
+ Text and reasoning blocks emit a single `content-block-delta` with
789
+ the full accumulated content. Already-finalized blocks (tool_call,
790
+ server_tool_call, image, etc.) skip the delta and rely on the
791
+ `content-block-finish` event alone.
792
+
793
+ Args:
794
+ msg: The finalized message — typically an `AIMessage`.
795
+ message_id: Optional stable message ID; falls back to `msg.id`.
796
+
797
+ Yields:
798
+ `MessagesData` lifecycle events.
799
+ """
800
+ response_metadata = msg.response_metadata or {}
801
+ yield _build_message_start(msg, message_id)
802
+
803
+ for wire_idx, (_key, block) in enumerate(_iter_protocol_blocks(msg)):
804
+ yield ContentBlockStartData(
805
+ event="content-block-start",
806
+ index=wire_idx,
807
+ content=_start_skeleton(block),
808
+ )
809
+ if _should_emit_delta(block):
810
+ yield ContentBlockDeltaData(
811
+ event="content-block-delta",
812
+ index=wire_idx,
813
+ delta=_to_content_delta(block),
814
+ )
815
+ yield ContentBlockFinishData(
816
+ event="content-block-finish",
817
+ index=wire_idx,
818
+ content=_finalize_block(block),
819
+ )
820
+
821
+ yield _build_message_finish(
822
+ usage=getattr(msg, "usage_metadata", None),
823
+ response_metadata=response_metadata,
824
+ )
825
+
826
+
827
+ async def amessage_to_events(
828
+ msg: BaseMessage,
829
+ *,
830
+ message_id: str | None = None,
831
+ ) -> AsyncIterator[MessagesData]:
832
+ """Async variant of `message_to_events`."""
833
+ for event in message_to_events(msg, message_id=message_id):
834
+ yield event
835
+
836
+
837
+ __all__ = [
838
+ "CompatBlock",
839
+ "achunks_to_events",
840
+ "amessage_to_events",
841
+ "chunks_to_events",
842
+ "finalize_tool_call_chunk",
843
+ "message_to_events",
844
+ ]