Scenario: A User Wants to Switch Between Claude and Kimi
You're using OpenClaw for daily work — mostly claude-sonnet-4-6, but for certain Chinese-language tasks you prefer Kimi (Moonshot AI). You have both providers' API keys in your config and want to address them as anthropic/claude-sonnet-4-6 or kimi-coding/k2p5. You also want automatic fallback to a backup model when Claude hits rate limits.
This scenario exposes five concrete problems:
-
Addressing: How does the system know
kimi-coding/k2p5means "Moonshot AI, k2p5 model"? - Authentication: Where are each provider's API keys stored and how are they retrieved?
- Auto-discovery: Do users have to manually list every provider in the config?
- Fallback: When the primary model fails, how does the system switch automatically?
- Extension: How do new providers like MiniMax integrate via the Plugin SDK?
1. Model Address: ModelRef
Every model operation starts from one fundamental unit:
// src/agents/model-selection.ts
export type ModelRef = {
provider: string; // "anthropic" | "kimi-coding" | "ollama" | ...
model: string; // "claude-sonnet-4-6" | "k2p5" | "llama3.2" | ...
};
Models are written everywhere as provider/model — for example anthropic/claude-sonnet-4-6. parseModelRef parses this string:
export function parseModelRef(raw: string, defaultProvider: string): ModelRef | null {
const slash = raw.indexOf("/");
if (slash === -1) {
// No slash: use the default provider (anthropic)
return normalizeModelRef(defaultProvider, raw);
}
const provider = raw.slice(0, slash);
const model = raw.slice(slash + 1);
return normalizeModelRef(provider, model);
}
Defaults (src/agents/defaults.ts):
export const DEFAULT_PROVIDER = "anthropic";
export const DEFAULT_MODEL = "claude-opus-4-6";
When a user writes model: claude-sonnet-4-6 (no slash), the system auto-completes it to anthropic/claude-sonnet-4-6 and issues a deprecation warning encouraging the full format.
Provider ID Normalization
Different users may use different names for the same provider. normalizeProviderId handles all known aliases:
export function normalizeProviderId(provider: string): string {
const normalized = provider.trim().toLowerCase();
if (normalized === "z.ai" || normalized === "z-ai") return "zai";
if (normalized === "qwen") return "qwen-portal";
if (normalized === "kimi-code") return "kimi-coding";
if (normalized === "bedrock" || normalized === "aws-bedrock") return "amazon-bedrock";
if (normalized === "bytedance" || normalized === "doubao") return "volcengine";
return normalized;
}
This means users can write doubao or bytedance in their config and both correctly route to the VolcEngine provider without needing to know the internal ID.
2. Provider Configuration: Static Declaration and Auto-Discovery
Static Configuration
Users declare each provider in openclaw.yml:
models:
providers:
anthropic:
apiKey: ANTHROPIC_API_KEY # environment variable name
kimi-coding:
baseUrl: https://api.kimi.com/coding/
apiKey: KIMI_CODING_API_KEY
api: anthropic-messages # protocol type
models:
- id: k2p5
name: "Kimi K2.5"
contextWindow: 262144
Each ProviderConfig contains:
-
apiKey: Can be the actual key string, an environment variable name (read automatically), or an OAuth placeholder -
baseUrl: The API endpoint -
api: Protocol type (anthropic-messages,openai-compatible,google-ai, etc.) -
models: The provider's model list with metadata — context window, reasoning capability, input types
Implicit Auto-Discovery
Requiring users to manually list every provider is too cumbersome. resolveImplicitProviders() scans automatically at Gateway startup:
export async function resolveImplicitProviders(params: {
agentDir: string;
explicitProviders?: Record<string, ProviderConfig> | null;
}): Promise<ModelsConfig["providers"]> {
const providers: Record<string, ProviderConfig> = {};
const authStore = ensureAuthProfileStore(params.agentDir, { allowKeychainPrompt: false });
// Check MiniMax: auto-activate if API key env var or auth profile exists
const minimaxKey = resolveEnvApiKeyVarName("minimax")
?? resolveApiKeyFromProfiles({ provider: "minimax", store: authStore });
if (minimaxKey) {
providers.minimax = { ...buildMinimaxProvider(), apiKey: minimaxKey };
}
// Check Kimi: same pattern
const kimiCodingKey = resolveEnvApiKeyVarName("kimi-coding")
?? resolveApiKeyFromProfiles({ provider: "kimi-coding", store: authStore });
if (kimiCodingKey) {
providers["kimi-coding"] = { ...buildKimiCodingProvider(), apiKey: kimiCodingKey };
}
// ... same pattern for 20+ providers
}
This means users only need to set a KIMI_CODING_API_KEY environment variable — the next time OpenClaw starts, it automatically finds Kimi and injects its full model list. No config changes needed.
Merge Strategy: merge Mode
When implicit discovery and explicit config coexist, models.mode = "merge" (the default) combines both:
function mergeProviderModels(implicit: ProviderConfig, explicit: ProviderConfig): ProviderConfig {
// Merge rules:
// - User-specified fields (cost, headers, compat) from explicit config are preserved
// - Metadata (input, contextWindow, maxTokens) refreshed from the built-in catalog
// - "reasoning" field: explicit user value wins; otherwise falls back to built-in default
return {
...explicitModel,
input: implicitModel.input,
reasoning: "reasoning" in explicitModel ? explicitModel.reasoning : implicitModel.reasoning,
contextWindow: implicitModel.contextWindow,
maxTokens: implicitModel.maxTokens,
};
}
This solves a real pain point: when Anthropic releases a new model, users don't need to manually update the contextWindow value in their config — the built-in catalog refreshes it automatically. But user-defined cost overrides and headers injections remain intact.
3. Model Catalog and Auth Profiles
Model Catalog
ModelCatalogEntry is the complete description of "one available model":
type ModelCatalogEntry = {
id: string; // "claude-sonnet-4-6"
name: string; // "Claude Sonnet 4.6"
provider: string; // "anthropic"
contextWindow?: number; // 200000
reasoning?: boolean; // supports extended thinking
input?: Array<"text" | "image">; // supported input types
};
The catalog is fetched through the ModelRegistry from the @mariozechner/pi-coding-agent SDK — the underlying reasoning SDK, which has a built-in list of the latest models from Anthropic, OpenAI, Google, and other major providers. Custom provider models are appended afterward.
Auth Profiles
To support multi-account and rate-limit rotation, OpenClaw doesn't store API keys directly. Instead it uses an Auth Profile system.
Each Profile looks like this:
type ApiKeyCredential = { type: "api_key"; provider: string; apiKey: string };
type OAuthCredential = { type: "oauth"; provider: string; access: string; refresh: string; expires: number };
type TokenCredential = { type: "token"; provider: string; token: string };
type AuthProfileCredential = ApiKeyCredential | OAuthCredential | TokenCredential;
Profiles are stored in ~/.openclaw/agents/<agentId>/auth.json. Users can configure multiple profiles for the same provider (e.g., two Anthropic accounts).
Cooldown mechanism: When a profile triggers a 429 (rate limit), the system calls markAuthProfileCooldown(), setting a cooldown period for that profile. On the next request, resolveAuthProfileOrder() prioritizes profiles not currently in cooldown — implementing automatic rotation across multiple accounts with no user intervention.
Request → resolveAuthProfileOrder() → select Profile (skip ones in cooldown)
↓
Hit 429? → markAuthProfileCooldown()
Success? → markAuthProfileGood() + markAuthProfileUsed()
4. Model Selection and Aliases
The Models Config Block: Both Allowlist and Alias Table
agents.defaults.models serves a dual purpose:
agents:
defaults:
model: anthropic/claude-sonnet-4-6
models:
anthropic/claude-sonnet-4-6:
alias: sonnet # short name for this model
kimi-coding/k2p5:
alias: kimi
anthropic/claude-haiku-4-5:
alias: haiku
-
When
modelsis non-empty: Only the listed models are allowed — this is an allowlist. Any unlistedprovider/modelis rejected with"model not allowed: ...". -
aliasfield: Lets users writekimiinstead of the fullkimi-coding/k2p5. Aliases are checked on every model reference.
Allowlist exception: Explicitly configured fallback lists bypass the allowlist. This is intentional — if a user configures a fallback chain, they're explicitly authorizing those models.
The Model Alias System
export function buildModelAliasIndex(params: {
cfg: OpenClawConfig;
defaultProvider: string;
}): ModelAliasIndex {
const byAlias = new Map<string, { alias: string; ref: ModelRef }>();
// ...
// Populates: alias "sonnet" → { provider: "anthropic", model: "claude-sonnet-4-6" }
}
Resolution always checks aliases first:
export function resolveModelRefFromString(params: {
raw: string;
defaultProvider: string;
aliasIndex?: ModelAliasIndex;
}): { ref: ModelRef; alias?: string } | null {
if (!params.raw.includes("/")) {
// No slash: check alias table first
const aliasMatch = params.aliasIndex?.byAlias.get(params.raw.toLowerCase());
if (aliasMatch) return { ref: aliasMatch.ref, alias: aliasMatch.alias };
}
return { ref: parseModelRef(params.raw, params.defaultProvider) };
}
So sending /model kimi in a Telegram message switches to kimi-coding/k2p5 without needing to remember the full path.
5. Fallback Chain: Automatic Downgrade When Primary Fails
Problem: Primary Model Hits Rate Limit or Goes Down
Configure a fallback chain:
agents:
defaults:
model:
primary: anthropic/claude-sonnet-4-6
fallbacks:
- anthropic/claude-haiku-4-5 # downgrade to smaller model first
- kimi-coding/k2p5 # then try Kimi
resolveFallbackCandidates() builds a deduped, ordered candidate list. The actual rotation logic lives in pi-embedded-runner.ts's outer retry loop — the same structure we analyzed in the Agent Engine article:
// Simplified
const candidates = resolveFallbackCandidates({ cfg, provider, model });
for (const candidate of candidates) {
try {
return await runWithModel(candidate); // return on success
} catch (err) {
if (isFailoverError(err)) continue; // degradable error: try next
if (isFallbackAbortError(err)) throw; // user abort: don't degrade
}
}
throw new Error(`All fallbacks failed: ...`);
Probe Mechanism
Models in cooldown are not skipped permanently. shouldProbePrimaryDuringCooldown implements a probe mechanism:
- Every 30 seconds, even if the primary model is still in cooldown, one probe request is allowed
- If the cooldown expiry is within 2 minutes, probing starts early
- Probe succeeds → primary model restored,
markAuthProfileGood()called
This ensures that once rate limiting clears, the system quickly returns to the primary model rather than staying in a degraded state forever.
6. Registering New Providers via the Plugin SDK
Problem: How Does a Third-Party Provider Integrate?
Take MiniMax as an example. Its OAuth flow isn't a standard API key — it requires a browser redirect via a Device Code flow. This needs:
- An
authmethod that interactively guides users through OAuth - After auth succeeds, write a
configPatchaddingmodels.providers.minimax-portalto the config - Write the auth profile (access token + refresh token)
All of this is done through the ProviderPlugin type and api.registerProvider():
// extensions/minimax-portal-auth/index.ts
const minimaxPortalPlugin = {
id: "minimax-portal-auth",
configSchema: emptyPluginConfigSchema(),
register(api: OpenClawPluginApi) {
api.registerProvider({
id: "minimax-portal",
label: "MiniMax",
docsPath: "/providers/minimax",
aliases: ["minimax"],
auth: [
{
id: "oauth",
label: "MiniMax OAuth (Global)",
kind: "device_code", // triggers Device Code flow
run: createOAuthHandler("global"),
},
{
id: "oauth-cn",
label: "MiniMax OAuth (CN)",
kind: "device_code",
run: createOAuthHandler("cn"),
},
],
});
},
};
The ProviderAuthMethod.run function returns a ProviderAuthResult containing:
return {
// Write to auth profile: access token + refresh token
profiles: [{
profileId: "minimax-portal:default",
credential: { type: "oauth", access: "...", refresh: "...", expires: ... },
}],
// configPatch written to openclaw.yml: provider config + model list
configPatch: {
models: {
providers: {
"minimax-portal": {
baseUrl: "https://api.minimax.io/anthropic",
apiKey: "minimax-oauth", // OAuth placeholder
api: "anthropic-messages",
models: [{ id: "MiniMax-M2.1", ... }, { id: "MiniMax-M2.5", ... }],
},
},
},
agents: { defaults: { models: {
"minimax-portal/MiniMax-M2.1": { alias: "minimax-m2.1" },
"minimax-portal/MiniMax-M2.5": { alias: "minimax-m2.5" },
}}},
},
// Guide user to immediately switch to this model
defaultModel: "minimax-portal/MiniMax-M2.5",
};
The user runs openclaw login, selects MiniMax, completes the OAuth redirect — and all of this is written automatically. From then on, the minimax-m2.5 alias is ready to use.
7. The reasoning Field and Thinking Levels
Certain models (such as Claude Sonnet 4.6 in extended thinking mode) support "slow thinking" — spending more tokens on reasoning before producing an answer. OpenClaw manages this with ThinkLevel:
export type ThinkLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
resolveThinkingDefault reads the reasoning: true field from the model catalog and automatically sets the default thinking level to "low" for reasoning-capable models:
export function resolveThinkingDefault(params: {
cfg: OpenClawConfig;
provider: string;
model: string;
catalog?: ModelCatalogEntry[];
}): ThinkLevel {
const configured = params.cfg.agents?.defaults?.thinkingDefault;
if (configured) return configured;
const candidate = params.catalog?.find(
(entry) => entry.provider === params.provider && entry.id === params.model,
);
// Models with reasoning capability default to "low" thinking level
return candidate?.reasoning ? "low" : "off";
}
Users don't need to manually configure "enable thinking for this model" — the capability declaration in the model catalog automatically determines the default behavior.
Summary
The model and provider system is fundamentally a multi-layered addressing and routing mechanism:
| Layer | Mechanism | Purpose |
|---|---|---|
| Address layer | ModelRef = { provider, model } |
Unified "provider/model" coordinate |
| Normalization layer |
normalizeProviderId() + normalizeProviderModelId()
|
Handle diversity in user input |
| Alias layer | ModelAliasIndex |
Let users use short names instead of full paths |
| Catalog layer | ModelCatalogEntry[] |
Declare model capabilities (context, reasoning, input types) |
| Discovery layer | resolveImplicitProviders() |
Scan env vars/auth profiles, auto-activate providers |
| Merge layer | mergeProviderModels() |
Built-in catalog refreshes metadata; preserves user customizations |
| Auth layer | Auth Profiles + cooldown mechanism | Multi-account rotation, automatic rate-limit evasion |
| Fallback layer |
resolveFallbackCandidates() + probe |
Auto-downgrade on primary failure; probe restores after cooldown |
| Extension layer |
ProviderPlugin + api.registerProvider()
|
Third-party providers inject OAuth flows and model configs via Plugin SDK |
In the next article, we'll dive into OpenClaw's node system and Canvas — exploring how the "node" concept in the Pi framework supports multi-Agent collaboration, and what Canvas actually is.
Top comments (0)