435 lines
14 KiB
Python
435 lines
14 KiB
Python
"""
|
||
LLM 模型配置 API
|
||
"""
|
||
from decimal import Decimal
|
||
from typing import Optional
|
||
|
||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||
from pydantic import BaseModel, Field, field_validator
|
||
from sqlalchemy import func, or_, select, update
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from app.core.database import get_db
|
||
from app.core.deps import get_current_user
|
||
from app.models.llm_model_config import LLMModelConfig
|
||
from app.models.user import User
|
||
from app.schemas.response import success_response
|
||
from app.services.llm_provider_service import LLMProviderService
|
||
|
||
router = APIRouter()
|
||
|
||
|
||
class LLMModelConfigUpsertRequest(BaseModel):
|
||
"""模型配置新增/编辑请求"""
|
||
|
||
model_code: Optional[str] = None
|
||
model_name: Optional[str] = None
|
||
model_type: str = Field("chat", pattern="^(chat|embedding)$")
|
||
provider: str = Field(..., min_length=1, max_length=64)
|
||
endpoint_url: Optional[str] = Field(None, max_length=512)
|
||
api_key: Optional[str] = Field(None, max_length=512)
|
||
llm_model_name: str = Field(..., min_length=1, max_length=128)
|
||
llm_timeout: int = Field(120, ge=5, le=600)
|
||
llm_temperature: float = Field(0.70, ge=0, le=2)
|
||
llm_top_p: float = Field(0.90, ge=0, le=1)
|
||
llm_max_tokens: int = Field(8192, ge=1, le=32768)
|
||
llm_system_prompt: Optional[str] = None
|
||
embedding_dimension: Optional[int] = Field(None, ge=1, le=8192)
|
||
description: Optional[str] = Field(None, max_length=500)
|
||
is_active: bool = True
|
||
is_default: bool = False
|
||
|
||
@field_validator(
|
||
"model_code",
|
||
"model_name",
|
||
"provider",
|
||
"endpoint_url",
|
||
"api_key",
|
||
"llm_model_name",
|
||
"llm_system_prompt",
|
||
"description",
|
||
mode="before",
|
||
)
|
||
@classmethod
|
||
def strip_string_fields(cls, value):
|
||
if isinstance(value, str):
|
||
value = value.strip()
|
||
return value or None
|
||
return value
|
||
|
||
|
||
class LLMModelConfigTestRequest(LLMModelConfigUpsertRequest):
|
||
"""模型测试请求"""
|
||
|
||
|
||
def serialize_model_config(config: LLMModelConfig, include_api_key: bool = False) -> dict:
|
||
"""序列化模型配置"""
|
||
api_key = config.api_key or ""
|
||
data = {
|
||
"config_id": config.config_id,
|
||
"model_code": config.model_code,
|
||
"model_name": config.model_name,
|
||
"model_type": config.model_type or "chat",
|
||
"provider": config.provider,
|
||
"endpoint_url": config.endpoint_url,
|
||
"llm_model_name": config.llm_model_name,
|
||
"llm_timeout": config.llm_timeout,
|
||
"llm_temperature": float(config.llm_temperature or 0),
|
||
"llm_top_p": float(config.llm_top_p or 0),
|
||
"llm_max_tokens": config.llm_max_tokens,
|
||
"llm_system_prompt": config.llm_system_prompt,
|
||
"embedding_dimension": config.embedding_dimension,
|
||
"description": config.description,
|
||
"is_active": bool(config.is_active),
|
||
"is_default": bool(config.is_default),
|
||
"has_api_key": bool(api_key),
|
||
"api_key_masked": mask_api_key(api_key),
|
||
"created_at": config.created_at.isoformat() if config.created_at else None,
|
||
"updated_at": config.updated_at.isoformat() if config.updated_at else None,
|
||
}
|
||
if include_api_key:
|
||
data["api_key"] = api_key
|
||
return data
|
||
|
||
|
||
def mask_api_key(api_key: str) -> str:
|
||
"""脱敏 API Key"""
|
||
if not api_key:
|
||
return ""
|
||
if len(api_key) <= 8:
|
||
return "*" * len(api_key)
|
||
return f"{api_key[:4]}{'*' * (len(api_key) - 8)}{api_key[-4:]}"
|
||
|
||
|
||
def normalize_payload(payload: LLMModelConfigUpsertRequest) -> dict:
|
||
"""补齐自动生成字段"""
|
||
data = payload.model_dump()
|
||
provider = data["provider"]
|
||
llm_model_name = data["llm_model_name"]
|
||
model_type = data.get("model_type") or "chat"
|
||
data["model_type"] = model_type
|
||
data["model_name"] = data.get("model_name") or LLMProviderService.build_model_name(provider, llm_model_name)
|
||
base_code = data.get("model_code") or LLMProviderService.build_model_code(provider, llm_model_name)
|
||
# embedding 类型加前缀,避免与同名对话模型编码冲突
|
||
if model_type == "embedding" and not base_code.startswith("emb_"):
|
||
base_code = f"emb_{base_code}"
|
||
data["model_code"] = base_code
|
||
data["endpoint_url"] = data.get("endpoint_url") or LLMProviderService.get_default_endpoint_url(provider)
|
||
if data["is_default"]:
|
||
data["is_active"] = True
|
||
data["llm_temperature"] = Decimal(str(data["llm_temperature"]))
|
||
data["llm_top_p"] = Decimal(str(data["llm_top_p"]))
|
||
# embedding 不需要对话采样参数,但列非空,保留默认值即可
|
||
if model_type == "embedding":
|
||
data["llm_system_prompt"] = None
|
||
return data
|
||
|
||
|
||
async def ensure_default_config(db: AsyncSession, preferred_config_id: Optional[int] = None):
|
||
"""确保始终存在一个默认启用模型"""
|
||
default_result = await db.execute(
|
||
select(LLMModelConfig.config_id).where(
|
||
LLMModelConfig.is_default == True,
|
||
LLMModelConfig.is_active == True,
|
||
)
|
||
)
|
||
if default_result.scalar_one_or_none():
|
||
return
|
||
|
||
candidate_id = None
|
||
if preferred_config_id:
|
||
candidate_result = await db.execute(
|
||
select(LLMModelConfig.config_id).where(
|
||
LLMModelConfig.config_id == preferred_config_id,
|
||
LLMModelConfig.is_active == True,
|
||
)
|
||
)
|
||
candidate_id = candidate_result.scalar_one_or_none()
|
||
|
||
if candidate_id is None:
|
||
fallback_result = await db.execute(
|
||
select(LLMModelConfig.config_id)
|
||
.where(LLMModelConfig.is_active == True)
|
||
.order_by(LLMModelConfig.updated_at.desc(), LLMModelConfig.config_id.desc())
|
||
.limit(1)
|
||
)
|
||
candidate_id = fallback_result.scalar_one_or_none()
|
||
|
||
if candidate_id is None:
|
||
return
|
||
|
||
await db.execute(update(LLMModelConfig).values(is_default=False))
|
||
await db.execute(
|
||
update(LLMModelConfig)
|
||
.where(LLMModelConfig.config_id == candidate_id)
|
||
.values(is_default=True)
|
||
)
|
||
|
||
|
||
@router.get("/providers", response_model=dict)
|
||
async def get_provider_catalog(
|
||
current_user: User = Depends(get_current_user),
|
||
):
|
||
"""获取模型提供方目录"""
|
||
return success_response(data=LLMProviderService.get_provider_catalog())
|
||
|
||
|
||
@router.get("/", response_model=dict)
|
||
async def get_llm_model_configs(
|
||
page: int = Query(1, ge=1),
|
||
page_size: int = Query(10, ge=1, le=100),
|
||
keyword: Optional[str] = Query(None, description="搜索关键词(模型名称、编码、模型名)"),
|
||
provider: Optional[str] = Query(None, description="提供方筛选"),
|
||
model_type: Optional[str] = Query(None, description="模型类型筛选: chat/embedding"),
|
||
is_active: Optional[bool] = Query(None, description="启用状态筛选"),
|
||
current_user: User = Depends(get_current_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""获取模型配置列表"""
|
||
|
||
conditions = []
|
||
if keyword:
|
||
conditions.append(
|
||
or_(
|
||
LLMModelConfig.model_name.like(f"%{keyword}%"),
|
||
LLMModelConfig.model_code.like(f"%{keyword}%"),
|
||
LLMModelConfig.llm_model_name.like(f"%{keyword}%"),
|
||
)
|
||
)
|
||
if provider:
|
||
conditions.append(LLMModelConfig.provider == provider)
|
||
if model_type:
|
||
conditions.append(LLMModelConfig.model_type == model_type)
|
||
if is_active is not None:
|
||
conditions.append(LLMModelConfig.is_active == is_active)
|
||
|
||
count_query = select(func.count(LLMModelConfig.config_id))
|
||
if conditions:
|
||
count_query = count_query.where(*conditions)
|
||
total_result = await db.execute(count_query)
|
||
total = total_result.scalar() or 0
|
||
|
||
query = select(LLMModelConfig).order_by(
|
||
LLMModelConfig.is_default.desc(),
|
||
LLMModelConfig.updated_at.desc(),
|
||
LLMModelConfig.config_id.desc(),
|
||
)
|
||
if conditions:
|
||
query = query.where(*conditions)
|
||
query = query.offset((page - 1) * page_size).limit(page_size)
|
||
|
||
result = await db.execute(query)
|
||
configs = result.scalars().all()
|
||
|
||
return {
|
||
"code": 200,
|
||
"message": "success",
|
||
"data": [serialize_model_config(item) for item in configs],
|
||
"total": total,
|
||
"page": page,
|
||
"page_size": page_size,
|
||
}
|
||
|
||
|
||
@router.get("/{config_id}", response_model=dict)
|
||
async def get_llm_model_config_detail(
|
||
config_id: int,
|
||
current_user: User = Depends(get_current_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""获取模型配置详情"""
|
||
result = await db.execute(
|
||
select(LLMModelConfig).where(LLMModelConfig.config_id == config_id)
|
||
)
|
||
config = result.scalar_one_or_none()
|
||
if not config:
|
||
raise HTTPException(status_code=404, detail="模型配置不存在")
|
||
|
||
return success_response(data=serialize_model_config(config, include_api_key=True))
|
||
|
||
|
||
@router.post("/", response_model=dict)
|
||
async def create_llm_model_config(
|
||
request_data: LLMModelConfigUpsertRequest,
|
||
current_user: User = Depends(get_current_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""创建模型配置"""
|
||
payload = normalize_payload(request_data)
|
||
|
||
existing_code_result = await db.execute(
|
||
select(LLMModelConfig).where(LLMModelConfig.model_code == payload["model_code"])
|
||
)
|
||
if existing_code_result.scalar_one_or_none():
|
||
raise HTTPException(status_code=400, detail="模型编码已存在")
|
||
|
||
new_config = LLMModelConfig(**payload)
|
||
db.add(new_config)
|
||
await db.flush()
|
||
|
||
if payload["is_default"]:
|
||
await db.execute(
|
||
update(LLMModelConfig)
|
||
.where(LLMModelConfig.config_id != new_config.config_id)
|
||
.values(is_default=False)
|
||
)
|
||
|
||
await ensure_default_config(db, preferred_config_id=new_config.config_id)
|
||
await db.commit()
|
||
await db.refresh(new_config)
|
||
|
||
return success_response(
|
||
data=serialize_model_config(new_config),
|
||
message="模型配置创建成功",
|
||
)
|
||
|
||
|
||
@router.put("/{config_id}", response_model=dict)
|
||
async def update_llm_model_config(
|
||
config_id: int,
|
||
request_data: LLMModelConfigUpsertRequest,
|
||
current_user: User = Depends(get_current_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""更新模型配置"""
|
||
result = await db.execute(
|
||
select(LLMModelConfig).where(LLMModelConfig.config_id == config_id)
|
||
)
|
||
config = result.scalar_one_or_none()
|
||
if not config:
|
||
raise HTTPException(status_code=404, detail="模型配置不存在")
|
||
|
||
payload = normalize_payload(request_data)
|
||
existing_code_result = await db.execute(
|
||
select(LLMModelConfig).where(
|
||
LLMModelConfig.model_code == payload["model_code"],
|
||
LLMModelConfig.config_id != config_id,
|
||
)
|
||
)
|
||
if existing_code_result.scalar_one_or_none():
|
||
raise HTTPException(status_code=400, detail="模型编码已被其他配置使用")
|
||
|
||
for key, value in payload.items():
|
||
setattr(config, key, value)
|
||
|
||
await db.flush()
|
||
|
||
if config.is_default:
|
||
await db.execute(
|
||
update(LLMModelConfig)
|
||
.where(LLMModelConfig.config_id != config.config_id)
|
||
.values(is_default=False)
|
||
)
|
||
|
||
await ensure_default_config(db, preferred_config_id=config.config_id)
|
||
await db.commit()
|
||
await db.refresh(config)
|
||
|
||
return success_response(
|
||
data=serialize_model_config(config),
|
||
message="模型配置更新成功",
|
||
)
|
||
|
||
|
||
@router.put("/{config_id}/status", response_model=dict)
|
||
async def update_llm_model_config_status(
|
||
config_id: int,
|
||
is_active: bool = Query(..., description="是否启用"),
|
||
current_user: User = Depends(get_current_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""更新模型配置启用状态"""
|
||
result = await db.execute(
|
||
select(LLMModelConfig).where(LLMModelConfig.config_id == config_id)
|
||
)
|
||
config = result.scalar_one_or_none()
|
||
if not config:
|
||
raise HTTPException(status_code=404, detail="模型配置不存在")
|
||
|
||
config.is_active = is_active
|
||
if not is_active and config.is_default:
|
||
config.is_default = False
|
||
|
||
await db.flush()
|
||
await ensure_default_config(db)
|
||
await db.commit()
|
||
await db.refresh(config)
|
||
|
||
return success_response(
|
||
data=serialize_model_config(config),
|
||
message="模型状态更新成功",
|
||
)
|
||
|
||
|
||
@router.put("/{config_id}/default", response_model=dict)
|
||
async def set_default_llm_model_config(
|
||
config_id: int,
|
||
current_user: User = Depends(get_current_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""设为默认模型配置"""
|
||
result = await db.execute(
|
||
select(LLMModelConfig).where(LLMModelConfig.config_id == config_id)
|
||
)
|
||
config = result.scalar_one_or_none()
|
||
if not config:
|
||
raise HTTPException(status_code=404, detail="模型配置不存在")
|
||
|
||
config.is_active = True
|
||
config.is_default = True
|
||
await db.flush()
|
||
await db.execute(
|
||
update(LLMModelConfig)
|
||
.where(LLMModelConfig.config_id != config.config_id)
|
||
.values(is_default=False)
|
||
)
|
||
await db.commit()
|
||
await db.refresh(config)
|
||
|
||
return success_response(
|
||
data=serialize_model_config(config),
|
||
message="默认模型切换成功",
|
||
)
|
||
|
||
|
||
@router.delete("/{config_id}", response_model=dict)
|
||
async def delete_llm_model_config(
|
||
config_id: int,
|
||
current_user: User = Depends(get_current_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""删除模型配置"""
|
||
result = await db.execute(
|
||
select(LLMModelConfig).where(LLMModelConfig.config_id == config_id)
|
||
)
|
||
config = result.scalar_one_or_none()
|
||
if not config:
|
||
raise HTTPException(status_code=404, detail="模型配置不存在")
|
||
|
||
was_default = bool(config.is_default)
|
||
await db.delete(config)
|
||
await db.flush()
|
||
|
||
if was_default:
|
||
await ensure_default_config(db)
|
||
|
||
await db.commit()
|
||
return success_response(message="模型配置删除成功")
|
||
|
||
|
||
@router.post("/test", response_model=dict)
|
||
async def test_llm_model_config(
|
||
request_data: LLMModelConfigTestRequest,
|
||
current_user: User = Depends(get_current_user),
|
||
):
|
||
"""测试模型连接(按类型分流:chat 走对话补全,embedding 走向量接口)"""
|
||
payload = normalize_payload(request_data)
|
||
try:
|
||
if payload.get("model_type") == "embedding":
|
||
test_result = await LLMProviderService.test_embedding_connection(payload)
|
||
else:
|
||
test_result = await LLMProviderService.test_model_connection(payload)
|
||
except ValueError as exc:
|
||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||
return success_response(data=test_result, message="模型测试成功")
|