Files
PolyGateway/research-wiki/plans/2026-07-20-m1-core-plan.md
T

222 lines
25 KiB
Markdown
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.
# M1 核心里程碑实现计划
> **目标**(一句话): 落地 PolyGateway M1 全部交付物,使 GovDoc 与 Video-Tree 能用 `GatewayClient.from_env()` + `chat()` 替代各自 `GovernedLLMClient` 跑通真实治理调用。
> **方案概述**: 严格按已批准设计 `designs/2026-07-20-m1-core-design.md`(签名冻结的唯一依据,下称"设计")与 ROADMAP §2 七步依赖序实施;内核类型先行冻结,叶子纯函数次之,再 transport → 内存后端与中间件 → 结构化 → 装配层,最后端到端验证。**任何与设计冲突之处以设计为准;想改签名必须回人类门。**
> **技术**: Python 3.11 / httpx / pydantic(+pydantic-settings);optional extras: redis、json_repair;pytest(asyncio_mode=auto)。conda 环境 `PolyGateway`。
> **流程**: 全部工作在 feature 分支 `feature/m1-core`;每任务一个 commit(用 `commit` skill);每个行为任务先写失败测试再实现,pytest 先红后绿输出即测试证据。
## 0. 文件结构(锁定分解)
```text
src/polygateway/
├── types.py # LLMResponse/ChatRequest/SourceConfig/Usage/SourceStats/
│ # TransportResult/RetryPolicy/BreakerConfig/BackpressurePolicy/GlobalLimits
├── errors.py # 设计 §3 全部错误类
├── ports.py # 全部 Protocol + Permit + Decision/Update(含 __post_init__ 校验)
├── streaming.py # 三层活性看门狗(移植 VT)
├── providers.py # ProviderProfile 注册表(qwen/deepseek/openai)
├── sources.py # RoundRobinSelector / LeastInflightSelector / SourceCooldownMemo
├── transports/openai_compat.py # SSE 解析纯函数 + OpenAICompatTransport
├── backends/memory/limiter.py # InMemoryLimiter + _MemoryPermit
├── backends/memory/breaker.py # InMemoryGate(CHS gate 契约)
├── backends/memory/cache.py # InMemoryCache(dict+TTL)
├── backends/redis_cache.py # RedisCache(笨 KV,optional extra)
├── middleware/base.py # Middleware/CallNext 类型 + 洋葱组装函数
├── middleware/telemetry.py # TelemetryMW + TelemetryEmitter(全库唯一遥测调用点)
├── middleware/cache.py # CacheMW(key 公式/命中重建 structured)
├── middleware/structured.py # StructuredMW(D14 阶梯)
├── middleware/retry.py # RetryMW(尝试编排:选源→熔断门→限流→transport)
├── middleware/ratelimit.py # QuotaGate(permit 获取 + wait/fail_fast + 后端故障报错)
├── middleware/breaker.py # BreakerGate(gate 进出 + 冷却备忘协作)
├── structured/json_repair.py # JsonRepairStrategy(normalize 钩子)
├── structured/native_schema.py # NativeSchemaStrategy
├── telemetry/sqlite.py # SQLiteRecorder(18 列)
├── config.py # GatewaySettings + 多源/韧性键解析与校验(新增模块,任务 T12 同步补 ARCH §8 模块图)
└── client.py # GatewayClient + from_env/from_settings + gather_bounded
tests/
├── unit/… # 每个 src 模块一个 test_ 文件
├── contracts/test_limiter_contract.py / test_breaker_contract.py # 参数化后端 fixture
├── integration/… # 远程 Redis / SQLite 并发 / 治理组合 / 取消穿透
└── e2e/… # 真实网关冒烟 + GovDoc/VT 兼容冒烟(输出落 tests/outputs/)
```
跨任务消费的冻结签名(设计 §2-§4 为全文,此处摘执行者最常引用的三组):
```python
# ports.py —— 洋葱与每次尝试的接缝
CallNext = Callable[[ChatRequest], Awaitable[LLMResponse]]
class Middleware(Protocol):
async def __call__(self, request: ChatRequest, call_next: CallNext) -> LLMResponse: ...
class Transport(Protocol):
async def complete(self, *, messages: list[dict[str, Any]], source: SourceConfig,
stream: bool, overlay: dict[str, Any], call_id: str) -> TransportResult: ...
```
```python
# ports.py —— 限流契约(CHS limiter.py 形态)
class Permit(Protocol):
async def release(self) -> None: ... # 幂等
async def settle(self, actual_tokens: int) -> None: ... # 幂等,delta 结算
class RateLimiter(Protocol):
async def try_acquire(self, source_key: str, est_tokens: int) -> Permit | None: ...
async def acquire(self, source_key: str, est_tokens: int) -> Permit: ...
async def source_stats(self, source_key: str) -> SourceStats: ...
async def mark_progress(self) -> None: ...
async def progress_age_s(self) -> float: ...
```
```python
# ports.py —— 熔断契约(CHS provider_gate.py 形态,时间一律秒)
class ProviderGate(Protocol):
async def try_enter(self, source_name: str, owner: str) -> GateDecision: ...
async def record_success(self, entry: GateDecision) -> GateUpdate: ...
async def record_failure(self, entry: GateDecision, reason: str, force_open: bool) -> GateUpdate: ...
async def release_probe(self, entry: GateDecision) -> GateUpdate: ...
async def retry_after_s(self, sources: tuple[str, ...]) -> float: ...
```
`GateDecision(source_name, allowed, state, epoch, is_probe, probe_owner, retry_after_s)``GateUpdate(applied, state, epoch, failure_count, retry_after_s)`,`__post_init__` 校验照 CHS `ports.py:405-455` 逐条移植。
---
## 任务清单
### T0 分支与骨架
- [ ] `git checkout -b feature/m1-core`;创建 §0 全部目录与空 `__init__.py`(含 `tests/contracts/`);`src/polygateway/py.typed` 占位。
- 验收: `conda run -n PolyGateway python -c "import polygateway"` 通过;`make ci` 仍绿。
- 提交: `chore: scaffold m1 module skeleton`
### T1 `types.py` + `errors.py`(设计 §2.1-§2.3 / §3)
- [ ] 实现设计 §2.1 `LLMResponse`(11 旧字段逐字保序无默认 + 4 新字段带默认)、§2.2 `ChatRequest`(chat() 参数全集 + `overlay: dict`,frozen)、§2.3 `SourceConfig`(字段/默认/三态 enable_thinking/missing_done/trust_env + `__post_init__` 不变式: `0 < inter < ttft < timeout_s``tpm > 0 → est_tokens > 0`、name/provider/base_url/api_key/model 非空)、`Usage`/`SourceStats`/`TransportResult`、韧性四件套(`RetryPolicy/BreakerConfig/BackpressurePolicy/GlobalLimits`,构造校验正数)。全部 frozen dataclass,中文 docstring。
- [ ] `errors.py`: 设计 §3 类层级逐字实现;`GatewayUnavailableError` 校验(scope 非空、reason ∈ scope 级 5 值、retry_after_s ≥ 0);`ResultInvalidError``raw_text/repair_error/validation_errors`
- 测试(先红后绿): `tests/unit/test_types.py`——LLMResponse 位置构造 11 参兼容(模拟三项目 fake)、frozen 不可变、SourceConfig 各不变式逐条触发 ValueError;`tests/unit/test_errors.py`——reason 值域拒绝、retry_after_s 负数拒绝、继承关系(`isinstance(CircuitOpenError(...), GatewayUnavailableError)`)。
- 验证: `conda run -n PolyGateway pytest tests/unit/test_types.py tests/unit/test_errors.py -v` 全 PASS。
- 保真: SourceConfig 不变式对照 CHS `config.py:66-82`;错误构造形态对照 CHS `errors.py:82-176`
- 提交: `feat: add frozen core types and error taxonomy`
### T2 `ports.py`
- [ ] §0 三组签名 + `CacheBackend`(get/set)、`TelemetryRecorder`(`record_llm_call` 18 字段 keyword-only,字段名单见设计 §4.4)、`SourceSelector`(`order(sources, stats)`)、`StructuredOutputStrategy`(`request_overlay(schema) -> dict` / `parse(text) -> Any`)、`GateDecision/GateUpdate` 数据类。全部 `@runtime_checkable``ports.py` 只 import `types.py`/`errors.py`/标准库。
- 测试: `tests/unit/test_ports.py`——最小 dummy 实现通过各 Protocol 的 isinstance;GateDecision `__post_init__` 全部非法组合逐条拒绝(OPEN 准入 / HALF_OPEN 非探针准入 / 探针缺 owner / 非探针带 owner / 负 epoch / 负 retry_after_s)。
- 保真: GateDecision 校验逐段比对 CHS `ports.py:405-440`
- 提交: `feat: freeze all port protocols and gate snapshots`
### T3 `streaming.py` 看门狗(ROADMAP 2a)
- [ ] 移植 VT `adapters/streaming.py`(与 GovDoc 逐字节一致): `StreamLivenessTimeout(kind, elapsed_s, first_token_seen)` + `stream_with_liveness_timeouts(source, *, ttft_s, inter_token_s, total_s)`。只包裹单次 `__anext__`;`asyncio.timeout``expired()` 区分本层 deadline;finally `aclose` 底层迭代器。
- 测试: `tests/unit/test_streaming.py`——ttft/inter_token/total 三层分别用慢迭代器触发(小超时值,真实时钟);正常流原样透传;上游 TimeoutError 不被误吞为本层超时;消费方取消时底层迭代器被 aclose。
- 保真: 逐段比对 VT `adapters/streaming.py`;语义零改动。
- 提交: `feat: port three-layer stream liveness watchdog`
### T4 `providers.py` 注册表(ROADMAP 2b)
- [ ] `ProviderProfile`(设计 §7)+ 模块级注册表 dict + `get_provider(name)`(未注册抛 `RequestRejectedError` 语义的装配错误——用 `ValueError`,装配期即炸)+ `register_provider(profile)`。首发三条目: qwen(`{"enable_thinking": True}`/`{"enable_thinking": False}`,strip_think_tags=True)、deepseek(`{"thinking": {"type": "enabled"}}`/`{"thinking": {"type": "disabled"}}`)、openai(全空)。注册表实例归属装配层持有(`from_env` 默认用模块级只读表)——**无可变全局状态**: `register_provider` 返回新表或要求显式表实例,禁止运行时改共享 dict(纯 asyncio 中立铁律)。
- 测试: 三 profile 内容断言;未注册名报错;自定义 profile 注册后可查。
- 保真: 注入片段比对 VT `llm.py:130-144` 与 CHS `invokers.py:230-238`(三态语义是设计定稿的统一,允许双向覆盖)。
- 提交: `feat: add explicit provider profile registry`
### T5 `transports/openai_compat.py`(ROADMAP 3)
- [ ] SSE 纯函数移植: `_sse_data_payload`/`_sse_delta`(usage 帧旁路;content 与 reasoning_content 区分)/`_iter_sse_deltas`(`[DONE]` 检测、畸形 JSON 抛 SSE 异常)——蓝本 VT `llm.py:51-124`
- [ ] `OpenAICompatTransport`: 每源持有预配 httpx.AsyncClient(Bearer/超时/trust_env);请求体 = model/messages/stream(+`stream_options.include_usage` 仅流式)+ overlay 合并 + profile thinking 注入;流式路径走看门狗(ttft/inter/total 取自 SourceConfig,None 回退 timeout_s;**任何增量含 reasoning 都算 token**);非流式快路径(`stream=False`,单 JSON 响应,仅 total 超时);`<think>` 剥离按 profile;usage 缺失按 `est_tokens` 兜底并标 `estimated`(est_tokens=0 则 0+estimated);缺 `[DONE]`: `missing_done="retry"` 抛瞬时 / `"salvage"` 用已收内容且强制 estimated;零内容 early_eof 恒抛瞬时。
- [ ] 错误翻译(设计引用 ARCH §6.2 全表): httpx Timeout/Transport 族、SSE 异常、看门狗超时 → `TransientError`;429 body `insufficient_quota`/401/403 → `SourceDeadError`;429 其余 → `TransientError(retry_after_s=秒数解析)`;400 → `RequestRejectedError`;≥500 → `TransientError`;其余 4xx → `RequestRejectedError`
- 测试: `tests/unit/test_openai_compat.py``httpx.MockTransport` + 从三项目真实响应二次构造的 SSE 帧样本——正常流(含 usage 帧/reasoning 帧)、畸形帧、缺 DONE 两种策略、early_eof、非流式、每条翻译规则逐行断言异常类型与 retry_after_s;`<think>` 剥离;usage 兜底与 usage_source。
- 保真: SSE 逐段比对 VT `llm.py`;翻译逐段比对 CHS `invokers.py:127-227`;Retry-After 仅秒数形态(`invokers.py:127-141`)。
- 提交: `feat: add openai-compatible transport with sse parsing and error translation`(可拆 2 个 commit: 纯函数先行)
### T6 `backends/memory/` limiter + breaker + 契约测试(ROADMAP 4b)
- [ ] `InMemoryLimiter`: 构造 `(scope, sources: dict[str, SourceConfig], global_limits: GlobalLimits, lease_ttl_s, now=time.monotonic)`;六道闸(全局/单源 × 并发/RPM/TPM,0 = 不启用);并发 = 带 lease_id+过期时刻的租约表(acquire 时惰性清理过期租约);RPM/TPM = 分钟滑动窗口计数;TPM 预扣 est、`settle(actual)` delta 结算可退款;`try_acquire` 任一闸满返 None **零副作用**;`acquire` 轮询 + `asyncio.sleep`(可取消);`mark_progress/progress_age_s`。单事件循环内的并发安全靠"检查-占用"同步完成(无 await 穿插),不引入锁。
- [ ] `InMemoryGate`: CHS gate 契约的进程内实现——CLOSED/OPEN/HALF_OPEN 状态机、连续失败阈值、force_open、冷却到期半开**单探针**(带 probe_ttl_s 租约,持有者死亡后过期回收)、epoch 递增与 fencing(旧 epoch 写回 `applied=False`)、`release_probe` 幂等、`retry_after_s(sources)` 取最早可尝试时刻。时钟构造注入。
- [ ] 契约测试 `tests/contracts/`: limiter——移植 CHS `tests/contracts_limiter.py` 5 项(并发占满/RPM 不归还/TPM 预扣结算退款/release 幂等/progress)+ 新增 settle 幂等、try_acquire 失败零副作用、租约过期回收;breaker——阈值开路/冷却半开单探针(第二个 try_enter 被拒)/探针成功闭路/探针失败重开/force_open 一击/探针租约过期后新探针可入/epoch fencing 拒迟到写回/release_probe 幂等。**fixture 参数化 backend**(M1 仅 memory,M2 加 redis 零改测试)。
- 保真: 状态机比对 VT `breaker.py`(含 `_half_open_inflight` 语义升级为租约)与 CHS `provider_gate.py`;limiter 语义比对 CHS `limiter.py`(`_RedisPermit` 的 released/settled 幂等 flag、settle 落 acquire 窗口)。
- 验证: `conda run -n PolyGateway pytest tests/contracts/ -v` 全 PASS。
- 提交: `feat: add in-memory limiter and breaker satisfying backend contracts`
### T7 `sources.py` 选源与冷却(设计 §2.3/多源拍板)
- [ ] `RoundRobinSelector`(内部游标轮转起点)与 `LeastInflightSelector`(按 inflight 升序)——逐字移植 CHS `selector.py:20,36`;`SourceCooldownMemo`(dict[str, float] 冷却截止,`set_until/active/skip_reason`,时钟注入)。
- 测试: 轮转顺序推进、least_inflight 排序稳定性、冷却备忘过期恢复。
- 提交: `feat: add source selectors and cooldown memo`
### T8 `middleware/` 骨架 + RetryMW(ROADMAP 4a;D13 自研)
- [ ] `middleware/base.py`: `compose(middlewares, terminal) -> CallNext` 洋葱组装(外→内)。
- [ ] `middleware/ratelimit.py` `QuotaGate`: 按 `quota_full`(wait/fail_fast)获取 permit;fail_fast 满时抛 `AllSourcesExhausted(reason="quota_exhausted")`;后端操作异常包为 `GovernanceBackendError`(**报错不放行**)。`middleware/breaker.py` `BreakerGate`: try_enter/写回/release_probe 封装,同样后端异常报错。
- [ ] `middleware/retry.py` `RetryMW`: 每次尝试 = selector.order → 跳过冷却源(计 gate_rejections)→ `try_acquire`(None 跳过)→ `try_enter`(不准入: settle(0)+release、读 retry_after_s 写冷却备忘、计 gate_rejections)→ transport(新 call_id/uuid4)。异常分派(蓝本 CHS `governance.py:200-268` 逐段比对): `RequestRejectedError` 网关已应答记成功否则探针释放,上抛;`ResultInvalidError` **记成功**上抛;`CancelledError` 探针释放后重抛(永不吞);`SourceDeadError` force_open+立即换源;`TransientError` 记失败、actual=est 保守、退避 `min(base*2^n, max)*uniform(0.5,1.5)` 与 retry_after_s 取大后重试;尝试数达 `max_attempts`(=总尝试次数)抛 `AllSourcesExhausted(reason="retry_exhausted")`;全源被门拒 → `CircuitOpenError`(per_source_reasons 逐源填);源列表空 → `reason="no_sources"`。成功: settle(actual)+mark_progress+record_success。finally 嵌套保证 settle 后必 release;取消路径 settle(0)。逐次遥测经注入的 `TelemetryEmitter`
- 测试(fake transport/后端注入): 换源顺序、瞬时重试次数语义(max_attempts=3 → 恰 3 次尝试)、SourceDead 不退避立即换源、ResultInvalid 记成功且不重试、退避与 Retry-After 取大(注入 rng/sleep 断言时长)、取消穿透(cancel 后 permit 已释放、探针已还、CancelledError 冒出)、fail_fast 语义、per_source_reasons 内容、每次尝试 call_id 不同。
- 保真: 循环结构逐段比对 CHS `governance.py:107-268`;退避公式比对 VT `llm.py`
- 验证: `conda run -n PolyGateway pytest tests/unit/test_retry.py -v` 全 PASS。
- 提交: `feat: add retry middleware with per-attempt governance orchestration`
### T9 `middleware/cache.py` + 缓存后端(ROADMAP 4c)
- [ ] key 公式(ARCH §7.5): `pgw:cache:` + `sha256(canonical_json({model, messages_digest, namespace, salt}))`;`messages_digest`——文本 part 原文、多模态 part(含 `image_url` data URL)各自 sha256 后参与;namespace 取 per-call `cache_namespace` 覆盖装配默认,缺失时(启用缓存而无 namespace)装配期已报错;salt 仅非 None 参与(VT 旧键语义)。
- [ ] `CacheMW`: get 命中 → 反序列化(未知字段过滤/缺字段吃默认)→ 若本次调用带 `structured` 用注入的 strategy 零网络重建 `structured_data`(失败按未命中 warning)→ 新 `cache_call_id` 构造 cache_hit=True 响应;未命中 → call_next → **成功且阶梯已通过**(StructuredMW 在内层,能返回即已通过)写缓存(排除 structured_data 字段)。get/set 异常一律 warning 降级。
- [ ] `backends/memory/cache.py`(dict+过期时刻,测试用)与 `backends/redis_cache.py`(笨 KV: get/set(ttl),`redis` import 失败报"缺 extra"错误)。
- 测试: key 稳定性(同请求同 key)、隔离性(model/namespace/salt/多模态字节任一变化 → key 变)、大 base64 不进 canonical_json(性能语义: digest 后长度恒定)、命中路径 structured 重建与 schema 变更重校验失败回源、后端炸时读写降级、TTL 过期。
- 保真: 降级与序列化行为比对 VT `redis_cache.py`;key 公式是设计声明的替换(旧缓存冷启动已记迁移文档)。
- 提交: `feat: add response cache middleware with poisoning-safe keys`
### T10 遥测: `telemetry/sqlite.py` + `middleware/telemetry.py`(ROADMAP 4d)
- [ ] `SQLiteRecorder`: 表 `llm_calls` 18 数据列 + created_at(旧 15 列名保留,`model_name``model` 更名,新增 `source_name/usage_source/cost`);WAL + busy_timeout=5000;单持久连接 + `threading.Lock`,写经 `asyncio.to_thread`;`INSERT OR IGNORE`;初始化/写入失败全降级 warning(`self._conn=None` 后续 no-op);`close()`
- [ ] `TelemetryEmitter`: **全库唯一** `record_llm_call` 调用点;`emit(request, *, response=None, error=None, call_id, source_name, latency_ms, ...)` 从请求与结果组装 18 字段;messages 落库前多模态 part 摘要(复用 T9 的 digest 函数);cost 恒 None(pricing M2)。`TelemetryMW`: 最外层,负责缓存命中与最终失败的记录;逐次记录由 RetryMW 持同一 Emitter 完成;取消路径 finally 尽力记 error="cancelled"。
- 测试: 18 列 schema 断言、并发 50 协程写全落库、call_id 冲突幂等、db 路径不可写时全链路不抛、缓存命中记录(cache_hit=True, latency_ms=0)、失败记录 error 字段、取消记录;grep 断言 `record_llm_call(` 在 src/ 仅出现于 emitter(铁律执法测试)。
- 保真: 比对 VT `telemetry.py`(连接管理/PRAGMA/降级);字段对照 ARCH §7.8。
- 提交: `feat: add sqlite telemetry with single-emitter discipline`
### T11 结构化: strategies + `middleware/structured.py`(ROADMAP 5)
- [ ] `structured/json_repair.py` `JsonRepairStrategy(normalize=None)`: 围栏剥离(VT `_CODE_FENCE_RE` 蓝本)→ `json_repair.repair_json``json.loads` → normalize 钩子;`json_repair` import 失败报"缺 extra polygateway[structured]";失败抛 `ResultInvalidError(raw_text, repair_error)``structured/native_schema.py` `NativeSchemaStrategy`: `request_overlay(schema)` 产出 `{"response_format": {"type": "json_schema", "json_schema": {...}}}`;parse 复用修复链。
- [ ] `StructuredMW`(设计 §5): 三档分派——不传直通;`"json"` 仅修复(dict/list 入 `structured_data`);pydantic 模型 = overlay 注入(profile 支持 native 时)→ call_next → 修复 → `model_validate`(**只形态**)→ 失败且余额 >0 则按反馈模板重问(原 messages + assistant 坏输出 + user 纠错,错误前 3 条各截 200 字符;重问叠加 NativeSchema)→ 耗尽抛 `ResultInvalidError(raw_text, repair_error, validation_errors)`。重问计数独立于 transport 重试;`max_structured_retries` 默认 1。
- 测试: 三档行为;修好的脏 JSON(真实样本: 围栏/尾逗号/单引号);修不好的;校验失败→重问 messages 形态断言(模板逐字)→ 第二次成功;retries=0 直接抛(CHS 语义);重问经过 call_next(即照过内层计数,用 fake 断言调用次数);normalize 钩子生效。
- 保真: 修复链比对 VT `loop.py:341-377`;`_normalize_action` **不入库**(设计 §5 细则 3)。
- 提交: `feat: add structured output ladder with bounded feedback retries`
### T12 装配: `config.py` + `client.py`(ROADMAP 6)
- [ ] `config.py` `GatewaySettings`(pydantic-settings): 设计 §8 全部键——多源 `{SCOPE}__{PROVIDER}__{N}__{FIELD}`(FIELD 全集含 MISSING_DONE/TRUST_ENV;段数≠4 或 GLOBAL 段跳过;未知 FIELD/未知 provider/缺必填字段报错)、scope 全局闸、per-scope 韧性键、平铺 `LLM_*` 简写(scope 键优先)、`PGW_*` 装配键(启用缓存缺 `PGW_CACHE_NAMESPACE` 或 TTL≤0 报错)。装配守卫: `timeout_s ≤ lease_ttl`、有效熔断阈值 `max(threshold, concurrency*2)` 自动、`max_attempts ≥ 1`
- [ ] `client.py`: `GatewayClient`(构造函数全量注入;`chat()` 设计 §2.2 签名逐字;组装洋葱 遥测→缓存→结构化→重试;`aclose()` 幂等 + async context manager)、`from_env(scope, *, limiter/breaker/cache/telemetry 注入覆盖)``from_settings(settings, ...)``gather_bounded(aws, *, concurrency)``__init__.py` 顶层导出: GatewayClient、LLMResponse、全部错误类、SourceConfig、gather_bounded、register_provider。
- [ ] 解除 import-linter 门控(Makefile 去掉跳过逻辑),契约按 ARCH §8 生效(必要时把 `sources.py`/`config.py` 归入正确层)。回填 `.env.example`(§8 键名全集注释模板)。
- 测试: from_env 缺键逐个报错、多源聚合(两源解析)、平铺与 scope 键优先级、注入覆盖生效(传入 fake limiter 断言被使用)、两个 client 共享同一 InMemoryLimiter 时全局并发闸生效(合计 inflight 封顶)、chat() 端到端(全 fake 后端+MockTransport: 命中缓存/瞬时重试/结构化三档 各一条)、aclose 幂等、gather_bounded 保序与并发上限、`isinstance` 结构性满足 GovDoc/VT 的 LLMProvider Protocol(只读 import reference)。
- 验证: `make ci` 绿(**首次含 import-linter**);`conda run -n PolyGateway pytest tests/ -v` 全 PASS。
- 提交: `feat: add gateway client with env-driven assembly`(config 与 client 可拆 2 commit)
### T13 集成测试(需真实 Redis)
> **前置(人类输入)**: 实验室远程 Redis 连接串写入 `.env`(`REDIS_URL`)。测试 namespace `pgw:test:{uuid4}`,teardown 全清理,禁止触碰既有键。
- [ ] `tests/integration/`: 远程 Redis 缓存读写与 TTL、断连降级(错误 URL 注入 → get/set 静默、chat 照常)、SQLite 并发写、治理组合(内存后端: 熔断开路→冷却→半开探针恢复全链;限流 wait/fail_fast;换源)、取消穿透(mid-stream 与 mid-backoff cancel → permit/探针释放断言)。
- 验证: `conda run -n PolyGateway pytest tests/integration/ -v` 全 PASS;覆盖率 `make test` ≥80%。
- 提交: `test: add integration suite for redis cache and governance combos`
### T14 e2e 冒烟(需真实网关凭据)
> **前置(人类输入)**: 真实网关 `.env`(至少一个 `LLM__{PROVIDER}__1__*` 源)。
- [ ] `tests/e2e/test_smoke_gateway.py`: 流式/非流式/`structured="json"`/pydantic 模型 四条真实调用,结果 + 遥测行断言,结构化 Markdown 输出落 `tests/outputs/e2e/`
- [ ] `tests/e2e/test_compat_govdoc.py` / `test_compat_videotree.py`: 只读 import 两项目 Protocol 断言 isinstance;按其调用点形态(`session_id/parent_call_id/cache_salt`)真实调用一次;用 VT 现有键名(`LLM_TIMEOUT` 等)装配成功。
- 验证: e2e 全 PASS,`tests/outputs/` 有产物(不提交)。
- 提交: `test: add real-gateway and dual-project onboarding smoke`
### T15 收尾: 验证门与合并
- [ ] 派**全新上下文 verifier subagent**(`verification-before-completion`): 对照设计逐节 + ROADMAP §2 验收出口逐条,证据必须指向本会话工具输出。
- [ ] 更新 ROADMAP §1 M1 状态、ARCH §8 模块图补 `config.py`;wiki 注册 plan 实体与 implements 边。
- [ ] `finishing-a-development-branch` skill 走合并(合并前 `requesting-code-review`)。
- 验证: `make ci` 绿;覆盖 ≥80%;验证报告无未闭合项。
## 风险与回滚
| 风险 | 对策 |
|---|---|
| 签名实现时发现设计矛盾 | 停下回人类门,先修设计再继续;严禁现场改签名 |
| 远程 Redis/网关凭据未就绪 | T13/T14 前向人类索取;其余任务不阻塞 |
| import-linter 契约与实际 import 冲突 | T12 才解除门控,前序任务自觉遵守层次,T12 一次清算 |
| 覆盖率不达 80% | 缺口集中在错误分支——按 T5/T8 翻译表与异常分派逐行补 |
保真校验总注: 本计划 T3/T5/T6/T8/T9/T10/T11 均涉及 ARCH §1.4 移植蓝本,各任务已标注比对文件;凡与蓝本语义有出入之处必须能指到设计 §9 审计表的对应行(保留/替换/放弃),否则视为未声明的隐式丢弃。