Files
writer-work-flow/packages/llm_gateway/tests/fakes_resilience.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

120 lines
3.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.

"""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