PMFlow/backend/app/services/model/model_routing/service.py

223 lines
8.6 KiB
Python

"""函数级模型路由配置服务。"""
from __future__ import annotations
import json
from typing import Any
from sqlalchemy import and_, select
from sqlalchemy.ext.asyncio import AsyncSession
from ....models import Integration
from ..model_config import get_tier_defaults
from ....utils.timezone import now_cn_naive
ALLOWED_PROVIDERS = {"openai", "glm", "deepseek", "ollama"}
ALLOWED_TIERS = {"low", "high", "safety"}
DEFAULT_FUNCTIONS = ("signal_planner_analyze",)
INTEGRATION_TYPE = "model_routing"
class ModelRoutingService:
@staticmethod
def _safe_dict(value: Any) -> dict[str, Any]:
if isinstance(value, dict):
try:
# 深拷贝,避免浅拷贝后嵌套 dict 原地修改导致 SQLAlchemy JSON 变更丢失。
return json.loads(json.dumps(value))
except Exception:
return dict(value)
if isinstance(value, str):
try:
parsed = json.loads(value)
return parsed if isinstance(parsed, dict) else {}
except Exception:
return {}
return {}
@staticmethod
def _normalize_function_name(function_name: str) -> str:
fn = str(function_name or "").strip().lower()
if not fn:
raise ValueError("function_name 不能为空")
return fn
@staticmethod
def _normalize_provider(provider: str) -> str:
p = str(provider or "").strip().lower()
if p not in ALLOWED_PROVIDERS:
raise ValueError("provider 仅支持 openai/glm/deepseek/ollama")
return p
@staticmethod
def _normalize_tier(tier: str) -> str:
t = str(tier or "").strip().lower() or "high"
if t not in ALLOWED_TIERS:
raise ValueError("tier 仅支持 low/high/safety")
return t
@classmethod
def _rule_matches_function(cls, *, rule: dict[str, Any], function_name: str) -> bool:
if bool(rule.get("match_all", False)):
return True
fn = cls._normalize_function_name(function_name)
exact = str(rule.get("function_exact") or "").strip().lower()
if exact and exact == fn:
return True
prefix = str(rule.get("function_prefix") or "").strip().lower()
if prefix in {"*", "all"}:
return True
if prefix and fn.startswith(prefix):
return True
match_any = rule.get("match_any") if isinstance(rule.get("match_any"), list) else []
for item in match_any:
token = str(item or "").strip().lower()
if token and token in fn:
return True
return False
@classmethod
def _load_default_rule(cls, *, function_name: str) -> dict[str, Any] | None:
_ = function_name
return None
@classmethod
async def _get_or_init_bundle(cls, db: AsyncSession, *, tenant_id: int) -> tuple[Integration, dict[str, Any]]:
row = (
await db.execute(
select(Integration).where(
and_(
Integration.tenant_id == int(tenant_id),
Integration.type == INTEGRATION_TYPE,
)
)
)
).scalar_one_or_none()
if row is None:
row = Integration(
tenant_id=int(tenant_id),
type=INTEGRATION_TYPE,
is_active=True,
config={"mappings": {}},
)
db.add(row)
await db.flush()
bundle = cls._safe_dict(row.config)
if not isinstance(bundle.get("mappings"), dict):
bundle["mappings"] = {}
return row, bundle
@classmethod
def _effective_from_override(cls, *, function_name: str, override: dict[str, Any]) -> dict[str, Any]:
return {
"function_name": cls._normalize_function_name(function_name),
"provider": str(override.get("provider") or "").strip().lower(),
"model": str(override.get("model") or "").strip(),
"api_env": str(override.get("api_env") or "").strip(),
"tier": str(override.get("tier") or "high").strip().lower(),
"base_url": str(override.get("base_url") or "").strip(),
"source": "override",
"updated_at": str(override.get("updated_at") or ""),
"updated_by": override.get("updated_by"),
}
@classmethod
def _effective_from_rule(cls, *, function_name: str, rule: dict[str, Any] | None) -> dict[str, Any]:
if rule is None:
low_defaults = get_tier_defaults("low")
return {
"function_name": cls._normalize_function_name(function_name),
"provider": str(low_defaults.get("provider") or "glm").strip().lower(),
"model": str(low_defaults.get("model") or "qwen-plus").strip(),
"api_env": str(low_defaults.get("api_env") or "DASHSCOPE_API_KEY").strip(),
"tier": "high",
"base_url": str(low_defaults.get("base_url") or "https://dashscope.aliyuncs.com/compatible-mode/v1").strip(),
"source": "default",
"updated_at": "",
"updated_by": None,
}
return {
"function_name": cls._normalize_function_name(function_name),
"provider": str(rule.get("provider") or "").strip().lower(),
"model": str(rule.get("model") or "").strip(),
"api_env": str(rule.get("api_env") or "").strip(),
"tier": str(rule.get("tier") or "high").strip().lower(),
"base_url": str(rule.get("base_url") or "").strip(),
"source": "default",
"updated_at": "",
"updated_by": None,
}
@classmethod
async def get_effective_routing(
cls,
db: AsyncSession,
*,
tenant_id: int,
function_name: str,
) -> dict[str, Any]:
fn = cls._normalize_function_name(function_name)
_, bundle = await cls._get_or_init_bundle(db, tenant_id=int(tenant_id))
mappings = bundle.get("mappings") if isinstance(bundle.get("mappings"), dict) else {}
override = mappings.get(fn) if isinstance(mappings.get(fn), dict) else None
if override and bool(override.get("enabled", True)):
return cls._effective_from_override(function_name=fn, override=override)
return cls._effective_from_rule(function_name=fn, rule=cls._load_default_rule(function_name=fn))
@classmethod
async def list_effective_routing(
cls,
db: AsyncSession,
*,
tenant_id: int,
) -> list[dict[str, Any]]:
_, bundle = await cls._get_or_init_bundle(db, tenant_id=int(tenant_id))
mappings = bundle.get("mappings") if isinstance(bundle.get("mappings"), dict) else {}
keys = {cls._normalize_function_name(k) for k in mappings.keys() if str(k).strip()}
keys.update(DEFAULT_FUNCTIONS)
items: list[dict[str, Any]] = []
for fn in sorted(keys):
items.append(await cls.get_effective_routing(db, tenant_id=int(tenant_id), function_name=fn))
return items
@classmethod
async def upsert_override(
cls,
db: AsyncSession,
*,
tenant_id: int,
function_name: str,
provider: str,
model: str,
api_env: str,
tier: str,
updated_by: int,
base_url: str | None = None,
) -> dict[str, Any]:
fn = cls._normalize_function_name(function_name)
normalized_provider = cls._normalize_provider(provider)
normalized_tier = cls._normalize_tier(tier)
normalized_model = str(model or "").strip()
if not normalized_model:
raise ValueError("model 不能为空")
normalized_api_env = str(api_env or "").strip()
if normalized_provider != "ollama" and not normalized_api_env:
raise ValueError("api_env 不能为空")
row, bundle = await cls._get_or_init_bundle(db, tenant_id=int(tenant_id))
mappings = bundle.get("mappings") if isinstance(bundle.get("mappings"), dict) else {}
mappings[fn] = {
"provider": normalized_provider,
"model": normalized_model,
"api_env": normalized_api_env,
"tier": normalized_tier,
"base_url": str(base_url or "").strip(),
"enabled": True,
"updated_at": now_cn_naive().isoformat(),
"updated_by": int(updated_by),
}
bundle["mappings"] = mappings
row.config = bundle
await db.flush()
return cls._effective_from_override(function_name=fn, override=mappings[fn])