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:
@@ -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-flow,K1.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-flow,K1.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(
|
||||
|
||||
97
apps/api/ww_api/services/job_runner.py
Normal file
97
apps/api/ww_api/services/job_runner.py
Normal file
@@ -0,0 +1,97 @@
|
||||
"""通用长任务 runner(BackgroundTask 跑 `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))` → commit(job 失败可见)。
|
||||
任何异常都被吞(后台任务边界,不冒泡崩进程);失败置态本身再炸只记日志。
|
||||
`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))
|
||||
240
apps/api/ww_api/services/kimi_oauth.py
Normal file
240
apps/api/ww_api/services/kimi_oauth.py
Normal 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 scope):opencode-kimi
|
||||
`constants.ts` 发送之;kimi-cli v1.41.0 已不发但服务端仍接受。实测省略 scope 拿到的 token
|
||||
缺 coding entitlement,调 `api.kimi.com/coding/v1` 回 `401 Invalid Authentication`,故重新带上。
|
||||
- scope 只放在 device_authorization 请求上;token 交换/刷新不带 scope(OAuth 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_id(kimi-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 scope(device 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_id(env `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"])),
|
||||
)
|
||||
@@ -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:
|
||||
"""规则写侧 repo(POST /rules:作者显式加规则;只 flush,端点提交)。测试经 override 注。"""
|
||||
return SqlRuleWriteRepo(session)
|
||||
|
||||
|
||||
async def get_skill_registry(
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
) -> SkillRegistry:
|
||||
"""从 `skills` 表加载声明式 skill registry(ARCH §5.6;越权声明 → VALIDATION)。
|
||||
|
||||
每请求按 session 加载(registry 不可变快照)。测试经 `app.dependency_overrides` 注 fake repo
|
||||
或直接注 `SkillRegistry`。技能库 UI(T5.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:
|
||||
"""角色写侧 repo(POST /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:
|
||||
"""规则读侧 repo(GET /rules 列表,复用 C5 assemble 读侧;测试经 override 注 fake)。"""
|
||||
return SqlRulesRepo(session)
|
||||
|
||||
|
||||
def get_outline_read_repo(
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
) -> OutlineRepo:
|
||||
"""大纲读侧 repo(GET /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_url;Anthropic/Gemini 走原生 SDK(base_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 路由行:退回全局默认(单 provider,M1 兼容)。
|
||||
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")
|
||||
|
||||
@@ -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 订阅 plan(OAuth device-flow,K1.3):coding 端点(OpenAI 兼容 + 伪造头)。
|
||||
"kimi-code": "https://api.kimi.com/coding/v1",
|
||||
# Kimi 订阅 plan(静态 Console Key,ToS 合规):同一 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:
|
||||
|
||||
Reference in New Issue
Block a user