-
Notifications
You must be signed in to change notification settings - Fork 4.4k
06.4 Runtime Model Switching
Relevant source files
The following files were used as context for generating this wiki page:
This page explains how users can dynamically switch LLM providers and models on a per-sender basis during channel conversations using slash commands. This feature allows different users in the same channel (e.g., Telegram, Discord) to use different models simultaneously without requiring daemon restarts or config changes.
For general channel configuration, see Channel Architecture. For provider configuration and model selection during onboarding, see Built-in Providers.
Runtime model switching enables:
- Per-sender routing: Each user in a channel can select their own provider and model
-
Zero-downtime switching: Change models without restarting
zeroclaw daemon - Provider discovery: List available providers and cached model IDs
- Session isolation: Model preferences persist per sender until explicitly changed or daemon restarts
This feature is channel-specific and only available in channels that support it (currently Telegram and Discord). CLI and gateway modes use the provider/model specified in config.toml or via command-line flags.
Runtime model switching is enabled for specific channels via the supports_runtime_model_switch() function:
| Channel | Supported | Notes |
|---|---|---|
| Telegram | ✅ Yes | Mention-only mode supported |
| Discord | ✅ Yes | Mention-only mode supported |
| Slack | ❌ No | Uses default config |
| Matrix | ❌ No | Uses default config |
| ❌ No | Uses default config | |
| ❌ No | Uses default config | |
| CLI | ❌ No | Use --model flag instead |
Sources: src/channels/mod.rs:142-144
List all providers:
/models
Response shows current provider, current model, and a list of all available providers with their aliases.
Switch provider:
/models <provider-name>
Examples:
/models anthropic
/models openai
/models ollama
/models groq
Provider names are case-insensitive and support aliases (e.g., grok → xai, google → gemini).
Show current model:
/model
Response shows current provider, current model, and a preview of cached models for that provider (up to 10 entries).
Switch model:
/model <model-id>
Examples:
/model claude-sonnet-4-5-20250929
/model gpt-4o
/model llama3.2
/model anthropic/claude-opus-4-20250514
Model IDs can include backticks (they are stripped automatically). This allows users to copy-paste from the cached model list.
In Telegram groups with mention_only = true, commands must mention the bot:
/models@botname anthropic
/model@botname gpt-4o
The @botname suffix is stripped automatically by the command parser.
Sources: src/channels/mod.rs:146-184
graph TB
User["User<br/>(Telegram/Discord)"]
Parse["parse_runtime_command()<br/>Lines 146-184"]
Handle["handle_runtime_command_if_needed()<br/>Lines 365-441"]
Resolve["resolve_provider_alias()<br/>Lines 186-205"]
GetProvider["get_or_create_provider()<br/>Lines 266-308"]
RouteMap["RouteSelectionMap<br/>Arc<Mutex<HashMap<String, ChannelRouteSelection>>><br/>Lines 74-80"]
ProviderCache["ProviderCacheMap<br/>Arc<Mutex<HashMap<String, Arc<dyn Provider>>>><br/>Line 73"]
ModelCache["models_cache.json<br/>workspace/state/<br/>Lines 243-264"]
ProcessMsg["process_channel_message()<br/>Lines 556-814"]
User --> Parse
Parse --> Handle
Handle --> Resolve
Resolve --> GetProvider
GetProvider --> ProviderCache
Handle --> RouteMap
Handle --> ModelCache
ProcessMsg --> RouteMap
ProcessMsg --> ProviderCache
style RouteMap fill:#f9f9f9
style ProviderCache fill:#f9f9f9
style ModelCache fill:#f9f9f9
Sources: src/channels/mod.rs:73-123, src/channels/mod.rs:146-441, src/channels/mod.rs:556-814
sequenceDiagram
participant User
participant parse_runtime_command
participant handle_runtime_command_if_needed
participant resolve_provider_alias
participant get_or_create_provider
participant set_route_selection
participant clear_sender_history
participant Channel
User->>parse_runtime_command: "/models anthropic"
parse_runtime_command->>parse_runtime_command: Strip @mention suffix
parse_runtime_command->>parse_runtime_command: Parse command type
parse_runtime_command-->>handle_runtime_command_if_needed: ChannelRuntimeCommand::SetProvider
handle_runtime_command_if_needed->>resolve_provider_alias: "anthropic"
resolve_provider_alias->>resolve_provider_alias: Check canonical names<br/>Check aliases
resolve_provider_alias-->>handle_runtime_command_if_needed: "anthropic"
handle_runtime_command_if_needed->>get_or_create_provider: provider_name, ctx
get_or_create_provider->>get_or_create_provider: Check provider_cache
alt Not in cache
get_or_create_provider->>get_or_create_provider: create_resilient_provider_with_options()
get_or_create_provider->>get_or_create_provider: provider.warmup()
get_or_create_provider->>get_or_create_provider: Insert into cache
end
get_or_create_provider-->>handle_runtime_command_if_needed: Arc<dyn Provider>
handle_runtime_command_if_needed->>set_route_selection: sender_key, new route
set_route_selection->>set_route_selection: Update route_overrides map
handle_runtime_command_if_needed->>clear_sender_history: sender_key
clear_sender_history->>clear_sender_history: Remove from conversation_histories
handle_runtime_command_if_needed->>Channel: Send success response
Channel->>User: "Provider switched to `anthropic`..."
Sources: src/channels/mod.rs:146-184, src/channels/mod.rs:186-205, src/channels/mod.rs:266-308, src/channels/mod.rs:223-234, src/channels/mod.rs:236-241, src/channels/mod.rs:365-441
Stores the provider and model selection for a specific sender:
struct ChannelRouteSelection {
provider: String, // e.g., "anthropic", "openai"
model: String, // e.g., "claude-sonnet-4-5-20250929"
}Sources: src/channels/mod.rs:76-80
Enum representing parsed slash commands:
enum ChannelRuntimeCommand {
ShowProviders, // /models
SetProvider(String), // /models <provider>
ShowModel, // /model
SetModel(String), // /model <model-id>
}Sources: src/channels/mod.rs:82-88
Runtime context shared across all channel message processing:
| Field | Type | Purpose |
|---|---|---|
provider |
Arc<dyn Provider> |
Default provider from config |
default_provider |
Arc<String> |
Default provider name |
model |
Arc<String> |
Default model name |
provider_cache |
ProviderCacheMap |
Cache of provider instances |
route_overrides |
RouteSelectionMap |
Per-sender routing overrides |
conversation_histories |
ConversationHistoryMap |
Per-sender chat history |
Sources: src/channels/mod.rs:101-123
Providers are cached by name in a thread-safe HashMap:
provider_cache: Arc<Mutex<HashMap<String, Arc<dyn Provider>>>>
-
Default provider: Always cached at daemon startup (from
config.toml) -
Runtime providers: Cached on first use via
/models <provider>command -
Warmup: Each provider calls
provider.warmup()after creation to initialize connection pools -
Reuse: Subsequent
/models <provider>commands for the same provider reuse the cached instance - Lifetime: Cache persists until daemon restarts
flowchart TD
Start["get_or_create_provider(ctx, provider_name)"]
CheckDefault{"provider_name ==<br/>default_provider?"}
CheckCache{"In provider_cache?"}
CreateProvider["create_resilient_provider_with_options()"]
Warmup["provider.warmup()"]
InsertCache["Insert into provider_cache"]
Return["Return Arc<dyn Provider>"]
Start --> CheckDefault
CheckDefault -->|Yes| Return
CheckDefault -->|No| CheckCache
CheckCache -->|Yes| Return
CheckCache -->|No| CreateProvider
CreateProvider --> Warmup
Warmup --> InsertCache
InsertCache --> Return
Sources: src/channels/mod.rs:266-308
The model cache stores lists of available models per provider to avoid repeated API calls during /model command usage. This is especially useful for providers with large model catalogs (e.g., OpenRouter has 200+ models).
~/.zeroclaw/workspace/state/models_cache.json
{
"entries": [
{
"provider": "openrouter",
"models": [
"anthropic/claude-sonnet-4.6",
"openai/gpt-5.2",
"google/gemini-3-pro-preview",
...
]
},
{
"provider": "anthropic",
"models": [
"claude-sonnet-4-5-20250929",
"claude-opus-4-6",
"claude-haiku-4-5-20251001"
]
}
]
}Sources: src/channels/mod.rs:90-99
The cache is populated by:
-
zeroclaw models refresh --provider <provider>: CLI command to fetch and cache models - Onboarding wizard: Optionally fetches models during provider setup
The cache is not automatically populated by /model commands. Users see a message prompting them to run zeroclaw models refresh if no cache exists.
The /model command shows up to 10 cached models by default:
Current provider: `anthropic`
Current model: `claude-sonnet-4-5-20250929`
Switch model with `/model <model-id>`.
Cached model IDs (top 10):
- `claude-sonnet-4-5-20250929`
- `claude-opus-4-6`
- `claude-haiku-4-5-20251001`
- ...
Sources: src/channels/mod.rs:70-71, src/channels/mod.rs:243-264, src/channels/mod.rs:310-338
Route overrides are keyed by conversation_history_key():
fn conversation_history_key(msg: &traits::ChannelMessage) -> String {
format!("{}_{}", msg.channel, msg.sender)
}Example keys:
telegram_123456789discord_987654321
Sources: src/channels/mod.rs:129-131
flowchart TD
GetRoute["get_route_selection(ctx, sender_key)"]
CheckMap{"sender_key in<br/>route_overrides?"}
UseOverride["Return ChannelRouteSelection<br/>from map"]
UseDefault["Return default_route_selection(ctx)"]
GetRoute --> CheckMap
CheckMap -->|Yes| UseOverride
CheckMap -->|No| UseDefault
UseDefault --> DefaultProvider["provider: ctx.default_provider"]
UseDefault --> DefaultModel["model: ctx.model"]
When a user switches provider or model:
-
set_route_selection()updates theroute_overridesmap for that sender - If the new route equals the default route, the entry is removed (optimization)
- Subsequent messages from that sender use
get_route_selection()to retrieve their route
Sources: src/channels/mod.rs:207-234
In process_channel_message(), the route is resolved before LLM calls:
let history_key = conversation_history_key(&msg);
let route = get_route_selection(ctx.as_ref(), &history_key);
let active_provider = get_or_create_provider(ctx.as_ref(), &route.provider).await?;
// Use route.provider and route.model for this sender
run_tool_call_loop(
active_provider.as_ref(),
&mut history,
/* ... */
route.provider.as_str(),
route.model.as_str(),
/* ... */
)Sources: src/channels/mod.rs:569-586
When a user switches provider or model, their conversation history is cleared to prevent context contamination:
// After successful provider switch
clear_sender_history(ctx, &sender_key);
// After successful model switch
clear_sender_history(ctx, &sender_key);This is necessary because:
- Different models may have different context window sizes
- Different providers may have different system prompt requirements
- Tool calling formats may vary between providers
The user's routing preference is preserved, but their chat history starts fresh.
Sources: src/channels/mod.rs:236-241, src/channels/mod.rs:390, src/channels/mod.rs:420
The resolve_provider_alias() function normalizes provider names and handles common aliases:
| Input | Resolved To |
|---|---|
grok |
xai |
together |
together-ai |
google |
gemini |
google-gemini |
gemini |
nvidia-nim |
nvidia |
build.nvidia.com |
nvidia |
Provider names and aliases are matched case-insensitively.
flowchart TD
Input["resolve_provider_alias(name)"]
Trim["Trim and validate"]
CheckEmpty{"name.is_empty()?"}
ListProviders["providers::list_providers()"]
SearchExact{"Exact match in<br/>provider.name?"}
SearchAlias{"Match in<br/>provider.aliases?"}
ReturnName["Return provider.name"]
ReturnNone["Return None"]
Input --> Trim
Trim --> CheckEmpty
CheckEmpty -->|Yes| ReturnNone
CheckEmpty -->|No| ListProviders
ListProviders --> SearchExact
SearchExact -->|Yes| ReturnName
SearchExact -->|No| SearchAlias
SearchAlias -->|Yes| ReturnName
SearchAlias -->|No| ReturnNone
Sources: src/channels/mod.rs:186-205
If a provider name cannot be resolved:
Unknown provider `<raw_provider>`. Use `/models` to list valid providers.
If provider creation fails (e.g., invalid API key):
Failed to initialize provider `<provider_name>`. Route unchanged.
Details: <sanitized_error>
API keys and sensitive data are stripped from error messages via providers::sanitize_api_error().
If user sends /model with no argument or empty string:
Model ID cannot be empty. Use `/model <model-id>`.
Sources: src/channels/mod.rs:398-407, src/channels/mod.rs:573-585, src/channels/mod.rs:413-416
Runtime model switching respects channel-specific settings:
| Setting | Impact on Runtime Switching |
|---|---|
mention_only |
Commands require @botname suffix |
allowed_users |
Only allowlisted users can switch models |
stream_mode |
Applies to all providers (if channel supports it) |
Runtime-switched providers inherit settings from config.toml:
| Setting | Inherited Behavior |
|---|---|
reliability.provider_retries |
Applied to all providers |
reliability.provider_backoff_ms |
Applied to all providers |
reliability.api_keys |
Key rotation applies |
api_url |
Only used for default provider |
Custom api_url overrides (e.g., remote Ollama) do not apply to runtime-switched providers—they use standard endpoints.
Sources: src/channels/mod.rs:284-296
- Channel support: Only Telegram and Discord support runtime switching
-
No custom endpoints: Runtime-switched providers use default API endpoints (cannot override
api_url) - No config persistence: Route preferences reset on daemon restart
- No model validation: Users can specify any model ID—validation happens at LLM API call time
-
Cache-only model discovery:
/modelshows cached models only; does not fetch live model lists
Sources: src/channels/mod.rs:142-144, src/channels/mod.rs:266-308
User (Telegram):
/models anthropic
Bot response:
Provider switched to `anthropic` for this sender session. Current model is `claude-sonnet-4-5-20250929`.
Use `/model <model-id>` to set a provider-compatible model.
User:
/model claude-opus-4-6
Bot response:
Model switched to `claude-opus-4-6` for provider `anthropic` in this sender session.
Now all subsequent messages from this user use Anthropic's Claude Opus 4.6, while other users in the same channel continue using their own provider/model selections.
Runtime model switching affects the agent turn cycle at the provider and model level:
-
Provider resolution:
get_route_selection()determines which provider to use -
Provider instantiation:
get_or_create_provider()fetches or creates the provider -
Model selection:
route.modelis passed torun_tool_call_loop() - Tool execution: Tools execute regardless of provider/model (provider-agnostic)
- History storage: Per-sender history is maintained separately per route
The agent core (see Agent Turn Cycle) is provider-agnostic—it delegates model execution to the selected provider via the Provider trait.
Sources: src/channels/mod.rs:698-714