Files
Timmy-time-dashboard/src/dashboard/routes/models.py
Alexander Whitestone 9d78eb31d1 ruff (#169)
* polish: streamline nav, extract inline styles, improve tablet UX

- Restructure desktop nav from 8+ flat links + overflow dropdown into
  5 grouped dropdowns (Core, Agents, Intel, System, More) matching
  the mobile menu structure to reduce decision fatigue
- Extract all inline styles from mission_control.html and base.html
  notification elements into mission-control.css with semantic classes
- Replace JS-built innerHTML with secure DOM construction in
  notification loader and chat history
- Add CONNECTING state to connection indicator (amber) instead of
  showing OFFLINE before WebSocket connects
- Add tablet breakpoint (1024px) with larger touch targets for
  Apple Pencil / stylus use and safe-area padding for iPad toolbar
- Add active-link highlighting in desktop dropdown menus
- Rename "Mission Control" page title to "System Overview" to
  disambiguate from the chat home page
- Add "Home — Timmy Time" page title to index.html

https://claude.ai/code/session_015uPUoKyYa8M2UAcyk5Gt6h

* fix(security): move auth-gate credentials to environment variables

Hardcoded username, password, and HMAC secret in auth-gate.py replaced
with os.environ lookups. Startup now refuses to run if any variable is
unset. Added AUTH_GATE_SECRET/USER/PASS to .env.example.

https://claude.ai/code/session_015uPUoKyYa8M2UAcyk5Gt6h

* refactor(tooling): migrate from black+isort+bandit to ruff

Replace three separate linting/formatting tools with a single ruff
invocation. Updates tox.ini (lint, format, pre-push, pre-commit envs),
.pre-commit-config.yaml, and CI workflow. Fixes all ruff errors
including unused imports, missing raise-from, and undefined names.
Ruff config maps existing bandit skips to equivalent S-rules.

https://claude.ai/code/session_015uPUoKyYa8M2UAcyk5Gt6h

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-03-11 12:23:35 -04:00

270 lines
8.9 KiB
Python

"""Custom model management routes — register, list, assign, and swap models.
Provides a REST API for managing custom model weights and their assignment
to swarm agents. Inspired by OpenClaw-RL's multi-model orchestration.
"""
import logging
from pathlib import Path
from typing import Any
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import HTMLResponse
from pydantic import BaseModel
from config import settings
from dashboard.templating import templates
from infrastructure.models.registry import (
CustomModel,
ModelFormat,
ModelRole,
model_registry,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/models", tags=["models"])
api_router = APIRouter(prefix="/api/v1/models", tags=["models-api"])
# ── Pydantic schemas ──────────────────────────────────────────────────────────
class RegisterModelRequest(BaseModel):
"""Request body for model registration."""
name: str
format: str # gguf, safetensors, hf, ollama
path: str
role: str = "general"
context_window: int = 4096
description: str = ""
default_temperature: float = 0.7
max_tokens: int = 2048
class AssignModelRequest(BaseModel):
"""Request body for assigning a model to an agent."""
agent_id: str
model_name: str
class SetActiveRequest(BaseModel):
"""Request body for enabling/disabling a model."""
active: bool
# ── API endpoints ─────────────────────────────────────────────────────────────
@api_router.get("")
async def list_models(role: str | None = None) -> dict[str, Any]:
"""List all registered custom models."""
model_role = ModelRole(role) if role else None
models = model_registry.list_models(role=model_role)
return {
"models": [
{
"name": m.name,
"format": m.format.value,
"path": m.path,
"role": m.role.value,
"context_window": m.context_window,
"description": m.description,
"active": m.active,
"registered_at": m.registered_at,
"default_temperature": m.default_temperature,
"max_tokens": m.max_tokens,
}
for m in models
],
"total": len(models),
"weights_dir": settings.custom_weights_dir,
}
@api_router.post("")
async def register_model(request: RegisterModelRequest) -> dict[str, Any]:
"""Register a new custom model."""
try:
fmt = ModelFormat(request.format)
except ValueError:
raise HTTPException(
status_code=400,
detail=f"Invalid format: {request.format}. "
f"Choose from: {[f.value for f in ModelFormat]}",
) from None
try:
role = ModelRole(request.role)
except ValueError:
raise HTTPException(
status_code=400,
detail=f"Invalid role: {request.role}. Choose from: {[r.value for r in ModelRole]}",
) from None
# Validate path exists for non-Ollama formats
if fmt != ModelFormat.OLLAMA:
weight_path = Path(request.path)
if not weight_path.exists():
raise HTTPException(
status_code=400,
detail=f"Weight path does not exist: {request.path}",
)
model = CustomModel(
name=request.name,
format=fmt,
path=request.path,
role=role,
context_window=request.context_window,
description=request.description,
default_temperature=request.default_temperature,
max_tokens=request.max_tokens,
)
registered = model_registry.register(model)
return {
"message": f"Model {registered.name} registered",
"model": {
"name": registered.name,
"format": registered.format.value,
"role": registered.role.value,
"path": registered.path,
},
}
@api_router.get("/{model_name}")
async def get_model(model_name: str) -> dict[str, Any]:
"""Get details of a specific model."""
model = model_registry.get(model_name)
if not model:
raise HTTPException(status_code=404, detail=f"Model {model_name} not found")
return {
"name": model.name,
"format": model.format.value,
"path": model.path,
"role": model.role.value,
"context_window": model.context_window,
"description": model.description,
"active": model.active,
"registered_at": model.registered_at,
"default_temperature": model.default_temperature,
"max_tokens": model.max_tokens,
}
@api_router.delete("/{model_name}")
async def unregister_model(model_name: str) -> dict[str, str]:
"""Remove a model from the registry."""
if not model_registry.unregister(model_name):
raise HTTPException(status_code=404, detail=f"Model {model_name} not found")
return {"message": f"Model {model_name} unregistered"}
@api_router.patch("/{model_name}/active")
async def set_model_active(model_name: str, request: SetActiveRequest) -> dict[str, str]:
"""Enable or disable a model."""
if not model_registry.set_active(model_name, request.active):
raise HTTPException(status_code=404, detail=f"Model {model_name} not found")
state = "enabled" if request.active else "disabled"
return {"message": f"Model {model_name} {state}"}
# ── Agent assignment endpoints ────────────────────────────────────────────────
@api_router.get("/assignments/all")
async def list_assignments() -> dict[str, Any]:
"""List all agent-to-model assignments."""
assignments = model_registry.get_agent_assignments()
return {
"assignments": [
{"agent_id": aid, "model_name": mname} for aid, mname in assignments.items()
],
"total": len(assignments),
}
@api_router.post("/assignments")
async def assign_model(request: AssignModelRequest) -> dict[str, str]:
"""Assign a model to a swarm agent."""
if not model_registry.assign_model(request.agent_id, request.model_name):
raise HTTPException(
status_code=404,
detail=f"Model {request.model_name} not found in registry",
)
return {
"message": f"Model {request.model_name} assigned to {request.agent_id}",
}
@api_router.delete("/assignments/{agent_id}")
async def unassign_model(agent_id: str) -> dict[str, str]:
"""Remove model assignment from an agent (reverts to default)."""
if not model_registry.unassign_model(agent_id):
raise HTTPException(
status_code=404,
detail=f"No model assignment for agent {agent_id}",
)
return {"message": f"Model assignment removed for {agent_id}"}
# ── Role-based lookups ────────────────────────────────────────────────────────
@api_router.get("/roles/reward")
async def get_reward_model() -> dict[str, Any]:
"""Get the active reward (PRM) model."""
model = model_registry.get_reward_model()
if not model:
return {"reward_model": None, "reward_enabled": settings.reward_model_enabled}
return {
"reward_model": {
"name": model.name,
"format": model.format.value,
"path": model.path,
},
"reward_enabled": settings.reward_model_enabled,
}
@api_router.get("/roles/teacher")
async def get_teacher_model() -> dict[str, Any]:
"""Get the active teacher model for distillation."""
model = model_registry.get_teacher_model()
if not model:
return {"teacher_model": None}
return {
"teacher_model": {
"name": model.name,
"format": model.format.value,
"path": model.path,
},
}
# ── Dashboard page ────────────────────────────────────────────────────────────
@router.get("", response_class=HTMLResponse)
async def models_page(request: Request):
"""Custom models management dashboard page."""
models = model_registry.list_models()
assignments = model_registry.get_agent_assignments()
reward = model_registry.get_reward_model()
return templates.TemplateResponse(
request,
"models.html",
{
"page_title": "Custom Models",
"models": models,
"assignments": assignments,
"reward_model": reward,
"weights_dir": settings.custom_weights_dir,
"reward_enabled": settings.reward_model_enabled,
},
)