MindRouter Documentation

MindRouter is a production-ready LLM inference load balancer and translation layer that fronts a heterogeneous cluster of Ollama and vLLM inference backends. It provides a unified OpenAI-compatible API surface with native Ollama compatibility, fair-share scheduling, per-user quotas, full audit logging, and real-time GPU telemetry.

Developed by Luke Sheneman, Research Computing and Data Services (RCDS), Institute for Interdisciplinary Data Sciences (IIDS), University of Idaho.

Mirrored from the MindRouter documentation page in the MindRouter repository. Example hostnames are placeholders — substitute your own deployment.


Overview

MindRouter sits between API consumers and GPU inference servers, providing:

  • Unified API Gateway — OpenAI-compatible /v1/* (including the agent-oriented Responses API at /v1/responses), Ollama-compatible /api/*, and Anthropic-compatible /anthropic/v1/* endpoints, all backed by the same pool of inference servers.
  • Cross-Engine Routing — A request arriving in OpenAI, Ollama, or Anthropic format can be served by any backend. The translation layer handles all protocol conversion transparently.
  • Fair-Share Scheduling — Weighted Deficit Round Robin (WDRR) ensures equitable GPU access across users in different groups with configurable priorities.
  • Multi-Modal Support — Text chat, text completion, embeddings, multimodal models, structured JSON outputs, tool calling (function calling), and voice (TTS/STT).
  • Per-User Quotas — Token budgets and requests-per-minute (RPM) rate limits, with defaults inherited from the user's group. RPM enforcement is shared across all workers via Redis.
  • Full Audit Logging — Every prompt, response, and token count is recorded for compliance and review.
  • Real-Time GPU Telemetry — Per-GPU utilization, memory, temperature, and power metrics via lightweight sidecar agents.
  • Web Dashboards — Public status page, user self-service dashboard, admin control panel, and built-in chat interface.

Who It's For

  • Research computing centers managing shared GPU clusters for multiple user groups
  • Universities providing LLM access to students, staff, and faculty with differentiated quotas
  • Organizations needing a unified API gateway across mixed Ollama/vLLM infrastructure

Architecture

MindRouter follows a layered architecture:

Client Request (OpenAI, Ollama, or Anthropic format)
        |
        v
+-----------------------------+
|     API Gateway Layer       |  ← /v1/*, /api/*, /anthropic/*, /api/admin/*
+-----------------------------+
|  Authentication & Quotas    |  ← API key verification, rate limiting
+-----------------------------+
|    Translation Layer        |  ← OpenAI/Ollama/Anthropic ↔ Canonical ↔ Ollama/vLLM
+-----------------------------+
|   Fair-Share Scheduler      |  ← WDRR with per-user deficit counters
+-----------------------------+
|    Backend Registry         |  ← Health monitoring, model tracking
+-----------------------------+
        |
        v
+---------------+-------------+
|  GPU Node 1   |  GPU Node 2 |  ...
|  +---------+  |  +--------+ |
|  | Sidecar |  |  |Sidecar | |  ← Per-node GPU metrics agent
|  +---------+  |  +--------+ |
|  | Ollama  |  |  |  vLLM  | |  ← Inference engines
|  +---------+  |  +--------+ |
+---------------+-------------+

MCP Client (Claude Code, Cursor, etc.)
        |
        v  SSE transport
+-----------------------------+
|    MCP Server Container     |  ← /mcp/sse (dedicated single-worker process)
+-----------------------------+
        |
        v
+-----------------------------+
| Search Provider (Brave API) |
+-----------------------------+

Key concepts:

  • A Node is a physical GPU server running a sidecar agent.
  • A Backend is an inference endpoint (Ollama or vLLM instance) running on a node. Multiple backends can share a node, each assigned specific GPUs via gpu_indices.
  • The MCP Server runs as a separate single-worker container, exposing MindRouter tools (e.g., web search) to MCP-compatible agents over SSE transport. The main app proxies /mcp/* requests to this container.

Getting Started

Prerequisites

  • Docker and Docker Compose
  • Python 3.11+ (for local development)

Quickstart with Docker Compose

# 1. Clone and configure
git clone <repository-url>
cd mindrouter
cp .env.example .env
nano .env  # Set DATABASE_URL, SECRET_KEY, etc.

# 2. Start all services
docker compose up --build

# 3. Run database migrations
docker compose exec app alembic upgrade head

# 4. Seed development data (creates users, quotas, API keys)
docker compose exec app python scripts/seed_dev_data.py

Development Credentials (first admin)

The seed script creates the default groups and one user — the initial administrator. No other accounts are seeded; other users are created on first SSO login or by an admin.

UsernamePasswordGroupScheduler Weight
adminadmin123 (override with ADMIN_PASSWORD)Admin10

The script also mints an API key for that user and prints it as a single parseable line (ADMIN_API_KEY=mr2_…); the full key cannot be shown again. Optional environment overrides:

VariableEffect
ADMIN_PASSWORDPassword for the seeded admin (default admin123)
ADMIN_API_KEYUse this exact key instead of minting a random one
MINT_ADMIN_KEY=1Mint an additional key for an already-existing admin
Development credentials — not for production. admin123 is a documented default and must never be left in place on a reachable deployment. Seed production once with ADMIN_PASSWORD set to a strong secret, then change the password from the dashboard. This first admin is what lets you reach Admin → Branding, SSO configuration, and node registration.

Accessing the Application

URLDescription
http://localhost:8000/Public status page
http://localhost:8000/dashboardUser dashboard (login required)
http://localhost:8000/adminAdmin dashboard (admin group required)
http://localhost:8000/chatChat interface (login required)
http://localhost:8000/docsInteractive API docs (Swagger UI)
http://localhost:8000/redocAPI reference (ReDoc)

API Reference

Interactive API Documentation

MindRouter includes built-in interactive API documentation powered by FastAPI:

  • Swagger UI at /docs — Interactive API explorer where you can try endpoints directly from your browser. Supports authentication via the "Authorize" button (enter your API key as a Bearer token).
  • ReDoc at /redoc — Clean, readable API reference with request/response schemas and examples.

Both are auto-generated from the application's route definitions and Pydantic models, so they always reflect the current API surface.

Authentication

All inference and admin endpoints require authentication. MindRouter supports two methods:

API Key (Bearer Token):

curl -H "Authorization: Bearer mr2_your-api-key" http://localhost:8000/v1/models

API Key (Header):

curl -H "X-API-Key: mr2_your-api-key" http://localhost:8000/v1/models

Session Cookie (dashboard/admin AJAX only): Browser-based dashboard calls authenticate via the mindrouter_session cookie set at login. This is used internally by the web UI and is not intended for programmatic access.

This section covers API authentication. Interactive sign-in to the web dashboard — local accounts plus optional Azure AD, SAML, OIDC, or Google single sign-on — is covered under Single Sign-On (SSO).

Error Responses

MindRouter registers no HTTPException handler — the only handler in main.py catches bare Exception — so on most endpoints FastAPI's default detail wrapper is applied to every error. The value of detail is either a plain string or a nested OpenAI-shaped error object:

// flat string detail (400 / 401 / 429 / 503)
{"detail": "Invalid JSON body"}
{"detail": "No healthy backends available for this model"}

// nested OpenAI-shaped object (404 unknown model)
{"detail": {"error": {"message": "The model 'foo' does not exist",
                      "type": "invalid_request_error",
                      "code": "model_not_found"}}}

Common HTTP status codes:

CodeMeaning
400Invalid request body or parameters
401Missing or invalid API key
403Insufficient permissions (e.g., non-admin accessing admin endpoint)
404Resource not found
409Conflict (duplicate name, URL, etc.)
413Request body exceeds the reverse proxy's client_max_body_size (50m in nginx/nginx.conf), or an uploaded OCR file exceeds ocr.max_file_size_mb. The application itself enforces no global body limit — see MAX_REQUEST_SIZE.
429Rate limit exceeded
500Internal server error

Error Response Formats by API Style

Error response formats differ depending on which API style the client is using:

  • OpenAI (/v1/*) — Wrapped in detail, as shown above. The detail value is either a plain string or a nested {"error": {...}} object.
  • Responses and Conversations (/v1/responses*, /v1/conversations*) — The only endpoints that emit the true bare OpenAI envelope: {"error": {"message": "...", "type": "...", "param": null, "code": "..."}}. These routes build their own JSONResponse and re-shape service HTTPExceptions into that form.
  • Ollama (/api/*) — Returns a plain detail string: {"detail": "..."}
  • Anthropic (/anthropic/v1/*) — Returns a typed error object: {"type": "error", "error": {"type": "...", "message": "..."}}

Parse errors by HTTP status code, not by body shape. Outside /v1/responses and /v1/conversations, detail may hold either a string or an {"error": {...}} object, so an OpenAI SDK that expects error.message at the top level will not find it and may surface the failure as an opaque unknown-format error.

Model Name Matching

Model names in requests are matched exactly against the model catalog. There is no prefix matching and no fuzzy matching — the model field must exactly match either a model name or a configured alias as listed by /v1/models or /api/tags.

Aliases are resolved on every inference endpoint (chat completions, completions, embeddings, rerank, score, tokenize, OCR, images, video, Ollama, Anthropic, and Responses). Alias entries are listed alongside real models in /v1/models and /api/tags, inheriting the target's metadata and adding "is_alias": true and "alias_target": "<real model name>". Aliases are administrator-defined (Admin > Models, stored in the model_aliases table), so the set published on any given deployment is purely a matter of local configuration. A common convention is to name them for an intent rather than a model version — for example default-llm, default-llm-large, default-agent, default-coder, default-embedding — so clients can pin a role and let the operator repoint it as models are upgraded.

OpenAI-Compatible Endpoints

These endpoints accept and return data in the OpenAI API format. Any OpenAI-compatible client or SDK can be pointed at MindRouter by changing the base URL.

MethodPathAuthDescription
POST/v1/chat/completionsAPI KeyChat completions (streaming and non-streaming)
POST/v1/responsesAPI KeyOpenAI Responses API — agent clients (Codex), typed items and SSE events
POST/v1/completionsAPI KeyText completions (legacy) — converted to a chat request internally, so the response is a chat.completion object, not text_completion
POST/v1/embeddingsAPI KeyGenerate embeddings
POST/v1/rerankAPI KeyRerank documents against a query
POST/v1/scoreAPI KeyScore similarity between text pairs
POST/v1/tokenizeAPI KeyCount input tokens for a request
POST/v1/ocrAPI KeyOCR images/PDFs/Office docs to markdown or JSON (multipart)
POST/v1/ocrmdAPI KeySame OCR pipeline as /v1/ocr, returns raw text/markdown
POST/v1/searchAPI KeyWeb search via the configured provider (also served at /api/search)
POST/v1/images/generationsAPI KeyImage generation (diffusion models; requires per-account enablement)
POST/v1/images/editsAPI KeyImage-to-image / reference edit (multipart)
POST/v1/videosAPI KeyVideo generation (async; text-to-video + keyframe conditioning)
POST/v1/videos/assetsAPI KeyUpload a keyframe image for image-guided video
GET/v1/videos/{id}API KeyPoll a video job; /content downloads the MP4
POST/v1/audio/speechAPI KeyText-to-speech
POST/v1/audio/transcriptionsAPI KeySpeech-to-text
GET/v1/modelsAPI KeyList available models

Chat Completions

curl -X POST http://localhost:8000/v1/chat/completions \
  -H "Authorization: Bearer mr2_your-api-key" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "llama3.2",
    "messages": [
      {"role": "system", "content": "You are a helpful assistant."},
      {"role": "user", "content": "Hello!"}
    ],
    "temperature": 0.7,
    "max_tokens": 500,
    "stream": false
  }'

Response:

{
  "id": "3f2a9c14-8d5e-4b71-9a03-1c6e5f88b2d7",
  "object": "chat.completion",
  "created": 1700000000,
  "model": "llama3.2",
  "choices": [
    {
      "index": 0,
      "message": {
        "role": "assistant",
        "content": "Hello! How can I help you today?"
      },
      "finish_reason": "stop"
    }
  ],
  "usage": {
    "prompt_tokens": 25,
    "completion_tokens": 10,
    "total_tokens": 35
  }
}

Streaming — Set "stream": true to receive Server-Sent Events (SSE):

curl -X POST http://localhost:8000/v1/chat/completions \
  -H "Authorization: Bearer mr2_your-api-key" \
  -H "Content-Type: application/json" \
  -d '{"model": "llama3.2", "messages": [{"role": "user", "content": "Hi"}], "stream": true}'

Thinking/Reasoning Mode:

MindRouter supports multiple formats for controlling thinking/reasoning on models that support it (qwen3.5, qwen3, gpt-oss):

// gpt-oss: control reasoning depth
{
  "model": "openai/gpt-oss-120b",
  "messages": [{"role": "user", "content": "Solve this step by step"}],
  "reasoning_effort": "high",
  "max_completion_tokens": 16384
}

// Qwen-style: toggle thinking on/off
{
  "model": "qwen/qwen3.5-400b",
  "messages": [{"role": "user", "content": "Explain quantum computing"}],
  "chat_template_kwargs": {"enable_thinking": true},
  "max_completion_tokens": 16384
}

When thinking is enabled, the response includes reasoning_content alongside content.

Reasoning is OFF by default — this is a gateway policy, not the model's own default. When a request omits every thinking control, the inference service sets think = false on the outbound backend call (chat_template_kwargs: {"enable_thinking": false} to vLLM, think: false to Ollama), so a thinking-capable model returns no reasoning_content unless you opt in. The policy is the THINKING_OFF_BY_DEFAULT setting (default true) and it applies on both the streaming and non-streaming paths. Models whose name contains gpt-oss are exempt — they use reasoning_effort and are left untouched.
Important: Thinking models can consume large numbers of output tokens for reasoning. Use max_completion_tokens (or max_tokens) to set an adequate budget — 16384 is recommended for qwen3.5-400b with thinking enabled.

Output Token Limits:

MindRouter accepts both max_completion_tokens (preferred, current OpenAI standard) and max_tokens (legacy). If both are provided, max_completion_tokens takes priority.

Structured Output (JSON Mode):

{
  "model": "llama3.2",
  "messages": [{"role": "user", "content": "List 3 colors as JSON"}],
  "response_format": {"type": "json_object"}
}

Structured Output (JSON Schema):

{
  "model": "llama3.2",
  "messages": [{"role": "user", "content": "List 3 colors"}],
  "response_format": {
    "type": "json_schema",
    "json_schema": {
      "name": "colors",
      "schema": {
        "type": "object",
        "properties": {
          "colors": {"type": "array", "items": {"type": "string"}}
        }
      }
    }
  }
}

Vision (Multimodal):

{
  "model": "llava",
  "messages": [
    {
      "role": "user",
      "content": [
        {"type": "text", "text": "What's in this image?"},
        {"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,..."}}
      ]
    }
  ]
}

Tool Calling (Function Calling):

{
  "model": "llama3.2",
  "messages": [{"role": "user", "content": "What's the weather in Seattle?"}],
  "tools": [{
    "type": "function",
    "function": {
      "name": "get_weather",
      "description": "Get the current weather",
      "parameters": {
        "type": "object",
        "properties": {"city": {"type": "string"}},
        "required": ["city"]
      }
    }
  }],
  "tool_choice": "auto"
}

When the model decides to call a tool, the response includes tool_calls with finish_reason: "tool_calls". Submit the tool result back as a role: "tool" message with the matching tool_call_id.

Embeddings

curl -X POST http://localhost:8000/v1/embeddings \
  -H "Authorization: Bearer mr2_your-api-key" \
  -H "Content-Type: application/json" \
  -d '{"model": "nomic-embed-text", "input": "Hello world"}'

Reranking

curl -X POST http://localhost:8000/v1/rerank \
  -H "Authorization: Bearer mr2_your-api-key" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "Qwen/Qwen3-Reranker-8B",
    "query": "What is machine learning?",
    "documents": [
      "Machine learning is a subset of AI.",
      "The weather is sunny today.",
      "Deep learning uses neural networks."
    ],
    "top_n": 2
  }'

Scoring

curl -X POST http://localhost:8000/v1/score \
  -H "Authorization: Bearer mr2_your-api-key" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "Qwen/Qwen3-Reranker-8B",
    "text_1": "What is machine learning?",
    "text_2": ["Machine learning is a subset of AI.", "The weather is sunny today."]
  }'

Tokenize

Count the number of input tokens a request will consume. For vLLM backends, this returns an exact count from the backend's native tokenizer, including all chat template overhead and tool definitions. For Ollama backends, the count is a tiktoken-based estimate.

curl -X POST http://localhost:8000/v1/tokenize \
  -H "Authorization: Bearer mr2_your-api-key" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "openai/gpt-oss-120b",
    "messages": [
      {"role": "system", "content": "You are a helpful assistant."},
      {"role": "user", "content": "Hello, how are you?"}
    ],
    "tools": [...]
  }'

Response:

{
  "count": 847,
  "max_model_len": 131072,
  "is_estimate": false
}

Fields:

  • count — number of input tokens (includes system prompt, messages, chat template overhead, and tool definitions)
  • max_model_len — model's context window size
  • is_estimatefalse for vLLM (exact count), true for Ollama (tiktoken estimate)

Context Window Management

MindRouter automatically caps max_tokens before dispatching each request to prevent context overflow errors. The logic is:

input_tokens  = tokenize(request)            # exact for vLLM, estimated for Ollama
remaining     = context_length - input_tokens - 1024   # 1024-token safety buffer
max_tokens    = min(requested_max_tokens, remaining, 65536)
  • vLLM backends: MindRouter calls the backend's /tokenize endpoint to get the exact input token count, including chat template inflation (e.g. Hermes tool-calling templates can add thousands of tokens). If the /tokenize call fails, it falls back to tiktoken estimation.
  • Ollama backends: Uses tiktoken (cl100k_base) for estimation. The 1024-token buffer absorbs the estimation error and chat template overhead. Ollama also auto-adjusts context internally, so overflow errors are rare.
  • 65,536 hard cap: No single response can exceed 65,536 output tokens, regardless of context window size.
  • Context overflow: If input tokens alone exceed the context window (minus buffer), MindRouter allows a minimal 256-token output. The backend may still reject the request if the input is genuinely too large for the model.

List Models

curl http://localhost:8000/v1/models \
  -H "Authorization: Bearer mr2_your-api-key"

Response:

{
  "object": "list",
  "data": [
    {
      "id": "llama3.3:70b",
      "object": "model",
      "created": 1700000000,
      "owned_by": "mindrouter",
      "capabilities": {
        "multimodal": false,
        "embeddings": false,
        "structured_output": true,
        "thinking": false,
        "tools": false
      },
      "backends": ["node1-gpu0", "node1-gpu2"],
      "context_length": 32768,
      "model_max_context": 131072,
      "parameter_count": "70B",
      "quantization": "Q4_K_M",
      "family": "llama"
    }
  ]
}

Fields include:

  • context_length — effective context window (num_ctx injected per request)
  • model_max_context — architectural maximum context the model supports
  • parameter_count — model size (e.g. "7B", "70B")
  • quantization — quantization level (e.g. "Q4_K_M", "FP16")
  • family — model family (e.g. "llama", "qwen2")
  • capabilities.thinking — whether the model supports thinking/reasoning mode
  • capabilities.tools — whether the model supports tool/function calling

Configured aliases are appended to the same data list. An alias entry inherits its target's metadata and adds "is_alias": true and "alias_target".

Image Generation

POST /v1/images/generations generates images with the cluster's diffusion backends (FLUX family), using the OpenAI Images API request/response shape plus diffusion-specific extensions. A browser-based playground with the same capabilities is available at /images in the dashboard (generate, save, history).

Access requirements — two switches must be on before any request succeeds:

  • Per-account enablement — image generation is opt-in per user. Without it you receive 403 Forbidden ("Image generation is not enabled for your account"). Contact an administrator to enable it.
  • Global switch — administrators can disable image generation cluster-wide, in which case all requests receive 503.

Request parameters:

FieldTypeDefaultDescription
promptstringrequiredText description of the desired image
modelstringserver defaultDiffusion model name; omit to use the administrator-configured default. Image models appear in GET /v1/models.
nint1Number of images (capped server-side, default cap 4)
sizestring"1024x1024"Must be on the administrator-configured allow-list (e.g. 512x512, 768x768, 1024x1024, 1024x768, 768x1024); disallowed sizes return 400 listing the permitted values
response_formatstring"url""b64_json" or "url". Always use b64_json from API clients — see Retrieving the image below.
num_inference_stepsint20Diffusion steps (quality/latency tradeoff); values above the server cap (default 50) are silently clamped
guidance_scalefloat3.5Prompt-adherence strength (FLUX-specific)
seedintrandomFix for reproducible generations
quality, stylestringOpenAI-compatible fields, passed through to the backend

Example:

curl -X POST http://localhost:8000/v1/images/generations \
  -H "Authorization: Bearer mr2_your-api-key" \
  -H "Content-Type: application/json" \
  -d '{
    "prompt": "A golden retriever wearing a tiny graduation cap, studio portrait",
    "size": "1024x1024",
    "response_format": "b64_json",
    "num_inference_steps": 28,
    "seed": 42
  }'

Response (OpenAI Images shape):

{
  "created": 1752444000,
  "data": [
    {"b64_json": "<base64-encoded PNG>"}
  ]
}
SDK Usage (Python)
import base64
from openai import OpenAI

client = OpenAI(base_url="http://localhost:8000/v1", api_key="mr2_your-api-key")
result = client.images.generate(
    prompt="A watercolor map of Idaho",
    size="1024x1024",
    response_format="b64_json",
    extra_body={"num_inference_steps": 28, "seed": 42},
)
with open("idaho.png", "wb") as f:
    f.write(base64.b64decode(result.data[0].b64_json))

(num_inference_steps, guidance_scale, and seed are MindRouter extensions, so the OpenAI SDK requires them via extra_body.)

Retrieving the Image

The generation response is the only delivery of the image — MindRouter does not retain images generated through the API, and there is no follow-up download endpoint. Decode the b64_json payload and save it client-side (as in the SDK example above).

Do not use response_format: "url" from API clients. The url value is passed through verbatim from the diffusion backend and is a backend-relative path (e.g. /images/<id>.png) that refers to a file on the backend node itself — it is not served by MindRouter and will 404 if resolved against the gateway. It exists for deployments where clients can reach the diffusion backend directly.

If you want server-side persistence with browsable history, use the /images dashboard playground instead — its save/history features store images on the server, but they apply only to images generated through that page, not through the API.

Content Policy

When an administrator has configured an image content policy, every prompt is screened by an LLM judge before generation. Denied prompts return 400 with the OpenAI error envelope:

{
  "error": {
    "message": "Your image request was denied by content policy: <reason>",
    "type": "content_policy_violation",
    "code": "policy_violation"
  }
}

Denied requests are recorded in the audit log with the judge's verdict. The /images playground offers a pre-flight policy check so you can test a prompt without spending a generation.

Server-Side Guardrails

Administrators configure defaults and limits that are applied to every request (values above a cap are clamped, not rejected, except for size): default model, default/maximum inference steps, default guidance scale, maximum image count per request, allowed sizes, and maximum dimensions.

Error summary: 400 missing prompt / disallowed size / dimensions over limit / policy denial · 403 image generation not enabled for your account · 404 unknown model · 429 rate limit · 503 image generation disabled or no healthy diffusion backend.

Notes: generation is slow relative to chat (tens of seconds for high step counts — the gateway allows up to 5 minutes per attempt); request IDs use the img-* prefix; standard RPM rate limits apply.

Image-to-Image (Reference Edits)

POST /v1/images/edits edits a reference image — it preserves the image's composition and applies your prompt (FLUX.2 Klein reference-edit). Send multipart/form-data, not JSON.

FieldTypeDefaultDescription
imagefile (1–4)requiredReference image file(s), PNG/JPEG/WebP. Up to 4; multiple act as combined references.
promptstringrequiredThe change to apply. Deictic references ("this man") are not auto-failed as ambiguous for edits, but the full content policy still applies to the described change and its result.
num_inference_stepsint8Edits default to 8 steps (vs 20 for generations) — cleaner reference edits
strengthfloatAccepted for forward-compat but ignored by FLUX.2 Klein (a distilled model)
size, n, response_format, guidance_scale, seed, modelAs in generations
curl -X POST http://localhost:8000/v1/images/edits \
  -H "Authorization: Bearer mr2_your-api-key" \
  -F image=@reference.png \
  -F prompt="the car is now bright red" \
  -F size="1024x1024" \
  -F response_format=b64_json

The response matches generations. Full reference: docs/images-api.md.

Video Generation

POST /v1/videos renders a short text-to-video clip, optionally guided by start/end keyframe images. It is asynchronous: you get a job immediately (202), poll it, then download the MP4. A dashboard playground is at /video. Requires per-account enablement (403 otherwise) and a global switch (503 when off).

MethodPathDescription
POST/v1/videosCreate a job → 202 + job object
POST/v1/videos/assetsUpload a keyframe image (multipart image) → asset id for conditioning
GET/v1/videos/{id}Poll job status / progress
GET/v1/videos/{id}/contentDownload the MP4 (Range/206) once completed
GET/v1/videosList your jobs (newest first)
DELETE/v1/videos/{id}Cancel a running job, or delete a finished one (frees storage)
GET/v1/videos/modelsCapability discovery (sizes, max length, fps)

Create parameters:

FieldTypeDefaultDescription
promptstringrequiredWhat to render
sizestringserver defaultFrom GET /v1/videos/models (e.g. 1280x704)
secondsintserver defaultWhole seconds, 4–30 (max clip length 30s)
qualitystringstandarddraft | standard | final
start_image_asset_id, end_image_asset_idintKeyframe asset ids from POST /v1/videos/assets (image-guided video)
seedintrandomReproducibility
fpsint24Accepted but not validated — only 24 is actually rendered

Image-guided example (upload keyframe → create → poll → download):

# 1. upload a keyframe, capture its asset id
ASSET=$(curl -s -X POST http://localhost:8000/v1/videos/assets \
  -H "Authorization: Bearer mr2_your-api-key" -F image=@opening.png | jq -r .id)

# 2. create a clip conditioned on that keyframe
VID=$(curl -s -X POST http://localhost:8000/v1/videos \
  -H "Authorization: Bearer mr2_your-api-key" -H "Content-Type: application/json" \
  -d "{\"prompt\":\"camera slowly pushes in\",\"size\":\"1280x704\",\"seconds\":5,\"start_image_asset_id\":$ASSET}" | jq -r .id)

# 3. poll until completed, then download the MP4
curl -s http://localhost:8000/v1/videos/$VID -H "Authorization: Bearer mr2_your-api-key" | jq .status
curl -L http://localhost:8000/v1/videos/$VID/content -H "Authorization: Bearer mr2_your-api-key" -o clip.mp4

Job object: { id, object: "video", status, progress, content_url, usage, … }. Statuses: queued → in_progress → completed (or failed / cancelled). content_url is null until completed.

Limits & behavior: one clip renders at a time (serialized queue); default one in-flight job per user (else 429); 50 GB per-user storage cap (507 when exceeded — DELETE finished clips to reclaim); 30s max length; fps 24. MindRouter renders single clips only — multi-shot assembly, stitching, and cross-shot coherence are the consuming app's responsibility.

Errors: 401 missing/invalid key · 400 bad size/duration/quality · 403 not enabled · 404 unknown model or job · 429 concurrency/quota · 507 storage cap · 503 disabled.

Full reference: docs/video-api.md. End-to-end studio/storyboard recipe (images → keyframes → clips → stitch → narration): docs/media-studio-integration.md.

Ollama-Compatible Endpoints

These endpoints accept and return data in Ollama's native format. Ollama clients can be pointed at MindRouter as a drop-in replacement.

MethodPathAuthDescription
POST/api/chatAPI KeyOllama chat (streaming by default)
POST/api/generateAPI KeyOllama text generation
POST/api/embeddingsAPI KeyOllama embeddings
GET/api/tagsAPI KeyList models (Ollama format)

List Models (Ollama Format)

curl http://localhost:8000/api/tags \
  -H "Authorization: Bearer mr2_your-api-key"

Response:

{
  "models": [
    {
      "name": "llama3.3:70b",
      "model": "llama3.3:70b",
      "modified_at": "2026-02-28T12:00:00",
      "details": {
        "parent_model": "",
        "format": "gguf",
        "family": "llama",
        "parameter_size": "70B",
        "quantization_level": "Q4_K_M"
      },
      "context_length": 32768,
      "model_max_context": 131072
    }
  ]
}

Ollama Chat

curl -X POST http://localhost:8000/api/chat \
  -H "Authorization: Bearer mr2_your-api-key" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "llama3.2",
    "messages": [{"role": "user", "content": "Hello!"}],
    "stream": false
  }'

Note: Ollama defaults to stream: true. Set "stream": false explicitly for non-streaming responses.

Thinking/Reasoning Mode (Ollama):

Use the think field at the top level:

// Qwen-style: boolean toggle
{"model": "qwen3-32k:32b", "messages": [...], "think": true, "stream": false}

// gpt-oss: string effort level
{"model": "gpt-oss-32k:120b", "messages": [...], "think": "high", "stream": false}

The response includes a thinking field in the message. For /api/generate, thinking content appears as a top-level thinking field alongside response.

Ollama Generate

curl -X POST http://localhost:8000/api/generate \
  -H "Authorization: Bearer mr2_your-api-key" \
  -H "Content-Type: application/json" \
  -d '{"model": "llama3.2", "prompt": "Why is the sky blue?"}'

Anthropic-Compatible Endpoint

This endpoint accepts and returns data in the Anthropic Messages API format. Anthropic SDK clients (Python, TypeScript) can be pointed at MindRouter by setting base_url.

MethodPathAuthDescription
POST/anthropic/v1/messagesAPI KeyAnthropic Messages API (streaming and non-streaming)

Messages

curl -X POST http://localhost:8000/anthropic/v1/messages \
  -H "Authorization: Bearer mr2_your-api-key" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "llama3.2",
    "max_tokens": 500,
    "messages": [{"role": "user", "content": "Hello!"}]
  }'

Response:

{
  "id": "msg_abc123...",
  "type": "message",
  "role": "assistant",
  "model": "llama3.2",
  "content": [
    {"type": "text", "text": "Hello! How can I help you today?"}
  ],
  "stop_reason": "end_turn",
  "stop_sequence": null,
  "usage": {
    "input_tokens": 10,
    "output_tokens": 12
  }
}

Streaming — Set "stream": true to receive Anthropic SSE events (message_start, content_block_delta, message_stop, etc.):

curl -X POST http://localhost:8000/anthropic/v1/messages \
  -H "Authorization: Bearer mr2_your-api-key" \
  -H "Content-Type: application/json" \
  -d '{"model": "llama3.2", "max_tokens": 500, "messages": [{"role": "user", "content": "Hi"}], "stream": true}'

System Prompt:

{
  "model": "llama3.2",
  "max_tokens": 500,
  "system": "You are a helpful assistant.",
  "messages": [{"role": "user", "content": "Hello!"}]
}

SDK Usage (Python)

import anthropic
client = anthropic.Anthropic(
    base_url="http://localhost:8000/anthropic",
    api_key="mr2_your-api-key",
)
message = client.messages.create(
    model="llama3.2",
    max_tokens=500,
    messages=[{"role": "user", "content": "Hello!"}],
)

Supported features:

  • System prompts (string or content block array)
  • Multimodal inputs (base64 and URL images)
  • Tool calling — tools with input_schema, tool_choice (auto/any/tool), tool_use/tool_result content blocks, streaming tool use with input_json_delta
  • Thinking/reasoning mode (thinking.type: enabled, adaptive, disabled)
  • Structured output via output_config.format with type: "json_schema"
  • Parameters: max_tokens, temperature, top_p, top_k, stop_sequences, stream
  • metadata.user_id mapping

Note: This is inbound-only — there are no Anthropic backends. Requests are translated to canonical format and routed to Ollama/vLLM backends like any other request.

OpenAI Responses API Endpoint

The OpenAI Responses API is the newer, agent-oriented OpenAI wire format: typed input items instead of a messages array, flat function-tool definitions, and named SSE events. Agent clients built on it — notably Codex in the ChatGPT desktop app and CLI — can use MindRouter as a custom provider.

MethodPathAuthDescription
POST/v1/responsesAPI KeyResponses API (streaming and non-streaming)
POST/v1/responses/input_tokensAPI KeyCount input tokens without generating
GET/v1/responses/{id}API KeyRetrieve a stored response
DELETE/v1/responses/{id}API KeyDelete a stored response
GET/v1/responses/{id}/input_itemsAPI KeyList a stored response's input items (paginated)
POST/v1/responses/{id}/cancelAPI KeyCancel a background response — always 400, since background mode is unsupported (404 if the id is unknown)
POST / GET / DELETE/v1/conversations, /v1/conversations/{id}API KeyConversations API: durable conversation objects (conv_*)
POST / GET / DELETE/v1/conversations/{id}/items[/{item_id}]API KeyConversation item CRUD (create up to 20 at a time; paginated list)

Basic Request

curl -X POST http://localhost:8000/v1/responses \
  -H "Authorization: Bearer mr2_your-api-key" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "llama3.2",
    "instructions": "You are a helpful assistant.",
    "input": "Why is the sky blue?"
  }'

Response:

{
  "id": "resp_abc123...",
  "object": "response",
  "status": "completed",
  "model": "llama3.2",
  "output": [
    {
      "id": "msg_...",
      "type": "message",
      "status": "completed",
      "role": "assistant",
      "content": [{"type": "output_text", "text": "...", "annotations": [], "logprobs": []}]
    }
  ],
  "usage": {"input_tokens": 18, "output_tokens": 64, "total_tokens": 82, ...}
}

Streaming — set "stream": true to receive typed SSE events (response.created, response.output_item.added, response.output_text.delta, ..., terminating with a single response.completed event — there is no data: [DONE] sentinel in this protocol). Reasoning models additionally emit reasoning output items with response.reasoning_text.delta events.

Codex (ChatGPT Desktop App / CLI) Setup

Add MindRouter as a custom provider in ~/.codex/config.toml:

[model_providers.mindrouter]
name = "MindRouter"
base_url = "https://mindrouter.example.com/v1"
env_key = "MINDROUTER_API_KEY"
wire_api = "responses"
requires_openai_auth = false

[profiles.mindrouter]
model_provider = "mindrouter"
model = "qwen/qwen3.5-122b"

Then export MINDROUTER_API_KEY=mr2_your-api-key and select the profile. Codex's hosted web_search tool is executed by MindRouter's own search engine (see Hosted Web Search below) — searches Codex performs count against your search quota.

SDK Usage (Python)

from openai import OpenAI
client = OpenAI(base_url="http://localhost:8000/v1", api_key="mr2_your-api-key")
response = client.responses.create(
    model="llama3.2",
    input="Hello!",
)
print(response.output_text)

Function Calling (Tool Round Trip)

Important: Responses function tools are flatname/description/parameters sit directly on the tool object, with no nested function wrapper as in chat completions. Correlate results via call_id:

# Turn 1 — model decides to call the tool
response = client.responses.create(
    model="llama3.2",
    input="What is the weather in Moscow, Idaho?",
    tools=[{
        "type": "function",
        "name": "get_weather",
        "description": "Get current weather for a city.",
        "parameters": {"type": "object",
                       "properties": {"city": {"type": "string"}},
                       "required": ["city"]},
    }],
)
call = next(i for i in response.output if i.type == "function_call")
# call.name == "get_weather", call.arguments == '{"city": "Moscow, Idaho"}'

# Turn 2 — run the tool yourself, send the result back
response2 = client.responses.create(
    model="llama3.2",
    previous_response_id=response.id,          # or resend the transcript
    input=[{"type": "function_call_output",
            "call_id": call.call_id,
            "output": "sunny, 25C"}],
    tools=[...],  # tools are NOT inherited across turns — resend them
)
print(response2.output_text)

Multi-Turn Conversations (previous_response_id)

With store left at its default (true), chain turns server-side — each follow-up sends only the new input:

r1 = client.responses.create(model="llama3.2", input="My name is Ada.")
r2 = client.responses.create(model="llama3.2",
                             input="What is my name?",
                             previous_response_id=r1.id)
# r2.output_text -> "Ada"
client.responses.delete(r1.id)   # stored responses can be deleted anytime

Alternatively, set "store": false and resend the full item transcript each turn (Codex's approach).

Conversations API (conversation objects)

The modern state pattern: create a durable conv_* object once, then pass it to every response. Stored items are prepended to your input, and each response's input + output items are appended back automatically after it completes (failed responses append nothing):

conv = client.conversations.create(metadata={"project": "thesis"})
r1 = client.responses.create(model="llama3.2", input="My name is Ada.",
                             conversation=conv.id)
r2 = client.responses.create(model="llama3.2", input="What is my name?",
                             conversation=conv.id)
# r2.output_text -> "Ada"

items = client.conversations.items.list(conv.id)   # paginated item log
client.conversations.delete(conv.id)               # delete state anytime

Conversations support metadata (updatable), seeding up to 20 items at create time, item-level retrieval/deletion, and item_reference input items resolved against the conversation. conversation and previous_response_id are mutually exclusive. Unlike stored responses (30-day default retention), conversations are kept indefinitely by default (admin-tunable, swept by last activity); per-user conversation and per-conversation item caps apply.

Supported features:

  • input as a string or item array (message, function_call, function_call_output, reasoning items); instructions; developer role
  • Function tools (flat Responses shape) with streaming response.function_call_arguments.delta events; tool round trips via call_id
  • Hosted web_search tool — executed server-side (see below)
  • Reasoning models — reasoning.effort mapped to backend thinking modes; reasoning output items
  • Structured output via text.format (json_object / json_schema)
  • Vision input via input_image parts (data URIs and https URLs)
  • truncation: "auto" — on context overflow, the oldest conversation turns are dropped server-side (tool-call pairs kept intact; system messages and the newest turn always preserved) instead of failing with a context-length error
  • Parameters: max_output_tokens, max_tool_calls, temperature, top_p, stream
Hosted Web Search

Requests that include {"type": "web_search"} in tools get OpenAI-style server-side search execution: when the model decides to search, MindRouter runs the query through the same provider stack as /v1/search, feeds the results back to the model, and repeats until the model answers (or the call budget is spent). Each executed search appears in output as a web_search_call item:

{"id": "ws_…", "type": "web_search_call", "status": "completed",
 "action": {"type": "search", "query": "…"}}

Streaming emits response.web_search_call.in_progress / .searching / .completed events around each search. Notes:

  • The call budget is max_tool_calls (default 4, server-capped); when spent, the model must answer from what it has.
  • Searches consume your web-search quota (same accounting as /v1/search).
  • Search failures are reported to the model as tool output, not surfaced as request errors.
  • url_citation annotations are not generated — source URLs appear in the answer text if the model includes them.
  • Defining your own function tool named web_search disables hosted execution (your tool wins).

Server-side conversation state: store defaults to true (OpenAI parity). Stored responses can be retrieved (GET), deleted, and chained via previous_response_id — MindRouter rebuilds the conversation from the stored chain, so follow-up requests only need the new input. Stored state is subject to a retention window (default 30 days, admin-tunable), a per-response payload cap, and a per-user row cap (oldest evicted). Per the OpenAI spec, instructions and tools are not inherited across a chain — resend them each turn. Codex does not use server-side state; it resends the full transcript with store: false.

Limitations:

  • Hosted tools other than web_search (file_search, code_interpreter, MCP) and custom freeform tools are accepted but stripped — they never reach the model.
  • background mode, file inputs (input_file, file_id), and stream replay of stored responses (GET ?stream=true) are not supported.

Errors use the OpenAI envelope: {"error": {"message", "type", "param", "code"}}.

The whole surface is gated by the RESPONSES_API_ENABLED setting (default true). When it is false, every route in both the Responses and Conversations routers returns 404 with "The Responses API is not enabled on this server." / "The Conversations API is not enabled on this server."

Health & Metrics Endpoints

These endpoints are unauthenticated and intended for monitoring infrastructure.

MethodPathAuthDescription
GET/healthzNoneLiveness probe (always 200 if app is running)
GET/readyzNoneReadiness probe (checks DB + healthy backends)
GET/metricsNonePrometheus metrics (text format)
GET/statusNoneCluster status summary (JSON)
GET/api/cluster/total-tokensNoneTotal tokens ever served (cached 10s)
GET/api/cluster/trendsNoneToken and active-user trends (?range=hour|day|week|month|year)
GET/api/cluster/throughputNoneToken throughput, requests/min, active requests

Prometheus Metrics

The /metrics endpoint exposes the following Prometheus metrics:

MetricTypeLabelsDescription
mindrouter_queue_sizeGaugeCurrent scheduler queue depth. Set on every scrape from the scheduler's stats.
mindrouter_active_backendsGaugeNumber of healthy backends. Set on every scrape from the registry.
mindrouter_requests_totalCounterendpoint, statusDeclared but never incremented. Labeled and never instantiated, so it exports no time series at all (not a zero).
mindrouter_request_latency_secondsHistogramendpointDeclared but never observed. Labeled and never instantiated, so it exports no time series at all (not empty buckets).
mindrouter_tokens_totalCountertype (prompt/completion)Declared but never incremented. Labeled and never instantiated, so it exports no time series at all (not a zero).
Only two of the five metrics carry data. mindrouter_queue_size and mindrouter_active_backends are refreshed inside the /metrics handler itself (api/health.py). The other three collectors are declared in the same module, but nothing anywhere in the backend calls .inc(), .observe(), or .labels() on them, so they are exported with no samples. Do not build alerts or dashboards on request counts, latency, or token totals from /metrics — use the telemetry API (/api/admin/telemetry/*) or the audit log for those.

Admin API Endpoints

Admin endpoints are mounted under /api/admin/. Read-only (GET) endpoints require membership in a group with is_admin = true or is_auditor = true. Mutating (POST/PATCH/DELETE) endpoints require is_admin = true. See Users, Groups & Quotas for details on the auditor role.

Two groups of routes deviate from that rule: every /api/admin/telemetry/* route requires full is_admin (auditors are refused), and the conversations export is authenticated by dashboard session cookie only. Some endpoints additionally accept the dashboard session cookie in place of an API key so admin pages can call them by AJAX — backend refresh, the Ollama pull/delete/pull-status routes, /queue/monitor, /top-users, and all telemetry routes.

Backend Management

MethodPathDescription
POST/api/admin/backends/registerRegister a new inference backend
GET/api/admin/backendsList all backends
PATCH/api/admin/backends/{id}Update backend properties
POST/api/admin/backends/{id}/disableDisable a backend
POST/api/admin/backends/{id}/enableEnable a disabled backend
POST/admin/backends/{id}/drainInitiate graceful drain (stops new requests, waits for in-flight to complete, then disables). Dashboard route only, not available via API.
POST/api/admin/backends/{id}/refreshForce-refresh capabilities and models
POST/api/admin/backends/{id}/ollama/pullStart pulling a model to an Ollama backend
GET/api/admin/backends/{id}/ollama/pull/{job_id}Poll progress of an Ollama model pull
POST/api/admin/backends/{id}/ollama/deleteDelete a model from an Ollama backend

Node Management

MethodPathDescription
POST/api/admin/nodes/registerRegister a new GPU node
GET/api/admin/nodesList all nodes
PATCH/api/admin/nodes/{id}Update node properties
DELETE/api/admin/nodes/{id}Delete a node (fails if backends reference it)
POST/api/admin/nodes/{id}/refreshForce-refresh sidecar data

User Management

MethodPathDescription
GET/api/admin/usersList users (filterable by group, searchable by name/email)
POST/api/admin/usersCreate a new user with group-based quota defaults
GET/api/admin/users/{id}User detail with usage stats, API keys, monthly usage
PATCH/api/admin/users/{id}Update user profile, group, and quota overrides
DELETE/api/admin/users/{id}Hard-delete a user and all associated data
POST/api/admin/users/{id}/api-keysCreate an API key for a user

Group Management

MethodPathDescription
GET/api/admin/groupsList all groups with user counts
POST/api/admin/groupsCreate a new group with quota defaults
PATCH/api/admin/groups/{id}Update group defaults (token budget, RPM, weight, etc.)
DELETE/api/admin/groups/{id}Delete a group (fails if users are assigned)

API Key Management

MethodPathDescription
GET/api/admin/api-keysList all API keys across users (searchable, filterable by status)

Quota Management

MethodPathDescription
GET/api/admin/quota-requestsList pending quota increase requests
POST/api/admin/quota-requests/{id}/reviewApprove or deny a quota request

Conversations

MethodPathDescription
GET/admin/conversations/exportExport conversations as CSV or JSON (form-based, filterable). Dashboard route: authenticated by the mindrouter_session cookie, admin or auditor.
GET/api/admin/conversations/exportSame handler, forced to format=json with content included. Despite the /api/ prefix it is also session-cookie only — an API key is not accepted, and a request without the cookie is redirected (302) to /login.

Queue & Audit

MethodPathDescription
GET/api/admin/queueScheduler queue statistics
GET/api/admin/audit/searchSearch audit logs (filter by user, model, status, date, text)
GET/api/admin/audit/{id}Full audit detail including prompt and response content

Telemetry

MethodPathDescription
GET/api/admin/telemetry/overviewCluster-wide telemetry (nodes, backends, GPUs)
GET/api/admin/telemetry/latestLightweight polling endpoint for dashboard
GET/api/admin/telemetry/backends/{id}/historyTime-series telemetry for a backend
GET/api/admin/telemetry/gpus/{id}/historyTime-series telemetry for a GPU device
GET/api/admin/telemetry/nodes/{id}/historyAggregated time-series for a node (all GPUs)
GET/api/admin/telemetry/exportExport raw telemetry as JSON or CSV

Web Dashboard

MindRouter includes a full web dashboard built with Bootstrap 5. All pages extend a common base template with navigation and accessibility features (WCAG 2.1 Level AA).

Public Pages

PageURLDescription
Cluster Status/Shows healthy backend count, available models, queue size, and overall cluster status
Login/loginUsername/password authentication, plus one button per enabled SSO provider (Azure AD, SAML, OIDC, Google) when configured
Blog/blogPublic blog listing with published posts
Blog Post/blog/{slug}Individual blog post rendered from Markdown

User Dashboard

PageURLDescription
Dashboard/dashboardToken usage progress bar, active API keys, quota usage history, change password
Change PasswordPOST /dashboard/change-passwordChange the password of an account that has a local password. Requires current password, new password (min 8 chars), and confirmation. Accounts provisioned through SSO have no local password and cannot use the local login form — this endpoint returns early for them. There is currently no admin UI or API for adding a local password to an existing SSO account; that would require creating a separate local account or direct database access.
Request Quota/dashboard/request-quotaSubmit a quota increase request with justification
Key Created(after creation)Displays the full API key once (copy-to-clipboard)

The user dashboard includes:

  • Dark mode toggle — saved to browser localStorage, persists across sessions
  • Live token usage — usage statistics poll every 1 second for real-time feedback without page refresh
  • Lifetime vs rolling usage — displays both Lifetime Token Usage (all-time total, never resets) and Current Period Usage (resets when the budget period rolls over)
  • Quota details — current RPM limit shown in the Quota Details card
  • API key expiration warnings — keys within 7 days of expiration show a yellow countdown; expired keys display an “Expired” badge; “Last Used” column shows last authentication time

Admin Dashboard

The admin dashboard has a persistent sidebar with links to all admin pages. Access requires membership in a group with is_admin = true or is_auditor = true. Auditors see all admin pages in read-only mode — mutating controls (create, edit, delete, toggle) are hidden.

PageURLDescription
Overview/adminSystem metrics overview, health alert banner for unhealthy backends/offline nodes, pending request badges, system force offline/online toggle
Backends/admin/backendsBackend health, models, enable/disable/drain controls
Nodes/admin/nodesGPU node management, sidecar status, hardware specs, take offline/bring online/force drain controls
Models/admin/modelsModel management: multimodal and thinking overrides, full metadata overrides (family, parameters, quantization, context length, etc.), Ollama pull/delete
GPU Metrics/admin/metricsReal-time GPU utilization, memory, temperature, power charts with time range controls
Users/admin/usersUser accounts with search, group filter, and pagination
User Detail/admin/users/{id}Individual user profile, usage stats, API keys, monthly usage chart, quota overrides, masquerade (view dashboard as user)
Groups/admin/groupsGroup management: create, edit, delete groups with quota defaults and scheduler weights
API Keys/admin/api-keysAll API keys across users with search, status filter, and pagination
Requests/admin/requestsPending API key and quota increase requests, approve/deny
Audit Log/admin/auditInference request history with filtering, search, and CSV/JSON export
Admin Audit/admin/admin-auditPersistent log of all admin actions with before/after snapshots, filterable by action, entity type, and admin user
Conversations/admin/conversationsBrowse and export all user conversations
Chat Configuration/admin/chat-configConfigure chat UI defaults: core models, default model, system prompt, max tokens, temperature, thinking mode
Search/admin/search-configConfigure web search API: provider selection (Brave, SearXNG), API keys, quota cost, health checks, test search
Blog Management/admin/blogBlog CMS: create, edit, publish/unpublish, and delete blog posts
Branding/admin/brandingUI branding & theming: organization name, tagline, light/dark accent colors, navbar/footer/login logos, favicon, live preview (see Branding & Theming)
Site Settings/admin/settingsGlobal settings: display timezone, Ollama context length enforcement
Queue/admin/queueLive queue monitor — per-model capacity and per-user completion stats, polled from GET /api/admin/queue/monitor
Energy/admin/energyServer and GPU power history per node and cluster-wide, with CSV/JSON export
DLP/admin/dlpData-loss-prevention scanner configuration and alert review (filter by severity, scanner, text; acknowledge alerts)
Voice Config/admin/voice-configConfigure TTS/STT backend connections, available voices, and API quota token costs
OCR/admin/ocr-configOCR enable toggle, default model, prompts, DPI, page/file limits, chunk size and overlap, chunk concurrency, retries
Images/admin/images-configImage generation enable toggle, default model, default/allowed sizes, steps and guidance defaults, safety judge models and policy, per-user access grants
Video/admin/video-configVideo generation enable toggle, default model, allowed sizes and durations, per-user concurrent job cap and storage cap, token cost per second, per-user access grants
Email/admin/emailCompose and send announcement email to selected users or groups, with recipient count preview and test send
Data Retention/admin/retentionRetention policies for request, conversation, and telemetry data, plus archive statistics and an archive browser
Backup & Restore/admin/backupExport or restore MindRouter configuration (nodes, backends, users, groups, API keys, quotas, models, settings, blog posts)

System Force Offline/Online

Administrators can force the entire MindRouter system offline from the admin overview page (/admin). This is useful for planned maintenance windows or emergency situations.

  • Force Offline (POST /admin/system/toggle-online) — Stops the backend polling loop, marks all backends as unhealthy in the database, and sets an internal _force_offline flag. While offline, no health checks or discovery runs, and no inference requests can be served.
  • Force Online — Clears the offline flag, closes and reloads all backend adapters and sidecar clients from the database, restarts the polling loop, and immediately runs a full poll cycle to restore backend health status.

The toggle is available as a button on the admin overview page. The system status is visible to all users on the public status page.

Node Lifecycle Management

Beyond basic node registration and editing, administrators can manage node operational state from the Nodes page (/admin/nodes):

ActionRouteDescription
Take OfflinePOST /admin/nodes/{id}/take-offlineDisables all backends on the node and marks the node status as OFFLINE. New requests will not be routed to any backend on this node.
Bring OnlinePOST /admin/nodes/{id}/bring-onlineRe-enables all backends on the node and marks the node status as ONLINE. Backends will begin receiving requests again after the next health poll.
View Active RequestsGET /admin/nodes/{id}/active-requestsReturns a JSON count of in-flight requests across all backends on the node. Useful for monitoring drain progress before taking a node offline.
Force DrainPOST /admin/nodes/{id}/force-drainForce-cancels all active (in-flight) requests on the node's backends. Returns the number of cancelled requests. Use this when you need to take a node offline immediately without waiting for requests to complete naturally.

Recommended workflow for node maintenance:

  1. Take the node offline to stop new requests from being routed there.
  2. Monitor active requests using the active requests endpoint until the count reaches zero.
  3. If requests are stuck, use force drain to cancel them.
  4. Perform maintenance (upgrade vLLM, restart Ollama, etc.).
  5. Bring the node back online.

Admin Models Page

The Models page at /admin/models provides comprehensive model management. Models are grouped by name (since the same model may appear on multiple backends) and presented with their current metadata and override status.

Capability Overrides

MindRouter auto-detects model capabilities during discovery, but admins can override the detected values:

  • Multimodal override — Toggle or reset the supports_multimodal flag for all instances of a model. Use this when auto-detection incorrectly classifies a model (e.g., a vision model whose name does not match the usual patterns).
  • Thinking override — Toggle or reset the supports_thinking flag for all instances. Use this for models that support thinking/reasoning mode but are not detected automatically.
  • Resetting an override returns the field to auto-detected values from the next discovery cycle.

Metadata Overrides

Admins can override any model metadata field across all instances of a model via the metadata edit form. Overridable fields:

FieldTypeDescription
familystringModel family (e.g., llama, qwen2)
parameter_countstringModel size (e.g., 7B, 70B)
quantizationstringQuantization level (e.g., Q4_K_M, FP16)
context_lengthintEffective context window (injected as num_ctx). Overrides the auto-discovered value.
embedding_lengthintEmbedding dimension length
head_countintNumber of attention heads
layer_countintNumber of transformer layers
feed_forward_lengthintFeed-forward network dimension
model_formatstringModel format (e.g., gguf, safetensors)
parent_modelstringParent model identifier
capabilitiesstringComma-separated list of capabilities (stored as JSON array)
descriptionstringHuman-readable model description
model_urlstringURL to model documentation or repository

Clearing a field (setting it to blank) removes the override and reverts to the auto-discovered value. Admins can also reset all overrides at once via the "Reset All Overrides" action.

Ollama Model Pull and Delete

The Models page lists all Ollama backends and provides UI controls to:

  • Pull a model — Trigger a model download on a specific Ollama backend via the admin API (POST /api/admin/backends/{id}/ollama/pull). Progress can be polled until completion.
  • Delete a model — Remove a model from a specific Ollama backend via the admin API (POST /api/admin/backends/{id}/ollama/delete).

Model Auto-Enrichment

MindRouter can automatically generate structured descriptions for models that have no description. This runs as a background task that searches the web for model cards and summarizes them using MindRouter's own inference API.

How It Works

  1. Every 120 seconds, the enrichment loop checks for models with no description (up to 3 per cycle).
  2. For each model, it searches the web via the Brave Search API for the model's card or documentation page.
  3. It then calls MindRouter's own /v1/chat/completions endpoint with the search results and model metadata, asking an LLM to produce a structured markdown description.
  4. The generated description is saved to all instances of that model across backends.

There is a 90-second startup delay before the first enrichment cycle, giving backends time to be discovered. A 5-second delay between individual model enrichments provides rate limiting.

Configuration

Auto-enrichment is configured via Admin > Settings. Four AppConfig keys control the feature:

KeyTypeDefaultDescription
catalog.auto_enrichboolfalseMaster toggle — enable or disable auto-enrichment
catalog.enrich_modelstring""Model name to use for generating descriptions (e.g. qwen3.5:32b). Must be available in the cluster.
catalog.enrich_api_keystring""API key for internal MindRouter inference calls
catalog.brave_api_keystring""Brave Search API key. Overrides the BRAVE_SEARCH_API_KEY environment variable if set.

All four values must be configured for enrichment to run. The enrichment model and API key fields are required; if either is empty, the loop exits immediately.

Behavior Details

  • Only NULL descriptions are enriched — models with any existing description (including admin-written ones) are never overwritten.
  • Re-enrichment — To re-generate a description, clear it via the Admin Models page. The next enrichment cycle will pick it up.
  • Batch size — At most 3 models are enriched per 120-second cycle to avoid flooding backends with enrichment requests.
  • Deduplication — The same model name is only processed once per batch, even if it appears on multiple backends. The resulting description is applied to all instances.
  • Failures are non-fatal — If a web search or LLM call fails for a specific model, it is logged and skipped. The loop continues with the next model and will retry the failed model in a subsequent cycle.
  • Description cache — Generated descriptions are cached in a separate model_description_cache table keyed by model name. When a model row is deleted (e.g. by remove_stale_models during node maintenance) and later rediscovered, the enrichment loop restores the description instantly from the cache instead of repeating the web search and LLM call. Cache hits are logged as model_enriched_from_cache.

Admin Masquerade

Administrators can "masquerade" as another user to view the user dashboard from that user's perspective. This is useful for troubleshooting user-reported issues or verifying quota and usage display.

  • Start masquerade (POST /admin/masquerade/{target_user_id}) — Sets a signed cookie (mindrouter_masquerade) containing the target user ID. The cookie is signed using itsdangerous.URLSafeTimedSerializer with the application's SECRET_KEY and a "masquerade" salt. The cookie expires after 24 hours.
  • During masquerade — The user dashboard (/dashboard) shows the target user's data (usage stats, API keys, quota, conversations) instead of the admin's own data. The masquerade applies only to read-only dashboard views — admin routes and actions are never affected.
  • Stop masquerade (POST /admin/masquerade/stop) — Deletes the masquerade cookie and redirects back to the admin users page.
  • Security — Only users in an admin group can start a masquerade. The target user ID is verified to exist before the cookie is set. The signed cookie prevents tampering.

Masquerade is initiated from the user detail page (/admin/users/{id}) via a "View as User" button.

Audit Log Export

Administrators can export audit log data as CSV or JSON via GET /admin/audit/export. The export supports the same filters as the audit log UI:

ParameterTypeDescription
formatstringOutput format: csv (default) or json
searchstringFree-text search across request fields
user_id_filterintFilter by user ID
model_filterstringFilter by model name
status_filterstringFilter by request status
start_datestringStart date (ISO 8601 format)
end_datestringEnd date (ISO 8601 format)
include_contentboolInclude prompt/response content in export (default: false)

Export fields: request_uuid, created_at, user_id, model, endpoint, status, prompt_tokens, completion_tokens, total_tokens, total_time_ms, error_message. When include_content=true, additional fields are included: messages, prompt, parameters, response_content, finish_reason.

Exports are limited to 10,000 records. The file is downloaded as an attachment (audit_log.csv or audit_log.json).

Admin Audit Log

Every mutating admin action — whether triggered via the REST API (/api/admin/...) or the dashboard UI — is recorded in the admin_audit_log database table. This provides a persistent, tamper-evident record of all administrative changes to the system.

What Gets Recorded

FieldTypeDescription
timestampdatetimeAuto-set by the database when the action occurs
user_idint (FK)The admin user who performed the action
actionstringVerb describing the action: create, update, delete, enable, disable, toggle_tools, toggle_multimodal, toggle_thinking, drain, pull_model, delete_model, approve, deny, etc.
entity_typestringWhat was acted on: backend, model, node, user, group, api_key, setting, blog_post, quota_request, system, etc.
entity_idstringID or name of the specific entity (e.g., backend ID, model name, user ID)
before_valueJSONSnapshot of the entity's state before the change (when applicable)
after_valueJSONSnapshot of the entity's state after the change
ip_addressstringClient IP address of the admin who made the request
detailtextHuman-readable summary of the action (e.g., "Disabled backend vllm-aspen1")

Coverage

Admin audit logging is instrumented across two entry points:

  • Admin API (/api/admin/...) — All mutating endpoints (create, update, delete, enable, disable, drain, register, refresh, etc.) log actions automatically.
  • Dashboard UI (/admin/...) — All admin form submissions and action buttons (toggle controls, pull/delete models, approve/deny requests, update settings, etc.) log actions automatically.

Before/after JSON snapshots capture the entity state around each mutation. Create actions record only after_value, delete actions record only before_value, and updates capture both.

Viewer

The Admin Audit page at /admin/admin-audit provides a paginated, filterable table of all admin actions. Filters include:

  • Action — Filter by action type (e.g., only show delete actions)
  • Entity Type — Filter by entity type (e.g., only show backend changes)
  • Admin User — Filter by which admin performed the action

Clicking any row expands it to reveal the full before/after JSON diff for that action.

Admin Chat Configuration

The Chat Configuration page at /admin/chat-config allows administrators to control default settings for the built-in chat interface. All settings are stored in the AppConfig database and take effect immediately.

SettingDescriptionDetails
Core ModelsSubset of models shown in the chat model selectorSelect from all available (non-embedding) models on healthy backends. When set, only these models appear in the chat UI dropdown. Empty means show all models.
Default ModelPre-selected model in the chat UIThe model automatically selected when a user starts a new conversation. Blank means no default.
System PromptGlobal system prompt for all chat conversationsPrepended to every chat conversation. Blank removes the override and reverts to the built-in default. Can also be explicitly reset via a "Reset" button.
Max TokensDefault max tokens for chat requestsRange: 256–131072. Default: 16384. Clamped to the allowed range on save.
TemperatureDefault temperature for chat responsesRange: 0.0–2.0. Blank means use the model's default temperature.
Thinking ModeDefault thinking/reasoning modeOptions: true, false, low, medium, high, or blank (no default). Controls whether thinking is enabled by default for models that support it.

Chat Interface

PageURLDescription
Chat/chatFull-featured chat UI with model selection, streaming, file upload, multimodal support

The chat interface supports:

  • Collapsible conversation sidebar
  • Model and backend selection
  • Real-time streaming responses
  • File upload via button or drag-and-drop anywhere in the chat window (images, PDFs, DOCX, XLSX, CSV, JSON, Markdown, etc.)
  • Vision model support with automatic image handling
  • Web search toggle — when enabled, queries the Brave Search API and injects results as context into the system prompt (requires BRAVE_SEARCH_API_KEY)
  • Code syntax highlighting
  • LaTeX rendering
  • Conversation renaming and deletion (individual messages cannot be edited or deleted)
  • Dark mode toggle (stored in browser localStorage)
  • Advanced models toggle — show or hide non-core models in the model selector
  • Per-request thinking controls — enable/disable thinking mode and set reasoning effort for supported models
  • Collapsible thinking blocks in assistant responses
  • Keyboard shortcuts — Shift+Enter for newline, Enter to send
  • Sidebar collapse for a wider chat area
  • Copy buttons on code blocks and assistant messages
  • Image lightbox for viewing uploaded images full-size
  • Auto-titling of conversations based on the first message

Branding & Theming

MindRouter can be rebranded per deployment so a single installation matches one institution's visual identity — organization name, tagline, logos, favicon, and accent colors — across the entire web UI, in both light and dark mode. Branding is installation-wide (not per-user or per-group): every user of the deployment sees the same brand. An un-branded install renders identically to stock MindRouter.

Where

Admin menu → Branding (/admin/branding). Viewing requires admin-read; saving requires a full admin (is_admin). All changes are recorded in the admin audit log as branding.* actions. The page includes a live preview (a mock navbar, buttons, link, and stat card rendered in both light and dark, updating as you pick colors) and a Reset to defaults action that restores the stock name/colors and removes all uploaded assets.

What you can customize

FieldApplies to
Organization / app nameTop navbar, browser tab titles, footer
Institution / organization name (optional)Login-page wording for single sign-on — “Sign in with <org>” (falls back to “Sign in with SSO”) and the “Use your <org> credentials to sign in” helper text
TaglineFooter, beside the name
Accent color — light themeButtons, links, focus rings, stat-card accents, active nav (light mode)
Accent color — dark themeSame, in dark mode
Headline colorHeadlines such as the email blog/notification title; defaults to a neutral near-black (a light accent reads poorly as headline text). Buttons/links keep the accent.
Logo — dark themeTop navbar (always a dark background), plus the footer and login card in dark mode
Logo — light themeFooter and login card in light mode
FaviconBrowser tab icon
Email logo (raster)Outgoing emails (blog posts, notifications) — PNG/JPG/GIF, since email clients don't render SVG

Logos appear in the navbar (header), the footer, and the login card. The navbar always has a dark background, so it uses the dark-theme logo (falling back to the light one). The footer and login card follow the active theme and swap between the two logo variants automatically. Uploads are capped at BRANDING_MAX_LOGO_MB (default 4 MB); allowed logo types are PNG, JPG, WebP, SVG, GIF (favicon: ICO, PNG, SVG).

Accessible accent colors (contrast handling)

Accent colors are applied as CSS custom properties layered on top of Bootstrap 5's theming, scoped to :root (light) and [data-bs-theme="dark"] (dark). A brand accent is often a light color (for example, University of Idaho Pride Gold #F1B300) that would be illegible with the default white button text, so MindRouter derives two accessible companions for each theme's accent:

  • --mr-accent-on — the foreground used on an accent fill (button text). It is white by default and automatically flips to black when white would fall below a 3.0 contrast ratio on the accent (matching Bootstrap's own convention: white text on blue/red, dark text on gold/yellow). A gold button therefore gets black, legible text.
  • --mr-accent-ink — the accent used as text on the page background (links, .text-primary, outline-button text, active sidebar item). It is darkened (on a light page) or lightened (on a dark page) only as far as needed to reach a 4.5:1 WCAG contrast ratio. Fills, borders, and focus rings keep the true brand color.

Stock mid-tone accents (such as the default blue #0d6efd) are left untouched — the derivation only intervenes when the chosen accent would otherwise be unreadable. Only server-validated hex values are ever emitted into the page, so the injection is safe.

How it works

  • Text/color values are stored as branding.* rows in the AppConfig key/value table.
  • Uploaded logos/favicon are written to BRANDING_STORAGE_PATH (default /data/branding, a persistent branding_data volume) and served, with a long immutable cache, from the public route GET /branding/asset/{filename} — public so logos also render on the login page. Filenames are server-generated with a random token, so replacing an asset cache-busts automatically.
  • Applied on every page via a small in-memory cache (backend/app/services/branding.py) loaded at startup and refreshed every ~15 seconds, exposed to templates as a Jinja global. A save propagates to all uvicorn workers within a few seconds without a restart (the saving worker refreshes immediately).

Emails

Outgoing emails (blog-post notifications, admin/bulk notifications, and the SMTP test email) follow the same brand. Email is a constrained medium, so the treatment differs from the web UI: styling is inline (clients strip <style>), and the logo must be a raster image because email clients don't render SVG — hence the separate Email logo slot. The logo is embedded inline via cid: (the message becomes multipart/related) so it renders even when the client blocks remote images; if unset, the header shows the organization name as text. Accent colors are contrast-safe (a thin accent rule, gold button fill with black text, darkened accent for links) so a light brand color stays legible on the white email background.

Deployment note: BRANDING_STORAGE_PATH and BRANDING_MAX_LOGO_MB must be present in both settings.py and docker-compose.yml, and the branding_data named volume must be mounted, so uploaded assets survive container rebuilds. See the branding guide for details.


Users, Groups & Quotas

Group System

MindRouter uses a database-driven group system for authorization, quota defaults, and scheduler weights. Each user belongs to exactly one group. Groups are fully manageable via the admin UI at /admin/groups.

Each group has two privilege flags:

  • is_admin — Full admin access. Users can view all admin pages and perform all administrative actions (create, edit, delete users/groups/backends/nodes, approve quotas, change settings, etc.).
  • is_auditor — Read-only admin access. Users can view all admin pages (dashboard, users, backends, nodes, metrics, audit logs, conversations, settings, etc.) but cannot make any changes. Mutating controls are hidden in the dashboard, and mutating API endpoints return HTTP 403.

A group can have is_admin, is_auditor, or neither. Admin groups implicitly have auditor-level read access, so setting both flags is unnecessary (though harmless). The auditor role is intended for oversight, compliance review, or stakeholders who need visibility into system operations without the ability to modify state.

Default Groups

GroupToken BudgetRPMSched. WeightRole
students100,000301
staff500,000602
faculty1,000,0001203
researchers1,000,0001203
admin10,000,0001,00010Admin
auditor100,000301Auditor
nerds500,000602
other100,000301

These 8 groups are created automatically by the database migration. Admins can create additional groups, edit defaults, or delete empty groups via the admin UI or API.

Auditor Role Details

The auditor role provides complete read-only visibility into MindRouter administration. This is useful for:

  • Compliance & oversight — Auditors can review user accounts, API key usage, request audit logs, admin audit trails, and system configuration without risk of accidental changes.
  • Stakeholder visibility — Give managers or department leads a view into cluster health, usage metrics, and capacity without granting mutation rights.
  • Security review — Reviewers can inspect all admin pages and data to verify policies are being followed.

What auditors can access

  • All admin dashboard pages (overview, backends, nodes, models, GPU metrics, energy, users, user detail, groups, API keys, requests, audit log, admin audit, conversations, chat config, voice config, settings, retention, backup)
  • All read-only (GET) admin API endpoints
  • Masquerade (view a user's dashboard as them) for investigation purposes
  • Export features (audit CSV/JSON, conversation export, backup export)

What auditors cannot do

  • Create, edit, or delete users, groups, backends, or nodes
  • Enable, disable, or drain backends
  • Approve or deny quota requests
  • Pull or delete Ollama models
  • Change system settings, retention policies, or chat/voice configuration
  • Toggle the system online/offline
  • Send emails or manage blog posts
  • Restore backups

Setting up auditors

To grant auditor access, either assign users to the built-in auditor group, or create a custom group with is_auditor = true. Via the API:

# Create a custom auditor group
curl -X POST https://mindrouter.example.edu/api/admin/groups \
  -H "X-API-Key: YOUR_ADMIN_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name": "security-reviewers", "display_name": "Security Reviewers", "is_auditor": true}'

# Move a user to the auditor group
curl -X PATCH https://mindrouter.example.edu/api/admin/users/42 \
  -H "X-API-Key: YOUR_ADMIN_KEY" \
  -H "Content-Type: application/json" \
  -d '{"group_id": 8}'

User Profiles

Each user has the following profile fields (editable by admins at /admin/users/{id}):

  • Username and Email — unique identifiers
  • Full Name — display name
  • Group — determines default quotas, scheduler weight, and admin access
  • College and Department — organizational affiliation
  • Intended Use — free-text description of how the user plans to use the service

Quota Inheritance & Overrides

When a user is created, their quota defaults are inherited from their group. Admins can override any quota value per-user:

  • Token budget — inherited from group, overridable per user
  • RPM limit — inherited from group, overridable per user or per API key
  • Weight override — if set, overrides the group's scheduler weight for this user

API Key Lifecycle

  1. Generation — Keys use the format mr2_<random_urlsafe_base64> (48+ characters total).
  2. Storage — The raw key is shown once at creation. Only the Argon2 hash and a prefix (mr2_<first 8 chars>) are stored in the database.
  3. Verification — Lookup by prefix (fast), then full Argon2 hash verification.
  4. Expiration — Optional expires_at timestamp.
  5. Revocation — Keys can be revoked (soft-delete) without deleting the audit trail.
  6. Usage trackinglast_used_at and usage_count updated atomically on each request.

Quota System

Each user has a quota record with:

  • Token budget — Total tokens allowed per period. Deducted on each completed request (prompt + completion tokens).
  • RPM limit — Maximum requests per minute. Enforced cluster-wide via Redis (shared across all workers). API key overrides take precedence over the user's quota RPM.

When a quota is exceeded, the request is rejected with HTTP 429. Token budget is checked first, then RPM. If Redis is unavailable, RPM enforcement fails open (requests are allowed).

Rolling Budget Period

Token budgets use rolling 30-day windows per user, not calendar months. Usage is tracked continuously and the oldest usage falls off as the window advances.

Quota Increase Requests

Users can submit quota increase requests via the dashboard (/dashboard/request-quota). The request includes:

  • Desired token budget
  • Written justification

Admins review requests at /admin/requests or via POST /api/admin/quota-requests/{id}/review, which can approve (with a custom granted token amount) or deny the request.


Service API Keys

What Are Service Keys?

Service API Keys are production-grade keys intended for long-running services, automated pipelines, and integrations that require uninterrupted API access. Unlike regular personal API keys, service keys:

  • Never expire — no need to rotate keys on a schedule or worry about service disruptions from key expiration.
  • Are admin-managed — only administrators can approve, promote, and revoke service keys.
  • Carry metadata — each service key tracks the service name, data risk level, compliance tags, and alternative contacts.

How to Request a Service Key

  1. Go to your Dashboard and find the API key you want to promote.
  2. Click Request Service Key next to any active personal key.
  3. Fill in the required information:
    • Service / Application Name — the name of the service or pipeline that will use this key.
    • Justification — why this service needs a non-expiring key.
    • Alternative Contacts — emails of people who can manage this key if you are unavailable.
    • Data Risk Level — Low, Medium, or High based on the sensitivity of data processed.
    • Compliance Tags — applicable data classifications (FERPA, HIPAA, CUI, FCI, PII, IP, or Other).
  4. Submit the request. An administrator will review it.

Approval Process

Administrators review service key requests from the Admin → Requests page. When approved:

  • The key is promoted to service status (the underlying key value does not change).
  • The expiration date is removed (the key never expires).
  • Service metadata (name, contacts, risk level, compliance tags) is attached to the key.

If denied, the key remains a regular personal key with its original expiration.

Revocation

Service keys cannot be self-revoked by users. To revoke a service key:

  1. The key owner submits a Revocation Request from the dashboard, providing a reason.
  2. An administrator reviews the request and executes the revocation from the admin panel.

This two-step process prevents accidental revocation of keys that may be in active production use.


Backend Management

Node/Backend Model

MindRouter separates the concept of physical GPU servers (Nodes) from inference endpoints (Backends):

  • A Node represents a physical server with GPUs and a sidecar agent.
  • A Backend is an Ollama or vLLM instance running on a node.
  • One node can host multiple backends, each assigned specific GPUs via gpu_indices.
  • Backends without a node_id work as standalone endpoints (no GPU telemetry).
Node: gpu-server-1 (4x A100-80GB, sidecar at :8007)
+-- Backend: vllm-large  (gpu_indices: [0, 1])  ← uses GPUs 0-1
+-- Backend: vllm-small  (gpu_indices: [2])      ← uses GPU 2
+-- Backend: ollama-misc (gpu_indices: [3])      ← uses GPU 3

Supported Engines

EngineHealth CheckModel DiscoveryTelemetry Source
Ollama (ollama)GET /api/tagsGET /api/tags + POST /api/ps (loaded models)Sidecar agent
vLLM (vllm)GET /health (fallback: GET /v1/models)GET /v1/modelsGET /metrics (Prometheus format)
Diffusion (diffusion)GET /health (fallback: GET /v1/models)GET /v1/modelsGET /metrics when the server exposes it; otherwise sidecar GPU data only
Video (video)GET /health (fallback: GET /v1/models)GET /v1/modelsGET /metrics when the server exposes it; otherwise sidecar GPU data only

The BackendEngine enum has exactly these four values. The registry builds an OllamaAdapter for ollama backends and a VLLMAdapter for every other engine, so diffusion and video servers must expose the same OpenAI-compatible /health and /v1/models surface as vLLM. Discovery then assigns modality by engine before looking at the model name: every model found on a diffusion backend becomes IMAGE_GENERATION, every model on a video backend becomes VIDEO_GENERATION.

Registration

Register a node:

curl -X POST http://localhost:8000/api/admin/nodes/register \
  -H "Authorization: Bearer admin-api-key" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "gpu-server-1",
    "hostname": "gpu1.example.com",
    "sidecar_url": "http://gpu1.example.com:8007",
    "sidecar_key": "your-sidecar-secret-key"
  }'

Register a backend on that node:

curl -X POST http://localhost:8000/api/admin/backends/register \
  -H "Authorization: Bearer admin-api-key" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "ollama-gpu1",
    "url": "http://gpu1.example.com:11434",
    "engine": "ollama",
    "max_concurrent": 4,
    "node_id": 1,
    "gpu_indices": [0, 1]
  }'

Enable/Disable/Drain/Refresh

  • Disable a backend to take it out of rotation without deleting it: POST /api/admin/backends/{id}/disable
  • Enable to bring it back: POST /api/admin/backends/{id}/enable
  • Drain for graceful offline maintenance: POST /admin/backends/{id}/drain (dashboard-only route)
  • Refresh to force re-discovery of models and capabilities: POST /api/admin/backends/{id}/refresh

Drain Mode

Drain mode provides graceful backend shutdown for maintenance. When a backend is set to draining:

  1. The scheduler stops routing new requests to the backend.
  2. All in-flight requests are allowed to complete normally.
  3. When the backend's queue depth reaches 0, it automatically transitions to disabled.

This avoids abruptly killing active requests when you need to restart an inference engine, upgrade models, or perform node maintenance. Use it before upgrading vLLM, restarting Ollama, or taking a GPU node offline.

Concurrency Alignment

The max_concurrent value registered in MindRouter must match the concurrency limit on the inference engine:

EngineEngine SettingMindRouter Setting
vLLM--max-num-seqs N"max_concurrent": N
OllamaOLLAMA_NUM_PARALLEL=N"max_concurrent": N

Why this matters: MindRouter uses max_concurrent to decide how many requests to route to a backend. If MindRouter thinks a backend can handle 8 concurrent requests but vLLM is configured with --max-num-seqs 4, the extra requests queue silently inside vLLM. MindRouter cannot see this hidden queue, so it continues routing requests there instead of spreading load to other backends. The result is uneven load distribution and unpredictable latency — the fair-share scheduler is effectively bypassed for those excess requests.

Context Length (num_ctx)

MindRouter automatically manages context length for Ollama backends:

  • Auto-discovery — During model discovery, context_length is set to min(model_max_context, 32768) to prevent small models from consuming excessive VRAM. The architectural maximum is stored separately as model_max_context.
  • num_ctx injection — For every Ollama request, MindRouter injects num_ctx matching the model's configured context_length.
  • Enforcement toggle — By default, MindRouter overrides any user-supplied num_ctx to prevent GPU memory oversubscription. Admins can toggle this in Site Settings (/admin/settings) to allow users to set their own num_ctx.
  • Manual override — Admins can set context_length_override per model via the admin UI to use a custom value instead of the auto-discovered one.
  • Ollama 0.17+ — Ollama automatically adjusts num_ctx downward if the requested value doesn't fit in GPU memory.

vLLM backends handle context length natively via --max-model-len and do not need num_ctx injection.


Scheduling & Fair Share

MindRouter implements Weighted Deficit Round Robin (WDRR) to ensure fair GPU access across users.

How It Works

  1. Share weights are determined by the user's group (e.g., students=1, staff=2, faculty=3, admin=10). Per-user weight overrides are supported via the quota system.
  2. Each user has a deficit counter that tracks how much service debt they've accrued.
  3. On each scheduling round, users with the highest deficit (most underserved) are served first.
  4. Burst credits allow full cluster utilization when the cluster is idle.
  5. Heavy user deprioritization kicks in when a user exceeds their fair share within the fairness window.

Backend Scoring

When multiple backends can serve a request, the scheduler scores them on:

  • Model already loaded (+100 points) — avoids cold-loading the model
  • Low GPU utilization (+50 points) — prefers idle GPUs
  • Low latency (+40 points) — based on EMA of recent response times
  • Short queue (+30 points) — prefers backends with fewer queued requests
  • High throughput (+20 points) — based on recent tokens/second
  • Priority (+N × 10 points) — from the backend's configured priority value

Hard constraints (multimodal capability, embedding support, model availability) are checked before soft scoring.

Priority Gating

When multiple requests are waiting for the same model, the scheduler ensures the highest-priority waiter proceeds first. Priority is determined by the user's group scheduler weight and deficit counter, preventing lower-priority requests from starving higher-priority ones during contention.


Retry & Failover

MindRouter automatically retries failed inference requests to improve reliability across the backend cluster.

  • Automatic retries — Up to 3 attempts on 5xx errors, timeouts, and connection failures. Configurable via the BACKEND_RETRY_MAX_ATTEMPTS environment variable.
  • Fast fail on 4xx — Client errors (400, 401, 404, etc.) are never retried and return immediately.
  • Streaming constraints — Retries can only occur before the first chunk is sent to the client. Once streaming has begun, a backend failure is terminal and the stream ends with an error.
  • Backend rotation — Each retry attempt selects a different backend when multiple backends are available for the requested model, maximizing the chance of success.
  • Clean timeout cancellation — Each backend request uses a dedicated HTTP connection. When a request times out (default 180s per attempt), MindRouter closes the connection, which signals the backend (vLLM) to abort the in-progress generation and free the execution slot. This prevents orphaned requests from consuming backend capacity after MindRouter has given up.

Mid-Stream Error Behavior

Once streaming has begun and the first chunk has been sent to the client, a backend failure terminates the SSE stream immediately with no error event. The client receives an abrupt end-of-stream. Retries are not possible after the first chunk because the response format has already been committed.


Circuit Breaker

MindRouter uses a per-backend circuit breaker to avoid routing requests to backends that are experiencing persistent failures.

  • Trip threshold — After 3 consecutive failures (configurable), the backend is marked as "open" and excluded from routing for 30 seconds (configurable).
  • Integration with retry — The circuit breaker works alongside retry. Backends with an open circuit are skipped during failover selection, so retries are directed to healthier backends.
  • Automatic recovery — After the exclusion window expires, the backend is eligible for routing again. A successful request resets the failure counter.

Rate Limiting

MindRouter enforces two types of rate limits. Both return HTTP 429 when exceeded — clients should implement exponential backoff.

  • Token quota — Rolling token budget per user (period defaults to 30 days). When the budget is exhausted, all requests are rejected until the period resets. Configured per group, overridable per user.
  • Requests per minute (RPM) — Enforced cluster-wide via Redis using an atomic INCR + EXPIRE counter over a fixed 60-second window (the TTL is set only on the first request of a window, not refreshed per request). The limit is shared across all application workers, so a user configured for 120 RPM gets exactly 120 RPM regardless of which worker handles the request. Configured per group, overridable per user or per API key. If Redis is unavailable, RPM enforcement fails open (requests are allowed).

Tool Calling

MindRouter supports tool calling (function calling) across all three API formats, with transparent translation between them.

  • Tool definitions — Pass an OpenAI-style tools array describing available functions, with optional tool_choice ("auto", "none", or a specific function name).
  • Tool results — Submit results back via role: "tool" messages with a matching tool_call_id.
  • Cross-format support — Tool calls work whether the request arrives in OpenAI, Ollama, or Anthropic format. MindRouter translates tool definitions, tool calls, and tool results between all formats automatically.
  • Backend requirement — The selected backend must support tool calling. For vLLM, this requires --enable-auto-tool-choice and the appropriate --tool-call-parser flag. For Ollama, tool calling is supported natively on compatible models.

Document OCR

MindRouter provides a dedicated POST /v1/ocr endpoint that converts images, PDFs, and Office documents into structured markdown or JSON using a multimodal LLM. This is particularly useful for extracting text from scanned documents, research papers, forms, and spreadsheets while preserving layout, tables, and formatting.

Supported Formats

  • Images: PNG, JPEG, WebP, GIF, TIFF, BMP (including multi-frame GIF/TIFF)
  • PDF: Converted to page images at configurable DPI (default 200)
  • Office: DOCX, PPTX, XLSX — converted to PDF via LibreOffice, then to images

How It Works

  1. Document to images — The uploaded file is converted to a series of PNG page images.
  2. Chunking — For multi-page documents, pages are split into overlapping chunks (default: 6 pages per chunk, 2-page overlap). This ensures no content is lost at page boundaries.
  3. Parallel OCR — Each chunk is sent to a multimodal LLM as a chat completion request containing the page images and an OCR prompt. Chunks are processed concurrently for speed.
  4. Completeness check — If a chunk’s output is suspiciously short (below a configurable threshold per page), it is automatically retried with a stronger prompt.
  5. Deterministic merge — Overlapping chunks are merged using difflib sequence matching to find the overlap boundary, then spliced together. No additional LLM calls are needed for merging.

API Usage

Send a multipart POST request with your API key and the file:

curl -X POST https://mindrouter.example.com/v1/ocr \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -F "file=@document.pdf" \
  -F "output_format=markdown"
Request Parameters (form fields)
FieldTypeDefaultDescription
filefilerequiredThe document to OCR (image, PDF, or Office file)
modelstringAdmin-configuredMultimodal model to use (must support vision)
output_formatstringmarkdownmarkdown or json
chunk_sizeint6Pages per chunk for multi-page documents
overlapint2Overlapping pages between chunks
dpiint200Image resolution for PDF/Office conversion
Response
{
  "id": "ocr-abc123...",
  "object": "ocr.result",
  "created": 1711700000,
  "model": "qwen/qwen3.5-122b",
  "content": "# Document Title\n\n## Section 1\n\nExtracted text...",
  "format": "markdown",
  "pages": 12,
  "chunks_processed": 3,
  "usage": {
    "prompt_tokens": 45000,
    "completion_tokens": 8000,
    "total_tokens": 53000
  }
}

Python Example

import requests

response = requests.post(
    "https://mindrouter.example.com/v1/ocr",
    headers={"Authorization": "Bearer YOUR_API_KEY"},
    files={"file": open("paper.pdf", "rb")},
    data={"output_format": "markdown"},
)

result = response.json()
print(result["content"])  # Markdown text
print(f"Pages: {result['pages']}, Chunks: {result['chunks_processed']}")

Simplified Endpoint: /v1/ocrmd

For convenience, POST /v1/ocrmd returns raw markdown directly (not JSON-wrapped). The response body is plain text with Content-Type: text/markdown. This is useful for piping output directly to a file:

curl https://mindrouter.example.com/v1/ocrmd \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -F "file=@document.pdf" \
  > output.md

Accepts the same optional parameters as /v1/ocr (model, chunk_size, overlap, dpi) except output_format (always markdown).

Configuration

Administrators can configure OCR settings via Admin → OCR in the web dashboard. Configurable options include:

  • Default model — Which multimodal model to use (must support vision/image inputs)
  • Chunk size & overlap — How many pages per chunk and how many pages overlap between chunks
  • DPI — Resolution for PDF and Office document conversion
  • Concurrency — How many chunks to process in parallel
  • Safety limits — Maximum pages, file size, retries per chunk
  • Temperature & max tokens — LLM parameters for OCR requests
  • Enable/disable — Toggle the entire OCR endpoint

All settings are stored in the app_config table and take effect immediately without restarting the application.



MCP Servers

MindRouter provides Model Context Protocol (MCP) servers that expose MindRouter APIs as tools for agentic systems like Claude Code, CoWork, Cursor, and other MCP-compatible clients.

Two connection options are available:

  • Hosted SSE server (recommended) — Connect directly to MindRouter’s built-in MCP endpoint. No local dependencies or setup required.
  • Local stdio server — Run the MCP server on your machine. Requires Python and a few packages.
Agent (Claude Code, Cursor, CoWork, etc.)
  ├―― SSE transport      ⟶ MindRouter SSE MCP Server (hosted) ⟶ Search backend
  ├―― Desktop Extension  ⟶ SSE bridge (.mcpb)    ⟶ SSE ⟶ MindRouter MCP Server
  └―― stdio transport    ⟶ MCP Server (local Python process)  ⟶ HTTPS ⟶ MindRouter API

Available Tools

ToolTool NameDescriptionHosted SSELocal Source
Web Search web_search Search the web via MindRouter (Brave Search / SearXNG) /mcp/sse agentic_ai/mcp/search/

Option 1: Hosted SSE Server (Recommended)

The simplest way to connect. MindRouter hosts the MCP server — no Python, no dependencies, no local processes. Just add a few lines to your MCP client config.

1. Get your API key

Generate an API key from the API Keys page in the MindRouter dashboard.

2. Configure your MCP client

Add this to your agent’s MCP configuration (.mcp.json, .cursor/mcp.json, etc.):

{
  "mcpServers": {
    "mindrouter": {
      "type": "sse",
      "url": "https://mindrouter.example.com/mcp/sse",
      "headers": {
        "Authorization": "Bearer mr2_your_key_here"
      }
    }
  }
}

Or in Claude Code via CLI:

claude mcp add --transport sse \
  -H "Authorization: Bearer mr2_your_key_here" \
  mindrouter https://mindrouter.example.com/mcp/sse

3. Verify

In Claude Code, run /mcp to confirm the connection and the web_search tool is listed. In other clients, check your MCP server status panel.

Authentication: The hosted MCP server authenticates with your MindRouter API key via the Authorization: Bearer or X-API-Key header. Each search deducts the same token cost as the REST API.

Option 2: Claude Desktop Extension

For Claude Desktop (the standalone app), install the MindRouter extension for one-click setup — no terminal, no dependencies, no config files.

1. Download the extension

Download mindrouter.mcpb from the MindRouter repository.

2. Install

Double-click the .mcpb file, or open Claude Desktop and go to Settings → Extensions → Advanced settings → Install Extension… and select the file.

3. Configure

Enter your MindRouter API key when prompted. The server URL defaults to https://mindrouter.example.com/mcp/sse — change it only if you run your own MindRouter instance.

How it works: The extension runs a lightweight bridge script (bundled inside the .mcpb) that connects to MindRouter’s hosted SSE server and translates it to the stdio transport that Claude Desktop expects. Claude Desktop provides the Node.js runtime — no local installation required.

Option 3: Local stdio Server

Run the MCP server locally if you prefer a self-hosted setup or need to work offline. The local server connects to MindRouter over HTTPS.

1. Install dependencies

pip install "mcp[cli]" httpx

2. Get your API key

Generate an API key from the API Keys page.

3. Configure in your MCP client

Claude Code CLI:

claude mcp add --scope user \
  --env MINDROUTER_API_KEY=mr2_your_key_here \
  mindrouter \
  -- python3 /path/to/agentic_ai/mcp/search/server.py

Project config (.mcp.json):

{
  "mcpServers": {
    "mindrouter": {
      "command": "python3",
      "args": ["/path/to/agentic_ai/mcp/search/server.py"],
      "env": {
        "MINDROUTER_API_KEY": "mr2_your_key_here",
        "MINDROUTER_BASE_URL": "https://mindrouter.example.com"
      }
    }
  }
}

Environment Variables (Local Server Only)

VariableDefaultDescription
MINDROUTER_API_KEY(required)Your MindRouter API key
MINDROUTER_BASE_URLhttps://mindrouter.example.comMindRouter instance URL (change for self-hosted instances)

Connection Options Comparison

FeatureHosted SSEDesktop ExtensionLocal stdio
Best forClaude Code, Cursor, CoWorkClaude Desktop appSelf-hosted / advanced
Setup3-line JSON config or CLIDouble-click .mcpb fileInstall Python + packages
DependenciesNoneNone (runtime bundled)Python 3.11+, mcp[cli], httpx
TransportSSE over HTTPSSSE bridge → stdiostdio (local process)
Technical skillEdit JSON or run CLINonePython environment setup

Other MCP-Compatible Clients

Both hosted and local MCP servers work with any MCP-compatible client:

  • Cursor — Configure in .cursor/mcp.json
  • CoWork — Add in your workspace MCP configuration
  • Custom agents — Use the MCP Python SDK client to connect programmatically

Agent Skills

MindRouter provides pre-built agent skills — markdown files containing API context, usage instructions, and example commands that AI coding agents use to interact with MindRouter services. Compatible with Claude Code, ForgeCode, OpenCode, Codex, and other agentic coding tools that support markdown-based skill or custom command definitions.

Skills are complementary to MCP servers. Use skills for lightweight, dependency-free integration; use MCP servers for a standardized tool protocol with structured input/output.

Available Skills

SkillNameDescriptionSource
Web Search web-search Search the web via MindRouter's search API (Brave Search / SearXNG) agentic_ai/skills/web-search/

Installation

Copy the skill folder into the location your agent tool expects for custom commands or skills. Each skill is a directory containing a SKILL.md file with YAML frontmatter and markdown instructions.

Claude Code

mkdir -p .claude/skills
cp -r agentic_ai/skills/web-search .claude/skills/

Other Agent Tools

Copy or symlink the skill directory into your tool’s custom command location. If your tool doesn’t have a specific skills directory, reference the SKILL.md file directly in your system prompt or project context — the content is standard markdown that any LLM-based agent can follow.

Configuration

Set your MindRouter API key in the environment before starting your agent:

export MINDROUTER_API_KEY=mr2_your_key_here

Optionally set a custom base URL (defaults to https://mindrouter.example.com):

export MINDROUTER_BASE_URL=https://your-instance.example.com

Usage

Once installed, invoke the skill via your agent’s command interface (e.g. /web-search in Claude Code) or ask the agent to search the web. The agent will call MindRouter’s search API, parse the results, and present titles, URLs, and snippets.

Skills vs MCP Servers

FeatureSkillsMCP (Hosted SSE)MCP (Local stdio)
FormatMarkdown + YAML frontmatterSSE endpoint (server-hosted)Python MCP server
SetupCopy folder into agent’s skill directory3-line JSON configInstall Python + packages + config
ScopeAny agent that reads markdown skill filesAny MCP-compatible client
DependenciesNone (uses curl / shell)NonePython, mcp[cli], httpx

Request ID

Every API response carries an X-Request-ID response header. The body id field is a separate value and is not the same thing.

  • X-Request-ID header — A request-ID middleware echoes a client-supplied X-Request-ID back verbatim, or generates a bare UUID4 if the client did not send one. This value is used for server-side log correlation.
  • Client-supplied IDs never appear in the response body. Sending X-Request-ID: my-id gets my-id back in the header only; the body id is still minted server-side.
  • Body id on inference endpoints/v1/chat/completions, /v1/completions, and /anthropic/v1/messages return the audit row's bare UUID4 (requests.request_uuid) — not a prefixed id — in the non-streaming body. In SSE chunks the same UUID is used for Ollama backends; vLLM backends pass their own chatcmpl-* chunk id through. /v1/responses returns a resp_* id and /v1/ocr returns its own ocr-* id.
  • The chatcmpl-*, cmpl-*, emb-*, rnk-*, scr-*, msg_*, img-* prefixes exist only in server logs. Each route mints one at entry to bind its log context, then the inference service replaces it with the audit UUID before the response is built.
  • Audit trail — Because the body id on chat/completion endpoints is requests.request_uuid, it is the value to search on when tracing a response back to its audit row.

Translation Layer

MindRouter's translation layer enables cross-engine routing: a request arriving in OpenAI, Ollama, or Anthropic format can be served by any Ollama or vLLM backend. All translation passes through a canonical internal schema.

Request Flow

OpenAI Request    --> OpenAIInTranslator    --> CanonicalChatRequest
                                                       |
Ollama Request    --> OllamaInTranslator    --> CanonicalChatRequest
                                                       |
Anthropic Request --> AnthropicInTranslator --> CanonicalChatRequest
                                                       |
                                                       v
                                                [Scheduler selects backend]
                                                       |
                           +---------------------------+-------------------+
                           v                                               v
                 OllamaOutTranslator                             VLLMOutTranslator
                 (Ollama backend)                                (vLLM backend, OpenAI format)

Canonical Schemas

The canonical internal representation (backend/app/core/canonical_schemas.py) includes:

  • CanonicalChatRequest — model, messages, temperature, top_p, max_tokens, stream, tools, tool_choice, response_format, think (bool or string), reasoning_effort, etc.
  • CanonicalMessage — role (system/user/assistant/tool), content (text or multimodal content blocks, nullable), tool_calls, tool_call_id
  • ContentBlock — TextContent, ImageUrlContent, or ImageBase64Content
  • CanonicalToolCall / CanonicalFunctionCall — tool call with id, function name, and arguments (JSON string)
  • CanonicalToolDefinition — tool definition with function name, description, and parameters schema
  • CanonicalEmbeddingRequest — model, input, encoding_format, dimensions
  • CanonicalChatResponse / CanonicalStreamChunk — response and streaming types (including tool call deltas)

Key Translation Mappings

ConceptOpenAIOllamaAnthropicCanonical
Max tokensmax_completion_tokens or max_tokensoptions.num_predictmax_tokens (required)max_tokens
Stream defaultfalsetruefalse
System promptmessages with role: systemmessages with role: systemTop-level system fieldCanonicalMessage(role=SYSTEM)
Stop sequencesstopoptions.stopstop_sequencesstop
JSON schemaresponse_formatformat: {schema}output_config.formatresponse_format
ParametersTop-level fieldsoptions dictTop-level fieldsTop-level fields
Imagesimage_url blockimages array (base64)image block with sourceImageBase64Content / ImageUrlContent
Tool definitionstoolstoolstools (with input_schema)tools (CanonicalToolDefinition)
Tool choicetool_choicetool_choice (auto/any/tool)tool_choice
Tool callstool_calls (JSON string args)tool_calls (dict args)tool_use content blocksCanonicalToolCall (JSON string args)
Tool resultsrole: "tool" + tool_call_idtool_result content blocksCanonicalMessage(role=TOOL, tool_call_id)
Thinking modethink, thinking.type, chat_template_kwargs, reasoning_effortthink (bool or "low"/"medium"/"high")thinking.type (enabled/disabled)think (bool or string)
User IDusermetadata.user_iduser
Stream formatSSE (data: {...})NDJSONSSE (Anthropic events)CanonicalStreamChunk

Translators

TranslatorDirectionPurpose
OpenAIInTranslatorAPI → CanonicalTranslates incoming OpenAI-format requests
OllamaInTranslatorAPI → CanonicalTranslates incoming Ollama-format requests
AnthropicInTranslatorAPI → CanonicalTranslates incoming Anthropic Messages API requests; also formats responses and SSE stream events back to Anthropic format
OllamaOutTranslatorCanonical → BackendTranslates outgoing requests to Ollama backends
VLLMOutTranslatorCanonical → BackendTranslates outgoing requests to vLLM backends

All translators use static methods — no instantiation needed.

Model-Specific Behaviors

  • Qwen3-32B on vLLM — This model embeds <think>...</think> tags directly in the content field instead of using the reasoning_content field. MindRouter automatically extracts these tags and moves the reasoning text to the canonical reasoning field for both streaming and non-streaming responses.
  • Qwen3.5 on vLLM with thinking disabled — When thinking is disabled but the model still returns reasoning content with an empty content field, MindRouter promotes the reasoning content to the content field so the response is not blank.

Telemetry & Monitoring

GPU Sidecar Agent

Each GPU node runs a lightweight FastAPI sidecar agent (sidecar/gpu_agent.py) that exposes per-GPU hardware metrics:

Collected metrics per GPU:

  • Utilization (GPU % and memory %)
  • Memory (used/free/total GB)
  • Temperature (GPU and memory)
  • Power draw and limit (watts)
  • Fan speed, SM/memory clocks
  • Running processes (PID + memory)
  • Device identity (name, UUID, compute capability)
  • Driver and CUDA versions

Authentication: Requires SIDECAR_SECRET_KEY env var. All requests must include X-Sidecar-Key header (constant-time comparison).

Prerequisites: Each GPU node must have NVIDIA drivers and the NVIDIA Container Toolkit installed so Docker can access GPUs via --gpus all:

# RHEL/Rocky Linux
curl -s -L https://nvidia.github.io/libnvidia-container/stable/rpm/nvidia-container-toolkit.repo \
  | sudo tee /etc/yum.repos.d/nvidia-container-toolkit.repo
sudo dnf install -y nvidia-container-toolkit

# Debian/Ubuntu
curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey \
  | sudo gpg --dearmor -o /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg
curl -s -L https://nvidia.github.io/libnvidia-container/stable/deb/nvidia-container-toolkit.list \
  | sed 's#deb https://#deb [signed-by=/usr/share/keyrings/nvidia-container-toolkit-keyring.gpg] https://#g' \
  | sudo tee /etc/apt/sources.list.d/nvidia-container-toolkit.list
sudo apt-get update && sudo apt-get install -y nvidia-container-toolkit

# Configure and restart Docker
sudo nvidia-ctk runtime configure --driver=docker
sudo systemctl restart docker

# Verify GPU access
docker run --rm --gpus all nvidia/cuda:12.2.0-base-ubuntu22.04 nvidia-smi

Deployment options:

  1. Docker Composedocker compose --profile gpu up gpu-sidecar
  2. Standalone Docker — Build from sidecar/Dockerfile.sidecar, run with --gpus all
  3. Direct Pythonpip install fastapi uvicorn nvidia-ml-py && python sidecar/gpu_agent.py

Health Polling

The Backend Registry runs an adaptive polling loop:

  • Startup fast polls: On container start, two immediate full poll cycles run (with a 5-second gap) so backends and nodes are marked healthy within seconds of a restart instead of waiting for the normal 30-second cycle.
  • Normal interval: 30 seconds (configurable via BACKEND_POLL_INTERVAL)
  • Fast interval: 10 seconds after a backend becomes unhealthy (configurable)
  • Fast duration: 120 seconds before returning to normal polling

Each poll cycle has two phases:

  1. Poll sidecar agents (one per physical node) for GPU snapshots
  2. Poll each backend adapter for health, models, and engine-specific telemetry

Health Alerts

The admin dashboard (/admin) displays a prominent warning banner when any backend is unhealthy/unknown or any node is offline/unknown. The alert includes counts and names of affected items with direct links to the backends or nodes management pages. Intentionally disabled backends are excluded from the alert — only unexpected health issues are flagged.

Circuit Breaker

Per-backend circuit breaker protects against cascading failures:

  • Threshold: 3 consecutive failures before opening (configurable via BACKEND_CIRCUIT_BREAKER_THRESHOLD)
  • Recovery: 30 seconds before allowing a probe request (BACKEND_CIRCUIT_BREAKER_RECOVERY_SECONDS)
  • States: Closed (healthy) → Open (failing) → Half-Open (probe) → Closed (recovered)

Latency Tracking

Exponential Moving Average (EMA) tracks per-backend latency:

  • Alpha: 0.3 (30% current observation, 70% history)
  • Metrics: Total latency EMA and TTFT (time-to-first-token) EMA
  • Throughput score: 1.0 / (1.0 + latency_ms / 5000.0) — used in backend scoring
  • Persistence: EMAs are periodically saved to the database for recovery after restart

Prometheus Metrics

Scrape /metrics for Prometheus-compatible metrics. See the Health & Metrics Endpoints section for the full list.

Telemetry API

Admin users can access detailed telemetry via the API:

  • Cluster overview — All nodes, backends, GPUs with current metrics
  • Historical data — Time-series with configurable resolution (1m, 5m, 15m, 1h, 6h, 1d)
  • Per-GPU history — Individual GPU device telemetry over time
  • Export — Download telemetry data as JSON or CSV

Redis (Optional)

Redis is an optional dependency configured via REDIS_URL. When available, it serves three roles:

  • Inflight streaming token counting — Atomically tracks tokens currently being streamed across all workers, enabling accurate real-time throughput metrics.
  • Per-user quota token caching — Caches per-user token counters (quota:tokens:{user_id}) for fast atomic increments without hitting the database on every request.
  • RPM rate limiting — Cluster-wide requests-per-minute enforcement via atomic INCR + EXPIRE counters (rpm:user:{user_id}) over a fixed 60-second window. Shared across all workers.
  • Graceful degradation — All features that depend on Redis continue to work without it. Token quota enforcement falls back to database queries, RPM enforcement fails open (requests are allowed), and inflight token counts default to zero. No functionality is lost; only caching and rate limiting benefits are reduced.

Data Retention

MindRouter includes a two-tier data retention system that automatically archives and purges old data to keep the primary database lean while preserving historical records in a separate archive database.

Architecture

The retention system manages three data categories:

CategoryTablesDefault Tier 1Default Tier 2
Requests requests, responses, scheduler_decisions, artifacts 90 days 730 days (2 years)
Chat chat_conversations, chat_messages, chat_attachments 90 days 730 days (2 years)
Telemetry backend_telemetry, gpu_device_telemetry 30 days Not archived (deleted only)
  • Tier 1 (App DB) — Data lives in the primary database for the configured retention period. When it expires, it is archived to Tier 2 (if configured) then deleted from the app DB.
  • Tier 2 (Archive DB) — A separate MariaDB instance that stores full copies of archived data. After the Tier 2 retention period expires, archived data is permanently purged.

Archive Database

The archive database is a second MariaDB container (mariadb-archive) defined in docker-compose.yml. It runs alongside the primary database and stores archived records in FK-free tables that mirror the app schema.

The archive DB is optional. If ARCHIVE_DATABASE_URL is not set, the retention system still works — expired data is simply deleted without archival.

Configuration

Add the following environment variable to enable archival:

ARCHIVE_DATABASE_URL=mysql+pymysql://mindrouter_archive:<password>@mariadb-archive:3306/mindrouter_archive

The Docker Compose files include a mariadb-archive service that is started automatically. In production, configure the archive password variables in your .env.prod file:

MYSQL_ARCHIVE_ROOT_PASSWORD=<secure-root-password>
MYSQL_ARCHIVE_PASSWORD=<secure-password>
ARCHIVE_DATABASE_URL=mysql+pymysql://mindrouter_archive:<secure-password>@mariadb-archive:3306/mindrouter_archive

Archive tables are created automatically at startup via create_all(checkfirst=True) — no Alembic migration is needed for the archive database.

Retention Policies

All retention policies are stored in the AppConfig table and are editable at runtime from the admin UI at /admin/retention. No restart is required when changing policies.

AppConfig KeyDefaultDescription
retention.requests.tier1_days90Days before request data is archived
retention.requests.tier2_days730Days archived requests are kept before purge
retention.chat.tier1_days90Days before chat data is archived
retention.chat.tier2_days730Days archived chat data is kept before purge
retention.telemetry.tier1_days30Days before telemetry is purged (no archival)
retention.telemetry.tier2_days00 = telemetry is not archived
retention.cleanup_interval3600Seconds between retention cycle runs
retention.batch_size500Rows per archive+delete batch

Setting a Tier 1 value to 0 disables retention for that category (data is kept indefinitely).

Admin UI

The Data Retention page in the admin dashboard (/admin/retention) provides three tabs:

  • Policies — Edit all retention periods and advanced settings (cleanup interval, batch size). Shows current row counts in the app DB for each category.
  • Archive Stats — Per-table row counts and disk usage in the archive database. Only available when the archive DB is configured.
  • Browse Archive — Paginated view of archived records with filters for model and user. Supports browsing both archived requests and chat conversations.

A Run Retention Now button triggers an immediate retention cycle without waiting for the next scheduled run.

Background Loop

A background task (_retention_loop) runs continuously and executes a full retention cycle at the configured interval. Each cycle:

  1. Archives and deletes expired requests from the app DB (Tier 1)
  2. Archives and deletes expired chat conversations from the app DB (Tier 1)
  3. Deletes expired telemetry data from the app DB (no archival)
  4. Purges expired records from the archive DB (Tier 2)
  5. Cleans up orphan chat attachments

All operations are batched and use FK-order deletion to maintain referential integrity. Archive inserts use INSERT IGNORE for idempotent re-runs.

Storage

The archive database volume can be mounted to a separate, larger disk. Set ARCHIVE_DB_HOST_PATH to a path on that disk (it defaults to a local ./data/archive-mariadb directory) so the archive data can reside on a different filesystem than the primary database.


Chat System

MindRouter includes a built-in chat interface at /chat with full conversation management.

Conversations

  • Each user has their own conversation history
  • Conversations store: title, selected model, creation/update timestamps
  • Users can rename, switch models, or delete conversations
  • Up to 50 conversations shown in the sidebar (most recent first)

Messages

  • Messages include role (user/assistant/system) and content
  • Assistant messages are streamed in real-time
  • Messages can be edited or deleted after creation
  • Attachments are linked to individual messages

File Upload

Supported file types and processing:

CategoryExtensionsProcessing
Images.jpg, .jpeg, .png, .gif, .webpResized to max 1536px, compressed JPEG q85, thumbnail generated
Documents.pdfText extracted from all pages, first-page thumbnail generated
Documents.docxText extracted from all paragraphs
Spreadsheets.xlsxAll sheets read, formatted as tab-separated text
Text files.txt, .md, .csv, .json, .html, .htm, .logRead as-is

Limits:

  • Max upload size: 10 MB (configurable via CHAT_UPLOAD_MAX_SIZE_MB)
  • Artifact storage path: /data/artifacts (configurable via ARTIFACT_STORAGE_PATH)
  • Artifact max size: 50 MB (configurable via ARTIFACT_MAX_SIZE_MB)
  • Artifact retention: 365 days

Storage layout:

/artifacts/YYYY/MM/DD/<sha256_prefix>/<full_sha256>_<uuid>.<ext>

Multimodal Model Support

  • Models with multimodal capability are automatically detected by name patterns (e.g., llava, -vl-, vision)
  • When images are sent to a multimodal model, they are included as base64-encoded content blocks
  • When images are sent to a non-multimodal model, they are replaced with a placeholder: [Image omitted -- model does not support multimodal input: filename]
  • A warning modal is shown in the chat UI when uploading images to a non-multimodal model
  • Admins can override multimodal detection per model via the Models admin page

Streaming

Chat responses are streamed in real-time:

  • Backend streaming uses NDJSON (Ollama) or SSE (vLLM/OpenAI)
  • The chat UI renders tokens as they arrive
  • TTFT (time-to-first-token) is tracked for latency monitoring
  • If a backend request times out or the client disconnects, MindRouter closes the backend connection, signalling the backend to abort generation and free the slot

Conversation Retention

Conversations older than CONVERSATION_RETENTION_DAYS (default 730 days / 2 years) are automatically purged by a background task that runs every CONVERSATION_CLEANUP_INTERVAL seconds (default 86400 / once per day). Associated messages and attachments are deleted along with the conversation.


Voice (TTS / STT)

MindRouter provides OpenAI-compatible Text-to-Speech (TTS) and Speech-to-Text (STT) endpoints that proxy to self-hosted GPU services. Audio is processed entirely on institutional infrastructure — no data leaves the cluster.

Overview

  • TTS — Powered by Kokoro (or any service exposing an OpenAI-compatible /v1/audio/speech endpoint)
  • STT — Powered by faster-whisper / Speaches (or any service exposing an OpenAI-compatible /v1/audio/transcriptions endpoint)
  • Both endpoints require API key authentication (same keys used for chat/completions)
  • Each request is logged with its own modality (tts or stt) and a configurable token cost is deducted from the user's quota
  • The built-in Chat UI integrates both features: Read Aloud buttons on assistant messages (TTS) and a microphone button for voice input (STT)

API Endpoints

Text-to-Speech — POST /v1/audio/speech

Converts text to audio. The response is a streaming audio file.

Request body (JSON):

FieldTypeDefaultDescription
modelstring"kokoro"TTS model name (must match the upstream service)
inputstringrequiredThe text to synthesize
voicestring"af_heart"Voice ID (see available voices below)
response_formatstring"mp3"Audio format: mp3, wav, opus, or flac
speedfloat1.0Playback speed (0.25 – 4.0)

Example (curl):

curl -X POST https://mindrouter.example.com/v1/audio/speech \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "kokoro",
    "input": "Hello from MindRouter!",
    "voice": "af_heart",
    "response_format": "mp3",
    "speed": 1.0
  }' \
  -o output.mp3

Example (Python — OpenAI SDK):

from openai import OpenAI

client = OpenAI(
    base_url="https://mindrouter.example.com/v1",
    api_key="YOUR_API_KEY",
)

response = client.audio.speech.create(
    model="kokoro",
    voice="af_heart",
    input="Hello from MindRouter!",
    response_format="mp3",
    speed=1.0,
)
response.stream_to_file("output.mp3")

Response: Streaming audio bytes with Content-Type matching the requested format (e.g., audio/mpeg for mp3).

Speech-to-Text — POST /v1/audio/transcriptions

Transcribes an audio file to text.

Long-audio constraints. The upstream proxy timeout on this endpoint is 600 seconds (hardcoded in voice_api.py; the chat UI's own microphone proxy uses a shorter 120-second timeout). Upload size is bounded by the gateway's client_max_body_size (50m as shipped) — a one-hour MP3 at 128 kbps is roughly 57 MB and will be rejected with a 413 before it reaches the app. The whole file is buffered in memory before forwarding, there is no server-side segmentation (one file = one upstream request), and the quota cost is flat regardless of duration. For long recordings, split the audio client-side and submit each segment separately.

Request (multipart form):

FieldTypeDefaultDescription
filefile uploadrequiredAudio file (mp3, wav, webm, m4a, ogg, flac, etc.)
modelstringserver defaultSTT model name (omit to use the admin-configured default)
languagestringauto-detectISO 639-1 language code (e.g., en, es, fr)
response_formatstring"json"Output format (see table below)

Response formats:

FormatContent-TypeDescription
jsonapplication/jsonSimple JSON: {"text": "..."}
verbose_jsonapplication/jsonJSON with word-level timestamps, segments, language, and duration
texttext/plainPlain text transcription only
srttext/plainSubRip subtitle format with timestamps
vtttext/vttWebVTT subtitle format with timestamps

Example (curl):

curl -X POST https://mindrouter.example.com/v1/audio/transcriptions \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -F "file=@recording.mp3" \
  -F "language=en" \
  -F "response_format=verbose_json"

Example (Python — OpenAI SDK):

from openai import OpenAI

client = OpenAI(
    base_url="https://mindrouter.example.com/v1",
    api_key="YOUR_API_KEY",
)

with open("recording.mp3", "rb") as audio_file:
    transcript = client.audio.transcriptions.create(
        model="default",
        file=audio_file,
        language="en",
        response_format="verbose_json",
    )
print(transcript.text)

Response examples:

json:

{"text": "Hello from MindRouter!"}

verbose_json:

{"task": "transcribe", "language": "english", "duration": 3.42, "text": "Hello from MindRouter!",
 "segments": [{"id": 0, "start": 0.0, "end": 3.42, "text": "Hello from MindRouter!", ...}]}

srt:

1
00:00:00,000 --> 00:00:03,420
Hello from MindRouter!

vtt:

WEBVTT

00:00:00.000 --> 00:00:03.420
Hello from MindRouter!

List Voices — GET /api/tts-voices

Returns the available TTS voices. When the upstream Kokoro service is reachable, the list is fetched dynamically; otherwise it falls back to the admin-configured voice list.

ParameterTypeDefaultDescription
allowed_onlyboolfalseIf true, only return voices in the admin-configured allowed list

Response (JSON):

{"voices": ["af_bella", "af_heart", "am_adam", "am_michael"], "source": "upstream", "default_voice": "af_heart"}

Voices

Kokoro ships with multiple voices out of the box. The admin can restrict which voices are available to users via the Voice Config admin page. Some commonly used voices:

Voice IDGenderDescription
af_heartFemaleDefault voice — warm and natural
af_bellaFemaleClear and articulate
am_adamMaleNeutral male voice
am_michaelMaleDeeper male voice

The full list of available voices depends on the Kokoro version deployed. Use the GET /api/tts-voices endpoint to discover all available voices dynamically.

Chat UI Integration

When TTS and/or STT are enabled by an admin, the built-in chat interface at /chat gains voice features:

Read Aloud (TTS)

  • A Read aloud button appears below each assistant message
  • Clicking it streams the response text through the TTS service, sentence by sentence, playing audio sequentially
  • Markdown formatting (code blocks, links, headers, bold/italic) is automatically stripped before synthesis so only natural text is spoken
  • During streaming responses, TTS begins as soon as the first complete sentence arrives — it does not wait for the full response
  • Clicking the button again (or starting a new Read Aloud) stops the current playback
  • An animated border around the input area indicates when TTS is playing (cyan/blue/purple glow)

Voice Input (STT)

  • A microphone button appears in the input bar when STT is enabled
  • Clicking it requests browser microphone permission, then starts recording
  • The button turns red during recording and an animated border (magenta/rose/amber) indicates active capture
  • Clicking the button again stops recording, sends the audio to the STT service, and inserts the transcribed text into the message input
  • Audio format is negotiated automatically (prefers WebM/Opus, falls back to WebM or MP4)
  • The STT service timeout is 120 seconds to accommodate longer recordings or cold-start model loading

User Preferences

When TTS is enabled, users can customize their voice experience from their User Dashboard (/dashboard):

  • TTS Voice — Choose from the admin-allowed voice list, or leave as system default
  • TTS Speed — Slider from 0.5x to 2.0x playback speed, with a reset-to-default button

Per-user preferences are stored in the app_config table as user.{id}.tts_voice and user.{id}.tts_speed. They take priority over the system-wide defaults but can be overridden per-request via the API.

Admin Configuration

Admins manage voice settings from two locations:

Chat Config (/admin/chat-config)

High-level on/off toggles and system-wide defaults:

SettingConfig KeyDefaultDescription
TTS Enabledvoice.tts_enabledfalseMaster switch for TTS in the chat UI and API
TTS Providervoice.tts_provider"kokoro"Model name sent to the upstream TTS service
TTS Voicevoice.tts_voice"af_heart"System-wide default voice (lowest priority fallback)
TTS Speedvoice.tts_speed1.0System-wide default speed
STT Enabledvoice.stt_enabledfalseMaster switch for STT in the chat UI and API

Voice Config (/admin/voice-config)

Backend connection details and quota settings:

TTS Backend:

SettingConfig KeyDefaultDescription
Service URLvoice.tts_urlnoneBase URL of the TTS service (e.g., http://kokoro:8880)
API Keyvoice.tts_api_keynoneBearer token for the upstream TTS service (if required)
Available Voicesvoice_api.tts_voicesaf_heart, af_bella, am_adam, am_michaelNewline-separated list of voice IDs users can select
Default Voicevoice_api.default_voice"af_heart"Voice assigned to new users who haven't chosen one

STT Backend:

SettingConfig KeyDefaultDescription
Service URLvoice.stt_urlnoneBase URL of the STT service (e.g., http://whisper:8000)
API Keyvoice.stt_api_keynoneBearer token for the upstream STT service (if required)
Default Modelvoice.stt_model"whisper-large-v3-turbo"Whisper model passed to the upstream service

Quota:

SettingConfig KeyDefaultDescription
TTS tokens per requestvoice_api.tts_quota_tokens100Fixed token cost deducted from user quota for each TTS call
STT tokens per requestvoice_api.stt_quota_tokens200Fixed token cost deducted from user quota for each STT call

Voice requests use a fixed token cost model rather than counting actual tokens, since audio processing does not map cleanly to token counts. Adjust these values to control how heavily voice usage counts against a user's quota budget.

Voice Priority Resolution

When a TTS request is made (via the API or the Chat UI), the voice is resolved in the following order:

  1. Per-requestvoice field in the API request body
  2. Per-user preferenceuser.{id}.tts_voice (set from the user dashboard)
  3. Admin default voicevoice_api.default_voice (set in Voice Config)
  4. System fallbackvoice.tts_voice (set in Chat Config)
  5. Hardcoded fallback"af_heart"

Speed follows a similar chain: per-user preference → system default → 1.0.

Error Handling

HTTP StatusCondition
400Empty or blank input text (TTS)
404TTS or STT is not enabled by the admin
429User's token quota exceeded or RPM rate limit hit
500TTS/STT service URL not configured
502Upstream TTS/STT service returned an error, timed out, or is unreachable

Failed requests are recorded with the error message but do not deduct tokens from the user's quota.


Blog / CMS

MindRouter includes a built-in blog system with public viewing and admin content management. Blog posts are written in Markdown and rendered to HTML with syntax highlighting, tables, fenced code blocks, and table of contents support.

Public Blog

  • Blog listing at /blog — displays all published posts, most recent first
  • Individual posts at /blog/{slug} — renders the post's Markdown content as styled HTML
  • Posts are accessible without authentication

Admin Blog Management

Admin users can manage blog posts at /admin/blog. The admin interface provides a full CMS workflow:

ActionRouteDescription
List all postsGET /admin/blogView all posts (published, draft, and soft-deleted) with status indicators
New postGET /admin/blog/newForm to create a new blog post
Create postPOST /admin/blog/newSubmit new post with title, slug, Markdown content, excerpt, and publish toggle
Edit postGET /admin/blog/{id}/editEdit form for an existing post
Update postPOST /admin/blog/{id}/editSave changes to a post
Toggle publishPOST /admin/blog/{id}/publishPublish a draft or unpublish a live post
Delete postPOST /admin/blog/{id}/deleteSoft-delete a post (not permanently removed from the database)

Post Fields

  • Title — Display title of the post
  • Slug — URL-safe identifier (auto-generated from title or manually set). Used in the public URL as /blog/{slug}
  • Content — Full post body written in Markdown. Supports fenced code blocks with syntax highlighting, tables, and table of contents
  • Excerpt — Optional short summary shown in the blog listing
  • Published — Toggle for publish state. The published_at timestamp is set automatically when a post is first published
  • Author — Automatically set to the admin user who creates the post

Soft Delete

Deleting a post is a soft delete — the post is marked as deleted but remains in the database. Soft-deleted posts are hidden from the public blog listing but still visible in the admin list.

Blog Email Notifications

When publishing a blog post, admins can optionally send an email notification to all users who have opted in to blog email notifications. The email contains the full post rendered as HTML with a "Read on the Web" link. Users can manage their email preference from their dashboard settings.


Email System

MindRouter includes an email system for admin-to-user communication. Emails are sent asynchronously in the background and support personalization, HTML formatting, and recipient targeting.

SMTP Configuration

Email requires SMTP credentials configured via the Admin > Settings page. The following AppConfig keys control SMTP:

KeyDescription
email.smtp_hostSMTP server hostname
email.smtp_portSMTP server port (default: 587)
email.smtp_usernameSMTP authentication username
email.smtp_passwordSMTP authentication password
email.use_tlsEnable STARTTLS (default: true)
email.default_senderDefault "From" address for emails
email.blog_sender"From" address for blog notification emails (falls back to default_sender)
email.test_addressRecipient address for test emails

Composing Emails

Admin users can compose and send emails at /admin/email. The interface supports three recipient modes:

  • All Users — Send to every registered user
  • By Group — Send to all members of a selected group
  • Select Users — Search and pick individual recipients

Template Variables

The subject and body support personalization variables that are replaced per-recipient:

VariableReplaced With
{{first_name}}User's first name (from full_name)
{{last_name}}User's last name (from full_name)
{{username}}User's username
{{email}}User's email address

Email Delivery

Emails are sent asynchronously as a background task. A small throttle delay (50ms) is inserted between recipients to avoid overwhelming the SMTP server. Delivery progress is tracked in an email log visible at the bottom of the /admin/email page, showing status (sending, completed, failed), success/failure counts, and any error messages.

Test Emails

Before sending to real users, admins can send a test email to the configured test address to verify SMTP connectivity and email rendering.


AppConfig System

MindRouter stores runtime configuration in a key-value AppConfig database table. Values are JSON-encoded and can be read or written at any time without restarting the application. This powers several features that need runtime-adjustable settings.

API

Configuration is accessed via two async CRUD functions in backend/app/db/crud.py:

  • get_config_json(db, key, default) — Read a config value, JSON-decoded. Returns default if the key does not exist.
  • set_config(db, key, value, description=None) — Upsert a config value (JSON-encoded). Creates the key if it does not exist, updates it otherwise.

Known Configuration Keys

KeyTypeDefaultDescriptionManaged Via
app.base_urlstringfalls back to APP_BASE_URLPublic-facing base URL for this installation, written by the Site URL field on Admin > Settings. Read by the blog/feed URL builders; SSO redirect URIs still come from the APP_BASE_URL environment variable.Site Settings
app.timezonestringNoneDisplay timezone for the dashboard (e.g. "America/Los_Angeles")Site Settings
ollama.enforce_num_ctxbooltrueWhether MindRouter overrides user-supplied num_ctx valuesSite Settings
chat.core_modelslist[]Subset of models shown in the chat interface model selector. Empty means show all.Chat Configuration
chat.default_modelstringnullPre-selected model in the chat interfaceChat Configuration
chat.system_promptstringnullGlobal system prompt prepended to all chat conversations. Blank removes the override.Chat Configuration
chat.max_tokensint16384Default max tokens for chat requests (range: 256–131072)Chat Configuration
chat.temperaturefloatnullDefault temperature for chat requests (range: 0.0–2.0). Null means use model default.Chat Configuration
chat.thinkbool/stringnullDefault thinking mode for chat: true, false, "low", "medium", "high", or null (no default)Chat Configuration
catalog.auto_enrichboolfalseEnable automatic model description enrichmentSite Settings
catalog.enrich_modelstring""Model name used for LLM enrichment callsSite Settings
catalog.enrich_api_keystring""API key for internal MindRouter enrichment callsSite Settings
catalog.brave_api_keystring""Brave Search API key for enrichment (overrides env var)Site Settings
search.enabledbooltrueEnable/disable the /v1/search APISearch
search.providerstring"brave"Active search provider: brave or searxngSearch
search.max_resultsint10Default max results per search (clients can override up to 50)Search
search.quota_tokens_per_requestint50Fixed token cost deducted per search requestSearch
search.brave.api_keystring""Brave Search API key (falls back to BRAVE_SEARCH_API_KEY env var)Search
search.brave.endpointstringhttps://api.search.brave.com/...Brave Search API endpoint URLSearch
search.searxng.endpointstring""SearXNG instance URL (must have JSON format enabled)Search
email.smtp_hoststring""SMTP server hostnameSite Settings
email.smtp_portint587SMTP server portSite Settings
email.smtp_usernamestring""SMTP authentication usernameSite Settings
email.smtp_passwordstring""SMTP authentication passwordSite Settings
email.use_tlsbooltrueEnable STARTTLS for SMTPSite Settings
email.default_senderstring""Default "From" address for emailsSite Settings
email.blog_senderstring"""From" address for blog notification emailsSite Settings
email.test_addressstring""Recipient address for test emailsSite Settings

All AppConfig values take effect immediately upon save — no application restart is required. These settings are managed via the Admin Dashboard under Site Settings and Chat Configuration.


Configuration Reference

All settings are loaded from environment variables or .env / .env.prod files. Variable names are case-insensitive.

Application

VariableTypeDefaultDescription
APP_NAMEstrMindRouterApplication name
APP_VERSIONstr(from pyproject.toml)Application version (read dynamically at startup)
APP_BASE_URLstrhttps://your-domain.example.com (placeholder — must be set per deployment; Docker Compose passes it as ${APP_BASE_URL:-}, so an unset variable arrives empty and the placeholder never applies)Public HTTPS origin of this install. Used to build absolute URLs — notably SSO redirect URIs and the SAML Destination/Recipient check, which are derived from this value in preference to forwarded request headers. If it is left blank, those URLs fall back to the request's own scheme and Host header — and the two paths differ: the OIDC driver honors X-Forwarded-Proto when deriving the scheme, while the SAML adapter uses request.url.scheme and never reads it. Behind a TLS-terminating proxy a blank APP_BASE_URL therefore yields http:// SAML URLs. Keep it set.
DEBUGboolfalseEnable debug mode
RELOADboolfalseAuto-reload on code changes (development)

Database

VariableTypeDefaultDescription
DATABASE_URLstrmysql+pymysql://...MariaDB/MySQL connection string
DATABASE_POOL_SIZEint30Connection pool size
DATABASE_MAX_OVERFLOWint20Max overflow connections beyond pool
DATABASE_ECHOboolfalseLog SQL queries
ARCHIVE_DATABASE_URLstrNoneArchive MariaDB connection string (optional — enables tiered archival)

Cache

VariableTypeDefaultDescription
REDIS_URLstrNoneRedis connection string (optional)

Security

VariableTypeDefaultDescription
SECRET_KEYstrdev-secret-key-...JWT/session signing key (change in production)
JWT_ALGORITHMstrHS256JWT signing algorithm
JWT_EXPIRATION_HOURSint24JWT token lifetime
SESSION_COOKIE_NAMEstrmindrouter_sessionSession cookie name
SESSION_COOKIE_SECUREboolfalseHTTPS-only cookies
SESSION_COOKIE_HTTPONLYbooltrueJavaScript-inaccessible cookies
SESSION_COOKIE_SAMESITEstrlaxSameSite cookie policy
API_KEY_HASH_ALGORITHMstrargon2API key hashing algorithm

Single Sign-On (SSO)

MindRouter supports four SSO providers for the web dashboard: Azure AD / Entra ID, SAML 2.0, generic OIDC, and Google. Each is enabled purely by environment variables — there is no admin-UI toggle — and any subset can be enabled simultaneously. The login page renders one button per enabled provider, in this fixed order: Azure AD, SAML, generic OIDC, Google.

Local username/password accounts are always available. SSO never disables the local login form; when at least one provider is enabled the form collapses under a “Sign in with a local account” toggle. With no provider configured, /login is simply the local login form. The complete administrator guide (step-by-step IdP registration, worked examples, troubleshooting) is docs/sso-configuration.md.

SSO governs interactive dashboard sign-in only. Programmatic access to the inference and admin APIs always uses API keys (see Authentication); an SSO user creates API keys from the user dashboard after signing in.

Enablement Rules and Routes

ProviderEnabled whenRoutes
Azure AD / Entra IDAZURE_AD_CLIENT_ID and AZURE_AD_TENANT_ID are set (the client secret is still required for the token exchange to succeed)GET /login/azure
GET /login/azure/authorized
SAML 2.0SAML_SP_ENTITY_ID and either SAML_IDP_METADATA_URL or all three of SAML_IDP_ENTITY_ID + SAML_IDP_SSO_URL + SAML_IDP_X509_CERTGET /login/saml
POST /login/saml/acs
GET /saml/metadata
Generic OIDCOIDC_SSO_ISSUER and OIDC_SSO_CLIENT_ID and OIDC_SSO_CLIENT_SECRET are setGET /login/oidc
GET /login/oidc/authorized
GoogleGOOGLE_SSO_CLIENT_ID and GOOGLE_SSO_CLIENT_SECRET are setGET /login/google
GET /login/google/authorized

GET /saml/metadata is the SP metadata XML endpoint an administrator registers with their IdP or federation (entity ID, ACS URL, NameID policy). It returns 404 when SAML is not configured. A missing python3-saml dependency reports 501 (“SAML support is not installed”) only in the explicit entity-ID / SSO-URL / certificate configuration: when SAML_IDP_METADATA_URL is set, the metadata parser import fails first, settings assembly returns nothing, and the endpoint answers 404 instead. So the two status codes are not a reliable way to tell “not configured” from “not installed” — check the application logs.

SSO configuration is process-level and cached per worker, so restart the application after changing any SSO variable. OIDC discovery documents and SAML IdP metadata are additionally cached in-process for 1 hour.

Azure AD / Entra ID

VariableTypeDefaultDescription
AZURE_AD_CLIENT_IDstrNoneAzure AD application (client) ID
AZURE_AD_CLIENT_SECRETstrNoneAzure AD client secret
AZURE_AD_TENANT_IDstrNoneAzure AD tenant ID
AZURE_AD_REDIRECT_URIstrhttps://your-domain.example.com/login/azure/authorized (placeholder)OAuth2 redirect URI. Absolute URL that must match the app registration exactly — unlike the other providers, it is not derived from APP_BASE_URL. The default is only a placeholder, and under Docker Compose it is passed as ${AZURE_AD_REDIRECT_URI:-}, so an unset variable arrives as an empty string and the placeholder never applies — always set this explicitly.
AZURE_AD_DEFAULT_GROUPstrotherGroup for new Azure AD users when the jobTitle mapping below finds no match

Azure AD is the one provider with directory-driven group assignment, and it keeps its own legacy identity column (users.azure_oid) and its own driver; the other three share users.sso_provider / users.sso_subject. The driver is separate, but its account-linking behavior is not: it applies the same unclaimed-account-only email guard described under JIT Provisioning and Account Linking. Genuinely unclaimed accounts (no azure_oid, no sso_provider — a local password account, for instance) still link exactly as before; the one operator-visible change is that an Azure login whose email matches an account carrying a different azure_oid now fails rather than silently rebinding. Entra object IDs are stable, so this is rare in practice.

Azure AD Group Mapping

Group assignment uses case-insensitive substring matching on the user's jobTitle claim from Microsoft Graph:

  • jobTitle contains "student"students group
  • jobTitle contains "faculty" or "professor"faculty group
  • jobTitle contains "staff"staff group
  • No match → falls back to AZURE_AD_DEFAULT_GROUP (default: other)

SAML 2.0 (Shibboleth, InCommon direct, ADFS)

VariableTypeDefaultDescription
SAML_SP_ENTITY_IDstrNoneService-provider entity ID for this MindRouter install (required)
SAML_SP_ACS_URLstrderivedAssertion Consumer Service URL. Defaults to <APP_BASE_URL>/login/saml/acs.
SAML_IDP_METADATA_URLstrNoneIdP metadata URL — must be https (a plain-http value disables the provider). Entity ID, SSO URL, and signing certificate are read from it.
SAML_IDP_ENTITY_IDstrNoneIdP entity ID — explicit alternative to metadata fetching
SAML_IDP_SSO_URLstrNoneIdP single sign-on (redirect) endpoint
SAML_IDP_X509_CERTstrNoneIdP signing certificate (PEM body). Pinning the trio above is the stronger trust anchor.
SAML_DISPLAY_NAMEstrSSOButton label — “Sign in with <this>”
SAML_DEFAULT_GROUPstrotherGroup for JIT-provisioned SAML users (the group must already exist)
SAML_ATTR_EMAILstrmailAssertion attribute carrying the email address
SAML_ATTR_NAMEstrdisplayNameAssertion attribute carrying the display name
SAML_ATTR_USERNAMEstreduPersonPrincipalNameAssertion attribute used as the username hint

The attribute-mapping defaults follow eduPerson / InCommon conventions, so a stock Shibboleth IdP releasing mail, displayName, and eduPersonPrincipalName needs no mapping overrides. Register GET /saml/metadata with the IdP rather than hand-writing SP metadata.

What the IdP must sign: MindRouter sets wantAssertionsSigned: True and wantMessagesSigned: False — the assertion must be signed, but a message-level (response-level) signature is not required. An IdP that signs only the response envelope and not the assertion will be rejected.

SAML requires HTTPS. The IdP returns the assertion by cross-site HTTP-POST to the ACS, and browsers do not send SameSite=Lax cookies on a cross-site POST — so the saml_request_id AuthnRequest-ID cookie is issued with SameSite=None; Secure. A Secure cookie is not stored over plain http, so SAML cannot be exercised over a plain-http development URL: every login would arrive at the ACS without the cookie and be rejected as unsolicited. Serve the dashboard over TLS (and set APP_BASE_URL to the https:// origin) before enabling SAML.

The IdP must release a persistent (or otherwise stable) NameID. MindRouter asks for urn:oasis:names:tc:SAML:2.0:nameid-format:persistent in its AuthnRequest, but it does not verify the format that comes back. If the IdP emits transient or rotating NameIDs, a user signs in successfully once — which stamps sso_provider / sso_subject on the account — and is then permanently refused: every later login misses on the new subject, falls back to the email match, finds sso_provider already set, and is rejected as sso_email_link_refused. Configure a persistent NameID (or a stable ePPN) on the IdP side before rolling SAML out.

Generic OIDC (Okta, Keycloak, Auth0, CILogon, ...)

VariableTypeDefaultDescription
OIDC_SSO_ISSUERstrNoneIssuer base URL. Endpoints are read from <issuer>/.well-known/openid-configuration, fetched on first login and cached in-process for 1 hour.
OIDC_SSO_CLIENT_IDstrNoneOAuth2 client ID
OIDC_SSO_CLIENT_SECRETstrNoneOAuth2 client secret
OIDC_SSO_REDIRECT_URIstrderivedDefaults to <APP_BASE_URL>/login/oidc/authorized
OIDC_SSO_DISPLAY_NAMEstrSSOButton label — “Sign in with <this>”
OIDC_SSO_SCOPESstropenid profile emailSpace-separated scopes requested at authorization
OIDC_SSO_DEFAULT_GROUPstrotherGroup for JIT-provisioned OIDC users (the group must already exist)

Claims used: sub (stable subject), email (required), name, and preferred_username. The IdP must publish a userinfo endpoint. Any spec-compliant OpenID Connect provider works.

InCommon via CILogon (recommended InCommon path)

CILogon is an OIDC gateway fronting the entire InCommon federation, so one client registration replaces per-campus SAML metadata exchange. Configure the generic OIDC provider with OIDC_SSO_ISSUER=https://cilogon.org, a client registered at cilogon.org whose callback is <APP_BASE_URL>/login/oidc/authorized, and OIDC_SSO_DISPLAY_NAME=InCommon. Users then pick their home institution on the CILogon discovery page. Native SAML remains available for organizations that require direct federation with their own Shibboleth IdP.

Google (Workspace or consumer accounts)

VariableTypeDefaultDescription
GOOGLE_SSO_CLIENT_IDstrNoneGoogle OAuth2 client ID
GOOGLE_SSO_CLIENT_SECRETstrNoneGoogle OAuth2 client secret
GOOGLE_SSO_REDIRECT_URIstrderivedDefaults to <APP_BASE_URL>/login/google/authorized
GOOGLE_SSO_HOSTED_DOMAINstrNoneRestrict sign-in to one Google Workspace domain. Passed as hd= on the authorize request and enforced against the hd claim server-side on callback.
GOOGLE_SSO_DEFAULT_GROUPstrotherGroup for JIT-provisioned Google users (the group must already exist)

The Google button label is always “Sign in with Google”.

Login Button Labels (Branding tie-in)

Button wording comes from Admin → Branding → “Institution / organization name” (config key branding.org_name, optional, max 120 characters), not from the provider's product name:

  • Azure AD is treated as the primary/institutional provider: its button reads “Sign in with <organization name>”, falling back to “Sign in with SSO” when no organization name is set.
  • SAML and generic OIDC use the organization name only when they are the primary provider and their *_DISPLAY_NAME is still the default; otherwise they use their own display name.
  • Google is always “Google”.
  • The helper text reads “Use your <organization name, or ‘organization’> credentials to sign in.”

JIT Provisioning and Account Linking

  • Just-in-time provisioning — a user who has never signed in is created on first successful login. The new account gets no password hash — it is SSO-only and cannot use the local login form, and there is currently no admin UI or API for adding a local password to it (that would take a separate local account or direct database access) — lands in that provider's *_DEFAULT_GROUP, and has its quota seeded from that group. The group must already exist — create it on the admin Groups page first.
  • Username generation — the local part of the username hint (ePPN / preferred_username) or of the email address, with the first 8 characters of the subject appended on collision.
  • Lookup order — MindRouter matches on (provider, subject) first, then falls back to email.
  • Linking is restricted to unclaimed accounts — an email match is adopted only if that account carries no identity at all: azure_oid unset and sso_provider unset. The check is on any value, not on a different one, so the same provider presenting a new subject is refused too. Otherwise the login is refused and logged as sso_email_link_refused. Unclaimed accounts — notably local password accounts — are linked, and the local password is retained so both login methods keep working. This guard applies to the Azure driver as well as to the shared Google / OIDC / SAML path.
  • A rotating IdP subject locks a user out after their first login. Because the guard fires on any set sso_provider, an IdP that issues transient or rotating subjects (a SAML transient NameID, for example) lets a user in once and then refuses every subsequent attempt: the new subject misses, the email match hits, and the account is already claimed. Configure the IdP to release a persistent (or otherwise stable) NameID / subject.
  • Clearing a stale identity requires direct database access. There is no admin UI or API for resetting azure_oid / sso_provider / sso_subject on a user row, so moving a user between providers — or recovering an account stranded by a rotated subject — means editing the users table directly.
  • Deactivated accounts (is_active = false) are refused at login regardless of provider.

SSO Security Posture

  • Unclaimed-account-only linking. Email is an IdP-supplied attribute, not proof of ownership. Without this rule, any enabled IdP could assert an existing user's address (including an administrator's) and inherit that account. Refusals are recorded as sso_email_link_refused.
  • Emails are rejected when the IdP sends email_verified and it is not true (OIDC and Google); IdPs that omit the claim entirely are trusted. When the claim is present it is normalized, so string forms such as "false" or "0" cannot fail open.
  • OIDC CSRF protection — a signed, timed state token (10-minute lifetime) is round-tripped through an HttpOnly cookie and validated on every callback.
  • SAML is SP-initiated only. The ACS requires a signed saml_request_id cookie issued by /login/saml, and the response's InResponseTo must echo that AuthnRequest ID. Consequence: IdP-initiated login is not supported — launching MindRouter from a campus app-portal tile will not work; users must start at the MindRouter login page. (This is enforced in MindRouter because python3-saml has no unsolicited-response setting and skips its own InResponseTo comparison when the attribute is absent.)
  • SAML requires HTTPS. That saml_request_id cookie is set SameSite=None; Secure, because the IdP delivers the assertion by cross-site HTTP-POST to the ACS and browsers withhold SameSite=Lax cookies on cross-site POSTs. Since a Secure cookie is never stored over plain http, SAML cannot be exercised over a plain-http dev URL — the ACS would see no cookie and reject every login as unsolicited.
  • A stable IdP subject is a security requirement, not just an ergonomic one. MindRouter requests a persistent NameID but does not verify the returned format; with a transient or rotating subject, a user is refused on every login after their first (see Linking is restricted to unclaimed accounts), and only direct database access can clear the stale identity.
  • Signed assertions, no SHA-1. Strict mode requires signed assertions (wantAssertionsSigned; message-level signing is not required), and rejectDeprecatedAlgorithm blocks SHA-1 signatures.
  • Both SSO cookies are signed with SECRET_KEY. The OIDC state cookie and the SAML saml_request_id cookie share the same signer, so a weak or leaked SECRET_KEY weakens SSO CSRF and SP-initiated-only enforcement. Rotating it invalidates in-flight logins — users mid-redirect must start over.
  • IdP metadata must be HTTPS. The metadata document carries the signing certificate — the only trust anchor — so a plain-http SAML_IDP_METADATA_URL disables the provider. For a stronger anchor, pin the certificate locally with the explicit SAML_IDP_ENTITY_ID / SAML_IDP_SSO_URL / SAML_IDP_X509_CERT trio instead of fetching metadata.
  • Public URLs are derived from APP_BASE_URL rather than from request headers — keep it set. When APP_BASE_URL is configured, a spoofed X-Forwarded-Host / X-Forwarded-Proto cannot influence OIDC redirect URIs or SAML Destination / Recipient validation. If it is left blank, the code falls back to the request's own scheme and Host header — the OIDC path consults X-Forwarded-Proto, the SAML adapter does not — which is exactly why leaving it set matters.

Installing with SSO Support

  • Docker (recommended) — the shipped image already includes everything. The Dockerfile installs libxmlsec1-dev / libxmlsec1-openssl and runs pip install -e .[saml]; xmlsec and lxml resolve as prebuilt wheels on both amd64 and arm64, so nothing compiles.
  • Bare metal / venv — SAML support is an optional extra: pip install -e .[saml] (install libxmlsec1-dev first). Without it, Azure AD, Google, and OIDC work normally and the SAML routes degrade with a clear “SAML support is not installed” error rather than crashing.
  • Set APP_BASE_URL to the public HTTPS origin. This is required for correct SSO: OIDC redirect URIs and the SAML Destination / Recipient check are derived from it.
  • Docker Compose — every SSO variable is already wired through the environment: block; production values live in the host .env file.
  • Database — migration 068 adds users.sso_provider and users.sso_subject. Run alembic upgrade head after upgrading.
  • Restart the application after changing any SSO variable.

SSO Scope and Limits

  • One IdP per provider type — a single generic-OIDC issuer and a single SAML IdP (use CILogon if you need many institutions behind one registration).
  • SAML — SP-initiated only, no Single Logout (SLO), and no exposed configuration for encrypted assertions.
  • No SCIM / directory sync and no group-claim-driven authorization — group assignment comes from the provider's *_DEFAULT_GROUP (Azure AD's jobTitle mapping is the one exception). Ongoing group and role changes are managed in MindRouter, not pushed from the IdP.
  • Azure AD keeps its own legacy identity column (users.azure_oid) and its own driver; the other three providers share users.sso_provider / users.sso_subject.

Web Search (Optional)

VariableTypeDefaultDescription
BRAVE_SEARCH_API_KEYstrNoneBrave Search API key — used as fallback for both the chat web search toggle and the /v1/search API if no key is configured in Admin → Search
BRAVE_SEARCH_MAX_RESULTSint5Maximum search results to inject as context (chat toggle only)

When configured, a search toggle appears in the chat input area. Enabling it queries the Brave Search API with the user's message and injects results into the system prompt as additional context.

The standalone /v1/search API (see Web Search) is configured separately via Admin → Search and supports multiple providers (Brave, SearXNG). The BRAVE_SEARCH_API_KEY env var serves as a fallback if no key is set in the admin UI.

Conversation Retention

VariableTypeDefaultDescription
CONVERSATION_RETENTION_DAYSint730Conversation retention period (2 years)
CONVERSATION_CLEANUP_INTERVALint86400Cleanup interval in seconds (24 hours)

Artifact Storage

VariableTypeDefaultDescription
ARTIFACT_STORAGE_PATHstr/data/artifactsFile storage directory
ARTIFACT_MAX_SIZE_MBint50Max artifact file size
ARTIFACT_RETENTION_DAYSint365Artifact retention period

Quotas

Quota defaults are now managed per-group in the database via /admin/groups. The environment variables below are deprecated (used only for initial migration seeding) and will be removed in a future release.

VariableTypeDefaultDescription
DEFAULT_TOKEN_BUDGET_STUDENTint100000Deprecated — use group defaults
DEFAULT_TOKEN_BUDGET_STAFFint500000Deprecated — use group defaults
DEFAULT_TOKEN_BUDGET_FACULTYint1000000Deprecated — use group defaults
DEFAULT_TOKEN_BUDGET_ADMINint10000000Deprecated — use group defaults
DEFAULT_RPM_STUDENTint30Deprecated — use group defaults
DEFAULT_RPM_STAFFint60Deprecated — use group defaults
DEFAULT_RPM_FACULTYint120Deprecated — use group defaults
DEFAULT_RPM_ADMINint1000Deprecated — use group defaults

Scheduler

Scheduler weights are now managed per-group in the database. The per-role weight variables below are deprecated and will be removed in a future release.

VariableTypeDefaultDescription
SCHEDULER_WEIGHT_STUDENTint1Deprecated — use group scheduler_weight
SCHEDULER_WEIGHT_STAFFint2Deprecated — use group scheduler_weight
SCHEDULER_WEIGHT_FACULTYint3Deprecated — use group scheduler_weight
SCHEDULER_WEIGHT_ADMINint10Deprecated — use group scheduler_weight
SCHEDULER_FAIRNESS_WINDOWint300Fairness tracking window (seconds)
SCHEDULER_DEPRIORITIZE_THRESHOLDfloat0.5Usage threshold for deprioritization
SCHEDULER_SCORE_MODEL_LOADEDint100Score bonus for pre-loaded model
SCHEDULER_SCORE_LOW_UTILIZATIONint50Score bonus for low GPU utilization
SCHEDULER_SCORE_LATENCYint40Score factor for low latency
SCHEDULER_SCORE_SHORT_QUEUEint30Score factor for short queue
SCHEDULER_SCORE_HIGH_THROUGHPUTint20Score factor for high throughput

Latency Tracking

VariableTypeDefaultDescription
LATENCY_EMA_ALPHAfloat0.3EMA smoothing factor
LATENCY_EMA_PERSIST_INTERVALint30EMA persistence interval (seconds)

Backend Registry

VariableTypeDefaultDescription
BACKEND_POLL_INTERVALint30Health check interval (seconds)
BACKEND_HEALTH_TIMEOUTint5Health check timeout (seconds)
BACKEND_UNHEALTHY_THRESHOLDint3Failed checks before marking unhealthy
BACKEND_CIRCUIT_BREAKER_THRESHOLDint3Failures before circuit opens
BACKEND_CIRCUIT_BREAKER_RECOVERY_SECONDSint30Circuit breaker recovery time
BACKEND_ADAPTIVE_POLL_FAST_INTERVALint10Fast poll interval after unhealthy
BACKEND_ADAPTIVE_POLL_FAST_DURATIONint120Duration of fast polling (seconds)

Request Handling

VariableTypeDefaultDescription
MAX_REQUEST_SIZEint52428800Inert. Defined in settings.py but nothing in the application reads it, so the app enforces no global body limit. The effective limit is the reverse proxy's client_max_body_size (50m in nginx/nginx.conf); raise that, not this.
BACKEND_REQUEST_TIMEOUTint300Total request timeout (seconds)
BACKEND_REQUEST_TIMEOUT_PER_ATTEMPTint180Per-attempt timeout (seconds, high for large model prefills)
BACKEND_RETRY_MAX_ATTEMPTSint3Max total retry attempts
STRUCTURED_OUTPUT_RETRY_ON_INVALIDbooltrueRetry on invalid structured output

Feature Flags

VariableTypeDefaultDescription
RESPONSES_API_ENABLEDbooltrueMaster switch for /v1/responses and the entire /v1/conversations surface — every route in both routers checks it and returns 404 when false
THINKING_OFF_BY_DEFAULTbooltrueGateway policy: reasoning/thinking is forced off unless the client explicitly opts in (think: true, thinking: {type: "enabled"}, or chat_template_kwargs.enable_thinking). Models whose name contains gpt-oss are exempt. Set false to restore per-model launch defaults. Not currently forwarded by docker-compose.yml, so it runs at this default under that stack.

Logging

VariableTypeDefaultDescription
LOG_LEVELstrINFOLog level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
LOG_FORMATstrjsonLog format (json or text)
LOG_FILEstrNoneLog file path (optional, stdout if not set)

Audit Logging

VariableTypeDefaultDescription
AUDIT_LOG_ENABLEDbooltrueEnable audit logging
AUDIT_LOG_PROMPTSbooltrueLog user prompts
AUDIT_LOG_RESPONSESbooltrueLog LLM responses

Telemetry & GPU

VariableTypeDefaultDescription
TELEMETRY_RETENTION_DAYSint30Telemetry data retention period
TELEMETRY_CLEANUP_INTERVALint3600Cleanup interval (seconds)
SIDECAR_TIMEOUTint15Sidecar HTTP call timeout (seconds)

Observability

VariableTypeDefaultDescription
METRICS_ENABLEDbooltrueEnable Prometheus metrics
METRICS_PREFIXstrmindrouterMetrics name prefix
OTEL_ENABLEDboolfalseEnable OpenTelemetry
OTEL_EXPORTER_OTLP_ENDPOINTstrNoneOpenTelemetry exporter endpoint

CORS

VariableTypeDefaultDescription
CORS_ORIGINSlist["http://localhost:3000", "http://localhost:8000"]Allowed origins (JSON array or comma-separated)

Chat UI

VariableTypeDefaultDescription
CHAT_FILES_PATHstr/data/chat_filesChat file upload directory
CHAT_UPLOAD_MAX_SIZE_MBint10Max upload file size (MB)
CHAT_UPLOAD_ALLOWED_EXTENSIONSlistSee belowAllowed upload file extensions

Default allowed extensions: .txt, .md, .csv, .json, .html, .htm, .log, .docx, .xlsx, .pdf, .jpg, .jpeg, .png, .gif, .webp

Tokenizer

VariableTypeDefaultDescription
DEFAULT_TOKENIZERstrcl100k_baseDefault tokenizer encoding

Status Enums Reference

MindRouter uses the following status enumerations throughout the system:

BackendStatus

  • HEALTHY — Backend is online and accepting requests
  • UNHEALTHY — Backend is failing health checks
  • DISABLED — Backend has been manually disabled by an admin
  • DRAINING — Backend is finishing in-flight requests but not accepting new ones
  • UNKNOWN — Backend status has not yet been determined

NodeStatus

  • ONLINE — Node sidecar is reachable and reporting metrics
  • OFFLINE — Node sidecar is unreachable
  • UNKNOWN — Node status has not yet been determined

RequestStatus

  • QUEUED — Request is waiting for a backend slot
  • PROCESSING — Request is being handled by a backend
  • COMPLETED — Request finished successfully
  • FAILED — Request encountered an error
  • CANCELLED — Request was cancelled (e.g., client disconnect)

Production Security Hardening

When deploying MindRouter to production, apply the following security measures:

  • Secure session cookies — Set SESSION_COOKIE_SECURE=True to ensure session cookies are only sent over HTTPS.
  • Security headers — Add Strict-Transport-Security (HSTS), X-Frame-Options, and Content-Security-Policy (CSP) headers at the reverse proxy layer (e.g., nginx).
  • Restrict CORS origins — Set CORS_ORIGINS to only the specific domains that need API access. Do not use * in production.

Transparent Data Encryption (TDE)

MindRouter enables MariaDB Transparent Data Encryption to protect data at rest. TDE encrypts all InnoDB tablespaces, redo logs, and temporary files on disk using AES-256. Data is decrypted transparently when accessed through the MySQL protocol — no application code changes required.

What TDE protects

  • InnoDB data files (.ibd) containing chat messages, audit logs, and attachment text
  • InnoDB redo logs (write-ahead logs)
  • Temporary files created during query execution

What TDE does NOT protect: Anyone with valid database credentials can still read plaintext data via SQL queries. TDE guards against stolen disks, leaked backups, and unauthorized filesystem access.

Generating encryption keys

# From the project root:
bash scripts/generate_encryption_keys.sh

This generates independent key sets for the primary and archive databases under mariadb/encryption/. The script is idempotent — it skips generation if keys already exist.

Key backup

Critical: Back up the encryption key files to a secure offline location separate from the database backups. Without the keys, encrypted data is permanently unrecoverable.

  • mariadb/encryption/primary/keyfile.key + keyfile.enc
  • mariadb/encryption/archive/keyfile.key + keyfile.enc

Verifying encryption is active

# Check which tablespaces are encrypted (ENCRYPTION_SCHEME=1 means encrypted):
SELECT NAME, ENCRYPTION_SCHEME
FROM information_schema.INNODB_TABLESPACES_ENCRYPTION
WHERE ENCRYPTION_SCHEME = 1;

After enabling TDE and restarting MariaDB, background encryption threads will encrypt existing tablespaces in-place. This happens automatically and does not block normal queries.


Data Loss Prevention (DLP)

MindRouter includes a built-in DLP system that scans prompts and responses for sensitive data including PII, HIPAA/PHI, FERPA, PCI, and CUI. Scanning happens asynchronously after the response is delivered, so there is zero impact on inference latency. DLP is a passive, bump-on-the-wire system — it never blocks, delays, or modifies requests or responses.

Architecture

After a request completes and the response is sent to the client, the request ID is enqueued (non-blocking) to a bounded in-memory queue. A background worker drains the queue and runs the configured scanners against the stored request/response text. If sensitive data is found, an alert is written to the dlp_alerts table and email notifications are sent if configured.

Request completes → enqueue request_id (non-blocking, ~100ns)
                         |
                    DLP Background Worker (separate task)
                         |
              +----------+----------+
              |          |          |
         Regex/KW    GLiNER      LLM Scanner
         (~0.1ms)    (~10ms)    (~500ms, self-routed)
              |          |          |
              +----------+----------+
                         |
                  Merge findings → Classify severity → Write alert → Email

Scanning Tiers

TierEngineLatencyDetectsBest for
Regex & Keywords Built-in regex patterns + custom patterns + keyword list ~0.1ms SSNs, credit cards, emails, phone numbers, custom patterns Known formats with fixed structure. High precision, no false positives.
GLiNER NER Neural entity recognition (urchade/gliner_multi_pii-v1, CPU) ~10ms Names, dates of birth, passport/license numbers, medical records, student IDs Entities in natural language that don't match fixed patterns. Zero-shot — can detect custom entity types by name.
LLM Contextual Dedicated LLM, self-routed via /v1/chat/completions ~500ms FERPA violations, HIPAA/PHI context, CUI, semantic sensitive data Contextual understanding — e.g. detecting that "John got a B+ in CS101" is a FERPA violation even though no single word is PII.

Severity Levels

LevelMeaningDefault categoriesAction
Major High-risk data exposure SSN, credit card, bank account, passport, medical record Immediate attention. Email sent to major recipients.
Moderate Potential compliance issue Driver license, date of birth, student ID Review recommended. Email sent to moderate recipients.
Minor Low-risk, informational Person name, email address, phone number Logged for audit. Email sent to minor recipients (if configured).

The highest severity among all findings in a single scan determines the alert level. Categories not mapped in severity rules default to Moderate.

Admin Configuration

Configure DLP in the admin dashboard at Admin → DLP. Each section has a help button with detailed instructions and examples.

  • Master toggle — Enable or disable all DLP scanning. When disabled, the background worker skips all processing (zero overhead).
  • Regex & Keyword Scanner — Toggle the fast pattern matcher. Add custom regex patterns with a visual builder (name, regex, category, severity). Add keywords one per line.
  • GLiNER NER Scanner — Toggle neural entity recognition. Adjust the confidence threshold (0.1–0.95). Check/uncheck built-in entity categories. Add custom entity categories for domain-specific detection (e.g. "vandal id", "grant number").
  • LLM Contextual Scanner — Toggle LLM-based semantic analysis. Choose the model (e.g. qwen/qwen3.5-4b). Customize the system prompt for your compliance regime (FERPA, HIPAA, CUI).
  • Severity Rules — Visual mapping of entity categories to severity levels. Add new mappings with the inline form. Remove mappings you don't need.
  • Email Notifications — Set email recipients per severity level (comma-separated). Requires SMTP to be configured in Settings.

Custom Entity Categories

You can extend DLP detection beyond the built-in categories:

  • Regex patterns — Click "Add Pattern" in the Regex scanner section. Provide a name, Python regex, category name, and default severity. Example: Name="Vandal ID", Regex=\bV\d{8}\b, Category="vandal id".
  • GLiNER categories — Type a descriptive category name in the "Add custom category" field and click Add. GLiNER is zero-shot and can detect entity types by description alone.
  • Severity rules — After adding a custom category, add a corresponding severity rule so the system knows how to classify findings in that category.

Self-Routing Loop Prevention

When the LLM scanner is enabled, MindRouter automatically creates a dedicated internal API key. The LLM scanner uses this key to call /v1/chat/completions on itself. The DLP worker checks the API key on each incoming request — if it matches the internal key, the scan is skipped. This prevents an infinite scanning loop where the DLP scan triggers another DLP scan.

Alerts & Acknowledgment

All DLP findings are stored in the dlp_alerts table and displayed on the Admin → DLP page. Alerts can be filtered by severity, scanner type, and free-text search. Admins can acknowledge alerts to mark them as reviewed. Stats cards show 24-hour alert counts, severity breakdown, average scan latency, and unacknowledged count. Auditors have read-only access to the alerts table.

Performance & Reliability

  • Zero latency impact — The only code in the request path is queue.put_nowait(request_id) (~100ns) wrapped in try/except. It cannot slow down or break inference.
  • Bounded queue — The queue holds up to 10,000 items. If full (e.g. during a traffic spike), new items are silently dropped with a log warning. Best-effort scanning.
  • Crash isolation — The DLP worker runs as an independent asyncio task with its own DB session. Any crash is caught, logged, and the worker continues processing the next item.
  • GLiNER lazy loading — The ~200MB model loads on first scan (~2-3s), then stays in memory. Subsequent scans take ~10ms on CPU.
  • Text truncation — The LLM scanner truncates input to 8,000 characters to limit token usage and cost.
  • Image content skipped — Multimodal image inputs are automatically excluded from scanning. Only text content (messages, prompts, responses) is scanned.

Deployment

MindRouter is designed for deployment on Linux servers with NVIDIA GPUs. The full deployment guide covers:

  • Rocky Linux 8 prerequisites and dependency installation
  • SSL/TLS configuration (self-signed and Let's Encrypt)
  • Apache reverse proxy setup
  • Firewall and SELinux configuration
  • Docker Compose production stack
  • Database migrations
  • GPU sidecar agent deployment
  • Node and backend registration
  • Verification and ongoing operations

Docker Compose Services

The production stack consists of the following containers:

ServicePortDescription
app8000Main FastAPI application (multi-worker)
mcp8001MCP SSE server (single-worker — required for session affinity)
mariadb3306Primary MariaDB database
mariadb-archive3307Archive database for tiered data retention
redis6379Redis for token counters and caching

The mcp service runs the MCP SSE server as a dedicated single-worker process. The MCP SSE transport stores sessions in memory, so it must run with exactly one worker to ensure that SSE connections and subsequent POST messages are handled by the same process. The main app service proxies all /mcp/* requests to this container transparently.

GPU Sidecar Deployment

The GPU sidecar agent runs on each inference node to expose per-GPU hardware metrics and enable auto-discovery of inference endpoints. Build and deploy directly from GitHub — no need to clone the repository.

Create the env file (once per node)

sudo mkdir -p /etc/mindrouter
python3 -c "import secrets; print('SIDECAR_SECRET_KEY=' + secrets.token_hex(32))" | sudo tee /etc/mindrouter/sidecar.env
sudo chmod 600 /etc/mindrouter/sidecar.env

Build a specific version

docker build -t mindrouter-sidecar:v2.0.0 \
  -f Dockerfile.sidecar \
  https://github.com/ui-insight/MindRouter.git#v2.0.0:sidecar

Build latest from master

docker build -t mindrouter-sidecar:latest \
  -f Dockerfile.sidecar \
  https://github.com/ui-insight/MindRouter.git:sidecar

Run the sidecar

# Run bound to localhost only (nginx will proxy external traffic)
docker run -d --name gpu-sidecar \
  --gpus all \
  -p 127.0.0.1:18007:8007 \
  --env-file /etc/mindrouter/sidecar.env \
  --restart unless-stopped \
  mindrouter-sidecar:v2.0.0

Upgrade to a new version

docker build -t mindrouter-sidecar:v2.0.0 \
  -f Dockerfile.sidecar \
  https://github.com/ui-insight/MindRouter.git#v2.0.0:sidecar
docker stop gpu-sidecar && docker rm gpu-sidecar
docker run -d --name gpu-sidecar \
  --gpus all \
  -p 127.0.0.1:18007:8007 \
  --env-file /etc/mindrouter/sidecar.env \
  --restart unless-stopped \
  mindrouter-sidecar:v2.0.0

In production, bind the sidecar to localhost only (as shown above) and use an nginx reverse proxy on port 8007 to handle external traffic. The env file at /etc/mindrouter/sidecar.env persists the secret key across upgrades — generate it once per node and it's reused automatically.

Nginx Reverse Proxy Configuration

Each inference node runs an nginx reverse proxy that terminates TLS and forwards requests to the locally-bound inference engine (Ollama or vLLM) and GPU sidecar. This is required because inference engines bind to 127.0.0.1 for security — nginx handles external TLS traffic on the public-facing ports.

Request body size limits (413 errors)

Two request classes routinely exceed nginx's default client_max_body_size of 1MB: multimodal requests (a high-resolution image is 1–10MB of base64) and full-transcript agent clients — Codex and other Responses API consumers resend the entire conversation every turn, crossing 1MB of plain text within a normal session. Either way the node proxy returns an opaque 413 Request Entity Too Large HTML page.

Set the limit once at http level so it covers every server block on the node — past practice of adding it inside individual server {} blocks caused an outage when new blocks were added without it:

# /etc/nginx/conf.d/00-body-size.conf   (one line, applies to ALL proxies on the node)
client_max_body_size 64m;

Keep the limits in lockstep across the chain. There are only two enforcing layers — the MindRouter application enforces no global body limit of its own (MAX_REQUEST_SIZE is defined in settings.py but never read):

gateway nginx (client_max_body_size 50m, in nginx/nginx.conf)
    >  inference-node nginx (client_max_body_size 64m)

As shipped the gateway is the strictest link, so an oversized request is rejected at the edge with nginx's own 413 page before it ever reaches the application. If you need to accept larger bodies, raise the gateway value first and keep the node configs at or above it. Diagnosis tip: the nginx version string in a 413 page identifies which layer rejected the request.

Example: Ollama proxy config

# /etc/nginx/conf.d/ollama-proxy.conf
# (body size limit comes from 00-body-size.conf above — do not set it per block)
server {
    listen 8001 ssl;
    listen [::]:8001 ssl;
    server_name node.example.edu;

    ssl_certificate     /etc/pki/tls/certs/node.example.edu.crt;
    ssl_certificate_key /etc/pki/tls/private/node.example.edu.key;

    location / {
        proxy_pass http://127.0.0.1:18001;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_read_timeout 300s;    # Long timeout for inference
        proxy_send_timeout 300s;
    }
}

Example: vLLM proxy config

# /etc/nginx/conf.d/vllm-proxy.conf
# Multiple server blocks, one per vLLM instance on different ports
# (body size limit comes from 00-body-size.conf — do not set it per block)
server {
    listen 8001 ssl;
    server_name node.example.edu;

    ssl_certificate     /etc/pki/tls/certs/node.example.edu.crt;
    ssl_certificate_key /etc/pki/tls/private/node.example.edu.key;

    location / {
        proxy_pass http://127.0.0.1:18001;
        proxy_set_header Host $host;
        proxy_read_timeout 300s;
        proxy_send_timeout 300s;
    }
}

Important: This setting must be applied on every inference node in the cluster, not just nodes currently running vision models. Any node may have a multimodal model deployed in the future, and missing this setting will cause silent failures (the request reaches MindRouter, gets routed to the backend, but nginx rejects it before it reaches the inference engine).

Verifying the setting

# Check all nginx proxy configs across the cluster:
for host in node1 node2 node3; do   # substitute your inference node hostnames
    echo -n "$host: "
    ssh $host "grep -r client_max_body_size /etc/nginx/conf.d/ 2>/dev/null || echo 'NOT SET - needs fix'"
done

Testing

MindRouter has a comprehensive test suite covering unit, integration, end-to-end, smoke, stress, and accessibility tests.

Quick Reference

CommandDescription
make test-unitRun unit tests (1,000+ tests, no live services needed)
make test-intIntegration tests (requires live backends)
make test-e2eEnd-to-end tests
make test-smokeSmoke tests (full API surface)
make test-stressLoad/stress tests
make test-a11yWCAG 2.1 accessibility tests
make test-sidecarGPU sidecar agent tests
make test-allRun all test suites

Implementation Notes

This section documents internal behaviors useful for operators and developers.

Inflight Token Estimation

During streaming responses, tokens are estimated at 1 token per 4 characters for real-time quota and throughput tracking. Estimates are flushed to Redis every 10 chunks. When the response completes, estimated counts are replaced by accurate backend-reported token counts.

Token count fallback: If a backend returns zero for both prompt and completion token counts, MindRouter falls back to the job's pre-estimated token counts (based on tiktoken encoding of the input).

Redis Token Counter Sync

A background sync loop flushes Redis token usage counters to the database every 60 seconds. On startup, counters are seeded from the database. A final flush runs on graceful shutdown to prevent token count drift.

Conversation Cleanup

A background task automatically deletes expired conversations every 24 hours (configurable via CONVERSATION_CLEANUP_INTERVAL). The default retention period is 2 years (CONVERSATION_RETENTION_DAYS=730).

Backend Options Passthrough

The backend_options dict in requests allows passing Ollama-specific options (e.g., mirostat, tfs_z, repeat_penalty) directly to Ollama backends. These options are ignored when the request is routed to a vLLM backend.

Thinking Input Format Priority

The system accepts four input formats for thinking/reasoning mode, resolved in priority order:

  1. think field (bool or string) — canonical format
  2. thinking: {type: "enabled"/"disabled"} — OpenAI/Anthropic style
  3. chat_template_kwargs: {enable_thinking: bool} — vLLM-specific
  4. Ollama top-level think field

Response Format Normalization

When an /api/chat request (Ollama format) is routed to a vLLM backend, responses are automatically converted back to Ollama format. The reasoning_content field from vLLM/OpenAI responses is promoted to the Ollama thinking field.

Per-Backend Performance Tracking

The scheduler maintains an exponential moving average (EMA) of request latency (latency_ema_ms) and time-to-first-token (ttft_ema_ms) for each backend. These metrics inform the "Low Latency" and "High Throughput" scoring factors. Circuit breaker state (live_failure_count, circuit_open_until) is also persisted per-backend, surviving application restarts.

Soft Delete

User accounts and blog posts use soft deletion — a deleted_at timestamp is set rather than removing the row. Soft-deleted records are excluded from normal queries but retained in the database for audit purposes.

Status Enums

BackendStatus: HEALTHY (available for routing), UNHEALTHY (failed health checks), DISABLED (admin-disabled), DRAINING (graceful shutdown — no new requests, existing ones complete), UNKNOWN (initial state before first health check).

NodeStatus: ONLINE (reachable), OFFLINE (unreachable), UNKNOWN (initial state).

RequestStatus: QUEUED (waiting in scheduler), PROCESSING (executing on backend), COMPLETED (success), FAILED (error), CANCELLED (timeout or user-cancelled).


Further Reading

Deeper references, each mirrored from the MindRouter repository: