- Canonicalize controlplane agent IDs/roles to: sysadmin, db-admin, git-admin (drop *_agent variants). - Add DB migration to rewrite existing *_agent rows and references to canonical IDs. - Tighten POST /api/controlplane/tasks contract: require assigned_to (remove agent_id alias). - Update tests and docs to match canonical IDs. --- Build: pass (just typecheck) Tests: pass — 1536 passed (92 files) (just test)
13 KiB
Multi-Provider Architecture
Date: 07.apr.2026
Status: Design — not yet implemented. src/provider-router.ts does not exist. PI_TUI_REASONING_PROVIDER config var not yet added. Use as a blueprint when implementing.
Current default: zai/glm-5-turbo (configured via PI_TUI_PROVIDER and PI_TUI_MODEL in .env). Runtime changes via just pi-config.
Goal
Clawdie should accept credentials from any LLM provider at install time, and route requests to the best provider for each task at runtime.
This is not a handoff document. It is a living design reference.
Supported Providers
| Provider | Auth Var | Cost | Strength | Use Case |
|---|---|---|---|---|
| Z.ai (GLM) | ZAI_API_KEY |
Low | Good | Default routine tasks |
| Anthropic | ANTHROPIC_API_KEY |
High | Excellent | Complex reasoning, orchestrator agent |
| Claude OAuth | CLAUDE_CODE_OAUTH_TOKEN |
High | Excellent | Claude Code integration |
| OpenAI | OPENAI_API_KEY |
High | Excellent | GPT models, Codex |
| OpenRouter | OPENROUTER_API_KEY |
Medium | Varies | Multi-model access |
| Ollama | Local (no key) | Zero | Limited | Offline, routine skill tasks |
| llama.cpp | Local (no key) | Zero | Limited | Offline, routine skill tasks |
| Future (Mistral, etc.) | <PROVIDER>_API_KEY |
TBD | TBD | Extensible |
Architecture
Two Layers
┌─────────────────────────────────────────────────┐
│ Install Time (firstboot wizard) │
│ │
│ "Which providers do you have keys for?" │
│ ┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐ │
│ │Zai│ │Ant│ │GPT│ │Oll│ │...│ ← multi-select │
│ └───┘ └───┘ └───┘ └───┘ └───┘ │
│ │
│ "Preferred for routine tasks?" → [dropdown] │
│ "Preferred for complex reasoning?" → [dropdown]│
│ │
│ Writes to: /root/.clawdie/.env │
└─────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────┐
│ Runtime (Clawdie service) │
│ │
│ Provider Registry (config.ts) │
│ ↓ reads all available keys from .env │
│ │
│ Provider Router (NEW) │
│ ↓ maps task profile → provider │
│ │
│ Agent Spawner (controlplane-runner.ts) │
│ ↓ injects PI_TUI_PROVIDER per task │
└─────────────────────────────────────────────────┘
Install Time
Shell Wizard (firstboot.sh)
After timezone and SSH key, add provider selection:
# ── LLM Provider Configuration ──────────────────────────────────────
_dialog --msgbox \
"LLM Provider Setup\n\n" \
"Clawdie supports multiple AI providers.\n" \
"You can configure one or more now, or skip and edit .env later." 12 70
# Collect optional API keys
ZAI_API_KEY=$(_dialog --passwordbox \
"Z.ai API Key (optional).\n\n" \
"Default provider. Low cost, good quality.\n" \
"Leave blank to skip." 12 70 "${ZAI_API_KEY:-}")
ANTHROPIC_API_KEY=$(_dialog --passwordbox \
"Anthropic API Key (optional).\n\n" \
"Claude models. High quality reasoning.\n" \
"Leave blank to skip." 12 70 "${ANTHROPIC_API_KEY:-}")
CLAUDE_CODE_OAUTH_TOKEN=$(_dialog --passwordbox \
"Claude Code OAuth Token (optional).\n\n" \
"Run 'claude setup-token' elsewhere, paste here.\n" \
"Leave blank to skip." 12 70 "")
OPENAI_API_KEY=$(_dialog --passwordbox \
"OpenAI API Key (optional).\n\n" \
"GPT and Codex models.\n" \
"Leave blank to skip." 12 70 "${OPENAI_API_KEY:-}")
OPENROUTER_API_KEY=$(_dialog --passwordbox \
"OpenRouter API Key (optional).\n\n" \
"Access to multiple model providers.\n" \
"Leave blank to skip." 12 70 "${OPENROUTER_API_KEY:-}")
# Detect local LLM
if pkg info ollama >/dev/null 2>&1; then
FEATURE_OLLAMA="YES"
fi
if pkg info llama-cpp >/dev/null 2>&1; then
FEATURE_LLAMA_CPP="YES"
fi
# Preferred provider selection
PROVIDER_COUNT=0
[ -n "$ZAI_API_KEY" ] && PROVIDER_COUNT=$((PROVIDER_COUNT + 1))
[ -n "$ANTHROPIC_API_KEY" ] && PROVIDER_COUNT=$((PROVIDER_COUNT + 1))
[ -n "$OPENAI_API_KEY" ] && PROVIDER_COUNT=$((PROVIDER_COUNT + 1))
[ -n "$OPENROUTER_API_KEY" ] && PROVIDER_COUNT=$((PROVIDER_COUNT + 1))
[ "$FEATURE_OLLAMA" = "YES" ] && PROVIDER_COUNT=$((PROVIDER_COUNT + 1))
[ "$FEATURE_LLAMA_CPP" = "YES" ] && PROVIDER_COUNT=$((PROVIDER_COUNT + 1))
if [ "$PROVIDER_COUNT" -ge 2 ]; then
DEFAULT_PROVIDER=$(_dialog --menu \
"Default provider for routine tasks:" 15 60 5 \
"zai" "Z.ai (GLM) — low cost" \
"ollama" "Ollama — local, free" \
"llama.cpp" "llama.cpp — local, free" \
"openai" "OpenAI — GPT models" \
"anthropic" "Anthropic — Claude" \
"openrouter" "OpenRouter — multi-model" 2>&1)
REASONING_PROVIDER=$(_dialog --menu \
"Provider for complex reasoning:" 15 60 5 \
"anthropic" "Anthropic — Claude" \
"openai" "OpenAI — GPT/Codex" \
"zai" "Z.ai (GLM)" \
"openrouter" "OpenRouter — multi-model" 2>&1)
else
DEFAULT_PROVIDER="${PI_TUI_PROVIDER:-zai}"
REASONING_PROVIDER="${PI_TUI_PROVIDER:-zai}"
fi
GUI Wizard (QML)
Add a new ProviderPage.qml (between UserPage and SummaryPage):
- Checkboxes for each provider (multi-select)
- Password fields appear dynamically for checked providers
- Dropdowns for default + reasoning provider (if 2+ checked)
- Auto-detect Ollama/llama.cpp presence
Config Output
All provider keys + preferences written to .env:
# Provider keys
ZAI_API_KEY="..."
ANTHROPIC_API_KEY="..."
CLAUDE_CODE_OAUTH_TOKEN="..."
OPENAI_API_KEY="..."
OPENROUTER_API_KEY="..."
# Provider preferences
PI_TUI_PROVIDER="zai" # default routine
PI_TUI_REASONING_PROVIDER="anthropic" # complex reasoning
# Local LLM
FEATURE_OLLAMA="YES"
FEATURE_LLAMA_CPP="NO"
Runtime
Provider Registry (config.ts)
Already partially exists. Extend to expose all available providers:
interface ProviderInfo {
name: string;
provider: string;
available: boolean;
keySet: boolean;
cost: 'zero' | 'low' | 'medium' | 'high';
strength: 'limited' | 'good' | 'excellent';
}
export const AVAILABLE_PROVIDERS: ProviderInfo[] = [
{
name: 'Z.ai (GLM)',
provider: 'zai',
available: !!ZAI_API_KEY,
keySet: !!ZAI_API_KEY,
cost: 'low',
strength: 'good',
},
{
name: 'Anthropic',
provider: 'anthropic',
available: !!ANTHROPIC_API_KEY,
keySet: !!ANTHROPIC_API_KEY,
cost: 'high',
strength: 'excellent',
},
{
name: 'OpenAI',
provider: 'openai',
available: !!OPENAI_API_KEY,
keySet: !!OPENAI_API_KEY,
cost: 'high',
strength: 'excellent',
},
{
name: 'OpenRouter',
provider: 'openrouter',
available: !!OPENROUTER_API_KEY,
keySet: !!OPENROUTER_API_KEY,
cost: 'medium',
strength: 'good',
},
{
name: 'Ollama',
provider: 'ollama',
available: FEATURE_OLLAMA,
keySet: true,
cost: 'zero',
strength: 'limited',
},
{
name: 'llama.cpp',
provider: 'llama.cpp',
available: FEATURE_LLAMA_CPP,
keySet: true,
cost: 'zero',
strength: 'limited',
},
];
Provider Router (NEW: src/provider-router.ts)
Maps task context to the best provider:
interface TaskProfile {
agentRole: string; // orchestrator, sysadmin, db-admin, git-admin
skillMatched: boolean; // skill catalog matched exactly
complexity: 'routine' | 'moderate' | 'complex';
requiresReasoning: boolean;
}
interface ProviderChoice {
provider: string;
model?: string;
reason: string;
}
function routeProvider(
profile: TaskProfile,
config: { defaultProvider: string; reasoningProvider: string },
): ProviderChoice {
// 1. Skill-matched routine task → default provider (or local)
if (profile.skillMatched && profile.complexity === 'routine') {
return {
provider: config.defaultProvider,
reason: 'skill-matched routine task',
};
}
// 2. Complex reasoning → reasoning provider
if (profile.requiresReasoning || profile.agentRole === 'orchestrator') {
return {
provider: config.reasoningProvider,
reason: 'complex reasoning required',
};
}
// 3. Default
return {
provider: config.defaultProvider,
reason: 'default provider',
};
}
Integration with Control Plane
controlplane-runner.ts already builds a pi spawn command. The provider
router injects the provider per-task:
// In controlplane-heartbeat.ts, before spawning:
const providerChoice = routeProvider(
{ agentRole: agent.role, skillMatched: match.confidence !== 'none', ... },
{ defaultProvider: PI_TUI_PROVIDER, reasoningProvider: PI_TUI_REASONING_PROVIDER },
);
const runConfig = buildControlplaneRunCommand({
...existingOpts,
provider: providerChoice.provider, // NEW field
});
// In controlplane-runner.ts, buildControlplaneRunCommand sets:
// env.PI_TUI_PROVIDER = opts.provider
Routing Rules (Default)
| Scenario | Route To | Rationale |
|---|---|---|
| Skill-matched (exact) | PI_TUI_PROVIDER (default) |
Deterministic, no reasoning needed |
| Skill-matched (pattern) | PI_TUI_PROVIDER (default) |
Likely routine |
| No skill match, non-orchestrator | PI_TUI_PROVIDER (default) |
Conservative |
| Orchestrator agent, any task | PI_TUI_REASONING_PROVIDER |
Orchestrator needs best reasoning |
| Any agent, task marked "critical" | PI_TUI_REASONING_PROVIDER |
Safety net |
| Budget exhausted on cloud provider | ollama or llama.cpp |
Fallback to local |
| No cloud keys configured | ollama or llama.cpp |
Only option |
Fallback Chain
When primary provider fails (rate limit, outage, key invalid):
Primary → Secondary → Tertiary → Local LLM → Error
Example with full config:
anthropic → openai → zai → ollama → error
Example with minimal config:
zai → ollama → error
The fallback chain is auto-generated from available providers, sorted by strength (excellent > good > limited).
Adding a New Provider
- Add key variable to
readEnvFile()inconfig.ts - Add
ProviderInfoentry toAVAILABLE_PROVIDERS - Add
piprofile support (ifpisupports it) - Add to firstboot wizard provider list
- Add to GUI provider checkboxes
No router changes needed — it works off the provider registry.
Open Questions
-
Should
PI_TUI_REASONING_PROVIDERbe a new config var, or should we use aPROVIDER_ROUTINGJSON config for more complex rules?Recommendation: Start with two vars (
PI_TUI_PROVIDER+PI_TUI_REASONING_PROVIDER). Add JSON routing config only when someone needs per-agent or per-skill routing. -
Should the provider router be a separate module or embedded in controlplane-runner?
Recommendation: Separate
src/provider-router.ts. Keeps runner focused on spawn command construction. Router is testable independently. -
Should the GUI provider page be a separate QML page or merged into UserPage?
Recommendation: Separate
ProviderPage.qml. Provider config is conceptually different from user identity. Keeps UserPage simple. -
How to handle providers that
pidoesn't support yet?Recommendation: Provider router checks
picapability before routing. Ifpidoesn't support the provider, skip it in the fallback chain and log a warning.