PMFlow/backend/app/services/model/model_config.py

330 lines
12 KiB
Python

from __future__ import annotations
import json
import os
from copy import deepcopy
from pathlib import Path
from typing import Any
DEFAULT_ROUTING_RULES_WORKSPACE_PATH = "backend/.control_plane/pmflow-routing-rules.json"
_WORKSPACE_ROOT = Path(__file__).resolve().parents[4]
_DEFAULT_ROUTING_RULES_PATH = (_WORKSPACE_ROOT / DEFAULT_ROUTING_RULES_WORKSPACE_PATH).resolve()
_PROVIDER_ALIASES = {
"deep-seek": "deepseek",
"local": "ollama",
}
_DEFAULT_PROVIDER_DEFAULTS: dict[str, dict[str, str]] = {
"deepseek": {
"name": "DeepSeek",
"base_url": "https://api.deepseek.com/v1",
"model": "deepseek-chat",
"api_env": "DEEPSEEK_API_KEY",
},
"dashscope": {
"name": "Qwen (DashScope)",
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
"model": "qwen-plus",
"api_env": "DASHSCOPE_API_KEY",
},
"openai": {
"name": "OpenAI",
"base_url": "https://api.edgefn.net/v1",
"model": "GLM-5",
"api_env": "OPENAI_API_KEY",
},
"zhipu": {
"name": "Zhipu BigModel",
"base_url": "https://open.bigmodel.cn/api/paas/v4",
"model": "glm-5.1",
"api_env": "ZHIPU_API_KEY",
},
"ollama": {
"name": "Ollama",
"base_url": "http://127.0.0.1:11434",
"model": "llama3:latest",
"api_env": "",
},
}
_DEFAULT_TIER_DEFAULTS: dict[str, dict[str, str]] = {
"low": {
"provider": "dashscope",
"model": "qwen-plus",
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
"api_env": "DASHSCOPE_API_KEY",
},
"high": {
"provider": "openai",
"model": "GLM-5",
"base_url": "https://api.edgefn.net/v1",
"api_env": "OPENAI_API_KEY",
},
}
VALID_TIER_ALIASES = tuple(_DEFAULT_TIER_DEFAULTS.keys())
_CONFIG_CACHE: dict[str, Any] = {"path": "", "mtime": -1.0, "bundle": None}
def _normalize_provider(provider: str) -> str:
value = str(provider or "").strip().lower()
value = _PROVIDER_ALIASES.get(value, value)
if value not in _DEFAULT_PROVIDER_DEFAULTS:
raise ValueError("provider 仅支持 deepseek/dashscope/openai/zhipu/ollama")
return value
def _normalize_tier(tier: str) -> str:
value = str(tier or "").strip().lower()
if value not in _DEFAULT_TIER_DEFAULTS:
raise ValueError("tier 仅支持 low/high")
return value
def resolve_model_config_path(path: str | Path | None = None) -> Path:
raw = path if path is not None else os.getenv("CONTROL_PLANE_ROUTING_RULES_PATH", "")
raw_text = str(raw or "").strip()
if not raw_text:
return _DEFAULT_ROUTING_RULES_PATH
candidate = Path(raw_text).expanduser()
if candidate.is_absolute():
return candidate
return (_WORKSPACE_ROOT / candidate).resolve()
def _normalize_provider_defaults(raw: Any) -> dict[str, dict[str, str]]:
payload = raw if isinstance(raw, dict) else {}
result: dict[str, dict[str, str]] = {}
for provider, defaults in _DEFAULT_PROVIDER_DEFAULTS.items():
item = payload.get(provider) if isinstance(payload.get(provider), dict) else {}
result[provider] = {
"name": str(item.get("name") or defaults["name"]).strip(),
"base_url": str(item.get("base_url") or defaults["base_url"]).strip().rstrip("/"),
"model": str(item.get("model") or defaults["model"]).strip(),
"api_env": str(item.get("api_env") or defaults["api_env"]).strip(),
}
return result
def _normalize_tier_defaults(raw: Any) -> dict[str, dict[str, str]]:
payload = raw if isinstance(raw, dict) else {}
extra_keys = sorted(
{
str(key or "").strip().lower()
for key in payload.keys()
if str(key or "").strip()
}
- set(VALID_TIER_ALIASES)
)
if extra_keys:
raise ValueError("tier 仅支持 low/high")
result: dict[str, dict[str, str]] = {}
for tier, defaults in _DEFAULT_TIER_DEFAULTS.items():
item = payload.get(tier) if isinstance(payload.get(tier), dict) else {}
provider_raw = str(item.get("provider") or defaults["provider"]).strip()
provider = _normalize_provider(provider_raw)
result[tier] = {
"provider": provider,
"model": str(item.get("model") or defaults["model"]).strip(),
"base_url": str(item.get("base_url") or defaults["base_url"]).strip().rstrip("/"),
"api_env": str(item.get("api_env") or defaults["api_env"]).strip(),
}
return result
def rule_matches_function(rule: dict[str, Any], function_name: str) -> bool:
if bool(rule.get("match_all", False)):
return True
fn = str(function_name or "").strip().lower()
if not fn:
return False
exact = str(rule.get("function_exact") or "").strip().lower()
if exact and fn == exact:
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(match_any, list):
for hint in match_any:
token = str(hint or "").strip().lower()
if token and token in fn:
return True
return False
def find_matching_rule(
function_name: str,
*,
tier_filter: set[str] | None = None,
bundle: dict[str, Any] | None = None,
path: str | Path | None = None,
) -> dict[str, Any] | None:
config_bundle = bundle if isinstance(bundle, dict) else load_model_config(path=path)
rules = config_bundle.get("rules") if isinstance(config_bundle.get("rules"), list) else []
indexed: list[tuple[int, int, dict[str, Any]]] = []
for idx, row in enumerate(rules):
if not isinstance(row, dict):
continue
if not bool(row.get("enabled", True)):
continue
tier_name = str(row.get("tier") or "").strip().lower()
if tier_name not in VALID_TIER_ALIASES:
continue
if tier_filter and tier_name not in tier_filter:
continue
if not rule_matches_function(row, function_name):
continue
indexed.append((int(row.get("priority") or 0), -idx, row))
if not indexed:
return None
indexed.sort(reverse=True)
return deepcopy(indexed[0][2])
def build_routing_config_snapshot(
*,
bundle: dict[str, Any] | None = None,
path: str | Path | None = None,
) -> dict[str, str]:
config_bundle = bundle if isinstance(bundle, dict) else load_model_config(path=path)
return {
"routing_config_version": str(config_bundle.get("version") or "").strip(),
"routing_config_updated_at": str(config_bundle.get("updated_at") or "").strip(),
"config_path": str(config_bundle.get("config_path") or "").strip(),
}
def _normalize_rules(raw: Any) -> list[dict[str, Any]]:
rows = raw if isinstance(raw, list) else []
result: list[dict[str, Any]] = []
for item in rows:
if not isinstance(item, dict):
continue
tier_name = str(item.get("tier") or "").strip().lower()
if tier_name and tier_name not in VALID_TIER_ALIASES:
raise ValueError("tier 仅支持 low/high")
result.append(deepcopy(item))
return result
def _normalize_bundle(raw: Any, *, config_path: Path) -> dict[str, Any]:
payload = raw if isinstance(raw, dict) else {}
bundle = {
"version": str(payload.get("version") or "v3").strip(),
"updated_at": str(payload.get("updated_at") or "").strip(),
"source_of_truth": str(payload.get("source_of_truth") or "control_plane").strip(),
"description": str(payload.get("description") or "").strip(),
"config_path": str(config_path),
"provider_defaults": _normalize_provider_defaults(payload.get("provider_defaults")),
"tier_defaults": _normalize_tier_defaults(payload.get("tier_defaults")),
"rules": _normalize_rules(payload.get("rules")),
}
return bundle
def _load_raw_payload(config_path: Path) -> dict[str, Any]:
if not config_path.exists():
return {}
try:
parsed = json.loads(config_path.read_text(encoding="utf-8"))
return parsed if isinstance(parsed, dict) else {}
except Exception:
return {}
def load_model_config(*, refresh: bool = False, path: str | Path | None = None) -> dict[str, Any]:
config_path = resolve_model_config_path(path)
mtime = config_path.stat().st_mtime if config_path.exists() else -1.0
cached = _CONFIG_CACHE
if (
not refresh
and cached.get("bundle") is not None
and cached.get("path") == str(config_path)
and cached.get("mtime") == mtime
):
return deepcopy(cached["bundle"])
raw = _load_raw_payload(config_path)
bundle = _normalize_bundle(raw, config_path=config_path)
_CONFIG_CACHE.update({"path": str(config_path), "mtime": mtime, "bundle": deepcopy(bundle)})
return deepcopy(bundle)
def get_provider_defaults(provider: str | None = None, *, refresh: bool = False) -> dict[str, Any]:
bundle = load_model_config(refresh=refresh)
defaults = bundle.get("provider_defaults") if isinstance(bundle.get("provider_defaults"), dict) else {}
if provider is None:
return deepcopy(defaults)
provider_name = _normalize_provider(provider)
fallback = _DEFAULT_PROVIDER_DEFAULTS[provider_name]
return deepcopy(defaults.get(provider_name, fallback))
def get_tier_defaults(tier: str | None = None, *, refresh: bool = False) -> dict[str, Any]:
bundle = load_model_config(refresh=refresh)
defaults = bundle.get("tier_defaults") if isinstance(bundle.get("tier_defaults"), dict) else {}
if tier is None:
return deepcopy(defaults)
tier_name = _normalize_tier(tier)
fallback = _DEFAULT_TIER_DEFAULTS[tier_name]
return deepcopy(defaults.get(tier_name, fallback))
def build_provider_runtime(
provider: str,
*,
api_key: str = "",
model: str | None = None,
base_url: str | None = None,
) -> dict[str, Any]:
provider_name = _normalize_provider(provider)
defaults = get_provider_defaults(provider_name)
return {
"id": f"env-{provider_name}",
"name": str(defaults.get("name") or _DEFAULT_PROVIDER_DEFAULTS[provider_name]["name"]).strip(),
"provider": provider_name,
"api_key": str(api_key or "").strip(),
"base_url": str(base_url or defaults.get("base_url") or "").strip().rstrip("/"),
"model": str(model or defaults.get("model") or "").strip(),
"api_env": str(defaults.get("api_env") or _DEFAULT_PROVIDER_DEFAULTS[provider_name]["api_env"]).strip(),
}
def build_tier_runtime(
tier: str,
*,
api_key: str = "",
) -> dict[str, Any]:
tier_name = _normalize_tier(tier)
tier_defaults = get_tier_defaults(tier_name)
provider_name = _normalize_provider(str(tier_defaults.get("provider") or "dashscope"))
provider_defaults = get_provider_defaults(provider_name)
return {
"id": f"tier-{tier_name}",
"name": str(provider_defaults.get("name") or "").strip(),
"provider": provider_name,
"api_key": str(api_key or "").strip(),
"base_url": str(tier_defaults.get("base_url") or provider_defaults.get("base_url") or "").strip().rstrip("/"),
"model": str(tier_defaults.get("model") or provider_defaults.get("model") or "").strip(),
"api_env": str(tier_defaults.get("api_env") or provider_defaults.get("api_env") or "").strip(),
"tier": tier_name,
}
__all__ = [
"VALID_TIER_ALIASES",
"build_provider_runtime",
"build_routing_config_snapshot",
"build_tier_runtime",
"find_matching_rule",
"get_provider_defaults",
"get_tier_defaults",
"load_model_config",
"rule_matches_function",
"resolve_model_config_path",
]