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.
switcher-api: Node.js + TypeScript + Express + Prisma (:8080)postgres: Postgres 16- Docker Compose deployment
- Admin endpoints require:
Authorization: Bearer ROUTER_ADMIN_KEY - Agent endpoints (
/api/*) require:Authorization: Bearer ROUTER_AGENT_KEY - Proxy endpoint (
/v1/chat/completions) also requiresROUTER_AGENT_KEYby default (REQUIRE_AGENT_KEY_FOR_PROXY=true) - Upstream provider requests use
UPSTREAM_TIMEOUT_MS(default30000) - 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
- Preferred:
Tables:
profilesmodelsagent_stateaudit_switchesaudit_requests
Schema/migrations live in prisma/schema.prisma and prisma/migrations/20260301130000_init/migration.sql.
- Unknown model on switch:
- If profile type is
openaioropenai_compatible, unknownmodel_nameis auto-created asenabled=true. - If profile type is
anthropic, model must already exist inmodelstable.
- If profile type is
- Streaming:
stream=trueis not supported in MVP.
npm install
npm run wizardThe wizard will:
- set/update
.envkeys - start Docker services
- create your first profile
- set your first active model for an
agent_id
- Copy env template:
cp .env.example .env- Optionally generate a random 32-byte KMS key:
openssl rand -base64 32- Start services:
docker compose up --build- Health check:
curl http://localhost:8080/healthzcurl -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_urloptional, defaults tohttps://api.openai.com/v1anthropic:base_urloptional, defaults tohttps://api.anthropic.comopenai_compatible:base_urlrequired
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.
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
}'curl -X POST \
-H "Authorization: Bearer $ROUTER_ADMIN_KEY" \
http://localhost:8080/admin/profiles/<PROFILE_ID>/sync-modelsCalls GET {base_url}/models and upserts discovered models as enabled=true.
Failures return JSON gracefully (ok=false) without crashing.
curl -H "Authorization: Bearer $ROUTER_AGENT_KEY" \
"http://localhost:8080/api/models?profile=openai-main"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"
}'curl -H "Authorization: Bearer $ROUTER_AGENT_KEY" \
"http://localhost:8080/api/active?agent_id=default"curl http://localhost:8080/v1/modelsReturns enabled models across profiles in OpenAI list format with IDs:
PROFILE_NAME::model_name
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
}'modelis ignored and overridden by active switch state.- If no active model exists for
agent_id, returns400with:No active model set for agent_id
For provider_type=anthropic, the proxy translates OpenAI-like chat request into Anthropic Messages API:
- Maps system/user/assistant messages
- Sends
x-api-keyandanthropic-versionheaders - Translates response back into OpenAI chat completion shape
audit_switches: written on each/api/switchaudit_requests: written on each proxied/v1/chat/completions- stores
sha256(prompt + messages)only - does not store raw prompt text
- stores
Run locally:
npm install
npm testTest coverage includes:
- multiple profiles of same
provider_type - switching updates
agent_stateand writesaudit_switches - proxy uses active profile/model and writes
audit_requests /v1/modelsunion formatPROFILE_NAME::model_name- openai-compatible sync success/failure handling