Files
writer-work-flow/apps/api/ww_api/services/provider_deps.py
Yaojia Wang 765dbdfbd4 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>
2026-06-20 10:39:58 +02:00

105 lines
3.9 KiB
Python
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.

"""提供商凭据端点的依赖装配(运行时实现)。
把 `get_session` 装配成 `SqlCredentialStore`;把网关适配器装配成探测器。
测试经 `app.dependency_overrides` 注入内存替身——不联网。
"""
from __future__ import annotations
import uuid
from typing import Annotated
from fastapi import Depends
from openai import AsyncOpenAI
from sqlalchemy.ext.asyncio import AsyncSession
from ww_config import get_settings
from ww_db import get_session
from ww_llm_gateway.adapters.base import Capabilities
from ww_llm_gateway.adapters.openai_compat import OpenAICompatAdapter
from ww_shared import AppError, ErrorCode
from ww_api.security.credentials import (
CredentialKeyError,
decrypt_api_key,
)
from ww_api.services.credentials import (
CredentialStore,
SqlCredentialStore,
)
# 已知 OpenAI 兼容提供商 → base_urlARCH §4.2)。
_PROVIDER_BASE_URLS: dict[str, str] = {
"deepseek": "https://api.deepseek.com",
"kimi": "https://api.moonshot.cn/v1",
"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",
}
def get_credential_store(
session: Annotated[AsyncSession, Depends(get_session)],
) -> CredentialStore:
return SqlCredentialStore(session)
class GatewayProviderProbe:
"""运行时探测:解密 Key→建 OpenAI 兼容适配器→最小请求验 Key→回能力矩阵。
依赖 store 取密文 + settings 取加密 key。绝不在日志/响应回显明文。
"""
def __init__(self, store: CredentialStore, enc_key: str) -> None:
self._store = store
self._enc_key = enc_key
async def probe(self, owner_id: uuid.UUID, provider: str) -> Capabilities:
cred = await self._store.get_credential(owner_id, provider)
if cred is None:
raise AppError(
ErrorCode.NOT_FOUND,
f"provider {provider} 未配置凭据",
{"provider": provider},
)
base_url = _PROVIDER_BASE_URLS.get(provider)
if base_url is None:
raise AppError(
ErrorCode.VALIDATION,
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:
raise AppError(ErrorCode.INTERNAL, str(exc)) from exc
client = AsyncOpenAI(api_key=api_key, base_url=base_url)
adapter = OpenAICompatAdapter(provider=provider, client=client)
try:
# 最小探测:列模型即可验证 Key 有效(不消耗生成额度)。
await client.models.list()
except Exception as exc: # noqa: BLE001 — 任一失败都映射为 LLM 不可用
raise AppError(
ErrorCode.LLM_UNAVAILABLE,
f"provider {provider} 连接探测失败",
{"provider": provider},
) from exc
return adapter.capabilities()
def get_provider_probe(
store: Annotated[CredentialStore, Depends(get_credential_store)],
) -> GatewayProviderProbe:
return GatewayProviderProbe(store, get_settings().credential_enc_key)