Files
hack/tools/scripts/llm-key-hunter/results/_cache/content/acaclaw_acaclaw.json
T

1 line
146 KiB
JSON
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"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.<id>`:\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<string, SecretInput> # 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.<id>.models[] │\n│ User-defined or override models │\n├─────────────────────────────────────────────┤\n│ 3. Plugin Extensions │\n│ extensions/<id>/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.<id>.apiKey = \"sk-...\"\n\n2. Environment variable reference\n models.providers.<id>.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: \"<uuid>\", method: \"models.list\", params: {} }\n\nResponse:\n {\n type: \"res\", id: \"<uuid>\", 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: \"<uuid>\", method: \"config.get\", params: {} }\n\nResponse:\n {\n type: \"res\", id: \"<uuid>\", 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: \"<uuid>\", method: \"config.set\",\n params: { raw: \"<full JSON string>\", 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.<id>` 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<string, string> = {\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<ConfigGetResult>(\"config.get\");\nconst cfg = (configResult?.config as Record<string, unknown>) ?? 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<ModelsListResult>(\"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<string, unknown>) ?? 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<boolean> {\n try {\n const result = await gateway.call<ModelsListResult>(\"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<string, ProviderInfo>();\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 \"<provider-id>\": {\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.<id>`:\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<string, SecretInput> # 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.<id>.models[] │\n│ User-defined or override models │\n├─────────────────────────────────────────────┤\n│ 3. Plugin Extensions │\n│ extensions/<id>/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.<id>.apiKey = \"sk-...\"\n\n2. Environment variable reference\n models.providers.<id>.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: \"<uuid>\", method: \"models.list\", params: {} }\n\nResponse:\n {\n type: \"res\", id: \"<uuid>\", 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: \"<uuid>\", method: \"config.get\", params: {} }\n\nResponse:\n {\n type: \"res\", id: \"<uuid>\", 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: \"<uuid>\", method: \"config.set\",\n params: { raw: \"<full JSON string>\", 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.<id>` 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<string, string> = {\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<ConfigGetResult>(\"config.get\");\nconst cfg = (configResult?.config as Record<string, unknown>) ?? 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<ModelsListResult>(\"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<string, unknown>) ?? 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<boolean> {\n try {\n const result = await gateway.call<ModelsListResult>(\"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<string, ProviderInfo>();\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 \"<provider-id>\": {\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.<id>`:\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<string, SecretInput> # 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.<id>.models[] │\n│ User-defined or override models │\n├─────────────────────────────────────────────┤\n│ 3. Plugin Extensions │\n│ extensions/<id>/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.<id>.apiKey = \"sk-...\"\n\n2. Environment variable reference\n models.providers.<id>.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: \"<uuid>\", method: \"models.list\", params: {} }\n\nResponse:\n {\n type: \"res\", id: \"<uuid>\", 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: \"<uuid>\", method: \"config.get\", params: {} }\n\nResponse:\n {\n type: \"res\", id: \"<uuid>\", 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: \"<uuid>\", method: \"config.set\",\n params: { raw: \"<full JSON string>\", 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.<id>` 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<string, string> = {\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<ConfigGetResult>(\"config.get\");\nconst cfg = (configResult?.config as Record<string, unknown>) ?? 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<ModelsListResult>(\"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<string, unknown>) ?? 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<boolean> {\n try {\n const result = await gateway.call<ModelsListResult>(\"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<string, ProviderInfo>();\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 \"<provider-id>\": {\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.<id>`:\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<string, SecretInput> # 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.<id>.models[] │\n│ User-defined or override models │\n├─────────────────────────────────────────────┤\n│ 3. Plugin Extensions │\n│ extensions/<id>/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.<id>.apiKey = \"sk-...\"\n\n2. Environment variable reference\n models.providers.<id>.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: \"<uuid>\", method: \"models.list\", params: {} }\n\nResponse:\n {\n type: \"res\", id: \"<uuid>\", 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: \"<uuid>\", method: \"config.get\", params: {} }\n\nResponse:\n {\n type: \"res\", id: \"<uuid>\", 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: \"<uuid>\", method: \"config.set\",\n params: { raw: \"<full JSON string>\", 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.<id>` 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<string, string> = {\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<ConfigGetResult>(\"config.get\");\nconst cfg = (configResult?.config as Record<string, unknown>) ?? 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<ModelsListResult>(\"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<string, unknown>) ?? 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<boolean> {\n try {\n const result = await gateway.call<ModelsListResult>(\"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<string, ProviderInfo>();\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 \"<provider-id>\": {\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"}}}}