This commit is contained in:
Mason Daugherty 2025-07-25 16:24:04 -04:00
parent 20f4736999
commit 10349b019a
No known key found for this signature in database
7 changed files with 985 additions and 38 deletions

View File

@ -339,6 +339,37 @@ def _get_image_from_data_content_block(block: dict) -> str:
raise ValueError(f"Blocks of type {block['type']} not supported.") raise ValueError(f"Blocks of type {block['type']} not supported.")
``` ```
#### 4.2 NonStandardContentBlock Support
Unknown content block types should be converted to `NonStandardContentBlock` instead of raising errors. This enables forward compatibility and provider-specific extensions.
Update message processing to handle unknown content blocks:
```python
from langchain_core.messages.content_blocks import NonStandardContentBlock
def _convert_unknown_content_block_to_non_standard(block: dict) -> NonStandardContentBlock:
"""Convert unknown content block to NonStandardContentBlock format."""
return NonStandardContentBlock(
type="non_standard",
value=block
)
```
In `_convert_messages_to_ollama_messages()`, replace error handling for unknown blocks:
```python
# Instead of raising ValueError for unknown blocks:
elif is_data_content_block(content_part):
image = _get_image_from_data_content_block(content_part)
images.append(image)
else:
# Convert unknown blocks to NonStandardContentBlock
non_standard_block = _convert_unknown_content_block_to_non_standard(content_part)
# Should we convert non-standard blocks to a string or keep them as-is?
continue
```
#### 4.2 Update Message Conversion #### 4.2 Update Message Conversion
Enhance `_convert_messages_to_ollama_messages()` to handle v1 content: Enhance `_convert_messages_to_ollama_messages()` to handle v1 content:

View File

@ -0,0 +1,167 @@
"""Compatibility module for handling v1 message format conversions."""
from __future__ import annotations
from typing import Any, Literal, Optional, cast
from langchain_core.messages import (
AIMessage,
AIMessageChunk,
is_data_content_block,
)
from langchain_core.messages.content_blocks import (
NonStandardContentBlock,
ReasoningContentBlock,
TextContentBlock,
)
from typing_extensions import TypedDict
class ToolCallReferenceBlock(TypedDict):
"""Reference to a tool call (metadata only).
This is used in v1 content blocks to reference tool calls
without duplicating the full tool call data.
"""
type: Literal["tool_call"]
id: Optional[str]
def _convert_unknown_content_block_to_non_standard(
block: dict,
) -> NonStandardContentBlock:
"""Convert unknown content block to NonStandardContentBlock format.
This enables forward compatibility by preserving unknown content block types
instead of raising errors.
Args:
block: Unknown content block dictionary.
Returns:
NonStandardContentBlock containing the original block data.
"""
return NonStandardContentBlock(type="non_standard", value=block)
def _convert_from_v1_message(message: AIMessage) -> AIMessage:
"""Convert a v1 message to Ollama-compatible request format.
Returns AIMessage with v0-style content and reasoning in ``additional_kwargs``.
If input is already v0 format, returns unchanged.
Args:
message: The message to convert, potentially in v1 format.
Returns:
AIMessage in v0 format suitable for Ollama API.
"""
if not isinstance(message.content, list):
# Already v0 format or string content (determined by content type)
return message
# Extract components from v1 content blocks
text_content = ""
reasoning_content = None
for block in message.content:
if isinstance(block, dict):
block_type = block.get("type")
if block_type == "text":
text_content += block.get("text", "")
elif block_type == "reasoning":
# Extract reasoning for additional_kwargs
reasoning_content = block.get("reasoning", "")
elif block_type == "tool_call":
# Skip - handled via tool_calls property
continue
elif is_data_content_block(block):
# Keep data blocks as-is (images already supported)
continue
else:
# Convert unknown content blocks to NonStandardContentBlock
# TODO what to do from here?
_convert_unknown_content_block_to_non_standard(block)
continue
# Update message with extracted content
updates: dict[str, Any] = {"content": text_content if text_content else ""}
if reasoning_content:
additional_kwargs = dict(message.additional_kwargs)
additional_kwargs["reasoning_content"] = reasoning_content
updates["additional_kwargs"] = additional_kwargs
return message.model_copy(update=updates)
def _convert_to_v1_message(message: AIMessage) -> AIMessage:
"""Convert an Ollama message to v1 format.
Args:
message: AIMessage in v0 format from Ollama.
Returns:
AIMessage in v1 format with content blocks.
"""
new_content: list[Any] = []
# Handle reasoning content first (from additional_kwargs)
additional_kwargs = dict(message.additional_kwargs)
if reasoning_content := additional_kwargs.pop("reasoning_content", None):
reasoning_block = ReasoningContentBlock(
type="reasoning", reasoning=reasoning_content
)
new_content.append(reasoning_block)
# Convert text content to content blocks
if isinstance(message.content, str) and message.content:
text_block = TextContentBlock(type="text", text=message.content)
new_content.append(text_block)
# Convert tool calls to content blocks
for tool_call in message.tool_calls:
if tool_call_id := tool_call.get("id"):
# Create a tool call reference block (metadata only)
tool_call_block = ToolCallReferenceBlock(type="tool_call", id=tool_call_id)
new_content.append(dict(tool_call_block))
# Handle any non-standard content blocks that might be stored in additional_kwargs
if non_standard_blocks := additional_kwargs.pop(
"non_standard_content_blocks", None
):
new_content.extend(non_standard_blocks)
return message.model_copy(
update={"content": new_content, "additional_kwargs": additional_kwargs}
)
def _convert_to_v1_chunk(chunk: AIMessageChunk) -> AIMessageChunk:
"""Convert an Ollama chunk to v1 format.
Args:
chunk: AIMessageChunk in v0 format from Ollama.
Returns:
AIMessageChunk in v1 format with content blocks.
"""
result = _convert_to_v1_message(cast(AIMessage, chunk))
return cast(AIMessageChunk, result)
def _convert_from_v03_ai_message(message: AIMessage) -> AIMessage:
"""Convert a LangChain v0.3 AIMessage to v1 format.
This handles compatibility for users migrating stored/cached AIMessage objects
from LangChain v0.3.
Args:
message: AIMessage potentially in v0.3 format.
Returns:
AIMessage in v1 format.
"""
# For now, treat v0.3 messages the same as v0 messages
return _convert_to_v1_message(message)

View File

@ -36,6 +36,7 @@ from langchain_core.messages import (
is_data_content_block, is_data_content_block,
) )
from langchain_core.messages.ai import UsageMetadata from langchain_core.messages.ai import UsageMetadata
from langchain_core.messages.content_blocks import NonStandardContentBlock
from langchain_core.messages.tool import tool_call from langchain_core.messages.tool import tool_call
from langchain_core.output_parsers import ( from langchain_core.output_parsers import (
JsonOutputKeyToolsParser, JsonOutputKeyToolsParser,
@ -57,7 +58,12 @@ from pydantic.json_schema import JsonSchemaValue
from pydantic.v1 import BaseModel as BaseModelV1 from pydantic.v1 import BaseModel as BaseModelV1
from typing_extensions import Self, is_typeddict from typing_extensions import Self, is_typeddict
from ._utils import validate_model from langchain_ollama._compat import (
_convert_from_v1_message,
_convert_to_v1_chunk,
_convert_to_v1_message,
)
from langchain_ollama._utils import validate_model
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@ -208,6 +214,23 @@ def _get_image_from_data_content_block(block: dict) -> str:
raise ValueError(error_message) raise ValueError(error_message)
def _convert_unknown_content_block_to_non_standard(
block: dict,
) -> NonStandardContentBlock:
"""Convert unknown content block to NonStandardContentBlock format.
This enables forward compatibility by preserving unknown content block types
instead of raising errors.
Args:
block: Unknown content block dictionary.
Returns:
NonStandardContentBlock containing the original block data.
"""
return NonStandardContentBlock(type="non_standard", value=block)
def _is_pydantic_class(obj: Any) -> bool: def _is_pydantic_class(obj: Any) -> bool:
return isinstance(obj, type) and is_basemodel_subclass(obj) return isinstance(obj, type) and is_basemodel_subclass(obj)
@ -418,12 +441,52 @@ class ChatOllama(BaseChatModel):
AIMessage(content='The word "strawberry" contains **three \'r\' letters**. Here\'s a breakdown for clarity:\n\n- The spelling of "strawberry" has two parts ... be 3.\n\nTo be thorough, let\'s confirm with an online source or common knowledge.\n\nI can recall that "strawberry" has: s-t-r-a-w-b-e-r-r-y — yes, three r\'s.\n\nPerhaps it\'s misspelled by some, but standard is correct.\n\nSo I think the response should be 3.\n'}, response_metadata={'model': 'deepseek-r1:8b', 'created_at': '2025-07-08T19:33:55.891269Z', 'done': True, 'done_reason': 'stop', 'total_duration': 98232561292, 'load_duration': 28036792, 'prompt_eval_count': 10, 'prompt_eval_duration': 40171834, 'eval_count': 3615, 'eval_duration': 98163832416, 'model_name': 'deepseek-r1:8b'}, id='run--18f8269f-6a35-4a7c-826d-b89d52c753b3-0', usage_metadata={'input_tokens': 10, 'output_tokens': 3615, 'total_tokens': 3625}) AIMessage(content='The word "strawberry" contains **three \'r\' letters**. Here\'s a breakdown for clarity:\n\n- The spelling of "strawberry" has two parts ... be 3.\n\nTo be thorough, let\'s confirm with an online source or common knowledge.\n\nI can recall that "strawberry" has: s-t-r-a-w-b-e-r-r-y — yes, three r\'s.\n\nPerhaps it\'s misspelled by some, but standard is correct.\n\nSo I think the response should be 3.\n'}, response_metadata={'model': 'deepseek-r1:8b', 'created_at': '2025-07-08T19:33:55.891269Z', 'done': True, 'done_reason': 'stop', 'total_duration': 98232561292, 'load_duration': 28036792, 'prompt_eval_count': 10, 'prompt_eval_duration': 40171834, 'eval_count': 3615, 'eval_duration': 98163832416, 'model_name': 'deepseek-r1:8b'}, id='run--18f8269f-6a35-4a7c-826d-b89d52c753b3-0', usage_metadata={'input_tokens': 10, 'output_tokens': 3615, 'total_tokens': 3625})
V1 Output Format:
.. code-block:: python
llm = ChatOllama(model="llama3.1", output_version="v1")
response = llm.invoke("Hello")
# Response content is now a list of standard output content blocks:
response.content
# [{"type": "text", "text": "Hello! How can I help you?"}] # follows TextContentBlock format
# With reasoning enabled:
llm_reasoning = ChatOllama(
model="deepseek-r1:8b",
output_version="v1",
reasoning=True
)
response = llm_reasoning.invoke("What is 2+2?")
# Response includes reasoning and text blocks:
response.content
# [
# {"type": "reasoning", "reasoning": "I need to add 2+2..."}, # ReasoningContentBlock
# {"type": "text", "text": "2+2 equals 4."} # TextContentBlock
# ]
""" # noqa: E501, pylint: disable=line-too-long """ # noqa: E501, pylint: disable=line-too-long
model: str model: str
"""Model name to use.""" """Model name to use."""
output_version: str = "v0"
"""Version of AIMessage output format to use.
This field is used to roll-out new output formats for chat model AIMessages
in a backwards-compatible way.
Supported values:
- ``"v0"``: AIMessage format as of langchain-ollama 0.x.x.
- ``"v1"``: v1 of LangChain cross-provider standard.
``output_version="v1"`` is recommended.
.. versionadded:: 0.4.0
"""
reasoning: Optional[bool] = None reasoning: Optional[bool] = None
"""Controls the reasoning/thinking mode for """Controls the reasoning/thinking mode for
`supported models <https://ollama.com/search?c=thinking>`__. `supported models <https://ollama.com/search?c=thinking>`__.
@ -627,6 +690,10 @@ class ChatOllama(BaseChatModel):
) -> Sequence[Message]: ) -> Sequence[Message]:
ollama_messages: list = [] ollama_messages: list = []
for message in messages: for message in messages:
# Handle v1 format messages in input
if isinstance(message, AIMessage) and isinstance(message.content, list):
# This is v1 format message (content is list) - convert for Ollama API
message = _convert_from_v1_message(message)
role: str role: str
tool_call_id: Optional[str] = None tool_call_id: Optional[str] = None
tool_calls: Optional[list[dict[str, Any]]] = None tool_calls: Optional[list[dict[str, Any]]] = None
@ -663,6 +730,12 @@ class ChatOllama(BaseChatModel):
content += f"\n{content_part['text']}" content += f"\n{content_part['text']}"
elif content_part.get("type") == "tool_use": elif content_part.get("type") == "tool_use":
continue continue
elif content_part.get("type") == "tool_call":
# Skip - handled by tool_calls property
continue
elif content_part.get("type") == "reasoning":
# Skip - handled by reasoning parameter
continue
elif content_part.get("type") == "image_url": elif content_part.get("type") == "image_url":
image_url = None image_url = None
temp_image_url = content_part.get("image_url") temp_image_url = content_part.get("image_url")
@ -692,12 +765,10 @@ class ChatOllama(BaseChatModel):
image = _get_image_from_data_content_block(content_part) image = _get_image_from_data_content_block(content_part)
images.append(image) images.append(image)
else: else:
msg = ( # Convert unknown content blocks to NonStandardContentBlock
"Unsupported message content type. " # TODO what to do with these?
"Must either have type 'text' or type 'image_url' " _convert_unknown_content_block_to_non_standard(content_part)
"with a string 'image_url' field." continue
)
raise ValueError(msg)
# Should convert to ollama.Message once role includes tool, # Should convert to ollama.Message once role includes tool,
# and tool_call_id is in Message # and tool_call_id is in Message
msg_: dict = { msg_: dict = {
@ -820,13 +891,18 @@ class ChatOllama(BaseChatModel):
messages, stop, run_manager, verbose=self.verbose, **kwargs messages, stop, run_manager, verbose=self.verbose, **kwargs
) )
generation_info = final_chunk.generation_info generation_info = final_chunk.generation_info
ai_message = AIMessage(
content=final_chunk.text,
usage_metadata=cast(AIMessageChunk, final_chunk.message).usage_metadata,
tool_calls=cast(AIMessageChunk, final_chunk.message).tool_calls,
additional_kwargs=final_chunk.message.additional_kwargs,
)
if self.output_version == "v1":
ai_message = _convert_to_v1_message(ai_message)
chat_generation = ChatGeneration( chat_generation = ChatGeneration(
message=AIMessage( message=ai_message,
content=final_chunk.text,
usage_metadata=cast(AIMessageChunk, final_chunk.message).usage_metadata,
tool_calls=cast(AIMessageChunk, final_chunk.message).tool_calls,
additional_kwargs=final_chunk.message.additional_kwargs,
),
generation_info=generation_info, generation_info=generation_info,
) )
return ChatResult(generations=[chat_generation]) return ChatResult(generations=[chat_generation])
@ -890,6 +966,11 @@ class ChatOllama(BaseChatModel):
generation_info=generation_info, generation_info=generation_info,
) )
if self.output_version == "v1":
chunk.message = _convert_to_v1_chunk(
cast(AIMessageChunk, chunk.message)
)
yield chunk yield chunk
def _stream( def _stream(
@ -966,6 +1047,11 @@ class ChatOllama(BaseChatModel):
generation_info=generation_info, generation_info=generation_info,
) )
if self.output_version == "v1":
chunk.message = _convert_to_v1_chunk(
cast(AIMessageChunk, chunk.message)
)
yield chunk yield chunk
async def _astream( async def _astream(
@ -994,13 +1080,18 @@ class ChatOllama(BaseChatModel):
messages, stop, run_manager, verbose=self.verbose, **kwargs messages, stop, run_manager, verbose=self.verbose, **kwargs
) )
generation_info = final_chunk.generation_info generation_info = final_chunk.generation_info
ai_message = AIMessage(
content=final_chunk.text,
usage_metadata=cast(AIMessageChunk, final_chunk.message).usage_metadata,
tool_calls=cast(AIMessageChunk, final_chunk.message).tool_calls,
additional_kwargs=final_chunk.message.additional_kwargs,
)
if self.output_version == "v1":
ai_message = _convert_to_v1_message(ai_message)
chat_generation = ChatGeneration( chat_generation = ChatGeneration(
message=AIMessage( message=ai_message,
content=final_chunk.text,
usage_metadata=cast(AIMessageChunk, final_chunk.message).usage_metadata,
tool_calls=cast(AIMessageChunk, final_chunk.message).tool_calls,
additional_kwargs=final_chunk.message.additional_kwargs,
),
generation_info=generation_info, generation_info=generation_info,
) )
return ChatResult(generations=[chat_generation]) return ChatResult(generations=[chat_generation])

View File

@ -54,7 +54,6 @@ select = [
"C4", # flake8-comprehensions "C4", # flake8-comprehensions
"COM", # flake8-commas "COM", # flake8-commas
"D", # pydocstyle "D", # pydocstyle
"DOC", # pydoclint
"E", # pycodestyle error "E", # pycodestyle error
"EM", # flake8-errmsg "EM", # flake8-errmsg
"F", # pyflakes "F", # pyflakes

View File

@ -41,8 +41,6 @@ def test_stream_no_reasoning(model: str) -> None:
assert result.content assert result.content
assert "reasoning_content" not in result.additional_kwargs assert "reasoning_content" not in result.additional_kwargs
assert "<think>" not in result.content and "</think>" not in result.content assert "<think>" not in result.content and "</think>" not in result.content
assert "<think>" not in result.additional_kwargs["reasoning_content"]
assert "</think>" not in result.additional_kwargs["reasoning_content"]
@pytest.mark.parametrize(("model"), [("deepseek-r1:1.5b")]) @pytest.mark.parametrize(("model"), [("deepseek-r1:1.5b")])
@ -66,8 +64,6 @@ async def test_astream_no_reasoning(model: str) -> None:
assert result.content assert result.content
assert "reasoning_content" not in result.additional_kwargs assert "reasoning_content" not in result.additional_kwargs
assert "<think>" not in result.content and "</think>" not in result.content assert "<think>" not in result.content and "</think>" not in result.content
assert "<think>" not in result.additional_kwargs["reasoning_content"]
assert "</think>" not in result.additional_kwargs["reasoning_content"]
@pytest.mark.parametrize(("model"), [("deepseek-r1:1.5b")]) @pytest.mark.parametrize(("model"), [("deepseek-r1:1.5b")])
@ -91,8 +87,10 @@ def test_stream_reasoning_none(model: str) -> None:
assert result.content assert result.content
assert "reasoning_content" not in result.additional_kwargs assert "reasoning_content" not in result.additional_kwargs
assert "<think>" in result.content and "</think>" in result.content assert "<think>" in result.content and "</think>" in result.content
assert "<think>" not in result.additional_kwargs["reasoning_content"] # For backward compatibility: if reasoning_content exists, check it
assert "</think>" not in result.additional_kwargs["reasoning_content"] if "reasoning_content" in result.additional_kwargs:
assert "<think>" not in result.additional_kwargs["reasoning_content"]
assert "</think>" not in result.additional_kwargs["reasoning_content"]
@pytest.mark.parametrize(("model"), [("deepseek-r1:1.5b")]) @pytest.mark.parametrize(("model"), [("deepseek-r1:1.5b")])
@ -116,8 +114,10 @@ async def test_astream_reasoning_none(model: str) -> None:
assert result.content assert result.content
assert "reasoning_content" not in result.additional_kwargs assert "reasoning_content" not in result.additional_kwargs
assert "<think>" in result.content and "</think>" in result.content assert "<think>" in result.content and "</think>" in result.content
assert "<think>" not in result.additional_kwargs["reasoning_content"] # For backward compatibility: if reasoning_content exists, check it
assert "</think>" not in result.additional_kwargs["reasoning_content"] if "reasoning_content" in result.additional_kwargs:
assert "<think>" not in result.additional_kwargs["reasoning_content"]
assert "</think>" not in result.additional_kwargs["reasoning_content"]
@pytest.mark.parametrize(("model"), [("deepseek-r1:1.5b")]) @pytest.mark.parametrize(("model"), [("deepseek-r1:1.5b")])
@ -179,10 +179,11 @@ def test_invoke_no_reasoning(model: str) -> None:
message = HumanMessage(content=SAMPLE) message = HumanMessage(content=SAMPLE)
result = llm.invoke([message]) result = llm.invoke([message])
assert result.content assert result.content
assert "reasoning_content" not in result.additional_kwargs
assert "<think>" not in result.content and "</think>" not in result.content assert "<think>" not in result.content and "</think>" not in result.content
assert "<think>" not in result.additional_kwargs["reasoning_content"] # For backward compatibility: if reasoning_content exists, check it
assert "</think>" not in result.additional_kwargs["reasoning_content"] if "reasoning_content" in result.additional_kwargs:
assert "<think>" not in result.additional_kwargs["reasoning_content"]
assert "</think>" not in result.additional_kwargs["reasoning_content"]
@pytest.mark.parametrize(("model"), [("deepseek-r1:1.5b")]) @pytest.mark.parametrize(("model"), [("deepseek-r1:1.5b")])
@ -192,10 +193,11 @@ async def test_ainvoke_no_reasoning(model: str) -> None:
message = HumanMessage(content=SAMPLE) message = HumanMessage(content=SAMPLE)
result = await llm.ainvoke([message]) result = await llm.ainvoke([message])
assert result.content assert result.content
assert "reasoning_content" not in result.additional_kwargs
assert "<think>" not in result.content and "</think>" not in result.content assert "<think>" not in result.content and "</think>" not in result.content
assert "<think>" not in result.additional_kwargs["reasoning_content"] # For backward compatibility: if reasoning_content exists, check it
assert "</think>" not in result.additional_kwargs["reasoning_content"] if "reasoning_content" in result.additional_kwargs:
assert "<think>" not in result.additional_kwargs["reasoning_content"]
assert "</think>" not in result.additional_kwargs["reasoning_content"]
@pytest.mark.parametrize(("model"), [("deepseek-r1:1.5b")]) @pytest.mark.parametrize(("model"), [("deepseek-r1:1.5b")])
@ -207,8 +209,10 @@ def test_invoke_reasoning_none(model: str) -> None:
assert result.content assert result.content
assert "reasoning_content" not in result.additional_kwargs assert "reasoning_content" not in result.additional_kwargs
assert "<think>" in result.content and "</think>" in result.content assert "<think>" in result.content and "</think>" in result.content
assert "<think>" not in result.additional_kwargs["reasoning_content"] # For backward compatibility: if reasoning_content exists, check it
assert "</think>" not in result.additional_kwargs["reasoning_content"] if "reasoning_content" in result.additional_kwargs:
assert "<think>" not in result.additional_kwargs["reasoning_content"]
assert "</think>" not in result.additional_kwargs["reasoning_content"]
@pytest.mark.parametrize(("model"), [("deepseek-r1:1.5b")]) @pytest.mark.parametrize(("model"), [("deepseek-r1:1.5b")])
@ -220,8 +224,10 @@ async def test_ainvoke_reasoning_none(model: str) -> None:
assert result.content assert result.content
assert "reasoning_content" not in result.additional_kwargs assert "reasoning_content" not in result.additional_kwargs
assert "<think>" in result.content and "</think>" in result.content assert "<think>" in result.content and "</think>" in result.content
assert "<think>" not in result.additional_kwargs["reasoning_content"] # For backward compatibility: if reasoning_content exists, check it
assert "</think>" not in result.additional_kwargs["reasoning_content"] if "reasoning_content" in result.additional_kwargs:
assert "<think>" not in result.additional_kwargs["reasoning_content"]
assert "</think>" not in result.additional_kwargs["reasoning_content"]
@pytest.mark.parametrize(("model"), [("deepseek-r1:1.5b")]) @pytest.mark.parametrize(("model"), [("deepseek-r1:1.5b")])

View File

@ -0,0 +1,272 @@
"""Integration tests for ChatOllama v1 format functionality."""
from __future__ import annotations
import pytest
from langchain_core.messages import AIMessage, HumanMessage
from langchain_ollama import ChatOllama
DEFAULT_MODEL_NAME = "llama3.1"
REASONING_MODEL_NAME = "deepseek-r1:8b"
@pytest.mark.requires("ollama")
class TestChatOllamaV1Integration:
"""Integration tests for ChatOllama v1 format functionality."""
def test_v1_output_format_basic_chat(self) -> None:
"""Test basic chat functionality with v1 output format."""
llm = ChatOllama(model=DEFAULT_MODEL_NAME, output_version="v1")
message = HumanMessage("Say hello")
result = llm.invoke([message])
# Result should be in v1 format (content as list)
assert isinstance(result, AIMessage)
assert isinstance(result.content, list)
assert len(result.content) > 0
# Should have at least one TextContentBlock
text_blocks = [
block
for block in result.content
if isinstance(block, dict) and block.get("type") == "text"
]
assert len(text_blocks) > 0
assert "text" in text_blocks[0]
def test_v1_output_format_streaming(self) -> None:
"""Test streaming functionality with v1 output format."""
llm = ChatOllama(model=DEFAULT_MODEL_NAME, output_version="v1")
message = HumanMessage("Count to 3")
chunks = list(llm.stream([message]))
# All chunks should be in v1 format
for chunk in chunks:
assert isinstance(chunk.content, list)
# Each chunk should have content blocks
if chunk.content: # Some chunks might be empty
for block in chunk.content:
assert isinstance(block, dict)
assert "type" in block
def test_v1_input_with_v0_output(self) -> None:
"""Test that v1 input works with v0 output format."""
llm = ChatOllama(model=DEFAULT_MODEL_NAME, output_version="v0")
# Send v1 format message as input
v1_message = AIMessage(
content=[{"type": "text", "text": "Hello, how are you?"}]
)
human_message = HumanMessage("Fine, thanks!")
result = llm.invoke([v1_message, human_message])
# Output should be in v0 format (content as string)
assert isinstance(result, AIMessage)
assert isinstance(result.content, str)
def test_v1_input_with_v1_output(self) -> None:
"""Test that v1 input works with v1 output format."""
llm = ChatOllama(model=DEFAULT_MODEL_NAME, output_version="v1")
# Send v1 format message as input
v1_message = AIMessage(
content=[{"type": "text", "text": "Hello, how are you?"}]
)
human_message = HumanMessage("Fine, thanks!")
result = llm.invoke([v1_message, human_message])
# Output should be in v1 format (content as list)
assert isinstance(result, AIMessage)
assert isinstance(result.content, list)
def test_v0_input_with_v1_output(self) -> None:
"""Test that v0 input works with v1 output format."""
llm = ChatOllama(model=DEFAULT_MODEL_NAME, output_version="v1")
# Send v0 format message as input
v0_message = AIMessage(content="Hello, how are you?")
human_message = HumanMessage("Fine, thanks!")
result = llm.invoke([v0_message, human_message])
# Output should be in v1 format (content as list)
assert isinstance(result, AIMessage)
assert isinstance(result.content, list)
@pytest.mark.parametrize("output_version", ["v0", "v1"])
def test_mixed_message_formats_input(self, output_version: str) -> None:
"""Test handling mixed v0 and v1 format messages in input.
Rare case but you never know...
"""
llm = ChatOllama(model=DEFAULT_MODEL_NAME, output_version=output_version)
messages = [
HumanMessage("Hello"),
AIMessage(content="Hi there!"), # v0 format
HumanMessage("How are you?"),
# v1 format
AIMessage(content=[{"type": "text", "text": "I'm doing well!"}]),
HumanMessage("Great!"),
]
result = llm.invoke(messages)
# Output format should match output_version setting
assert isinstance(result, AIMessage)
if output_version == "v0":
assert isinstance(result.content, str)
else:
assert isinstance(result.content, list)
@pytest.mark.requires("ollama")
class TestChatOllamaV1WithReasoning:
"""Integration tests for ChatOllama v1 format with reasoning functionality."""
def test_v1_output_with_reasoning_enabled(self) -> None:
"""Test v1 output format with reasoning enabled."""
# Note: This test requires a reasoning-capable model
llm = ChatOllama(
model=REASONING_MODEL_NAME,
output_version="v1",
reasoning=True,
)
message = HumanMessage("What is 2+2? Think step by step.")
result = llm.invoke([message])
# Result should be in v1 format with reasoning block
assert isinstance(result, AIMessage)
assert isinstance(result.content, list)
# Should have both reasoning and text blocks
reasoning_blocks = [
block
for block in result.content
if isinstance(block, dict) and block.get("type") == "reasoning"
]
text_blocks = [
block
for block in result.content
if isinstance(block, dict) and block.get("type") == "text"
]
# Should have reasoning content when reasoning=True
assert len(reasoning_blocks) > 0 or len(text_blocks) > 0
# Should be able to use the reasoning property on the AIMessage
# TODO
def test_v1_input_with_reasoning_content(self) -> None:
"""Test v1 input format with reasoning content blocks."""
llm = ChatOllama(model=DEFAULT_MODEL_NAME, output_version="v1")
# Send message with reasoning content
reasoning_message = AIMessage(
content=[
{"type": "reasoning", "reasoning": "I need to be helpful and accurate"},
{"type": "text", "text": "I'll do my best to help you."},
]
)
human_message = HumanMessage("Thank you!")
result = llm.invoke([reasoning_message, human_message])
# Should process the input correctly and return v1 format
assert isinstance(result, AIMessage)
assert isinstance(result.content, list)
@pytest.mark.requires("ollama")
class TestChatOllamaV1WithTools:
"""Integration tests for ChatOllama v1 format with tool calling."""
def test_v1_output_with_tool_calls(self) -> None:
"""Test v1 output format with tool calls."""
from langchain_core.tools import tool
@tool
def get_weather(location: str) -> str:
"""Get weather for a location."""
return f"The weather in {location} is sunny."
llm = ChatOllama(model=DEFAULT_MODEL_NAME, output_version="v1")
llm_with_tools = llm.bind_tools([get_weather])
message = HumanMessage("What's the weather in Paris?")
result = llm_with_tools.invoke([message])
# Result should be in v1 format
assert isinstance(result, AIMessage)
assert isinstance(result.content, list)
# If tool calls were made, should have tool_call blocks
if result.tool_calls:
tool_call_blocks = [
block
for block in result.content
if isinstance(block, dict) and block.get("type") == "tool_call"
]
assert len(tool_call_blocks) == len(result.tool_calls)
def test_v1_input_with_tool_call_blocks(self) -> None:
"""Test v1 input format with tool call content blocks."""
llm = ChatOllama(model=DEFAULT_MODEL_NAME, output_version="v1")
# Send message with tool call content
tool_message = AIMessage(
content=[
{"type": "text", "text": "I'll check the weather for you."},
{"type": "tool_call", "id": "call_123"},
],
tool_calls=[
{"name": "get_weather", "args": {"location": "Paris"}, "id": "call_123"}
],
)
human_message = HumanMessage("Thanks!")
result = llm.invoke([tool_message, human_message])
# Should process the input correctly and return v1 format
assert isinstance(result, AIMessage)
assert isinstance(result.content, list)
@pytest.mark.requires("ollama")
class TestV1BackwardsCompatibility:
"""Test backwards compatibility when using v1 format."""
def test_existing_v0_code_still_works(self) -> None:
"""Test that existing v0 code continues to work unchanged."""
# This is the default behavior - should not break existing code
llm = ChatOllama(model=DEFAULT_MODEL_NAME) # defaults to v0
message = HumanMessage("Hello")
result = llm.invoke([message])
# Should return v0 format (string content)
assert isinstance(result, AIMessage)
assert isinstance(result.content, str)
def test_gradual_migration_v0_to_v1(self) -> None:
"""Test gradual migration from v0 to v1 format."""
# Test that the same code works with both formats
for output_version in ["v0", "v1"]:
llm = ChatOllama(model=DEFAULT_MODEL_NAME, output_version=output_version)
message = HumanMessage("Hello")
result = llm.invoke([message])
assert isinstance(result, AIMessage)
if output_version == "v0":
assert isinstance(result.content, str)
else:
assert isinstance(result.content, list)
# Should have at least one content block
assert len(result.content) > 0

View File

@ -0,0 +1,381 @@
"""Test chat model v1 format conversion."""
from langchain_core.messages import AIMessage, AIMessageChunk, BaseMessage, HumanMessage
from langchain_ollama._compat import (
_convert_from_v1_message,
_convert_to_v1_chunk,
_convert_to_v1_message,
_convert_unknown_content_block_to_non_standard,
)
from langchain_ollama.chat_models import (
ChatOllama,
)
from langchain_ollama.chat_models import (
_convert_unknown_content_block_to_non_standard as chat_convert_unknown,
)
class TestV1MessageConversion:
"""Test v1 message format conversion functions."""
def test_convert_from_v1_message_with_text_content(self) -> None:
"""Test converting v1 message with text content to v0 format."""
# Create a v1 message with TextContentBlock
v1_message = AIMessage(content=[{"type": "text", "text": "Hello world"}])
result = _convert_from_v1_message(v1_message)
assert result.content == "Hello world"
assert isinstance(result.content, str)
def test_convert_from_v1_message_with_reasoning_content(self) -> None:
"""Test converting v1 message with reasoning content to v0 format."""
v1_message = AIMessage(
content=[
{ # ReasoningContentBlock
"type": "reasoning",
"reasoning": "I need to think about this",
},
# TextContentBlock
{"type": "text", "text": "Hello world"},
]
)
result = _convert_from_v1_message(v1_message)
assert result.content == "Hello world"
assert (
result.additional_kwargs["reasoning_content"]
== "I need to think about this"
)
def test_convert_from_v1_message_with_tool_call_content(self) -> None:
"""Test converting v1 message with tool call content to v0 format."""
v1_message = AIMessage(
content=[
{"type": "text", "text": "Let me search for that"},
{"type": "tool_call", "id": "tool_123"}, # ToolCallContentBlock
]
)
result = _convert_from_v1_message(v1_message)
assert result.content == "Let me search for that"
# Tool calls should be handled via tool_calls property, not content
assert "tool_call" not in str(result.content)
def test_convert_from_v1_message_with_mixed_content(self) -> None:
"""Test converting v1 message with mixed content types."""
v1_message = AIMessage(
content=[
{"type": "reasoning", "reasoning": "Let me think"},
{"type": "text", "text": "A"},
{"type": "text", "text": "B"},
{"type": "tool_call", "id": "tool_456"},
]
)
result = _convert_from_v1_message(v1_message)
assert result.content == "AB"
assert result.additional_kwargs["reasoning_content"] == "Let me think"
def test_convert_from_v1_message_preserves_v0_format(self) -> None:
"""Test that v0 format messages are preserved unchanged."""
v0_message = AIMessage(content="Hello world")
result = _convert_from_v1_message(v0_message)
assert result == v0_message
assert result.content == "Hello world"
def test_convert_to_v1_message_with_text_content(self) -> None:
"""Test converting v0 message with text content to v1 format."""
v0_message = AIMessage(content="Hello world")
result = _convert_to_v1_message(v0_message)
assert isinstance(result.content, list)
assert len(result.content) == 1
assert result.content[0] == {"type": "text", "text": "Hello world"}
def test_convert_to_v1_message_with_reasoning_content(self) -> None:
"""Test converting v0 message with reasoning to v1 format."""
v0_message = AIMessage(
content="Hello world",
additional_kwargs={"reasoning_content": "I need to be helpful"},
)
result = _convert_to_v1_message(v0_message)
assert isinstance(result.content, list)
assert len(result.content) == 2
expected_reasoning_block = {
"type": "reasoning",
"reasoning": "I need to be helpful",
}
assert result.content[0] == expected_reasoning_block
assert result.content[1] == {"type": "text", "text": "Hello world"}
assert "reasoning_content" not in result.additional_kwargs
def test_convert_to_v1_message_with_tool_calls(self) -> None:
"""Test converting v0 message with tool calls to v1 format."""
v0_message = AIMessage(
content="Let me search for that",
tool_calls=[
{"name": "search", "args": {"query": "test"}, "id": "tool_123"}
],
)
result = _convert_to_v1_message(v0_message)
assert isinstance(result.content, list)
assert len(result.content) == 2
assert result.content[0] == {"type": "text", "text": "Let me search for that"}
assert result.content[1] == {"type": "tool_call", "id": "tool_123"}
def test_convert_to_v1_message_with_empty_content(self) -> None:
"""Test converting v0 message with empty content to v1 format."""
v0_message = AIMessage(content="")
result = _convert_to_v1_message(v0_message)
assert isinstance(result.content, list)
assert len(result.content) == 0
def test_convert_to_v1_chunk(self) -> None:
"""Test converting v0 chunk to v1 format."""
v0_chunk = AIMessageChunk(content="Hello")
result = _convert_to_v1_chunk(v0_chunk)
assert isinstance(result, AIMessageChunk)
assert isinstance(result.content, list)
assert result.content == [{"type": "text", "text": "Hello"}]
class TestNonStandardContentBlockHandling:
"""Test handling of unknown content blocks via NonStandardContentBlock."""
def test_convert_unknown_content_block_to_non_standard(self) -> None:
"""Test conversion of unknown content block to NonStandardContentBlock."""
unknown_block = {
"type": "custom_block_type",
"custom_field": "some_value",
"data": {"nested": "content"},
}
result = _convert_unknown_content_block_to_non_standard(unknown_block)
assert result["type"] == "non_standard"
assert result["value"] == unknown_block
def test_chat_models_convert_unknown_content_block_to_non_standard(self) -> None:
"""Test conversion of unknown content block in chat_models module."""
unknown_block = {
"type": "audio_transcript",
"transcript": "Hello world",
"confidence": 0.95,
}
result = chat_convert_unknown(unknown_block)
assert result["type"] == "non_standard"
assert result["value"] == unknown_block
def test_v1_content_with_unknown_type_creates_non_standard_block(self) -> None:
"""Test v1 content with unknown block type creates NonStandardContentBlock."""
unknown_block = {
"type": "future_block_type",
"future_field": "future_value",
}
v1_message = AIMessage(
content=[
{"type": "text", "text": "Hello"},
unknown_block,
{"type": "text", "text": " world"},
]
)
result = _convert_from_v1_message(v1_message)
# Unknown types should be converted to NonStandardContentBlock
# and skipped in content processing, text content should be preserved
assert result.content == "Hello world"
def test_multiple_unknown_blocks_handled_gracefully(self) -> None:
"""Test multiple unknown content blocks are handled gracefully."""
v1_message = AIMessage(
content=[
{"type": "text", "text": "Start"},
{"type": "unknown_1", "data": "first"},
{"type": "text", "text": " middle"},
{"type": "unknown_2", "data": "second"},
{"type": "text", "text": " end"},
]
)
result = _convert_from_v1_message(v1_message)
# All unknown blocks should be converted and skipped
assert result.content == "Start middle end"
def test_non_standard_content_block_structure(self) -> None:
"""Test that NonStandardContentBlock has correct structure."""
original_block = {
"type": "custom_provider_block",
"provider_specific_field": "value",
"metadata": {"version": "1.0"},
}
non_standard_block = _convert_unknown_content_block_to_non_standard(
original_block
)
# Verify it matches NonStandardContentBlock structure
assert isinstance(non_standard_block, dict)
assert non_standard_block["type"] == "non_standard"
assert "value" in non_standard_block
assert non_standard_block["value"] == original_block
def test_empty_unknown_block_handling(self) -> None:
"""Test handling of empty unknown blocks."""
empty_block = {"type": "empty_block"}
result = _convert_unknown_content_block_to_non_standard(empty_block)
assert result["type"] == "non_standard"
assert result["value"] == empty_block
class TestChatOllamaV1Integration:
"""Test ChatOllama integration with v1 format."""
def test_chat_ollama_default_output_version(self) -> None:
"""Test that ChatOllama defaults to v0 output format."""
llm = ChatOllama(model="test-model")
assert llm.output_version == "v0"
def test_chat_ollama_v1_output_version_setting(self) -> None:
"""Test setting ChatOllama to v1 output format."""
llm = ChatOllama(model="test-model", output_version="v1")
assert llm.output_version == "v1"
def test_convert_messages_handles_v1_input(self) -> None:
"""Test that _convert_messages_to_ollama_messages handles v1 input."""
llm = ChatOllama(model="test-model", output_version="v0")
# Create a v1 format AIMessage
v1_message = AIMessage(
content=[
{"type": "reasoning", "reasoning": "Let me think"},
{"type": "text", "text": "Hello world"},
]
)
messages = [HumanMessage("Hi"), v1_message]
result = llm._convert_messages_to_ollama_messages(messages)
# Should have processed both messages
assert len(result) == 2
# The AI message should have been converted to v0 format for Ollama API
ai_msg = result[1]
assert ai_msg["content"] == "Hello world"
def test_convert_messages_preserves_v0_input(self) -> None:
"""Test that _convert_messages_to_ollama_messages preserves v0 input."""
llm = ChatOllama(model="test-model", output_version="v0")
messages = [HumanMessage("Hi"), AIMessage(content="Hello world")]
result = llm._convert_messages_to_ollama_messages(messages)
# Should have processed both messages normally
assert len(result) == 2
assert result[1]["content"] == "Hello world"
class TestV1BackwardsCompatibility:
"""Test backwards compatibility with v0 format."""
def test_v0_messages_unchanged_with_v0_output(self) -> None:
"""Test that v0 messages are unchanged when output_version=v0."""
llm = ChatOllama(model="test-model", output_version="v0")
# This test would require mocking the actual ollama calls
# TODO complete this test with a mock or fixture
assert llm.output_version == "v0"
def test_v1_input_works_with_v0_output(self) -> None:
"""Test that v1 input messages work even when output_version=v0."""
llm = ChatOllama(model="test-model", output_version="v0")
v1_message = AIMessage(content=[{"type": "text", "text": "Hello"}])
messages: list[BaseMessage] = [v1_message]
result = llm._convert_messages_to_ollama_messages(messages)
# Should handle v1 input regardless of output_version setting
assert len(result) == 1
assert result[0]["content"] == "Hello"
class TestV1EdgeCases:
"""Test edge cases in v1 format handling."""
def test_empty_v1_content_list(self) -> None:
"""Test handling empty v1 content list."""
v1_message = AIMessage(content=[])
result = _convert_from_v1_message(v1_message)
assert result.content == ""
def test_v1_content_with_unknown_type(self) -> None:
"""Test v1 content with unknown block type converted to NonStandard."""
v1_message = AIMessage(
content=[
{"type": "text", "text": "Hello"},
{"type": "unknown_type", "data": "converted_to_non_standard"},
{"type": "text", "text": " world"},
]
)
result = _convert_from_v1_message(v1_message)
# Unknown types should be converted to NonStandardContentBlock and skipped
# in content processing, text content should be preserved
assert result.content == "Hello world"
def test_v1_content_with_malformed_blocks(self) -> None:
"""Test v1 content with malformed blocks."""
v1_message = AIMessage(
content=[
{"type": "text", "text": "Hello"},
{"type": "text"}, # Missing 'text' field
{"type": "reasoning"}, # Missing 'reasoning' field
{"type": "text", "text": " world"},
]
)
result = _convert_from_v1_message(v1_message)
# Should handle malformed blocks gracefully
assert result.content == "Hello world"
def test_non_dict_blocks_ignored(self) -> None:
"""Test that non-dict items in content list are ignored."""
v1_message = AIMessage(
content=[
{"type": "text", "text": "Hello"},
"invalid_block", # Not a dict
{"type": "text", "text": " world"},
]
)
result = _convert_from_v1_message(v1_message)
assert result.content == "Hello world"