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