From f89c8dec31ed9c107d3e6e7e77a753f5db5097ba Mon Sep 17 00:00:00 2001 From: worldmozara Date: Fri, 9 Oct 2026 20:09:36 +0800 Subject: [PATCH] lmproto/openai_compatible: drop provider blocks on a chat wire `provider_blocks` means something only to the surface that produced them (Anthropic `thinking` / server-tool blocks). A session that switched surface would otherwise send those dicts to a chat-completions provider as an assistant-message key it does not know; the request normalization drops the field, on a copy, so the history keeps it. --- .../lmproto/openai_compatible/compat.py | 29 +++++++++++++++-- tests/test_lmproto/test_chat_compat.py | 31 +++++++++++++++++++ 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/src/plyngent/lmproto/openai_compatible/compat.py b/src/plyngent/lmproto/openai_compatible/compat.py index 067ccdc..77c4a3f 100644 --- a/src/plyngent/lmproto/openai_compatible/compat.py +++ b/src/plyngent/lmproto/openai_compatible/compat.py @@ -5,8 +5,9 @@ from __future__ import annotations from typing import TYPE_CHECKING, Any, cast import msgspec +from msgspec import UNSET -from .model import DeveloperChatMessage, SystemChatMessage +from .model import AssistantChatMessage, DeveloperChatMessage, SystemChatMessage if TYPE_CHECKING: from collections.abc import Sequence @@ -37,9 +38,31 @@ def coerce_developer_messages_to_system(messages: Sequence[AnyChatMessage]) -> l return out if changed else list(messages) +def coerce_chat_messages(messages: Sequence[AnyChatMessage]) -> list[AnyChatMessage]: + """Return *messages* as a chat-completions wire can take them. + + Both normalizations are for broad provider compatibility: a ``developer`` + role becomes ``system`` (see :func:`coerce_developer_messages_to_system`), + and ``AssistantChatMessage.provider_blocks`` is dropped — those blocks + belong to the surface that produced them (Anthropic ``thinking`` and + server-tool blocks; see ``agent.messages_bridge``) and no other provider + knows the key. + """ + coerced = coerce_developer_messages_to_system(messages) + changed = False + out: list[AnyChatMessage] = [] + for message in coerced: + if isinstance(message, AssistantChatMessage) and message.provider_blocks is not UNSET: + out.append(msgspec.structs.replace(message, provider_blocks=UNSET)) + changed = True + else: + out.append(message) + return out if changed else coerced + + def coerce_chat_completions_param(param: ChatCompletionsParam) -> ChatCompletionsParam: """Normalize a chat-completions request for broad provider compatibility.""" - messages = coerce_developer_messages_to_system(param.messages) + messages = coerce_chat_messages(param.messages) if messages == param.messages: return param return msgspec.structs.replace(param, messages=messages) @@ -56,7 +79,7 @@ def coerce_chat_completions_param_any(param: Any) -> Any: if not isinstance(messages_obj, list): return param messages = cast("list[AnyChatMessage]", messages_obj) - coerced = coerce_developer_messages_to_system(messages) + coerced = coerce_chat_messages(messages) if coerced == messages: return param return msgspec.structs.replace(param, messages=coerced) diff --git a/tests/test_lmproto/test_chat_compat.py b/tests/test_lmproto/test_chat_compat.py index 46463f8..fa1addc 100644 --- a/tests/test_lmproto/test_chat_compat.py +++ b/tests/test_lmproto/test_chat_compat.py @@ -5,11 +5,13 @@ from __future__ import annotations from collections.abc import Sequence import msgspec +from msgspec import UNSET from plyngent.lmproto.deepseek.openai_compat.model import ChatCompletionsParam as DeepseekChatCompletionsParam from plyngent.lmproto.openai_compatible.compat import ( coerce_chat_completions_param, coerce_chat_completions_param_any, + coerce_chat_messages, coerce_developer_messages_to_system, developer_to_system_message, ) @@ -88,3 +90,32 @@ def test_generic_coercion_handles_deepseek_runtime_param() -> None: coerced = coerce_chat_completions_param_any(base) raw = msgspec.json.decode(msgspec.json.encode(coerced)) assert raw["messages"][0]["role"] == "system" + + +def test_coerce_chat_messages_drops_provider_blocks() -> None: + """Another surface's opaque blocks are not a chat-completions key.""" + messages: list[AnyChatMessage] = [ + UserChatMessage(content="hi"), + AssistantChatMessage( + content="done", + reasoning_content="hmm", + provider_blocks=[{"type": "thinking", "thinking": "hmm", "signature": "sig-1"}], + ), + ] + coerced = coerce_chat_messages(messages) + assistant = coerced[1] + assert isinstance(assistant, AssistantChatMessage) + assert assistant.provider_blocks is UNSET + # The reasoning slot is the provider-neutral one and stays. + assert assistant.reasoning_content == "hmm" + raw = msgspec.json.decode(msgspec.json.encode(ChatCompletionsParam(model="m", messages=coerced))) + assert "provider_blocks" not in raw["messages"][1] + # The history the copy came from keeps them for the surface that made them. + original = messages[1] + assert isinstance(original, AssistantChatMessage) + assert original.provider_blocks is not UNSET + + +def test_coerce_chat_messages_leaves_plain_history_alone() -> None: + messages: list[AnyChatMessage] = [UserChatMessage(content="hi"), AssistantChatMessage(content="done")] + assert coerce_chat_messages(messages) == messages