Skip to content

0xdippo/infra-model-switch

Repository files navigation

OpenClaw Model Switcher (Self-hosted, Manual)

Self-hosted local API to manually switch active provider/model per agent_id with profile support (multiple named accounts per provider).

This is not an auto-router.

Stack

  • switcher-api: Node.js + TypeScript + Express + Prisma (:8080)
  • postgres: Postgres 16
  • Docker Compose deployment

Security and Auth

  • Admin endpoints require: Authorization: Bearer ROUTER_ADMIN_KEY
  • Agent endpoints (/api/*) require: Authorization: Bearer ROUTER_AGENT_KEY
  • Proxy endpoint (/v1/chat/completions) also requires ROUTER_AGENT_KEY by default (REQUIRE_AGENT_KEY_FOR_PROXY=true)
  • Upstream provider requests use UPSTREAM_TIMEOUT_MS (default 30000)
  • Credentials are encrypted at rest in Postgres using AES-256-GCM
  • Encryption key source:
    • Preferred: ROUTER_KMS_KEY (base64-encoded 32 bytes)
    • Fallback: SHA-256 derivation from ROUTER_ADMIN_KEY

Data Model

Tables:

  • profiles
  • models
  • agent_state
  • audit_switches
  • audit_requests

Schema/migrations live in prisma/schema.prisma and prisma/migrations/20260301130000_init/migration.sql.

Behavior Choices

  • Unknown model on switch:
    • If profile type is openai or openai_compatible, unknown model_name is auto-created as enabled=true.
    • If profile type is anthropic, model must already exist in models table.
  • Streaming:
    • stream=true is not supported in MVP.

Quick Start (Local)

Easiest path (interactive wizard)

npm install
npm run wizard

The wizard will:

  • set/update .env keys
  • start Docker services
  • create your first profile
  • set your first active model for an agent_id

Manual path

  1. Copy env template:
cp .env.example .env
  1. Optionally generate a random 32-byte KMS key:
openssl rand -base64 32
  1. Start services:
docker compose up --build
  1. Health check:
curl http://localhost:8080/healthz

API

Admin: Create profile

curl -X POST http://localhost:8080/admin/profiles \
  -H "Authorization: Bearer $ROUTER_ADMIN_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "profile_name": "openai-main",
    "provider_type": "openai",
    "credentials": {"api_key": "sk-..."}
  }'

provider_type:

  • openai: base_url optional, defaults to https://api.openai.com/v1
  • anthropic: base_url optional, defaults to https://api.anthropic.com
  • openai_compatible: base_url required

Admin: List/Delete profiles

curl -H "Authorization: Bearer $ROUTER_ADMIN_KEY" \
  http://localhost:8080/admin/profiles

curl -X DELETE \
  -H "Authorization: Bearer $ROUTER_ADMIN_KEY" \
  http://localhost:8080/admin/profiles/<PROFILE_ID>

If the profile is currently referenced by agent_state, deletion returns 409 and is blocked.

Admin: Add model manually

curl -X POST http://localhost:8080/admin/models \
  -H "Authorization: Bearer $ROUTER_ADMIN_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "profile_name": "openai-main",
    "model_name": "gpt-4.1",
    "enabled": true,
    "tags": ["prod"],
    "max_tokens": 8192
  }'

Admin: Sync models (openai_compatible)

curl -X POST \
  -H "Authorization: Bearer $ROUTER_ADMIN_KEY" \
  http://localhost:8080/admin/profiles/<PROFILE_ID>/sync-models

Calls GET {base_url}/models and upserts discovered models as enabled=true. Failures return JSON gracefully (ok=false) without crashing.

Agent: List models for profile (enabled + disabled)

curl -H "Authorization: Bearer $ROUTER_AGENT_KEY" \
  "http://localhost:8080/api/models?profile=openai-main"

Agent: Switch active model

curl -X POST http://localhost:8080/api/switch \
  -H "Authorization: Bearer $ROUTER_AGENT_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "agent_id": "default",
    "profile_name": "openai-main",
    "model_name": "gpt-4.1"
  }'

Agent: Read active model

curl -H "Authorization: Bearer $ROUTER_AGENT_KEY" \
  "http://localhost:8080/api/active?agent_id=default"

OpenAI-compatible model list (union)

curl http://localhost:8080/v1/models

Returns enabled models across profiles in OpenAI list format with IDs:

  • PROFILE_NAME::model_name

Proxy: Chat completions

curl -X POST http://localhost:8080/v1/chat/completions \
  -H "Authorization: Bearer $ROUTER_AGENT_KEY" \
  -H "x-agent-id: default" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "ignored",
    "messages": [{"role":"user","content":"Hello"}],
    "max_tokens": 128,
    "temperature": 0.2,
    "stream": false
  }'
  • model is ignored and overridden by active switch state.
  • If no active model exists for agent_id, returns 400 with:
    • No active model set for agent_id

Anthropic Adapter (MVP)

For provider_type=anthropic, the proxy translates OpenAI-like chat request into Anthropic Messages API:

  • Maps system/user/assistant messages
  • Sends x-api-key and anthropic-version headers
  • Translates response back into OpenAI chat completion shape

Auditing

  • audit_switches: written on each /api/switch
  • audit_requests: written on each proxied /v1/chat/completions
    • stores sha256(prompt + messages) only
    • does not store raw prompt text

Tests

Run locally:

npm install
npm test

Test coverage includes:

  • multiple profiles of same provider_type
  • switching updates agent_state and writes audit_switches
  • proxy uses active profile/model and writes audit_requests
  • /v1/models union format PROFILE_NAME::model_name
  • openai-compatible sync success/failure handling

About

Self-hosted model switcher for agents with profile-based provider accounts, encrypted credentials, OpenAI-compatible proxying, and per-agent manual model selection.

Topics

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors