mirror of
https://github.com/NCBM/plyngent.git
synced 2026-11-07 15:37:55 +08:00
core/lmproto: coerce developer role for chat completions
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user