Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
153 changes: 153 additions & 0 deletions libs/partners/mistralai/langchain_mistralai/_stream_events.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
"""Native content-block streaming-event converter for MistralAI.

Mirrors `ChatMistralAI._stream`: threads `(index, index_type, default_class)`
through `_convert_chunk_to_message_chunk` (injected to avoid a circular import)
and feeds each resulting `AIMessageChunk`'s content blocks into the shared
`BlockStreamTracker`.
"""

from __future__ import annotations

from collections.abc import Callable
from typing import TYPE_CHECKING, Any

from langchain_core.language_models.stream_events import (
BlockStreamTracker,
accumulate_usage,
build_message_finish,
iter_protocol_blocks,
)
from langchain_core.messages import AIMessageChunk

if TYPE_CHECKING:
from collections.abc import AsyncIterator, Iterator

from langchain_core.messages import BaseMessageChunk
from langchain_protocol.protocol import (
MessageMetadata,
MessagesData,
MessageStartData,
)

# The module-level `_convert_chunk_to_message_chunk`, injected so the converter
# stays pure and avoids a circular import. It takes a raw chunk plus the running
# `default_class`, `index`, and `index_type`, returning the built chunk and the
# updated index/index_type.
ConvertChunk = Callable[..., "tuple[BaseMessageChunk, int, str]"]


def _message_start(message_id: str | None, model: str | None) -> MessageStartData:
# Do not use the provider chunk id here: on the v3 path core seeds the
# stream with the LangChain run id, and an empty id lets that stand
# (matching the compat bridge). Only an explicit `message_id` wins.
metadata: MessageMetadata = {"provider": "mistralai"}
if model:
metadata["model"] = model
return {
"event": "message-start",
"role": "ai",
"id": message_id or "",
"metadata": metadata,
}


def convert_mistral_stream(
raw: Iterator[Any],
convert_chunk: ConvertChunk,
*,
output_version: str | None = None,
message_id: str | None = None,
) -> Iterator[MessagesData]:
"""Convert a raw Mistral chat stream to protocol events.

Args:
raw: Raw Mistral chat-completion chunks (OpenAI-shaped dicts).
convert_chunk: `_convert_chunk_to_message_chunk`, injected so the
converter stays pure and avoids a circular import.
output_version: Forwarded to `convert_chunk`; reasoning blocks only
surface under `"v1"`.
message_id: Overrides the id on `message-start`.

Yields:
Protocol `MessagesData` lifecycle events.
"""
tracker = BlockStreamTracker()
started = False
index = -1
index_type = ""
default_class: type[BaseMessageChunk] = AIMessageChunk
usage: dict[str, Any] | None = None
response_metadata: dict[str, Any] = {"model_provider": "mistralai"}
model: str | None = None

for chunk in raw:
if len(chunk.get("choices", [])) == 0:
continue
if model is None:
model = chunk.get("model")
new_chunk, index, index_type = convert_chunk(
chunk, default_class, index, index_type, output_version
)
# Make future chunks the same type as the first chunk.
default_class = new_chunk.__class__
if not started:
started = True
yield _message_start(message_id, model)
if isinstance(new_chunk, AIMessageChunk):
for key, block in iter_protocol_blocks(new_chunk):
yield from tracker.feed(key, block)
Comment thread
open-swe[bot] marked this conversation as resolved.
Comment on lines +97 to +98

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Unindexed tool calls overwrite text blocks

This feeds Mistral chunks into BlockStreamTracker using the generic iter_protocol_blocks keys. For the text-then-tool stream added in the tests, neither the text blocks nor the tool-call chunk carry an index, so iter_protocol_blocks gives both source key 0. The tracker then treats the tool call as an update to the already-open text block: no content-block-start is emitted for the tool call, the accumulated text is replaced, and content-block-finish only returns the tool call. Users streaming a response that contains text before an unindexed tool call lose the finalized text block and get an inconsistent block lifecycle. The native converter should assign stable distinct keys for Mistral text vs tool-call chunks (or ensure tool calls get their own index) before feeding the tracker.

(Refers to lines 97-98)


Your feedback helps Open SWE learn. React with 👍 or 👎 to tell us if this review comment was useful.

if new_chunk.usage_metadata:
usage = accumulate_usage(usage, new_chunk.usage_metadata)
if new_chunk.response_metadata:
response_metadata.update(new_chunk.response_metadata)

if not started:
return
yield from tracker.finish_all()
yield build_message_finish(usage=usage, response_metadata=response_metadata)


async def aconvert_mistral_stream(
raw: AsyncIterator[Any],
convert_chunk: ConvertChunk,
*,
output_version: str | None = None,
message_id: str | None = None,
) -> AsyncIterator[MessagesData]:
"""Async twin of `convert_mistral_stream`. `convert_chunk` is sync."""
tracker = BlockStreamTracker()
started = False
index = -1
index_type = ""
default_class: type[BaseMessageChunk] = AIMessageChunk
usage: dict[str, Any] | None = None
response_metadata: dict[str, Any] = {"model_provider": "mistralai"}
model: str | None = None

async for chunk in raw:
if len(chunk.get("choices", [])) == 0:
continue
if model is None:
model = chunk.get("model")
new_chunk, index, index_type = convert_chunk(
chunk, default_class, index, index_type, output_version
)
# Make future chunks the same type as the first chunk.
default_class = new_chunk.__class__
if not started:
started = True
yield _message_start(message_id, model)
if isinstance(new_chunk, AIMessageChunk):
for key, block in iter_protocol_blocks(new_chunk):
for ev in tracker.feed(key, block):
yield ev
if new_chunk.usage_metadata:
usage = accumulate_usage(usage, new_chunk.usage_metadata)
if new_chunk.response_metadata:
response_metadata.update(new_chunk.response_metadata)

if not started:
return
for ev in tracker.finish_all():
yield ev
yield build_message_finish(usage=usage, response_metadata=response_metadata)
74 changes: 74 additions & 0 deletions libs/partners/mistralai/langchain_mistralai/chat_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@
from collections.abc import AsyncIterator, Iterator
from contextlib import AbstractAsyncContextManager

from langchain_protocol.protocol import MessagesData

logger = logging.getLogger(__name__)

# Mistral enforces a specific pattern for tool call IDs
Expand Down Expand Up @@ -830,6 +832,78 @@ async def _astream(
)
yield gen_chunk

def _stream_chat_model_events(
self,
messages: list[BaseMessage],
stop: list[str] | None = None,
run_manager: CallbackManagerForLLMRun | None = None,
*,
message_id: str | None = None,
**kwargs: Any,
) -> Iterator[MessagesData]:
"""Emit Mistral-native content-block protocol events.

Detected by `langchain-core`'s `_iter_v2_events`; powers
`stream_events(version="v3")`. Falls through to the compat bridge
only if this method is absent. `message_id` is threaded from the
stream so `message-start` matches the bridge's LangChain run id.
"""
# Local import avoids a circular import: `_stream_events` imports
# `_convert_chunk_to_message_chunk` from this module.
from langchain_mistralai._stream_events import convert_mistral_stream

message_dicts, params = self._create_message_dicts(messages, stop)
params = {**params, **kwargs, "stream": True}
raw = self.completion_with_retry(
messages=message_dicts, run_manager=run_manager, **params
)
for event in convert_mistral_stream(
raw,
_convert_chunk_to_message_chunk,
output_version=self.output_version,
message_id=message_id,
):
if (
run_manager is not None
and event["event"] == "content-block-delta"
and event["delta"].get("type") == "text-delta"
):
run_manager.on_llm_new_token(str(event["delta"].get("text", "")))
yield event

async def _astream_chat_model_events(
self,
messages: list[BaseMessage],
stop: list[str] | None = None,
run_manager: AsyncCallbackManagerForLLMRun | None = None,
*,
message_id: str | None = None,
**kwargs: Any,
) -> AsyncIterator[MessagesData]:
"""Async twin of `_stream_chat_model_events`."""
# Local import avoids a circular import: `_stream_events` imports
# `_convert_chunk_to_message_chunk` from this module.
from langchain_mistralai._stream_events import aconvert_mistral_stream

message_dicts, params = self._create_message_dicts(messages, stop)
params = {**params, **kwargs, "stream": True}
raw = await acompletion_with_retry(
self, messages=message_dicts, run_manager=run_manager, **params
)
async for event in aconvert_mistral_stream(
raw,
_convert_chunk_to_message_chunk,
output_version=self.output_version,
message_id=message_id,
):
if (
run_manager is not None
and event["event"] == "content-block-delta"
and event["delta"].get("type") == "text-delta"
):
await run_manager.on_llm_new_token(str(event["delta"].get("text", "")))
yield event

async def _agenerate(
self,
messages: list[BaseMessage],
Expand Down
Loading
Loading