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:
125
apps/api/tests/test_kimi_code_wiring.py
Normal file
125
apps/api/tests/test_kimi_code_wiring.py
Normal file
@@ -0,0 +1,125 @@
|
||||
"""K1.3 接线测试:`_build_provider_adapter` 为 kimi-code 走 OAuth 路径 + 按需刷新。
|
||||
|
||||
不联网:refresh 路径 monkeypatch httpx + kimi_oauth.refresh。断言:
|
||||
- 凭据未临近过期 → 直接用现有 access token 建 KimiCodeAdapter(不刷新);
|
||||
- 临近过期 → 调 refresh + 持久化新包 + 用新 access token 建适配器;
|
||||
- 无 oauth 凭据 → LLM_UNAVAILABLE。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from fakes_providers import FakeCredentialStore
|
||||
from ww_api.services.credentials import STUB_OWNER_ID
|
||||
from ww_api.services.kimi_oauth import TokenSet, decrypt_oauth_bundle, encrypt_oauth_bundle
|
||||
from ww_llm_gateway.adapters.kimi_code import KIMI_CODE_PROVIDER, KimiCodeAdapter
|
||||
from ww_shared import AppError, ErrorCode
|
||||
|
||||
_ENC_KEY = "imB6TEcV2AzlpXjR4NjTgK_9Zt-OoqaLFQc3XqBQ0CQ="
|
||||
|
||||
|
||||
def _store_with_token(token: TokenSet) -> FakeCredentialStore:
|
||||
store = FakeCredentialStore()
|
||||
store.oauth[(STUB_OWNER_ID, KIMI_CODE_PROVIDER)] = encrypt_oauth_bundle(token, key=_ENC_KEY)
|
||||
return store
|
||||
|
||||
|
||||
def _set_env() -> None:
|
||||
os.environ["CREDENTIAL_ENC_KEY"] = _ENC_KEY
|
||||
from ww_config import get_settings
|
||||
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_adapter_uses_existing_token_when_fresh() -> None:
|
||||
_set_env()
|
||||
from ww_api.services.project_deps import _build_provider_adapter
|
||||
|
||||
fresh = TokenSet(
|
||||
access_token="fresh-acc",
|
||||
refresh_token="ref",
|
||||
expires_at=datetime.now(UTC) + timedelta(seconds=900),
|
||||
)
|
||||
store = _store_with_token(fresh)
|
||||
|
||||
adapter = await _build_provider_adapter(store, KIMI_CODE_PROVIDER)
|
||||
|
||||
assert isinstance(adapter, KimiCodeAdapter)
|
||||
# 未刷新:oauth 凭据未变。
|
||||
cred = store.oauth[(STUB_OWNER_ID, KIMI_CODE_PROVIDER)]
|
||||
assert decrypt_oauth_bundle(cred, key=_ENC_KEY).access_token == "fresh-acc"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_adapter_refreshes_when_near_expiry(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_set_env()
|
||||
from ww_api.services import project_deps
|
||||
from ww_api.services.project_deps import _build_provider_adapter
|
||||
|
||||
near = TokenSet(
|
||||
access_token="old-acc",
|
||||
refresh_token="old-ref",
|
||||
expires_at=datetime.now(UTC) + timedelta(seconds=30), # 临近过期
|
||||
)
|
||||
store = _store_with_token(near)
|
||||
|
||||
refreshed = TokenSet(
|
||||
access_token="new-acc",
|
||||
refresh_token="new-ref",
|
||||
expires_at=datetime.now(UTC) + timedelta(seconds=900),
|
||||
)
|
||||
|
||||
async def _fake_refresh(_http: Any, _refresh_token: str) -> TokenSet:
|
||||
assert _refresh_token == "old-ref"
|
||||
return refreshed
|
||||
|
||||
# 替换 refresh + httpx.AsyncClient(避免真联网)。
|
||||
monkeypatch.setattr(project_deps, "kimi_refresh", _fake_refresh)
|
||||
|
||||
class _FakeAsyncClient:
|
||||
async def __aenter__(self) -> _FakeAsyncClient:
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc: object) -> None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(
|
||||
"ww_api.services.project_deps.httpx.AsyncClient", lambda *a, **k: _FakeAsyncClient()
|
||||
)
|
||||
|
||||
adapter = await _build_provider_adapter(store, KIMI_CODE_PROVIDER)
|
||||
|
||||
assert isinstance(adapter, KimiCodeAdapter)
|
||||
# 刷新后新包持久化(下次复用刷新结果)。
|
||||
cred = store.oauth[(STUB_OWNER_ID, KIMI_CODE_PROVIDER)]
|
||||
assert decrypt_oauth_bundle(cred, key=_ENC_KEY).access_token == "new-acc"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_adapter_no_credential_returns_none() -> None:
|
||||
_set_env()
|
||||
from ww_api.services.project_deps import _build_provider_adapter
|
||||
|
||||
store = FakeCredentialStore() # 无任何凭据
|
||||
adapter = await _build_provider_adapter(store, KIMI_CODE_PROVIDER)
|
||||
assert adapter is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_adapter_raises_when_oauth_missing_blob() -> None:
|
||||
"""auth_type=oauth 但 oauth_enc=None(数据不一致)→ LLM_UNAVAILABLE。"""
|
||||
_set_env()
|
||||
from ww_api.services.credentials import StoredCredential
|
||||
from ww_api.services.project_deps import _resolve_kimi_code_token
|
||||
|
||||
bad = StoredCredential(
|
||||
provider=KIMI_CODE_PROVIDER, api_key_enc=None, auth_type="oauth", oauth_enc=None
|
||||
)
|
||||
with pytest.raises(AppError) as exc:
|
||||
await _resolve_kimi_code_token(FakeCredentialStore(), bad, _ENC_KEY)
|
||||
assert exc.value.code == ErrorCode.LLM_UNAVAILABLE
|
||||
Reference in New Issue
Block a user