core/lmproto: move Responses API into openai package

openai_compatible stays chat-completions-only; OpenAI provider uses lmproto.openai.OpenAIClient.
This commit is contained in:
2026-07-15 20:52:17 +08:00
parent 3e20b1559c
commit 38edc09854
9 changed files with 180 additions and 94 deletions
+5 -3
View File
@@ -30,13 +30,15 @@ All protocol models use `msgspec.Struct` — not dataclasses, not Pydantic. Opti
``` ```
lmproto/openai_compatible/ ← Base: model, config, client lmproto/openai_compatible/ ← Base: model, config, client
lmproto/openai/ ← OpenAI platform: Responses + chat (extends base)
lmproto/deepseek/openai_compat/ ← Extends base via inheritance + extra fields lmproto/deepseek/openai_compat/ ← Extends base via inheritance + extra fields
``` ```
- **`openai_compatible/model.py`** — tagged chat messages (`SystemChatMessage`, `UserChatMessage`, …), tools, request/response, streaming chunks. - **`openai_compatible/model.py`** — tagged chat messages (`SystemChatMessage`, `UserChatMessage`, …), tools, request/response, streaming chunks.
- **`openai_compatible/responses_model.py`** — OpenAI Responses API (`ResponsesCreateParam`, `Response`, function_call items, stream events). - **`openai_compatible/client.py`** — `BaseOpenAIClient` / `OpenAICompatibleClient` via `niquests` async + SSE; `chat_completions`, `models` only (`OpenAIClient` is a compat alias).
- **`openai_compatible/client.py`** — `BaseOpenAIClient` / `OpenAIClient` via `niquests` async + SSE; `chat_completions`, `models`, `responses` / `get_response` / `delete_response`.
- **`openai_compatible/config.py`** — `OpenAIConfig` (token + base URL). - **`openai_compatible/config.py`** — `OpenAIConfig` (token + base URL).
- **`openai/model.py`** — OpenAI Responses API (`ResponsesCreateParam`, `Response`, function_call items, stream events).
- **`openai/client.py`** — platform `OpenAIClient`: chat completions + `responses` / `get_response` / `delete_response`.
- **DeepSeek** — `DeepseekOpenAIClient`; models add `reasoning_content`, `prefix`, `ThinkingOptions`. Config default model ids: `deepseek-v4-flash`, `deepseek-v4-pro`. - **DeepSeek** — `DeepseekOpenAIClient`; models add `reasoning_content`, `prefix`, `ThinkingOptions`. Config default model ids: `deepseek-v4-flash`, `deepseek-v4-pro`.
### Config (`config/`) ### Config (`config/`)
@@ -45,7 +47,7 @@ TOML load/store (`ConfigStore`): `[providers]` tagged union presets, `[database]
### Runtime (`runtime/`) ### Runtime (`runtime/`)
`create_client(provider)` maps config `Provider` → protocol client. OpenAI / openai-compatible / deepseek(openai convention) supported; anthropic and deepseek anthropic convention raise `ProviderNotSupportedError`. `create_client(provider)` maps config `Provider` → protocol client. OpenAI `lmproto.openai.OpenAIClient` (Responses-capable); openai-compatible / deepseek(openai convention) → chat-completions clients; anthropic and deepseek anthropic convention raise `ProviderNotSupportedError`.
### Memory (`memory/`) ### Memory (`memory/`)
+12
View File
@@ -0,0 +1,12 @@
from .client import OpenAIClient as OpenAIClient
from .model import Response as Response
from .model import ResponseDeleted as ResponseDeleted
from .model import ResponseFunctionTool as ResponseFunctionTool
from .model import ResponseFunctionToolCall as ResponseFunctionToolCall
from .model import ResponseFunctionToolCallOutput as ResponseFunctionToolCallOutput
from .model import ResponseOutputMessage as ResponseOutputMessage
from .model import ResponseOutputText as ResponseOutputText
from .model import ResponsesCreateParam as ResponsesCreateParam
from .model import ResponseStreamEvent as ResponseStreamEvent
from .model import response_function_calls as response_function_calls
from .model import response_output_text as response_output_text
+133
View File
@@ -0,0 +1,133 @@
"""OpenAI platform client: chat completions (compat) + Responses API."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any, Literal, cast, overload
import msgspec
from plyngent.lmproto.openai_compatible.client import BaseOpenAIClient
from plyngent.lmproto.openai_compatible.config import OpenAIConfig # noqa: TC001
from plyngent.lmproto.openai_compatible.model import ( # noqa: TC001
ChatCompletionChunk,
ChatCompletionResponse,
ChatCompletionsParam,
)
from .model import Response, ResponseDeleted, ResponsesCreateParam, ResponseStreamEvent
if TYPE_CHECKING:
from collections.abc import AsyncIterator
class OpenAIClient(BaseOpenAIClient):
"""Full OpenAI HTTP client (``/chat/completions`` + ``/responses`` + ``/models``)."""
response_decoder: msgspec.json.Decoder[Response]
response_deleted_decoder: msgspec.json.Decoder[ResponseDeleted]
response_event_decoder: msgspec.json.Decoder[ResponseStreamEvent]
def __init__(self, config: OpenAIConfig) -> None:
super().__init__(config)
self.response_decoder = msgspec.json.Decoder(Response)
self.response_deleted_decoder = msgspec.json.Decoder(ResponseDeleted)
self.response_event_decoder = msgspec.json.Decoder(ResponseStreamEvent)
@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,
)
body = await self._read_json_body(resp, what="chat completions")
return self.decoder.decode(body)
@overload
async def responses(
self, param: ResponsesCreateParam, *, stream: Literal[False] = False
) -> Response: ...
@overload
async def responses(
self, param: ResponsesCreateParam, *, stream: Literal[True]
) -> AsyncIterator[ResponseStreamEvent]: ...
async def responses(
self, param: ResponsesCreateParam, *, stream: bool = False
) -> Response | AsyncIterator[ResponseStreamEvent]:
"""Create a model response via OpenAI ``POST /responses``."""
param = msgspec.structs.replace(param, stream=stream)
data = self.encoder.encode(param)
if stream:
resp = await self.session.post(
"/responses",
data=data,
headers={"Content-Type": "application/json"},
stream=True,
)
return self._parse_response_sse(resp)
resp = await self.session.post(
"/responses",
data=data,
headers={"Content-Type": "application/json"},
stream=False,
)
body = await self._read_json_body(resp, what="responses")
return self.response_decoder.decode(body)
async def get_response(self, response_id: str) -> Response:
"""Retrieve a stored response via ``GET /responses/{id}``."""
resp = await self.session.get(f"/responses/{response_id}", stream=False)
body = await self._read_json_body(resp, what="responses")
return self.response_decoder.decode(body)
async def delete_response(self, response_id: str) -> ResponseDeleted:
"""Delete a stored response via ``DELETE /responses/{id}``."""
resp = await self.session.delete(f"/responses/{response_id}", stream=False)
body = await self._read_json_body(resp, what="responses")
return self.response_deleted_decoder.decode(body)
async def _parse_response_sse(self, resp: object) -> AsyncIterator[ResponseStreamEvent]:
"""Yield Responses API SSE events; stop at ``data: [DONE]``."""
from plyngent.lmproto.openai_compatible.client import (
close_async_response,
sse_data_payload,
)
typed = cast("Any", resp)
try:
await self._ensure_ok(typed, what="responses")
async for raw in typed.iter_lines():
if not raw:
continue
parsed = sse_data_payload(bytes(raw))
if parsed is None:
continue
if parsed is False:
break
yield self.response_event_decoder.decode(parsed)
finally:
await close_async_response(typed)
@@ -13,10 +13,9 @@ from typing import Any, Literal, cast
import msgspec import msgspec
from msgspec import UNSET, Struct, field from msgspec import UNSET, Struct, field
from plyngent.lmproto.openai_compatible.model import ServiceTier # noqa: TC001
from plyngent.typedef import JSONSchema, Unset # noqa: TC001 from plyngent.typedef import JSONSchema, Unset # noqa: TC001
from .model import ServiceTier # noqa: TC001
type ResponseStatus = Literal[ type ResponseStatus = Literal[
"completed", "completed",
"failed", "failed",
@@ -294,7 +293,7 @@ def response_output_text(response: Response) -> str:
for raw in response.output: for raw in response.output:
if raw.get("type") != "message": if raw.get("type") != "message":
continue continue
content = raw.get("content") content: list[object] | None = raw.get("content")
if not isinstance(content, list): if not isinstance(content, list):
continue continue
for block_obj in content: for block_obj in content:
@@ -1,5 +1,6 @@
from .client import BaseOpenAIClient as BaseOpenAIClient from .client import BaseOpenAIClient as BaseOpenAIClient
from .client import OpenAIClient as OpenAIClient from .client import OpenAIClient as OpenAIClient
from .client import OpenAICompatibleClient as OpenAICompatibleClient
from .config import OpenAIConfig as OpenAIConfig from .config import OpenAIConfig as OpenAIConfig
from .model import AnyAssistantToolCall as AnyAssistantToolCall from .model import AnyAssistantToolCall as AnyAssistantToolCall
from .model import AnyChatMessage as AnyChatMessage from .model import AnyChatMessage as AnyChatMessage
@@ -40,14 +41,3 @@ from .model import ToolFunctionItem as ToolFunctionItem
from .model import UserChatMessage as UserChatMessage from .model import UserChatMessage as UserChatMessage
from .model import Verbosity as Verbosity from .model import Verbosity as Verbosity
from .model import VoiceName as VoiceName from .model import VoiceName as VoiceName
from .responses_model import Response as Response
from .responses_model import ResponseDeleted as ResponseDeleted
from .responses_model import ResponseFunctionTool as ResponseFunctionTool
from .responses_model import ResponseFunctionToolCall as ResponseFunctionToolCall
from .responses_model import ResponseFunctionToolCallOutput as ResponseFunctionToolCallOutput
from .responses_model import ResponseOutputMessage as ResponseOutputMessage
from .responses_model import ResponseOutputText as ResponseOutputText
from .responses_model import ResponsesCreateParam as ResponsesCreateParam
from .responses_model import ResponseStreamEvent as ResponseStreamEvent
from .responses_model import response_function_calls as response_function_calls
from .responses_model import response_output_text as response_output_text
@@ -18,7 +18,6 @@ from .model import (
ModelsResponse, ModelsResponse,
StreamToolCallDelta, StreamToolCallDelta,
) )
from .responses_model import Response, ResponseDeleted, ResponsesCreateParam, ResponseStreamEvent
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
@@ -31,7 +30,7 @@ _HTTP_ERROR = 400
_ERROR_BODY_PREVIEW = 500 _ERROR_BODY_PREVIEW = 500
async def _close_async_response(resp: AsyncResponse) -> None: async def close_async_response(resp: AsyncResponse) -> None:
"""Close a streaming response; support sync or async close().""" """Close a streaming response; support sync or async close()."""
for name in ("aclose", "close"): for name in ("aclose", "close"):
method = getattr(resp, name, None) method = getattr(resp, name, None)
@@ -43,6 +42,10 @@ async def _close_async_response(resp: AsyncResponse) -> None:
return return
# Internal alias kept for older call sites in this module.
_close_async_response = close_async_response
async def read_response_body(resp: object) -> bytes | str | None: async def read_response_body(resp: object) -> bytes | str | None:
"""Read response body; niquests ``AsyncResponse.content`` is an async property.""" """Read response body; niquests ``AsyncResponse.content`` is an async property."""
content = getattr(resp, "content", None) content = getattr(resp, "content", None)
@@ -98,14 +101,13 @@ def http_error_message(
class BaseOpenAIClient: class BaseOpenAIClient:
"""Shared OpenAI-compatible HTTP surface (session, models, chat SSE)."""
session: AsyncSession session: AsyncSession
encoder: msgspec.json.Encoder encoder: msgspec.json.Encoder
decoder: msgspec.json.Decoder[ChatCompletionResponse] decoder: msgspec.json.Decoder[ChatCompletionResponse]
chunk_decoder: msgspec.json.Decoder[ChatCompletionChunk] chunk_decoder: msgspec.json.Decoder[ChatCompletionChunk]
models_decoder: msgspec.json.Decoder[ModelsResponse] models_decoder: msgspec.json.Decoder[ModelsResponse]
response_decoder: msgspec.json.Decoder[Response]
response_deleted_decoder: msgspec.json.Decoder[ResponseDeleted]
response_event_decoder: msgspec.json.Decoder[ResponseStreamEvent]
def __init__(self, config: OpenAIConfig) -> None: def __init__(self, config: OpenAIConfig) -> None:
self.session = niquests.AsyncSession( self.session = niquests.AsyncSession(
@@ -116,9 +118,6 @@ class BaseOpenAIClient:
self.decoder = msgspec.json.Decoder(ChatCompletionResponse) self.decoder = msgspec.json.Decoder(ChatCompletionResponse)
self.chunk_decoder = msgspec.json.Decoder(ChatCompletionChunk) self.chunk_decoder = msgspec.json.Decoder(ChatCompletionChunk)
self.models_decoder = msgspec.json.Decoder(ModelsResponse) self.models_decoder = msgspec.json.Decoder(ModelsResponse)
self.response_decoder = msgspec.json.Decoder(Response)
self.response_deleted_decoder = msgspec.json.Decoder(ResponseDeleted)
self.response_event_decoder = msgspec.json.Decoder(ResponseStreamEvent)
async def models(self) -> list[str]: async def models(self) -> list[str]:
"""List model ids via OpenAI-compatible ``GET /models``. """List model ids via OpenAI-compatible ``GET /models``.
@@ -164,22 +163,6 @@ class BaseOpenAIClient:
finally: finally:
await _close_async_response(resp) await _close_async_response(resp)
async def _parse_response_sse(self, resp: AsyncResponse) -> AsyncIterator[ResponseStreamEvent]:
"""Yield Responses API SSE events; stop at ``data: [DONE]``."""
try:
await self._ensure_ok(resp, what="responses")
async for raw in resp.iter_lines():
if not raw:
continue
parsed = sse_data_payload(bytes(raw))
if parsed is None:
continue
if parsed is False:
break
yield self.response_event_decoder.decode(parsed)
finally:
await _close_async_response(resp)
async def _read_json_body(self, resp: object, *, what: str) -> bytes: async def _read_json_body(self, resp: object, *, what: str) -> bytes:
await self._ensure_ok(resp, what=what) await self._ensure_ok(resp, what=what)
body = await read_response_body(resp) body = await read_response_body(resp)
@@ -192,7 +175,9 @@ class BaseOpenAIClient:
return bytes(body) return bytes(body)
class OpenAIClient(BaseOpenAIClient): class OpenAICompatibleClient(BaseOpenAIClient):
"""Chat Completions only (``POST /chat/completions`` + ``GET /models``)."""
def __init__(self, config: OpenAIConfig) -> None: def __init__(self, config: OpenAIConfig) -> None:
super().__init__(config) super().__init__(config)
@@ -229,50 +214,9 @@ class OpenAIClient(BaseOpenAIClient):
body = await self._read_json_body(resp, what="chat completions") body = await self._read_json_body(resp, what="chat completions")
return self.decoder.decode(body) return self.decoder.decode(body)
@overload
async def responses(
self, param: ResponsesCreateParam, *, stream: Literal[False] = False
) -> Response: ...
@overload # Backward-compatible alias: many call sites still import OpenAIClient from this package.
async def responses( OpenAIClient = OpenAICompatibleClient
self, param: ResponsesCreateParam, *, stream: Literal[True]
) -> AsyncIterator[ResponseStreamEvent]: ...
async def responses(
self, param: ResponsesCreateParam, *, stream: bool = False
) -> Response | AsyncIterator[ResponseStreamEvent]:
"""Create a model response via OpenAI ``POST /responses``."""
param = msgspec.structs.replace(param, stream=stream)
data = self.encoder.encode(param)
if stream:
resp = await self.session.post(
"/responses",
data=data,
headers={"Content-Type": "application/json"},
stream=True,
)
return self._parse_response_sse(resp)
resp = await self.session.post(
"/responses",
data=data,
headers={"Content-Type": "application/json"},
stream=False,
)
body = await self._read_json_body(resp, what="responses")
return self.response_decoder.decode(body)
async def get_response(self, response_id: str) -> Response:
"""Retrieve a stored response via ``GET /responses/{id}``."""
resp = await self.session.get(f"/responses/{response_id}", stream=False)
body = await self._read_json_body(resp, what="responses")
return self.response_decoder.decode(body)
async def delete_response(self, response_id: str) -> ResponseDeleted:
"""Delete a stored response via ``DELETE /responses/{id}``."""
resp = await self.session.delete(f"/responses/{response_id}", stream=False)
body = await self._read_json_body(resp, what="responses")
return self.response_deleted_decoder.decode(body)
def merge_stream_tool_calls(deltas: list[StreamToolCallDelta]) -> list[AssistantFunctionToolCall]: def merge_stream_tool_calls(deltas: list[StreamToolCallDelta]) -> list[AssistantFunctionToolCall]:
+7 -4
View File
@@ -9,7 +9,8 @@ from plyngent.config.models import (
Provider, Provider,
) )
from plyngent.lmproto.deepseek import DeepseekOpenAIClient from plyngent.lmproto.deepseek import DeepseekOpenAIClient
from plyngent.lmproto.openai_compatible import OpenAIClient, OpenAIConfig from plyngent.lmproto.openai import OpenAIClient
from plyngent.lmproto.openai_compatible import OpenAICompatibleClient, OpenAIConfig
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Mapping from collections.abc import Mapping
@@ -17,7 +18,9 @@ if TYPE_CHECKING:
DEFAULT_OPENAI_BASE_URL = "https://api.openai.com/v1" DEFAULT_OPENAI_BASE_URL = "https://api.openai.com/v1"
DEFAULT_DEEPSEEK_BASE_URL = "https://api.deepseek.com" DEFAULT_DEEPSEEK_BASE_URL = "https://api.deepseek.com"
type OpenAICompatibleClient = OpenAIClient | DeepseekOpenAIClient type ProtocolClient = OpenAIClient | OpenAICompatibleClient | DeepseekOpenAIClient
# Backward-compatible name used by older imports/tests.
type OpenAICompatibleClientUnion = ProtocolClient
class ProviderNotSupportedError(NotImplementedError): class ProviderNotSupportedError(NotImplementedError):
@@ -45,7 +48,7 @@ def _deepseek_convention(extras: Mapping[str, str]) -> str:
return extras.get("convention", "openai").lower() return extras.get("convention", "openai").lower()
def create_client(provider: Provider) -> OpenAICompatibleClient: def create_client(provider: Provider) -> ProtocolClient:
"""Build a protocol client for the given provider config entry. """Build a protocol client for the given provider config entry.
Raises: Raises:
@@ -55,7 +58,7 @@ def create_client(provider: Provider) -> OpenAICompatibleClient:
if isinstance(provider, OpenAIProvider): if isinstance(provider, OpenAIProvider):
return OpenAIClient(provider_to_openai_config(provider)) return OpenAIClient(provider_to_openai_config(provider))
if isinstance(provider, OpenAICompatibleProvider): if isinstance(provider, OpenAICompatibleProvider):
return OpenAIClient(provider_to_openai_config(provider)) return OpenAICompatibleClient(provider_to_openai_config(provider))
if isinstance(provider, DeepseekProvider): if isinstance(provider, DeepseekProvider):
convention = _deepseek_convention(provider.extras) convention = _deepseek_convention(provider.extras)
if convention in {"openai", "openai_compat", "openai-compatible"}: if convention in {"openai", "openai_compat", "openai-compatible"}:
+3 -3
View File
@@ -3,9 +3,8 @@ from __future__ import annotations
import msgspec import msgspec
import pytest import pytest
from plyngent.lmproto.openai_compatible.client import OpenAIClient from plyngent.lmproto.openai import (
from plyngent.lmproto.openai_compatible.config import OpenAIConfig OpenAIClient,
from plyngent.lmproto.openai_compatible.responses_model import (
Response, Response,
ResponseDeleted, ResponseDeleted,
ResponseFunctionTool, ResponseFunctionTool,
@@ -15,6 +14,7 @@ from plyngent.lmproto.openai_compatible.responses_model import (
response_function_calls, response_function_calls,
response_output_text, response_output_text,
) )
from plyngent.lmproto.openai_compatible.config import OpenAIConfig
def _sample_response_body() -> bytes: def _sample_response_body() -> bytes:
+5 -2
View File
@@ -9,7 +9,8 @@ from plyngent.config.models import (
OpenAIProvider, OpenAIProvider,
) )
from plyngent.lmproto.deepseek import DeepseekOpenAIClient from plyngent.lmproto.deepseek import DeepseekOpenAIClient
from plyngent.lmproto.openai_compatible import OpenAIClient from plyngent.lmproto.openai import OpenAIClient
from plyngent.lmproto.openai_compatible import OpenAICompatibleClient
from plyngent.runtime import ProviderNotSupportedError, create_client, provider_to_openai_config from plyngent.runtime import ProviderNotSupportedError, create_client, provider_to_openai_config
@@ -20,6 +21,7 @@ def test_openai_provider_defaults_base_url() -> None:
assert config.base_url == "https://api.openai.com/v1" assert config.base_url == "https://api.openai.com/v1"
client = create_client(provider) client = create_client(provider)
assert isinstance(client, OpenAIClient) assert isinstance(client, OpenAIClient)
assert hasattr(client, "responses")
def test_openai_compatible_requires_url() -> None: def test_openai_compatible_requires_url() -> None:
@@ -34,7 +36,8 @@ def test_openai_compatible_client() -> None:
url="https://example.com/v1", url="https://example.com/v1",
) )
client = create_client(provider) client = create_client(provider)
assert isinstance(client, OpenAIClient) assert isinstance(client, OpenAICompatibleClient)
assert not isinstance(client, OpenAIClient)
assert provider_to_openai_config(provider).base_url == "https://example.com/v1" assert provider_to_openai_config(provider).base_url == "https://example.com/v1"