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

195 lines
7.2 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 LLMOpenclawRoutingMixin:
def _mixed_routing_enabled(self) -> bool:
return _env_bool("LLM_MIXED_ROUTING_ENABLED", False)
def _openclaw_routing_enabled(self) -> bool:
return _env_bool("OPENCLAW_ROUTING_ENABLED", False)
def _openclaw_control_plane_only(self) -> bool:
return _env_bool("OPENCLAW_CONTROL_PLANE_ONLY", False)
def _ensure_openclaw_control_plane_engine(self, engine: dict[str, Any]) -> dict[str, Any]:
runtime = dict(engine or {})
runtime["source_provider"] = str(runtime.get("source_provider") or runtime.get("provider") or "").strip().lower()
return runtime
def _openclaw_rules_path(self) -> str:
return str(resolve_model_config_path())
def _load_openclaw_routing_bundle(self) -> dict[str, Any]:
now = time.time()
if self._openclaw_routing_cache and self._openclaw_routing_cache[0] > now:
return dict(self._openclaw_routing_cache[1])
path = Path(self._openclaw_rules_path())
bundle: dict[str, Any] = load_model_config(path=path)
self._openclaw_routing_cache = (now + OPENCLAW_ROUTING_CACHE_TTL_SECONDS, dict(bundle))
return dict(bundle)
@staticmethod
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 == "*" or prefix == "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 _match_openclaw_rule(
self,
function_name: str,
*,
tier_filter: Optional[set[str]] = None,
) -> Optional[dict[str, Any]]:
if not self._openclaw_routing_enabled():
return None
bundle = self._load_openclaw_routing_bundle()
rules = bundle.get("rules") if isinstance(bundle.get("rules"), list) else []
if not rules:
return None
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
if tier_filter:
tier_name = str(row.get("tier") or "").strip().lower()
if tier_name not in tier_filter:
continue
if not self._rule_matches_function(row, function_name):
continue
priority = int(row.get("priority") or 0)
indexed.append((priority, -idx, row))
if not indexed:
return None
indexed.sort(reverse=True)
return dict(indexed[0][2])
def _resolve_openclaw_engine(
self,
*,
function_name: str,
engines: list[dict[str, Any]],
active_engine: dict[str, Any],
) -> Optional[dict[str, Any]]:
rule = self._match_openclaw_rule(function_name, tier_filter={"low", "high"})
if not rule:
return None
routed = self._resolve_engine_from_rule(
rule=rule,
engines=engines,
active_engine=active_engine,
)
if routed is None:
return None
_runtime_log(
"OpenClaw路由",
(
"规则命中:"
f"function={function_name}, provider={routed.get('provider')}, "
f"model={routed.get('model')}, rule={rule.get('name') or 'unnamed'}"
),
)
return routed
def _resolve_engine_from_rule(
self,
*,
rule: dict[str, Any],
engines: list[dict[str, Any]],
active_engine: dict[str, Any],
) -> Optional[dict[str, Any]]:
tier = str(rule.get("tier") or "").strip().lower()
provider_raw = str(rule.get("provider") or "").strip().lower()
engine_id = str(rule.get("engine_id") or "").strip()
model_override = str(rule.get("model") or "").strip()
base_url_override = str(rule.get("base_url") or "").strip().rstrip("/")
api_env_override = str(rule.get("api_env") or "").strip()
routed: Optional[dict[str, Any]] = None
if engine_id:
for item in engines:
if str(item.get("id") or "").strip() == engine_id:
routed = self._normalize_engine_runtime(item)
break
if routed is None and provider_raw:
try:
provider = _normalize_provider(provider_raw)
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 and tier in {"high", "low", "safety"}:
routed = self._resolve_tier_engine(
tier=tier,
engines=engines,
active_engine=active_engine,
)
if routed is None:
return None
if model_override:
routed["model"] = model_override
if base_url_override:
routed["base_url"] = base_url_override
if api_env_override:
env_api_key = str(os.getenv(api_env_override, "") or "").strip()
if env_api_key:
routed["api_key"] = env_api_key
routed["api_env"] = api_env_override
return routed
def _resolve_function_tier(self, function_name: str) -> str:
fn = str(function_name or "").strip().lower()
if not fn:
return str(os.getenv("LLM_ROUTING_DEFAULT_TIER", "active") or "active").strip().lower()
high_hints = _parse_csv_items(os.getenv("LLM_ROUTING_HIGH_FUNCTIONS", ""))
low_hints = _parse_csv_items(os.getenv("LLM_ROUTING_LOW_FUNCTIONS", ""))
high_pool = high_hints or DEFAULT_HIGH_TIER_HINTS
low_pool = low_hints or DEFAULT_LOW_TIER_HINTS
if any(hint and hint in fn for hint in high_pool):
return "high"
if any(hint and hint in fn for hint in low_pool):
return "low"
default_tier = str(os.getenv("LLM_ROUTING_DEFAULT_TIER", "active") or "active").strip().lower()
if default_tier != "active":
return default_tier
# mixed routing 开启时,未知函数默认走 high,避免被 active 引擎“全吞”。
return "high" if self._mixed_routing_enabled() else "active"