Files
2026-07-13 13:22:34 +08:00

531 lines
18 KiB
Python

from unittest.mock import MagicMock, patch
import pytest
from langchain_core.language_models.chat_models import SimpleChatModel
from langchain_core.messages import (
AIMessage,
AIMessageChunk,
HumanMessage,
SystemMessage,
ToolMessage,
)
from langchain_core.outputs import ChatGenerationChunk
from langchain_core.outputs.chat_generation import ChatGeneration
from langchain_core.outputs.generation import Generation
from mlflow.exceptions import MlflowException
from mlflow.langchain.utils.chat import (
convert_lc_message_to_chat_message,
parse_token_usage,
transform_request_json_for_chat_if_necessary,
try_transform_response_iter_to_chat_format,
try_transform_response_to_chat_format,
)
from mlflow.types.chat import ChatMessage, Function
from mlflow.types.chat import ToolCall as _ToolCall
@pytest.mark.parametrize(
("message", "expected"),
[
(
AIMessage(content="foo", id="123"),
ChatMessage(role="assistant", content="foo", id="123"),
),
(
ToolMessage(content="foo", tool_call_id="123"),
ChatMessage(role="tool", content="foo", tool_call_id="123"),
),
(
SystemMessage(content="foo"),
ChatMessage(role="system", content="foo"),
),
(
HumanMessage(content="foo"),
ChatMessage(role="user", content="foo"),
),
],
)
def test_convert_lc_message_to_chat_message(message, expected):
assert convert_lc_message_to_chat_message(message) == expected
@pytest.mark.parametrize(
("message", "expected"),
[
(
AIMessage(
content=[
{"type": "text", "text": "Response text"},
{"type": "tool_use", "id": "123", "name": "tool"},
],
tool_calls=[{"id": "123", "name": "tool", "args": {}, "type": "tool_call"}],
),
ChatMessage(
role="assistant",
content=[{"type": "text", "text": "Response text"}],
tool_calls=[
_ToolCall(
id="123",
type="function",
function=Function(name="tool", arguments="{}"),
)
],
),
),
(
AIMessage(
content="",
tool_calls=[{"id": "123", "name": "tool_name", "args": {"arg1": "val1"}}],
),
ChatMessage(
role="assistant",
content=None,
tool_calls=[
_ToolCall(
id="123",
type="function",
function=Function(name="tool_name", arguments='{"arg1": "val1"}'),
)
],
),
),
],
)
def test_convert_lc_message_to_chat_message_tool_calls(message, expected):
assert convert_lc_message_to_chat_message(message) == expected
def test_convert_lc_message_to_chat_message_audio_content():
message = HumanMessage(
content=[
{"type": "text", "text": "What is this audio?"},
{
"type": "audio",
"source_type": "base64",
"data": "SGVsbG8=",
"mime_type": "audio/wav",
},
]
)
result = convert_lc_message_to_chat_message(message)
assert result.role == "user"
assert len(result.content) == 2
assert result.content[0].type == "text"
assert result.content[0].text == "What is this audio?"
assert result.content[1].type == "input_audio"
assert result.content[1].input_audio.data == "SGVsbG8="
assert result.content[1].input_audio.format == "wav"
def test_convert_lc_message_to_chat_message_audio_mp3():
message = HumanMessage(
content=[
{
"type": "audio",
"source_type": "base64",
"data": "AAAA",
"mime_type": "audio/mp3",
},
]
)
result = convert_lc_message_to_chat_message(message)
assert result.content[0].type == "input_audio"
assert result.content[0].input_audio.data == "AAAA"
assert result.content[0].input_audio.format == "mp3"
def test_convert_lc_message_to_chat_message_audio_mpeg():
message = HumanMessage(
content=[
{
"type": "audio",
"source_type": "base64",
"data": "AAAA",
"mime_type": "audio/mpeg",
},
]
)
result = convert_lc_message_to_chat_message(message)
assert result.content[0].type == "input_audio"
assert result.content[0].input_audio.data == "AAAA"
assert result.content[0].input_audio.format == "mp3"
def test_convert_lc_message_to_chat_message_string_content_unchanged():
message = HumanMessage(content="just text")
result = convert_lc_message_to_chat_message(message)
assert result.content == "just text"
def test_convert_lc_message_audio_url_source_raises():
message = HumanMessage(
content=[
{
"type": "audio",
"source_type": "url",
"url": "https://example.com/audio.wav",
"mime_type": "audio/wav",
},
]
)
with pytest.raises(MlflowException, match="Only base64-encoded audio"):
convert_lc_message_to_chat_message(message)
def test_convert_lc_message_audio_no_mime_type_raises():
message = HumanMessage(
content=[
{
"type": "audio",
"source_type": "base64",
"data": "SGVsbG8=",
},
]
)
with pytest.raises(MlflowException, match="Only base64-encoded audio"):
convert_lc_message_to_chat_message(message)
def test_convert_lc_message_audio_unsupported_format_raises():
message = HumanMessage(
content=[
{
"type": "audio",
"source_type": "base64",
"data": "SGVsbG8=",
"mime_type": "audio/ogg",
},
]
)
with pytest.raises(MlflowException, match="Unsupported audio format"):
convert_lc_message_to_chat_message(message)
def test_transform_response_to_chat_format_no_conversion():
response = ["list_response"]
assert try_transform_response_to_chat_format(response) == response
response = {"dict_response": "response"}
assert try_transform_response_to_chat_format(response) == response
def test_transform_response_to_chat_format_conversion():
response = "string_response"
converted_response = try_transform_response_to_chat_format(response)
assert isinstance(converted_response, dict)
assert converted_response["id"] is None
assert converted_response["choices"][0]["message"]["content"] == response
response = AIMessage(content="ai_message_response")
converted_response = try_transform_response_to_chat_format(response)
assert isinstance(converted_response, dict)
assert converted_response["id"] == getattr(response, "id", None)
assert converted_response["choices"][0]["message"]["content"] == response.content
def test_transform_response_iter_to_chat_format_no_conversion():
response = [{"dict_response": "response"}]
converted_response = list(try_transform_response_iter_to_chat_format(response))
assert len(converted_response) == 1
assert converted_response[0] == response[0]
def test_transform_response_iter_to_chat_format_ai_message():
response = ["string response"]
converted_response = list(try_transform_response_iter_to_chat_format(response))
assert len(converted_response) == 1
assert converted_response[0]["id"] is None
assert converted_response[0]["choices"][0]["delta"]["content"] == response[0]
response = [
AIMessage(
content="ai_message_response", id="123", response_metadata={"finish_reason": "done"}
)
]
converted_response = list(try_transform_response_iter_to_chat_format(response))
assert len(converted_response) == 1
assert converted_response[0]["id"] == getattr(response[0], "id", None)
assert converted_response[0]["choices"][0]["delta"]["content"] == response[0].content
assert converted_response[0]["choices"][0]["finish_reason"] == "stop"
response = [
AIMessageChunk(
content="ai_message_chunk_response",
id="123",
response_metadata={"finish_reason": "done"},
),
AIMessageChunk(
content="ai_message_chunk_response",
id="456",
response_metadata={"finish_reason": "stop"},
),
]
converted_response = list(try_transform_response_iter_to_chat_format(response))
assert len(converted_response) == 2
for i in range(2):
assert converted_response[i]["id"] == getattr(response[i], "id", None)
assert converted_response[i]["choices"][0]["delta"]["content"] == response[i].content
assert (
converted_response[i]["choices"][0]["finish_reason"]
== response[i].response_metadata["finish_reason"]
)
def test_transform_request_json_for_chat_if_necessary_conversion():
model = MagicMock(spec=SimpleChatModel)
request_json = {"messages": [{"role": "user", "content": "some_input"}]}
with patch("mlflow.langchain.utils.chat._get_lc_model_input_fields", return_value={"messages"}):
transformed_request = transform_request_json_for_chat_if_necessary(request_json, model)
assert transformed_request == (request_json, False)
with patch(
"mlflow.langchain.utils.chat._get_lc_model_input_fields",
return_value={},
):
transformed_request = transform_request_json_for_chat_if_necessary(request_json, model)
assert transformed_request[0][0] == HumanMessage(content="some_input")
assert transformed_request[1] is True
request_json = [
{"messages": [{"role": "system", "content": "You are a helpful assistant."}]},
{"messages": [{"role": "assistant", "content": "What would you like to ask?"}]},
{"messages": [{"role": "user", "content": "Who owns MLflow?"}]},
]
with patch(
"mlflow.langchain.utils.chat._get_lc_model_input_fields",
return_value={},
):
transformed_request = transform_request_json_for_chat_if_necessary(request_json, model)
assert transformed_request[0][0][0] == SystemMessage(content="You are a helpful assistant.")
assert transformed_request[0][1][0] == AIMessage(content="What would you like to ask?")
assert transformed_request[0][2][0] == HumanMessage(content="Who owns MLflow?")
assert transformed_request[1] is True
@pytest.mark.parametrize(
("generation", "expected"),
[
(ChatGeneration(message=AIMessage(content="foo", id="123")), None),
(
ChatGeneration(
message=AIMessage(
content="foo",
id="123",
usage_metadata={"input_tokens": 5, "output_tokens": 10, "total_tokens": 15},
)
),
{"input_tokens": 5, "output_tokens": 10, "total_tokens": 15},
),
(
ChatGeneration(
message=AIMessageChunk(
content="foo",
id="123",
usage_metadata={"input_tokens": 5, "output_tokens": 10, "total_tokens": 15},
)
),
{"input_tokens": 5, "output_tokens": 10, "total_tokens": 15},
),
(
ChatGeneration(
message=AIMessage(
content="foo",
id="123",
response_metadata={
"usage": {"prompt_tokens": 5, "completion_tokens": 10, "total_tokens": 15}
},
)
),
{"input_tokens": 5, "output_tokens": 10, "total_tokens": 15},
),
# OpenAI usage_metadata with input_token_details (LangChain standardized format)
(
ChatGeneration(
message=AIMessage(
content="foo",
id="123",
usage_metadata={
"input_tokens": 50,
"output_tokens": 20,
"total_tokens": 70,
"input_token_details": {"cache_read": 30, "cache_creation": 0},
},
)
),
{
"input_tokens": 50,
"output_tokens": 20,
"total_tokens": 70,
"cache_read_input_tokens": 30,
"cache_creation_input_tokens": 0,
},
),
# OpenAI usage_metadata with both cache_read and cache_creation
(
ChatGeneration(
message=AIMessage(
content="foo",
id="123",
usage_metadata={
"input_tokens": 100,
"output_tokens": 50,
"total_tokens": 150,
"input_token_details": {"cache_read": 25, "cache_creation": 15},
},
)
),
{
"input_tokens": 100,
"output_tokens": 50,
"total_tokens": 150,
"cache_read_input_tokens": 25,
"cache_creation_input_tokens": 15,
},
),
# Raw OpenAI response_metadata with prompt_tokens_details
(
ChatGeneration(
message=AIMessage(
content="foo",
id="123",
response_metadata={
"token_usage": {
"prompt_tokens": 50,
"completion_tokens": 20,
"total_tokens": 70,
"prompt_tokens_details": {"cached_tokens": 30},
}
},
)
),
{
"input_tokens": 50,
"output_tokens": 20,
"total_tokens": 70,
"cache_read_input_tokens": 30,
},
),
# Gemini usage_metadata with cached_content_token_count
(
ChatGeneration(
message=AIMessage(
content="foo",
id="123",
usage_metadata={
"input_tokens": 50,
"output_tokens": 20,
"total_tokens": 70,
"cached_content_token_count": 30,
},
)
),
{
"input_tokens": 50,
"output_tokens": 20,
"total_tokens": 70,
"cache_read_input_tokens": 30,
},
),
# Legacy completion generation object
(Generation(text="foo"), None),
],
)
def test_parse_token_usage(generation, expected):
assert parse_token_usage([generation]) == expected
def test_parse_token_usage_streaming_chunks():
"""
Test that streaming chunks with cumulative token usage are handled correctly.
In streaming mode, each ChatGenerationChunk contains:
- Same input_tokens (repeated for each chunk)
- Cumulative output_tokens (increasing with each chunk)
Expected behavior: Use only the last chunk's usage (final cumulative values)
"""
# Simulate 3 streaming chunks with same input_tokens but cumulative output_tokens
# This matches the pattern observed in real streaming scenarios
chunks = [
ChatGenerationChunk(
message=AIMessageChunk(
content="Agreement",
usage_metadata={
"input_tokens": 16049,
"output_tokens": 2,
"total_tokens": 16051,
},
)
),
ChatGenerationChunk(
message=AIMessageChunk(
content=" ",
usage_metadata={
"input_tokens": 16049,
"output_tokens": 58,
"total_tokens": 16107,
},
)
),
ChatGenerationChunk(
message=AIMessageChunk(
content="",
usage_metadata={
"input_tokens": 16049,
"output_tokens": 115,
"total_tokens": 16164,
},
)
),
]
result = parse_token_usage(chunks)
# Should use only the last chunk's usage (final cumulative values)
assert result is not None
assert result["input_tokens"] == 16049
assert result["output_tokens"] == 115
assert result["total_tokens"] == 16164
def test_parse_token_usage_non_streaming_multiple_calls():
"""
Test that non-streaming multiple calls still sum correctly (existing behavior).
When multiple ChatGeneration objects are present (non-streaming), they represent
separate LLM calls and should be summed.
"""
# Simulate 2 separate non-streaming calls with different token usage
generations = [
ChatGeneration(
message=AIMessage(
content="Response 1",
usage_metadata={
"input_tokens": 10,
"output_tokens": 20,
"total_tokens": 30,
},
)
),
ChatGeneration(
message=AIMessage(
content="Response 2",
usage_metadata={
"input_tokens": 15,
"output_tokens": 25,
"total_tokens": 40,
},
)
),
]
result = parse_token_usage(generations)
# Should sum all generations (existing non-streaming behavior)
assert result is not None
assert result["input_tokens"] == 25 # 10 + 15
assert result["output_tokens"] == 45 # 20 + 25
assert result["total_tokens"] == 70 # 30 + 40