mirror of
https://github.com/NCBM/plyngent.git
synced 2026-11-07 23:47:54 +08:00
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:
@@ -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
|
||||
@@ -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)
|
||||
+2
-3
@@ -13,10 +13,9 @@ from typing import Any, Literal, cast
|
||||
import msgspec
|
||||
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 .model import ServiceTier # noqa: TC001
|
||||
|
||||
type ResponseStatus = Literal[
|
||||
"completed",
|
||||
"failed",
|
||||
@@ -294,7 +293,7 @@ def response_output_text(response: Response) -> str:
|
||||
for raw in response.output:
|
||||
if raw.get("type") != "message":
|
||||
continue
|
||||
content = raw.get("content")
|
||||
content: list[object] | None = raw.get("content")
|
||||
if not isinstance(content, list):
|
||||
continue
|
||||
for block_obj in content:
|
||||
@@ -1,5 +1,6 @@
|
||||
from .client import BaseOpenAIClient as BaseOpenAIClient
|
||||
from .client import OpenAIClient as OpenAIClient
|
||||
from .client import OpenAICompatibleClient as OpenAICompatibleClient
|
||||
from .config import OpenAIConfig as OpenAIConfig
|
||||
from .model import AnyAssistantToolCall as AnyAssistantToolCall
|
||||
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 Verbosity as Verbosity
|
||||
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,
|
||||
StreamToolCallDelta,
|
||||
)
|
||||
from .responses_model import Response, ResponseDeleted, ResponsesCreateParam, ResponseStreamEvent
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncIterator
|
||||
@@ -31,7 +30,7 @@ _HTTP_ERROR = 400
|
||||
_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()."""
|
||||
for name in ("aclose", "close"):
|
||||
method = getattr(resp, name, None)
|
||||
@@ -43,6 +42,10 @@ async def _close_async_response(resp: AsyncResponse) -> None:
|
||||
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:
|
||||
"""Read response body; niquests ``AsyncResponse.content`` is an async property."""
|
||||
content = getattr(resp, "content", None)
|
||||
@@ -98,14 +101,13 @@ def http_error_message(
|
||||
|
||||
|
||||
class BaseOpenAIClient:
|
||||
"""Shared OpenAI-compatible HTTP surface (session, models, chat SSE)."""
|
||||
|
||||
session: AsyncSession
|
||||
encoder: msgspec.json.Encoder
|
||||
decoder: msgspec.json.Decoder[ChatCompletionResponse]
|
||||
chunk_decoder: msgspec.json.Decoder[ChatCompletionChunk]
|
||||
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:
|
||||
self.session = niquests.AsyncSession(
|
||||
@@ -116,9 +118,6 @@ class BaseOpenAIClient:
|
||||
self.decoder = msgspec.json.Decoder(ChatCompletionResponse)
|
||||
self.chunk_decoder = msgspec.json.Decoder(ChatCompletionChunk)
|
||||
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]:
|
||||
"""List model ids via OpenAI-compatible ``GET /models``.
|
||||
@@ -164,22 +163,6 @@ class BaseOpenAIClient:
|
||||
finally:
|
||||
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:
|
||||
await self._ensure_ok(resp, what=what)
|
||||
body = await read_response_body(resp)
|
||||
@@ -192,7 +175,9 @@ class BaseOpenAIClient:
|
||||
return bytes(body)
|
||||
|
||||
|
||||
class OpenAIClient(BaseOpenAIClient):
|
||||
class OpenAICompatibleClient(BaseOpenAIClient):
|
||||
"""Chat Completions only (``POST /chat/completions`` + ``GET /models``)."""
|
||||
|
||||
def __init__(self, config: OpenAIConfig) -> None:
|
||||
super().__init__(config)
|
||||
|
||||
@@ -229,50 +214,9 @@ class OpenAIClient(BaseOpenAIClient):
|
||||
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)
|
||||
# Backward-compatible alias: many call sites still import OpenAIClient from this package.
|
||||
OpenAIClient = OpenAICompatibleClient
|
||||
|
||||
|
||||
def merge_stream_tool_calls(deltas: list[StreamToolCallDelta]) -> list[AssistantFunctionToolCall]:
|
||||
|
||||
@@ -9,7 +9,8 @@ from plyngent.config.models import (
|
||||
Provider,
|
||||
)
|
||||
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:
|
||||
from collections.abc import Mapping
|
||||
@@ -17,7 +18,9 @@ if TYPE_CHECKING:
|
||||
DEFAULT_OPENAI_BASE_URL = "https://api.openai.com/v1"
|
||||
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):
|
||||
@@ -45,7 +48,7 @@ def _deepseek_convention(extras: Mapping[str, str]) -> str:
|
||||
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.
|
||||
|
||||
Raises:
|
||||
@@ -55,7 +58,7 @@ def create_client(provider: Provider) -> OpenAICompatibleClient:
|
||||
if isinstance(provider, OpenAIProvider):
|
||||
return OpenAIClient(provider_to_openai_config(provider))
|
||||
if isinstance(provider, OpenAICompatibleProvider):
|
||||
return OpenAIClient(provider_to_openai_config(provider))
|
||||
return OpenAICompatibleClient(provider_to_openai_config(provider))
|
||||
if isinstance(provider, DeepseekProvider):
|
||||
convention = _deepseek_convention(provider.extras)
|
||||
if convention in {"openai", "openai_compat", "openai-compatible"}:
|
||||
|
||||
Reference in New Issue
Block a user