From 7f63e7980bda327e4d6657c1e8ed61a9efe987f6 Mon Sep 17 00:00:00 2001 From: worldmozara Date: Tue, 14 Jul 2026 17:19:16 +0800 Subject: [PATCH] core/lmproto: add openai-compatible and deepseek clients OpenAI-compatible models, config, and async client via niquests; DeepSeek OpenAI-compat extension with reasoning fields. --- src/plyngent/lmproto/deepseek/__init__.py | 8 + .../lmproto/deepseek/openai_compat/client.py | 49 ++++ .../lmproto/deepseek/openai_compat/model.py | 60 +++++ .../lmproto/openai_compatible/__init__.py | 35 +++ .../lmproto/openai_compatible/client.py | 75 ++++++ .../lmproto/openai_compatible/config.py | 7 + .../lmproto/openai_compatible/model.py | 232 ++++++++++++++++++ 7 files changed, 466 insertions(+) create mode 100644 src/plyngent/lmproto/deepseek/__init__.py create mode 100644 src/plyngent/lmproto/deepseek/openai_compat/client.py create mode 100644 src/plyngent/lmproto/deepseek/openai_compat/model.py create mode 100644 src/plyngent/lmproto/openai_compatible/__init__.py create mode 100644 src/plyngent/lmproto/openai_compatible/client.py create mode 100644 src/plyngent/lmproto/openai_compatible/config.py create mode 100644 src/plyngent/lmproto/openai_compatible/model.py diff --git a/src/plyngent/lmproto/deepseek/__init__.py b/src/plyngent/lmproto/deepseek/__init__.py new file mode 100644 index 0000000..a2c5087 --- /dev/null +++ b/src/plyngent/lmproto/deepseek/__init__.py @@ -0,0 +1,8 @@ +from .openai_compat.client import DeepseekOpenAIClient as DeepseekOpenAIClient +from .openai_compat.model import AnyChatMessage as AnyChatMessage +from .openai_compat.model import AssistantChatMessage as AssistantChatMessage +from .openai_compat.model import ChatCompletionsParam as ChatCompletionsParam +from .openai_compat.model import DeepSeekReasoningEffort as DeepSeekReasoningEffort +from .openai_compat.model import NamedChatMessage as NamedChatMessage +from .openai_compat.model import ThinkingOptions as ThinkingOptions +from .openai_compat.model import ToolChatMessage as ToolChatMessage diff --git a/src/plyngent/lmproto/deepseek/openai_compat/client.py b/src/plyngent/lmproto/deepseek/openai_compat/client.py new file mode 100644 index 0000000..f3b7e53 --- /dev/null +++ b/src/plyngent/lmproto/deepseek/openai_compat/client.py @@ -0,0 +1,49 @@ +from typing import TYPE_CHECKING, Literal, overload + +import msgspec + +from ...openai_compatible.client import BaseOpenAIClient + +if TYPE_CHECKING: + from collections.abc import AsyncIterator + + from ...openai_compatible.config import OpenAIConfig + from ...openai_compatible.model import ChatCompletionChunk, ChatCompletionResponse + from .model import ChatCompletionsParam + + +class DeepseekOpenAIClient(BaseOpenAIClient): + def __init__(self, config: OpenAIConfig) -> None: + super().__init__(config) + + @overload + async def chat_completions( + self, param: ChatCompletionsParam, *, stream: Literal[False] = False + ) -> ChatCompletionResponse: ... + + @overload + async def chat_completions( + self, param: ChatCompletionsParam, *, stream: Literal[True] + ) -> AsyncIterator[ChatCompletionChunk]: ... + + async def chat_completions( + self, param: ChatCompletionsParam, *, stream: bool = False + ) -> ChatCompletionResponse | AsyncIterator[ChatCompletionChunk]: + param = msgspec.structs.replace(param, stream=stream) + data = self.encoder.encode(param) + if stream: + resp = await self.session.post( + "/chat/completions", + data=data, + headers={"Content-Type": "application/json"}, + stream=True, + ) + return self._parse_sse(resp) + resp = await self.session.post( + "/chat/completions", + data=data, + headers={"Content-Type": "application/json"}, + stream=False, + ) + assert resp.content is not None + return self.decoder.decode(resp.content) diff --git a/src/plyngent/lmproto/deepseek/openai_compat/model.py b/src/plyngent/lmproto/deepseek/openai_compat/model.py new file mode 100644 index 0000000..e7e87d1 --- /dev/null +++ b/src/plyngent/lmproto/deepseek/openai_compat/model.py @@ -0,0 +1,60 @@ +from typing import Literal + +from msgspec import UNSET, Struct + +from plyngent.typedef import Unset # noqa: TC001 + +from ...openai_compatible.model import AssistantChatMessage as BaseAssistantChatMessage +from ...openai_compatible.model import ( + ChatMessage, + ReasoningEffort, + ResponseFormat, + StreamOptions, + ToolChoiceMode, + ToolFunctionItem, +) +from ...openai_compatible.model import ToolChatMessage as BaseToolChatMessage + +type DeepSeekReasoningEffort = ReasoningEffort | Literal["max"] + + +class NamedChatMessage(ChatMessage): + role: Literal["system", "user"] + name: str | Unset = UNSET + + +class AssistantChatMessage(BaseAssistantChatMessage): + prefix: bool | Unset = UNSET + reasoning_content: str | Unset = UNSET + + +class ToolChatMessage(BaseToolChatMessage): + pass + + +type AnyChatMessage = NamedChatMessage | AssistantChatMessage | ToolChatMessage + + +class ThinkingOptions(Struct): + type: Literal["enabled", "disabled"] + + +class ChatCompletionsParam(Struct): + messages: list[AnyChatMessage] + model: str + thinking: ThinkingOptions | Unset = UNSET + reasoning_effort: DeepSeekReasoningEffort | Unset = UNSET + max_tokens: int | Unset = UNSET + response_format: ResponseFormat | Unset = UNSET + stop: str | list[str] | Unset = UNSET + stream: bool | Unset = UNSET + stream_options: StreamOptions | Unset = UNSET + temperature: float | Unset = UNSET + top_p: int | Unset = UNSET + tool_choice: ToolChoiceMode | ToolFunctionItem | Unset = UNSET + tools: list[ToolFunctionItem] | Unset = UNSET + logprobs: bool | Unset = UNSET + top_logprobs: int | Unset = UNSET + user_id: str | Unset = UNSET + frequency_penalty: float | Unset = UNSET + presence_penalty: float | Unset = UNSET diff --git a/src/plyngent/lmproto/openai_compatible/__init__.py b/src/plyngent/lmproto/openai_compatible/__init__.py new file mode 100644 index 0000000..e4d5723 --- /dev/null +++ b/src/plyngent/lmproto/openai_compatible/__init__.py @@ -0,0 +1,35 @@ +from .client import BaseOpenAIClient as BaseOpenAIClient +from .client import OpenAIClient as OpenAIClient +from .config import OpenAIConfig as OpenAIConfig +from .model import AnyAssistantToolCall as AnyAssistantToolCall +from .model import AnyChatMessage as AnyChatMessage +from .model import AnyResponseFormat as AnyResponseFormat +from .model import AnyToolItem as AnyToolItem +from .model import AssistantChatMessage as AssistantChatMessage +from .model import AudioFormatStr as AudioFormatStr +from .model import AudioOptions as AudioOptions +from .model import CacheRetention as CacheRetention +from .model import ChatCompletionChoice as ChatCompletionChoice +from .model import ChatCompletionChunk as ChatCompletionChunk +from .model import ChatCompletionResponse as ChatCompletionResponse +from .model import ChatCompletionsParam as ChatCompletionsParam +from .model import ChatMessage as ChatMessage +from .model import ChunkChoice as ChunkChoice +from .model import DeltaMessage as DeltaMessage +from .model import FinishReason as FinishReason +from .model import GrammarSyntax as GrammarSyntax +from .model import Modality as Modality +from .model import NamedChatMessage as NamedChatMessage +from .model import NamedRole as NamedRole +from .model import ReasoningEffort as ReasoningEffort +from .model import ResponseFormat as ResponseFormat +from .model import RoleAssistant as RoleAssistant +from .model import RoleTool as RoleTool +from .model import SchemaResponseFormat as SchemaResponseFormat +from .model import ServiceTier as ServiceTier +from .model import StreamOptions as StreamOptions +from .model import ToolChatMessage as ToolChatMessage +from .model import ToolChoiceMode as ToolChoiceMode +from .model import ToolFunctionItem as ToolFunctionItem +from .model import Verbosity as Verbosity +from .model import VoiceName as VoiceName diff --git a/src/plyngent/lmproto/openai_compatible/client.py b/src/plyngent/lmproto/openai_compatible/client.py new file mode 100644 index 0000000..62ee035 --- /dev/null +++ b/src/plyngent/lmproto/openai_compatible/client.py @@ -0,0 +1,75 @@ +from typing import TYPE_CHECKING, Literal, overload + +import msgspec +import niquests +from niquests.auth import BearerTokenAuth + +from .config import OpenAIConfig # noqa: TC001 +from .model import ChatCompletionChunk, ChatCompletionResponse, ChatCompletionsParam + +if TYPE_CHECKING: + from collections.abc import AsyncIterator + + from niquests.async_session import AsyncSession + from niquests.models import AsyncResponse + + +class BaseOpenAIClient: + session: AsyncSession + encoder: msgspec.json.Encoder + decoder: msgspec.json.Decoder[ChatCompletionResponse] + chunk_decoder: msgspec.json.Decoder[ChatCompletionChunk] + + def __init__(self, config: OpenAIConfig) -> None: + self.session = niquests.AsyncSession( + base_url=config.base_url, + auth=BearerTokenAuth(config.access_key_or_token), + ) + self.encoder = msgspec.json.Encoder() + self.decoder = msgspec.json.Decoder(ChatCompletionResponse) + self.chunk_decoder = msgspec.json.Decoder(ChatCompletionChunk) + + async def _parse_sse(self, resp: AsyncResponse) -> AsyncIterator[ChatCompletionChunk]: + lines = resp.iter_lines() + async for line in lines: + if not line or line == b"data: [DONE]": + continue + if line.startswith(b"data: "): + yield self.chunk_decoder.decode(line[6:]) + + +class OpenAIClient(BaseOpenAIClient): + def __init__(self, config: OpenAIConfig) -> None: + super().__init__(config) + + @overload + async def chat_completions( + self, param: ChatCompletionsParam, *, stream: Literal[False] = False + ) -> ChatCompletionResponse: ... + + @overload + async def chat_completions( + self, param: ChatCompletionsParam, *, stream: Literal[True] + ) -> AsyncIterator[ChatCompletionChunk]: ... + + async def chat_completions( + self, param: ChatCompletionsParam, *, stream: bool = False + ) -> ChatCompletionResponse | AsyncIterator[ChatCompletionChunk]: + param = msgspec.structs.replace(param, stream=stream) + data = self.encoder.encode(param) + if stream: + resp = await self.session.post( + "/chat/completions", + data=data, + headers={"Content-Type": "application/json"}, + stream=True, + ) + return self._parse_sse(resp) + resp = await self.session.post( + "/chat/completions", + data=data, + headers={"Content-Type": "application/json"}, + stream=False, + ) + assert resp.content is not None + return self.decoder.decode(resp.content) diff --git a/src/plyngent/lmproto/openai_compatible/config.py b/src/plyngent/lmproto/openai_compatible/config.py new file mode 100644 index 0000000..1de9c46 --- /dev/null +++ b/src/plyngent/lmproto/openai_compatible/config.py @@ -0,0 +1,7 @@ +from dataclasses import dataclass + + +@dataclass +class OpenAIConfig: + access_key_or_token: str + base_url: str = "https://api.openai.com/v1" diff --git a/src/plyngent/lmproto/openai_compatible/model.py b/src/plyngent/lmproto/openai_compatible/model.py new file mode 100644 index 0000000..c3e77cd --- /dev/null +++ b/src/plyngent/lmproto/openai_compatible/model.py @@ -0,0 +1,232 @@ +from typing import Any, Literal + +from msgspec import UNSET, Struct + +from plyngent.typedef import JSONSchema, Unset # noqa: TC001 + +type NamedRole = Literal["developer", "system", "user"] +type RoleAssistant = Literal["assistant"] +type RoleTool = Literal["tool"] +type ReasoningEffort = Literal["none", "minimal", "low", "medium", "high", "xhigh"] +type ServiceTier = Literal["auto", "default", "flex", "scale", "priority"] +type ToolChoiceMode = Literal["none", "auto", "required"] +type AudioFormatStr = Literal["wav", "aac", "mp3", "flac", "opus", "pcm16"] +type VoiceName = Literal["alloy", "ash", "ballad", "coral", "echo", "sage", "shimmer", "verse", "marin", "cedar"] +type FinishReason = Literal["stop", "length", "tool_calls", "content_filter", "function_call"] +type Modality = Literal["text", "audio"] +type CacheRetention = Literal["in_memory", "24h"] +type Verbosity = Literal["low", "medium", "high"] +type GrammarSyntax = Literal["lark", "regex"] + + +class ChatMessage(Struct): + content: str + + +class NamedChatMessage(ChatMessage): + role: NamedRole + name: str | Unset = UNSET + + +class IDObject(Struct): + id: str + + +class AssistantFunctionTool(Struct): + name: str + arguments: str + + +class AssistantFunctionToolCall(Struct): + id: str + type: Literal["function"] + function: AssistantFunctionTool + + +class AssistantCustomTool(Struct): + name: str + input: str + + +class AssistantCustomToolCall(Struct): + id: str + type: Literal["custom"] + custom: AssistantCustomTool + + +type AnyAssistantToolCall = AssistantFunctionToolCall | AssistantCustomToolCall + + +class AssistantChatMessage(ChatMessage): + role: RoleAssistant + name: str | Unset = UNSET + audio: IDObject | Unset = UNSET + refusal: str | Unset = UNSET + tool_calls: list[AnyAssistantToolCall] | Unset = UNSET + + +class ToolChatMessage(ChatMessage): + role: RoleTool + tool_call_id: str + + +type AnyChatMessage = NamedChatMessage | AssistantChatMessage | ToolChatMessage + + +class ResponseFormat(Struct): + type: Literal["text", "json_object"] + + +class SchemaResponseFormat(Struct): + type: Literal["json_schema"] + json_schema: JSONSchema + + +type AnyResponseFormat = ResponseFormat | SchemaResponseFormat + + +class ToolFunction(Struct): + name: str + description: str | Unset = UNSET + parameters: JSONSchema | Unset = UNSET + strict: bool | Unset = UNSET + + +class ToolFunctionItem(Struct): + type: Literal["function"] + function: ToolFunction + + +class TextFormat(Struct): + type: Literal["text"] + + +class GrammarDefinition(Struct): + syntax: GrammarSyntax + definition: str + + +class GrammarFormat(Struct): + type: Literal["grammar"] + grammar: GrammarDefinition + + +class ToolCustom(Struct): + name: str + description: str | Unset = UNSET + format: TextFormat | GrammarFormat | Unset = UNSET + + +class ToolCustomItem(Struct): + type: Literal["custom"] + custom: ToolCustom + + +type AnyToolItem = ToolFunctionItem | ToolCustomItem + + +class AudioOptions(Struct): + format: AudioFormatStr + voice: VoiceName | IDObject + + +class ModerationOptions(Struct): + model: str + + +class PredictionOptions(Struct): + type: Literal["content"] + content: str + + +class StreamOptions(Struct): + include_obfuscation: bool | Unset = UNSET + include_usage: bool | Unset = UNSET + + +class AllowedTools(Struct): + mode: Literal["auto", "required"] + tools: list[AnyToolItem] + + +class AllowedToolChoice(Struct): + type: Literal["allowed_tools"] + allowed_tools: AllowedTools + + +class ChatCompletionsParam(Struct): + messages: list[AnyChatMessage] + model: str + audio: AudioOptions | Unset = UNSET + frequency_penalty: float | Unset = UNSET + logit_bias: dict[int, int] | Unset = UNSET + logprobs: bool | Unset = UNSET + max_completion_tokens: int | Unset = UNSET + max_tokens: int | Unset = UNSET + metadata: dict[str, str] | Unset = UNSET + modalities: set[Modality] | Unset = UNSET + moderation: ModerationOptions | Unset = UNSET + n: int | Unset = UNSET + parallel_tool_calls: bool | Unset = UNSET + prediction: PredictionOptions | Unset = UNSET + presence_penalty: float | Unset = UNSET + prompt_cache_key: str | Unset = UNSET + prompt_cache_retention: CacheRetention | Unset = UNSET + reasoning_effort: ReasoningEffort | Unset = UNSET + response_format: AnyResponseFormat | Unset = UNSET + safety_identifier: str | Unset = UNSET + seed: int | Unset = UNSET + service_tier: ServiceTier | Unset = UNSET + stop: str | list[str] | Unset = UNSET + store: bool | Unset = UNSET + stream: bool | Unset = UNSET + stream_options: StreamOptions | Unset = UNSET + temperature: float | Unset = UNSET + tool_choice: ToolChoiceMode | AllowedToolChoice | AnyToolItem | Unset = UNSET + tools: list[AnyToolItem] | Unset = UNSET + top_logprobs: int | Unset = UNSET + top_p: int | Unset = UNSET + user: str | Unset = UNSET + verbosity: Verbosity | Unset = UNSET + web_search_options: dict[str, Any] | Unset = UNSET + + +class ChatCompletionChoice(Struct): + index: int + message: AssistantChatMessage + logprobs: dict[str, Any] + finish_reason: FinishReason + + +class ChatCompletionResponse(Struct): + id: str + object: Literal["chat.completion"] + created: int + model: str + choices: list[ChatCompletionChoice] + system_fingerprint: str + usage: dict[str, Any] + moderation: dict[str, Any] | Unset = UNSET + service_tier: ServiceTier | Unset = UNSET + + +class DeltaMessage(Struct): + role: RoleAssistant | Unset = UNSET + content: str | Unset = UNSET + tool_calls: list[AnyAssistantToolCall] | Unset = UNSET + + +class ChunkChoice(Struct): + index: int + delta: DeltaMessage + logprobs: dict[str, Any] | Unset = UNSET + finish_reason: FinishReason | None | Unset = UNSET + + +class ChatCompletionChunk(Struct): + id: str + object: Literal["chat.completion.chunk"] + created: int + model: str + choices: list[ChunkChoice] + usage: dict[str, Any] | Unset = UNSET