{"repo": "acaclaw/acaclaw", "files": {"docs/en/providers-and-models.md": {"c18e476aacc68687e542e29de46809d44e0d0250": {"t": 1785746375.3469014, "n": 33708, "h": "5adae4a70517fff8", "c": "---\nlayout: page\ntitle: Providers & Models\nlang: en\npermalink: /en/providers-and-models/\n---\n\n> **Golden Rule**: OpenClaw owns provider management, model discovery, and API key storage. AcaClaw's GUI reads and writes through OpenClaw's APIs — never maintaining a parallel catalog.\n\n---\n\n## How OpenClaw Manages LLM Providers\n\n### Provider Definition\n\nEach LLM provider is a `ModelProviderConfig` object stored in `openclaw.json` under `models.providers.`:\n\n```json\n{\n \"models\": {\n \"providers\": {\n \"anthropic\": {\n \"baseUrl\": \"https://api.anthropic.com\",\n \"apiKey\": \"sk-ant-...\",\n \"auth\": \"api-key\",\n \"api\": \"anthropic-messages\",\n \"models\": [\n {\n \"id\": \"claude-opus-4-6\",\n \"name\": \"Claude Opus\",\n \"reasoning\": true,\n \"input\": [\"text\", \"image\"],\n \"contextWindow\": 200000,\n \"maxTokens\": 4096,\n \"cost\": { \"input\": 0.003, \"output\": 0.015, \"cacheRead\": 0.0003, \"cacheWrite\": 0.00375 }\n }\n ]\n }\n }\n }\n}\n```\n\n### Provider Type Reference\n\n```\nModelProviderConfig\n├── baseUrl: string # API endpoint URL\n├── apiKey?: SecretInput # Literal string, env var ref, or exec source\n├── auth?: ModelProviderAuthMode # \"api-key\" | \"aws-sdk\" | \"oauth\" | \"token\"\n├── api?: ModelApi # API format (see list below)\n├── headers?: Record # Custom auth/routing headers\n├── authHeader?: boolean # Include Authorization header\n├── injectNumCtxForOpenAICompat?: boolean # Ollama context injection\n└── models: ModelDefinitionConfig[] # Static model list\n```\n\n### Supported API Formats (`ModelApi`)\n\n| Value | Used By |\n|---|---|\n| `openai-completions` | OpenAI, DeepSeek, Together, Groq, most OpenAI-compatible |\n| `openai-responses` | OpenAI Responses API |\n| `anthropic-messages` | Anthropic Claude |\n| `google-generative-ai` | Google Gemini (Generative AI) |\n| `bedrock-converse-stream` | AWS Bedrock |\n| `ollama` | Ollama (local models) |\n| `github-copilot` | GitHub Copilot |\n| `openai-codex-responses` | OpenAI Codex |\n\n### Provider Auth Modes\n\n| Mode | When To Use |\n|---|---|\n| `api-key` | Standard API key authentication (most providers) |\n| `oauth` | OAuth2 token exchange (Google Vertex, some enterprise) |\n| `aws-sdk` | AWS IAM / Bedrock (uses AWS SDK credentials) |\n| `token` | Bearer token authentication |\n\n---\n\n## How OpenClaw Maps Provider → Model Lists\n\n### Three Sources of Models\n\nOpenClaw's model catalog is assembled from three sources, merged at startup:\n\n```\n┌─────────────────────────────────────────────┐\n│ 1. Built-in Catalog (PI SDK) │\n│ ~50+ models (Claude, GPT, Gemini, etc.) │\n│ Loaded from embedded model registry │\n├─────────────────────────────────────────────┤\n│ 2. User Config (openclaw.json) │\n│ models.providers..models[] │\n│ User-defined or override models │\n├─────────────────────────────────────────────┤\n│ 3. Plugin Extensions │\n│ extensions//provider-catalog.ts │\n│ Dynamic provider discovery hooks │\n└─────────────────────────────────────────────┘\n │\n ▼\n ┌───────────────┐\n │ Model Catalog │ ← Deduplicated, sorted by provider then name\n │ (in memory) │\n └───────────────┘\n```\n\n### Model Definition Structure\n\nEach model in the catalog has:\n\n```\nModelDefinitionConfig\n├── id: string # Model ID (e.g. \"claude-opus-4-6\", \"gpt-5.4\")\n├── name: string # Display name\n├── api?: ModelApi # Override provider-level API format\n├── reasoning: boolean # Supports extended thinking / chain-of-thought\n├── input: string[] # [\"text\"] or [\"text\", \"image\"]\n├── contextWindow: number # Max input tokens (e.g. 200000)\n├── maxTokens: number # Max output tokens\n├── cost: CostConfig # Per-token pricing\n│ ├── input: number\n│ ├── output: number\n│ ├── cacheRead: number\n│ └── cacheWrite: number\n├── headers?: Record # Model-specific request headers\n└── compat?: CompatConfig # API quirks and feature flags\n```\n\n### Catalog Entry (What the API Returns)\n\nThe `models.list` RPC returns a simplified view:\n\n```\nModelCatalogEntry\n├── id: string # Model ID\n├── name: string # Display name\n├── provider: string # Provider ID (e.g. \"anthropic\", \"openai\")\n├── contextWindow?: number\n├── reasoning?: boolean\n└── input?: string[] # [\"text\"] or [\"text\", \"image\"]\n```\n\n### Model Loading Flow\n\n```\nloadModelCatalog()\n│\n├─ 1. ensureOpenClawModelsJson()\n│ └─ Fingerprints config + env → writes models.json if changed\n│\n├─ 2. Import PI SDK (pi-model-discovery-runtime)\n│ └─ Built-in Anthropic/OpenAI/Google catalog (~50+ models)\n│\n├─ 3. Build ModelRegistry from models.json\n│ ├─ Filter suppressed models\n│ └─ Merge user-configured opt-in providers\n│\n├─ 4. augmentModelCatalogWithProviderPlugins()\n│ └─ Each extension's discovery hook adds its models\n│\n├─ 5. Deduplicate by lowercase \"provider::modelId\"\n│\n└─ 6. Sort by provider, then model name → return cached\n```\n\n### Config Merging: `mode`\n\n| Mode | Behavior |\n|---|---|\n| `merge` (default) | User providers are ADDED to the built-in catalog |\n| `replace` | ONLY user-configured providers are used (built-ins ignored) |\n\n### Model Allowlist\n\nIf `agents.defaults.models` is a non-empty map, `models.list` returns ONLY models in that map. Keep it empty (`{}`) for unrestricted access.\n\n---\n\n## API Key Resolution Chain\n\nWhen OpenClaw needs an API key for a provider, it checks these sources in order:\n\n```\n1. Literal value in config\n models.providers..apiKey = \"sk-...\"\n\n2. Environment variable reference\n models.providers..apiKey = \"${ANTHROPIC_API_KEY}\"\n\n3. Auth profiles store\n ~/.openclaw/credentials/ or auth-profiles.json\n\n4. Well-known environment variables\n ANTHROPIC_API_KEY, OPENAI_API_KEY, etc.\n\n5. OAuth token exchange (for oauth auth mode)\n\n6. AWS SDK credentials (for bedrock auth mode)\n```\n\nOpenClaw redacts keys in API responses with `\"__OPENCLAW_REDACTED__\"` — the actual key value is never returned via `config.get`.\n\n---\n\n## Gateway API Endpoints\n\n### WebSocket RPC: `models.list`\n\nThe primary way to get available models.\n\n```\nRequest:\n { type: \"req\", id: \"\", method: \"models.list\", params: {} }\n\nResponse:\n {\n type: \"res\", id: \"\", ok: true,\n payload: {\n models: [\n { id: \"claude-opus-4-6\", name: \"Claude Opus\", provider: \"anthropic\", contextWindow: 200000, reasoning: true },\n { id: \"gpt-5.4\", name: \"GPT-5.4\", provider: \"openai\", contextWindow: 128000, reasoning: false },\n ...\n ]\n }\n }\n```\n\nThe response is filtered by `agents.defaults.models` allowlist if configured.\n\n### WebSocket RPC: `config.get`\n\nReturns the full config snapshot (with API keys redacted).\n\n```\nRequest:\n { type: \"req\", id: \"\", method: \"config.get\", params: {} }\n\nResponse:\n {\n type: \"res\", id: \"\", ok: true,\n payload: {\n config: { ... full openclaw.json ... },\n baseHash: \"abc123\",\n hash: \"def456\"\n }\n }\n```\n\n### WebSocket RPC: `config.set`\n\nWrites the full config (requires `baseHash` for optimistic concurrency).\n\n```\nRequest:\n {\n type: \"req\", id: \"\", method: \"config.set\",\n params: { raw: \"\", baseHash: \"abc123\" }\n }\n```\n\n### HTTP: `GET /v1/models` (OpenAI-compatible)\n\nReturns agent-level model IDs (not the raw provider models):\n\n```json\n{\n \"object\": \"list\",\n \"data\": [\n { \"id\": \"openclaw\", \"object\": \"model\", \"created\": 0, \"owned_by\": \"openclaw\" },\n { \"id\": \"openclaw/main\", \"object\": \"model\", \"created\": 0, \"owned_by\": \"openclaw\" }\n ]\n}\n```\n\n---\n\n## Bundled Providers (~30+)\n\nOpenClaw ships with built-in support for these providers (no config needed if API key is set):\n\n### Core LLM Providers\n\n| Config ID | Display Name | API Format | Base URL | Env Var | Catalog IDs |\n|---|---|---|---|---|---|\n| `anthropic` | Anthropic | `anthropic-messages` | `https://api.anthropic.com` | `ANTHROPIC_API_KEY` | `anthropic` |\n| `openai` | OpenAI | `openai-completions` | `https://api.openai.com/v1` | `OPENAI_API_KEY` | `openai`, `openai-codex` |\n| `google` | Google AI | `google-generative-ai` | (SDK-managed) | `GOOGLE_API_KEY` | `google`, `google-gemini-cli` |\n| `deepseek` | DeepSeek | `openai-completions` | `https://api.deepseek.com/v1` | `DEEPSEEK_API_KEY` | `deepseek` |\n| `mistral` | Mistral | `openai-completions` | `https://api.mistral.ai/v1` | `MISTRAL_API_KEY` | `mistral` |\n| `openrouter` | OpenRouter | `openai-completions` | `https://openrouter.ai/api/v1` | `OPENROUTER_API_KEY` | `openrouter` |\n| `ollama` | Ollama (local) | `ollama` | `http://localhost:11434` | — | `ollama` |\n| `amazon-bedrock` | Amazon Bedrock | `bedrock-converse-stream` | (AWS SDK) | AWS credentials | `amazon-bedrock` |\n| `azure` | Azure OpenAI | `openai-completions` | (user-configured) | `AZURE_OPENAI_API_KEY` | `azure-openai-responses` |\n\n### xAI, Chinese Providers, and Others\n\n| Config ID | Display Name | API Format | Base URL | Env Var | Catalog IDs |\n|---|---|---|---|---|---|\n| `xai` | xAI (Grok) | `openai-completions` | `https://api.x.ai/v1` | `XAI_API_KEY` | `xai` |\n| `modelstudio` | Qwen (Alibaba Cloud Model Studio) | `openai-completions` | `https://coding-intl.dashscope.aliyuncs.com/v1` (Global) / `https://coding.dashscope.aliyuncs.com/v1` (CN) | `MODELSTUDIO_API_KEY` | `modelstudio` |\n| `volcengine` | Volcengine (Doubao / ByteDance) | `openai-completions` | `https://ark.cn-beijing.volces.com/api/v3` | `VOLCANO_ENGINE_API_KEY` | `volcengine`, `volcengine-plan` |\n| `moonshot` | Moonshot / Kimi | `openai-completions` | `https://api.moonshot.ai/v1` (Intl) / `https://api.moonshot.cn/v1` (CN) | `MOONSHOT_API_KEY` | `moonshot`, `kimi`, `kimi-coding` |\n| `qianfan` | Qianfan (Baidu) | `openai-completions` | `https://qianfan.baidubce.com/v2` | `QIANFAN_API_KEY` | `qianfan` |\n| `minimax` | MiniMax | `openai-completions` | `https://api.minimax.io/v1` | `MINIMAX_API_KEY` | `minimax`, `minimax-portal` |\n| `together` | Together AI | `openai-completions` | `https://api.together.xyz/v1` | `TOGETHER_API_KEY` | `together` |\n| `nvidia` | NVIDIA | `openai-completions` | `https://integrate.api.nvidia.com/v1` | `NVIDIA_API_KEY` | `nvidia` |\n| `venice` | Venice AI | `openai-completions` | `https://api.venice.ai/api/v1` | `VENICE_API_KEY` | `venice` |\n| `github-copilot` | GitHub Copilot | `openai-responses` | `https://api.individual.githubcopilot.com` | `COPILOT_GITHUB_TOKEN` / `GH_TOKEN` | `github-copilot` |\n| `huggingface` | Hugging Face | `openai-completions` | (Inference API) | `HF_TOKEN` | `huggingface` |\n\n### Utility-Only Plugins (Not Model Providers)\n\nThese extensions provide non-LLM services and do **not** appear in `models.list`:\n\n| Plugin ID | Purpose | Env Var |\n|---|---|---|\n| `groq` | Audio transcription (Whisper) | `GROQ_API_KEY` |\n| `perplexity` | Web search | `PERPLEXITY_API_KEY` |\n| `brave` | Brave web search | `BRAVE_API_KEY` |\n\n### Provider ID Aliases\n\nOpenClaw normalizes some legacy/alternative provider IDs via `normalizeProviderId()`:\n\n| Input | Normalized To |\n|---|---|\n| `bytedance`, `doubao` | `volcengine` |\n| `bedrock`, `aws-bedrock` | `amazon-bedrock` |\n| `qwen` | `qwen-portal` |\n| `kimi`, `kimi-code`, `kimi-coding` | `kimi` |\n\nAdditional providers can be added via extensions or user config.\n\n---\n\n## Plugin Provider Discovery\n\nExtensions register providers through the Plugin SDK:\n\n```typescript\n// extensions/my-provider/provider-catalog.ts\nexport function buildMyProvider(): ModelProviderConfig {\n return {\n baseUrl: \"https://api.myprovider.com/v1\",\n api: \"openai-completions\",\n models: [\n { id: \"my-model-1\", name: \"My Model 1\", reasoning: false, input: [\"text\"], contextWindow: 128000, maxTokens: 4096, cost: { input: 0.001, output: 0.002 } }\n ]\n };\n}\n```\n\nDiscovery runs in 4 stages: `simple` → `profile` → `paired` → `late`.\n\n---\n\n## Model Reference Format\n\nModels are referenced as `provider/model-id` strings:\n\n```\nanthropic/claude-opus-4-6\nopenai/gpt-5.4\nopenrouter/anthropic/claude-sonnet-4\nmoonshot/kimi-k2.5\nollama/llama3\n```\n\nThe `parseModelRef(raw)` function splits on the FIRST `/`:\n- `openrouter/anthropic/claude-sonnet-4` → provider=`openrouter`, model=`anthropic/claude-sonnet-4`\n\n---\n\n## Catalog-to-Config Provider ID Mapping\n\n### The Problem: Two ID Systems\n\nOpenClaw has **two independent provider ID systems** that AcaClaw must bridge:\n\n1. **Config provider IDs** — Keys under `models.providers.` in `openclaw.json` (e.g. `moonshot`, `azure`, `google`)\n2. **Catalog provider IDs** — The `provider` field returned by `models.list` (e.g. `kimi-coding`, `azure-openai-responses`, `google-vertex`)\n\nThese IDs often **do not match**. For example:\n- Config has `moonshot` → catalog returns models with `provider: \"kimi-coding\"` and `provider: \"moonshot\"`\n- Config has `azure` → catalog returns `provider: \"azure-openai-responses\"`\n- Config has `google` → catalog returns `provider: \"google-vertex\"` and `provider: \"google-generative-ai\"`\n\nWithout bridging these, the UI cannot filter the model catalog to show only models from configured providers.\n\n### Why Two Systems Exist\n\nOpenClaw extensions often register multiple provider entries under a single API key. The moonshot extension is a good example:\n\n```\nExtension: moonshot (api.moonshot.ai, 4 models)\nExtension: kimi-coding (api.kimi.com/coding/, 2 models)\nBoth share the same \"moonshot\" config key and API key\n```\n\nOpenClaw's `normalizeProviderId()` (in `src/agents/provider-id.ts`) maps aliases:\n- `kimi`, `kimi-code`, `kimi-coding` → `\"kimi\"` (internally)\n- `bedrock`, `aws-bedrock` → `\"amazon-bedrock\"`\n\nAnd `PLUGIN_ID_ALIASES` in `config-state.ts` maps extension IDs:\n- `\"kimi-coding\"` → `\"kimi\"` (shares moonshot's API key)\n- `\"openai-codex\"` → `\"openai\"`\n\n### The Fix: `CATALOG_TO_CONFIG_PROVIDER` Mapping\n\nAcaClaw maintains a static mapping table in `ui/src/models/provider-mapping.ts`:\n\n```typescript\nexport const CATALOG_TO_CONFIG_PROVIDER: Record = {\n // Core providers\n \"anthropic\": \"anthropic\",\n \"openai\": \"openai\",\n \"openai-codex\": \"openai\",\n \"openai-responses\": \"openai\",\n \"azure-openai-responses\": \"azure\",\n \"google\": \"google\",\n \"google-vertex\": \"google\",\n \"google-gemini-cli\": \"google\",\n \"deepseek\": \"deepseek\",\n \"mistral\": \"mistral\",\n \"openrouter\": \"openrouter\",\n \"ollama\": \"ollama\",\n // Moonshot / Kimi\n \"moonshot\": \"moonshot\",\n \"kimi\": \"moonshot\",\n \"kimi-coding\": \"moonshot\",\n // xAI\n \"xai\": \"xai\",\n // ModelStudio (Alibaba / Qwen)\n \"modelstudio\": \"modelstudio\",\n // Volcengine (Doubao / ByteDance)\n \"volcengine\": \"volcengine\",\n \"volcengine-plan\": \"volcengine\",\n // GitHub Copilot\n \"github-copilot\": \"github-copilot\",\n // Others\n \"together\": \"together\",\n \"nvidia\": \"nvidia\",\n \"venice\": \"venice\",\n \"qianfan\": \"qianfan\",\n \"amazon-bedrock\": \"amazon-bedrock\",\n \"huggingface\": \"huggingface\",\n \"minimax\": \"minimax\",\n \"minimax-portal\": \"minimax\",\n \"byteplus\": \"volcengine\",\n \"byteplus-plan\": \"volcengine\",\n};\n```\n\nThe helper function `catalogToConfigProvider(catalogId)` returns the config key, falling back to the catalog ID itself for unknown providers.\n\n### How Filtering Works\n\nWhen a user has `openrouter` configured and the catalog returns 809 models across 23 providers:\n\n```\nmodels.list → 809 models (all providers)\nconfig.get → env = { OPENROUTER_API_KEY: \"...\" }\n models.providers = { openrouter: { apiKey: \"...\" } } (legacy)\n\nDetection: check config.env for known env vars, then check models.providers (backward compat)\nFilter: model.provider → catalogToConfigProvider() → check if result is in configuredProviders\nResult: 246 models (only openrouter)\n```\n\nAcaClaw detects configured providers from **both** `config.env` (env vars, new approach) and `models.providers` (legacy entries with apiKey).\n\n### Reverse Lookup: Config → Catalog Models\n\nFor per-provider model tables (showing which models a configured provider gives you), we do a reverse lookup:\n\n```typescript\n// Find all catalog providers that map to a config provider\n_modelsForConfigProvider(configId: string) {\n const catalogProviders = Object.entries(CATALOG_TO_CONFIG_PROVIDER)\n .filter(([, cfg]) => cfg === configId)\n .map(([cat]) => cat);\n return this._modelCatalog.filter(m =>\n m.provider && catalogProviders.includes(m.provider)\n );\n}\n```\n\nFor `moonshot`, this returns models from both `\"moonshot\"` and `\"kimi-coding\"` catalog entries.\n\n### Default Model and Stale References\n\nThe default model is stored at `agents.defaults.model` as a `\"provider/model-id\"` string (e.g. `\"kimi-coding/kimi-k2-thinking\"`). If the user later removes the moonshot API key, this reference becomes \"stale\" — the model still exists in config but its provider is no longer configured. The UI handles this by:\n\n1. Displaying the raw model ID string when no friendly name match exists\n2. Keeping the reference intact (the gateway may still honor it via other routing)\n\n### Test Coverage\n\nThe mapping is covered by 30 unit tests across three files:\n- `tests/provider-mapping.test.ts` — Validates mapping correctness and completeness\n- `tests/model-config.test.ts` — Validates model filtering and default save behavior\n- `tests/chat-default-model.test.ts` — Validates chat reads config and filters models\n\n---\n\n## AcaClaw GUI Integration Method\n\n### Principle\n\nAcaClaw's GUI is a frontend to OpenClaw's backend. For provider and model management:\n\n1. **Read** provider/model state via `config.get` and `models.list` RPCs\n2. **Write** changes via `config.set` RPC (full config with `baseHash`)\n3. **Never** maintain a separate provider catalog or model list\n4. **Never** hardcode provider URLs — OpenClaw resolves these automatically\n5. **Always** use `catalogToConfigProvider()` when filtering catalog models by configured providers\n\n### Data Flow\n\n```\n┌─────────────────┐ WebSocket RPC ┌──────────────────┐\n│ AcaClaw GUI │ ◄─────────────────────► │ OpenClaw Gateway │\n│ │ │ │\n│ api-keys.ts │──── config.get ────────►│ Config store │\n│ │◄─── {config, baseHash} ──│ │\n│ │ │ │\n│ │──── models.list ────────►│ Model catalog │\n│ │◄─── {models: [...]} ────│ (cached) │\n│ │ │ │\n│ provider- │ catalogToConfigProvider │ │\n│ mapping.ts │ bridges the two ID │ │\n│ │ systems for filtering │ │\n│ │ │ │\n│ │──── config.set ─────────►│ Validate + save │\n│ │ {raw, baseHash} │ → restart │\n└─────────────────┘ └──────────────────┘\n```\n\n### Gateway Restart on `config.env` Changes\n\nOpenClaw's config reloader has no hot-reload rule for the `env` prefix.\nAny change to `config.env.*` triggers a full gateway restart (default behavior\nfor unmatched config paths). AcaClaw handles this by:\n\n1. **Not refreshing models immediately** — env vars are applied only at startup, so\n the model catalog would be stale until the restart completes.\n2. **Setting `_loaded = false`** so the existing `state-change` listener on the\n gateway re-runs `_loadState()` (including model refresh) once the WebSocket\n reconnects after the restart.\n3. **Showing \"gateway reloading…\"** in the flash message so the user knows the\n brief disconnect is expected.\n\n### Implementation: Reading Provider State\n\n```typescript\n// 1. Get full config (API keys are redacted)\n// Note: config.get returns { config: {...}, hash: \"...\" } — unwrap the .config property\nconst configResult = await gateway.call(\"config.get\");\nconst cfg = (configResult?.config as Record) ?? configResult ?? {};\nconst baseHash = configResult.payload?.baseHash;\n\n// 2. Extract configured providers\nconst providers = cfg.models?.providers ?? {};\n// providers = { anthropic: { baseUrl, apiKey: \"__OPENCLAW_REDACTED__\", models: [...] }, ... }\n\n// 3. Detect which providers have keys set\nfor (const [id, provider] of Object.entries(providers)) {\n const hasKey = provider.apiKey && provider.apiKey !== \"\";\n const isRedacted = provider.apiKey === \"__OPENCLAW_REDACTED__\";\n // hasKey || isRedacted → provider is configured\n}\n\n// 4. Also check auth profiles (alternative key storage)\nconst authProfiles = config.auth?.profiles ?? {};\nfor (const [profileId, profile] of Object.entries(authProfiles)) {\n // profile.provider tells you which provider this profile configures\n}\n```\n\n### Implementation: Getting Available Models\n\n```typescript\nimport { catalogToConfigProvider } from \"../models/provider-mapping.js\";\n\n// Call models.list — returns the full merged catalog (ALL providers, not just configured ones)\nconst result = await gateway.call(\"models.list\");\nconst allModels = result.payload.models;\n// allModels may contain 800+ models across 23+ providers\n\n// IMPORTANT: Filter to only configured providers using the mapping\nconst configuredProviders = new Set(Object.keys(cfg.models?.providers ?? {}));\nconst models = allModels.filter(m =>\n m.provider ? configuredProviders.has(catalogToConfigProvider(m.provider)) : false\n);\n// Now models only contains models from providers the user has API keys for\n```\n\n### Implementation: Saving API Keys\n\n```typescript\n// 1. Read current config + baseHash\nconst { config, baseHash } = (await gateway.call(\"config.get\")).payload;\n\n// 2. Set the API key in the config\nconfig.models ??= {};\nconfig.models.providers ??= {};\nconfig.models.providers[\"anthropic\"] ??= { baseUrl: \"https://api.anthropic.com\", models: [] };\nconfig.models.providers[\"anthropic\"].apiKey = \"sk-ant-...\";\n\n// 3. Write back with baseHash (optimistic concurrency)\nawait gateway.call(\"config.set\", {\n raw: JSON.stringify(config),\n baseHash,\n});\n\n// After config.set, the gateway restarts and model catalog refreshes\n```\n\n### Implementation: Setting Default Model\n\n```typescript\n// 1. Read config\nconst { config, baseHash } = (await gateway.call(\"config.get\")).payload;\n\n// 2. Set default model (format: \"provider/model-id\")\nconfig.agents ??= {};\nconfig.agents.defaults ??= {};\nconfig.agents.defaults.model = \"anthropic/claude-opus-4-6\";\n\n// 3. Write back\nawait gateway.call(\"config.set\", { raw: JSON.stringify(config), baseHash });\n```\n\n### Implementation: Removing a Provider's API Key\n\n```typescript\n// Use updateConfig (read-modify-write) to delete the provider entry\nawait updateConfig((cfg) => {\n const providers = cfg.models?.providers;\n if (providers) delete providers[\"anthropic\"];\n\n // Also clean auth profiles that reference this provider\n const profiles = cfg.auth?.profiles;\n if (profiles) {\n for (const [key, val] of Object.entries(profiles)) {\n if (val?.provider === \"anthropic\") delete profiles[key];\n }\n }\n\n // If the default model used this provider, clear it\n const defaultModel = cfg.agents?.defaults?.model;\n if (typeof defaultModel === \"string\" && defaultModel.startsWith(\"anthropic/\")) {\n delete cfg.agents.defaults.model;\n }\n\n return cfg;\n});\n```\n\nAfter removal, re-call `models.list` — the provider's models will no longer appear in the catalog.\n\n### Implementation: Per-Provider Model Browsing\n\nGroup models by provider and display each provider's available models. Use `catalogToConfigProvider()` to bridge the two ID systems:\n\n```typescript\nimport { catalogToConfigProvider, CATALOG_TO_CONFIG_PROVIDER } from \"../models/provider-mapping.js\";\n\n// After calling models.list\nconst models = result.payload.models;\n\n// Filter to only configured providers\nconst configuredProviders = new Set(Object.keys(config.models?.providers ?? {}));\nconst filtered = models.filter(m =>\n m.provider ? configuredProviders.has(catalogToConfigProvider(m.provider)) : false\n);\n\n// For a selected config provider (e.g. \"moonshot\"),\n// find ALL catalog providers that map to it\nfunction modelsForConfigProvider(configId: string) {\n const catalogProviders = Object.entries(CATALOG_TO_CONFIG_PROVIDER)\n .filter(([, cfg]) => cfg === configId)\n .map(([cat]) => cat);\n return models.filter(m => m.provider && catalogProviders.includes(m.provider));\n}\n\n// moonshot → returns models from both \"moonshot\" and \"kimi-coding\" providers\nconst moonshotModels = modelsForConfigProvider(\"moonshot\");\n```\n\n### Implementation: Chat Uses Default Model\n\nThe chat view reads the default model and filters models in `_loadModels()`:\n\n```typescript\nimport { catalogToConfigProvider } from \"../models/provider-mapping.js\";\n\nasync _loadModels() {\n const [modelsResult, configResult] = await Promise.all([\n gateway.call(\"models.list\", {}),\n gateway.call(\"config.get\"),\n ]);\n\n // Handle config.get response wrapper: { config: {...}, hash: \"...\" }\n const cfg = (configResult?.config as Record) ?? configResult ?? {};\n\n // Which config providers are configured (have API keys)?\n const providers = cfg.models?.providers;\n const configuredProviders = new Set(providers ? Object.keys(providers) : []);\n\n // Read the user-saved default model from agents.defaults.model\n const savedDefaultModel = cfg.agents?.defaults?.model ?? \"\";\n\n // Filter models to only configured providers using the mapping\n const filtered = modelsResult.models.filter(m =>\n m.provider ? configuredProviders.has(catalogToConfigProvider(m.provider)) : false\n );\n\n // Build dropdown options\n this._availableModels = filtered.map(m => ({\n value: m.provider ? `${m.provider}/${m.id}` : m.id,\n label: m.provider ? `${m.name} · ${m.provider}` : m.name,\n }));\n\n // Display the saved default model, or fall back to first available\n if (savedDefaultModel) {\n const match = filtered.find(m => `${m.provider}/${m.id}` === savedDefaultModel);\n this._defaultModelDisplay = match\n ? `${match.name} · ${match.provider}`\n : savedDefaultModel; // Show raw ID if provider is unconfigured\n }\n}\n```\n\nKey points:\n- Chat reads `agents.defaults.model` from `config.get` (not from `sessions.list`)\n- Models are filtered using `catalogToConfigProvider()` to match catalog IDs to config keys\n- When the saved default model belongs to an unconfigured provider, the raw model ID is displayed\n- The gateway resolves model fallback: session override → agent config → `agents.defaults.model`\n\n### Implementation: Test Connection\n\nOpenClaw has no dedicated \"verify API key\" endpoint. Use `models.list` as a connectivity test:\n\n```typescript\nasync function testConnection(): Promise {\n try {\n const result = await gateway.call(\"models.list\");\n return result.payload.models.length > 0;\n } catch {\n return false;\n }\n}\n```\n\nIf `models.list` returns an empty array after setting a key, the key may be invalid or the provider URL unreachable.\n\n### GUI Component Design\n\n```\n┌──────────────────────────────────────────────────────────────┐\n│ API Keys & Models │\n├──────────────────────────────────────────────────────────────┤\n│ │\n│ ┌─ Provider Tabs ──────────────────────────────────────┐ │\n│ │ [Anthropic ✓] [OpenAI] [Google] [Moonshot] [+More] │ │\n│ └──────────────────────────────────────────────────────┘ │\n│ │\n│ API Key: [sk-ant-•••••••••••••] [👁] [Save] │\n│ Status: ● Connected (3 models available) │\n│ │\n│ ┌─ Default Model ──────────────────────────────────────┐ │\n│ │ [▼ Claude Opus (claude-opus-4-6) ] │ │\n│ │ │ │\n│ │ Context: 200K tokens │ Reasoning: ✓ │ Vision: ✓ │ │\n│ └──────────────────────────────────────────────────────┘ │\n│ │\n│ Available Models (from configured providers): │\n│ ┌──────────────────────────────────────────────────────┐ │\n│ │ Provider │ Model │ Context │ Reasoning │ Cost │ │\n│ │──────────│───────────────│─────────│───────────│──────│ │\n│ │ anthropic│ Claude Opus │ 200K │ ✓ │ $$$ │ │\n│ │ anthropic│ Claude Sonnet │ 200K │ ✓ │ $$ │ │\n│ │ openai │ GPT-5.4 │ 128K │ – │ $$ │ │\n│ └──────────────────────────────────────────────────────┘ │\n│ │\n└──────────────────────────────────────────────────────────────┘\n```\n\n### Key Design Rules\n\n| Rule | Rationale |\n|---|---|\n| Provider tabs come from `config.get` + `models.list`, not hardcoded | New providers automatically appear |\n| Show `__OPENCLAW_REDACTED__` as `••••••••` | Never display actual keys; OpenClaw already redacts |\n| Use `config.set` with `baseHash` for all writes | Optimistic concurrency prevents config conflicts |\n| Refresh `models.list` after saving a new API key | Gateway restarts on config change; new models appear |\n| Show model capabilities (reasoning, vision, context) | Helps researchers choose the right model |\n| Filter model dropdown to providers with configured keys | Don't show models the user can't actually use |\n\n### Dynamic Provider List Strategy\n\nInstead of hardcoding `[\"anthropic\", \"openai\", \"google\", ...]`, derive the provider list dynamically:\n\n```typescript\n// Combine providers from config + models.list for the complete picture\nfunction getProviderList(config: OpenClawConfig, models: ModelCatalogEntry[]): ProviderInfo[] {\n const seen = new Map();\n\n // 1. Providers with API keys configured\n for (const [id, p] of Object.entries(config.models?.providers ?? {})) {\n seen.set(id, { id, configured: true, modelCount: 0 });\n }\n\n // 2. Providers from model catalog (includes built-in + plugin-discovered)\n for (const model of models) {\n const info = seen.get(model.provider) ?? { id: model.provider, configured: false, modelCount: 0 };\n info.modelCount++;\n seen.set(model.provider, info);\n }\n\n return [...seen.values()].sort((a, b) => a.id.localeCompare(b.id));\n}\n```\n\n### Recommended \"Quick Setup\" Providers\n\nFor the onboarding wizard, show these top-level providers with guided setup. All others accessible via \"Add Provider\" in settings:\n\n| Provider | Why | Key Prefix |\n|---|---|---|\n| Anthropic | Best for research tasks, default | `sk-ant-` |\n| OpenAI | Widely used, good all-rounder | `sk-` |\n| Google AI | Free tier available | — |\n| OpenRouter | Aggregator, access to many models | `sk-or-` |\n| DeepSeek | Cost-effective, strong reasoning | `sk-` |\n| Ollama | Local/private, no API key needed | (none) |\n\n---\n\n## Common Pitfalls\n\n| Pitfall | Solution |\n|---|---|\n| Setting `agents.defaults.models` to a non-empty map | This creates an allowlist — `models.list` only returns matched models. Keep it `{}` for unrestricted. |\n| Provider with `models: []` (empty array) | Fallback resolution skips providers with no model entries. Include at least one model definition. |\n| Reading API keys from `config.get` response | Keys are redacted. Check for `__OPENCLAW_REDACTED__` to detect \"configured but not shown\". |\n| Writing config without `baseHash` | Rejected by gateway. Always read `config.get` first to get `baseHash`. |\n| After `config.set`, stale model list | Gateway restarts on config change. Re-call `models.list` after a short delay. |\n| Hardcoding provider names in UI | New providers from plugins won't appear. Always derive from `config.get` + `models.list`. |\n\n---\n\n## Reference: Full Config Schema for Models\n\n```json\n{\n \"models\": {\n \"mode\": \"merge\",\n \"providers\": {\n \"\": {\n \"baseUrl\": \"https://...\",\n \"apiKey\": \"sk-...\",\n \"auth\": \"api-key\",\n \"api\": \"openai-completions\",\n \"headers\": {},\n \"authHeader\": true,\n \"models\": [\n {\n \"id\": \"model-name\",\n \"name\": \"Display Name\",\n \"api\": \"openai-completions\",\n \"reasoning\": false,\n \"input\": [\"text\", \"image\"],\n \"cost\": { \"input\": 0.001, \"output\": 0.002, \"cacheRead\": 0.0001, \"cacheWrite\": 0.0005 },\n \"contextWindow\": 128000,\n \"maxTokens\": 4096,\n \"headers\": {},\n \"compat\": {\n \"supportsTools\": true,\n \"supportsReasoningEffort\": false,\n \"thinkingFormat\": \"openai\"\n }\n }\n ]\n }\n },\n \"bedrockDiscovery\": {\n \"enabled\": false,\n \"region\": \"us-east-1\",\n \"providerFilter\": [],\n \"refreshInterval\": 3600,\n \"defaultContextWindow\": 200000,\n \"defaultMaxTokens\": 4096\n }\n },\n \"agents\": {\n \"defaults\": {\n \"model\": \"anthropic/claude-opus-4-6\",\n \"models\": {}\n }\n }\n}\n```\n\n---\n\n## See Also\n\n- [Architecture](/en/architecture/) — Overall system design and layer model\n- [Getting Started](/en/getting-started/) — Installation and first setup\n- [Security](/en/security/) — API key protection and credential scrubbing\n"}, "62d5d41bfa2fe561e5b5d0bc7a23646c0339ad18": {"t": 1785763060.5251539, "n": 33708, "h": "5adae4a70517fff8", "c": "---\nlayout: page\ntitle: Providers & Models\nlang: en\npermalink: /en/providers-and-models/\n---\n\n> **Golden Rule**: OpenClaw owns provider management, model discovery, and API key storage. AcaClaw's GUI reads and writes through OpenClaw's APIs — never maintaining a parallel catalog.\n\n---\n\n## How OpenClaw Manages LLM Providers\n\n### Provider Definition\n\nEach LLM provider is a `ModelProviderConfig` object stored in `openclaw.json` under `models.providers.`:\n\n```json\n{\n \"models\": {\n \"providers\": {\n \"anthropic\": {\n \"baseUrl\": \"https://api.anthropic.com\",\n \"apiKey\": \"sk-ant-...\",\n \"auth\": \"api-key\",\n \"api\": \"anthropic-messages\",\n \"models\": [\n {\n \"id\": \"claude-opus-4-6\",\n \"name\": \"Claude Opus\",\n \"reasoning\": true,\n \"input\": [\"text\", \"image\"],\n \"contextWindow\": 200000,\n \"maxTokens\": 4096,\n \"cost\": { \"input\": 0.003, \"output\": 0.015, \"cacheRead\": 0.0003, \"cacheWrite\": 0.00375 }\n }\n ]\n }\n }\n }\n}\n```\n\n### Provider Type Reference\n\n```\nModelProviderConfig\n├── baseUrl: string # API endpoint URL\n├── apiKey?: SecretInput # Literal string, env var ref, or exec source\n├── auth?: ModelProviderAuthMode # \"api-key\" | \"aws-sdk\" | \"oauth\" | \"token\"\n├── api?: ModelApi # API format (see list below)\n├── headers?: Record # Custom auth/routing headers\n├── authHeader?: boolean # Include Authorization header\n├── injectNumCtxForOpenAICompat?: boolean # Ollama context injection\n└── models: ModelDefinitionConfig[] # Static model list\n```\n\n### Supported API Formats (`ModelApi`)\n\n| Value | Used By |\n|---|---|\n| `openai-completions` | OpenAI, DeepSeek, Together, Groq, most OpenAI-compatible |\n| `openai-responses` | OpenAI Responses API |\n| `anthropic-messages` | Anthropic Claude |\n| `google-generative-ai` | Google Gemini (Generative AI) |\n| `bedrock-converse-stream` | AWS Bedrock |\n| `ollama` | Ollama (local models) |\n| `github-copilot` | GitHub Copilot |\n| `openai-codex-responses` | OpenAI Codex |\n\n### Provider Auth Modes\n\n| Mode | When To Use |\n|---|---|\n| `api-key` | Standard API key authentication (most providers) |\n| `oauth` | OAuth2 token exchange (Google Vertex, some enterprise) |\n| `aws-sdk` | AWS IAM / Bedrock (uses AWS SDK credentials) |\n| `token` | Bearer token authentication |\n\n---\n\n## How OpenClaw Maps Provider → Model Lists\n\n### Three Sources of Models\n\nOpenClaw's model catalog is assembled from three sources, merged at startup:\n\n```\n┌─────────────────────────────────────────────┐\n│ 1. Built-in Catalog (PI SDK) │\n│ ~50+ models (Claude, GPT, Gemini, etc.) │\n│ Loaded from embedded model registry │\n├─────────────────────────────────────────────┤\n│ 2. User Config (openclaw.json) │\n│ models.providers..models[] │\n│ User-defined or override models │\n├─────────────────────────────────────────────┤\n│ 3. Plugin Extensions │\n│ extensions//provider-catalog.ts │\n│ Dynamic provider discovery hooks │\n└─────────────────────────────────────────────┘\n │\n ▼\n ┌───────────────┐\n │ Model Catalog │ ← Deduplicated, sorted by provider then name\n │ (in memory) │\n └───────────────┘\n```\n\n### Model Definition Structure\n\nEach model in the catalog has:\n\n```\nModelDefinitionConfig\n├── id: string # Model ID (e.g. \"claude-opus-4-6\", \"gpt-5.4\")\n├── name: string # Display name\n├── api?: ModelApi # Override provider-level API format\n├── reasoning: boolean # Supports extended thinking / chain-of-thought\n├── input: string[] # [\"text\"] or [\"text\", \"image\"]\n├── contextWindow: number # Max input tokens (e.g. 200000)\n├── maxTokens: number # Max output tokens\n├── cost: CostConfig # Per-token pricing\n│ ├── input: number\n│ ├── output: number\n│ ├── cacheRead: number\n│ └── cacheWrite: number\n├── headers?: Record # Model-specific request headers\n└── compat?: CompatConfig # API quirks and feature flags\n```\n\n### Catalog Entry (What the API Returns)\n\nThe `models.list` RPC returns a simplified view:\n\n```\nModelCatalogEntry\n├── id: string # Model ID\n├── name: string # Display name\n├── provider: string # Provider ID (e.g. \"anthropic\", \"openai\")\n├── contextWindow?: number\n├── reasoning?: boolean\n└── input?: string[] # [\"text\"] or [\"text\", \"image\"]\n```\n\n### Model Loading Flow\n\n```\nloadModelCatalog()\n│\n├─ 1. ensureOpenClawModelsJson()\n│ └─ Fingerprints config + env → writes models.json if changed\n│\n├─ 2. Import PI SDK (pi-model-discovery-runtime)\n│ └─ Built-in Anthropic/OpenAI/Google catalog (~50+ models)\n│\n├─ 3. Build ModelRegistry from models.json\n│ ├─ Filter suppressed models\n│ └─ Merge user-configured opt-in providers\n│\n├─ 4. augmentModelCatalogWithProviderPlugins()\n│ └─ Each extension's discovery hook adds its models\n│\n├─ 5. Deduplicate by lowercase \"provider::modelId\"\n│\n└─ 6. Sort by provider, then model name → return cached\n```\n\n### Config Merging: `mode`\n\n| Mode | Behavior |\n|---|---|\n| `merge` (default) | User providers are ADDED to the built-in catalog |\n| `replace` | ONLY user-configured providers are used (built-ins ignored) |\n\n### Model Allowlist\n\nIf `agents.defaults.models` is a non-empty map, `models.list` returns ONLY models in that map. Keep it empty (`{}`) for unrestricted access.\n\n---\n\n## API Key Resolution Chain\n\nWhen OpenClaw needs an API key for a provider, it checks these sources in order:\n\n```\n1. Literal value in config\n models.providers..apiKey = \"sk-...\"\n\n2. Environment variable reference\n models.providers..apiKey = \"${ANTHROPIC_API_KEY}\"\n\n3. Auth profiles store\n ~/.openclaw/credentials/ or auth-profiles.json\n\n4. Well-known environment variables\n ANTHROPIC_API_KEY, OPENAI_API_KEY, etc.\n\n5. OAuth token exchange (for oauth auth mode)\n\n6. AWS SDK credentials (for bedrock auth mode)\n```\n\nOpenClaw redacts keys in API responses with `\"__OPENCLAW_REDACTED__\"` — the actual key value is never returned via `config.get`.\n\n---\n\n## Gateway API Endpoints\n\n### WebSocket RPC: `models.list`\n\nThe primary way to get available models.\n\n```\nRequest:\n { type: \"req\", id: \"\", method: \"models.list\", params: {} }\n\nResponse:\n {\n type: \"res\", id: \"\", ok: true,\n payload: {\n models: [\n { id: \"claude-opus-4-6\", name: \"Claude Opus\", provider: \"anthropic\", contextWindow: 200000, reasoning: true },\n { id: \"gpt-5.4\", name: \"GPT-5.4\", provider: \"openai\", contextWindow: 128000, reasoning: false },\n ...\n ]\n }\n }\n```\n\nThe response is filtered by `agents.defaults.models` allowlist if configured.\n\n### WebSocket RPC: `config.get`\n\nReturns the full config snapshot (with API keys redacted).\n\n```\nRequest:\n { type: \"req\", id: \"\", method: \"config.get\", params: {} }\n\nResponse:\n {\n type: \"res\", id: \"\", ok: true,\n payload: {\n config: { ... full openclaw.json ... },\n baseHash: \"abc123\",\n hash: \"def456\"\n }\n }\n```\n\n### WebSocket RPC: `config.set`\n\nWrites the full config (requires `baseHash` for optimistic concurrency).\n\n```\nRequest:\n {\n type: \"req\", id: \"\", method: \"config.set\",\n params: { raw: \"\", baseHash: \"abc123\" }\n }\n```\n\n### HTTP: `GET /v1/models` (OpenAI-compatible)\n\nReturns agent-level model IDs (not the raw provider models):\n\n```json\n{\n \"object\": \"list\",\n \"data\": [\n { \"id\": \"openclaw\", \"object\": \"model\", \"created\": 0, \"owned_by\": \"openclaw\" },\n { \"id\": \"openclaw/main\", \"object\": \"model\", \"created\": 0, \"owned_by\": \"openclaw\" }\n ]\n}\n```\n\n---\n\n## Bundled Providers (~30+)\n\nOpenClaw ships with built-in support for these providers (no config needed if API key is set):\n\n### Core LLM Providers\n\n| Config ID | Display Name | API Format | Base URL | Env Var | Catalog IDs |\n|---|---|---|---|---|---|\n| `anthropic` | Anthropic | `anthropic-messages` | `https://api.anthropic.com` | `ANTHROPIC_API_KEY` | `anthropic` |\n| `openai` | OpenAI | `openai-completions` | `https://api.openai.com/v1` | `OPENAI_API_KEY` | `openai`, `openai-codex` |\n| `google` | Google AI | `google-generative-ai` | (SDK-managed) | `GOOGLE_API_KEY` | `google`, `google-gemini-cli` |\n| `deepseek` | DeepSeek | `openai-completions` | `https://api.deepseek.com/v1` | `DEEPSEEK_API_KEY` | `deepseek` |\n| `mistral` | Mistral | `openai-completions` | `https://api.mistral.ai/v1` | `MISTRAL_API_KEY` | `mistral` |\n| `openrouter` | OpenRouter | `openai-completions` | `https://openrouter.ai/api/v1` | `OPENROUTER_API_KEY` | `openrouter` |\n| `ollama` | Ollama (local) | `ollama` | `http://localhost:11434` | — | `ollama` |\n| `amazon-bedrock` | Amazon Bedrock | `bedrock-converse-stream` | (AWS SDK) | AWS credentials | `amazon-bedrock` |\n| `azure` | Azure OpenAI | `openai-completions` | (user-configured) | `AZURE_OPENAI_API_KEY` | `azure-openai-responses` |\n\n### xAI, Chinese Providers, and Others\n\n| Config ID | Display Name | API Format | Base URL | Env Var | Catalog IDs |\n|---|---|---|---|---|---|\n| `xai` | xAI (Grok) | `openai-completions` | `https://api.x.ai/v1` | `XAI_API_KEY` | `xai` |\n| `modelstudio` | Qwen (Alibaba Cloud Model Studio) | `openai-completions` | `https://coding-intl.dashscope.aliyuncs.com/v1` (Global) / `https://coding.dashscope.aliyuncs.com/v1` (CN) | `MODELSTUDIO_API_KEY` | `modelstudio` |\n| `volcengine` | Volcengine (Doubao / ByteDance) | `openai-completions` | `https://ark.cn-beijing.volces.com/api/v3` | `VOLCANO_ENGINE_API_KEY` | `volcengine`, `volcengine-plan` |\n| `moonshot` | Moonshot / Kimi | `openai-completions` | `https://api.moonshot.ai/v1` (Intl) / `https://api.moonshot.cn/v1` (CN) | `MOONSHOT_API_KEY` | `moonshot`, `kimi`, `kimi-coding` |\n| `qianfan` | Qianfan (Baidu) | `openai-completions` | `https://qianfan.baidubce.com/v2` | `QIANFAN_API_KEY` | `qianfan` |\n| `minimax` | MiniMax | `openai-completions` | `https://api.minimax.io/v1` | `MINIMAX_API_KEY` | `minimax`, `minimax-portal` |\n| `together` | Together AI | `openai-completions` | `https://api.together.xyz/v1` | `TOGETHER_API_KEY` | `together` |\n| `nvidia` | NVIDIA | `openai-completions` | `https://integrate.api.nvidia.com/v1` | `NVIDIA_API_KEY` | `nvidia` |\n| `venice` | Venice AI | `openai-completions` | `https://api.venice.ai/api/v1` | `VENICE_API_KEY` | `venice` |\n| `github-copilot` | GitHub Copilot | `openai-responses` | `https://api.individual.githubcopilot.com` | `COPILOT_GITHUB_TOKEN` / `GH_TOKEN` | `github-copilot` |\n| `huggingface` | Hugging Face | `openai-completions` | (Inference API) | `HF_TOKEN` | `huggingface` |\n\n### Utility-Only Plugins (Not Model Providers)\n\nThese extensions provide non-LLM services and do **not** appear in `models.list`:\n\n| Plugin ID | Purpose | Env Var |\n|---|---|---|\n| `groq` | Audio transcription (Whisper) | `GROQ_API_KEY` |\n| `perplexity` | Web search | `PERPLEXITY_API_KEY` |\n| `brave` | Brave web search | `BRAVE_API_KEY` |\n\n### Provider ID Aliases\n\nOpenClaw normalizes some legacy/alternative provider IDs via `normalizeProviderId()`:\n\n| Input | Normalized To |\n|---|---|\n| `bytedance`, `doubao` | `volcengine` |\n| `bedrock`, `aws-bedrock` | `amazon-bedrock` |\n| `qwen` | `qwen-portal` |\n| `kimi`, `kimi-code`, `kimi-coding` | `kimi` |\n\nAdditional providers can be added via extensions or user config.\n\n---\n\n## Plugin Provider Discovery\n\nExtensions register providers through the Plugin SDK:\n\n```typescript\n// extensions/my-provider/provider-catalog.ts\nexport function buildMyProvider(): ModelProviderConfig {\n return {\n baseUrl: \"https://api.myprovider.com/v1\",\n api: \"openai-completions\",\n models: [\n { id: \"my-model-1\", name: \"My Model 1\", reasoning: false, input: [\"text\"], contextWindow: 128000, maxTokens: 4096, cost: { input: 0.001, output: 0.002 } }\n ]\n };\n}\n```\n\nDiscovery runs in 4 stages: `simple` → `profile` → `paired` → `late`.\n\n---\n\n## Model Reference Format\n\nModels are referenced as `provider/model-id` strings:\n\n```\nanthropic/claude-opus-4-6\nopenai/gpt-5.4\nopenrouter/anthropic/claude-sonnet-4\nmoonshot/kimi-k2.5\nollama/llama3\n```\n\nThe `parseModelRef(raw)` function splits on the FIRST `/`:\n- `openrouter/anthropic/claude-sonnet-4` → provider=`openrouter`, model=`anthropic/claude-sonnet-4`\n\n---\n\n## Catalog-to-Config Provider ID Mapping\n\n### The Problem: Two ID Systems\n\nOpenClaw has **two independent provider ID systems** that AcaClaw must bridge:\n\n1. **Config provider IDs** — Keys under `models.providers.` in `openclaw.json` (e.g. `moonshot`, `azure`, `google`)\n2. **Catalog provider IDs** — The `provider` field returned by `models.list` (e.g. `kimi-coding`, `azure-openai-responses`, `google-vertex`)\n\nThese IDs often **do not match**. For example:\n- Config has `moonshot` → catalog returns models with `provider: \"kimi-coding\"` and `provider: \"moonshot\"`\n- Config has `azure` → catalog returns `provider: \"azure-openai-responses\"`\n- Config has `google` → catalog returns `provider: \"google-vertex\"` and `provider: \"google-generative-ai\"`\n\nWithout bridging these, the UI cannot filter the model catalog to show only models from configured providers.\n\n### Why Two Systems Exist\n\nOpenClaw extensions often register multiple provider entries under a single API key. The moonshot extension is a good example:\n\n```\nExtension: moonshot (api.moonshot.ai, 4 models)\nExtension: kimi-coding (api.kimi.com/coding/, 2 models)\nBoth share the same \"moonshot\" config key and API key\n```\n\nOpenClaw's `normalizeProviderId()` (in `src/agents/provider-id.ts`) maps aliases:\n- `kimi`, `kimi-code`, `kimi-coding` → `\"kimi\"` (internally)\n- `bedrock`, `aws-bedrock` → `\"amazon-bedrock\"`\n\nAnd `PLUGIN_ID_ALIASES` in `config-state.ts` maps extension IDs:\n- `\"kimi-coding\"` → `\"kimi\"` (shares moonshot's API key)\n- `\"openai-codex\"` → `\"openai\"`\n\n### The Fix: `CATALOG_TO_CONFIG_PROVIDER` Mapping\n\nAcaClaw maintains a static mapping table in `ui/src/models/provider-mapping.ts`:\n\n```typescript\nexport const CATALOG_TO_CONFIG_PROVIDER: Record = {\n // Core providers\n \"anthropic\": \"anthropic\",\n \"openai\": \"openai\",\n \"openai-codex\": \"openai\",\n \"openai-responses\": \"openai\",\n \"azure-openai-responses\": \"azure\",\n \"google\": \"google\",\n \"google-vertex\": \"google\",\n \"google-gemini-cli\": \"google\",\n \"deepseek\": \"deepseek\",\n \"mistral\": \"mistral\",\n \"openrouter\": \"openrouter\",\n \"ollama\": \"ollama\",\n // Moonshot / Kimi\n \"moonshot\": \"moonshot\",\n \"kimi\": \"moonshot\",\n \"kimi-coding\": \"moonshot\",\n // xAI\n \"xai\": \"xai\",\n // ModelStudio (Alibaba / Qwen)\n \"modelstudio\": \"modelstudio\",\n // Volcengine (Doubao / ByteDance)\n \"volcengine\": \"volcengine\",\n \"volcengine-plan\": \"volcengine\",\n // GitHub Copilot\n \"github-copilot\": \"github-copilot\",\n // Others\n \"together\": \"together\",\n \"nvidia\": \"nvidia\",\n \"venice\": \"venice\",\n \"qianfan\": \"qianfan\",\n \"amazon-bedrock\": \"amazon-bedrock\",\n \"huggingface\": \"huggingface\",\n \"minimax\": \"minimax\",\n \"minimax-portal\": \"minimax\",\n \"byteplus\": \"volcengine\",\n \"byteplus-plan\": \"volcengine\",\n};\n```\n\nThe helper function `catalogToConfigProvider(catalogId)` returns the config key, falling back to the catalog ID itself for unknown providers.\n\n### How Filtering Works\n\nWhen a user has `openrouter` configured and the catalog returns 809 models across 23 providers:\n\n```\nmodels.list → 809 models (all providers)\nconfig.get → env = { OPENROUTER_API_KEY: \"...\" }\n models.providers = { openrouter: { apiKey: \"...\" } } (legacy)\n\nDetection: check config.env for known env vars, then check models.providers (backward compat)\nFilter: model.provider → catalogToConfigProvider() → check if result is in configuredProviders\nResult: 246 models (only openrouter)\n```\n\nAcaClaw detects configured providers from **both** `config.env` (env vars, new approach) and `models.providers` (legacy entries with apiKey).\n\n### Reverse Lookup: Config → Catalog Models\n\nFor per-provider model tables (showing which models a configured provider gives you), we do a reverse lookup:\n\n```typescript\n// Find all catalog providers that map to a config provider\n_modelsForConfigProvider(configId: string) {\n const catalogProviders = Object.entries(CATALOG_TO_CONFIG_PROVIDER)\n .filter(([, cfg]) => cfg === configId)\n .map(([cat]) => cat);\n return this._modelCatalog.filter(m =>\n m.provider && catalogProviders.includes(m.provider)\n );\n}\n```\n\nFor `moonshot`, this returns models from both `\"moonshot\"` and `\"kimi-coding\"` catalog entries.\n\n### Default Model and Stale References\n\nThe default model is stored at `agents.defaults.model` as a `\"provider/model-id\"` string (e.g. `\"kimi-coding/kimi-k2-thinking\"`). If the user later removes the moonshot API key, this reference becomes \"stale\" — the model still exists in config but its provider is no longer configured. The UI handles this by:\n\n1. Displaying the raw model ID string when no friendly name match exists\n2. Keeping the reference intact (the gateway may still honor it via other routing)\n\n### Test Coverage\n\nThe mapping is covered by 30 unit tests across three files:\n- `tests/provider-mapping.test.ts` — Validates mapping correctness and completeness\n- `tests/model-config.test.ts` — Validates model filtering and default save behavior\n- `tests/chat-default-model.test.ts` — Validates chat reads config and filters models\n\n---\n\n## AcaClaw GUI Integration Method\n\n### Principle\n\nAcaClaw's GUI is a frontend to OpenClaw's backend. For provider and model management:\n\n1. **Read** provider/model state via `config.get` and `models.list` RPCs\n2. **Write** changes via `config.set` RPC (full config with `baseHash`)\n3. **Never** maintain a separate provider catalog or model list\n4. **Never** hardcode provider URLs — OpenClaw resolves these automatically\n5. **Always** use `catalogToConfigProvider()` when filtering catalog models by configured providers\n\n### Data Flow\n\n```\n┌─────────────────┐ WebSocket RPC ┌──────────────────┐\n│ AcaClaw GUI │ ◄─────────────────────► │ OpenClaw Gateway │\n│ │ │ │\n│ api-keys.ts │──── config.get ────────►│ Config store │\n│ │◄─── {config, baseHash} ──│ │\n│ │ │ │\n│ │──── models.list ────────►│ Model catalog │\n│ │◄─── {models: [...]} ────│ (cached) │\n│ │ │ │\n│ provider- │ catalogToConfigProvider │ │\n│ mapping.ts │ bridges the two ID │ │\n│ │ systems for filtering │ │\n│ │ │ │\n│ │──── config.set ─────────►│ Validate + save │\n│ │ {raw, baseHash} │ → restart │\n└─────────────────┘ └──────────────────┘\n```\n\n### Gateway Restart on `config.env` Changes\n\nOpenClaw's config reloader has no hot-reload rule for the `env` prefix.\nAny change to `config.env.*` triggers a full gateway restart (default behavior\nfor unmatched config paths). AcaClaw handles this by:\n\n1. **Not refreshing models immediately** — env vars are applied only at startup, so\n the model catalog would be stale until the restart completes.\n2. **Setting `_loaded = false`** so the existing `state-change` listener on the\n gateway re-runs `_loadState()` (including model refresh) once the WebSocket\n reconnects after the restart.\n3. **Showing \"gateway reloading…\"** in the flash message so the user knows the\n brief disconnect is expected.\n\n### Implementation: Reading Provider State\n\n```typescript\n// 1. Get full config (API keys are redacted)\n// Note: config.get returns { config: {...}, hash: \"...\" } — unwrap the .config property\nconst configResult = await gateway.call(\"config.get\");\nconst cfg = (configResult?.config as Record) ?? configResult ?? {};\nconst baseHash = configResult.payload?.baseHash;\n\n// 2. Extract configured providers\nconst providers = cfg.models?.providers ?? {};\n// providers = { anthropic: { baseUrl, apiKey: \"__OPENCLAW_REDACTED__\", models: [...] }, ... }\n\n// 3. Detect which providers have keys set\nfor (const [id, provider] of Object.entries(providers)) {\n const hasKey = provider.apiKey && provider.apiKey !== \"\";\n const isRedacted = provider.apiKey === \"__OPENCLAW_REDACTED__\";\n // hasKey || isRedacted → provider is configured\n}\n\n// 4. Also check auth profiles (alternative key storage)\nconst authProfiles = config.auth?.profiles ?? {};\nfor (const [profileId, profile] of Object.entries(authProfiles)) {\n // profile.provider tells you which provider this profile configures\n}\n```\n\n### Implementation: Getting Available Models\n\n```typescript\nimport { catalogToConfigProvider } from \"../models/provider-mapping.js\";\n\n// Call models.list — returns the full merged catalog (ALL providers, not just configured ones)\nconst result = await gateway.call(\"models.list\");\nconst allModels = result.payload.models;\n// allModels may contain 800+ models across 23+ providers\n\n// IMPORTANT: Filter to only configured providers using the mapping\nconst configuredProviders = new Set(Object.keys(cfg.models?.providers ?? {}));\nconst models = allModels.filter(m =>\n m.provider ? configuredProviders.has(catalogToConfigProvider(m.provider)) : false\n);\n// Now models only contains models from providers the user has API keys for\n```\n\n### Implementation: Saving API Keys\n\n```typescript\n// 1. Read current config + baseHash\nconst { config, baseHash } = (await gateway.call(\"config.get\")).payload;\n\n// 2. Set the API key in the config\nconfig.models ??= {};\nconfig.models.providers ??= {};\nconfig.models.providers[\"anthropic\"] ??= { baseUrl: \"https://api.anthropic.com\", models: [] };\nconfig.models.providers[\"anthropic\"].apiKey = \"sk-ant-...\";\n\n// 3. Write back with baseHash (optimistic concurrency)\nawait gateway.call(\"config.set\", {\n raw: JSON.stringify(config),\n baseHash,\n});\n\n// After config.set, the gateway restarts and model catalog refreshes\n```\n\n### Implementation: Setting Default Model\n\n```typescript\n// 1. Read config\nconst { config, baseHash } = (await gateway.call(\"config.get\")).payload;\n\n// 2. Set default model (format: \"provider/model-id\")\nconfig.agents ??= {};\nconfig.agents.defaults ??= {};\nconfig.agents.defaults.model = \"anthropic/claude-opus-4-6\";\n\n// 3. Write back\nawait gateway.call(\"config.set\", { raw: JSON.stringify(config), baseHash });\n```\n\n### Implementation: Removing a Provider's API Key\n\n```typescript\n// Use updateConfig (read-modify-write) to delete the provider entry\nawait updateConfig((cfg) => {\n const providers = cfg.models?.providers;\n if (providers) delete providers[\"anthropic\"];\n\n // Also clean auth profiles that reference this provider\n const profiles = cfg.auth?.profiles;\n if (profiles) {\n for (const [key, val] of Object.entries(profiles)) {\n if (val?.provider === \"anthropic\") delete profiles[key];\n }\n }\n\n // If the default model used this provider, clear it\n const defaultModel = cfg.agents?.defaults?.model;\n if (typeof defaultModel === \"string\" && defaultModel.startsWith(\"anthropic/\")) {\n delete cfg.agents.defaults.model;\n }\n\n return cfg;\n});\n```\n\nAfter removal, re-call `models.list` — the provider's models will no longer appear in the catalog.\n\n### Implementation: Per-Provider Model Browsing\n\nGroup models by provider and display each provider's available models. Use `catalogToConfigProvider()` to bridge the two ID systems:\n\n```typescript\nimport { catalogToConfigProvider, CATALOG_TO_CONFIG_PROVIDER } from \"../models/provider-mapping.js\";\n\n// After calling models.list\nconst models = result.payload.models;\n\n// Filter to only configured providers\nconst configuredProviders = new Set(Object.keys(config.models?.providers ?? {}));\nconst filtered = models.filter(m =>\n m.provider ? configuredProviders.has(catalogToConfigProvider(m.provider)) : false\n);\n\n// For a selected config provider (e.g. \"moonshot\"),\n// find ALL catalog providers that map to it\nfunction modelsForConfigProvider(configId: string) {\n const catalogProviders = Object.entries(CATALOG_TO_CONFIG_PROVIDER)\n .filter(([, cfg]) => cfg === configId)\n .map(([cat]) => cat);\n return models.filter(m => m.provider && catalogProviders.includes(m.provider));\n}\n\n// moonshot → returns models from both \"moonshot\" and \"kimi-coding\" providers\nconst moonshotModels = modelsForConfigProvider(\"moonshot\");\n```\n\n### Implementation: Chat Uses Default Model\n\nThe chat view reads the default model and filters models in `_loadModels()`:\n\n```typescript\nimport { catalogToConfigProvider } from \"../models/provider-mapping.js\";\n\nasync _loadModels() {\n const [modelsResult, configResult] = await Promise.all([\n gateway.call(\"models.list\", {}),\n gateway.call(\"config.get\"),\n ]);\n\n // Handle config.get response wrapper: { config: {...}, hash: \"...\" }\n const cfg = (configResult?.config as Record) ?? configResult ?? {};\n\n // Which config providers are configured (have API keys)?\n const providers = cfg.models?.providers;\n const configuredProviders = new Set(providers ? Object.keys(providers) : []);\n\n // Read the user-saved default model from agents.defaults.model\n const savedDefaultModel = cfg.agents?.defaults?.model ?? \"\";\n\n // Filter models to only configured providers using the mapping\n const filtered = modelsResult.models.filter(m =>\n m.provider ? configuredProviders.has(catalogToConfigProvider(m.provider)) : false\n );\n\n // Build dropdown options\n this._availableModels = filtered.map(m => ({\n value: m.provider ? `${m.provider}/${m.id}` : m.id,\n label: m.provider ? `${m.name} · ${m.provider}` : m.name,\n }));\n\n // Display the saved default model, or fall back to first available\n if (savedDefaultModel) {\n const match = filtered.find(m => `${m.provider}/${m.id}` === savedDefaultModel);\n this._defaultModelDisplay = match\n ? `${match.name} · ${match.provider}`\n : savedDefaultModel; // Show raw ID if provider is unconfigured\n }\n}\n```\n\nKey points:\n- Chat reads `agents.defaults.model` from `config.get` (not from `sessions.list`)\n- Models are filtered using `catalogToConfigProvider()` to match catalog IDs to config keys\n- When the saved default model belongs to an unconfigured provider, the raw model ID is displayed\n- The gateway resolves model fallback: session override → agent config → `agents.defaults.model`\n\n### Implementation: Test Connection\n\nOpenClaw has no dedicated \"verify API key\" endpoint. Use `models.list` as a connectivity test:\n\n```typescript\nasync function testConnection(): Promise {\n try {\n const result = await gateway.call(\"models.list\");\n return result.payload.models.length > 0;\n } catch {\n return false;\n }\n}\n```\n\nIf `models.list` returns an empty array after setting a key, the key may be invalid or the provider URL unreachable.\n\n### GUI Component Design\n\n```\n┌──────────────────────────────────────────────────────────────┐\n│ API Keys & Models │\n├──────────────────────────────────────────────────────────────┤\n│ │\n│ ┌─ Provider Tabs ──────────────────────────────────────┐ │\n│ │ [Anthropic ✓] [OpenAI] [Google] [Moonshot] [+More] │ │\n│ └──────────────────────────────────────────────────────┘ │\n│ │\n│ API Key: [sk-ant-•••••••••••••] [👁] [Save] │\n│ Status: ● Connected (3 models available) │\n│ │\n│ ┌─ Default Model ──────────────────────────────────────┐ │\n│ │ [▼ Claude Opus (claude-opus-4-6) ] │ │\n│ │ │ │\n│ │ Context: 200K tokens │ Reasoning: ✓ │ Vision: ✓ │ │\n│ └──────────────────────────────────────────────────────┘ │\n│ │\n│ Available Models (from configured providers): │\n│ ┌──────────────────────────────────────────────────────┐ │\n│ │ Provider │ Model │ Context │ Reasoning │ Cost │ │\n│ │──────────│───────────────│─────────│───────────│──────│ │\n│ │ anthropic│ Claude Opus │ 200K │ ✓ │ $$$ │ │\n│ │ anthropic│ Claude Sonnet │ 200K │ ✓ │ $$ │ │\n│ │ openai │ GPT-5.4 │ 128K │ – │ $$ │ │\n│ └──────────────────────────────────────────────────────┘ │\n│ │\n└──────────────────────────────────────────────────────────────┘\n```\n\n### Key Design Rules\n\n| Rule | Rationale |\n|---|---|\n| Provider tabs come from `config.get` + `models.list`, not hardcoded | New providers automatically appear |\n| Show `__OPENCLAW_REDACTED__` as `••••••••` | Never display actual keys; OpenClaw already redacts |\n| Use `config.set` with `baseHash` for all writes | Optimistic concurrency prevents config conflicts |\n| Refresh `models.list` after saving a new API key | Gateway restarts on config change; new models appear |\n| Show model capabilities (reasoning, vision, context) | Helps researchers choose the right model |\n| Filter model dropdown to providers with configured keys | Don't show models the user can't actually use |\n\n### Dynamic Provider List Strategy\n\nInstead of hardcoding `[\"anthropic\", \"openai\", \"google\", ...]`, derive the provider list dynamically:\n\n```typescript\n// Combine providers from config + models.list for the complete picture\nfunction getProviderList(config: OpenClawConfig, models: ModelCatalogEntry[]): ProviderInfo[] {\n const seen = new Map();\n\n // 1. Providers with API keys configured\n for (const [id, p] of Object.entries(config.models?.providers ?? {})) {\n seen.set(id, { id, configured: true, modelCount: 0 });\n }\n\n // 2. Providers from model catalog (includes built-in + plugin-discovered)\n for (const model of models) {\n const info = seen.get(model.provider) ?? { id: model.provider, configured: false, modelCount: 0 };\n info.modelCount++;\n seen.set(model.provider, info);\n }\n\n return [...seen.values()].sort((a, b) => a.id.localeCompare(b.id));\n}\n```\n\n### Recommended \"Quick Setup\" Providers\n\nFor the onboarding wizard, show these top-level providers with guided setup. All others accessible via \"Add Provider\" in settings:\n\n| Provider | Why | Key Prefix |\n|---|---|---|\n| Anthropic | Best for research tasks, default | `sk-ant-` |\n| OpenAI | Widely used, good all-rounder | `sk-` |\n| Google AI | Free tier available | — |\n| OpenRouter | Aggregator, access to many models | `sk-or-` |\n| DeepSeek | Cost-effective, strong reasoning | `sk-` |\n| Ollama | Local/private, no API key needed | (none) |\n\n---\n\n## Common Pitfalls\n\n| Pitfall | Solution |\n|---|---|\n| Setting `agents.defaults.models` to a non-empty map | This creates an allowlist — `models.list` only returns matched models. Keep it `{}` for unrestricted. |\n| Provider with `models: []` (empty array) | Fallback resolution skips providers with no model entries. Include at least one model definition. |\n| Reading API keys from `config.get` response | Keys are redacted. Check for `__OPENCLAW_REDACTED__` to detect \"configured but not shown\". |\n| Writing config without `baseHash` | Rejected by gateway. Always read `config.get` first to get `baseHash`. |\n| After `config.set`, stale model list | Gateway restarts on config change. Re-call `models.list` after a short delay. |\n| Hardcoding provider names in UI | New providers from plugins won't appear. Always derive from `config.get` + `models.list`. |\n\n---\n\n## Reference: Full Config Schema for Models\n\n```json\n{\n \"models\": {\n \"mode\": \"merge\",\n \"providers\": {\n \"\": {\n \"baseUrl\": \"https://...\",\n \"apiKey\": \"sk-...\",\n \"auth\": \"api-key\",\n \"api\": \"openai-completions\",\n \"headers\": {},\n \"authHeader\": true,\n \"models\": [\n {\n \"id\": \"model-name\",\n \"name\": \"Display Name\",\n \"api\": \"openai-completions\",\n \"reasoning\": false,\n \"input\": [\"text\", \"image\"],\n \"cost\": { \"input\": 0.001, \"output\": 0.002, \"cacheRead\": 0.0001, \"cacheWrite\": 0.0005 },\n \"contextWindow\": 128000,\n \"maxTokens\": 4096,\n \"headers\": {},\n \"compat\": {\n \"supportsTools\": true,\n \"supportsReasoningEffort\": false,\n \"thinkingFormat\": \"openai\"\n }\n }\n ]\n }\n },\n \"bedrockDiscovery\": {\n \"enabled\": false,\n \"region\": \"us-east-1\",\n \"providerFilter\": [],\n \"refreshInterval\": 3600,\n \"defaultContextWindow\": 200000,\n \"defaultMaxTokens\": 4096\n }\n },\n \"agents\": {\n \"defaults\": {\n \"model\": \"anthropic/claude-opus-4-6\",\n \"models\": {}\n }\n }\n}\n```\n\n---\n\n## See Also\n\n- [Architecture](/en/architecture/) — Overall system design and layer model\n- [Getting Started](/en/getting-started/) — Installation and first setup\n- [Security](/en/security/) — API key protection and credential scrubbing\n"}, "59ebc1f89976b66eb27e8a6c976fb6a7a5f8ba13": {"t": 1785763061.9220202, "n": 33002, "h": "aef1337afd34dcbf", "c": "---\nlayout: page\ntitle: Providers & Models\nlang: en\npermalink: /en/providers-and-models/\n---\n\n> **Golden Rule**: OpenClaw owns provider management, model discovery, and API key storage. AcaClaw's GUI reads and writes through OpenClaw's APIs — never maintaining a parallel catalog.\n\n---\n\n## How OpenClaw Manages LLM Providers\n\n### Provider Definition\n\nEach LLM provider is a `ModelProviderConfig` object stored in `openclaw.json` under `models.providers.`:\n\n```json\n{\n \"models\": {\n \"providers\": {\n \"anthropic\": {\n \"baseUrl\": \"https://api.anthropic.com\",\n \"apiKey\": \"sk-ant-...\",\n \"auth\": \"api-key\",\n \"api\": \"anthropic-messages\",\n \"models\": [\n {\n \"id\": \"claude-opus-4-6\",\n \"name\": \"Claude Opus\",\n \"reasoning\": true,\n \"input\": [\"text\", \"image\"],\n \"contextWindow\": 200000,\n \"maxTokens\": 4096,\n \"cost\": { \"input\": 0.003, \"output\": 0.015, \"cacheRead\": 0.0003, \"cacheWrite\": 0.00375 }\n }\n ]\n }\n }\n }\n}\n```\n\n### Provider Type Reference\n\n```\nModelProviderConfig\n├── baseUrl: string # API endpoint URL\n├── apiKey?: SecretInput # Literal string, env var ref, or exec source\n├── auth?: ModelProviderAuthMode # \"api-key\" | \"aws-sdk\" | \"oauth\" | \"token\"\n├── api?: ModelApi # API format (see list below)\n├── headers?: Record # Custom auth/routing headers\n├── authHeader?: boolean # Include Authorization header\n├── injectNumCtxForOpenAICompat?: boolean # Ollama context injection\n└── models: ModelDefinitionConfig[] # Static model list\n```\n\n### Supported API Formats (`ModelApi`)\n\n| Value | Used By |\n|---|---|\n| `openai-completions` | OpenAI, DeepSeek, Together, Groq, most OpenAI-compatible |\n| `openai-responses` | OpenAI Responses API |\n| `anthropic-messages` | Anthropic Claude |\n| `google-generative-ai` | Google Gemini (Generative AI) |\n| `bedrock-converse-stream` | AWS Bedrock |\n| `ollama` | Ollama (local models) |\n| `github-copilot` | GitHub Copilot |\n| `openai-codex-responses` | OpenAI Codex |\n\n### Provider Auth Modes\n\n| Mode | When To Use |\n|---|---|\n| `api-key` | Standard API key authentication (most providers) |\n| `oauth` | OAuth2 token exchange (Google Vertex, some enterprise) |\n| `aws-sdk` | AWS IAM / Bedrock (uses AWS SDK credentials) |\n| `token` | Bearer token authentication |\n\n---\n\n## How OpenClaw Maps Provider → Model Lists\n\n### Three Sources of Models\n\nOpenClaw's model catalog is assembled from three sources, merged at startup:\n\n```\n┌─────────────────────────────────────────────┐\n│ 1. Built-in Catalog (PI SDK) │\n│ ~50+ models (Claude, GPT, Gemini, etc.) │\n│ Loaded from embedded model registry │\n├─────────────────────────────────────────────┤\n│ 2. User Config (openclaw.json) │\n│ models.providers..models[] │\n│ User-defined or override models │\n├─────────────────────────────────────────────┤\n│ 3. Plugin Extensions │\n│ extensions//provider-catalog.ts │\n│ Dynamic provider discovery hooks │\n└─────────────────────────────────────────────┘\n │\n ▼\n ┌───────────────┐\n │ Model Catalog │ ← Deduplicated, sorted by provider then name\n │ (in memory) │\n └───────────────┘\n```\n\n### Model Definition Structure\n\nEach model in the catalog has:\n\n```\nModelDefinitionConfig\n├── id: string # Model ID (e.g. \"claude-opus-4-6\", \"gpt-5.4\")\n├── name: string # Display name\n├── api?: ModelApi # Override provider-level API format\n├── reasoning: boolean # Supports extended thinking / chain-of-thought\n├── input: string[] # [\"text\"] or [\"text\", \"image\"]\n├── contextWindow: number # Max input tokens (e.g. 200000)\n├── maxTokens: number # Max output tokens\n├── cost: CostConfig # Per-token pricing\n│ ├── input: number\n│ ├── output: number\n│ ├── cacheRead: number\n│ └── cacheWrite: number\n├── headers?: Record # Model-specific request headers\n└── compat?: CompatConfig # API quirks and feature flags\n```\n\n### Catalog Entry (What the API Returns)\n\nThe `models.list` RPC returns a simplified view:\n\n```\nModelCatalogEntry\n├── id: string # Model ID\n├── name: string # Display name\n├── provider: string # Provider ID (e.g. \"anthropic\", \"openai\")\n├── contextWindow?: number\n├── reasoning?: boolean\n└── input?: string[] # [\"text\"] or [\"text\", \"image\"]\n```\n\n### Model Loading Flow\n\n```\nloadModelCatalog()\n│\n├─ 1. ensureOpenClawModelsJson()\n│ └─ Fingerprints config + env → writes models.json if changed\n│\n├─ 2. Import PI SDK (pi-model-discovery-runtime)\n│ └─ Built-in Anthropic/OpenAI/Google catalog (~50+ models)\n│\n├─ 3. Build ModelRegistry from models.json\n│ ├─ Filter suppressed models\n│ └─ Merge user-configured opt-in providers\n│\n├─ 4. augmentModelCatalogWithProviderPlugins()\n│ └─ Each extension's discovery hook adds its models\n│\n├─ 5. Deduplicate by lowercase \"provider::modelId\"\n│\n└─ 6. Sort by provider, then model name → return cached\n```\n\n### Config Merging: `mode`\n\n| Mode | Behavior |\n|---|---|\n| `merge` (default) | User providers are ADDED to the built-in catalog |\n| `replace` | ONLY user-configured providers are used (built-ins ignored) |\n\n### Model Allowlist\n\nIf `agents.defaults.models` is a non-empty map, `models.list` returns ONLY models in that map. Keep it empty (`{}`) for unrestricted access.\n\n---\n\n## API Key Resolution Chain\n\nWhen OpenClaw needs an API key for a provider, it checks these sources in order:\n\n```\n1. Literal value in config\n models.providers..apiKey = \"sk-...\"\n\n2. Environment variable reference\n models.providers..apiKey = \"${ANTHROPIC_API_KEY}\"\n\n3. Auth profiles store\n ~/.openclaw/credentials/ or auth-profiles.json\n\n4. Well-known environment variables\n ANTHROPIC_API_KEY, OPENAI_API_KEY, etc.\n\n5. OAuth token exchange (for oauth auth mode)\n\n6. AWS SDK credentials (for bedrock auth mode)\n```\n\nOpenClaw redacts keys in API responses with `\"__OPENCLAW_REDACTED__\"` — the actual key value is never returned via `config.get`.\n\n---\n\n## Gateway API Endpoints\n\n### WebSocket RPC: `models.list`\n\nThe primary way to get available models.\n\n```\nRequest:\n { type: \"req\", id: \"\", method: \"models.list\", params: {} }\n\nResponse:\n {\n type: \"res\", id: \"\", ok: true,\n payload: {\n models: [\n { id: \"claude-opus-4-6\", name: \"Claude Opus\", provider: \"anthropic\", contextWindow: 200000, reasoning: true },\n { id: \"gpt-5.4\", name: \"GPT-5.4\", provider: \"openai\", contextWindow: 128000, reasoning: false },\n ...\n ]\n }\n }\n```\n\nThe response is filtered by `agents.defaults.models` allowlist if configured.\n\n### WebSocket RPC: `config.get`\n\nReturns the full config snapshot (with API keys redacted).\n\n```\nRequest:\n { type: \"req\", id: \"\", method: \"config.get\", params: {} }\n\nResponse:\n {\n type: \"res\", id: \"\", ok: true,\n payload: {\n config: { ... full openclaw.json ... },\n baseHash: \"abc123\",\n hash: \"def456\"\n }\n }\n```\n\n### WebSocket RPC: `config.set`\n\nWrites the full config (requires `baseHash` for optimistic concurrency).\n\n```\nRequest:\n {\n type: \"req\", id: \"\", method: \"config.set\",\n params: { raw: \"\", baseHash: \"abc123\" }\n }\n```\n\n### HTTP: `GET /v1/models` (OpenAI-compatible)\n\nReturns agent-level model IDs (not the raw provider models):\n\n```json\n{\n \"object\": \"list\",\n \"data\": [\n { \"id\": \"openclaw\", \"object\": \"model\", \"created\": 0, \"owned_by\": \"openclaw\" },\n { \"id\": \"openclaw/main\", \"object\": \"model\", \"created\": 0, \"owned_by\": \"openclaw\" }\n ]\n}\n```\n\n---\n\n## Bundled Providers (~30+)\n\nOpenClaw ships with built-in support for these providers (no config needed if API key is set):\n\n### Core LLM Providers\n\n| Config ID | Display Name | API Format | Base URL | Env Var | Catalog IDs |\n|---|---|---|---|---|---|\n| `anthropic` | Anthropic | `anthropic-messages` | `https://api.anthropic.com` | `ANTHROPIC_API_KEY` | `anthropic` |\n| `openai` | OpenAI | `openai-completions` | `https://api.openai.com/v1` | `OPENAI_API_KEY` | `openai`, `openai-codex` |\n| `google` | Google AI | `google-generative-ai` | (SDK-managed) | `GOOGLE_API_KEY` | `google`, `google-gemini-cli` |\n| `deepseek` | DeepSeek | `openai-completions` | `https://api.deepseek.com/v1` | `DEEPSEEK_API_KEY` | `deepseek` |\n| `mistral` | Mistral | `openai-completions` | `https://api.mistral.ai/v1` | `MISTRAL_API_KEY` | `mistral` |\n| `openrouter` | OpenRouter | `openai-completions` | `https://openrouter.ai/api/v1` | `OPENROUTER_API_KEY` | `openrouter` |\n| `ollama` | Ollama (local) | `ollama` | `http://localhost:11434` | — | `ollama` |\n| `amazon-bedrock` | Amazon Bedrock | `bedrock-converse-stream` | (AWS SDK) | AWS credentials | `amazon-bedrock` |\n| `azure` | Azure OpenAI | `openai-completions` | (user-configured) | `AZURE_OPENAI_API_KEY` | `azure-openai-responses` |\n\n### xAI, Chinese Providers, and Others\n\n| Config ID | Display Name | API Format | Base URL | Env Var | Catalog IDs |\n|---|---|---|---|---|---|\n| `xai` | xAI (Grok) | `openai-completions` | `https://api.x.ai/v1` | `XAI_API_KEY` | `xai` |\n| `modelstudio` | Qwen (Alibaba Cloud Model Studio) | `openai-completions` | `https://coding-intl.dashscope.aliyuncs.com/v1` (Global) / `https://coding.dashscope.aliyuncs.com/v1` (CN) | `MODELSTUDIO_API_KEY` | `modelstudio` |\n| `volcengine` | Volcengine (Doubao / ByteDance) | `openai-completions` | `https://ark.cn-beijing.volces.com/api/v3` | `VOLCANO_ENGINE_API_KEY` | `volcengine`, `volcengine-plan` |\n| `moonshot` | Moonshot / Kimi | `openai-completions` | `https://api.moonshot.ai/v1` (Intl) / `https://api.moonshot.cn/v1` (CN) | `MOONSHOT_API_KEY` | `moonshot`, `kimi`, `kimi-coding` |\n| `qianfan` | Qianfan (Baidu) | `openai-completions` | `https://qianfan.baidubce.com/v2` | `QIANFAN_API_KEY` | `qianfan` |\n| `minimax` | MiniMax | `openai-completions` | `https://api.minimax.io/v1` | `MINIMAX_API_KEY` | `minimax`, `minimax-portal` |\n| `together` | Together AI | `openai-completions` | `https://api.together.xyz/v1` | `TOGETHER_API_KEY` | `together` |\n| `nvidia` | NVIDIA | `openai-completions` | `https://integrate.api.nvidia.com/v1` | `NVIDIA_API_KEY` | `nvidia` |\n| `venice` | Venice AI | `openai-completions` | `https://api.venice.ai/api/v1` | `VENICE_API_KEY` | `venice` |\n| `github-copilot` | GitHub Copilot | `openai-responses` | `https://api.individual.githubcopilot.com` | `COPILOT_GITHUB_TOKEN` / `GH_TOKEN` | `github-copilot` |\n| `huggingface` | Hugging Face | `openai-completions` | (Inference API) | `HF_TOKEN` | `huggingface` |\n\n### Utility-Only Plugins (Not Model Providers)\n\nThese extensions provide non-LLM services and do **not** appear in `models.list`:\n\n| Plugin ID | Purpose | Env Var |\n|---|---|---|\n| `groq` | Audio transcription (Whisper) | `GROQ_API_KEY` |\n| `perplexity` | Web search | `PERPLEXITY_API_KEY` |\n| `brave` | Brave web search | `BRAVE_API_KEY` |\n\n### Provider ID Aliases\n\nOpenClaw normalizes some legacy/alternative provider IDs via `normalizeProviderId()`:\n\n| Input | Normalized To |\n|---|---|\n| `bytedance`, `doubao` | `volcengine` |\n| `bedrock`, `aws-bedrock` | `amazon-bedrock` |\n| `qwen` | `qwen-portal` |\n| `kimi`, `kimi-code`, `kimi-coding` | `kimi` |\n\nAdditional providers can be added via extensions or user config.\n\n---\n\n## Plugin Provider Discovery\n\nExtensions register providers through the Plugin SDK:\n\n```typescript\n// extensions/my-provider/provider-catalog.ts\nexport function buildMyProvider(): ModelProviderConfig {\n return {\n baseUrl: \"https://api.myprovider.com/v1\",\n api: \"openai-completions\",\n models: [\n { id: \"my-model-1\", name: \"My Model 1\", reasoning: false, input: [\"text\"], contextWindow: 128000, maxTokens: 4096, cost: { input: 0.001, output: 0.002 } }\n ]\n };\n}\n```\n\nDiscovery runs in 4 stages: `simple` → `profile` → `paired` → `late`.\n\n---\n\n## Model Reference Format\n\nModels are referenced as `provider/model-id` strings:\n\n```\nanthropic/claude-opus-4-6\nopenai/gpt-5.4\nopenrouter/anthropic/claude-sonnet-4\nmoonshot/kimi-k2.5\nollama/llama3\n```\n\nThe `parseModelRef(raw)` function splits on the FIRST `/`:\n- `openrouter/anthropic/claude-sonnet-4` → provider=`openrouter`, model=`anthropic/claude-sonnet-4`\n\n---\n\n## Catalog-to-Config Provider ID Mapping\n\n### The Problem: Two ID Systems\n\nOpenClaw has **two independent provider ID systems** that AcaClaw must bridge:\n\n1. **Config provider IDs** — Keys under `models.providers.` in `openclaw.json` (e.g. `moonshot`, `azure`, `google`)\n2. **Catalog provider IDs** — The `provider` field returned by `models.list` (e.g. `kimi-coding`, `azure-openai-responses`, `google-vertex`)\n\nThese IDs often **do not match**. For example:\n- Config has `moonshot` → catalog returns models with `provider: \"kimi-coding\"` and `provider: \"moonshot\"`\n- Config has `azure` → catalog returns `provider: \"azure-openai-responses\"`\n- Config has `google` → catalog returns `provider: \"google-vertex\"` and `provider: \"google-generative-ai\"`\n\nWithout bridging these, the UI cannot filter the model catalog to show only models from configured providers.\n\n### Why Two Systems Exist\n\nOpenClaw extensions often register multiple provider entries under a single API key. The moonshot extension is a good example:\n\n```\nExtension: moonshot (api.moonshot.ai, 4 models)\nExtension: kimi-coding (api.kimi.com/coding/, 2 models)\nBoth share the same \"moonshot\" config key and API key\n```\n\nOpenClaw's `normalizeProviderId()` (in `src/agents/provider-id.ts`) maps aliases:\n- `kimi`, `kimi-code`, `kimi-coding` → `\"kimi\"` (internally)\n- `bedrock`, `aws-bedrock` → `\"amazon-bedrock\"`\n\nAnd `PLUGIN_ID_ALIASES` in `config-state.ts` maps extension IDs:\n- `\"kimi-coding\"` → `\"kimi\"` (shares moonshot's API key)\n- `\"openai-codex\"` → `\"openai\"`\n\n### The Fix: `CATALOG_TO_CONFIG_PROVIDER` Mapping\n\nAcaClaw maintains a static mapping table in `ui/src/models/provider-mapping.ts`:\n\n```typescript\nexport const CATALOG_TO_CONFIG_PROVIDER: Record = {\n // Core providers\n \"anthropic\": \"anthropic\",\n \"openai\": \"openai\",\n \"openai-codex\": \"openai\",\n \"openai-responses\": \"openai\",\n \"azure-openai-responses\": \"azure\",\n \"google\": \"google\",\n \"google-vertex\": \"google\",\n \"google-gemini-cli\": \"google\",\n \"deepseek\": \"deepseek\",\n \"mistral\": \"mistral\",\n \"openrouter\": \"openrouter\",\n \"ollama\": \"ollama\",\n // Moonshot / Kimi\n \"moonshot\": \"moonshot\",\n \"kimi\": \"moonshot\",\n \"kimi-coding\": \"moonshot\",\n // xAI\n \"xai\": \"xai\",\n // ModelStudio (Alibaba / Qwen)\n \"modelstudio\": \"modelstudio\",\n // Volcengine (Doubao / ByteDance)\n \"volcengine\": \"volcengine\",\n \"volcengine-plan\": \"volcengine\",\n // GitHub Copilot\n \"github-copilot\": \"github-copilot\",\n // Others\n \"together\": \"together\",\n \"nvidia\": \"nvidia\",\n \"venice\": \"venice\",\n \"qianfan\": \"qianfan\",\n \"amazon-bedrock\": \"amazon-bedrock\",\n \"huggingface\": \"huggingface\",\n \"minimax\": \"minimax\",\n \"minimax-portal\": \"minimax\",\n \"byteplus\": \"volcengine\",\n \"byteplus-plan\": \"volcengine\",\n};\n```\n\nThe helper function `catalogToConfigProvider(catalogId)` returns the config key, falling back to the catalog ID itself for unknown providers.\n\n### How Filtering Works\n\nWhen a user has `openrouter` configured and the catalog returns 809 models across 23 providers:\n\n```\nmodels.list → 809 models (all providers)\nconfig.get → env = { OPENROUTER_API_KEY: \"...\" }\n models.providers = { openrouter: { apiKey: \"...\" } } (legacy)\n\nDetection: check config.env for known env vars, then check models.providers (backward compat)\nFilter: model.provider → catalogToConfigProvider() → check if result is in configuredProviders\nResult: 246 models (only openrouter)\n```\n\nAcaClaw detects configured providers from **both** `config.env` (env vars, new approach) and `models.providers` (legacy entries with apiKey).\n\n### Reverse Lookup: Config → Catalog Models\n\nFor per-provider model tables (showing which models a configured provider gives you), we do a reverse lookup:\n\n```typescript\n// Find all catalog providers that map to a config provider\n_modelsForConfigProvider(configId: string) {\n const catalogProviders = Object.entries(CATALOG_TO_CONFIG_PROVIDER)\n .filter(([, cfg]) => cfg === configId)\n .map(([cat]) => cat);\n return this._modelCatalog.filter(m =>\n m.provider && catalogProviders.includes(m.provider)\n );\n}\n```\n\nFor `moonshot`, this returns models from both `\"moonshot\"` and `\"kimi-coding\"` catalog entries.\n\n### Default Model and Stale References\n\nThe default model is stored at `agents.defaults.model` as a `\"provider/model-id\"` string (e.g. `\"kimi-coding/kimi-k2-thinking\"`). If the user later removes the moonshot API key, this reference becomes \"stale\" — the model still exists in config but its provider is no longer configured. The UI handles this by:\n\n1. Displaying the raw model ID string when no friendly name match exists\n2. Keeping the reference intact (the gateway may still honor it via other routing)\n\n### Test Coverage\n\nThe mapping is covered by 30 unit tests across three files:\n- `tests/provider-mapping.test.ts` — Validates mapping correctness and completeness\n- `tests/model-config.test.ts` — Validates model filtering and default save behavior\n- `tests/chat-default-model.test.ts` — Validates chat reads config and filters models\n\n---\n\n## AcaClaw GUI Integration Method\n\n### Principle\n\nAcaClaw's GUI is a frontend to OpenClaw's backend. For provider and model management:\n\n1. **Read** provider/model state via `config.get` and `models.list` RPCs\n2. **Write** changes via `config.set` RPC (full config with `baseHash`)\n3. **Never** maintain a separate provider catalog or model list\n4. **Never** hardcode provider URLs — OpenClaw resolves these automatically\n5. **Always** use `catalogToConfigProvider()` when filtering catalog models by configured providers\n\n### Data Flow\n\n```\n┌─────────────────┐ WebSocket RPC ┌──────────────────┐\n│ AcaClaw GUI │ ◄─────────────────────► │ OpenClaw Gateway │\n│ │ │ │\n│ api-keys.ts │──── config.get ────────►│ Config store │\n│ │◄─── {config, baseHash} ──│ │\n│ │ │ │\n│ │──── models.list ────────►│ Model catalog │\n│ │◄─── {models: [...]} ────│ (cached) │\n│ │ │ │\n│ provider- │ catalogToConfigProvider │ │\n│ mapping.ts │ bridges the two ID │ │\n│ │ systems for filtering │ │\n│ │ │ │\n│ │──── config.set ─────────►│ Validate + save │\n│ │ {raw, baseHash} │ → restart │\n└─────────────────┘ └──────────────────┘\n```\n\n### Implementation: Reading Provider State\n\n```typescript\n// 1. Get full config (API keys are redacted)\n// Note: config.get returns { config: {...}, hash: \"...\" } — unwrap the .config property\nconst configResult = await gateway.call(\"config.get\");\nconst cfg = (configResult?.config as Record) ?? configResult ?? {};\nconst baseHash = configResult.payload?.baseHash;\n\n// 2. Extract configured providers\nconst providers = cfg.models?.providers ?? {};\n// providers = { anthropic: { baseUrl, apiKey: \"__OPENCLAW_REDACTED__\", models: [...] }, ... }\n\n// 3. Detect which providers have keys set\nfor (const [id, provider] of Object.entries(providers)) {\n const hasKey = provider.apiKey && provider.apiKey !== \"\";\n const isRedacted = provider.apiKey === \"__OPENCLAW_REDACTED__\";\n // hasKey || isRedacted → provider is configured\n}\n\n// 4. Also check auth profiles (alternative key storage)\nconst authProfiles = config.auth?.profiles ?? {};\nfor (const [profileId, profile] of Object.entries(authProfiles)) {\n // profile.provider tells you which provider this profile configures\n}\n```\n\n### Implementation: Getting Available Models\n\n```typescript\nimport { catalogToConfigProvider } from \"../models/provider-mapping.js\";\n\n// Call models.list — returns the full merged catalog (ALL providers, not just configured ones)\nconst result = await gateway.call(\"models.list\");\nconst allModels = result.payload.models;\n// allModels may contain 800+ models across 23+ providers\n\n// IMPORTANT: Filter to only configured providers using the mapping\nconst configuredProviders = new Set(Object.keys(cfg.models?.providers ?? {}));\nconst models = allModels.filter(m =>\n m.provider ? configuredProviders.has(catalogToConfigProvider(m.provider)) : false\n);\n// Now models only contains models from providers the user has API keys for\n```\n\n### Implementation: Saving API Keys\n\n```typescript\n// 1. Read current config + baseHash\nconst { config, baseHash } = (await gateway.call(\"config.get\")).payload;\n\n// 2. Set the API key in the config\nconfig.models ??= {};\nconfig.models.providers ??= {};\nconfig.models.providers[\"anthropic\"] ??= { baseUrl: \"https://api.anthropic.com\", models: [] };\nconfig.models.providers[\"anthropic\"].apiKey = \"sk-ant-...\";\n\n// 3. Write back with baseHash (optimistic concurrency)\nawait gateway.call(\"config.set\", {\n raw: JSON.stringify(config),\n baseHash,\n});\n\n// After config.set, the gateway restarts and model catalog refreshes\n```\n\n### Implementation: Setting Default Model\n\n```typescript\n// 1. Read config\nconst { config, baseHash } = (await gateway.call(\"config.get\")).payload;\n\n// 2. Set default model (format: \"provider/model-id\")\nconfig.agents ??= {};\nconfig.agents.defaults ??= {};\nconfig.agents.defaults.model = \"anthropic/claude-opus-4-6\";\n\n// 3. Write back\nawait gateway.call(\"config.set\", { raw: JSON.stringify(config), baseHash });\n```\n\n### Implementation: Removing a Provider's API Key\n\n```typescript\n// Use updateConfig (read-modify-write) to delete the provider entry\nawait updateConfig((cfg) => {\n const providers = cfg.models?.providers;\n if (providers) delete providers[\"anthropic\"];\n\n // Also clean auth profiles that reference this provider\n const profiles = cfg.auth?.profiles;\n if (profiles) {\n for (const [key, val] of Object.entries(profiles)) {\n if (val?.provider === \"anthropic\") delete profiles[key];\n }\n }\n\n // If the default model used this provider, clear it\n const defaultModel = cfg.agents?.defaults?.model;\n if (typeof defaultModel === \"string\" && defaultModel.startsWith(\"anthropic/\")) {\n delete cfg.agents.defaults.model;\n }\n\n return cfg;\n});\n```\n\nAfter removal, re-call `models.list` — the provider's models will no longer appear in the catalog.\n\n### Implementation: Per-Provider Model Browsing\n\nGroup models by provider and display each provider's available models. Use `catalogToConfigProvider()` to bridge the two ID systems:\n\n```typescript\nimport { catalogToConfigProvider, CATALOG_TO_CONFIG_PROVIDER } from \"../models/provider-mapping.js\";\n\n// After calling models.list\nconst models = result.payload.models;\n\n// Filter to only configured providers\nconst configuredProviders = new Set(Object.keys(config.models?.providers ?? {}));\nconst filtered = models.filter(m =>\n m.provider ? configuredProviders.has(catalogToConfigProvider(m.provider)) : false\n);\n\n// For a selected config provider (e.g. \"moonshot\"),\n// find ALL catalog providers that map to it\nfunction modelsForConfigProvider(configId: string) {\n const catalogProviders = Object.entries(CATALOG_TO_CONFIG_PROVIDER)\n .filter(([, cfg]) => cfg === configId)\n .map(([cat]) => cat);\n return models.filter(m => m.provider && catalogProviders.includes(m.provider));\n}\n\n// moonshot → returns models from both \"moonshot\" and \"kimi-coding\" providers\nconst moonshotModels = modelsForConfigProvider(\"moonshot\");\n```\n\n### Implementation: Chat Uses Default Model\n\nThe chat view reads the default model and filters models in `_loadModels()`:\n\n```typescript\nimport { catalogToConfigProvider } from \"../models/provider-mapping.js\";\n\nasync _loadModels() {\n const [modelsResult, configResult] = await Promise.all([\n gateway.call(\"models.list\", {}),\n gateway.call(\"config.get\"),\n ]);\n\n // Handle config.get response wrapper: { config: {...}, hash: \"...\" }\n const cfg = (configResult?.config as Record) ?? configResult ?? {};\n\n // Which config providers are configured (have API keys)?\n const providers = cfg.models?.providers;\n const configuredProviders = new Set(providers ? Object.keys(providers) : []);\n\n // Read the user-saved default model from agents.defaults.model\n const savedDefaultModel = cfg.agents?.defaults?.model ?? \"\";\n\n // Filter models to only configured providers using the mapping\n const filtered = modelsResult.models.filter(m =>\n m.provider ? configuredProviders.has(catalogToConfigProvider(m.provider)) : false\n );\n\n // Build dropdown options\n this._availableModels = filtered.map(m => ({\n value: m.provider ? `${m.provider}/${m.id}` : m.id,\n label: m.provider ? `${m.name} · ${m.provider}` : m.name,\n }));\n\n // Display the saved default model, or fall back to first available\n if (savedDefaultModel) {\n const match = filtered.find(m => `${m.provider}/${m.id}` === savedDefaultModel);\n this._defaultModelDisplay = match\n ? `${match.name} · ${match.provider}`\n : savedDefaultModel; // Show raw ID if provider is unconfigured\n }\n}\n```\n\nKey points:\n- Chat reads `agents.defaults.model` from `config.get` (not from `sessions.list`)\n- Models are filtered using `catalogToConfigProvider()` to match catalog IDs to config keys\n- When the saved default model belongs to an unconfigured provider, the raw model ID is displayed\n- The gateway resolves model fallback: session override → agent config → `agents.defaults.model`\n\n### Implementation: Test Connection\n\nOpenClaw has no dedicated \"verify API key\" endpoint. Use `models.list` as a connectivity test:\n\n```typescript\nasync function testConnection(): Promise {\n try {\n const result = await gateway.call(\"models.list\");\n return result.payload.models.length > 0;\n } catch {\n return false;\n }\n}\n```\n\nIf `models.list` returns an empty array after setting a key, the key may be invalid or the provider URL unreachable.\n\n### GUI Component Design\n\n```\n┌──────────────────────────────────────────────────────────────┐\n│ API Keys & Models │\n├──────────────────────────────────────────────────────────────┤\n│ │\n│ ┌─ Provider Tabs ──────────────────────────────────────┐ │\n│ │ [Anthropic ✓] [OpenAI] [Google] [Moonshot] [+More] │ │\n│ └──────────────────────────────────────────────────────┘ │\n│ │\n│ API Key: [sk-ant-•••••••••••••] [👁] [Save] │\n│ Status: ● Connected (3 models available) │\n│ │\n│ ┌─ Default Model ──────────────────────────────────────┐ │\n│ │ [▼ Claude Opus (claude-opus-4-6) ] │ │\n│ │ │ │\n│ │ Context: 200K tokens │ Reasoning: ✓ │ Vision: ✓ │ │\n│ └──────────────────────────────────────────────────────┘ │\n│ │\n│ Available Models (from configured providers): │\n│ ┌──────────────────────────────────────────────────────┐ │\n│ │ Provider │ Model │ Context │ Reasoning │ Cost │ │\n│ │──────────│───────────────│─────────│───────────│──────│ │\n│ │ anthropic│ Claude Opus │ 200K │ ✓ │ $$$ │ │\n│ │ anthropic│ Claude Sonnet │ 200K │ ✓ │ $$ │ │\n│ │ openai │ GPT-5.4 │ 128K │ – │ $$ │ │\n│ └──────────────────────────────────────────────────────┘ │\n│ │\n└──────────────────────────────────────────────────────────────┘\n```\n\n### Key Design Rules\n\n| Rule | Rationale |\n|---|---|\n| Provider tabs come from `config.get` + `models.list`, not hardcoded | New providers automatically appear |\n| Show `__OPENCLAW_REDACTED__` as `••••••••` | Never display actual keys; OpenClaw already redacts |\n| Use `config.set` with `baseHash` for all writes | Optimistic concurrency prevents config conflicts |\n| Refresh `models.list` after saving a new API key | Gateway restarts on config change; new models appear |\n| Show model capabilities (reasoning, vision, context) | Helps researchers choose the right model |\n| Filter model dropdown to providers with configured keys | Don't show models the user can't actually use |\n\n### Dynamic Provider List Strategy\n\nInstead of hardcoding `[\"anthropic\", \"openai\", \"google\", ...]`, derive the provider list dynamically:\n\n```typescript\n// Combine providers from config + models.list for the complete picture\nfunction getProviderList(config: OpenClawConfig, models: ModelCatalogEntry[]): ProviderInfo[] {\n const seen = new Map();\n\n // 1. Providers with API keys configured\n for (const [id, p] of Object.entries(config.models?.providers ?? {})) {\n seen.set(id, { id, configured: true, modelCount: 0 });\n }\n\n // 2. Providers from model catalog (includes built-in + plugin-discovered)\n for (const model of models) {\n const info = seen.get(model.provider) ?? { id: model.provider, configured: false, modelCount: 0 };\n info.modelCount++;\n seen.set(model.provider, info);\n }\n\n return [...seen.values()].sort((a, b) => a.id.localeCompare(b.id));\n}\n```\n\n### Recommended \"Quick Setup\" Providers\n\nFor the onboarding wizard, show these top-level providers with guided setup. All others accessible via \"Add Provider\" in settings:\n\n| Provider | Why | Key Prefix |\n|---|---|---|\n| Anthropic | Best for research tasks, default | `sk-ant-` |\n| OpenAI | Widely used, good all-rounder | `sk-` |\n| Google AI | Free tier available | — |\n| OpenRouter | Aggregator, access to many models | `sk-or-` |\n| DeepSeek | Cost-effective, strong reasoning | `sk-` |\n| Ollama | Local/private, no API key needed | (none) |\n\n---\n\n## Common Pitfalls\n\n| Pitfall | Solution |\n|---|---|\n| Setting `agents.defaults.models` to a non-empty map | This creates an allowlist — `models.list` only returns matched models. Keep it `{}` for unrestricted. |\n| Provider with `models: []` (empty array) | Fallback resolution skips providers with no model entries. Include at least one model definition. |\n| Reading API keys from `config.get` response | Keys are redacted. Check for `__OPENCLAW_REDACTED__` to detect \"configured but not shown\". |\n| Writing config without `baseHash` | Rejected by gateway. Always read `config.get` first to get `baseHash`. |\n| After `config.set`, stale model list | Gateway restarts on config change. Re-call `models.list` after a short delay. |\n| Hardcoding provider names in UI | New providers from plugins won't appear. Always derive from `config.get` + `models.list`. |\n\n---\n\n## Reference: Full Config Schema for Models\n\n```json\n{\n \"models\": {\n \"mode\": \"merge\",\n \"providers\": {\n \"\": {\n \"baseUrl\": \"https://...\",\n \"apiKey\": \"sk-...\",\n \"auth\": \"api-key\",\n \"api\": \"openai-completions\",\n \"headers\": {},\n \"authHeader\": true,\n \"models\": [\n {\n \"id\": \"model-name\",\n \"name\": \"Display Name\",\n \"api\": \"openai-completions\",\n \"reasoning\": false,\n \"input\": [\"text\", \"image\"],\n \"cost\": { \"input\": 0.001, \"output\": 0.002, \"cacheRead\": 0.0001, \"cacheWrite\": 0.0005 },\n \"contextWindow\": 128000,\n \"maxTokens\": 4096,\n \"headers\": {},\n \"compat\": {\n \"supportsTools\": true,\n \"supportsReasoningEffort\": false,\n \"thinkingFormat\": \"openai\"\n }\n }\n ]\n }\n },\n \"bedrockDiscovery\": {\n \"enabled\": false,\n \"region\": \"us-east-1\",\n \"providerFilter\": [],\n \"refreshInterval\": 3600,\n \"defaultContextWindow\": 200000,\n \"defaultMaxTokens\": 4096\n }\n },\n \"agents\": {\n \"defaults\": {\n \"model\": \"anthropic/claude-opus-4-6\",\n \"models\": {}\n }\n }\n}\n```\n\n---\n\n## See Also\n\n- [Architecture](/en/architecture/) — Overall system design and layer model\n- [Getting Started](/en/getting-started/) — Installation and first setup\n- [Security](/en/security/) — API key protection and credential scrubbing\n"}, "8601f38ad7a1fc6001701d7a7528de281c6e8570": {"t": 1785763063.3831005, "n": 32662, "h": "af37207779d61d48", "c": "---\nlayout: page\ntitle: Providers & Models\nlang: en\npermalink: /en/providers-and-models/\n---\n\n> **Golden Rule**: OpenClaw owns provider management, model discovery, and API key storage. AcaClaw's GUI reads and writes through OpenClaw's APIs — never maintaining a parallel catalog.\n\n---\n\n## How OpenClaw Manages LLM Providers\n\n### Provider Definition\n\nEach LLM provider is a `ModelProviderConfig` object stored in `openclaw.json` under `models.providers.`:\n\n```json\n{\n \"models\": {\n \"providers\": {\n \"anthropic\": {\n \"baseUrl\": \"https://api.anthropic.com\",\n \"apiKey\": \"sk-ant-...\",\n \"auth\": \"api-key\",\n \"api\": \"anthropic-messages\",\n \"models\": [\n {\n \"id\": \"claude-opus-4-6\",\n \"name\": \"Claude Opus\",\n \"reasoning\": true,\n \"input\": [\"text\", \"image\"],\n \"contextWindow\": 200000,\n \"maxTokens\": 4096,\n \"cost\": { \"input\": 0.003, \"output\": 0.015, \"cacheRead\": 0.0003, \"cacheWrite\": 0.00375 }\n }\n ]\n }\n }\n }\n}\n```\n\n### Provider Type Reference\n\n```\nModelProviderConfig\n├── baseUrl: string # API endpoint URL\n├── apiKey?: SecretInput # Literal string, env var ref, or exec source\n├── auth?: ModelProviderAuthMode # \"api-key\" | \"aws-sdk\" | \"oauth\" | \"token\"\n├── api?: ModelApi # API format (see list below)\n├── headers?: Record # Custom auth/routing headers\n├── authHeader?: boolean # Include Authorization header\n├── injectNumCtxForOpenAICompat?: boolean # Ollama context injection\n└── models: ModelDefinitionConfig[] # Static model list\n```\n\n### Supported API Formats (`ModelApi`)\n\n| Value | Used By |\n|---|---|\n| `openai-completions` | OpenAI, DeepSeek, Together, Groq, most OpenAI-compatible |\n| `openai-responses` | OpenAI Responses API |\n| `anthropic-messages` | Anthropic Claude |\n| `google-generative-ai` | Google Gemini (Generative AI) |\n| `bedrock-converse-stream` | AWS Bedrock |\n| `ollama` | Ollama (local models) |\n| `github-copilot` | GitHub Copilot |\n| `openai-codex-responses` | OpenAI Codex |\n\n### Provider Auth Modes\n\n| Mode | When To Use |\n|---|---|\n| `api-key` | Standard API key authentication (most providers) |\n| `oauth` | OAuth2 token exchange (Google Vertex, some enterprise) |\n| `aws-sdk` | AWS IAM / Bedrock (uses AWS SDK credentials) |\n| `token` | Bearer token authentication |\n\n---\n\n## How OpenClaw Maps Provider → Model Lists\n\n### Three Sources of Models\n\nOpenClaw's model catalog is assembled from three sources, merged at startup:\n\n```\n┌─────────────────────────────────────────────┐\n│ 1. Built-in Catalog (PI SDK) │\n│ ~50+ models (Claude, GPT, Gemini, etc.) │\n│ Loaded from embedded model registry │\n├─────────────────────────────────────────────┤\n│ 2. User Config (openclaw.json) │\n│ models.providers..models[] │\n│ User-defined or override models │\n├─────────────────────────────────────────────┤\n│ 3. Plugin Extensions │\n│ extensions//provider-catalog.ts │\n│ Dynamic provider discovery hooks │\n└─────────────────────────────────────────────┘\n │\n ▼\n ┌───────────────┐\n │ Model Catalog │ ← Deduplicated, sorted by provider then name\n │ (in memory) │\n └───────────────┘\n```\n\n### Model Definition Structure\n\nEach model in the catalog has:\n\n```\nModelDefinitionConfig\n├── id: string # Model ID (e.g. \"claude-opus-4-6\", \"gpt-5.4\")\n├── name: string # Display name\n├── api?: ModelApi # Override provider-level API format\n├── reasoning: boolean # Supports extended thinking / chain-of-thought\n├── input: string[] # [\"text\"] or [\"text\", \"image\"]\n├── contextWindow: number # Max input tokens (e.g. 200000)\n├── maxTokens: number # Max output tokens\n├── cost: CostConfig # Per-token pricing\n│ ├── input: number\n│ ├── output: number\n│ ├── cacheRead: number\n│ └── cacheWrite: number\n├── headers?: Record # Model-specific request headers\n└── compat?: CompatConfig # API quirks and feature flags\n```\n\n### Catalog Entry (What the API Returns)\n\nThe `models.list` RPC returns a simplified view:\n\n```\nModelCatalogEntry\n├── id: string # Model ID\n├── name: string # Display name\n├── provider: string # Provider ID (e.g. \"anthropic\", \"openai\")\n├── contextWindow?: number\n├── reasoning?: boolean\n└── input?: string[] # [\"text\"] or [\"text\", \"image\"]\n```\n\n### Model Loading Flow\n\n```\nloadModelCatalog()\n│\n├─ 1. ensureOpenClawModelsJson()\n│ └─ Fingerprints config + env → writes models.json if changed\n│\n├─ 2. Import PI SDK (pi-model-discovery-runtime)\n│ └─ Built-in Anthropic/OpenAI/Google catalog (~50+ models)\n│\n├─ 3. Build ModelRegistry from models.json\n│ ├─ Filter suppressed models\n│ └─ Merge user-configured opt-in providers\n│\n├─ 4. augmentModelCatalogWithProviderPlugins()\n│ └─ Each extension's discovery hook adds its models\n│\n├─ 5. Deduplicate by lowercase \"provider::modelId\"\n│\n└─ 6. Sort by provider, then model name → return cached\n```\n\n### Config Merging: `mode`\n\n| Mode | Behavior |\n|---|---|\n| `merge` (default) | User providers are ADDED to the built-in catalog |\n| `replace` | ONLY user-configured providers are used (built-ins ignored) |\n\n### Model Allowlist\n\nIf `agents.defaults.models` is a non-empty map, `models.list` returns ONLY models in that map. Keep it empty (`{}`) for unrestricted access.\n\n---\n\n## API Key Resolution Chain\n\nWhen OpenClaw needs an API key for a provider, it checks these sources in order:\n\n```\n1. Literal value in config\n models.providers..apiKey = \"sk-...\"\n\n2. Environment variable reference\n models.providers..apiKey = \"${ANTHROPIC_API_KEY}\"\n\n3. Auth profiles store\n ~/.openclaw/credentials/ or auth-profiles.json\n\n4. Well-known environment variables\n ANTHROPIC_API_KEY, OPENAI_API_KEY, etc.\n\n5. OAuth token exchange (for oauth auth mode)\n\n6. AWS SDK credentials (for bedrock auth mode)\n```\n\nOpenClaw redacts keys in API responses with `\"__OPENCLAW_REDACTED__\"` — the actual key value is never returned via `config.get`.\n\n---\n\n## Gateway API Endpoints\n\n### WebSocket RPC: `models.list`\n\nThe primary way to get available models.\n\n```\nRequest:\n { type: \"req\", id: \"\", method: \"models.list\", params: {} }\n\nResponse:\n {\n type: \"res\", id: \"\", ok: true,\n payload: {\n models: [\n { id: \"claude-opus-4-6\", name: \"Claude Opus\", provider: \"anthropic\", contextWindow: 200000, reasoning: true },\n { id: \"gpt-5.4\", name: \"GPT-5.4\", provider: \"openai\", contextWindow: 128000, reasoning: false },\n ...\n ]\n }\n }\n```\n\nThe response is filtered by `agents.defaults.models` allowlist if configured.\n\n### WebSocket RPC: `config.get`\n\nReturns the full config snapshot (with API keys redacted).\n\n```\nRequest:\n { type: \"req\", id: \"\", method: \"config.get\", params: {} }\n\nResponse:\n {\n type: \"res\", id: \"\", ok: true,\n payload: {\n config: { ... full openclaw.json ... },\n baseHash: \"abc123\",\n hash: \"def456\"\n }\n }\n```\n\n### WebSocket RPC: `config.set`\n\nWrites the full config (requires `baseHash` for optimistic concurrency).\n\n```\nRequest:\n {\n type: \"req\", id: \"\", method: \"config.set\",\n params: { raw: \"\", baseHash: \"abc123\" }\n }\n```\n\n### HTTP: `GET /v1/models` (OpenAI-compatible)\n\nReturns agent-level model IDs (not the raw provider models):\n\n```json\n{\n \"object\": \"list\",\n \"data\": [\n { \"id\": \"openclaw\", \"object\": \"model\", \"created\": 0, \"owned_by\": \"openclaw\" },\n { \"id\": \"openclaw/main\", \"object\": \"model\", \"created\": 0, \"owned_by\": \"openclaw\" }\n ]\n}\n```\n\n---\n\n## Bundled Providers (~30+)\n\nOpenClaw ships with built-in support for these providers (no config needed if API key is set):\n\n### Core LLM Providers\n\n| Config ID | Display Name | API Format | Base URL | Env Var | Catalog IDs |\n|---|---|---|---|---|---|\n| `anthropic` | Anthropic | `anthropic-messages` | `https://api.anthropic.com` | `ANTHROPIC_API_KEY` | `anthropic` |\n| `openai` | OpenAI | `openai-completions` | `https://api.openai.com/v1` | `OPENAI_API_KEY` | `openai`, `openai-codex` |\n| `google` | Google AI | `google-generative-ai` | (SDK-managed) | `GOOGLE_API_KEY` | `google`, `google-gemini-cli` |\n| `deepseek` | DeepSeek | `openai-completions` | `https://api.deepseek.com/v1` | `DEEPSEEK_API_KEY` | `deepseek` |\n| `mistral` | Mistral | `openai-completions` | `https://api.mistral.ai/v1` | `MISTRAL_API_KEY` | `mistral` |\n| `openrouter` | OpenRouter | `openai-completions` | `https://openrouter.ai/api/v1` | `OPENROUTER_API_KEY` | `openrouter` |\n| `ollama` | Ollama (local) | `ollama` | `http://localhost:11434` | — | `ollama` |\n| `amazon-bedrock` | Amazon Bedrock | `bedrock-converse-stream` | (AWS SDK) | AWS credentials | `amazon-bedrock` |\n| `azure` | Azure OpenAI | `openai-completions` | (user-configured) | `AZURE_OPENAI_API_KEY` | `azure-openai-responses` |\n\n### xAI, Chinese Providers, and Others\n\n| Config ID | Display Name | API Format | Base URL | Env Var | Catalog IDs |\n|---|---|---|---|---|---|\n| `xai` | xAI (Grok) | `openai-completions` | `https://api.x.ai/v1` | `XAI_API_KEY` | `xai` |\n| `modelstudio` | Qwen (Alibaba Cloud Model Studio) | `openai-completions` | `https://coding-intl.dashscope.aliyuncs.com/v1` (Global) / `https://coding.dashscope.aliyuncs.com/v1` (CN) | `MODELSTUDIO_API_KEY` | `modelstudio` |\n| `volcengine` | Volcengine (Doubao / ByteDance) | `openai-completions` | `https://ark.cn-beijing.volces.com/api/v3` | `VOLCANO_ENGINE_API_KEY` | `volcengine`, `volcengine-plan` |\n| `moonshot` | Moonshot / Kimi | `openai-completions` | `https://api.moonshot.cn/v1` | `MOONSHOT_API_KEY` | `moonshot`, `kimi`, `kimi-coding` |\n| `qianfan` | Qianfan (Baidu) | `openai-completions` | `https://qianfan.baidubce.com/v2` | `QIANFAN_API_KEY` | `qianfan` |\n| `minimax` | MiniMax | `openai-completions` | `https://api.minimax.io/v1` | `MINIMAX_API_KEY` | `minimax`, `minimax-portal` |\n| `together` | Together AI | `openai-completions` | `https://api.together.xyz/v1` | `TOGETHER_API_KEY` | `together` |\n| `nvidia` | NVIDIA | `openai-completions` | `https://integrate.api.nvidia.com/v1` | `NVIDIA_API_KEY` | `nvidia` |\n| `venice` | Venice AI | `openai-completions` | `https://api.venice.ai/api/v1` | `VENICE_API_KEY` | `venice` |\n| `github-copilot` | GitHub Copilot | `openai-responses` | `https://api.individual.githubcopilot.com` | `COPILOT_GITHUB_TOKEN` / `GH_TOKEN` | `github-copilot` |\n| `huggingface` | Hugging Face | `openai-completions` | (Inference API) | `HF_TOKEN` | `huggingface` |\n\n### Utility-Only Plugins (Not Model Providers)\n\nThese extensions provide non-LLM services and do **not** appear in `models.list`:\n\n| Plugin ID | Purpose | Env Var |\n|---|---|---|\n| `groq` | Audio transcription (Whisper) | `GROQ_API_KEY` |\n| `perplexity` | Web search | `PERPLEXITY_API_KEY` |\n| `brave` | Brave web search | `BRAVE_API_KEY` |\n\n### Provider ID Aliases\n\nOpenClaw normalizes some legacy/alternative provider IDs via `normalizeProviderId()`:\n\n| Input | Normalized To |\n|---|---|\n| `bytedance`, `doubao` | `volcengine` |\n| `bedrock`, `aws-bedrock` | `amazon-bedrock` |\n| `qwen` | `qwen-portal` |\n| `kimi`, `kimi-code`, `kimi-coding` | `kimi` |\n\nAdditional providers can be added via extensions or user config.\n\n---\n\n## Plugin Provider Discovery\n\nExtensions register providers through the Plugin SDK:\n\n```typescript\n// extensions/my-provider/provider-catalog.ts\nexport function buildMyProvider(): ModelProviderConfig {\n return {\n baseUrl: \"https://api.myprovider.com/v1\",\n api: \"openai-completions\",\n models: [\n { id: \"my-model-1\", name: \"My Model 1\", reasoning: false, input: [\"text\"], contextWindow: 128000, maxTokens: 4096, cost: { input: 0.001, output: 0.002 } }\n ]\n };\n}\n```\n\nDiscovery runs in 4 stages: `simple` → `profile` → `paired` → `late`.\n\n---\n\n## Model Reference Format\n\nModels are referenced as `provider/model-id` strings:\n\n```\nanthropic/claude-opus-4-6\nopenai/gpt-5.4\nopenrouter/anthropic/claude-sonnet-4\nmoonshot/kimi-k2.5\nollama/llama3\n```\n\nThe `parseModelRef(raw)` function splits on the FIRST `/`:\n- `openrouter/anthropic/claude-sonnet-4` → provider=`openrouter`, model=`anthropic/claude-sonnet-4`\n\n---\n\n## Catalog-to-Config Provider ID Mapping\n\n### The Problem: Two ID Systems\n\nOpenClaw has **two independent provider ID systems** that AcaClaw must bridge:\n\n1. **Config provider IDs** — Keys under `models.providers.` in `openclaw.json` (e.g. `moonshot`, `azure`, `google`)\n2. **Catalog provider IDs** — The `provider` field returned by `models.list` (e.g. `kimi-coding`, `azure-openai-responses`, `google-vertex`)\n\nThese IDs often **do not match**. For example:\n- Config has `moonshot` → catalog returns models with `provider: \"kimi-coding\"` and `provider: \"moonshot\"`\n- Config has `azure` → catalog returns `provider: \"azure-openai-responses\"`\n- Config has `google` → catalog returns `provider: \"google-vertex\"` and `provider: \"google-generative-ai\"`\n\nWithout bridging these, the UI cannot filter the model catalog to show only models from configured providers.\n\n### Why Two Systems Exist\n\nOpenClaw extensions often register multiple provider entries under a single API key. The moonshot extension is a good example:\n\n```\nExtension: moonshot (api.moonshot.ai, 4 models)\nExtension: kimi-coding (api.kimi.com/coding/, 2 models)\nBoth share the same \"moonshot\" config key and API key\n```\n\nOpenClaw's `normalizeProviderId()` (in `src/agents/provider-id.ts`) maps aliases:\n- `kimi`, `kimi-code`, `kimi-coding` → `\"kimi\"` (internally)\n- `bedrock`, `aws-bedrock` → `\"amazon-bedrock\"`\n\nAnd `PLUGIN_ID_ALIASES` in `config-state.ts` maps extension IDs:\n- `\"kimi-coding\"` → `\"kimi\"` (shares moonshot's API key)\n- `\"openai-codex\"` → `\"openai\"`\n\n### The Fix: `CATALOG_TO_CONFIG_PROVIDER` Mapping\n\nAcaClaw maintains a static mapping table in `ui/src/models/provider-mapping.ts`:\n\n```typescript\nexport const CATALOG_TO_CONFIG_PROVIDER: Record = {\n // Core providers\n \"anthropic\": \"anthropic\",\n \"openai\": \"openai\",\n \"openai-codex\": \"openai\",\n \"openai-responses\": \"openai\",\n \"azure-openai-responses\": \"azure\",\n \"google\": \"google\",\n \"google-vertex\": \"google\",\n \"google-gemini-cli\": \"google\",\n \"deepseek\": \"deepseek\",\n \"mistral\": \"mistral\",\n \"openrouter\": \"openrouter\",\n \"ollama\": \"ollama\",\n // Moonshot / Kimi\n \"moonshot\": \"moonshot\",\n \"kimi\": \"moonshot\",\n \"kimi-coding\": \"moonshot\",\n // xAI\n \"xai\": \"xai\",\n // ModelStudio (Alibaba / Qwen)\n \"modelstudio\": \"modelstudio\",\n // Volcengine (Doubao / ByteDance)\n \"volcengine\": \"volcengine\",\n \"volcengine-plan\": \"volcengine\",\n // GitHub Copilot\n \"github-copilot\": \"github-copilot\",\n // Others\n \"together\": \"together\",\n \"nvidia\": \"nvidia\",\n \"venice\": \"venice\",\n \"qianfan\": \"qianfan\",\n \"amazon-bedrock\": \"amazon-bedrock\",\n \"huggingface\": \"huggingface\",\n \"minimax\": \"minimax\",\n \"minimax-portal\": \"minimax\",\n \"byteplus\": \"volcengine\",\n \"byteplus-plan\": \"volcengine\",\n};\n```\n\nThe helper function `catalogToConfigProvider(catalogId)` returns the config key, falling back to the catalog ID itself for unknown providers.\n\n### How Filtering Works\n\nWhen a user has `openrouter` configured and the catalog returns 809 models across 23 providers:\n\n```\nmodels.list → 809 models (all providers)\nconfig.get → models.providers = { openrouter: { apiKey: \"...\" } }\n\nFilter: model.provider → catalogToConfigProvider() → check if result is in configuredProviders\nResult: 246 models (only openrouter)\n```\n\n### Reverse Lookup: Config → Catalog Models\n\nFor per-provider model tables (showing which models a configured provider gives you), we do a reverse lookup:\n\n```typescript\n// Find all catalog providers that map to a config provider\n_modelsForConfigProvider(configId: string) {\n const catalogProviders = Object.entries(CATALOG_TO_CONFIG_PROVIDER)\n .filter(([, cfg]) => cfg === configId)\n .map(([cat]) => cat);\n return this._modelCatalog.filter(m =>\n m.provider && catalogProviders.includes(m.provider)\n );\n}\n```\n\nFor `moonshot`, this returns models from both `\"moonshot\"` and `\"kimi-coding\"` catalog entries.\n\n### Default Model and Stale References\n\nThe default model is stored at `agents.defaults.model` as a `\"provider/model-id\"` string (e.g. `\"kimi-coding/kimi-k2-thinking\"`). If the user later removes the moonshot API key, this reference becomes \"stale\" — the model still exists in config but its provider is no longer configured. The UI handles this by:\n\n1. Displaying the raw model ID string when no friendly name match exists\n2. Keeping the reference intact (the gateway may still honor it via other routing)\n\n### Test Coverage\n\nThe mapping is covered by 30 unit tests across three files:\n- `tests/provider-mapping.test.ts` — Validates mapping correctness and completeness\n- `tests/model-config.test.ts` — Validates model filtering and default save behavior\n- `tests/chat-default-model.test.ts` — Validates chat reads config and filters models\n\n---\n\n## AcaClaw GUI Integration Method\n\n### Principle\n\nAcaClaw's GUI is a frontend to OpenClaw's backend. For provider and model management:\n\n1. **Read** provider/model state via `config.get` and `models.list` RPCs\n2. **Write** changes via `config.set` RPC (full config with `baseHash`)\n3. **Never** maintain a separate provider catalog or model list\n4. **Never** hardcode provider URLs — OpenClaw resolves these automatically\n5. **Always** use `catalogToConfigProvider()` when filtering catalog models by configured providers\n\n### Data Flow\n\n```\n┌─────────────────┐ WebSocket RPC ┌──────────────────┐\n│ AcaClaw GUI │ ◄─────────────────────► │ OpenClaw Gateway │\n│ │ │ │\n│ api-keys.ts │──── config.get ────────►│ Config store │\n│ │◄─── {config, baseHash} ──│ │\n│ │ │ │\n│ │──── models.list ────────►│ Model catalog │\n│ │◄─── {models: [...]} ────│ (cached) │\n│ │ │ │\n│ provider- │ catalogToConfigProvider │ │\n│ mapping.ts │ bridges the two ID │ │\n│ │ systems for filtering │ │\n│ │ │ │\n│ │──── config.set ─────────►│ Validate + save │\n│ │ {raw, baseHash} │ → restart │\n└─────────────────┘ └──────────────────┘\n```\n\n### Implementation: Reading Provider State\n\n```typescript\n// 1. Get full config (API keys are redacted)\n// Note: config.get returns { config: {...}, hash: \"...\" } — unwrap the .config property\nconst configResult = await gateway.call(\"config.get\");\nconst cfg = (configResult?.config as Record) ?? configResult ?? {};\nconst baseHash = configResult.payload?.baseHash;\n\n// 2. Extract configured providers\nconst providers = cfg.models?.providers ?? {};\n// providers = { anthropic: { baseUrl, apiKey: \"__OPENCLAW_REDACTED__\", models: [...] }, ... }\n\n// 3. Detect which providers have keys set\nfor (const [id, provider] of Object.entries(providers)) {\n const hasKey = provider.apiKey && provider.apiKey !== \"\";\n const isRedacted = provider.apiKey === \"__OPENCLAW_REDACTED__\";\n // hasKey || isRedacted → provider is configured\n}\n\n// 4. Also check auth profiles (alternative key storage)\nconst authProfiles = config.auth?.profiles ?? {};\nfor (const [profileId, profile] of Object.entries(authProfiles)) {\n // profile.provider tells you which provider this profile configures\n}\n```\n\n### Implementation: Getting Available Models\n\n```typescript\nimport { catalogToConfigProvider } from \"../models/provider-mapping.js\";\n\n// Call models.list — returns the full merged catalog (ALL providers, not just configured ones)\nconst result = await gateway.call(\"models.list\");\nconst allModels = result.payload.models;\n// allModels may contain 800+ models across 23+ providers\n\n// IMPORTANT: Filter to only configured providers using the mapping\nconst configuredProviders = new Set(Object.keys(cfg.models?.providers ?? {}));\nconst models = allModels.filter(m =>\n m.provider ? configuredProviders.has(catalogToConfigProvider(m.provider)) : false\n);\n// Now models only contains models from providers the user has API keys for\n```\n\n### Implementation: Saving API Keys\n\n```typescript\n// 1. Read current config + baseHash\nconst { config, baseHash } = (await gateway.call(\"config.get\")).payload;\n\n// 2. Set the API key in the config\nconfig.models ??= {};\nconfig.models.providers ??= {};\nconfig.models.providers[\"anthropic\"] ??= { baseUrl: \"https://api.anthropic.com\", models: [] };\nconfig.models.providers[\"anthropic\"].apiKey = \"sk-ant-...\";\n\n// 3. Write back with baseHash (optimistic concurrency)\nawait gateway.call(\"config.set\", {\n raw: JSON.stringify(config),\n baseHash,\n});\n\n// After config.set, the gateway restarts and model catalog refreshes\n```\n\n### Implementation: Setting Default Model\n\n```typescript\n// 1. Read config\nconst { config, baseHash } = (await gateway.call(\"config.get\")).payload;\n\n// 2. Set default model (format: \"provider/model-id\")\nconfig.agents ??= {};\nconfig.agents.defaults ??= {};\nconfig.agents.defaults.model = \"anthropic/claude-opus-4-6\";\n\n// 3. Write back\nawait gateway.call(\"config.set\", { raw: JSON.stringify(config), baseHash });\n```\n\n### Implementation: Removing a Provider's API Key\n\n```typescript\n// Use updateConfig (read-modify-write) to delete the provider entry\nawait updateConfig((cfg) => {\n const providers = cfg.models?.providers;\n if (providers) delete providers[\"anthropic\"];\n\n // Also clean auth profiles that reference this provider\n const profiles = cfg.auth?.profiles;\n if (profiles) {\n for (const [key, val] of Object.entries(profiles)) {\n if (val?.provider === \"anthropic\") delete profiles[key];\n }\n }\n\n // If the default model used this provider, clear it\n const defaultModel = cfg.agents?.defaults?.model;\n if (typeof defaultModel === \"string\" && defaultModel.startsWith(\"anthropic/\")) {\n delete cfg.agents.defaults.model;\n }\n\n return cfg;\n});\n```\n\nAfter removal, re-call `models.list` — the provider's models will no longer appear in the catalog.\n\n### Implementation: Per-Provider Model Browsing\n\nGroup models by provider and display each provider's available models. Use `catalogToConfigProvider()` to bridge the two ID systems:\n\n```typescript\nimport { catalogToConfigProvider, CATALOG_TO_CONFIG_PROVIDER } from \"../models/provider-mapping.js\";\n\n// After calling models.list\nconst models = result.payload.models;\n\n// Filter to only configured providers\nconst configuredProviders = new Set(Object.keys(config.models?.providers ?? {}));\nconst filtered = models.filter(m =>\n m.provider ? configuredProviders.has(catalogToConfigProvider(m.provider)) : false\n);\n\n// For a selected config provider (e.g. \"moonshot\"),\n// find ALL catalog providers that map to it\nfunction modelsForConfigProvider(configId: string) {\n const catalogProviders = Object.entries(CATALOG_TO_CONFIG_PROVIDER)\n .filter(([, cfg]) => cfg === configId)\n .map(([cat]) => cat);\n return models.filter(m => m.provider && catalogProviders.includes(m.provider));\n}\n\n// moonshot → returns models from both \"moonshot\" and \"kimi-coding\" providers\nconst moonshotModels = modelsForConfigProvider(\"moonshot\");\n```\n\n### Implementation: Chat Uses Default Model\n\nThe chat view reads the default model and filters models in `_loadModels()`:\n\n```typescript\nimport { catalogToConfigProvider } from \"../models/provider-mapping.js\";\n\nasync _loadModels() {\n const [modelsResult, configResult] = await Promise.all([\n gateway.call(\"models.list\", {}),\n gateway.call(\"config.get\"),\n ]);\n\n // Handle config.get response wrapper: { config: {...}, hash: \"...\" }\n const cfg = (configResult?.config as Record) ?? configResult ?? {};\n\n // Which config providers are configured (have API keys)?\n const providers = cfg.models?.providers;\n const configuredProviders = new Set(providers ? Object.keys(providers) : []);\n\n // Read the user-saved default model from agents.defaults.model\n const savedDefaultModel = cfg.agents?.defaults?.model ?? \"\";\n\n // Filter models to only configured providers using the mapping\n const filtered = modelsResult.models.filter(m =>\n m.provider ? configuredProviders.has(catalogToConfigProvider(m.provider)) : false\n );\n\n // Build dropdown options\n this._availableModels = filtered.map(m => ({\n value: m.provider ? `${m.provider}/${m.id}` : m.id,\n label: m.provider ? `${m.name} · ${m.provider}` : m.name,\n }));\n\n // Display the saved default model, or fall back to first available\n if (savedDefaultModel) {\n const match = filtered.find(m => `${m.provider}/${m.id}` === savedDefaultModel);\n this._defaultModelDisplay = match\n ? `${match.name} · ${match.provider}`\n : savedDefaultModel; // Show raw ID if provider is unconfigured\n }\n}\n```\n\nKey points:\n- Chat reads `agents.defaults.model` from `config.get` (not from `sessions.list`)\n- Models are filtered using `catalogToConfigProvider()` to match catalog IDs to config keys\n- When the saved default model belongs to an unconfigured provider, the raw model ID is displayed\n- The gateway resolves model fallback: session override → agent config → `agents.defaults.model`\n\n### Implementation: Test Connection\n\nOpenClaw has no dedicated \"verify API key\" endpoint. Use `models.list` as a connectivity test:\n\n```typescript\nasync function testConnection(): Promise {\n try {\n const result = await gateway.call(\"models.list\");\n return result.payload.models.length > 0;\n } catch {\n return false;\n }\n}\n```\n\nIf `models.list` returns an empty array after setting a key, the key may be invalid or the provider URL unreachable.\n\n### GUI Component Design\n\n```\n┌──────────────────────────────────────────────────────────────┐\n│ API Keys & Models │\n├──────────────────────────────────────────────────────────────┤\n│ │\n│ ┌─ Provider Tabs ──────────────────────────────────────┐ │\n│ │ [Anthropic ✓] [OpenAI] [Google] [Moonshot] [+More] │ │\n│ └──────────────────────────────────────────────────────┘ │\n│ │\n│ API Key: [sk-ant-•••••••••••••] [👁] [Save] │\n│ Status: ● Connected (3 models available) │\n│ │\n│ ┌─ Default Model ──────────────────────────────────────┐ │\n│ │ [▼ Claude Opus (claude-opus-4-6) ] │ │\n│ │ │ │\n│ │ Context: 200K tokens │ Reasoning: ✓ │ Vision: ✓ │ │\n│ └──────────────────────────────────────────────────────┘ │\n│ │\n│ Available Models (from configured providers): │\n│ ┌──────────────────────────────────────────────────────┐ │\n│ │ Provider │ Model │ Context │ Reasoning │ Cost │ │\n│ │──────────│───────────────│─────────│───────────│──────│ │\n│ │ anthropic│ Claude Opus │ 200K │ ✓ │ $$$ │ │\n│ │ anthropic│ Claude Sonnet │ 200K │ ✓ │ $$ │ │\n│ │ openai │ GPT-5.4 │ 128K │ – │ $$ │ │\n│ └──────────────────────────────────────────────────────┘ │\n│ │\n└──────────────────────────────────────────────────────────────┘\n```\n\n### Key Design Rules\n\n| Rule | Rationale |\n|---|---|\n| Provider tabs come from `config.get` + `models.list`, not hardcoded | New providers automatically appear |\n| Show `__OPENCLAW_REDACTED__` as `••••••••` | Never display actual keys; OpenClaw already redacts |\n| Use `config.set` with `baseHash` for all writes | Optimistic concurrency prevents config conflicts |\n| Refresh `models.list` after saving a new API key | Gateway restarts on config change; new models appear |\n| Show model capabilities (reasoning, vision, context) | Helps researchers choose the right model |\n| Filter model dropdown to providers with configured keys | Don't show models the user can't actually use |\n\n### Dynamic Provider List Strategy\n\nInstead of hardcoding `[\"anthropic\", \"openai\", \"google\", ...]`, derive the provider list dynamically:\n\n```typescript\n// Combine providers from config + models.list for the complete picture\nfunction getProviderList(config: OpenClawConfig, models: ModelCatalogEntry[]): ProviderInfo[] {\n const seen = new Map();\n\n // 1. Providers with API keys configured\n for (const [id, p] of Object.entries(config.models?.providers ?? {})) {\n seen.set(id, { id, configured: true, modelCount: 0 });\n }\n\n // 2. Providers from model catalog (includes built-in + plugin-discovered)\n for (const model of models) {\n const info = seen.get(model.provider) ?? { id: model.provider, configured: false, modelCount: 0 };\n info.modelCount++;\n seen.set(model.provider, info);\n }\n\n return [...seen.values()].sort((a, b) => a.id.localeCompare(b.id));\n}\n```\n\n### Recommended \"Quick Setup\" Providers\n\nFor the onboarding wizard, show these top-level providers with guided setup. All others accessible via \"Add Provider\" in settings:\n\n| Provider | Why | Key Prefix |\n|---|---|---|\n| Anthropic | Best for research tasks, default | `sk-ant-` |\n| OpenAI | Widely used, good all-rounder | `sk-` |\n| Google AI | Free tier available | — |\n| OpenRouter | Aggregator, access to many models | `sk-or-` |\n| DeepSeek | Cost-effective, strong reasoning | `sk-` |\n| Ollama | Local/private, no API key needed | (none) |\n\n---\n\n## Common Pitfalls\n\n| Pitfall | Solution |\n|---|---|\n| Setting `agents.defaults.models` to a non-empty map | This creates an allowlist — `models.list` only returns matched models. Keep it `{}` for unrestricted. |\n| Provider with `models: []` (empty array) | Fallback resolution skips providers with no model entries. Include at least one model definition. |\n| Reading API keys from `config.get` response | Keys are redacted. Check for `__OPENCLAW_REDACTED__` to detect \"configured but not shown\". |\n| Writing config without `baseHash` | Rejected by gateway. Always read `config.get` first to get `baseHash`. |\n| After `config.set`, stale model list | Gateway restarts on config change. Re-call `models.list` after a short delay. |\n| Hardcoding provider names in UI | New providers from plugins won't appear. Always derive from `config.get` + `models.list`. |\n\n---\n\n## Reference: Full Config Schema for Models\n\n```json\n{\n \"models\": {\n \"mode\": \"merge\",\n \"providers\": {\n \"\": {\n \"baseUrl\": \"https://...\",\n \"apiKey\": \"sk-...\",\n \"auth\": \"api-key\",\n \"api\": \"openai-completions\",\n \"headers\": {},\n \"authHeader\": true,\n \"models\": [\n {\n \"id\": \"model-name\",\n \"name\": \"Display Name\",\n \"api\": \"openai-completions\",\n \"reasoning\": false,\n \"input\": [\"text\", \"image\"],\n \"cost\": { \"input\": 0.001, \"output\": 0.002, \"cacheRead\": 0.0001, \"cacheWrite\": 0.0005 },\n \"contextWindow\": 128000,\n \"maxTokens\": 4096,\n \"headers\": {},\n \"compat\": {\n \"supportsTools\": true,\n \"supportsReasoningEffort\": false,\n \"thinkingFormat\": \"openai\"\n }\n }\n ]\n }\n },\n \"bedrockDiscovery\": {\n \"enabled\": false,\n \"region\": \"us-east-1\",\n \"providerFilter\": [],\n \"refreshInterval\": 3600,\n \"defaultContextWindow\": 200000,\n \"defaultMaxTokens\": 4096\n }\n },\n \"agents\": {\n \"defaults\": {\n \"model\": \"anthropic/claude-opus-4-6\",\n \"models\": {}\n }\n }\n}\n```\n\n---\n\n## See Also\n\n- [Architecture](/en/architecture/) — Overall system design and layer model\n- [Getting Started](/en/getting-started/) — Installation and first setup\n- [Security](/en/security/) — API key protection and credential scrubbing\n"}}}}