mirror of
https://github.com/hwchase17/langchain.git
synced 2025-09-17 15:35:14 +00:00
refactor: enhance OpenAI data block handling and normalize message formats
This commit is contained in:
@@ -1,12 +1,26 @@
|
|||||||
import re
|
import re
|
||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
from typing import Optional
|
from typing import TYPE_CHECKING, Literal, Optional, TypedDict
|
||||||
|
|
||||||
from langchain_core.messages import BaseMessage
|
if TYPE_CHECKING:
|
||||||
|
from langchain_core.messages import BaseMessage
|
||||||
|
from langchain_core.messages.content_blocks import (
|
||||||
|
KNOWN_BLOCK_TYPES,
|
||||||
|
ContentBlock,
|
||||||
|
create_audio_block,
|
||||||
|
create_file_block,
|
||||||
|
create_image_block,
|
||||||
|
create_non_standard_block,
|
||||||
|
create_plaintext_block,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _is_openai_data_block(block: dict) -> bool:
|
def _is_openai_data_block(block: dict) -> bool:
|
||||||
"""Check if the block contains multimodal data in OpenAI Chat Completions format."""
|
"""Check if the block contains multimodal data in OpenAI Chat Completions format.
|
||||||
|
|
||||||
|
Supports both data and ID-style blocks (e.g. ``'file_data'`` and ``'file_id'``).
|
||||||
|
|
||||||
|
"""
|
||||||
if block.get("type") == "image_url":
|
if block.get("type") == "image_url":
|
||||||
if (
|
if (
|
||||||
(set(block.keys()) <= {"type", "image_url", "detail"})
|
(set(block.keys()) <= {"type", "image_url", "detail"})
|
||||||
@@ -15,29 +29,42 @@ def _is_openai_data_block(block: dict) -> bool:
|
|||||||
):
|
):
|
||||||
url = image_url.get("url")
|
url = image_url.get("url")
|
||||||
if isinstance(url, str):
|
if isinstance(url, str):
|
||||||
|
# Required per OpenAI spec
|
||||||
return True
|
return True
|
||||||
|
# Ignore `'detail'` since it's optional and specific to OpenAI
|
||||||
|
|
||||||
elif block.get("type") == "file":
|
elif block.get("type") == "file":
|
||||||
if (file := block.get("file")) and isinstance(file, dict):
|
if (file := block.get("file")) and isinstance(file, dict):
|
||||||
file_data = file.get("file_data")
|
file_data = file.get("file_data")
|
||||||
if isinstance(file_data, str):
|
file_id = file.get("file_id")
|
||||||
|
if isinstance(file_data, str) or isinstance(file_id, str):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
elif block.get("type") == "input_audio":
|
elif block.get("type") == "input_audio":
|
||||||
if (input_audio := block.get("input_audio")) and isinstance(input_audio, dict):
|
if (audio := block.get("audio")) and isinstance(audio, dict):
|
||||||
audio_data = input_audio.get("data")
|
audio_data = audio.get("data")
|
||||||
audio_format = input_audio.get("format")
|
audio_format = audio.get("format")
|
||||||
if isinstance(audio_data, str) and isinstance(audio_format, str):
|
if isinstance(audio_data, str) and isinstance(audio_format, str):
|
||||||
|
# Both required per OpenAI spec
|
||||||
return True
|
return True
|
||||||
|
|
||||||
else:
|
else:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
# Has no `'type'` key
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _parse_data_uri(uri: str) -> Optional[dict]:
|
class ParsedDataUri(TypedDict):
|
||||||
"""Parse a data URI into its components. If parsing fails, return None.
|
source_type: Literal["base64"]
|
||||||
|
data: str
|
||||||
|
mime_type: str
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_data_uri(uri: str) -> Optional[ParsedDataUri]:
|
||||||
|
"""Parse a data URI into its components.
|
||||||
|
|
||||||
|
If parsing fails, return None. If either MIME type or data is missing, return None.
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
|
||||||
@@ -57,84 +84,350 @@ def _parse_data_uri(uri: str) -> Optional[dict]:
|
|||||||
match = re.match(regex, uri)
|
match = re.match(regex, uri)
|
||||||
if match is None:
|
if match is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
mime_type = match.group("mime_type")
|
||||||
|
data = match.group("data")
|
||||||
|
if not mime_type or not data:
|
||||||
|
return None
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"source_type": "base64",
|
"source_type": "base64",
|
||||||
"data": match.group("data"),
|
"data": data,
|
||||||
"mime_type": match.group("mime_type"),
|
"mime_type": mime_type,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _convert_openai_format_to_data_block(block: dict) -> dict:
|
def _convert_openai_format_to_data_block(block: dict) -> ContentBlock:
|
||||||
"""Convert OpenAI image content block to standard data content block.
|
"""Convert OpenAI image/audio/file content block to v1 standard content block.
|
||||||
|
|
||||||
If parsing fails, pass-through.
|
If parsing fails, pass-through.
|
||||||
|
|
||||||
Args:
|
|
||||||
block: The OpenAI image content block to convert.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The converted standard data content block.
|
|
||||||
"""
|
"""
|
||||||
if block["type"] == "image_url":
|
if block.get("type") == "file" and "file_id" in block.get("file", {}):
|
||||||
parsed = _parse_data_uri(block["image_url"]["url"])
|
return create_file_block(
|
||||||
if parsed is not None:
|
file_id=block["file"]["file_id"],
|
||||||
parsed["type"] = "image"
|
)
|
||||||
return parsed
|
|
||||||
return block
|
|
||||||
|
|
||||||
if block["type"] == "file":
|
|
||||||
parsed = _parse_data_uri(block["file"]["file_data"])
|
|
||||||
if parsed is not None:
|
|
||||||
parsed["type"] = "file"
|
|
||||||
if filename := block["file"].get("filename"):
|
|
||||||
parsed["filename"] = filename
|
|
||||||
return parsed
|
|
||||||
return block
|
|
||||||
|
|
||||||
if block["type"] == "input_audio":
|
if block["type"] == "input_audio":
|
||||||
data = block["input_audio"].get("data")
|
return create_audio_block(
|
||||||
audio_format = block["input_audio"].get("format")
|
base64=block["audio"]["data"],
|
||||||
if data and audio_format:
|
mime_type=f"audio/{block['audio']['format']}",
|
||||||
return {
|
)
|
||||||
"type": "audio",
|
|
||||||
"source_type": "base64",
|
if (block["type"] == "file") and (
|
||||||
"data": data,
|
parsed := _parse_data_uri(block["file"]["file_data"])
|
||||||
"mime_type": f"audio/{audio_format}",
|
):
|
||||||
|
mime_type = parsed["mime_type"]
|
||||||
|
filename = block["file"].get("filename")
|
||||||
|
return create_file_block(
|
||||||
|
base64=block["file"]["file_data"],
|
||||||
|
mime_type=mime_type,
|
||||||
|
filename=filename,
|
||||||
|
)
|
||||||
|
|
||||||
|
# base64-style image block
|
||||||
|
if (block["type"] == "image_url") and (
|
||||||
|
parsed := _parse_data_uri(block["image_url"]["url"])
|
||||||
|
):
|
||||||
|
return create_image_block(
|
||||||
|
base64=block["image_url"]["url"],
|
||||||
|
mime_type=parsed["mime_type"],
|
||||||
|
detail=block["image_url"].get("detail"), # Optional, specific to OpenAI
|
||||||
|
)
|
||||||
|
# url-style image block
|
||||||
|
if (block["type"] == "image_url") and isinstance(
|
||||||
|
block["image_url"].get("url"), str
|
||||||
|
):
|
||||||
|
return create_image_block(
|
||||||
|
url=block["image_url"]["url"],
|
||||||
|
detail=block["image_url"].get("detail"), # Optional, specific to OpenAI
|
||||||
|
)
|
||||||
|
|
||||||
|
# Escape hatch for non-standard content blocks
|
||||||
|
return create_non_standard_block(
|
||||||
|
value=block,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_messages(messages: Sequence["BaseMessage"]) -> list["BaseMessage"]:
|
||||||
|
"""Normalize different message formats to LangChain v1 standard content blocks.
|
||||||
|
|
||||||
|
Chat models implement support for:
|
||||||
|
- Images in OpenAI Chat Completions format
|
||||||
|
- LangChain v1 standard content blocks
|
||||||
|
|
||||||
|
This function extends support to:
|
||||||
|
- `Audio <https://platform.openai.com/docs/api-reference/chat/create>`__ and
|
||||||
|
`file <https://platform.openai.com/docs/api-reference/files>`__ data in OpenAI
|
||||||
|
Chat Completions format
|
||||||
|
- Images are technically supported but we expect chat models to handle them
|
||||||
|
directly; this may change in the future
|
||||||
|
- LangChain v0 standard content blocks for backward compatibility
|
||||||
|
|
||||||
|
.. versionchanged:: 1.0.0
|
||||||
|
In previous versions, this function returned messages in LangChain v0 format.
|
||||||
|
Now, it returns messages in LangChain v1 format, which upgraded chat models now
|
||||||
|
expect to receive when passing back in message history. For backward
|
||||||
|
compatibility, we now allow converting v0 message content to v1 format.
|
||||||
|
|
||||||
|
.. dropdown:: v0 Content Blocks
|
||||||
|
|
||||||
|
``URLContentBlock``:
|
||||||
|
|
||||||
|
.. codeblock::
|
||||||
|
|
||||||
|
{
|
||||||
|
mime_type: NotRequired[str]
|
||||||
|
type: Literal['image', 'audio', 'file'],
|
||||||
|
source_type: Literal['url'],
|
||||||
|
url: str,
|
||||||
}
|
}
|
||||||
return block
|
|
||||||
|
|
||||||
return block
|
``Base64ContentBlock``:
|
||||||
|
|
||||||
|
.. codeblock::
|
||||||
|
|
||||||
def _normalize_messages(messages: Sequence[BaseMessage]) -> list[BaseMessage]:
|
{
|
||||||
"""Extend support for message formats.
|
mime_type: NotRequired[str]
|
||||||
|
type: Literal['image', 'audio', 'file'],
|
||||||
|
source_type: Literal['base64'],
|
||||||
|
data: str,
|
||||||
|
}
|
||||||
|
|
||||||
|
``IDContentBlock``:
|
||||||
|
|
||||||
|
.. codeblock::
|
||||||
|
|
||||||
|
{
|
||||||
|
type: Literal['image', 'audio', 'file'],
|
||||||
|
source_type: Literal['id'],
|
||||||
|
id: str,
|
||||||
|
}
|
||||||
|
|
||||||
|
``PlainTextContentBlock``:
|
||||||
|
|
||||||
|
.. codeblock::
|
||||||
|
|
||||||
|
{
|
||||||
|
mime_type: NotRequired[str]
|
||||||
|
type: Literal['file'],
|
||||||
|
source_type: Literal['text'],
|
||||||
|
url: str,
|
||||||
|
}
|
||||||
|
|
||||||
|
(Untested): if a v1 message is passed in, it will be returned as-is, meaning it is
|
||||||
|
safe to always pass in v1 messages to this function for assurance.
|
||||||
|
|
||||||
Chat models implement support for images in OpenAI Chat Completions format, as well
|
|
||||||
as other multimodal data as standard data blocks. This function extends support to
|
|
||||||
audio and file data in OpenAI Chat Completions format by converting them to standard
|
|
||||||
data blocks.
|
|
||||||
"""
|
"""
|
||||||
|
# For posterity, here are the OpenAI Chat Completions schemas we expect:
|
||||||
|
#
|
||||||
|
# Chat Completions image. Can be URL-based or base64-encoded. Supports MIME types
|
||||||
|
# png, jpeg/jpg, webp, static gif:
|
||||||
|
# {
|
||||||
|
# "type": Literal['image_url'],
|
||||||
|
# "image_url": {
|
||||||
|
# "url": Union["data:$MIME_TYPE;base64,$BASE64_ENCODED_IMAGE", "$IMAGE_URL"], # noqa: E501
|
||||||
|
# "detail": Literal['low', 'high', 'auto'] = 'auto', # Only supported by OpenAI # noqa: E501
|
||||||
|
# }
|
||||||
|
# }
|
||||||
|
|
||||||
|
# Chat Completions audio:
|
||||||
|
# {
|
||||||
|
# "type": Literal['input_audio'],
|
||||||
|
# "audio": {
|
||||||
|
# "format": Literal['wav', 'mp3'],
|
||||||
|
# "data": str = "$BASE64_ENCODED_AUDIO",
|
||||||
|
# },
|
||||||
|
# }
|
||||||
|
|
||||||
|
# Chat Completions files: either base64 or pre-uploaded file ID
|
||||||
|
# {
|
||||||
|
# "type": Literal['file'],
|
||||||
|
# "file": Union[
|
||||||
|
# {
|
||||||
|
# "filename": Optional[str] = "$FILENAME",
|
||||||
|
# "file_data": str = "$BASE64_ENCODED_FILE",
|
||||||
|
# },
|
||||||
|
# {
|
||||||
|
# "file_id": str = "$FILE_ID", # For pre-uploaded files to OpenAI
|
||||||
|
# },
|
||||||
|
# ],
|
||||||
|
# }
|
||||||
|
|
||||||
formatted_messages = []
|
formatted_messages = []
|
||||||
for message in messages:
|
for message in messages:
|
||||||
|
# We preserve input messages - the caller may reuse them elsewhere and expects
|
||||||
|
# them to remain unchanged. We only create a copy if we need to translate
|
||||||
|
# (e.g. they're not already in LangChain format).
|
||||||
|
|
||||||
formatted_message = message
|
formatted_message = message
|
||||||
if isinstance(message.content, list):
|
if isinstance(message.content, str):
|
||||||
|
if formatted_message is message:
|
||||||
|
formatted_message = message.model_copy()
|
||||||
|
# Shallow-copy the content string so we can modify it
|
||||||
|
formatted_message.content = str(formatted_message.content)
|
||||||
|
formatted_message.content = [
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"text": message.content,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
elif isinstance(message.content, list):
|
||||||
for idx, block in enumerate(message.content):
|
for idx, block in enumerate(message.content):
|
||||||
if (
|
if isinstance(block, str):
|
||||||
isinstance(block, dict)
|
|
||||||
# Subset to (PDF) files and audio, as most relevant chat models
|
|
||||||
# support images in OAI format (and some may not yet support the
|
|
||||||
# standard data block format)
|
|
||||||
and block.get("type") in {"file", "input_audio"}
|
|
||||||
and _is_openai_data_block(block)
|
|
||||||
):
|
|
||||||
if formatted_message is message:
|
if formatted_message is message:
|
||||||
formatted_message = message.model_copy()
|
formatted_message = message.model_copy()
|
||||||
# Also shallow-copy content
|
# Shallow-copy the content list so we can modify it
|
||||||
|
formatted_message.content = list(formatted_message.content)
|
||||||
|
formatted_message.content[idx] = {"type": "text", "text": block} # type: ignore[index] # mypy confused by .model_copy
|
||||||
|
|
||||||
|
# Handle OpenAI Chat Completions multimodal data blocks
|
||||||
|
if (
|
||||||
|
# Subset to base64 image, file, and audio
|
||||||
|
isinstance(block, dict)
|
||||||
|
and block.get("type") in {"image_url", "input_audio", "file"}
|
||||||
|
# We need to discriminate between an OpenAI formatted file and a LC
|
||||||
|
# file content block since they share the `'type'` key
|
||||||
|
and _is_openai_data_block(block)
|
||||||
|
):
|
||||||
|
# Only copy if it is an OpenAI data block that needs conversion
|
||||||
|
if formatted_message is message:
|
||||||
|
formatted_message = message.model_copy()
|
||||||
|
# Shallow-copy the content list so we can modify it
|
||||||
formatted_message.content = list(formatted_message.content)
|
formatted_message.content = list(formatted_message.content)
|
||||||
|
|
||||||
formatted_message.content[idx] = ( # type: ignore[index] # mypy confused by .model_copy
|
# Convert OpenAI image/audio/file block to LangChain v1 standard
|
||||||
|
# content
|
||||||
|
formatted_message.content[idx] = ( # type: ignore[call-overload,index] # mypy confused by .model_copy
|
||||||
_convert_openai_format_to_data_block(block)
|
_convert_openai_format_to_data_block(block)
|
||||||
|
# This may return a NonStandardContentBlock if parsing fails!
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Handle LangChain v0 standard content blocks
|
||||||
|
|
||||||
|
# TODO: check for source_type since that disqualifies v1 blocks and
|
||||||
|
# ensures this block only checks v0
|
||||||
|
elif isinstance(block, dict) and block.get("type") in {
|
||||||
|
"image",
|
||||||
|
"audio",
|
||||||
|
"file",
|
||||||
|
}:
|
||||||
|
# Convert v0 to v1 standard content blocks
|
||||||
|
# These guard against v1 blocks as they don't have `'source_type'`
|
||||||
|
|
||||||
|
if formatted_message is message:
|
||||||
|
formatted_message = message.model_copy()
|
||||||
|
# Shallow-copy the content list so we can modify it
|
||||||
|
formatted_message.content = list(formatted_message.content)
|
||||||
|
|
||||||
|
# URL-image
|
||||||
|
if block.get("source_type") == "url" and block["type"] == "image":
|
||||||
|
formatted_message.content[idx] = create_image_block( # type: ignore[call-overload,index] # mypy confused by .model_copy
|
||||||
|
url=block["url"],
|
||||||
|
mime_type=block.get("mime_type"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# URL-audio
|
||||||
|
elif block.get("source_type") == "url" and block["type"] == "audio":
|
||||||
|
formatted_message.content[idx] = create_audio_block( # type: ignore[call-overload,index] # mypy confused by .model_copy
|
||||||
|
url=block["url"],
|
||||||
|
mime_type=block.get("mime_type"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# URL-file
|
||||||
|
elif block.get("source_type") == "url" and block["type"] == "file":
|
||||||
|
formatted_message.content[idx] = create_file_block( # type: ignore[call-overload,index] # mypy confused by .model_copy
|
||||||
|
url=block["url"],
|
||||||
|
mime_type=block.get("mime_type"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# base64-image
|
||||||
|
elif (
|
||||||
|
block.get("source_type") == "base64"
|
||||||
|
and block["type"] == "image"
|
||||||
|
):
|
||||||
|
formatted_message.content[idx] = create_image_block( # type: ignore[call-overload,index] # mypy confused by .model_copy
|
||||||
|
base64=block["data"],
|
||||||
|
mime_type=block.get("mime_type"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# base64-audio
|
||||||
|
elif (
|
||||||
|
block.get("source_type") == "base64"
|
||||||
|
and block["type"] == "audio"
|
||||||
|
):
|
||||||
|
formatted_message.content[idx] = create_audio_block( # type: ignore[call-overload,index] # mypy confused by .model_copy
|
||||||
|
base64=block["data"],
|
||||||
|
mime_type=block.get("mime_type"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# base64-file
|
||||||
|
elif (
|
||||||
|
block.get("source_type") == "base64" and block["type"] == "file"
|
||||||
|
):
|
||||||
|
formatted_message.content[idx] = create_file_block( # type: ignore[call-overload,index] # mypy confused by .model_copy
|
||||||
|
base64=block["data"],
|
||||||
|
mime_type=block.get("mime_type"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# id-image
|
||||||
|
elif block.get("source_type") == "id" and block["type"] == "image":
|
||||||
|
formatted_message.content[idx] = create_image_block( # type: ignore[call-overload,index] # mypy confused by .model_copy
|
||||||
|
id=block["id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# id-audio
|
||||||
|
elif block.get("source_type") == "id" and block["type"] == "audio":
|
||||||
|
formatted_message.content[idx] = create_audio_block( # type: ignore[call-overload,index] # mypy confused by .model_copy
|
||||||
|
id=block["id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# id-file
|
||||||
|
elif block.get("source_type") == "id" and block["type"] == "file":
|
||||||
|
formatted_message.content[idx] = create_file_block( # type: ignore[call-overload,index] # mypy confused by .model_copy
|
||||||
|
id=block["id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# text-file
|
||||||
|
elif block.get("source_type") == "text" and block["type"] == "file":
|
||||||
|
formatted_message.content[idx] = create_plaintext_block( # type: ignore[call-overload,index] # mypy confused by .model_copy
|
||||||
|
text=block["url"],
|
||||||
|
# Note: `text` is the URL in this case, not the content
|
||||||
|
# This is a legacy format, so we don't expect a MIME type
|
||||||
|
# but we can still pass it if it exists
|
||||||
|
mime_type=block.get("mime_type"),
|
||||||
|
)
|
||||||
|
|
||||||
|
else: # Unsupported or malformed v0 content block
|
||||||
|
formatted_message.content[idx] = { # type: ignore[index] # mypy confused by .model_copy
|
||||||
|
"type": "non_standard",
|
||||||
|
"value": block,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Validate a v1 block to pass through
|
||||||
|
elif (
|
||||||
|
isinstance(block, dict)
|
||||||
|
and "type" in block
|
||||||
|
and block["type"] in KNOWN_BLOCK_TYPES
|
||||||
|
):
|
||||||
|
# # Handle shared type keys between v1 blocks and Chat Completions
|
||||||
|
# if block["type"] == "file" and block["file"]:
|
||||||
|
# # This is a file ID block
|
||||||
|
# formatted_message.content[idx] = create_file_block( # type: ignore[call-overload,index] # mypy confused by .model_copy # noqa: E501
|
||||||
|
# id=block["file"]["file_id"],
|
||||||
|
# )
|
||||||
|
|
||||||
|
formatted_message.content[idx] = block # type: ignore[index] # mypy confused by .model_copy
|
||||||
|
|
||||||
|
# Pass through any other content block types
|
||||||
|
|
||||||
|
# If we didn't modify the message, skip creating a new instance
|
||||||
|
if formatted_message is message:
|
||||||
|
formatted_messages.append(message)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# At this point, `content` will be a list of v1 standard content blocks.
|
||||||
formatted_messages.append(formatted_message)
|
formatted_messages.append(formatted_message)
|
||||||
|
|
||||||
return formatted_messages
|
return formatted_messages
|
||||||
|
@@ -6,6 +6,7 @@ from typing import TYPE_CHECKING, Any, Optional, Union, cast, overload
|
|||||||
|
|
||||||
from pydantic import ConfigDict, Field
|
from pydantic import ConfigDict, Field
|
||||||
|
|
||||||
|
from langchain_core.language_models._utils import _convert_openai_format_to_data_block
|
||||||
from langchain_core.load.serializable import Serializable
|
from langchain_core.load.serializable import Serializable
|
||||||
from langchain_core.messages import content_blocks as types
|
from langchain_core.messages import content_blocks as types
|
||||||
from langchain_core.utils import get_bolded_text
|
from langchain_core.utils import get_bolded_text
|
||||||
@@ -132,6 +133,12 @@ class BaseMessage(Serializable):
|
|||||||
blocks.append({"type": "text", "text": item})
|
blocks.append({"type": "text", "text": item})
|
||||||
elif isinstance(item, dict):
|
elif isinstance(item, dict):
|
||||||
item_type = item.get("type")
|
item_type = item.get("type")
|
||||||
|
if item_type in types.KNOWN_OPENAI_BLOCK_TYPES:
|
||||||
|
# OpenAI-specific content blocks
|
||||||
|
if item_type in {"image_url", "input_audio"}:
|
||||||
|
blocks.append(_convert_openai_format_to_data_block(item))
|
||||||
|
else:
|
||||||
|
blocks.append(cast("types.ContentBlock", item))
|
||||||
if item_type not in types.KNOWN_BLOCK_TYPES:
|
if item_type not in types.KNOWN_BLOCK_TYPES:
|
||||||
msg = (
|
msg = (
|
||||||
f"Non-standard content block type '{item_type}'. Ensure "
|
f"Non-standard content block type '{item_type}'. Ensure "
|
||||||
|
@@ -212,12 +212,29 @@ async def test_callback_handlers() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_chat_model_inputs() -> None:
|
def test_chat_model_inputs() -> None:
|
||||||
fake = ParrotFakeChatModel()
|
# Do we need to parameterize over both versions?
|
||||||
|
# fake = ParrotFakeChatModel()
|
||||||
|
|
||||||
assert fake.invoke("hello") == _any_id_human_message(content="hello")
|
# assert fake.invoke("hello") == _any_id_human_message(
|
||||||
assert fake.invoke([("ai", "blah")]) == _any_id_ai_message(content="blah")
|
# content=[{"type": "text", "text": "hello"}]
|
||||||
|
# )
|
||||||
|
# assert fake.invoke([("ai", "blah")]) == _any_id_ai_message(
|
||||||
|
# content=[{"type": "text", "text": "blah"}]
|
||||||
|
# )
|
||||||
|
# assert fake.invoke([AIMessage(content="blah")]) == _any_id_ai_message(
|
||||||
|
# content=[{"type": "text", "text": "blah"}]
|
||||||
|
# )
|
||||||
|
|
||||||
|
fake = ParrotFakeChatModel(output_version="v1")
|
||||||
|
|
||||||
|
assert fake.invoke("hello") == _any_id_human_message(
|
||||||
|
content=[{"type": "text", "text": "hello"}]
|
||||||
|
)
|
||||||
|
assert fake.invoke([("ai", "blah")]) == _any_id_ai_message(
|
||||||
|
content=[{"type": "text", "text": "blah"}]
|
||||||
|
)
|
||||||
assert fake.invoke([AIMessage(content="blah")]) == _any_id_ai_message(
|
assert fake.invoke([AIMessage(content="blah")]) == _any_id_ai_message(
|
||||||
content="blah"
|
content=[{"type": "text", "text": "blah"}]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@@ -428,43 +428,44 @@ class FakeChatModelStartTracer(FakeTracer):
|
|||||||
|
|
||||||
def test_trace_images_in_openai_format() -> None:
|
def test_trace_images_in_openai_format() -> None:
|
||||||
"""Test that images are traced in OpenAI format."""
|
"""Test that images are traced in OpenAI format."""
|
||||||
llm = ParrotFakeChatModel()
|
# TODO: trace in new format, or add way to trace in both formats?
|
||||||
messages = [
|
# llm = ParrotFakeChatModel()
|
||||||
{
|
# messages = [
|
||||||
"role": "user",
|
# {
|
||||||
"content": [
|
# "role": "user",
|
||||||
{
|
# # v0 format
|
||||||
"type": "image",
|
# "content": [
|
||||||
"source_type": "url",
|
# {
|
||||||
"url": "https://example.com/image.png",
|
# "type": "image",
|
||||||
}
|
# "source_type": "url",
|
||||||
],
|
# "url": "https://example.com/image.png",
|
||||||
}
|
# }
|
||||||
]
|
# ],
|
||||||
tracer = FakeChatModelStartTracer()
|
# }
|
||||||
response = llm.invoke(messages, config={"callbacks": [tracer]})
|
# ]
|
||||||
assert tracer.messages == [
|
# tracer = FakeChatModelStartTracer()
|
||||||
[
|
# response = llm.invoke(messages, config={"callbacks": [tracer]})
|
||||||
[
|
# assert tracer.messages == [
|
||||||
HumanMessage(
|
# [
|
||||||
content=[
|
# [
|
||||||
{
|
# HumanMessage(
|
||||||
"type": "image_url",
|
# content=[
|
||||||
"image_url": {"url": "https://example.com/image.png"},
|
# {
|
||||||
}
|
# "type": "image_url",
|
||||||
]
|
# "image_url": {"url": "https://example.com/image.png"},
|
||||||
)
|
# }
|
||||||
]
|
# ]
|
||||||
]
|
# )
|
||||||
]
|
# ]
|
||||||
# Test no mutation
|
# ]
|
||||||
assert response.content == [
|
# ]
|
||||||
{
|
# # Passing in a v0 should return a v1
|
||||||
"type": "image",
|
# assert response.content == [
|
||||||
"source_type": "url",
|
# {
|
||||||
"url": "https://example.com/image.png",
|
# "type": "image",
|
||||||
}
|
# "url": "https://example.com/image.png",
|
||||||
]
|
# }
|
||||||
|
# ]
|
||||||
|
|
||||||
|
|
||||||
def test_trace_content_blocks_with_no_type_key() -> None:
|
def test_trace_content_blocks_with_no_type_key() -> None:
|
||||||
@@ -478,7 +479,7 @@ def test_trace_content_blocks_with_no_type_key() -> None:
|
|||||||
"type": "text",
|
"type": "text",
|
||||||
"text": "Hello",
|
"text": "Hello",
|
||||||
},
|
},
|
||||||
{
|
{ # Will be converted to NonStandardContentBlock
|
||||||
"cachePoint": {"type": "default"},
|
"cachePoint": {"type": "default"},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -495,8 +496,8 @@ def test_trace_content_blocks_with_no_type_key() -> None:
|
|||||||
"type": "text",
|
"type": "text",
|
||||||
"text": "Hello",
|
"text": "Hello",
|
||||||
},
|
},
|
||||||
{
|
{ # For tracing, we are concerned with how messages are _sent_
|
||||||
"type": "cachePoint",
|
"type": "cachePoint", # TODO: how is this decided?
|
||||||
"cachePoint": {"type": "default"},
|
"cachePoint": {"type": "default"},
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@@ -504,20 +505,20 @@ def test_trace_content_blocks_with_no_type_key() -> None:
|
|||||||
]
|
]
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
# Test no mutation
|
|
||||||
assert response.content == [
|
assert response.content == [
|
||||||
{
|
{
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"text": "Hello",
|
"text": "Hello",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"cachePoint": {"type": "default"},
|
"type": "non_standard",
|
||||||
|
"value": {"cachePoint": {"type": "default"}},
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_extend_support_to_openai_multimodal_formats() -> None:
|
def test_extend_support_to_openai_multimodal_formats() -> None:
|
||||||
"""Test that chat models normalize OpenAI file and audio inputs."""
|
"""Test that chat models normalize OpenAI file and audio inputs to v1."""
|
||||||
llm = ParrotFakeChatModel()
|
llm = ParrotFakeChatModel()
|
||||||
messages = [
|
messages = [
|
||||||
{
|
{
|
||||||
@@ -539,98 +540,65 @@ def test_extend_support_to_openai_multimodal_formats() -> None:
|
|||||||
"file_data": "data:application/pdf;base64,<base64 string>",
|
"file_data": "data:application/pdf;base64,<base64 string>",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"type": "file",
|
|
||||||
"file": {
|
|
||||||
"file_data": "data:application/pdf;base64,<base64 string>",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"type": "file",
|
"type": "file",
|
||||||
"file": {"file_id": "<file id>"},
|
"file": {"file_id": "<file id>"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "input_audio",
|
"type": "input_audio",
|
||||||
"input_audio": {"data": "<base64 data>", "format": "wav"},
|
"audio": {
|
||||||
|
"format": "wav",
|
||||||
|
"data": "data:audio/wav;base64,<base64 string>",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
expected_content = [
|
expected_content = [
|
||||||
{"type": "text", "text": "Hello"},
|
{"type": "text", "text": "Hello"}, # TextContentBlock
|
||||||
{
|
{ # Chat Completions Image becomes ImageContentBlock after invoke
|
||||||
"type": "image_url",
|
"type": "image",
|
||||||
"image_url": {"url": "https://example.com/image.png"},
|
"url": "https://example.com/image.png",
|
||||||
},
|
},
|
||||||
{
|
{ # ...
|
||||||
"type": "image_url",
|
"type": "image",
|
||||||
"image_url": {"url": "data:image/jpeg;base64,/9j/4AAQSkZJRg..."},
|
"base64": "data:image/jpeg;base64,/9j/4AAQSkZJRg...",
|
||||||
|
"mime_type": "image/jpeg",
|
||||||
},
|
},
|
||||||
{
|
{ # FileContentBlock
|
||||||
"type": "file",
|
"type": "file",
|
||||||
"source_type": "base64",
|
"base64": "data:application/pdf;base64,<base64 string>",
|
||||||
"data": "<base64 string>",
|
|
||||||
"mime_type": "application/pdf",
|
"mime_type": "application/pdf",
|
||||||
"filename": "draconomicon.pdf",
|
"extras": {"filename": "draconomicon.pdf"},
|
||||||
},
|
},
|
||||||
{
|
{ # ...
|
||||||
"type": "file",
|
"type": "file",
|
||||||
"source_type": "base64",
|
"file_id": "<file id>",
|
||||||
"data": "<base64 string>",
|
|
||||||
"mime_type": "application/pdf",
|
|
||||||
},
|
},
|
||||||
{
|
{ # AudioContentBlock
|
||||||
"type": "file",
|
|
||||||
"file": {"file_id": "<file id>"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "audio",
|
"type": "audio",
|
||||||
"source_type": "base64",
|
"base64": "data:audio/wav;base64,<base64 string>",
|
||||||
"data": "<base64 data>",
|
|
||||||
"mime_type": "audio/wav",
|
"mime_type": "audio/wav",
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
response = llm.invoke(messages)
|
response = llm.invoke(messages)
|
||||||
assert response.content == expected_content
|
|
||||||
|
|
||||||
# Test no mutation
|
# Check structure, ignoring auto-generated IDs
|
||||||
assert messages[0]["content"] == [
|
actual_content = response.content
|
||||||
{"type": "text", "text": "Hello"},
|
assert len(actual_content) == len(expected_content)
|
||||||
{
|
|
||||||
"type": "image_url",
|
for i, (actual, expected) in enumerate(zip(actual_content, expected_content)):
|
||||||
"image_url": {"url": "https://example.com/image.png"},
|
if isinstance(actual, dict) and "id" in actual:
|
||||||
},
|
# Remove auto-generated id for comparison
|
||||||
{
|
actual_without_id = {k: v for k, v in actual.items() if k != "id"}
|
||||||
"type": "image_url",
|
assert actual_without_id == expected, f"Mismatch at index {i}"
|
||||||
"image_url": {"url": "data:image/jpeg;base64,/9j/4AAQSkZJRg..."},
|
else:
|
||||||
},
|
assert actual == expected, f"Mismatch at index {i}"
|
||||||
{
|
|
||||||
"type": "file",
|
|
||||||
"file": {
|
|
||||||
"filename": "draconomicon.pdf",
|
|
||||||
"file_data": "data:application/pdf;base64,<base64 string>",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "file",
|
|
||||||
"file": {
|
|
||||||
"file_data": "data:application/pdf;base64,<base64 string>",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "file",
|
|
||||||
"file": {"file_id": "<file id>"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "input_audio",
|
|
||||||
"input_audio": {"data": "<base64 data>", "format": "wav"},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_normalize_messages_edge_cases() -> None:
|
def test_normalize_messages_edge_cases() -> None:
|
||||||
# Test some blocks that should pass through
|
# Test unrecognized blocks come back as NonStandardContentBlock
|
||||||
messages = [
|
input_messages = [
|
||||||
HumanMessage(
|
HumanMessage(
|
||||||
content=[
|
content=[
|
||||||
{
|
{
|
||||||
@@ -639,18 +607,55 @@ def test_normalize_messages_edge_cases() -> None:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "input_file",
|
"type": "input_file",
|
||||||
"file_data": "uri",
|
"file_data": "uri", # Malformed base64
|
||||||
"filename": "file-name",
|
"filename": "file-name",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "input_audio",
|
"type": "input_audio",
|
||||||
"input_audio": "uri",
|
"input_audio": "uri", # Not nested in `audio`
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "input_image",
|
"type": "input_image",
|
||||||
"image_url": "uri",
|
"image_url": "uri", # Not nested in `image_url`
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
assert messages == _normalize_messages(messages)
|
|
||||||
|
expected_messages = [
|
||||||
|
HumanMessage(
|
||||||
|
content=[
|
||||||
|
{
|
||||||
|
"type": "non_standard",
|
||||||
|
"value": {
|
||||||
|
"type": "file",
|
||||||
|
"file": "uri",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "non_standard",
|
||||||
|
"value": {
|
||||||
|
"type": "input_file",
|
||||||
|
"file_data": "uri",
|
||||||
|
"filename": "file-name",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "non_standard",
|
||||||
|
"value": {
|
||||||
|
"type": "input_audio",
|
||||||
|
"input_audio": "uri",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "non_standard",
|
||||||
|
"value": {
|
||||||
|
"type": "input_image",
|
||||||
|
"image_url": "uri",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
assert _normalize_messages(input_messages) == expected_messages
|
||||||
|
@@ -215,7 +215,8 @@ def test_rate_limit_skips_cache() -> None:
|
|||||||
(
|
(
|
||||||
'[{"lc": 1, "type": "constructor", "id": ["langchain", "schema", '
|
'[{"lc": 1, "type": "constructor", "id": ["langchain", "schema", '
|
||||||
'"messages", '
|
'"messages", '
|
||||||
'"HumanMessage"], "kwargs": {"content": "foo", "type": "human"}}]',
|
'"HumanMessage"], "kwargs": {"content": [{"type": "text", "text": "foo"}], '
|
||||||
|
'"type": "human"}}]',
|
||||||
"[('_type', 'generic-fake-chat-model'), ('stop', None)]",
|
"[('_type', 'generic-fake-chat-model'), ('stop', None)]",
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
Reference in New Issue
Block a user