libs/core/langchain_core/language_models/_compat_bridge.py PYTHON 826 lines View on github.com → Search inside
1"""Compat bridge: convert `AIMessageChunk` streams to protocol events.23The bridge trusts `AIMessageChunk.content_blocks` as the single4protocol view of any chunk.  That property runs the three-tier lookup5(`output_version == "v1"` short-circuit, registered translator, or6best-effort parsing) and returns a `list[ContentBlock]` for every7well-formed message  whether the provider is a registered partner, an8unregistered community model, or not tagged at all.910Per-chunk `content_blocks` output is a **delta slice**, not accumulated11state: providers in this ecosystem emit SSE-style chunks that each carry12their own increment.  The bridge therefore forwards each slice straight13through as a `content-block-delta` event, and accumulates per-index14state only so the final `content-block-finish` event can report a15finalized block (e.g. `tool_call_chunk` args parsed to a dict).1617Lifecycle::1819    message-start20      -> 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-finish2425Public API:2627- `chunks_to_events` / `achunks_to_events`  for live streams where28  chunks arrive over time.29- `message_to_events` / `amessage_to_events`  for replaying a finalized30  `AIMessage` (cache hit, checkpoint restore, graph-node return value)31  as a synthetic event lifecycle.32"""3334from __future__ import annotations3536import json37from typing import TYPE_CHECKING, Any, cast3839from langchain_protocol.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)5859from langchain_core.messages import AIMessageChunk, BaseMessage60from langchain_core.utils._merge import merge_dicts6162if TYPE_CHECKING:63    from collections.abc import AsyncIterator, Iterator6465    from langchain_protocol.protocol import (66        BlockDelta,67        BlockDeltaFields,68        ContentBlockDelta,69        DataDelta,70        ReasoningDelta,71        TextDelta,72    )7374    from langchain_core.outputs import ChatGenerationChunk757677CompatBlock = dict[str, Any]78"""Internal working type for a content block.7980The bridge works with plain dicts internally because two separate but81structurally similar `ContentBlock` Unions exist  one in82`langchain_core.messages.content` (returned by `msg.content_blocks`),83one in `langchain_protocol.protocol` (the wire/event shape).  They are84not mypy-compatible despite being near-isomorphic.  Passing through85`dict[str, Any]` launders between them.  See `_to_protocol_block` for86the single seam where the laundering cast lives.87"""888990# ---------------------------------------------------------------------------91# Type laundering between core and protocol `ContentBlock` unions92# ---------------------------------------------------------------------------939495def _to_protocol_block(block: CompatBlock) -> ContentBlock:96    """Narrow an internal working dict to a protocol `ContentBlock`.9798    Single seam between the two `ContentBlock` type systems:99    `langchain_core.messages.content` (what `msg.content_blocks`100    returns) and `langchain_protocol.protocol` (what event payloads101    require).  The two Unions overlap structurally but are nominally102    distinct to mypy, so we launder through `dict[str, Any]`.  When the103    Unions are unified, this helper and its finalized counterpart can be104    deleted.105    """106    return cast("ContentBlock", block)107108109def _to_finalized_block(block: CompatBlock) -> FinalizedContentBlock:110    """Counterpart of `_to_protocol_block` for finalized blocks."""111    return cast("FinalizedContentBlock", block)112113114def _to_block_delta_fields(block: CompatBlock) -> BlockDeltaFields:115    """Narrow an internal working dict to protocol block-delta fields."""116    return cast("BlockDeltaFields", block)117118119def _to_content_delta(block: CompatBlock) -> ContentBlockDelta:120    """Convert a content-block slice/snapshot to an explicit protocol delta."""121    btype = block.get("type")122    if btype == "text":123        return cast("TextDelta", {"type": "text-delta", "text": block.get("text", "")})124    if btype == "reasoning":125        return cast(126            "ReasoningDelta",127            {128                "type": "reasoning-delta",129                "reasoning": block.get("reasoning", ""),130            },131        )132    if "data" in block:133        delta = cast("DataDelta", {"type": "data-delta", "data": block.get("data", "")})134        if block.get("encoding") == "base64":135            delta["encoding"] = "base64"136        return delta137    return cast(138        "BlockDelta",139        {140            "type": "block-delta",141            "fields": _to_block_delta_fields(block),142        },143    )144145146# ---------------------------------------------------------------------------147# Block iteration148# ---------------------------------------------------------------------------149150151def _iter_protocol_blocks(msg: BaseMessage) -> list[tuple[Any, CompatBlock]]:152    """Read per-chunk protocol blocks from `msg.content_blocks`.153154    Returns `(key, block)` pairs.  The key is the block's stable identifier155    across the stream: the block's `index` field when present (can be an156    int or a string  some providers use string identifiers like157    `"lc_rs_305f30"`), or the positional index within the message as a158    fallback.  Callers are responsible for allocating wire-level `uint`159    indices; this helper only surfaces the source-side identity.160161    For finalized `AIMessage`, also surfaces `invalid_tool_calls`162     which `AIMessage.content_blocks` currently omits from its return163    value even though they are a defined protocol block type.164165    The positional fallback is a known fragility: when a provider emits166    blocks without an `index` field (e.g. Anthropic's `_stream` with167    `coerce_content_to_string=True`, where text chunks lose their168    source-side index), every such chunk gets positional key 0 and169    successive chunks merge into one block. This works correctly for170    single-type streams (pure-text responses merge cleanly) because all171    chunks share the same key and the open-block logic collapses them.172    It would miscategorise a stream that mixed indexed structured173    blocks with non-indexed coerced-text blocks, since an indexed174    block with `index == 0` would collide with the anonymous text175    block's positional-0 key.  In the anthropic integration this176    cannot currently occur: coerce-to-string mode is only selected177    when no tools, thinking, or documents are present, and any of178    those flips the stream to structured mode where every block179    carries an integer index.  A native `_stream_chat_model_events`180    hook per provider (or a bridge-level "continue the open block when181    the source has no identity" rule) would close the gap if another182    integration ever emits mixed content.183    """184    try:185        raw = msg.content_blocks186    except Exception:187        return []188189    result: list[tuple[Any, CompatBlock]] = []190    for i, block in enumerate(raw):191        if not isinstance(block, dict):192            continue193        explicit_idx = block.get("index")194        if explicit_idx is None:195            # No source-side identity. Bucket by (sentinel, block type,196            # positional `i`) so two blocks of different types at the197            # same position across chunks (e.g. Gemini emitting a198            # reasoning block in one chunk and a `tool_call` in the199            # next, both at positional 0 because each chunk carries one200            # block) get distinct wire blocks. Without this, the second201            # type's incoming block hits `_accumulate`'s self-contained202            # `else` branch and clobbers the first. Same-type chunks203            # still share the bucket and merge cleanly, which is what204            # streaming text / reasoning relies on.205            key: Any = ("__lc_no_index__", block.get("type"), i)206        else:207            key = explicit_idx208        result.append((key, dict(block)))209210    if not isinstance(msg, AIMessageChunk):211        # Finalized AIMessage: pull invalid_tool_calls from the dedicated212        # field  AIMessage.content_blocks does not currently include them.213        for itc in getattr(msg, "invalid_tool_calls", None) or []:214            itc_block: CompatBlock = {"type": "invalid_tool_call"}215            for key_name in ("id", "name", "args", "error"):216                if itc.get(key_name) is not None:217                    itc_block[key_name] = itc[key_name]218            result.append((len(result), itc_block))219220    return result221222223# ---------------------------------------------------------------------------224# Per-block helpers225# ---------------------------------------------------------------------------226227228# Fields that can carry large payloads (inline base64 media, parsed args,229# arbitrary dicts).  Stripped from `content-block-start` for self-contained230# block types so the payload rides on `content-block-finish` alone instead231# of being serialized twice on the wire.232_HEAVY_FIELDS = frozenset({"args", "data", "output", "transcript", "value"})233234235def _start_skeleton(block: CompatBlock) -> ContentBlock:236    """Empty-content placeholder for the `content-block-start` event.237238    Deltaable block types (text, reasoning, the `_chunk` tool variants)239    get an empty payload so the lifecycle's "start" signal is distinct240    from the first incremental delta.  Self-contained types (image,241    audio, video, file, non_standard, finalized tool calls) drop their242    heavy payload fields; those are carried by `content-block-finish`.243    Correlation fields (id, name, toolCallId) and small metadata244    (mime_type, url, status, …) are preserved on the start event.245    """246    btype = block.get("type", "text")247    if btype == "text":248        return TextContentBlock(type="text", text="")249    if btype == "reasoning":250        return ReasoningContentBlock(type="reasoning", reasoning="")251    if btype == "tool_call_chunk":252        return ToolCallChunk(253            type="tool_call_chunk",254            id=block.get("id"),255            name=block.get("name"),256            args="",257        )258    if btype == "server_tool_call_chunk":259        s_skel = ServerToolCallChunk(260            type="server_tool_call_chunk",261            args="",262        )263        if block.get("id") is not None:264            s_skel["id"] = block["id"]265        if block.get("name") is not None:266            s_skel["name"] = block["name"]267        return s_skel268269    stripped: CompatBlock = {k: v for k, v in block.items() if k not in _HEAVY_FIELDS}270    # Restore required-but-heavy fields with minimal placeholders so the271    # start event still validates against the CDDL shape of the block type.272    if btype in ("tool_call", "server_tool_call"):273        stripped["args"] = {}274    elif btype == "non_standard":275        stripped["value"] = {}276    return _to_protocol_block(stripped)277278279def _should_emit_delta(block: CompatBlock) -> bool:280    """Whether a per-chunk block carries content worth a delta event.281282    Deltaable types emit only when they have fresh content.  Self-contained283    / already-finalized types skip the delta entirely  the `finish`284    event carries them.285    """286    btype = block.get("type")287    if btype == "text":288        return bool(block.get("text"))289    if btype == "reasoning":290        return bool(block.get("reasoning"))291    if btype in ("tool_call_chunk", "server_tool_call_chunk"):292        return bool(293            block.get("args") or block.get("id") or block.get("name"),294        )295    if "data" in block:296        return bool(block.get("data"))297    return False298299300def _accumulate(state: CompatBlock | None, delta: CompatBlock) -> CompatBlock:301    """Merge a per-chunk delta slice into accumulated per-index state.302303    Used only for the finalization pass  live delta events are emitted304    directly from the per-chunk block, without round-tripping through305    accumulated state.306    """307    if state is None:308        return dict(delta)309    btype = state.get("type")310    dtype = delta.get("type")311    if btype == "text" and dtype == "text":312        state["text"] = state.get("text", "") + delta.get("text", "")313        # Providers may send non-text fields (like `id`, or annotations)314        # on later deltas. Merging (not replacing) keeps earlier keys315        # intact while picking up these late-arriving fields.316        for key, value in delta.items():317            if key in ("type", "text") or value is None:318                continue319            if key == "extras" and isinstance(value, dict):320                state["extras"] = {**(state.get("extras") or {}), **value}321            else:322                state[key] = value323    elif btype == "reasoning" and dtype == "reasoning":324        state["reasoning"] = state.get("reasoning", "") + delta.get("reasoning", "")325        # Providers may ship non-text fields on later deltas. Claude's326        # `signature_delta` arrives after the reasoning text, surfaced327        # as `extras.signature`; merging (not replacing) keeps earlier328        # keys intact.329        for key, value in delta.items():330            if key in ("type", "reasoning") or value is None:331                continue332            if key == "extras" and isinstance(value, dict):333                state["extras"] = {**(state.get("extras") or {}), **value}334            else:335                state[key] = value336    elif btype in ("tool_call_chunk", "server_tool_call_chunk") and dtype == btype:337        state["args"] = (state.get("args", "") or "") + (delta.get("args") or "")338        if delta.get("id") is not None:339            state["id"] = delta["id"]340        if delta.get("name") is not None:341            state["name"] = delta["name"]342    elif btype == dtype and "data" in delta:343        state["data"] = (state.get("data", "") or "") + (delta.get("data") or "")344        for key, value in delta.items():345            if key in ("type", "data") or value is None:346                continue347            if key == "extras" and isinstance(value, dict):348                state["extras"] = {**(state.get("extras") or {}), **value}349            else:350                state[key] = value351    else:352        # Self-contained or already-finalized types: replace wholesale.353        state.clear()354        state.update(delta)355    return state356357358def finalize_tool_call_chunk(359    *,360    raw_args: str | None,361    id_: str | None,362    name: str | None,363    extras: dict[str, Any],364    finalized_type: str,365) -> FinalizedContentBlock:366    """Parse accumulated tool-chunk args into a finalized block.367368    Shared between the compat bridge's `_finalize_block` and the369    `ChatModelStream` end-of-stream sweep. Parses `raw_args` as JSON:370    on success builds the requested finalized type (`tool_call` or371    `server_tool_call`) with provider-specific fields (`extras`)372    preserved; on failure falls back to `invalid_tool_call` carrying373    the raw string so downstream consumers can still introspect the374    malformed payload.375376    Args:377        raw_args: Accumulated partial-JSON string; `None` or empty378            treated as `{}`.379        id_: Tool-call id collected across chunks.380        name: Tool name collected across chunks.381        extras: Provider-specific fields to carry onto the finalized382            block. Callers are responsible for having already dropped383            keys they don't want propagated (notably `type`, `id`,384            `name`, `args`, and `index` on client-side `tool_call`).385        finalized_type: `"tool_call"` or `"server_tool_call"`.386387    Returns:388        A `ToolCall`, `ServerToolCall`, or `InvalidToolCall`  the389        latter when `raw_args` is non-empty but not valid JSON.390    """391    raw = raw_args or "{}"392    try:393        parsed = json.loads(raw) if raw else {}394    except (json.JSONDecodeError, TypeError):395        invalid = InvalidToolCall(396            type="invalid_tool_call",397            id=id_,398            name=name,399            args=raw,400            error="Failed to parse tool call arguments as JSON",401        )402        invalid.update(extras)  # type: ignore[typeddict-item]403        return invalid404    if finalized_type == "tool_call":405        finalized_tc = ToolCall(406            type="tool_call",407            id=id_ or "",408            name=name or "",409            args=parsed,410        )411        finalized_tc.update(extras)  # type: ignore[typeddict-item]412        return finalized_tc413    finalized_stc = ServerToolCall(414        type="server_tool_call",415        id=id_ or "",416        name=name or "",417        args=parsed,418    )419    finalized_stc.update(extras)  # type: ignore[typeddict-item]420    return finalized_stc421422423def _finalize_block(block: CompatBlock) -> FinalizedContentBlock:424    """Promote chunk variants to their finalized form.425426    `tool_call_chunk` becomes `tool_call`  or `invalid_tool_call`427    if the accumulated `args` don't parse as JSON.428    `server_tool_call_chunk` becomes `server_tool_call` under the same429    rule.  Everything else passes through: text/reasoning blocks carry430    their accumulated snapshot, and self-contained types are already in431    their terminal shape.432    """433    btype = block.get("type")434    if btype in ("tool_call_chunk", "server_tool_call_chunk"):435        # Carry provider-specific fields from the accumulated chunk onto436        # the finalized block. Drop the chunk-only keys we rewrite437        # explicitly. `index` is stripped on client-side438        # `tool_call` / `invalid_tool_call` finalizations to match v1439        # (`AIMessage.init_tool_calls` rebuilds tool_call blocks without440        # `index`), preventing `merge_lists` from re-merging further441        # chunks into an already-parsed args dict. `server_tool_call`442        # retains `index` because v1's `init_server_tool_calls`443        # finalizes in-place and preserves it.444        client_tool_call = btype == "tool_call_chunk"445        extras_drop = {"type", "id", "name", "args"}446        if client_tool_call:447            extras_drop = extras_drop | {"index"}448        extras = {449            k: v for k, v in block.items() if k not in extras_drop and v is not None450        }451        return finalize_tool_call_chunk(452            raw_args=block.get("args"),453            id_=block.get("id"),454            name=block.get("name"),455            extras=extras,456            finalized_type="tool_call" if client_tool_call else "server_tool_call",457        )458    return _to_finalized_block(block)459460461# ---------------------------------------------------------------------------462# Metadata, usage, finish-reason463# ---------------------------------------------------------------------------464465466def _extract_start_metadata(response_metadata: dict[str, Any]) -> MessageMetadata:467    """Pull provider/model hints for the `message-start` event."""468    metadata: MessageMetadata = {}469    if "model_provider" in response_metadata:470        metadata["provider"] = response_metadata["model_provider"]471    if "model_name" in response_metadata:472        metadata["model"] = response_metadata["model_name"]473    return metadata474475476def _accumulate_usage(477    current: dict[str, Any] | None, delta: Any478) -> dict[str, Any] | None:479    """Sum usage counts and merge detail dicts across chunks."""480    if not isinstance(delta, dict):481        return current482    if current is None:483        return dict(delta)484    for key in ("input_tokens", "output_tokens", "total_tokens", "cached_tokens"):485        if key in delta:486            current[key] = current.get(key, 0) + delta[key]487    for detail_key in ("input_token_details", "output_token_details"):488        if detail_key in delta and isinstance(delta[detail_key], dict):489            if detail_key not in current:490                current[detail_key] = {}491            current[detail_key].update(delta[detail_key])492    return current493494495def _to_protocol_usage(usage: dict[str, Any] | None) -> UsageInfo | None:496    """Convert accumulated usage to the protocol's `UsageInfo` shape."""497    if usage is None:498        return None499    result: dict[str, Any] = {}500    for key in ("input_tokens", "output_tokens", "total_tokens", "cached_tokens"):501        if key in usage:502            result[key] = usage[key]503    return cast("UsageInfo", result) if result else None504505506# ---------------------------------------------------------------------------507# Event builders508# ---------------------------------------------------------------------------509510511def _build_message_start(512    msg: BaseMessage,513    message_id: str | None,514) -> MessageStartData:515    start_data = MessageStartData(event="message-start", role="ai", id="")516    resolved_id = message_id if message_id is not None else getattr(msg, "id", None)517    if resolved_id:518        start_data["id"] = resolved_id519    start_metadata = _extract_start_metadata(msg.response_metadata or {})520    if start_metadata:521        start_data["metadata"] = start_metadata522    return start_data523524525def _build_message_finish(526    *,527    usage: dict[str, Any] | None,528    response_metadata: dict[str, Any] | None,529    additional_kwargs: dict[str, Any] | None = None,530) -> MessageFinishData:531    # Protocol 0.0.9 removed the top-level `reason` field from532    # `MessageFinishData`; the provider's raw `finish_reason` /533    # `stop_reason` now rides inside `metadata` alongside other534    # response metadata. Pass it through unchanged.535    finish_data: dict[str, Any] = {"event": "message-finish"}536    usage_info = _to_protocol_usage(usage)537    if usage_info is not None:538        finish_data["usage"] = usage_info539    if response_metadata:540        finish_data["metadata"] = dict(response_metadata)541    # `additional_kwargs` is an off-spec extension on the message-finish542    # event (parallel to `metadata`, which `MessageFinishData` also doesn't543    # formally declare but the consumer reads). It carries provider-side544    # kwargs that don't map onto a typed protocol field — notably Gemini's545    # `__gemini_function_call_thought_signatures__`, which the model546    # requires on follow-up turns to replay prior thinking. Without this,547    # streaming-assembled messages would silently drop data that548    # `ainvoke` preserves, breaking multi-turn streaming flows.549    if additional_kwargs:550        finish_data["additional_kwargs"] = dict(additional_kwargs)551    return cast("MessageFinishData", finish_data)552553554def _finalize_and_build_finish(555    wire_idx: int,556    block: CompatBlock,557) -> MessagesData:558    """Finalize a block and wrap it in a `content-block-finish` event."""559    return ContentBlockFinishData(560        event="content-block-finish",561        index=wire_idx,562        content=_finalize_block(block),563    )564565566# ---------------------------------------------------------------------------567# Main generators568# ---------------------------------------------------------------------------569570571def chunks_to_events(572    chunks: Iterator[ChatGenerationChunk],573    *,574    message_id: str | None = None,575) -> Iterator[MessagesData]:576    """Convert a stream of `ChatGenerationChunk` to protocol events.577578    Blocks are tracked independently by source-side identifier. Providers579    such as Anthropic can interleave parallel tool-call chunks by index, so580    each first-seen block gets a `content-block-start`, deltas keep their581    stable wire index, and all open blocks are finalized at message end.582    Source-side identifiers (from the block's `index` field, which may be583    int or string) are translated to sequential `uint` wire indices.584585    Args:586        chunks: Iterator of `ChatGenerationChunk` from `_stream()`.587        message_id: Optional stable message ID.588589    Yields:590        `MessagesData` lifecycle events.591    """592    started = False593    blocks: dict[Any, tuple[int, CompatBlock]] = {}594    next_wire_idx = 0595    usage: dict[str, Any] | None = None596    response_metadata: dict[str, Any] = {}597    additional_kwargs: dict[str, Any] = {}598599    for chunk in chunks:600        msg = chunk.message601        if not isinstance(msg, AIMessageChunk):602            continue603604        # The v1 `stream()` wrapper merges `generation_info` into605        # `response_metadata` before yielding (`chat_models.py` via606        # `_gen_info_and_msg_metadata`). We bypass that wrapper by reading607        # `_stream` directly, so reproduce the merge here with the same608        # priority: `generation_info` first, then `message.response_metadata`609        # overlays. This is how provider fields like `model_name`,610        # `system_fingerprint`, and `finish_reason` reach the bridge when611        # a provider emits them via `generation_info` instead of the612        # message's `response_metadata`.613        merged_rm: dict[str, Any] = {614            **(chunk.generation_info or {}),615            **(msg.response_metadata or {}),616        }617        if merged_rm:618            response_metadata.update(merged_rm)619620        # Carry chunks' `additional_kwargs` through to the assembled621        # message. Provider-side fields that don't map onto a typed622        # protocol block (e.g. Gemini's per-tool-call thought signatures)623        # live here on non-streaming `ainvoke` results; dropping them on624        # the streaming path silently diverges multi-turn behavior. Use625        # `merge_dicts` because the same key can arrive in pieces across626        # chunks (e.g. an accumulating `function_call`), matching how627        # `AIMessageChunk` merges itself.628        if msg.additional_kwargs:629            additional_kwargs = merge_dicts(additional_kwargs, msg.additional_kwargs)630631        if not started:632            started = True633            yield _build_message_start(msg, message_id)634635        for key, block in _iter_protocol_blocks(msg):636            if key not in blocks:637                wire_idx = next_wire_idx638                next_wire_idx += 1639                blocks[key] = (wire_idx, dict(block))640                yield ContentBlockStartData(641                    event="content-block-start",642                    index=wire_idx,643                    content=_start_skeleton(block),644                )645            else:646                wire_idx, existing = blocks[key]647                blocks[key] = (wire_idx, _accumulate(existing, block))648            if _should_emit_delta(block):649                wire_idx, current = blocks[key]650                is_block_delta = block.get("type") in (651                    "tool_call_chunk",652                    "server_tool_call_chunk",653                )654                delta_source = current if is_block_delta else block655                yield ContentBlockDeltaData(656                    event="content-block-delta",657                    index=wire_idx,658                    delta=_to_content_delta(delta_source or block),659                )660661        if msg.usage_metadata:662            usage = _accumulate_usage(usage, msg.usage_metadata)663664    if not started:665        return666667    for wire_idx, block in blocks.values():668        yield _finalize_and_build_finish(wire_idx, block)669670    yield _build_message_finish(671        usage=usage,672        response_metadata=response_metadata,673        additional_kwargs=additional_kwargs,674    )675676677async def achunks_to_events(678    chunks: AsyncIterator[ChatGenerationChunk],679    *,680    message_id: str | None = None,681) -> AsyncIterator[MessagesData]:682    """Async variant of `chunks_to_events`."""683    started = False684    blocks: dict[Any, tuple[int, CompatBlock]] = {}685    next_wire_idx = 0686    usage: dict[str, Any] | None = None687    response_metadata: dict[str, Any] = {}688    additional_kwargs: dict[str, Any] = {}689690    async for chunk in chunks:691        msg = chunk.message692        if not isinstance(msg, AIMessageChunk):693            continue694695        # See sync twin for rationale: merge `generation_info` into the696        # accumulated `response_metadata` with the same priority as the697        # v1 `stream()` wrapper.698        merged_rm: dict[str, Any] = {699            **(chunk.generation_info or {}),700            **(msg.response_metadata or {}),701        }702        if merged_rm:703            response_metadata.update(merged_rm)704705        # See sync twin: carry chunk `additional_kwargs` through so706        # provider-specific data (e.g. Gemini thought signatures) reaches707        # the assembled message instead of being dropped.708        if msg.additional_kwargs:709            additional_kwargs = merge_dicts(additional_kwargs, msg.additional_kwargs)710711        if not started:712            started = True713            yield _build_message_start(msg, message_id)714715        for key, block in _iter_protocol_blocks(msg):716            if key not in blocks:717                wire_idx = next_wire_idx718                next_wire_idx += 1719                blocks[key] = (wire_idx, dict(block))720                yield ContentBlockStartData(721                    event="content-block-start",722                    index=wire_idx,723                    content=_start_skeleton(block),724                )725            else:726                wire_idx, existing = blocks[key]727                blocks[key] = (wire_idx, _accumulate(existing, block))728            if _should_emit_delta(block):729                wire_idx, current = blocks[key]730                is_block_delta = block.get("type") in (731                    "tool_call_chunk",732                    "server_tool_call_chunk",733                )734                delta_source = current if is_block_delta else block735                yield ContentBlockDeltaData(736                    event="content-block-delta",737                    index=wire_idx,738                    delta=_to_content_delta(delta_source or block),739                )740741        if msg.usage_metadata:742            usage = _accumulate_usage(usage, msg.usage_metadata)743744    if not started:745        return746747    for wire_idx, block in blocks.values():748        yield _finalize_and_build_finish(wire_idx, block)749750    yield _build_message_finish(751        usage=usage,752        response_metadata=response_metadata,753        additional_kwargs=additional_kwargs,754    )755756757def message_to_events(758    msg: BaseMessage,759    *,760    message_id: str | None = None,761) -> Iterator[MessagesData]:762    """Replay a finalized message as a synthetic event lifecycle.763764    For a message returned whole (from a graph node, checkpoint, or765    cache), produce the same `message-start` / per-block /766    `message-finish` event stream a live call would produce.  Consumers767    downstream see a uniform event shape regardless of source.768769    Text and reasoning blocks emit a single `content-block-delta` with770    the full accumulated content.  Already-finalized blocks (tool_call,771    server_tool_call, image, etc.) skip the delta and rely on the772    `content-block-finish` event alone.773774    Args:775        msg: The finalized message  typically an `AIMessage`.776        message_id: Optional stable message ID; falls back to `msg.id`.777778    Yields:779        `MessagesData` lifecycle events.780    """781    response_metadata = msg.response_metadata or {}782    yield _build_message_start(msg, message_id)783784    for wire_idx, (_key, block) in enumerate(_iter_protocol_blocks(msg)):785        yield ContentBlockStartData(786            event="content-block-start",787            index=wire_idx,788            content=_start_skeleton(block),789        )790        if _should_emit_delta(block):791            yield ContentBlockDeltaData(792                event="content-block-delta",793                index=wire_idx,794                delta=_to_content_delta(block),795            )796        yield ContentBlockFinishData(797            event="content-block-finish",798            index=wire_idx,799            content=_finalize_block(block),800        )801802    yield _build_message_finish(803        usage=getattr(msg, "usage_metadata", None),804        response_metadata=response_metadata,805    )806807808async def amessage_to_events(809    msg: BaseMessage,810    *,811    message_id: str | None = None,812) -> AsyncIterator[MessagesData]:813    """Async variant of `message_to_events`."""814    for event in message_to_events(msg, message_id=message_id):815        yield event816817818__all__ = [819    "CompatBlock",820    "achunks_to_events",821    "amessage_to_events",822    "chunks_to_events",823    "finalize_tool_call_chunk",824    "message_to_events",825]

Code quality findings 15

Catch specific exceptions instead of Exception to avoid masking bugs
broad-except
except Exception:
Overuse may indicate design issues; consider polymorphism
isinstance-overuse
if not isinstance(block, dict):
Overuse may indicate design issues; consider polymorphism
isinstance-overuse
if not isinstance(msg, AIMessageChunk):
Overuse may indicate design issues; consider polymorphism
isinstance-overuse
if key == "extras" and isinstance(value, dict):
Overuse may indicate design issues; consider polymorphism
isinstance-overuse
if key == "extras" and isinstance(value, dict):
Overuse may indicate design issues; consider polymorphism
isinstance-overuse
if key == "extras" and isinstance(value, dict):
Ensure functions have docstrings for documentation
missing-docstring
def finalize_tool_call_chunk(
Overuse may indicate design issues; consider polymorphism
isinstance-overuse
if not isinstance(delta, dict):
Overuse may indicate design issues; consider polymorphism
isinstance-overuse
if detail_key in delta and isinstance(delta[detail_key], dict):
Ensure functions have docstrings for documentation
missing-docstring
def chunks_to_events(
Overuse may indicate design issues; consider polymorphism
isinstance-overuse
if not isinstance(msg, AIMessageChunk):
Ensure functions have docstrings for documentation
missing-docstring
async def achunks_to_events(
Overuse may indicate design issues; consider polymorphism
isinstance-overuse
if not isinstance(msg, AIMessageChunk):
Ensure functions have docstrings for documentation
missing-docstring
def message_to_events(
Ensure functions have docstrings for documentation
missing-docstring
async def amessage_to_events(

Get this view in your editor

Same data, no extra tab — call code_get_file + code_get_findings over MCP from Claude/Cursor/Copilot.