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>
This commit is contained in:
Yaojia Wang
2026-06-20 10:39:58 +02:00
parent 5fb7bfb1de
commit 765dbdfbd4
161 changed files with 17330 additions and 208 deletions

View File

@@ -1,7 +1,11 @@
"""LLM 网关C1 / ARCH §4薄自建tier→provider+model屏蔽厂商差异。"""
"""LLM 网关C1 / ARCH §4薄自建tier→provider+model屏蔽厂商差异。
T5.4 扩多适配器Anthropic/Gemini/OpenAI 兼容)+ 回退链 + 熔断 + 能力协商/降级。
"""
from __future__ import annotations
from .adapters.anthropic import AnthropicAdapter
from .adapters.base import (
Capabilities,
ProviderAdapter,
@@ -9,10 +13,30 @@ from .adapters.base import (
ProviderUsage,
StreamChunk,
)
from .adapters.gemini import GeminiAdapter
from .adapters.kimi_code import (
KIMI_CLI_VERSION,
KIMI_CODE_BASE_URL,
KIMI_CODE_PLATFORM,
KIMI_CODE_PROVIDER,
KIMI_CODE_USER_AGENT,
KimiCodeAdapter,
build_kimi_code_client,
kimi_code_headers,
kimi_device_id,
kimi_device_model,
)
from .adapters.kimi_code_key import (
KIMI_CODE_KEY_PROVIDER,
KimiCodeKeyAdapter,
build_kimi_code_key_client,
)
from .adapters.openai_compat import OpenAICompatAdapter
from .gateway import Gateway
from .errors import TransientProviderError
from .factory import build_adapter
from .gateway import CircuitBreaker, Gateway
from .ledger import LedgerSink, SqlAlchemyLedgerSink
from .routing import Route, resolve_route
from .routing import ChainResolver, Route, chain_from_routing, resolve_chain, resolve_route
from .types import (
Block,
Delta,
@@ -25,10 +49,22 @@ from .types import (
)
__all__ = [
"AnthropicAdapter",
"Block",
"Capabilities",
"ChainResolver",
"CircuitBreaker",
"Delta",
"Gateway",
"GeminiAdapter",
"KIMI_CLI_VERSION",
"KIMI_CODE_BASE_URL",
"KIMI_CODE_KEY_PROVIDER",
"KIMI_CODE_PLATFORM",
"KIMI_CODE_PROVIDER",
"KIMI_CODE_USER_AGENT",
"KimiCodeAdapter",
"KimiCodeKeyAdapter",
"LedgerSink",
"LlmRequest",
"LlmResponse",
@@ -42,6 +78,15 @@ __all__ = [
"SqlAlchemyLedgerSink",
"StreamChunk",
"Tier",
"TransientProviderError",
"Usage",
"build_adapter",
"build_kimi_code_client",
"build_kimi_code_key_client",
"chain_from_routing",
"kimi_code_headers",
"kimi_device_id",
"kimi_device_model",
"resolve_chain",
"resolve_route",
]

View File

@@ -0,0 +1,184 @@
"""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

View File

@@ -0,0 +1,127 @@
"""Google Gemini 适配器ARCH §4.2/§4.4)。
- 能力:原生结构化输出(`response_mime_type=application/json` + `response_schema`)、思考。
前缀缓存走 Gemini 隐式/显式缓存,机制差异大,原型保守标 `prefix_cache=False`。
- 客户端注入(`GeminiClient` Protocol`google.genai.Client().aio` 满足)——测试注入替身,
绝不联网;真实 SDK`google-genai`)仅在 apps/api 构造客户端时需要,本模块不硬 import。
- 瞬时故障翻译为 `TransientProviderError`,交网关退避/回退。
"""
from __future__ import annotations
from collections.abc import AsyncIterator
from typing import Any, Protocol
from ..errors import TransientProviderError
from ..types import LlmRequest
from .base import Capabilities, ProviderResult, ProviderUsage, StreamChunk
_TRANSIENT_NAMES = frozenset(
{
"ResourceExhausted",
"ServiceUnavailable",
"DeadlineExceeded",
"InternalServerError",
"ServerError",
"APIError",
}
)
class GeminiModels(Protocol):
async def generate_content(self, **kwargs: Any) -> Any: ...
def generate_content_stream(self, **kwargs: Any) -> Any: ...
class GeminiAio(Protocol):
models: GeminiModels
class GeminiClient(Protocol):
"""`google.genai.Client()` 满足此 Protocol用其 `.aio.models`)。"""
aio: GeminiAio
def _is_transient(exc: Exception) -> bool:
name = type(exc).__name__
if name in _TRANSIENT_NAMES:
return True
status = getattr(exc, "code", None) or getattr(exc, "status_code", None)
return isinstance(status, int) and (status == 429 or status >= 500)
def _contents(req: LlmRequest) -> str:
if isinstance(req.input, str):
return req.input
return "\n\n".join(b.text for b in req.input)
def _system_text(req: LlmRequest) -> str:
return "\n\n".join(b.text for b in req.system)
def _usage_from(meta: Any) -> ProviderUsage:
if meta is None:
return ProviderUsage(input_tokens=0, output_tokens=0)
return ProviderUsage(
input_tokens=int(getattr(meta, "prompt_token_count", 0) or 0),
output_tokens=int(getattr(meta, "candidates_token_count", 0) or 0),
cache_read_tokens=int(getattr(meta, "cached_content_token_count", 0) or 0),
)
def _config(req: LlmRequest) -> dict[str, Any]:
cfg: dict[str, Any] = {}
system = _system_text(req)
if system:
cfg["system_instruction"] = system
if req.max_tokens is not None:
cfg["max_output_tokens"] = req.max_tokens
if req.output_schema is not None:
cfg["response_mime_type"] = "application/json"
cfg["response_schema"] = req.output_schema
return cfg
class GeminiAdapter:
def __init__(self, provider: str, client: GeminiClient) -> None:
self.provider = provider
self._client = client
def capabilities(self) -> Capabilities:
return Capabilities(structured_output=True, prefix_cache=False, thinking=True)
async def complete(self, req: LlmRequest, model: str) -> ProviderResult:
try:
resp = await self._client.aio.models.generate_content(
model=model, contents=_contents(req), config=_config(req)
)
except Exception as exc:
if _is_transient(exc):
raise TransientProviderError(str(exc), provider=self.provider) from exc
raise
text = getattr(resp, "text", "") or ""
usage = _usage_from(getattr(resp, "usage_metadata", None))
if req.output_schema is not None:
parsed = req.output_schema.model_validate_json(text)
return ProviderResult(text=text, usage=usage, parsed=parsed)
return ProviderResult(text=text, usage=usage)
async def stream(self, req: LlmRequest, model: str) -> AsyncIterator[StreamChunk]:
try:
stream = await self._client.aio.models.generate_content_stream(
model=model, contents=_contents(req), config=_config(req)
)
async for chunk in stream:
text = getattr(chunk, "text", "") or ""
if text:
yield StreamChunk(text=text)
meta = getattr(chunk, "usage_metadata", None)
if meta is not None:
yield StreamChunk(usage=_usage_from(meta))
except Exception as exc:
if _is_transient(exc):
raise TransientProviderError(str(exc), provider=self.provider) from exc
raise

View File

@@ -0,0 +1,190 @@
"""Kimi Code 订阅 plan 适配器K1.2 / PROGRESS K1
Kimi 的 coding 端点是 **OpenAI 兼容** 的,因此本适配器复用 `OpenAICompatAdapter`——
区别仅在:① base_url 指向 coding 端点;② 必须携带伪造的官方客户端头集;③ access_token
作为 bearerOpenAI SDK 的 `api_key` 自动发 `Authorization: Bearer <token>`)。
**伪造头集的真源 = `github.com/ooojustin/opencode-kimi``src/headers.ts` + `src/constants.ts`
1:1 镜像 kimi-cli v1.37.0)。** coding API 会校验这 7 个头;任何偏差都会让 Moonshot 后端
返回 `access_terminated_error: only available for Coding Agents`403。本实现据此发送完整
7 头UA `KimiCLI/1.37.0` + 6 个 `X-Msh-*`。
注意:早先曾参照 `picassio/pi-kimi-coder``extensions/index.ts`**仅**发 UA、不带 X-Msh-*
把头集裁成 UA-only——那是分歧/错误的参考实现,导致了误修。现已回退到 opencode 规范的完整
7 头(见 `memory/gotchas.md`)。
`access_token` 由 @backendK1.3 OAuth device 服务)按需刷新后传入;本适配器只接收当前
access token 作为 `api_key`,不负责刷新/获取 token。
"""
from __future__ import annotations
import os
import platform
import socket
import uuid
from pathlib import Path
import instructor
from openai import AsyncOpenAI
from .openai_compat import OpenAICompatAdapter, StructuredClient
#: Kimi 订阅 plan 的 coding 推理端点OpenAI 兼容)。
KIMI_CODE_BASE_URL = "https://api.kimi.com/coding/v1"
#: CLI 版本opencode `constants.ts` KIMI_CLI_VERSION镜像 kimi-cli v1.37.0)。
KIMI_CLI_VERSION = "1.37.0"
#: 伪造的官方客户端 UAopencode `USER_AGENT = f"KimiCLI/{KIMI_CLI_VERSION}"`)。
#: UA 前缀必须精确为 `KimiCLI/<version>`,且 version 必须与 X-Msh-Version 一致。
KIMI_CODE_USER_AGENT = f"KimiCLI/{KIMI_CLI_VERSION}"
#: `X-Msh-Platform` 是字面常量字符串(**不是** OS 名)。
KIMI_CODE_PLATFORM = "kimi_cli"
#: provider 名(与既有 API-key provider `kimi` 分离,便于档位切换)。
KIMI_CODE_PROVIDER = "kimi-code"
#: device-id 覆盖用环境变量(优先级高于持久化文件,便于测试/CI 注入)。
KIMI_DEVICE_ID_ENV = "KIMI_DEVICE_ID"
#: device-id 持久化路径(与 kimi-cli / opencode 共享 `~/.kimi/device_id`,保证单一稳定指纹)。
KIMI_DEVICE_ID_DIR = Path.home() / ".kimi"
KIMI_DEVICE_ID_PATH = KIMI_DEVICE_ID_DIR / "device_id"
#: HTTP 头值若含非 ASCII 会被底层 fetch/httpx 拒绝opencode `asciiHeaderValue` 同样裁剪。
_ASCII_FALLBACK = "unknown"
def _ascii_header_value(value: str, fallback: str = _ASCII_FALLBACK) -> str:
"""裁掉非可打印 ASCII`\\x20-\\x7e` 之外)+ 去空白;空则回退。
镜像 opencode `asciiHeaderValue`(含非 ASCII 的头值会被底层 fetch/httpx 拒绝)。
"""
sanitized = "".join(ch for ch in value if "\x20" <= ch <= "\x7e").strip()
return sanitized or fallback
def kimi_device_id() -> str:
"""返回稳定的 device-id32 位无连字符 UUID4 hex
优先级:① 环境变量 `KIMI_DEVICE_ID`;② 持久化文件 `~/.kimi/device_id`(不存在则生成一次
`uuid.uuid4().hex` 写入、之后复用)。跨调用/进程重启保持稳定,**绝不**每次随机。
"""
env_value = os.environ.get(KIMI_DEVICE_ID_ENV, "").strip()
if env_value:
return env_value
if KIMI_DEVICE_ID_PATH.exists():
existing = KIMI_DEVICE_ID_PATH.read_text(encoding="utf-8").strip()
if existing:
return existing
KIMI_DEVICE_ID_DIR.mkdir(mode=0o700, parents=True, exist_ok=True)
device_id = uuid.uuid4().hex
KIMI_DEVICE_ID_PATH.write_text(device_id, encoding="utf-8")
KIMI_DEVICE_ID_PATH.chmod(0o600)
return device_id
def kimi_device_model() -> str:
"""`X-Msh-Device-Model`:镜像 opencode `kimiDeviceModel()` 的 OS 分支格式。
- macOS`f"macOS {mac_ver} {machine}"`(如 `"macOS 14.5 arm64"`
- Windows`f"Windows {release} {machine}"`
- 其它:`f"{system} {release} {machine}"`。
`platform.machine()` 返回 `arm64`/`x86_64`**不**做归一化)。
"""
system = platform.system()
release = platform.release()
machine = platform.machine()
if system == "Darwin":
version = platform.mac_ver()[0] or release
if version and machine:
return f"macOS {version} {machine}"
if version:
return f"macOS {version}"
return f"macOS {machine}".strip()
if system == "Windows":
if release and machine:
return f"Windows {release} {machine}"
if release:
return f"Windows {release}"
return f"Windows {machine}".strip()
if system:
if release and machine:
return f"{system} {release} {machine}"
if release:
return f"{system} {release}"
return f"{system} {machine}".strip()
return "Unknown"
def _device_name() -> str:
"""`X-Msh-Device-Name`主机名ASCII 化)。`socket.gethostname()` ≈ Node `os.hostname()`。"""
return _ascii_header_value(socket.gethostname() or platform.node())
def _os_version() -> str:
"""`X-Msh-Os-Version`OS 内核版本串。`platform.version()` ≈ Node `os.version()`。"""
fallback = f"{platform.system()} {platform.release()}"
return _ascii_header_value(platform.version() or fallback)
def kimi_code_headers() -> dict[str, str]:
"""组装 Kimi coding API 必需的伪造客户端头集opencode 规范的完整 7 头)。
真源:`ooojustin/opencode-kimi` `src/headers.ts` `kimiHeaders()`。
"""
return {
"User-Agent": KIMI_CODE_USER_AGENT,
"X-Msh-Platform": KIMI_CODE_PLATFORM,
"X-Msh-Version": KIMI_CLI_VERSION,
"X-Msh-Device-Name": _device_name(),
"X-Msh-Device-Model": _ascii_header_value(kimi_device_model()),
"X-Msh-Device-Id": kimi_device_id(),
"X-Msh-Os-Version": _os_version(),
}
def build_kimi_code_client(access_token: str, *, base_url: str | None = None) -> AsyncOpenAI:
"""构造带伪造头的 `AsyncOpenAI` 客户端access_token → bearercoding base"""
return AsyncOpenAI(
api_key=access_token,
base_url=base_url or KIMI_CODE_BASE_URL,
default_headers=kimi_code_headers(),
)
def build_kimi_code_structured_client(client: AsyncOpenAI) -> StructuredClient:
"""Kimi 结构化输出走 instructor **JSON 模式**`response_format`**不**用 TOOLS 模式。
缘由:`kimi-for-coding` 默认开启 thinking而 Moonshot 后端对「thinking 开启 + 强制
`tool_choice`」组合返回 `400 tool_choice 'specified' is incompatible with thinking enabled`。
instructor 默认 `Mode.TOOLS` 会发强制 `tool_choice` → 触发该 400。改用 `Mode.JSON`
`response_format={"type":"json_object"}` + schema 注入 prompt即避开 tool_choice
与 thinking 兼容。仅对 `kimi-code` 生效,其它 provider 维持默认模式。
"""
return instructor.from_openai(client, mode=instructor.Mode.JSON)
class KimiCodeAdapter(OpenAICompatAdapter):
"""Kimi Code 适配器:复用 OpenAI 兼容行为,固定 provider 名 `kimi-code`。
model如 `kimi-for-coding`)经 `.complete(req, model)` 传入(路由/档位关注点),
**不**在此硬编码。结构化输出强制走 JSON 模式(见 `build_kimi_code_structured_client`)。
"""
def __init__(
self, client: AsyncOpenAI, *, structured_client: StructuredClient | None = None
) -> None:
super().__init__(
KIMI_CODE_PROVIDER,
client,
structured_client=structured_client or build_kimi_code_structured_client(client),
)

View File

@@ -0,0 +1,64 @@
"""Kimi Code 订阅 plan — 静态 Console API Key 适配器。
Kimi Code Console`kimi.com/code/console`)签发的 API Key 走**订阅额度**,命中与 OAuth
`kimi-code` **相同**的 coding 端点(`https://api.kimi.com/coding/v1`OpenAI 兼容)+ 相同
model `kimi-for-coding`。
**实测纠正2026-06-20**coding 端点会校验 `User-Agent`,对非 allow-list 客户端返回
`403 access_terminated_error: only available for Coding Agents such as Kimi CLI, Claude Code,
Roo Code, ...`——**无论用静态 key 还是 OAuth token**。即静态 key 仅解决鉴权(无 401
UA 门禁403依旧。故本适配器**也必须发**与 OAuth 相同的伪造客户端头集
`kimi_code_headers()``KimiCLI/1.37.0` + `X-Msh-*`),否则 403。早先「纯 key 无需伪造头、
ToS 合规」的判断被实测推翻——**两条订阅路径都需 UA 伪造 = 同样的 ToS 违规/封号风险**。本路径
相对 OAuth 的唯一优势是用静态 Console key无 token 刷新机制),风险等同。真正合规的只有
按量付费的 Moonshot 平台 keyprovider `kimi`)。
唯一与普通 OpenAI 兼容 provider 的另一区别coding 端点跑 `kimi-for-coding` 且 **thinking 开启**
Moonshot 后端对「thinking + 强制 `tool_choice`」返回 `400`。因此结构化输出走 instructor
**JSON 模式**(复用 `build_kimi_code_structured_client`),而非默认 TOOLS 模式。
"""
from __future__ import annotations
from openai import AsyncOpenAI
from .kimi_code import (
KIMI_CODE_BASE_URL,
build_kimi_code_structured_client,
kimi_code_headers,
)
from .openai_compat import OpenAICompatAdapter, StructuredClient
#: provider 名(与 OAuth 的 `kimi-code`、moonshot 的 `kimi` 均分离,便于档位切换)。
KIMI_CODE_KEY_PROVIDER = "kimi-code-key"
def build_kimi_code_key_client(api_key: str, *, base_url: str | None = None) -> AsyncOpenAI:
"""构造 coding 端点客户端key → bearer + **伪造客户端头**coding base
实测coding 端点据 `User-Agent` 做 allow-list 门禁,缺伪造头 → 403。故与 OAuth 的
`build_kimi_code_client` 一样必须带 `default_headers=kimi_code_headers()`
`KimiCLI/1.37.0` + `X-Msh-*`);唯一区别是凭据来源是静态 Console key 而非 OAuth token。
"""
return AsyncOpenAI(
api_key=api_key,
base_url=base_url or KIMI_CODE_BASE_URL,
default_headers=kimi_code_headers(),
)
class KimiCodeKeyAdapter(OpenAICompatAdapter):
"""Kimi Code 静态 Key 适配器OpenAI 兼容 + 伪造客户端头 + JSON 模式结构化。
model`kimi-for-coding`)经 `.complete(req, model)` 由路由/档位传入,**不**在此硬编码。
结构化输出强制走 JSON 模式thinking 兼容);发与 OAuth 相同的伪造头以过 coding 端点 UA 门禁。
"""
def __init__(
self, client: AsyncOpenAI, *, structured_client: StructuredClient | None = None
) -> None:
super().__init__(
KIMI_CODE_KEY_PROVIDER,
client,
structured_client=structured_client or build_kimi_code_structured_client(client),
)

View File

@@ -13,9 +13,29 @@ from openai import AsyncOpenAI
from openai.types.chat import ChatCompletionMessageParam
from pydantic import BaseModel
from ..errors import TransientProviderError
from ..types import LlmRequest
from .base import Capabilities, ProviderResult, ProviderUsage, StreamChunk
# OpenAI 兼容 SDK 的瞬时错误类名(按名匹配,覆盖 DeepSeek/Kimi/Qwen/GLM 等共用 SDK
_TRANSIENT_NAMES = frozenset(
{
"RateLimitError",
"APITimeoutError",
"APIConnectionError",
"InternalServerError",
"APIStatusError",
}
)
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)
class StructuredClient(Protocol):
"""instructor 风格的结构化客户端缝(`AsyncInstructor` 即满足此协议)。
@@ -87,9 +107,14 @@ class OpenAICompatAdapter:
return self._structured_client
async def complete(self, req: LlmRequest, model: str) -> ProviderResult:
if req.output_schema is not None:
return await self._complete_structured(req, model)
return await self._complete_text(req, model)
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:
resp = await self._client.chat.completions.create(
@@ -113,17 +138,22 @@ class OpenAICompatAdapter:
return ProviderResult(text=parsed.model_dump_json(), usage=usage, parsed=parsed)
async def stream(self, req: LlmRequest, model: str) -> AsyncIterator[StreamChunk]:
stream = await self._client.chat.completions.create(
model=model,
messages=_messages(req),
max_tokens=req.max_tokens,
stream=True,
stream_options={"include_usage": True},
)
async for chunk in stream:
if chunk.choices:
delta = chunk.choices[0].delta
if delta and delta.content:
yield StreamChunk(text=delta.content)
if getattr(chunk, "usage", None):
yield StreamChunk(usage=_usage_from(chunk.usage))
try:
stream = await self._client.chat.completions.create(
model=model,
messages=_messages(req),
max_tokens=req.max_tokens,
stream=True,
stream_options={"include_usage": True},
)
async for chunk in stream:
if chunk.choices:
delta = chunk.choices[0].delta
if delta and delta.content:
yield StreamChunk(text=delta.content)
if getattr(chunk, "usage", None):
yield StreamChunk(usage=_usage_from(chunk.usage))
except Exception as exc:
if _is_transient(exc):
raise TransientProviderError(str(exc), provider=self.provider) from exc
raise

View File

@@ -0,0 +1,22 @@
"""网关内部异常类型ARCH §4.5)。
适配器把厂商的可重试故障(限流/超时/5xx翻译为 `TransientProviderError`
网关据此决定退避重试 / 切回退链。非瞬时错误(内容策略拒绝等)由适配器抛
`AppError` 或原样上抛,网关不盲目重试。
`ww_shared.AppError(ErrorCode.RATE_LIMITED)` 也被网关视作可重试/可回退(限流即瞬时)。
"""
from __future__ import annotations
class TransientProviderError(Exception):
"""瞬时、可重试的 provider 故障429/超时/5xx/连接错误)。
适配器在 `except` 厂商异常时包装抛出,携带原始 provider 名便于日志/熔断归因。
"""
def __init__(self, message: str, *, provider: str | None = None) -> None:
super().__init__(message)
self.message = message
self.provider = provider

View File

@@ -0,0 +1,77 @@
"""Provider→适配器工厂ARCH §4.2/§4.3)。
`build_gateway_for_tier`apps/api据 DB `tier_routing` 的 primary + fallback 逐 provider
建适配器进 `adapters` dict——本工厂按 provider 名分派到对应适配器类,构造其真实客户端。
- `anthropic` → `AnthropicAdapter`(懒构建 `AsyncAnthropic`,不在模块顶层硬 import SDK
- `gemini`/`google` → `GeminiAdapter`(懒构建 `google.genai.Client`)。
- `kimi-code` → `KimiCodeAdapter`OAuth access token 当 api_key + coding base + 伪造头)。
- `kimi-code-key` → `KimiCodeKeyAdapter`(静态 Console Key + coding base**无伪造头**)。
- 其余deepseek/kimi/qwen/glm/openai→ `OpenAICompatAdapter(provider, AsyncOpenAI(...))`。
适配器本身仍接注入客户端(测试零联网);本工厂从 api_key 构造**真实**客户端,
故单测只断分派逻辑(按 provider 名选哪个适配器类),不联网。
"""
from __future__ import annotations
from typing import cast
from openai import AsyncOpenAI
from .adapters.anthropic import AnthropicAdapter, AnthropicClient
from .adapters.base import ProviderAdapter
from .adapters.gemini import GeminiAdapter, GeminiClient
from .adapters.kimi_code import KIMI_CODE_PROVIDER, KimiCodeAdapter, build_kimi_code_client
from .adapters.kimi_code_key import (
KIMI_CODE_KEY_PROVIDER,
KimiCodeKeyAdapter,
build_kimi_code_key_client,
)
from .adapters.openai_compat import OpenAICompatAdapter
# 走专用 SDK 适配器的 provider 名(其余一律走 OpenAI 兼容)。
_ANTHROPIC_PROVIDERS = frozenset({"anthropic"})
_GEMINI_PROVIDERS = frozenset({"gemini", "google"})
def _build_anthropic(provider: str, *, api_key: str, base_url: str | None) -> ProviderAdapter:
# 懒 importSDK 仅在真实接线 Anthropic 时才需要(单测不触发此分支)。
from anthropic import AsyncAnthropic
kwargs: dict[str, object] = {"api_key": api_key}
if base_url is not None:
kwargs["base_url"] = base_url
client = AsyncAnthropic(**kwargs) # type: ignore[arg-type]
# `AsyncAnthropic` 满足 `AnthropicClient` Protocolread-only `messages` 不影响鸭子用法);
# cast 跨过 Protocol settable-attr 静态约束(适配器仅读取该属性)。
return AnthropicAdapter(provider, cast("AnthropicClient", client))
def _build_gemini(provider: str, *, api_key: str, base_url: str | None) -> ProviderAdapter:
# 懒 import`google-genai` 仅在真实接线 Gemini 时才需要。
from google import genai
client = genai.Client(api_key=api_key)
# `genai.Client` 满足 `GeminiClient` Protocol用其 `.aio.models`cast 跨过
# Protocol settable-attr 静态约束(适配器仅读取 `.aio`)。
return GeminiAdapter(provider, cast("GeminiClient", client))
def build_adapter(provider: str, *, api_key: str, base_url: str | None = None) -> ProviderAdapter:
"""按 provider 名构造对应适配器,从 api_key+可选 base_url建真实客户端。
`build_gateway_for_tier`apps/api逐 provider 调用本工厂,组装回退链的 adapters。
"""
if provider == KIMI_CODE_PROVIDER:
# Kimi 订阅 planOAuthapi_key = OAuth access tokencoding base + 伪造头(缺头 403
return KimiCodeAdapter(build_kimi_code_client(api_key, base_url=base_url))
if provider == KIMI_CODE_KEY_PROVIDER:
# Kimi 订阅 plan静态 Console KeyToS 合规coding base + 纯 bearer**无伪造头**
# 结构化走 JSON 模式thinking 兼容)。
return KimiCodeKeyAdapter(build_kimi_code_key_client(api_key, base_url=base_url))
if provider in _ANTHROPIC_PROVIDERS:
return _build_anthropic(provider, api_key=api_key, base_url=base_url)
if provider in _GEMINI_PROVIDERS:
return _build_gemini(provider, api_key=api_key, base_url=base_url)
return OpenAICompatAdapter(provider, AsyncOpenAI(api_key=api_key, base_url=base_url))

View File

@@ -1,24 +1,56 @@
"""网关核心:路由 → 调用适配器 → 记账 → 返回ARCH §4.14.3/§4.8
"""网关核心:路由 → 回退链 → 重试/熔断 → 调用适配器 → 记账 → 返回。
ARCH §4.3(路由)/§4.4(能力协商降级)/§4.5(回退/重试/熔断)/§4.8(记账)。
T5.4 多 provider 韧性:
- **回退链**`chain_resolver(tier)` 返 [(provider, model), ...];主失败→退避重试→
仍失败切下一个;首个成功者服务并标 `served_by.fell_back`(非链首即 True
- **熔断**`CircuitBreaker` 按 provider 连续失败计数,超阈值短时熔断、直接跳过。
- **能力降级**:结构化输出请求优先选声明支持的 provider链上无支持者则降级用
首个可用(适配器自走 instructor JSON 提示),标 `served_by.degraded`。
- **重试归网关**不变量瞬时错误TransientProviderError / RATE_LIMITED
tenacity 指数退避重试 R 次;节点只感知干净的最终失败,绝不自循环。
M1 单 provider无回退/熔断(那是 M5/T5.4)。流式经 `stream()` 归一为 `Delta`。
日志脱敏:只记长度,绝不记原文/api key不变量、§9.3)。
"""
from __future__ import annotations
import time
from collections.abc import AsyncIterator, Callable
import structlog
from tenacity import (
AsyncRetrying,
retry_if_exception,
stop_after_attempt,
wait_exponential,
)
from ww_shared import AppError, ErrorCode
from .adapters.base import ProviderAdapter, ProviderUsage
from .adapters.base import ProviderAdapter, ProviderResult, ProviderUsage
from .errors import TransientProviderError
from .ledger import LedgerSink
from .pricing import cost_minor
from .routing import Route, resolve_route
from .routing import Route, resolve_chain
from .types import Delta, LlmRequest, LlmResponse, ServedBy, Tier, Usage
log = structlog.get_logger(__name__)
# 退避min 0.05s、max 2s确定性测试通过 max_retries 控制次数,退避不影响断言)。
_RETRY_MIN_SECONDS = 0.05
_RETRY_MAX_SECONDS = 2.0
_DEFAULT_MAX_RETRIES = 2
_DEFAULT_BREAKER_THRESHOLD = 5
_DEFAULT_BREAKER_RESET_SECONDS = 30.0
def _is_retryable(exc: BaseException) -> bool:
"""瞬时、可退避重试 + 可回退的错误TransientProviderError 或 RATE_LIMITED。"""
if isinstance(exc, TransientProviderError):
return True
return isinstance(exc, AppError) and exc.code == ErrorCode.RATE_LIMITED
def _input_len(req: LlmRequest) -> int:
if isinstance(req.input, str):
@@ -26,22 +58,92 @@ def _input_len(req: LlmRequest) -> int:
return sum(len(b.text) for b in req.input)
class CircuitBreaker:
"""每 provider 连续失败计数 → 超阈值短时熔断;成功清零;冷却后半开放行。"""
def __init__(
self,
*,
threshold: int = _DEFAULT_BREAKER_THRESHOLD,
reset_seconds: float = _DEFAULT_BREAKER_RESET_SECONDS,
clock: Callable[[], float] = time.monotonic,
) -> None:
self._threshold = threshold
self._reset_seconds = reset_seconds
self._clock = clock
self._failures: dict[str, int] = {}
self._opened_at: dict[str, float] = {}
def is_open(self, provider: str) -> bool:
opened = self._opened_at.get(provider)
if opened is None:
return False
if self._clock() - opened >= self._reset_seconds:
# 冷却窗口过 → 半开:清状态、放行一次试探。
self._opened_at.pop(provider, None)
self._failures.pop(provider, None)
return False
return True
def record_failure(self, provider: str) -> None:
count = self._failures.get(provider, 0) + 1
self._failures[provider] = count
if count >= self._threshold:
self._opened_at[provider] = self._clock()
def record_success(self, provider: str) -> None:
self._failures.pop(provider, None)
self._opened_at.pop(provider, None)
class Gateway:
def __init__(
self,
adapters: dict[str, ProviderAdapter],
ledger: LedgerSink,
resolver: Callable[[Tier], Route] = resolve_route,
*,
chain_resolver: Callable[[Tier], list[Route]] | None = None,
resolver: Callable[[Tier], Route] | None = None,
max_retries: int = _DEFAULT_MAX_RETRIES,
breaker: CircuitBreaker | None = None,
) -> None:
self._adapters = adapters
self._ledger = ledger
self._resolve = resolver
# `resolver`单路由C1/M1 兼容)会被包成单元素链;优先用 `chain_resolver`(回退链)。
if chain_resolver is not None:
self._resolve_chain = chain_resolver
elif resolver is not None:
single = resolver
def _adapter_for(self, provider: str) -> ProviderAdapter:
adapter = self._adapters.get(provider)
if adapter is None:
raise AppError(ErrorCode.LLM_UNAVAILABLE, f"no adapter for provider {provider!r}")
return adapter
def _as_chain(tier: Tier) -> list[Route]:
return [single(tier)]
self._resolve_chain = _as_chain
else:
self._resolve_chain = resolve_chain
self._max_retries = max_retries
self._breaker = breaker or CircuitBreaker()
# ---- 路由 / 链选择 ----
def _ordered_chain(self, req: LlmRequest) -> list[Route]:
"""据能力协商重排回退链:结构化输出请求优先把声明支持的 provider 提前。
仅在请求带 `output_schema` 时重排——把支持结构化输出的(且已注册适配器、未熔断)
provider 提到前面降低降级概率§4.4)。保持相对顺序稳定。
"""
chain = self._resolve_chain(req.tier)
if req.output_schema is None:
return chain
capable: list[Route] = []
rest: list[Route] = []
for route in chain:
adapter = self._adapters.get(route.provider)
if adapter is not None and adapter.capabilities().structured_output:
capable.append(route)
else:
rest.append(route)
return capable + rest
def _usage(self, route: Route, pu: ProviderUsage) -> Usage:
cost, currency = cost_minor(route.provider, route.model, pu.input_tokens, pu.output_tokens)
@@ -55,7 +157,19 @@ class Gateway:
currency=currency,
)
def _log_call(self, req: LlmRequest, usage: Usage, *, stream: bool) -> None:
def _served_by(
self, route: Route, req: LlmRequest, adapter: ProviderAdapter, *, fell_back: bool
) -> ServedBy:
degraded = bool(
req.output_schema is not None and not adapter.capabilities().structured_output
)
return ServedBy(
provider=route.provider, model=route.model, fell_back=fell_back, degraded=degraded
)
def _log_call(
self, req: LlmRequest, usage: Usage, served_by: ServedBy, *, stream: bool
) -> None:
log.info(
"llm_call",
provider=usage.provider,
@@ -67,33 +181,116 @@ class Gateway:
cost_minor=usage.cost_minor,
currency=usage.currency,
stream=stream,
fell_back=served_by.fell_back,
degraded=served_by.degraded,
input_chars=_input_len(req),
project_id=str(req.scope.project_id) if req.scope.project_id else None,
)
async def run(self, req: LlmRequest) -> LlmResponse:
route = self._resolve(req.tier)
adapter = self._adapter_for(route.provider)
result = await adapter.complete(req, route.model)
usage = self._usage(route, result.usage)
await self._ledger.record(req.scope, usage)
self._log_call(req, usage, stream=False)
return LlmResponse(
text=result.text,
parsed=result.parsed,
usage=usage,
served_by=ServedBy(provider=route.provider, model=route.model),
async def _retrying(self) -> AsyncRetrying:
return AsyncRetrying(
stop=stop_after_attempt(self._max_retries + 1),
wait=wait_exponential(min=_RETRY_MIN_SECONDS, max=_RETRY_MAX_SECONDS),
retry=retry_if_exception(_is_retryable),
reraise=True,
)
# ---- run非流式----
async def run(self, req: LlmRequest) -> LlmResponse:
chain = self._ordered_chain(req)
last_error: Exception | None = None
for idx, route in enumerate(chain):
adapter = self._adapters.get(route.provider)
if adapter is None:
log.warning("llm_route_no_adapter", provider=route.provider, tier=req.tier)
continue
if self._breaker.is_open(route.provider):
log.warning("llm_circuit_open_skip", provider=route.provider, tier=req.tier)
continue
try:
result = await self._complete_with_retry(adapter, req, route.model)
except Exception as exc: # noqa: BLE001 — 链内逐 provider 兜底,最终统一上抛
if not _is_retryable(exc):
raise
self._breaker.record_failure(route.provider)
last_error = exc
log.warning(
"llm_provider_failed",
provider=route.provider,
tier=req.tier,
error=type(exc).__name__,
)
continue
self._breaker.record_success(route.provider)
fell_back = idx > 0
served_by = self._served_by(route, req, adapter, fell_back=fell_back)
usage = self._usage(route, result.usage)
await self._ledger.record(req.scope, usage)
self._log_call(req, usage, served_by, stream=False)
return LlmResponse(
text=result.text, parsed=result.parsed, usage=usage, served_by=served_by
)
raise AppError(
ErrorCode.LLM_UNAVAILABLE,
"所有 provider 回退链耗尽,请稍后重试或更换档位",
{"tier": req.tier, "last_error": type(last_error).__name__ if last_error else None},
)
async def _complete_with_retry(
self, adapter: ProviderAdapter, req: LlmRequest, model: str
) -> ProviderResult:
retrying = await self._retrying()
async for attempt in retrying:
with attempt:
return await adapter.complete(req, model)
raise AssertionError("unreachable: reraise=True")
# ---- stream流式----
async def stream(self, req: LlmRequest) -> AsyncIterator[Delta]:
route = self._resolve(req.tier)
adapter = self._adapter_for(route.provider)
final = ProviderUsage(input_tokens=0, output_tokens=0)
async for chunk in adapter.stream(req, route.model):
if chunk.text:
yield Delta(text=chunk.text)
if chunk.usage is not None:
final = chunk.usage
usage = self._usage(route, final)
await self._ledger.record(req.scope, usage)
self._log_call(req, usage, stream=True)
chain = self._ordered_chain(req)
last_error: Exception | None = None
for idx, route in enumerate(chain):
adapter = self._adapters.get(route.provider)
if adapter is None:
continue
if self._breaker.is_open(route.provider):
continue
# 流式回退:仅在「尚未吐出任何 token」前可切。首块产出后失败属中途失败
# 不静默重连§4.5:已存部分留 draft节点报错停在 write 前 checkpoint
try:
stream_iter = adapter.stream(req, route.model)
final = ProviderUsage(input_tokens=0, output_tokens=0)
started = False
async for chunk in stream_iter:
if chunk.text:
started = True
yield Delta(text=chunk.text)
if chunk.usage is not None:
final = chunk.usage
except Exception as exc: # noqa: BLE001
if started or not _is_retryable(exc):
raise
self._breaker.record_failure(route.provider)
last_error = exc
log.warning(
"llm_provider_failed",
provider=route.provider,
tier=req.tier,
error=type(exc).__name__,
stream=True,
)
continue
self._breaker.record_success(route.provider)
fell_back = idx > 0
served_by = self._served_by(route, req, adapter, fell_back=fell_back)
usage = self._usage(route, final)
await self._ledger.record(req.scope, usage)
self._log_call(req, usage, served_by, stream=True)
return
raise AppError(
ErrorCode.LLM_UNAVAILABLE,
"所有 provider 回退链耗尽,请稍后重试或更换档位",
{"tier": req.tier, "last_error": type(last_error).__name__ if last_error else None},
)

View File

@@ -1,11 +1,14 @@
"""档位路由tier -> (provider, model)ARCH §4.3)。
"""档位路由tier -> 回退链 [(provider, model), ...]ARCH §4.3/§4.5)。
M1 只读全局默认config.tier_defaults形如 "deepseek:deepseek-chat"
作品级 / Skill 级覆盖留待后续§4.3 三级解析)。
- `resolve_route(tier)`单路由M1 兼容;返回链首)。
- `resolve_chain(tier)`:回退链。默认实现只读全局 `config.tier_defaults`(单元素链)。
真正的多 provider 回退链由 apps/api 按 DB `tier_routing.fallback` 构造一个
`ChainResolver` 注入网关§4.3 三级解析 + §4.5 回退链)。
"""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from ww_config import get_settings
@@ -19,11 +22,42 @@ class Route:
model: str
def resolve_route(tier: Tier) -> Route:
spec = get_settings().tier_defaults.get(tier)
if not spec:
raise ValueError(f"no tier_defaults entry for tier={tier!r}")
# 解析器缝tier -> 回退链。apps/api 注入据 DB tier_routing 构造的实现。
ChainResolver = Callable[[Tier], "list[Route]"]
def _parse_spec(spec: str) -> Route:
provider, sep, model = spec.partition(":")
if not sep or not provider or not model:
raise ValueError(f"invalid tier route {spec!r}; expected 'provider:model'")
return Route(provider=provider, model=model)
def resolve_route(tier: Tier) -> Route:
spec = get_settings().tier_defaults.get(tier)
if not spec:
raise ValueError(f"no tier_defaults entry for tier={tier!r}")
return _parse_spec(spec)
def resolve_chain(tier: Tier) -> list[Route]:
"""默认链解析:仅全局默认(单元素)。多 provider 回退由注入的 `ChainResolver` 提供。"""
return [resolve_route(tier)]
def chain_from_routing(tier: Tier, primary: str, fallback: list[str]) -> list[Route]:
"""据 DB `tier_routing` 行primary `provider:model` + fallback 列表)构造回退链。
供 apps/api 包成 `ChainResolver` 注入网关;放这里以便单测覆盖解析逻辑。
去重保序:同一 (provider, model) 只保留首次出现。
"""
routes: list[Route] = [_parse_spec(primary)]
routes.extend(_parse_spec(s) for s in fallback)
seen: set[tuple[str, str]] = set()
unique: list[Route] = []
for r in routes:
key = (r.provider, r.model)
if key not in seen:
seen.add(key)
unique.append(r)
return unique

View File

@@ -56,11 +56,17 @@ class Usage(BaseModel):
class ServedBy(BaseModel):
"""实际服务方`fell_back` 标记是否走了回退链M5 才有回退M1 恒 False"""
"""实际服务方C1 扩T5.4)。
- `fell_back`:是否走了回退链(主模型失败/熔断后由链上后续 provider 服务)。
- `degraded`:是否发生能力降级(如所选 provider 不支持原生结构化输出,
改走 instructor JSON-提示路径)。两者仅观测/记账标注,对上层正确性透明。
"""
provider: str
model: str
fell_back: bool = False
degraded: bool = False
class LlmResponse(BaseModel):