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
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)