410 lines
14 KiB
Python
410 lines
14 KiB
Python
"""LLM 服务:Provider / Model / RoutingRule CRUD、路由匹配、对话调用、用量记账。"""
|
|
from __future__ import annotations
|
|
|
|
import time
|
|
from typing import Any
|
|
|
|
from fastapi import status
|
|
from sqlalchemy import func, or_, select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.core.crypto import decrypt, encrypt, mask
|
|
from app.core.exceptions import AppError
|
|
from app.integrations.openai_client import ChatResult, chat_completion
|
|
from app.models import LLMModel, LLMProvider, LLMRoutingRule, LLMUsage, User
|
|
from app.schemas.llm import (
|
|
ChatResponse,
|
|
ModelCreate,
|
|
ModelRead,
|
|
ModelUpdate,
|
|
ProviderCreate,
|
|
ProviderRead,
|
|
ProviderUpdate,
|
|
RuleCreate,
|
|
RuleRead,
|
|
RuleUpdate,
|
|
)
|
|
|
|
|
|
# -------- Provider --------
|
|
|
|
async def list_providers(db: AsyncSession) -> list[ProviderRead]:
|
|
res = await db.execute(select(LLMProvider).order_by(LLMProvider.id))
|
|
return [provider_to_read(p) for p in res.scalars().all()]
|
|
|
|
|
|
def provider_to_read(p: LLMProvider) -> ProviderRead:
|
|
plain = decrypt(p.api_key_cipher) if p.api_key_cipher else None
|
|
return ProviderRead(
|
|
id=p.id,
|
|
code=p.code,
|
|
name=p.name,
|
|
vendor=p.vendor,
|
|
api_base=p.api_base,
|
|
api_key_mask=mask(plain) if plain else "",
|
|
has_api_key=bool(plain),
|
|
description=p.description,
|
|
status=p.status,
|
|
)
|
|
|
|
|
|
async def get_provider(db: AsyncSession, pid: int) -> LLMProvider:
|
|
p = await db.get(LLMProvider, pid)
|
|
if p is None:
|
|
raise AppError("Provider 不存在", code=404, http_status=status.HTTP_404_NOT_FOUND)
|
|
return p
|
|
|
|
|
|
async def create_provider(db: AsyncSession, data: ProviderCreate) -> LLMProvider:
|
|
exists = await db.execute(select(LLMProvider).where(LLMProvider.code == data.code))
|
|
if exists.scalar_one_or_none():
|
|
raise AppError("code 已存在", code=409, http_status=status.HTTP_409_CONFLICT)
|
|
p = LLMProvider(
|
|
code=data.code,
|
|
name=data.name,
|
|
vendor=data.vendor,
|
|
api_base=data.api_base,
|
|
api_key_cipher=encrypt(data.api_key) if data.api_key else None,
|
|
description=data.description,
|
|
status=data.status,
|
|
)
|
|
db.add(p)
|
|
await db.commit()
|
|
await db.refresh(p)
|
|
return p
|
|
|
|
|
|
async def update_provider(
|
|
db: AsyncSession, pid: int, data: ProviderUpdate
|
|
) -> LLMProvider:
|
|
p = await get_provider(db, pid)
|
|
changes = data.model_dump(exclude_unset=True)
|
|
if "api_key" in changes:
|
|
val = changes.pop("api_key")
|
|
if val is not None:
|
|
p.api_key_cipher = encrypt(val) if val else None
|
|
for k, v in changes.items():
|
|
setattr(p, k, v)
|
|
await db.commit()
|
|
await db.refresh(p)
|
|
return p
|
|
|
|
|
|
async def delete_provider(db: AsyncSession, pid: int) -> None:
|
|
p = await get_provider(db, pid)
|
|
# 若有模型使用,阻止删除
|
|
cnt = (await db.execute(
|
|
select(func.count()).select_from(LLMModel).where(LLMModel.provider_id == pid)
|
|
)).scalar() or 0
|
|
if cnt > 0:
|
|
raise AppError(
|
|
f"该 Provider 下还有 {cnt} 个模型,不允许删除",
|
|
code=409, http_status=status.HTTP_409_CONFLICT,
|
|
)
|
|
await db.delete(p)
|
|
await db.commit()
|
|
|
|
|
|
# -------- Model --------
|
|
|
|
async def list_models(db: AsyncSession) -> list[ModelRead]:
|
|
res = await db.execute(select(LLMModel).order_by(LLMModel.id))
|
|
out: list[ModelRead] = []
|
|
for m in res.scalars().all():
|
|
out.append(await model_to_read(db, m))
|
|
return out
|
|
|
|
|
|
async def model_to_read(db: AsyncSession, m: LLMModel) -> ModelRead:
|
|
prov = await db.get(LLMProvider, m.provider_id)
|
|
return ModelRead(
|
|
id=m.id,
|
|
provider_id=m.provider_id,
|
|
provider_code=prov.code if prov else None,
|
|
provider_name=prov.name if prov else None,
|
|
model=m.model,
|
|
name=m.name,
|
|
description=m.description,
|
|
max_context=m.max_context,
|
|
price_input_per_1k=float(m.price_input_per_1k),
|
|
price_output_per_1k=float(m.price_output_per_1k),
|
|
status=m.status,
|
|
)
|
|
|
|
|
|
async def create_model(db: AsyncSession, data: ModelCreate) -> LLMModel:
|
|
await get_provider(db, data.provider_id)
|
|
m = LLMModel(**data.model_dump())
|
|
db.add(m)
|
|
await db.commit()
|
|
await db.refresh(m)
|
|
return m
|
|
|
|
|
|
async def update_model(db: AsyncSession, mid: int, data: ModelUpdate) -> LLMModel:
|
|
m = await db.get(LLMModel, mid)
|
|
if m is None:
|
|
raise AppError("Model 不存在", code=404, http_status=status.HTTP_404_NOT_FOUND)
|
|
for k, v in data.model_dump(exclude_unset=True).items():
|
|
setattr(m, k, v)
|
|
await db.commit()
|
|
await db.refresh(m)
|
|
return m
|
|
|
|
|
|
async def delete_model(db: AsyncSession, mid: int) -> None:
|
|
m = await db.get(LLMModel, mid)
|
|
if m is None:
|
|
raise AppError("Model 不存在", code=404, http_status=status.HTTP_404_NOT_FOUND)
|
|
cnt = (await db.execute(
|
|
select(func.count()).select_from(LLMRoutingRule).where(LLMRoutingRule.model_id == mid)
|
|
)).scalar() or 0
|
|
if cnt > 0:
|
|
raise AppError(
|
|
f"该模型被 {cnt} 条路由规则引用", code=409,
|
|
http_status=status.HTTP_409_CONFLICT,
|
|
)
|
|
await db.delete(m)
|
|
await db.commit()
|
|
|
|
|
|
# -------- Routing --------
|
|
|
|
async def list_rules(db: AsyncSession) -> list[RuleRead]:
|
|
res = await db.execute(select(LLMRoutingRule).order_by(LLMRoutingRule.priority, LLMRoutingRule.id))
|
|
out: list[RuleRead] = []
|
|
for r in res.scalars().all():
|
|
model = r.model_obj
|
|
prov = model.provider if model else None
|
|
out.append(
|
|
RuleRead(
|
|
id=r.id, name=r.name, priority=r.priority, conditions=r.conditions,
|
|
model_id=r.model_id,
|
|
model_label=f"{prov.code}/{model.model}" if prov and model else None,
|
|
enabled=r.enabled, description=r.description,
|
|
)
|
|
)
|
|
return out
|
|
|
|
|
|
async def create_rule(db: AsyncSession, data: RuleCreate) -> LLMRoutingRule:
|
|
if (await db.get(LLMModel, data.model_id)) is None:
|
|
raise AppError("model 不存在", code=400, http_status=status.HTTP_400_BAD_REQUEST)
|
|
r = LLMRoutingRule(**data.model_dump())
|
|
db.add(r)
|
|
await db.commit()
|
|
await db.refresh(r)
|
|
return r
|
|
|
|
|
|
async def update_rule(db: AsyncSession, rid: int, data: RuleUpdate) -> LLMRoutingRule:
|
|
r = await db.get(LLMRoutingRule, rid)
|
|
if r is None:
|
|
raise AppError("规则不存在", code=404, http_status=status.HTTP_404_NOT_FOUND)
|
|
for k, v in data.model_dump(exclude_unset=True).items():
|
|
setattr(r, k, v)
|
|
await db.commit()
|
|
await db.refresh(r)
|
|
return r
|
|
|
|
|
|
async def delete_rule(db: AsyncSession, rid: int) -> None:
|
|
r = await db.get(LLMRoutingRule, rid)
|
|
if r is None:
|
|
raise AppError("规则不存在", code=404, http_status=status.HTTP_404_NOT_FOUND)
|
|
await db.delete(r)
|
|
await db.commit()
|
|
|
|
|
|
# -------- Routing 匹配 --------
|
|
|
|
def _match(rule_conditions: dict | None, conditions: dict) -> bool:
|
|
if not rule_conditions:
|
|
return True # 默认规则永远命中
|
|
for k, v in rule_conditions.items():
|
|
if conditions.get(k) != v:
|
|
return False
|
|
return True
|
|
|
|
|
|
async def route(
|
|
db: AsyncSession, conditions: dict | None = None
|
|
) -> tuple[LLMProvider, LLMModel]:
|
|
"""按优先级匹配路由;没命中时抛 400。"""
|
|
stmt = (
|
|
select(LLMRoutingRule)
|
|
.where(LLMRoutingRule.enabled.is_(True))
|
|
.order_by(LLMRoutingRule.priority, LLMRoutingRule.id)
|
|
)
|
|
rules = list((await db.execute(stmt)).scalars().all())
|
|
if not rules:
|
|
raise AppError("没有可用的 LLM 路由规则", code=400, http_status=status.HTTP_400_BAD_REQUEST)
|
|
|
|
cond = conditions or {}
|
|
picked = next((r for r in rules if _match(r.conditions, cond)), None)
|
|
if picked is None:
|
|
raise AppError(
|
|
f"没有匹配条件 {cond} 的路由规则", code=400,
|
|
http_status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
model = picked.model_obj
|
|
prov = model.provider
|
|
if model.status != "ACTIVE" or prov.status != "ACTIVE":
|
|
raise AppError(
|
|
"路由命中的 Provider/Model 已禁用", code=400,
|
|
http_status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
return prov, model
|
|
|
|
|
|
async def resolve_by_label(db: AsyncSession, label: str) -> tuple[LLMProvider, LLMModel]:
|
|
"""解析 `provider_code/model_name`。"""
|
|
if "/" not in label:
|
|
raise AppError("model 必须形如 provider_code/model_name",
|
|
code=400, http_status=status.HTTP_400_BAD_REQUEST)
|
|
code, name = label.split("/", 1)
|
|
prov = (await db.execute(
|
|
select(LLMProvider).where(LLMProvider.code == code)
|
|
)).scalar_one_or_none()
|
|
if prov is None:
|
|
raise AppError(f"provider {code} 不存在",
|
|
code=400, http_status=status.HTTP_400_BAD_REQUEST)
|
|
m = (await db.execute(
|
|
select(LLMModel).where(LLMModel.provider_id == prov.id, LLMModel.model == name)
|
|
)).scalar_one_or_none()
|
|
if m is None:
|
|
raise AppError(f"model {name} 不存在于 {code}",
|
|
code=400, http_status=status.HTTP_400_BAD_REQUEST)
|
|
return prov, m
|
|
|
|
|
|
# -------- Chat 调用 --------
|
|
|
|
def _compute_cost(model: LLMModel, prompt_t: int, completion_t: int) -> float:
|
|
return round(
|
|
float(model.price_input_per_1k) * prompt_t / 1000
|
|
+ float(model.price_output_per_1k) * completion_t / 1000,
|
|
6,
|
|
)
|
|
|
|
|
|
async def call_chat(
|
|
db: AsyncSession,
|
|
*,
|
|
operator: User,
|
|
messages: list[dict[str, Any]],
|
|
model_label: str | None = None,
|
|
conditions: dict | None = None,
|
|
temperature: float | None = None,
|
|
max_tokens: int | None = None,
|
|
source: str = "playground",
|
|
task_id: int | None = None,
|
|
skill_id: int | None = None,
|
|
) -> ChatResponse:
|
|
if model_label:
|
|
prov, model = await resolve_by_label(db, model_label)
|
|
else:
|
|
prov, model = await route(db, conditions)
|
|
|
|
start = time.perf_counter()
|
|
api_key = decrypt(prov.api_key_cipher) if prov.api_key_cipher else None
|
|
|
|
try:
|
|
result: ChatResult = await chat_completion(
|
|
api_base=prov.api_base,
|
|
api_key=api_key,
|
|
model=model.model,
|
|
messages=messages,
|
|
temperature=temperature,
|
|
max_tokens=max_tokens,
|
|
)
|
|
cost_ms = int((time.perf_counter() - start) * 1000)
|
|
cost_usd = _compute_cost(model, result.prompt_tokens, result.completion_tokens)
|
|
db.add(LLMUsage(
|
|
provider_id=prov.id, provider_code=prov.code,
|
|
model_id=model.id, model=model.model,
|
|
operator_id=operator.id, operator_name=operator.username,
|
|
source=source, task_id=task_id, skill_id=skill_id,
|
|
prompt_tokens=result.prompt_tokens,
|
|
completion_tokens=result.completion_tokens,
|
|
total_tokens=result.total_tokens,
|
|
cost_usd=cost_usd, result="ok", cost_ms=cost_ms,
|
|
))
|
|
await db.commit()
|
|
return ChatResponse(
|
|
provider=prov.code, model=model.model, content=result.content,
|
|
prompt_tokens=result.prompt_tokens,
|
|
completion_tokens=result.completion_tokens,
|
|
total_tokens=result.total_tokens,
|
|
cost_usd=cost_usd, cost_ms=cost_ms,
|
|
)
|
|
except Exception as e: # noqa: BLE001
|
|
cost_ms = int((time.perf_counter() - start) * 1000)
|
|
db.add(LLMUsage(
|
|
provider_id=prov.id, provider_code=prov.code,
|
|
model_id=model.id, model=model.model,
|
|
operator_id=operator.id, operator_name=operator.username,
|
|
source=source, task_id=task_id, skill_id=skill_id,
|
|
result="failed", message=f"{e.__class__.__name__}: {e}",
|
|
cost_ms=cost_ms,
|
|
))
|
|
await db.commit()
|
|
raise AppError(
|
|
f"LLM 调用失败:{e}", code=502,
|
|
http_status=status.HTTP_502_BAD_GATEWAY,
|
|
) from e
|
|
|
|
|
|
# -------- Usage 查询 --------
|
|
|
|
async def paginate_usage(
|
|
db: AsyncSession,
|
|
page: int,
|
|
size: int,
|
|
provider_id: int | None = None,
|
|
source: str | None = None,
|
|
) -> tuple[list[LLMUsage], int]:
|
|
stmt = select(LLMUsage)
|
|
if provider_id:
|
|
stmt = stmt.where(LLMUsage.provider_id == provider_id)
|
|
if source:
|
|
stmt = stmt.where(LLMUsage.source == source)
|
|
total = (await db.execute(
|
|
select(func.count()).select_from(stmt.order_by(None).subquery())
|
|
)).scalar() or 0
|
|
stmt = stmt.order_by(LLMUsage.id.desc()).offset((page - 1) * size).limit(size)
|
|
rows = list((await db.execute(stmt)).scalars().all())
|
|
return rows, total
|
|
|
|
|
|
async def usage_summary(db: AsyncSession) -> dict:
|
|
by_model = (await db.execute(
|
|
select(
|
|
LLMUsage.provider_code,
|
|
LLMUsage.model,
|
|
func.count(LLMUsage.id),
|
|
func.sum(LLMUsage.prompt_tokens),
|
|
func.sum(LLMUsage.completion_tokens),
|
|
func.sum(LLMUsage.cost_usd),
|
|
)
|
|
.group_by(LLMUsage.provider_code, LLMUsage.model)
|
|
.order_by(func.sum(LLMUsage.total_tokens).desc())
|
|
)).all()
|
|
total_cost = (await db.execute(select(func.sum(LLMUsage.cost_usd)))).scalar() or 0
|
|
total_calls = (await db.execute(select(func.count(LLMUsage.id)))).scalar() or 0
|
|
return {
|
|
"total_calls": int(total_calls),
|
|
"total_cost_usd": round(float(total_cost), 6),
|
|
"by_model": [
|
|
{
|
|
"provider_code": pc,
|
|
"model": m,
|
|
"calls": int(c),
|
|
"prompt_tokens": int(pt or 0),
|
|
"completion_tokens": int(ct or 0),
|
|
"cost_usd": round(float(cu or 0), 6),
|
|
}
|
|
for pc, m, c, pt, ct, cu in by_model
|
|
],
|
|
}
|