From 9600d2f75733f81fa4c374916c90c348dc6ec662 Mon Sep 17 00:00:00 2001 From: worldmozara Date: Tue, 28 Jul 2026 22:16:36 +0800 Subject: [PATCH] core/lmproto: coerce developer role for chat completions --- .../lmproto/deepseek/openai_compat/client.py | 3 +- .../lmproto/openai_compatible/client.py | 3 +- .../lmproto/openai_compatible/compat.py | 62 +++++++++++++ tests/test_lmproto/test_chat_compat.py | 90 +++++++++++++++++++ 4 files changed, 156 insertions(+), 2 deletions(-) create mode 100644 src/plyngent/lmproto/openai_compatible/compat.py create mode 100644 tests/test_lmproto/test_chat_compat.py diff --git a/src/plyngent/lmproto/deepseek/openai_compat/client.py b/src/plyngent/lmproto/deepseek/openai_compat/client.py index cb57f38..2a9c2a3 100644 --- a/src/plyngent/lmproto/deepseek/openai_compat/client.py +++ b/src/plyngent/lmproto/deepseek/openai_compat/client.py @@ -3,6 +3,7 @@ from typing import TYPE_CHECKING, Literal, overload import msgspec from ...openai_compatible.client import BaseOpenAIClient, read_response_body +from ...openai_compatible.compat import coerce_chat_completions_param_any if TYPE_CHECKING: from collections.abc import AsyncIterator @@ -29,7 +30,7 @@ class DeepseekOpenAIClient(BaseOpenAIClient): async def chat_completions( self, param: ChatCompletionsParam, *, stream: bool = False ) -> ChatCompletionResponse | AsyncIterator[ChatCompletionChunk]: - param = msgspec.structs.replace(param, stream=stream) + param = coerce_chat_completions_param_any(msgspec.structs.replace(param, stream=stream)) data = self.encoder.encode(param) if stream: resp = await self.session.post( diff --git a/src/plyngent/lmproto/openai_compatible/client.py b/src/plyngent/lmproto/openai_compatible/client.py index 224c3db..257e175 100644 --- a/src/plyngent/lmproto/openai_compatible/client.py +++ b/src/plyngent/lmproto/openai_compatible/client.py @@ -8,6 +8,7 @@ import niquests from msgspec import UNSET from niquests.auth import BearerTokenAuth +from .compat import coerce_chat_completions_param from .config import OpenAIConfig # noqa: TC001 from .model import ( AssistantFunctionTool, @@ -196,7 +197,7 @@ class OpenAICompatibleClient(BaseOpenAIClient): self, param: ChatCompletionsParam, *, stream: bool = False ) -> ChatCompletionResponse | AsyncIterator[ChatCompletionChunk]: # Library pattern: async def returns AsyncIterator when stream=True. - param = msgspec.structs.replace(param, stream=stream) + param = coerce_chat_completions_param(msgspec.structs.replace(param, stream=stream)) data = self.encoder.encode(param) if stream: resp = await self.session.post( diff --git a/src/plyngent/lmproto/openai_compatible/compat.py b/src/plyngent/lmproto/openai_compatible/compat.py new file mode 100644 index 0000000..067ccdc --- /dev/null +++ b/src/plyngent/lmproto/openai_compatible/compat.py @@ -0,0 +1,62 @@ +"""Message normalization for chat-completions-compatible wire APIs.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, cast + +import msgspec + +from .model import DeveloperChatMessage, SystemChatMessage + +if TYPE_CHECKING: + from collections.abc import Sequence + + from .model import AnyChatMessage, ChatCompletionsParam + + +def developer_to_system_message(message: DeveloperChatMessage) -> SystemChatMessage: + """Convert an internal developer-role message to classic chat ``system`` role.""" + return SystemChatMessage(content=message.content, name=message.name) + + +def coerce_developer_messages_to_system(messages: Sequence[AnyChatMessage]) -> list[AnyChatMessage]: + """Return *messages* with ``developer`` roles rewritten as ``system``. + + Most OpenAI-compatible ``/chat/completions`` providers only accept + system/user/assistant/tool. Internal developer checkpoints still carry + control-plane intent best as ``system`` on that wire protocol. + """ + changed = False + out: list[AnyChatMessage] = [] + for message in messages: + if isinstance(message, DeveloperChatMessage): + out.append(developer_to_system_message(message)) + changed = True + else: + out.append(message) + return out if changed else list(messages) + + +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) + if messages == param.messages: + return param + return msgspec.structs.replace(param, messages=messages) + + +def coerce_chat_completions_param_any(param: Any) -> Any: + """Generic variant for provider-specific chat param structs. + + DeepSeek's request type excludes ``DeveloperChatMessage`` from its annotation, + but callers can still pass the generic agent param shape at runtime. Replace + messages structurally before encoding without depending on the exact class. + """ + messages_obj: Any = getattr(param, "messages", None) + if not isinstance(messages_obj, list): + return param + messages = cast("list[AnyChatMessage]", messages_obj) + coerced = coerce_developer_messages_to_system(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 new file mode 100644 index 0000000..46463f8 --- /dev/null +++ b/tests/test_lmproto/test_chat_compat.py @@ -0,0 +1,90 @@ +"""Compatibility normalization for chat-completions wire requests.""" + +from __future__ import annotations + +from collections.abc import Sequence + +import msgspec + +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_developer_messages_to_system, + developer_to_system_message, +) +from plyngent.lmproto.openai_compatible.model import ( + AnyChatMessage, + AssistantChatMessage, + ChatCompletionsParam, + DeveloperChatMessage, + SystemChatMessage, + UserChatMessage, +) + + +def _roles(messages: Sequence[object]) -> list[str]: + raw = msgspec.json.decode(msgspec.json.encode(messages)) + return [str(item["role"]) for item in raw] + + +def test_developer_to_system_message_preserves_content_and_name() -> None: + msg = DeveloperChatMessage(content="follow these rules", name="policy") + converted = developer_to_system_message(msg) + assert isinstance(converted, SystemChatMessage) + assert converted.content == "follow these rules" + assert converted.name == "policy" + + +def test_coerce_developer_messages_to_system() -> None: + messages: list[AnyChatMessage] = [ + SystemChatMessage(content="system"), + DeveloperChatMessage(content="dev"), + UserChatMessage(content="hi"), + AssistantChatMessage(content="hello"), + ] + coerced = coerce_developer_messages_to_system(messages) + assert coerced is not messages + assert _roles(coerced) == ["system", "system", "user", "assistant"] + assert coerced[1].content == "dev" + + +def test_coerce_returns_equal_list_when_no_developer() -> None: + messages: list[AnyChatMessage] = [SystemChatMessage(content="system"), UserChatMessage(content="hi")] + assert coerce_developer_messages_to_system(messages) == messages + + +def test_coerce_chat_param_encodes_without_developer_role() -> None: + param = ChatCompletionsParam( + model="model", + messages=[ + DeveloperChatMessage(content="dev"), + UserChatMessage(content="hi"), + ], + ) + coerced = coerce_chat_completions_param(param) + raw = msgspec.json.decode(msgspec.json.encode(coerced)) + assert raw["messages"][0]["role"] == "system" + assert raw["messages"][1]["role"] == "user" + + +def test_deepseek_param_accepts_coerced_messages() -> None: + base = ChatCompletionsParam( + model="deepseek", + messages=[DeveloperChatMessage(content="dev"), UserChatMessage(content="hi")], + ) + coerced = coerce_chat_completions_param(base) + raw = msgspec.json.decode(msgspec.json.encode(coerced)) + deepseek_param = msgspec.convert(raw, DeepseekChatCompletionsParam) + assert _roles(deepseek_param.messages) == ["system", "user"] + + +def test_generic_coercion_handles_deepseek_runtime_param() -> None: + base = ChatCompletionsParam( + model="deepseek", + messages=[DeveloperChatMessage(content="dev"), UserChatMessage(content="hi")], + ) + # Runtime calls may pass a generic param through a DeepSeek-typed method. + coerced = coerce_chat_completions_param_any(base) + raw = msgspec.json.decode(msgspec.json.encode(coerced)) + assert raw["messages"][0]["role"] == "system"