From 49e878fe3a1a4c17ae1baad141192f033413aa19 Mon Sep 17 00:00:00 2001 From: worldmozara Date: Wed, 29 Jul 2026 00:33:58 +0800 Subject: [PATCH] core/lmproto: add Anthropic messages models, config, and client --- src/plyngent/lmproto/anthropic/__init__.py | 2 + src/plyngent/lmproto/anthropic/client.py | 130 ++++++++++++++++ src/plyngent/lmproto/anthropic/config.py | 22 +++ src/plyngent/lmproto/anthropic/model.py | 173 +++++++++++++++++++++ 4 files changed, 327 insertions(+) create mode 100644 src/plyngent/lmproto/anthropic/__init__.py create mode 100644 src/plyngent/lmproto/anthropic/client.py create mode 100644 src/plyngent/lmproto/anthropic/config.py create mode 100644 src/plyngent/lmproto/anthropic/model.py diff --git a/src/plyngent/lmproto/anthropic/__init__.py b/src/plyngent/lmproto/anthropic/__init__.py new file mode 100644 index 0000000..5bd2ed7 --- /dev/null +++ b/src/plyngent/lmproto/anthropic/__init__.py @@ -0,0 +1,2 @@ +from .client import AnthropicClient as AnthropicClient +from .config import AnthropicConfig as AnthropicConfig diff --git a/src/plyngent/lmproto/anthropic/client.py b/src/plyngent/lmproto/anthropic/client.py new file mode 100644 index 0000000..345f029 --- /dev/null +++ b/src/plyngent/lmproto/anthropic/client.py @@ -0,0 +1,130 @@ +"""Anthropic ``/messages`` HTTP client.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Literal, cast, overload + +import msgspec +import niquests + +from .model import ( + AnthropicErrorEvent, + AnthropicMessageResponse, + AnthropicMessagesParam, + AnthropicMessageStop, + AnthropicModelsResponse, + AnthropicStreamEvent, +) + +if TYPE_CHECKING: + from collections.abc import AsyncIterator + + from niquests.async_session import AsyncSession + + from .config import AnthropicConfig + + +class AnthropicClient: + """Anthropic API client for ``POST /messages`` + ``GET /models``.""" + + kind: str = "messages" + _HTTP_ERROR: int = 400 + session: AsyncSession + encoder: msgspec.json.Encoder + decoder: msgspec.json.Decoder[AnthropicMessageResponse] + stream_decoder: msgspec.json.Decoder[AnthropicStreamEvent] + models_decoder: msgspec.json.Decoder[AnthropicModelsResponse] + _api_key: str + _api_version: str + + def __init__(self, config: AnthropicConfig) -> None: + self.session = niquests.AsyncSession(base_url=config.base_url, timeout=config.timeout) + self.encoder = msgspec.json.Encoder() + self.decoder = msgspec.json.Decoder(AnthropicMessageResponse) + self.stream_decoder = msgspec.json.Decoder(AnthropicStreamEvent) + self.models_decoder = msgspec.json.Decoder(AnthropicModelsResponse) + self._api_key = config.api_key + self._api_version = config.anthropic_version + + def _headers(self) -> dict[str, str]: + return { + "Content-Type": "application/json", + "x-api-key": self._api_key, + "anthropic-version": self._api_version, + } + + async def models(self) -> list[str]: + resp = await self.session.get("/models", headers=self._headers(), stream=False) + body = await self._read_json_body(resp, what="models") + parsed = self.models_decoder.decode(body) + return [item.id for item in parsed.data if item.id] + + @overload + async def messages( + self, param: AnthropicMessagesParam, *, stream: Literal[False] = False + ) -> AnthropicMessageResponse: ... + + @overload + async def messages( + self, param: AnthropicMessagesParam, *, stream: Literal[True] + ) -> AsyncIterator[AnthropicStreamEvent]: ... + + async def messages( + self, param: AnthropicMessagesParam, *, stream: bool = False + ) -> AnthropicMessageResponse | AsyncIterator[AnthropicStreamEvent]: + param = msgspec.structs.replace(param, stream=stream) + data = self.encoder.encode(param) + if stream: + resp = await self.session.post("/messages", data=data, headers=self._headers(), stream=True) + return self._parse_sse(resp) + resp = await self.session.post("/messages", data=data, headers=self._headers(), stream=False) + body = await self._read_json_body(resp, what="messages") + return self.decoder.decode(body) + + async def _ensure_ok(self, resp: object, *, what: str = "request") -> None: + from plyngent.lmproto.openai_compatible.client import http_error_message, read_response_body + + status = getattr(resp, "status_code", None) + if not isinstance(status, int): + return + body: bytes | str | None = None + if status >= AnthropicClient._HTTP_ERROR: + body = await read_response_body(resp) + msg = http_error_message(status, body, what=what) + if msg is None: + return + raise RuntimeError(msg) + + async def _read_json_body(self, resp: object, *, what: str) -> bytes: + from plyngent.lmproto.openai_compatible.client import read_response_body + + await self._ensure_ok(resp, what=what) + body = await read_response_body(resp) + if body is None: + msg = f"{what} response body is empty" + raise RuntimeError(msg) + if not isinstance(body, (bytes, bytearray)): + msg = f"{what} response body has unexpected type {type(body)!r}" + raise TypeError(msg) + return bytes(body) + + async def _parse_sse(self, resp: object) -> AsyncIterator[AnthropicStreamEvent]: + from plyngent.lmproto.openai_compatible.client import close_async_response, sse_data_payload + + typed = cast("Any", resp) + try: + await self._ensure_ok(typed, what="messages") + 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 + event = self.stream_decoder.decode(parsed) + yield event + if isinstance(event, (AnthropicMessageStop, AnthropicErrorEvent)): + break + finally: + await close_async_response(typed) diff --git a/src/plyngent/lmproto/anthropic/config.py b/src/plyngent/lmproto/anthropic/config.py new file mode 100644 index 0000000..ef57991 --- /dev/null +++ b/src/plyngent/lmproto/anthropic/config.py @@ -0,0 +1,22 @@ +"""Anthropic API configuration.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from plyngent.lmproto.openai_compatible.config import ( + DEFAULT_HTTP_CONNECT_TIMEOUT, + DEFAULT_HTTP_READ_TIMEOUT, + HttpTimeout, +) + +DEFAULT_ANTHROPIC_BASE_URL = "https://api.anthropic.com/v1" +DEFAULT_ANTHROPIC_VERSION = "2023-06-01" + + +@dataclass +class AnthropicConfig: + api_key: str + base_url: str = DEFAULT_ANTHROPIC_BASE_URL + anthropic_version: str = DEFAULT_ANTHROPIC_VERSION + timeout: HttpTimeout = (DEFAULT_HTTP_CONNECT_TIMEOUT, DEFAULT_HTTP_READ_TIMEOUT) diff --git a/src/plyngent/lmproto/anthropic/model.py b/src/plyngent/lmproto/anthropic/model.py new file mode 100644 index 0000000..95f0950 --- /dev/null +++ b/src/plyngent/lmproto/anthropic/model.py @@ -0,0 +1,173 @@ +"""Anthropic ``/messages`` API msgspec models.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Literal + +from msgspec import UNSET, Struct, field + +if TYPE_CHECKING: + from plyngent.typedef import Unset + + +class AnthropicTextContent(Struct, tag_field="type", tag="text"): + text: str + + +class AnthropicImageContent(Struct, tag_field="type", tag="image"): + source: dict[str, Any] + + +class AnthropicToolUseContent(Struct, tag_field="type", tag="tool_use"): + id: str + name: str + input: dict[str, Any] + + +class AnthropicToolResultContent(Struct, tag_field="type", tag="tool_result"): + tool_use_id: str + content: str | list[AnthropicTextContent | AnthropicImageContent] + is_error: bool = False + + +type AnthropicContentBlock = ( + AnthropicTextContent | AnthropicImageContent | AnthropicToolUseContent | AnthropicToolResultContent +) + + +class AnthropicUserMessage(Struct, tag_field="role", tag="user"): + content: str | list[AnthropicTextContent | AnthropicImageContent | AnthropicToolResultContent] + + +class AnthropicAssistantMessage(Struct, tag_field="role", tag="assistant"): + content: str | list[AnthropicTextContent | AnthropicToolUseContent] + + +type AnthropicMessage = AnthropicUserMessage | AnthropicAssistantMessage + + +class AnthropicToolDefinition(Struct, omit_defaults=True): + name: str + description: str | Unset = UNSET + input_schema: dict[str, Any] | Unset = UNSET + + +class AnthropicToolChoice(Struct, omit_defaults=True): + type: Literal["auto", "any", "tool"] = "auto" + name: str | Unset = UNSET + disable_parallel_tool_use: bool | Unset = UNSET + + +class AnthropicMetadata(Struct, omit_defaults=True): + user_id: str | Unset = UNSET + + +class AnthropicMessagesParam(Struct, omit_defaults=True): + model: str + max_tokens: int = 8192 + messages: list[AnthropicMessage] = field(default_factory=list) + system: str | list[AnthropicTextContent] | Unset = UNSET + tools: list[AnthropicToolDefinition] | Unset = UNSET + tool_choice: AnthropicToolChoice | Unset = UNSET + metadata: AnthropicMetadata | Unset = UNSET + stop_sequences: list[str] | Unset = UNSET + stream: bool = False + temperature: float | Unset = UNSET + top_p: float | Unset = UNSET + top_k: int | Unset = UNSET + + +class AnthropicUsage(Struct, omit_defaults=True): + input_tokens: int = 0 + output_tokens: int = 0 + cache_creation_input_tokens: int | None = None + cache_read_input_tokens: int | None = None + + +class AnthropicResponseText(Struct, tag_field="type", tag="text"): + text: str + + +class AnthropicResponseToolUse(Struct, tag_field="type", tag="tool_use"): + id: str + name: str + input: dict[str, Any] + + +type AnthropicResponseContent = AnthropicResponseText | AnthropicResponseToolUse + + +class AnthropicMessageResponse(Struct, omit_defaults=True): + id: str + type: str = "message" + role: str = "assistant" + content: list[AnthropicResponseContent] = field(default_factory=list) + model: str = "" + stop_reason: str | None = None + stop_sequence: str | None = None + usage: AnthropicUsage = field(default_factory=AnthropicUsage) + + +class AnthropicRawContentBlock(Struct, omit_defaults=True): + type: str = "" + id: str | None = None + name: str | None = None + text: str | None = None + input: dict[str, Any] | None = None + partial_json: str | None = None + + +class AnthropicMessageStart(Struct, tag_field="type", tag="message_start"): + message: AnthropicMessageResponse + + +class AnthropicPing(Struct, tag_field="type", tag="ping"): + pass + + +class AnthropicContentBlockStart(Struct, tag_field="type", tag="content_block_start"): + index: int + content_block: AnthropicRawContentBlock + + +class AnthropicContentBlockDelta(Struct, tag_field="type", tag="content_block_delta"): + index: int + delta: AnthropicRawContentBlock + + +class AnthropicContentBlockStop(Struct, tag_field="type", tag="content_block_stop"): + index: int + + +class AnthropicMessageDelta(Struct, tag_field="type", tag="message_delta"): + delta: dict[str, Any] + usage: AnthropicUsage + + +class AnthropicMessageStop(Struct, tag_field="type", tag="message_stop"): + pass + + +class AnthropicErrorEvent(Struct, tag_field="type", tag="error"): + error: dict[str, Any] + + +type AnthropicStreamEvent = ( + AnthropicMessageStart + | AnthropicPing + | AnthropicContentBlockStart + | AnthropicContentBlockDelta + | AnthropicContentBlockStop + | AnthropicMessageDelta + | AnthropicMessageStop + | AnthropicErrorEvent +) + + +class AnthropicModelInfo(Struct, omit_defaults=True): + type: str = "" + id: str = "" + + +class AnthropicModelsResponse(Struct, omit_defaults=True): + data: list[AnthropicModelInfo] = field(default_factory=list)