195 lines
7.2 KiB
Python
195 lines
7.2 KiB
Python
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"
|