refactor(api): LLM 提供商管理端点收敛为通用 manage 接口,端点层零特色

- schemas 新增 LlmProviderAction 公共动作词汇表
- LLMProviderManager 新增 provider_manage 统一入口,默认值填充、
  API Key 豁免判断、密钥脱敏与错误归因改写全部下沉封闭,
  不再硬编码 chatgpt/github-copilot 等产品名
- endpoint 收敛为 POST /llm/manage(ManageRequest 透传);
  OAuth 回跳地址由端点按具名回调路由统一构造后注入动作参数,
  /provider-auth/callback/{provider_id} 因浏览器回跳协议约束保留具名路由
- 新增 14 项 provider_manage 契约守护测试,
  替换原针对端点函数的两个旧测试文件
This commit is contained in:
jxxghp
2026-08-16 07:50:16 +08:00
parent a6dbd799d5
commit 441ec9475e
7 changed files with 461 additions and 675 deletions
+27 -302
View File
@@ -1,236 +1,45 @@
import re
from typing import Annotated, Optional
from typing import Any, Dict, Optional
from fastapi import Body, Depends, Request, Response
from fastapi import Depends, Request, Response
from fastapi.responses import HTMLResponse
from pydantic import BaseModel
from app import schemas
from app.api.response import ResponseAPIRouter
from app.agent.llm import (
LLMHelper,
LLMProviderManager,
LLMTestTimeout,
render_auth_result_html,
)
from app.runtime.config import settings
from app.agent.llm import LLMProviderManager, render_auth_result_html
from app.db.models import User
from app.api.deps import get_current_active_superuser_async, get_current_active_user_async
from app.runtime.log import logger
from app.api.deps import get_current_active_superuser_async
router = ResponseAPIRouter()
class LlmTestRequest(BaseModel):
"""
LLM 测试调用请求参数。
"""
enabled: Optional[bool] = None
provider: Optional[str] = None
model: Optional[str] = None
thinking_level: Optional[str] = None
api_key: Optional[str] = None
base_url: Optional[str] = None
base_url_preset: Optional[str] = None
user_agent: Optional[str] = None
temperature: Optional[float] = None
use_proxy: Optional[bool] = None
api_protocol: Optional[str] = None
web_search_mode: Optional[str] = None
class LlmProviderAuthStartRequest(BaseModel):
"""
LLM 提供商授权启动请求参数。
"""
provider: str
method: str
def _sanitize_llm_error(message: str, api_key: Optional[str] = None) -> str:
"""
清理错误信息中的敏感字段,避免回显密钥。
"""
if not message:
return "LLM 没有返回任何内容"
sanitized = message
if api_key:
sanitized = sanitized.replace(api_key, "***")
sanitized = re.sub(
r"(?i)(api[_-]?key\s*[:=]\s*)([^\s,;]+)",
r"\1***",
sanitized,
)
sanitized = re.sub(
r"(?i)authorization\s*:\s*bearer\s+[^\s,;]+",
"Authorization: ***",
sanitized,
)
normalized_message = sanitized.lower().replace("_", "").replace(" ", "")
if "str" in normalized_message and (
"modeldump" in normalized_message
or "setprivateattributes" in normalized_message
):
return (
"服务返回内容不是兼容的模型响应,请检查基础地址是否填写为 "
"API Base URL,如果服务要求 /v1 等版本路径,请包含在基础地址中,"
"不要填写网页地址或完整的 chat/completions 路径"
)
return sanitized
@router.get(
"/models",
summary="获取LLM模型列表",
response_model=schemas.Response[schemas.LLMModelCatalogData],
)
async def get_llm_models(
provider: str,
api_key: Optional[str] = None,
base_url: Optional[str] = None,
base_url_preset: Optional[str] = None,
user_agent: Optional[str] = None,
use_proxy: Optional[bool] = None,
force_refresh: Optional[bool] = False,
_: User = Depends(get_current_active_user_async),
):
"""
获取指定 provider 的模型目录。
"""
try:
provider_manager = LLMProviderManager()
models = await LLMHelper().get_models(
provider=provider,
api_key=api_key,
base_url=base_url,
base_url_preset=base_url_preset,
user_agent=user_agent,
use_proxy=use_proxy,
force_refresh=bool(force_refresh),
)
return schemas.Response(
success=True,
data={
"provider": provider,
"models": models,
"auth_status": provider_manager.get_auth_status(provider),
},
)
except Exception as err:
return schemas.Response(
success=False,
message=_sanitize_llm_error(str(err), api_key),
)
@router.get(
"/providers",
summary="获取LLM提供商目录",
response_model=schemas.Response[list[schemas.LLMProviderInfo]],
)
async def get_llm_providers(
_: User = Depends(get_current_active_user_async),
):
"""
返回前端可直接渲染的 provider 目录。
"""
try:
providers = await LLMProviderManager().list_providers_async()
return schemas.Response(success=True, data=providers)
except Exception as err:
return schemas.Response(success=False, message=str(err))
@router.post(
"/provider-auth/start",
summary="启动LLM提供商授权",
response_model=schemas.Response[schemas.LLMProviderAuthSession],
"/manage",
summary="LLM提供商统一管理",
response_model=schemas.Response[Dict[str, Any]],
)
async def start_llm_provider_auth(
payload: LlmProviderAuthStartRequest,
request: Request,
_: User = Depends(get_current_active_superuser_async),
async def manage_provider(
request: Request,
payload: schemas.ManageRequest,
_: User = Depends(get_current_active_superuser_async),
):
"""
启动 provider 授权会话。
LLM 提供商统一管理入口:前端上送 target/action/params 原样透传,
端点不定义任何提供商特定的名称、参数或响应字段;
OAuth 回跳地址由具名回调路由统一构造后注入动作参数
"""
try:
callback_url = None
if payload.provider == "chatgpt" and payload.method == "browser_oauth":
callback_url = str(
request.url_for(
"llm_provider_auth_callback", provider_id=payload.provider
)
)
result = await LLMProviderManager().start_auth(
payload.provider,
payload.method,
callback_url,
)
return schemas.Response(success=True, data=result)
except Exception as err:
return schemas.Response(success=False, message=str(err))
@router.get(
"/provider-auth/{session_id}",
summary="获取LLM提供商授权会话状态",
response_model=schemas.Response[schemas.LLMProviderAuthSession],
)
async def get_llm_provider_auth_session(
session_id: str,
_: User = Depends(get_current_active_superuser_async),
):
"""
查询授权会话状态。
"""
try:
result = LLMProviderManager().get_session_status(session_id)
return schemas.Response(success=True, data=result)
except Exception as err:
return schemas.Response(success=False, message=str(err))
@router.post(
"/provider-auth/{session_id}/poll",
summary="轮询LLM提供商授权会话",
response_model=schemas.Response[schemas.LLMProviderAuthSession],
)
async def poll_llm_provider_auth_session(
session_id: str,
_: User = Depends(get_current_active_superuser_async),
):
"""
轮询 device code / OAuth 会话状态。
"""
try:
result = await LLMProviderManager().poll_auth_session(session_id)
return schemas.Response(success=True, data=result)
except Exception as err:
return schemas.Response(success=False, message=str(err))
@router.delete(
"/provider-auth/{provider_id}",
summary="断开LLM提供商授权",
response_model=schemas.Response[None],
)
async def delete_llm_provider_auth(
provider_id: str,
_: User = Depends(get_current_active_superuser_async),
):
"""
删除已保存的 provider 授权信息。
"""
try:
await LLMProviderManager().clear_auth(provider_id)
return schemas.Response(success=True)
except Exception as err:
return schemas.Response(success=False, message=str(err))
params = dict(payload.params)
params.setdefault(
"callback_url",
str(request.url_for("llm_provider_auth_callback", provider_id=payload.target)),
)
result = await LLMProviderManager().provider_manage(
payload.target, payload.action, **params
)
return schemas.Response(
success=bool(result.get("success")),
message=result.get("message"),
data=result.get("data"),
)
@router.get(
@@ -264,87 +73,3 @@ async def llm_provider_auth_callback(
error_description,
)
return HTMLResponse(content=render_auth_result_html(success, message))
@router.post(
"/test",
summary="测试LLM调用",
response_model=schemas.Response[schemas.LLMTestResult],
)
async def llm_test(
payload: Annotated[Optional[LlmTestRequest], Body()] = None,
_: User = Depends(get_current_active_superuser_async),
):
"""
使用传入配置或当前已保存配置执行一次最小 LLM 调用。
"""
payload = payload or LlmTestRequest(
enabled=settings.AI_AGENT_ENABLE,
provider=settings.LLM_PROVIDER,
model=settings.LLM_MODEL,
thinking_level=settings.LLM_THINKING_LEVEL,
api_key=settings.LLM_API_KEY,
base_url=settings.LLM_BASE_URL,
base_url_preset=settings.LLM_BASE_URL_PRESET,
user_agent=settings.LLM_USER_AGENT,
use_proxy=settings.LLM_USE_PROXY,
api_protocol=settings.LLM_API_PROTOCOL,
web_search_mode=settings.LLM_WEB_SEARCH_MODE,
)
if not payload.provider:
return schemas.Response(success=False, message="请配置LLM提供商和模型")
if not payload.model or not payload.model.strip():
return schemas.Response(success=False, message="请先配置 LLM 模型")
data = {
"provider": payload.provider,
"model": payload.model,
}
if not payload.enabled:
return schemas.Response(success=False, message="请先启用智能助手", data=data)
if payload.provider not in {"chatgpt", "github-copilot"} and (
not payload.api_key or not payload.api_key.strip()
):
return schemas.Response(
success=False,
message="请先配置 LLM API Key",
data=data,
)
try:
test_kwargs = {
"provider": payload.provider,
"model": payload.model,
"thinking_level": payload.thinking_level,
"api_key": payload.api_key,
"base_url": payload.base_url,
"base_url_preset": payload.base_url_preset,
"user_agent": payload.user_agent,
"use_proxy": payload.use_proxy,
"api_protocol": payload.api_protocol,
"web_search_mode": payload.web_search_mode,
}
if payload.temperature is not None:
test_kwargs["temperature"] = payload.temperature
result = await LLMHelper.test_current_settings(**test_kwargs)
if not result.get("reply_preview"):
return schemas.Response(
success=False,
message="模型响应为空",
data=result,
)
return schemas.Response(success=True, data=result)
except (LLMTestTimeout, TimeoutError) as err:
logger.warning(err)
return schemas.Response(
success=False,
message="LLM 调用超时",
)
except Exception as err:
return schemas.Response(
success=False,
message=_sanitize_llm_error(str(err), payload.api_key),
)