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,1525 @@
1
+ """Per-message streaming objects for content-block protocol events.
2
+
3
+ `ChatModelStream` is the synchronous variant returned by
4
+ `BaseChatModel.stream_events(version="v3")`. `AsyncChatModelStream` is the
5
+ asynchronous variant returned by `BaseChatModel.astream_events(version="v3")`.
6
+
7
+ Both expose typed projection properties (`.text`, `.reasoning`,
8
+ `.tool_calls`, `.usage`, `.output`) that accumulate protocol
9
+ events as they arrive. Projections can be iterated for deltas or
10
+ drained for the final accumulated value.
11
+
12
+ Raw protocol events are also available via direct iteration on the
13
+ stream object (replay-buffer semantics — multiple independent
14
+ consumers supported).
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import asyncio
20
+ import contextlib
21
+ from typing import TYPE_CHECKING, Any, cast
22
+
23
+ from coco_core.language_models._compat_bridge import finalize_tool_call_chunk
24
+ from coco_core.messages import AIMessage
25
+
26
+ if TYPE_CHECKING:
27
+ from collections.abc import Awaitable, Callable, Generator, Iterator, Mapping
28
+
29
+ from coco_core._protocol import (
30
+ ContentBlockDeltaData,
31
+ ContentBlockFinishData,
32
+ FinalizedContentBlock,
33
+ InvalidToolCall,
34
+ MessageFinishData,
35
+ MessageMetadata,
36
+ MessagesData,
37
+ MessageStartData,
38
+ ReasoningContentBlock,
39
+ ServerToolCallChunk,
40
+ TextContentBlock,
41
+ ToolCall,
42
+ ToolCallChunk,
43
+ UsageInfo,
44
+ )
45
+ from typing_extensions import Self
46
+
47
+
48
+ # ---------------------------------------------------------------------------
49
+ # Tool-call chunk helpers (shared by tool_call_chunk and server_tool_call_chunk)
50
+ # ---------------------------------------------------------------------------
51
+
52
+
53
+ def _merge_chunk_into_store(
54
+ store: dict[int, dict[str, Any]],
55
+ idx: int,
56
+ block: dict[str, Any],
57
+ ) -> None:
58
+ """Merge a tool-call-chunk delta: sticky id/name, concat args."""
59
+ existing = store.get(idx, {})
60
+ if block.get("id") and "id" not in existing:
61
+ existing["id"] = block["id"]
62
+ if block.get("name") and "name" not in existing:
63
+ existing["name"] = block["name"]
64
+ existing["args"] = existing.get("args", "") + (block.get("args") or "")
65
+ store[idx] = existing
66
+
67
+
68
+ def _merge_block_delta_into_store(
69
+ store: dict[int, dict[str, Any]],
70
+ idx: int,
71
+ fields: dict[str, Any],
72
+ ) -> None:
73
+ """Shallow-merge a block-delta snapshot into an indexed chunk store."""
74
+ existing = store.get(idx, {})
75
+ for key, value in fields.items():
76
+ if value is not None:
77
+ existing[key] = value
78
+ store[idx] = existing
79
+
80
+
81
+ def _event_content_block(data: Mapping[str, Any]) -> dict[str, Any] | None:
82
+ """Return start/finish content, tolerating the pre-delta field name."""
83
+ block = data.get("content") or data.get("content_block")
84
+ return block if isinstance(block, dict) else None
85
+
86
+
87
+ def _legacy_block_to_delta(block: Mapping[str, Any]) -> dict[str, Any]:
88
+ """Convert the old content-block delta shape to an explicit delta."""
89
+ btype = block.get("type")
90
+ if btype == "text":
91
+ return {"type": "text-delta", "text": block.get("text", "")}
92
+ if btype == "reasoning":
93
+ return {
94
+ "type": "reasoning-delta",
95
+ "reasoning": block.get("reasoning", ""),
96
+ }
97
+ if "data" in block:
98
+ delta = {"type": "data-delta", "data": block.get("data", "")}
99
+ if block.get("encoding") == "base64":
100
+ delta["encoding"] = "base64"
101
+ return delta
102
+ return {"type": "legacy-block-delta", "fields": block}
103
+
104
+
105
+ def _event_delta(data: Mapping[str, Any]) -> dict[str, Any] | None:
106
+ """Return an explicit delta, converting legacy content-block deltas."""
107
+ delta = data.get("delta")
108
+ if isinstance(delta, dict):
109
+ return delta
110
+ block = data.get("content_block")
111
+ if isinstance(block, dict):
112
+ return _legacy_block_to_delta(block)
113
+ return None
114
+
115
+
116
+ def _sweep_chunk_store(
117
+ store: dict[int, dict[str, Any]],
118
+ *,
119
+ finalized_type: str,
120
+ finalized_blocks: dict[int, FinalizedContentBlock],
121
+ tool_calls_acc: list[ToolCall] | None,
122
+ invalid_acc: list[InvalidToolCall],
123
+ ) -> None:
124
+ """Parse each unswept chunk's `args`; record as `finalized_type` or invalid.
125
+
126
+ `tool_calls_acc` is only populated when `finalized_type == "tool_call"`
127
+ (server-side calls don't surface through `.tool_calls`).
128
+
129
+ Deliberately does not backfill `index` onto finalized tool-call blocks:
130
+ matches v1 (`AIMessage.init_tool_calls` drops `index` when substituting
131
+ `tool_call_chunk` → `tool_call`) and prevents `merge_lists` from
132
+ re-merging further chunks into an already-parsed args dict.
133
+ """
134
+ for idx in sorted(store):
135
+ chunk = store[idx]
136
+ # Carry over any non-finalize-rewritten fields the chunk collected
137
+ # (e.g., `extras`). `_merge_chunk_into_store` only populates
138
+ # `id` / `name` / `args`, so this is empty in practice today;
139
+ # future provider-specific fields would flow through here.
140
+ extras = {
141
+ k: v
142
+ for k, v in chunk.items()
143
+ if k not in {"type", "id", "name", "args"} and v is not None
144
+ }
145
+ final_block = finalize_tool_call_chunk(
146
+ raw_args=chunk.get("args"),
147
+ id_=chunk.get("id"),
148
+ name=chunk.get("name"),
149
+ extras=extras,
150
+ finalized_type=finalized_type,
151
+ )
152
+ if final_block["type"] == "invalid_tool_call":
153
+ invalid_acc.append(final_block)
154
+ elif tool_calls_acc is not None and finalized_type == "tool_call":
155
+ tool_calls_acc.append(cast("ToolCall", final_block))
156
+ finalized_blocks[idx] = final_block
157
+ store.clear()
158
+
159
+
160
+ # ---------------------------------------------------------------------------
161
+ # Projection base — shared producer API
162
+ # ---------------------------------------------------------------------------
163
+
164
+
165
+ class _ProjectionBase:
166
+ """Shared state and producer API for sync and async projections.
167
+
168
+ The `push` / `complete` / `fail` methods are the producer-side
169
+ API — called by the stream as events arrive. Subclasses add the
170
+ consumer protocol (sync iteration or async iteration + await).
171
+
172
+ `done` and `error` are safe read-only views of the terminal state
173
+ for iterators and other siblings that need to observe lifecycle
174
+ without reaching into the underlying fields.
175
+ """
176
+
177
+ __slots__ = ("_deltas", "_done", "_error", "_final_set", "_final_value")
178
+
179
+ def __init__(self) -> None:
180
+ """Initialize empty projection state."""
181
+ self._deltas: list[Any] = []
182
+ self._final_value: Any = None
183
+ self._final_set: bool = False
184
+ self._done: bool = False
185
+ self._error: BaseException | None = None
186
+
187
+ @property
188
+ def done(self) -> bool:
189
+ """Whether the projection has finished (successfully or via error)."""
190
+ return self._done
191
+
192
+ @property
193
+ def error(self) -> BaseException | None:
194
+ """The terminal error, if any."""
195
+ return self._error
196
+
197
+ def push(self, delta: Any) -> None:
198
+ """Append a delta value. Producer-side API."""
199
+ self._deltas.append(delta)
200
+
201
+ def complete(self, final_value: Any) -> None:
202
+ """Set the final accumulated value and mark as done. Producer-side API."""
203
+ self._final_value = final_value
204
+ self._final_set = True
205
+ self._done = True
206
+
207
+ def fail(self, error: BaseException) -> None:
208
+ """Mark as errored. Producer-side API."""
209
+ self._error = error
210
+ self._done = True
211
+
212
+
213
+ # ---------------------------------------------------------------------------
214
+ # Sync projections
215
+ # ---------------------------------------------------------------------------
216
+
217
+
218
+ class SyncProjection(_ProjectionBase):
219
+ """Sync iterable of deltas with pull-based backpressure.
220
+
221
+ Follows the same `_request_more` convention as coco_graph's
222
+ `EventLog`: when the cursor catches up to the buffer and the
223
+ projection is not done, it calls `_request_more()` to pull more
224
+ events from the producer.
225
+
226
+ Each call to `__iter__` creates a new cursor at position 0.
227
+ Multiple iterators replay all deltas from the start.
228
+ """
229
+
230
+ __slots__ = ("_ensure_started", "_request_more")
231
+
232
+ def __init__(self) -> None:
233
+ """Initialize with no pull callback."""
234
+ super().__init__()
235
+ self._ensure_started: Callable[[], None] | None = None
236
+ self._request_more: Callable[[], bool] | None = None
237
+
238
+ def set_start(self, cb: Callable[[], None] | None) -> None:
239
+ """Install a lazy-start callback invoked on first consumption."""
240
+ self._ensure_started = cb
241
+
242
+ def set_request_more(self, cb: Callable[[], bool] | None) -> None:
243
+ """Install the pull callback the iterator uses to drain the source."""
244
+ self._request_more = cb
245
+
246
+ def __iter__(self) -> Iterator[Any]:
247
+ """Yield deltas, pulling via `_request_more` when caught up."""
248
+ if self._ensure_started is not None:
249
+ self._ensure_started()
250
+ cursor = 0
251
+ while True:
252
+ if cursor < len(self._deltas):
253
+ yield self._deltas[cursor]
254
+ cursor += 1
255
+ elif self._error is not None:
256
+ raise self._error
257
+ elif self._done:
258
+ return
259
+ elif self._request_more is not None:
260
+ while cursor >= len(self._deltas) and not self._done:
261
+ if not self._request_more():
262
+ break
263
+ if cursor >= len(self._deltas):
264
+ if self._error is not None:
265
+ raise self._error
266
+ return
267
+ else:
268
+ return
269
+
270
+ def get(self) -> Any:
271
+ """Drain via `_request_more` and return the final value."""
272
+ if self._ensure_started is not None:
273
+ self._ensure_started()
274
+ if not self._done and self._request_more is not None:
275
+ while not self._done:
276
+ if not self._request_more():
277
+ break
278
+ if self._error is not None:
279
+ raise self._error
280
+ return self._final_value
281
+
282
+
283
+ class SyncTextProjection(SyncProjection):
284
+ """String-specialized sync projection.
285
+
286
+ Adds typed string producers and consumers, plus `__str__`, `__bool__`,
287
+ `__repr__` for ergonomic use with `.text` and `.reasoning` projections.
288
+ """
289
+
290
+ __slots__ = ()
291
+
292
+ def push(self, delta: str) -> None:
293
+ """Append a text delta."""
294
+ if not isinstance(cast("Any", delta), str):
295
+ msg = "SyncTextProjection requires a string delta"
296
+ raise TypeError(msg)
297
+ super().push(delta)
298
+
299
+ def complete(self, final_value: str) -> None:
300
+ """Set the final accumulated text and mark the projection as done."""
301
+ if not isinstance(cast("Any", final_value), str):
302
+ msg = "SyncTextProjection requires a string final value"
303
+ raise TypeError(msg)
304
+ super().complete(final_value)
305
+
306
+ def __iter__(self) -> Iterator[str]:
307
+ """Yield text deltas, raising if a producer supplied a non-string value."""
308
+ for delta in super().__iter__():
309
+ if not isinstance(delta, str):
310
+ msg = "SyncTextProjection received a non-string delta"
311
+ raise TypeError(msg)
312
+ yield delta
313
+
314
+ def get(self) -> str:
315
+ """Drain and return the full accumulated string, or empty if unfinished."""
316
+ value = super().get()
317
+ if value is None:
318
+ return ""
319
+ if not isinstance(value, str):
320
+ msg = "SyncTextProjection received a non-string final value"
321
+ raise TypeError(msg)
322
+ return value
323
+
324
+ def __str__(self) -> str:
325
+ """Drain and return the full accumulated string."""
326
+ return self.get()
327
+
328
+ def __bool__(self) -> bool:
329
+ """Return whether any deltas have been pushed."""
330
+ return len(self._deltas) > 0
331
+
332
+ def __repr__(self) -> str:
333
+ """Return repr of the accumulated text so far."""
334
+ if self._final_set:
335
+ return repr(self._final_value)
336
+ return repr("".join(self._deltas))
337
+
338
+
339
+ # ---------------------------------------------------------------------------
340
+ # Async projection
341
+ # ---------------------------------------------------------------------------
342
+
343
+
344
+ class AsyncProjection(_ProjectionBase):
345
+ """Async iterable of deltas that is also awaitable for the final value.
346
+
347
+ Uses an `asyncio.Event` to notify consumers of state changes. Each
348
+ waiter — the awaitable (`__await__`) and each async iterator cursor
349
+ — shares the event and re-checks its own condition on wake. The event
350
+ is cleared before a waiter awaits, so stale "something happened"
351
+ signals don't cause spin loops.
352
+
353
+ This is single-loop only — producers and consumers must share an
354
+ event loop. If cross-thread wake is ever required, revert to a
355
+ list-of-futures pattern with `call_soon_threadsafe`.
356
+ """
357
+
358
+ __slots__ = ("_arequest_more", "_ensure_started", "_event")
359
+
360
+ def __init__(self) -> None:
361
+ """Initialize with an un-set event and no pump callback."""
362
+ super().__init__()
363
+ self._event = asyncio.Event()
364
+ self._arequest_more: Callable[[], Awaitable[bool]] | None = None
365
+ self._ensure_started: Callable[[], Awaitable[None]] | None = None
366
+
367
+ def set_start(self, cb: Callable[[], Awaitable[None]] | None) -> None:
368
+ """Install a lazy-start callback invoked on first consumption."""
369
+ self._ensure_started = cb
370
+
371
+ def set_arequest_more(self, cb: Callable[[], Awaitable[bool]] | None) -> None:
372
+ """Wire the async pull callback iterators use to drive the source.
373
+
374
+ Mirrors `SyncProjection.set_request_more`. Under caller-driven
375
+ streaming, consumers call this callback when their buffer is
376
+ empty so that the owning graph advances one step.
377
+
378
+ Args:
379
+ cb: Async no-arg callable returning `True` when a new event
380
+ was produced, `False` when the source is exhausted. Pass
381
+ `None` to unwire.
382
+ """
383
+ self._arequest_more = cb
384
+
385
+ def push(self, delta: Any) -> None:
386
+ """Append a delta and notify waiters."""
387
+ super().push(delta)
388
+ self._event.set()
389
+
390
+ def complete(self, final_value: Any) -> None:
391
+ """Set the final value, mark done, and notify waiters."""
392
+ super().complete(final_value)
393
+ self._event.set()
394
+
395
+ def fail(self, error: BaseException) -> None:
396
+ """Mark errored and notify waiters."""
397
+ super().fail(error)
398
+ self._event.set()
399
+
400
+ # -- Async iterable (yields deltas) ------------------------------------
401
+
402
+ def __aiter__(self) -> _AsyncProjectionIterator:
403
+ """Return an async iterator over deltas."""
404
+ return _AsyncProjectionIterator(self)
405
+
406
+ # -- Awaitable (returns final value) -----------------------------------
407
+
408
+ def __await__(self) -> Generator[Any, None, Any]:
409
+ """Await the final accumulated value."""
410
+ return self._await_impl().__await__()
411
+
412
+ async def _await_impl(self) -> Any:
413
+ """Wait until the final value is set and return it.
414
+
415
+ When a caller-driven pump is wired via `set_arequest_more`, drive
416
+ it instead of blocking on `self._event`; otherwise fall back to
417
+ the event (used by tests that dispatch manually).
418
+ """
419
+ if self._ensure_started is not None:
420
+ await self._ensure_started()
421
+ while not self._final_set:
422
+ if self._error is not None:
423
+ raise self._error
424
+ if self._arequest_more is not None:
425
+ if not await self._arequest_more() and not self._final_set:
426
+ # Pump exhausted without completing this projection —
427
+ # nothing more will arrive. Return current state and
428
+ # let callers observe the missing final via the
429
+ # returned None / unset error.
430
+ break
431
+ else:
432
+ self._event.clear()
433
+ await self._event.wait()
434
+ if self._error is not None:
435
+ raise self._error
436
+ return self._final_value
437
+
438
+
439
+ class _AsyncProjectionIterator:
440
+ """Async iterator over an `AsyncProjection`'s deltas."""
441
+
442
+ __slots__ = ("_offset", "_proj")
443
+
444
+ def __init__(self, proj: AsyncProjection) -> None:
445
+ """Initialize cursor at position 0."""
446
+ self._proj = proj
447
+ self._offset = 0
448
+
449
+ def __aiter__(self) -> _AsyncProjectionIterator:
450
+ """Return self for the async iteration protocol."""
451
+ return self
452
+
453
+ async def __anext__(self) -> Any:
454
+ """Return the next delta, awaiting if necessary.
455
+
456
+ When the projection has an `_arequest_more` pump wired, drain it
457
+ in an inner loop (mirrors `SyncProjection.__iter__`) until this
458
+ cursor advances or the pump reports exhaustion. Without a pump,
459
+ fall back to waiting on the shared event.
460
+ """
461
+ proj = self._proj
462
+ if proj._ensure_started is not None: # noqa: SLF001
463
+ await proj._ensure_started() # noqa: SLF001
464
+ while True:
465
+ # Direct access to the projection's internal list/event is
466
+ # intentional — the iterator is the projection's sidekick and
467
+ # depends on reading the shared buffer by cursor.
468
+ if self._offset < len(proj._deltas): # noqa: SLF001
469
+ item = proj._deltas[self._offset] # noqa: SLF001
470
+ self._offset += 1
471
+ return item
472
+ if proj.error is not None:
473
+ raise proj.error
474
+ if proj.done:
475
+ raise StopAsyncIteration
476
+ if proj._arequest_more is not None: # noqa: SLF001
477
+ # Caller-driven: drive the producer. Pump may land new
478
+ # deltas for a sibling projection — loop until our cursor
479
+ # advances, the projection terminates, or the pump is
480
+ # exhausted.
481
+ while (
482
+ self._offset >= len(proj._deltas) # noqa: SLF001
483
+ and not proj.done
484
+ ):
485
+ if not await proj._arequest_more(): # noqa: SLF001
486
+ break
487
+ if (
488
+ self._offset >= len(proj._deltas) # noqa: SLF001
489
+ and not proj.done
490
+ ):
491
+ if proj.error is not None:
492
+ raise proj.error
493
+ raise StopAsyncIteration
494
+ else:
495
+ proj._event.clear() # noqa: SLF001
496
+ await proj._event.wait() # noqa: SLF001
497
+
498
+
499
+ class _AsyncTextProjectionIterator(_AsyncProjectionIterator):
500
+ """Async iterator over an `AsyncTextProjection`'s text deltas."""
501
+
502
+ __slots__ = ()
503
+
504
+ async def __anext__(self) -> str:
505
+ """Return the next text delta."""
506
+ item = await super().__anext__()
507
+ if not isinstance(item, str):
508
+ msg = "AsyncTextProjection received a non-string delta"
509
+ raise TypeError(msg)
510
+ return item
511
+
512
+
513
+ class AsyncTextProjection(AsyncProjection):
514
+ """String-specialized async projection for `.text` and `.reasoning`."""
515
+
516
+ __slots__ = ()
517
+
518
+ def push(self, delta: str) -> None:
519
+ """Append a text delta and notify waiters."""
520
+ if not isinstance(cast("Any", delta), str):
521
+ msg = "AsyncTextProjection requires a string delta"
522
+ raise TypeError(msg)
523
+ super().push(delta)
524
+
525
+ def complete(self, final_value: str) -> None:
526
+ """Set the final accumulated text and notify waiters."""
527
+ if not isinstance(cast("Any", final_value), str):
528
+ msg = "AsyncTextProjection requires a string final value"
529
+ raise TypeError(msg)
530
+ super().complete(final_value)
531
+
532
+ def __aiter__(self) -> _AsyncTextProjectionIterator:
533
+ """Return an async iterator over text deltas."""
534
+ return _AsyncTextProjectionIterator(self)
535
+
536
+ def __await__(self) -> Generator[Any, None, str]:
537
+ """Await the full accumulated text."""
538
+ return self._await_text_impl().__await__()
539
+
540
+ async def _await_text_impl(self) -> str:
541
+ """Return accumulated text or raise if the final value is not a string."""
542
+ value = await self._await_impl()
543
+ if value is None:
544
+ return ""
545
+ if not isinstance(value, str):
546
+ msg = "AsyncTextProjection received a non-string final value"
547
+ raise TypeError(msg)
548
+ return value
549
+
550
+
551
+ # ---------------------------------------------------------------------------
552
+ # Sync stream
553
+ # ---------------------------------------------------------------------------
554
+
555
+
556
+ class _ChatModelStreamBase:
557
+ """Shared state and event dispatch for chat-model streams.
558
+
559
+ Holds accumulated protocol state (text, reasoning, tool calls,
560
+ usage, metadata) and the event-dispatch machinery that drives the
561
+ typed projections. `ChatModelStream` (sync) and
562
+ `AsyncChatModelStream` (async) inherit from this base and add the
563
+ projection types and consumer APIs for their flavor.
564
+ """
565
+
566
+ # Projection instances — concrete subclasses create them as sync or
567
+ # async variants in their own __init__ after calling super().
568
+ _text_proj: _ProjectionBase
569
+ _reasoning_proj: _ProjectionBase
570
+ _tool_calls_proj: _ProjectionBase
571
+
572
+ def __init__(
573
+ self,
574
+ *,
575
+ namespace: list[str] | None = None,
576
+ node: str | None = None,
577
+ message_id: str | None = None,
578
+ ) -> None:
579
+ self._namespace = namespace or []
580
+ self._node = node
581
+ self._message_id = message_id
582
+
583
+ # Accumulated state
584
+ self._text_acc: str = ""
585
+ self._reasoning_acc: str = ""
586
+ # Per-block text / reasoning storage keyed by wire index. Used to
587
+ # populate the finalized block payload without cross-contaminating
588
+ # other blocks of the same type in the same message. Without
589
+ # per-block storage the message-wide accumulator would bleed
590
+ # earlier block text into later finalized blocks.
591
+ self._text_per_block: dict[int, str] = {}
592
+ self._reasoning_per_block: dict[int, str] = {}
593
+ self._tool_call_chunks: dict[int, dict[str, Any]] = {}
594
+ self._tool_calls_acc: list[ToolCall] = []
595
+ self._invalid_tool_calls_acc: list[InvalidToolCall] = []
596
+ self._server_tool_call_chunks: dict[int, dict[str, Any]] = {}
597
+ # Ordered snapshot of every finalized block, keyed by event index.
598
+ # Single source of truth for .output.content. Typed accumulators
599
+ # (text/reasoning/tool_calls/invalid_tool_calls) continue to serve
600
+ # the public projections.
601
+ self._blocks: dict[int, FinalizedContentBlock] = {}
602
+ self._usage_value: UsageInfo | None = None
603
+ self._start_metadata: MessageMetadata | None = None
604
+ self._finish_metadata: dict[str, Any] | None = None
605
+ self._additional_kwargs: dict[str, Any] | None = None
606
+ self._done: bool = False
607
+ self._error: BaseException | None = None
608
+ self._output_message: AIMessage | None = None
609
+
610
+ # Raw event replay buffer
611
+ self._events: list[MessagesData] = []
612
+
613
+ # -- Common properties ------------------------------------------------
614
+
615
+ @property
616
+ def namespace(self) -> list[str]:
617
+ """Graph namespace path for this message."""
618
+ return self._namespace
619
+
620
+ @property
621
+ def node(self) -> str | None:
622
+ """Graph node that produced this message."""
623
+ return self._node
624
+
625
+ @property
626
+ def message_id(self) -> str | None:
627
+ """Stable message identifier."""
628
+ return self._message_id
629
+
630
+ def set_message_id(self, message_id: str) -> None:
631
+ """Assign the stable message identifier once the run starts.
632
+
633
+ Called by the stream driver (`stream_events(version="v3")` /
634
+ `astream_events(version="v3")`) after `on_chat_model_start` produces a run
635
+ id. Not intended for end-user code.
636
+ """
637
+ self._message_id = message_id
638
+
639
+ @property
640
+ def done(self) -> bool:
641
+ """Whether the stream has finished."""
642
+ return self._done
643
+
644
+ @property
645
+ def has_events(self) -> bool:
646
+ """Whether any protocol events have been recorded."""
647
+ return bool(self._events)
648
+
649
+ @property
650
+ def output_message(self) -> AIMessage | None:
651
+ """The assembled message if the stream has finished, else `None`.
652
+
653
+ Unlike `ChatModelStream.output` (which blocks until the stream
654
+ finishes), this never pumps, blocks, or raises. Intended for the
655
+ stream driver (`stream_events(version="v3")` and its async
656
+ equivalent) to check whether the stream produced a message before
657
+ firing `on_llm_end` callbacks.
658
+ """
659
+ return self._output_message
660
+
661
+ # -- Event ingestion (public) ------------------------------------------
662
+
663
+ def dispatch(self, event: Mapping[str, Any]) -> None:
664
+ """Route a protocol event to the appropriate internal handler.
665
+
666
+ Public entry point for feeding events into the stream. Called by
667
+ the stream driver (the `stream_events(version="v3")` pump and its
668
+ async equivalent) and by any observer or test that needs to
669
+ inject protocol events.
670
+ """
671
+ self._record_event(event)
672
+ event_type = event.get("event")
673
+ if event_type == "message-start":
674
+ self._push_message_start(cast("MessageStartData", event))
675
+ elif event_type == "content-block-delta":
676
+ self._push_content_block_delta(cast("ContentBlockDeltaData", event))
677
+ elif event_type == "content-block-finish":
678
+ self._push_content_block_finish(cast("ContentBlockFinishData", event))
679
+ elif event_type == "message-finish":
680
+ self._finish(cast("MessageFinishData", event))
681
+ elif event_type == "error":
682
+ self.fail(RuntimeError(event.get("message", "Unknown error")))
683
+ # content-block-start is informational — no accumulation needed
684
+
685
+ # -- Internal push API (called by dispatch) ----------------------------
686
+
687
+ def _record_event(self, event: Mapping[str, Any]) -> None:
688
+ """Append a raw event to the replay buffer."""
689
+ self._events.append(cast("MessagesData", event))
690
+
691
+ def _push_message_start(self, data: MessageStartData) -> None:
692
+ """Process a `message-start` event."""
693
+ self._start_metadata = data.get("metadata")
694
+ message_id = data.get("id")
695
+ if message_id:
696
+ self._message_id = message_id
697
+
698
+ def _push_content_block_delta(self, data: ContentBlockDeltaData) -> None:
699
+ """Process a `content-block-delta` event."""
700
+ delta = _event_delta(data)
701
+ if delta is None:
702
+ return
703
+ event_idx = data.get("index")
704
+ dtype = delta.get("type", "")
705
+
706
+ if dtype == "text-delta":
707
+ delta_text = delta.get("text", "")
708
+ if delta_text:
709
+ self._text_acc += delta_text
710
+ if event_idx is not None:
711
+ self._text_per_block[event_idx] = (
712
+ self._text_per_block.get(event_idx, "") + delta_text
713
+ )
714
+ self._text_proj.push(delta_text)
715
+ elif dtype == "reasoning-delta":
716
+ delta_r = delta.get("reasoning", "")
717
+ if delta_r:
718
+ self._reasoning_acc += delta_r
719
+ if event_idx is not None:
720
+ self._reasoning_per_block[event_idx] = (
721
+ self._reasoning_per_block.get(event_idx, "") + delta_r
722
+ )
723
+ self._reasoning_proj.push(delta_r)
724
+ elif dtype == "block-delta":
725
+ fields = delta.get("fields")
726
+ if not isinstance(fields, dict):
727
+ return
728
+ btype = fields.get("type", "")
729
+ if btype == "tool_call_chunk":
730
+ tcc = cast("ToolCallChunk", fields)
731
+ idx = data.get("index")
732
+ if idx is None:
733
+ idx = tcc.get("index", len(self._tool_call_chunks)) # type: ignore[unreachable]
734
+ _merge_block_delta_into_store(self._tool_call_chunks, idx, dict(tcc))
735
+ chunk_block: ToolCallChunk = {
736
+ "type": "tool_call_chunk",
737
+ "id": tcc.get("id"),
738
+ "name": tcc.get("name"),
739
+ "args": tcc.get("args"),
740
+ }
741
+ if "index" in tcc:
742
+ chunk_block["index"] = tcc["index"]
743
+ self._tool_calls_proj.push(chunk_block)
744
+ elif btype == "server_tool_call_chunk":
745
+ stcc = cast("ServerToolCallChunk", fields)
746
+ idx = data.get("index")
747
+ if idx is None:
748
+ idx = len(self._server_tool_call_chunks) # type: ignore[unreachable]
749
+ _merge_block_delta_into_store(
750
+ self._server_tool_call_chunks,
751
+ idx,
752
+ dict(stcc),
753
+ )
754
+ elif dtype == "legacy-block-delta":
755
+ fields = delta.get("fields")
756
+ if not isinstance(fields, dict):
757
+ return
758
+ btype = fields.get("type", "")
759
+ if btype == "tool_call_chunk":
760
+ tcc = cast("ToolCallChunk", fields)
761
+ idx = data.get("index")
762
+ if idx is None:
763
+ idx = tcc.get("index", len(self._tool_call_chunks)) # type: ignore[unreachable]
764
+ _merge_chunk_into_store(self._tool_call_chunks, idx, dict(tcc))
765
+ legacy_chunk_block: ToolCallChunk = {
766
+ "type": "tool_call_chunk",
767
+ "id": tcc.get("id"),
768
+ "name": tcc.get("name"),
769
+ "args": tcc.get("args"),
770
+ }
771
+ if "index" in tcc:
772
+ legacy_chunk_block["index"] = tcc["index"]
773
+ self._tool_calls_proj.push(legacy_chunk_block)
774
+ elif btype == "server_tool_call_chunk":
775
+ stcc = cast("ServerToolCallChunk", fields)
776
+ idx = data.get("index")
777
+ if idx is None:
778
+ idx = len(self._server_tool_call_chunks) # type: ignore[unreachable]
779
+ _merge_chunk_into_store(
780
+ self._server_tool_call_chunks,
781
+ idx,
782
+ dict(stcc),
783
+ )
784
+ elif dtype == "data-delta":
785
+ # Binary/modal payload deltas are reflected in the final
786
+ # content-block finish event; there is no dedicated projection.
787
+ return
788
+ else:
789
+ # Transitional legacy path for old `content_block` deltas that
790
+ # should not be reachable after `_event_delta` conversion, kept
791
+ # here for custom in-tree test fixtures or third-party emitters.
792
+ block = data.get("content_block")
793
+ if not isinstance(block, dict):
794
+ return
795
+ btype = block.get("type", "")
796
+ if btype != "tool_call_chunk":
797
+ return
798
+ tcc = cast("ToolCallChunk", block)
799
+ idx = data.get("index")
800
+ if idx is None:
801
+ idx = tcc.get("index", len(self._tool_call_chunks)) # type: ignore[unreachable]
802
+ _merge_chunk_into_store(self._tool_call_chunks, idx, dict(tcc))
803
+ fallback_chunk_block: ToolCallChunk = {
804
+ "type": "tool_call_chunk",
805
+ "id": tcc.get("id"),
806
+ "name": tcc.get("name"),
807
+ "args": tcc.get("args"),
808
+ }
809
+ if "index" in tcc:
810
+ fallback_chunk_block["index"] = tcc["index"]
811
+ self._tool_calls_proj.push(fallback_chunk_block)
812
+
813
+ def _resolve_block_text(self, idx: int | None, full_text: str) -> str:
814
+ """Return authoritative text for a single text block at `idx`.
815
+
816
+ Prefers per-block delta accumulation; reconciles with the finish
817
+ event's `full_text` when the provider emits authoritative text
818
+ that differs from what the deltas built up.
819
+
820
+ Does not mutate `self._text_acc` (the delta-sum accumulator) —
821
+ the message-wide projection value is derived from per-block
822
+ storage at `_finish` time, so reconciliation remains correct
823
+ regardless of finish ordering across blocks.
824
+ """
825
+ if idx is None:
826
+ # No wire index — legacy behavior: use the message-wide
827
+ # accumulator. Preserved for pre-index semantics; not
828
+ # exercised by the compat bridge or any in-tree provider.
829
+ if full_text and full_text != self._text_acc:
830
+ self._text_acc = full_text
831
+ return self._text_acc
832
+ existing = self._text_per_block.get(idx, "")
833
+ if full_text and full_text != existing:
834
+ if not existing:
835
+ # No deltas arrived for this block — surface the full
836
+ # text as a single delta so the stream projection
837
+ # reflects it.
838
+ self._text_acc += full_text
839
+ self._text_proj.push(full_text)
840
+ elif full_text.startswith(existing):
841
+ # Authoritative text extends the partial deltas — emit
842
+ # the tail so delta consumers see the completion.
843
+ tail = full_text[len(existing) :]
844
+ self._text_acc += tail
845
+ self._text_proj.push(tail)
846
+ # else: authoritative text replaces the partial deltas
847
+ # entirely. No corrective delta is emitted (semantics
848
+ # would be ambiguous mid-stream). `_text_acc` is not
849
+ # spliced — the final value is computed from per-block
850
+ # storage at `_finish`, so this remains correct even when
851
+ # other blocks have added to `_text_acc` in between.
852
+ self._text_per_block[idx] = full_text
853
+ return self._text_per_block.get(idx, "")
854
+
855
+ def _resolve_block_reasoning(self, idx: int | None, full_r: str) -> str:
856
+ """Return authoritative reasoning text for a single block at `idx`.
857
+
858
+ Mirrors `_resolve_block_text` for the reasoning projection.
859
+ """
860
+ if idx is None:
861
+ if full_r and full_r != self._reasoning_acc:
862
+ self._reasoning_acc = full_r
863
+ return self._reasoning_acc
864
+ existing = self._reasoning_per_block.get(idx, "")
865
+ if full_r and full_r != existing:
866
+ if not existing:
867
+ self._reasoning_acc += full_r
868
+ self._reasoning_proj.push(full_r)
869
+ elif full_r.startswith(existing):
870
+ tail = full_r[len(existing) :]
871
+ self._reasoning_acc += tail
872
+ self._reasoning_proj.push(tail)
873
+ self._reasoning_per_block[idx] = full_r
874
+ return self._reasoning_per_block.get(idx, "")
875
+
876
+ def _push_content_block_finish(self, data: ContentBlockFinishData) -> None:
877
+ """Process a `content-block-finish` event."""
878
+ block = _event_content_block(data)
879
+ if block is None:
880
+ return
881
+ btype = block.get("type", "")
882
+ idx = data.get("index")
883
+ finalized: FinalizedContentBlock | None = None
884
+
885
+ if btype == "text":
886
+ text_block = cast("TextContentBlock", block)
887
+ full_text = text_block.get("text", "")
888
+ block_text = self._resolve_block_text(idx, full_text)
889
+ finalized = cast(
890
+ "FinalizedContentBlock",
891
+ {
892
+ **text_block,
893
+ "type": "text",
894
+ "text": block_text,
895
+ },
896
+ )
897
+ elif btype == "reasoning":
898
+ reasoning_block = cast("ReasoningContentBlock", block)
899
+ full_r = reasoning_block.get("reasoning", "")
900
+ block_reasoning = self._resolve_block_reasoning(idx, full_r)
901
+ # Keep provider-specific fields alongside the accumulated
902
+ # reasoning text. Anthropic's `signature` arrives under
903
+ # `extras` and is required on follow-up turns. Only overwrite
904
+ # `reasoning` when we have accumulated content; OpenAI can
905
+ # emit a reasoning block with no text deltas, and writing an
906
+ # empty string there makes downstream serializers synthesize
907
+ # an empty summary entry.
908
+ finalized_dict: dict[str, Any] = {**reasoning_block, "type": "reasoning"}
909
+ if block_reasoning:
910
+ finalized_dict["reasoning"] = block_reasoning
911
+ finalized = cast("FinalizedContentBlock", finalized_dict)
912
+ elif btype == "tool_call":
913
+ tcb = cast("ToolCall", block)
914
+ # Preserve provider-specific fields (extras, etc.) on the
915
+ # content block. `_assemble_message` separately projects the
916
+ # minimal {id, name, args, type} shape onto
917
+ # `AIMessage.tool_calls`. Strip `index` to match v1
918
+ # (`AIMessage.init_tool_calls` rebuilds the block without
919
+ # `index`); see `_finalize_block` in `_compat_bridge.py`.
920
+ tc = cast(
921
+ "ToolCall",
922
+ {
923
+ **{k: v for k, v in tcb.items() if k != "index"},
924
+ "type": "tool_call",
925
+ "id": tcb.get("id", ""),
926
+ "name": tcb.get("name", ""),
927
+ "args": tcb.get("args", {}),
928
+ },
929
+ )
930
+ self._tool_calls_acc.append(tc)
931
+ if idx is not None and idx in self._tool_call_chunks:
932
+ del self._tool_call_chunks[idx]
933
+ finalized = tc
934
+ elif btype == "invalid_tool_call":
935
+ itc = cast("InvalidToolCall", block)
936
+ # Strip `index` on the stored block to stay symmetric with
937
+ # the `tool_call` path.
938
+ itc = cast(
939
+ "InvalidToolCall",
940
+ {k: v for k, v in itc.items() if k != "index"},
941
+ )
942
+ self._invalid_tool_calls_acc.append(itc)
943
+ # Critical: drop the stale chunk so _finish's sweep doesn't revive
944
+ # it as an empty-args ToolCall.
945
+ if idx is not None and idx in self._tool_call_chunks:
946
+ del self._tool_call_chunks[idx]
947
+ if idx is not None and idx in self._server_tool_call_chunks:
948
+ del self._server_tool_call_chunks[idx]
949
+ finalized = itc
950
+ elif btype in {
951
+ "server_tool_call",
952
+ "server_tool_result",
953
+ "image",
954
+ "audio",
955
+ "video",
956
+ "file",
957
+ "non_standard",
958
+ }:
959
+ if btype == "server_tool_call" and idx is not None:
960
+ self._server_tool_call_chunks.pop(idx, None)
961
+ finalized = cast("FinalizedContentBlock", block)
962
+
963
+ if finalized is not None and idx is not None:
964
+ # Backfill the wire index onto the finalized block when the
965
+ # source didn't supply one. `coco_core.utils._merge`'s
966
+ # block-merger (used by `AIMessageChunk.__add__` /
967
+ # `add_ai_message_chunks`) keys on `block["index"]` to group
968
+ # deltas into the same output block — without it, a v2-
969
+ # assembled `AIMessage` that later re-enters the chunk
970
+ # aggregation path won't merge cleanly. Client-side
971
+ # `tool_call` / `invalid_tool_call` blocks are excluded: v1
972
+ # finalization drops `index` on them so further deltas
973
+ # cannot clobber already-parsed args, and v2 mirrors that.
974
+ if btype not in {"tool_call", "invalid_tool_call"}:
975
+ finalized.setdefault("index", idx)
976
+ self._blocks[idx] = finalized
977
+
978
+ def _finish(self, data: MessageFinishData) -> None:
979
+ """Process a `message-finish` event."""
980
+ self._done = True
981
+ self._usage_value = data.get("usage")
982
+ self._finish_metadata = cast("dict[str, Any] | None", data.get("metadata"))
983
+ # Off-spec extension carrying provider-side `additional_kwargs`
984
+ # that don't map onto a typed protocol field (e.g. Gemini's
985
+ # `__gemini_function_call_thought_signatures__`). The compat
986
+ # bridge emits this on `message-finish` so the assembled message
987
+ # carries the same data `ainvoke` would have preserved.
988
+ self._additional_kwargs = cast(
989
+ "dict[str, Any] | None",
990
+ cast("dict[str, Any]", data).get("additional_kwargs"),
991
+ )
992
+
993
+ # Finalize any unswept chunks — both client- and server-side.
994
+ _sweep_chunk_store(
995
+ self._tool_call_chunks,
996
+ finalized_type="tool_call",
997
+ finalized_blocks=self._blocks,
998
+ tool_calls_acc=self._tool_calls_acc,
999
+ invalid_acc=self._invalid_tool_calls_acc,
1000
+ )
1001
+ _sweep_chunk_store(
1002
+ self._server_tool_call_chunks,
1003
+ finalized_type="server_tool_call",
1004
+ finalized_blocks=self._blocks,
1005
+ tool_calls_acc=None,
1006
+ invalid_acc=self._invalid_tool_calls_acc,
1007
+ )
1008
+
1009
+ # Prefer the per-block sum when any indexed text / reasoning
1010
+ # arrived — it stays correct regardless of finish ordering and
1011
+ # of whether finish events carried authoritative text that
1012
+ # differed from the deltas. Fall back to the delta-sum
1013
+ # accumulator only for the legacy no-index path.
1014
+ if self._text_per_block:
1015
+ text_final = "".join(
1016
+ self._text_per_block[i] for i in sorted(self._text_per_block)
1017
+ )
1018
+ else:
1019
+ text_final = self._text_acc
1020
+ if self._reasoning_per_block:
1021
+ reasoning_final = "".join(
1022
+ self._reasoning_per_block[i] for i in sorted(self._reasoning_per_block)
1023
+ )
1024
+ else:
1025
+ reasoning_final = self._reasoning_acc
1026
+
1027
+ self._text_proj.complete(text_final)
1028
+ self._reasoning_proj.complete(reasoning_final)
1029
+ self._tool_calls_proj.complete(self._tool_calls_acc)
1030
+ self._output_message = self._assemble_message()
1031
+
1032
+ def fail(self, error: BaseException) -> None:
1033
+ """Mark the stream as errored and propagate to all projections.
1034
+
1035
+ Public API — called by the stream driver (`stream_events(version="v3")` /
1036
+ `astream_events(version="v3")`) when the underlying producer raises, by
1037
+ `dispatch` when an `error` protocol event arrives, and by
1038
+ cancellation paths.
1039
+ """
1040
+ self._done = True
1041
+ self._error = error
1042
+ self._text_proj.fail(error)
1043
+ self._reasoning_proj.fail(error)
1044
+ self._tool_calls_proj.fail(error)
1045
+
1046
+ def _assemble_message(self) -> AIMessage:
1047
+ """Build an `AIMessage` from accumulated state.
1048
+
1049
+ Content is built from `self._blocks`, an index-ordered snapshot of
1050
+ finalized protocol blocks. The bare-string fast path is used when
1051
+ the message has exactly one `text` block (the common chat case);
1052
+ otherwise content is a list of protocol-shape block dicts.
1053
+ """
1054
+ content: Any
1055
+ if not self._blocks:
1056
+ # No protocol blocks ever arrived. Fall back to the accumulated
1057
+ # text (possibly empty) as bare-string content.
1058
+ content = self._text_acc
1059
+ else:
1060
+ # `ChatModelStream` is the v1 content-block surface: content
1061
+ # is always a list of protocol blocks when any block arrived.
1062
+ # Do not collapse a single text block down to a bare string —
1063
+ # that would drop block-level fields (`id`, `index`,
1064
+ # annotations, extras) that downstream serializers need to
1065
+ # round-trip the message on a follow-up turn.
1066
+ ordered_blocks = [self._blocks[idx] for idx in sorted(self._blocks)]
1067
+ content = [dict(b) for b in ordered_blocks]
1068
+
1069
+ response_metadata: dict[str, Any] = {}
1070
+ if self._start_metadata:
1071
+ if "provider" in self._start_metadata:
1072
+ response_metadata["model_provider"] = self._start_metadata["provider"]
1073
+ if "model" in self._start_metadata:
1074
+ response_metadata["model_name"] = self._start_metadata["model"]
1075
+ if self._finish_metadata:
1076
+ response_metadata.update(self._finish_metadata)
1077
+ # Pin `output_version` last: `stream_events(version="v3")` always
1078
+ # assembles content as v1 protocol blocks, regardless of the
1079
+ # provider's configured output format.
1080
+ # A provider-supplied `output_version` in finish metadata (e.g.
1081
+ # `"responses/v1"` from `ChatOpenAI(use_responses_api=True, ...)`) would
1082
+ # otherwise cause `AIMessage.content_blocks` to re-run the wrong
1083
+ # translator on already-v1 content.
1084
+ response_metadata["output_version"] = "v1"
1085
+
1086
+ tool_calls = [
1087
+ {
1088
+ "id": tc.get("id", ""),
1089
+ "name": tc.get("name", ""),
1090
+ "args": tc.get("args", {}),
1091
+ "type": "tool_call",
1092
+ }
1093
+ for tc in self._tool_calls_acc
1094
+ ]
1095
+
1096
+ invalid_tool_calls = [
1097
+ {
1098
+ "type": "invalid_tool_call",
1099
+ "id": itc.get("id") or None,
1100
+ "name": itc.get("name") or None,
1101
+ "args": itc.get("args") or None,
1102
+ "error": itc.get("error"),
1103
+ }
1104
+ for itc in self._invalid_tool_calls_acc
1105
+ ]
1106
+
1107
+ message_kwargs: dict[str, Any] = {
1108
+ "content": content,
1109
+ "id": self._message_id,
1110
+ "tool_calls": tool_calls,
1111
+ "invalid_tool_calls": invalid_tool_calls,
1112
+ "usage_metadata": self._usage_value,
1113
+ "response_metadata": response_metadata,
1114
+ }
1115
+ if self._additional_kwargs:
1116
+ message_kwargs["additional_kwargs"] = dict(self._additional_kwargs)
1117
+ return AIMessage(**message_kwargs)
1118
+
1119
+
1120
+ # ---------------------------------------------------------------------------
1121
+ # Sync stream
1122
+ # ---------------------------------------------------------------------------
1123
+
1124
+
1125
+ class ChatModelStream(_ChatModelStreamBase):
1126
+ """Synchronous per-message streaming object for a single LLM response.
1127
+
1128
+ Returned by `BaseChatModel.stream_events(version="v3")`. Content-block protocol
1129
+ events are fed into this object and accumulated into typed projections.
1130
+
1131
+ Projections (always return the same cached object):
1132
+
1133
+ - `.text` — iterable of `str` deltas; `str()` for full text
1134
+ - `.reasoning` — same as `.text` for reasoning content
1135
+ - `.tool_calls` — iterable of `ToolCallChunk` deltas;
1136
+ `.get()` returns `list[ToolCall]`
1137
+ - `.output` — blocking property, returns assembled `AIMessage`
1138
+
1139
+ Usage info is available on `.output.usage_metadata` once the stream
1140
+ has finished.
1141
+
1142
+ !!! note "Output shape is always v1 content blocks"
1143
+
1144
+ `.output.content` is always a list of v1 protocol blocks
1145
+ (text, reasoning, tool_call, image, …), regardless of the
1146
+ underlying model's `output_version` setting. That attribute
1147
+ only controls the legacy `stream()` / `astream()` / `invoke()`
1148
+ paths; `ChatModelStream` is built on the content-block
1149
+ protocol and emits v1 shapes by construction.
1150
+
1151
+ Raw event iteration::
1152
+
1153
+ for event in stream:
1154
+ print(event) # MessagesData dicts
1155
+ """
1156
+
1157
+ _text_proj: SyncTextProjection
1158
+ _reasoning_proj: SyncTextProjection
1159
+ _tool_calls_proj: SyncProjection
1160
+
1161
+ def __init__( # noqa: D107
1162
+ self,
1163
+ *,
1164
+ namespace: list[str] | None = None,
1165
+ node: str | None = None,
1166
+ message_id: str | None = None,
1167
+ ) -> None:
1168
+ super().__init__(namespace=namespace, node=node, message_id=message_id)
1169
+ # Projections — created eagerly
1170
+ self._text_proj = SyncTextProjection()
1171
+ self._reasoning_proj = SyncTextProjection()
1172
+ self._tool_calls_proj = SyncProjection()
1173
+ # Pull callback (set by bind_pump or set_request_more)
1174
+ self._ensure_started: Callable[[], None] | None = None
1175
+ self._request_more: Callable[[], bool] | None = None
1176
+
1177
+ # -- Pump/pull wiring --------------------------------------------------
1178
+
1179
+ def bind_pump(self, pump_one: Callable[[], bool]) -> None:
1180
+ """Bind a pump for standalone streaming.
1181
+
1182
+ Delegates to `set_request_more`. Used by
1183
+ `BaseChatModel.stream_events(version="v3")`.
1184
+ """
1185
+ self.set_request_more(pump_one)
1186
+
1187
+ def set_start(self, cb: Callable[[], None] | None) -> None:
1188
+ """Install a lazy-start callback on this stream and its projections."""
1189
+ self._ensure_started = cb
1190
+ self._text_proj.set_start(cb)
1191
+ self._reasoning_proj.set_start(cb)
1192
+ self._tool_calls_proj.set_start(cb)
1193
+
1194
+ def set_request_more(self, cb: Callable[[], bool]) -> None:
1195
+ """Set the pull callback on this stream and all its projections.
1196
+
1197
+ Used by coco_graph's `GraphRunStream._wire_request_more` to
1198
+ connect the shared graph pump.
1199
+ """
1200
+ self._request_more = cb
1201
+ self._text_proj.set_request_more(cb)
1202
+ self._reasoning_proj.set_request_more(cb)
1203
+ self._tool_calls_proj.set_request_more(cb)
1204
+
1205
+ # -- Public projections ------------------------------------------------
1206
+
1207
+ @property
1208
+ def text(self) -> SyncTextProjection:
1209
+ """Text content — iterable of `str` deltas, `str()` for full."""
1210
+ return self._text_proj
1211
+
1212
+ @property
1213
+ def reasoning(self) -> SyncTextProjection:
1214
+ """Reasoning content — same interface as :attr:`text`."""
1215
+ return self._reasoning_proj
1216
+
1217
+ @property
1218
+ def tool_calls(self) -> SyncProjection:
1219
+ """Tool calls — iterable of `ToolCallChunk` deltas.
1220
+
1221
+ `.get()` returns finalized `list[ToolCall]`.
1222
+ """
1223
+ return self._tool_calls_proj
1224
+
1225
+ @property
1226
+ def output(self) -> AIMessage:
1227
+ """Assembled `AIMessage` — blocks until the stream finishes."""
1228
+ self._drain()
1229
+ if self._error is not None:
1230
+ raise self._error
1231
+ if self._output_message is None:
1232
+ msg = "Stream finished without producing a message"
1233
+ raise RuntimeError(msg)
1234
+ return self._output_message
1235
+
1236
+ # -- Raw event iteration (replay buffer) -------------------------------
1237
+
1238
+ def __iter__(self) -> Iterator[MessagesData]:
1239
+ """Iterate raw protocol events with replay-buffer semantics."""
1240
+ if self._ensure_started is not None:
1241
+ self._ensure_started()
1242
+ cursor = 0
1243
+ while True:
1244
+ if cursor < len(self._events):
1245
+ yield self._events[cursor]
1246
+ cursor += 1
1247
+ elif self._error is not None:
1248
+ raise self._error
1249
+ elif self._done:
1250
+ return
1251
+ elif self._request_more is not None:
1252
+ while cursor >= len(self._events) and not self._done:
1253
+ if not self._request_more():
1254
+ break
1255
+ if cursor >= len(self._events):
1256
+ if self._error is not None:
1257
+ raise self._error
1258
+ return
1259
+ else:
1260
+ return
1261
+
1262
+ # -- Internal helpers --------------------------------------------------
1263
+
1264
+ def _drain(self) -> None:
1265
+ """Pull all remaining events until done."""
1266
+ if self._done:
1267
+ return
1268
+ if self._ensure_started is not None:
1269
+ self._ensure_started()
1270
+ if self._request_more is not None:
1271
+ while not self._done:
1272
+ if not self._request_more():
1273
+ break
1274
+
1275
+
1276
+ # ---------------------------------------------------------------------------
1277
+ # Async stream
1278
+ # ---------------------------------------------------------------------------
1279
+
1280
+
1281
+ class AsyncChatModelStream(_ChatModelStreamBase):
1282
+ """Asynchronous per-message streaming object for a single LLM response.
1283
+
1284
+ Returned by `BaseChatModel.astream_events(version="v3")`. Content-block events
1285
+ are fed into this object by a background producer task.
1286
+
1287
+ Projections:
1288
+
1289
+ - `.text` — async iterable of text deltas; awaitable for full text
1290
+ - `.reasoning` — async iterable of reasoning deltas; awaitable
1291
+ - `.tool_calls` — async iterable of `ToolCallChunk` deltas;
1292
+ awaitable for `list[ToolCall]`
1293
+ - `.output` — awaitable for assembled `AIMessage`
1294
+
1295
+ Usage info is available on `.output.usage_metadata` once the stream
1296
+ has finished.
1297
+
1298
+ !!! note "Output shape is always v1 content blocks"
1299
+
1300
+ The assembled message's content is always a list of v1
1301
+ protocol blocks, regardless of the model's `output_version`
1302
+ setting — see `ChatModelStream` for the full rationale.
1303
+
1304
+ The stream itself is awaitable (`msg = await stream`) and
1305
+ async-iterable (`async for event in stream`).
1306
+ """
1307
+
1308
+ _text_proj: AsyncTextProjection
1309
+ _reasoning_proj: AsyncTextProjection
1310
+ _tool_calls_proj: AsyncProjection
1311
+
1312
+ def __init__( # noqa: D107
1313
+ self,
1314
+ *,
1315
+ namespace: list[str] | None = None,
1316
+ node: str | None = None,
1317
+ message_id: str | None = None,
1318
+ ) -> None:
1319
+ super().__init__(namespace=namespace, node=node, message_id=message_id)
1320
+ self._text_proj = AsyncTextProjection()
1321
+ self._reasoning_proj = AsyncTextProjection()
1322
+ self._tool_calls_proj = AsyncProjection()
1323
+ self._output_proj = AsyncProjection()
1324
+ self._events_proj = AsyncProjection()
1325
+ self._ensure_started: Callable[[], Awaitable[None]] | None = None
1326
+ self._producer_task: asyncio.Task[None] | None = None
1327
+ # Teardown callback invoked by `aclose()` only when the producer
1328
+ # task was cancelled before its body ran (so the normal
1329
+ # `_produce` CancelledError handler — which fires
1330
+ # `on_llm_error` — never executed). Set by `astream_events(version="v3")`.
1331
+ self._on_aclose_fail: Callable[[BaseException], Awaitable[None]] | None = None
1332
+
1333
+ # -- Pump/pull wiring (async) ------------------------------------------
1334
+
1335
+ def set_arequest_more(self, cb: Callable[[], Awaitable[bool]] | None) -> None:
1336
+ """Fan the async pump callback out to every projection.
1337
+
1338
+ Used by coco_graph's `AsyncGraphRunStream._wire_arequest_more` so
1339
+ cursors on `stream.text`, `stream.reasoning`, etc. can drive the
1340
+ shared graph pump when their buffer is empty.
1341
+
1342
+ Args:
1343
+ cb: Async no-arg callable returning `True` when a new event
1344
+ was produced, `False` when the source is exhausted. Pass
1345
+ `None` to unwire.
1346
+ """
1347
+ for proj in (
1348
+ self._text_proj,
1349
+ self._reasoning_proj,
1350
+ self._tool_calls_proj,
1351
+ self._output_proj,
1352
+ self._events_proj,
1353
+ ):
1354
+ proj.set_arequest_more(cb)
1355
+
1356
+ def set_start(self, cb: Callable[[], Awaitable[None]] | None) -> None:
1357
+ """Install a lazy-start callback on this stream and its projections."""
1358
+ self._ensure_started = cb
1359
+ for proj in (
1360
+ self._text_proj,
1361
+ self._reasoning_proj,
1362
+ self._tool_calls_proj,
1363
+ self._output_proj,
1364
+ self._events_proj,
1365
+ ):
1366
+ proj.set_start(cb)
1367
+
1368
+ # -- Public projections ------------------------------------------------
1369
+
1370
+ @property
1371
+ def text(self) -> AsyncTextProjection:
1372
+ """Text content — async iterable of `str` deltas, awaitable for full text."""
1373
+ return self._text_proj
1374
+
1375
+ @property
1376
+ def reasoning(self) -> AsyncTextProjection:
1377
+ """Reasoning content — same interface as :attr:`text`."""
1378
+ return self._reasoning_proj
1379
+
1380
+ @property
1381
+ def tool_calls(self) -> AsyncProjection:
1382
+ """Tool calls — async iterable, awaitable for finalized list."""
1383
+ return self._tool_calls_proj
1384
+
1385
+ @property
1386
+ def output(self) -> AsyncProjection:
1387
+ """Assembled `AIMessage` — awaitable."""
1388
+ return self._output_proj
1389
+
1390
+ def __await__(self) -> Generator[Any, None, AIMessage]:
1391
+ """Await the assembled `AIMessage` and full producer lifecycle.
1392
+
1393
+ The producer task is awaited after the output projection resolves so
1394
+ that post-stream work (notably `on_llm_end` callbacks) has run by
1395
+ the time the caller's `await` returns.
1396
+ """
1397
+ return self._await_full().__await__()
1398
+
1399
+ async def _await_full(self) -> AIMessage:
1400
+ if self._ensure_started is not None:
1401
+ await self._ensure_started()
1402
+ message: AIMessage = await self._output_proj
1403
+ if self._producer_task is not None:
1404
+ await self._producer_task
1405
+ return message
1406
+
1407
+ def __aiter__(self) -> _AsyncProjectionIterator:
1408
+ """Iterate raw protocol events asynchronously."""
1409
+ return _AsyncProjectionIterator(self._events_proj)
1410
+
1411
+ # -- Cleanup -----------------------------------------------------------
1412
+
1413
+ async def aclose(self) -> None:
1414
+ """Cancel the background producer task and release resources.
1415
+
1416
+ If a consumer cancels mid-stream or decides to stop iterating
1417
+ early, the producer task keeps pumping the provider HTTP call to
1418
+ completion because `asyncio.Task` has no implicit link to its
1419
+ awaiter. Call this method to cancel the producer explicitly; the
1420
+ stream transitions to an errored state with `CancelledError`.
1421
+
1422
+ If the stream has already produced a message successfully (for
1423
+ example, after `await stream.output`), the producer may still be
1424
+ running post-stream work such as `on_llm_end` callbacks. In that
1425
+ case `aclose()` awaits the task rather than cancelling it —
1426
+ turning a successful run into a cancelled one would drop the
1427
+ end callback and corrupt tracing.
1428
+
1429
+ Idempotent: safe to call multiple times, including after the
1430
+ stream has finished normally. Also invoked by the async context
1431
+ manager protocol on `__aexit__`.
1432
+ """
1433
+ if self._ensure_started is not None and self._producer_task is None:
1434
+ await self._ensure_started()
1435
+
1436
+ task = self._producer_task
1437
+ if task is None:
1438
+ return
1439
+ if task.done() and self._done:
1440
+ return
1441
+
1442
+ we_cancelled = not (self._output_message is not None and self._error is None)
1443
+ if we_cancelled and not task.done():
1444
+ task.cancel()
1445
+
1446
+ # Wait for the task via a linked `Future`, not by awaiting the
1447
+ # task directly. Awaiting the task would raise `CancelledError`
1448
+ # in two indistinguishable cases: (1) the task we just cancelled
1449
+ # completed, (2) our caller cancelled us. `asyncio.Task.cancelling()`
1450
+ # disambiguates on 3.11+ but doesn't exist on 3.10.
1451
+ #
1452
+ # The `done_future` resolves with `None` whenever the task
1453
+ # finishes (any reason). It is not a `Task` itself, so its
1454
+ # `await` only raises when our caller is cancelled — giving us
1455
+ # a portable, unambiguous signal to propagate.
1456
+ if not task.done():
1457
+ loop = asyncio.get_running_loop()
1458
+ done_future: asyncio.Future[None] = loop.create_future()
1459
+
1460
+ def _link(_: asyncio.Task[None]) -> None:
1461
+ if not done_future.done():
1462
+ done_future.set_result(None)
1463
+
1464
+ task.add_done_callback(_link)
1465
+ try:
1466
+ await done_future
1467
+ finally:
1468
+ task.remove_done_callback(_link)
1469
+
1470
+ # If the task was cancelled before `_produce` ran (e.g.
1471
+ # `astream_events(version="v3")` immediately followed by `aclose()`), the stream
1472
+ # never reached `_produce`'s CancelledError handler — its
1473
+ # projections are still pending and no end-of-lifecycle callback
1474
+ # has fired. Resolve both here so callers of `await stream.output`
1475
+ # don't hang and tracing sees a matching end event.
1476
+ if we_cancelled and not self._done:
1477
+ cancel_exc = asyncio.CancelledError()
1478
+ self.fail(cancel_exc)
1479
+ teardown = self._on_aclose_fail
1480
+ if teardown is not None:
1481
+ with contextlib.suppress(Exception):
1482
+ await teardown(cancel_exc)
1483
+
1484
+ async def __aenter__(self) -> Self:
1485
+ """Enter the async context — returns self."""
1486
+ return self
1487
+
1488
+ async def __aexit__(
1489
+ self,
1490
+ exc_type: type[BaseException] | None,
1491
+ exc: BaseException | None,
1492
+ tb: object,
1493
+ ) -> None:
1494
+ """Exit the async context — cancels the producer via `aclose()`."""
1495
+ del exc_type, exc, tb
1496
+ await self.aclose()
1497
+
1498
+ # -- Internal API (extend base to drive async projections) -------------
1499
+
1500
+ def _record_event(self, event: Mapping[str, Any]) -> None:
1501
+ """Record event and push to async event replay projection."""
1502
+ super()._record_event(event)
1503
+ self._events_proj.push(cast("MessagesData", event))
1504
+
1505
+ def _finish(self, data: MessageFinishData) -> None:
1506
+ """Finish base projections and async-only projections."""
1507
+ super()._finish(data)
1508
+ self._output_proj.complete(self._output_message)
1509
+ self._events_proj.complete(self._events)
1510
+
1511
+ def fail(self, error: BaseException) -> None:
1512
+ """Fail base projections and async-only projections."""
1513
+ super().fail(error)
1514
+ self._output_proj.fail(error)
1515
+ self._events_proj.fail(error)
1516
+
1517
+
1518
+ __all__ = [
1519
+ "AsyncChatModelStream",
1520
+ "AsyncProjection",
1521
+ "AsyncTextProjection",
1522
+ "ChatModelStream",
1523
+ "SyncProjection",
1524
+ "SyncTextProjection",
1525
+ ]