DEV Community

WonderLab
WonderLab

Posted on

OpenClaw Deep Dive (5): Model and Provider System

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:

  1. Addressing: How does the system know kimi-coding/k2p5 means "Moonshot AI, k2p5 model"?
  2. Authentication: Where are each provider's API keys stored and how are they retrieved?
  3. Auto-discovery: Do users have to manually list every provider in the config?
  4. Fallback: When the primary model fails, how does the system switch automatically?
  5. 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" | ...
};
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

Defaults (src/agents/defaults.ts):

export const DEFAULT_PROVIDER = "anthropic";
export const DEFAULT_MODEL = "claude-opus-4-6";
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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,
  };
}
Enter fullscreen mode Exit fullscreen mode

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
};
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
  • When models is non-empty: Only the listed models are allowed — this is an allowlist. Any unlisted provider/model is rejected with "model not allowed: ...".
  • alias field: Lets users write kimi instead of the full kimi-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" }
}
Enter fullscreen mode Exit fullscreen mode

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) };
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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: ...`);
Enter fullscreen mode Exit fullscreen mode

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:

  1. An auth method that interactively guides users through OAuth
  2. After auth succeeds, write a configPatch adding models.providers.minimax-portal to the config
  3. 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"),
        },
      ],
    });
  },
};
Enter fullscreen mode Exit fullscreen mode

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",
};
Enter fullscreen mode Exit fullscreen mode

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";
Enter fullscreen mode Exit fullscreen mode

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";
}
Enter fullscreen mode Exit fullscreen mode

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)