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

@@ -0,0 +1,119 @@
"""T5.4 韧性测试替身(多 provider 适配器 + 失败模拟 + 记账嗅探)——绝不联网。
放独立模块(非 conftest测试走绝对导入 `from fakes_resilience import ...`。
本目录无 __init__.py见 fakes.py 注释)。
"""
from __future__ import annotations
from collections.abc import AsyncIterator, Callable
from ww_llm_gateway.adapters.base import (
Capabilities,
ProviderResult,
ProviderUsage,
StreamChunk,
)
from ww_llm_gateway.errors import TransientProviderError
from ww_llm_gateway.routing import Route
from ww_llm_gateway.types import LlmRequest, Scope, Tier, Usage
class ScriptedAdapter:
"""可编排成功/失败序列的假适配器。
`failures` 为开头要抛的异常列表(每次 complete/stream 消费一个);耗尽后正常返回。
`capabilities_` 控制能力矩阵(测降级)。记录调用次数以断言回退/重试行为。
"""
def __init__(
self,
provider: str,
*,
text: str = "ok",
failures: list[Exception] | None = None,
capabilities_: Capabilities | None = None,
input_tokens: int = 100,
output_tokens: int = 50,
structured_text: str | None = None,
) -> None:
self.provider = provider
self.text = text
self._failures = list(failures or [])
self._caps = capabilities_ or Capabilities(structured_output=True, prefix_cache=True)
self.input_tokens = input_tokens
self.output_tokens = output_tokens
self.structured_text = structured_text
self.complete_calls = 0
self.stream_calls = 0
def capabilities(self) -> Capabilities:
return self._caps
def _maybe_fail(self) -> None:
if self._failures:
raise self._failures.pop(0)
async def complete(self, req: LlmRequest, model: str) -> ProviderResult:
self.complete_calls += 1
self._maybe_fail()
if req.output_schema is not None:
# 模拟原生结构化输出:仅当能力声明支持时才会被网关派到这里。
parsed = req.output_schema.model_validate({} if not _has_fields(req) else _stub(req))
return ProviderResult(
text=parsed.model_dump_json(),
usage=ProviderUsage(
input_tokens=self.input_tokens, output_tokens=self.output_tokens
),
parsed=parsed,
)
return ProviderResult(
text=self.structured_text or self.text,
usage=ProviderUsage(input_tokens=self.input_tokens, output_tokens=self.output_tokens),
)
async def stream(self, req: LlmRequest, model: str) -> AsyncIterator[StreamChunk]:
self.stream_calls += 1
self._maybe_fail()
for ch in self.text:
yield StreamChunk(text=ch)
yield StreamChunk(
usage=ProviderUsage(input_tokens=self.input_tokens, output_tokens=self.output_tokens)
)
def _has_fields(req: LlmRequest) -> bool:
return bool(req.output_schema and req.output_schema.model_fields)
def _stub(req: LlmRequest) -> dict[str, object]:
# 用各字段默认/最小填充——测试 schema 仅需可构造。
assert req.output_schema is not None
out: dict[str, object] = {}
for name, field in req.output_schema.model_fields.items():
if field.is_required():
out[name] = "x"
return out
class FakeLedger:
def __init__(self) -> None:
self.records: list[Usage] = []
async def record(self, scope: Scope, usage: Usage) -> None:
self.records.append(usage)
def transient(msg: str = "boom") -> TransientProviderError:
return TransientProviderError(msg)
def chain(*routes: tuple[str, str]) -> list[Route]:
return [Route(provider=p, model=m) for p, m in routes]
def chain_resolver(routes: list[Route]) -> Callable[[Tier], list[Route]]:
def _resolve(tier: Tier) -> list[Route]:
return routes
return _resolve

View File

@@ -0,0 +1,59 @@
"""`build_adapter` provider→适配器分派C1扩T5.4 follow-up #2
只断「按 provider 名选对适配器类 + provider 字段透传」,不联网(客户端构造无网络 IO
api_key 仅持有不校验。Anthropic/Gemini 分支懒 import 真 SDK已 `uv sync`)。
"""
from __future__ import annotations
from ww_llm_gateway import build_adapter
from ww_llm_gateway.adapters.anthropic import AnthropicAdapter
from ww_llm_gateway.adapters.gemini import GeminiAdapter
from ww_llm_gateway.adapters.openai_compat import OpenAICompatAdapter
_FAKE_KEY = "sk-test-not-a-real-key"
def test_dispatches_anthropic_provider_to_anthropic_adapter() -> None:
adapter = build_adapter("anthropic", api_key=_FAKE_KEY)
assert isinstance(adapter, AnthropicAdapter)
assert adapter.provider == "anthropic"
def test_dispatches_gemini_provider_to_gemini_adapter() -> None:
adapter = build_adapter("gemini", api_key=_FAKE_KEY)
assert isinstance(adapter, GeminiAdapter)
assert adapter.provider == "gemini"
def test_dispatches_google_alias_to_gemini_adapter() -> None:
adapter = build_adapter("google", api_key=_FAKE_KEY)
assert isinstance(adapter, GeminiAdapter)
assert adapter.provider == "google"
def test_dispatches_deepseek_to_openai_compat_adapter() -> None:
adapter = build_adapter("deepseek", api_key=_FAKE_KEY, base_url="https://api.deepseek.com")
assert isinstance(adapter, OpenAICompatAdapter)
assert adapter.provider == "deepseek"
def test_dispatches_unknown_provider_to_openai_compat_adapter() -> None:
# kimi/qwen/glm/openai 等一律走 OpenAI 兼容默认分支。
for provider in ("openai", "kimi", "qwen", "glm"):
adapter = build_adapter(provider, api_key=_FAKE_KEY)
assert isinstance(adapter, OpenAICompatAdapter)
assert adapter.provider == provider
def test_anthropic_adapter_capabilities_reflect_native_structured_output() -> None:
adapter = build_adapter("anthropic", api_key=_FAKE_KEY)
caps = adapter.capabilities()
assert caps.structured_output is True
assert caps.prefix_cache is True

View File

@@ -0,0 +1,109 @@
"""T5.4 能力协商 / 降级ARCH §4.4)。
结构化输出原生不支持时降级instructor JSON-提示路径仍由适配器自处理;网关
层负责在链内**优先选支持结构化输出的 provider**,无则降级到首个可用、对上层透明)。
降级时 `served_by.degraded=True` 标注,记账/日志可见,正确性不受影响。
"""
from __future__ import annotations
import uuid
from fakes_resilience import FakeLedger, ScriptedAdapter, chain, chain_resolver
from pydantic import BaseModel
from ww_llm_gateway.adapters.base import Capabilities
from ww_llm_gateway.gateway import Gateway
from ww_llm_gateway.types import Block, LlmRequest, Scope
class Tiny(BaseModel):
x: str
def _structured_req() -> LlmRequest:
return LlmRequest(
tier="analyst",
input="给我结构化",
output_schema=Tiny,
scope=Scope(user_id=uuid.UUID(int=1)),
)
async def test_prefers_structured_capable_provider_in_chain() -> None:
# 主 provider 不支持结构化输出,回退 provider 支持 → 网关优先选支持者服务结构化请求。
no_struct = ScriptedAdapter(
"weakprov",
capabilities_=Capabilities(structured_output=False, prefix_cache=False),
)
struct = ScriptedAdapter(
"strongprov",
capabilities_=Capabilities(structured_output=True, prefix_cache=True),
)
ledger = FakeLedger()
gw = Gateway(
{"weakprov": no_struct, "strongprov": struct},
ledger,
chain_resolver=chain_resolver(chain(("weakprov", "w"), ("strongprov", "s"))),
)
resp = await gw.run(_structured_req())
assert resp.served_by.provider == "strongprov"
assert resp.parsed is not None
assert no_struct.complete_calls == 0
async def test_degrades_when_no_structured_capable_provider() -> None:
# 链上无 provider 支持结构化输出 → 降级用首个可用(适配器自走 instructor JSON 提示)
# 并标 served_by.degraded=True不硬失败。
weak = ScriptedAdapter(
"weakprov",
capabilities_=Capabilities(structured_output=False),
)
gw = Gateway(
{"weakprov": weak},
FakeLedger(),
chain_resolver=chain_resolver(chain(("weakprov", "w"))),
)
resp = await gw.run(_structured_req())
assert resp.served_by.provider == "weakprov"
assert resp.served_by.degraded is True
assert weak.complete_calls == 1
async def test_no_degradation_flag_for_plain_text() -> None:
# 纯文本请求对任何 provider 都不算降级。
weak = ScriptedAdapter("weakprov", capabilities_=Capabilities(structured_output=False))
gw = Gateway(
{"weakprov": weak},
FakeLedger(),
chain_resolver=chain_resolver(chain(("weakprov", "w"))),
)
req = LlmRequest(tier="writer", input="正文", scope=Scope(user_id=uuid.UUID(int=1)))
resp = await gw.run(req)
assert resp.served_by.degraded is False
async def test_cache_blocks_passed_through_regardless_of_capability() -> None:
# 前缀缓存不支持时只是跳过,不改正确性、不算降级。
weak = ScriptedAdapter("weakprov", capabilities_=Capabilities(prefix_cache=False))
gw = Gateway(
{"weakprov": weak},
FakeLedger(),
chain_resolver=chain_resolver(chain(("weakprov", "w"))),
)
req = LlmRequest(
tier="writer",
input="正文",
system=[Block(text="世界观硬规则", cache=True)],
scope=Scope(user_id=uuid.UUID(int=1)),
)
resp = await gw.run(req)
assert resp.text
assert resp.served_by.degraded is False

View File

@@ -0,0 +1,174 @@
"""T5.4 回退链 + 重试 + 熔断ARCH §4.5)。
主模型 transient 失败 → 退避重试 → 仍失败切回退链下一个;回退服务时标
`served_by.fell_back=True`;记账落实际服务方;链耗尽抛 LLM_UNAVAILABLE。
熔断:某 provider 连续失败超阈值后短时熔断、直接跳过走回退。
"""
from __future__ import annotations
import pytest
from fakes_resilience import FakeLedger, ScriptedAdapter, chain, chain_resolver, transient
from ww_llm_gateway.gateway import CircuitBreaker, Gateway
from ww_llm_gateway.types import LlmRequest
from ww_shared import AppError, ErrorCode
async def test_primary_success_no_fallback(req: LlmRequest) -> None:
primary = ScriptedAdapter("deepseek", text="主模型")
backup = ScriptedAdapter("openai", text="备用")
ledger = FakeLedger()
gw = Gateway(
{"deepseek": primary, "openai": backup},
ledger,
chain_resolver=chain_resolver(chain(("deepseek", "deepseek-chat"), ("openai", "gpt-4o"))),
)
resp = await gw.run(req)
assert resp.text == "主模型"
assert resp.served_by.provider == "deepseek"
assert resp.served_by.fell_back is False
assert backup.complete_calls == 0
assert ledger.records[0].provider == "deepseek"
async def test_falls_through_to_next_provider_on_transient(req: LlmRequest) -> None:
# 主模型每次 complete 都 transient 失败(足够耗尽重试) → 切回退。
primary = ScriptedAdapter("deepseek", failures=[transient() for _ in range(10)])
backup = ScriptedAdapter("openai", text="备用结果")
ledger = FakeLedger()
gw = Gateway(
{"deepseek": primary, "openai": backup},
ledger,
chain_resolver=chain_resolver(chain(("deepseek", "deepseek-chat"), ("openai", "gpt-4o"))),
max_retries=2,
)
resp = await gw.run(req)
assert resp.text == "备用结果"
assert resp.served_by.provider == "openai"
assert resp.served_by.fell_back is True
# 记账记实际服务方 openai不是失败的 deepseek。
assert len(ledger.records) == 1
assert ledger.records[0].provider == "openai"
async def test_retries_then_succeeds_on_same_provider(req: LlmRequest) -> None:
# 前两次 transient第三次成功 → 不应切回退max_retries=2 即最多 3 次尝试)。
primary = ScriptedAdapter("deepseek", text="重试后成功", failures=[transient(), transient()])
backup = ScriptedAdapter("openai", text="备用")
ledger = FakeLedger()
gw = Gateway(
{"deepseek": primary, "openai": backup},
ledger,
chain_resolver=chain_resolver(chain(("deepseek", "deepseek-chat"), ("openai", "gpt-4o"))),
max_retries=2,
)
resp = await gw.run(req)
assert resp.text == "重试后成功"
assert resp.served_by.provider == "deepseek"
assert resp.served_by.fell_back is False
assert backup.complete_calls == 0
async def test_rate_limited_triggers_fallback(req: LlmRequest) -> None:
rate_limited = AppError(ErrorCode.RATE_LIMITED, "429")
primary = ScriptedAdapter("deepseek", failures=[rate_limited for _ in range(10)])
backup = ScriptedAdapter("openai", text="降级备用")
ledger = FakeLedger()
gw = Gateway(
{"deepseek": primary, "openai": backup},
ledger,
chain_resolver=chain_resolver(chain(("deepseek", "deepseek-chat"), ("openai", "gpt-4o"))),
max_retries=1,
)
resp = await gw.run(req)
assert resp.served_by.provider == "openai"
assert resp.served_by.fell_back is True
async def test_chain_exhausted_raises_llm_unavailable(req: LlmRequest) -> None:
p1 = ScriptedAdapter("deepseek", failures=[transient() for _ in range(10)])
p2 = ScriptedAdapter("openai", failures=[transient() for _ in range(10)])
gw = Gateway(
{"deepseek": p1, "openai": p2},
FakeLedger(),
chain_resolver=chain_resolver(chain(("deepseek", "deepseek-chat"), ("openai", "gpt-4o"))),
max_retries=1,
)
with pytest.raises(AppError) as exc:
await gw.run(req)
assert exc.value.code == ErrorCode.LLM_UNAVAILABLE
async def test_missing_adapter_in_chain_skipped(req: LlmRequest) -> None:
# 链上首个 provider 没注册适配器 → 跳过、走下一个(不硬失败)。
backup = ScriptedAdapter("openai", text="可用")
gw = Gateway(
{"openai": backup},
FakeLedger(),
chain_resolver=chain_resolver(chain(("missing", "m"), ("openai", "gpt-4o"))),
)
resp = await gw.run(req)
assert resp.served_by.provider == "openai"
assert resp.served_by.fell_back is True
# ---- 熔断器 ----
def test_circuit_breaker_trips_after_threshold() -> None:
cb = CircuitBreaker(threshold=3, reset_seconds=60.0)
assert cb.is_open("deepseek") is False
cb.record_failure("deepseek")
cb.record_failure("deepseek")
assert cb.is_open("deepseek") is False # 未到阈值
cb.record_failure("deepseek")
assert cb.is_open("deepseek") is True # 第 3 次 → 熔断
def test_circuit_breaker_success_resets() -> None:
cb = CircuitBreaker(threshold=2, reset_seconds=60.0)
cb.record_failure("deepseek")
cb.record_success("deepseek")
cb.record_failure("deepseek")
assert cb.is_open("deepseek") is False # 成功清零计数
def test_circuit_breaker_reopens_after_cooldown() -> None:
now = [1000.0]
cb = CircuitBreaker(threshold=1, reset_seconds=30.0, clock=lambda: now[0])
cb.record_failure("deepseek")
assert cb.is_open("deepseek") is True
now[0] += 31.0 # 冷却窗口过 → 半开(放行试探)
assert cb.is_open("deepseek") is False
async def test_open_circuit_skips_provider(req: LlmRequest) -> None:
# 熔断已打开的主 provider 被直接跳过,连 complete 都不调,直接走回退。
primary = ScriptedAdapter("deepseek", text="不该被调")
backup = ScriptedAdapter("openai", text="回退服务")
cb = CircuitBreaker(threshold=1, reset_seconds=60.0)
cb.record_failure("deepseek") # 预先熔断
gw = Gateway(
{"deepseek": primary, "openai": backup},
FakeLedger(),
chain_resolver=chain_resolver(chain(("deepseek", "deepseek-chat"), ("openai", "gpt-4o"))),
breaker=cb,
)
resp = await gw.run(req)
assert resp.served_by.provider == "openai"
assert resp.served_by.fell_back is True
assert primary.complete_calls == 0

View File

@@ -0,0 +1,139 @@
"""Kimi Code 适配器单测K1.2):断完整 7 头伪造头集 + base_url + bearer不联网
验证点:构造出的 `AsyncOpenAI` 客户端带正确 base_url + default_headersopencode 规范的
完整 7 头UA `KimiCLI/1.37.0` + 6 个 `X-Msh-*`,真源 `ooojustin/opencode-kimi`
access_token 作为 bearer且适配器 provider 字段为 `kimi-code`。device-id 经环境变量
`KIMI_DEVICE_ID` 注入以保证 CI 确定性(不触碰真实文件/HOME
"""
from __future__ import annotations
import re
import pytest
from ww_llm_gateway.adapters.kimi_code import (
KIMI_CLI_VERSION,
KIMI_CODE_BASE_URL,
KIMI_CODE_PLATFORM,
KIMI_CODE_USER_AGENT,
KimiCodeAdapter,
build_kimi_code_client,
kimi_code_headers,
kimi_device_id,
)
_ACCESS_TOKEN = "kimi-oauth-access-token-not-real"
_TEST_DEVICE_ID = "0123456789abcdef0123456789abcdef"
#: opencode 发送的全部 7 个头键。
_EXPECTED_HEADER_KEYS = {
"User-Agent",
"X-Msh-Platform",
"X-Msh-Version",
"X-Msh-Device-Name",
"X-Msh-Device-Model",
"X-Msh-Device-Id",
"X-Msh-Os-Version",
}
_HEX32_RE = re.compile(r"^[0-9a-f]{32}$")
_ASCII_RE = re.compile(r"^[\x20-\x7e]+$")
@pytest.fixture(autouse=True)
def _stable_device_id(monkeypatch: pytest.MonkeyPatch) -> None:
"""注入固定 device-id使测试确定且不依赖真实 ~/.kimi 文件。"""
monkeypatch.setenv("KIMI_DEVICE_ID", _TEST_DEVICE_ID)
def test_user_agent_matches_kimi_cli_pattern() -> None:
assert KIMI_CODE_USER_AGENT == "KimiCLI/1.37.0"
assert KIMI_CODE_USER_AGENT == f"KimiCLI/{KIMI_CLI_VERSION}"
assert KIMI_CODE_USER_AGENT.startswith("KimiCLI/")
def test_headers_contain_all_seven_keys() -> None:
headers = kimi_code_headers()
assert set(headers) == _EXPECTED_HEADER_KEYS
def test_headers_fixed_literal_values() -> None:
headers = kimi_code_headers()
assert headers["User-Agent"] == "KimiCLI/1.37.0"
assert headers["X-Msh-Platform"] == KIMI_CODE_PLATFORM == "kimi_cli"
assert headers["X-Msh-Version"] == "1.37.0"
# UA 版本必须与 X-Msh-Version 一致。
assert headers["X-Msh-Version"] == headers["User-Agent"].removeprefix("KimiCLI/")
def test_device_id_is_stable_32_char_lowercase_hex() -> None:
headers_a = kimi_code_headers()
headers_b = kimi_code_headers()
device_id = headers_a["X-Msh-Device-Id"]
assert _HEX32_RE.match(device_id) # 32 位小写 hex无连字符
assert "-" not in device_id
# 跨两次调用稳定。
assert headers_a["X-Msh-Device-Id"] == headers_b["X-Msh-Device-Id"]
assert device_id == _TEST_DEVICE_ID
def test_kimi_device_id_helper_stable_and_matches_env() -> None:
assert kimi_device_id() == _TEST_DEVICE_ID
assert kimi_device_id() == kimi_device_id()
def test_host_derived_headers_present_nonempty_ascii() -> None:
headers = kimi_code_headers()
for key in ("X-Msh-Device-Name", "X-Msh-Device-Model", "X-Msh-Os-Version"):
value = headers[key]
assert value, f"{key} must be non-empty"
assert _ASCII_RE.match(value), f"{key} must be printable ASCII"
def test_build_client_sets_base_url_and_full_headers() -> None:
client = build_kimi_code_client(_ACCESS_TOKEN)
assert str(client.base_url).rstrip("/") == KIMI_CODE_BASE_URL.rstrip("/")
assert client.default_headers["User-Agent"] == KIMI_CODE_USER_AGENT
assert _EXPECTED_HEADER_KEYS.issubset(set(client.default_headers))
def test_build_client_uses_access_token_as_bearer() -> None:
client = build_kimi_code_client(_ACCESS_TOKEN)
# access token 作为 OpenAI client 的 api_key → SDK 自动发 Authorization: Bearer。
assert client.api_key == _ACCESS_TOKEN
def test_build_client_honors_explicit_base_url_override() -> None:
custom = "https://example.test/coding/v1"
client = build_kimi_code_client(_ACCESS_TOKEN, base_url=custom)
assert str(client.base_url).rstrip("/") == custom.rstrip("/")
def test_adapter_provider_is_kimi_code() -> None:
adapter = KimiCodeAdapter(build_kimi_code_client(_ACCESS_TOKEN))
assert adapter.provider == "kimi-code"
# 复用 OpenAI 兼容能力(结构化输出 + 前缀缓存)。
caps = adapter.capabilities()
assert caps.structured_output is True
def test_structured_client_uses_instructor_json_mode() -> None:
"""kimi-for-coding 开启 thinking与强制 tool_choice 互斥live 400
因此 KimiCodeAdapter 的结构化客户端必须走 instructor JSON 模式(发
`response_format`**不**发 `tool_choice`),而非默认的 TOOLS 模式。
"""
import instructor
adapter = KimiCodeAdapter(build_kimi_code_client(_ACCESS_TOKEN))
structured = adapter._structured()
assert getattr(structured, "mode", None) is instructor.Mode.JSON

View File

@@ -0,0 +1,62 @@
"""工厂分派 `kimi-code` 单测K1.2access_token 当 api_key、完整 7 伪造头、coding base。
不联网。
"""
from __future__ import annotations
import pytest
from ww_llm_gateway import build_adapter
from ww_llm_gateway.adapters.kimi_code import (
KIMI_CODE_BASE_URL,
KIMI_CODE_USER_AGENT,
KimiCodeAdapter,
)
_ACCESS_TOKEN = "kimi-oauth-access-token-not-real"
_TEST_DEVICE_ID = "0123456789abcdef0123456789abcdef"
_EXPECTED_HEADER_KEYS = {
"User-Agent",
"X-Msh-Platform",
"X-Msh-Version",
"X-Msh-Device-Name",
"X-Msh-Device-Model",
"X-Msh-Device-Id",
"X-Msh-Os-Version",
}
@pytest.fixture(autouse=True)
def _stable_device_id(monkeypatch: pytest.MonkeyPatch) -> None:
"""注入固定 device-id使测试确定且不依赖真实 ~/.kimi 文件。"""
monkeypatch.setenv("KIMI_DEVICE_ID", _TEST_DEVICE_ID)
def test_factory_dispatches_kimi_code_to_kimi_code_adapter() -> None:
adapter = build_adapter("kimi-code", api_key=_ACCESS_TOKEN)
assert isinstance(adapter, KimiCodeAdapter)
assert adapter.provider == "kimi-code"
def test_factory_kimi_code_defaults_base_url_and_attaches_full_headers() -> None:
adapter = build_adapter("kimi-code", api_key=_ACCESS_TOKEN)
assert isinstance(adapter, KimiCodeAdapter)
client = adapter._client # noqa: SLF001 — 断言注入客户端配置
assert str(client.base_url).rstrip("/") == KIMI_CODE_BASE_URL.rstrip("/")
assert client.api_key == _ACCESS_TOKEN
assert client.default_headers["User-Agent"] == KIMI_CODE_USER_AGENT
assert client.default_headers["X-Msh-Platform"] == "kimi_cli"
assert client.default_headers["X-Msh-Version"] == "1.37.0"
assert client.default_headers["X-Msh-Device-Id"] == _TEST_DEVICE_ID
assert _EXPECTED_HEADER_KEYS.issubset(set(client.default_headers))
def test_factory_kimi_code_honors_explicit_base_url() -> None:
custom = "https://example.test/coding/v1"
adapter = build_adapter("kimi-code", api_key=_ACCESS_TOKEN, base_url=custom)
assert isinstance(adapter, KimiCodeAdapter)
assert str(adapter._client.base_url).rstrip("/") == custom.rstrip("/") # noqa: SLF001

View File

@@ -0,0 +1,69 @@
"""工厂分派 `kimi-code-key` 单测:静态 Console Key 的 ToS 合规变体(不联网)。
`kimi-code-key` = Kimi Code 订阅 plan 的 **Console 静态 API Key** 路径——与 OAuth 的
`kimi-code` 命中同一 coding 端点 + 同一 model `kimi-for-coding` + 同样 thinking 开启
(故结构化输出走 JSON 模式)。**实测纠正**coding 端点据 `User-Agent` 做 allow-list 门禁,
缺伪造头 → `403 access_terminated_error`**无论 key 还是 OAuth**。故静态 key 路径也必须发
与 OAuth 相同的 `KimiCLI/1.37.0` + `X-Msh-*` 头(区别仅凭据来源是静态 key。两条订阅路径
都需 UA 伪造 = 同样 ToS 风险;真正合规的只有 moonshot 平台 keyprovider `kimi`)。
断言点:
- 命中 coding base URLkey 作为 bearerOpenAI client 的 `api_key`
- default_headers **含** 伪造 UA`KimiCLI`+ `X-Msh-*` 头(过 coding 端点门禁);
- 结构化客户端走 instructor JSON 模式(与 thinking 兼容)。
"""
from __future__ import annotations
import instructor
from ww_llm_gateway import build_adapter
from ww_llm_gateway.adapters.kimi_code import KIMI_CODE_BASE_URL
from ww_llm_gateway.adapters.kimi_code_key import KIMI_CODE_KEY_PROVIDER, KimiCodeKeyAdapter
_API_KEY = "kimi-console-static-key-not-real"
def test_factory_dispatches_kimi_code_key_to_kimi_code_key_adapter() -> None:
adapter = build_adapter(KIMI_CODE_KEY_PROVIDER, api_key=_API_KEY)
assert isinstance(adapter, KimiCodeKeyAdapter)
assert adapter.provider == "kimi-code-key"
def test_factory_kimi_code_key_defaults_coding_base_url_and_bearer() -> None:
adapter = build_adapter(KIMI_CODE_KEY_PROVIDER, api_key=_API_KEY)
assert isinstance(adapter, KimiCodeKeyAdapter)
client = adapter._client # noqa: SLF001 — 断言注入客户端配置
assert str(client.base_url).rstrip("/") == KIMI_CODE_BASE_URL.rstrip("/")
assert client.api_key == _API_KEY
def test_factory_kimi_code_key_sends_spoofed_headers() -> None:
"""实测纠正coding 端点据 UA 做 allow-list 门禁缺伪造头→403故静态 key 也必须发
与 OAuth 相同的 `KimiCLI/1.37.0` + `X-Msh-*` 头。"""
adapter = build_adapter(KIMI_CODE_KEY_PROVIDER, api_key=_API_KEY)
assert isinstance(adapter, KimiCodeKeyAdapter)
headers = adapter._client.default_headers # noqa: SLF001
# 伪造官方客户端 UA过 coding 端点 access_terminated_error 门禁)。
assert "KimiCLI" in str(headers.get("User-Agent", ""))
# 带 OAuth 路径同款 X-Msh-* 头集。
assert any(str(k).lower().startswith("x-msh-") for k in headers)
def test_factory_kimi_code_key_structured_client_uses_json_mode() -> None:
"""coding 端点 thinking 开启 → 结构化必须走 instructor JSON 模式(非 TOOLS"""
adapter = build_adapter(KIMI_CODE_KEY_PROVIDER, api_key=_API_KEY)
assert isinstance(adapter, KimiCodeKeyAdapter)
structured = adapter._structured() # noqa: SLF001
assert getattr(structured, "mode", None) is instructor.Mode.JSON
def test_factory_kimi_code_key_honors_explicit_base_url() -> None:
custom = "https://example.test/coding/v1"
adapter = build_adapter(KIMI_CODE_KEY_PROVIDER, api_key=_API_KEY, base_url=custom)
assert isinstance(adapter, KimiCodeKeyAdapter)
assert str(adapter._client.base_url).rstrip("/") == custom.rstrip("/") # noqa: SLF001

View File

@@ -0,0 +1,51 @@
"""T5.4 多 provider 记账维度ARCH §4.8)。
记账行须带**实际服务方** provider/model回退后不能记成主模型让 usage_ledger
的多 provider 维度可查询/聚合。
"""
from __future__ import annotations
from fakes_resilience import FakeLedger, ScriptedAdapter, chain, chain_resolver, transient
from ww_llm_gateway.gateway import Gateway
from ww_llm_gateway.types import LlmRequest
async def test_ledger_records_actual_serving_provider_after_fallback(req: LlmRequest) -> None:
primary = ScriptedAdapter("deepseek", failures=[transient() for _ in range(10)])
backup = ScriptedAdapter("openai", text="备用", input_tokens=222, output_tokens=33)
ledger = FakeLedger()
gw = Gateway(
{"deepseek": primary, "openai": backup},
ledger,
chain_resolver=chain_resolver(chain(("deepseek", "deepseek-chat"), ("openai", "gpt-4o"))),
max_retries=1,
)
await gw.run(req)
assert len(ledger.records) == 1
rec = ledger.records[0]
assert rec.provider == "openai"
assert rec.model == "gpt-4o"
assert rec.input_tokens == 222
assert rec.output_tokens == 33
async def test_stream_ledger_records_actual_provider_after_fallback(req: LlmRequest) -> None:
primary = ScriptedAdapter("deepseek", failures=[transient() for _ in range(10)])
backup = ScriptedAdapter("openai", text="流式备用", input_tokens=10, output_tokens=4)
ledger = FakeLedger()
gw = Gateway(
{"deepseek": primary, "openai": backup},
ledger,
chain_resolver=chain_resolver(chain(("deepseek", "deepseek-chat"), ("openai", "gpt-4o"))),
max_retries=1,
)
collected = [d.text async for d in gw.stream(req)]
assert "".join(collected) == "流式备用"
assert len(ledger.records) == 1
assert ledger.records[0].provider == "openai"
assert ledger.records[0].model == "gpt-4o"

View File

@@ -0,0 +1,198 @@
"""T5.4 新适配器Anthropic + GeminiARCH §4.2/§4.4)。
网络客户端经注入的 Protocol 替身——绝不联网、绝不依赖真实 SDK。
断言能力矩阵声明正确、complete/stream 归一化、结构化输出、usage 提取。
"""
from __future__ import annotations
import uuid
from collections.abc import AsyncIterator
from typing import Any
from pydantic import BaseModel
from ww_llm_gateway.adapters.anthropic import AnthropicAdapter
from ww_llm_gateway.adapters.gemini import GeminiAdapter
from ww_llm_gateway.types import Block, LlmRequest, Scope
class Out(BaseModel):
score: int
# ---- Anthropic ----
class _FakeAnthropicMessages:
def __init__(self, text: str, in_tok: int, out_tok: int, cache_read: int) -> None:
self._text = text
self._in = in_tok
self._out = out_tok
self._cache_read = cache_read
self.last_kwargs: dict[str, Any] = {}
async def create(self, **kwargs: Any) -> Any:
self.last_kwargs = kwargs
class _Block:
type = "text"
text = self._text
class _Usage:
input_tokens = self._in
output_tokens = self._out
cache_read_input_tokens = self._cache_read
class _Resp:
content = [_Block()]
usage = _Usage()
return _Resp()
class _FakeAnthropicClient:
def __init__(self, msgs: _FakeAnthropicMessages) -> None:
self.messages = msgs
def _req(text: str = "写第一章", *, cache: bool = False) -> LlmRequest:
sys = [Block(text="世界观", cache=cache)] if cache else []
return LlmRequest(tier="writer", input=text, system=sys, scope=Scope(user_id=uuid.UUID(int=1)))
def test_anthropic_capabilities() -> None:
client = _FakeAnthropicClient(_FakeAnthropicMessages("", 0, 0, 0))
adapter = AnthropicAdapter("anthropic", client)
caps = adapter.capabilities()
assert caps.structured_output is True
assert caps.prefix_cache is True
assert caps.thinking is True
async def test_anthropic_complete_text_and_usage() -> None:
msgs = _FakeAnthropicMessages("生成正文", in_tok=120, out_tok=80, cache_read=30)
adapter = AnthropicAdapter("anthropic", _FakeAnthropicClient(msgs))
result = await adapter.complete(_req(), "claude-3-5-sonnet")
assert result.text == "生成正文"
assert result.usage.input_tokens == 120
assert result.usage.output_tokens == 80
assert result.usage.cache_read_tokens == 30
async def test_anthropic_marks_cache_breakpoint() -> None:
msgs = _FakeAnthropicMessages("x", 1, 1, 0)
adapter = AnthropicAdapter("anthropic", _FakeAnthropicClient(msgs))
await adapter.complete(_req(cache=True), "claude-3-5-sonnet")
# 稳定块应带 cache_control 断点Claude 显式缓存§4.6)。
system = msgs.last_kwargs["system"]
assert any(blk.get("cache_control") for blk in system)
# ---- Gemini ----
class _FakeGeminiModels:
def __init__(self, text: str, in_tok: int, out_tok: int) -> None:
self._text = text
self._in = in_tok
self._out = out_tok
self.last_kwargs: dict[str, Any] = {}
async def generate_content(self, **kwargs: Any) -> Any:
self.last_kwargs = kwargs
class _Usage:
prompt_token_count = self._in
candidates_token_count = self._out
cached_content_token_count = 0
class _Resp:
text = self._text
usage_metadata = _Usage()
return _Resp()
class _FakeGeminiAio:
def __init__(self, models: _FakeGeminiModels) -> None:
self.models = models
class _FakeGeminiClient:
def __init__(self, models: _FakeGeminiModels) -> None:
self.aio = _FakeGeminiAio(models)
def test_gemini_capabilities() -> None:
adapter = GeminiAdapter("gemini", _FakeGeminiClient(_FakeGeminiModels("", 0, 0))) # type: ignore[arg-type]
caps = adapter.capabilities()
assert caps.structured_output is True
assert caps.thinking is True
async def test_gemini_complete_text_and_usage() -> None:
models = _FakeGeminiModels("双子座正文", in_tok=200, out_tok=90)
adapter = GeminiAdapter("gemini", _FakeGeminiClient(models)) # type: ignore[arg-type]
result = await adapter.complete(_req(), "gemini-2.0-flash")
assert result.text == "双子座正文"
assert result.usage.input_tokens == 200
assert result.usage.output_tokens == 90
async def test_gemini_structured_output() -> None:
models = _FakeGeminiModels('{"score": 7}', in_tok=10, out_tok=5)
adapter = GeminiAdapter("gemini", _FakeGeminiClient(models)) # type: ignore[arg-type]
req = LlmRequest(
tier="analyst",
input="打分",
output_schema=Out,
scope=Scope(user_id=uuid.UUID(int=1)),
)
result = await adapter.complete(req, "gemini-2.0-flash")
assert result.parsed is not None
assert isinstance(result.parsed, Out)
assert result.parsed.score == 7
# 结构化请求应带 response schema 配置。
assert "config" in models.last_kwargs
async def test_anthropic_stream_yields_text_then_usage() -> None:
class _StreamEvent:
def __init__(self, **kw: Any) -> None:
self.__dict__.update(kw)
class _StreamMessages:
def stream(self, **kwargs: Any) -> Any:
class _Ctx:
async def __aenter__(self_inner) -> AsyncIterator[Any]:
async def _gen() -> AsyncIterator[Any]:
yield _StreamEvent(
type="content_block_delta", delta=_StreamEvent(text="hel")
)
yield _StreamEvent(
type="content_block_delta", delta=_StreamEvent(text="lo")
)
return _gen()
async def __aexit__(self_inner, *a: Any) -> None:
return None
return _Ctx()
class _Client:
def __init__(self) -> None:
self.messages = _StreamMessages()
adapter = AnthropicAdapter("anthropic", _Client())
chunks = [c async for c in adapter.stream(_req(), "claude-3-5-sonnet")]
text = "".join(c.text for c in chunks if c.text)
assert text == "hello"