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,510 @@
"""K1 端到端Kimi Code OAuth device-flowdevice/token/refresh **全 mock**)→ 真 pg。
证明 K1 闭环DoDdevice-flow 连接 → 加密 token 持久化(DB 真源,无明文) → 网关建出
KimiCodeAdapter(coding base + 伪造头 + bearer) → 建网关时按需刷新更新存量 token → 断开清除)。
**绝不联网**device authorization / token / refresh 全经注入的 fake httpx 客户端,
auth.kimi.com / api.kimi.com 一次都不碰。
用例 1 · 连接 device flow → token 加密持久化DB 真源)
`POST .../oauth/start` → 202 `{job_id, user_code, verification_uri, ...}`。fake
`device_authorization` 返 device_codefake `token` 端点先 `authorization_pending`
两次、再成功 `{access_token, refresh_token, expires_in}`。后台 `run_job` 轮询循环跑在
**真 e2e_sm session** 上ASGITransport await background taskoverride
`get_session_factory`→e2e_sm、monkeypatch 模块级 `_default_http_client`→fake、
`asyncio.sleep`→no-op。`GET /jobs/{id}` → doneresult `{connected:true,
provider:"kimi-code"}` 且**不含任何 token 串**。DB 真源:`provider_credentials` 行
`auth_type="oauth"`、`oauth_enc` 非空、`api_key_enc` 为 None解密 `oauth_enc` 回环
access/refresh token证明加密持久化、非明文。`GET .../oauth/status` →
`{connected:true, expires_at}` 无 token。
用例 2 · 网关建出 Kimi Code 适配器(正确 token + 伪造头 + coding base
存 oauth 凭据 + `tier_routing` 行把某档位指向 `kimi-code:kimi-for-coding` →
`build_gateway_for_tier(...,"writer")` → 断言适配器是 `KimiCodeAdapter`,其
`AsyncOpenAI` 客户端 `base_url=https://api.kimi.com/coding/v1`、带伪造 `User-Agent`
+ 全 `X-Msh-*` 默认头、`api_key`(=bearer) = 存量 access token。
用例 3 · 建网关时刷新refresh-on-build
存一个 `expires_at` 临近过期的 oauth 包fake token 端点对 `grant_type=refresh_token`
返**新** token 组。`_build_provider_adapter(store,"kimi-code")`(经真 e2e_sm session
→ 断言 refresh 被调、适配器用**新** access token、`oauth_enc`(DB 真源)已更新为新包。
用例 4 · 断开disconnect
`POST .../oauth/disconnect` → 凭据行被清除DB 真源:之后无 oauth 凭据)。
零成本 & 确定性fake httpxdevice/token/refresh+ 真 `SqlCredentialStore`/真 pg。无 pg → skip。
坑(见 memory/gotchas
- 后台 work 自建 http 走**模块级** `routers.kimi_oauth._default_http_client()`(非 dep——
须 monkeypatch 它指到同一 scripted fake端点 device-auth call[0] + work poll call[1..]
顺序共享);端点 device-auth 走 `http` dep再 override `_default_http_client` dep。
- 真 pg 持久化:**不** monkeypatch `SqlCredentialStore`(让真 store 落库override
`get_session_factory`→`e2e_sm`(同测试 engine/loop使后台 work 用真 session 写库。
- monkeypatch `routers.kimi_oauth.asyncio.sleep`→no-op不真等 interval 秒)。
- `_resolve_kimi_code_token` 刷新走 `project_deps.httpx.AsyncClient` + `project_deps.kimi_refresh`——
用字符串 target monkeypatchmypy strict 不静态校验字符串属性)。
- token 绝不进 job 结果/状态响应/start 响应——显式负向断言。
- get_sessionmaker engine 绑首个 loop → 每 DB 测清缓存重建、结束 dispose同 M3/M4/M5
"""
from __future__ import annotations
import os
from collections.abc import AsyncIterator
from datetime import UTC, datetime, timedelta
from typing import Any
import httpx
import pytest
from asgi_lifespan import LifespanManager
from cryptography.fernet import Fernet
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from ww_db import get_sessionmaker
from ww_db.models import Job, ProviderCredential, TierRouting
from ww_llm_gateway.adapters.kimi_code import (
KIMI_CODE_BASE_URL,
KIMI_CODE_PROVIDER,
KIMI_CODE_USER_AGENT,
KimiCodeAdapter,
)
_ACCESS_TOKEN = "kimi-acc-e2e-not-real-12345"
_REFRESH_TOKEN = "kimi-ref-e2e-not-real-67890"
_NEW_ACCESS_TOKEN = "kimi-acc-e2e-refreshed-abcde"
_NEW_REFRESH_TOKEN = "kimi-ref-e2e-refreshed-fghij"
_OAUTH_BASE = "/settings/providers/kimi-code/oauth"
_START_URL = f"{_OAUTH_BASE}/start"
_DISCONNECT_URL = f"{_OAUTH_BASE}/disconnect"
_STATUS_URL = f"{_OAUTH_BASE}/status"
_REQUIRED_HEADER_KEYS = (
"User-Agent",
"X-Msh-Platform",
"X-Msh-Version",
"X-Msh-Device-Name",
"X-Msh-Device-Model",
"X-Msh-Os-Version",
"X-Msh-Device-Id",
)
class _FakeResponse:
"""`HttpResponse` Protocol 最小实现status_code + json())。"""
def __init__(self, status_code: int, body: dict[str, Any]) -> None:
self.status_code = status_code
self._body = body
def json(self) -> Any:
return self._body
class _ScriptedHttp:
"""按调用序号返回预设响应device auth → poll attempts记录每次调用。aclose 无操作。
端点的 device authorization 调用是 call[0],后台 work 的 token 轮询是 call[1..]。
同一实例供两者顺序共享(须 monkeypatch 模块级 `_default_http_client` 指到它)。
"""
def __init__(self, responses: list[_FakeResponse]) -> None:
self._responses = responses
self.calls: list[tuple[str, dict[str, str]]] = []
async def post(self, url: str, *, data: dict[str, str]) -> _FakeResponse:
idx = len(self.calls)
self.calls.append((url, data))
return self._responses[idx]
async def aclose(self) -> None:
return None
def _device_auth_response() -> _FakeResponse:
return _FakeResponse(
200,
{
"device_code": "dev-e2e-1",
"user_code": "ABCD-1234",
"verification_uri": "https://auth.kimi.com/device",
"verification_uri_complete": "https://auth.kimi.com/device?code=ABCD-1234",
"expires_in": 600,
"interval": 5,
},
)
def _pending_response() -> _FakeResponse:
return _FakeResponse(400, {"error": "authorization_pending"})
def _token_success_response() -> _FakeResponse:
return _FakeResponse(
200,
{
"access_token": _ACCESS_TOKEN,
"refresh_token": _REFRESH_TOKEN,
"expires_in": 900,
},
)
def _enc_key() -> str:
"""设置 `CREDENTIAL_ENC_KEY` 为有效 Fernet 密钥 + 清 settings 缓存,返回该密钥。"""
key = Fernet.generate_key().decode()
os.environ["CREDENTIAL_ENC_KEY"] = key
from ww_config import get_settings
get_settings.cache_clear()
return key
@pytest.fixture
async def e2e_sm() -> AsyncIterator[async_sessionmaker[AsyncSession]]:
"""真实 DB session 工厂;无 pg 时跳过(每测试清缓存重建 engine、结束 dispose"""
get_sessionmaker.cache_clear()
maker = get_sessionmaker()
try:
async with maker() as probe:
await probe.execute(select(1))
except Exception:
pytest.skip("postgres not reachable")
yield maker
await maker.kw["bind"].dispose()
get_sessionmaker.cache_clear()
async def _cleanup_credentials(e2e_sm: async_sessionmaker[AsyncSession]) -> None:
"""清理本测试族写入的 kimi-code 凭据 + kimi_oauth job 行(单用户 stub owner"""
from ww_api.services.credentials import STUB_OWNER_ID
async with e2e_sm() as cleanup:
await cleanup.execute(
delete(ProviderCredential).where(
ProviderCredential.owner_id == STUB_OWNER_ID,
ProviderCredential.provider == KIMI_CODE_PROVIDER,
)
)
await cleanup.execute(delete(Job).where(Job.kind == "kimi_oauth"))
await cleanup.execute(delete(TierRouting).where(TierRouting.project_id.is_(None)))
await cleanup.commit()
async def _seed_oauth_credential(e2e_sm: async_sessionmaker[AsyncSession], blob: bytes) -> None:
"""直接经真 store 在真 pg 写一行 oauth 凭据(用例 2/3 前置)。"""
from ww_api.services.credentials import STUB_OWNER_ID, SqlCredentialStore
async with e2e_sm() as session:
store = SqlCredentialStore(session)
await store.upsert_oauth_credential(STUB_OWNER_ID, KIMI_CODE_PROVIDER, blob)
async def test_k1_connect_device_flow_persists_encrypted_token(
e2e_sm: async_sessionmaker[AsyncSession],
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""用例 1device flow 连接 → 后台轮询(pending×2→成功) → 加密 token 真落 pg → status 已连接。
全程零联网fake httpxtoken 绝不进 job 结果/状态响应/start 响应(负向断言)。
"""
import ww_api.routers.kimi_oauth as kimi_mod
from ww_api.main import create_app
from ww_api.services.credentials import (
AUTH_TYPE_OAUTH,
STUB_OWNER_ID,
SqlCredentialStore,
)
from ww_api.services.kimi_oauth import decrypt_oauth_bundle
from ww_api.services.project_deps import get_session_factory
enc_key = _enc_key()
# device auth(call0) → poll pending(call1) → poll pending(call2) → poll success(call3)。
http = _ScriptedHttp(
[
_device_auth_response(),
_pending_response(),
_pending_response(),
_token_success_response(),
]
)
# 后台 work 不真等 interval 秒。
async def _no_sleep(_seconds: float) -> None:
return None
monkeypatch.setattr("ww_api.routers.kimi_oauth.asyncio.sleep", _no_sleep)
# 后台 work 自建 http 走模块级 `_default_http_client()`(非 dep→ 指到同一 scripted fake。
monkeypatch.setattr(kimi_mod, "_default_http_client", lambda: http)
app = create_app()
# 端点 device-auth 走 http dep → 也指到同一 fake保证顺序共享
app.dependency_overrides[kimi_mod._default_http_client] = lambda: http
# 后台 work 用 run_job 自建独立 session → 真 e2e_sm同测试 engine/loop真 store 落库。
app.dependency_overrides[get_session_factory] = lambda: e2e_sm
transport = httpx.ASGITransport(app=app)
try:
await _cleanup_credentials(e2e_sm)
async with LifespanManager(app):
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
# 1) 发起 device flow → 202 + user_code/verification_uriASGITransport 等后台)。
start_resp = await client.post(_START_URL)
assert start_resp.status_code == 202
start_body = start_resp.json()
assert start_body["user_code"] == "ABCD-1234"
assert start_body["verification_uri"] == "https://auth.kimi.com/device"
assert start_body["interval"] == 5
job_id = start_body["job_id"]
# start 响应绝不含 token。
assert _ACCESS_TOKEN not in start_resp.text
assert _REFRESH_TOKEN not in start_resp.text
# 2) 轮询 job → doneresult 只 {connected, provider},无 token。
job_resp = await client.get(f"/jobs/{job_id}")
assert job_resp.status_code == 200
job = job_resp.json()
assert job["status"] == "done"
assert job["kind"] == "kimi_oauth"
assert job["result"] == {"connected": True, "provider": KIMI_CODE_PROVIDER}
# job 整体(含 result绝不泄露 token。
assert _ACCESS_TOKEN not in job_resp.text
assert _REFRESH_TOKEN not in job_resp.text
# 3) status → 已连接 + expires_at无 token 本体。
status_resp = await client.get(_STATUS_URL)
assert status_resp.status_code == 200
status_body = status_resp.json()
assert status_body["connected"] is True
assert status_body["expires_at"] is not None
assert "access_token" not in status_body
assert "refresh_token" not in status_body
assert _ACCESS_TOKEN not in status_resp.text
assert _REFRESH_TOKEN not in status_resp.text
# 后台经历两次 pending 才成功(证明 pending 路径真走device + 3 token = 4 调用。
assert len(http.calls) == 4
# DB 真源断言oauth 凭据行加密持久化(非明文),解密回环 token。
async with e2e_sm() as verify:
row = (
await verify.execute(
select(ProviderCredential).where(
ProviderCredential.owner_id == STUB_OWNER_ID,
ProviderCredential.provider == KIMI_CODE_PROVIDER,
)
)
).scalar_one()
assert row.auth_type == AUTH_TYPE_OAUTH
assert row.oauth_enc is not None
assert row.api_key_enc is None
# 密文不是明文token 不出现在原始字节里)。
assert _ACCESS_TOKEN.encode() not in row.oauth_enc
assert _REFRESH_TOKEN.encode() not in row.oauth_enc
# 解密回环:证明真的是这套 token 被加密持久化。
token = decrypt_oauth_bundle(row.oauth_enc, key=enc_key)
assert token.access_token == _ACCESS_TOKEN
assert token.refresh_token == _REFRESH_TOKEN
# 经真 store 读 status 也是已连接store 层一致)。
store = SqlCredentialStore(verify)
cred = await store.get_credential(STUB_OWNER_ID, KIMI_CODE_PROVIDER)
assert cred is not None
assert cred.auth_type == AUTH_TYPE_OAUTH
finally:
await _cleanup_credentials(e2e_sm)
async def test_k1_gateway_builds_kimi_code_adapter_with_headers_and_bearer(
e2e_sm: async_sessionmaker[AsyncSession],
) -> None:
"""用例 2存 oauth + 档位路由指向 kimi-code → 建网关 → 适配器带 coding base/伪造头/bearer。"""
from ww_api.services.credentials import SqlCredentialStore, StoredRouting
from ww_api.services.kimi_oauth import TokenSet, encrypt_oauth_bundle
from ww_api.services.project_deps import build_gateway_for_tier
enc_key = _enc_key()
fresh = TokenSet(
access_token=_ACCESS_TOKEN,
refresh_token=_REFRESH_TOKEN,
expires_at=datetime.now(UTC) + timedelta(seconds=900), # 远未过期 → 不刷新
)
blob = encrypt_oauth_bundle(fresh, key=enc_key)
try:
await _cleanup_credentials(e2e_sm)
await _seed_oauth_credential(e2e_sm, blob)
# 档位路由writer → kimi-code:kimi-for-codingDB 真源)。
async with e2e_sm() as seed:
store = SqlCredentialStore(seed)
await store.upsert_routing(
StoredRouting(
tier="writer",
provider=KIMI_CODE_PROVIDER,
model="kimi-for-coding",
fallback=[],
)
)
async with e2e_sm() as session:
store = SqlCredentialStore(session)
gateway = await build_gateway_for_tier(session, store, "writer")
adapter = gateway._adapters[KIMI_CODE_PROVIDER]
assert isinstance(adapter, KimiCodeAdapter)
client = adapter._client
# coding base。
assert str(client.base_url).rstrip("/") == KIMI_CODE_BASE_URL.rstrip("/")
# 伪造头UA + 全 X-Msh-*。
for key in _REQUIRED_HEADER_KEYS:
assert key in client.default_headers
assert client.default_headers["User-Agent"] == KIMI_CODE_USER_AGENT
# bearer = 存量 access tokenSDK 据 api_key 自动发 Authorization: Bearer
assert client.api_key == _ACCESS_TOKEN
# 未刷新 → DB oauth 凭据未变(仍是原 token
async with e2e_sm() as verify:
from ww_api.services.kimi_oauth import decrypt_oauth_bundle
row = (
await verify.execute(
select(ProviderCredential).where(
ProviderCredential.provider == KIMI_CODE_PROVIDER
)
)
).scalar_one()
assert row.oauth_enc is not None
assert decrypt_oauth_bundle(row.oauth_enc, key=enc_key).access_token == _ACCESS_TOKEN
finally:
await _cleanup_credentials(e2e_sm)
async def test_k1_refresh_on_build_updates_stored_token(
e2e_sm: async_sessionmaker[AsyncSession],
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""用例 3存量 token 临近过期 → 建适配器触发刷新 → 用新 token + DB oauth_enc 更新为新包。
刷新经 fake httpx + fake `kimi_refresh`(不联网);新包真落 pgDB 真源)。
"""
from ww_api.services import project_deps
from ww_api.services.credentials import STUB_OWNER_ID, SqlCredentialStore
from ww_api.services.kimi_oauth import (
TokenSet,
decrypt_oauth_bundle,
encrypt_oauth_bundle,
)
from ww_api.services.project_deps import _build_provider_adapter
enc_key = _enc_key()
near = TokenSet(
access_token=_ACCESS_TOKEN,
refresh_token=_REFRESH_TOKEN,
expires_at=datetime.now(UTC) + timedelta(seconds=30), # 临近过期 → 触发刷新
)
blob = encrypt_oauth_bundle(near, key=enc_key)
refreshed = TokenSet(
access_token=_NEW_ACCESS_TOKEN,
refresh_token=_NEW_REFRESH_TOKEN,
expires_at=datetime.now(UTC) + timedelta(seconds=900),
)
refresh_calls: list[str] = []
async def _fake_refresh(_http: Any, refresh_token: str) -> TokenSet:
# 证明用存量 refresh token 刷新。
refresh_calls.append(refresh_token)
return refreshed
class _FakeAsyncClient:
async def __aenter__(self) -> _FakeAsyncClient:
return self
async def __aexit__(self, *exc: object) -> None:
return None
monkeypatch.setattr(project_deps, "kimi_refresh", _fake_refresh)
monkeypatch.setattr(
"ww_api.services.project_deps.httpx.AsyncClient", lambda *a, **k: _FakeAsyncClient()
)
try:
await _cleanup_credentials(e2e_sm)
await _seed_oauth_credential(e2e_sm, blob)
async with e2e_sm() as session:
store = SqlCredentialStore(session)
adapter = await _build_provider_adapter(store, KIMI_CODE_PROVIDER)
# refresh 被调,且用存量 refresh token。
assert refresh_calls == [_REFRESH_TOKEN]
# 适配器用新 access token。
assert isinstance(adapter, KimiCodeAdapter)
assert adapter._client.api_key == _NEW_ACCESS_TOKEN
# DB 真源oauth_enc 已更新为新包(下次建网关复用刷新结果)。
async with e2e_sm() as verify:
row = (
await verify.execute(
select(ProviderCredential).where(
ProviderCredential.owner_id == STUB_OWNER_ID,
ProviderCredential.provider == KIMI_CODE_PROVIDER,
)
)
).scalar_one()
assert row.oauth_enc is not None
stored = decrypt_oauth_bundle(row.oauth_enc, key=enc_key)
assert stored.access_token == _NEW_ACCESS_TOKEN
assert stored.refresh_token == _NEW_REFRESH_TOKEN
finally:
await _cleanup_credentials(e2e_sm)
async def test_k1_disconnect_clears_credential(
e2e_sm: async_sessionmaker[AsyncSession],
) -> None:
"""用例 4disconnect → 凭据行被清除DB 真源:之后无 oauth 凭据)。"""
from ww_api.main import create_app
from ww_api.services.credentials import STUB_OWNER_ID
from ww_api.services.kimi_oauth import TokenSet, encrypt_oauth_bundle
enc_key = _enc_key()
token = TokenSet(
access_token=_ACCESS_TOKEN,
refresh_token=_REFRESH_TOKEN,
expires_at=datetime.now(UTC) + timedelta(seconds=900),
)
blob = encrypt_oauth_bundle(token, key=enc_key)
app = create_app()
transport = httpx.ASGITransport(app=app)
try:
await _cleanup_credentials(e2e_sm)
await _seed_oauth_credential(e2e_sm, blob)
# 前置:凭据确实在库。
async with e2e_sm() as pre:
assert (
await pre.execute(
select(ProviderCredential).where(
ProviderCredential.owner_id == STUB_OWNER_ID,
ProviderCredential.provider == KIMI_CODE_PROVIDER,
)
)
).scalar_one_or_none() is not None
async with LifespanManager(app):
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.post(_DISCONNECT_URL)
assert resp.status_code == 200
assert resp.json()["disconnected"] is True
# DB 真源:凭据行已清除。
async with e2e_sm() as verify:
assert (
await verify.execute(
select(ProviderCredential).where(
ProviderCredential.owner_id == STUB_OWNER_ID,
ProviderCredential.provider == KIMI_CODE_PROVIDER,
)
)
).scalar_one_or_none() is None
finally:
await _cleanup_credentials(e2e_sm)