mirror of
https://github.com/NCBM/plyngent.git
synced 2026-11-07 23:47:54 +08:00
core/lmproto+agent: carry image parts on user and tool messages
A user turn's `content` may now be a part array (`ChatTextPart` + `ChatImagePart`), which chat completions take as-is; the Anthropic and Responses bridges translate it into `image` blocks and `input_image` parts. A tool result keeps its text in `content` and carries `images` beside it: Anthropic and Responses put them in the result item itself (a live probe reads both back), while chat completions cannot, so `compat.chat_messages_with_images` moves them into the user turn after the tool run. Prose-only readers go through `content_to_text`, which renders an image as `[image]`. `agent.images` sniffs the real format from the bytes and builds the data URL, capped at 8 MiB inline because every stored message is re-sent on each round.
This commit is contained in:
@@ -15,6 +15,7 @@ from plyngent.lmproto.openai_compatible.model import (
|
|||||||
SystemChatMessage,
|
SystemChatMessage,
|
||||||
ToolChatMessage,
|
ToolChatMessage,
|
||||||
UserChatMessage,
|
UserChatMessage,
|
||||||
|
content_to_text,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .budget import (
|
from .budget import (
|
||||||
@@ -162,12 +163,12 @@ def incomplete_turn_user_text(messages: Sequence[AnyChatMessage]) -> str | None:
|
|||||||
return None
|
return None
|
||||||
last = body[-1]
|
last = body[-1]
|
||||||
if isinstance(last, UserChatMessage):
|
if isinstance(last, UserChatMessage):
|
||||||
return last.content
|
return content_to_text(last.content)
|
||||||
if isinstance(last, ToolChatMessage) or (truncated and isinstance(last, AssistantChatMessage)):
|
if isinstance(last, ToolChatMessage) or (truncated and isinstance(last, AssistantChatMessage)):
|
||||||
# Walk back to the user that opened this turn.
|
# Walk back to the user that opened this turn.
|
||||||
for msg in reversed(body):
|
for msg in reversed(body):
|
||||||
if isinstance(msg, UserChatMessage):
|
if isinstance(msg, UserChatMessage):
|
||||||
return msg.content
|
return content_to_text(msg.content)
|
||||||
return None
|
return None
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -558,7 +559,9 @@ class ChatAgent:
|
|||||||
raise RuntimeError(msg)
|
raise RuntimeError(msg)
|
||||||
for i in range(len(self.messages) - 1, -1, -1):
|
for i in range(len(self.messages) - 1, -1, -1):
|
||||||
msg = self.messages[i]
|
msg = self.messages[i]
|
||||||
if isinstance(msg, UserChatMessage) and msg.content == text:
|
# ``incomplete_turn_user_text`` returns the text view, so compare that
|
||||||
|
# (a turn carrying images is a part array, not a string).
|
||||||
|
if isinstance(msg, UserChatMessage) and content_to_text(msg.content) == text:
|
||||||
return i
|
return i
|
||||||
msg = "nothing to retry"
|
msg = "nothing to retry"
|
||||||
raise RuntimeError(msg)
|
raise RuntimeError(msg)
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"""Image parts for the model: data URLs, format sniffing, and the size policy.
|
||||||
|
|
||||||
|
A model-visible image is a :class:`ChatImagePart` holding a URL — an ``http(s)``
|
||||||
|
link or an inline ``data:`` URL. Providers sniff the real format from the bytes
|
||||||
|
(never from a file name), so this module sniffs too and builds the data URL from
|
||||||
|
what it found.
|
||||||
|
|
||||||
|
Inline bytes are capped at :data:`MAX_INLINE_IMAGE_BYTES`, well under the 32 MiB
|
||||||
|
a provider accepts: every stored message is re-sent on each round and lives in
|
||||||
|
the session's JSON message column, so a huge screenshot would be paid for over
|
||||||
|
and over. A larger image has to be resized first.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
|
||||||
|
from plyngent.lmproto.openai_compatible.model import ChatImagePart, ChatImageUrl, ImageDetail
|
||||||
|
|
||||||
|
# Inline payload ceiling for one image (the API allows 32 MiB; see the module docstring).
|
||||||
|
MAX_INLINE_IMAGE_BYTES = 8 * 1024 * 1024
|
||||||
|
|
||||||
|
# Formatted images the API accepts, by their leading bytes.
|
||||||
|
_IMAGE_SIGNATURES: tuple[tuple[bytes, str], ...] = (
|
||||||
|
(b"\x89PNG\r\n\x1a\n", "image/png"),
|
||||||
|
(b"\xff\xd8\xff", "image/jpeg"),
|
||||||
|
(b"GIF87a", "image/gif"),
|
||||||
|
(b"GIF89a", "image/gif"),
|
||||||
|
)
|
||||||
|
# RIFF....WEBP: four bytes of container, four of size, then the form type.
|
||||||
|
_WEBP_HEADER_LEN = 12
|
||||||
|
|
||||||
|
|
||||||
|
def sniff_image_media_type(data: bytes) -> str | None:
|
||||||
|
"""Media type of an image *data* by its content, or ``None`` if unsupported."""
|
||||||
|
for signature, media_type in _IMAGE_SIGNATURES:
|
||||||
|
if data.startswith(signature):
|
||||||
|
return media_type
|
||||||
|
# WebP: RIFF....WEBP
|
||||||
|
if len(data) >= _WEBP_HEADER_LEN and data[:4] == b"RIFF" and data[8:12] == b"WEBP":
|
||||||
|
return "image/webp"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def to_data_url(media_type: str, data: bytes) -> str:
|
||||||
|
"""Encode *data* as an inline ``data:`` URL of *media_type*."""
|
||||||
|
return f"data:{media_type};base64,{base64.b64encode(data).decode('ascii')}"
|
||||||
|
|
||||||
|
|
||||||
|
def split_data_url(url: str) -> tuple[str, str] | None:
|
||||||
|
"""``(media_type, base64_payload)`` of a ``data:`` URL, or ``None`` otherwise."""
|
||||||
|
if not url.startswith("data:"):
|
||||||
|
return None
|
||||||
|
header, _, payload = url.partition(",")
|
||||||
|
media_type = header[len("data:") :].split(";")[0].strip()
|
||||||
|
if not media_type or not payload:
|
||||||
|
return None
|
||||||
|
return media_type, payload
|
||||||
|
|
||||||
|
|
||||||
|
def image_part_from_bytes(data: bytes, *, detail: ImageDetail = "auto") -> ChatImagePart | str:
|
||||||
|
"""An image part for *data*; a string is the model-facing error instead."""
|
||||||
|
media_type = sniff_image_media_type(data)
|
||||||
|
if media_type is None:
|
||||||
|
return "error: not a JPEG, PNG, GIF, or WebP image"
|
||||||
|
if len(data) > MAX_INLINE_IMAGE_BYTES:
|
||||||
|
return (
|
||||||
|
f"error: image is {len(data)} bytes, over the {MAX_INLINE_IMAGE_BYTES}-byte inline limit; "
|
||||||
|
"resize it and try again"
|
||||||
|
)
|
||||||
|
return ChatImagePart(image_url=ChatImageUrl(url=to_data_url(media_type, data), detail=detail))
|
||||||
|
|
||||||
|
|
||||||
|
def image_part_from_url(url: str, *, detail: ImageDetail = "auto") -> ChatImagePart:
|
||||||
|
"""An image part for a public ``http(s)`` URL or an inline ``data:`` URL."""
|
||||||
|
return ChatImagePart(image_url=ChatImageUrl(url=url, detail=detail))
|
||||||
@@ -13,8 +13,10 @@ from typing import TYPE_CHECKING, Any, cast
|
|||||||
|
|
||||||
from msgspec import UNSET
|
from msgspec import UNSET
|
||||||
|
|
||||||
|
from plyngent.agent.images import split_data_url
|
||||||
from plyngent.lmproto.anthropic.model import (
|
from plyngent.lmproto.anthropic.model import (
|
||||||
AnthropicAssistantMessage,
|
AnthropicAssistantMessage,
|
||||||
|
AnthropicImageContent,
|
||||||
AnthropicMessageResponse,
|
AnthropicMessageResponse,
|
||||||
AnthropicMessagesParam,
|
AnthropicMessagesParam,
|
||||||
AnthropicOutputConfig,
|
AnthropicOutputConfig,
|
||||||
@@ -39,8 +41,11 @@ from plyngent.lmproto.openai_compatible.model import (
|
|||||||
ChatCompletionChoice,
|
ChatCompletionChoice,
|
||||||
ChatCompletionResponse,
|
ChatCompletionResponse,
|
||||||
ChatCompletionsParam,
|
ChatCompletionsParam,
|
||||||
|
ChatImagePart,
|
||||||
|
ChatTextPart,
|
||||||
DeveloperChatMessage,
|
DeveloperChatMessage,
|
||||||
SystemChatMessage,
|
SystemChatMessage,
|
||||||
|
ToolChatMessage,
|
||||||
ToolFunctionItem,
|
ToolFunctionItem,
|
||||||
UserChatMessage,
|
UserChatMessage,
|
||||||
)
|
)
|
||||||
@@ -135,6 +140,52 @@ def _tool_choice_from_chat(param: ChatCompletionsParam) -> AnthropicToolChoice |
|
|||||||
return UNSET
|
return UNSET
|
||||||
|
|
||||||
|
|
||||||
|
def _image_to_anthropic(part: ChatImagePart) -> AnthropicImageContent:
|
||||||
|
"""One chat image part as an Anthropic ``image`` block.
|
||||||
|
|
||||||
|
An inline ``data:`` URL becomes a ``base64`` source (the provider then reads
|
||||||
|
the media type we sniffed); anything else is a ``url`` source.
|
||||||
|
"""
|
||||||
|
inline = split_data_url(part.image_url.url)
|
||||||
|
if inline is None:
|
||||||
|
return AnthropicImageContent(source={"type": "url", "url": part.image_url.url})
|
||||||
|
media_type, payload = inline
|
||||||
|
return AnthropicImageContent(source={"type": "base64", "media_type": media_type, "data": payload})
|
||||||
|
|
||||||
|
|
||||||
|
type _AnthropicUserBlock = AnthropicTextContent | AnthropicImageContent | AnthropicToolResultContent
|
||||||
|
|
||||||
|
|
||||||
|
def _user_blocks_to_anthropic(
|
||||||
|
content: str | list[ChatTextPart | ChatImagePart],
|
||||||
|
) -> str | list[_AnthropicUserBlock]:
|
||||||
|
"""A chat user turn's content as Anthropic blocks (plain text stays a string)."""
|
||||||
|
if isinstance(content, str):
|
||||||
|
return content
|
||||||
|
return [
|
||||||
|
AnthropicTextContent(text=part.text) if isinstance(part, ChatTextPart) else _image_to_anthropic(part)
|
||||||
|
for part in content
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _tool_result_content(
|
||||||
|
message: ToolChatMessage,
|
||||||
|
) -> str | list[AnthropicTextContent | AnthropicImageContent]:
|
||||||
|
"""A tool result's content: its text, plus any images the tool returned.
|
||||||
|
|
||||||
|
Anthropic takes image blocks inside ``tool_result.content`` (a live probe
|
||||||
|
confirms the DeepSeek surface reads them), so the images ride in the result
|
||||||
|
itself instead of a separate user turn.
|
||||||
|
"""
|
||||||
|
if message.images is UNSET or not message.images:
|
||||||
|
return message.content
|
||||||
|
blocks: list[AnthropicTextContent | AnthropicImageContent] = []
|
||||||
|
if message.content:
|
||||||
|
blocks.append(AnthropicTextContent(text=message.content))
|
||||||
|
blocks.extend(_image_to_anthropic(part) for part in message.images)
|
||||||
|
return blocks
|
||||||
|
|
||||||
|
|
||||||
def _assistant_to_anthropic(message: AssistantChatMessage) -> AnthropicAssistantMessage | None:
|
def _assistant_to_anthropic(message: AssistantChatMessage) -> AnthropicAssistantMessage | None:
|
||||||
"""The assistant turn for the wire: opaque blocks, then text, then calls.
|
"""The assistant turn for the wire: opaque blocks, then text, then calls.
|
||||||
|
|
||||||
@@ -211,7 +262,7 @@ def chat_messages_to_anthropic( # noqa: C901 — multi-role conversion
|
|||||||
|
|
||||||
if isinstance(message, UserChatMessage):
|
if isinstance(message, UserChatMessage):
|
||||||
flush_tool_results()
|
flush_tool_results()
|
||||||
out.append(AnthropicUserMessage(content=message.content))
|
out.append(AnthropicUserMessage(content=_user_blocks_to_anthropic(message.content)))
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if isinstance(message, AssistantChatMessage):
|
if isinstance(message, AssistantChatMessage):
|
||||||
@@ -225,7 +276,7 @@ def chat_messages_to_anthropic( # noqa: C901 — multi-role conversion
|
|||||||
pending_tool_results.append(
|
pending_tool_results.append(
|
||||||
AnthropicToolResultContent(
|
AnthropicToolResultContent(
|
||||||
tool_use_id=message.tool_call_id,
|
tool_use_id=message.tool_call_id,
|
||||||
content=message.content,
|
content=_tool_result_content(message),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -29,8 +29,11 @@ from plyngent.lmproto.openai_compatible.model import (
|
|||||||
ChatCompletionChunk,
|
ChatCompletionChunk,
|
||||||
ChatCompletionResponse,
|
ChatCompletionResponse,
|
||||||
ChatCompletionsParam,
|
ChatCompletionsParam,
|
||||||
|
ChatImagePart,
|
||||||
|
ChatTextPart,
|
||||||
DeveloperChatMessage,
|
DeveloperChatMessage,
|
||||||
SystemChatMessage,
|
SystemChatMessage,
|
||||||
|
ToolChatMessage,
|
||||||
ToolFunctionItem,
|
ToolFunctionItem,
|
||||||
UserChatMessage,
|
UserChatMessage,
|
||||||
)
|
)
|
||||||
@@ -175,6 +178,35 @@ def _group_tool_outputs(
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _image_to_input_part(part: ChatImagePart) -> dict[str, Any]:
|
||||||
|
"""One chat image part as a Responses ``input_image`` item."""
|
||||||
|
item: dict[str, Any] = {"type": "input_image", "image_url": part.image_url.url}
|
||||||
|
if part.image_url.detail is not UNSET:
|
||||||
|
item["detail"] = part.image_url.detail
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
def _user_content_to_input(content: str | list[ChatTextPart | ChatImagePart]) -> str | list[dict[str, Any]]:
|
||||||
|
"""A chat user turn's content as ``input`` content (plain text stays a string)."""
|
||||||
|
if isinstance(content, str):
|
||||||
|
return content
|
||||||
|
return [
|
||||||
|
{"type": "input_text", "text": part.text} if isinstance(part, ChatTextPart) else _image_to_input_part(part)
|
||||||
|
for part in content
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _tool_output(message: ToolChatMessage) -> str | list[dict[str, Any]]:
|
||||||
|
"""A tool result's ``output``: its text, plus any images the tool returned."""
|
||||||
|
if message.images is UNSET or not message.images:
|
||||||
|
return message.content
|
||||||
|
parts: list[dict[str, Any]] = []
|
||||||
|
if message.content:
|
||||||
|
parts.append({"type": "input_text", "text": message.content})
|
||||||
|
parts.extend(_image_to_input_part(part) for part in message.images)
|
||||||
|
return parts
|
||||||
|
|
||||||
|
|
||||||
def chat_messages_to_responses_input(
|
def chat_messages_to_responses_input(
|
||||||
messages: Sequence[AnyChatMessage],
|
messages: Sequence[AnyChatMessage],
|
||||||
) -> tuple[str | None, list[dict[str, Any] | ResponseEasyInputMessage | ResponseFunctionToolCallOutput]]:
|
) -> tuple[str | None, list[dict[str, Any] | ResponseEasyInputMessage | ResponseFunctionToolCallOutput]]:
|
||||||
@@ -191,7 +223,7 @@ def chat_messages_to_responses_input(
|
|||||||
if message.content.strip():
|
if message.content.strip():
|
||||||
items.append(ResponseEasyInputMessage(role="developer", content=message.content))
|
items.append(ResponseEasyInputMessage(role="developer", content=message.content))
|
||||||
elif isinstance(message, UserChatMessage):
|
elif isinstance(message, UserChatMessage):
|
||||||
items.append(ResponseEasyInputMessage(role="user", content=message.content))
|
items.append(ResponseEasyInputMessage(role="user", content=_user_content_to_input(message.content)))
|
||||||
elif isinstance(message, AssistantChatMessage):
|
elif isinstance(message, AssistantChatMessage):
|
||||||
items.extend(_assistant_to_input_items(message))
|
items.extend(_assistant_to_input_items(message))
|
||||||
else:
|
else:
|
||||||
@@ -199,7 +231,7 @@ def chat_messages_to_responses_input(
|
|||||||
items.append(
|
items.append(
|
||||||
ResponseFunctionToolCallOutput(
|
ResponseFunctionToolCallOutput(
|
||||||
call_id=message.tool_call_id,
|
call_id=message.tool_call_id,
|
||||||
output=message.content,
|
output=_tool_output(message),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from plyngent.lmproto.openai_compatible.model import (
|
|||||||
AssistantFunctionToolCall,
|
AssistantFunctionToolCall,
|
||||||
ToolChatMessage,
|
ToolChatMessage,
|
||||||
UserChatMessage,
|
UserChatMessage,
|
||||||
|
content_to_text,
|
||||||
)
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -78,7 +79,7 @@ def format_session_export_md(
|
|||||||
|
|
||||||
def _format_message_md(message: AnyChatMessage) -> list[str]:
|
def _format_message_md(message: AnyChatMessage) -> list[str]:
|
||||||
if isinstance(message, UserChatMessage):
|
if isinstance(message, UserChatMessage):
|
||||||
return ["## user", "", message.content]
|
return ["## user", "", content_to_text(message.content)]
|
||||||
if isinstance(message, AssistantChatMessage):
|
if isinstance(message, AssistantChatMessage):
|
||||||
parts: list[str] = ["## assistant", ""]
|
parts: list[str] = ["## assistant", ""]
|
||||||
reasoning = message.reasoning_content
|
reasoning = message.reasoning_content
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ from plyngent.lmproto.openai_compatible.model import (
|
|||||||
SystemChatMessage,
|
SystemChatMessage,
|
||||||
ToolChatMessage,
|
ToolChatMessage,
|
||||||
UserChatMessage,
|
UserChatMessage,
|
||||||
|
content_to_text,
|
||||||
)
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -225,7 +226,7 @@ def build_transcript(messages: Sequence[AnyChatMessage], *, pending_retry_text:
|
|||||||
|
|
||||||
if turns and pending_retry_text is not None:
|
if turns and pending_retry_text is not None:
|
||||||
user = turns[-1].user.message
|
user = turns[-1].user.message
|
||||||
if isinstance(user, UserChatMessage) and user.content == pending_retry_text:
|
if isinstance(user, UserChatMessage) and content_to_text(user.content) == pending_retry_text:
|
||||||
turns[-1] = replace(turns[-1], incomplete=True)
|
turns[-1] = replace(turns[-1], incomplete=True)
|
||||||
return Transcript(prelude=tuple(prelude), turns=tuple(turns))
|
return Transcript(prelude=tuple(prelude), turns=tuple(turns))
|
||||||
|
|
||||||
@@ -239,7 +240,9 @@ def echo_row(row: Row, *, detail: Detail) -> None:
|
|||||||
_echo_assistant(row, reasoning=detail == "full")
|
_echo_assistant(row, reasoning=detail == "full")
|
||||||
return
|
return
|
||||||
click.echo(row.prefix())
|
click.echo(row.prefix())
|
||||||
click.echo(row.message.content or "")
|
message = row.message
|
||||||
|
click.echo(content_to_text(message.content) if isinstance(message, UserChatMessage) else message.content)
|
||||||
|
click.echo()
|
||||||
click.echo()
|
click.echo()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from typing import TYPE_CHECKING, Any, Literal, cast, overload
|
|||||||
import msgspec
|
import msgspec
|
||||||
|
|
||||||
from plyngent.lmproto.openai_compatible.client import BaseOpenAIClient
|
from plyngent.lmproto.openai_compatible.client import BaseOpenAIClient
|
||||||
|
from plyngent.lmproto.openai_compatible.compat import chat_messages_with_images
|
||||||
from plyngent.lmproto.openai_compatible.config import OpenAIConfig # noqa: TC001
|
from plyngent.lmproto.openai_compatible.config import OpenAIConfig # noqa: TC001
|
||||||
from plyngent.lmproto.openai_compatible.model import ( # noqa: TC001
|
from plyngent.lmproto.openai_compatible.model import ( # noqa: TC001
|
||||||
ChatCompletionChunk,
|
ChatCompletionChunk,
|
||||||
@@ -49,6 +50,11 @@ class OpenAIClient(BaseOpenAIClient):
|
|||||||
self, param: ChatCompletionsParam, *, stream: bool = False
|
self, param: ChatCompletionsParam, *, stream: bool = False
|
||||||
) -> ChatCompletionResponse | AsyncIterator[ChatCompletionChunk]:
|
) -> ChatCompletionResponse | AsyncIterator[ChatCompletionChunk]:
|
||||||
param = msgspec.structs.replace(param, stream=stream)
|
param = msgspec.structs.replace(param, stream=stream)
|
||||||
|
# Lift images into content parts, like every other chat-completions wire;
|
||||||
|
# the OpenAI API takes the ``developer`` role as-is, so only that step.
|
||||||
|
images = chat_messages_with_images(param.messages)
|
||||||
|
if images != param.messages:
|
||||||
|
param = msgspec.structs.replace(param, messages=images)
|
||||||
data = self.encoder.encode(param)
|
data = self.encoder.encode(param)
|
||||||
if stream:
|
if stream:
|
||||||
resp = await self.session.post(
|
resp = await self.session.post(
|
||||||
|
|||||||
@@ -98,10 +98,15 @@ class ResponseFunctionToolCall(Struct, tag_field="type", tag="function_call"):
|
|||||||
|
|
||||||
|
|
||||||
class ResponseFunctionToolCallOutput(Struct, tag_field="type", tag="function_call_output"):
|
class ResponseFunctionToolCallOutput(Struct, tag_field="type", tag="function_call_output"):
|
||||||
"""Tool result item passed back in subsequent ``input``."""
|
"""Tool result item passed back in subsequent ``input``.
|
||||||
|
|
||||||
|
``output`` may be a part array instead of text: a tool that returns an image
|
||||||
|
puts an ``input_image`` part beside its ``input_text`` (the Responses guide
|
||||||
|
documents images there, and a live probe reads them back).
|
||||||
|
"""
|
||||||
|
|
||||||
call_id: str
|
call_id: str
|
||||||
output: str
|
output: str | list[dict[str, Any]]
|
||||||
id: str | Unset = UNSET
|
id: str | Unset = UNSET
|
||||||
status: ResponseItemStatus | Unset = UNSET
|
status: ResponseItemStatus | Unset = UNSET
|
||||||
|
|
||||||
|
|||||||
@@ -7,12 +7,18 @@ from typing import TYPE_CHECKING, Any, cast
|
|||||||
import msgspec
|
import msgspec
|
||||||
from msgspec import UNSET
|
from msgspec import UNSET
|
||||||
|
|
||||||
from .model import AssistantChatMessage, DeveloperChatMessage, SystemChatMessage
|
from .model import (
|
||||||
|
AssistantChatMessage,
|
||||||
|
DeveloperChatMessage,
|
||||||
|
SystemChatMessage,
|
||||||
|
ToolChatMessage,
|
||||||
|
UserChatMessage,
|
||||||
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
|
|
||||||
from .model import AnyChatMessage, ChatCompletionsParam
|
from .model import AnyChatMessage, ChatCompletionsParam, ChatImagePart
|
||||||
|
|
||||||
|
|
||||||
def developer_to_system_message(message: DeveloperChatMessage) -> SystemChatMessage:
|
def developer_to_system_message(message: DeveloperChatMessage) -> SystemChatMessage:
|
||||||
@@ -38,6 +44,38 @@ def coerce_developer_messages_to_system(messages: Sequence[AnyChatMessage]) -> l
|
|||||||
return out if changed else list(messages)
|
return out if changed else list(messages)
|
||||||
|
|
||||||
|
|
||||||
|
def chat_messages_with_images(messages: Sequence[AnyChatMessage]) -> list[AnyChatMessage]:
|
||||||
|
"""Return *messages* with tool-result images lifted into the chat wire.
|
||||||
|
|
||||||
|
A user turn already carries its images in ``content`` blocks, which chat
|
||||||
|
completions take as-is. A ``tool`` role message may only carry text, so a
|
||||||
|
tool result's images move into the user turn emitted right after the run of
|
||||||
|
consecutive tool results.
|
||||||
|
|
||||||
|
With no such images the sequence's items come back unchanged.
|
||||||
|
"""
|
||||||
|
out: list[AnyChatMessage] = []
|
||||||
|
pending: list[ChatImagePart] = []
|
||||||
|
changed = False
|
||||||
|
|
||||||
|
def flush_images() -> None:
|
||||||
|
if pending:
|
||||||
|
out.append(UserChatMessage(content=list(pending)))
|
||||||
|
pending.clear()
|
||||||
|
|
||||||
|
for message in messages:
|
||||||
|
if isinstance(message, ToolChatMessage) and message.images is not UNSET and message.images:
|
||||||
|
out.append(msgspec.structs.replace(message, images=UNSET))
|
||||||
|
pending.extend(message.images)
|
||||||
|
changed = True
|
||||||
|
continue
|
||||||
|
if pending and not isinstance(message, ToolChatMessage):
|
||||||
|
flush_images()
|
||||||
|
out.append(message)
|
||||||
|
flush_images()
|
||||||
|
return out if changed else list(messages)
|
||||||
|
|
||||||
|
|
||||||
def coerce_chat_messages(messages: Sequence[AnyChatMessage]) -> list[AnyChatMessage]:
|
def coerce_chat_messages(messages: Sequence[AnyChatMessage]) -> list[AnyChatMessage]:
|
||||||
"""Return *messages* as a chat-completions wire can take them.
|
"""Return *messages* as a chat-completions wire can take them.
|
||||||
|
|
||||||
@@ -46,18 +84,19 @@ def coerce_chat_messages(messages: Sequence[AnyChatMessage]) -> list[AnyChatMess
|
|||||||
and ``AssistantChatMessage.provider_blocks`` is dropped — those blocks
|
and ``AssistantChatMessage.provider_blocks`` is dropped — those blocks
|
||||||
belong to the surface that produced them (Anthropic ``thinking`` and
|
belong to the surface that produced them (Anthropic ``thinking`` and
|
||||||
server-tool blocks; see ``agent.messages_bridge``) and no other provider
|
server-tool blocks; see ``agent.messages_bridge``) and no other provider
|
||||||
knows the key.
|
knows the key. Tool-result images are lifted last (see
|
||||||
|
:func:`chat_messages_with_images`).
|
||||||
"""
|
"""
|
||||||
coerced = coerce_developer_messages_to_system(messages)
|
coerced = coerce_developer_messages_to_system(messages)
|
||||||
changed = False
|
changed = False
|
||||||
out: list[AnyChatMessage] = []
|
stripped: list[AnyChatMessage] = []
|
||||||
for message in coerced:
|
for message in coerced:
|
||||||
if isinstance(message, AssistantChatMessage) and message.provider_blocks is not UNSET:
|
if isinstance(message, AssistantChatMessage) and message.provider_blocks is not UNSET:
|
||||||
out.append(msgspec.structs.replace(message, provider_blocks=UNSET))
|
stripped.append(msgspec.structs.replace(message, provider_blocks=UNSET))
|
||||||
changed = True
|
changed = True
|
||||||
else:
|
else:
|
||||||
out.append(message)
|
stripped.append(message)
|
||||||
return out if changed else coerced
|
return chat_messages_with_images(stripped if changed else coerced)
|
||||||
|
|
||||||
|
|
||||||
def coerce_chat_completions_param(param: ChatCompletionsParam) -> ChatCompletionsParam:
|
def coerce_chat_completions_param(param: ChatCompletionsParam) -> ChatCompletionsParam:
|
||||||
|
|||||||
@@ -19,6 +19,41 @@ type Verbosity = Literal["low", "medium", "high"]
|
|||||||
type GrammarSyntax = Literal["lark", "regex"]
|
type GrammarSyntax = Literal["lark", "regex"]
|
||||||
|
|
||||||
|
|
||||||
|
type ImageDetail = Literal["low", "high", "original", "auto"]
|
||||||
|
|
||||||
|
|
||||||
|
class ChatImageUrl(Struct, omit_defaults=True):
|
||||||
|
"""The ``image_url`` payload of one image part (the canonical chat shape)."""
|
||||||
|
|
||||||
|
url: str
|
||||||
|
detail: ImageDetail | Unset = UNSET
|
||||||
|
|
||||||
|
|
||||||
|
class ChatImagePart(Struct, tag_field="type", tag="image_url"):
|
||||||
|
"""One image a message carries: an ``http(s)`` URL or a ``data:`` URL.
|
||||||
|
|
||||||
|
This is the shape OpenAI-compatible chat completions take. The other two
|
||||||
|
surfaces lift it into their own form — ``compat.chat_messages_with_images``
|
||||||
|
for chat completions, ``agent.messages_bridge`` (Anthropic ``image``
|
||||||
|
blocks) and ``agent.responses_bridge`` (``input_image`` parts).
|
||||||
|
"""
|
||||||
|
|
||||||
|
image_url: ChatImageUrl
|
||||||
|
|
||||||
|
|
||||||
|
class ChatTextPart(Struct, tag_field="type", tag="text"):
|
||||||
|
"""A text block beside images, when a message's content is a part array."""
|
||||||
|
|
||||||
|
text: str
|
||||||
|
|
||||||
|
|
||||||
|
type ChatContentPart = ChatTextPart | ChatImagePart
|
||||||
|
type ChatContent = str | list[ChatContentPart]
|
||||||
|
|
||||||
|
# What a text view of a part array shows where an image sits.
|
||||||
|
IMAGE_MARKER = "[image]"
|
||||||
|
|
||||||
|
|
||||||
class ChatMessage(Struct):
|
class ChatMessage(Struct):
|
||||||
content: str
|
content: str
|
||||||
|
|
||||||
@@ -28,6 +63,12 @@ class SystemChatMessage(ChatMessage, tag_field="role", tag="system"):
|
|||||||
|
|
||||||
|
|
||||||
class UserChatMessage(ChatMessage, tag_field="role", tag="user"):
|
class UserChatMessage(ChatMessage, tag_field="role", tag="user"):
|
||||||
|
# A user turn may carry images beside its text; chat completions already take
|
||||||
|
# that shape as ``content`` blocks, and the other two surfaces translate it
|
||||||
|
# (``agent.messages_bridge``, ``agent.responses_bridge``). The wider type is
|
||||||
|
# deliberate: only a user turn takes images, so the base stays a string and
|
||||||
|
# prose-only callers read it through ``content_to_text``.
|
||||||
|
content: str | list[ChatContentPart] # pyright: ignore[reportIncompatibleVariableOverride]
|
||||||
name: str | Unset = UNSET
|
name: str | Unset = UNSET
|
||||||
|
|
||||||
|
|
||||||
@@ -35,6 +76,17 @@ class DeveloperChatMessage(ChatMessage, tag_field="role", tag="developer"):
|
|||||||
name: str | Unset = UNSET
|
name: str | Unset = UNSET
|
||||||
|
|
||||||
|
|
||||||
|
def content_to_text(content: ChatContent) -> str:
|
||||||
|
"""The plain text of a message ``content``, with ``[image]`` for each image.
|
||||||
|
|
||||||
|
Callers that only need prose (summaries, transcripts, exports, comparisons)
|
||||||
|
must not print a part array's ``repr``.
|
||||||
|
"""
|
||||||
|
if isinstance(content, str):
|
||||||
|
return content
|
||||||
|
return " ".join(part.text if isinstance(part, ChatTextPart) else IMAGE_MARKER for part in content)
|
||||||
|
|
||||||
|
|
||||||
type NamedChatMessage = SystemChatMessage | UserChatMessage | DeveloperChatMessage
|
type NamedChatMessage = SystemChatMessage | UserChatMessage | DeveloperChatMessage
|
||||||
|
|
||||||
|
|
||||||
@@ -84,6 +136,11 @@ class AssistantChatMessage(Struct, tag_field="role", tag="assistant"):
|
|||||||
|
|
||||||
class ToolChatMessage(ChatMessage, tag_field="role", tag="tool"):
|
class ToolChatMessage(ChatMessage, tag_field="role", tag="tool"):
|
||||||
tool_call_id: str
|
tool_call_id: str
|
||||||
|
# Images the tool returned. Chat completions cannot put an image in a
|
||||||
|
# ``tool`` message, so on that wire they move into the user message that
|
||||||
|
# follows the tool run (``compat.chat_messages_with_images``); the Anthropic
|
||||||
|
# and Responses surfaces carry them in the result itself.
|
||||||
|
images: list[ChatImagePart] | Unset = UNSET
|
||||||
|
|
||||||
|
|
||||||
type AnyChatMessage = (
|
type AnyChatMessage = (
|
||||||
|
|||||||
@@ -20,10 +20,12 @@ from plyngent.agent.messages_bridge import (
|
|||||||
)
|
)
|
||||||
from plyngent.lmproto.anthropic.model import (
|
from plyngent.lmproto.anthropic.model import (
|
||||||
AnthropicAssistantMessage,
|
AnthropicAssistantMessage,
|
||||||
|
AnthropicImageContent,
|
||||||
AnthropicMessageResponse,
|
AnthropicMessageResponse,
|
||||||
AnthropicResponseText,
|
AnthropicResponseText,
|
||||||
AnthropicResponseToolUse,
|
AnthropicResponseToolUse,
|
||||||
AnthropicServerToolUseContent,
|
AnthropicServerToolUseContent,
|
||||||
|
AnthropicTextContent,
|
||||||
AnthropicThinkingContent,
|
AnthropicThinkingContent,
|
||||||
AnthropicToolDefinition,
|
AnthropicToolDefinition,
|
||||||
AnthropicToolResultContent,
|
AnthropicToolResultContent,
|
||||||
@@ -37,6 +39,9 @@ from plyngent.lmproto.openai_compatible.model import (
|
|||||||
AssistantFunctionTool,
|
AssistantFunctionTool,
|
||||||
AssistantFunctionToolCall,
|
AssistantFunctionToolCall,
|
||||||
ChatCompletionsParam,
|
ChatCompletionsParam,
|
||||||
|
ChatImagePart,
|
||||||
|
ChatImageUrl,
|
||||||
|
ChatTextPart,
|
||||||
DeveloperChatMessage,
|
DeveloperChatMessage,
|
||||||
ReasoningEffort,
|
ReasoningEffort,
|
||||||
SystemChatMessage,
|
SystemChatMessage,
|
||||||
@@ -136,6 +141,57 @@ def test_merge_consecutive_tool_results() -> None:
|
|||||||
assert len(items[2].content) == 2
|
assert len(items[2].content) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_user_turn_carries_its_images_as_blocks() -> None:
|
||||||
|
messages = [
|
||||||
|
UserChatMessage(
|
||||||
|
content=[
|
||||||
|
ChatTextPart(text="what is this?"),
|
||||||
|
ChatImagePart(image_url=ChatImageUrl(url="data:image/png;base64,AAAA", detail="low")),
|
||||||
|
ChatImagePart(image_url=ChatImageUrl(url="https://example.com/a.jpg")),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
]
|
||||||
|
_, items = chat_messages_to_anthropic(messages)
|
||||||
|
|
||||||
|
assert isinstance(items[0], AnthropicUserMessage)
|
||||||
|
blocks = items[0].content
|
||||||
|
assert isinstance(blocks, list)
|
||||||
|
assert blocks[0] == AnthropicTextContent(text="what is this?")
|
||||||
|
# An inline data URL becomes a base64 source carrying the sniffed media type.
|
||||||
|
assert blocks[1] == AnthropicImageContent(source={"type": "base64", "media_type": "image/png", "data": "AAAA"})
|
||||||
|
# A public link stays a url source.
|
||||||
|
assert blocks[2] == AnthropicImageContent(source={"type": "url", "url": "https://example.com/a.jpg"})
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_tool_result_carries_its_images_inside_the_result() -> None:
|
||||||
|
messages = [
|
||||||
|
UserChatMessage(content="screenshot please"),
|
||||||
|
AssistantChatMessage(
|
||||||
|
content=None,
|
||||||
|
tool_calls=[
|
||||||
|
AssistantFunctionToolCall(id="c1", function=AssistantFunctionTool(name="view_image", arguments="{}"))
|
||||||
|
],
|
||||||
|
),
|
||||||
|
ToolChatMessage(
|
||||||
|
content="image: shot.png (2 KB)",
|
||||||
|
tool_call_id="c1",
|
||||||
|
images=[ChatImagePart(image_url=ChatImageUrl(url="data:image/png;base64,AAAA"))],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
_, items = chat_messages_to_anthropic(messages)
|
||||||
|
|
||||||
|
result = items[2]
|
||||||
|
assert isinstance(result, AnthropicUserMessage)
|
||||||
|
assert isinstance(result.content, list)
|
||||||
|
block = result.content[0]
|
||||||
|
assert isinstance(block, AnthropicToolResultContent)
|
||||||
|
assert block.tool_use_id == "c1"
|
||||||
|
assert block.content == [
|
||||||
|
AnthropicTextContent(text="image: shot.png (2 KB)"),
|
||||||
|
AnthropicImageContent(source={"type": "base64", "media_type": "image/png", "data": "AAAA"}),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_anthropic_response_to_assistant_with_tools() -> None:
|
def test_anthropic_response_to_assistant_with_tools() -> None:
|
||||||
response = AnthropicMessageResponse(
|
response = AnthropicMessageResponse(
|
||||||
id="msg_1",
|
id="msg_1",
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ from plyngent.lmproto.openai_compatible.model import (
|
|||||||
AssistantFunctionTool,
|
AssistantFunctionTool,
|
||||||
AssistantFunctionToolCall,
|
AssistantFunctionToolCall,
|
||||||
ChatCompletionsParam,
|
ChatCompletionsParam,
|
||||||
|
ChatImagePart,
|
||||||
|
ChatImageUrl,
|
||||||
|
ChatTextPart,
|
||||||
DeveloperChatMessage,
|
DeveloperChatMessage,
|
||||||
SystemChatMessage,
|
SystemChatMessage,
|
||||||
ToolChatMessage,
|
ToolChatMessage,
|
||||||
@@ -76,6 +79,62 @@ def test_chat_messages_to_input_and_instructions() -> None:
|
|||||||
assert _input_item_types(items) == ["message", "reasoning", "function_call", "function_call_output"]
|
assert _input_item_types(items) == ["message", "reasoning", "function_call", "function_call_output"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_user_turn_carries_its_images_as_input_parts() -> None:
|
||||||
|
messages = [
|
||||||
|
UserChatMessage(
|
||||||
|
content=[
|
||||||
|
ChatTextPart(text="what is this?"),
|
||||||
|
ChatImagePart(image_url=ChatImageUrl(url="https://example.com/a.jpg", detail="low")),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
]
|
||||||
|
_, items = chat_messages_to_responses_input(messages)
|
||||||
|
|
||||||
|
assert items[0] == ResponseEasyInputMessage(
|
||||||
|
role="user",
|
||||||
|
content=[
|
||||||
|
{"type": "input_text", "text": "what is this?"},
|
||||||
|
{"type": "input_image", "image_url": "https://example.com/a.jpg", "detail": "low"},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_tool_output_carries_the_images_the_tool_returned() -> None:
|
||||||
|
messages = [
|
||||||
|
AssistantChatMessage(
|
||||||
|
content=None,
|
||||||
|
tool_calls=[
|
||||||
|
AssistantFunctionToolCall(
|
||||||
|
id="call_1", function=AssistantFunctionTool(name="view_image", arguments="{}")
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
ToolChatMessage(
|
||||||
|
content="image: shot.png (2 KB)",
|
||||||
|
tool_call_id="call_1",
|
||||||
|
images=[ChatImagePart(image_url=ChatImageUrl(url="data:image/png;base64,AAAA"))],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
_, items = chat_messages_to_responses_input(messages)
|
||||||
|
|
||||||
|
output = items[-1]
|
||||||
|
assert isinstance(output, ResponseFunctionToolCallOutput)
|
||||||
|
assert output.call_id == "call_1"
|
||||||
|
assert output.output == [
|
||||||
|
{"type": "input_text", "text": "image: shot.png (2 KB)"},
|
||||||
|
{"type": "input_image", "image_url": "data:image/png;base64,AAAA"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_plain_tool_output_stays_a_string() -> None:
|
||||||
|
messages = [ToolChatMessage(content="file body", tool_call_id="call_1")]
|
||||||
|
_, items = chat_messages_to_responses_input(messages)
|
||||||
|
|
||||||
|
output = items[0]
|
||||||
|
assert isinstance(output, ResponseFunctionToolCallOutput)
|
||||||
|
assert output.output == "file body"
|
||||||
|
|
||||||
|
|
||||||
def test_parallel_tool_outputs_group_after_the_rounds_calls() -> None:
|
def test_parallel_tool_outputs_group_after_the_rounds_calls() -> None:
|
||||||
"""Regression: DeepSeek Responses rejected an interleaved batch.
|
"""Regression: DeepSeek Responses rejected an interleaved batch.
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
import msgspec
|
import msgspec
|
||||||
from msgspec import UNSET
|
from msgspec import UNSET
|
||||||
@@ -18,10 +19,18 @@ from plyngent.lmproto.openai_compatible.compat import (
|
|||||||
from plyngent.lmproto.openai_compatible.model import (
|
from plyngent.lmproto.openai_compatible.model import (
|
||||||
AnyChatMessage,
|
AnyChatMessage,
|
||||||
AssistantChatMessage,
|
AssistantChatMessage,
|
||||||
|
AssistantFunctionTool,
|
||||||
|
AssistantFunctionToolCall,
|
||||||
ChatCompletionsParam,
|
ChatCompletionsParam,
|
||||||
|
ChatImagePart,
|
||||||
|
ChatImageUrl,
|
||||||
|
ChatTextPart,
|
||||||
DeveloperChatMessage,
|
DeveloperChatMessage,
|
||||||
|
ImageDetail,
|
||||||
SystemChatMessage,
|
SystemChatMessage,
|
||||||
|
ToolChatMessage,
|
||||||
UserChatMessage,
|
UserChatMessage,
|
||||||
|
content_to_text,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -119,3 +128,82 @@ def test_coerce_chat_messages_drops_provider_blocks() -> None:
|
|||||||
def test_coerce_chat_messages_leaves_plain_history_alone() -> None:
|
def test_coerce_chat_messages_leaves_plain_history_alone() -> None:
|
||||||
messages: list[AnyChatMessage] = [UserChatMessage(content="hi"), AssistantChatMessage(content="done")]
|
messages: list[AnyChatMessage] = [UserChatMessage(content="hi"), AssistantChatMessage(content="done")]
|
||||||
assert coerce_chat_messages(messages) == messages
|
assert coerce_chat_messages(messages) == messages
|
||||||
|
|
||||||
|
|
||||||
|
def _image(url: str = "data:image/png;base64,AAAA", detail: ImageDetail = "low") -> ChatImagePart:
|
||||||
|
return ChatImagePart(image_url=ChatImageUrl(url=url, detail=detail))
|
||||||
|
|
||||||
|
|
||||||
|
def _wire(messages: Sequence[AnyChatMessage]) -> list[dict[str, Any]]:
|
||||||
|
param = ChatCompletionsParam(model="m", messages=coerce_chat_messages(messages))
|
||||||
|
raw: dict[str, Any] = msgspec.json.decode(msgspec.json.encode(param))
|
||||||
|
messages_raw: list[dict[str, Any]] = raw["messages"]
|
||||||
|
return messages_raw
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_images_reach_the_chat_wire_as_blocks() -> None:
|
||||||
|
messages: list[AnyChatMessage] = [
|
||||||
|
UserChatMessage(
|
||||||
|
content=[
|
||||||
|
ChatTextPart(text="what is this?"),
|
||||||
|
ChatImagePart(image_url=ChatImageUrl(url="https://example.com/a.png", detail="low")),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
assert _wire(messages) == [
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": [
|
||||||
|
{"type": "text", "text": "what is this?"},
|
||||||
|
{"type": "image_url", "image_url": {"url": "https://example.com/a.png", "detail": "low"}},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_tool_result_images_move_into_the_user_turn_after_the_tool_run() -> None:
|
||||||
|
messages: list[AnyChatMessage] = [
|
||||||
|
AssistantChatMessage(
|
||||||
|
content="",
|
||||||
|
tool_calls=[
|
||||||
|
AssistantFunctionToolCall(
|
||||||
|
id="call-1", function=AssistantFunctionTool(name="view_image", arguments="{}")
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
ToolChatMessage(content="image: shot.png (1 KB)", tool_call_id="call-1", images=[_image()]),
|
||||||
|
ToolChatMessage(content="second result", tool_call_id="call-2"),
|
||||||
|
AssistantChatMessage(content="I can see it."),
|
||||||
|
]
|
||||||
|
raw = _wire(messages)
|
||||||
|
|
||||||
|
assert [item["role"] for item in raw] == ["assistant", "tool", "tool", "user", "assistant"]
|
||||||
|
# The tool message keeps its text and loses the field chat completions cannot send.
|
||||||
|
assert raw[1] == {"role": "tool", "content": "image: shot.png (1 KB)", "tool_call_id": "call-1"}
|
||||||
|
assert raw[3]["content"] == [
|
||||||
|
{"type": "image_url", "image_url": {"url": "data:image/png;base64,AAAA", "detail": "low"}}
|
||||||
|
]
|
||||||
|
# The stored history keeps its own image parts.
|
||||||
|
assert messages[1].images is not UNSET
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_tool_result_without_images_keeps_the_history_untouched() -> None:
|
||||||
|
messages: list[AnyChatMessage] = [ToolChatMessage(content="plain", tool_call_id="call-1")]
|
||||||
|
assert coerce_chat_messages(messages) == messages
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_deepseek_param_takes_a_user_turn_with_images() -> None:
|
||||||
|
base = DeepseekChatCompletionsParam(
|
||||||
|
model="deepseek-flash",
|
||||||
|
messages=[UserChatMessage(content=[_image()])],
|
||||||
|
)
|
||||||
|
coerced = coerce_chat_completions_param_any(base)
|
||||||
|
raw = msgspec.json.decode(msgspec.json.encode(coerced))
|
||||||
|
|
||||||
|
assert raw["messages"][0]["content"][0]["type"] == "image_url"
|
||||||
|
|
||||||
|
|
||||||
|
def test_content_to_text_replaces_images_with_a_marker() -> None:
|
||||||
|
assert content_to_text("plain") == "plain"
|
||||||
|
assert content_to_text([ChatTextPart(text="look:"), _image()]) == "look: [image]"
|
||||||
|
|||||||
Reference in New Issue
Block a user