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

@@ -20,12 +20,24 @@ from ww_llm_gateway.adapters.base import Capabilities
STUB_OWNER_ID = uuid.UUID(int=1)
# 凭据认证类型(`provider_credentials.auth_type`,见 C2 扩 K1.1)。
AUTH_TYPE_API_KEY = "api_key"
AUTH_TYPE_OAUTH = "oauth"
@dataclass(frozen=True)
class StoredCredential:
"""存储层视图:含密文,绝不出 API 边界(路由仅取 provider 并掩码)。"""
"""存储层视图:含密文,绝不出 API 边界(路由仅取 provider 并掩码)。
一行二选一:`auth_type="api_key"` → `api_key_enc` 有值、`oauth_enc=None`
`auth_type="oauth"`Kimi Code device-flowK1.3)→ `oauth_enc` 有值、`api_key_enc=None`
(持 Fernet 加密的 `{access_token,refresh_token,expires_at}` JSON 包)。
"""
provider: str
api_key_enc: bytes
api_key_enc: bytes | None
auth_type: str = AUTH_TYPE_API_KEY
oauth_enc: bytes | None = None
@dataclass(frozen=True)
@@ -51,6 +63,12 @@ class CredentialStore(Protocol):
self, owner_id: uuid.UUID, provider: str, api_key_enc: bytes
) -> None: ...
async def upsert_oauth_credential(
self, owner_id: uuid.UUID, provider: str, oauth_enc: bytes
) -> None: ...
async def delete_credential(self, owner_id: uuid.UUID, provider: str) -> bool: ...
async def upsert_routing(self, routing: StoredRouting) -> None: ...
@@ -72,7 +90,15 @@ class SqlCredentialStore:
select(ProviderCredential).where(ProviderCredential.owner_id == owner_id)
)
).scalars()
return [StoredCredential(provider=r.provider, api_key_enc=r.api_key_enc) for r in rows]
return [
StoredCredential(
provider=r.provider,
api_key_enc=r.api_key_enc,
auth_type=r.auth_type,
oauth_enc=r.oauth_enc,
)
for r in rows
]
async def list_routing(self) -> list[StoredRouting]:
rows = (await self._session.execute(select(TierRouting))).scalars()
@@ -94,7 +120,12 @@ class SqlCredentialStore:
).scalar_one_or_none()
if row is None:
return None
return StoredCredential(provider=row.provider, api_key_enc=row.api_key_enc)
return StoredCredential(
provider=row.provider,
api_key_enc=row.api_key_enc,
auth_type=row.auth_type,
oauth_enc=row.oauth_enc,
)
async def upsert_credential(
self, owner_id: uuid.UUID, provider: str, api_key_enc: bytes
@@ -117,12 +148,68 @@ class SqlCredentialStore:
project_id=None,
provider=provider,
api_key_enc=api_key_enc,
auth_type=AUTH_TYPE_API_KEY,
oauth_enc=None,
)
)
else:
existing.api_key_enc = api_key_enc
existing.auth_type = AUTH_TYPE_API_KEY
existing.oauth_enc = None
await self._session.commit()
async def upsert_oauth_credential(
self, owner_id: uuid.UUID, provider: str, oauth_enc: bytes
) -> None:
"""写/更新 OAuth 凭据行Kimi Code device-flowK1.3)。
`auth_type="oauth"`、`oauth_enc=<Fernet 加密 token 包>`、`api_key_enc=None`。
显式 read-modify-write同 `upsert_credential`:含可空 project_id 的唯一约束不能用
PG `ON CONFLICT`,见 memory/gotchas。明文 token 绝不进此层(已加密)。
"""
existing = (
await self._session.execute(
select(ProviderCredential).where(
ProviderCredential.owner_id == owner_id,
ProviderCredential.project_id.is_(None),
ProviderCredential.provider == provider,
)
)
).scalar_one_or_none()
if existing is None:
self._session.add(
ProviderCredential(
owner_id=owner_id,
project_id=None,
provider=provider,
api_key_enc=None,
auth_type=AUTH_TYPE_OAUTH,
oauth_enc=oauth_enc,
)
)
else:
existing.api_key_enc = None
existing.auth_type = AUTH_TYPE_OAUTH
existing.oauth_enc = oauth_enc
await self._session.commit()
async def delete_credential(self, owner_id: uuid.UUID, provider: str) -> bool:
"""删除凭据行OAuth disconnect / 撤销)。返回是否删到行。"""
existing = (
await self._session.execute(
select(ProviderCredential).where(
ProviderCredential.owner_id == owner_id,
ProviderCredential.project_id.is_(None),
ProviderCredential.provider == provider,
)
)
).scalar_one_or_none()
if existing is None:
return False
await self._session.delete(existing)
await self._session.commit()
return True
async def upsert_routing(self, routing: StoredRouting) -> None:
existing = (
await self._session.execute(

View File

@@ -0,0 +1,97 @@
"""通用长任务 runnerBackgroundTask 跑 `jobs` 表上的异步工作ARCH §7.4)。
T4.3「学文风走 jobs」复用此基建`POST /style` 立即写一行 `jobs(status=queued)` 返
202 `{job_id}`,再经 FastAPI `BackgroundTasks` 登记 `run_job(...)` 跑真正的提取工作。
**独立 session 纪律**(同 `services/foreshadow_scan.run_overdue_scan` 先例 + memory/gotchas
BackgroundTask 在请求-response 发回、请求 session 关闭**之后**才跑——故 `run_job`
**自建新 session**(经 `session_factory`),绝不复用请求 session。
`work: Callable[[AsyncSession], Awaitable[dict]]` 是业务逻辑缝T4.3 部分应用「跑提取
→ 写 style_fingerprint → 返回 result 摘要」)。`work` 拿到的 session 与 job 状态写同一
session → 一次 `commit()` 一并落库(业务写 + job done 原子)。
可测性:`run_job` 经可注入 `session_factory`/`repo_factory` 缝——单测直接 `await` 它,
注 fake session 工厂 + fake job repo + fake work**不起后台线程、不连真 DB**),断言
成功路置 done、异常路置 failed。
"""
from __future__ import annotations
import uuid
from collections.abc import Awaitable, Callable
from typing import Any, Protocol
import structlog
from sqlalchemy.ext.asyncio import AsyncSession
from ww_core.domain.job_repo import JobView, SqlJobRepo
from ww_api.services.foreshadow_scan import SessionFactory
log = structlog.get_logger(__name__)
# 业务工作缝:拿 session 跑真正的长任务,返回写回 job.result 的摘要 dict。
JobWork = Callable[[AsyncSession], Awaitable[dict[str, Any]]]
class JobLifecycleRepo(Protocol):
"""`run_job` 对 job repo 的**最小**依赖(仅生命周期三态写)——便于注入 fake。"""
async def set_running(self, job_id: uuid.UUID) -> JobView: ...
async def complete(self, job_id: uuid.UUID, result: dict[str, Any]) -> JobView: ...
async def fail(self, job_id: uuid.UUID, error: str) -> JobView: ...
# repo 工厂:从新 session 造 job repo。默认建 SQL 实现;测试注 fake避免真连 DB
JobRepoFactory = Callable[[AsyncSession], JobLifecycleRepo]
def _default_repo_factory(session: AsyncSession) -> JobLifecycleRepo:
return SqlJobRepo(session)
async def run_job(
session_factory: SessionFactory,
job_id: uuid.UUID,
work: JobWork,
*,
request_id: str | None = None,
repo_factory: JobRepoFactory = _default_repo_factory,
) -> None:
"""跑一个长任务:新建独立 session → set_running → await work → complete/fail → commit。
成功:`complete(job_id, result)`status=done, progress=100, result=work 返回值)后 commit。
异常:回滚 work 的部分写 → 新 session 里 `fail(job_id, str(exc))` → commitjob 失败可见)。
任何异常都被吞(后台任务边界,不冒泡崩进程);失败置态本身再炸只记日志。
`session_factory`/`repo_factory` 是可注入缝:测试直接 await、注 fake绝不联网/起线程。
"""
try:
async with session_factory() as session:
repo = repo_factory(session)
await repo.set_running(job_id)
result = await work(session)
await repo.complete(job_id, result)
await session.commit()
log.info("job_done", job_id=str(job_id), request_id=request_id)
except Exception as exc: # noqa: BLE001 — 后台任务边界:记错误 + 置 job failed不冒泡。
log.error("job_failed", job_id=str(job_id), request_id=request_id, error=str(exc))
await _mark_failed(session_factory, job_id, str(exc), repo_factory, request_id)
async def _mark_failed(
session_factory: SessionFactory,
job_id: uuid.UUID,
error: str,
repo_factory: JobRepoFactory,
request_id: str | None,
) -> None:
"""在一个**全新** session 里把 job 置 failed前一 session 的事务已因异常作废)。"""
try:
async with session_factory() as session:
repo = repo_factory(session)
await repo.fail(job_id, error)
await session.commit()
except Exception as exc: # noqa: BLE001 — 置失败态本身再炸只记日志,不冒泡。
log.error("job_fail_mark_failed", job_id=str(job_id), request_id=request_id, error=str(exc))

View File

@@ -0,0 +1,240 @@
"""Kimi Code OAuth device-flow 客户端K1.3 / PROGRESS K1
Kimi 订阅 plan 走 OAuth 2.0 **device authorization flow**RFC 8628
1. `start_device_authorization` → `POST .../device_authorization`,拿 `device_code` +
`user_code` + `verification_uri`(用户在浏览器授权)+ 轮询 `interval`/过期 `expires_in`。
2. `poll_token`(一次尝试,调用方按 `interval` 循环)→ `POST .../token`
grant=device_code`authorization_pending` → 继续轮询、`slow_down` → 增大间隔、
`expired_token`/`access_denied` → 停止失败;成功 → access/refresh token。
3. `refresh` → 同 token 端点grant=refresh_token换新的 access/refresh token。
**httpx 注入**:所有 HTTP 经注入的 `AsyncHttpClient` Protocol= `httpx.AsyncClient`
的 `.post` 子集)——测试注 fake**绝不联网**。
**token 不落明文**`TokenSet` 序列化为 JSON 串经 Fernet 加密入 `provider_credentials.oauth_enc`
`encrypt_oauth_bundle`/`decrypt_oauth_bundle`),明文 token 绝不进日志/响应/job 结果。
研究确认(对照 `github.com/ooojustin/opencode-kimi` `constants.ts` + `picassio/pi-kimi-coder`
- client_id `17e5f671-d194-4dfb-9706-5516cb48c098`env `KIMI_CLIENT_ID` 可覆盖)。
- device authorization **带 `scope=kimi-code`**coding-agent OAuth scopeopencode-kimi
`constants.ts` 发送之kimi-cli v1.41.0 已不发但服务端仍接受。实测省略 scope 拿到的 token
缺 coding entitlement调 `api.kimi.com/coding/v1` 回 `401 Invalid Authentication`,故重新带上。
- scope 只放在 device_authorization 请求上token 交换/刷新不带 scopeOAuth device flow 惯例)。
"""
from __future__ import annotations
import json
import os
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from typing import Any, Protocol
from ww_shared import AppError, ErrorCode
from ww_api.security.credentials import decrypt_api_key, encrypt_api_key
#: Kimi OAuth 端点device authorization + token
KIMI_AUTH_BASE_URL = "https://auth.kimi.com/api/oauth"
DEVICE_AUTHORIZATION_URL = f"{KIMI_AUTH_BASE_URL}/device_authorization"
TOKEN_URL = f"{KIMI_AUTH_BASE_URL}/token"
#: 默认 client_idkimi-cli 公开常量env `KIMI_CLIENT_ID` 可覆盖)。
DEFAULT_CLIENT_ID = "17e5f671-d194-4dfb-9706-5516cb48c098"
ENV_CLIENT_ID = "KIMI_CLIENT_ID"
GRANT_DEVICE_CODE = "urn:ietf:params:oauth:grant-type:device_code"
GRANT_REFRESH_TOKEN = "refresh_token"
#: coding-agent OAuth scopedevice authorization 专用;缺它 token 无 coding entitlement
KIMI_CODE_SCOPE = "kimi-code"
#: device flow 默认轮询间隔(秒)——服务端未给 `interval` 时的兜底。
DEFAULT_POLL_INTERVAL = 5
#: token 刷新触发缓冲(秒):剩余寿命低于 max(300, 0.5*expires_in) 即刷新。
MIN_REFRESH_BUFFER_SECONDS = 300
def client_id() -> str:
"""当前 OAuth client_idenv `KIMI_CLIENT_ID` 优先,否则公开默认值)。"""
return os.environ.get(ENV_CLIENT_ID) or DEFAULT_CLIENT_ID
class AsyncHttpClient(Protocol):
"""`run_job`/服务对 HTTP 客户端的**最小**依赖(= `httpx.AsyncClient.post` 子集)。
便于测试注 fake绝不联网。运行时传 `httpx.AsyncClient`。
"""
async def post(self, url: str, *, data: dict[str, str]) -> HttpResponse: ...
class HttpResponse(Protocol):
"""HTTP 响应的最小读接口(`httpx.Response` 满足之)。"""
status_code: int
def json(self) -> Any: ...
@dataclass(frozen=True)
class DeviceAuth:
"""device authorization 响应(用户面:展示 user_code + 打开 verification_uri"""
device_code: str
user_code: str
verification_uri: str
verification_uri_complete: str | None
expires_in: int
interval: int
@dataclass(frozen=True)
class TokenSet:
"""OAuth token 三元组access 短期 / refresh 长期 / 服务端驱动过期时刻 UTC"""
access_token: str
refresh_token: str
expires_at: datetime
class AuthorizationPending(Exception):
"""device flow 轮询:用户尚未授权(继续轮询)。"""
class SlowDown(Exception):
"""device flow 轮询:轮询过快(增大 interval 后继续)。"""
def _now() -> datetime:
return datetime.now(UTC)
def _expires_at(expires_in: int) -> datetime:
return _now() + timedelta(seconds=max(0, expires_in))
def needs_refresh(token: TokenSet, *, now: datetime | None = None) -> bool:
"""判定 access token 是否临近过期(剩余寿命 < `MIN_REFRESH_BUFFER_SECONDS`)。
建网关时按需刷新§token 刷新启发式);过期时刻已是服务端驱动的绝对时刻,故只需
与缓冲比较(无需原始 expires_in缓冲固定 300s对 ~15min access 足够)。
"""
current = now or _now()
remaining = (token.expires_at - current).total_seconds()
return remaining < MIN_REFRESH_BUFFER_SECONDS
async def start_device_authorization(http: AsyncHttpClient) -> DeviceAuth:
"""发起 device authorization带 `scope=kimi-code` 以获取 coding entitlement"""
resp = await http.post(
DEVICE_AUTHORIZATION_URL,
data={"client_id": client_id(), "scope": KIMI_CODE_SCOPE},
)
if resp.status_code >= 400:
raise AppError(
ErrorCode.LLM_UNAVAILABLE,
"Kimi 设备授权请求失败",
{"status": resp.status_code},
)
body = resp.json()
return DeviceAuth(
device_code=str(body["device_code"]),
user_code=str(body["user_code"]),
verification_uri=str(body["verification_uri"]),
verification_uri_complete=(
str(body["verification_uri_complete"])
if body.get("verification_uri_complete")
else None
),
expires_in=int(body.get("expires_in", 0)),
interval=int(body.get("interval", DEFAULT_POLL_INTERVAL)),
)
def _token_set_from_body(body: dict[str, Any]) -> TokenSet:
return TokenSet(
access_token=str(body["access_token"]),
refresh_token=str(body["refresh_token"]),
expires_at=_expires_at(int(body.get("expires_in", 0))),
)
async def poll_token(http: AsyncHttpClient, device_code: str) -> TokenSet:
"""轮询一次 token 端点(调用方按 interval 循环)。
`authorization_pending` → 抛 `AuthorizationPending`(继续轮询);
`slow_down` → 抛 `SlowDown`(增大 interval
`expired_token`/`access_denied`/其它 → 抛 `AppError`(停止失败);
成功 → `TokenSet`。
"""
resp = await http.post(
TOKEN_URL,
data={
"grant_type": GRANT_DEVICE_CODE,
"device_code": device_code,
"client_id": client_id(),
},
)
body = resp.json()
if resp.status_code >= 400 or body.get("error"):
error = str(body.get("error", "unknown_error"))
if error == "authorization_pending":
raise AuthorizationPending
if error == "slow_down":
raise SlowDown
# expired_token / access_denied / 其它 → 终止失败。
raise AppError(
ErrorCode.LLM_UNAVAILABLE,
f"Kimi 设备授权失败:{error}",
{"error": error},
)
return _token_set_from_body(body)
async def refresh(http: AsyncHttpClient, refresh_token: str) -> TokenSet:
"""用 refresh_token 换新 token 组access 临近过期时建网关触发)。"""
resp = await http.post(
TOKEN_URL,
data={
"grant_type": GRANT_REFRESH_TOKEN,
"refresh_token": refresh_token,
"client_id": client_id(),
},
)
body = resp.json()
if resp.status_code >= 400 or body.get("error"):
error = str(body.get("error", "unknown_error"))
raise AppError(
ErrorCode.LLM_UNAVAILABLE,
f"Kimi token 刷新失败:{error}",
{"error": error},
)
return _token_set_from_body(body)
def encrypt_oauth_bundle(token: TokenSet, *, key: str) -> bytes:
"""把 `TokenSet` 序列化为 JSON 串并 Fernet 加密为 `oauth_enc` 密文。
JSON 形 `{access_token, refresh_token, expires_at(ISO8601)}`——明文 token 绝不出此函数。
"""
bundle = json.dumps(
{
"access_token": token.access_token,
"refresh_token": token.refresh_token,
"expires_at": token.expires_at.isoformat(),
}
)
return encrypt_api_key(bundle, key=key)
def decrypt_oauth_bundle(blob: bytes, *, key: str) -> TokenSet:
"""解密 `oauth_enc` 密文回 `TokenSet`reuse Fernet helper工作在 str 上)。"""
bundle = json.loads(decrypt_api_key(blob, key=key))
return TokenSet(
access_token=str(bundle["access_token"]),
refresh_token=str(bundle["refresh_token"]),
expires_at=datetime.fromisoformat(str(bundle["expires_at"])),
)

View File

@@ -11,40 +11,58 @@ from __future__ import annotations
from typing import Annotated
import httpx
from fastapi import Depends
from openai import AsyncOpenAI
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from ww_config import get_settings
from ww_core.domain import ForeshadowLedgerRepo, SqlForeshadowLedgerRepo
from ww_core.domain.chapter_repo import ChapterRepo, SqlChapterRepo
from ww_core.domain.character_repo import CharacterWriteRepo, SqlCharacterWriteRepo
from ww_core.domain.digest_repo import DigestAppendRepo, SqlDigestAppendRepo
from ww_core.domain.job_repo import JobRepo, SqlJobRepo
from ww_core.domain.outline_write_repo import OutlineWriteRepo, SqlOutlineWriteRepo
from ww_core.domain.project_repo import ProjectRepo, SqlProjectRepo
from ww_core.domain.repositories import MemoryRepos
from ww_core.domain.repositories import MemoryRepos, OutlineRepo, RulesRepo
from ww_core.domain.review_repo import ReviewRepo, SqlReviewRepo
from ww_core.memory.sql_repositories import sql_memory_repos
from ww_core.domain.rule_repo import RuleWriteRepo, SqlRuleWriteRepo
from ww_core.domain.style_repo import SqlStyleFingerprintWriteRepo, StyleFingerprintWriteRepo
from ww_core.domain.world_entity_repo import SqlWorldEntityWriteRepo, WorldEntityWriteRepo
from ww_core.memory.sql_repositories import SqlOutlineRepo, SqlRulesRepo, sql_memory_repos
from ww_db import get_session, get_sessionmaker
from ww_db.models import User
from ww_llm_gateway import (
Gateway,
OpenAICompatAdapter,
ProviderAdapter,
Route,
SqlAlchemyLedgerSink,
build_adapter,
chain_from_routing,
resolve_route,
)
from ww_llm_gateway.adapters.kimi_code import KIMI_CODE_PROVIDER
from ww_llm_gateway.types import Tier
from ww_shared import AppError, ErrorCode
from ww_skills import SkillRegistry, SqlSkillRepo
from ww_api.security.credentials import (
CredentialKeyError,
decrypt_api_key,
)
from ww_api.services.credentials import (
AUTH_TYPE_OAUTH,
STUB_OWNER_ID,
CredentialStore,
SqlCredentialStore,
StoredCredential,
)
from ww_api.services.foreshadow_scan import SessionFactory
from ww_api.services.kimi_oauth import (
decrypt_oauth_bundle,
encrypt_oauth_bundle,
needs_refresh,
)
from ww_api.services.kimi_oauth import refresh as kimi_refresh
from ww_api.services.provider_deps import _PROVIDER_BASE_URLS
# 单用户 stub 的占位邮箱(多租户化时由真实主体替换)。
@@ -105,6 +123,16 @@ def get_foreshadow_repo(
return SqlForeshadowLedgerRepo(session)
def get_job_repo(
session: Annotated[AsyncSession, Depends(get_session)],
) -> JobRepo:
"""长任务写侧 repo创建/进度/完成/失败;状态写只 flush提交归 run_job/端点)。
测试经 `app.dependency_overrides[get_job_repo]` 注入 fake避免真连 DB
"""
return SqlJobRepo(session)
def get_outline_write_repo(
session: Annotated[AsyncSession, Depends(get_session)],
) -> OutlineWriteRepo:
@@ -112,6 +140,90 @@ def get_outline_write_repo(
return SqlOutlineWriteRepo(session)
def get_rule_write_repo(
session: Annotated[AsyncSession, Depends(get_session)],
) -> RuleWriteRepo:
"""规则写侧 repoPOST /rules作者显式加规则只 flush端点提交。测试经 override 注。"""
return SqlRuleWriteRepo(session)
async def get_skill_registry(
session: Annotated[AsyncSession, Depends(get_session)],
) -> SkillRegistry:
"""从 `skills` 表加载声明式 skill registryARCH §5.6;越权声明 → VALIDATION
每请求按 session 加载registry 不可变快照)。测试经 `app.dependency_overrides` 注 fake repo
或直接注 `SkillRegistry`。技能库 UIT5.6)经此读 builtin/custom/community。
"""
return await SkillRegistry.load(SqlSkillRepo(session))
def get_style_write_repo(
session: Annotated[AsyncSession, Depends(get_session)],
) -> StyleFingerprintWriteRepo:
"""文风指纹写侧 repo`GET /style` 读最新 + 学文风后台任务 append 版本化指纹)。
注:学文风的 `work` 在 `run_job` 自建的独立 session 上自造 repo请求 session 已关闭),
故本依赖只服务于 `GET /style` 读侧。测试经 `app.dependency_overrides` 注 fake。
"""
return SqlStyleFingerprintWriteRepo(session)
def get_character_write_repo(
session: Annotated[AsyncSession, Depends(get_session)],
) -> CharacterWriteRepo:
"""角色写侧 repoPOST /characters 入库schema→DB 形变;只 flush端点提交
测试经 `app.dependency_overrides` 注 fake。
"""
return SqlCharacterWriteRepo(session)
def get_world_entity_write_repo(
session: Annotated[AsyncSession, Depends(get_session)],
) -> WorldEntityWriteRepo:
"""世界观实体写侧 repo预留对称入库当前生成端点只用其形变能力"""
return SqlWorldEntityWriteRepo(session)
def get_rules_read_repo(
session: Annotated[AsyncSession, Depends(get_session)],
) -> RulesRepo:
"""规则读侧 repoGET /rules 列表,复用 C5 assemble 读侧;测试经 override 注 fake"""
return SqlRulesRepo(session)
def get_outline_read_repo(
session: Annotated[AsyncSession, Depends(get_session)],
) -> OutlineRepo:
"""大纲读侧 repoGET /outline 列表,复用 C5 assemble 读侧;测试经 override 注 fake"""
return SqlOutlineRepo(session)
async def get_worldbuilder_gateway(
session: Annotated[AsyncSession, Depends(get_session)],
) -> Gateway:
"""世界观生成writer 档位)的可注入网关缝。测试经 override 注 mock产 WorldGenResult"""
store = SqlCredentialStore(session)
return await build_gateway_for_tier(session, store, "writer")
async def get_character_gen_gateway(
session: Annotated[AsyncSession, Depends(get_session)],
) -> Gateway:
"""角色生成writer 档位)的可注入网关缝。测试经 override 注 mock产 CharacterGenResult"""
store = SqlCredentialStore(session)
return await build_gateway_for_tier(session, store, "writer")
async def get_precheck_gateway(
session: Annotated[AsyncSession, Depends(get_session)],
) -> Gateway:
"""入库前 continuity 预检analyst 档位)的可注入网关缝。测试注 mock产 ContinuityReview"""
store = SqlCredentialStore(session)
return await build_gateway_for_tier(session, store, "analyst")
def get_session_factory() -> SessionFactory:
"""验收后到期扫描的**独立 session 工厂**缝。
@@ -122,42 +234,125 @@ def get_session_factory() -> SessionFactory:
return get_sessionmaker()
async def build_gateway_for_tier(
session: AsyncSession, store: CredentialStore, tier: Tier
) -> Gateway:
"""据指定档位路由解密对应 provider 凭据 → 建网关(解析器仍为全局 `resolve_route`)。
async def _build_provider_adapter(store: CredentialStore, provider: str) -> ProviderAdapter | None:
"""据 provider 解密凭据 → 经 `build_adapter` 工厂建对应 provider 适配器T5.4 follow-up
无凭据/未知 provider → `LLM_UNAVAILABLE`(友好提示,前端引导去配置)。
解析器用 `resolve_route`(按 tier 路由);这里只决定**要预备哪个 provider 的适配器**。
`build_adapter(provider, *, api_key, base_url=None)` 按 provider 选适配器类:
OpenAI 兼容deepseek/kimi/qwen/glm/openai经 `base_url` 走 OpenAI 兼容适配器;
Anthropic/Gemini 走各自原生适配器(无需 `base_url``kimi-code` 走 OAuth bearer +
coding base + 伪造头(**access token 经 `_resolve_kimi_code_token` 按需刷新**K1.3)。
返回 `None` 表示该 provider 未配置凭据——回退链里缺位时网关会跳到下一个,故宽容返回
None不直接抛
"""
settings = get_settings()
route = resolve_route(tier)
base_url = _PROVIDER_BASE_URLS.get(route.provider)
if base_url is None:
raise AppError(
ErrorCode.LLM_UNAVAILABLE,
f"{tier} 档位 provider {route.provider} 暂不支持",
{"provider": route.provider, "tier": tier},
)
cred = await store.get_credential(STUB_OWNER_ID, route.provider)
cred = await store.get_credential(STUB_OWNER_ID, provider)
if cred is None:
raise AppError(
ErrorCode.LLM_UNAVAILABLE,
f"{tier} 档位 provider {route.provider} 未配置凭据,请先在设置中配置",
{"provider": route.provider, "tier": tier},
return None
settings = get_settings()
if cred.auth_type == AUTH_TYPE_OAUTH or provider == KIMI_CODE_PROVIDER:
# OAuth 凭据Kimi Code解密 token 包 → 临近过期则刷新并持久化 → access token
# 当 api_key 喂工厂(工厂为 kimi-code 构建带伪造头 + coding base 的客户端)。
access_token = await _resolve_kimi_code_token(store, cred, settings.credential_enc_key)
return build_adapter(
provider, api_key=access_token, base_url=_PROVIDER_BASE_URLS.get(provider)
)
if cred.api_key_enc is None:
# api_key 凭据但密文缺失(数据不一致)——视作未配置,回退链跳过。
return None
try:
api_key = decrypt_api_key(cred.api_key_enc, key=settings.credential_enc_key)
except CredentialKeyError as exc:
raise AppError(ErrorCode.INTERNAL, str(exc)) from exc
# OpenAI 兼容 provider 需 base_urlAnthropic/Gemini 走原生 SDKbase_url=None
base_url = _PROVIDER_BASE_URLS.get(provider)
return build_adapter(provider, api_key=api_key, base_url=base_url)
client = AsyncOpenAI(api_key=api_key, base_url=base_url)
adapter = OpenAICompatAdapter(provider=route.provider, client=client)
return Gateway(
adapters={route.provider: adapter},
ledger=SqlAlchemyLedgerSink(session),
resolver=resolve_route,
)
async def _resolve_kimi_code_token(
store: CredentialStore, cred: StoredCredential, enc_key: str
) -> str:
"""解密 Kimi Code OAuth token 包 → 临近过期时刷新并持久化 → 返回当前 access token。
刷新经一个**临时 httpx 客户端**(与 token 端点交互);新 token 包经
`store.upsert_oauth_credential` 持久化(下次建网关复用刷新结果)。明文 token 绝不进
日志/响应。无 `oauth_enc` → `LLM_UNAVAILABLE`(未连接 Kimi Code
"""
if cred.oauth_enc is None:
raise AppError(
ErrorCode.LLM_UNAVAILABLE,
f"{KIMI_CODE_PROVIDER} 未连接(无 OAuth 凭据),请先在设置中连接 Kimi Code",
{"provider": KIMI_CODE_PROVIDER},
)
try:
token = decrypt_oauth_bundle(cred.oauth_enc, key=enc_key)
except CredentialKeyError as exc:
raise AppError(ErrorCode.INTERNAL, str(exc)) from exc
if not needs_refresh(token):
return token.access_token
# 临近过期 → 刷新并持久化新包。
async with httpx.AsyncClient() as http:
refreshed = await kimi_refresh(http, token.refresh_token)
new_blob = encrypt_oauth_bundle(refreshed, key=enc_key)
await store.upsert_oauth_credential(STUB_OWNER_ID, KIMI_CODE_PROVIDER, new_blob)
return refreshed.access_token
async def build_gateway_for_tier(
session: AsyncSession, store: CredentialStore, tier: Tier
) -> Gateway:
"""据指定档位路由 + DB `tier_routing.fallback` 装配**多 provider 回退链**网关T5.4 接线)。
流程§4.3 三级解析 / §4.5 回退链):
1. 读 DB `tier_routing` 取该 tier 的 primary `provider:model` + fallback 列表(缺则退回
全局 `resolve_route`,单 provider向后兼容
2. 为 primary + fallback 里**每个能建出适配器**的 provider 预备 OpenAI 兼容适配器
(未知 base_url / 未配凭据的 provider 跳过——回退链自然绕过它)。
3. 至少要有一个可用适配器,否则 `LLM_UNAVAILABLE`(无任何凭据可用)。
4. 注入 `chain_resolver=chain_from_routing(...)`(多元素链,启用回退);无 DB 路由时
用 `resolver=resolve_route`单路由M1 行为不变)。
单 provider 配置仍走单元素链(网关把它当无回退处理),**不破既有行为**。
"""
ledger = SqlAlchemyLedgerSink(session)
stored = next((r for r in await store.list_routing() if r.tier == tier), None)
if stored is None:
# 无 DB 路由行:退回全局默认(单 providerM1 兼容)。
route = resolve_route(tier)
adapter = await _build_provider_adapter(store, route.provider)
if adapter is None:
raise AppError(
ErrorCode.LLM_UNAVAILABLE,
f"{tier} 档位 provider {route.provider} 未配置凭据,请先在设置中配置",
{"provider": route.provider, "tier": tier},
)
return Gateway(adapters={route.provider: adapter}, ledger=ledger, resolver=resolve_route)
# DB 路由primary + fallback 构链;为每个可建的 provider 预备适配器。
primary_spec = f"{stored.provider}:{stored.model}"
chain: list[Route] = chain_from_routing(tier, primary_spec, list(stored.fallback))
adapters: dict[str, ProviderAdapter] = {}
for route in chain:
if route.provider in adapters:
continue
built = await _build_provider_adapter(store, route.provider)
if built is not None:
adapters[route.provider] = built
if not adapters:
raise AppError(
ErrorCode.LLM_UNAVAILABLE,
f"{tier} 档位无任何已配置凭据的 provider请先在设置中配置",
{"providers": [r.provider for r in chain], "tier": tier},
)
def _resolver(_tier: Tier) -> list[Route]:
return chain_from_routing(_tier, primary_spec, list(stored.fallback))
return Gateway(adapters=adapters, ledger=ledger, chain_resolver=_resolver)
async def build_writer_gateway(session: AsyncSession, store: CredentialStore) -> Gateway:
@@ -195,3 +390,25 @@ async def get_outline_gateway(
"""大纲生成analyst 档位)的可注入网关缝。测试经 override 注 mock产 OutlineResult"""
store = SqlCredentialStore(session)
return await build_gateway_for_tier(session, store, "analyst")
async def get_style_extract_gateway(
session: Annotated[AsyncSession, Depends(get_session)],
) -> Gateway:
"""学文风提取analyst 档位)的可注入网关缝。
`POST /style` 在 dep 解析阶段构建网关 → 无凭据时这里抛 `LLM_UNAVAILABLE`503
调度 job 之前拦下,避免凭空写一行注定失败的 job。提取本体在 BackgroundTask 里
用 `run_job` 自建的独立 session 重新构网关跑(请求 session 已关闭);本依赖确保
凭据探测在请求阶段发生。测试经 override 注 mock产 StyleFingerprintResult
"""
store = SqlCredentialStore(session)
return await build_gateway_for_tier(session, store, "analyst")
async def get_refine_gateway(
session: Annotated[AsyncSession, Depends(get_session)],
) -> Gateway:
"""回炉writer 档位)的可注入网关缝。测试经 override 注 mock产纯文本重写段"""
store = SqlCredentialStore(session)
return await build_gateway_for_tier(session, store, "writer")

View File

@@ -34,6 +34,10 @@ _PROVIDER_BASE_URLS: dict[str, str] = {
"qwen": "https://dashscope.aliyuncs.com/compatible-mode/v1",
"glm": "https://open.bigmodel.cn/api/paas/v4",
"openai": "https://api.openai.com/v1",
# Kimi 订阅 planOAuth device-flowK1.3coding 端点OpenAI 兼容 + 伪造头)。
"kimi-code": "https://api.kimi.com/coding/v1",
# Kimi 订阅 plan静态 Console KeyToS 合规):同一 coding 端点,纯 bearer 无伪造头。
"kimi-code-key": "https://api.kimi.com/coding/v1",
}
@@ -68,6 +72,13 @@ class GatewayProviderProbe:
f"未知提供商 {provider}",
{"provider": provider},
)
if cred.api_key_enc is None:
# api_key 探测不支持 OAuth 凭据(无 api_key 密文——OAuth provider 走专属端点。
raise AppError(
ErrorCode.VALIDATION,
f"provider {provider} 为 OAuth 凭据,不支持 api_key 连接测试",
{"provider": provider},
)
try:
api_key = decrypt_api_key(cred.api_key_enc, key=self._enc_key)
except CredentialKeyError as exc: