Files
loom/backend/app/services/llm_service.py
T
2026-04-24 17:46:03 +08:00

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
],
}