Files
writer-work-flow/apps/api/ww_api/services/provider_deps.py

113 lines
4.5 KiB
Python
Raw Permalink 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 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_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_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
# 经网关工厂构造适配器base_url 单点归网关 build_adapter不在此本地构造 AsyncOpenAI
adapter = build_adapter(provider, api_key=api_key, base_url=base_url)
# `_PROVIDER_BASE_URLS` 里的 api_key 型 provider 均映射到 OpenAI 兼容适配器(含
# kimi-code-key 子类);据此收窄类型,用公开 `probe_connection` 探测(不反手私有客户端)。
if not isinstance(adapter, OpenAICompatAdapter):
raise AppError(
ErrorCode.VALIDATION,
f"provider {provider} 不支持 api_key 连接测试",
{"provider": provider},
)
try:
# 最小探测:列模型即可验证 Key 有效(不消耗生成额度)。
await adapter.probe_connection()
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())