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>
120 lines
3.9 KiB
Python
120 lines
3.9 KiB
Python
"""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
|