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

510 lines
18 KiB
Python

import json
import logging
import time
from collections import defaultdict
from collections.abc import Iterator
from typing import Any
import pydantic
from langchain_core.messages import (
AIMessage,
BaseMessage,
FunctionMessage,
HumanMessage,
SystemMessage,
ToolMessage,
)
from langchain_core.messages import (
ChatMessage as LangChainChatMessage,
)
from langchain_core.outputs import ChatGenerationChunk
from langchain_core.outputs.generation import Generation
from mlflow.environment_variables import MLFLOW_CONVERT_MESSAGES_DICT_FOR_LANGCHAIN
from mlflow.exceptions import MlflowException
from mlflow.tracing.constant import TokenUsageKey
from mlflow.types.chat import (
AudioContentPart,
ChatChoice,
ChatChoiceDelta,
ChatChunkChoice,
ChatCompletionChunk,
ChatCompletionRequest,
ChatCompletionResponse,
ChatMessage,
ChatUsage,
InputAudio,
)
_logger = logging.getLogger(__name__)
_TOKEN_USAGE_KEY_MAPPING = {
# OpenAI
"prompt_tokens": TokenUsageKey.INPUT_TOKENS,
"completion_tokens": TokenUsageKey.OUTPUT_TOKENS,
"total_tokens": TokenUsageKey.TOTAL_TOKENS,
# OpenAI Streaming, Anthropic, etc.
"input_tokens": TokenUsageKey.INPUT_TOKENS,
"output_tokens": TokenUsageKey.OUTPUT_TOKENS,
# Anthropic
"cache_read_input_tokens": TokenUsageKey.CACHE_READ_INPUT_TOKENS,
"cache_creation_input_tokens": TokenUsageKey.CACHE_CREATION_INPUT_TOKENS,
# Gemini
"cached_content_token_count": TokenUsageKey.CACHE_READ_INPUT_TOKENS,
}
# Maps MIME subtypes to the formats InputAudio accepts: Literal["wav", "mp3"].
# Identity mappings (e.g. "wav" -> "wav") are handled by the fallback in _normalize_content.
_MIME_TO_AUDIO_FORMAT: dict[str, str] = {
"x-wav": "wav",
"mpeg": "mp3",
}
def _normalize_content(
content: str | list[dict[str, Any]],
) -> str | list[dict[str, Any]]:
"""
Normalize multi-modal content blocks from LangChain's format to MLflow's expected format.
LangChain uses:
{"type": "audio", "source_type": "base64", "data": "...", "mime_type": "audio/wav"}
while MLflow expects:
{"type": "input_audio", "input_audio": {"data": "...", "format": "wav"}}
This function converts audio blocks to MLflow's format and returns the normalized content
so that it can be validated by :class:`~mlflow.types.chat.ChatMessage`.
"""
if isinstance(content, str):
return content
normalized = []
for block in content:
match block:
case {
"type": "audio",
"source_type": "base64",
"mime_type": str(mime_type),
"data": str(data),
}:
# Extract and normalize format from mime_type (e.g. "audio/wav" -> "wav",
# "audio/mpeg" -> "mp3"). Strip parameters like "; codecs=..."
raw_subtype = mime_type.rsplit("/", 1)[-1].split(";")[0].strip()
audio_format = _MIME_TO_AUDIO_FORMAT.get(raw_subtype, raw_subtype)
try:
audio_part = AudioContentPart(
type="input_audio",
input_audio=InputAudio(
data=data,
format=audio_format,
),
)
except pydantic.ValidationError as e:
raise MlflowException.invalid_parameter_value(
f"Unsupported audio format {audio_format!r} derived from "
f"mime_type {mime_type!r}. Supported formats: 'wav', 'mp3'."
) from e
normalized.append(audio_part.model_dump())
case {"type": "audio"}:
raise MlflowException.invalid_parameter_value(
"Unsupported LangChain audio content. Only base64-encoded audio with a valid "
"mime_type is supported for conversion to MLflow chat messages."
)
case _:
normalized.append(block)
return normalized
def _extract_nested_token_details(d: dict[str, Any]) -> Iterator[tuple[str, int]]:
"""Extract cached token counts from nested detail dicts."""
match d:
case {"input_token_details": {"cache_read": int(tokens)}}:
yield (TokenUsageKey.CACHE_READ_INPUT_TOKENS, tokens)
match d:
case {"input_token_details": {"cache_creation": int(tokens)}}:
yield (TokenUsageKey.CACHE_CREATION_INPUT_TOKENS, tokens)
match d:
case {"prompt_tokens_details": {"cached_tokens": int(tokens)}}:
yield (TokenUsageKey.CACHE_READ_INPUT_TOKENS, tokens)
def convert_lc_message_to_chat_message(lc_message: BaseMessage) -> ChatMessage:
"""
Convert LangChain's message format to the MLflow's standard chat message format.
"""
if isinstance(lc_message, AIMessage):
if tool_calls := _get_tool_calls_from_ai_message(lc_message):
content = lc_message.content
# For Anthropic model tool calls are returned twice so we need to filter them out
if isinstance(content, list):
content = [c for c in content if c["type"] != "tool_use"]
content = _normalize_content(content)
return ChatMessage(
role="assistant",
# If tool calls present, content null value should be None not empty string
# according to the OpenAI spec, which ChatMessage is following
# Ref: https://github.com/langchain-ai/langchain/blob/32917a0b98cb8edcfb8d0e84f0878434e1c3f192/libs/partners/openai/langchain_openai/chat_models/base.py#L116-L117
content=content or None,
tool_calls=tool_calls,
)
else:
return ChatMessage(role="assistant", content=_normalize_content(lc_message.content))
elif isinstance(lc_message, LangChainChatMessage):
return ChatMessage(role=lc_message.role, content=_normalize_content(lc_message.content))
elif isinstance(lc_message, FunctionMessage):
return ChatMessage(role="function", content=_normalize_content(lc_message.content))
elif isinstance(lc_message, ToolMessage):
return ChatMessage(
role="tool",
content=_normalize_content(lc_message.content),
tool_call_id=lc_message.tool_call_id,
)
elif isinstance(lc_message, HumanMessage):
return ChatMessage(role="user", content=_normalize_content(lc_message.content))
elif isinstance(lc_message, SystemMessage):
return ChatMessage(role="system", content=_normalize_content(lc_message.content))
else:
raise MlflowException.invalid_parameter_value(
f"Unexpected message type. Expected a BaseMessage subclass, but got: {type(lc_message)}"
)
def _chat_model_to_langchain_message(message: ChatMessage) -> BaseMessage:
"""
Convert the MLflow's standard chat message format to LangChain's message format.
"""
if message.role == "system":
return SystemMessage(content=message.content)
elif message.role == "assistant":
return AIMessage(content=message.content)
elif message.role == "user":
return HumanMessage(content=message.content)
elif message.role == "tool":
return ToolMessage(content=message.content, tool_call_id=message.tool_call_id)
elif message.role == "function":
return FunctionMessage(content=message.content)
else:
raise MlflowException.invalid_parameter_value(
f"Unrecognized chat message role: {message.role}"
)
def _get_tool_calls_from_ai_message(message: AIMessage) -> list[dict[str, Any]]:
# Extract tool calls from AIMessage
tool_calls = [
{
"type": "function",
"id": tc["id"],
"function": {
"name": tc["name"],
"arguments": json.dumps(tc["args"]),
},
}
for tc in message.tool_calls
]
invalid_tool_calls = [
{
"type": "function",
"id": tc["id"],
"function": {
"name": tc["name"],
"arguments": tc["args"],
},
}
for tc in message.invalid_tool_calls
]
if tool_calls or invalid_tool_calls:
return tool_calls + invalid_tool_calls
# Get tool calls from additional kwargs if present.
return [
{
k: v
for k, v in tool_call.items() # type: ignore[union-attr]
if k in {"id", "type", "function"}
}
for tool_call in message.additional_kwargs.get("tool_calls", [])
]
def try_transform_response_to_chat_format(response: Any) -> dict[str, Any]:
"""
Try to convert the response to the standard chat format and return its dict representation.
If the response is not one of the supported types, return the response as-is.
"""
if isinstance(response, (str, AIMessage)):
if isinstance(response, str):
message_id = None
message = ChatMessage(role="assistant", content=response)
else:
message_id = getattr(response, "id", None)
message = convert_lc_message_to_chat_message(response)
transformed_response = ChatCompletionResponse(
id=message_id,
created=int(time.time()),
model="",
object="chat.completion",
choices=[
ChatChoice(
index=0,
message=message,
finish_reason=None,
)
],
usage=ChatUsage(
prompt_tokens=None,
completion_tokens=None,
total_tokens=None,
),
)
return transformed_response.model_dump(mode="json", exclude_unset=True)
else:
return response
def try_transform_response_iter_to_chat_format(chunk_iter):
from langchain_core.messages.ai import AIMessageChunk
def _gen_converted_chunk(message_content, message_id, finish_reason):
transformed_response = ChatCompletionChunk(
id=message_id,
object="chat.completion.chunk",
created=int(time.time()),
model="",
choices=[
ChatChunkChoice(
index=0,
delta=ChatChoiceDelta(
role="assistant",
content=message_content,
),
finish_reason=finish_reason,
)
],
)
return transformed_response.model_dump(mode="json", exclude_unset=True)
def _convert(chunk):
if isinstance(chunk, str):
message_content = chunk
message_id = None
finish_reason = None
elif isinstance(chunk, AIMessageChunk):
message_content = chunk.content
message_id = getattr(chunk, "id", None)
if response_metadata := getattr(chunk, "response_metadata", None):
finish_reason = response_metadata.get("finish_reason")
else:
finish_reason = None
elif isinstance(chunk, AIMessage):
# The langchain chat model does not support stream
# so `model.stream` returns the whole result.
message_content = chunk.content
message_id = getattr(chunk, "id", None)
finish_reason = "stop"
else:
return chunk
return _gen_converted_chunk(
message_content,
message_id=message_id,
finish_reason=finish_reason,
)
return map(_convert, chunk_iter)
def _convert_chat_request_or_throw(
chat_request: dict[str, Any],
) -> list[BaseMessage]:
model = ChatCompletionRequest.model_validate(chat_request)
return [_chat_model_to_langchain_message(message) for message in model.messages]
def _convert_chat_request(chat_request: dict[str, Any] | list[dict[str, Any]]):
if isinstance(chat_request, list):
return [_convert_chat_request_or_throw(request) for request in chat_request]
else:
return _convert_chat_request_or_throw(chat_request)
def _get_lc_model_input_fields(lc_model) -> set[str]:
try:
if hasattr(lc_model, "input_schema"):
return set(lc_model.input_schema.model_fields)
except Exception as e:
_logger.debug(
f"Unexpected exception while checking LangChain input schema for"
f" request transformation: {e}"
)
return set()
def _should_transform_request_json_for_chat(lc_model):
# Don't convert the request to LangChain's Message format for LangGraph models.
# Inputs may have key like "messages", but they are graph state fields, not OAI chat format.
try:
from langgraph.graph.state import CompiledStateGraph
if isinstance(lc_model, CompiledStateGraph):
return False
except ImportError:
pass
# Avoid converting the request to LangChain's Message format if the chain
# is an AgentExecutor, as LangChainChatMessage might not be accepted by the chain
from mlflow.langchain._compat import try_import_agent_executor
AgentExecutor = try_import_agent_executor()
if AgentExecutor and isinstance(lc_model, AgentExecutor):
return False
input_fields = _get_lc_model_input_fields(lc_model)
if "messages" in input_fields:
# If the chain accepts a "messages" field directly, don't attempt to convert
# the request to LangChain's Message format automatically. Assume that the chain
# is handling the "messages" field by itself
return False
return True
def transform_request_json_for_chat_if_necessary(request_json, lc_model):
"""
Convert the input request JSON to LangChain's Message format if the LangChain model
accepts ChatMessage objects (e.g. AIMessage, HumanMessage, SystemMessage) as input.
Args:
request_json: The input request JSON.
lc_model: The LangChain model.
Returns:
A 2-element tuple containing:
1. The new request.
2. A boolean indicating whether or not the request was transformed from the OpenAI
chat format.
"""
def json_dict_might_be_chat_request(json_message):
return (
isinstance(json_message, dict)
and "messages" in json_message
and
# Additional keys can't be specified when calling LangChain invoke() / batch()
# with chat messages
len(json_message) == 1
# messages field should be a list
and isinstance(json_message["messages"], list)
)
def is_list_of_chat_messages(json_message: list[dict[str, Any]]):
return isinstance(json_message, list) and all(
json_dict_might_be_chat_request(message) for message in json_message
)
should_convert = MLFLOW_CONVERT_MESSAGES_DICT_FOR_LANGCHAIN.get()
if should_convert is None:
should_convert = _should_transform_request_json_for_chat(lc_model) and (
json_dict_might_be_chat_request(request_json) or is_list_of_chat_messages(request_json)
)
if should_convert:
_logger.debug(
"Converting the request JSON to LangChain's Message format. "
"To disable this conversion, set the environment variable "
f"`{MLFLOW_CONVERT_MESSAGES_DICT_FOR_LANGCHAIN}` to 'false'."
)
if should_convert:
try:
return _convert_chat_request(request_json), True
except pydantic.ValidationError:
_logger.debug(
"Failed to convert the request JSON to LangChain's Message format. "
"The request will be passed to the LangChain model as-is. ",
exc_info=True,
)
return request_json, False
else:
return request_json, False
def parse_token_usage(
lc_generations: list[Generation],
) -> dict[str, int] | None:
"""Parse the token usage from the LangChain generations."""
# Check if this is streaming (contains ChatGenerationChunk)
is_streaming = any(isinstance(gen, ChatGenerationChunk) for gen in lc_generations)
if is_streaming:
# Streaming mode: collect all generations with usage, use only the last one
# (which contains the final cumulative token counts)
generations_with_usage = [
token_usage
for generation in lc_generations
if (token_usage := _parse_token_usage_from_generation(generation))
]
if generations_with_usage:
return generations_with_usage[-1]
return None
# Non-streaming mode: existing behavior (sum all generations)
aggregated = defaultdict(int)
for generation in lc_generations:
if token_usage := _parse_token_usage_from_generation(generation):
for key in token_usage:
aggregated[key] += token_usage[key]
return dict(aggregated) if aggregated else None
def _parse_token_usage_from_generation(
generation: Generation,
) -> dict[str, int] | None:
message = getattr(generation, "message", None)
if not message:
return None
metadata = (
message.usage_metadata
or message.response_metadata.get("usage")
or message.response_metadata.get("token_usage")
)
return _parse_token_counts(metadata) if metadata else None
def _parse_token_counts(usage_metadata: dict[str, Any]) -> dict[str, int]:
"""Standardize token usage metadata keys to MLflow's token usage keys."""
usage = {}
for key, value in usage_metadata.items():
if usage_key := _TOKEN_USAGE_KEY_MAPPING.get(key):
usage[usage_key] = value
# Extract from nested detail dicts (e.g. input_token_details.cache_read).
# Uses setdefault so flat keys above take priority.
for usage_key, value in _extract_nested_token_details(usage_metadata):
usage.setdefault(usage_key, value)
# If the total tokens are not present, calculate it from the input and output tokens
if usage and usage.get(TokenUsageKey.TOTAL_TOKENS) is None:
usage[TokenUsageKey.TOTAL_TOKENS] = usage.get(TokenUsageKey.INPUT_TOKENS, 0) + usage.get(
TokenUsageKey.OUTPUT_TOKENS, 0
)
return usage