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.
- coco/__init__.py +3 -0
- coco/agents/__init__.py +9 -0
- coco/agents/_subagent_transformer.py +307 -0
- coco/agents/factory.py +2164 -0
- coco/agents/middleware/__init__.py +100 -0
- coco/agents/middleware/_execution.py +385 -0
- coco/agents/middleware/_redaction.py +454 -0
- coco/agents/middleware/_retry.py +134 -0
- coco/agents/middleware/_trace_policy.py +61 -0
- coco/agents/middleware/context_editing.py +306 -0
- coco/agents/middleware/file_search.py +433 -0
- coco/agents/middleware/human_in_the_loop.py +680 -0
- coco/agents/middleware/internal_call_transformer.py +108 -0
- coco/agents/middleware/model_call_limit.py +267 -0
- coco/agents/middleware/model_fallback.py +417 -0
- coco/agents/middleware/model_retry.py +327 -0
- coco/agents/middleware/pii.py +878 -0
- coco/agents/middleware/provider_tool_search.py +307 -0
- coco/agents/middleware/shell_tool.py +949 -0
- coco/agents/middleware/summarization.py +910 -0
- coco/agents/middleware/todo.py +357 -0
- coco/agents/middleware/tool_call_limit.py +495 -0
- coco/agents/middleware/tool_emulator.py +251 -0
- coco/agents/middleware/tool_error.py +209 -0
- coco/agents/middleware/tool_retry.py +421 -0
- coco/agents/middleware/tool_selection.py +515 -0
- coco/agents/middleware/types.py +2201 -0
- coco/agents/structured_output.py +472 -0
- coco/chat_models/__init__.py +7 -0
- coco/chat_models/base.py +1082 -0
- coco/embeddings/__init__.py +18 -0
- coco/embeddings/base.py +279 -0
- coco/mcp/__init__.py +36 -0
- coco/mcp/adapter.py +245 -0
- coco/mcp/elicitation.py +394 -0
- coco/mcp/tools.py +307 -0
- coco/messages/__init__.py +73 -0
- coco/py.typed +0 -0
- coco/rate_limiters/__init__.py +13 -0
- coco/tools/__init__.py +22 -0
- coco/tools/tool_node.py +20 -0
- coco_anthropic/__init__.py +15 -0
- coco_anthropic/_client_utils.py +81 -0
- coco_anthropic/_compat.py +228 -0
- coco_anthropic/_sdk_compat.py +124 -0
- coco_anthropic/_version.py +3 -0
- coco_anthropic/chat_models.py +3349 -0
- coco_anthropic/data/__init__.py +1 -0
- coco_anthropic/data/_profiles.py +491 -0
- coco_anthropic/data/profile_augmentations.toml +69 -0
- coco_anthropic/experimental.py +140 -0
- coco_anthropic/llms.py +428 -0
- coco_anthropic/middleware/__init__.py +25 -0
- coco_anthropic/middleware/anthropic_tools.py +1197 -0
- coco_anthropic/middleware/bash.py +84 -0
- coco_anthropic/middleware/file_search.py +360 -0
- coco_anthropic/middleware/prompt_caching.py +276 -0
- coco_anthropic/output_parsers.py +93 -0
- coco_anthropic/py.typed +0 -0
- coco_core/__init__.py +20 -0
- coco_core/_api/__init__.py +87 -0
- coco_core/_api/beta_decorator.py +266 -0
- coco_core/_api/deprecation.py +637 -0
- coco_core/_api/internal.py +23 -0
- coco_core/_api/path.py +50 -0
- coco_core/_import_utils.py +41 -0
- coco_core/_protocol.py +625 -0
- coco_core/_security/__init__.py +36 -0
- coco_core/_security/_exceptions.py +9 -0
- coco_core/_security/_policy.py +313 -0
- coco_core/_security/_ssrf_protection.py +155 -0
- coco_core/_security/_transport.py +254 -0
- coco_core/agents.py +258 -0
- coco_core/caches.py +272 -0
- coco_core/callbacks/__init__.py +132 -0
- coco_core/callbacks/base.py +1229 -0
- coco_core/callbacks/file.py +266 -0
- coco_core/callbacks/manager.py +2826 -0
- coco_core/callbacks/stdout.py +123 -0
- coco_core/callbacks/streaming_stdout.py +154 -0
- coco_core/callbacks/usage.py +124 -0
- coco_core/chat_history.py +267 -0
- coco_core/chat_loaders.py +26 -0
- coco_core/chat_sessions.py +19 -0
- coco_core/cross_encoders.py +18 -0
- coco_core/document_loaders/__init__.py +36 -0
- coco_core/document_loaders/base.py +155 -0
- coco_core/document_loaders/blob_loaders.py +38 -0
- coco_core/documents/__init__.py +55 -0
- coco_core/documents/base.py +347 -0
- coco_core/documents/compressor.py +74 -0
- coco_core/documents/transformers.py +79 -0
- coco_core/embeddings/__init__.py +31 -0
- coco_core/embeddings/embeddings.py +78 -0
- coco_core/embeddings/fake.py +129 -0
- coco_core/env.py +22 -0
- coco_core/example_selectors/__init__.py +47 -0
- coco_core/example_selectors/base.py +60 -0
- coco_core/example_selectors/length_based.py +131 -0
- coco_core/example_selectors/semantic_similarity.py +366 -0
- coco_core/exceptions.py +166 -0
- coco_core/globals.py +72 -0
- coco_core/indexing/__init__.py +53 -0
- coco_core/indexing/api.py +954 -0
- coco_core/indexing/base.py +661 -0
- coco_core/indexing/in_memory.py +104 -0
- coco_core/language_models/__init__.py +116 -0
- coco_core/language_models/_compat_bridge.py +844 -0
- coco_core/language_models/_utils.py +343 -0
- coco_core/language_models/base.py +500 -0
- coco_core/language_models/chat_model_stream.py +1525 -0
- coco_core/language_models/chat_models.py +2748 -0
- coco_core/language_models/fake.py +137 -0
- coco_core/language_models/fake_chat_models.py +396 -0
- coco_core/language_models/llms.py +1569 -0
- coco_core/language_models/model_profile.py +183 -0
- coco_core/load/__init__.py +44 -0
- coco_core/load/_validation.py +191 -0
- coco_core/load/dump.py +120 -0
- coco_core/load/load.py +828 -0
- coco_core/load/mapping.py +1097 -0
- coco_core/load/serializable.py +406 -0
- coco_core/messages/__init__.py +198 -0
- coco_core/messages/ai.py +854 -0
- coco_core/messages/base.py +518 -0
- coco_core/messages/block_translators/__init__.py +112 -0
- coco_core/messages/block_translators/anthropic.py +534 -0
- coco_core/messages/block_translators/bedrock.py +108 -0
- coco_core/messages/block_translators/bedrock_converse.py +329 -0
- coco_core/messages/block_translators/coco_v0.py +303 -0
- coco_core/messages/block_translators/google_genai.py +610 -0
- coco_core/messages/block_translators/google_vertexai.py +21 -0
- coco_core/messages/block_translators/groq.py +157 -0
- coco_core/messages/block_translators/openai.py +1089 -0
- coco_core/messages/chat.py +64 -0
- coco_core/messages/content.py +1488 -0
- coco_core/messages/function.py +62 -0
- coco_core/messages/human.py +70 -0
- coco_core/messages/modifier.py +33 -0
- coco_core/messages/system.py +70 -0
- coco_core/messages/tool.py +418 -0
- coco_core/messages/utils.py +2442 -0
- coco_core/output_parsers/__init__.py +101 -0
- coco_core/output_parsers/base.py +361 -0
- coco_core/output_parsers/format_instructions.py +16 -0
- coco_core/output_parsers/json.py +139 -0
- coco_core/output_parsers/list.py +249 -0
- coco_core/output_parsers/openai_functions.py +324 -0
- coco_core/output_parsers/openai_tools.py +383 -0
- coco_core/output_parsers/pydantic.py +141 -0
- coco_core/output_parsers/string.py +63 -0
- coco_core/output_parsers/transform.py +175 -0
- coco_core/output_parsers/xml.py +301 -0
- coco_core/outputs/__init__.py +66 -0
- coco_core/outputs/chat_generation.py +147 -0
- coco_core/outputs/chat_result.py +39 -0
- coco_core/outputs/generation.py +80 -0
- coco_core/outputs/llm_result.py +112 -0
- coco_core/outputs/run_info.py +22 -0
- coco_core/prompt_values.py +161 -0
- coco_core/prompts/__init__.py +101 -0
- coco_core/prompts/base.py +508 -0
- coco_core/prompts/chat.py +1495 -0
- coco_core/prompts/dict.py +177 -0
- coco_core/prompts/few_shot.py +483 -0
- coco_core/prompts/few_shot_with_templates.py +239 -0
- coco_core/prompts/image.py +177 -0
- coco_core/prompts/loading.py +296 -0
- coco_core/prompts/message.py +97 -0
- coco_core/prompts/prompt.py +312 -0
- coco_core/prompts/string.py +399 -0
- coco_core/prompts/structured.py +214 -0
- coco_core/py.typed +0 -0
- coco_core/rate_limiters.py +254 -0
- coco_core/retrievers.py +328 -0
- coco_core/runnables/__init__.py +136 -0
- coco_core/runnables/base.py +6725 -0
- coco_core/runnables/branch.py +457 -0
- coco_core/runnables/config.py +712 -0
- coco_core/runnables/configurable.py +710 -0
- coco_core/runnables/fallbacks.py +663 -0
- coco_core/runnables/graph.py +741 -0
- coco_core/runnables/graph_ascii.py +366 -0
- coco_core/runnables/graph_mermaid.py +503 -0
- coco_core/runnables/graph_png.py +215 -0
- coco_core/runnables/history.py +631 -0
- coco_core/runnables/passthrough.py +849 -0
- coco_core/runnables/retry.py +379 -0
- coco_core/runnables/router.py +239 -0
- coco_core/runnables/schema.py +188 -0
- coco_core/runnables/utils.py +798 -0
- coco_core/stores.py +291 -0
- coco_core/structured_query.py +203 -0
- coco_core/sys_info.py +154 -0
- coco_core/tools/__init__.py +95 -0
- coco_core/tools/base.py +1948 -0
- coco_core/tools/convert.py +497 -0
- coco_core/tools/render.py +67 -0
- coco_core/tools/retriever.py +94 -0
- coco_core/tools/simple.py +208 -0
- coco_core/tools/structured.py +322 -0
- coco_core/tracers/__init__.py +47 -0
- coco_core/tracers/_compat.py +95 -0
- coco_core/tracers/_context.py +337 -0
- coco_core/tracers/_streaming.py +53 -0
- coco_core/tracers/base.py +955 -0
- coco_core/tracers/coco.py +279 -0
- coco_core/tracers/context.py +205 -0
- coco_core/tracers/core.py +766 -0
- coco_core/tracers/event_stream.py +1101 -0
- coco_core/tracers/log_stream.py +765 -0
- coco_core/tracers/memory_stream.py +200 -0
- coco_core/tracers/root_listeners.py +130 -0
- coco_core/tracers/run_collector.py +40 -0
- coco_core/tracers/schemas.py +14 -0
- coco_core/tracers/stdout.py +205 -0
- coco_core/utils/__init__.py +111 -0
- coco_core/utils/_gateway.py +274 -0
- coco_core/utils/_merge.py +218 -0
- coco_core/utils/aiter.py +357 -0
- coco_core/utils/env.py +86 -0
- coco_core/utils/formatting.py +80 -0
- coco_core/utils/function_calling.py +869 -0
- coco_core/utils/html.py +132 -0
- coco_core/utils/image.py +15 -0
- coco_core/utils/input.py +82 -0
- coco_core/utils/interactive_env.py +12 -0
- coco_core/utils/iter.py +229 -0
- coco_core/utils/json.py +230 -0
- coco_core/utils/json_schema.py +273 -0
- coco_core/utils/mustache.py +706 -0
- coco_core/utils/pydantic.py +631 -0
- coco_core/utils/strings.py +69 -0
- coco_core/utils/usage.py +61 -0
- coco_core/utils/utils.py +528 -0
- coco_core/utils/uuid.py +57 -0
- coco_core/vectorstores/__init__.py +53 -0
- coco_core/vectorstores/base.py +1111 -0
- coco_core/vectorstores/in_memory.py +546 -0
- coco_core/vectorstores/utils.py +163 -0
- coco_core/version.py +3 -0
- coco_framework-0.1.0.dist-info/METADATA +184 -0
- coco_framework-0.1.0.dist-info/RECORD +400 -0
- coco_framework-0.1.0.dist-info/WHEEL +4 -0
- coco_framework-0.1.0.dist-info/licenses/LICENSE +24 -0
- coco_framework-0.1.0.dist-info/licenses/NOTICE +15 -0
- coco_graph/_internal/__init__.py +4 -0
- coco_graph/_internal/_cache.py +31 -0
- coco_graph/_internal/_config.py +474 -0
- coco_graph/_internal/_constants.py +140 -0
- coco_graph/_internal/_fields.py +213 -0
- coco_graph/_internal/_future.py +220 -0
- coco_graph/_internal/_pydantic.py +275 -0
- coco_graph/_internal/_queue.py +124 -0
- coco_graph/_internal/_replay.py +90 -0
- coco_graph/_internal/_retry.py +29 -0
- coco_graph/_internal/_runnable.py +969 -0
- coco_graph/_internal/_scratchpad.py +19 -0
- coco_graph/_internal/_serde.py +253 -0
- coco_graph/_internal/_timeout.py +25 -0
- coco_graph/_internal/_typing.py +54 -0
- coco_graph/cache/base/__init__.py +48 -0
- coco_graph/cache/base/py.typed +0 -0
- coco_graph/cache/memory/__init__.py +73 -0
- coco_graph/cache/redis/__init__.py +144 -0
- coco_graph/cache/sqlite/__init__.py +120 -0
- coco_graph/callbacks.py +394 -0
- coco_graph/channels/__init__.py +29 -0
- coco_graph/channels/any_value.py +72 -0
- coco_graph/channels/base.py +121 -0
- coco_graph/channels/binop.py +155 -0
- coco_graph/channels/delta.py +202 -0
- coco_graph/channels/ephemeral_value.py +79 -0
- coco_graph/channels/last_value.py +151 -0
- coco_graph/channels/named_barrier_value.py +167 -0
- coco_graph/channels/topic.py +94 -0
- coco_graph/channels/untracked_value.py +73 -0
- coco_graph/checkpoint/base/__init__.py +861 -0
- coco_graph/checkpoint/base/id.py +109 -0
- coco_graph/checkpoint/base/py.typed +0 -0
- coco_graph/checkpoint/memory/__init__.py +698 -0
- coco_graph/checkpoint/memory/py.typed +0 -0
- coco_graph/checkpoint/postgres/__init__.py +602 -0
- coco_graph/checkpoint/postgres/_ainternal.py +23 -0
- coco_graph/checkpoint/postgres/_internal.py +21 -0
- coco_graph/checkpoint/postgres/aio.py +691 -0
- coco_graph/checkpoint/postgres/base.py +666 -0
- coco_graph/checkpoint/postgres/py.typed +0 -0
- coco_graph/checkpoint/postgres/shallow.py +967 -0
- coco_graph/checkpoint/serde/__init__.py +0 -0
- coco_graph/checkpoint/serde/_msgpack.py +97 -0
- coco_graph/checkpoint/serde/base.py +64 -0
- coco_graph/checkpoint/serde/encrypted.py +80 -0
- coco_graph/checkpoint/serde/event_hooks.py +52 -0
- coco_graph/checkpoint/serde/jsonplus.py +883 -0
- coco_graph/checkpoint/serde/py.typed +0 -0
- coco_graph/checkpoint/serde/types.py +68 -0
- coco_graph/checkpoint/sqlite/__init__.py +645 -0
- coco_graph/checkpoint/sqlite/_delta.py +172 -0
- coco_graph/checkpoint/sqlite/aio.py +738 -0
- coco_graph/checkpoint/sqlite/py.typed +0 -0
- coco_graph/checkpoint/sqlite/utils.py +116 -0
- coco_graph/config.py +196 -0
- coco_graph/constants.py +64 -0
- coco_graph/errors.py +241 -0
- coco_graph/func/__init__.py +620 -0
- coco_graph/graph/__init__.py +12 -0
- coco_graph/graph/_branch.py +225 -0
- coco_graph/graph/_node.py +103 -0
- coco_graph/graph/message.py +437 -0
- coco_graph/graph/state.py +1978 -0
- coco_graph/graph/ui.py +227 -0
- coco_graph/managed/__init__.py +3 -0
- coco_graph/managed/base.py +31 -0
- coco_graph/managed/is_last_step.py +24 -0
- coco_graph/prebuilt/__init__.py +23 -0
- coco_graph/prebuilt/_tool_call_stream.py +117 -0
- coco_graph/prebuilt/_tool_call_transformer.py +165 -0
- coco_graph/prebuilt/chat_agent_executor.py +1015 -0
- coco_graph/prebuilt/interrupt.py +105 -0
- coco_graph/prebuilt/py.typed +0 -0
- coco_graph/prebuilt/tool_node.py +2030 -0
- coco_graph/prebuilt/tool_validator.py +221 -0
- coco_graph/pregel/__init__.py +3 -0
- coco_graph/pregel/_algo.py +1460 -0
- coco_graph/pregel/_call.py +298 -0
- coco_graph/pregel/_checkpoint.py +331 -0
- coco_graph/pregel/_config.py +0 -0
- coco_graph/pregel/_draw.py +294 -0
- coco_graph/pregel/_executor.py +223 -0
- coco_graph/pregel/_io.py +174 -0
- coco_graph/pregel/_log.py +3 -0
- coco_graph/pregel/_loop.py +1988 -0
- coco_graph/pregel/_messages.py +461 -0
- coco_graph/pregel/_read.py +312 -0
- coco_graph/pregel/_retry.py +854 -0
- coco_graph/pregel/_runner.py +941 -0
- coco_graph/pregel/_tools.py +268 -0
- coco_graph/pregel/_utils.py +221 -0
- coco_graph/pregel/_validate.py +120 -0
- coco_graph/pregel/_write.py +192 -0
- coco_graph/pregel/debug.py +302 -0
- coco_graph/pregel/main.py +4364 -0
- coco_graph/pregel/protocol.py +288 -0
- coco_graph/pregel/types.py +38 -0
- coco_graph/py.typed +0 -0
- coco_graph/runtime.py +350 -0
- coco_graph/store/base/__init__.py +1322 -0
- coco_graph/store/base/batch.py +371 -0
- coco_graph/store/base/embed.py +436 -0
- coco_graph/store/base/py.typed +0 -0
- coco_graph/store/memory/__init__.py +592 -0
- coco_graph/store/memory/py.typed +0 -0
- coco_graph/store/postgres/__init__.py +4 -0
- coco_graph/store/postgres/aio.py +592 -0
- coco_graph/store/postgres/base.py +1480 -0
- coco_graph/store/postgres/py.typed +0 -0
- coco_graph/store/sqlite/__init__.py +4 -0
- coco_graph/store/sqlite/aio.py +632 -0
- coco_graph/store/sqlite/base.py +1514 -0
- coco_graph/stream/__init__.py +45 -0
- coco_graph/stream/_convert.py +32 -0
- coco_graph/stream/_mux.py +523 -0
- coco_graph/stream/_types.py +330 -0
- coco_graph/stream/run_stream.py +735 -0
- coco_graph/stream/stream_channel.py +341 -0
- coco_graph/stream/transformers.py +1039 -0
- coco_graph/types.py +1079 -0
- coco_graph/typing.py +48 -0
- coco_graph/utils/__init__.py +1 -0
- coco_graph/utils/config.py +4 -0
- coco_graph/utils/runnable.py +3 -0
- coco_graph/version.py +13 -0
- coco_graph/warnings.py +69 -0
- coco_openai/__init__.py +20 -0
- coco_openai/_compat.py +45 -0
- coco_openai/_version.py +3 -0
- coco_openai/chat_models/__init__.py +6 -0
- coco_openai/chat_models/_client_utils.py +684 -0
- coco_openai/chat_models/_compat.py +525 -0
- coco_openai/chat_models/azure.py +1227 -0
- coco_openai/chat_models/base.py +5654 -0
- coco_openai/chat_models/codex.py +557 -0
- coco_openai/chatgpt_oauth.py +1063 -0
- coco_openai/data/__init__.py +1 -0
- coco_openai/data/_profiles.py +1655 -0
- coco_openai/data/profile_augmentations.toml +127 -0
- coco_openai/embeddings/__init__.py +6 -0
- coco_openai/embeddings/azure.py +240 -0
- coco_openai/embeddings/base.py +820 -0
- coco_openai/llms/__init__.py +6 -0
- coco_openai/llms/azure.py +249 -0
- coco_openai/llms/base.py +919 -0
- coco_openai/middleware/__init__.py +11 -0
- coco_openai/middleware/openai_moderation.py +484 -0
- coco_openai/output_parsers/__init__.py +9 -0
- coco_openai/output_parsers/tools.py +9 -0
- coco_openai/py.typed +0 -0
- coco_openai/tools/__init__.py +5 -0
- 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
|
+
]
|