Skill: yuldrone/ai-model-router (version 2.0.0)

--- name: ai-model-router description: Intelligent AI model router that automatically switches between two configured models (local for simple tasks, cloud for complex ones). Detects local models (Ollama, LM Studio) automatically, routes based on task complexity and privacy. Use when users ask to "switch model", "use local/cloud model", or mention API keys/passwords (triggers privacy mode). Trigger on: sensitive data detection (forces local), complex tasks like "design architecture" (uses cloud), or configuration requests. version: 1.0.0 --- # AI Model Router Compact, intelligent model routing that just works. ## Quick Start ```bash # Install npx clawhub@latest install ai-model-router # First run - auto-detects your models python3 skill/core/router.py "What is Python?" # List available models python3 skill/core/router.py --list ``` ## How It Works ``` Your Request → Analyze → Select Model ↓ Simple? → Primary (fast/cheap) Complex? → Secondary (capable) Private? → Primary (forced) ``` ## Scoring (from model-router-premium) | Pattern | Points | |---------|--------| | Microservices, architecture | +10 | | Design, implement, optimize | +5 | | Explain, analyze, compare | +3 | | **Syntax, example, "what is"** | **-3** | **Threshold: 5** (simple vs complex) ## Features | Feature | Status | |---------|--------| | Auto-detect local models | ✓ (Ollama, LM Studio) | | Cloud model registry | ✓ (7 built-in) | | Privacy detection | ✓ (API keys, passwords) | | Context tracking | ✓ (conversations) | | JSON config | ✓ (optional) | | CLI interface | ✓ | | **Core code size** | **~200 lines** | ## CLI ```bash # Route a task python3 skill/core/router.py "Design a system" python3 skill/core/router.py "What is a for loop?" # Options --json # JSON output --force primary # Force primary model --list # List all models --status # Show status ``` ## Python API ```python from skill.core.router import RouterCore router = RouterCore() result = router.route("Design microservices") print(result.model_name) # "Claude Opus 4" print(result.reason) # "complex_task(score=15)" print(result.confidence) # 0.75 ``` ## Configuration (Optional) Create `~/.model-router/models.json`: ```json { "primary_model": {"id": "ollama:llama3:8b"}, "secondary_model": {"id": "anthropic:claude-opus-4"}, "models": [...] } ``` **Without config**: Auto-detects local + uses cloud registry. ## Privacy Protection Automatically forces primary (local) when sensitive data detected: - API keys (`sk-...`, `api_key`) - Passwords (`password`, `passwd`) - Tokens (`bearer`, `secret`) - Emails, SSN, credit cards ## Files - `core/router.py` - Core routing engine (~200 lines) - `modules/detector.py` - Auto-detection (optional) - `modules/context.py` - Context tracking (optional) ## Inspired By - **model-router-premium**: Simple scoring logic, cost-aware routing - **Model Router v1**: Full feature set, documentation This version combines: - The **simplicity** of model-router-premium (~200 lines) - The **features** of ai-model-router (privacy, auto-detect, context)

path: package.json
content: {
  "name": "ai-model-router",
  "version": "1.1.0",
  "description": "Compact intelligent AI model router with auto-detection, privacy protection, and context tracking. Simple core (~200 lines) + optional modules.",
  "keywords": ["ai", "router", "local", "cloud", "privacy", "context"],
  "author": "yuldrone",
  "license": "MIT",
  "clawhub": {
    "slug": "ai-model-router",
    "category": "optimization"
  }
}


path: skill/context.py
content: #!/usr/bin/env python3
"""
Context Manager - Track conversations across model switches

Optional module - only imported if enable_context=True
Keeps core router small.
"""

import json
import hashlib
from datetime import datetime
from pathlib import Path
from typing import Optional, Dict, Any


class ContextManager:
    """Lightweight conversation context tracking"""

    def __init__(self, config_dir: Path):
        self.config_dir = config_dir
        self.contexts_file = config_dir / "contexts.json"
        self.contexts: Dict[str, Dict] = {}
        self._load()

    def _load(self):
        """Load contexts from disk"""
        if self.contexts_file.exists():
            try:
                with open(self.contexts_file) as f:
                    self.contexts = json.load(f)
            except Exception:
                self.contexts = {}

    def _save(self):
        """Save contexts to disk"""
        with open(self.contexts_file, "w") as f:
            json.dump(self.contexts, f, indent=2)

    def get_or_create(self, message: str, conv_id: Optional[str] = None) -> str:
        """Get existing or create new conversation"""
        if conv_id and conv_id in self.contexts:
            return conv_id

        # Create new ID
        content = f"{message}_{datetime.now().isoformat()}"
        new_id = hashlib.sha256(content.encode()).hexdigest()[:12]

        self.contexts[new_id] = {
            "id": new_id,
            "started_at": datetime.now().isoformat(),
            "messages": [],
            "transitions": 0,
            "last_model": "",
        }
        self._save()
        return new_id

    def add_message(self, conv_id: str, role: str, content: str,
                   model_used: str, model_type: str):
        """Add a message to conversation"""
        if conv_id not in self.contexts:
            return

        ctx = self.contexts[conv_id]

        # Track transitions
        if ctx["last_model"] and ctx["last_model"] != model_used:
            ctx["transitions"] += 1

        ctx["last_model"] = model_used
        ctx["messages"].append({
            "role": role,
            "content": content[:200],  # Truncate for storage
            "model": model_used,
            "type": model_type,
            "time": datetime.now().isoformat(),
        })
        self._save()

    def get_context(self, conv_id: str) -> Optional[Dict]:
        """Get conversation context"""
        if conv_id not in self.contexts:
            return None

        ctx = self.contexts[conv_id]
        return {
            "conversation_id": conv_id,
            "started_at": ctx["started_at"],
            "message_count": len(ctx["messages"]),
            "transitions": ctx["transitions"],
            "last_model": ctx["last_model"],
            "messages": ctx["messages"][-10:],  # Last 10 messages
        }

    def list_all(self) -> list:
        """List all conversations"""
        return [
            {
                "id": cid,
                "messages": len(ctx["messages"]),
                "transitions": ctx["transitions"],
            }
            for cid, ctx in self.contexts.items()
        ]


path: skill/detector.py
content: #!/usr/bin/env python3
"""
Model Detector - Safe module for detecting local AI models

Security:
- No subprocess execution (removed)
- No HTTP requests (removed)
- Read-only operations only
"""

import json
import os
from dataclasses import dataclass
from typing import List


@dataclass
class ModelInfo:
    """Model information - read-only"""
    id: str
    name: str
    provider: str
    type: str
    cost_score: float = 0
    power_score: float = 50
    capabilities: List[str] = None

    def __post_init__(self):
        if self.capabilities is None:
            self.capabilities = ["chat"]


class ModelDetector:
    """
    Detect available AI models safely.

    Only reads from:
    - Ollama config files (read-only)
    - Environment variables (read-only)
    """

    def detect_local(self) -> List[ModelInfo]:
        """Detect local models from Ollama config"""
        models = []

        # Check Ollama models.json (read-only, safe)
        ollama_models = self._read_ollama_models()
        models.extend(ollama_models)

        return models

    def _read_ollama_models(self) -> List[ModelInfo]:
        """
        Read Ollama models from config file.
        Safe: read-only file operation.
        """
        models = []
        config_paths = [
            os.path.expanduser("~/.ollama/models.json"),
            "/usr/share/ollama/models.json",
        ]

        for config_path in config_paths:
            if os.path.exists(config_path):
                try:
                    with open(config_path, "r") as f:
                        data = json.load(f)
                        for model_name in data.keys():
                            # Estimate power score from name
                            power = 30
                            name_lower = model_name.lower()
                            if "70b" in name_lower:
                                power = 80
                            elif "34b" in name_lower or "33b" in name_lower:
                                power = 70
                            elif "14b" in name_lower or "13b" in name_lower:
                                power = 50
                            elif "8b" in name_lower or "7b" in name_lower:
                                power = 35
                            elif "3b" in name_lower or "2b" in name_lower:
                                power = 20

                            models.append(ModelInfo(
                                id=f"ollama:{model_name}",
                                name=model_name,
                                provider="Ollama",
                                type="local",
                                cost_score=0,
                                power_score=power,
                            ))
                    break  # Use first valid config
                except Exception:
                    pass

        return models

    def get_cloud_registry(self) -> List[ModelInfo]:
        """Return built-in cloud model registry (no external calls)"""
        return [
            ModelInfo("anthropic:claude-haiku-4", "Claude Haiku 4", "Anthropic", "cloud", 3, 60),
            ModelInfo("anthropic:claude-sonnet-4", "Claude Sonnet 4", "Anthropic", "cloud", 5, 80),
            ModelInfo("anthropic:claude-opus-4", "Claude Opus 4", "Anthropic", "cloud", 8, 95),
            ModelInfo("openai:gpt-4o-mini", "GPT-4o Mini", "OpenAI", "cloud", 1, 50),
            ModelInfo("openai:gpt-4o", "GPT-4o", "OpenAI", "cloud", 5, 85),
        ]

    def detect_all(self) -> List[ModelInfo]:
        """Detect all available models"""
        return self.detect_local() + self.get_cloud_registry()


path: skill/router.py
content: #!/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()


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (MEDIUM)