core/lmproto: add Anthropic messages models, config, and client

This commit is contained in:
2026-07-29 00:33:58 +08:00
parent 200bde0bfb
commit 49e878fe3a
4 changed files with 327 additions and 0 deletions
@@ -0,0 +1,2 @@
from .client import AnthropicClient as AnthropicClient
from .config import AnthropicConfig as AnthropicConfig
+130
View File
@@ -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)
+22
View File
@@ -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)
+173
View File
@@ -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)