P0-1 SqlCredentialStore/save_draft 由自提交改 flush,端点/服务统一 commit (新增 CredentialStore.commit() 统一提交点;token 刷新落库显式提交); 补多凭据一请求中途失败整体回滚集成测试。 P0-2 启动校验 _fernet(enc_key) 快速失败 + catch-all Exception → ErrorEnvelope; credential_enc_key 改 SecretStr。 P0-3 run_job 异常分类:AppError 存 code+message,其余存通用文案不泄 str(exc)。 P0-4 评审/正文 SSE 失败先发 error 事件,尾部 commit 包 try/except。 P1-4 max_version 加 FOR UPDATE 行锁消除 TOCTOU。 P1-5 scan_overdue 谓词下推 + 批量 UPDATE RETURNING。 P1-10 移除 OAuth user_code 日志。 P2 provider_deps 改调网关 build_adapter;accept_service Committable Protocol; CORS 白名单收窄;request_id 安全字符集白名单;stdlib 日志接管;读端点 404 校验; httpx timeout;测试用合法 Fernet key;类型化响应模型(JobResponse/DimensionEntry/ ReviewConflictView/selling_points)+路由 ErrorEnvelope responses(供 codegen)。
105 lines
4.1 KiB
Python
105 lines
4.1 KiB
Python
"""提供商凭据端点的依赖装配(运行时实现)。
|
||
|
||
把 `get_session` 装配成 `SqlCredentialStore`;把网关适配器装配成探测器。
|
||
测试经 `app.dependency_overrides` 注入内存替身——不联网。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import uuid
|
||
from typing import Annotated
|
||
|
||
from fastapi import Depends
|
||
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.factory import build_adapter
|
||
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_url(ARCH §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 订阅 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",
|
||
}
|
||
|
||
|
||
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
|
||
|
||
# 经网关工厂构造适配器(base_url 单点归网关 build_adapter,不在此本地构造 AsyncOpenAI)。
|
||
adapter = build_adapter(provider, api_key=api_key, base_url=base_url)
|
||
try:
|
||
# 最小探测:列模型即可验证 Key 有效(不消耗生成额度)。底层 AsyncOpenAI 由适配器持有
|
||
# (已知 provider 为 OpenAI 兼容,见 _PROVIDER_BASE_URLS);读私有客户端属同仓既有约定。
|
||
await adapter._client.models.list() # type: ignore[attr-defined] # noqa: SLF001
|
||
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.get_secret_value())
|