Files
writer-work-flow/packages/llm_gateway/ww_llm_gateway/adapters/anthropic.py
Yaojia Wang 765dbdfbd4 feat: M4 文风 + M5 生成/多provider/Skill + Kimi Code 订阅接入 + 本地联调修复
M4(文风): style-auditor 双轨(提取指纹/漂移第四审)+ jobs 长任务框架(zombie reaper) + 回炉 refine + GET /style read-back。
M5(生成+扩展): worldbuilder/character-gen(入库 continuity 409 gate + partition_writes 白名单 + schema→JSONB 形变);
  网关多 provider 回退链/熔断/能力降级(Anthropic/Gemini 适配器);Skill registry + 表权限沙箱 + 规则;
  前端 角色生成器/世界观/Codex/规则页/技能库/⌘K 命令面板。
K1(Kimi Code 订阅接入): OAuth device-flow(kimi-code)+ 静态 Console key(kimi-code-key)两路径;
  coding 端点 KimiCLI 伪造头(实测 UA allow-list 门禁,缺则 403)+ JSON 模式结构化(thinking ⊥ tool_choice)。
本地联调修复: CORS 中间件;assemble 注入 premise+「写第N章」指令(修空 prompt 400);
  GET /outline·/draft read-back + 大纲/工作台/审稿页重载;写页 client/server 常量边界 + notFound 健壮化;
  字数 toLocaleString locale 水合;审稿页终稿从已存草稿 seed(修 accept 422)。
门禁: backend ruff/mypy(157)/alembic 无漂移/pytest 451 · frontend lint/tsc/vitest/build。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 10:39:58 +02:00

185 lines
6.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""AnthropicClaude适配器ARCH §4.2/§4.4/§4.6)。
- 能力:原生结构化输出(经 instructor、显式前缀缓存`cache_control` 断点)、思考。
- 网络客户端注入(`AnthropicClient` Protocol`AsyncAnthropic` 即满足)——测试注入替身,
绝不联网;真实 SDK 仅在构造客户端时apps/api需要本模块不硬 import `anthropic`。
- 瞬时故障429/超时/5xx/连接错误)翻译为 `TransientProviderError`,交网关退避/回退。
"""
from __future__ import annotations
from collections.abc import AsyncIterator
from typing import TYPE_CHECKING, Any, Protocol, cast
import instructor
from pydantic import BaseModel
from ..errors import TransientProviderError
from ..types import LlmRequest
from .base import Capabilities, ProviderResult, ProviderUsage, StreamChunk
if TYPE_CHECKING:
from anthropic import AsyncAnthropic
# 瞬时错误类名(按名匹配,避免硬依赖 anthropic SDK 类型)。
_TRANSIENT_NAMES = frozenset(
{
"RateLimitError",
"APITimeoutError",
"APIConnectionError",
"InternalServerError",
"APIStatusError",
}
)
class AnthropicClient(Protocol):
"""`AsyncAnthropic` 满足此 Protocol仅用到 `messages`)。"""
messages: Any
class StructuredAnthropic(Protocol):
async def create(self, *, response_model: type[BaseModel], **kwargs: Any) -> BaseModel: ...
def _is_transient(exc: Exception) -> bool:
name = type(exc).__name__
if name in _TRANSIENT_NAMES:
return True
status = getattr(exc, "status_code", None)
return isinstance(status, int) and (status == 429 or status >= 500)
def _system_blocks(req: LlmRequest) -> list[dict[str, Any]]:
"""system 块;缓存断点前的稳定块带 `cache_control`§4.6)。"""
out: list[dict[str, Any]] = []
for b in req.system:
block: dict[str, Any] = {"type": "text", "text": b.text}
if b.cache:
block["cache_control"] = {"type": "ephemeral"}
out.append(block)
return out
def _input_text(req: LlmRequest) -> str:
if isinstance(req.input, str):
return req.input
return "\n\n".join(b.text for b in req.input)
def _user_messages(req: LlmRequest) -> list[dict[str, Any]]:
return [{"role": "user", "content": _input_text(req)}]
def _usage_from(usage: Any) -> ProviderUsage:
if usage is None:
return ProviderUsage(input_tokens=0, output_tokens=0)
return ProviderUsage(
input_tokens=int(getattr(usage, "input_tokens", 0) or 0),
output_tokens=int(getattr(usage, "output_tokens", 0) or 0),
cache_read_tokens=int(getattr(usage, "cache_read_input_tokens", 0) or 0),
)
def _text_from(resp: Any) -> str:
parts: list[str] = []
for block in getattr(resp, "content", []) or []:
if getattr(block, "type", None) == "text":
parts.append(getattr(block, "text", "") or "")
return "".join(parts)
_DEFAULT_MAX_TOKENS = 4096
class AnthropicAdapter:
def __init__(
self,
provider: str,
client: AnthropicClient,
*,
structured_client: StructuredAnthropic | None = None,
) -> None:
self.provider = provider
self._client = client
self._structured_client = structured_client
def capabilities(self) -> Capabilities:
return Capabilities(structured_output=True, prefix_cache=True, thinking=True)
def _structured(self) -> StructuredAnthropic:
if self._structured_client is None:
# 注入客户端是 `AnthropicClient` Protocol保留测试替身缝
# 但 instructor.from_anthropic 只重载真实 SDK 的具体类型;
# 本适配器异步 → 转 `AsyncAnthropic` 选中 AsyncInstructor 重载,
# 返回的 AsyncInstructor 鸭子匹配 StructuredAnthropic。
async_client = cast("AsyncAnthropic", self._client)
self._structured_client = cast(
"StructuredAnthropic", instructor.from_anthropic(async_client)
)
return self._structured_client
async def complete(self, req: LlmRequest, model: str) -> ProviderResult:
try:
if req.output_schema is not None:
return await self._complete_structured(req, model)
return await self._complete_text(req, model)
except Exception as exc:
if _is_transient(exc):
raise TransientProviderError(str(exc), provider=self.provider) from exc
raise
async def _complete_text(self, req: LlmRequest, model: str) -> ProviderResult:
kwargs: dict[str, Any] = {
"model": model,
"max_tokens": req.max_tokens or _DEFAULT_MAX_TOKENS,
"messages": _user_messages(req),
}
system = _system_blocks(req)
if system:
kwargs["system"] = system
resp = await self._client.messages.create(**kwargs)
usage = _usage_from(getattr(resp, "usage", None))
return ProviderResult(text=_text_from(resp), usage=usage)
async def _complete_structured(self, req: LlmRequest, model: str) -> ProviderResult:
assert req.output_schema is not None
kwargs: dict[str, Any] = {
"model": model,
"max_tokens": req.max_tokens or _DEFAULT_MAX_TOKENS,
"messages": _user_messages(req),
}
system = _system_blocks(req)
if system:
kwargs["system"] = system
parsed = await self._structured().create(response_model=req.output_schema, **kwargs)
# instructor.from_anthropic 默认不回 raw usage按名隐藏usage 经流/text 路径覆盖。
usage = ProviderUsage(input_tokens=0, output_tokens=0)
return ProviderResult(text=parsed.model_dump_json(), usage=usage, parsed=parsed)
async def stream(self, req: LlmRequest, model: str) -> AsyncIterator[StreamChunk]:
kwargs: dict[str, Any] = {
"model": model,
"max_tokens": req.max_tokens or _DEFAULT_MAX_TOKENS,
"messages": _user_messages(req),
}
system = _system_blocks(req)
if system:
kwargs["system"] = system
try:
async with self._client.messages.stream(**kwargs) as stream:
async for event in stream:
if getattr(event, "type", None) == "content_block_delta":
delta = getattr(event, "delta", None)
text = getattr(delta, "text", "") if delta is not None else ""
if text:
yield StreamChunk(text=text)
usage = getattr(event, "usage", None)
if usage is not None:
yield StreamChunk(usage=_usage_from(usage))
except Exception as exc:
if _is_transient(exc):
raise TransientProviderError(str(exc), provider=self.provider) from exc
raise