PMFlow/backend/app/services/llm_service/mixin_tier_engine_selection.py

266 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from __future__ import annotations
from .shared import *
class LLMTierEngineSelectionMixin:
@staticmethod
def _is_same_runtime_engine(a: dict[str, Any], b: dict[str, Any]) -> bool:
return (
str(a.get("provider") or "").strip().lower() == str(b.get("provider") or "").strip().lower()
and str(a.get("model") or "").strip().lower() == str(b.get("model") or "").strip().lower()
and str(a.get("base_url") or "").strip().rstrip("/").lower()
== str(b.get("base_url") or "").strip().rstrip("/").lower()
)
async def _resolve_high_tier_engine_for_escalation(
self,
db: AsyncSession,
*,
tenant_id: int,
primary_engine: dict[str, Any],
) -> Optional[dict[str, Any]]:
_, bundle = await self._get_or_init_bundle(db, tenant_id=tenant_id)
active_engine = self._resolve_active_engine_from_bundle(bundle)
engines: list[dict[str, Any]] = [e for e in bundle.get("engines", []) if isinstance(e, dict)]
routed = self._resolve_tier_engine(
tier="high",
engines=engines,
active_engine=active_engine,
)
if not routed:
return None
if self._is_same_runtime_engine(routed, primary_engine):
return None
provider = str(routed.get("provider") or "").strip().lower()
api_key = str(routed.get("api_key") or "").strip()
if provider != "ollama" and not api_key:
return None
return routed
async def _resolve_safety_tier_engine(
self,
db: AsyncSession,
*,
tenant_id: int,
function_name: str,
primary_engine: dict[str, Any],
) -> Optional[dict[str, Any]]:
_, bundle = await self._get_or_init_bundle(db, tenant_id=tenant_id)
active_engine = self._resolve_active_engine_from_bundle(bundle)
engines: list[dict[str, Any]] = [e for e in bundle.get("engines", []) if isinstance(e, dict)]
safety_rule = self._match_openclaw_rule(function_name, tier_filter={"safety"})
routed: Optional[dict[str, Any]] = None
if safety_rule is not None:
routed = self._resolve_engine_from_rule(
rule=safety_rule,
engines=engines,
active_engine=active_engine,
)
if routed is None:
routed = self._resolve_tier_engine(
tier="safety",
engines=engines,
active_engine=active_engine,
)
if not routed:
return None
routed = self._ensure_openclaw_control_plane_engine(routed)
if self._is_same_runtime_engine(routed, primary_engine):
return None
provider = str(routed.get("provider") or "").strip().lower()
api_key = str(routed.get("api_key") or "").strip()
if provider != "ollama" and not api_key:
return None
return routed
async def _try_safety_engine(
self,
db: AsyncSession,
*,
tenant_id: int,
function_name: str,
prompt: str,
system_prompt: str,
temperature: float,
max_tokens: int,
primary_engine: dict[str, Any],
reason: str,
) -> tuple[str, Optional[dict[str, Any]]]:
safety_engine = await self._resolve_safety_tier_engine(
db,
tenant_id=tenant_id,
function_name=function_name,
primary_engine=primary_engine,
)
if safety_engine is None:
_runtime_log(
"LLM路由",
f"Safety 引擎不可用,无法触发兜底:function={function_name}, reason={reason}",
)
return "", None
safety_provider = str(safety_engine.get("provider") or "")
safety_model = str(safety_engine.get("model") or "")
_runtime_log(
"LLM路由",
(
"触发 Safety 兜底:"
f"function={function_name}, provider={safety_provider}, model={safety_model}, reason={reason}"
),
)
try:
text = await self._chat_completion(
provider=safety_provider,
prompt=prompt,
system_prompt=system_prompt,
api_key=str(safety_engine.get("api_key") or ""),
base_url=str(safety_engine.get("base_url") or ""),
model=safety_model,
temperature=temperature,
max_tokens=max_tokens,
function_name=function_name,
)
if not text.strip():
raise ValueError("safety_engine_empty_response")
if self._is_invalid_structured_output(function_name=function_name, response_text=text):
raise ValueError("safety_engine_invalid_structured_output")
return text, safety_engine
except Exception as exc:
_runtime_log("LLM路由", f"Safety 兜底失败:function={function_name}, error={exc}")
return "", None
def _pick_tier_fallback_engine(
self,
*,
tier: str,
engines: list[dict[str, Any]],
active_engine: dict[str, Any],
) -> Optional[dict[str, Any]]:
for item in engines:
candidate = self._normalize_engine_runtime(item)
provider = str(candidate.get("provider") or "")
api_key = str(candidate.get("api_key") or "").strip()
if provider != "ollama" and not api_key:
continue
if tier == "high" and provider == "ollama":
continue
if str(candidate.get("id") or "") == str(active_engine.get("id") or ""):
continue
_runtime_log(
"LLM路由",
(
"分层路由回退引擎生效:"
f"tier={tier}, provider={provider}, model={candidate.get('model')}"
),
)
return candidate
return None
def _resolve_tier_engine(
self,
*,
tier: str,
engines: list[dict[str, Any]],
active_engine: dict[str, Any],
) -> Optional[dict[str, Any]]:
tier = str(tier or "").strip().lower()
upper = tier.upper()
default_provider_by_tier = {
"low": "glm",
"high": "openai",
"safety": "glm",
}
provider_env = str(
os.getenv(
f"LLM_ROUTING_{upper}_PROVIDER",
default_provider_by_tier.get(
tier,
os.getenv("LLM_PROVIDER", "glm"),
),
)
or ""
).strip()
engine_id = str(os.getenv(f"LLM_ROUTING_{upper}_ENGINE_ID", "") or "").strip()
model_override = str(os.getenv(f"LLM_ROUTING_{upper}_MODEL", "") or "").strip()
base_url_override = str(os.getenv(f"LLM_ROUTING_{upper}_BASE_URL", "") or "").strip().rstrip("/")
routed: Optional[dict[str, Any]] = None
if engine_id:
for item in engines:
if str(item.get("id") or "") == engine_id:
routed = self._normalize_engine_runtime(item)
break
if routed is None and provider_env:
try:
provider = _normalize_provider(provider_env)
except Exception:
provider = ""
if provider:
for item in engines:
if str(item.get("provider") or "").strip().lower() == provider:
routed = self._normalize_engine_runtime(item)
break
if routed is None:
try:
routed = self._env_provider_runtime(provider)
except Exception:
routed = None
if routed is None:
return None
provider = str(routed.get("provider") or "").strip().lower()
if model_override:
routed["model"] = model_override
elif self._openclaw_routing_enabled() and provider == "openai":
default_model = DEFAULT_TIER_MODEL_OVERRIDES.get(tier)
if default_model:
routed["model"] = default_model
if base_url_override:
routed["base_url"] = base_url_override
elif self._openclaw_routing_enabled() and provider == "openai":
configured_openai_base = str(os.getenv("OPENAI_BASE_URL", "") or "").strip().rstrip("/")
routed["base_url"] = configured_openai_base or "https://api.openai.com/v1"
if str(routed.get("id") or "") != str(active_engine.get("id") or ""):
_runtime_log(
"LLM路由",
(
"混合路由已命中:"
f"层级={tier}, provider={routed.get('provider')}, model={routed.get('model')}"
),
)
return self._ensure_openclaw_control_plane_engine(routed)
async def _resolve_engine_for_test(
self,
db: AsyncSession,
*,
tenant_id: int,
engine_id: Optional[str],
) -> dict[str, Any]:
_, bundle = await self._get_or_init_bundle(db, tenant_id=tenant_id)
settings = self._normalize_engine_settings(bundle.get("settings"))
if not engine_id:
engine = self._resolve_active_engine_from_bundle(bundle)
return self._ensure_engine_allowed_for_test(
engine=engine,
settings=settings,
engine_label="当前激活引擎",
)
engines: list[dict[str, Any]] = [e for e in bundle.get("engines", []) if isinstance(e, dict)]
for item in engines:
if str(item.get("id") or "") == str(engine_id):
engine = self._normalize_engine_runtime(item)
return self._ensure_engine_allowed_for_test(
engine=engine,
settings=settings,
engine_label="目标引擎",
)
raise ValueError("engine 不存在")