Parcourir la source

Initial commit

Lukas Goldschmidt il y a 2 mois
commit
06f243309e

+ 0 - 0
.codex


+ 4 - 0
.gitignore

@@ -0,0 +1,4 @@
+.env
+.env.*
+data/
+

+ 25 - 0
AGENTS.md

@@ -0,0 +1,25 @@
+# AGENTS.md — Model Selector
+
+## Purpose
+This repo hosts a FastAPI dashboard for browsing and editing the model/provider configuration in `~/.openclaw/openclaw.json`.
+
+## Non-negotiables
+- Treat `/home/lucky/.openclaw/openclaw.json` as the canonical source.
+- Never edit the live file in tests. Copy it to a temp path and point the app there.
+- Preserve unrelated config sections exactly in behavior and key structure.
+- Keep alias edits in `agents.defaults.models.<provider/model>.alias`.
+- Run the app on port `3300`.
+
+## Working rules
+- Read the current JSON shape before changing schema-sensitive code.
+- Use atomic writes for config saves.
+- Keep provider refresh snapshots local as JSON cache files.
+- Prefer explicit provider/model adapters over implicit magic.
+- If a capability is not present in provider metadata, show it as missing instead of inventing it.
+- Use `./run.sh` to start the app, `./killserver.sh` to clear stale listeners on port `3300`, and `./restart.sh` to do both in sequence.
+
+## Tests
+- Use a copied config fixture for all persistence tests.
+- Verify that saving aliases does not change the original source file.
+- Verify provider cache snapshots are written to local JSON files.
+- Verify the dashboard renders both the configured model panel and the provider tabs.

+ 45 - 0
killserver.sh

@@ -0,0 +1,45 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+PORT="${MODEL_SELECTOR_PORT:-3300}"
+
+get_pids_with_lsof() {
+  if command -v lsof >/dev/null 2>&1; then
+    lsof -ti "tcp:${PORT}" 2>/dev/null || true
+  fi
+}
+
+get_pids_with_fuser() {
+  if command -v fuser >/dev/null 2>&1; then
+    fuser "${PORT}/tcp" 2>/dev/null | tr ' ' '\n' | awk 'NF { print }' || true
+  fi
+}
+
+pids="$(
+  get_pids_with_lsof
+  get_pids_with_fuser
+)"
+
+if [[ -z "${pids}" ]]; then
+  exit 0
+fi
+
+printf '%s\n' "${pids}" | sort -u | while read -r pid; do
+  [[ -z "${pid}" ]] && continue
+  kill "${pid}" 2>/dev/null || true
+done
+
+sleep 1
+
+remaining="$(
+  get_pids_with_lsof
+  get_pids_with_fuser
+)"
+
+if [[ -n "${remaining}" ]]; then
+  printf '%s\n' "${remaining}" | sort -u | while read -r pid; do
+    [[ -z "${pid}" ]] && continue
+    kill -9 "${pid}" 2>/dev/null || true
+  done
+fi
+

+ 2 - 0
model_selector/__init__.py

@@ -0,0 +1,2 @@
+"""Model selector dashboard package."""
+

BIN
model_selector/__pycache__/__init__.cpython-313.pyc


BIN
model_selector/__pycache__/cache.cpython-313.pyc


BIN
model_selector/__pycache__/config.cpython-313.pyc


BIN
model_selector/__pycache__/main.cpython-313.pyc


BIN
model_selector/__pycache__/providers.cpython-313.pyc


BIN
model_selector/__pycache__/service.cpython-313.pyc


BIN
model_selector/__pycache__/settings.cpython-313.pyc


BIN
model_selector/__pycache__/ui.cpython-313.pyc


+ 49 - 0
model_selector/cache.py

@@ -0,0 +1,49 @@
+from __future__ import annotations
+
+import json
+from dataclasses import dataclass
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Any
+
+
+def now_iso() -> str:
+    return datetime.now(timezone.utc).isoformat()
+
+
+@dataclass(frozen=True)
+class CachedSnapshot:
+    provider_key: str
+    updated_at: str
+    data: dict[str, Any]
+
+
+class SnapshotCache:
+    def __init__(self, cache_dir: Path) -> None:
+        self.cache_dir = cache_dir
+
+    def path_for(self, provider_key: str) -> Path:
+        return self.cache_dir / f"{provider_key}.json"
+
+    def write(self, provider_key: str, data: dict[str, Any]) -> Path:
+        self.cache_dir.mkdir(parents=True, exist_ok=True)
+        payload = {
+            "provider_key": provider_key,
+            "updated_at": now_iso(),
+            "data": data,
+        }
+        path = self.path_for(provider_key)
+        path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
+        return path
+
+    def read(self, provider_key: str) -> CachedSnapshot | None:
+        path = self.path_for(provider_key)
+        if not path.exists():
+            return None
+        payload = json.loads(path.read_text(encoding="utf-8"))
+        return CachedSnapshot(
+            provider_key=payload["provider_key"],
+            updated_at=payload["updated_at"],
+            data=payload["data"],
+        )
+

+ 154 - 0
model_selector/config.py

@@ -0,0 +1,154 @@
+from __future__ import annotations
+
+import json
+import shutil
+import tempfile
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any, Iterable
+
+
+PROVIDER_LABELS = {
+    "openai": "OpenAI",
+    "groq-cloud": "Groq",
+    "openrouter": "OpenRouter",
+}
+
+
+@dataclass(frozen=True)
+class ConfiguredModel:
+    provider_key: str
+    provider_label: str
+    model_key: str
+    model_id: str
+    name: str
+    reasoning: bool | None
+    inputs: tuple[str, ...]
+    context_window: int | None
+    max_tokens: int | None
+    alias: str | None
+
+    @property
+    def full_key(self) -> str:
+        return f"{self.provider_key}/{self.model_id}"
+
+
+def provider_label(provider_key: str) -> str:
+    return PROVIDER_LABELS.get(provider_key, provider_key.replace("-", " ").title())
+
+
+class OpenClawConfigRepository:
+    def __init__(self, config_path: Path) -> None:
+        self.config_path = config_path
+
+    def load(self) -> dict[str, Any]:
+        with self.config_path.open("r", encoding="utf-8") as handle:
+            return json.load(handle)
+
+    def save(self, config: dict[str, Any]) -> None:
+        self.config_path.parent.mkdir(parents=True, exist_ok=True)
+        if self.config_path.exists():
+            backup_path = self.config_path.with_suffix(self.config_path.suffix + ".bak")
+            shutil.copy2(self.config_path, backup_path)
+
+        with tempfile.NamedTemporaryFile(
+            "w",
+            encoding="utf-8",
+            dir=self.config_path.parent,
+            delete=False,
+        ) as handle:
+            json.dump(config, handle, indent=2, ensure_ascii=False)
+            handle.write("\n")
+            temp_path = Path(handle.name)
+
+        temp_path.replace(self.config_path)
+
+    def configured_models(self, config: dict[str, Any] | None = None) -> list[ConfiguredModel]:
+        data = config if config is not None else self.load()
+        providers = data.get("models", {}).get("providers", {})
+        aliases = data.get("agents", {}).get("defaults", {}).get("models", {})
+        models: list[ConfiguredModel] = []
+
+        for provider_key, provider in providers.items():
+            provider_models = provider.get("models", []) or []
+            for model in provider_models:
+                model_id = str(model.get("id", ""))
+                full_key = f"{provider_key}/{model_id}"
+                alias = aliases.get(full_key, {}).get("alias")
+                models.append(
+                    ConfiguredModel(
+                        provider_key=provider_key,
+                        provider_label=provider_label(provider_key),
+                        model_key=full_key,
+                        model_id=model_id,
+                        name=str(model.get("name", model_id)),
+                        reasoning=model.get("reasoning"),
+                        inputs=tuple(model.get("input", []) or ()),
+                        context_window=model.get("contextWindow"),
+                        max_tokens=model.get("maxTokens"),
+                        alias=alias,
+                    )
+                )
+
+        return models
+
+    def update_alias(self, full_key: str, alias: str | None) -> dict[str, Any]:
+        config = self.load()
+        model_aliases = self._aliases_section(config)
+
+        if alias:
+            model_aliases[full_key] = {"alias": alias}
+        else:
+            model_aliases.pop(full_key, None)
+
+        self.save(config)
+        return config
+
+    def add_model(self, provider_key: str, model_entry: dict[str, Any]) -> dict[str, Any]:
+        config = self.load()
+        providers = self._providers_section(config)
+        provider = providers.setdefault(provider_key, {})
+        models = provider.setdefault("models", [])
+        model_id = str(model_entry.get("id", ""))
+        existing = next((item for item in models if str(item.get("id", "")) == model_id), None)
+        if existing is None:
+            models.append(model_entry)
+        else:
+            existing.clear()
+            existing.update(model_entry)
+        self.save(config)
+        return config
+
+    def remove_model(self, full_key: str) -> dict[str, Any]:
+        provider_key, model_id = self._split_model_key(full_key)
+        config = self.load()
+        providers = self._providers_section(config)
+        provider = providers.get(provider_key)
+        if provider:
+            models = provider.setdefault("models", [])
+            provider["models"] = [item for item in models if str(item.get("id", "")) != model_id]
+        self._aliases_section(config).pop(full_key, None)
+        self.save(config)
+        return config
+
+    def ensure_copy(self, destination: Path) -> None:
+        destination.parent.mkdir(parents=True, exist_ok=True)
+        shutil.copy2(self.config_path, destination)
+
+    def _providers_section(self, config: dict[str, Any]) -> dict[str, Any]:
+        return config.setdefault("models", {}).setdefault("providers", {})
+
+    def _aliases_section(self, config: dict[str, Any]) -> dict[str, Any]:
+        return config.setdefault("agents", {}).setdefault("defaults", {}).setdefault("models", {})
+
+    def _split_model_key(self, full_key: str) -> tuple[str, str]:
+        if "/" not in full_key:
+            raise ValueError("model key must be provider/model")
+        provider_key, model_id = full_key.split("/", 1)
+        return provider_key, model_id
+
+
+def iter_configured_model_keys(config: dict[str, Any]) -> Iterable[str]:
+    repo = OpenClawConfigRepository(Path("/dev/null"))
+    for model in repo.configured_models(config):
+        yield model.full_key

+ 154 - 0
model_selector/main.py

@@ -0,0 +1,154 @@
+from __future__ import annotations
+
+import asyncio
+from contextlib import asynccontextmanager, suppress
+from pathlib import Path
+
+from fastapi import FastAPI, HTTPException
+from fastapi.responses import HTMLResponse, JSONResponse
+from pydantic import BaseModel
+from starlette.routing import Router
+
+from .cache import SnapshotCache
+from .config import OpenClawConfigRepository
+from .providers import ProviderAdapter
+from .service import ModelDashboardService
+from .settings import AppSettings
+from .ui import render_page
+
+
+def _patch_router_startup_compatibility() -> None:
+    """Bridge older FastAPI startup arguments to newer Starlette routers.
+
+    The environment ships a Starlette build that no longer accepts the legacy
+    ``on_startup`` and ``on_shutdown`` keyword arguments FastAPI still sends
+    during app construction.  Keep the wrapper narrow: ignore those legacy
+    hooks because this app uses lifespan management instead.
+    """
+
+    original_init = Router.__init__
+    if getattr(original_init, "__model_selector_patched__", False):
+        return
+
+    def patched_init(self, *args, on_startup=None, on_shutdown=None, **kwargs):
+        return original_init(self, *args, **kwargs)
+
+    patched_init.__model_selector_patched__ = True  # type: ignore[attr-defined]
+    Router.__init__ = patched_init  # type: ignore[assignment]
+
+
+_patch_router_startup_compatibility()
+
+
+class AliasUpdate(BaseModel):
+    full_key: str
+    alias: str | None = None
+
+
+class ModelDeleteRequest(BaseModel):
+    full_key: str
+
+
+class ModelAddRequest(BaseModel):
+    provider_key: str
+    model_id: str
+
+
+def create_app(
+    settings: AppSettings | None = None,
+    config_path: Path | None = None,
+    cache_dir: Path | None = None,
+    adapters: dict[str, ProviderAdapter] | None = None,
+) -> FastAPI:
+    settings = settings or AppSettings.from_env()
+    config_repo = OpenClawConfigRepository(config_path or settings.config_path)
+    cache = SnapshotCache(cache_dir or settings.cache_dir)
+    service = ModelDashboardService(config_repo, cache, adapters=adapters)
+
+    @asynccontextmanager
+    async def lifespan(app: FastAPI):
+        stop_event = asyncio.Event()
+        await service.refresh_all()
+        refresh_task = asyncio.create_task(
+            service.refresh_periodically(stop_event, settings.refresh_interval_seconds)
+        )
+        app.state.service = service
+        app.state.refresh_stop_event = stop_event
+        app.state.refresh_task = refresh_task
+        try:
+            yield
+        finally:
+            stop_event.set()
+            refresh_task.cancel()
+            with suppress(asyncio.CancelledError):
+                await refresh_task
+
+    app = FastAPI(title="Model Selector", lifespan=lifespan)
+    app.state.service = service
+    app.state.refresh_stop_event = None
+    app.state.refresh_task = None
+
+    @app.get("/", response_class=HTMLResponse)
+    async def index() -> HTMLResponse:
+        return HTMLResponse(render_page(service.state_payload()))
+
+    @app.get("/api/state")
+    async def state() -> JSONResponse:
+        return JSONResponse(service.state_payload())
+
+    @app.get("/api/config")
+    async def config() -> JSONResponse:
+        return JSONResponse(
+            {
+                "config_path": str(config_repo.config_path),
+                "configured_models": service.configured_models(),
+            }
+        )
+
+    @app.post("/api/alias")
+    async def alias(update: AliasUpdate) -> JSONResponse:
+        if "/" not in update.full_key:
+            raise HTTPException(status_code=400, detail="full_key must be provider/model")
+        updated = service.update_alias(update.full_key, update.alias)
+        return JSONResponse({"ok": True, "config": updated})
+
+    @app.post("/api/config/models/delete")
+    async def delete_model(request: ModelDeleteRequest) -> JSONResponse:
+        try:
+            updated = service.delete_configured_model(request.full_key)
+        except ValueError as exc:
+            raise HTTPException(status_code=400, detail=str(exc)) from exc
+        return JSONResponse({"ok": True, "config": updated})
+
+    @app.post("/api/config/models/add")
+    async def add_model(request: ModelAddRequest) -> JSONResponse:
+        try:
+            updated = await service.add_provider_model(request.provider_key, request.model_id)
+        except ValueError as exc:
+            raise HTTPException(status_code=404, detail=str(exc)) from exc
+        return JSONResponse({"ok": True, "config": updated})
+
+    @app.post("/api/refresh")
+    async def refresh() -> JSONResponse:
+        states = await service.refresh_all()
+        return JSONResponse(
+            {
+                "ok": True,
+                "providers": {key: service._state_to_payload(value) for key, value in states.items()},
+            }
+        )
+
+    return app
+
+
+app = create_app()
+
+
+def main() -> None:
+    import uvicorn
+
+    uvicorn.run("model_selector.main:app", host="0.0.0.0", port=3300, reload=False)
+
+
+if __name__ == "__main__":
+    main()

+ 300 - 0
model_selector/providers.py

@@ -0,0 +1,300 @@
+from __future__ import annotations
+
+import asyncio
+from dataclasses import dataclass
+from typing import Any
+
+import httpx
+
+from .config import provider_label
+
+
+@dataclass(frozen=True)
+class ProviderModel:
+    provider_key: str
+    provider_label: str
+    model_id: str
+    name: str
+    source: str
+    reasoning: bool | None
+    context_window: int | None
+    max_tokens: int | None
+    pricing_prompt: str | None
+    pricing_completion: str | None
+    pricing_request: str | None
+    input_modalities: tuple[str, ...]
+    output_modalities: tuple[str, ...]
+    supported_parameters: tuple[str, ...]
+    active: bool | None
+    features: tuple[str, ...]
+    raw: dict[str, Any]
+
+    @property
+    def key(self) -> str:
+        return f"{self.provider_key}/{self.model_id}"
+
+
+def _feature_set(*values: str) -> tuple[str, ...]:
+    seen: list[str] = []
+    for value in values:
+        if value and value not in seen:
+            seen.append(value)
+    return tuple(seen)
+
+
+def _format_price(value: Any) -> str | None:
+    if value is None:
+        return None
+    return str(value)
+
+
+def infer_reasoning(raw: dict[str, Any], features: tuple[str, ...]) -> bool | None:
+    if "reasoning" in raw:
+        return bool(raw.get("reasoning"))
+    if "reasoning" in features:
+        return True
+    return None
+
+
+def provider_model_to_config_entry(model: ProviderModel) -> dict[str, Any]:
+    return {
+        "id": model.model_id,
+        "name": model.name,
+        "reasoning": bool(model.reasoning) if model.reasoning is not None else False,
+        "input": list(model.input_modalities or ("text",)),
+        "contextWindow": model.context_window,
+        "maxTokens": model.max_tokens,
+    }
+
+
+def derive_features(provider_key: str, raw: dict[str, Any]) -> tuple[str, ...]:
+    features: list[str] = []
+
+    pricing = raw.get("pricing") or {}
+    zero_pricing = False
+    if pricing:
+        price_values = [pricing.get("prompt"), pricing.get("completion"), pricing.get("request")]
+        zero_pricing = all(str(item) in {"0", "0.0", "0.00000", "0.000000"} for item in price_values if item is not None)
+    if raw.get("id", "").endswith(":free") or zero_pricing:
+        features.append("free")
+
+    supported_parameters = raw.get("supported_parameters") or []
+    if any(param in supported_parameters for param in ("response_format", "json_schema")):
+        features.append("structured output")
+    if "tools" in supported_parameters:
+        features.append("tool use")
+
+    architecture = raw.get("architecture") or {}
+    if architecture.get("input_modalities"):
+        for modality in architecture.get("input_modalities", []):
+            features.append(str(modality))
+    if architecture.get("output_modalities"):
+        for modality in architecture.get("output_modalities", []):
+            features.append(str(modality))
+
+    provider_hints = {
+        "groq-cloud": {
+            "openai/gpt-oss-20b": [
+                "structured output",
+                "browser search",
+                "code execution",
+                "reasoning",
+            ],
+            "openai/gpt-oss-120b": [
+                "structured output",
+                "browser search",
+                "code execution",
+                "reasoning",
+            ],
+            "openai/gpt-oss-safeguard-20b": [
+                "structured output",
+                "safety",
+            ],
+            "meta-llama/llama-4-scout-17b-16e-instruct": [
+                "structured output",
+                "reasoning",
+            ],
+        },
+        "openai": {
+            "gpt-5.4": ["structured output", "reasoning"],
+            "gpt-5.4-mini": ["structured output", "reasoning"],
+            "gpt-5.4-nano": ["structured output", "reasoning"],
+            "gpt-5.1-codex": ["structured output", "reasoning", "code"],
+            "gpt-5.1-codex-mini": ["structured output", "reasoning", "code"],
+            "gpt-5.3-chat-latest": ["structured output", "reasoning"],
+            "gpt-5-nano": ["structured output", "reasoning"],
+            "gpt-4.1-nano-2025-04-14": ["structured output"],
+        },
+    }
+    features.extend(provider_hints.get(provider_key, {}).get(raw.get("id", ""), []))
+
+    if provider_key == "openrouter":
+        for param in supported_parameters:
+            if param == "response_format":
+                features.append("structured output")
+            elif param in {"tools", "tool_choice"}:
+                features.append("tool use")
+            elif param == "seed":
+                features.append("seed")
+
+    if raw.get("active") is False:
+        features.append("inactive")
+
+    return _feature_set(*features)
+
+
+class ProviderAdapter:
+    provider_key: str
+    base_url: str
+    api_key_env: str
+    model_path: str
+
+    def __init__(self, base_url: str, api_key_env: str) -> None:
+        self.base_url = base_url.rstrip("/")
+        self.api_key_env = api_key_env
+
+    def api_key(self) -> str:
+        import os
+
+        return os.environ.get(self.api_key_env, "").strip()
+
+    def headers(self) -> dict[str, str]:
+        headers = {"Content-Type": "application/json"}
+        api_key = self.api_key()
+        if api_key:
+            headers["Authorization"] = f"Bearer {api_key}"
+        return headers
+
+    async def fetch(self) -> list[ProviderModel]:
+        raise NotImplementedError
+
+    async def _get_json(self, path: str) -> dict[str, Any]:
+        async with httpx.AsyncClient(timeout=20.0) as client:
+            response = await client.get(f"{self.base_url}{path}", headers=self.headers())
+            response.raise_for_status()
+            return response.json()
+
+
+class OpenRouterAdapter(ProviderAdapter):
+    provider_key = "openrouter"
+    model_path = "/models"
+
+    async def fetch(self) -> list[ProviderModel]:
+        if not self.api_key():
+            raise RuntimeError("missing OPENROUTER_API_KEY")
+        payload = await self._get_json(self.model_path)
+        models: list[ProviderModel] = []
+        for item in payload.get("data", []):
+            pricing = item.get("pricing") or {}
+            architecture = item.get("architecture") or {}
+            raw = dict(item)
+            raw["supported_parameters"] = item.get("supported_parameters") or []
+            raw["pricing"] = pricing
+            raw["architecture"] = architecture
+            features = derive_features(self.provider_key, raw)
+            models.append(
+                ProviderModel(
+                    provider_key=self.provider_key,
+                    provider_label=provider_label(self.provider_key),
+                    model_id=str(item.get("id")),
+                    name=str(item.get("name", item.get("id"))),
+                    source="live",
+                    reasoning=infer_reasoning(raw, features),
+                    context_window=item.get("context_length"),
+                    max_tokens=(item.get("top_provider") or {}).get("max_completion_tokens"),
+                    pricing_prompt=_format_price(pricing.get("prompt")),
+                    pricing_completion=_format_price(pricing.get("completion")),
+                    pricing_request=_format_price(pricing.get("request")),
+                    input_modalities=tuple(architecture.get("input_modalities") or ()),
+                    output_modalities=tuple(architecture.get("output_modalities") or ()),
+                    supported_parameters=tuple(item.get("supported_parameters") or ()),
+                    active=None,
+                    features=features,
+                    raw=raw,
+                )
+            )
+        return models
+
+
+class GroqAdapter(ProviderAdapter):
+    provider_key = "groq-cloud"
+    model_path = "/models"
+
+    async def fetch(self) -> list[ProviderModel]:
+        if not self.api_key():
+            raise RuntimeError("missing GROQ_API_KEY")
+        payload = await self._get_json(self.model_path)
+        models: list[ProviderModel] = []
+        for item in payload.get("data", []):
+            raw = dict(item)
+            features = derive_features(self.provider_key, raw)
+            models.append(
+                ProviderModel(
+                    provider_key=self.provider_key,
+                    provider_label=provider_label(self.provider_key),
+                    model_id=str(item.get("id")),
+                    name=str(item.get("id")),
+                    source="live",
+                    reasoning=infer_reasoning(raw, features),
+                    context_window=item.get("context_window"),
+                    max_tokens=None,
+                    pricing_prompt=None,
+                    pricing_completion=None,
+                    pricing_request=None,
+                    input_modalities=("text",),
+                    output_modalities=("text",),
+                    supported_parameters=(),
+                    active=item.get("active"),
+                    features=features,
+                    raw=raw,
+                )
+            )
+        return models
+
+
+class OpenAIAdapter(ProviderAdapter):
+    provider_key = "openai"
+    model_path = "/models"
+
+    async def fetch(self) -> list[ProviderModel]:
+        if not self.api_key():
+            raise RuntimeError("missing OPENAI_API_KEY")
+        payload = await self._get_json(self.model_path)
+        models: list[ProviderModel] = []
+        for item in payload.get("data", []):
+            raw = dict(item)
+            features = derive_features(self.provider_key, raw)
+            models.append(
+                ProviderModel(
+                    provider_key=self.provider_key,
+                    provider_label=provider_label(self.provider_key),
+                    model_id=str(item.get("id")),
+                    name=str(item.get("id")),
+                    source="live",
+                    reasoning=infer_reasoning(raw, features),
+                    context_window=None,
+                    max_tokens=None,
+                    pricing_prompt=None,
+                    pricing_completion=None,
+                    pricing_request=None,
+                    input_modalities=("text",),
+                    output_modalities=("text",),
+                    supported_parameters=(),
+                    active=None,
+                    features=features,
+                    raw=raw,
+                )
+            )
+        return models
+
+
+def build_provider_adapters() -> dict[str, ProviderAdapter]:
+    return {
+        "openai": OpenAIAdapter("https://api.openai.com/v1", "OPENAI_API_KEY"),
+        "groq-cloud": GroqAdapter("https://api.groq.com/openai/v1", "GROQ_API_KEY"),
+        "openrouter": OpenRouterAdapter("https://openrouter.ai/api/v1", "OPENROUTER_API_KEY"),
+    }
+
+
+async def fetch_with_timeout(adapter: ProviderAdapter) -> list[ProviderModel]:
+    return await asyncio.wait_for(adapter.fetch(), timeout=30.0)

+ 231 - 0
model_selector/service.py

@@ -0,0 +1,231 @@
+from __future__ import annotations
+
+import asyncio
+import json
+from dataclasses import asdict, dataclass
+from pathlib import Path
+from typing import Any
+
+from .cache import SnapshotCache, now_iso
+from .config import OpenClawConfigRepository
+from .providers import (
+    ProviderModel,
+    ProviderAdapter,
+    build_provider_adapters,
+    fetch_with_timeout,
+    provider_model_to_config_entry,
+)
+
+
+@dataclass
+class ProviderState:
+    provider_key: str
+    provider_label: str
+    updated_at: str | None
+    source: str
+    error: str | None
+    models: list[ProviderModel]
+
+
+class ModelDashboardService:
+    def __init__(
+        self,
+        config_repo: OpenClawConfigRepository,
+        cache: SnapshotCache,
+        adapters: dict[str, ProviderAdapter] | None = None,
+    ) -> None:
+        self.config_repo = config_repo
+        self.cache = cache
+        self.adapters = adapters or build_provider_adapters()
+        self._lock = asyncio.Lock()
+
+    def configured_models(self) -> list[dict[str, Any]]:
+        config = self.config_repo.load()
+        items = []
+        for model in self.config_repo.configured_models(config):
+            items.append(
+                {
+                    "provider_key": model.provider_key,
+                    "provider_label": model.provider_label,
+                    "model_key": model.model_key,
+                    "model_id": model.model_id,
+                    "name": model.name,
+                    "reasoning": model.reasoning,
+                    "inputs": list(model.inputs),
+                    "context_window": model.context_window,
+                    "max_tokens": model.max_tokens,
+                    "alias": model.alias,
+                }
+            )
+        return items
+
+    def configured_model_keys(self) -> list[str]:
+        return [model["model_key"] for model in self.configured_models()]
+
+    def load_provider_state_from_cache(self, provider_key: str) -> ProviderState | None:
+        cached = self.cache.read(provider_key)
+        if not cached:
+            return None
+        provider_models = []
+        for item in cached.data["models"]:
+            provider_models.append(
+                ProviderModel(
+                    provider_key=item["provider_key"],
+                    provider_label=item["provider_label"],
+                    model_id=item["model_id"],
+                    name=item["name"],
+                    source=item["source"],
+                    reasoning=item.get("reasoning"),
+                    context_window=item.get("context_window"),
+                    max_tokens=item.get("max_tokens"),
+                    pricing_prompt=item.get("pricing_prompt"),
+                    pricing_completion=item.get("pricing_completion"),
+                    pricing_request=item.get("pricing_request"),
+                    input_modalities=tuple(item.get("input_modalities", ())),
+                    output_modalities=tuple(item.get("output_modalities", ())),
+                    supported_parameters=tuple(item.get("supported_parameters", ())),
+                    active=item.get("active"),
+                    features=tuple(item.get("features", ())),
+                    raw=item.get("raw", {}),
+                )
+            )
+        return ProviderState(
+            provider_key=provider_key,
+            provider_label=provider_models[0].provider_label if provider_models else provider_key,
+            updated_at=cached.updated_at,
+            source=cached.data.get("source", "cache"),
+            error=cached.data.get("error"),
+            models=provider_models,
+        )
+
+    def _configured_provider_models(self, provider_key: str) -> list[ProviderModel]:
+        models: list[ProviderModel] = []
+        for model in self.config_repo.configured_models():
+            if model.provider_key != provider_key:
+                continue
+            raw = {
+                "id": model.model_id,
+                "name": model.name,
+                "context_window": model.context_window,
+                "max_tokens": model.max_tokens,
+            }
+            models.append(
+                ProviderModel(
+                    provider_key=provider_key,
+                    provider_label=model.provider_label,
+                    model_id=model.model_id,
+                    name=model.name,
+                    source="config",
+                    reasoning=model.reasoning,
+                    context_window=model.context_window,
+                    max_tokens=model.max_tokens,
+                    pricing_prompt=None,
+                    pricing_completion=None,
+                    pricing_request=None,
+                    input_modalities=model.inputs or ("text",),
+                    output_modalities=("text",),
+                    supported_parameters=(),
+                    active=True,
+                    features=("configured",),
+                    raw=raw,
+                )
+            )
+        return models
+
+    async def refresh_provider(self, provider_key: str) -> ProviderState:
+        adapter = self.adapters[provider_key]
+        error: str | None = None
+        source = "live"
+        updated_at = now_iso()
+        models: list[ProviderModel] = []
+        try:
+            models = await fetch_with_timeout(adapter)
+        except Exception as exc:  # pragma: no cover - network failure fallback
+            error = str(exc)
+            source = "config"
+            models = self._configured_provider_models(provider_key)
+            cached = self.load_provider_state_from_cache(provider_key)
+            if cached:
+                return cached
+        payload = {
+            "source": source,
+            "error": error,
+            "models": [asdict(model) for model in models],
+        }
+        self.cache.write(provider_key, payload)
+        return ProviderState(
+            provider_key=provider_key,
+            provider_label=models[0].provider_label if models else provider_key,
+            updated_at=updated_at,
+            source=source,
+            error=error,
+            models=models,
+        )
+
+    async def add_provider_model(self, provider_key: str, model_id: str) -> dict[str, Any]:
+        state = self.load_provider_state_from_cache(provider_key)
+        if state is None or not any(model.model_id == model_id for model in state.models):
+            state = await self.refresh_provider(provider_key)
+
+        model = next((item for item in state.models if item.model_id == model_id), None)
+        if model is None:
+            raise ValueError(f"model {provider_key}/{model_id} is not available")
+
+        config_entry = provider_model_to_config_entry(model)
+        return self.config_repo.add_model(provider_key, config_entry)
+
+    def delete_configured_model(self, full_key: str) -> dict[str, Any]:
+        return self.config_repo.remove_model(full_key)
+
+    async def refresh_all(self) -> dict[str, ProviderState]:
+        states: dict[str, ProviderState] = {}
+        for provider_key in self.adapters:
+            states[provider_key] = await self.refresh_provider(provider_key)
+        return states
+
+    async def refresh_periodically(self, stop_event: asyncio.Event, interval_seconds: int) -> None:
+        while not stop_event.is_set():
+            async with self._lock:
+                await self.refresh_all()
+            try:
+                await asyncio.wait_for(stop_event.wait(), timeout=interval_seconds)
+            except asyncio.TimeoutError:
+                continue
+
+    def state_payload(self) -> dict[str, Any]:
+        providers = {}
+        for provider_key in self.adapters:
+            cached = self.load_provider_state_from_cache(provider_key)
+            providers[provider_key] = self._state_to_payload(cached)
+
+        return {
+            "updated_at": now_iso(),
+            "configured_models": self.configured_models(),
+            "configured_model_keys": self.configured_model_keys(),
+            "providers": providers,
+        }
+
+    def _state_to_payload(self, state: ProviderState | None) -> dict[str, Any]:
+        if not state:
+            return {
+                "provider_key": None,
+                "provider_label": None,
+                "updated_at": None,
+                "source": "missing",
+                "error": "No cached data yet.",
+                "models": [],
+            }
+        return {
+            "provider_key": state.provider_key,
+            "provider_label": state.provider_label,
+            "updated_at": state.updated_at,
+            "source": state.source,
+            "error": state.error,
+            "models": [asdict(model) for model in state.models],
+        }
+
+    def update_alias(self, full_key: str, alias: str | None) -> dict[str, Any]:
+        return self.config_repo.update_alias(full_key, alias)
+
+    def dump_state(self) -> str:
+        return json.dumps(self.state_payload(), indent=2, ensure_ascii=False)

+ 50 - 0
model_selector/settings.py

@@ -0,0 +1,50 @@
+from __future__ import annotations
+
+import os
+from dataclasses import dataclass
+from pathlib import Path
+
+
+def _load_env_file(path: Path) -> None:
+    if not path.exists():
+        return
+
+    for raw_line in path.read_text(encoding="utf-8").splitlines():
+        line = raw_line.strip()
+        if not line or line.startswith("#"):
+            continue
+        if line.startswith("export "):
+            line = line[len("export ") :].lstrip()
+        if "=" not in line:
+            continue
+        key, value = line.split("=", 1)
+        key = key.strip()
+        value = value.strip()
+        if not key or key in os.environ:
+            continue
+        if len(value) >= 2 and value[0] == value[-1] and value[0] in {'"', "'"}:
+            value = value[1:-1]
+        os.environ[key] = value
+
+
+@dataclass(frozen=True)
+class AppSettings:
+    config_path: Path
+    cache_dir: Path
+    refresh_interval_seconds: int
+    host: str
+    port: int
+
+    @classmethod
+    def from_env(cls) -> "AppSettings":
+        project_root = Path(__file__).resolve().parents[1]
+        _load_env_file(project_root / ".env")
+        return cls(
+            config_path=Path(
+                os.environ.get("OPENCLAW_CONFIG_PATH", "/home/lucky/.openclaw/openclaw.json")
+            ),
+            cache_dir=Path(os.environ.get("MODEL_SELECTOR_CACHE_DIR", "data/model-cache")),
+            refresh_interval_seconds=int(os.environ.get("MODEL_SELECTOR_REFRESH_INTERVAL", "1800")),
+            host=os.environ.get("MODEL_SELECTOR_HOST", "0.0.0.0"),
+            port=int(os.environ.get("MODEL_SELECTOR_PORT", "3300")),
+        )

+ 514 - 0
model_selector/ui.py

@@ -0,0 +1,514 @@
+from __future__ import annotations
+
+import json
+from html import escape
+from typing import Any
+
+
+def _json_for_script(value: Any) -> str:
+    return json.dumps(value, ensure_ascii=False).replace("</", "<\\/")
+
+
+def render_page(state: dict[str, Any]) -> str:
+    return f"""<!doctype html>
+<html lang="en">
+<head>
+  <meta charset="utf-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1">
+  <title>Model Selector</title>
+  <style>
+    :root {{
+      color-scheme: dark;
+      --bg: #0b1020;
+      --panel: rgba(14, 20, 39, 0.86);
+      --panel-2: rgba(18, 27, 52, 0.95);
+      --line: rgba(148, 163, 184, 0.16);
+      --text: #e5eefc;
+      --muted: #91a4c7;
+      --accent: #7dd3fc;
+      --accent-2: #a78bfa;
+      --good: #34d399;
+      --warn: #f59e0b;
+      --bad: #fb7185;
+    }}
+    * {{ box-sizing: border-box; }}
+    body {{
+      margin: 0;
+      font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+      background:
+        radial-gradient(circle at top left, rgba(125, 211, 252, 0.12), transparent 32%),
+        radial-gradient(circle at bottom right, rgba(167, 139, 250, 0.15), transparent 28%),
+        linear-gradient(180deg, #09111f, #050816 64%, #04050b);
+      color: var(--text);
+      min-height: 100vh;
+    }}
+    header {{
+      display: flex;
+      justify-content: space-between;
+      align-items: center;
+      gap: 1rem;
+      padding: 1.4rem 1.5rem;
+      border-bottom: 1px solid var(--line);
+      background: rgba(7, 11, 23, 0.82);
+      position: sticky;
+      top: 0;
+      backdrop-filter: blur(12px);
+      z-index: 5;
+    }}
+    h1 {{
+      margin: 0;
+      font-size: 1.15rem;
+      letter-spacing: 0.06em;
+      text-transform: uppercase;
+    }}
+    .meta {{
+      color: var(--muted);
+      font-size: 0.92rem;
+    }}
+    .toolbar {{
+      display: flex;
+      gap: 0.75rem;
+      align-items: center;
+      flex-wrap: wrap;
+    }}
+    button, input {{
+      font: inherit;
+    }}
+    button {{
+      border: 1px solid var(--line);
+      background: linear-gradient(180deg, rgba(125, 211, 252, 0.2), rgba(125, 211, 252, 0.08));
+      color: var(--text);
+      padding: 0.65rem 0.9rem;
+      border-radius: 0.8rem;
+      cursor: pointer;
+    }}
+    button.secondary {{
+      background: rgba(255, 255, 255, 0.04);
+    }}
+    main {{
+      padding: 1.2rem;
+      display: grid;
+      grid-template-columns: minmax(320px, 1fr) minmax(420px, 1.5fr);
+      gap: 1rem;
+    }}
+    .panel {{
+      background: var(--panel);
+      border: 1px solid var(--line);
+      border-radius: 1.2rem;
+      box-shadow: 0 18px 55px rgba(0, 0, 0, 0.28);
+      overflow: hidden;
+    }}
+    .panel h2 {{
+      margin: 0;
+      padding: 1rem 1.1rem 0.2rem;
+      font-size: 1rem;
+    }}
+    .panel .sub {{
+      padding: 0 1.1rem 1rem;
+      color: var(--muted);
+      font-size: 0.9rem;
+    }}
+    .section {{
+      padding: 0.95rem 1.1rem 1.15rem;
+      border-top: 1px solid var(--line);
+    }}
+    .provider-meta {{
+      display: flex;
+      gap: 0.5rem;
+      flex-wrap: wrap;
+      color: var(--muted);
+      font-size: 0.85rem;
+      margin-bottom: 0.65rem;
+    }}
+    .tabs {{
+      display: flex;
+      gap: 0.5rem;
+      padding: 0 1.1rem 1rem;
+      border-top: 1px solid var(--line);
+      flex-wrap: wrap;
+    }}
+    .tab {{
+      background: rgba(255, 255, 255, 0.03);
+    }}
+    .tab.active {{
+      border-color: rgba(125, 211, 252, 0.45);
+      background: rgba(125, 211, 252, 0.16);
+    }}
+    .provider-panel {{
+      display: none;
+    }}
+    .provider-panel.active {{
+      display: block;
+    }}
+    .card {{
+      border: 1px solid var(--line);
+      border-radius: 1rem;
+      padding: 0.9rem;
+      margin-bottom: 0.8rem;
+      background: var(--panel-2);
+    }}
+    .card-head {{
+      display: flex;
+      justify-content: space-between;
+      gap: 0.75rem;
+      align-items: flex-start;
+    }}
+    .title {{
+      font-weight: 650;
+      margin-bottom: 0.25rem;
+    }}
+    .small {{
+      font-size: 0.84rem;
+      color: var(--muted);
+    }}
+    .chips {{
+      display: flex;
+      flex-wrap: wrap;
+      gap: 0.35rem;
+      margin-top: 0.55rem;
+    }}
+    .chip {{
+      display: inline-flex;
+      align-items: center;
+      border-radius: 999px;
+      padding: 0.24rem 0.62rem;
+      font-size: 0.78rem;
+      border: 1px solid var(--line);
+      background: rgba(255, 255, 255, 0.04);
+    }}
+    .chip.free {{ color: var(--good); }}
+    .chip.structured {{ color: var(--accent); }}
+    .chip.tooling {{ color: #c4b5fd; }}
+    .chip.reasoning {{ color: #fcd34d; }}
+    .chip.audio, .chip.image {{ color: #f9a8d4; }}
+    .chip.added {{ color: var(--good); }}
+    .grid {{
+      display: grid;
+      gap: 0.7rem;
+    }}
+    table {{
+      width: 100%;
+      border-collapse: collapse;
+      font-size: 0.88rem;
+    }}
+    th, td {{
+      text-align: left;
+      padding: 0.56rem 0.35rem;
+      border-bottom: 1px solid var(--line);
+      vertical-align: top;
+    }}
+    th {{
+      color: var(--muted);
+      font-weight: 600;
+      font-size: 0.8rem;
+    }}
+    .alias-row {{
+      display: grid;
+      grid-template-columns: 1fr 1fr auto;
+      gap: 0.5rem;
+      align-items: center;
+      margin-top: 0.5rem;
+    }}
+    .alias-row input {{
+      width: 100%;
+      border-radius: 0.8rem;
+      border: 1px solid var(--line);
+      background: rgba(255, 255, 255, 0.04);
+      color: var(--text);
+      padding: 0.55rem 0.7rem;
+    }}
+    .note {{
+      color: var(--muted);
+      font-size: 0.82rem;
+      margin-top: 0.4rem;
+    }}
+    .empty {{
+      color: var(--muted);
+      font-style: italic;
+      padding: 0.75rem 0;
+    }}
+    .status {{
+      color: var(--muted);
+      font-size: 0.85rem;
+    }}
+    .split {{
+      display: flex;
+      justify-content: space-between;
+      gap: 0.5rem;
+      align-items: center;
+    }}
+    .panel-tools {{
+      display: flex;
+      align-items: center;
+      gap: 0.75rem;
+      flex-wrap: wrap;
+    }}
+    .filter-input {{
+      width: min(100%, 420px);
+      border-radius: 0.8rem;
+      border: 1px solid var(--line);
+      background: rgba(255, 255, 255, 0.04);
+      color: var(--text);
+      padding: 0.6rem 0.75rem;
+    }}
+    .danger {{
+      border-color: rgba(251, 113, 133, 0.3);
+      background: rgba(251, 113, 133, 0.12);
+    }}
+    @media (max-width: 1100px) {{
+      main {{ grid-template-columns: 1fr; }}
+    }}
+  </style>
+</head>
+<body>
+  <header>
+    <div>
+      <h1>Model Selector</h1>
+      <div class="meta">OpenClaw dashboard on port 3300</div>
+    </div>
+    <div class="toolbar">
+      <div class="status" id="status">Loaded</div>
+      <button onclick="refreshAll()">Refresh now</button>
+    </div>
+  </header>
+  <main>
+    <section class="panel">
+      <h2>Configured Models</h2>
+      <div class="sub">Models currently declared in openclaw.json, with alias support from agents.defaults.models.</div>
+      <div id="configured"></div>
+    </section>
+    <section class="panel">
+      <h2>Provider Catalog</h2>
+      <div class="sub">Live or cached provider catalogs, with chips for capabilities and simple pricing cues when the metadata exposes them.</div>
+      <div class="section">
+        <div class="panel-tools">
+          <input id="pattern" class="filter-input" placeholder="Filter pattern" oninput="setPattern(this.value)">
+          <div class="status" id="provider-summary"></div>
+        </div>
+      </div>
+      <div class="tabs" id="tabs"></div>
+      <div id="providers"></div>
+    </section>
+  </main>
+  <script>
+    window.__INITIAL_STATE__ = {_json_for_script(state)};
+
+    const providerOrder = ["openai", "groq-cloud", "openrouter"];
+    const providerTitles = {{
+      "openai": "OpenAI",
+      "groq-cloud": "Groq",
+      "openrouter": "OpenRouter",
+    }};
+    let currentProvider = "openai";
+    let currentPattern = "";
+    let configuredKeySet = new Set();
+
+    function esc(value) {{
+      return String(value ?? "").replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
+    }}
+
+    function fullKey(model) {{
+      return `${{model.provider_key}}/${{model.model_id}}`;
+    }}
+
+    function isConfigured(model) {{
+      return configuredKeySet.has(fullKey(model));
+    }}
+
+    function matchesPattern(model, pattern) {{
+      if (!pattern) return true;
+      const haystack = [
+        model.model_id,
+        model.name,
+        model.source,
+        model.pricing_prompt,
+        model.pricing_completion,
+        model.pricing_request,
+        ...(model.features || []),
+      ].filter(Boolean).join(" ");
+      try {{
+        return new RegExp(pattern, "i").test(haystack);
+      }} catch (error) {{
+        return haystack.toLowerCase().includes(pattern.toLowerCase());
+      }}
+    }}
+
+    function chip(label, className = "") {{
+      return `<span class="chip ${{className}}">${{esc(label)}}</span>`;
+    }}
+
+    function renderConfigured(models) {{
+      if (!models.length) return '<div class="empty">No configured models found.</div>';
+      return `
+        <div class="grid">
+          ${{models.map(model => `
+            <div class="card">
+              <div class="card-head">
+                <div>
+                  <div class="title">${{esc(model.name)}}</div>
+                  <div class="small">${{esc(model.model_key)}} · ${{esc(model.provider_label)}}</div>
+                </div>
+                <div class="small">
+                  ${{model.alias ? `alias: <strong>${{esc(model.alias)}}</strong>` : 'no alias'}}
+                </div>
+              </div>
+              <div class="chips">
+                ${{model.reasoning ? chip("reasoning", "reasoning") : ""}}
+                ${{model.context_window ? chip(`ctx ${{model.context_window}}`) : ""}}
+                ${{model.max_tokens ? chip(`max ${{model.max_tokens}}`) : ""}}
+                ${{model.inputs?.map(value => chip(value))?.join("") || ""}}
+              </div>
+              <div class="alias-row">
+                <input data-fullkey="${{esc(model.model_key)}}" data-current-alias="${{esc(model.alias || "")}}" placeholder="Alias" value="${{esc(model.alias || "")}}">
+                <button class="secondary" onclick="saveAlias('${{esc(model.model_key)}}')">Save alias</button>
+                <button class="danger" onclick="deleteModel('${{esc(model.model_key)}}')">Delete</button>
+              </div>
+            </div>
+          `).join("")}}
+        </div>
+      `;
+    }}
+
+    function featureClass(feature) {{
+      if (feature === "free") return "free";
+      if (feature.includes("structured")) return "structured";
+      if (feature.includes("tool") || feature.includes("browser") || feature.includes("code")) return "tooling";
+      if (feature.includes("reason")) return "reasoning";
+      if (feature === "audio" || feature === "image") return feature;
+      return "";
+    }}
+
+    function renderModelCard(model) {{
+      const priceBits = [];
+      if (model.pricing_prompt) priceBits.push(`prompt $${{esc(model.pricing_prompt)}}`);
+      if (model.pricing_completion) priceBits.push(`completion $${{esc(model.pricing_completion)}}`);
+      if (model.pricing_request) priceBits.push(`request $${{esc(model.pricing_request)}}`);
+      const price = priceBits.length ? priceBits.join(" · ") : "price not exposed";
+      const configured = isConfigured(model);
+      return `
+        <div class="card">
+          <div class="card-head">
+            <div>
+              <div class="title">${{esc(model.name)}}</div>
+              <div class="small">${{esc(model.model_id)}} · ${{esc(model.source)}}</div>
+            </div>
+            <div class="small">${{model.active === false ? '<span style="color: var(--warn)">inactive</span>' : ''}}</div>
+          </div>
+          <div class="small" style="margin-top:0.35rem">${{price}}</div>
+          <div class="small">${{model.context_window ? `context ${{model.context_window}}` : "context not exposed"}}${{model.max_tokens ? ` · max ${{model.max_tokens}}` : ""}}</div>
+          <div class="chips">
+            ${{(model.features || []).map(feature => chip(feature, featureClass(feature))).join("")}}
+            ${{configured ? chip("in config", "added") : ""}}
+          </div>
+          <div class="alias-row" style="grid-template-columns: 1fr auto;">
+            <div class="small">${{configured ? 'Already in config' : 'Not in config yet'}}</div>
+            <button class="secondary" onclick="addModel('${{esc(model.provider_key)}}', '${{esc(model.model_id)}}')" ${{configured ? 'disabled' : ''}}>
+              ${{configured ? 'Added' : 'Add to config'}}
+            </button>
+          </div>
+        </div>
+      `;
+    }}
+
+    function renderProviderPanel(providerKey, providerState) {{
+      const title = providerTitles[providerKey] || providerKey;
+      const models = (providerState?.models || []).filter(model => matchesPattern(model, currentPattern));
+      const header = `
+        <div class="section">
+          <div class="split">
+            <strong>${{esc(title)}}</strong>
+            <span class="status">${{esc(providerState?.source || "missing")}}${{providerState?.updated_at ? " · updated " + esc(providerState.updated_at) : ""}}</span>
+          </div>
+          <div class="provider-meta">
+            <span>${{(providerState?.models || []).length}} models</span>
+            ${{providerState?.error ? `<span style="color: var(--bad)">${{esc(providerState.error)}}</span>` : ""}}
+          </div>
+        </div>
+      `;
+      return `${{header}}<div class="section">${{models.length ? models.map(renderModelCard).join("") : '<div class="empty">No provider snapshot yet.</div>'}}</div>`;
+    }}
+
+    function renderTabs() {{
+      return providerOrder.map(providerKey => `
+        <button class="tab ${{providerKey === currentProvider ? 'active' : ''}}" onclick="switchProvider('${{providerKey}}')">${{providerTitles[providerKey]}}</button>
+      `).join("");
+    }}
+
+    function renderProviders(state) {{
+      const provider = state.providers?.[currentProvider];
+      return renderProviderPanel(currentProvider, provider);
+    }}
+
+    function applyState(state) {{
+      configuredKeySet = new Set((state.configured_model_keys || []).map(String));
+      document.getElementById("configured").innerHTML = renderConfigured(state.configured_models || []);
+      document.getElementById("tabs").innerHTML = renderTabs();
+      document.getElementById("providers").innerHTML = renderProviders(state);
+      document.getElementById("provider-summary").textContent = `${{configuredKeySet.size}} configured models`;
+      document.getElementById("status").textContent = `Updated ${{new Date().toLocaleTimeString()}}`;
+    }}
+
+    function setPattern(value) {{
+      currentPattern = value || "";
+      document.getElementById("providers").innerHTML = renderProviders(window.__APP_STATE__);
+    }}
+
+    async function refreshAll() {{
+      document.getElementById("status").textContent = "Refreshing...";
+      await fetch("/api/refresh", {{ method: "POST" }});
+      const response = await fetch("/api/state");
+      const state = await response.json();
+      applyState(state);
+    }}
+
+    async function saveAlias(fullKey) {{
+      const input = document.querySelector(`input[data-fullkey="${{CSS.escape(fullKey)}}"]`);
+      const alias = input.value.trim();
+      await fetch("/api/alias", {{
+        method: "POST",
+        headers: {{ "Content-Type": "application/json" }},
+        body: JSON.stringify({{ full_key: fullKey, alias: alias || null }})
+      }});
+      const response = await fetch("/api/state");
+      window.__APP_STATE__ = await response.json();
+      applyState(window.__APP_STATE__);
+    }}
+
+    async function deleteModel(fullKey) {{
+      await fetch("/api/config/models/delete", {{
+        method: "POST",
+        headers: {{ "Content-Type": "application/json" }},
+        body: JSON.stringify({{ full_key: fullKey }})
+      }});
+      const response = await fetch("/api/state");
+      window.__APP_STATE__ = await response.json();
+      applyState(window.__APP_STATE__);
+    }}
+
+    async function addModel(providerKey, modelId) {{
+      await fetch("/api/config/models/add", {{
+        method: "POST",
+        headers: {{ "Content-Type": "application/json" }},
+        body: JSON.stringify({{ provider_key: providerKey, model_id: modelId }})
+      }});
+      const response = await fetch("/api/state");
+      window.__APP_STATE__ = await response.json();
+      applyState(window.__APP_STATE__);
+    }}
+
+    function switchProvider(providerKey) {{
+      currentProvider = providerKey;
+      applyState(window.__APP_STATE__);
+    }}
+
+    window.__APP_STATE__ = window.__INITIAL_STATE__;
+    applyState(window.__APP_STATE__);
+    setInterval(async () => {{
+      const response = await fetch("/api/state");
+      const state = await response.json();
+      window.__APP_STATE__ = state;
+      applyState(state);
+    }}, 60000);
+  </script>
+</body>
+</html>"""

+ 6 - 0
restart.sh

@@ -0,0 +1,6 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+"${ROOT_DIR}/killserver.sh"
+exec "${ROOT_DIR}/run.sh"

+ 16 - 0
run.sh

@@ -0,0 +1,16 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+PYTHON_BIN="${PYTHON_BIN:-}"
+
+if [[ -z "${PYTHON_BIN}" ]]; then
+  if [[ -x "${ROOT_DIR}/.venv/bin/python" ]]; then
+    PYTHON_BIN="${ROOT_DIR}/.venv/bin/python"
+  else
+    PYTHON_BIN="python3"
+  fi
+fi
+
+cd "${ROOT_DIR}"
+exec "${PYTHON_BIN}" -m uvicorn model_selector.main:app --host 0.0.0.0 --port "${MODEL_SELECTOR_PORT:-3300}"

BIN
tests/__pycache__/test_app.cpython-313.pyc


BIN
tests/__pycache__/test_config.cpython-313.pyc


BIN
tests/__pycache__/test_providers.cpython-313.pyc


+ 115 - 0
tests/test_app.py

@@ -0,0 +1,115 @@
+from __future__ import annotations
+
+import tempfile
+from pathlib import Path
+import unittest
+import warnings
+
+import httpx
+
+warnings.filterwarnings("ignore", category=DeprecationWarning)
+
+from model_selector.config import provider_label
+from model_selector.main import create_app
+from model_selector.providers import ProviderModel
+from model_selector.settings import AppSettings
+
+
+class FakeAdapter:
+    def __init__(self, provider_key: str, models: list[dict]) -> None:
+        self.provider_key = provider_key
+        self.models = models
+
+    async def fetch(self):
+        return [
+            ProviderModel(
+                provider_key=self.provider_key,
+                provider_label=provider_label(self.provider_key),
+                model_id=item["id"],
+                name=item.get("name", item["id"]),
+                source="live",
+                reasoning=item.get("reasoning"),
+                context_window=item.get("context_window"),
+                max_tokens=item.get("max_tokens"),
+                pricing_prompt=item.get("pricing_prompt"),
+                pricing_completion=item.get("pricing_completion"),
+                pricing_request=item.get("pricing_request"),
+                input_modalities=tuple(item.get("input_modalities", ("text",))),
+                output_modalities=tuple(item.get("output_modalities", ("text",))),
+                supported_parameters=tuple(item.get("supported_parameters", ())),
+                active=item.get("active"),
+                features=tuple(item.get("features", ())),
+                raw=item,
+            )
+            for item in self.models
+        ]
+
+
+class AppTests(unittest.IsolatedAsyncioTestCase):
+    async def test_dashboard_renders_expected_panels(self) -> None:
+        with tempfile.TemporaryDirectory() as tmp:
+            config_path = Path(tmp) / "openclaw.json"
+            config_path.write_text(
+                """
+{
+  "models": {
+    "providers": {
+      "openai": {"models": [{"id": "gpt-5.4", "name": "GPT-5.4", "reasoning": true, "input": ["text"], "contextWindow": 100, "maxTokens": 10}]},
+      "groq-cloud": {"models": [{"id": "openai/gpt-oss-20b", "name": "GPT OSS 20B", "reasoning": true, "input": ["text"], "contextWindow": 100, "maxTokens": 10}]},
+      "openrouter": {"models": [{"id": "openai/gpt-oss-120b", "name": "GPT OSS 120B", "reasoning": true, "input": ["text"], "contextWindow": 100, "maxTokens": 10}]}
+    }
+  },
+  "agents": {"defaults": {"models": {"openai/gpt-5.4": {"alias": "osiris"}}}}
+}
+""".strip(),
+                encoding="utf-8",
+            )
+            app = create_app(
+                settings=AppSettings(
+                    config_path=config_path,
+                    cache_dir=Path(tmp) / "cache",
+                    refresh_interval_seconds=3600,
+                    host="127.0.0.1",
+                    port=3300,
+                ),
+                adapters={
+                "openai": FakeAdapter("openai", [{"id": "gpt-5.4", "name": "GPT-5.4", "reasoning": True}]),
+                "groq-cloud": FakeAdapter("groq-cloud", [{"id": "openai/gpt-oss-20b", "name": "GPT OSS 20B", "reasoning": True}]),
+                "openrouter": FakeAdapter("openrouter", [{"id": "openai/gpt-oss-120b", "name": "GPT OSS 120B"}]),
+            },
+            )
+            await app.state.service.refresh_all()
+
+            transport = httpx.ASGITransport(app=app)
+            async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
+                response = await client.get("/")
+                add_response = await client.post(
+                    "/api/config/models/add",
+                    json={"provider_key": "openrouter", "model_id": "openai/gpt-oss-120b"},
+                )
+                delete_response = await client.post(
+                    "/api/config/models/delete",
+                    json={"full_key": "openrouter/openai/gpt-oss-120b"},
+                )
+
+            self.assertEqual(response.status_code, 200)
+            self.assertEqual(add_response.status_code, 200)
+            self.assertEqual(delete_response.status_code, 200)
+            self.assertTrue(
+                any(
+                    model["id"] == "openai/gpt-oss-120b"
+                    for model in add_response.json()["config"]["models"]["providers"]["openrouter"]["models"]
+                )
+            )
+            self.assertFalse(
+                any(
+                    model["id"] == "openai/gpt-oss-120b"
+                    for model in delete_response.json()["config"]["models"]["providers"]["openrouter"]["models"]
+                )
+            )
+            html = response.text
+            self.assertIn("Configured Models", html)
+            self.assertIn("Provider Catalog", html)
+            self.assertIn("OpenAI", html)
+            self.assertIn("Groq", html)
+            self.assertIn("OpenRouter", html)

+ 69 - 0
tests/test_config.py

@@ -0,0 +1,69 @@
+from __future__ import annotations
+
+import json
+import tempfile
+from pathlib import Path
+import unittest
+
+from model_selector.config import OpenClawConfigRepository
+
+
+SOURCE_CONFIG = Path("/home/lucky/.openclaw/openclaw.json")
+
+
+class ConfigRepositoryTests(unittest.TestCase):
+    def test_alias_update_uses_copy_and_preserves_original(self) -> None:
+        original_bytes = SOURCE_CONFIG.read_bytes()
+        with tempfile.TemporaryDirectory() as tmp:
+            temp_config = Path(tmp) / "openclaw.json"
+            temp_config.write_bytes(original_bytes)
+
+            repo = OpenClawConfigRepository(temp_config)
+            loaded = repo.load()
+            self.assertIn("models", loaded)
+
+            repo.update_alias("openai/gpt-5.4", "osiris-test")
+            updated = json.loads(temp_config.read_text(encoding="utf-8"))
+            self.assertEqual(updated["agents"]["defaults"]["models"]["openai/gpt-5.4"]["alias"], "osiris-test")
+
+            self.assertEqual(SOURCE_CONFIG.read_bytes(), original_bytes)
+
+    def test_configured_models_parses_expected_shape(self) -> None:
+        with tempfile.TemporaryDirectory() as tmp:
+            temp_config = Path(tmp) / "openclaw.json"
+            temp_config.write_bytes(SOURCE_CONFIG.read_bytes())
+
+            repo = OpenClawConfigRepository(temp_config)
+            models = repo.configured_models()
+
+            self.assertTrue(any(model.full_key == "openai/gpt-5.4" for model in models))
+            self.assertTrue(any(model.full_key == "groq-cloud/openai/gpt-oss-20b" for model in models))
+
+    def test_add_and_remove_model_updates_only_copy(self) -> None:
+        with tempfile.TemporaryDirectory() as tmp:
+            temp_config = Path(tmp) / "openclaw.json"
+            original_bytes = SOURCE_CONFIG.read_bytes()
+            temp_config.write_bytes(original_bytes)
+
+            repo = OpenClawConfigRepository(temp_config)
+            repo.add_model(
+                "openai",
+                {
+                    "id": "gpt-5.4-test",
+                    "name": "GPT-5.4 Test",
+                    "reasoning": True,
+                    "input": ["text"],
+                    "contextWindow": 1024,
+                    "maxTokens": 256,
+                },
+            )
+
+            after_add = repo.load()
+            models = after_add["models"]["providers"]["openai"]["models"]
+            self.assertTrue(any(model["id"] == "gpt-5.4-test" for model in models))
+
+            repo.remove_model("openai/gpt-5.4-test")
+            after_remove = repo.load()
+            models = after_remove["models"]["providers"]["openai"]["models"]
+            self.assertFalse(any(model["id"] == "gpt-5.4-test" for model in models))
+            self.assertEqual(SOURCE_CONFIG.read_bytes(), original_bytes)

+ 97 - 0
tests/test_providers.py

@@ -0,0 +1,97 @@
+from __future__ import annotations
+
+import asyncio
+import tempfile
+from dataclasses import asdict
+from pathlib import Path
+import unittest
+
+from model_selector.cache import SnapshotCache
+from model_selector.providers import derive_features
+from model_selector.service import ModelDashboardService
+from model_selector.config import OpenClawConfigRepository
+
+
+class FakeAdapter:
+    def __init__(self, provider_key: str, models: list[dict]) -> None:
+        self.provider_key = provider_key
+        self.models = models
+
+    async def fetch(self):
+        from model_selector.config import provider_label
+        from model_selector.providers import ProviderModel
+
+        return [
+            ProviderModel(
+                provider_key=self.provider_key,
+                provider_label=provider_label(self.provider_key),
+                model_id=item["id"],
+                name=item.get("name", item["id"]),
+                source="live",
+                reasoning=item.get("reasoning"),
+                context_window=item.get("context_window"),
+                max_tokens=item.get("max_tokens"),
+                pricing_prompt=item.get("pricing_prompt"),
+                pricing_completion=item.get("pricing_completion"),
+                pricing_request=item.get("pricing_request"),
+                input_modalities=tuple(item.get("input_modalities", ("text",))),
+                output_modalities=tuple(item.get("output_modalities", ("text",))),
+                supported_parameters=tuple(item.get("supported_parameters", ())),
+                active=item.get("active"),
+                features=tuple(item.get("features", ())),
+                raw=item,
+            )
+            for item in self.models
+        ]
+
+
+class ProviderTests(unittest.IsolatedAsyncioTestCase):
+    def test_feature_derivation_understands_free_and_structured(self) -> None:
+        raw = {
+            "id": "openai/gpt-oss-20b",
+            "pricing": {"prompt": "0", "completion": "0", "request": "0"},
+            "supported_parameters": ["response_format", "tools"],
+            "architecture": {"input_modalities": ["text"], "output_modalities": ["text"]},
+        }
+        features = derive_features("openrouter", raw)
+        self.assertIn("free", features)
+        self.assertIn("structured output", features)
+        self.assertIn("tool use", features)
+
+    async def test_refresh_writes_snapshot_json(self) -> None:
+        with tempfile.TemporaryDirectory() as tmp:
+            config_path = Path(tmp) / "openclaw.json"
+            cache_dir = Path(tmp) / "cache"
+            config_path.write_text(
+                """
+{
+  "models": {
+    "providers": {
+      "openai": {"models": [{"id": "gpt-5.4", "name": "GPT-5.4", "reasoning": true, "input": ["text"], "contextWindow": 100, "maxTokens": 10}]},
+      "groq-cloud": {"models": [{"id": "openai/gpt-oss-20b", "name": "GPT OSS 20B", "reasoning": true, "input": ["text"], "contextWindow": 100, "maxTokens": 10}]},
+      "openrouter": {"models": [{"id": "openai/gpt-oss-120b", "name": "GPT OSS 120B", "reasoning": true, "input": ["text"], "contextWindow": 100, "maxTokens": 10}]}
+    }
+  },
+  "agents": {"defaults": {"models": {}}}
+}
+""".strip(),
+                encoding="utf-8",
+            )
+
+            repo = OpenClawConfigRepository(config_path)
+            service = ModelDashboardService(
+                repo,
+                SnapshotCache(cache_dir),
+                adapters={
+                    "openai": FakeAdapter("openai", [{"id": "gpt-5.4", "name": "GPT-5.4", "reasoning": True}]),
+                    "groq-cloud": FakeAdapter("groq-cloud", [{"id": "openai/gpt-oss-20b", "name": "GPT OSS 20B", "reasoning": True}]),
+                    "openrouter": FakeAdapter("openrouter", [{"id": "openai/gpt-oss-120b", "name": "GPT OSS 120B"}]),
+                },
+            )
+
+            states = await service.refresh_all()
+
+            self.assertIn("openai", states)
+            self.assertTrue((cache_dir / "openai.json").exists())
+            self.assertTrue((cache_dir / "groq-cloud.json").exists())
+            self.assertTrue((cache_dir / "openrouter.json").exists())