core/lmproto: coerce developer role for chat completions

This commit is contained in:
2026-07-28 22:16:36 +08:00
parent c7ee9b0063
commit 9600d2f757
4 changed files with 156 additions and 2 deletions
@@ -3,6 +3,7 @@ from typing import TYPE_CHECKING, Literal, overload
import msgspec import msgspec
from ...openai_compatible.client import BaseOpenAIClient, read_response_body from ...openai_compatible.client import BaseOpenAIClient, read_response_body
from ...openai_compatible.compat import coerce_chat_completions_param_any
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
@@ -29,7 +30,7 @@ class DeepseekOpenAIClient(BaseOpenAIClient):
async def chat_completions( async def chat_completions(
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 = coerce_chat_completions_param_any(msgspec.structs.replace(param, stream=stream))
data = self.encoder.encode(param) data = self.encoder.encode(param)
if stream: if stream:
resp = await self.session.post( resp = await self.session.post(
@@ -8,6 +8,7 @@ import niquests
from msgspec import UNSET from msgspec import UNSET
from niquests.auth import BearerTokenAuth from niquests.auth import BearerTokenAuth
from .compat import coerce_chat_completions_param
from .config import OpenAIConfig # noqa: TC001 from .config import OpenAIConfig # noqa: TC001
from .model import ( from .model import (
AssistantFunctionTool, AssistantFunctionTool,
@@ -196,7 +197,7 @@ class OpenAICompatibleClient(BaseOpenAIClient):
self, param: ChatCompletionsParam, *, stream: bool = False self, param: ChatCompletionsParam, *, stream: bool = False
) -> ChatCompletionResponse | AsyncIterator[ChatCompletionChunk]: ) -> ChatCompletionResponse | AsyncIterator[ChatCompletionChunk]:
# Library pattern: async def returns AsyncIterator when stream=True. # 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) data = self.encoder.encode(param)
if stream: if stream:
resp = await self.session.post( resp = await self.session.post(
@@ -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)
+90
View File
@@ -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"