初始化提交
This commit is contained in:
commit
887c1f449e
963 files changed
+166869
No files matched your search
@@ -0,0 +1,433 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from app.ai_provider.config.models import ModelConfig, ProviderConfig
|
||||
from app.ai_provider.pipeline import ParameterPipeline, PipelineContext, PipelineFactory
|
||||
from app.ai_provider.types.chat import ChatChunk, ChatResult, TokenUsage
|
||||
from app.ai_provider.types.errors import AIProviderError
|
||||
from app.ai_provider.types.messages import (
|
||||
AIMessage,
|
||||
BaseMessage,
|
||||
SystemMessage,
|
||||
ToolMessage,
|
||||
)
|
||||
from app.core.logging import logger
|
||||
|
||||
|
||||
class AnthropicFormatAdapter:
|
||||
"""兼容 Anthropic Messages API 格式的适配器。
|
||||
|
||||
覆盖通过 Anthropic API 访问的 Claude 模型,以及任何镜像
|
||||
Anthropic 消息格式的提供商。
|
||||
"""
|
||||
|
||||
API_VERSION = "2023-06-01"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
provider_config: ProviderConfig,
|
||||
pipeline: ParameterPipeline | None = None,
|
||||
) -> None:
|
||||
self._provider = provider_config
|
||||
self._pipeline = pipeline or PipelineFactory.create(provider_config)
|
||||
self._client: httpx.AsyncClient | None = None
|
||||
|
||||
@property
|
||||
def _http(self) -> httpx.AsyncClient:
|
||||
if self._client is None:
|
||||
self._client = httpx.AsyncClient(
|
||||
base_url=self._provider.base_url or "",
|
||||
timeout=self._provider.timeout_seconds,
|
||||
)
|
||||
return self._client
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 内部方法:消息转换
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _convert_message(self, msg: BaseMessage) -> dict[str, Any]:
|
||||
"""将单条消息转换为 Anthropic 消息格式。
|
||||
|
||||
Anthropic 使用 content blocks 数组而非纯字符串。
|
||||
System 消息**不在此处处理** —— 它们由 :meth:`build_request`
|
||||
提取到顶层的 ``system`` 字段中。
|
||||
"""
|
||||
if isinstance(msg, ToolMessage):
|
||||
return {
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": msg.tool_call_id,
|
||||
"content": msg.content,
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
if isinstance(msg, AIMessage):
|
||||
if msg.raw_content_blocks:
|
||||
return {"role": "assistant", "content": msg.raw_content_blocks}
|
||||
blocks: list[dict[str, Any]] = []
|
||||
if msg.content:
|
||||
blocks.append({"type": "text", "text": msg.content})
|
||||
for tc in msg.tool_calls:
|
||||
blocks.append(
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": tc.get("id", f"tu_{tc['name']}"),
|
||||
"name": tc["name"],
|
||||
"input": tc.get("args", {}),
|
||||
}
|
||||
)
|
||||
return {"role": "assistant", "content": blocks}
|
||||
|
||||
# HumanMessage
|
||||
return {
|
||||
"role": "user",
|
||||
"content": [{"type": "text", "text": msg.content}],
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _convert_tools(tools: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""将 OpenAI 兼容格式的工具转换为 Anthropic 格式。
|
||||
|
||||
OpenAI 格式::
|
||||
|
||||
{"type": "function", "function": {"name": ..., "parameters": ...}}
|
||||
|
||||
Anthropic 格式::
|
||||
|
||||
{"name": ..., "input_schema": ...}
|
||||
"""
|
||||
result: list[dict[str, Any]] = []
|
||||
for tool in tools:
|
||||
fn = tool.get("function", tool)
|
||||
result.append(
|
||||
{
|
||||
"name": fn.get("name", ""),
|
||||
"description": fn.get("description", ""),
|
||||
"input_schema": fn.get("parameters", {"type": "object"}),
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 请求构建
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def build_request(
|
||||
self,
|
||||
model: ModelConfig,
|
||||
messages: list[BaseMessage],
|
||||
params: dict[str, Any] | None = None,
|
||||
tools: list[dict[str, Any]] | None = None,
|
||||
tool_choice: str | dict[str, Any] | None = None,
|
||||
stream: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
ctx = PipelineContext(
|
||||
params=params or {},
|
||||
model_config=model,
|
||||
provider_config=self._provider,
|
||||
)
|
||||
pipe_result = self._pipeline.run(ctx)
|
||||
|
||||
# Extract system messages — Anthropic uses a top-level "system" field
|
||||
system_parts: list[str] = []
|
||||
non_system: list[BaseMessage] = []
|
||||
for m in messages:
|
||||
if isinstance(m, SystemMessage):
|
||||
system_parts.append(m.content)
|
||||
else:
|
||||
non_system.append(m)
|
||||
|
||||
body: dict[str, Any] = {
|
||||
"model": model.model,
|
||||
"messages": [self._convert_message(m) for m in non_system],
|
||||
"stream": stream,
|
||||
}
|
||||
|
||||
if system_parts:
|
||||
body["system"] = "\n".join(system_parts)
|
||||
|
||||
body.update(pipe_result.params)
|
||||
|
||||
if tools:
|
||||
body["tools"] = self._convert_tools(tools)
|
||||
if tool_choice:
|
||||
body["tool_choice"] = tool_choice
|
||||
|
||||
# Anthropic requires explicit max_tokens
|
||||
if "max_tokens" not in body:
|
||||
body["max_tokens"] = model.max_output_tokens or 4096
|
||||
|
||||
return body
|
||||
|
||||
def bind_tools(
|
||||
self,
|
||||
request_body: dict[str, Any],
|
||||
tools: list[dict[str, Any]],
|
||||
tool_choice: str | dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
request_body["tools"] = self._convert_tools(tools)
|
||||
if tool_choice:
|
||||
request_body["tool_choice"] = tool_choice
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 认证头
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _headers(self, api_key: str) -> dict[str, str]:
|
||||
return {
|
||||
"x-api-key": api_key,
|
||||
"anthropic-version": self.API_VERSION,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 响应解析 —— 非流式
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _parse_content_blocks(raw: dict[str, Any]) -> tuple[str, list[dict[str, Any]], str]:
|
||||
"""解析 Anthropic content blocks 为 (text, tool_calls, reasoning)。"""
|
||||
content_blocks = raw.get("content", [])
|
||||
text_parts: list[str] = []
|
||||
tool_calls: list[dict[str, Any]] = []
|
||||
reasoning = ""
|
||||
|
||||
for block in content_blocks:
|
||||
block_type = block.get("type", "")
|
||||
if block_type == "text":
|
||||
text_parts.append(block.get("text", ""))
|
||||
elif block_type == "tool_use":
|
||||
tool_calls.append(
|
||||
{
|
||||
"name": block.get("name", ""),
|
||||
"args": block.get("input", {}),
|
||||
"id": block.get("id", ""),
|
||||
}
|
||||
)
|
||||
elif block_type == "thinking":
|
||||
reasoning += block.get("thinking", "")
|
||||
# signature 块是 thinking 的加密签名,不是 reasoning 内容,忽略
|
||||
|
||||
return "".join(text_parts), tool_calls, reasoning
|
||||
|
||||
def parse_nonstreaming_response(
|
||||
self,
|
||||
raw: dict[str, Any],
|
||||
model_name: str,
|
||||
) -> ChatResult:
|
||||
content, tool_calls, reasoning = self._parse_content_blocks(raw)
|
||||
|
||||
result = ChatResult(
|
||||
content=content,
|
||||
model_name=model_name,
|
||||
)
|
||||
if tool_calls:
|
||||
result.tool_calls = tool_calls
|
||||
if reasoning:
|
||||
result.reasoning = reasoning
|
||||
|
||||
usage = raw.get("usage")
|
||||
if usage:
|
||||
result.usage = TokenUsage(
|
||||
prompt_tokens=usage.get("input_tokens", 0),
|
||||
prompt_tokens_cached=usage.get("cache_read_input_tokens", 0),
|
||||
completion_tokens=usage.get("output_tokens", 0),
|
||||
total_tokens=usage.get("input_tokens", 0) + usage.get("output_tokens", 0),
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 响应解析 —— 流式(SSE 事件行协议)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _parse_stream_event(event_type: str, data: dict[str, Any]) -> ChatChunk:
|
||||
"""解析单条 SSE 流事件为 ChatChunk。"""
|
||||
chunk = ChatChunk()
|
||||
|
||||
if event_type == "message_start":
|
||||
message = data.get("message", {})
|
||||
usage = message.get("usage")
|
||||
if usage:
|
||||
chunk.usage = TokenUsage(
|
||||
prompt_tokens=usage.get("input_tokens", 0),
|
||||
prompt_tokens_cached=usage.get("cache_read_input_tokens", 0),
|
||||
completion_tokens=usage.get("output_tokens", 0),
|
||||
)
|
||||
|
||||
elif event_type == "content_block_start":
|
||||
block = data.get("content_block", {})
|
||||
chunk.content_block_index = data.get("index", 0)
|
||||
chunk.content_block_start = block
|
||||
block_type = block.get("type", "")
|
||||
if block_type == "text":
|
||||
chunk.delta_content = block.get("text", "")
|
||||
elif block_type == "thinking":
|
||||
chunk.delta_reasoning = block.get("thinking", "")
|
||||
elif block_type == "tool_use":
|
||||
chunk.tool_call_chunks.append(
|
||||
{
|
||||
"index": data.get("index", 0),
|
||||
"id": block.get("id"),
|
||||
"name": block.get("name"),
|
||||
"args": "",
|
||||
}
|
||||
)
|
||||
|
||||
elif event_type == "content_block_delta":
|
||||
delta = data.get("delta", {})
|
||||
chunk.content_block_index = data.get("index", 0)
|
||||
chunk.content_block_delta = delta
|
||||
delta_type = delta.get("type", "")
|
||||
if delta_type == "text_delta":
|
||||
chunk.delta_content = delta.get("text", "")
|
||||
elif delta_type == "thinking_delta":
|
||||
chunk.delta_reasoning = delta.get("thinking", "")
|
||||
elif delta_type == "signature_delta":
|
||||
# signature 不展示,但由 Agent Runtime 原样保留供下一轮回放。
|
||||
pass
|
||||
elif delta_type == "input_json_delta":
|
||||
chunk.tool_call_chunks.append(
|
||||
{
|
||||
"index": data.get("index", 0),
|
||||
"id": None,
|
||||
"name": None,
|
||||
"args": delta.get("partial_json", ""),
|
||||
}
|
||||
)
|
||||
|
||||
elif event_type == "message_delta":
|
||||
delta = data.get("delta", {})
|
||||
finish_reason_map = {
|
||||
"end_turn": "stop",
|
||||
"max_tokens": "length",
|
||||
"stop_sequence": "stop",
|
||||
"tool_use": "tool_calls",
|
||||
}
|
||||
stop_reason = delta.get("stop_reason")
|
||||
if stop_reason:
|
||||
chunk.finish_reason = finish_reason_map.get(stop_reason, stop_reason)
|
||||
|
||||
usage = data.get("usage")
|
||||
if usage:
|
||||
chunk.usage = TokenUsage(
|
||||
prompt_tokens=0,
|
||||
completion_tokens=usage.get("output_tokens", 0),
|
||||
)
|
||||
|
||||
# content_block_stop, message_stop, ping — no content to extract
|
||||
|
||||
return chunk
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 公开 API:chat
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def chat(
|
||||
self,
|
||||
model: ModelConfig,
|
||||
messages: list[BaseMessage],
|
||||
api_key: str,
|
||||
base_url: str | None = None,
|
||||
params: dict[str, Any] | None = None,
|
||||
tools: list[dict[str, Any]] | None = None,
|
||||
tool_choice: str | dict[str, Any] | None = None,
|
||||
) -> ChatResult:
|
||||
url = f"{base_url or self._provider.base_url}/v1/messages"
|
||||
body = self.build_request(
|
||||
model,
|
||||
messages,
|
||||
params=params,
|
||||
tools=tools,
|
||||
tool_choice=tool_choice,
|
||||
)
|
||||
|
||||
try:
|
||||
response = await self._http.post(url, json=body, headers=self._headers(api_key))
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
return self.parse_nonstreaming_response(data, model.name)
|
||||
except httpx.HTTPStatusError as exc:
|
||||
raise AIProviderError.from_provider(
|
||||
message=f"Anthropic API error: {exc.response.status_code} {exc.response.text[:200]}",
|
||||
code="PROVIDER_ERROR",
|
||||
provider=self._provider.name,
|
||||
original=exc,
|
||||
) from exc
|
||||
|
||||
async def chat_stream(
|
||||
self,
|
||||
model: ModelConfig,
|
||||
messages: list[BaseMessage],
|
||||
api_key: str,
|
||||
base_url: str | None = None,
|
||||
params: dict[str, Any] | None = None,
|
||||
tools: list[dict[str, Any]] | None = None,
|
||||
tool_choice: str | dict[str, Any] | None = None,
|
||||
) -> AsyncIterator[ChatChunk]:
|
||||
url = f"{base_url or self._provider.base_url}/v1/messages"
|
||||
body = self.build_request(
|
||||
model,
|
||||
messages,
|
||||
params=params,
|
||||
tools=tools,
|
||||
tool_choice=tool_choice,
|
||||
stream=True,
|
||||
)
|
||||
|
||||
async with self._http.stream(
|
||||
"POST",
|
||||
url,
|
||||
json=body,
|
||||
headers=self._headers(api_key),
|
||||
) as response:
|
||||
if response.status_code != 200:
|
||||
error_text = await response.aread()
|
||||
raise AIProviderError.from_provider(
|
||||
message=f"Anthropic stream error: {response.status_code} {error_text[:200]}",
|
||||
code="PROVIDER_ERROR",
|
||||
provider=self._provider.name,
|
||||
)
|
||||
|
||||
current_event = ""
|
||||
async for line in response.aiter_lines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
if line.startswith("event: "):
|
||||
current_event = line[7:]
|
||||
elif line.startswith("data: "):
|
||||
data_str = line[6:]
|
||||
if data_str and current_event:
|
||||
try:
|
||||
raw = json.loads(data_str)
|
||||
yield self._parse_stream_event(current_event, raw)
|
||||
except json.JSONDecodeError:
|
||||
logger.warning(
|
||||
"anthropic_stream_parse_error",
|
||||
event=current_event,
|
||||
raw=data_str[:100],
|
||||
)
|
||||
current_event = ""
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._client:
|
||||
await self._client.aclose()
|
||||
self._client = None
|
||||
|
||||
|
||||
class AnthropicFormatAdapterFactory:
|
||||
"""工厂:创建 Anthropic 格式适配器。"""
|
||||
|
||||
@staticmethod
|
||||
def create(provider_config: ProviderConfig) -> AnthropicFormatAdapter:
|
||||
return AnthropicFormatAdapter(provider_config=provider_config)
|
||||
Reference in new issue
Block a user