Skill: frank-bot07/model-switchboard (version 3.0.0)

# Model Switchboard v3.0 — Safe AI Model Configuration for OpenClaw > ⛔ **HARD RULE: NEVER edit `openclaw.json` model fields directly.** > Always use this skill's commands. No exceptions. Ever. ## Why This Exists Editing `openclaw.json` directly for model changes is the #1 cause of OpenClaw gateway crashes. Wrong model type in wrong slot = instant death. No backup = hours rebuilding. This skill eliminates that entirely. ## How It Works 1. **Validates** model format and role compatibility before any change 2. **Auto-backs up** config before every modification (30 rolling backups) 3. **Uses OpenClaw CLI** (`openclaw models set`) — never raw JSON 4. **Blocks unsafe assignments** (image-gen model as primary LLM = blocked) 5. **Instant rollback** if anything goes wrong 6. **Canvas UI** for visual model management ## Quick Reference ```bash SWITCHBOARD="$SKILL_DIR/scripts/switchboard.sh" # View current setup $SWITCHBOARD status # Change models $SWITCHBOARD set-primary "anthropic/claude-opus-4-6" $SWITCHBOARD set-image "google/gemini-3-pro-preview" $SWITCHBOARD add-fallback "openai/gpt-5.2" $SWITCHBOARD remove-fallback "openai/gpt-5.2" $SWITCHBOARD add-image-fallback "openai/gpt-5.1" # Preview before applying $SWITCHBOARD dry-run set-primary "openai/gpt-5.2" # Discovery & recommendations $SWITCHBOARD discover # List all available models $SWITCHBOARD recommend # Get optimal suggestions # Redundancy (3-deep failover) $SWITCHBOARD redundancy # Assess current redundancy $SWITCHBOARD redundancy-deploy # Preview optimal config $SWITCHBOARD redundancy-apply # Apply optimal config $SWITCHBOARD redundancy-apply 4 # Custom depth # Backup & restore $SWITCHBOARD backup # Manual backup $SWITCHBOARD list-backups # Show all backups $SWITCHBOARD restore latest # Undo last change # Import/Export (portable model configs) $SWITCHBOARD export config.json $SWITCHBOARD import config.json # Cron model validation $SWITCHBOARD validate-cron-models # Check cron jobs use valid models # Diagnostics $SWITCHBOARD health # Gateway + provider status $SWITCHBOARD validate <model> <role> # Test compatibility ``` ## Model Roles | Role | Purpose | Config Key | |------|---------|-----------| | **Primary** | Main LLM for all conversations | `agents.defaults.model.primary` | | **Fallback** | Ordered backup LLMs | `agents.defaults.model.fallbacks` | | **Image** | Vision/image processing | `agents.defaults.imageModel.primary` | | **Image Fallback** | Backup vision models | `agents.defaults.imageModel.fallbacks` | | **Heartbeat** | Low-cost polling model | `agents.defaults.heartbeat.model` | | **Coding** | Sub-agent code generation | Spawn-time model param | ## Validation Rules The validation engine (`scripts/validate.py`) enforces: - **Format**: Must be `provider/model-name` (e.g., `anthropic/claude-opus-4-6`) - **Capability match**: LLM roles require `llm` + `tools` capabilities - **Image roles**: Require `vision` capability - **Hard blocks**: Image-generation-only models (DALL-E, Stability) blocked from ALL LLM roles - **Registry warnings**: Unknown models get a caution warning but are allowed (for OpenRouter/new models) ## Known Providers - `anthropic` — Claude family (Opus, Sonnet, Haiku) - `openai` — GPT family - `openai-codex` — Codex OAuth models - `google` — Gemini family - `opencode` — Zen proxy (routes to various models) - `zai` — GLM family - `xai` — Grok family - `openrouter` — Multi-provider gateway - `groq`, `cerebras` — Fast inference ## Canvas UI To show the visual dashboard: ```bash # Get UI data DATA=$($SWITCHBOARD ui) # Present via canvas # The UI reads window.__switchboardData JSON ``` The Canvas UI at `ui/index.html` shows: - Primary LLM and Image model with color coding - Fallback chains (ordered) - Provider auth status (green/red indicators) - Model allowlist - Config issues with severity levels - Backup count ## For Agents: Operating Protocol When a user asks to change model assignments: 1. **Read this SKILL.md first** 2. **Show current status**: `$SWITCHBOARD status` 3. **Preview the change**: `$SWITCHBOARD dry-run <action> <model>` 4. **Confirm with user** before applying 5. **Apply**: `$SWITCHBOARD <action> <model>` 6. **Verify**: Check gateway health after change **NEVER:** - Edit `openclaw.json` directly for model fields - Skip the dry-run for primary model changes - Apply without user confirmation - Ignore validation failures ## Troubleshooting **Gateway won't start:** ```bash $SWITCHBOARD restore latest openclaw gateway restart # Or: openclaw doctor --fix ``` **"Model is not allowed" error:** Model isn't in the allowlist. Add it or clear the list: ```bash openclaw config set 'agents.defaults.models."provider/model"' '{"alias":"Name"}' # Or clear: openclaw config unset agents.defaults.models ``` **Unknown model warning:** The model isn't in `model-registry.json`. Add it for future validation: ```bash # Edit model-registry.json to add the model entry ``` ## File Structure ``` model-switchboard/ ├── SKILL.md # This file — agent instructions ├── README.md # ClawHub publishing readme ├── model-registry.json # Known model capabilities database ├── scripts/ │ ├── switchboard.sh # Main CLI tool (bash) │ └── validate.py # Validation engine (python3, no deps) └── ui/ └── index.html # Canvas dashboard (single-file, no deps) ```

path: ARCHITECTURE.md
content: # Model Switchboard — Architecture & Vision

## Mission

**No OpenClaw agent should ever go completely dead because of a model configuration error.**

Every OpenClaw user — from solo hobbyists to enterprise deployments — deserves:
1. **Per-task model routing** — right model for the right job
2. **3-deep fallback chains** — if A fails, B catches it, if B fails, C catches it
3. **One-command recovery** — never spend hours rebuilding
4. **Visual management** — see your entire model tree at a glance
5. **Fail-closed safety** — bad config is rejected before it can crash anything

## The Problem We're Solving

Today, OpenClaw users face these failure modes:

1. **Wrong model in wrong slot** → Gateway crash → Hours of manual recovery
2. **Single model dependency** → Provider outage → Complete system death
3. **No task-level routing** → Expensive models doing cheap jobs → Wasted money
4. **No backup strategy** → Config corruption → Start from scratch
5. **Agent self-modification** → AI edits config wrong → Cascading failure

## Architecture

```
┌─────────────────────────────────────────────────────────┐
│                    USER / AGENT                          │
│           "Set Gemini for images"                        │
└───────────────────┬─────────────────────────────────────┘
                    │
┌───────────────────▼─────────────────────────────────────┐
│              MODEL SWITCHBOARD                           │
│                                                          │
│  ┌──────────┐  ┌──────────┐  ┌──────────┐              │
│  │ Validate  │  │  Backup  │  │  Route   │              │
│  │  Engine   │→ │  Engine  │→ │  Engine  │              │
│  └──────────┘  └──────────┘  └──────────┘              │
│       │                            │                     │
│  ┌────▼────┐                 ┌─────▼─────┐              │
│  │Registry │                 │  OpenClaw  │              │
│  │  JSON   │                 │    CLI     │              │
│  └─────────┘                 └─────┬─────┘              │
│                                    │                     │
└────────────────────────────────────┼─────────────────────┘
                                     │
┌────────────────────────────────────▼─────────────────────┐
│                   openclaw.json                           │
│                                                           │
│  model.primary ──→ model.fallbacks[0] ──→ fallbacks[1]   │
│       │                    │                    │         │
│   Anthropic            OpenAI              Google         │
│   Claude Opus         GPT-5.2          Gemini 3 Pro      │
│                                                           │
│  imageModel.primary ──→ imageFallbacks[0]                │
│       │                       │                           │
│   Claude Opus            Gemini 3 Pro                    │
│                                                           │
│  Per-agent / Per-cron model overrides                    │
│       │                                                   │
│   heartbeat → Haiku/Flash (cheap)                        │
│   coding → Opus/Codex (powerful)                         │
│   research → Grok (search-enabled)                       │
└───────────────────────────────────────────────────────────┘
```

## Task-Model Routing Matrix

| Task | Priority | Why | Cost Tier |
|------|----------|-----|-----------|
| **Conversation** | Highest quality | User-facing, needs best reasoning | High |
| **Coding** | High quality + tools | Needs code generation, file editing | High |
| **Image/Vision** | Vision capability | Must process images/screenshots | Medium-High |
| **Research/Search** | Search-enabled | Web search, data gathering | Medium |
| **Heartbeat** | Lowest cost | Simple polling, status checks | Very Low |
| **Cron Jobs** | Varies by job | Some need quality, some just run scripts | Low-Medium |
| **Sub-agents** | Task-dependent | Spawn with appropriate model | Varies |

## Redundancy Strategy: "Never Dead"

### Tier 1: Primary Chain (3 deep minimum)
```
Primary → Fallback 1 → Fallback 2 → [Fallback 3...]
```
Every role has AT LEAST 3 models. If all paid models fail, the system falls through to free OpenRouter models as last resort.

### Tier 2: Provider Diversity
Never stack the same provider in a fallback chain. If Anthropic is primary, fallbacks should be OpenAI and Google — not another Anthropic model.

### Tier 3: API Key Rotation
OpenClaw supports multiple keys per provider:
```
ANTHROPIC_API_KEY, ANTHROPIC_API_KEY_1, ANTHROPIC_API_KEY_2
```
Rate limit on key 1? Automatically rotates to key 2.

### Tier 4: Config Backup Mesh
- Auto-backup before every change (30 rolling)
- Daily backup to external drive
- Export/import for portable configs
- One-command restore: `switchboard.sh restore latest`

## Deployment Phases

### Phase 1: Core Safety (COMPLETE ✅)
- Validation engine with fail-closed design
- Auto-backup before changes
- Role compatibility checking
- Canvas UI dashboard

### Phase 2: Full Redundancy (IN PROGRESS)
- 3-deep fallback chains for all roles
- Per-task model routing config
- Provider diversity enforcement
- Auto-discovery of available models

### Phase 3: Smart Routing (PLANNED)
- Cost-aware model selection
- Latency-aware failover
- Usage tracking per model
- Budget limits per task type

### Phase 4: Community Features (PLANNED)
- Shareable model configs ("presets")
- Community-voted optimal configurations
- Provider status dashboard
- Model benchmark integration

## Open Source Strategy

This skill will be published to ClawHub as free, open-source software:
- MIT License
- Zero external dependencies
- Works on macOS, Linux, Windows (WSL)
- Python 3.8+ and Bash only
- No API keys required for the tool itself
- Comprehensive documentation

**Goal: Become the standard model management tool for every OpenClaw installation.**


path: AUDIT-OPUS.md
content: # Model Switchboard v2.0 — Security & Code Audit

**Auditor:** Claude Opus 4.6 (subagent)
**Date:** 2026-02-25
**Scope:** All source files (switchboard.sh, validate.py, redundancy.py, model-registry.json, task-routing.json, ui/index.html, SKILL.md, README.md)
**Verdict:** **CONDITIONAL PASS** — No critical vulnerabilities found. Several medium/low issues to address before ClawHub publish.

---

## Summary

The codebase is well-architected with strong safety fundamentals: fail-closed validation, atomic writes, automatic backups, and no direct config editing. The code quality is above average for a skill of this scope. However, there are real issues that should be fixed.

**Findings by severity:**
- CRITICAL: 0
- HIGH: 2
- MEDIUM: 5
- LOW: 6

---

## HIGH Findings

### H-1: XSS via innerHTML in UI allowlist and Telegram user rendering
**Files:** `ui/index.html` lines ~610, ~840-860
**Severity:** HIGH

The `renderAllowlist()` function uses innerHTML with model names:
```js
t.innerHTML = `${esc(m)} <button class="xbtn" ... onclick="removeAllow('${esc(m).replace(/'/g, "\\'")}')">x</button>`;
```

Similarly `renderTelegramUsers()`:
```js
tag.innerHTML = `${esc(u)} <button class="xbtn" ... onclick="removeTelegramUser('${esc(u).replace(/'/g, "\\'")}')">x</button>`;
```

The `esc()` function only does `String()` — it does NOT HTML-encode. A model name like `<img onerror=alert(1) src=x>` would execute. While model names are validated server-side via regex (`^[a-zA-Z0-9][a-zA-Z0-9._/-]*$`), the UI receives data from an API and shouldn't trust it.

**Fix:** Replace `esc()` with proper HTML escaping:
```js
function esc(v) {
  const s = String(v == null ? '' : v);
  const d = document.createElement('div');
  d.textContent = s;
  return d.innerHTML;
}
```
Or switch to `textContent` + `createElement` pattern (as used in `renderRoles()`).

### H-2: Shell injection possible via model names in inline Python
**Files:** `switchboard.sh` lines ~620-640 (import_config function)
**Severity:** HIGH

In `import_config()`, the `$input` variable is interpolated directly into a Python string as a fallback:
```bash
imp_primary=$(python3 -c "
import json, os
with open(os.environ.get('SWITCHBOARD_IMPORT_FILE', '$input')) as f:
```

If `$input` contains a single quote, this breaks the Python string and allows code injection. While the file path comes from a CLI argument (not untrusted user input in most cases), this is still a bad pattern.

**Fix:** Always use the env var, never the shell interpolation fallback:
```bash
SWITCHBOARD_IMPORT_FILE="$input" python3 -c "
import json, os
with open(os.environ['SWITCHBOARD_IMPORT_FILE']) as f:
```
(This pattern IS correctly used in the atomic-write Python block in the same function — just not in the extraction blocks.)

---

## MEDIUM Findings

### M-1: No cron job model validation against allowlist
**Severity:** MEDIUM

**There is no code anywhere** that validates cron job model assignments against the model registry or allowlist. The `task-routing.json` defines a `cron` routing to `google/gemini-2.5-flash`, but nothing validates that cron jobs spawned by OpenClaw use only allowed/safe models. A cron job could theoretically be configured to use a blocked or unsafe model.

**Fix:** Add a `validate_cron_models()` function to validate.py that checks:
1. All cron job model references against the registry
2. That cron models have minimum required capabilities for their task type
3. Add a `cron-validate` command to switchboard.sh

### M-2: Stale model reference in task-routing.json
**File:** `task-routing.json` line 13
**Severity:** MEDIUM

`google/gemini-2.5-flash` is listed as the cron and heartbeat model. While this model IS in the registry, the registry marks it as "Gemini 2.5 Flash" — an older generation model. The registry has `google/gemini-3-flash-preview` available. This isn't a bug (the model still works), but for a production ClawHub skill, the default task routing should reference current-generation models.

**Fix:** Update task-routing.json:
```json
"cron": { "model": "google/gemini-3-flash-preview", ... },
"heartbeat": { "model": "google/gemini-3-flash-preview", ... }
```

### M-3: Race condition on backup pruning
**File:** `switchboard.sh` lines ~130-140
**Severity:** MEDIUM

The backup pruning logic uses `ls -t | tail | xargs rm -f`. If two switchboard commands run concurrently (e.g., two agents, or rapid CLI calls), both could backup and prune simultaneously, potentially deleting each other's backups or counting wrong.

**Fix:** Use a lockfile for backup operations:
```bash
LOCKFILE="$BACKUP_DIR/.switchboard.lock"
(
  flock -n 9 || { log_warn "Backup lock held — skipping prune"; return; }
  # ... prune logic ...
) 9>"$LOCKFILE"
```

### M-4: redundancy.py fails open when registry can't load
**File:** `redundancy.py` lines 21-26
**Severity:** MEDIUM

`load_registry()` in redundancy.py returns `{"models": {}, "providers": {}, "roles": {}}` on failure (no strict mode option). This means `generate_redundant_config()` will produce an empty config with no models, which then gets applied via `redundancy-apply`. The error path returns `{"error": ...}` only when no providers are detected, not when registry fails.

The shell wrapper does check for empty output and error keys, so this is partially mitigated. But the Python function itself should be more defensive.

**Fix:** Add an explicit registry validation check at the top of `generate_redundant_config()`:
```python
registry = load_registry()
if not registry.get("models"):
    return {"error": "Model registry is empty or corrupt. Cannot generate redundancy config."}
```

### M-5: Atomic write in import_config validates after write, not before
**File:** `switchboard.sh` lines ~680-695
**Severity:** MEDIUM

The inline Python for import does:
```python
json.dump(cfg, f, indent=2)  # Write to temp
json.load(open(tmp))          # Re-read to validate
os.rename(tmp, config_path)   # Move into place
```

The re-read validation only checks JSON parse validity, not schema validity. A structurally valid JSON that is semantically wrong (e.g., model set to an integer) would pass this check. The validate.py engine IS called for individual model refs before this point, but the final merged config isn't schema-validated.

**Fix:** After the atomic write, call `validate_config_schema()` on the merged config before renaming:
```python
issues = validate_config_schema(cfg)
errors = [i for i in issues if i['level'] == 'error']
if errors:
    os.unlink(tmp)
    print('VALIDATION_FAILED')
    sys.exit(1)
```

---

## LOW Findings

### L-1: Version string mismatch
**Files:** SKILL.md says "v2.0", README.md says "v2.0", switchboard.sh header says "v2.0", but ui/index.html title says "v2". The task referenced "v3".
**Fix:** Decide on the canonical version and update all files.

### L-2: Dead code — `setup.sh` referenced but not audited
**File:** `switchboard.sh` line ~960: `bash "$SKILL_DIR/scripts/setup.sh"`
The setup command delegates to `scripts/setup.sh` which was not provided for audit. If it doesn't exist, the command silently fails.
**Fix:** Verify setup.sh exists or add a guard: `[ -f "$SKILL_DIR/scripts/setup.sh" ] || { log_error "setup.sh not found"; exit 1; }`

### L-3: stderr suppression masks real errors
**File:** `switchboard.sh` — many `2>/dev/null` on python3 calls (lines ~60, 70, 80, etc.)
Python errors (import failures, syntax errors, crashes) are silently swallowed. The fail-closed design mitigates this (empty output = reject), but debugging becomes harder.
**Fix:** Log stderr to a temp file or the backup directory for diagnostics.

### L-4: Backup permissions — umask not set
**File:** `switchboard.sh` line ~125
`chmod 600 "$backup_file"` is good, but the `mkdir -p "$BACKUP_DIR"` doesn't set directory permissions. On a multi-user system, the backup directory could be world-readable.
**Fix:** Add `chmod 700 "$BACKUP_DIR"` after mkdir.

### L-5: model-registry.json — `openai/dall-e-3` has empty safeRoles
**File:** `model-registry.json` ~line 240
DALL-E 3 has `"safeRoles": []` AND `"blocked": true`. The blocked flag is sufficient, but the empty safeRoles is redundant. Not a bug, just noise.

### L-6: UI uses `innerHTML` in several render functions with `esc()`
**File:** `ui/index.html` — `renderRoles()`, `renderPipeline()`, `renderProviders()`, etc.
Similar to H-1 but these use `esc()` on values that come from the model registry (controlled data). Lower risk than H-1 because the data source is a local JSON file, not user input. Still best practice to use textContent.

---

## Positive Findings (What's Done Well)

1. **Fail-closed validation** — validate.py correctly blocks on registry load failure in strict mode (line ~30). The shell wrapper treats empty/error responses as rejections. This is textbook correct.

2. **Atomic writes** — Config changes use temp file → validate → rename pattern. This prevents corruption on crash/power loss.

3. **Operation-specific rollback** — Each change tracks its own backup file with PID in timestamp. Rollback restores the exact pre-operation state, not just "latest."

4. **No direct JSON editing** — All config changes go through `openclaw models set` CLI. The skill never does raw JSON manipulation of model fields (except import, which validates first).

5. **Model format regex** — `validate_model_ref()` uses strict regex that blocks shell metacharacters, path traversal (`../`), and null bytes. A malicious model name like `anthropic/../../etc/passwd` is rejected by the regex.

6. **Provider diversity enforcement** — redundancy.py correctly ensures fallback chains don't stack the same provider.

7. **Registry completeness** — 50+ models across 20+ providers with capability/role metadata. Well-maintained.

---

## Recommendations for ClawHub Publish

1. **Fix H-1 and H-2** before publishing — these are real injection vectors even if exploitation requires unusual conditions.
2. **Add M-1** (cron validation) — this is a feature gap that users will expect.
3. **Update M-2** (stale model refs) — easy win for credibility.
4. **Add integration tests** — no test suite was found. At minimum, test validate.py functions with edge cases (empty strings, special chars, corrupt JSON).
5. **Add a CHANGELOG.md** — version history for ClawHub users.

---

*Audit complete. No showstoppers for a private deployment, but H-1 and H-2 should be fixed before public ClawHub publishing.*


path: CHANGELOG.md
content: # Changelog

## v3.0 — 2026-02-25

Full security audit by Claude Opus with all findings fixed.

### Security Fixes (HIGH)

- **H-1: XSS in UI fixed** — Replaced broken `esc()` function with proper HTML encoding via `textContent`/`createElement`. All `innerHTML` usages with user/model data converted to safe DOM APIs. Inline `onclick` handlers with string interpolation replaced with `data-*` attributes and `addEventListener`.
- **H-2: Shell injection in import_config fixed** — Removed all `$input` shell variable interpolation inside inline Python strings. All file paths now passed exclusively via environment variables (`SWITCHBOARD_IMPORT_FILE`).

### New Features (MEDIUM)

- **M-1: Cron model validation** — New `validate-cron-models` command validates all model references in cron jobs against the registry and allowlist. Added `validate_cron_models()` to validate.py.
- **M-2: Updated task-routing.json** — Changed `google/gemini-2.5-flash` to `google/gemini-3-flash-preview` for cron and heartbeat routing.

### Bug Fixes (MEDIUM)

- **M-3: Backup pruning race condition** — Added `flock`-based locking to prevent concurrent backup pruning operations.
- **M-4: Redundancy engine fail-open** — Added registry validation at top of `generate_redundant_config()`. Returns error if registry is empty or corrupt instead of generating empty config.
- **M-5: Schema validation on import** — Import atomic write now validates merged config schema before committing (rename). Invalid schemas cause the import to abort.

### Improvements (LOW)

- **L-1: Version updated to v3.0** across all files (SKILL.md, README.md, UI title, switchboard.sh header).
- **L-2: Setup script guard** — `setup`/`init` command now checks `setup.sh` exists before executing.
- **L-3: Python stderr logging** — Validation engine stderr redirected to `$BACKUP_DIR/switchboard-stderr.log` instead of `/dev/null` for debugging.
- **L-4: Backup directory permissions** — `chmod 700` applied to backup directory after creation.
- **L-6: innerHTML elimination** — All remaining `innerHTML` + `esc()` patterns in the UI converted to `textContent`/`createElement` (renderRoles, renderPipeline, renderProviders, renderChannels, renderIssues, drawPickerList, renderStatus).

## v2.0 — 2026-02-24

Initial release with full model orchestration, validation engine, redundancy engine, backup/restore, and Canvas UI.


path: README.md
content: # 🔀 Model Switchboard

**Never crash your OpenClaw gateway again.**

Safe, redundant AI model configuration with automatic fallback chains, validation, and one-command recovery. Built for every OpenClaw user — from first install to production deployment.

## The Problem

Editing `openclaw.json` directly for model changes is the #1 cause of OpenClaw gateway crashes:

- Put an image model as primary LLM → **gateway dead**
- Typo in model reference → **gateway dead**
- Provider goes down, no fallback → **gateway dead**
- No config backup → **hours rebuilding from scratch**

## The Solution

Model Switchboard gives you:

- **3-deep fallback chains** — if Model A fails, B catches it, then C
- **Provider diversity** — never stack the same provider in a chain
- **Pre-flight validation** — blocks unsafe assignments before they touch config
- **Auto-backup** — snapshots config before every change (30 rolling)
- **One-command recovery** — `restore latest` = 2 seconds, not 5 hours
- **Per-task routing** — right model for the right job (conversation, coding, images, heartbeat)
- **Visual dashboard** — see your entire model tree at a glance
- **Setup wizard** — guided first-time configuration

## Quick Start

```bash
# First time? Run the setup wizard:
./scripts/switchboard.sh setup

# See current model assignments:
./scripts/switchboard.sh status

# Auto-generate 3-deep redundant config:
./scripts/switchboard.sh redundancy-apply

# Change a model (validates + backs up first):
./scripts/switchboard.sh set-primary anthropic/claude-opus-4-6

# Something broke? Instant recovery:
./scripts/switchboard.sh restore latest
```

## All Commands

**Getting Started**
- `setup` / `init` — First-time setup wizard with provider detection

**Model Assignment**
- `status` — Show current model tree + health
- `set-primary <model>` — Set primary LLM
- `set-image <model>` — Set image/vision model
- `add-fallback <model>` — Add LLM fallback
- `remove-fallback <model>` — Remove LLM fallback
- `add-image-fallback <model>` — Add image fallback
- `remove-image-fallback <model>` — Remove image fallback

**Redundancy**
- `redundancy [depth]` — Assess current redundancy (default: 3-deep)
- `redundancy-deploy [depth]` — Preview optimal redundant config
- `redundancy-apply [depth]` — Apply redundant config to live gateway

**Discovery & Planning**
- `discover` — List all available models
- `recommend` — Suggest optimal assignments per role
- `dry-run <action> <model>` — Preview changes without applying
- `validate <model> <role>` — Test model-role compatibility

**Backup & Restore**
- `backup` — Manual config backup
- `list-backups` — Show available backups (30 rolling)
- `restore <file|latest>` — Instant restore from any backup

**Import / Export**
- `export [file]` — Export model config as portable JSON
- `import <file>` — Import config (validates all models before applying)

**Diagnostics**
- `health` — Gateway + provider auth status
- `ui` — Generate Canvas dashboard data

## How Redundancy Works

Model Switchboard builds fallback chains with three rules:

1. **Minimum 3 models deep** per critical role
2. **Provider diversity** — never use the same provider twice in a chain
3. **Cost-appropriate** — expensive models for conversation, cheap ones for heartbeat

```
Primary LLM:  anthropic/claude-opus → openai/gpt-5.2 → google/gemini-3-pro
Image:         anthropic/claude-opus → google/gemini-3-pro → openai/gpt-5.1
Heartbeat:     google/gemini-flash → minimax/m1 → groq/llama-4
Coding:        anthropic/claude-opus → openai/gpt-5.2 → google/gemini-3-pro
```

If Anthropic goes down → OpenAI catches it.
If OpenAI goes down → Google catches it.
**Your agent never dies.**

## Task-Model Routing

Different tasks need different models. The dashboard shows two types of roles:

### Core Roles (auto-wired)
These map directly to `openclaw.json` and work immediately:
- **Primary LLM** — Your main conversational model
- **LLM Fallbacks** — Ordered backup chain for primary
- **Image Model** — Vision/image processing
- **Image Fallbacks** — Backup chain for vision

### Extended Roles (planning & reference)
These show as "(not set)" by default. They're a **visual planner** for assigning models to task types. Click any role to assign a model — the assignment is saved to `task-routing.json` and can be referenced when configuring cron jobs and sub-agent spawns.

- **Research** — Deep analysis, web search (e.g., Grok for native search)
- **Coding Pass 1/2/3** — Multi-pass code generation with provider diversity enforcement
- **Social Media** — Creative content generation
- **Web Ops** — Search/scrape capable models
- **Heartbeat** — Cheapest model for periodic polling (e.g., Haiku, Flash)

Extended roles don't auto-wire into OpenClaw's routing yet — they're a reference for how you want to assign models across your automation. Use them to plan your cron job model assignments and sub-agent configurations, then set the actual model param on each job accordingly.

**Example workflow:**
1. Set Research role → `xai/grok-4-fast` in the dashboard
2. When creating research cron jobs, use `model: "xai/grok-4-fast"` in the job config
3. The `validate-cron-models` command checks that all your cron jobs use valid, allowed models

## Supported Providers

Works with every OpenClaw-compatible provider:

- **Anthropic** — Claude Opus, Sonnet, Haiku
- **OpenAI** — GPT-5.x family, Codex
- **Google** — Gemini 3 Pro, 2.5 Flash, Vertex AI
- **OpenCode Zen** — Multi-model proxy
- **Z.AI** — GLM family
- **xAI** — Grok (search-enabled)
- **OpenRouter** — 300+ models from all providers
- **Groq** — Ultra-fast inference
- **Cerebras** — Fast inference
- **MiniMax** — Cost-effective LLM
- **Vercel AI Gateway** — Enterprise proxy

## Safety Guarantees

1. **Fail-closed** — if validation can't run, changes are blocked (not allowed through)
2. **Type enforcement** — image-gen models (DALL-E) can never be set as primary LLM
3. **Atomic writes** — config written to temp file, validated, then renamed (no corruption)
4. **Operation-specific rollback** — each change tracks its own backup (not just "latest")
5. **XSS-safe dashboard** — all data rendered via textContent/createElement, never raw innerHTML
6. **Opus-audited** — full security audit by Claude Opus, all findings fixed

## Requirements

- **OpenClaw** — any recent version
- **Python 3.8+** — pre-installed on macOS and most Linux
- **Bash 3.2+** — pre-installed on macOS, Linux, WSL
- **No external dependencies** — no npm, no pip, no network required

## File Structure

```
model-switchboard/
├── SKILL.md                 — Agent instructions (read by OpenClaw agents)
├── README.md                — This file
├── ARCHITECTURE.md          — Vision and design docs
├── model-registry.json      — Known model capabilities (22 models, 14 providers)
├── scripts/
│   ├── switchboard.sh       — Main CLI (21 commands)
│   ├── validate.py          — Fail-closed validation engine
│   ├── redundancy.py        — Auto-generates optimal fallback chains
│   └── setup.sh             — First-time setup wizard
└── ui/
    └── index.html           — Canvas dashboard (XSS-safe)
```

## For OpenClaw Agents

If you're an AI agent reading this: **NEVER edit `openclaw.json` model fields directly.** Always use this skill's commands. Read `SKILL.md` for complete operating instructions.

## Contributing

- **Add models:** Edit `model-registry.json` with new model capabilities
- **Add providers:** Add to the providers section with auth env vars
- **Report bugs:** Open an issue with your OS, Python version, and error output
- **Share configs:** Use `export` to share your optimal model setup

## License

MIT — Free and open source for every OpenClaw user.

---

*Built with love for the OpenClaw community. No agent should ever go dark because of a config mistake.*


path: model-registry.json
content: {
  "version": "2.0.0",
  "lastUpdated": "2026-02-22",
  "note": "Comprehensive registry of ALL OpenClaw-compatible models. Source: OpenClaw v2026.2.21-2 source code + official docs.",
  "models": {
    "anthropic/claude-opus-4-6": {
      "displayName": "Claude Opus 4.6",
      "capabilities": ["llm", "vision", "tools", "code", "reasoning"],
      "costTier": "high",
      "contextWindow": 200000,
      "safeRoles": ["primary", "fallback", "image", "coding"],
      "unsafeRoles": []
    },
    "anthropic/claude-opus-4-5": {
      "displayName": "Claude Opus 4.5",
      "capabilities": ["llm", "vision", "tools", "code", "reasoning"],
      "costTier": "high",
      "contextWindow": 200000,
      "safeRoles": ["primary", "fallback", "image", "coding"],
      "unsafeRoles": []
    },
    "anthropic/claude-sonnet-4-6": {
      "displayName": "Claude Sonnet 4.6",
      "capabilities": ["llm", "vision", "tools", "code", "reasoning"],
      "costTier": "medium",
      "contextWindow": 200000,
      "safeRoles": ["primary", "fallback", "image", "coding"],
      "unsafeRoles": []
    },
    "anthropic/claude-sonnet-4-5": {
      "displayName": "Claude Sonnet 4.5",
      "capabilities": ["llm", "vision", "tools", "code", "reasoning"],
      "costTier": "medium",
      "contextWindow": 200000,
      "safeRoles": ["primary", "fallback", "image", "coding"],
      "unsafeRoles": []
    },
    "anthropic/claude-haiku-4-5": {
      "displayName": "Claude Haiku 4.5",
      "capabilities": ["llm", "vision", "tools"],
      "costTier": "low",
      "contextWindow": 200000,
      "safeRoles": ["primary", "fallback", "image", "heartbeat"],
      "unsafeRoles": []
    },
    "openai/gpt-5.2": {
      "displayName": "GPT-5.2",
      "capabilities": ["llm", "vision", "tools", "code"],
      "costTier": "high",
      "contextWindow": 128000,
      "safeRoles": ["primary", "fallback", "image", "coding"],
      "unsafeRoles": []
    },
    "openai/gpt-5.1-codex": {
      "displayName": "GPT-5.1 Codex",
      "capabilities": ["llm", "vision", "tools", "code"],
      "costTier": "medium",
      "contextWindow": 128000,
      "safeRoles": ["primary", "fallback", "image", "coding"],
      "unsafeRoles": []
    },
    "openai/gpt-5-mini": {
      "displayName": "GPT-5 Mini",
      "capabilities": ["llm", "vision", "tools"],
      "costTier": "low",
      "contextWindow": 128000,
      "safeRoles": ["primary", "fallback", "image", "heartbeat"],
      "unsafeRoles": []
    },
    "openai/gpt-oss-120b": {
      "displayName": "GPT OSS 120B",
      "capabilities": ["llm", "tools", "code"],
      "costTier": "medium",
      "contextWindow": 128000,
      "safeRoles": ["primary", "fallback", "coding"],
      "unsafeRoles": []
    },
    "openai-codex/gpt-5.3-codex": {
      "displayName": "GPT-5.3 Codex (OAuth)",
      "capabilities": ["llm", "tools", "code"],
      "costTier": "subscription",
      "contextWindow": 128000,
      "safeRoles": ["primary", "fallback", "coding"],
      "unsafeRoles": ["image"],
      "authMethod": "oauth"
    },
    "openai-codex/gpt-5.3-codex-spark": {
      "displayName": "GPT-5.3 Codex Spark (OAuth)",
      "capabilities": ["llm", "tools", "code"],
      "costTier": "subscription",
      "contextWindow": 128000,
      "safeRoles": ["primary", "fallback", "coding"],
      "unsafeRoles": ["image"],
      "authMethod": "oauth"
    },
    "openai-codex/gpt-5.2-codex": {
      "displayName": "GPT-5.2 Codex (OAuth)",
      "capabilities": ["llm", "tools", "code"],
      "costTier": "subscription",
      "contextWindow": 128000,
      "safeRoles": ["primary", "fallback", "coding"],
      "unsafeRoles": ["image"],
      "authMethod": "oauth"
    },
    "openai-codex/gpt-5.2": {
      "displayName": "GPT-5.2 (Codex OAuth)",
      "capabilities": ["llm", "tools", "code"],
      "costTier": "subscription",
      "contextWindow": 128000,
      "safeRoles": ["primary", "fallback", "coding"],
      "unsafeRoles": ["image"],
      "authMethod": "oauth"
    },
    "openai-codex/gpt-5.1-codex": {
      "displayName": "GPT-5.1 Codex (OAuth)",
      "capabilities": ["llm", "tools", "code"],
      "costTier": "subscription",
      "contextWindow": 128000,
      "safeRoles": ["primary", "fallback", "coding"],
      "unsafeRoles": ["image"],
      "authMethod": "oauth"
    },
    "google/gemini-3-pro-preview": {
      "displayName": "Gemini 3 Pro Preview",
      "capabilities": ["llm", "vision", "tools", "code"],
      "costTier": "medium",
      "contextWindow": 1000000,
      "safeRoles": ["primary", "fallback", "image", "coding"],
      "unsafeRoles": []
    },
    "google/gemini-3-flash-preview": {
      "displayName": "Gemini 3 Flash Preview",
      "capabilities": ["llm", "vision", "tools"],
      "costTier": "low",
      "contextWindow": 1000000,
      "safeRoles": ["primary", "fallback", "image", "heartbeat"],
      "unsafeRoles": []
    },
    "google/gemini-2.5-flash": {
      "displayName": "Gemini 2.5 Flash",
      "capabilities": ["llm", "vision", "tools"],
      "costTier": "very-low",
      "contextWindow": 1000000,
      "safeRoles": ["primary", "fallback", "image", "heartbeat"],
      "unsafeRoles": []
    },
    "google-antigravity/claude-opus-4-5": {
      "displayName": "Claude Opus 4.5 (Antigravity)",
      "capabilities": ["llm", "vision", "tools", "code", "reasoning"],
      "costTier": "free",
      "contextWindow": 200000,
      "safeRoles": ["primary", "fallback", "image", "coding"],
      "unsafeRoles": [],
      "authMethod": "oauth-plugin",
      "note": "Requires openclaw plugins enable google-antigravity-auth"
    },
    "google-antigravity/claude-opus-4-5-thinking": {
      "displayName": "Claude Opus 4.5 Thinking (Antigravity)",
      "capabilities": ["llm", "vision", "tools", "code", "reasoning"],
      "costTier": "free",
      "contextWindow": 200000,
      "safeRoles": ["primary", "fallback", "image", "coding"],
      "unsafeRoles": [],
      "authMethod": "oauth-plugin",
      "note": "Requires openclaw plugins enable google-antigravity-auth"
    },
    "opencode/claude-opus-4-6": {
      "displayName": "Claude Opus 4.6 (Zen)",
      "capabilities": ["llm", "vision", "tools", "code", "reasoning"],
      "costTier": "proxy",
      "contextWindow": 200000,
      "safeRoles": ["primary", "fallback", "image", "coding"],
      "unsafeRoles": []
    },
    "zai/glm-5": {
      "displayName": "GLM 5",
      "capabilities": ["llm", "tools", "code"],
      "costTier": "medium",
      "contextWindow": 128000,
      "safeRoles": ["primary", "fallback", "coding"],
      "unsafeRoles": [],
      "note": "Better for coding/tool calling per OpenClaw docs"
    },
    "zai/glm-4.7": {
      "displayName": "GLM 4.7",
      "capabilities": ["llm", "tools", "code"],
      "costTier": "medium",
      "contextWindow": 128000,
      "safeRoles": ["primary", "fallback", "coding"],
      "unsafeRoles": [],
      "note": "Better for coding/tool calling per OpenClaw docs"
    },
    "zai/glm-4.6v": {
      "displayName": "GLM 4.6 Vision",
      "capabilities": ["llm", "vision", "tools", "code"],
      "costTier": "low",
      "contextWindow": 128000,
      "safeRoles": ["primary", "fallback", "image", "coding", "heartbeat"],
      "unsafeRoles": []
    },
    "xai/grok-3": {
      "displayName": "Grok 3",
      "capabilities": ["llm", "tools", "search"],
      "costTier": "medium",
      "contextWindow": 131072,
      "safeRoles": ["primary", "fallback"],
      "unsafeRoles": []
    },
    "minimax/minimax-m1": {
      "displayName": "MiniMax M1",
      "capabilities": ["llm", "tools", "code"],
      "costTier": "low",
      "contextWindow": 1000000,
      "safeRoles": ["primary", "fallback", "coding", "heartbeat"],
      "unsafeRoles": [],
      "note": "Better for writing and vibes per OpenClaw docs. Requires models.providers config."
    },
    "groq/llama-4-maverick": {
      "displayName": "Llama 4 Maverick (Groq)",
      "capabilities": ["llm", "tools", "code"],
      "costTier": "very-low",
      "contextWindow": 131072,
      "safeRoles": ["primary", "fallback", "coding", "heartbeat"],
      "unsafeRoles": []
    },
    "cerebras/llama-4-scout": {
      "displayName": "Llama 4 Scout (Cerebras)",
      "capabilities": ["llm", "tools", "code"],
      "costTier": "very-low",
      "contextWindow": 131072,
      "safeRoles": ["primary", "fallback", "coding", "heartbeat"],
      "unsafeRoles": []
    },
    "cerebras/zai-glm-4.7": {
      "displayName": "GLM 4.7 (Cerebras)",
      "capabilities": ["llm", "tools", "code"],
      "costTier": "very-low",
      "contextWindow": 128000,
      "safeRoles": ["primary", "fallback", "coding", "heartbeat"],
      "unsafeRoles": [],
      "note": "GLM models on Cerebras use special IDs"
    },
    "cerebras/zai-glm-4.6": {
      "displayName": "GLM 4.6 (Cerebras)",
      "capabilities": ["llm", "tools", "code"],
      "costTier": "very-low",
      "contextWindow": 128000,
      "safeRoles": ["primary", "fallback", "coding", "heartbeat"],
      "unsafeRoles": [],
      "note": "GLM models on Cerebras use special IDs"
    },
    "perplexity/sonar-pro": {
      "displayName": "Sonar Pro",
      "capabilities": ["llm", "tools", "search"],
      "costTier": "medium",
      "contextWindow": 128000,
      "safeRoles": ["primary", "fallback"],
      "unsafeRoles": []
    },
    "github-copilot/gpt-5.2": {
      "displayName": "GPT-5.2 (GitHub Copilot)",
      "capabilities": ["llm", "vision", "tools", "code"],
      "costTier": "subscription",
      "contextWindow": 128000,
      "safeRoles": ["primary", "fallback", "image", "coding"],
      "unsafeRoles": [],
      "authMethod": "github-token"
    },
    "github-copilot/gpt-5.2-codex": {
      "displayName": "GPT-5.2 Codex (GitHub Copilot)",
      "capabilities": ["llm", "tools", "code"],
      "costTier": "subscription",
      "contextWindow": 128000,
      "safeRoles": ["primary", "fallback", "coding"],
      "unsafeRoles": ["image"],
      "authMethod": "github-token"
    },
    "github-copilot/gpt-4o": {
      "displayName": "GPT-4o (GitHub Copilot)",
      "capabilities": ["llm", "vision", "tools", "code"],
      "costTier": "subscription",
      "contextWindow": 128000,
      "safeRoles": ["primary", "fallback", "image", "coding"],
      "unsafeRoles": [],
      "authMethod": "github-token"
    },
    "litellm/claude-opus-4-6": {
      "displayName": "Claude Opus 4.6 (LiteLLM)",
      "capabilities": ["llm", "vision", "tools", "code", "reasoning"],
      "costTier": "proxy",
      "contextWindow": 200000,
      "safeRoles": ["primary", "fallback", "image", "coding"],
      "unsafeRoles": [],
      "note": "Requires LiteLLM proxy running locally"
    },
    "together/meta-llama/llama-3.3-70b-instruct": {
      "displayName": "Llama 3.3 70B (Together)",
      "capabilities": ["llm", "tools", "code"],
      "costTier": "low",
      "contextWindow": 131072,
      "safeRoles": ["primary", "fallback", "coding", "heartbeat"],
      "unsafeRoles": []
    },
    "mistral/mistral-large-latest": {
      "displayName": "Mistral Large",
      "capabilities": ["llm", "vision", "tools", "code"],
      "costTier": "medium",
      "contextWindow": 128000,
      "safeRoles": ["primary", "fallback", "image", "coding"],
      "unsafeRoles": []
    },
    "mistral/codestral-latest": {
      "displayName": "Codestral",
      "capabilities": ["llm", "tools", "code"],
      "costTier": "low",
      "contextWindow": 256000,
      "safeRoles": ["primary", "fallback", "coding"],
      "unsafeRoles": ["image"]
    },
    "mistral/mistral-small-latest": {
      "displayName": "Mistral Small",
      "capabilities": ["llm", "tools"],
      "costTier": "very-low",
      "contextWindow": 128000,
      "safeRoles": ["primary", "fallback", "heartbeat"],
      "unsafeRoles": []
    },
    [REDACTED_SECRET]: {
      "displayName": "DeepSeek R1 (Hugging Face)",
      "capabilities": ["llm", "tools", "code", "reasoning"],
      "costTier": "low",
      "contextWindow": 128000,
      "safeRoles": ["primary", "fallback", "coding"],
      "unsafeRoles": [],
      "note": "OpenAI-compatible via HF Inference"
    },
    "moonshot/kimi-k2.5": {
      "displayName": "Kimi K2.5",
      "capabilities": ["llm", "tools", "code"],
      "costTier": "low",
      "contextWindow": 131072,
      "safeRoles": ["primary", "fallback", "coding"],
      "unsafeRoles": [],
      "note": "Requires models.providers config (OpenAI-compatible)"
    },
    "moonshot/kimi-k2-thinking": {
      "displayName": "Kimi K2 Thinking",
      "capabilities": ["llm", "tools", "code", "reasoning"],
      "costTier": "medium",
      "contextWindow": 131072,
      "safeRoles": ["primary", "fallback", "coding"],
      "unsafeRoles": [],
      "note": "Requires models.providers config (OpenAI-compatible)"
    },
    "moonshot/kimi-k2-turbo-preview": {
      "displayName": "Kimi K2 Turbo Preview",
      "capabilities": ["llm", "tools", "code"],
      "costTier": "low",
      "contextWindow": 131072,
      "safeRoles": ["primary", "fallback", "coding", "heartbeat"],
      "unsafeRoles": [],
      "note": "Requires models.providers config (OpenAI-compatible)"
    },
    "kimi-coding/k2p5": {
      "displayName": "Kimi K2.5 Coding",
      "capabilities": ["llm", "tools", "code"],
      "costTier": "low",
      "contextWindow": 131072,
      "safeRoles": ["primary", "fallback", "coding"],
      "unsafeRoles": [],
      "note": "Anthropic-compatible endpoint"
    },
    "qwen-portal/coder-model": {
      "displayName": "Qwen Coder (Portal)",
      "capabilities": ["llm", "tools", "code"],
      "costTier": "free",
      "contextWindow": 128000,
      "safeRoles": ["primary", "fallback", "coding", "heartbeat"],
      "unsafeRoles": [],
      "authMethod": "oauth-plugin",
      "note": "Free tier. Requires openclaw plugins enable qwen-portal-auth"
    },
    "qwen-portal/vision-model": {
      "displayName": "Qwen Vision (Portal)",
      "capabilities": ["llm", "vision", "tools"],
      "costTier": "free",
      "contextWindow": 128000,
      "safeRoles": ["primary", "fallback", "image", "heartbeat"],
      "unsafeRoles": [],
      "authMethod": "oauth-plugin",
      "note": "Free tier. Requires openclaw plugins enable qwen-portal-auth"
    },
    "volcengine/doubao-seed-1-8-251228": {
      "displayName": "Doubao Seed 1.8",
      "capabilities": ["llm", "tools", "code"],
      "costTier": "low",
      "contextWindow": 128000,
      "safeRoles": ["primary", "fallback", "coding"],
      "unsafeRoles": [],
      "note": "Volcano Engine (China). Requires models.providers config."
    },
    "volcengine/deepseek-v3-2-251201": {
      "displayName": "DeepSeek V3.2 128K (Volcano)",
      "capabilities": ["llm", "tools", "code"],
      "costTier": "low",
      "contextWindow": 128000,
      "safeRoles": ["primary", "fallback", "coding"],
      "unsafeRoles": [],
      "note": "Volcano Engine (China). Requires models.providers config."
    },
    "byteplus/seed-1-8-251228": {
      "displayName": "Seed 1.8 (BytePlus)",
      "capabilities": ["llm", "tools", "code"],
      "costTier": "low",
      "contextWindow": 128000,
      "safeRoles": ["primary", "fallback", "coding"],
      "unsafeRoles": [],
      "note": "BytePlus ARK (international). Requires models.providers config."
    },
    "synthetic/hf:MiniMaxAI/MiniMax-M2.1": {
      "displayName": "MiniMax M2.1 (Synthetic)",
      "capabilities": ["llm", "tools", "code"],
      "costTier": "low",
      "contextWindow": 200000,
      "safeRoles": ["primary", "fallback", "coding"],
      "unsafeRoles": [],
      "note": "Anthropic-compatible via Synthetic. Requires models.providers config."
    },
    "nvidia/llama-3.1-nemotron-70b-instruct": {
      "displayName": "Llama 3.1 Nemotron 70B (NVIDIA)",
      "capabilities": ["llm", "tools", "code"],
      "costTier": "low",
      "contextWindow": 128000,
      "safeRoles": ["primary", "fallback", "coding", "heartbeat"],
      "unsafeRoles": []
    },
    "nvidia/mistral-nemo-minitron-8b-8k-instruct": {
      "displayName": "Mistral Nemo Minitron 8B (NVIDIA)",
      "capabilities": ["llm", "tools"],
      "costTier": "very-low",
      "contextWindow": 8000,
      "safeRoles": ["fallback", "heartbeat"],
      "unsafeRoles": ["primary"],
      "note": "Very small context window — heartbeat/fallback only"
    },
    "meta/llama-3.3-70b-instruct": {
      "displayName": "Llama 3.3 70B Instruct",
      "capabilities": ["llm", "tools", "code"],
      "costTier": "low",
      "contextWindow": 131072,
      "safeRoles": ["primary", "fallback", "coding", "heartbeat"],
      "unsafeRoles": [],
      "note": "Reference model ID — typically accessed via Groq, Cerebras, Together, etc."
    },
    "openrouter/anthropic/claude-sonnet-4-5": {
      "displayName": "Claude Sonnet 4.5 (OpenRouter)",
      "capabilities": ["llm", "vision", "tools", "code", "reasoning"],
      "costTier": "medium",
      "contextWindow": 200000,
      "safeRoles": ["primary", "fallback", "image", "coding"],
      "unsafeRoles": []
    },
    "openrouter/openai/gpt-5.2": {
      "displayName": "GPT-5.2 (OpenRouter)",
      "capabilities": ["llm", "vision", "tools", "code"],
      "costTier": "high",
      "contextWindow": 128000,
      "safeRoles": ["primary", "fallback", "image", "coding"],
      "unsafeRoles": []
    },
    "openrouter/google/gemini-3-pro": {
      "displayName": "Gemini 3 Pro (OpenRouter)",
      "capabilities": ["llm", "vision", "tools", "code"],
      "costTier": "medium",
      "contextWindow": 1000000,
      "safeRoles": ["primary", "fallback", "image", "coding"],
      "unsafeRoles": []
    },
    "openrouter/moonshotai/kimi-k2": {
      "displayName": "Kimi K2 (OpenRouter)",
      "capabilities": ["llm", "tools", "code"],
      "costTier": "low",
      "contextWindow": 131072,
      "safeRoles": ["primary", "fallback", "coding"],
      "unsafeRoles": [],
      "note": "OpenRouter multi-slash model ref"
    },
    "openrouter/auto": {
      "displayName": "OpenRouter Auto",
      "capabilities": ["llm", "tools"],
      "costTier": "variable",
      "contextWindow": 128000,
      "safeRoles": ["primary", "fallback"],
      "unsafeRoles": [],
      "note": "OpenRouter auto-selects best available model"
    },
    "vercel-ai-gateway/anthropic/claude-opus-4.6": {
      "displayName": "Claude Opus 4.6 (Vercel AI)",
      "capabilities": ["llm", "vision", "tools", "code", "reasoning"],
      "costTier": "proxy",
      "contextWindow": 200000,
      "safeRoles": ["primary", "fallback", "image", "coding"],
      "unsafeRoles": []
    },
    "lmstudio/minimax-m2.1-gs32": {
      "displayName": "MiniMax M2.1 (LM Studio)",
      "capabilities": ["llm", "tools", "code"],
      "costTier": "free",
      "contextWindow": 200000,
      "safeRoles": ["primary", "fallback", "coding", "heartbeat"],
      "unsafeRoles": [],
      "note": "Local model. Requires LM Studio running."
    },
    "openai/dall-e-3": {
      "displayName": "DALL-E 3",
      "capabilities": ["image-generation"],
      "costTier": "medium",
      "contextWindow": 0,
      "safeRoles": [],
      "unsafeRoles": ["primary", "fallback", "image", "heartbeat", "coding"],
      "blocked": true,
      "blockReason": "Image generation model — cannot process text or serve as LLM"
    }
  },
  "providers": {
    "anthropic": {
      "displayName": "Anthropic",
      "type": "built-in",
      "authEnv": ["ANTHROPIC_API_KEY"],
      "authAlt": "claude setup-token",
      "supportsRotation": true,
      "website": "https://console.anthropic.com"
    },
    "openai": {
      "displayName": "OpenAI",
      "type": "built-in",
      "authEnv": ["OPENAI_API_KEY"],
      "supportsRotation": true,
      "website": "https://platform.openai.com"
    },
    "openai-codex": {
      "displayName": "OpenAI Codex",
      "type": "built-in",
      "authEnv": [],
      "authMethod": "oauth",
      "website": "https://chatgpt.com",
      "cli": "openclaw onboard --auth-choice openai-codex"
    },
    "google": {
      "displayName": "Google Gemini",
      "type": "built-in",
      "authEnv": ["GEMINI_API_KEY", "GOOGLE_API_KEY", "GOOGLE_GEMINI_API_KEY"],
      "supportsRotation": true,
      "website": "https://aistudio.google.dev"
    },
    "google-vertex": {
      "displayName": "Google Vertex AI",
      "type": "built-in",
      "authEnv": [],
      "authMethod": "gcloud-adc",
      "website": "https://cloud.google.com/vertex-ai"
    },
    "google-antigravity": {
      "displayName": "Google Antigravity",
      "type": "built-in",
      "authEnv": [],
      "authMethod": "oauth-plugin",
      "website": "https://aistudio.google.dev",
      "note": "Requires: openclaw plugins enable google-antigravity-auth"
    },
    "google-gemini-cli": {
      "displayName": "Google Gemini CLI",
      "type": "built-in",
      "authEnv": [],
      "authMethod": "oauth-plugin",
      "note": "Requires: openclaw plugins enable google-gemini-cli-auth"
    },
    "opencode": {
      "displayName": "OpenCode Zen",
      "type": "built-in",
      "authEnv": ["OPENCODE_API_KEY", "OPENCODE_ZEN_API_KEY"],
      "website": "https://opencode.ai"
    },
    "zai": {
      "displayName": "Z.AI (GLM)",
      "type": "built-in",
      "authEnv": ["ZAI_API_KEY"],
      "aliases": ["z.ai", "z-ai"],
      "website": "https://z.ai"
    },
    "xai": {
      "displayName": "xAI (Grok)",
      "type": "built-in",
      "authEnv": ["XAI_API_KEY"],
      "website": "https://x.ai"
    },
    "openrouter": {
      "displayName": "OpenRouter",
      "type": "built-in",
      "authEnv": ["OPENROUTER_API_KEY"],
      "website": "https://openrouter.ai",
      "note": "Multi-provider gateway — model format: openrouter/provider/model. Use 'openclaw models scan' to find free models."
    },
    "minimax": {
      "displayName": "MiniMax",
      "type": "custom-config",
      "authEnv": ["MINIMAX_API_KEY"],
      "website": "https://minimax.chat",
      "note": "Requires models.providers config (Anthropic-compatible)"
    },
    "groq": {
      "displayName": "Groq",
      "type": "built-in",
      "authEnv": ["GROQ_API_KEY"],
      "website": "https://console.groq.com"
    },
    "cerebras": {
      "displayName": "Cerebras",
      "type": "built-in",
      "authEnv": ["CEREBRAS_API_KEY"],
      "website": "https://cerebras.ai",
      "note": "GLM models use special IDs: zai-glm-4.7, zai-glm-4.6"
    },
    "mistral": {
      "displayName": "Mistral",
      "type": "built-in",
      "authEnv": ["MISTRAL_API_KEY"],
      "website": "https://mistral.ai"
    },
    "perplexity": {
      "displayName": "Perplexity",
      "type": "built-in",
      "authEnv": ["PERPLEXITY_API_KEY"],
      "website": "https://perplexity.ai"
    },
    "github-copilot": {
      "displayName": "GitHub Copilot",
      "type": "built-in",
      "authEnv": ["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"],
      "authMethod": "github-token",
      "website": "https://github.com/features/copilot"
    },
    "huggingface": {
      "displayName": "Hugging Face Inference",
      "type": "built-in",
      "authEnv": ["HUGGINGFACE_HUB_TOKEN", "HF_TOKEN"],
      "website": "https://huggingface.co",
      "note": "OpenAI-compatible router. Model format: huggingface/org/model"
    },
    "together": {
      "displayName": "Together AI",
      "type": "built-in",
      "authEnv": ["TOGETHER_API_KEY"],
      "website": "https://together.ai"
    },
    "vercel-ai-gateway": {
      "displayName": "Vercel AI Gateway",
      "type": "built-in",
      "authEnv": ["AI_GATEWAY_API_KEY"],
      "website": "https://vercel.com/ai"
    },
    "litellm": {
      "displayName": "LiteLLM",
      "type": "built-in",
      "authEnv": ["LITELLM_API_KEY"],
      "note": "Local proxy — any model your LiteLLM instance supports"
    },
    "nvidia": {
      "displayName": "NVIDIA",
      "type": "built-in",
      "authEnv": ["NVIDIA_API_KEY"],
      "website": "https://build.nvidia.com"
    },
    "moonshot": {
      "displayName": "Moonshot AI (Kimi)",
      "type": "custom-config",
      "authEnv": ["MOONSHOT_API_KEY"],
      "website": "https://moonshot.ai",
      "note": "OpenAI-compatible. Requires models.providers config."
    },
    "kimi-coding": {
      "displayName": "Kimi Coding",
      "type": "custom-config",
      "authEnv": ["KIMI_API_KEY"],
      "note": "Anthropic-compatible endpoint"
    },
    "qwen-portal": {
      "displayName": "Qwen Portal",
      "type": "built-in",
      "authEnv": [],
      "authMethod": "oauth-plugin",
      "note": "Free tier. Requires: openclaw plugins enable qwen-portal-auth"
    },
    "volcengine": {
      "displayName": "Volcano Engine (Doubao)",
      "type": "built-in",
      "authEnv": ["VOLCANO_ENGINE_API_KEY"],
      "note": "China region. Coding provider: volcengine-plan"
    },
    "byteplus": {
      "displayName": "BytePlus ARK",
      "type": "built-in",
      "authEnv": ["BYTEPLUS_API_KEY"],
      "note": "International version of Volcano Engine. Coding provider: byteplus-plan"
    },
    "synthetic": {
      "displayName": "Synthetic",
      "type": "custom-config",
      "authEnv": ["SYNTHETIC_API_KEY"],
      "website": "https://synthetic.new",
      "note": "Anthropic-compatible. Requires models.providers config."
    },
    "ollama": {
      "displayName": "Ollama",
      "type": "built-in",
      "authEnv": [],
      "authMethod": "local",
      "website": "https://ollama.ai",
      "note": "Local LLM runtime. Auto-detected at http://127.0.0.1:11434"
    },
    "vllm": {
      "displayName": "vLLM",
      "type": "built-in",
      "authEnv": ["VLLM_API_KEY"],
      "note": "Local/self-hosted OpenAI-compatible server at http://127.0.0.1:8000"
    },
    "lmstudio": {
      "displayName": "LM Studio",
      "type": "custom-config",
      "authEnv": ["LMSTUDIO_KEY"],
      "website": "https://lmstudio.ai",
      "note": "Local. Requires models.providers config."
    }
  },
  "roles": {
    "primary": {
      "displayName": "Primary LLM",
      "description": "Main conversational model — handles all user interactions",
      "configKey": "agents.defaults.model.primary",
      "requiredCapabilities": ["llm", "tools"],
      "recommendedCapabilities": ["vision", "code", "reasoning"]
    },
    "fallback": {
      "displayName": "LLM Fallback",
      "description": "Backup models used when primary fails (in order)",
      "configKey": "agents.defaults.model.fallbacks",
      "requiredCapabilities": ["llm", "tools"]
    },
    "image": {
      "displayName": "Image Model",
      "description": "Handles vision/image processing when primary can't",
      "configKey": "agents.defaults.imageModel.primary",
      "requiredCapabilities": ["vision"]
    },
    "heartbeat": {
      "displayName": "Heartbeat",
      "description": "Low-cost model for periodic polling checks",
      "configKey": "agents.defaults.heartbeat.model",
      "requiredCapabilities": ["llm"],
      "preferCostTier": ["very-low", "low", "free"]
    },
    "coding": {
      "displayName": "Coding Agent",
      "description": "Model used when spawning coding sub-agents",
      "configKey": "session_spawn.model",
      "requiredCapabilities": ["llm", "code"]
    }
  }
}


path: scripts/redundancy.py
content: #!/usr/bin/env python3
"""
Model Switchboard — Redundancy Engine
Generates optimal 3-deep fallback configurations based on available providers.
Enforces provider diversity (never stack same provider in a chain).
Zero external dependencies. Python 3.8+.
"""

import json
import os
import sys
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from datetime import datetime

SKILL_DIR = Path(__file__).resolve().parent.parent
REGISTRY_PATH = SKILL_DIR / "model-registry.json"
CONFIG_PATH = Path.home() / ".openclaw" / "openclaw.json"


def load_registry() -> Dict[str, Any]:
    """Load model registry."""
    try:
        with open(REGISTRY_PATH) as f:
            return json.load(f)
    except (FileNotFoundError, json.JSONDecodeError) as e:
        print(json.dumps({"error": f"Cannot load registry: {e}"}), file=sys.stderr)
        return {"models": {}, "providers": {}, "roles": {}}


def detect_available_providers() -> Dict[str, Dict[str, Any]]:
    """
    Detect which providers have auth configured.
    Checks environment variables and common auth patterns.
    """
    registry = load_registry()
    available = {}

    for pid, pinfo in registry.get("providers", {}).items():
        auth_envs = pinfo.get("authEnv", [])
        has_auth = False
        auth_via = None

        # Check environment variables
        for env_var in auth_envs:
            if os.environ.get(env_var):
                has_auth = True
                auth_via = env_var
                break

        # Special cases
        if pid == "openai-codex":
            # OAuth-based — check if auth profile exists
            auth_profile = Path.home() / ".openclaw" / "auth" / "openai-codex.json"
            if auth_profile.exists():
                has_auth = True
                auth_via = "OAuth profile"

        if pid == "anthropic":
            # May use setup-token instead of env var
            auth_store = Path.home() / ".openclaw" / "auth"
            if auth_store.exists():
                for f in auth_store.glob("anthropic*"):
                    has_auth = True
                    auth_via = "setup-token/auth profile"
                    break
            # Also check if we're running on Anthropic (we are if this agent is alive)
            if not has_auth and os.environ.get("ANTHROPIC_API_KEY"):
                has_auth = True
                auth_via = "ANTHROPIC_API_KEY"

        if has_auth:
            available[pid] = {
                "displayName": pinfo.get("displayName", pid),
                "authVia": auth_via,
                "models": []
            }
            # Find compatible models for this provider
            for mid, minfo in registry.get("models", {}).items():
                if mid.startswith(pid + "/") and not minfo.get("blocked"):
                    available[pid]["models"].append({
                        "id": mid,
                        "displayName": minfo.get("displayName", mid),
                        "capabilities": minfo.get("capabilities", []),
                        "costTier": minfo.get("costTier", "unknown"),
                        "safeRoles": minfo.get("safeRoles", [])
                    })

    return available


def build_fallback_chain(
    role: str,
    available: Dict[str, Dict[str, Any]],
    min_depth: int = 3,
    exclude_providers: Optional[List[str]] = None
) -> List[str]:
    """
    Build a fallback chain for a role with provider diversity.
    Returns ordered list of model IDs.

    Rules:
    1. Never repeat a provider in the chain
    2. Minimum 3 deep (if enough providers available)
    3. Higher quality models first
    4. Cost-appropriate for the role
    """
    registry = load_registry()
    role_info = registry.get("roles", {}).get(role, {})
    required_caps = set(role_info.get("requiredCapabilities", []))
    preferred_cost = role_info.get("preferCostTier", [])

    candidates: List[Tuple[int, str, str]] = []  # (score, model_id, provider)

    for pid, pdata in available.items():
        if exclude_providers and pid in exclude_providers:
            continue
        for model in pdata["models"]:
            mid = model["id"]
            caps = set(model.get("capabilities", []))
            safe = model.get("safeRoles", [])

            # Must have required capabilities
            if not required_caps.issubset(caps):
                continue

            # Must be safe for this role
            if role not in safe:
                continue

            # Score: higher = better
            score = 0

            # Cost tier scoring (varies by role)
            cost = model.get("costTier", "medium")
            if role in ("heartbeat",):
                # Prefer cheap for heartbeat
                cost_scores = {"very-low": 100, "low": 80, "medium": 40, "high": 10, "subscription": 30, "proxy": 50}
            elif role in ("primary", "coding"):
                # Prefer quality for primary/coding
                cost_scores = {"high": 100, "medium": 70, "proxy": 90, "subscription": 85, "low": 30, "very-low": 10}
            else:
                # Balanced for others
                cost_scores = {"medium": 80, "high": 70, "proxy": 75, "low": 60, "very-low": 40, "subscription": 65}

            score += cost_scores.get(cost, 50)

            # Bonus for preferred cost tier
            if preferred_cost and cost in preferred_cost:
                score += 50

            # Bonus for recommended capabilities
            recommended = set(role_info.get("recommendedCapabilities", []))
            score += len(caps & recommended) * 15

            # Bonus for vision capability (useful for many roles)
            if "vision" in caps:
                score += 10

            candidates.append((score, mid, pid))

    # Sort by score descending
    candidates.sort(key=lambda x: x[0], reverse=True)

    # Build chain with provider diversity
    chain: List[str] = []
    used_providers: set = set()

    for score, mid, pid in candidates:
        if pid in used_providers:
            continue
        chain.append(mid)
        used_providers.add(pid)
        if len(chain) >= min_depth:
            break

    # If we couldn't hit min_depth with diversity, allow provider repeats
    if len(chain) < min_depth:
        for score, mid, pid in candidates:
            if mid not in chain:
                chain.append(mid)
                if len(chain) >= min_depth:
                    break

    return chain


def generate_redundant_config(min_depth: int = 3) -> Dict[str, Any]:
    """
    Generate a complete redundant model configuration.
    Returns a config snippet ready to merge into openclaw.json.
    """
    registry = load_registry()
    if not registry.get("models"):
        return {"error": "Model registry is empty or corrupt. Cannot generate redundancy config."}

    available = detect_available_providers()

    if not available:
        return {
            "error": "No providers detected. Configure at least one provider API key.",
            "hint": "Set ANTHROPIC_API_KEY, OPENAI_API_KEY, or GEMINI_API_KEY in your environment."
        }

    # Build chains for each role
    primary_chain = build_fallback_chain("primary", available, min_depth)
    image_chain = build_fallback_chain("image", available, min_depth)
    heartbeat_chain = build_fallback_chain("heartbeat", available, min_depth)
    coding_chain = build_fallback_chain("coding", available, min_depth)
    fallback_chain = build_fallback_chain("fallback", available, min_depth)

    # Build the config
    config = {
        "generatedAt": datetime.now().isoformat(),
        "generatedBy": "model-switchboard/redundancy-engine",
        "minDepth": min_depth,
        "providersDetected": list(available.keys()),
        "providerCount": len(available),
        "config": {
            "agents": {
                "defaults": {
                    "model": {
                        "primary": primary_chain[0] if primary_chain else None,
                        "fallbacks": primary_chain[1:] if len(primary_chain) > 1 else []
                    },
                    "imageModel": {
                        "primary": image_chain[0] if image_chain else None,
                        "fallbacks": image_chain[1:] if len(image_chain) > 1 else []
                    },
                    "models": {}
                }
            }
        },
        "taskRouting": {
            "primary": {
                "chain": primary_chain,
                "depth": len(primary_chain),
                "description": "Main conversational model — highest quality"
            },
            "image": {
                "chain": image_chain,
                "depth": len(image_chain),
                "description": "Vision/image processing"
            },
            "heartbeat": {
                "chain": heartbeat_chain,
                "depth": len(heartbeat_chain),
                "description": "Low-cost periodic polling"
            },
            "coding": {
                "chain": coding_chain,
                "depth": len(coding_chain),
                "description": "Code generation sub-agents"
            }
        },
        "redundancyReport": {
            "totalModelsAvailable": sum(len(p["models"]) for p in available.values()),
            "uniqueProviders": len(available),
            "primaryDepth": len(primary_chain),
            "imageDepth": len(image_chain),
            "heartbeatDepth": len(heartbeat_chain),
            "codingDepth": len(coding_chain),
            "meetsMininumDepth": all(
                len(c) >= min_depth
                for c in [primary_chain, image_chain]
            ),
            "singlePointsOfFailure": []
        }
    }

    # Build allowlist from all chains
    all_models = set()
    for chain in [primary_chain, image_chain, heartbeat_chain, coding_chain]:
        all_models.update(chain)

    for mid in sorted(all_models):
        registry = load_registry()
        minfo = registry.get("models", {}).get(mid, {})
        config["config"]["agents"]["defaults"]["models"][mid] = {
            "alias": minfo.get("displayName", mid.split("/")[-1])
        }

    # Check for single points of failure
    spof = []
    for role, chain_data in config["taskRouting"].items():
        chain = chain_data["chain"]
        if len(chain) < 2:
            spof.append(f"{role}: only {len(chain)} model(s) — needs at least 2")
        providers_in_chain = set(m.split("/")[0] for m in chain)
        if len(providers_in_chain) < 2 and len(chain) > 1:
            spof.append(f"{role}: all models from same provider ({list(providers_in_chain)[0]})")

    config["redundancyReport"]["singlePointsOfFailure"] = spof

    # Provider details
    config["providers"] = {}
    for pid, pdata in available.items():
        config["providers"][pid] = {
            "displayName": pdata["displayName"],
            "authVia": pdata["authVia"],
            "modelsAvailable": len(pdata["models"]),
            "modelIds": [m["id"] for m in pdata["models"]]
        }

    return config


def generate_deployment_config(min_depth: int = 3) -> Dict[str, Any]:
    """
    Generate a deployment-ready openclaw.json config snippet.
    This is what gets merged into the live config.
    """
    full = generate_redundant_config(min_depth)
    if "error" in full:
        return full
    return full.get("config", {})


def print_redundancy_report(config: Dict[str, Any]) -> None:
    """Pretty-print the redundancy report to terminal."""
    if "error" in config:
        print(f"\n  ❌ {config['error']}")
        if "hint" in config:
            print(f"  💡 {config['hint']}")
        return

    print(f"\n  🔀 Model Switchboard — Redundancy Report")
    print(f"  {'═' * 50}")
    print(f"  Generated: {config.get('generatedAt', 'unknown')}")
    print(f"  Providers: {config.get('providerCount', 0)} detected")
    print(f"  Min depth: {config.get('minDepth', 3)}")
    print()

    # Provider inventory
    print(f"  🔑 Provider Inventory")
    for pid, pdata in config.get("providers", {}).items():
        print(f"     ✅ {pdata['displayName']:<20} {pdata['modelsAvailable']} models  (via {pdata['authVia']})")

    print()

    # Task routing
    print(f"  📋 Task Routing Chains")
    for role, data in config.get("taskRouting", {}).items():
        chain = data.get("chain", [])
        depth = data.get("depth", 0)
        status = "✅" if depth >= config.get("minDepth", 3) else "⚠️"
        print(f"     {status} {role:<12} [{depth} deep]  {' → '.join(chain) if chain else '(none)'}")

    print()

    # Redundancy assessment
    report = config.get("redundancyReport", {})
    spof = report.get("singlePointsOfFailure", [])
    meets = report.get("meetsMininumDepth", False)

    if meets and not spof:
        print(f"  🛡️  Redundancy: STRONG — all roles meet minimum depth, no single points of failure")
    elif meets:
        print(f"  ⚠️  Redundancy: MODERATE — meets minimum depth but has warnings:")
        for s in spof:
            print(f"     ⚠️  {s}")
    else:
        print(f"  🔴 Redundancy: WEAK — does NOT meet minimum depth requirement")
        for s in spof:
            print(f"     ❌ {s}")

    print()


# ── CLI ────────────────────────────────────────────────────────

def main():
    command = sys.argv[1] if len(sys.argv) > 1 else "report"
    min_depth = int(sys.argv[2]) if len(sys.argv) > 2 else 3

    if command == "report":
        config = generate_redundant_config(min_depth)
        print_redundancy_report(config)

    elif command == "json":
        config = generate_redundant_config(min_depth)
        print(json.dumps(config, indent=2))

    elif command == "deploy":
        config = generate_deployment_config(min_depth)
        print(json.dumps(config, indent=2))

    elif command == "providers":
        available = detect_available_providers()
        print(json.dumps(available, indent=2))

    elif command == "chains":
        available = detect_available_providers()
        roles = ["primary", "image", "heartbeat", "coding", "fallback"]
        chains = {}
        for role in roles:
            chain = build_fallback_chain(role, available, min_depth)
            chains[role] = {"chain": chain, "depth": len(chain)}
        print(json.dumps(chains, indent=2))

    else:
        print("Usage: redundancy.py <command> [min_depth]")
        print("")
        print("Commands:")
        print("  report     Pretty-print redundancy assessment")
        print("  json       Full redundancy config as JSON")
        print("  deploy     Deployment-ready config snippet")
        print("  providers  Detected providers and their models")
        print("  chains     Fallback chains for all roles")
        sys.exit(1)


if __name__ == "__main__":
    main()


path: scripts/setup.sh
content: #!/bin/bash
# ═══════════════════════════════════════════════════════════════
# Model Switchboard — First-Time Setup Wizard
# Guides new users through provider detection and redundancy config
# ═══════════════════════════════════════════════════════════════

set -euo pipefail

SKILL_DIR="$(cd "$(dirname "$0")/.." && pwd)"
SWITCHBOARD="$SKILL_DIR/scripts/switchboard.sh"
REDUNDANCY="$SKILL_DIR/scripts/redundancy.py"

# Colors
CYAN='\033[0;36m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
BLUE='\033[0;34m'
BOLD='\033[1m'
DIM='\033[2m'
NC='\033[0m'

echo ""
echo -e "${CYAN}${BOLD}╔══════════════════════════════════════════════════════════╗${NC}"
echo -e "${CYAN}${BOLD}║         🔀 MODEL SWITCHBOARD — SETUP WIZARD            ║${NC}"
echo -e "${CYAN}${BOLD}║                                                          ║${NC}"
echo -e "${CYAN}${BOLD}║   Safe AI model configuration for OpenClaw               ║${NC}"
echo -e "${CYAN}${BOLD}║   Never crash your gateway again.                        ║${NC}"
echo -e "${CYAN}${BOLD}╚══════════════════════════════════════════════════════════╝${NC}"
echo ""

# ── Step 1: Check Prerequisites ───────────────────────────────
echo -e "${BOLD}Step 1: Checking prerequisites...${NC}"
echo ""

prereqs_ok=true

# Python 3
if command -v python3 &>/dev/null; then
    py_ver=$(python3 --version 2>&1 | awk '{print $2}')
    echo -e "  ${GREEN}✅${NC} Python $py_ver"
else
    echo -e "  ${RED}❌${NC} Python 3 not found — required for validation engine"
    echo -e "     Install: ${DIM}https://python.org/downloads${NC}"
    prereqs_ok=false
fi

# OpenClaw
if command -v openclaw &>/dev/null; then
    echo -e "  ${GREEN}✅${NC} OpenClaw CLI found"
else
    echo -e "  ${YELLOW}⚠️${NC}  OpenClaw CLI not in PATH"
    echo -e "     Install: ${DIM}npm install -g openclaw${NC}"
    echo -e "     ${DIM}(Switchboard can still validate and backup without CLI)${NC}"
fi

# Config file
if [ -f "$HOME/.openclaw/openclaw.json" ]; then
    echo -e "  ${GREEN}✅${NC} Config found: ~/.openclaw/openclaw.json"
else
    echo -e "  ${YELLOW}⚠️${NC}  No config file found"
    echo -e "     Run: ${DIM}openclaw onboard${NC} to create one"
fi

echo ""

if [ "$prereqs_ok" = false ]; then
    echo -e "${RED}Fix prerequisites above before continuing.${NC}"
    exit 1
fi

# ── Step 2: Detect Providers ──────────────────────────────────
echo -e "${BOLD}Step 2: Detecting available AI providers...${NC}"
echo ""

provider_json=$(python3 "$REDUNDANCY" providers 2>/dev/null || echo "{}")
provider_count=$(echo "$provider_json" | python3 -c "import sys,json; print(len(json.load(sys.stdin)))" 2>/dev/null || echo "0")

if [ "$provider_count" -eq "0" ]; then
    echo -e "  ${RED}❌${NC} No providers detected!"
    echo ""
    echo -e "  ${BOLD}To use Model Switchboard, configure at least one AI provider:${NC}"
    echo ""
    echo -e "  ${CYAN}Anthropic (Claude):${NC}"
    echo "    export ANTHROPIC_API_KEY=sk-ant-..."
    echo "    ${DIM}or: openclaw models auth paste-token --provider anthropic${NC}"
    echo ""
    echo -e "  ${CYAN}OpenAI (GPT):${NC}"
    echo "    export OPENAI_API_KEY=sk-..."
    echo ""
    echo -e "  ${CYAN}Google (Gemini):${NC}"
    echo "    export GEMINI_API_KEY=AI..."
    echo ""
    echo -e "  ${CYAN}OpenRouter (hundreds of models):${NC}"
    echo "    export OPENROUTER_API_KEY=sk-or-..."
    echo ""
    echo -e "  ${DIM}After setting keys, run this setup again.${NC}"
    exit 1
fi

echo "$provider_json" | python3 -c "
import sys, json
data = json.load(sys.stdin)
for pid, p in data.items():
    count = len(p.get('models', []))
    print(f'  \033[32m✅\033[0m {p[\"displayName\"]:<25} {count} models  (via {p[\"authVia\"]})')
" 2>/dev/null

echo ""
echo -e "  ${BOLD}$provider_count provider(s) detected${NC}"
echo ""

# ── Step 3: Redundancy Assessment ─────────────────────────────
echo -e "${BOLD}Step 3: Assessing redundancy...${NC}"

python3 "$REDUNDANCY" report 3 2>/dev/null

# ── Step 4: Recommendations ───────────────────────────────────
echo ""
echo -e "${BOLD}Step 4: Recommendations${NC}"
echo ""

if [ "$provider_count" -lt 2 ]; then
    echo -e "  ${YELLOW}⚠️${NC}  You have only $provider_count provider. For true redundancy, add at least 2 more:"
    echo ""
    echo -e "  ${DIM}Recommended free/cheap options:${NC}"
    echo "    • OpenRouter — OPENROUTER_API_KEY (access to hundreds of models, many free)"
    echo "    • Google Gemini — GEMINI_API_KEY (generous free tier)"
    echo "    • Groq — GROQ_API_KEY (very fast, free tier available)"
    echo ""
elif [ "$provider_count" -lt 3 ]; then
    echo -e "  ${YELLOW}⚠️${NC}  You have $provider_count providers. Adding 1 more would give full 3-deep provider diversity."
    echo ""
else
    echo -e "  ${GREEN}✅${NC} You have $provider_count providers — excellent redundancy potential!"
    echo ""
fi

# ── Step 5: Ready to Deploy ───────────────────────────────────
echo -e "${BOLD}Step 5: Ready to deploy${NC}"
echo ""
echo -e "  To apply optimal redundant configuration:"
echo -e "    ${CYAN}./scripts/switchboard.sh redundancy-apply${NC}"
echo ""
echo -e "  To preview first (no changes):"
echo -e "    ${CYAN}./scripts/switchboard.sh redundancy-deploy${NC}"
echo ""
echo -e "  To see all commands:"
echo -e "    ${CYAN}./scripts/switchboard.sh help${NC}"
echo ""
echo -e "${DIM}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo -e "${BOLD}Model Switchboard is ready. Your gateway will never crash from bad model config again.${NC}"
echo ""


path: scripts/switchboard.sh
content: #!/bin/bash
# ═══════════════════════════════════════════════════════════════
# Model Switchboard v3.0 — Production-Grade Model Configuration
# Never crashes the gateway. Always validates. Always backs up.
# ═══════════════════════════════════════════════════════════════

set -euo pipefail

OPENCLAW_CONFIG="$HOME/.openclaw/openclaw.json"
BACKUP_DIR="$HOME/.openclaw/model-backups"
SKILL_DIR="$(cd "$(dirname "$0")/.." && pwd)"
VALIDATE="$SKILL_DIR/scripts/validate.py"
REDUNDANCY="$SKILL_DIR/scripts/redundancy.py"
MAX_BACKUPS="${SWITCHBOARD_MAX_BACKUPS:-30}"

# Ensure backup/log directory exists early
mkdir -p "$BACKUP_DIR" 2>/dev/null
chmod 700 "$BACKUP_DIR" 2>/dev/null

# ── Colors & Output ────────────────────────────────────────────
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
DIM='\033[2m'
BOLD='\033[1m'
NC='\033[0m'

log_info()    { echo -e "${BLUE}ℹ${NC}  $1"; }
log_ok()      { echo -e "${GREEN}✅${NC} $1"; }
log_warn()    { echo -e "${YELLOW}⚠️${NC}  $1"; }
log_error()   { echo -e "${RED}❌${NC} $1"; }
log_header()  { echo -e "\n${CYAN}${BOLD}$1${NC}"; }
log_dim()     { echo -e "${DIM}$1${NC}"; }

# ── Pre-flight Checks ─────────────────────────────────────────
check_config_exists() {
    if [ ! -f "$OPENCLAW_CONFIG" ]; then
        log_error "Config not found: $OPENCLAW_CONFIG"
        log_info "Run 'openclaw onboard' to create initial config"
        exit 1
    fi
}

check_cli_available() {
    if ! command -v openclaw &>/dev/null; then
        log_error "openclaw CLI not found in PATH"
        log_info "Install: npm install -g openclaw"
        return 1
    fi
    return 0
}

check_python() {
    if ! command -v python3 &>/dev/null; then
        log_error "Python 3 required but not found"
        exit 1
    fi
}

# ── Validation ─────────────────────────────────────────────────
run_validation() {
    local cmd="$1"
    shift
    python3 "$VALIDATE" "$cmd" "$@" 2>>"$BACKUP_DIR/switchboard-stderr.log"
}

validate_model_for_role() {
    local model="$1"
    local role="$2"

    # FAIL-CLOSED: any parse/validation failure = reject
    # This is a safety tool. We never allow through on error.

    # Step 1: Format validation
    local ref_result ref_exit
    ref_result=$(run_validation ref "$model" 2>/dev/null) || ref_exit=$?
    if [ -z "$ref_result" ]; then
        log_error "Format validation failed (validator returned no output)"
        return 1
    fi

    local ref_valid
    ref_valid=$(echo "$ref_result" | python3 -c "import sys,json; print(json.load(sys.stdin).get('valid', False))" 2>/dev/null) || ref_valid=""

    if [ "$ref_valid" != "True" ]; then
        local ref_msg
        ref_msg=$(echo "$ref_result" | python3 -c "import sys,json; print(json.load(sys.stdin).get('message', 'Invalid format'))" 2>/dev/null) || ref_msg="Validation parse error"
        log_error "Format error: $ref_msg"
        return 1
    fi

    # Step 2: Role compatibility
    local role_result role_exit
    role_result=$(run_validation role "$model" "$role" 2>/dev/null) || role_exit=$?
    if [ -z "$role_result" ]; then
        log_error "Role validation failed (empty response from validator)"
        return 1
    fi

    local role_valid
    role_valid=$(echo "$role_result" | python3 -c "import sys,json; print(json.load(sys.stdin).get('valid', False))" 2>/dev/null) || role_valid=""

    # FAIL-CLOSED: anything other than explicit "True" is a rejection
    if [ "$role_valid" != "True" ]; then
        local role_msg
        role_msg=$(echo "$role_result" | python3 -c "import sys,json; print(json.load(sys.stdin).get('message', 'Incompatible or validation error'))" 2>/dev/null) || role_msg="Role validation failed"
        log_error "Role validation failed: $role_msg"
        return 1
    fi

    # Warnings (valid but not in registry — still allowed)
    local role_msg
    role_msg=$(echo "$role_result" | python3 -c "import sys,json; print(json.load(sys.stdin).get('message', ''))" 2>/dev/null) || role_msg=""
    if echo "$role_msg" | grep -qi "caution\|warning\|not in registry"; then
        log_warn "$role_msg"
    fi

    return 0
}

# ── Backup Engine ──────────────────────────────────────────────
backup_config() {
    mkdir -p "$BACKUP_DIR"
    chmod 700 "$BACKUP_DIR"
    local ts
    ts=$(date +%Y%m%d-%H%M%S)-$$
    local backup_file="$BACKUP_DIR/openclaw-$ts.json"

    # Validate source is valid JSON before backing up
    if ! SWITCHBOARD_CONFIG="$OPENCLAW_CONFIG" python3 -c "import json,os; json.load(open(os.environ['SWITCHBOARD_CONFIG']))" 2>/dev/null; then
        log_warn "Current config is not valid JSON — backing up anyway for recovery"
    fi

    cp "$OPENCLAW_CONFIG" "$backup_file"
    chmod 600 "$backup_file"
    log_ok "Config backed up: ${DIM}$backup_file${NC}"

    # Prune old backups (keep MAX_BACKUPS) with lockfile to prevent races
    local LOCKFILE="$BACKUP_DIR/.switchboard.lock"
    (
        flock -n 9 || { log_warn "Backup lock held — skipping prune"; return; }
        local count
        count=$(ls -1 "$BACKUP_DIR"/openclaw-*.json 2>/dev/null | wc -l | tr -d ' ')
        if [ "$count" -gt "$MAX_BACKUPS" ]; then
            ls -t "$BACKUP_DIR"/openclaw-*.json | tail -n +"$((MAX_BACKUPS + 1))" | xargs rm -f 2>/dev/null || true
            log_dim "Pruned old backups (keeping last $MAX_BACKUPS)"
        fi
    ) 9>"$LOCKFILE"

    echo "$backup_file"
}

# ── Status ─────────────────────────────────────────────────────
show_status() {
    check_config_exists
    check_python

    echo ""
    echo -e "${CYAN}╔══════════════════════════════════════════════════════════╗${NC}"
    echo -e "${CYAN}║         ${BOLD}🔀 MODEL SWITCHBOARD — STATUS${NC}${CYAN}                  ║${NC}"
    echo -e "${CYAN}╚══════════════════════════════════════════════════════════╝${NC}"
    echo ""

    # Try CLI first, fall back to direct config read
    if check_cli_available 2>/dev/null; then
        local cli_output
        cli_output=$(openclaw models status 2>&1) || true
        if [ -n "$cli_output" ] && ! echo "$cli_output" | grep -qi "error\|cannot find module"; then
            echo "$cli_output"
            echo ""
        else
            log_warn "CLI unavailable — reading config directly"
            echo ""
            _show_status_from_config
        fi
    else
        _show_status_from_config
    fi

    # Show validation issues
    local issues
    issues=$(run_validation config 2>&1) || true
    local issue_count
    issue_count=$(echo "$issues" | python3 -c "import sys,json; d=json.load(sys.stdin); print(len(d.get('issues',[])))" 2>/dev/null || echo "0")

    if [ "$issue_count" -gt "0" ]; then
        log_header "⚠️  Config Issues"
        echo "$issues" | python3 -c "
import sys, json
data = json.load(sys.stdin)
for i in data.get('issues', []):
    level = '🔴' if i['level'] == 'error' else '🟡'
    print(f'  {level} [{i[\"field\"]}] {i[\"message\"]}')
" 2>/dev/null
        echo ""
    fi

    # Backup info
    local backup_count=0
    if [ -d "$BACKUP_DIR" ]; then
        backup_count=$(ls -1 "$BACKUP_DIR"/openclaw-*.json 2>/dev/null | wc -l | tr -d ' ')
    fi
    local latest_backup="none"
    if [ "$backup_count" -gt 0 ]; then
        latest_backup=$(ls -t "$BACKUP_DIR"/openclaw-*.json 2>/dev/null | head -1 | xargs basename 2>/dev/null)
    fi

    echo -e "${DIM}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
    echo -e "  Backups: ${GREEN}${BOLD}$backup_count${NC} ${DIM}(latest: $latest_backup)${NC}"
    echo -e "  Config:  ${DIM}$OPENCLAW_CONFIG${NC}"
    echo ""
}

_show_status_from_config() {
    SWITCHBOARD_CONFIG="$OPENCLAW_CONFIG" python3 -c "
import json, sys, os
try:
    with open(os.environ['SWITCHBOARD_CONFIG']) as f:
        cfg = json.load(f)
except Exception as e:
    print(f'  Error reading config: {e}')
    sys.exit(0)

agents = cfg.get('agents', {}).get('defaults', {})
model = agents.get('model', {})
image = agents.get('imageModel', {})

# Primary
if isinstance(model, str):
    print(f'  \033[1mPrimary LLM:\033[0m      \033[34m{model}\033[0m')
elif isinstance(model, dict):
    p = model.get('primary', '(not set)')
    print(f'  \033[1mPrimary LLM:\033[0m      \033[34m{p}\033[0m')
    fb = model.get('fallbacks', [])
    if fb:
        print(f'  \033[1mFallbacks:\033[0m        \033[33m{\" → \".join(fb)}\033[0m')
    else:
        print(f'  \033[1mFallbacks:\033[0m        \033[2m(none)\033[0m')
else:
    print(f'  \033[1mPrimary LLM:\033[0m      \033[31m(not set)\033[0m')

# Image
if isinstance(image, str):
    print(f'  \033[1mImage Model:\033[0m      \033[35m{image}\033[0m')
elif isinstance(image, dict):
    ip = image.get('primary', '(not set)')
    print(f'  \033[1mImage Model:\033[0m      \033[35m{ip}\033[0m')
    ifb = image.get('fallbacks', [])
    if ifb:
        print(f'  \033[1mImage Fallbacks:\033[0m  \033[33m{\" → \".join(ifb)}\033[0m')
else:
    print(f'  \033[1mImage Model:\033[0m      \033[2m(not set)\033[0m')

# Allowlist
models_list = agents.get('models', {})
if models_list:
    print(f'  \033[1mAllowlist:\033[0m        {len(models_list)} models')
    for m, v in models_list.items():
        alias = v.get('alias', '') if isinstance(v, dict) else ''
        suffix = f' ({alias})' if alias else ''
        print(f'    • {m}{suffix}')
" 2>/dev/null || log_error "Could not parse config"
}

# ── Set Primary Model ──────────────────────────────────────────
set_primary() {
    local model="$1"
    check_config_exists

    log_header "Setting Primary LLM"
    log_info "Model: $model"

    # Validate
    validate_model_for_role "$model" "primary" || return 1

    # Dry run
    log_info "Dry run:"
    run_validation dry-run set-primary "$model" primary 2>/dev/null | python3 -c "
import sys, json
d = json.load(sys.stdin)
for k, v in d.get('current', {}).items():
    print(f'  \033[31m- {k}: {v}\033[0m')
for k, v in d.get('proposed', {}).items():
    print(f'  \033[32m+ {k}: {v}\033[0m')
" 2>/dev/null

    # Backup (capture exact path for rollback)
    local this_backup
    this_backup=$(backup_config 2>/dev/null | tail -1)

    # Apply
    if check_cli_available 2>/dev/null; then
        if openclaw models set "$model" 2>&1; then
            log_ok "Primary model set to: ${BOLD}$model${NC}"
            _verify_health
        else
            log_error "CLI command failed. Restoring from this operation's backup..."
            if [ -n "$this_backup" ] && [ -f "$this_backup" ]; then
                restore_backup "$this_backup"
            else
                restore_backup "latest"
            fi
        fi
    else
        log_error "openclaw CLI not available — cannot apply change safely"
        log_info "Run manually: openclaw models set $model"
        return 1
    fi
}

# ── Set Image Model ────────────────────────────────────────────
set_image() {
    local model="$1"
    check_config_exists

    log_header "Setting Image Model"
    log_info "Model: $model"

    validate_model_for_role "$model" "image" || return 1

    # Dry run
    log_info "Dry run:"
    run_validation dry-run set-image "$model" image 2>/dev/null | python3 -c "
import sys, json
d = json.load(sys.stdin)
for k, v in d.get('current', {}).items():
    print(f'  \033[31m- {k}: {v}\033[0m')
for k, v in d.get('proposed', {}).items():
    print(f'  \033[32m+ {k}: {v}\033[0m')
" 2>/dev/null

    local this_backup
    this_backup=$(backup_config 2>/dev/null | tail -1)

    if check_cli_available 2>/dev/null; then
        if openclaw models set-image "$model" 2>&1; then
            log_ok "Image model set to: ${BOLD}$model${NC}"
            _verify_health
        else
            log_error "CLI command failed. Restoring from this operation's backup..."
            if [ -n "$this_backup" ] && [ -f "$this_backup" ]; then
                restore_backup "$this_backup"
            else
                restore_backup "latest"
            fi
        fi
    else
        log_error "openclaw CLI not available"
        return 1
    fi
}

# ── Fallback Management ───────────────────────────────────────
add_fallback() {
    local model="$1"
    check_config_exists

    log_header "Adding LLM Fallback"
    validate_model_for_role "$model" "fallback" || return 1
    local this_backup
    this_backup=$(backup_config 2>/dev/null | tail -1)

    if check_cli_available 2>/dev/null; then
        if openclaw models fallbacks add "$model" 2>&1; then
            log_ok "Fallback added: ${BOLD}$model${NC}"
            _verify_health
        else
            log_error "Failed to add fallback. Restoring..."
            [ -n "$this_backup" ] && [ -f "$this_backup" ] && restore_backup "$this_backup"
        fi
    else
        log_error "openclaw CLI not available"
        return 1
    fi
}

remove_fallback() {
    local model="$1"
    check_config_exists
    local this_backup
    this_backup=$(backup_config 2>/dev/null | tail -1)

    if check_cli_available 2>/dev/null; then
        if openclaw models fallbacks remove "$model" 2>&1; then
            log_ok "Fallback removed: $model"
            _verify_health
        else
            log_error "Failed to remove fallback. Restoring..."
            [ -n "$this_backup" ] && [ -f "$this_backup" ] && restore_backup "$this_backup"
        fi
    else
        log_error "openclaw CLI not available"
        return 1
    fi
}

add_image_fallback() {
    local model="$1"
    check_config_exists

    log_header "Adding Image Fallback"
    validate_model_for_role "$model" "image" || return 1
    local this_backup
    this_backup=$(backup_config 2>/dev/null | tail -1)

    if check_cli_available 2>/dev/null; then
        if openclaw models image-fallbacks add "$model" 2>&1; then
            log_ok "Image fallback added: ${BOLD}$model${NC}"
            _verify_health
        else
            log_error "Failed to add image fallback. Restoring..."
            [ -n "$this_backup" ] && [ -f "$this_backup" ] && restore_backup "$this_backup"
        fi
    else
        log_error "openclaw CLI not available"
        return 1
    fi
}

remove_image_fallback() {
    local model="$1"
    check_config_exists
    local this_backup
    this_backup=$(backup_config 2>/dev/null | tail -1)

    if check_cli_available 2>/dev/null; then
        if openclaw models image-fallbacks remove "$model" 2>&1; then
            log_ok "Image fallback removed: $model"
            _verify_health
        else
            log_error "Failed to remove image fallback. Restoring..."
            [ -n "$this_backup" ] && [ -f "$this_backup" ] && restore_backup "$this_backup"
        fi
    else
        log_error "openclaw CLI not available"
        return 1
    fi
}

# ── Health Check ───────────────────────────────────────────────
_verify_health() {
    log_info "Verifying gateway health..."
    sleep 2
    if check_cli_available 2>/dev/null; then
        if openclaw status &>/dev/null; then
            log_ok "Gateway is healthy!"
        else
            log_warn "Gateway may need restart: openclaw gateway restart"
        fi
    fi
}

check_health() {
    log_header "Gateway Health Check"

    if ! check_cli_available 2>/dev/null; then
        log_error "openclaw CLI not available"
        return 1
    fi

    # Gateway status
    if openclaw status &>/dev/null; then
        log_ok "Gateway: running"
    else
        log_error "Gateway: not responding"
    fi

    # Provider auth
    log_info "Provider auth status:"
    if openclaw models status --plain 2>/dev/null; then
        log_ok "Models: configured"
    else
        log_warn "Cannot query model status"
    fi
}

# ── Discovery ──────────────────────────────────────────────────
discover_models() {
    log_header "Model Discovery"

    if check_cli_available 2>/dev/null; then
        log_info "Querying available models..."
        openclaw models list --all 2>&1 || {
            log_warn "CLI discovery failed — showing registry models"
            _show_registry_models
        }
    else
        log_warn "CLI unavailable — showing registry models"
        _show_registry_models
    fi
}

_show_registry_models() {
    SWITCHBOARD_REGISTRY="$SKILL_DIR/model-registry.json" python3 -c "
import json, os
with open(os.environ['SWITCHBOARD_REGISTRY']) as f:
    reg = json.load(f)
for mid, m in sorted(reg.get('models', {}).items()):
    if m.get('blocked'):
        continue
    caps = ', '.join(m.get('capabilities', []))
    cost = m.get('costTier', '?')
    print(f'  {mid:<40} [{cost}] {caps}')
" 2>/dev/null
}

# ── Recommendations ────────────────────────────────────────────
recommend() {
    log_header "Model Recommendations"
    check_python

    run_validation recommend 2>/dev/null | python3 -c "
import sys, json
data = json.load(sys.stdin)
for role_id, info in data.items():
    print(f'\n  \033[1m{info[\"role\"]}\033[0m')
    for c in info.get('candidates', []):
        auth = '\033[32m✓\033[0m' if c['hasAuth'] else '\033[31m✗\033[0m'
        print(f'    {auth} {c[\"model\"]:<40} [{c[\"costTier\"]}]')
    if not info.get('candidates'):
        print('    \033[2m(no compatible models found)\033[0m')
" 2>/dev/null || log_error "Recommendation engine failed"
}

# ── Dry Run ────────────────────────────────────────────────────
dry_run() {
    local action="${1:?Usage: switchboard.sh dry-run <action> <model>}"
    local model="${2:?Usage: switchboard.sh dry-run <action> <model>}"

    log_header "Dry Run — $action $model"
    check_python

    # Validate first
    local role="primary"
    case "$action" in
        set-primary)     role="primary" ;;
        set-image)       role="image" ;;
        add-fallback)    role="fallback" ;;
        add-image-fallback) role="image" ;;
    esac

    validate_model_for_role "$model" "$role" || return 1

    # Show diff
    run_validation dry-run "$action" "$model" "$role" 2>/dev/null | python3 -c "
import sys, json
d = json.load(sys.stdin)
print()
if d.get('wouldChange'):
    print('  \033[1mChanges:\033[0m')
    for k, v in d.get('current', {}).items():
        print(f'    \033[31m- {k}: {json.dumps(v)}\033[0m')
    for k, v in d.get('proposed', {}).items():
        print(f'    \033[32m+ {k}: {json.dumps(v)}\033[0m')
else:
    print('  \033[2mNo changes — already set to this value\033[0m')
print()
print('  \033[2mThis is a dry run. No changes were made.\033[0m')
" 2>/dev/null
}

# ── Export / Import ────────────────────────────────────────────
export_config() {
    check_config_exists
    check_python

    local output="${1:-model-config-export.json}"

    SWITCHBOARD_CONFIG="$OPENCLAW_CONFIG" SWITCHBOARD_EXPORT="$output" python3 -c "
import json, os, datetime
config_path = os.environ['SWITCHBOARD_CONFIG']
export_path = os.environ['SWITCHBOARD_EXPORT']
with open(config_path) as f:
    cfg = json.load(f)
agents = cfg.get('agents', {}).get('defaults', {})
export_data = {
    'version': '1.0',
    'exportedAt': datetime.datetime.now().isoformat(),
    'model': agents.get('model', {}),
    'imageModel': agents.get('imageModel', {}),
    'models': agents.get('models', {}),
}
with open(export_path, 'w') as f:
    json.dump(export_data, f, indent=2)
print(f'Exported to: {export_path}')
" 2>/dev/null

    log_ok "Model config exported to: $output"
}

import_config() {
    local input="${1:?Usage: switchboard.sh import <file>}"
    check_config_exists
    check_python

    if [ ! -f "$input" ]; then
        log_error "File not found: $input"
        return 1
    fi

    log_header "Importing Model Config"
    log_info "Source: $input"

    # Validate import file
    SWITCHBOARD_IMPORT_FILE="$input" SWITCHBOARD_CONFIG="$OPENCLAW_CONFIG" python3 -c "
import json, sys, os
import_file = os.environ['SWITCHBOARD_IMPORT_FILE']
with open(import_file) as f:
    data = json.load(f)
if 'model' not in data and 'imageModel' not in data:
    print('ERROR: Invalid export file — missing model configuration')
    sys.exit(1)
print(f'  Version: {data.get(\"version\", \"unknown\")}')
print(f'  Exported: {data.get(\"exportedAt\", \"unknown\")}')
model = data.get('model', {})
if isinstance(model, dict):
    print(f'  Primary: {model.get(\"primary\", \"(not set)\")}')
    print(f'  Fallbacks: {len(model.get(\"fallbacks\", []))}')
image = data.get('imageModel', {})
if isinstance(image, dict):
    print(f'  Image: {image.get(\"primary\", \"(not set)\")}')
models = data.get('models', {})
print(f'  Allowlist: {len(models)} models')
" 2>/dev/null || { log_error "Invalid import file"; return 1; }

    # Validate all model assignments in import before applying
    log_info "Validating imported model assignments..."
    local import_valid=true

    # Extract and validate primary model
    local imp_primary
    imp_primary=$(SWITCHBOARD_IMPORT_FILE="$input" python3 -c "
import json, os
with open(os.environ['SWITCHBOARD_IMPORT_FILE']) as f:
    d = json.load(f)
m = d.get('model', {})
print(m.get('primary', '') if isinstance(m, dict) else (m if isinstance(m, str) else ''))
" 2>/dev/null)
    if [ -n "$imp_primary" ]; then
        if ! validate_model_for_role "$imp_primary" "primary"; then
            log_error "Import blocked: primary model '$imp_primary' failed validation"
            import_valid=false
        fi
    fi

    # Extract and validate image model
    local imp_image
    imp_image=$(SWITCHBOARD_IMPORT_FILE="$input" python3 -c "
import json, os
with open(os.environ['SWITCHBOARD_IMPORT_FILE']) as f:
    d = json.load(f)
m = d.get('imageModel', {})
print(m.get('primary', '') if isinstance(m, dict) else (m if isinstance(m, str) else ''))
" 2>/dev/null)
    if [ -n "$imp_image" ]; then
        if ! validate_model_for_role "$imp_image" "image"; then
            log_error "Import blocked: image model '$imp_image' failed validation"
            import_valid=false
        fi
    fi

    if [ "$import_valid" != "true" ]; then
        log_error "Import aborted — one or more model assignments failed validation"
        return 1
    fi

    backup_config > /dev/null
    log_info "Applying imported config..."

    SWITCHBOARD_IMPORT_FILE="$input" SWITCHBOARD_CONFIG="$OPENCLAW_CONFIG" SWITCHBOARD_VALIDATE="$VALIDATE" python3 -c "
import json, os, sys, tempfile
config_path = os.environ['SWITCHBOARD_CONFIG']
import_file = os.environ['SWITCHBOARD_IMPORT_FILE']

with open(config_path) as f:
    cfg = json.load(f)
with open(import_file) as f:
    imp = json.load(f)

if 'agents' not in cfg:
    cfg['agents'] = {}
if 'defaults' not in cfg['agents']:
    cfg['agents']['defaults'] = {}

if 'model' in imp:
    cfg['agents']['defaults']['model'] = imp['model']
if 'imageModel' in imp:
    cfg['agents']['defaults']['imageModel'] = imp['imageModel']
if 'models' in imp:
    cfg['agents']['defaults']['models'] = imp['models']

# Atomic write: temp file → validate JSON + schema → rename
fd, tmp = tempfile.mkstemp(suffix='.json', dir=os.path.dirname(config_path))
with os.fdopen(fd, 'w') as f:
    json.dump(cfg, f, indent=2)
# Validate the temp file is valid JSON
with open(tmp) as vf:
    validated = json.load(vf)
# Schema validation before committing
sys.path.insert(0, os.path.dirname(os.path.realpath(os.environ['SWITCHBOARD_VALIDATE'])))
try:
    from validate import validate_config_schema
    issues = validate_config_schema(validated)
    errors = [i for i in issues if i['level'] == 'error']
    if errors:
        os.unlink(tmp)
        for e in errors:
            print('VALIDATION ERROR: ' + e['message'], file=sys.stderr)
        print('VALIDATION_FAILED')
        sys.exit(1)
except ImportError:
    pass  # validate.py not importable, skip schema check
os.rename(tmp, config_path)
os.chmod(config_path, 0o600)
print('OK')
" 2>/dev/null || { log_error "Import failed — config unchanged"; return 1; }

    log_ok "Model config imported successfully"
    log_info "Restart gateway: openclaw gateway restart"
}

# ── Backup Management ─────────────────────────────────────────
list_backups() {
    log_header "Config Backups"
    echo ""

    if ! ls "$BACKUP_DIR"/openclaw-*.json &>/dev/null 2>&1; then
        log_warn "No backups found in $BACKUP_DIR"
        return
    fi

    local count
    count=$(ls -1 "$BACKUP_DIR"/openclaw-*.json 2>/dev/null | wc -l | tr -d ' ')
    echo -e "  ${BOLD}Total: $count backups${NC} ${DIM}(max retention: $MAX_BACKUPS)${NC}"
    echo ""

    ls -lht "$BACKUP_DIR"/openclaw-*.json 2>/dev/null | head -20 | while IFS= read -r line; do
        echo "  $line"
    done

    if [ "$count" -gt 20 ]; then
        echo -e "\n  ${DIM}... and $((count - 20)) more${NC}"
    fi
}

restore_backup() {
    local target="$1"

    if [ "$target" = "latest" ]; then
        target=$(ls -t "$BACKUP_DIR"/openclaw-*.json 2>/dev/null | head -1)
        if [ -z "$target" ]; then
            log_error "No backups found!"
            return 1
        fi
    fi

    # Resolve path
    if [ ! -f "$target" ]; then
        if [ -f "$BACKUP_DIR/$target" ]; then
            target="$BACKUP_DIR/$target"
        else
            log_error "Backup not found: $target"
            log_info "Run: $0 list-backups"
            return 1
        fi
    fi

    # Validate backup is valid JSON
    if ! SWITCHBOARD_TARGET="$target" python3 -c "import json,os; json.load(open(os.environ['SWITCHBOARD_TARGET']))" 2>/dev/null; then
        log_error "Backup file is not valid JSON: $target"
        return 1
    fi

    log_header "Restoring Config"
    log_info "From: $target"

    # Save current broken config for forensics
    mkdir -p "$BACKUP_DIR"
    cp "$OPENCLAW_CONFIG" "$BACKUP_DIR/pre-restore-$(date +%Y%m%d-%H%M%S).json" 2>/dev/null || true

    # Atomic restore
    local tmp
    tmp=$(mktemp "${OPENCLAW_CONFIG}.tmp.XXXXXX")
    cp "$target" "$tmp"
    mv "$tmp" "$OPENCLAW_CONFIG"
    chmod 600 "$OPENCLAW_CONFIG"

    log_ok "Config restored from: $(basename "$target")"
    log_info "Restart gateway: ${BOLD}openclaw gateway restart${NC}"
}

# ── Redundancy Engine ──────────────────────────────────────────
redundancy_report() {
    check_python
    log_header "Redundancy Assessment"
    python3 "$REDUNDANCY" report "${1:-3}" 2>/dev/null || log_error "Redundancy engine failed"
}

redundancy_deploy() {
    local min_depth="${1:-3}"
    check_config_exists
    check_python

    log_header "Generating ${min_depth}-Deep Redundant Config"

    local deploy_json
    deploy_json=$(python3 "$REDUNDANCY" deploy "$min_depth" 2>/dev/null)

    if [ -z "$deploy_json" ]; then
        log_error "Redundancy engine produced no output"
        return 1
    fi

    # Check for error
    if echo "$deploy_json" | python3 -c "import sys,json; d=json.load(sys.stdin); sys.exit(0 if 'error' not in d else 1)" 2>/dev/null; then
        log_info "Preview of generated config:"
        echo "$deploy_json" | python3 -m json.tool 2>/dev/null
        echo ""
        log_info "To apply this config, run:"
        echo "  switchboard.sh redundancy-apply $min_depth"
    else
        local err_msg
        err_msg=$(echo "$deploy_json" | python3 -c "import sys,json; print(json.load(sys.stdin).get('error','Unknown error'))" 2>/dev/null)
        log_error "$err_msg"
        return 1
    fi
}

redundancy_apply() {
    local min_depth="${1:-3}"
    check_config_exists
    check_python

    log_header "Applying ${min_depth}-Deep Redundant Config"

    local deploy_json
    deploy_json=$(python3 "$REDUNDANCY" deploy "$min_depth" 2>/dev/null)

    if [ -z "$deploy_json" ] || echo "$deploy_json" | python3 -c "import sys,json; d=json.load(sys.stdin); sys.exit(1 if 'error' in d else 0)" 2>/dev/null; then
        log_error "Cannot generate valid redundant config"
        return 1
    fi

    # Backup first
    local this_backup
    this_backup=$(backup_config 2>/dev/null | tail -1)

    # Merge into existing config using atomic write
    SWITCHBOARD_CONFIG="$OPENCLAW_CONFIG" SWITCHBOARD_DEPLOY="$deploy_json" python3 -c "
import json, os, tempfile

config_path = os.environ['SWITCHBOARD_CONFIG']
deploy_data = json.loads(os.environ['SWITCHBOARD_DEPLOY'])

with open(config_path) as f:
    cfg = json.load(f)

# Deep merge: only update agents.defaults model fields
if 'agents' not in cfg:
    cfg['agents'] = {}
if 'defaults' not in cfg['agents']:
    cfg['agents']['defaults'] = {}

deploy_defaults = deploy_data.get('agents', {}).get('defaults', {})

if 'model' in deploy_defaults:
    cfg['agents']['defaults']['model'] = deploy_defaults['model']
if 'imageModel' in deploy_defaults:
    cfg['agents']['defaults']['imageModel'] = deploy_defaults['imageModel']
if 'models' in deploy_defaults:
    # Merge allowlist (don't overwrite existing entries)
    existing = cfg['agents']['defaults'].get('models', {}) or {}
    for k, v in deploy_defaults['models'].items():
        if k not in existing:
            existing[k] = v
    cfg['agents']['defaults']['models'] = existing

# Atomic write
fd, tmp = tempfile.mkstemp(suffix='.json', dir=os.path.dirname(config_path))
with os.fdopen(fd, 'w') as f:
    json.dump(cfg, f, indent=2)
json.load(open(tmp))  # Validate
os.rename(tmp, config_path)
os.chmod(config_path, 0o600)
print('OK')
" 2>/dev/null

    if [ $? -eq 0 ]; then
        log_ok "Redundant config applied successfully!"
        log_info "Run 'openclaw gateway restart' to activate"
        log_info "Run 'switchboard.sh status' to verify"

        # Show what was deployed
        python3 "$REDUNDANCY" report "$min_depth" 2>/dev/null
    else
        log_error "Failed to apply config. Restoring backup..."
        if [ -n "$this_backup" ] && [ -f "$this_backup" ]; then
            restore_backup "$this_backup"
        fi
        return 1
    fi
}

# ── Cron Model Validation ──────────────────────────────────
validate_cron_models() {
    check_python
    log_header "Validating Cron Job Models"
    run_validation validate-cron-models 2>&1 | python3 -c "
import sys, json
data = json.load(sys.stdin)
issues = data.get('issues', [])
if not issues:
    print('  ✅ All cron job models are valid')
else:
    for i in issues:
        level = '🔴' if i.get('level') == 'error' else '🟡'
        print(f'  {level} Job \"{i.get(\"job\", \"?\")}\" — {i.get(\"message\", \"unknown issue\")}')
print(f'\n  Total jobs checked: {data.get(\"totalJobs\", 0)}')
print(f'  Issues found: {len(issues)}')
" 2>/dev/null || log_error "Cron model validation failed"
}

# ── Canvas UI Data ─────────────────────────────────────────────
generate_ui_data() {
    check_python
    run_validation status 2>/dev/null
}

# ── Main Router ────────────────────────────────────────────────
show_help() {
    echo ""
    echo -e "${CYAN}${BOLD}🔀 Model Switchboard v3.0${NC}"
    echo -e "${DIM}Safe AI Model Configuration for OpenClaw${NC}"
    echo ""
    echo -e "${BOLD}USAGE${NC}"
    echo "  switchboard.sh <command> [args]"
    echo ""
    echo -e "${BOLD}MODEL ASSIGNMENT${NC}"
    echo "  status                    Show current model assignments"
    echo "  set-primary <model>       Set primary LLM"
    echo "  set-image <model>         Set image/vision model"
    echo "  add-fallback <model>      Add LLM fallback"
    echo "  remove-fallback <model>   Remove LLM fallback"
    echo "  add-image-fallback <m>    Add image model fallback"
    echo "  remove-image-fallback <m> Remove image model fallback"
    echo ""
    echo -e "${BOLD}DISCOVERY & RECOMMENDATIONS${NC}"
    echo "  discover                  List all available models"
    echo "  recommend                 Suggest optimal assignments"
    echo "  dry-run <action> <model>  Preview changes without applying"
    echo ""
    echo -e "${BOLD}BACKUP & RESTORE${NC}"
    echo "  backup                    Backup current config"
    echo "  list-backups              List available backups"
    echo "  restore <file|latest>     Restore from backup"
    echo ""
    echo -e "${BOLD}IMPORT / EXPORT${NC}"
    echo "  export [file]             Export model config as JSON"
    echo "  import <file>             Import model config from JSON"
    echo ""
    echo -e "${BOLD}REDUNDANCY${NC}"
    echo "  redundancy [depth]        Assess current redundancy (default: 3)"
    echo "  redundancy-deploy [depth] Preview redundant config"
    echo "  redundancy-apply [depth]  Apply redundant config to gateway"
    echo ""
    echo -e "${BOLD}DIAGNOSTICS${NC}"
    echo "  health                    Check gateway & provider status"
    echo "  validate <model> <role>   Test model-role compatibility"
    echo "  validate-cron-models      Validate models used in cron jobs"
    echo "  ui                        Generate Canvas UI data (JSON)"
    echo ""
    echo -e "${BOLD}MODEL FORMAT${NC}"
    echo "  provider/model-name       e.g., anthropic/claude-opus-4-6"
    echo ""
    echo -e "${BOLD}GETTING STARTED${NC}"
    echo "  setup                     First-time setup wizard"
    echo "  init                      Alias for setup"
    echo ""
    echo -e "${BOLD}EXAMPLES${NC}"
    echo "  switchboard.sh set-primary anthropic/claude-opus-4-6"
    echo "  switchboard.sh set-image google/gemini-3-pro-preview"
    echo "  switchboard.sh dry-run set-primary openai/gpt-5.2"
    echo "  switchboard.sh restore latest"
    echo ""
}

case "${1:-}" in
    setup|init)          [ -f "$SKILL_DIR/scripts/setup.sh" ] || { log_error "setup.sh not found"; exit 1; }; bash "$SKILL_DIR/scripts/setup.sh" ;;
    status)              show_status ;;
    set-primary)         set_primary "${2:?Model required. Usage: switchboard.sh set-primary provider/model}" ;;
    set-image)           set_image "${2:?Model required. Usage: switchboard.sh set-image provider/model}" ;;
    add-fallback)        add_fallback "${2:?Model required}" ;;
    remove-fallback)     remove_fallback "${2:?Model required}" ;;
    add-image-fallback)  add_image_fallback "${2:?Model required}" ;;
    remove-image-fallback) remove_image_fallback "${2:?Model required}" ;;
    discover)            discover_models ;;
    recommend)           recommend ;;
    dry-run)             dry_run "${2:-}" "${3:-}" ;;
    redundancy)          redundancy_report "${2:-3}" ;;
    redundancy-deploy)   redundancy_deploy "${2:-3}" ;;
    redundancy-apply)    redundancy_apply "${2:-3}" ;;
    health)              check_health ;;
    validate)            validate_model_for_role "${2:?Model required}" "${3:-primary}" ;;
    backup)              backup_config ;;
    list-backups)        list_backups ;;
    restore)             restore_backup "${2:?Target required. Use 'latest' or a filename}" ;;
    export)              export_config "${2:-model-config-export.json}" ;;
    import)              import_config "${2:?File required}" ;;
    validate-cron-models) validate_cron_models ;;
    ui)                  generate_ui_data ;;
    help|--help|-h)      show_help ;;
    *)                   show_help ;;
esac


path: scripts/validate.py
content: #!/usr/bin/env python3
"""
Model Switchboard — Validation Engine
Validates model assignments, config integrity, and generates dry-run diffs.
Zero external dependencies. Python 3.8+.
"""

import json
import os
import re
import sys
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple

# ── Paths ──────────────────────────────────────────────────────
SKILL_DIR = Path(__file__).resolve().parent.parent
REGISTRY_PATH = SKILL_DIR / "model-registry.json"
CONFIG_PATH = Path.home() / ".openclaw" / "openclaw.json"
BACKUP_DIR = Path.home() / ".openclaw" / "model-backups"

# ── Registry ───────────────────────────────────────────────────

class RegistryLoadError(Exception):
    """Raised when the model registry cannot be loaded."""
    pass


class ConfigLoadError(Exception):
    """Raised when openclaw.json cannot be loaded."""
    pass


def load_registry(strict: bool = True) -> Dict[str, Any]:
    """
    Load the model registry.
    In strict mode (default for safety operations): raises on failure.
    In non-strict mode (status/display): returns empty structure.
    """
    try:
        with open(REGISTRY_PATH, "r") as f:
            data = json.load(f)
            if "models" not in data or "roles" not in data:
                if strict:
                    raise RegistryLoadError(f"Registry missing required keys (models/roles): {REGISTRY_PATH}")
                return {"models": {}, "providers": {}, "roles": {}}
            return data
    except (FileNotFoundError, json.JSONDecodeError) as e:
        if strict:
            raise RegistryLoadError(f"Cannot load model registry: {e}")
        return {"models": {}, "providers": {}, "roles": {}}


def load_config(strict: bool = False) -> Dict[str, Any]:
    """
    Load openclaw.json.
    In strict mode: raises on failure.
    In non-strict mode (default): returns empty dict with _load_error flag.
    """
    try:
        with open(CONFIG_PATH, "r") as f:
            return json.load(f)
    except FileNotFoundError:
        if strict:
            raise ConfigLoadError(f"Config not found: {CONFIG_PATH}")
        return {"_load_error": "Config file not found"}
    except json.JSONDecodeError as e:
        if strict:
            raise ConfigLoadError(f"Config is not valid JSON: {e}")
        return {"_load_error": f"Invalid JSON: {e}"}


# ── Validation Functions ───────────────────────────────────────

def validate_model_ref(model: str) -> Tuple[bool, str]:
    """
    Validate model reference format.
    Must be provider/model-name (e.g., anthropic/claude-opus-4-6).
    OpenRouter uses provider/subprovider/model format.
    """
    if not model or not isinstance(model, str):
        return False, "Model reference cannot be empty"

    parts = model.split("/", 1)
    if len(parts) < 2 or not parts[0] or not parts[1]:
        return False, f"Invalid format: '{model}'. Must be provider/model-name (e.g., anthropic/claude-opus-4-6)"

    provider = parts[0]
    model_id = parts[1]

    # Provider must be alphanumeric + hyphens
    if not re.match(r'^[a-z0-9][a-z0-9-]*$', provider):
        return False, f"Invalid provider: '{provider}'. Use lowercase alphanumeric + hyphens"

    # Model ID: alphanumeric + hyphens + dots + slashes (for OpenRouter)
    if not re.match(r'^[a-zA-Z0-9][a-zA-Z0-9._/-]*$', model_id):
        return False, f"Invalid model ID: '{model_id}'"

    return True, "Valid"


def validate_role_compatibility(model: str, role: str) -> Tuple[bool, str, Optional[str]]:
    """
    Check if a model is compatible with a given role.
    Returns (is_valid, message, suggestion).
    FAIL-CLOSED: if registry can't load, rejects the assignment.
    """
    try:
        registry = load_registry(strict=True)
    except RegistryLoadError as e:
        return False, f"SAFETY BLOCK: Cannot load model registry — {e}. Fix registry before changing models.", None
    roles = registry.get("roles", {})
    models = registry.get("models", {})

    if role not in roles:
        return True, f"Unknown role '{role}' — skipping capability check", None

    role_info = roles[role]
    required_caps = set(role_info.get("requiredCapabilities", []))

    # Check registry for known models
    if model in models:
        model_info = models[model]

        # Hard block check
        if model_info.get("blocked", False):
            reason = model_info.get("blockReason", "This model is blocked for all roles")
            return False, f"BLOCKED: {reason}", None

        # Unsafe role check
        unsafe = model_info.get("unsafeRoles", [])
        if role in unsafe:
            return False, f"'{model}' is not safe for the '{role}' role", None

        # Safe role check
        safe = set(model_info.get("safeRoles", []))
        if role not in safe:
            return False, f"'{model}' is not verified for the '{role}' role. Safe roles: {', '.join(safe)}", None

        # Capability check
        model_caps = set(model_info.get("capabilities", []))
        missing = required_caps - model_caps
        if missing:
            return False, f"'{model}' lacks required capabilities for '{role}': {', '.join(missing)}", None

        return True, f"'{model}' is compatible with '{role}' role", None

    # Unknown model — warn but allow (might be OpenRouter or new model)
    provider = model.split("/")[0]
    if provider == "openrouter":
        return True, f"OpenRouter model — cannot verify capabilities offline. Proceed with caution.", None

    return True, f"Model '{model}' not in registry — cannot verify capabilities. Proceed with caution.", \
        "Add this model to model-registry.json for future validation"


def validate_config_schema(config: Dict[str, Any]) -> List[Dict[str, str]]:
    """
    Validate the config structure for model-related keys.
    Returns list of issues found.
    """
    issues: List[Dict[str, str]] = []

    # Check for load errors
    if "_load_error" in config:
        issues.append({
            "level": "error",
            "field": "openclaw.json",
            "message": f"Config load failed: {config['_load_error']}"
        })
        return issues

    agents = config.get("agents", {})
    defaults = agents.get("defaults", {}) if isinstance(agents, dict) else {}

    model = defaults.get("model", {}) if isinstance(defaults, dict) else {}
    image_model = defaults.get("imageModel", {}) if isinstance(defaults, dict) else {}

    # Check model structure
    if isinstance(model, str):
        # Old format — should be { primary: "..." }
        issues.append({
            "level": "warning",
            "field": "agents.defaults.model",
            "message": f"Model is a plain string ('{model}'). Recommended: {{ primary: \"{model}\" }}"
        })
    elif isinstance(model, dict):
        primary = model.get("primary")
        if primary:
            valid, msg = validate_model_ref(primary)
            if not valid:
                issues.append({"level": "error", "field": "agents.defaults.model.primary", "message": msg})

        fallbacks = model.get("fallbacks", [])
        if not isinstance(fallbacks, list):
            issues.append({
                "level": "error",
                "field": "agents.defaults.model.fallbacks",
                "message": "Fallbacks must be an array"
            })
        else:
            for i, fb in enumerate(fallbacks):
                valid, msg = validate_model_ref(fb)
                if not valid:
                    issues.append({
                        "level": "error",
                        "field": f"agents.defaults.model.fallbacks[{i}]",
                        "message": msg
                    })

    # Check imageModel structure
    if isinstance(image_model, str):
        issues.append({
            "level": "warning",
            "field": "agents.defaults.imageModel",
            "message": f"Image model is a plain string ('{image_model}'). Recommended: {{ primary: \"{image_model}\" }}"
        })
    elif isinstance(image_model, dict):
        img_primary = image_model.get("primary")
        if img_primary:
            valid, msg = validate_model_ref(img_primary)
            if not valid:
                issues.append({"level": "error", "field": "agents.defaults.imageModel.primary", "message": msg})

        img_fallbacks = image_model.get("fallbacks", [])
        if not isinstance(img_fallbacks, list):
            issues.append({
                "level": "error",
                "field": "agents.defaults.imageModel.fallbacks",
                "message": "Image fallbacks must be an array"
            })
        else:
            for i, ifb in enumerate(img_fallbacks):
                valid, msg = validate_model_ref(ifb)
                if not valid:
                    issues.append({
                        "level": "error",
                        "field": f"agents.defaults.imageModel.fallbacks[{i}]",
                        "message": msg
                    })

    # Check allowlist
    models_list = defaults.get("models", {})
    if models_list and isinstance(models_list, dict):
        for model_key in models_list:
            valid, msg = validate_model_ref(model_key)
            if not valid:
                issues.append({
                    "level": "warning",
                    "field": f"agents.defaults.models.{model_key}",
                    "message": f"Invalid model ref in allowlist: {msg}"
                })

    return issues


def check_provider_auth(provider: str) -> Tuple[bool, str]:
    """Check if a provider has auth configured (via environment variables)."""
    registry = load_registry(strict=False)
    providers = registry.get("providers", {})

    if provider not in providers:
        return False, f"Unknown provider: '{provider}'"

    provider_info = providers[provider]
    auth_envs = provider_info.get("authEnv", [])

    if not auth_envs:
        auth_method = provider_info.get("authMethod", "unknown")
        return True, f"Provider uses {auth_method} auth (not env-based)"

    for env_var in auth_envs:
        if os.environ.get(env_var):
            return True, f"Auth configured via {env_var}"

    return False, f"No auth found. Set one of: {', '.join(auth_envs)}"


def generate_dry_run_diff(action: str, model: str, role: str) -> Dict[str, Any]:
    """Generate a diff showing what would change without applying."""
    config = load_config()
    agents = config.get("agents", {}).get("defaults", {})

    current = {}
    proposed = {}

    if action == "set-primary":
        model_cfg = agents.get("model", {})
        current_primary = model_cfg.get("primary") if isinstance(model_cfg, dict) else model_cfg
        current = {"agents.defaults.model.primary": current_primary or "(not set)"}
        proposed = {"agents.defaults.model.primary": model}

    elif action == "set-image":
        img_cfg = agents.get("imageModel", {})
        current_img = img_cfg.get("primary") if isinstance(img_cfg, dict) else img_cfg
        current = {"agents.defaults.imageModel.primary": current_img or "(not set)"}
        proposed = {"agents.defaults.imageModel.primary": model}

    elif action == "add-fallback":
        model_cfg = agents.get("model", {})
        fb = model_cfg.get("fallbacks", []) if isinstance(model_cfg, dict) else []
        current = {"agents.defaults.model.fallbacks": fb}
        proposed = {"agents.defaults.model.fallbacks": fb + [model]}

    elif action == "add-image-fallback":
        img_cfg = agents.get("imageModel", {})
        ifb = img_cfg.get("fallbacks", []) if isinstance(img_cfg, dict) else []
        current = {"agents.defaults.imageModel.fallbacks": ifb}
        proposed = {"agents.defaults.imageModel.fallbacks": ifb + [model]}

    return {
        "action": action,
        "model": model,
        "role": role,
        "current": current,
        "proposed": proposed,
        "wouldChange": current != proposed
    }


def validate_cron_models() -> Dict[str, Any]:
    """
    Validate model references in cron jobs against the registry and allowlist.
    Reads from ~/.openclaw/cron/jobs.json.
    """
    cron_path = Path.home() / ".openclaw" / "cron" / "jobs.json"
    issues: List[Dict[str, str]] = []

    if not cron_path.exists():
        return {"totalJobs": 0, "issues": [], "note": "No cron jobs file found"}

    try:
        with open(cron_path) as f:
            jobs = json.load(f)
    except (json.JSONDecodeError, IOError) as e:
        return {"totalJobs": 0, "issues": [{"level": "error", "job": "(file)", "message": f"Cannot read cron jobs: {e}"}]}

    if not isinstance(jobs, list):
        jobs = list(jobs.values()) if isinstance(jobs, dict) else []

    registry = load_registry(strict=False)
    config = load_config(strict=False)
    agents = config.get("agents", {}).get("defaults", {})
    raw_models = agents.get("models")
    allowlist = set(raw_models.keys()) if isinstance(raw_models, dict) else set()

    for job in jobs:
        if not isinstance(job, dict):
            continue
        job_name = job.get("name") or job.get("id") or "unnamed"
        # Extract model from various possible locations
        model = (job.get("model") or
                 (job.get("payload") or {}).get("model") or
                 (job.get("config") or {}).get("model"))
        if not model:
            continue

        # Validate format
        valid, msg = validate_model_ref(model)
        if not valid:
            issues.append({"level": "error", "job": job_name, "message": f"Invalid model ref '{model}': {msg}"})
            continue

        # Check registry
        if model not in registry.get("models", {}):
            issues.append({"level": "warning", "job": job_name, "message": f"Model '{model}' not in registry"})

        # Check allowlist (if allowlist is configured)
        if allowlist and model not in allowlist:
            issues.append({"level": "warning", "job": job_name, "message": f"Model '{model}' not in allowlist"})

        # Check if blocked
        model_info = registry.get("models", {}).get(model, {})
        if model_info.get("blocked"):
            issues.append({"level": "error", "job": job_name, "message": f"Model '{model}' is blocked: {model_info.get('blockReason', 'no reason')}"})

    return {"totalJobs": len(jobs), "issues": issues}


def get_full_status() -> Dict[str, Any]:
    """Get complete model configuration status for UI rendering."""
    config = load_config(strict=False)
    registry = load_registry(strict=False)
    agents = config.get("agents", {}).get("defaults", {})

    model_cfg = agents.get("model", {})
    image_cfg = agents.get("imageModel", {})

    # Extract primary + fallbacks
    if isinstance(model_cfg, str):
        primary = model_cfg
        fallbacks = []
    elif isinstance(model_cfg, dict):
        primary = model_cfg.get("primary")
        fallbacks = model_cfg.get("fallbacks", [])
    else:
        primary = None
        fallbacks = []

    if isinstance(image_cfg, str):
        image_primary = image_cfg
        image_fallbacks = []
    elif isinstance(image_cfg, dict):
        image_primary = image_cfg.get("primary")
        image_fallbacks = image_cfg.get("fallbacks", [])
    else:
        image_primary = None
        image_fallbacks = []

    # Allowlist (safe: handle non-dict types)
    raw_models = agents.get("models")
    allowlist = list(raw_models.keys()) if isinstance(raw_models, dict) else []

    # Provider auth status
    provider_status = {}
    for pid, pinfo in registry.get("providers", {}).items():
        has_auth, auth_msg = check_provider_auth(pid)
        provider_status[pid] = {
            "displayName": pinfo.get("displayName", pid),
            "hasAuth": has_auth,
            "authMessage": auth_msg
        }

    # Backup count
    backup_count = 0
    if BACKUP_DIR.exists():
        backup_count = len(list(BACKUP_DIR.glob("openclaw-*.json")))

    # Config validation
    issues = validate_config_schema(config)

    return {
        "primary": primary,
        "fallbacks": fallbacks,
        "imageModel": image_primary,
        "imageFallbacks": image_fallbacks,
        "allowlist": allowlist,
        "providers": provider_status,
        "backupCount": backup_count,
        "issues": issues,
        "registryModels": list(registry.get("models", {}).keys()),
        "timestamp": __import__("datetime").datetime.now().isoformat()
    }


# ── CLI Interface ──────────────────────────────────────────────

def main():
    if len(sys.argv) < 2:
        print(json.dumps({"error": "Usage: validate.py <command> [args]"}, indent=2))
        sys.exit(1)

    command = sys.argv[1]

    if command == "ref":
        # Validate a model reference format
        model = sys.argv[2] if len(sys.argv) > 2 else ""
        valid, msg = validate_model_ref(model)
        result = {"valid": valid, "message": msg, "model": model}
        print(json.dumps(result, indent=2))
        sys.exit(0 if valid else 1)

    elif command == "role":
        # Validate model + role compatibility
        model = sys.argv[2] if len(sys.argv) > 2 else ""
        role = sys.argv[3] if len(sys.argv) > 3 else "primary"
        valid, msg, suggestion = validate_role_compatibility(model, role)
        result = {"valid": valid, "message": msg, "model": model, "role": role}
        if suggestion:
            result["suggestion"] = suggestion
        print(json.dumps(result, indent=2))
        sys.exit(0 if valid else 1)

    elif command == "config":
        # Validate current config
        config = load_config()
        issues = validate_config_schema(config)
        result = {"valid": len([i for i in issues if i["level"] == "error"]) == 0, "issues": issues}
        print(json.dumps(result, indent=2))
        sys.exit(0 if result["valid"] else 1)

    elif command == "auth":
        # Check provider auth
        provider = sys.argv[2] if len(sys.argv) > 2 else ""
        has_auth, msg = check_provider_auth(provider)
        result = {"provider": provider, "hasAuth": has_auth, "message": msg}
        print(json.dumps(result, indent=2))
        sys.exit(0 if has_auth else 1)

    elif command == "dry-run":
        # Generate dry-run diff
        action = sys.argv[2] if len(sys.argv) > 2 else ""
        model = sys.argv[3] if len(sys.argv) > 3 else ""
        role = sys.argv[4] if len(sys.argv) > 4 else "primary"
        diff = generate_dry_run_diff(action, model, role)
        print(json.dumps(diff, indent=2))

    elif command == "status":
        # Full status for UI
        status = get_full_status()
        print(json.dumps(status, indent=2))

    elif command == "validate-cron-models":
        result = validate_cron_models()
        print(json.dumps(result, indent=2))
        sys.exit(0 if not any(i["level"] == "error" for i in result.get("issues", [])) else 1)

    elif command == "recommend":
        # Recommend models for each role based on available providers
        registry = load_registry(strict=False)
        recommendations = {}
        for role_id, role_info in registry.get("roles", {}).items():
            required = set(role_info.get("requiredCapabilities", []))
            preferred_cost = role_info.get("preferCostTier", [])
            candidates = []
            for mid, minfo in registry.get("models", {}).items():
                if minfo.get("blocked"):
                    continue
                if role_id not in minfo.get("safeRoles", []):
                    continue
                caps = set(minfo.get("capabilities", []))
                if required.issubset(caps):
                    # Check if provider has auth
                    provider = mid.split("/")[0]
                    has_auth, _ = check_provider_auth(provider)
                    score = 0
                    if has_auth:
                        score += 100
                    cost = minfo.get("costTier", "medium")
                    if preferred_cost and cost in preferred_cost:
                        score += 50
                    recommended_caps = set(role_info.get("recommendedCapabilities", []))
                    score += len(caps & recommended_caps) * 10
                    candidates.append({
                        "model": mid,
                        "displayName": minfo.get("displayName", mid),
                        "costTier": cost,
                        "hasAuth": has_auth,
                        "score": score
                    })
            candidates.sort(key=lambda x: x["score"], reverse=True)
            recommendations[role_id] = {
                "role": role_info.get("displayName", role_id),
                "candidates": candidates[:5]
            }
        print(json.dumps(recommendations, indent=2))

    else:
        print(json.dumps({"error": f"Unknown command: {command}. Use: ref, role, config, auth, dry-run, status, recommend"}, indent=2))
        sys.exit(1)


if __name__ == "__main__":
    main()


path: task-routing.json
content: {
  "taskRouting": {
    "reasoning": {
      "model": "anthropic/claude-opus-4-6",
      "description": "Complex analysis, strategy, planning"
    },
    "toolCalling": {
      "model": "anthropic/claude-opus-4-6",
      "description": "File editing, CLI commands, API calls"
    },
    "cron": {
      "model": "google/gemini-3-flash-preview",
      "description": "Cron jobs, scheduled automation"
    },
    "heartbeat": {
      "model": "google/gemini-3-flash-preview",
      "description": "Low-cost periodic polling"
    },
    "imageGen": {
      "model": null,
      "description": "Image generation (DALL-E, etc.)"
    },
    "vision": {
      "model": "google/gemini-3-pro-preview",
      "description": "Vision, screenshot analysis, video watching"
    },
    "research": {
      "model": "anthropic/claude-opus-4-6",
      "description": "Web search, page fetching, data gathering"
    },
    "social": {
      "model": "anthropic/claude-opus-4-6",
      "description": "Twitter/X posting, monitoring, engagement"
    },
    "fileOps": {
      "model": "anthropic/claude-opus-4-6",
      "description": "File organization, cleanup, backups"
    },
    "coding": {
      "model": "anthropic/claude-opus-4-6",
      "description": "Code generation, refactoring, reviews"
    }
  }
}

path: ui/dashboard.html
content: <!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Model Switchboard v2</title>
<style>
  :root {
    --bg: #09090b;
    --surface: #18181b;
    --surface2: #1f1f23;
    --border: #27272a;
    --text: #fafafa;
    --muted: #a1a1aa;
    --dim: #71717a;
    --blue: #3b82f6;
    --purple: #8b5cf6;
    --green: #22c55e;
    --amber: #f59e0b;
    --red: #ef4444;
    --cyan: #06b6d4;
    --radius: 10px;
  }
  * { box-sizing: border-box; }
  html, body { margin: 0; padding: 0; }
  body {
    font-family: ui-sans-serif, -apple-system, "Segoe UI", system-ui, sans-serif;
    background: radial-gradient(circle at 20% 0%, #111827 0%, var(--bg) 45%), var(--bg);
    color: var(--text);
    min-height: 100vh;
    padding: 18px;
  }

  .wrap { max-width: 1180px; margin: 0 auto; }

  .header { margin-bottom: 16px; }
  .title {
    margin: 0;
    font-size: 28px;
    font-weight: 800;
    letter-spacing: .2px;
    background: linear-gradient(135deg, var(--blue), var(--purple), var(--cyan));
    -webkit-background-clip: text;
    -webkit-text-fill-color: transparent;
  }
  .subtitle { margin: 4px 0 0; color: var(--muted); font-size: 13px; }

  .safety {
    border: 1px solid rgba(6, 182, 212, .35);
    background: linear-gradient(95deg, rgba(59,130,246,.15), rgba(139,92,246,.12));
    border-radius: 8px;
    padding: 10px 12px;
    font-size: 12px;
    color: #dbeafe;
    margin-bottom: 16px;
  }

  .grid {
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: 12px;
  }

  .card {
    background: linear-gradient(160deg, rgba(24,24,27,.96), rgba(24,24,27,.86));
    border: 1px solid var(--border);
    border-radius: var(--radius);
    padding: 14px;
    position: relative;
    overflow: hidden;
    transition: border-color .2s ease, transform .16s ease;
  }
  .card:hover { border-color: #3f3f46; }
  .card.full { grid-column: 1 / -1; }

  .accent {
    position: absolute;
    top: 0;
    left: 0;
    right: 0;
    height: 2px;
    opacity: .9;
  }

  .label {
    font-size: 11px;
    color: var(--muted);
    letter-spacing: .65px;
    text-transform: uppercase;
    font-weight: 700;
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 8px;
    margin-bottom: 8px;
  }
  .value {
    font-size: 14px;
    font-weight: 700;
    line-height: 1.4;
    word-break: break-word;
  }
  .value.empty { color: var(--red); font-style: italic; font-weight: 600; }
  .hint { margin-top: 5px; color: var(--dim); font-size: 12px; }
  .clickable { cursor: pointer; }

  .roles-grid {
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: 10px;
  }

  .list {
    display: flex;
    flex-direction: column;
    gap: 6px;
    margin-top: 8px;
  }
  .list-item {
    display: flex;
    align-items: center;
    gap: 8px;
    background: var(--surface2);
    border: 1px solid #2f2f35;
    border-radius: 7px;
    padding: 6px 8px;
    font-size: 12px;
  }
  .list-item .ord { color: var(--dim); width: 18px; font-size: 10px; }
  .xbtn {
    margin-left: auto;
    border: 1px solid rgba(239,68,68,.3);
    background: rgba(239,68,68,.1);
    color: #fca5a5;
    border-radius: 6px;
    padding: 2px 7px;
    cursor: pointer;
    font-size: 11px;
  }
  .add-btn {
    border: 1px solid rgba(6,182,212,.45);
    background: rgba(6,182,212,.12);
    color: var(--cyan);
    border-radius: 6px;
    padding: 5px 10px;
    font-size: 11px;
    font-weight: 700;
    cursor: pointer;
    margin-top: 8px;
    width: fit-content;
  }

  .pipeline {
    display: grid;
    grid-template-columns: 1fr auto 1fr auto 1fr;
    gap: 10px;
    align-items: center;
    margin-top: 8px;
  }
  .pipe-node {
    border: 1px solid #35353c;
    background: var(--surface2);
    border-radius: 8px;
    padding: 10px;
  }
  .pipe-node .k { font-size: 11px; color: var(--muted); text-transform: uppercase; }
  .pipe-node .m { font-size: 12px; font-weight: 700; margin-top: 4px; word-break: break-word; }
  .pipe-node .p { font-size: 10px; color: var(--dim); margin-top: 2px; }
  .arrow { color: var(--muted); font-weight: 800; }
  .diversity {
    margin-top: 8px;
    padding: 8px 10px;
    border-radius: 7px;
    font-size: 12px;
    border: 1px solid transparent;
  }
  .diversity.good { background: rgba(34,197,94,.12); border-color: rgba(34,197,94,.3); color: #86efac; }
  .diversity.bad { background: rgba(239,68,68,.12); border-color: rgba(239,68,68,.3); color: #fca5a5; }

  .providers {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(210px, 1fr));
    gap: 8px;
    margin-top: 8px;
  }
  .provider {
    background: var(--surface2);
    border: 1px solid #33333a;
    border-radius: 8px;
    padding: 9px;
    display: flex;
    align-items: center;
    gap: 8px;
    cursor: pointer;
    transition: all .15s ease;
  }
  .provider:hover { border-color: var(--blue); transform: translateY(-1px); }
  .provider .dot { width: 8px; height: 8px; border-radius: 99px; }
  .provider .dot.on { background: var(--green); box-shadow: 0 0 0 4px rgba(34,197,94,.15); }
  .provider .dot.off { background: var(--red); }
  .provider .name { font-size: 12px; font-weight: 700; flex: 1; }
  .provider .meta { font-size: 10px; color: var(--dim); }

  .channels {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
    gap: 10px;
    margin-top: 8px;
  }
  .channel {
    background: var(--surface2);
    border: 1px solid #33333a;
    border-radius: 8px;
    padding: 10px;
    cursor: pointer;
  }
  .channel:hover { border-color: var(--cyan); }
  .channel .n { font-size: 13px; font-weight: 700; }
  .channel .s { font-size: 11px; color: var(--muted); margin-top: 4px; }

  .tags {
    display: flex;
    flex-wrap: wrap;
    gap: 7px;
    margin-top: 8px;
  }
  .tag {
    border-radius: 999px;
    border: 1px solid #34343b;
    background: var(--surface2);
    padding: 4px 10px;
    font-size: 11px;
    color: var(--muted);
    display: inline-flex;
    align-items: center;
    gap: 6px;
  }

  .issues { margin-top: 8px; display: grid; gap: 7px; }
  .issue {
    border-radius: 8px;
    border: 1px solid transparent;
    padding: 10px;
    display: grid;
    grid-template-columns: 1fr auto;
    gap: 8px;
    align-items: center;
  }
  .issue.error { background: rgba(239,68,68,.12); border-color: rgba(239,68,68,.25); }
  .issue.warning { background: rgba(245,158,11,.12); border-color: rgba(245,158,11,.25); }
  .issue.info { background: rgba(59,130,246,.12); border-color: rgba(59,130,246,.25); }
  .it { font-size: 13px; font-weight: 700; }
  .id { font-size: 11px; color: var(--muted); margin-top: 2px; }
  .ia { display: flex; gap: 6px; flex-wrap: wrap; justify-content: flex-end; }
  .btn {
    border-radius: 6px;
    border: 1px solid transparent;
    padding: 5px 10px;
    font-size: 11px;
    font-weight: 700;
    cursor: pointer;
  }
  .btn.blue { background: var(--blue); color: white; }
  .btn.purple { background: rgba(139,92,246,.2); border-color: rgba(139,92,246,.35); color: #ddd6fe; }
  .btn.cyan { background: rgba(6,182,212,.18); border-color: rgba(6,182,212,.35); color: #a5f3fc; }
  .btn.red { background: rgba(239,68,68,.16); border-color: rgba(239,68,68,.35); color: #fecaca; }
  .btn.ghost { background: transparent; border-color: #3f3f46; color: var(--muted); }

  .status {
    margin-top: 12px;
    border-radius: 8px;
    border: 1px solid var(--border);
    background: var(--surface);
    padding: 9px 12px;
    font-size: 12px;
    color: var(--muted);
    display: grid;
    grid-template-columns: repeat(5, 1fr);
    gap: 8px;
  }
  .status .v { color: #86efac; font-weight: 700; }

  .overlay {
    position: fixed;
    inset: 0;
    background: rgba(0,0,0,.66);
    backdrop-filter: blur(4px);
    display: none;
    align-items: center;
    justify-content: center;
    z-index: 1000;
  }
  .overlay.show { display: flex; }
  .modal {
    width: min(760px, 94vw);
    max-height: 88vh;
    overflow: auto;
    background: var(--surface);
    border: 1px solid #37373f;
    border-radius: 12px;
    padding: 14px;
  }
  .mh { font-size: 16px; font-weight: 800; margin: 0; }
  .ms { font-size: 12px; color: var(--muted); margin: 3px 0 10px; }
  .search {
    width: 100%;
    border: 1px solid #34343b;
    border-radius: 7px;
    background: var(--surface2);
    color: var(--text);
    padding: 8px 10px;
    font-size: 13px;
  }
  .pick-list { margin-top: 8px; display: grid; gap: 6px; }
  .pick {
    border: 1px solid #33333a;
    background: var(--surface2);
    border-radius: 7px;
    padding: 7px 9px;
    cursor: pointer;
    display: grid;
    grid-template-columns: 1fr auto auto;
    align-items: center;
    gap: 8px;
  }
  .pick:hover { border-color: var(--blue); }
  .pick.sel { border-color: var(--green); background: rgba(34,197,94,.12); }
  .pick .nm { font-size: 12px; font-weight: 700; word-break: break-word; }
  .pick .cp { font-size: 10px; color: var(--muted); }
  .pick .au { font-size: 10px; }

  label { display: block; font-size: 12px; color: var(--muted); margin: 8px 0 5px; }
  input, select {
    width: 100%;
    border: 1px solid #34343b;
    border-radius: 7px;
    background: var(--surface2);
    color: var(--text);
    padding: 8px 10px;
    font-size: 13px;
  }

  .row { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
  .actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 10px; }

  .toast {
    position: fixed;
    left: 50%;
    transform: translateX(-50%);
    bottom: 18px;
    padding: 9px 14px;
    border-radius: 7px;
    font-size: 12px;
    font-weight: 700;
    opacity: 0;
    pointer-events: none;
    transition: opacity .2s;
    z-index: 1300;
  }
  .toast.show { opacity: 1; }
  .toast.ok { background: var(--green); color: black; }
  .toast.err { background: var(--red); color: white; }

  @media (max-width: 920px) {
    .grid, .roles-grid, .status { grid-template-columns: 1fr; }
    .pipeline { grid-template-columns: 1fr; }
    .arrow { display: none; }
  }
</style>
</head>
<body>
<div class="wrap">
  <div class="header">
    <h1 class="title">Model Switchboard v2</h1>
    <p class="subtitle">OpenClaw model orchestration with transactional config safety</p>
  </div>

  <div class="safety">🛡️ Every write validates config, creates a backup, writes atomically, health-checks, and auto-rolls back on failure.</div>

  <div class="grid">
    <div class="card full">
      <div class="accent" style="background:var(--blue)"></div>
      <div class="label">Role Grid <span>Click any role to change model</span></div>
      <div class="roles-grid" id="rolesGrid"></div>
    </div>

    <div class="card full">
      <div class="accent" style="background:var(--purple)"></div>
      <div class="label">Coding Pipeline</div>
      <div class="pipeline" id="pipeline"></div>
      <div class="diversity" id="diversityBanner"></div>
    </div>

    <div class="card full" id="providersCard">
      <div class="accent" style="background:var(--green)"></div>
      <div class="label">Provider Auth Status <span>Click provider to add/edit key</span></div>
      <div class="providers" id="providers"></div>
    </div>

    <div class="card full">
      <div class="accent" style="background:var(--cyan)"></div>
      <div class="label">Channels</div>
      <div class="channels" id="channels"></div>
    </div>

    <div class="card">
      <div class="accent" style="background:var(--amber)"></div>
      <div class="label">LLM Fallbacks</div>
      <div id="fallbacks"></div>
      <button class="add-btn" onclick="openPicker('llm_fallbacks')">+ Add Fallback</button>
    </div>

    <div class="card">
      <div class="accent" style="background:var(--amber)"></div>
      <div class="label">Image Fallbacks</div>
      <div id="imageFallbacks"></div>
      <button class="add-btn" onclick="openPicker('image_fallbacks')">+ Add Image Fallback</button>
    </div>

    <div class="card full">
      <div class="accent" style="background:var(--cyan)"></div>
      <div class="label">Model Allowlist</div>
      <div class="tags" id="allowlist"></div>
      <button class="add-btn" onclick="openPicker('allowlist')">+ Add Model</button>
    </div>

    <div class="card full">
      <div class="accent" style="background:var(--red)"></div>
      <div class="label">Config Issues <span>Every alert is actionable</span></div>
      <div class="issues" id="issues"></div>
    </div>
  </div>

  <div class="status">
    <div>Registry <span class="v" id="sReg">-</span></div>
    <div>Models <span class="v" id="sModels">-</span></div>
    <div>Providers <span class="v" id="sProviders">-</span></div>
    <div>Backups <span class="v" id="sBackups">-</span></div>
    <div>Updated <span class="v" id="sUpdated">-</span></div>
  </div>
</div>

<div class="overlay" id="pickerOverlay">
  <div class="modal">
    <h3 class="mh" id="pickerTitle">Select Model</h3>
    <p class="ms" id="pickerSub"></p>
    <input class="search" id="pickerSearch" placeholder="Search model, provider, capability, cost tier">
    <div class="pick-list" id="pickerList"></div>
    <div class="actions">
      <button class="btn ghost" onclick="closePicker()">Cancel</button>
      <button class="btn blue" id="pickerSave" onclick="submitPicker()" disabled>Apply</button>
    </div>
  </div>
</div>

<div class="overlay" id="providerOverlay">
  <div class="modal">
    <h3 class="mh" id="providerTitle">Provider Key</h3>
    <p class="ms" id="providerSub"></p>
    <div id="providerBody"></div>
    <div class="actions">
      <button class="btn red" id="providerDeleteBtn" onclick="deleteProviderKey()">Remove Key</button>
      <button class="btn ghost" onclick="closeProvider()">Cancel</button>
      <button class="btn blue" id="providerSaveBtn" onclick="saveProviderKey()">Save</button>
    </div>
  </div>
</div>

<div class="overlay" id="tgOverlay">
  <div class="modal">
    <h3 class="mh">Telegram Configuration</h3>
    <p class="ms">Configure bot token, DM policy, allowlist, and group mention behavior</p>

    <div class="row">
      <div>
        <label>Enabled</label>
        <select id="tgEnabled">
          <option value="false">Disabled</option>
          <option value="true">Enabled</option>
        </select>
      </div>
      <div>
        <label>DM Policy</label>
        <select id="tgPolicy">
          <option value="disabled">disabled</option>
          <option value="pairing">pairing</option>
          <option value="allowlist">allowlist</option>
          <option value="open">open</option>
        </select>
      </div>
    </div>

    <label>Bot Token (saved as TELEGRAM_BOT_TOKEN)</label>
    <input id="tgToken" type="password" placeholder="123456:ABC... (leave blank to keep current)">

    <div class="row">
      <div>
        <label>Require Mention in Groups</label>
        <select id="tgMention">
          <option value="true">true</option>
          <option value="false">false</option>
        </select>
      </div>
      <div>
        <label>Add Allowed User ID</label>
        <div style="display:flex;gap:8px;">
          <input id="tgUserId" placeholder="123456789">
          <button class="btn cyan" onclick="addTelegramUser()">Add</button>
        </div>
      </div>
    </div>

    <label>Current allowFrom</label>
    <div class="tags" id="tgAllowFrom"></div>

    <div class="actions">
      <button class="btn ghost" onclick="closeTelegram()">Cancel</button>
      <button class="btn blue" onclick="saveTelegram()">Save Telegram Settings</button>
    </div>
  </div>
</div>

<div class="toast" id="toast"></div>

<script>
const API = window.location.origin;
const ROLE_META = [
  { key: 'primary', icon: '🧠', title: 'Primary LLM', hint: 'Main conversational model' },
  { key: 'llm_fallbacks', icon: '🔄', title: 'LLM Fallbacks', hint: 'Backup chain for primary LLM' },
  { key: 'image_model', icon: '👁️', title: 'Image Model', hint: 'Vision processing model' },
  { key: 'image_fallbacks', icon: '🔄', title: 'Image Fallbacks', hint: 'Vision backup chain' },
  { key: 'research', icon: '🔬', title: 'Research', hint: 'Deep analysis + synthesis' },
  { key: 'coding_pass1', icon: '💻', title: 'Coding Pass 1', hint: 'Initial code generation' },
  { key: 'coding_pass2', icon: '🔍', title: 'Coding Pass 2', hint: 'Code review (different provider)' },
  { key: 'coding_pass3', icon: '🛡️', title: 'Coding Pass 3', hint: 'Security audit (different provider)' },
  { key: 'social_media', icon: '📱', title: 'Social Media', hint: 'Creative engagement content' },
  { key: 'web_ops', icon: '🌐', title: 'Web Ops', hint: 'Search/scrape/crawl capable model' },
  { key: 'heartbeat', icon: '💓', title: 'Heartbeat', hint: 'Low-cost periodic checks' },
];

let state = null;
let pickerCtx = { target: null, selected: null };
let providerCtx = null;

function esc(v) { return String(v == null ? '' : v); }
function providerOf(ref) { return (ref || '').includes('/') ? ref.split('/')[0] : ''; }

function toast(msg, ok = true) {
  const t = document.getElementById('toast');
  t.className = 'toast ' + (ok ? 'ok' : 'err') + ' show';
  t.textContent = msg;
  setTimeout(() => { t.className = 'toast'; }, 2600);
}

async function api(path, method = 'GET', body = null) {
  const opts = { method, headers: { 'Content-Type': 'application/json' } };
  if (body) opts.body = JSON.stringify(body);
  const res = await fetch(API + path, opts);
  const data = await res.json().catch(() => ({ ok: false, error: 'Invalid response' }));
  if (!res.ok && data && data.error) throw new Error(data.error);
  if (!res.ok) throw new Error('Request failed');
  return data;
}

function roleValue(key) {
  if (!state) return '';
  if (key === 'primary') return state.primary || '';
  if (key === 'image_model') return state.imageModel || '';
  if (key === 'llm_fallbacks') return (state.fallbacks || []).join(', ');
  if (key === 'image_fallbacks') return (state.imageFallbacks || []).join(', ');
  return (state.roles || {})[key] || '';
}

function renderRoles() {
  const wrap = document.getElementById('rolesGrid');
  wrap.replaceChildren();

  ROLE_META.forEach((r) => {
    const card = document.createElement('div');
    card.className = 'card clickable';
    card.innerHTML = `
      <div class="label">${r.icon} ${r.title}</div>
      <div class="value ${roleValue(r.key) ? '' : 'empty'}">${esc(roleValue(r.key) || '(not set)')}</div>
      <div class="hint">${esc(r.hint)}</div>
    `;
    card.onclick = () => {
      if (r.key === 'llm_fallbacks' || r.key === 'image_fallbacks') {
        openPicker(r.key);
      } else {
        openPicker(r.key);
      }
    };
    wrap.appendChild(card);
  });
}

function renderFallbackList(id, items, image) {
  const el = document.getElementById(id);
  el.replaceChildren();
  if (!items || !items.length) {
    const empty = document.createElement('div');
    empty.className = 'value empty';
    empty.textContent = '(none)';
    el.appendChild(empty);
    return;
  }
  const list = document.createElement('div');
  list.className = 'list';
  items.forEach((m, i) => {
    const row = document.createElement('div');
    row.className = 'list-item';
    row.innerHTML = `<span class="ord">#${i + 1}</span><span>${esc(m)}</span>`;
    const x = document.createElement('button');
    x.className = 'xbtn';
    x.textContent = 'remove';
    x.onclick = async () => {
      try {
        await api('/api/config/remove-fallback', 'POST', { model: m, image: !!image });
        toast('Fallback removed');
        load();
      } catch (e) {
        toast(e.message, false);
      }
    };
    row.appendChild(x);
    list.appendChild(row);
  });
  el.appendChild(list);
}

function renderPipeline() {
  const pipe = document.getElementById('pipeline');
  pipe.replaceChildren();
  const r = state.roles || {};
  const c1 = r.coding_pass1 || '';
  const c2 = r.coding_pass2 || '';
  const c3 = r.coding_pass3 || '';
  const values = [c1, c2, c3].filter(Boolean);
  const providers = values.map(providerOf);
  const diverse = values.length < 2 ? true : new Set(providers).size === providers.length;

  [['Pass 1', c1], ['Pass 2', c2], ['Pass 3', c3]].forEach((row, idx) => {
    const node = document.createElement('div');
    node.className = 'pipe-node clickable';
    node.onclick = () => openPicker('coding_pass' + (idx + 1));
    node.innerHTML = `
      <div class="k">${row[0]}</div>
      <div class="m ${row[1] ? '' : 'empty'}">${esc(row[1] || '(not set)')}</div>
      <div class="p">Provider: ${esc(providerOf(row[1]) || '-')}</div>
    `;
    pipe.appendChild(node);
    if (idx < 2) {
      const ar = document.createElement('div');
      ar.className = 'arrow';
      ar.textContent = '→';
      pipe.appendChild(ar);
    }
  });

  const b = document.getElementById('diversityBanner');
  b.className = 'diversity ' + (diverse ? 'good' : 'bad');
  b.textContent = diverse
    ? 'Provider diversity check: OK'
    : 'Provider diversity violation: coding pass providers must all differ';
}

function renderProviders() {
  const wrap = document.getElementById('providers');
  wrap.replaceChildren();
  const entries = Object.entries(state.providers || {}).sort((a, b) => {
    if (a[1].hasAuth !== b[1].hasAuth) return a[1].hasAuth ? -1 : 1;
    return (a[1].displayName || a[0]).localeCompare(b[1].displayName || b[0]);
  });
  entries.forEach(([id, p]) => {
    const d = document.createElement('div');
    d.className = 'provider';
    d.innerHTML = `
      <span class="dot ${p.hasAuth ? 'on' : 'off'}"></span>
      <span class="name">${esc(p.displayName || id)}</span>
      <span class="meta">${p.hasAuth ? 'configured' : 'missing'}</span>
    `;
    d.onclick = () => openProvider(id, p);
    wrap.appendChild(d);
  });
}

function renderChannels() {
  const ch = document.getElementById('channels');
  ch.replaceChildren();
  const tg = ((state.channels || {}).telegram) || {};

  const card = document.createElement('div');
  card.className = 'channel';
  card.innerHTML = `
    <div class="n">Telegram</div>
    <div class="s">Enabled: ${tg.enabled ? 'yes' : 'no'}</div>
    <div class="s">DM Policy: ${esc(tg.dmPolicy || 'disabled')}</div>
    <div class="s">[REDACTED_SECRET] ? 'configured' : 'missing'}</div>
    <div class="s">allowFrom: ${(tg.allowFrom || []).length} user(s)</div>
  `;
  card.onclick = () => openTelegram();
  ch.appendChild(card);

  const pending = document.createElement('div');
  pending.className = 'channel';
  pending.innerHTML = '<div class="n">Discord (Future)</div><div class="s">Reserved for upcoming channel integration</div>';
  ch.appendChild(pending);

  const pending2 = document.createElement('div');
  pending2.className = 'channel';
  pending2.innerHTML = '<div class="n">Signal (Future)</div><div class="s">Reserved for upcoming channel integration</div>';
  ch.appendChild(pending2);
}

function renderAllowlist() {
  const wrap = document.getElementById('allowlist');
  wrap.replaceChildren();
  const models = state.allowlist || [];
  if (!models.length) {
    const m = document.createElement('span');
    m.className = 'value empty';
    m.textContent = '(empty allowlist)';
    wrap.appendChild(m);
    return;
  }
  models.sort().forEach((m) => {
    const t = document.createElement('span');
    t.className = 'tag';
    t.innerHTML = `${esc(m)} <button class="xbtn" style="padding:1px 6px;" onclick="removeAllow('${esc(m).replace(/'/g, "\\'")}')">x</button>`;
    wrap.appendChild(t);
  });
}

async function removeAllow(model) {
  try {
    await api('/api/config/remove-allowlist', 'POST', { model });
    toast('Removed from allowlist');
    load();
  } catch (e) {
    toast(e.message, false);
  }
}

function levelIcon(level) {
  if (level === 'error') return '🔴';
  if (level === 'warning') return '🟡';
  return '🔵';
}

function renderIssues() {
  const wrap = document.getElementById('issues');
  wrap.replaceChildren();
  const issues = state.issues || [];
  if (!issues.length) {
    const ok = document.createElement('div');
    ok.className = 'issue info';
    ok.innerHTML = '<div><div class="it">✅ No config issues detected</div><div class="id">System checks passed</div></div>';
    wrap.appendChild(ok);
    return;
  }

  issues.forEach((issue) => {
    const row = document.createElement('div');
    row.className = 'issue ' + (issue.severity || 'info');

    const left = document.createElement('div');
    left.innerHTML = `<div class="it">${levelIcon(issue.severity)} ${esc(issue.title)}</div><div class="id">${esc(issue.description)}</div>`;

    const right = document.createElement('div');
    right.className = 'ia';
    (issue.actions || []).forEach((a) => {
      const btn = document.createElement('button');
      btn.className = 'btn ' + (a.type === 'auto_fix' ? 'blue' : a.type === 'choose' ? 'cyan' : 'purple');
      btn.textContent = a.label || 'Fix';
      btn.onclick = () => onIssueAction(a, issue);
      right.appendChild(btn);
    });

    row.appendChild(left);
    row.appendChild(right);
    wrap.appendChild(row);
  });
}

async function onIssueAction(action, issue) {
  try {
    if (action.type === 'auto_fix') {
      await api('/api/config/auto-fix', 'POST', { issue: action.issue || issue.id });
      toast('Auto-fix applied');
      load();
      return;
    }
    if (action.type === 'choose') {
      openPicker(action.target || issue.target || 'primary');
      return;
    }
    if (action.type === 'provider_key') {
      const p = (state.providers || {})[action.provider];
      if (p) openProvider(action.provider, p);
      return;
    }
    if (action.type === 'scroll') {
      document.getElementById(action.target === 'providers' ? 'providersCard' : 'issues').scrollIntoView({ behavior: 'smooth', block: 'center' });
      return;
    }
    if (action.type === 'channel') {
      if (action.channel === 'telegram') openTelegram();
    }
  } catch (e) {
    toast(e.message, false);
  }
}

function renderStatus() {
  document.getElementById('sReg').textContent = esc(state.registryVersion || '-');
  document.getElementById('sModels').textContent = esc(state.modelCount || 0);
  document.getElementById('sProviders').textContent = esc(state.providerCount || 0);
  document.getElementById('sBackups').textContent = esc(state.backupCount || 0);
  document.getElementById('sUpdated').textContent = esc(state.registryUpdated || '-');
}

function modelPassesTarget(ref, target) {
  const model = (state.models || {})[ref] || {};
  const caps = model.capabilities || [];
  if (target === 'primary') return caps.includes('llm') && caps.includes('tools');
  if (target === 'image_model') return caps.includes('vision');
  if (target === 'llm_fallbacks') return caps.includes('llm') && caps.includes('tools');
  if (target === 'image_fallbacks') return caps.includes('vision');
  if (target === 'research') return caps.includes('llm') && caps.includes('reasoning');
  if (target === 'coding_pass1' || target === 'coding_pass2' || target === 'coding_pass3') return caps.includes('llm') && caps.includes('code');
  if (target === 'social_media' || target === 'heartbeat') return caps.includes('llm');
  if (target === 'web_ops') return caps.includes('llm') && (caps.includes('tools') || caps.includes('search'));
  if (target === 'allowlist') return true;
  return true;
}

function pickerSubtitle(target) {
  const map = {
    primary: 'Pick a model with llm + tools capability.',
    image_model: 'Pick a model with vision capability.',
    llm_fallbacks: 'Pick a fallback model (llm + tools).',
    image_fallbacks: 'Pick an image fallback model (vision).',
    research: 'Pick a model with llm + reasoning capabilities.',
    coding_pass1: 'Pick coding model for pass 1 (llm + code).',
    coding_pass2: 'Pick coding model for pass 2, provider must differ from pass 1.',
    coding_pass3: 'Pick coding model for pass 3, provider must differ from pass 1 and pass 2.',
    social_media: 'Pick a creative llm model.',
    web_ops: 'Pick a model with llm and tools/search.',
    heartbeat: 'Pick a low-cost llm model.',
    allowlist: 'Add any model to allowlist.',
  };
  return map[target] || 'Pick a model.';
}

function openPicker(target) {
  pickerCtx = { target, selected: null };
  document.getElementById('pickerTitle').textContent = 'Select Model: ' + target;
  document.getElementById('pickerSub').textContent = pickerSubtitle(target);
  document.getElementById('pickerSearch').value = '';
  document.getElementById('pickerSave').disabled = true;
  drawPickerList('');
  document.getElementById('pickerOverlay').classList.add('show');
  setTimeout(() => document.getElementById('pickerSearch').focus(), 50);
}

function closePicker() {
  document.getElementById('pickerOverlay').classList.remove('show');
}

function drawPickerList(filterText) {
  const list = document.getElementById('pickerList');
  list.replaceChildren();

  const target = pickerCtx.target;
  const f = (filterText || '').toLowerCase();
  const currentFallbacks = new Set(state.fallbacks || []);
  const currentImageFallbacks = new Set(state.imageFallbacks || []);
  const currentAllowlist = new Set(state.allowlist || []);

  let items = Object.entries(state.models || {}).filter(([ref, m]) => {
    if (m.blocked) return false;
    if (!modelPassesTarget(ref, target)) return false;
    if (target === 'llm_fallbacks' && (currentFallbacks.has(ref) || ref === state.primary)) return false;
    if (target === 'image_fallbacks' && (currentImageFallbacks.has(ref) || ref === state.imageModel)) return false;
    if (target === 'allowlist' && currentAllowlist.has(ref)) return false;

    const hay = [ref, m.displayName || '', m.costTier || '', (m.capabilities || []).join(' ')].join(' ').toLowerCase();
    if (f && !hay.includes(f)) return false;
    return true;
  });

  items.sort((a, b) => {
    const pa = (state.providers || {})[providerOf(a[0])] || {};
    const pb = (state.providers || {})[providerOf(b[0])] || {};
    if (pa.hasAuth !== pb.hasAuth) return pa.hasAuth ? -1 : 1;
    const ca = ({ free:0,'very-low':1,low:2,medium:3,high:4,subscription:5,proxy:3 }[a[1].costTier] ?? 3);
    const cb = ({ free:0,'very-low':1,low:2,medium:3,high:4,subscription:5,proxy:3 }[b[1].costTier] ?? 3);
    if (ca !== cb) return ca - cb;
    return a[0].localeCompare(b[0]);
  });

  if (!items.length) {
    const emp = document.createElement('div');
    emp.className = 'id';
    emp.textContent = 'No matching models.';
    list.appendChild(emp);
    return;
  }

  items.forEach(([ref, m]) => {
    const pid = providerOf(ref);
    const p = (state.providers || {})[pid] || {};
    const row = document.createElement('div');
    row.className = 'pick' + (pickerCtx.selected === ref ? ' sel' : '');
    row.innerHTML = `
      <div>
        <div class="nm">${esc(ref)}</div>
        <div class="cp">${esc((m.capabilities || []).join(', '))}</div>
      </div>
      <div class="cp">${esc(m.costTier || '-')}</div>
      <div class="au" style="color:${p.hasAuth ? '#86efac' : '#fca5a5'}">${p.hasAuth ? 'auth ✓' : 'auth ✗'}</div>
    `;
    row.onclick = () => {
      pickerCtx.selected = ref;
      drawPickerList(document.getElementById('pickerSearch').value);
      document.getElementById('pickerSave').disabled = false;
    };
    list.appendChild(row);
  });
}

document.getElementById('pickerSearch').addEventListener('input', (e) => drawPickerList(e.target.value));

document.getElementById('pickerOverlay').addEventListener('click', (e) => {
  if (e.target.id === 'pickerOverlay') closePicker();
});

async function submitPicker() {
  const target = pickerCtx.target;
  const model = pickerCtx.selected;
  if (!model) return;

  if (target === 'coding_pass2' || target === 'coding_pass3') {
    const selectedProvider = providerOf(model);
    const roles = state.roles || {};
    const used = new Set();
    if (roles.coding_pass1) used.add(providerOf(roles.coding_pass1));
    if (target === 'coding_pass3' && roles.coding_pass2) used.add(providerOf(roles.coding_pass2));
    if (used.has(selectedProvider)) {
      toast('Coding pipeline providers must be different', false);
      return;
    }
  }

  try {
    if (target === 'primary') {
      await api('/api/config/set-primary', 'POST', { model });
    } else if (target === 'image_model') {
      await api('/api/config/set-image-model', 'POST', { model });
    } else if (target === 'llm_fallbacks') {
      await api('/api/config/add-fallback', 'POST', { model });
    } else if (target === 'image_fallbacks') {
      await api('/api/config/add-image-fallback', 'POST', { model });
    } else if (target === 'allowlist') {
      await api('/api/config/add-allowlist', 'POST', { model });
    } else {
      await api('/api/config/set-role', 'POST', { role: target, model });
    }
    toast('Configuration updated');
    closePicker();
    load();
  } catch (e) {
    toast(e.message, false);
  }
}

function openProvider(id, p) {
  providerCtx = { id, p };
  document.getElementById('providerTitle').textContent = (p.displayName || id) + ' auth';
  document.getElementById('providerSub').textContent = 'Auth method: ' + (p.authMethod || 'api-key');

  const body = document.getElementById('providerBody');
  body.replaceChildren();

  const authEnv = p.authEnvVars || [];
  const save = document.getElementById('providerSaveBtn');
  const del = document.getElementById('providerDeleteBtn');

  if (!authEnv.length) {
    const m = document.createElement('div');
    m.className = 'id';
    m.textContent = 'This provider does not use direct API key env vars.';
    body.appendChild(m);
    save.style.display = 'none';
    del.style.display = 'none';
  } else {
    save.style.display = '';
    del.style.display = p.hasAuth ? '' : 'none';

    const lab = document.createElement('label');
    lab.textContent = 'Env var: ' + authEnv[0];
    body.appendChild(lab);
    const inp = document.createElement('input');
    inp.id = 'providerKeyInput';
    inp.type = 'password';
    inp.placeholder = 'Paste API key';
    body.appendChild(inp);

    if (p.website) {
      const a = document.createElement('a');
      a.href = p.website;
      a.target = '_blank';
      a.rel = 'noreferrer noopener';
      a.className = 'id';
      a.style.display = 'inline-block';
      a.style.marginTop = '8px';
      a.style.color = '#93c5fd';
      a.textContent = 'Get key: ' + p.website;
      body.appendChild(a);
    }
  }

  document.getElementById('providerOverlay').classList.add('show');
}

function closeProvider() {
  document.getElementById('providerOverlay').classList.remove('show');
  providerCtx = null;
}

async function saveProviderKey() {
  if (!providerCtx) return;
  const authEnv = providerCtx.p.authEnvVars || [];
  if (!authEnv.length) return;
  const key = authEnv[0];
  const value = (document.getElementById('providerKeyInput').value || '').trim();
  if (!value) {
    toast('Enter key value', false);
    return;
  }
  try {
    await api('/api/key', 'POST', { key, value });
    toast('Provider key saved');
    closeProvider();
    load();
  } catch (e) {
    toast(e.message, false);
  }
}

async function deleteProviderKey() {
  if (!providerCtx) return;
  const authEnv = providerCtx.p.authEnvVars || [];
  if (!authEnv.length) return;
  try {
    await api('/api/key/delete', 'POST', { key: authEnv[0] });
    toast('Provider key removed');
    closeProvider();
    load();
  } catch (e) {
    toast(e.message, false);
  }
}

document.getElementById('providerOverlay').addEventListener('click', (e) => {
  if (e.target.id === 'providerOverlay') closeProvider();
});

function openTelegram() {
  const tg = ((state.channels || {}).telegram) || {};
  document.getElementById('tgEnabled').value = String(!!tg.enabled);
  document.getElementById('tgPolicy').value = tg.dmPolicy || 'disabled';
  document.getElementById('tgToken').value = '';
  document.getElementById('tgMention').value = String(!!((tg.groups || {}).requireMention));
  document.getElementById('tgUserId').value = '';
  renderTelegramUsers();
  document.getElementById('tgOverlay').classList.add('show');
}

function closeTelegram() {
  document.getElementById('tgOverlay').classList.remove('show');
}

document.getElementById('tgOverlay').addEventListener('click', (e) => {
  if (e.target.id === 'tgOverlay') closeTelegram();
});

function renderTelegramUsers() {
  const wrap = document.getElementById('tgAllowFrom');
  wrap.replaceChildren();
  const users = ((((state || {}).channels || {}).telegram || {}).allowFrom) || [];
  if (!users.length) {
    const empty = document.createElement('span');
    empty.className = 'value empty';
    empty.textContent = '(none)';
    wrap.appendChild(empty);
    return;
  }
  users.forEach((u) => {
    const tag = document.createElement('span');
    tag.className = 'tag';
    tag.innerHTML = `${esc(u)} <button class="xbtn" style="padding:1px 6px;" onclick="removeTelegramUser('${esc(u).replace(/'/g, "\\'")}')">x</button>`;
    wrap.appendChild(tag);
  });
}

async function saveTelegram() {
  const payload = {
    enabled: document.getElementById('tgEnabled').value === 'true',
    dmPolicy: document.getElementById('tgPolicy').value,
    groups: { requireMention: document.getElementById('tgMention').value === 'true' }
  };
  const [REDACTED_SECRET]'tgToken').value || '').trim();
  if (token) payload.botToken = token;
  try {
    await api('/api/channels/telegram', 'POST', payload);
    toast('Telegram updated');
    closeTelegram();
    load();
  } catch (e) {
    toast(e.message, false);
  }
}

async function addTelegramUser() {
  const userId = (document.getElementById('tgUserId').value || '').trim();
  if (!userId) {
    toast('Enter user ID', false);
    return;
  }
  try {
    await api('/api/channels/telegram/add-user', 'POST', { userId });
    document.getElementById('tgUserId').value = '';
    toast('User added');
    load(true);
  } catch (e) {
    toast(e.message, false);
  }
}

async function removeTelegramUser(userId) {
  try {
    await api('/api/channels/telegram/remove-user', 'POST', { userId });
    toast('User removed');
    load(true);
  } catch (e) {
    toast(e.message, false);
  }
}

function renderAll() {
  renderRoles();
  renderFallbackList('fallbacks', state.fallbacks || [], false);
  renderFallbackList('imageFallbacks', state.imageFallbacks || [], true);
  renderPipeline();
  renderProviders();
  renderChannels();
  renderAllowlist();
  renderIssues();
  renderStatus();
}

async function load(skipClose = false) {
  try {
    const data = await api('/api/status');
    state = data;
    renderAll();
    if (!skipClose) {
      renderTelegramUsers();
    }
  } catch (e) {
    toast(e.message || 'Failed to load status', false);
  }
}

document.addEventListener('keydown', (e) => {
  if (e.key === 'Escape') {
    closePicker();
    closeProvider();
    closeTelegram();
  }
});

load();
</script>
</body>
</html>


path: ui/index.html
content: <!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Model Switchboard v3.0</title>
<style>
  :root {
    --bg: #09090b;
    --surface: #18181b;
    --surface2: #1f1f23;
    --border: #27272a;
    --text: #fafafa;
    --muted: #a1a1aa;
    --dim: #71717a;
    --blue: #3b82f6;
    --purple: #8b5cf6;
    --green: #22c55e;
    --amber: #f59e0b;
    --red: #ef4444;
    --cyan: #06b6d4;
    --radius: 10px;
  }
  * { box-sizing: border-box; }
  html, body { margin: 0; padding: 0; }
  body {
    font-family: ui-sans-serif, -apple-system, "Segoe UI", system-ui, sans-serif;
    background: radial-gradient(circle at 20% 0%, #111827 0%, var(--bg) 45%), var(--bg);
    color: var(--text);
    min-height: 100vh;
    padding: 18px;
  }

  .wrap { max-width: 1180px; margin: 0 auto; }

  .header { margin-bottom: 16px; }
  .title {
    margin: 0;
    font-size: 28px;
    font-weight: 800;
    letter-spacing: .2px;
    background: linear-gradient(135deg, var(--blue), var(--purple), var(--cyan));
    -webkit-background-clip: text;
    -webkit-text-fill-color: transparent;
  }
  .subtitle { margin: 4px 0 0; color: var(--muted); font-size: 13px; }

  .safety {
    border: 1px solid rgba(6, 182, 212, .35);
    background: linear-gradient(95deg, rgba(59,130,246,.15), rgba(139,92,246,.12));
    border-radius: 8px;
    padding: 10px 12px;
    font-size: 12px;
    color: #dbeafe;
    margin-bottom: 16px;
  }

  .grid {
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: 12px;
  }

  .card {
    background: linear-gradient(160deg, rgba(24,24,27,.96), rgba(24,24,27,.86));
    border: 1px solid var(--border);
    border-radius: var(--radius);
    padding: 14px;
    position: relative;
    overflow: hidden;
    transition: border-color .2s ease, transform .16s ease;
  }
  .card:hover { border-color: #3f3f46; }
  .card.full { grid-column: 1 / -1; }

  .accent {
    position: absolute;
    top: 0;
    left: 0;
    right: 0;
    height: 2px;
    opacity: .9;
  }

  .label {
    font-size: 11px;
    color: var(--muted);
    letter-spacing: .65px;
    text-transform: uppercase;
    font-weight: 700;
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 8px;
    margin-bottom: 8px;
  }
  .value {
    font-size: 14px;
    font-weight: 700;
    line-height: 1.4;
    word-break: break-word;
  }
  .value.empty { color: var(--red); font-style: italic; font-weight: 600; }
  .hint { margin-top: 5px; color: var(--dim); font-size: 12px; }
  .clickable { cursor: pointer; }

  .roles-grid {
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: 10px;
  }

  .list {
    display: flex;
    flex-direction: column;
    gap: 6px;
    margin-top: 8px;
  }
  .list-item {
    display: flex;
    align-items: center;
    gap: 8px;
    background: var(--surface2);
    border: 1px solid #2f2f35;
    border-radius: 7px;
    padding: 6px 8px;
    font-size: 12px;
  }
  .list-item .ord { color: var(--dim); width: 18px; font-size: 10px; }
  .xbtn {
    margin-left: auto;
    border: 1px solid rgba(239,68,68,.3);
    background: rgba(239,68,68,.1);
    color: #fca5a5;
    border-radius: 6px;
    padding: 2px 7px;
    cursor: pointer;
    font-size: 11px;
  }
  .add-btn {
    border: 1px solid rgba(6,182,212,.45);
    background: rgba(6,182,212,.12);
    color: var(--cyan);
    border-radius: 6px;
    padding: 5px 10px;
    font-size: 11px;
    font-weight: 700;
    cursor: pointer;
    margin-top: 8px;
    width: fit-content;
  }

  .pipeline {
    display: grid;
    grid-template-columns: 1fr auto 1fr auto 1fr;
    gap: 10px;
    align-items: center;
    margin-top: 8px;
  }
  .pipe-node {
    border: 1px solid #35353c;
    background: var(--surface2);
    border-radius: 8px;
    padding: 10px;
  }
  .pipe-node .k { font-size: 11px; color: var(--muted); text-transform: uppercase; }
  .pipe-node .m { font-size: 12px; font-weight: 700; margin-top: 4px; word-break: break-word; }
  .pipe-node .p { font-size: 10px; color: var(--dim); margin-top: 2px; }
  .arrow { color: var(--muted); font-weight: 800; }
  .diversity {
    margin-top: 8px;
    padding: 8px 10px;
    border-radius: 7px;
    font-size: 12px;
    border: 1px solid transparent;
  }
  .diversity.good { background: rgba(34,197,94,.12); border-color: rgba(34,197,94,.3); color: #86efac; }
  .diversity.bad { background: rgba(239,68,68,.12); border-color: rgba(239,68,68,.3); color: #fca5a5; }

  .providers {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(210px, 1fr));
    gap: 8px;
    margin-top: 8px;
  }
  .provider {
    background: var(--surface2);
    border: 1px solid #33333a;
    border-radius: 8px;
    padding: 9px;
    display: flex;
    align-items: center;
    gap: 8px;
    cursor: pointer;
    transition: all .15s ease;
  }
  .provider:hover { border-color: var(--blue); transform: translateY(-1px); }
  .provider .dot { width: 8px; height: 8px; border-radius: 99px; }
  .provider .dot.on { background: var(--green); box-shadow: 0 0 0 4px rgba(34,197,94,.15); }
  .provider .dot.off { background: var(--red); }
  .provider .name { font-size: 12px; font-weight: 700; flex: 1; }
  .provider .meta { font-size: 10px; color: var(--dim); }

  .channels {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
    gap: 10px;
    margin-top: 8px;
  }
  .channel {
    background: var(--surface2);
    border: 1px solid #33333a;
    border-radius: 8px;
    padding: 10px;
    cursor: pointer;
  }
  .channel:hover { border-color: var(--cyan); }
  .channel .n { font-size: 13px; font-weight: 700; }
  .channel .s { font-size: 11px; color: var(--muted); margin-top: 4px; }

  .tags {
    display: flex;
    flex-wrap: wrap;
    gap: 7px;
    margin-top: 8px;
  }
  .tag {
    border-radius: 999px;
    border: 1px solid #34343b;
    background: var(--surface2);
    padding: 4px 10px;
    font-size: 11px;
    color: var(--muted);
    display: inline-flex;
    align-items: center;
    gap: 6px;
  }

  .issues { margin-top: 8px; display: grid; gap: 7px; }
  .issue {
    border-radius: 8px;
    border: 1px solid transparent;
    padding: 10px;
    display: grid;
    grid-template-columns: 1fr auto;
    gap: 8px;
    align-items: center;
  }
  .issue.error { background: rgba(239,68,68,.12); border-color: rgba(239,68,68,.25); }
  .issue.warning { background: rgba(245,158,11,.12); border-color: rgba(245,158,11,.25); }
  .issue.info { background: rgba(59,130,246,.12); border-color: rgba(59,130,246,.25); }
  .it { font-size: 13px; font-weight: 700; }
  .id { font-size: 11px; color: var(--muted); margin-top: 2px; }
  .ia { display: flex; gap: 6px; flex-wrap: wrap; justify-content: flex-end; }
  .btn {
    border-radius: 6px;
    border: 1px solid transparent;
    padding: 5px 10px;
    font-size: 11px;
    font-weight: 700;
    cursor: pointer;
  }
  .btn.blue { background: var(--blue); color: white; }
  .btn.purple { background: rgba(139,92,246,.2); border-color: rgba(139,92,246,.35); color: #ddd6fe; }
  .btn.cyan { background: rgba(6,182,212,.18); border-color: rgba(6,182,212,.35); color: #a5f3fc; }
  .btn.red { background: rgba(239,68,68,.16); border-color: rgba(239,68,68,.35); color: #fecaca; }
  .btn.ghost { background: transparent; border-color: #3f3f46; color: var(--muted); }

  .status {
    margin-top: 12px;
    border-radius: 8px;
    border: 1px solid var(--border);
    background: var(--surface);
    padding: 9px 12px;
    font-size: 12px;
    color: var(--muted);
    display: grid;
    grid-template-columns: repeat(5, 1fr);
    gap: 8px;
  }
  .status .v { color: #86efac; font-weight: 700; }

  .overlay {
    position: fixed;
    inset: 0;
    background: rgba(0,0,0,.66);
    backdrop-filter: blur(4px);
    display: none;
    align-items: center;
    justify-content: center;
    z-index: 1000;
  }
  .overlay.show { display: flex; }
  .modal {
    width: min(760px, 94vw);
    max-height: 88vh;
    overflow: auto;
    background: var(--surface);
    border: 1px solid #37373f;
    border-radius: 12px;
    padding: 14px;
  }
  .mh { font-size: 16px; font-weight: 800; margin: 0; }
  .ms { font-size: 12px; color: var(--muted); margin: 3px 0 10px; }
  .search {
    width: 100%;
    border: 1px solid #34343b;
    border-radius: 7px;
    background: var(--surface2);
    color: var(--text);
    padding: 8px 10px;
    font-size: 13px;
  }
  .pick-list { margin-top: 8px; display: grid; gap: 6px; }
  .pick {
    border: 1px solid #33333a;
    background: var(--surface2);
    border-radius: 7px;
    padding: 7px 9px;
    cursor: pointer;
    display: grid;
    grid-template-columns: 1fr auto auto;
    align-items: center;
    gap: 8px;
  }
  .pick:hover { border-color: var(--blue); }
  .pick.sel { border-color: var(--green); background: rgba(34,197,94,.12); }
  .pick .nm { font-size: 12px; font-weight: 700; word-break: break-word; }
  .pick .cp { font-size: 10px; color: var(--muted); }
  .pick .au { font-size: 10px; }

  label { display: block; font-size: 12px; color: var(--muted); margin: 8px 0 5px; }
  input, select {
    width: 100%;
    border: 1px solid #34343b;
    border-radius: 7px;
    background: var(--surface2);
    color: var(--text);
    padding: 8px 10px;
    font-size: 13px;
  }

  .row { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
  .actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 10px; }

  .toast {
    position: fixed;
    left: 50%;
    transform: translateX(-50%);
    bottom: 18px;
    padding: 9px 14px;
    border-radius: 7px;
    font-size: 12px;
    font-weight: 700;
    opacity: 0;
    pointer-events: none;
    transition: opacity .2s;
    z-index: 1300;
  }
  .toast.show { opacity: 1; }
  .toast.ok { background: var(--green); color: black; }
  .toast.err { background: var(--red); color: white; }

  @media (max-width: 920px) {
    .grid, .roles-grid, .status { grid-template-columns: 1fr; }
    .pipeline { grid-template-columns: 1fr; }
    .arrow { display: none; }
  }
</style>
</head>
<body>
<div class="wrap">
  <div class="header">
    <h1 class="title">Model Switchboard v3.0</h1>
    <p class="subtitle">OpenClaw model orchestration with transactional config safety</p>
  </div>

  <div class="safety">🛡️ Every write validates config, creates a backup, writes atomically, health-checks, and auto-rolls back on failure.</div>

  <div class="grid">
    <div class="card full">
      <div class="accent" style="background:var(--blue)"></div>
      <div class="label">Role Grid <span>Click any role to change model</span></div>
      <div class="roles-grid" id="rolesGrid"></div>
    </div>

    <div class="card full">
      <div class="accent" style="background:var(--purple)"></div>
      <div class="label">Coding Pipeline</div>
      <div class="pipeline" id="pipeline"></div>
      <div class="diversity" id="diversityBanner"></div>
    </div>

    <div class="card full" id="providersCard">
      <div class="accent" style="background:var(--green)"></div>
      <div class="label">Provider Auth Status <span>Click provider to add/edit key</span></div>
      <div class="providers" id="providers"></div>
    </div>

    <div class="card full">
      <div class="accent" style="background:var(--cyan)"></div>
      <div class="label">Channels</div>
      <div class="channels" id="channels"></div>
    </div>

    <div class="card">
      <div class="accent" style="background:var(--amber)"></div>
      <div class="label">LLM Fallbacks</div>
      <div id="fallbacks"></div>
      <button class="add-btn" onclick="openPicker('llm_fallbacks')">+ Add Fallback</button>
    </div>

    <div class="card">
      <div class="accent" style="background:var(--amber)"></div>
      <div class="label">Image Fallbacks</div>
      <div id="imageFallbacks"></div>
      <button class="add-btn" onclick="openPicker('image_fallbacks')">+ Add Image Fallback</button>
    </div>

    <div class="card full">
      <div class="accent" style="background:var(--cyan)"></div>
      <div class="label">Model Allowlist</div>
      <div class="tags" id="allowlist"></div>
      <button class="add-btn" onclick="openPicker('allowlist')">+ Add Model</button>
    </div>

    <div class="card full">
      <div class="accent" style="background:var(--red)"></div>
      <div class="label">Config Issues <span>Every alert is actionable</span></div>
      <div class="issues" id="issues"></div>
    </div>
  </div>

  <div class="status">
    <div>Registry <span class="v" id="sReg">-</span></div>
    <div>Models <span class="v" id="sModels">-</span></div>
    <div>Providers <span class="v" id="sProviders">-</span></div>
    <div>Backups <span class="v" id="sBackups">-</span></div>
    <div>Updated <span class="v" id="sUpdated">-</span></div>
  </div>
</div>

<div class="overlay" id="pickerOverlay">
  <div class="modal">
    <h3 class="mh" id="pickerTitle">Select Model</h3>
    <p class="ms" id="pickerSub"></p>
    <input class="search" id="pickerSearch" placeholder="Search model, provider, capability, cost tier">
    <div class="pick-list" id="pickerList"></div>
    <div class="actions">
      <button class="btn ghost" onclick="closePicker()">Cancel</button>
      <button class="btn blue" id="pickerSave" onclick="submitPicker()" disabled>Apply</button>
    </div>
  </div>
</div>

<div class="overlay" id="providerOverlay">
  <div class="modal">
    <h3 class="mh" id="providerTitle">Provider Key</h3>
    <p class="ms" id="providerSub"></p>
    <div id="providerBody"></div>
    <div class="actions">
      <button class="btn red" id="providerDeleteBtn" onclick="deleteProviderKey()">Remove Key</button>
      <button class="btn ghost" onclick="closeProvider()">Cancel</button>
      <button class="btn blue" id="providerSaveBtn" onclick="saveProviderKey()">Save</button>
    </div>
  </div>
</div>

<div class="overlay" id="tgOverlay">
  <div class="modal">
    <h3 class="mh">Telegram Configuration</h3>
    <p class="ms">Configure bot token, DM policy, allowlist, and group mention behavior</p>

    <div class="row">
      <div>
        <label>Enabled</label>
        <select id="tgEnabled">
          <option value="false">Disabled</option>
          <option value="true">Enabled</option>
        </select>
      </div>
      <div>
        <label>DM Policy</label>
        <select id="tgPolicy">
          <option value="disabled">disabled</option>
          <option value="pairing">pairing</option>
          <option value="allowlist">allowlist</option>
          <option value="open">open</option>
        </select>
      </div>
    </div>

    <label>Bot Token (saved as TELEGRAM_BOT_TOKEN)</label>
    <input id="tgToken" type="password" placeholder="123456:ABC... (leave blank to keep current)">

    <div class="row">
      <div>
        <label>Require Mention in Groups</label>
        <select id="tgMention">
          <option value="true">true</option>
          <option value="false">false</option>
        </select>
      </div>
      <div>
        <label>Add Allowed User ID</label>
        <div style="display:flex;gap:8px;">
          <input id="tgUserId" placeholder="123456789">
          <button class="btn cyan" onclick="addTelegramUser()">Add</button>
        </div>
      </div>
    </div>

    <label>Current allowFrom</label>
    <div class="tags" id="tgAllowFrom"></div>

    <div class="actions">
      <button class="btn ghost" onclick="closeTelegram()">Cancel</button>
      <button class="btn blue" onclick="saveTelegram()">Save Telegram Settings</button>
    </div>
  </div>
</div>

<div class="toast" id="toast"></div>

<script>
const API = window.location.origin;
const ROLE_META = [
  { key: 'primary', icon: '🧠', title: 'Primary LLM', hint: 'Main conversational model' },
  { key: 'llm_fallbacks', icon: '🔄', title: 'LLM Fallbacks', hint: 'Backup chain for primary LLM' },
  { key: 'image_model', icon: '👁️', title: 'Image Model', hint: 'Vision processing model' },
  { key: 'image_fallbacks', icon: '🔄', title: 'Image Fallbacks', hint: 'Vision backup chain' },
  { key: 'research', icon: '🔬', title: 'Research', hint: 'Deep analysis + synthesis' },
  { key: 'coding_pass1', icon: '💻', title: 'Coding Pass 1', hint: 'Initial code generation' },
  { key: 'coding_pass2', icon: '🔍', title: 'Coding Pass 2', hint: 'Code review (different provider)' },
  { key: 'coding_pass3', icon: '🛡️', title: 'Coding Pass 3', hint: 'Security audit (different provider)' },
  { key: 'social_media', icon: '📱', title: 'Social Media', hint: 'Creative engagement content' },
  { key: 'web_ops', icon: '🌐', title: 'Web Ops', hint: 'Search/scrape/crawl capable model' },
  { key: 'heartbeat', icon: '💓', title: 'Heartbeat', hint: 'Low-cost periodic checks' },
];

let state = null;
let pickerCtx = { target: null, selected: null };
let providerCtx = null;

function esc(v) {
  const d = document.createElement('div');
  d.textContent = String(v == null ? '' : v);
  return d.innerHTML;
}
function providerOf(ref) { return (ref || '').includes('/') ? ref.split('/')[0] : ''; }

function toast(msg, ok = true) {
  const t = document.getElementById('toast');
  t.className = 'toast ' + (ok ? 'ok' : 'err') + ' show';
  t.textContent = msg;
  setTimeout(() => { t.className = 'toast'; }, 2600);
}

async function api(path, method = 'GET', body = null) {
  const opts = { method, headers: { 'Content-Type': 'application/json' } };
  if (body) opts.body = JSON.stringify(body);
  const res = await fetch(API + path, opts);
  const data = await res.json().catch(() => ({ ok: false, error: 'Invalid response' }));
  if (!res.ok && data && data.error) throw new Error(data.error);
  if (!res.ok) throw new Error('Request failed');
  return data;
}

function roleValue(key) {
  if (!state) return '';
  if (key === 'primary') return state.primary || '';
  if (key === 'image_model') return state.imageModel || '';
  if (key === 'llm_fallbacks') return (state.fallbacks || []).join(', ');
  if (key === 'image_fallbacks') return (state.imageFallbacks || []).join(', ');
  return (state.roles || {})[key] || '';
}

function renderRoles() {
  const wrap = document.getElementById('rolesGrid');
  wrap.replaceChildren();

  ROLE_META.forEach((r) => {
    const card = document.createElement('div');
    card.className = 'card clickable';
    const lbl = document.createElement('div');
    lbl.className = 'label';
    lbl.textContent = r.icon + ' ' + r.title;
    card.appendChild(lbl);
    const val = document.createElement('div');
    val.className = 'value' + (roleValue(r.key) ? '' : ' empty');
    val.textContent = roleValue(r.key) || '(not set)';
    card.appendChild(val);
    const hnt = document.createElement('div');
    hnt.className = 'hint';
    hnt.textContent = r.hint;
    card.appendChild(hnt);
    card.onclick = () => {
      if (r.key === 'llm_fallbacks' || r.key === 'image_fallbacks') {
        openPicker(r.key);
      } else {
        openPicker(r.key);
      }
    };
    wrap.appendChild(card);
  });
}

function renderFallbackList(id, items, image) {
  const el = document.getElementById(id);
  el.replaceChildren();
  if (!items || !items.length) {
    const empty = document.createElement('div');
    empty.className = 'value empty';
    empty.textContent = '(none)';
    el.appendChild(empty);
    return;
  }
  const list = document.createElement('div');
  list.className = 'list';
  items.forEach((m, i) => {
    const row = document.createElement('div');
    row.className = 'list-item';
    const ord = document.createElement('span');
    ord.className = 'ord';
    ord.textContent = '#' + (i + 1);
    row.appendChild(ord);
    const name = document.createElement('span');
    name.textContent = m;
    row.appendChild(name);
    const x = document.createElement('button');
    x.className = 'xbtn';
    x.textContent = 'remove';
    x.onclick = async () => {
      try {
        await api('/api/config/remove-fallback', 'POST', { model: m, image: !!image });
        toast('Fallback removed');
        load();
      } catch (e) {
        toast(e.message, false);
      }
    };
    row.appendChild(x);
    list.appendChild(row);
  });
  el.appendChild(list);
}

function renderPipeline() {
  const pipe = document.getElementById('pipeline');
  pipe.replaceChildren();
  const r = state.roles || {};
  const c1 = r.coding_pass1 || '';
  const c2 = r.coding_pass2 || '';
  const c3 = r.coding_pass3 || '';
  const values = [c1, c2, c3].filter(Boolean);
  const providers = values.map(providerOf);
  const diverse = values.length < 2 ? true : new Set(providers).size === providers.length;

  [['Pass 1', c1], ['Pass 2', c2], ['Pass 3', c3]].forEach((row, idx) => {
    const node = document.createElement('div');
    node.className = 'pipe-node clickable';
    node.onclick = () => openPicker('coding_pass' + (idx + 1));
    const kDiv = document.createElement('div');
    kDiv.className = 'k';
    kDiv.textContent = row[0];
    node.appendChild(kDiv);
    const mDiv = document.createElement('div');
    mDiv.className = 'm' + (row[1] ? '' : ' empty');
    mDiv.textContent = row[1] || '(not set)';
    node.appendChild(mDiv);
    const pDiv = document.createElement('div');
    pDiv.className = 'p';
    pDiv.textContent = 'Provider: ' + (providerOf(row[1]) || '-');
    node.appendChild(pDiv);
    pipe.appendChild(node);
    if (idx < 2) {
      const ar = document.createElement('div');
      ar.className = 'arrow';
      ar.textContent = '→';
      pipe.appendChild(ar);
    }
  });

  const b = document.getElementById('diversityBanner');
  b.className = 'diversity ' + (diverse ? 'good' : 'bad');
  b.textContent = diverse
    ? 'Provider diversity check: OK'
    : 'Provider diversity violation: coding pass providers must all differ';
}

function renderProviders() {
  const wrap = document.getElementById('providers');
  wrap.replaceChildren();
  const entries = Object.entries(state.providers || {}).sort((a, b) => {
    if (a[1].hasAuth !== b[1].hasAuth) return a[1].hasAuth ? -1 : 1;
    return (a[1].displayName || a[0]).localeCompare(b[1].displayName || b[0]);
  });
  entries.forEach(([id, p]) => {
    const d = document.createElement('div');
    d.className = 'provider';
    const dot = document.createElement('span');
    dot.className = 'dot ' + (p.hasAuth ? 'on' : 'off');
    d.appendChild(dot);
    const nm = document.createElement('span');
    nm.className = 'name';
    nm.textContent = p.displayName || id;
    d.appendChild(nm);
    const meta = document.createElement('span');
    meta.className = 'meta';
    meta.textContent = p.hasAuth ? 'configured' : 'missing';
    d.appendChild(meta);
    d.onclick = () => openProvider(id, p);
    wrap.appendChild(d);
  });
}

function renderChannels() {
  const ch = document.getElementById('channels');
  ch.replaceChildren();
  const tg = ((state.channels || {}).telegram) || {};

  const card = document.createElement('div');
  card.className = 'channel';
  ['Telegram', 'Enabled: ' + (tg.enabled ? 'yes' : 'no'), 'DM Policy: ' + (tg.dmPolicy || 'disabled'),
   'Token: ' + (tg.botTokenConfigured ? 'configured' : 'missing'), 'allowFrom: ' + (tg.allowFrom || []).length + ' user(s)'].forEach((text, i) => {
    const div = document.createElement('div');
    div.className = i === 0 ? 'n' : 's';
    div.textContent = text;
    card.appendChild(div);
  });
  card.onclick = () => openTelegram();
  ch.appendChild(card);

  const pending = document.createElement('div');
  pending.className = 'channel';
  const pn1 = document.createElement('div'); pn1.className = 'n'; pn1.textContent = 'Discord (Future)';
  const ps1 = document.createElement('div'); ps1.className = 's'; ps1.textContent = 'Reserved for upcoming channel integration';
  pending.appendChild(pn1); pending.appendChild(ps1);
  ch.appendChild(pending);

  const pending2 = document.createElement('div');
  pending2.className = 'channel';
  const pn2 = document.createElement('div'); pn2.className = 'n'; pn2.textContent = 'Signal (Future)';
  const ps2 = document.createElement('div'); ps2.className = 's'; ps2.textContent = 'Reserved for upcoming channel integration';
  pending2.appendChild(pn2); pending2.appendChild(ps2);
  ch.appendChild(pending2);
}

function renderAllowlist() {
  const wrap = document.getElementById('allowlist');
  wrap.replaceChildren();
  const models = state.allowlist || [];
  if (!models.length) {
    const m = document.createElement('span');
    m.className = 'value empty';
    m.textContent = '(empty allowlist)';
    wrap.appendChild(m);
    return;
  }
  models.sort().forEach((m) => {
    const t = document.createElement('span');
    t.className = 'tag';
    const txt = document.createTextNode(m + ' ');
    t.appendChild(txt);
    const btn = document.createElement('button');
    btn.className = 'xbtn';
    btn.style.padding = '1px 6px';
    btn.textContent = 'x';
    btn.dataset.model = m;
    btn.addEventListener('click', function() { removeAllow(this.dataset.model); });
    t.appendChild(btn);
    wrap.appendChild(t);
  });
}

async function removeAllow(model) {
  try {
    await api('/api/config/remove-allowlist', 'POST', { model });
    toast('Removed from allowlist');
    load();
  } catch (e) {
    toast(e.message, false);
  }
}

function levelIcon(level) {
  if (level === 'error') return '🔴';
  if (level === 'warning') return '🟡';
  return '🔵';
}

function renderIssues() {
  const wrap = document.getElementById('issues');
  wrap.replaceChildren();
  const issues = state.issues || [];
  if (!issues.length) {
    const ok = document.createElement('div');
    ok.className = 'issue info';
    const okInner = document.createElement('div');
    const okTitle = document.createElement('div'); okTitle.className = 'it'; okTitle.textContent = '✅ No config issues detected';
    const okDesc = document.createElement('div'); okDesc.className = 'id'; okDesc.textContent = 'System checks passed';
    okInner.appendChild(okTitle); okInner.appendChild(okDesc);
    ok.appendChild(okInner);
    wrap.appendChild(ok);
    return;
  }

  issues.forEach((issue) => {
    const row = document.createElement('div');
    row.className = 'issue ' + (issue.severity || 'info');

    const left = document.createElement('div');
    const itDiv = document.createElement('div'); itDiv.className = 'it'; itDiv.textContent = levelIcon(issue.severity) + ' ' + (issue.title || '');
    const idDiv = document.createElement('div'); idDiv.className = 'id'; idDiv.textContent = issue.description || '';
    left.appendChild(itDiv); left.appendChild(idDiv);

    const right = document.createElement('div');
    right.className = 'ia';
    (issue.actions || []).forEach((a) => {
      const btn = document.createElement('button');
      btn.className = 'btn ' + (a.type === 'auto_fix' ? 'blue' : a.type === 'choose' ? 'cyan' : 'purple');
      btn.textContent = a.label || 'Fix';
      btn.onclick = () => onIssueAction(a, issue);
      right.appendChild(btn);
    });

    row.appendChild(left);
    row.appendChild(right);
    wrap.appendChild(row);
  });
}

async function onIssueAction(action, issue) {
  try {
    if (action.type === 'auto_fix') {
      await api('/api/config/auto-fix', 'POST', { issue: action.issue || issue.id });
      toast('Auto-fix applied');
      load();
      return;
    }
    if (action.type === 'choose') {
      openPicker(action.target || issue.target || 'primary');
      return;
    }
    if (action.type === 'provider_key') {
      const p = (state.providers || {})[action.provider];
      if (p) openProvider(action.provider, p);
      return;
    }
    if (action.type === 'scroll') {
      document.getElementById(action.target === 'providers' ? 'providersCard' : 'issues').scrollIntoView({ behavior: 'smooth', block: 'center' });
      return;
    }
    if (action.type === 'channel') {
      if (action.channel === 'telegram') openTelegram();
    }
  } catch (e) {
    toast(e.message, false);
  }
}

function renderStatus() {
  document.getElementById('sReg').textContent = state.registryVersion || '-';
  document.getElementById('sModels').textContent = state.modelCount || 0;
  document.getElementById('sProviders').textContent = state.providerCount || 0;
  document.getElementById('sBackups').textContent = state.backupCount || 0;
  document.getElementById('sUpdated').textContent = state.registryUpdated || '-';
}

function modelPassesTarget(ref, target) {
  const model = (state.models || {})[ref] || {};
  const caps = model.capabilities || [];
  if (target === 'primary') return caps.includes('llm') && caps.includes('tools');
  if (target === 'image_model') return caps.includes('vision');
  if (target === 'llm_fallbacks') return caps.includes('llm') && caps.includes('tools');
  if (target === 'image_fallbacks') return caps.includes('vision');
  if (target === 'research') return caps.includes('llm') && caps.includes('reasoning');
  if (target === 'coding_pass1' || target === 'coding_pass2' || target === 'coding_pass3') return caps.includes('llm') && caps.includes('code');
  if (target === 'social_media' || target === 'heartbeat') return caps.includes('llm');
  if (target === 'web_ops') return caps.includes('llm') && (caps.includes('tools') || caps.includes('search'));
  if (target === 'allowlist') return true;
  return true;
}

function pickerSubtitle(target) {
  const map = {
    primary: 'Pick a model with llm + tools capability.',
    image_model: 'Pick a model with vision capability.',
    llm_fallbacks: 'Pick a fallback model (llm + tools).',
    image_fallbacks: 'Pick an image fallback model (vision).',
    research: 'Pick a model with llm + reasoning capabilities.',
    coding_pass1: 'Pick coding model for pass 1 (llm + code).',
    coding_pass2: 'Pick coding model for pass 2, provider must differ from pass 1.',
    coding_pass3: 'Pick coding model for pass 3, provider must differ from pass 1 and pass 2.',
    social_media: 'Pick a creative llm model.',
    web_ops: 'Pick a model with llm and tools/search.',
    heartbeat: 'Pick a low-cost llm model.',
    allowlist: 'Add any model to allowlist.',
  };
  return map[target] || 'Pick a model.';
}

function openPicker(target) {
  pickerCtx = { target, selected: null };
  document.getElementById('pickerTitle').textContent = 'Select Model: ' + target;
  document.getElementById('pickerSub').textContent = pickerSubtitle(target);
  document.getElementById('pickerSearch').value = '';
  document.getElementById('pickerSave').disabled = true;
  drawPickerList('');
  document.getElementById('pickerOverlay').classList.add('show');
  setTimeout(() => document.getElementById('pickerSearch').focus(), 50);
}

function closePicker() {
  document.getElementById('pickerOverlay').classList.remove('show');
}

function drawPickerList(filterText) {
  const list = document.getElementById('pickerList');
  list.replaceChildren();

  const target = pickerCtx.target;
  const f = (filterText || '').toLowerCase();
  const currentFallbacks = new Set(state.fallbacks || []);
  const currentImageFallbacks = new Set(state.imageFallbacks || []);
  const currentAllowlist = new Set(state.allowlist || []);

  let items = Object.entries(state.models || {}).filter(([ref, m]) => {
    if (m.blocked) return false;
    if (!modelPassesTarget(ref, target)) return false;
    if (target === 'llm_fallbacks' && (currentFallbacks.has(ref) || ref === state.primary)) return false;
    if (target === 'image_fallbacks' && (currentImageFallbacks.has(ref) || ref === state.imageModel)) return false;
    if (target === 'allowlist' && currentAllowlist.has(ref)) return false;

    const hay = [ref, m.displayName || '', m.costTier || '', (m.capabilities || []).join(' ')].join(' ').toLowerCase();
    if (f && !hay.includes(f)) return false;
    return true;
  });

  items.sort((a, b) => {
    const pa = (state.providers || {})[providerOf(a[0])] || {};
    const pb = (state.providers || {})[providerOf(b[0])] || {};
    if (pa.hasAuth !== pb.hasAuth) return pa.hasAuth ? -1 : 1;
    const ca = ({ free:0,'very-low':1,low:2,medium:3,high:4,subscription:5,proxy:3 }[a[1].costTier] ?? 3);
    const cb = ({ free:0,'very-low':1,low:2,medium:3,high:4,subscription:5,proxy:3 }[b[1].costTier] ?? 3);
    if (ca !== cb) return ca - cb;
    return a[0].localeCompare(b[0]);
  });

  if (!items.length) {
    const emp = document.createElement('div');
    emp.className = 'id';
    emp.textContent = 'No matching models.';
    list.appendChild(emp);
    return;
  }

  items.forEach(([ref, m]) => {
    const pid = providerOf(ref);
    const p = (state.providers || {})[pid] || {};
    const row = document.createElement('div');
    row.className = 'pick' + (pickerCtx.selected === ref ? ' sel' : '');
    const info = document.createElement('div');
    const nmDiv = document.createElement('div'); nmDiv.className = 'nm'; nmDiv.textContent = ref;
    const cpDiv = document.createElement('div'); cpDiv.className = 'cp'; cpDiv.textContent = (m.capabilities || []).join(', ');
    info.appendChild(nmDiv); info.appendChild(cpDiv);
    const costDiv = document.createElement('div'); costDiv.className = 'cp'; costDiv.textContent = m.costTier || '-';
    const auDiv = document.createElement('div'); auDiv.className = 'au'; auDiv.style.color = p.hasAuth ? '#86efac' : '#fca5a5'; auDiv.textContent = p.hasAuth ? 'auth ✓' : 'auth ✗';
    row.appendChild(info); row.appendChild(costDiv); row.appendChild(auDiv);
    row.onclick = () => {
      pickerCtx.selected = ref;
      drawPickerList(document.getElementById('pickerSearch').value);
      document.getElementById('pickerSave').disabled = false;
    };
    list.appendChild(row);
  });
}

document.getElementById('pickerSearch').addEventListener('input', (e) => drawPickerList(e.target.value));

document.getElementById('pickerOverlay').addEventListener('click', (e) => {
  if (e.target.id === 'pickerOverlay') closePicker();
});

async function submitPicker() {
  const target = pickerCtx.target;
  const model = pickerCtx.selected;
  if (!model) return;

  if (target === 'coding_pass2' || target === 'coding_pass3') {
    const selectedProvider = providerOf(model);
    const roles = state.roles || {};
    const used = new Set();
    if (roles.coding_pass1) used.add(providerOf(roles.coding_pass1));
    if (target === 'coding_pass3' && roles.coding_pass2) used.add(providerOf(roles.coding_pass2));
    if (used.has(selectedProvider)) {
      toast('Coding pipeline providers must be different', false);
      return;
    }
  }

  try {
    if (target === 'primary') {
      await api('/api/config/set-primary', 'POST', { model });
    } else if (target === 'image_model') {
      await api('/api/config/set-image-model', 'POST', { model });
    } else if (target === 'llm_fallbacks') {
      await api('/api/config/add-fallback', 'POST', { model });
    } else if (target === 'image_fallbacks') {
      await api('/api/config/add-image-fallback', 'POST', { model });
    } else if (target === 'allowlist') {
      await api('/api/config/add-allowlist', 'POST', { model });
    } else {
      await api('/api/config/set-role', 'POST', { role: target, model });
    }
    toast('Configuration updated');
    closePicker();
    load();
  } catch (e) {
    toast(e.message, false);
  }
}

function openProvider(id, p) {
  providerCtx = { id, p };
  document.getElementById('providerTitle').textContent = (p.displayName || id) + ' auth';
  document.getElementById('providerSub').textContent = 'Auth method: ' + (p.authMethod || 'api-key');

  const body = document.getElementById('providerBody');
  body.replaceChildren();

  const authEnv = p.authEnvVars || [];
  const save = document.getElementById('providerSaveBtn');
  const del = document.getElementById('providerDeleteBtn');

  if (!authEnv.length) {
    const m = document.createElement('div');
    m.className = 'id';
    m.textContent = 'This provider does not use direct API key env vars.';
    body.appendChild(m);
    save.style.display = 'none';
    del.style.display = 'none';
  } else {
    save.style.display = '';
    del.style.display = p.hasAuth ? '' : 'none';

    const lab = document.createElement('label');
    lab.textContent = 'Env var: ' + authEnv[0];
    body.appendChild(lab);
    const inp = document.createElement('input');
    inp.id = 'providerKeyInput';
    inp.type = 'password';
    inp.placeholder = 'Paste API key';
    body.appendChild(inp);

    if (p.website) {
      const a = document.createElement('a');
      a.href = p.website;
      a.target = '_blank';
      a.rel = 'noreferrer noopener';
      a.className = 'id';
      a.style.display = 'inline-block';
      a.style.marginTop = '8px';
      a.style.color = '#93c5fd';
      a.textContent = 'Get key: ' + p.website;
      body.appendChild(a);
    }
  }

  document.getElementById('providerOverlay').classList.add('show');
}

function closeProvider() {
  document.getElementById('providerOverlay').classList.remove('show');
  providerCtx = null;
}

async function saveProviderKey() {
  if (!providerCtx) return;
  const authEnv = providerCtx.p.authEnvVars || [];
  if (!authEnv.length) return;
  const key = authEnv[0];
  const value = (document.getElementById('providerKeyInput').value || '').trim();
  if (!value) {
    toast('Enter key value', false);
    return;
  }
  try {
    await api('/api/key', 'POST', { key, value });
    toast('Provider key saved');
    closeProvider();
    load();
  } catch (e) {
    toast(e.message, false);
  }
}

async function deleteProviderKey() {
  if (!providerCtx) return;
  const authEnv = providerCtx.p.authEnvVars || [];
  if (!authEnv.length) return;
  try {
    await api('/api/key/delete', 'POST', { key: authEnv[0] });
    toast('Provider key removed');
    closeProvider();
    load();
  } catch (e) {
    toast(e.message, false);
  }
}

document.getElementById('providerOverlay').addEventListener('click', (e) => {
  if (e.target.id === 'providerOverlay') closeProvider();
});

function openTelegram() {
  const tg = ((state.channels || {}).telegram) || {};
  document.getElementById('tgEnabled').value = String(!!tg.enabled);
  document.getElementById('tgPolicy').value = tg.dmPolicy || 'disabled';
  document.getElementById('tgToken').value = '';
  document.getElementById('tgMention').value = String(!!((tg.groups || {}).requireMention));
  document.getElementById('tgUserId').value = '';
  renderTelegramUsers();
  document.getElementById('tgOverlay').classList.add('show');
}

function closeTelegram() {
  document.getElementById('tgOverlay').classList.remove('show');
}

document.getElementById('tgOverlay').addEventListener('click', (e) => {
  if (e.target.id === 'tgOverlay') closeTelegram();
});

function renderTelegramUsers() {
  const wrap = document.getElementById('tgAllowFrom');
  wrap.replaceChildren();
  const users = ((((state || {}).channels || {}).telegram || {}).allowFrom) || [];
  if (!users.length) {
    const empty = document.createElement('span');
    empty.className = 'value empty';
    empty.textContent = '(none)';
    wrap.appendChild(empty);
    return;
  }
  users.forEach((u) => {
    const tag = document.createElement('span');
    tag.className = 'tag';
    const txt = document.createTextNode(u + ' ');
    tag.appendChild(txt);
    const btn = document.createElement('button');
    btn.className = 'xbtn';
    btn.style.padding = '1px 6px';
    btn.textContent = 'x';
    btn.dataset.user = u;
    btn.addEventListener('click', function() { removeTelegramUser(this.dataset.user); });
    tag.appendChild(btn);
    wrap.appendChild(tag);
  });
}

async function saveTelegram() {
  const payload = {
    enabled: document.getElementById('tgEnabled').value === 'true',
    dmPolicy: document.getElementById('tgPolicy').value,
    groups: { requireMention: document.getElementById('tgMention').value === 'true' }
  };
  const [REDACTED_SECRET]'tgToken').value || '').trim();
  if (token) payload.botToken = token;
  try {
    await api('/api/channels/telegram', 'POST', payload);
    toast('Telegram updated');
    closeTelegram();
    load();
  } catch (e) {
    toast(e.message, false);
  }
}

async function addTelegramUser() {
  const userId = (document.getElementById('tgUserId').value || '').trim();
  if (!userId) {
    toast('Enter user ID', false);
    return;
  }
  try {
    await api('/api/channels/telegram/add-user', 'POST', { userId });
    document.getElementById('tgUserId').value = '';
    toast('User added');
    load(true);
  } catch (e) {
    toast(e.message, false);
  }
}

async function removeTelegramUser(userId) {
  try {
    await api('/api/channels/telegram/remove-user', 'POST', { userId });
    toast('User removed');
    load(true);
  } catch (e) {
    toast(e.message, false);
  }
}

function renderAll() {
  renderRoles();
  renderFallbackList('fallbacks', state.fallbacks || [], false);
  renderFallbackList('imageFallbacks', state.imageFallbacks || [], true);
  renderPipeline();
  renderProviders();
  renderChannels();
  renderAllowlist();
  renderIssues();
  renderStatus();
}

async function load(skipClose = false) {
  try {
    const data = await api('/api/status');
    state = data;
    renderAll();
    if (!skipClose) {
      renderTelegramUsers();
    }
  } catch (e) {
    toast(e.message || 'Failed to load status', false);
  }
}

document.addEventListener('keydown', (e) => {
  if (e.key === 'Escape') {
    closePicker();
    closeProvider();
    closeTelegram();
  }
});

load();
</script>
</body>
</html>


path: ui/server_v1_backup.py
content: #!/usr/bin/env python3
"""Model Switchboard — Local API server for the interactive dashboard."""

import http.server
import json
import os
import re
import shutil
import sys
import time
import urllib.parse
from pathlib import Path

PORT = int(os.environ.get("SWITCHBOARD_PORT", "8770"))
ENV_FILE = os.environ.get("SWITCHBOARD_ENV", os.path.expanduser("~/.openclaw/workspace/.env"))
REGISTRY_FILE = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "model-registry.json")
CONFIG_FILE = os.path.expanduser("~/.openclaw/openclaw.json")
BACKUP_DIR = os.path.expanduser("~/.openclaw/backups/switchboard")
UI_DIR = os.path.dirname(os.path.abspath(__file__))

# ── Helpers ──────────────────────────────────────────────────────────────

def read_env() -> dict:
    env = {}
    p = Path(ENV_FILE)
    if not p.exists():
        return env
    for line in p.read_text().splitlines():
        line = line.strip()
        if not line or line.startswith("#"):
            continue
        if "=" in line:
            k, v = line.split("=", 1)
            env[k.strip()] = v.strip().strip('"').strip("'")
    return env


def write_env_key(key: str, value: str) -> None:
    p = Path(ENV_FILE)
    p.parent.mkdir(parents=True, exist_ok=True)
    lines = p.read_text().splitlines() if p.exists() else []
    found = False
    new_lines = []
    pattern = re.compile(rf"^{re.escape(key)}\s*=")
    for line in lines:
        if pattern.match(line.strip()):
            new_lines.append(f'{key}="{value}"')
            found = True
        else:
            new_lines.append(line)
    if not found:
        new_lines.append(f'{key}="{value}"')
    p.write_text("\n".join(new_lines) + "\n")
    os.chmod(str(p), 0o600)


def delete_env_key(key: str) -> bool:
    p = Path(ENV_FILE)
    if not p.exists():
        return False
    lines = p.read_text().splitlines()
    pattern = re.compile(rf"^{re.escape(key)}\s*=")
    new_lines = [l for l in lines if not pattern.match(l.strip())]
    if len(new_lines) == len(lines):
        return False
    p.write_text("\n".join(new_lines) + "\n")
    os.chmod(str(p), 0o600)
    return True


def read_registry() -> dict:
    try:
        return json.loads(Path(REGISTRY_FILE).read_text())
    except Exception:
        return {"models": {}, "providers": {}}


def read_config() -> dict:
    try:
        return json.loads(Path(CONFIG_FILE).read_text())
    except Exception:
        return {}


def write_config(config: dict) -> None:
    """Write openclaw.json with backup."""
    backup_config()
    Path(CONFIG_FILE).write_text(json.dumps(config, indent=2) + "\n")


def backup_config() -> str:
    """Create timestamped backup of openclaw.json. Returns backup path."""
    src = Path(CONFIG_FILE)
    if not src.exists():
        return ""
    bdir = Path(BACKUP_DIR)
    bdir.mkdir(parents=True, exist_ok=True)
    ts = time.strftime("%Y%m%d-%H%M%S")
    dest = bdir / f"openclaw-{ts}.json"
    shutil.copy2(str(src), str(dest))
    # Keep max 20 backups
    backups = sorted(bdir.glob("openclaw-*.json"))
    while len(backups) > 20:
        backups.pop(0).unlink()
    return str(dest)


def count_backups() -> int:
    bdir = Path(BACKUP_DIR)
    if not bdir.exists():
        return 0
    return len(list(bdir.glob("openclaw-*.json")))


def build_status() -> dict:
    registry = read_registry()
    config = read_config()
    env = read_env()

    agents = config.get("agents", {}).get("defaults", {})
    model_cfg = agents.get("model", {})
    img_cfg = agents.get("imageModel", {})

    providers_status = {}
    for pid, pinfo in registry.get("providers", {}).items():
        auth_envs = pinfo.get("authEnv", [])
        has_auth = any(env.get(k) for k in auth_envs)
        auth_method = pinfo.get("authMethod", "api-key")
        providers_status[pid] = {
            "displayName": pinfo.get("displayName", pid),
            "type": pinfo.get("type", "built-in"),
            "hasAuth": has_auth,
            "authMethod": auth_method,
            "authEnvVars": auth_envs,
            "website": pinfo.get("website", ""),
            "note": pinfo.get("note", ""),
        }

    models_cfg = agents.get("models", {})
    allowlist = list(models_cfg.keys()) if models_cfg else []

    return {
        "primary": model_cfg.get("primary", ""),
        "imageModel": img_cfg.get("primary", ""),
        "fallbacks": model_cfg.get("fallbacks", []),
        "imageFallbacks": img_cfg.get("fallbacks", []),
        "providers": providers_status,
        "allowlist": allowlist,
        "models": registry.get("models", {}),
        "registryVersion": registry.get("version", "?"),
        "timestamp": registry.get("lastUpdated", ""),
        "backupCount": count_backups(),
    }


# ── Config modification helpers ──────────────────────────────────────────

def validate_model_ref(ref: str) -> bool:
    """Validate a model reference format."""
    return bool(ref and "/" in ref and len(ref) < 200 and
                re.match(r'^[a-z][a-z0-9_-]*/[a-z0-9._:/-]+$', ref))


def validate_model_for_role(ref: str, role: str, registry: dict) -> tuple:
    """Check if model is safe for given role. Returns (ok, reason)."""
    models = registry.get("models", {})
    model_info = models.get(ref)
    if not model_info:
        # Unknown model — allow but warn
        return True, "Model not in registry — cannot verify compatibility"
    if model_info.get("blocked"):
        return False, model_info.get("blockReason", "Model is blocked")
    unsafe = model_info.get("unsafeRoles", [])
    if role in unsafe:
        return False, f"Model is unsafe for {role} role"
    safe = model_info.get("safeRoles", [])
    if role not in safe:
        return True, f"Model not explicitly listed as safe for {role} — proceed with caution"
    return True, ""


def config_add_fallback(ref: str) -> dict:
    """Add LLM fallback. Returns result dict."""
    if not validate_model_ref(ref):
        return {"error": f"Invalid model ref: {ref}"}
    registry = read_registry()
    ok, reason = validate_model_for_role(ref, "fallback", registry)
    if not ok:
        return {"error": reason}

    config = read_config()
    agents = config.setdefault("agents", {}).setdefault("defaults", {})
    model_cfg = agents.setdefault("model", {})
    fallbacks = model_cfg.setdefault("fallbacks", [])
    if ref in fallbacks:
        return {"error": f"{ref} is already a fallback"}
    fallbacks.append(ref)
    # Also add to allowlist if it exists
    models = agents.get("models")
    if models is not None and ref not in models:
        models[ref] = {}
    write_config(config)
    return {"ok": True, "action": "add_fallback", "model": ref, "warning": reason}


def config_add_image_fallback(ref: str) -> dict:
    """Add image model fallback."""
    if not validate_model_ref(ref):
        return {"error": f"Invalid model ref: {ref}"}
    registry = read_registry()
    ok, reason = validate_model_for_role(ref, "image", registry)
    if not ok:
        return {"error": reason}

    config = read_config()
    agents = config.setdefault("agents", {}).setdefault("defaults", {})
    img_cfg = agents.setdefault("imageModel", {})
    fallbacks = img_cfg.setdefault("fallbacks", [])
    if ref in fallbacks:
        return {"error": f"{ref} is already an image fallback"}
    fallbacks.append(ref)
    models = agents.get("models")
    if models is not None and ref not in models:
        models[ref] = {}
    write_config(config)
    return {"ok": True, "action": "add_image_fallback", "model": ref, "warning": reason}


def config_remove_fallback(ref: str, is_image: bool = False) -> dict:
    """Remove a fallback."""
    config = read_config()
    agents = config.get("agents", {}).get("defaults", {})
    key = "imageModel" if is_image else "model"
    cfg = agents.get(key, {})
    fallbacks = cfg.get("fallbacks", [])
    if ref not in fallbacks:
        return {"error": f"{ref} not in {'image ' if is_image else ''}fallbacks"}
    fallbacks.remove(ref)
    write_config(config)
    return {"ok": True, "action": "remove_fallback", "model": ref}


def config_add_to_allowlist(ref: str) -> dict:
    """Add model to allowlist."""
    if not validate_model_ref(ref):
        return {"error": f"Invalid model ref: {ref}"}
    config = read_config()
    agents = config.setdefault("agents", {}).setdefault("defaults", {})
    models = agents.setdefault("models", {})
    if ref in models:
        return {"error": f"{ref} already in allowlist"}
    models[ref] = {}
    write_config(config)
    return {"ok": True, "action": "add_allowlist", "model": ref}


def config_remove_from_allowlist(ref: str) -> dict:
    """Remove model from allowlist."""
    config = read_config()
    agents = config.get("agents", {}).get("defaults", {})
    models = agents.get("models", {})
    if ref not in models:
        return {"error": f"{ref} not in allowlist"}
    # Don't remove if it's the primary or in fallbacks
    model_cfg = agents.get("model", {})
    if ref == model_cfg.get("primary"):
        return {"error": "Cannot remove primary model from allowlist"}
    del models[ref]
    write_config(config)
    return {"ok": True, "action": "remove_allowlist", "model": ref}


def config_set_primary(ref: str) -> dict:
    """Set primary model."""
    if not validate_model_ref(ref):
        return {"error": f"Invalid model ref: {ref}"}
    registry = read_registry()
    ok, reason = validate_model_for_role(ref, "primary", registry)
    if not ok:
        return {"error": reason}

    config = read_config()
    agents = config.setdefault("agents", {}).setdefault("defaults", {})
    model_cfg = agents.setdefault("model", {})
    model_cfg["primary"] = ref
    models = agents.get("models")
    if models is not None and ref not in models:
        models[ref] = {}
    write_config(config)
    return {"ok": True, "action": "set_primary", "model": ref, "warning": reason}


def config_set_image_model(ref: str) -> dict:
    """Set image model."""
    if not validate_model_ref(ref):
        return {"error": f"Invalid model ref: {ref}"}
    registry = read_registry()
    ok, reason = validate_model_for_role(ref, "image", registry)
    if not ok:
        return {"error": reason}

    config = read_config()
    agents = config.setdefault("agents", {}).setdefault("defaults", {})
    img_cfg = agents.setdefault("imageModel", {})
    img_cfg["primary"] = ref
    models = agents.get("models")
    if models is not None and ref not in models:
        models[ref] = {}
    write_config(config)
    return {"ok": True, "action": "set_image_model", "model": ref, "warning": reason}


def config_auto_fix(issue_id: str) -> dict:
    """Auto-fix a known issue by ID."""
    registry = read_registry()
    config = read_config()
    env = read_env()
    agents = config.setdefault("agents", {}).setdefault("defaults", {})

    if issue_id == "no_llm_fallbacks":
        # Add diverse fallbacks from authenticated providers
        model_cfg = agents.setdefault("model", {})
        primary = model_cfg.get("primary", "")
        primary_provider = primary.split("/")[0] if primary else ""
        fallbacks = model_cfg.setdefault("fallbacks", [])

        candidates = _get_auth_candidates(registry, env, "fallback", primary_provider, fallbacks)
        added = []
        for ref in candidates[:3]:
            if ref not in fallbacks and ref != primary:
                fallbacks.append(ref)
                models = agents.get("models")
                if models is not None and ref not in models:
                    models[ref] = {}
                added.append(ref)
        if added:
            write_config(config)
            return {"ok": True, "action": "auto_fix", "issue": issue_id,
                    "added": added, "message": f"Added {len(added)} fallback(s): {', '.join(added)}"}
        return {"error": "No authenticated models available to add as fallbacks"}

    elif issue_id == "no_image_fallbacks":
        img_cfg = agents.setdefault("imageModel", {})
        img_primary = img_cfg.get("primary", "")
        img_provider = img_primary.split("/")[0] if img_primary else ""
        img_fallbacks = img_cfg.setdefault("fallbacks", [])

        candidates = _get_auth_candidates(registry, env, "image", img_provider, img_fallbacks)
        added = []
        for ref in candidates[:2]:
            if ref not in img_fallbacks and ref != img_primary:
                img_fallbacks.append(ref)
                models = agents.get("models")
                if models is not None and ref not in models:
                    models[ref] = {}
                added.append(ref)
        if added:
            write_config(config)
            return {"ok": True, "action": "auto_fix", "issue": issue_id,
                    "added": added, "message": f"Added {len(added)} image fallback(s): {', '.join(added)}"}
        return {"error": "No authenticated vision-capable models available"}

    elif issue_id == "small_allowlist":
        # Add all models from authenticated providers
        models_cfg = agents.setdefault("models", {})
        all_models = registry.get("models", {})
        added = []
        for ref, minfo in all_models.items():
            if ref in models_cfg or minfo.get("blocked"):
                continue
            provider = ref.split("/")[0]
            pinfo = registry.get("providers", {}).get(provider, {})
            auth_envs = pinfo.get("authEnv", [])
            if any(env.get(k) for k in auth_envs):
                models_cfg[ref] = {}
                added.append(ref)
        if added:
            write_config(config)
            return {"ok": True, "action": "auto_fix", "issue": issue_id,
                    "added": added, "message": f"Added {len(added)} models from authenticated providers to allowlist"}
        return {"error": "No additional models from authenticated providers to add"}

    elif issue_id == "few_providers":
        return {"ok": False, "action": "redirect",
                "message": "Click on unconfigured providers above to add API keys",
                "redirect": "providers"}

    return {"error": f"Unknown issue: {issue_id}"}


def _get_auth_candidates(registry, env, role, exclude_provider, exclude_refs):
    """Get model candidates from authenticated providers for a role, excluding given provider."""
    candidates = []
    all_models = registry.get("models", {})
    providers = registry.get("providers", {})

    # Score: prefer different providers, lower cost, more capabilities
    scored = []
    for ref, minfo in all_models.items():
        if minfo.get("blocked"):
            continue
        safe_roles = minfo.get("safeRoles", [])
        if role not in safe_roles:
            continue
        provider = ref.split("/")[0]
        if provider == exclude_provider:
            continue
        if ref in exclude_refs:
            continue
        pinfo = providers.get(provider, {})
        auth_envs = pinfo.get("authEnv", [])
        if not any(env.get(k) for k in auth_envs):
            continue
        # Score: lower cost = better
        cost_scores = {"free": 0, "very-low": 1, "low": 2, "medium": 3, "high": 4, "subscription": 5, "proxy": 3}
        score = cost_scores.get(minfo.get("costTier", "medium"), 3)
        scored.append((score, ref))

    scored.sort()
    return [ref for _, ref in scored]


# ── HTTP Handler ─────────────────────────────────────────────────────────

class Handler(http.server.SimpleHTTPRequestHandler):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, directory=UI_DIR, **kwargs)

    def do_GET(self):
        parsed = urllib.parse.urlparse(self.path)
        if parsed.path == "/api/status":
            self._json_response(build_status())
        elif parsed.path == "/api/registry":
            self._json_response(read_registry())
        elif parsed.path == "/api/env":
            env = read_env()
            self._json_response({"keys": list(env.keys())})
        elif parsed.path == "/api/backups":
            bdir = Path(BACKUP_DIR)
            backups = []
            if bdir.exists():
                for f in sorted(bdir.glob("openclaw-*.json"), reverse=True)[:10]:
                    backups.append({"name": f.name, "size": f.stat().st_size,
                                    "modified": f.stat().st_mtime})
            self._json_response({"backups": backups})
        else:
            super().do_GET()

    def do_POST(self):
        parsed = urllib.parse.urlparse(self.path)
        content_len = int(self.headers.get("Content-Length", 0))
        body = self.rfile.read(content_len) if content_len else b"{}"
        try:
            data = json.loads(body)
        except json.JSONDecodeError:
            self._json_response({"error": "Invalid JSON"}, 400)
            return

        routes = {
            "/api/key": self._handle_save_key,
            "/api/key/delete": self._handle_delete_key,
            "/api/config/add-fallback": lambda d: self._json_response(config_add_fallback(d.get("model", ""))),
            "/api/config/add-image-fallback": lambda d: self._json_response(config_add_image_fallback(d.get("model", ""))),
            "/api/config/remove-fallback": lambda d: self._json_response(config_remove_fallback(d.get("model", ""), d.get("image", False))),
            "/api/config/add-allowlist": lambda d: self._json_response(config_add_to_allowlist(d.get("model", ""))),
            "/api/config/remove-allowlist": lambda d: self._json_response(config_remove_from_allowlist(d.get("model", ""))),
            "/api/config/set-primary": lambda d: self._json_response(config_set_primary(d.get("model", ""))),
            "/api/config/set-image-model": lambda d: self._json_response(config_set_image_model(d.get("model", ""))),
            "/api/config/auto-fix": lambda d: self._json_response(config_auto_fix(d.get("issue", ""))),
            "/api/config/backup": lambda d: self._json_response({"ok": True, "path": backup_config()}),
        }

        handler = routes.get(parsed.path)
        if handler:
            handler(data)
        else:
            self._json_response({"error": "Not found"}, 404)

    def _handle_save_key(self, data):
        key = data.get("key", "").strip()
        value = data.get("value", "").strip()
        if not key or not value:
            self._json_response({"error": "Missing key or value"}, 400)
            return
        if not re.match(r"^[A-Z][A-Z0-9_]*$", key):
            self._json_response({"error": "Invalid env var name"}, 400)
            return
        if any(c in value for c in ["\n", "\r", "`", "$", ";"]):
            self._json_response({"error": "Invalid characters in value"}, 400)
            return
        write_env_key(key, value)
        self._json_response({"ok": True, "key": key, "saved": True})

    def _handle_delete_key(self, data):
        key = data.get("key", "").strip()
        if not key:
            self._json_response({"error": "Missing key"}, 400)
            return
        removed = delete_env_key(key)
        self._json_response({"ok": True, "key": key, "removed": removed})

    def _json_response(self, data, status=200):
        body = json.dumps(data).encode()
        self.send_response(status)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(body)))
        self.send_header("Access-Control-Allow-Origin", "*")
        self.end_headers()
        self.wfile.write(body)

    def do_OPTIONS(self):
        self.send_response(204)
        self.send_header("Access-Control-Allow-Origin", "*")
        self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
        self.send_header("Access-Control-Allow-Headers", "Content-Type")
        self.end_headers()

    def log_message(self, format, *args):
        pass


if __name__ == "__main__":
    print(f"🔀 Model Switchboard server on http://127.0.0.1:{PORT}")
    print(f"   .env file: {ENV_FILE}")
    print(f"   Registry:  {REGISTRY_FILE}")
    print(f"   Config:    {CONFIG_FILE}")
    server = http.server.HTTPServer(("127.0.0.1", PORT), Handler)
    try:
        server.serve_forever()
    except KeyboardInterrupt:
        print("\nShutting down.")
        server.shutdown()


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