mirror of
https://github.com/LeoYeAI/openclaw-master-skills.git
synced 2026-07-27 22:15:43 +00:00
427 lines
13 KiB
Python
427 lines
13 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Model Router Core - Compact routing engine
|
|
Minimal, readable, extensible.
|
|
|
|
Design principles from model-router-premium:
|
|
- Keep decision logic small and deterministic
|
|
- Default to cheapest/fastest for simple tasks
|
|
- Escalate to stronger models when complex
|
|
|
|
New features retained:
|
|
- Privacy detection
|
|
- Local model auto-detection
|
|
- Context tracking (optional)
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import re
|
|
from dataclasses import dataclass, asdict
|
|
from pathlib import Path
|
|
from typing import Optional, Literal, Callable
|
|
|
|
|
|
@dataclass
|
|
class Model:
|
|
"""Model definition"""
|
|
id: str
|
|
name: str
|
|
provider: str
|
|
type: str # "local" or "cloud"
|
|
cost_score: float = 1.0 # lower = cheaper
|
|
power_score: float = 50.0 # higher = more capable
|
|
capabilities: list = None
|
|
requires_api_key: bool = False
|
|
api_key_env: str = None
|
|
|
|
def __post_init__(self):
|
|
if self.capabilities is None:
|
|
self.capabilities = ["chat", "general"]
|
|
|
|
|
|
@dataclass
|
|
class RouteResult:
|
|
"""Routing decision result"""
|
|
model_id: str
|
|
model_name: str
|
|
model_type: str
|
|
reason: str
|
|
confidence: float
|
|
complexity_score: int = 0
|
|
privacy_detected: list = None
|
|
context_id: str = None
|
|
is_switch: bool = False
|
|
previous_model: str = None
|
|
|
|
def __post_init__(self):
|
|
if self.privacy_detected is None:
|
|
self.privacy_detected = []
|
|
|
|
|
|
class RouterCore:
|
|
"""
|
|
Core routing engine - minimal and readable.
|
|
|
|
Inspired by model-router-premium:
|
|
- Simple scoring logic
|
|
- Cost-aware routing
|
|
- Capability matching
|
|
|
|
Enhanced with:
|
|
- Privacy detection
|
|
- Local model detection
|
|
"""
|
|
|
|
# Privacy patterns (sensitive data)
|
|
PRIVACY_PATTERNS = [
|
|
r"sk-[a-zA-Z0-9_-]{15,}", # Stripe, API keys
|
|
r"api[_-]?key\s*[:=]\s*['\"]?[a-zA-Z0-9_-]{10,}",
|
|
r"password\s*[:=]\s*['\"]?.{6,}",
|
|
r"secret\s*[:=]\s*['\"]?.{10,}",
|
|
r"bearer\s+[a-zA-Z0-9_-]{20,}",
|
|
r"\b[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[A-Z|a-z]{2,}\b", # email
|
|
]
|
|
|
|
# Complexity patterns (from model-router-premium + additions)
|
|
COMPLEX_PATTERNS = {
|
|
# Very high complexity (+10)
|
|
"microservices": 10, "architecture": 10, "scalable": 10,
|
|
"multi-step": 10, "comprehensive": 10, "end-to-end": 10,
|
|
# High complexity (+5)
|
|
"design": 5, "implement": 5, "optimize": 5,
|
|
"explain": 3, "analyze": 3, "compare": 3,
|
|
# Simple (-3)
|
|
"syntax": -3, "example": -3, "what is": -3,
|
|
}
|
|
|
|
def __init__(self, config_path: Optional[str] = None):
|
|
self.config_path = config_path or Path.home() / ".model-router" / "models.json"
|
|
self.config_dir = Path(self.config_path).parent
|
|
self.config_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
self.models = []
|
|
self.primary_id = None
|
|
self.secondary_id = None
|
|
|
|
# Optional modules (loaded on demand)
|
|
self._context = None
|
|
self._detector = None
|
|
|
|
self._load_config()
|
|
|
|
@property
|
|
def context(self):
|
|
"""Lazy load context module"""
|
|
if self._context is None:
|
|
try:
|
|
from modules.context import ContextManager
|
|
self._context = ContextManager(self.config_dir)
|
|
except ImportError:
|
|
self._context = False
|
|
return self._context
|
|
|
|
@property
|
|
def detector(self):
|
|
"""Lazy load detector module"""
|
|
if self._detector is None:
|
|
from modules.detector import ModelDetector
|
|
self._detector = ModelDetector()
|
|
return self._detector
|
|
|
|
def _load_config(self):
|
|
"""Load model configuration"""
|
|
if os.path.exists(self.config_path):
|
|
with open(self.config_path) as f:
|
|
config = json.load(f)
|
|
self.primary_id = config.get("primary_model", {}).get("id")
|
|
self.secondary_id = config.get("secondary_model", {}).get("id")
|
|
self.models = [self._dict_to_model(m) for m in config.get("models", [])]
|
|
else:
|
|
# Auto-detect local models if no config
|
|
self._auto_detect()
|
|
|
|
def _auto_detect(self):
|
|
"""Auto-detect available models"""
|
|
try:
|
|
from modules.detector import ModelDetector
|
|
detector = ModelDetector()
|
|
local_models = detector.detect_local()
|
|
cloud_models = detector.get_cloud_registry()
|
|
|
|
self.models = local_models + cloud_models
|
|
|
|
# Set defaults
|
|
if local_models:
|
|
self.primary_id = local_models[0].id
|
|
if cloud_models:
|
|
self.secondary_id = cloud_models[0].id
|
|
except ImportError:
|
|
# Fallback models (always available)
|
|
self.models = [
|
|
Model("ollama:llama3:8b", "Llama 3 8B", "Ollama", "local", 0, 35),
|
|
Model("anthropic:claude-haiku-4", "Claude Haiku 4", "Anthropic", "cloud", 3, 60, requires_api_key=True, api_key_env="ANTHROPIC_API_KEY"),
|
|
]
|
|
self.primary_id = self.models[0].id
|
|
self.secondary_id = self.models[1].id
|
|
|
|
# Ensure we always have primary and secondary set
|
|
if not self.primary_id and self.models:
|
|
self.primary_id = self.models[0].id
|
|
if not self.secondary_id and len(self.models) > 1:
|
|
self.secondary_id = self.models[1].id
|
|
elif not self.secondary_id and self.models:
|
|
self.secondary_id = self.models[0].id
|
|
|
|
def _dict_to_model(self, d: dict) -> Model:
|
|
"""Convert dict to Model"""
|
|
return Model(
|
|
id=d.get("id", ""),
|
|
name=d.get("name", ""),
|
|
provider=d.get("provider", ""),
|
|
type=d.get("type", "cloud"),
|
|
cost_score=d.get("cost_score", 1.0),
|
|
power_score=d.get("power_score", 50.0),
|
|
capabilities=d.get("capabilities", []),
|
|
requires_api_key=d.get("requires_api_key", False),
|
|
api_key_env=d.get("api_key_env"),
|
|
)
|
|
|
|
def get_model(self, model_id: str) -> Optional[Model]:
|
|
"""Get model by ID"""
|
|
for m in self.models:
|
|
if m.id == model_id:
|
|
return m
|
|
return None
|
|
|
|
def check_privacy(self, text: str) -> tuple[bool, list]:
|
|
"""Check for sensitive data"""
|
|
detected = []
|
|
for pattern in self.PRIVACY_PATTERNS:
|
|
if re.search(pattern, text, re.IGNORECASE):
|
|
detected.append(pattern)
|
|
return len(detected) > 0, detected
|
|
|
|
def score_complexity(self, text: str) -> int:
|
|
"""
|
|
Score task complexity.
|
|
Returns 0-50+ (higher = more complex)
|
|
|
|
Based on model-router-premium heuristics:
|
|
- Length: longer = more complex
|
|
- Keywords: complex words add points
|
|
"""
|
|
text_lower = text.lower()
|
|
score = 0
|
|
|
|
# Length scoring (from model-router-premium)
|
|
length = len(text)
|
|
if length > 200:
|
|
score += 3
|
|
elif length > 80:
|
|
score += 2
|
|
elif length > 40:
|
|
score += 1
|
|
|
|
# Keyword scoring (simplified)
|
|
for keyword, points in self.COMPLEX_PATTERNS.items():
|
|
if keyword in text_lower:
|
|
score += points
|
|
|
|
return max(0, score)
|
|
|
|
def route(
|
|
self,
|
|
task: str,
|
|
force: Optional[Literal["primary", "secondary"]] = None,
|
|
conversation_id: Optional[str] = None,
|
|
enable_context: bool = True
|
|
) -> RouteResult:
|
|
"""
|
|
Route a task to the appropriate model.
|
|
|
|
Args:
|
|
task: The user's task/request
|
|
force: Force primary or secondary model
|
|
conversation_id: Continue existing conversation
|
|
enable_context: Use context tracking
|
|
|
|
Returns:
|
|
RouteResult with selected model and reasoning
|
|
"""
|
|
# Privacy check - always routes to primary (usually local)
|
|
has_privacy, privacy_detected = self.check_privacy(task)
|
|
if has_privacy:
|
|
primary = self.get_model(self.primary_id)
|
|
if primary:
|
|
return RouteResult(
|
|
model_id=primary.id,
|
|
model_name=primary.name,
|
|
model_type="primary",
|
|
reason=f"privacy_detected:{len(privacy_detected)}_patterns",
|
|
confidence=1.0,
|
|
privacy_detected=privacy_detected,
|
|
)
|
|
|
|
# Forced model
|
|
if force == "primary":
|
|
m = self.get_model(self.primary_id)
|
|
return RouteResult(m.id, m.name, "primary", "forced", 1.0)
|
|
if force == "secondary":
|
|
m = self.get_model(self.secondary_id)
|
|
return RouteResult(m.id, m.name, "secondary", "forced", 1.0)
|
|
|
|
# Score complexity
|
|
complexity = self.score_complexity(task)
|
|
|
|
# Get context if available
|
|
context_data = None
|
|
previous_model = None
|
|
is_switch = False
|
|
|
|
if enable_context and self.context and conversation_id:
|
|
context_data = self.context.get_context(conversation_id)
|
|
if context_data:
|
|
previous_model = context_data.get("last_model")
|
|
|
|
# Decision: primary vs secondary
|
|
# Threshold at 5 (simpler than model-router-premium's 3)
|
|
threshold = 5
|
|
|
|
primary = self.get_model(self.primary_id)
|
|
secondary = self.get_model(self.secondary_id)
|
|
|
|
if not primary or not secondary:
|
|
# Fallback - use any available model
|
|
if self.models:
|
|
m = self.models[0]
|
|
return RouteResult(
|
|
m.id, m.name, m.type, "no_config", 0.5,
|
|
complexity_score=complexity
|
|
)
|
|
# Last resort - create default model
|
|
return RouteResult(
|
|
model_id="ollama:llama3:8b",
|
|
model_name="Llama 3 8B",
|
|
model_type="primary",
|
|
reason="auto_detected_fallback",
|
|
confidence=0.5,
|
|
complexity_score=complexity
|
|
)
|
|
|
|
if complexity < threshold:
|
|
selected = primary
|
|
selected_type = "primary"
|
|
reason = f"simple_task(score={complexity})"
|
|
else:
|
|
selected = secondary
|
|
selected_type = "secondary"
|
|
reason = f"complex_task(score={complexity})"
|
|
|
|
# Check switch
|
|
if previous_model and previous_model != selected.id:
|
|
is_switch = True
|
|
reason += f",switch_from:{previous_model}"
|
|
|
|
# Calculate confidence
|
|
if selected_type == "primary":
|
|
confidence = max(0.5, (threshold - complexity) / threshold)
|
|
else:
|
|
confidence = min(1.0, (complexity - threshold) / 20 + 0.5)
|
|
|
|
return RouteResult(
|
|
model_id=selected.id,
|
|
model_name=selected.name,
|
|
model_type=selected_type,
|
|
reason=reason,
|
|
confidence=confidence,
|
|
complexity_score=complexity,
|
|
context_id=conversation_id,
|
|
is_switch=is_switch,
|
|
previous_model=previous_model,
|
|
)
|
|
|
|
def record_message(
|
|
self,
|
|
conversation_id: str,
|
|
role: str,
|
|
content: str,
|
|
model_id: str,
|
|
model_type: str
|
|
):
|
|
"""Record a message in context (if enabled)"""
|
|
if self.context:
|
|
self.context.add_message(
|
|
conv_id=conversation_id,
|
|
role=role,
|
|
content=content,
|
|
model_used=model_id,
|
|
model_type=model_type,
|
|
)
|
|
|
|
def get_conversation(self, conversation_id: str):
|
|
"""Get conversation context"""
|
|
if self.context:
|
|
return self.context.get_context(conversation_id)
|
|
return None
|
|
|
|
def list_models(self) -> list:
|
|
"""List all available models"""
|
|
return [asdict(m) for m in self.models]
|
|
|
|
def get_status(self) -> dict:
|
|
"""Get router status"""
|
|
return {
|
|
"primary_id": self.primary_id,
|
|
"secondary_id": self.secondary_id,
|
|
"model_count": len(self.models),
|
|
"context_enabled": bool(self.context),
|
|
"config_path": str(self.config_path),
|
|
}
|
|
|
|
|
|
def main():
|
|
"""Simple CLI"""
|
|
import argparse
|
|
|
|
p = argparse.ArgumentParser(description="Model Router - Compact & Fast")
|
|
p.add_argument("task", nargs="*", help="Task to route")
|
|
p.add_argument("--json", action="store_true", help="JSON output")
|
|
p.add_argument("--force", choices=["primary", "secondary"])
|
|
p.add_argument("--list", action="store_true", help="List models")
|
|
p.add_argument("--status", action="store_true", help="Show status")
|
|
|
|
args = p.parse_args()
|
|
|
|
router = RouterCore()
|
|
|
|
if args.list:
|
|
for m in router.models:
|
|
print(f"{m.id:30} {m.name:20} [{m.type}]")
|
|
return
|
|
|
|
if args.status:
|
|
status = router.get_status()
|
|
for k, v in status.items():
|
|
print(f"{k}: {v}")
|
|
return
|
|
|
|
if not args.task:
|
|
p.print_help()
|
|
return
|
|
|
|
task = " ".join(args.task)
|
|
result = router.route(task, force=args.force)
|
|
|
|
if args.json:
|
|
print(json.dumps(asdict(result), indent=2))
|
|
else:
|
|
symbol = "1️⃣" if result.model_type == "primary" else "2️⃣"
|
|
print(f"{symbol} {result.model_name}")
|
|
print(f" Reason: {result.reason}")
|
|
print(f" Confidence: {result.confidence:.0%}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|