From 65471a2da62900824289f3dc1ac5365512ddb1f0 Mon Sep 17 00:00:00 2001
From: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com>
Date: Thu, 21 May 2026 20:17:09 -0600
Subject: [PATCH] feat: add xai oauth web search and provider timeouts
---
docs/.generated/config-baseline.sha256 | 6 +-
.../.generated/plugin-sdk-api-baseline.sha256 | 4 +-
docs/cli/configure.md | 4 +-
docs/cli/onboard.md | 2 +-
docs/concepts/model-providers.md | 2 +-
docs/help/faq.md | 5 +-
docs/plugins/sdk-provider-plugins.md | 2 +
docs/providers/xai.md | 27 +-
docs/reference/api-usage-costs.md | 2 +-
docs/reference/wizard.md | 2 +-
docs/start/wizard-cli-reference.md | 2 +-
docs/tools/grok-search.md | 42 +-
docs/tools/image-generation.md | 11 +-
docs/tools/tts.md | 4 +-
docs/tools/video-generation.md | 2 +-
docs/tools/web.md | 8 +-
extensions/speech-core/src/tts.test.ts | 42 ++
extensions/speech-core/src/tts.ts | 54 ++-
.../xai/image-generation-provider.test.ts | 5 +-
extensions/xai/image-generation-provider.ts | 2 +-
extensions/xai/model-definitions.ts | 44 +-
extensions/xai/model-id.test.ts | 5 +-
extensions/xai/model-id.ts | 3 +
extensions/xai/onboard.test.ts | 2 +
extensions/xai/openclaw.plugin.json | 5 +-
extensions/xai/provider-models.ts | 6 +-
.../xai/src/web-search-provider.runtime.ts | 202 +++++++++-
.../xai/video-generation-provider.test.ts | 3 +-
extensions/xai/video-generation-provider.ts | 3 +-
extensions/xai/web-search-contract-api.ts | 3 +-
extensions/xai/web-search.test.ts | 378 +++++++++++++++++-
extensions/xai/web-search.ts | 3 +-
src/agents/model-auth.profiles.test.ts | 88 ++++
src/agents/model-auth.ts | 19 +-
src/agents/tools/model-config.helpers.ts | 45 ++-
src/config/schema.help.ts | 2 +
src/flows/search-setup.test.ts | 45 +++
src/flows/search-setup.ts | 75 +++-
.../openai-compatible-image-provider.ts | 3 +
src/image-generation/runtime.test.ts | 39 ++
src/image-generation/runtime.ts | 7 +-
src/image-generation/types.ts | 2 +
src/media-generation/runtime-shared.ts | 15 +
src/plugin-sdk/video-generation.ts | 2 +
src/plugins/types.ts | 2 +
src/plugins/web-provider-types.ts | 2 +
.../web-provider-runtime.test-helpers.ts | 2 +
src/tts/tts-types.ts | 1 +
src/video-generation/runtime.test.ts | 31 ++
src/video-generation/runtime.ts | 7 +-
src/video-generation/types.ts | 2 +
src/web-search/runtime.test.ts | 54 +++
src/web-search/runtime.ts | 11 +-
src/web/provider-runtime-shared.ts | 8 +
54 files changed, 1233 insertions(+), 114 deletions(-)
diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256
index 092ed15c8a92..a72358220808 100644
--- a/docs/.generated/config-baseline.sha256
+++ b/docs/.generated/config-baseline.sha256
@@ -1,4 +1,4 @@
-4f9946cf7d5afea985c8b9be185c7d8b420e038d915ce91be7476dd6541e0f08 config-baseline.json
-35d17c60d2858a9cbc875807cdfc7f2fc8ba4745aa4e140a3cdf7ecf38b8a034 config-baseline.core.json
+5482b1a125a5c41856f6f49dfd70e2efe9e52a7cc0e2d4c24a56d99adfeda6be config-baseline.json
+3d686075da4d4f6c6319c3247e93f486a6c48314a28a2961cd4acab7f3fa5389 config-baseline.core.json
11839c7a1b858c66075156f0e203aa8367cd8321047684679a18e18b7c8fe1f7 config-baseline.channel.json
-a0a88df97080adf50c2c2bccd2ca076ad43e81b24dd25f3c3cace41f09a7c8f0 config-baseline.plugin.json
+5c214ab364011fd95735755f9fa4298aa4de8ad81144ae8dd08d969bb7ba318b config-baseline.plugin.json
diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256
index 6e8efd3dc63c..8feac8f0e2d8 100644
--- a/docs/.generated/plugin-sdk-api-baseline.sha256
+++ b/docs/.generated/plugin-sdk-api-baseline.sha256
@@ -1,2 +1,2 @@
-bb0da3ba4560521d2c9725cd96429f64ce8e6150972ba77be71fdf8ea03e0234 plugin-sdk-api-baseline.json
-4d951b989cc00a86f64907bb28d52a950a466116382c9877f24807b0fba3df44 plugin-sdk-api-baseline.jsonl
+b3105c70370edd21c77b943b8c34f5d7ea99df2a92eaf3871f36016823579ffe plugin-sdk-api-baseline.json
+b0fe23ab4862aa667111a3b433e42faed77d4b7126e9db974b1a00a298232b85 plugin-sdk-api-baseline.jsonl
diff --git a/docs/cli/configure.md b/docs/cli/configure.md
index 5858e4dedee7..813f1f70d628 100644
--- a/docs/cli/configure.md
+++ b/docs/cli/configure.md
@@ -27,8 +27,8 @@ For web search, `openclaw configure --section web` lets you choose a provider
and configure its credentials. Some providers also show provider-specific
follow-up prompts:
-- **Grok** can offer optional `x_search` setup with the same `XAI_API_KEY` and
- let you pick an `x_search` model.
+- **Grok** can offer optional `x_search` setup with the same xAI OAuth profile
+ or API key and let you pick an `x_search` model.
- **Kimi** can ask for the Moonshot API region (`api.moonshot.ai` vs
`api.moonshot.cn`) and the default Kimi web-search model.
diff --git a/docs/cli/onboard.md b/docs/cli/onboard.md
index d561578bd26e..218559d4e29f 100644
--- a/docs/cli/onboard.md
+++ b/docs/cli/onboard.md
@@ -217,7 +217,7 @@ openclaw onboard --non-interactive \
Some web-search providers trigger provider-specific follow-up prompts:
- - **Grok** can offer optional `x_search` setup with the same `XAI_API_KEY` and an `x_search` model choice.
+ - **Grok** can offer optional `x_search` setup with the same xAI OAuth profile or API key and an `x_search` model choice.
- **Kimi** can ask for the Moonshot API region (`api.moonshot.ai` vs `api.moonshot.cn`) and the default Kimi web-search model.
diff --git a/docs/concepts/model-providers.md b/docs/concepts/model-providers.md
index cf5e2f9e5ee5..7a806de3f4ff 100644
--- a/docs/concepts/model-providers.md
+++ b/docs/concepts/model-providers.md
@@ -333,7 +333,7 @@ See [/providers/kilocode](/providers/kilocode) for setup details.
Model ids use a `nvidia//` namespace (for example `nvidia/nvidia/nemotron-...` alongside `nvidia/moonshotai/kimi-k2.5`); pickers preserve the literal `/` composition while the canonical key sent to the API stays single-prefixed.
- Uses the xAI Responses path. The recommended path is SuperGrok/X Premium OAuth; API keys still work via `XAI_API_KEY` or plugin config. `grok-4.3` is the bundled default chat model. `/fast` or `params.fastMode: true` rewrites `grok-3`, `grok-3-mini`, `grok-4`, and `grok-4-0709` to their `*-fast` variants. `tool_stream` defaults on; disable via `agents.defaults.models["xai/"].params.tool_stream=false`.
+ Uses the xAI Responses path. The recommended path is SuperGrok/X Premium OAuth; API keys still work via `XAI_API_KEY` or plugin config, and Grok `web_search` reuses the same auth profile before API-key fallback. `grok-4.3` is the bundled default chat model, and `grok-build-0.1` is selectable for build/coding-focused work. `/fast` or `params.fastMode: true` rewrites `grok-3`, `grok-3-mini`, `grok-4`, and `grok-4-0709` to their `*-fast` variants. `tool_stream` defaults on; disable via `agents.defaults.models["xai/"].params.tool_stream=false`.
Ships as the bundled `cerebras` provider plugin. GLM uses `zai-glm-4.7`; OpenAI-compatible base URL is `https://api.cerebras.ai/v1`.
diff --git a/docs/help/faq.md b/docs/help/faq.md
index a6b8e834d396..ec3a9314d37c 100644
--- a/docs/help/faq.md
+++ b/docs/help/faq.md
@@ -736,7 +736,8 @@ lives on the [First-run FAQ](/help/faq-first-run).
`web_fetch` works without an API key. `web_search` depends on your selected
provider:
- - API-backed providers such as Brave, Exa, Firecrawl, Gemini, Grok, Kimi, MiniMax Search, Perplexity, and Tavily require their normal API key setup.
+ - API-backed providers such as Brave, Exa, Firecrawl, Gemini, Kimi, MiniMax Search, Perplexity, and Tavily require their normal API key setup.
+ - Grok can reuse xAI OAuth from model auth, or fall back to `XAI_API_KEY` / plugin web-search config.
- Ollama Web Search is key-free, but it uses your configured Ollama host and requires `ollama signin`.
- DuckDuckGo is key-free, but it is an unofficial HTML-based integration.
- SearXNG is key-free/self-hosted; configure `SEARXNG_BASE_URL` or `plugins.entries.searxng.config.webSearch.baseUrl`.
@@ -748,7 +749,7 @@ lives on the [First-run FAQ](/help/faq-first-run).
- Exa: `EXA_API_KEY`
- Firecrawl: `FIRECRAWL_API_KEY`
- Gemini: `GEMINI_API_KEY`
- - Grok: `XAI_API_KEY`
+ - Grok: xAI OAuth, `XAI_API_KEY`
- Kimi: `KIMI_API_KEY` or `MOONSHOT_API_KEY`
- MiniMax Search: `MINIMAX_CODE_PLAN_KEY`, `MINIMAX_CODING_API_KEY`, or `MINIMAX_API_KEY`
- Perplexity: `PERPLEXITY_API_KEY` or `OPENROUTER_API_KEY`
diff --git a/docs/plugins/sdk-provider-plugins.md b/docs/plugins/sdk-provider-plugins.md
index 232c00d8ace2..1c6b52694adc 100644
--- a/docs/plugins/sdk-provider-plugins.md
+++ b/docs/plugins/sdk-provider-plugins.md
@@ -532,6 +532,7 @@ API key auth, and dynamic model resolution.
api.registerSpeechProvider({
id: "acme-ai",
label: "Acme Speech",
+ defaultTimeoutMs: 120_000,
isConfigured: ({ config }) => Boolean(config.messages?.tts),
synthesize: async (req) => {
const { response, release } = await postJsonRequest({
@@ -705,6 +706,7 @@ API key auth, and dynamic model resolution.
api.registerVideoGenerationProvider({
id: "acme-ai",
label: "Acme Video",
+ defaultTimeoutMs: 600_000,
capabilities: {
generate: { maxVideos: 1, maxDurationSeconds: 10, supportsResolution: true },
imageToVideo: {
diff --git a/docs/providers/xai.md b/docs/providers/xai.md
index 590f5a4b880b..d6ff28a5b2ff 100644
--- a/docs/providers/xai.md
+++ b/docs/providers/xai.md
@@ -89,9 +89,10 @@ OpenClaw uses the xAI Responses API as the bundled xAI transport. The same
credential from `openclaw models auth login --provider xai --method oauth`,
`openclaw models auth login --provider xai --device-code`, or
`openclaw models auth login --provider xai --method api-key` can also power first-class
-`x_search`, remote `code_execution`, and xAI image/video generation.
+`web_search`, `x_search`, remote `code_execution`, and xAI image/video generation.
Speech and transcription currently require `XAI_API_KEY` or provider config.
-`XAI_API_KEY` or plugin web-search config can power Grok-backed `web_search` too.
+Grok-backed `web_search` prefers xAI OAuth and falls back to `XAI_API_KEY` or
+plugin web-search config.
If you store an xAI key under `plugins.entries.xai.config.webSearch.apiKey`,
the bundled xAI model provider reuses that key as a fallback too.
Set `plugins.entries.xai.config.webSearch.baseUrl` to route Grok `web_search`
@@ -128,16 +129,18 @@ first in model pickers:
| Family | Model ids |
| -------------- | ------------------------------------------------------------------------ |
+| Grok Build 0.1 | `grok-build-0.1` |
| Grok 4.3 | `grok-4.3` |
| Grok 4.20 Beta | `grok-4.20-beta-latest-reasoning`, `grok-4.20-beta-latest-non-reasoning` |
The plugin still forward-resolves older Grok 3, Grok 4, Grok 4 Fast, Grok 4.1
-Fast, and Grok Code slugs for existing configs, but OpenClaw no longer shows
-those retired upstream slugs in the selectable catalog.
+Fast, and Grok Code slugs for existing configs. Official Grok Code Fast aliases
+normalize to `grok-build-0.1`; OpenClaw no longer shows the other retired
+upstream slugs in the selectable catalog.
-Use `grok-4.3` for new chat and coding workloads unless you explicitly need a
-Grok 4.20 beta alias.
+Use `grok-4.3` for general chat and `grok-build-0.1` for build/coding-focused
+workloads unless you explicitly need a Grok 4.20 beta alias.
## OpenClaw feature coverage
@@ -189,6 +192,9 @@ Legacy aliases still normalize to the canonical bundled ids:
| Legacy alias | Canonical id |
| ------------------------- | ------------------------------------- |
+| `grok-code-fast-1` | `grok-build-0.1` |
+| `grok-code-fast` | `grok-build-0.1` |
+| `grok-code-fast-1-0825` | `grok-build-0.1` |
| `grok-4-fast-reasoning` | `grok-4-fast` |
| `grok-4-1-fast-reasoning` | `grok-4-1-fast` |
| `grok-4.20-reasoning` | `grok-4.20-beta-latest-reasoning` |
@@ -198,10 +204,11 @@ Legacy aliases still normalize to the canonical bundled ids:
- The bundled `grok` web-search provider can use `XAI_API_KEY` or a plugin
- web-search key:
+ The bundled `grok` web-search provider prefers xAI OAuth, then falls back
+ to `XAI_API_KEY` or a plugin web-search key:
```bash
+ openclaw models auth login --provider xai --method oauth
openclaw config set tools.web.search.provider grok
```
@@ -220,6 +227,8 @@ Legacy aliases still normalize to the canonical bundled ids:
using `reference_image` roles, 2-10 seconds for extension
- Reference-image generation: set `imageRoles` to `reference_image` for
every supplied image; xAI accepts up to 7 such images
+ - Default operation timeout: 600 seconds unless `video_generate.timeoutMs`
+ or `agents.defaults.videoGenerationModel.timeoutMs` is set
Local video buffers are not accepted. Use remote `http(s)` URLs for
@@ -259,6 +268,8 @@ Legacy aliases still normalize to the canonical bundled ids:
- Aspect ratios: `1:1`, `16:9`, `9:16`, `4:3`, `3:4`, `2:3`, `3:2`
- Resolutions: `1K`, `2K`
- Count: up to 4 images
+ - Default operation timeout: 600 seconds unless `image_generate.timeoutMs`
+ or `agents.defaults.imageGenerationModel.timeoutMs` is set
OpenClaw asks xAI for `b64_json` image responses so generated media can be
stored and delivered through the normal channel attachment path. Local
diff --git a/docs/reference/api-usage-costs.md b/docs/reference/api-usage-costs.md
index 73a2625f2e22..c7ac22361f28 100644
--- a/docs/reference/api-usage-costs.md
+++ b/docs/reference/api-usage-costs.md
@@ -128,7 +128,7 @@ See [Memory](/concepts/memory).
- **Exa**: `EXA_API_KEY` or `plugins.entries.exa.config.webSearch.apiKey`
- **Firecrawl**: `FIRECRAWL_API_KEY` or `plugins.entries.firecrawl.config.webSearch.apiKey`
- **Gemini (Google Search)**: `GEMINI_API_KEY` or `plugins.entries.google.config.webSearch.apiKey`
-- **Grok (xAI)**: `XAI_API_KEY` or `plugins.entries.xai.config.webSearch.apiKey`
+- **Grok (xAI)**: xAI OAuth profile, `XAI_API_KEY`, or `plugins.entries.xai.config.webSearch.apiKey`
- **Kimi (Moonshot)**: `KIMI_API_KEY`, `MOONSHOT_API_KEY`, or `plugins.entries.moonshot.config.webSearch.apiKey`
- **MiniMax Search**: `MINIMAX_CODE_PLAN_KEY`, `MINIMAX_CODING_API_KEY`, `MINIMAX_API_KEY`, or `plugins.entries.minimax.config.webSearch.apiKey`
- **Ollama Web Search**: key-free for a reachable signed-in local Ollama host; direct `https://ollama.com` search uses `OLLAMA_API_KEY`, and auth-protected hosts can reuse normal Ollama provider bearer auth
diff --git a/docs/reference/wizard.md b/docs/reference/wizard.md
index 2278f538edb5..ffd019c331ba 100644
--- a/docs/reference/wizard.md
+++ b/docs/reference/wizard.md
@@ -38,7 +38,7 @@ For a high-level overview, see [Onboarding (CLI)](/start/wizard).
- Sets `agents.defaults.model` to `openai/gpt-5.5` through the Codex runtime when model is unset or already OpenAI-family.
- **OpenAI API key**: uses `OPENAI_API_KEY` if present or prompts for a key, then stores it in auth profiles.
- Sets `agents.defaults.model` to `openai/gpt-5.5` when model is unset, `openai/*`, or `openai-codex/*`.
- - **xAI (Grok) API key**: prompts for `XAI_API_KEY` and configures xAI as a model provider.
+ - **xAI (Grok) OAuth / API key**: signs in with xAI OAuth when chosen, or prompts for `XAI_API_KEY` on the API-key path, and configures xAI as a model provider.
- **OpenCode**: prompts for `OPENCODE_API_KEY` (or `OPENCODE_ZEN_API_KEY`, get it at https://opencode.ai/auth) and lets you pick the Zen or Go catalog.
- **Ollama**: offers **Cloud + Local**, **Cloud only**, or **Local only** first. `Cloud only` prompts for `OLLAMA_API_KEY` and uses `https://ollama.com`; the host-backed modes prompt for the Ollama base URL, discover available models, and auto-pull the selected local model when needed; `Cloud + Local` also checks whether that Ollama host is signed in for cloud access.
- More detail: [Ollama](/providers/ollama)
diff --git a/docs/start/wizard-cli-reference.md b/docs/start/wizard-cli-reference.md
index 74551a8cbc1c..dbb27c42a033 100644
--- a/docs/start/wizard-cli-reference.md
+++ b/docs/start/wizard-cli-reference.md
@@ -156,7 +156,7 @@ What you set:
Browser sign-in for eligible SuperGrok or X Premium accounts. This is the
recommended xAI path for most users. OpenClaw stores the resulting auth
- profile for Grok models, `x_search`, and `code_execution`.
+ profile for Grok models, Grok `web_search`, `x_search`, and `code_execution`.
Remote-friendly browser sign-in with a short code instead of a localhost
diff --git a/docs/tools/grok-search.md b/docs/tools/grok-search.md
index a8f3b2463dec..f71a9a9c218c 100644
--- a/docs/tools/grok-search.md
+++ b/docs/tools/grok-search.md
@@ -2,7 +2,7 @@
summary: "Grok web search via xAI web-grounded responses"
read_when:
- You want to use Grok for web_search
- - You need an XAI_API_KEY for web search
+ - You want to use xAI OAuth or an XAI_API_KEY for web search
title: "Grok search"
---
@@ -10,10 +10,11 @@ OpenClaw supports Grok as a `web_search` provider, using xAI web-grounded
responses to produce AI-synthesized answers backed by live search results
with citations.
-The same xAI API key can also power the built-in `x_search` tool for X
-(formerly Twitter) post search and the `code_execution` tool. If you store the
-key under `plugins.entries.xai.config.webSearch.apiKey`, OpenClaw now reuses it
-as a fallback for the bundled xAI model provider too.
+Grok web search prefers your existing xAI OAuth sign-in when one is available.
+If no OAuth profile exists, the same xAI API key can also power the built-in
+`x_search` tool for X (formerly Twitter) post search and the `code_execution`
+tool. If you store the key under `plugins.entries.xai.config.webSearch.apiKey`,
+OpenClaw reuses it as a fallback for the bundled xAI model provider too.
For post-level X metrics such as reposts, replies, bookmarks, or views, prefer
`x_search` with the exact post URL or status ID instead of a broad search
@@ -26,8 +27,10 @@ If you choose **Grok** during:
- `openclaw onboard`
- `openclaw configure --section web`
-OpenClaw can show a separate follow-up step to enable `x_search` with the same
-`XAI_API_KEY`. That follow-up:
+OpenClaw can use an existing xAI OAuth profile without prompting for a separate
+web-search key. If OAuth is not available, it falls back to xAI API-key setup.
+OpenClaw can also show a separate follow-up step to enable `x_search` with the
+same xAI credential. That follow-up:
- only appears after you choose Grok for `web_search`
- is not a separate top-level web-search provider choice
@@ -35,11 +38,22 @@ OpenClaw can show a separate follow-up step to enable `x_search` with the same
If you skip it, you can enable or change `x_search` later in config.
-## Get an API key
+## Sign in or get an API key
-
- Get an API key from [xAI](https://console.x.ai/).
+
+ If you already signed in with xAI during onboarding or model auth, choose
+ Grok as the `web_search` provider. No separate API key is required:
+
+ ```bash
+ openclaw onboard --auth-choice xai-oauth
+ openclaw config set tools.web.search.provider grok
+ ```
+
+
+
+ Get an API key from [xAI](https://console.x.ai/) when OAuth is unavailable
+ or you intentionally want key-backed web-search config.
Set `XAI_API_KEY` in the Gateway environment, or configure via:
@@ -60,7 +74,7 @@ If you skip it, you can enable or change `x_search` later in config.
xai: {
config: {
webSearch: {
- apiKey: "xai-...", // optional if XAI_API_KEY is set
+ apiKey: "xai-...", // optional if xAI OAuth or XAI_API_KEY is available
baseUrl: "https://api.x.ai/v1", // optional Responses API proxy/base URL override
},
},
@@ -77,8 +91,10 @@ If you skip it, you can enable or change `x_search` later in config.
}
```
-**Environment alternative:** set `XAI_API_KEY` in the Gateway environment.
-For a gateway install, put it in `~/.openclaw/.env`.
+**Credential alternatives:** sign in with `openclaw models auth login
+--provider xai --method oauth`, set `XAI_API_KEY` in the Gateway environment,
+or store `plugins.entries.xai.config.webSearch.apiKey`. For a gateway install,
+put env vars in `~/.openclaw/.env`.
## How it works
diff --git a/docs/tools/image-generation.md b/docs/tools/image-generation.md
index 5108467413ca..ed0f48309226 100644
--- a/docs/tools/image-generation.md
+++ b/docs/tools/image-generation.md
@@ -235,11 +235,12 @@ from each attempt.
Set `agents.defaults.imageGenerationModel.timeoutMs` for slow image
backends. A per-call `timeoutMs` tool parameter overrides the configured
- default. Google, OpenRouter, and xAI hosted image providers use 180 second
- defaults; Azure OpenAI image generation uses 600 seconds. Codex dynamic-tool
- calls use a 120 second `image_generate` bridge default and honor the same
- timeout budget when configured, bounded by OpenClaw's 600000 ms dynamic-tool
- bridge maximum.
+ default, and configured defaults override plugin-authored provider
+ defaults. Google and OpenRouter hosted image providers use 180 second
+ defaults; xAI and Azure OpenAI image generation use 600 seconds. Codex
+ dynamic-tool calls use a 120 second `image_generate` bridge default and
+ honor the same timeout budget when configured, bounded by OpenClaw's 600000
+ ms dynamic-tool bridge maximum.
Use `action: "list"` to inspect the currently registered providers,
diff --git a/docs/tools/tts.md b/docs/tools/tts.md
index 289f070cd8be..017da6c34ddf 100644
--- a/docs/tools/tts.md
+++ b/docs/tools/tts.md
@@ -964,7 +964,9 @@ WhatsApp sends audio through Baileys as a PTT voice note (`audio` with
clients do not consistently render captions on voice notes.
The tool accepts optional `channel` and `timeoutMs` fields; `timeoutMs` is a
-per-call provider request timeout in milliseconds.
+per-call provider request timeout in milliseconds. Per-call values override
+`messages.tts.timeoutMs`; configured TTS timeouts override any plugin-authored
+provider default.
## Gateway RPC
diff --git a/docs/tools/video-generation.md b/docs/tools/video-generation.md
index 7e98f9739ced..36a5dac785fd 100644
--- a/docs/tools/video-generation.md
+++ b/docs/tools/video-generation.md
@@ -223,7 +223,7 @@ dimensions). Providers that do not declare it surface the value via
Provider/model override (e.g. `runway/gen4.5`).
Output filename hint.
-Optional provider operation timeout in milliseconds. When omitted, OpenClaw uses `agents.defaults.videoGenerationModel.timeoutMs` if configured.
+Optional provider operation timeout in milliseconds. When omitted, OpenClaw uses `agents.defaults.videoGenerationModel.timeoutMs` if configured, otherwise the plugin-authored provider default when one exists.
Provider-specific options as a JSON object (e.g. `{"seed": 42, "draft": true}`).
Providers that declare a typed schema validate the keys and types; unknown
diff --git a/docs/tools/web.md b/docs/tools/web.md
index e15356ebda99..2bb6dc7a2115 100644
--- a/docs/tools/web.md
+++ b/docs/tools/web.md
@@ -104,7 +104,7 @@ local while `web_search` and `x_search` can use xAI Responses under the hood.
| [Exa](/tools/exa-search) | Structured + extracted | Neural/keyword mode, date, content extraction | `EXA_API_KEY` |
| [Firecrawl](/tools/firecrawl) | Structured snippets | Via `firecrawl_search` tool | `FIRECRAWL_API_KEY` |
| [Gemini](/tools/gemini-search) | AI-synthesized + citations | -- | `GEMINI_API_KEY` |
-| [Grok](/tools/grok-search) | AI-synthesized + citations | -- | `XAI_API_KEY` |
+| [Grok](/tools/grok-search) | AI-synthesized + citations | -- | xAI OAuth, `XAI_API_KEY`, or `plugins.entries.xai.config.webSearch.apiKey` |
| [Kimi](/tools/kimi-search) | AI-synthesized + citations; fails on ungrounded chat fallbacks | -- | `KIMI_API_KEY` / `MOONSHOT_API_KEY` |
| [MiniMax Search](/tools/minimax-search) | Structured snippets | Region (`global` / `cn`) | `MINIMAX_CODE_PLAN_KEY` / `MINIMAX_CODING_API_KEY` / `MINIMAX_OAUTH_TOKEN` |
| [Ollama Web Search](/tools/ollama-search) | Structured snippets | -- | None for signed-in local hosts; `OLLAMA_API_KEY` for direct `https://ollama.com` search |
@@ -178,7 +178,7 @@ API-backed providers first:
1. **Brave** -- `BRAVE_API_KEY` or `plugins.entries.brave.config.webSearch.apiKey` (order 10)
2. **MiniMax Search** -- `MINIMAX_CODE_PLAN_KEY` / `MINIMAX_CODING_API_KEY` / `MINIMAX_OAUTH_TOKEN` / `MINIMAX_API_KEY` or `plugins.entries.minimax.config.webSearch.apiKey` (order 15)
3. **Gemini** -- `plugins.entries.google.config.webSearch.apiKey`, `GEMINI_API_KEY`, or `models.providers.google.apiKey` (order 20)
-4. **Grok** -- `XAI_API_KEY` or `plugins.entries.xai.config.webSearch.apiKey` (order 30)
+4. **Grok** -- xAI OAuth, `XAI_API_KEY`, or `plugins.entries.xai.config.webSearch.apiKey` (order 30)
5. **Kimi** -- `KIMI_API_KEY` / `MOONSHOT_API_KEY` or `plugins.entries.moonshot.config.webSearch.apiKey` (order 40)
6. **Perplexity** -- `PERPLEXITY_API_KEY` / `OPENROUTER_API_KEY` or `plugins.entries.perplexity.config.webSearch.apiKey` (order 50)
7. **Firecrawl** -- `FIRECRAWL_API_KEY` or `plugins.entries.firecrawl.config.webSearch.apiKey` (order 60)
@@ -229,6 +229,8 @@ Provider-specific config (API keys, base URLs, modes) lives under
`models.providers.google.apiKey` and `models.providers.google.baseUrl` as lower-priority
fallbacks after its dedicated web-search config and `GEMINI_API_KEY`. See the
provider pages for examples.
+Grok can also reuse an xAI OAuth auth profile from `openclaw models auth login
+--provider xai --method oauth`; API-key config remains the fallback.
`tools.web.search.provider` is validated against the web-search provider ids
declared by bundled and installed plugin manifests. A typo such as `"brvae"`
@@ -259,7 +261,7 @@ same xAI auth profile as chat, or the `XAI_API_KEY` / plugin web-search
credential used by Grok web search.
Legacy `tools.web.x_search.*` config is auto-migrated by `openclaw doctor --fix`.
When you choose Grok during `openclaw onboard` or `openclaw configure --section web`,
-OpenClaw can also offer optional `x_search` setup with the same key.
+OpenClaw can also offer optional `x_search` setup with the same credential.
This is a separate follow-up step inside the Grok path, not a separate top-level
web-search provider choice. If you pick another provider, OpenClaw does not
show the `x_search` prompt.
diff --git a/extensions/speech-core/src/tts.test.ts b/extensions/speech-core/src/tts.test.ts
index 013a48f4c039..953ff219aa04 100644
--- a/extensions/speech-core/src/tts.test.ts
+++ b/extensions/speech-core/src/tts.test.ts
@@ -397,6 +397,48 @@ describe("speech-core native voice-note routing", () => {
expect(providerConfig.apiKey).toBe("resolved-minimax-key");
});
+ it("uses provider default TTS timeout when the call and config omit timeoutMs", async () => {
+ installSpeechProviders([createMockSpeechProvider("mock", { defaultTimeoutMs: 600_000 })]);
+
+ const result = await synthesizeSpeech({
+ text: "Use provider timeout.",
+ cfg: {
+ messages: {
+ tts: {
+ enabled: true,
+ provider: "mock",
+ },
+ },
+ } as OpenClawConfig,
+ disableFallback: true,
+ });
+
+ expect(result.success).toBe(true);
+ const request = requireFirstSynthesisRequest("provider default timeout synthesis request");
+ expect(request.timeoutMs).toBe(600_000);
+ });
+
+ it("keeps explicit TTS config timeout ahead of provider default timeout", async () => {
+ installSpeechProviders([createMockSpeechProvider("mock", { defaultTimeoutMs: 600_000 })]);
+
+ await synthesizeSpeech({
+ text: "Use configured timeout.",
+ cfg: {
+ messages: {
+ tts: {
+ enabled: true,
+ provider: "mock",
+ timeoutMs: 45_000,
+ },
+ },
+ } as OpenClawConfig,
+ disableFallback: true,
+ });
+
+ const request = requireFirstSynthesisRequest("configured timeout synthesis request");
+ expect(request.timeoutMs).toBe(45_000);
+ });
+
it.each(["feishu", "whatsapp"] as const)(
"marks %s voice-note TTS for channel-side transcoding when provider returns mp3",
async (channel) => {
diff --git a/extensions/speech-core/src/tts.ts b/extensions/speech-core/src/tts.ts
index 19c0dba77a19..c11a13918e44 100644
--- a/extensions/speech-core/src/tts.ts
+++ b/extensions/speech-core/src/tts.ts
@@ -44,6 +44,7 @@ import {
type ResolvedTtsModelOverrides,
scheduleCleanup,
summarizeText,
+ type SpeechProviderPlugin,
type SpeechProviderConfig,
type SpeechProviderOverrides,
type SpeechVoiceOption,
@@ -64,6 +65,26 @@ const DEFAULT_TTS_MAX_LENGTH = 1500;
const DEFAULT_TTS_SUMMARIZE = true;
const DEFAULT_MAX_TEXT_LENGTH = 4096;
+function resolvePositiveTimeoutMs(timeoutMs: number | undefined): number | undefined {
+ return typeof timeoutMs === "number" && Number.isFinite(timeoutMs) && timeoutMs > 0
+ ? Math.floor(timeoutMs)
+ : undefined;
+}
+
+function resolveSpeechProviderTimeoutMs(params: {
+ timeoutMs?: number;
+ config: ResolvedTtsConfig;
+ provider: Pick;
+}): number {
+ if (params.timeoutMs !== undefined) {
+ return params.timeoutMs;
+ }
+ if (params.config.timeoutMsSource !== "default") {
+ return params.config.timeoutMs;
+ }
+ return resolvePositiveTimeoutMs(params.provider.defaultTimeoutMs) ?? params.config.timeoutMs;
+}
+
type TtsUserPrefs = {
tts?: {
auto?: TtsAutoMode;
@@ -387,7 +408,7 @@ function resolveLazyProviderConfig(
...(config.rawConfig as Record | undefined),
providers: asProviderConfigMap(config.rawConfig?.providers),
},
- timeoutMs: config.timeoutMs,
+ timeoutMs: resolveSpeechProviderTimeoutMs({ config, provider: resolvedProvider }),
})
: rawConfig;
config.providerConfigs[canonical] = next;
@@ -449,6 +470,7 @@ export function resolveTtsConfig(
const raw: TtsConfig = resolveEffectiveTtsConfig(cfg, contextOrAgentId);
const providerSource = raw.provider ? "config" : "default";
const timeoutMs = raw.timeoutMs ?? DEFAULT_TIMEOUT_MS;
+ const timeoutMsSource = raw.timeoutMs === undefined ? "default" : "config";
const auto = resolveConfiguredTtsAutoMode(raw);
const persona = normalizeTtsPersonaId(raw.persona);
return {
@@ -466,6 +488,7 @@ export function resolveTtsConfig(
prefsPath: raw.prefsPath,
maxTextLength: raw.maxTextLength ?? DEFAULT_MAX_TEXT_LENGTH,
timeoutMs,
+ timeoutMsSource,
rawConfig: raw,
sourceConfig: cfg,
};
@@ -632,7 +655,7 @@ export function getTtsProvider(config: ResolvedTtsConfig, prefsPath: string): Tt
provider.isConfigured({
cfg: effectiveCfg,
providerConfig: config.providerConfigs[provider.id] ?? {},
- timeoutMs: config.timeoutMs,
+ timeoutMs: resolveSpeechProviderTimeoutMs({ config, provider }),
})
) {
return provider.id;
@@ -861,7 +884,7 @@ export function isTtsProviderConfigured(
resolvedProvider.isConfigured({
cfg: effectiveCfg,
providerConfig: getResolvedSpeechProviderConfig(config, resolvedProvider.id, effectiveCfg),
- timeoutMs: config.timeoutMs,
+ timeoutMs: resolveSpeechProviderTimeoutMs({ config, provider: resolvedProvider }),
}) ?? false
);
}
@@ -953,7 +976,10 @@ function resolveReadySpeechProvider(params: {
!resolvedProvider.isConfigured({
cfg: params.cfg,
providerConfig: merged.providerConfig,
- timeoutMs: params.config.timeoutMs,
+ timeoutMs: resolveSpeechProviderTimeoutMs({
+ config: params.config,
+ provider: resolvedProvider,
+ }),
})
) {
return {
@@ -1235,7 +1261,6 @@ export async function synthesizeSpeech(params: {
}
const { cfg, config, persona, providers } = setup;
- const timeoutMs = params.timeoutMs ?? config.timeoutMs;
const target = resolveTtsSynthesisTarget(params.channel);
const errors: string[] = [];
@@ -1271,6 +1296,11 @@ export async function synthesizeSpeech(params: {
logVerbose(`TTS: provider ${provider} skipped (${resolvedProvider.message})`);
continue;
}
+ const timeoutMs = resolveSpeechProviderTimeoutMs({
+ timeoutMs: params.timeoutMs,
+ config,
+ provider: resolvedProvider.provider,
+ });
const prepared = await prepareSpeechSynthesis({
provider: resolvedProvider.provider,
text: params.text,
@@ -1375,7 +1405,6 @@ export async function streamSpeech(params: {
}
const { cfg, config, persona, providers } = setup;
- const timeoutMs = params.timeoutMs ?? config.timeoutMs;
const target = resolveTtsSynthesisTarget(params.channel);
const errors: string[] = [];
const attemptedProviders: string[] = [];
@@ -1424,6 +1453,11 @@ export async function streamSpeech(params: {
logVerbose(`TTS stream: provider ${provider} skipped (${message})`);
continue;
}
+ const timeoutMs = resolveSpeechProviderTimeoutMs({
+ timeoutMs: params.timeoutMs,
+ config,
+ provider: resolvedProvider.provider,
+ });
const prepared = await prepareSpeechSynthesis({
provider: resolvedProvider.provider,
text: params.text,
@@ -1578,6 +1612,10 @@ export async function textToSpeechTelephony(params: {
logVerbose(`TTS telephony: provider ${provider} skipped (${resolvedProvider.message})`);
continue;
}
+ const timeoutMs = resolveSpeechProviderTimeoutMs({
+ config,
+ provider: resolvedProvider.provider,
+ });
const synthesizeTelephony = resolvedProvider.provider.synthesizeTelephony as NonNullable<
typeof resolvedProvider.provider.synthesizeTelephony
>;
@@ -1590,14 +1628,14 @@ export async function textToSpeechTelephony(params: {
persona: resolvedProvider.synthesisPersona,
personaProviderConfig: resolvedProvider.personaProviderConfig,
target: "telephony",
- timeoutMs: config.timeoutMs,
+ timeoutMs,
});
const synthesis = await synthesizeTelephony({
text: prepared.text,
cfg,
providerConfig: prepared.providerConfig,
providerOverrides: prepared.providerOverrides,
- timeoutMs: config.timeoutMs,
+ timeoutMs,
});
const latencyMs = Date.now() - providerStart;
attempts.push({
diff --git a/extensions/xai/image-generation-provider.test.ts b/extensions/xai/image-generation-provider.test.ts
index 6a3cd7c13b42..281f9bdf56cf 100644
--- a/extensions/xai/image-generation-provider.test.ts
+++ b/extensions/xai/image-generation-provider.test.ts
@@ -176,12 +176,13 @@ describe("xai image generation provider", () => {
expect(httpParams?.baseUrl).toBe("https://custom.x.ai/v1");
const request = requirePostJsonCall();
expect(request.url).toContain("/images/generations");
- expect(request.timeoutMs).toBe(180_000);
+ expect(provider.defaultTimeoutMs).toBe(600_000);
+ expect(request.timeoutMs).toBe(600_000);
expect(request.body?.aspect_ratio).toBe("2:3");
expect(request.body?.resolution).toBe("2k");
expect(resolveProviderOperationTimeoutMsMock).toHaveBeenCalledWith(
expect.objectContaining({
- defaultTimeoutMs: 180_000,
+ defaultTimeoutMs: 600_000,
}),
);
});
diff --git a/extensions/xai/image-generation-provider.ts b/extensions/xai/image-generation-provider.ts
index e62f46b631d8..95f9dd552076 100644
--- a/extensions/xai/image-generation-provider.ts
+++ b/extensions/xai/image-generation-provider.ts
@@ -13,7 +13,7 @@ import {
} from "openclaw/plugin-sdk/string-coerce-runtime";
import { XAI_BASE_URL, XAI_DEFAULT_IMAGE_MODEL, XAI_IMAGE_MODELS } from "./model-definitions.js";
-const DEFAULT_TIMEOUT_MS = 180_000;
+const DEFAULT_TIMEOUT_MS = 600_000;
const XAI_SUPPORTED_ASPECT_RATIOS = ["1:1", "16:9", "9:16", "4:3", "3:4", "2:3", "3:2"] as const;
diff --git a/extensions/xai/model-definitions.ts b/extensions/xai/model-definitions.ts
index 3e48be24416a..8072dd34fc0c 100644
--- a/extensions/xai/model-definitions.ts
+++ b/extensions/xai/model-definitions.ts
@@ -54,6 +54,13 @@ const XAI_GROK_43_COST = {
cacheWrite: 0,
} satisfies XaiCost;
+const XAI_GROK_BUILD_COST = {
+ input: 1,
+ output: 2,
+ cacheRead: 0.2,
+ cacheWrite: 0,
+} satisfies XaiCost;
+
const XAI_CODE_FAST_COST = {
input: 0.2,
output: 1.5,
@@ -62,6 +69,14 @@ const XAI_CODE_FAST_COST = {
} satisfies XaiCost;
const XAI_MODEL_CATALOG = [
+ {
+ id: "grok-build-0.1",
+ name: "Grok Build 0.1",
+ reasoning: true,
+ input: ["text", "image"],
+ contextWindow: XAI_CODE_CONTEXT_WINDOW,
+ cost: XAI_GROK_BUILD_COST,
+ },
{
id: "grok-3",
name: "Grok 3",
@@ -179,33 +194,40 @@ const XAI_MODEL_CATALOG = [
maxTokens: 30_000,
cost: XAI_GROK_420_COST,
},
- {
- id: "grok-code-fast-1",
- name: "Grok Code Fast 1",
- reasoning: true,
- input: ["text"],
- contextWindow: XAI_CODE_CONTEXT_WINDOW,
- maxTokens: 10_000,
- cost: XAI_CODE_FAST_COST,
- },
] as const satisfies readonly XaiCatalogEntry[];
const XAI_SELECTABLE_MODEL_IDS = new Set([
+ "grok-build-0.1",
"grok-4.3",
"grok-4.20-beta-latest-reasoning",
"grok-4.20-beta-latest-non-reasoning",
]);
+const XAI_GROK_BUILD_ALIASES = new Set([
+ "grok-code-fast-1",
+ "grok-code-fast",
+ "grok-code-fast-1-0825",
+]);
+
const XAI_RETIRED_BUILTIN_MODEL_IDS = new Set(
XAI_MODEL_CATALOG.map((entry) => entry.id).filter((id) => !XAI_SELECTABLE_MODEL_IDS.has(id)),
);
function normalizeXaiCatalogModelId(modelId: string): string {
const lower = normalizeOptionalLowercaseString(modelId) ?? "";
- return lower.startsWith("xai/") ? lower.slice("xai/".length) : lower;
+ const unprefixed = lower.startsWith("xai/") ? lower.slice("xai/".length) : lower;
+ if (XAI_GROK_BUILD_ALIASES.has(unprefixed)) {
+ return "grok-build-0.1";
+ }
+ return unprefixed;
}
export function isRetiredXaiBuiltinModelId(modelId: string): boolean {
+ const lower = normalizeOptionalLowercaseString(modelId) ?? "";
+ const unprefixed = lower.startsWith("xai/") ? lower.slice("xai/".length) : lower;
+ if (XAI_GROK_BUILD_ALIASES.has(unprefixed)) {
+ return true;
+ }
return XAI_RETIRED_BUILTIN_MODEL_IDS.has(normalizeXaiCatalogModelId(modelId));
}
@@ -243,7 +265,7 @@ export function buildXaiCatalogModels(): ModelDefinitionConfig[] {
export function resolveXaiCatalogEntry(modelId: string) {
const trimmed = modelId.trim();
- const lower = normalizeOptionalLowercaseString(modelId) ?? "";
+ const lower = normalizeXaiCatalogModelId(modelId);
const exact = XAI_MODEL_CATALOG.find(
(entry) => normalizeOptionalLowercaseString(entry.id) === lower,
);
diff --git a/extensions/xai/model-id.test.ts b/extensions/xai/model-id.test.ts
index 84efd36631a1..fee5820532f6 100644
--- a/extensions/xai/model-id.test.ts
+++ b/extensions/xai/model-id.test.ts
@@ -11,7 +11,10 @@ describe("normalizeXaiModelId", () => {
);
});
- it("maps older fast and 4.20 ids to the current Pi-backed ids", () => {
+ it("maps older fast and 4.20 ids to the current canonical ids", () => {
+ expect(normalizeXaiModelId("grok-code-fast-1")).toBe("grok-build-0.1");
+ expect(normalizeXaiModelId("grok-code-fast")).toBe("grok-build-0.1");
+ expect(normalizeXaiModelId("grok-code-fast-1-0825")).toBe("grok-build-0.1");
expect(normalizeXaiModelId("grok-4-fast-reasoning")).toBe("grok-4-fast");
expect(normalizeXaiModelId("grok-4-1-fast-reasoning")).toBe("grok-4-1-fast");
expect(normalizeXaiModelId("grok-4.20-reasoning")).toBe("grok-4.20-beta-latest-reasoning");
diff --git a/extensions/xai/model-id.ts b/extensions/xai/model-id.ts
index 9bf95e28a329..42df0984f6f9 100644
--- a/extensions/xai/model-id.ts
+++ b/extensions/xai/model-id.ts
@@ -1,4 +1,7 @@
export function normalizeXaiModelId(id: string): string {
+ if (id === "grok-code-fast-1" || id === "grok-code-fast" || id === "grok-code-fast-1-0825") {
+ return "grok-build-0.1";
+ }
if (id === "grok-4-fast-reasoning") {
return "grok-4-fast";
}
diff --git a/extensions/xai/onboard.test.ts b/extensions/xai/onboard.test.ts
index e3b4906d7f46..6e945d3baa81 100644
--- a/extensions/xai/onboard.test.ts
+++ b/extensions/xai/onboard.test.ts
@@ -57,6 +57,7 @@ describe("xai onboard", () => {
expect(cfg.models?.providers?.xai?.apiKey).toBe("old-key");
expect(cfg.models?.providers?.xai?.models.map((m) => m.id)).toEqual([
"custom-model",
+ "grok-build-0.1",
"grok-4.3",
"grok-4.20-beta-latest-reasoning",
"grok-4.20-beta-latest-non-reasoning",
@@ -69,6 +70,7 @@ describe("xai onboard", () => {
expect(cfg.models?.providers?.xai?.baseUrl).toBe("https://api.x.ai/v1");
expect(cfg.models?.providers?.xai?.api).toBe("openai-responses");
expect(cfg.models?.providers?.xai?.models.map((m) => m.id)).toEqual([
+ "grok-build-0.1",
"grok-4.3",
"grok-4.20-beta-latest-reasoning",
"grok-4.20-beta-latest-non-reasoning",
diff --git a/extensions/xai/openclaw.plugin.json b/extensions/xai/openclaw.plugin.json
index a51b4bad36c0..0a5d6bf37a4a 100644
--- a/extensions/xai/openclaw.plugin.json
+++ b/extensions/xai/openclaw.plugin.json
@@ -10,6 +10,9 @@
"providers": {
"xai": {
"aliases": {
+ "grok-code-fast-1": "grok-build-0.1",
+ "grok-code-fast": "grok-build-0.1",
+ "grok-code-fast-1-0825": "grok-build-0.1",
"grok-4-fast-reasoning": "grok-4-fast",
"grok-4-1-fast-reasoning": "grok-4-1-fast",
"grok-4.20-experimental-beta-0304-reasoning": "grok-4.20-beta-latest-reasoning",
@@ -78,7 +81,7 @@
"uiHints": {
"webSearch.apiKey": {
"label": "Grok Search API Key",
- "help": "xAI API key for Grok web search (fallback: XAI_API_KEY env var).",
+ "help": "Optional xAI API key for Grok web search; xAI OAuth or XAI_API_KEY can satisfy it.",
"sensitive": true
},
"webSearch.model": {
diff --git a/extensions/xai/provider-models.ts b/extensions/xai/provider-models.ts
index 2258998db45e..a1123d78da5f 100644
--- a/extensions/xai/provider-models.ts
+++ b/extensions/xai/provider-models.ts
@@ -5,12 +5,14 @@ import type {
import { normalizeModelCompat } from "openclaw/plugin-sdk/provider-model-shared";
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { resolveXaiCatalogEntry, XAI_BASE_URL } from "./model-definitions.js";
+import { normalizeXaiModelId } from "./model-id.js";
import { applyXaiRuntimeModelCompat } from "./runtime-model-compat.js";
-const XAI_MODERN_MODEL_PREFIXES = ["grok-4.3", "grok-4.20"] as const;
+const XAI_MODERN_MODEL_PREFIXES = ["grok-build-0.1", "grok-4.3", "grok-4.20"] as const;
export function isModernXaiModel(modelId: string): boolean {
- const lower = normalizeOptionalLowercaseString(modelId) ?? "";
+ const normalized = normalizeXaiModelId(modelId.trim());
+ const lower = normalizeOptionalLowercaseString(normalized) ?? "";
if (!lower || lower.includes("multi-agent")) {
return false;
}
diff --git a/extensions/xai/src/web-search-provider.runtime.ts b/extensions/xai/src/web-search-provider.runtime.ts
index 57f4313430a7..b37945d402c0 100644
--- a/extensions/xai/src/web-search-provider.runtime.ts
+++ b/extensions/xai/src/web-search-provider.runtime.ts
@@ -1,3 +1,11 @@
+import { resolveDefaultAgentDir } from "openclaw/plugin-sdk/agent-runtime";
+import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
+import {
+ coerceSecretRef,
+ ensureAuthProfileStore,
+ listUsableProviderAuthProfileIds,
+} from "openclaw/plugin-sdk/provider-auth";
+import { resolveApiKeyForProvider } from "openclaw/plugin-sdk/provider-auth-runtime";
import {
DEFAULT_CACHE_TTL_MINUTES,
formatCliCommand,
@@ -30,6 +38,7 @@ const XAI_WEB_SEARCH_CACHE = new Map<
{ value: Record; insertedAt: number; expiresAt: number }
>();
const XAI_WEB_SEARCH_DEFAULT_TIMEOUT_SECONDS = 60;
+const XAI_PROVIDER_ID = "xai";
const X_SEARCH_MODEL_OPTIONS = [
{
@@ -61,7 +70,7 @@ export async function runXaiSearchProviderSetup(
await ctx.prompter.note(
[
"x_search lets your agent search X (formerly Twitter) posts via xAI.",
- "It reuses the same xAI API key you just configured for Grok web search.",
+ "It reuses the same xAI credential you configured for Grok web search.",
`You can change this later with ${formatCliCommand("openclaw configure --section web")}.`,
].join("\n"),
"X search",
@@ -73,7 +82,7 @@ export async function runXaiSearchProviderSetup(
{
value: "yes",
label: "Yes, enable x_search",
- hint: "Search X posts with the same xAI key",
+ hint: "Search X posts with the same xAI credential",
},
{
value: "skip",
@@ -179,6 +188,155 @@ function resolveXaiWebSearchCredential(searchConfig?: Record):
});
}
+function resolveConfiguredXaiWebSearchCredential(
+ searchConfig?: Record,
+): string | undefined {
+ return resolveWebSearchProviderCredential({
+ credentialValue: getScopedCredentialValue(searchConfig, "grok"),
+ path: "tools.web.search.grok.apiKey",
+ envVars: [],
+ });
+}
+
+function hasConfiguredXaiWebSearchCredentialRef(searchConfig?: Record): boolean {
+ return coerceSecretRef(getScopedCredentialValue(searchConfig, "grok")) !== null;
+}
+
+type XaiResolvedWebSearchAuth = {
+ apiKey: string;
+ mode?: "api-key" | "oauth" | "token" | "aws-sdk";
+ profileId?: string;
+};
+
+async function resolveXaiProviderAuthCredential(params: {
+ config?: Record;
+ agentDir?: string;
+ credentialPrecedence?: "profile-first" | "env-first";
+ forceRefresh?: boolean;
+ profileId?: string;
+}): Promise {
+ try {
+ const config = params.config as OpenClawConfig | undefined;
+ const agentDir =
+ params.agentDir?.trim() || (config ? resolveDefaultAgentDir(config) : undefined);
+ const resolved = await resolveApiKeyForProvider({
+ provider: XAI_PROVIDER_ID,
+ cfg: config,
+ ...(agentDir ? { agentDir } : {}),
+ ...(params.profileId
+ ? {
+ profileId: params.profileId,
+ lockedProfile: true,
+ }
+ : {}),
+ ...(params.forceRefresh ? { forceRefresh: true } : {}),
+ ...(params.credentialPrecedence ? { credentialPrecedence: params.credentialPrecedence } : {}),
+ });
+ const apiKey = typeof resolved.apiKey === "string" ? resolved.apiKey.trim() : "";
+ if (!apiKey) {
+ return undefined;
+ }
+ return {
+ apiKey,
+ mode: resolved.mode,
+ ...(resolved.profileId ? { profileId: resolved.profileId } : {}),
+ };
+ } catch {
+ return undefined;
+ }
+}
+
+async function resolveXaiProviderApiKeyProfileFallback(params: {
+ config?: Record;
+}): Promise {
+ const config = params.config as OpenClawConfig | undefined;
+ const usableProfiles = listUsableProviderAuthProfileIds({
+ cfg: config,
+ provider: XAI_PROVIDER_ID,
+ });
+ if (!usableProfiles.agentDir || usableProfiles.profileIds.length === 0) {
+ return undefined;
+ }
+
+ const store = ensureAuthProfileStore(usableProfiles.agentDir, {
+ allowKeychainPrompt: false,
+ });
+ for (const profileId of usableProfiles.profileIds) {
+ const profile = store.profiles[profileId];
+ if (!profile || profile.provider !== XAI_PROVIDER_ID || profile.type === "oauth") {
+ continue;
+ }
+ const resolved = await resolveXaiProviderAuthCredential({
+ agentDir: usableProfiles.agentDir,
+ config: params.config,
+ profileId,
+ });
+ if (resolved?.apiKey && resolved.mode !== "oauth") {
+ return resolved;
+ }
+ }
+
+ return undefined;
+}
+
+async function resolveXaiWebSearchAuth(
+ ctx: { config?: Record },
+ searchConfig?: Record,
+ options?: { forceRefresh?: boolean; profileId?: string },
+): Promise {
+ const providerAuth = await resolveXaiProviderAuthCredential({
+ config: ctx.config,
+ forceRefresh: options?.forceRefresh,
+ profileId: options?.profileId,
+ });
+ if (providerAuth?.mode === "oauth") {
+ return providerAuth;
+ }
+
+ const configured = resolveConfiguredXaiWebSearchCredential(searchConfig);
+ if (configured) {
+ return {
+ apiKey: configured,
+ mode: "api-key",
+ };
+ }
+ if (hasConfiguredXaiWebSearchCredentialRef(searchConfig)) {
+ return undefined;
+ }
+
+ return providerAuth;
+}
+
+async function resolveXaiWebSearchApiKeyFallback(
+ ctx: { config?: Record },
+ searchConfig?: Record,
+): Promise {
+ const configured = resolveConfiguredXaiWebSearchCredential(searchConfig);
+ if (configured) {
+ return {
+ apiKey: configured,
+ mode: "api-key",
+ };
+ }
+ if (hasConfiguredXaiWebSearchCredentialRef(searchConfig)) {
+ return undefined;
+ }
+
+ const providerAuth = await resolveXaiProviderAuthCredential({
+ config: ctx.config,
+ credentialPrecedence: "env-first",
+ });
+ if (providerAuth?.apiKey && providerAuth.mode !== "oauth") {
+ return providerAuth;
+ }
+
+ return await resolveXaiProviderApiKeyProfileFallback({ config: ctx.config });
+}
+
+function isXaiUnauthorizedError(error: unknown): boolean {
+ return error instanceof Error && error.message.includes("xAI API error (401)");
+}
+
function resolveXaiWebSearchTimeoutSeconds(searchConfig?: Record): number {
return resolveTimeoutSeconds(
searchConfig?.timeoutSeconds,
@@ -191,13 +349,13 @@ export async function executeXaiWebSearchProviderTool(
args: Record,
): Promise> {
const searchConfig = resolveXaiToolSearchConfig(ctx);
- const apiKey = resolveXaiWebSearchCredential(searchConfig);
+ const auth = await resolveXaiWebSearchAuth(ctx, searchConfig);
- if (!apiKey) {
+ if (!auth) {
return {
error: "missing_xai_api_key",
message:
- "web_search (grok) needs xAI credentials. Run `openclaw onboard --auth-choice xai-oauth` to sign in with Grok, run `openclaw onboard --auth-choice xai-api-key`, set `XAI_API_KEY` in the Gateway environment, or configure `plugins.entries.xai.config.webSearch.apiKey`. If you do not want to configure a search API key, use web_fetch for a specific URL or the browser tool for interactive pages.",
+ "web_search (grok) needs xAI credentials. Run `openclaw onboard --auth-choice xai-oauth` to sign in with Grok, run `openclaw onboard --auth-choice xai-api-key`, set `XAI_API_KEY` in the Gateway environment, or configure `plugins.entries.xai.config.webSearch.apiKey`. If you do not want to configure search credentials, use web_fetch for a specific URL or the browser tool for interactive pages.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
@@ -205,21 +363,49 @@ export async function executeXaiWebSearchProviderTool(
const query = readStringParam(args, "query", { required: true });
void readNumberParam(args, "count", { integer: true });
- return await runXaiWebSearch({
+ const request = {
query,
model: resolveXaiWebSearchModel(searchConfig),
endpoint: resolveXaiWebSearchEndpoint(searchConfig),
- apiKey,
timeoutSeconds: resolveXaiWebSearchTimeoutSeconds(searchConfig),
inlineCitations: resolveXaiInlineCitations(searchConfig),
cacheTtlMs: resolveCacheTtlMs(searchConfig?.cacheTtlMinutes, DEFAULT_CACHE_TTL_MINUTES),
- });
+ };
+ try {
+ return await runXaiWebSearch({
+ ...request,
+ apiKey: auth.apiKey,
+ });
+ } catch (error) {
+ if (auth.mode !== "oauth" || !auth.profileId || !isXaiUnauthorizedError(error)) {
+ throw error;
+ }
+ const refreshed = await resolveXaiWebSearchAuth(ctx, searchConfig, {
+ forceRefresh: true,
+ profileId: auth.profileId,
+ });
+ if (refreshed?.apiKey && refreshed.apiKey !== auth.apiKey) {
+ return await runXaiWebSearch({
+ ...request,
+ apiKey: refreshed.apiKey,
+ });
+ }
+ const fallback = await resolveXaiWebSearchApiKeyFallback(ctx, searchConfig);
+ if (!fallback?.apiKey || fallback.apiKey === auth.apiKey) {
+ throw error;
+ }
+ return await runXaiWebSearch({
+ ...request,
+ apiKey: fallback.apiKey,
+ });
+ }
}
export const testing = {
buildXaiWebSearchPayload,
extractXaiWebSearchContent,
resolveXaiToolSearchConfig,
+ resolveXaiWebSearchAuth,
resolveXaiInlineCitations,
resolveXaiWebSearchCredential,
resolveXaiWebSearchEndpoint,
diff --git a/extensions/xai/video-generation-provider.test.ts b/extensions/xai/video-generation-provider.test.ts
index 839042ac6503..b468b25f798b 100644
--- a/extensions/xai/video-generation-provider.test.ts
+++ b/extensions/xai/video-generation-provider.test.ts
@@ -99,7 +99,8 @@ describe("xai video generation provider", () => {
const pollRequest = requireFetchInitCall(0);
expect(pollRequest.url).toBe("https://api.x.ai/v1/videos/req_123");
expect(pollRequest.init?.method).toBe("GET");
- expect(pollRequest.timeoutMs).toBe(120000);
+ expect(provider.defaultTimeoutMs).toBe(600_000);
+ expect(pollRequest.timeoutMs).toBe(600_000);
expect(result.videos[0]?.mimeType).toBe("video/webm");
expect(result.videos[0]?.fileName).toBe("video-1.webm");
expect(result.metadata?.requestId).toBe("req_123");
diff --git a/extensions/xai/video-generation-provider.ts b/extensions/xai/video-generation-provider.ts
index 3c0c7a46ff59..ccdae8c21ec3 100644
--- a/extensions/xai/video-generation-provider.ts
+++ b/extensions/xai/video-generation-provider.ts
@@ -22,7 +22,7 @@ import type {
const DEFAULT_XAI_VIDEO_BASE_URL = "https://api.x.ai/v1";
const DEFAULT_XAI_VIDEO_MODEL = "grok-imagine-video";
-const DEFAULT_TIMEOUT_MS = 120_000;
+const DEFAULT_TIMEOUT_MS = 600_000;
const POLL_INTERVAL_MS = 5_000;
const MAX_POLL_ATTEMPTS = 120;
const XAI_VIDEO_ASPECT_RATIOS = new Set(["1:1", "16:9", "9:16", "4:3", "3:4", "3:2", "2:3"]);
@@ -369,6 +369,7 @@ export function buildXaiVideoGenerationProvider(): VideoGenerationProvider {
id: "xai",
label: "xAI",
defaultModel: DEFAULT_XAI_VIDEO_MODEL,
+ defaultTimeoutMs: DEFAULT_TIMEOUT_MS,
models: [DEFAULT_XAI_VIDEO_MODEL],
isConfigured: ({ agentDir }) =>
isProviderApiKeyConfigured({
diff --git a/extensions/xai/web-search-contract-api.ts b/extensions/xai/web-search-contract-api.ts
index 30e06779115f..0950444b725e 100644
--- a/extensions/xai/web-search-contract-api.ts
+++ b/extensions/xai/web-search-contract-api.ts
@@ -9,10 +9,11 @@ export function createXaiWebSearchProvider(): WebSearchProviderPlugin {
return {
id: "grok",
label: "Grok (xAI)",
- hint: "Requires xAI API key · xAI web-grounded responses",
+ hint: "Uses xAI OAuth or API key · xAI web-grounded responses",
onboardingScopes: ["text-inference"],
credentialLabel: "xAI API key",
envVars: ["XAI_API_KEY"],
+ authProviderId: "xai",
placeholder: "xai-...",
signupUrl: "https://console.x.ai/",
docsUrl: "https://docs.openclaw.ai/tools/web",
diff --git a/extensions/xai/web-search.test.ts b/extensions/xai/web-search.test.ts
index 6ddab3473f3a..1fabe4eccbfd 100644
--- a/extensions/xai/web-search.test.ts
+++ b/extensions/xai/web-search.test.ts
@@ -8,8 +8,36 @@ import { isModernXaiModel, resolveXaiForwardCompatModel } from "./provider-model
import { resolveFallbackXaiAuth } from "./src/tool-auth-shared.js";
import { wrapXaiWebSearchError } from "./src/web-search-shared.js";
import { testing } from "./test-api.js";
+import { createXaiWebSearchProvider as createXaiWebSearchContractProvider } from "./web-search-contract-api.js";
import { createXaiWebSearchProvider } from "./web-search.js";
+const providerAuthRuntimeMocks = vi.hoisted(() => ({
+ resolveApiKeyForProvider: vi.fn(),
+}));
+
+const providerAuthMocks = vi.hoisted(() => ({
+ ensureAuthProfileStore: vi.fn(),
+ listUsableProviderAuthProfileIds: vi.fn(() => ({ agentDir: "", profileIds: [] as string[] })),
+}));
+
+vi.mock("openclaw/plugin-sdk/provider-auth", async (importOriginal) => {
+ const original = await importOriginal();
+ return {
+ ...original,
+ ensureAuthProfileStore: providerAuthMocks.ensureAuthProfileStore,
+ listUsableProviderAuthProfileIds: providerAuthMocks.listUsableProviderAuthProfileIds,
+ };
+});
+
+vi.mock("openclaw/plugin-sdk/provider-auth-runtime", async (importOriginal) => {
+ const original =
+ await importOriginal();
+ return {
+ ...original,
+ resolveApiKeyForProvider: providerAuthRuntimeMocks.resolveApiKeyForProvider,
+ };
+});
+
vi.mock("openclaw/plugin-sdk/provider-web-search", async (importOriginal) => {
const original = await importOriginal();
return {
@@ -33,6 +61,13 @@ vi.mock("openclaw/plugin-sdk/provider-web-search", async (importOriginal) => {
},
body: JSON.stringify(params.body),
});
+ if (!response.ok) {
+ const detail =
+ typeof response.text === "function"
+ ? await response.text()
+ : response.statusText || String(response.status);
+ throw new Error(`xAI API error (${response.status}): ${detail || response.statusText}`);
+ }
return await parseResponse(response);
},
};
@@ -75,6 +110,26 @@ function firstFetchUrl(mockFetch: ReturnType) {
return String(url);
}
+function fetchCallHeader(
+ mockFetch: { mock: { calls: unknown[][] } },
+ index: number,
+ name: string,
+): string | undefined {
+ const init = mockFetch.mock.calls[index]?.[1] as RequestInit | undefined;
+ const headers = init?.headers;
+ if (!headers) {
+ return undefined;
+ }
+ if (headers instanceof Headers) {
+ return headers.get(name) ?? undefined;
+ }
+ if (Array.isArray(headers)) {
+ const lowerName = name.toLowerCase();
+ return headers.find(([key]) => key.toLowerCase() === lowerName)?.[1];
+ }
+ return (headers as Record)[name];
+}
+
function expectCatalogEntry(
modelId: string,
expected: {
@@ -107,9 +162,21 @@ function expectCatalogEntry(
afterEach(() => {
vi.restoreAllMocks();
+ providerAuthMocks.ensureAuthProfileStore.mockReset();
+ providerAuthMocks.listUsableProviderAuthProfileIds.mockReset();
+ providerAuthMocks.listUsableProviderAuthProfileIds.mockReturnValue({
+ agentDir: "",
+ profileIds: [],
+ });
+ providerAuthRuntimeMocks.resolveApiKeyForProvider.mockReset();
});
describe("xai web search config resolution", () => {
+ it("advertises xAI auth profiles in runtime and setup contracts", () => {
+ expect(createXaiWebSearchProvider().authProviderId).toBe("xai");
+ expect(createXaiWebSearchContractProvider().authProviderId).toBe("xai");
+ });
+
it("prefers configured api keys and resolves grok scoped defaults", () => {
expect(resolveXaiWebSearchCredential({ grok: { apiKey: "xai-secret" } })).toBe("xai-secret");
expect(resolveXaiWebSearchModel()).toBe("grok-4-1-fast");
@@ -203,6 +270,295 @@ describe("xai web search config resolution", () => {
});
});
+ it("uses xAI OAuth auth before API-key fallback for web search", async () => {
+ providerAuthRuntimeMocks.resolveApiKeyForProvider.mockResolvedValue({
+ apiKey: "oauth-web-search-token",
+ source: "profile:xai:default",
+ mode: "oauth",
+ profileId: "xai:default",
+ });
+ const mockFetch = installXaiWebSearchFetch();
+ const provider = createXaiWebSearchProvider();
+ const tool = provider.createTool({
+ config: {
+ agents: {
+ list: [{ id: "main", default: true, agentDir: "/tmp/openclaw-xai-main-agent" }],
+ },
+ plugins: {
+ entries: {
+ xai: {
+ config: {
+ webSearch: {
+ apiKey: "configured-xai-key",
+ },
+ },
+ },
+ },
+ },
+ },
+ });
+ if (!tool) {
+ throw new Error("Expected xAI web search tool");
+ }
+
+ await tool.execute({ query: "OpenClaw Grok OAuth web search" });
+
+ expect(providerAuthRuntimeMocks.resolveApiKeyForProvider).toHaveBeenCalledWith(
+ expect.objectContaining({
+ provider: "xai",
+ agentDir: "/tmp/openclaw-xai-main-agent",
+ }),
+ );
+ expect(fetchCallHeader(mockFetch, 0, "Authorization")).toBe("Bearer oauth-web-search-token");
+ });
+
+ it("refreshes xAI OAuth auth and retries web search after a 401", async () => {
+ providerAuthRuntimeMocks.resolveApiKeyForProvider
+ .mockResolvedValueOnce({
+ apiKey: "expired-oauth-token",
+ source: "profile:xai:default",
+ mode: "oauth",
+ profileId: "xai:default",
+ })
+ .mockResolvedValueOnce({
+ apiKey: "fresh-oauth-token",
+ source: "profile:xai:default",
+ mode: "oauth",
+ profileId: "xai:default",
+ });
+ const mockFetch = vi
+ .fn()
+ .mockResolvedValueOnce({
+ ok: false,
+ status: 401,
+ statusText: "Unauthorized",
+ text: () => Promise.resolve("expired"),
+ } as Response)
+ .mockResolvedValueOnce({
+ ok: true,
+ json: () =>
+ Promise.resolve({
+ output: [
+ {
+ type: "message",
+ content: [{ type: "output_text", text: "Fresh OAuth Grok answer" }],
+ },
+ ],
+ }),
+ } as Response);
+ global.fetch = withFetchPreconnect(mockFetch);
+ const provider = createXaiWebSearchProvider();
+ const tool = provider.createTool({
+ config: {
+ agents: {
+ list: [{ id: "main", default: true, agentDir: "/tmp/openclaw-xai-main-agent" }],
+ },
+ tools: {
+ web: {
+ search: {
+ provider: "grok",
+ },
+ },
+ },
+ },
+ });
+ if (!tool) {
+ throw new Error("Expected xAI web search tool");
+ }
+
+ const result = await tool.execute({ query: "OpenClaw Grok OAuth refresh test" });
+
+ expect(result.content).toContain("Fresh OAuth Grok answer");
+ expect(providerAuthRuntimeMocks.resolveApiKeyForProvider).toHaveBeenNthCalledWith(
+ 2,
+ expect.objectContaining({
+ provider: "xai",
+ agentDir: "/tmp/openclaw-xai-main-agent",
+ profileId: "xai:default",
+ lockedProfile: true,
+ forceRefresh: true,
+ }),
+ );
+ expect(fetchCallHeader(mockFetch, 0, "Authorization")).toBe("Bearer expired-oauth-token");
+ expect(fetchCallHeader(mockFetch, 1, "Authorization")).toBe("Bearer fresh-oauth-token");
+ });
+
+ it("falls back to xAI API-key auth when OAuth refresh cannot recover", async () => {
+ providerAuthRuntimeMocks.resolveApiKeyForProvider
+ .mockResolvedValueOnce({
+ apiKey: "expired-oauth-token",
+ source: "profile:xai:default",
+ mode: "oauth",
+ profileId: "xai:default",
+ })
+ .mockResolvedValueOnce({
+ apiKey: "expired-oauth-token",
+ source: "profile:xai:default",
+ mode: "oauth",
+ profileId: "xai:default",
+ })
+ .mockResolvedValueOnce({
+ apiKey: "xai-env-fallback-key",
+ source: "XAI_API_KEY",
+ mode: "api-key",
+ });
+ const mockFetch = vi
+ .fn()
+ .mockResolvedValueOnce({
+ ok: false,
+ status: 401,
+ statusText: "Unauthorized",
+ text: () => Promise.resolve("revoked"),
+ } as Response)
+ .mockResolvedValueOnce({
+ ok: true,
+ json: () =>
+ Promise.resolve({
+ output: [
+ {
+ type: "message",
+ content: [{ type: "output_text", text: "API key fallback Grok answer" }],
+ },
+ ],
+ }),
+ } as Response);
+ global.fetch = withFetchPreconnect(mockFetch);
+ const provider = createXaiWebSearchProvider();
+ const tool = provider.createTool({
+ config: {
+ agents: {
+ list: [{ id: "main", default: true, agentDir: "/tmp/openclaw-xai-main-agent" }],
+ },
+ tools: {
+ web: {
+ search: {
+ provider: "grok",
+ },
+ },
+ },
+ },
+ });
+ if (!tool) {
+ throw new Error("Expected xAI web search tool");
+ }
+
+ const result = await tool.execute({ query: "OpenClaw Grok API fallback test" });
+
+ expect(result.content).toContain("API key fallback Grok answer");
+ expect(providerAuthRuntimeMocks.resolveApiKeyForProvider).toHaveBeenNthCalledWith(
+ 3,
+ expect.objectContaining({
+ provider: "xai",
+ agentDir: "/tmp/openclaw-xai-main-agent",
+ credentialPrecedence: "env-first",
+ }),
+ );
+ expect(fetchCallHeader(mockFetch, 1, "Authorization")).toBe("Bearer xai-env-fallback-key");
+ });
+
+ it("falls back to an xAI API-key auth profile when stale OAuth remains first", async () => {
+ providerAuthRuntimeMocks.resolveApiKeyForProvider
+ .mockResolvedValueOnce({
+ apiKey: "expired-oauth-token",
+ source: "profile:xai:default",
+ mode: "oauth",
+ profileId: "xai:default",
+ })
+ .mockResolvedValueOnce({
+ apiKey: "expired-oauth-token",
+ source: "profile:xai:default",
+ mode: "oauth",
+ profileId: "xai:default",
+ })
+ .mockResolvedValueOnce({
+ apiKey: "expired-oauth-token",
+ source: "profile:xai:default",
+ mode: "oauth",
+ profileId: "xai:default",
+ })
+ .mockResolvedValueOnce({
+ apiKey: "xai-profile-api-key",
+ source: "profile:xai:key",
+ mode: "api-key",
+ profileId: "xai:key",
+ });
+ providerAuthMocks.listUsableProviderAuthProfileIds.mockReturnValue({
+ agentDir: "/tmp/openclaw-xai-main-agent",
+ profileIds: ["xai:default", "xai:key"],
+ });
+ providerAuthMocks.ensureAuthProfileStore.mockReturnValue({
+ version: 1,
+ active: "xai:default",
+ profiles: {
+ "xai:default": {
+ provider: "xai",
+ type: "oauth",
+ access: "expired-oauth-token",
+ refresh: "refresh-oauth-token",
+ expires: Date.now() + 3_600_000,
+ },
+ "xai:key": {
+ provider: "xai",
+ type: "api_key",
+ keyRef: { source: "env", id: "XAI_API_KEY" },
+ },
+ },
+ });
+ const mockFetch = vi
+ .fn()
+ .mockResolvedValueOnce({
+ ok: false,
+ status: 401,
+ statusText: "Unauthorized",
+ text: () => Promise.resolve("revoked"),
+ } as Response)
+ .mockResolvedValueOnce({
+ ok: true,
+ json: () =>
+ Promise.resolve({
+ output: [
+ {
+ type: "message",
+ content: [{ type: "output_text", text: "Profile API key Grok answer" }],
+ },
+ ],
+ }),
+ } as Response);
+ global.fetch = withFetchPreconnect(mockFetch);
+ const provider = createXaiWebSearchProvider();
+ const tool = provider.createTool({
+ config: {
+ agents: {
+ list: [{ id: "main", default: true, agentDir: "/tmp/openclaw-xai-main-agent" }],
+ },
+ tools: {
+ web: {
+ search: {
+ provider: "grok",
+ },
+ },
+ },
+ },
+ });
+ if (!tool) {
+ throw new Error("Expected xAI web search tool");
+ }
+
+ const result = await tool.execute({ query: "OpenClaw Grok profile fallback test" });
+
+ expect(result.content).toContain("Profile API key Grok answer");
+ expect(providerAuthRuntimeMocks.resolveApiKeyForProvider).toHaveBeenNthCalledWith(
+ 4,
+ expect.objectContaining({
+ provider: "xai",
+ agentDir: "/tmp/openclaw-xai-main-agent",
+ profileId: "xai:key",
+ lockedProfile: true,
+ }),
+ );
+ expect(fetchCallHeader(mockFetch, 1, "Authorization")).toBe("Bearer xai-profile-api-key");
+ });
+
it("offers plugin-owned xSearch setup after Grok is selected", async () => {
const provider = createXaiWebSearchProvider();
const select = vi.fn().mockResolvedValueOnce("yes").mockResolvedValueOnce("grok-4-1-fast");
@@ -576,6 +932,7 @@ describe("xai web search response parsing", () => {
describe("xai provider models", () => {
it("publishes only current selectable chat models newest first", () => {
expect(buildXaiCatalogModels().map((model) => model.id)).toEqual([
+ "grok-build-0.1",
"grok-4.3",
"grok-4.20-beta-latest-reasoning",
"grok-4.20-beta-latest-non-reasoning",
@@ -593,7 +950,7 @@ describe("xai provider models", () => {
});
});
- it("keeps retired Grok fast and code slugs resolving for compatibility", () => {
+ it("keeps retired Grok fast slugs resolving for compatibility", () => {
expectCatalogEntry("grok-4-1-fast", {
id: "grok-4-1-fast",
reasoning: true,
@@ -601,12 +958,20 @@ describe("xai provider models", () => {
contextWindow: 2_000_000,
maxTokens: 30_000,
});
- expectCatalogEntry("grok-code-fast-1", {
- id: "grok-code-fast-1",
+ });
+
+ it("resolves Grok Build and its official code aliases", () => {
+ const expected = {
+ id: "grok-build-0.1",
reasoning: true,
+ input: ["text", "image"],
contextWindow: 256_000,
- maxTokens: 10_000,
- });
+ cost: { input: 1, output: 2, cacheRead: 0.2, cacheWrite: 0 },
+ };
+ expectCatalogEntry("grok-build-0.1", expected);
+ expectCatalogEntry("grok-code-fast-1", expected);
+ expectCatalogEntry("grok-code-fast", expected);
+ expectCatalogEntry("grok-code-fast-1-0825", expected);
});
it("publishes Grok 4.20 reasoning and non-reasoning models", () => {
@@ -655,8 +1020,9 @@ describe("xai provider models", () => {
it("marks current Grok families as modern while excluding multi-agent ids", () => {
expect(isModernXaiModel("grok-4.3")).toBe(true);
+ expect(isModernXaiModel("grok-build-0.1")).toBe(true);
expect(isModernXaiModel("grok-4.20-beta-latest-reasoning")).toBe(true);
- expect(isModernXaiModel("grok-code-fast-1")).toBe(false);
+ expect(isModernXaiModel("grok-code-fast-1")).toBe(true);
expect(isModernXaiModel("grok-3-mini-fast")).toBe(false);
expect(isModernXaiModel("grok-4.20-multi-agent-experimental-beta-0304")).toBe(false);
});
diff --git a/extensions/xai/web-search.ts b/extensions/xai/web-search.ts
index e57a97863c33..a3c6524e7955 100644
--- a/extensions/xai/web-search.ts
+++ b/extensions/xai/web-search.ts
@@ -39,10 +39,11 @@ export function createXaiWebSearchProvider(): WebSearchProviderPlugin {
return {
id: "grok",
label: "Grok (xAI)",
- hint: "Requires xAI API key · xAI web-grounded responses",
+ hint: "Uses xAI OAuth or API key · xAI web-grounded responses",
onboardingScopes: ["text-inference"],
credentialLabel: "xAI API key",
envVars: ["XAI_API_KEY"],
+ authProviderId: "xai",
placeholder: "xai-...",
signupUrl: "https://console.x.ai/",
docsUrl: "https://docs.openclaw.ai/tools/web",
diff --git a/src/agents/model-auth.profiles.test.ts b/src/agents/model-auth.profiles.test.ts
index 0477bbddddcf..9455f3ac81b2 100644
--- a/src/agents/model-auth.profiles.test.ts
+++ b/src/agents/model-auth.profiles.test.ts
@@ -417,6 +417,94 @@ describe("getApiKeyForModel", () => {
);
});
+ it("uses the config default agent dir when resolving provider profiles", async () => {
+ await withOpenClawTestState(
+ {
+ layout: "state-only",
+ prefix: "openclaw-auth-agent-dir-",
+ agentEnv: "clear",
+ env: {
+ XAI_API_KEY: undefined,
+ },
+ },
+ async (state) => {
+ await state.writeAuthProfiles(
+ {
+ version: 1,
+ profiles: {
+ "xai:default": {
+ type: "api_key",
+ provider: "xai",
+ key: "process-default-key",
+ },
+ },
+ },
+ "main",
+ );
+ await state.writeAuthProfiles(
+ {
+ version: 1,
+ profiles: {
+ "xai:default": {
+ type: "api_key",
+ provider: "xai",
+ key: "configured-agent-key",
+ },
+ },
+ },
+ "configured",
+ );
+
+ const cfg: OpenClawConfig = {
+ agents: {
+ list: [
+ {
+ id: "configured",
+ default: true,
+ agentDir: state.agentDir("configured"),
+ },
+ ],
+ },
+ };
+
+ const resolved = await resolveApiKeyForProvider({ provider: "xai", cfg });
+ expect(resolved.apiKey).toBe("configured-agent-key");
+ expect(resolved.source).toBe("profile:xai:default");
+ },
+ );
+ });
+
+ it("reports the config default agent dir when provider auth is missing", async () => {
+ await withOpenClawTestState(
+ {
+ layout: "state-only",
+ prefix: "openclaw-auth-missing-agent-dir-",
+ agentEnv: "clear",
+ env: {
+ XAI_API_KEY: undefined,
+ },
+ },
+ async (state) => {
+ const configuredAgentDir = state.agentDir("configured");
+ const cfg: OpenClawConfig = {
+ agents: {
+ list: [
+ {
+ id: "configured",
+ default: true,
+ agentDir: configuredAgentDir,
+ },
+ ],
+ },
+ };
+
+ await expect(resolveApiKeyForProvider({ provider: "xai", cfg })).rejects.toThrow(
+ `agentDir: ${configuredAgentDir}`,
+ );
+ },
+ );
+ });
+
it("suggests openai-codex when only Codex OAuth is configured", async () => {
await withOpenClawTestState(
{
diff --git a/src/agents/model-auth.ts b/src/agents/model-auth.ts
index 9ae85482f3f8..c471a026f809 100644
--- a/src/agents/model-auth.ts
+++ b/src/agents/model-auth.ts
@@ -19,6 +19,7 @@ import {
normalizeOptionalLowercaseString,
} from "../shared/string-coerce.js";
import { normalizeOptionalSecretInput } from "../utils/normalize-secret-input.js";
+import { resolveDefaultAgentDir } from "./agent-scope-config.js";
import {
type AuthProfileCredential,
type AuthProfileStore,
@@ -548,9 +549,11 @@ export async function resolveApiKeyForProvider(params: {
/** When true, treat profileId as a user-locked selection that must not be
* silently overridden by env/config credentials. */
lockedProfile?: boolean;
+ forceRefresh?: boolean;
credentialPrecedence?: ProviderCredentialPrecedence;
}): Promise {
const { provider, cfg, profileId, preferredProfile } = params;
+ const agentDir = params.agentDir?.trim() || (cfg ? resolveDefaultAgentDir(cfg) : undefined);
let scopedStore: AuthProfileStore | undefined = params.store;
if (profileId) {
@@ -561,7 +564,7 @@ export async function resolveApiKeyForProvider(params: {
const store =
params.store ??
resolveScopedAuthProfileStore({
- agentDir: params.agentDir,
+ agentDir,
cfg,
provider,
profileId,
@@ -571,7 +574,8 @@ export async function resolveApiKeyForProvider(params: {
cfg,
store,
profileId,
- agentDir: params.agentDir,
+ agentDir,
+ forceRefresh: params.forceRefresh,
});
if (!resolved) {
throw new Error(`No credentials found for profile "${profileId}".`);
@@ -605,7 +609,7 @@ export async function resolveApiKeyForProvider(params: {
if (cfg?.auth?.profiles || cfg?.auth?.order) {
scopedStore ??= resolveScopedAuthProfileStore({
- agentDir: params.agentDir,
+ agentDir,
cfg,
provider,
preferredProfile,
@@ -681,7 +685,7 @@ export async function resolveApiKeyForProvider(params: {
const store =
scopedStore ??
resolveScopedAuthProfileStore({
- agentDir: params.agentDir,
+ agentDir,
cfg,
provider,
preferredProfile,
@@ -707,7 +711,8 @@ export async function resolveApiKeyForProvider(params: {
cfg,
store,
profileId: candidate,
- agentDir: params.agentDir,
+ agentDir,
+ forceRefresh: params.forceRefresh,
});
if (resolved) {
const resolvedProfileId = resolved.profileId ?? candidate;
@@ -780,7 +785,7 @@ export async function resolveApiKeyForProvider(params: {
config: cfg,
context: {
config: cfg,
- agentDir: params.agentDir,
+ agentDir,
env: process.env,
provider,
listProfileIds: (providerId) => listProfilesForProvider(store, providerId),
@@ -791,7 +796,7 @@ export async function resolveApiKeyForProvider(params: {
}
}
- const authStorePath = resolveAuthStorePathForDisplay(params.agentDir);
+ const authStorePath = resolveAuthStorePathForDisplay(agentDir);
const resolvedAgentDir = path.dirname(authStorePath);
throw new Error(
[
diff --git a/src/agents/tools/model-config.helpers.ts b/src/agents/tools/model-config.helpers.ts
index 060f9b05379e..94468b38cc7b 100644
--- a/src/agents/tools/model-config.helpers.ts
+++ b/src/agents/tools/model-config.helpers.ts
@@ -8,10 +8,11 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js";
import {
externalCliDiscoveryForProviderAuth,
ensureAuthProfileStore,
+ ensureAuthProfileStoreWithoutExternalProfiles,
hasAnyAuthProfileStoreSource,
listProfilesForProvider,
} from "../auth-profiles.js";
-import type { AuthProfileStore } from "../auth-profiles/types.js";
+import type { AuthProfileCredential, AuthProfileStore } from "../auth-profiles/types.js";
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../defaults.js";
import { resolveEnvApiKey } from "../model-auth.js";
import { resolveConfiguredModelRef } from "../model-selection.js";
@@ -44,20 +45,38 @@ export function hasAuthForProvider(params: {
if (resolveEnvApiKey(params.provider)?.apiKey) {
return true;
}
- if (params.authStore) {
- return listProfilesForProvider(params.authStore, params.provider).length > 0;
+ return hasAuthProfileForProvider({ ...params, includeExternalCli: true });
+}
+
+export function hasAuthProfileForProvider(params: {
+ provider: string;
+ agentDir?: string;
+ authStore?: AuthProfileStore;
+ includeExternalCli?: boolean;
+ type?: AuthProfileCredential["type"];
+}): boolean {
+ let store = params.authStore;
+ if (!store) {
+ const agentDir = params.agentDir?.trim();
+ if (!agentDir) {
+ return false;
+ }
+ if (!hasAnyAuthProfileStoreSource(agentDir)) {
+ return false;
+ }
+ store = params.includeExternalCli
+ ? ensureAuthProfileStore(agentDir, {
+ externalCli: externalCliDiscoveryForProviderAuth({ provider: params.provider }),
+ })
+ : ensureAuthProfileStoreWithoutExternalProfiles(agentDir, {
+ allowKeychainPrompt: false,
+ });
}
- const agentDir = params.agentDir?.trim();
- if (!agentDir) {
- return false;
+ const profileIds = listProfilesForProvider(store, params.provider);
+ if (!params.type) {
+ return profileIds.length > 0;
}
- if (!hasAnyAuthProfileStoreSource(agentDir)) {
- return false;
- }
- const store = ensureAuthProfileStore(agentDir, {
- externalCli: externalCliDiscoveryForProviderAuth({ provider: params.provider }),
- });
- return listProfilesForProvider(store, params.provider).length > 0;
+ return profileIds.some((profileId) => store.profiles[profileId]?.type === params.type);
}
export function coerceToolModelConfig(model?: AgentToolModelConfig): ToolModelConfig {
diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts
index ed348a1114d5..27d2756e7979 100644
--- a/src/config/schema.help.ts
+++ b/src/config/schema.help.ts
@@ -1410,6 +1410,8 @@ export const FIELD_HELP: Record = {
"Default provider request timeout in milliseconds for image_generate calls. Per-call timeoutMs overrides this.",
"agents.defaults.videoGenerationModel.primary":
"Optional video-generation model (provider/model) used by the shared video generation capability.",
+ "agents.defaults.videoGenerationModel.timeoutMs":
+ "Default provider request timeout in milliseconds for video_generate calls. Per-call timeoutMs overrides this, and this value overrides provider-authored defaults.",
"agents.defaults.videoGenerationModel.fallbacks":
"Ordered fallback video-generation models (provider/model).",
"agents.defaults.musicGenerationModel.primary":
diff --git a/src/flows/search-setup.test.ts b/src/flows/search-setup.test.ts
index f858c3dffb7b..9b671ae46f63 100644
--- a/src/flows/search-setup.test.ts
+++ b/src/flows/search-setup.test.ts
@@ -4,6 +4,14 @@ import { createNonExitingRuntime } from "../runtime.js";
import { withEnvAsync } from "../test-utils/env.js";
import { runSearchSetupFlow } from "./search-setup.js";
+const authMocks = vi.hoisted(() => ({
+ hasAuthProfileForProvider: vi.fn((_params: { provider: string; type?: string }) => false),
+}));
+
+vi.mock("../agents/tools/model-config.helpers.js", () => ({
+ hasAuthProfileForProvider: authMocks.hasAuthProfileForProvider,
+}));
+
const mockGrokProvider = vi.hoisted(() => ({
id: "grok",
pluginId: "xai",
@@ -15,6 +23,7 @@ const mockGrokProvider = vi.hoisted(() => ({
placeholder: "xai-...",
signupUrl: "https://x.ai/api",
envVars: ["XAI_API_KEY"],
+ authProviderId: "xai",
onboardingScopes: ["text-inference"],
credentialPath: "plugins.entries.xai.config.webSearch.apiKey",
credentialNote: "Configure Grok web search prerequisites before entering the credential.",
@@ -158,6 +167,8 @@ function latestPluginInstallRequest(): {
describe("runSearchSetupFlow", () => {
beforeEach(() => {
ensureOnboardingPluginInstalled.mockClear();
+ authMocks.hasAuthProfileForProvider.mockReset();
+ authMocks.hasAuthProfileForProvider.mockReturnValue(false);
});
it("localizes setup copy for web search provider selection", async () => {
@@ -226,6 +237,40 @@ describe("runSearchSetupFlow", () => {
expect(xaiConfig?.xSearch?.model).toBe("grok-4-1-fast");
});
+ it("uses existing xAI OAuth for Grok web search without prompting for an API key", async () => {
+ authMocks.hasAuthProfileForProvider.mockImplementation(
+ ({ provider, type }) => provider === "xai" && (!type || type === "oauth"),
+ );
+ const select = vi.fn().mockResolvedValueOnce("grok").mockResolvedValueOnce("no");
+ const text = vi.fn(async () => {
+ throw new Error("API key prompt should not run when xAI OAuth is available");
+ });
+ const note = vi.fn(async () => {});
+ const prompter = createWizardPrompter({
+ note: note as never,
+ select: select as never,
+ text: text as never,
+ });
+
+ const next = await runSearchSetupFlow(
+ { plugins: { allow: ["xai"] } },
+ createNonExitingRuntime(),
+ prompter,
+ );
+
+ const xaiConfig = next.plugins?.entries?.xai?.config as
+ | { webSearch?: { apiKey?: string } }
+ | undefined;
+ expect(next.tools?.web?.search?.provider).toBe("grok");
+ expect(next.tools?.web?.search?.enabled).toBe(true);
+ expect(xaiConfig?.webSearch?.apiKey).toBeUndefined();
+ expect(text).not.toHaveBeenCalled();
+ expect(note).toHaveBeenCalledWith(
+ expect.stringContaining("existing xAI OAuth sign-in"),
+ "Web search",
+ );
+ });
+
it("shows provider credential notes before plaintext credential prompts", async () => {
const select = vi.fn().mockResolvedValueOnce("grok").mockResolvedValueOnce("no");
const text = vi.fn().mockResolvedValue("xai-test-key");
diff --git a/src/flows/search-setup.ts b/src/flows/search-setup.ts
index 18fe24873c08..9445a1266564 100644
--- a/src/flows/search-setup.ts
+++ b/src/flows/search-setup.ts
@@ -1,3 +1,5 @@
+import { resolveDefaultAgentDir } from "../agents/agent-scope-config.js";
+import { hasAuthProfileForProvider } from "../agents/tools/model-config.helpers.js";
import type { SecretInputMode } from "../commands/onboard-types.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import {
@@ -154,13 +156,29 @@ function providerNeedsCredential(
return entry.requiresCredential !== false;
}
+function formatAuthProviderLabel(providerId: string): string {
+ return providerId === "xai" ? "xAI" : providerId;
+}
+
function providerIsReady(
config: OpenClawConfig,
- entry: Pick,
+ entry: Pick<
+ PluginWebSearchProviderEntry,
+ "id" | "authProviderId" | "envVars" | "requiresCredential"
+ >,
): boolean {
if (!providerNeedsCredential(entry)) {
return true;
}
+ if (
+ entry.authProviderId &&
+ hasAuthProfileForProvider({
+ provider: entry.authProviderId,
+ agentDir: resolveDefaultAgentDir(config),
+ })
+ ) {
+ return true;
+ }
return hasExistingKey(config, entry.id) || hasKeyInEnv(entry);
}
@@ -460,9 +478,22 @@ export async function runSearchSetupFlow(
const existingKey = resolveExistingKey(config, choice);
const keyConfigured = hasExistingKey(config, choice);
const envAvailable = hasKeyInEnv(entry);
+ const agentDir = resolveDefaultAgentDir(config);
+ const authProviderId = entry.authProviderId;
+ const providerAuthProfileAvailable = authProviderId
+ ? hasAuthProfileForProvider({ provider: authProviderId, agentDir })
+ : false;
+ const oauthAuthProfileAvailable =
+ authProviderId && providerAuthProfileAvailable
+ ? hasAuthProfileForProvider({
+ provider: authProviderId,
+ agentDir,
+ type: "oauth",
+ })
+ : false;
const needsCredential = providerNeedsCredential(entry);
- if (opts?.quickstartDefaults && (keyConfigured || envAvailable)) {
+ if (opts?.quickstartDefaults && (providerAuthProfileAvailable || keyConfigured || envAvailable)) {
const result = existingKey
? applySearchKey(config, choice, existingKey)
: applySearchProviderSelection(config, choice);
@@ -499,6 +530,46 @@ export async function runSearchSetupFlow(
await prompter.note(entry.credentialNote, entry.label);
}
+ if (oauthAuthProfileAvailable && authProviderId) {
+ const authProviderLabel = formatAuthProviderLabel(authProviderId);
+ await prompter.note(
+ [
+ `${entry.label} can use your existing ${authProviderLabel} OAuth sign-in for web_search.`,
+ "No separate API key is required; API-key auth remains available as a fallback.",
+ `Docs: ${entry.docsUrl ?? WEB_SEARCH_DOCS_URL}`,
+ ].join("\n"),
+ "Web search",
+ );
+ return await finalizeSearchProviderSetup({
+ originalConfig: config,
+ nextConfig: applySearchProviderSelection(config, choice),
+ entry,
+ runtime,
+ prompter,
+ opts,
+ });
+ }
+
+ if (providerAuthProfileAvailable && authProviderId) {
+ const authProviderLabel = formatAuthProviderLabel(authProviderId);
+ await prompter.note(
+ [
+ `${entry.label} can use your existing ${authProviderLabel} auth profile for web_search.`,
+ "No separate web-search key is required; API-key auth remains available as a fallback.",
+ `Docs: ${entry.docsUrl ?? WEB_SEARCH_DOCS_URL}`,
+ ].join("\n"),
+ "Web search",
+ );
+ return await finalizeSearchProviderSetup({
+ originalConfig: config,
+ nextConfig: applySearchProviderSelection(config, choice),
+ entry,
+ runtime,
+ prompter,
+ opts,
+ });
+ }
+
const useSecretRefMode = opts?.secretInputMode === "ref"; // pragma: allowlist secret
if (useSecretRefMode) {
if (keyConfigured) {
diff --git a/src/image-generation/openai-compatible-image-provider.ts b/src/image-generation/openai-compatible-image-provider.ts
index d3d2ebac17d8..6199e850d15f 100644
--- a/src/image-generation/openai-compatible-image-provider.ts
+++ b/src/image-generation/openai-compatible-image-provider.ts
@@ -138,6 +138,9 @@ export function createOpenAiCompatibleImageGenerationProvider(
id: options.id,
label: options.label,
defaultModel: options.defaultModel,
+ ...(options.defaultTimeoutMs !== undefined
+ ? { defaultTimeoutMs: options.defaultTimeoutMs }
+ : {}),
models: [...options.models],
isConfigured: ({ agentDir }) =>
isProviderApiKeyConfigured({
diff --git a/src/image-generation/runtime.test.ts b/src/image-generation/runtime.test.ts
index 3041da0b45a1..e7e630e92e3f 100644
--- a/src/image-generation/runtime.test.ts
+++ b/src/image-generation/runtime.test.ts
@@ -180,6 +180,45 @@ describe("image-generation runtime", () => {
expect(seenTimeoutMs).toBe(180_000);
});
+ it("uses provider default image-generation timeout when the call and config omit timeoutMs", async () => {
+ let seenTimeoutMs: number | undefined;
+ const provider: ImageGenerationProvider = {
+ id: "image-plugin",
+ defaultTimeoutMs: 600_000,
+ capabilities: {
+ generate: {},
+ edit: { enabled: false },
+ },
+ async generateImage(req: { timeoutMs?: number }) {
+ seenTimeoutMs = req.timeoutMs;
+ return {
+ images: [
+ {
+ buffer: Buffer.from("png-bytes"),
+ mimeType: "image/png",
+ fileName: "sample.png",
+ },
+ ],
+ model: "img-v1",
+ };
+ },
+ };
+ providers = [provider];
+
+ await runGenerateImage({
+ cfg: {
+ agents: {
+ defaults: {
+ imageGenerationModel: { primary: "image-plugin/img-v1" },
+ },
+ },
+ } as OpenClawConfig,
+ prompt: "draw a cat",
+ });
+
+ expect(seenTimeoutMs).toBe(600_000);
+ });
+
it("auto-detects and falls through to another configured image-generation provider by default", async () => {
providers = [
{
diff --git a/src/image-generation/runtime.ts b/src/image-generation/runtime.ts
index bd8d91f94409..f26112134a30 100644
--- a/src/image-generation/runtime.ts
+++ b/src/image-generation/runtime.ts
@@ -8,6 +8,7 @@ import {
buildMediaGenerationNormalizationMetadata,
buildNoCapabilityModelConfiguredMessage,
resolveCapabilityModelCandidates,
+ resolveMediaProviderRequestTimeoutMs,
throwCapabilityGenerationFailure,
} from "../media-generation/runtime-shared.js";
import { getProviderEnvVars } from "../secrets/provider-env-vars.js";
@@ -55,7 +56,7 @@ export async function generateImage(
const getProvider = deps.getProvider ?? getImageGenerationProvider;
const listProviders = deps.listProviders ?? listImageGenerationProviders;
const logger = deps.log ?? log;
- const timeoutMs =
+ const requestedTimeoutMs =
params.timeoutMs ??
resolveAgentModelTimeoutMsValue(params.cfg.agents?.defaults?.imageGenerationModel);
const candidates = resolveCapabilityModelCandidates({
@@ -91,6 +92,10 @@ export async function generateImage(
}
try {
+ const timeoutMs = resolveMediaProviderRequestTimeoutMs({
+ timeoutMs: requestedTimeoutMs,
+ providerDefaultTimeoutMs: provider.defaultTimeoutMs,
+ });
const sanitized = resolveImageGenerationOverrides({
provider,
size: params.size,
diff --git a/src/image-generation/types.ts b/src/image-generation/types.ts
index 8c69fbd6395e..555d7fc3d073 100644
--- a/src/image-generation/types.ts
+++ b/src/image-generation/types.ts
@@ -127,6 +127,8 @@ export type ImageGenerationProvider = {
aliases?: string[];
label?: string;
defaultModel?: string;
+ /** Default provider operation timeout in milliseconds when caller/config omit timeoutMs. */
+ defaultTimeoutMs?: number;
models?: string[];
capabilities: ImageGenerationProviderCapabilities;
isConfigured?: (ctx: ImageGenerationProviderConfiguredContext) => boolean;
diff --git a/src/media-generation/runtime-shared.ts b/src/media-generation/runtime-shared.ts
index 76309c32d2c2..d640fad3a0ea 100644
--- a/src/media-generation/runtime-shared.ts
+++ b/src/media-generation/runtime-shared.ts
@@ -60,6 +60,21 @@ export function hasMediaNormalizationEntry 0
+ ? Math.floor(timeoutMs)
+ : undefined;
+}
+
+export function resolveMediaProviderRequestTimeoutMs(params: {
+ timeoutMs?: number;
+ providerDefaultTimeoutMs?: number;
+}): number | undefined {
+ return params.timeoutMs ?? resolveMediaProviderDefaultTimeoutMs(params.providerDefaultTimeoutMs);
+}
+
type CapabilityProviderCandidate = {
id: string;
aliases?: readonly string[];
diff --git a/src/plugin-sdk/video-generation.ts b/src/plugin-sdk/video-generation.ts
index 5845dfb7d3c5..b872f694950c 100644
--- a/src/plugin-sdk/video-generation.ts
+++ b/src/plugin-sdk/video-generation.ts
@@ -158,6 +158,8 @@ export type VideoGenerationProvider = {
aliases?: string[];
label?: string;
defaultModel?: string;
+ /** Default provider operation timeout in milliseconds when caller/config omit timeoutMs. */
+ defaultTimeoutMs?: number;
models?: string[];
capabilities: VideoGenerationProviderCapabilities;
isConfigured?: (ctx: VideoGenerationProviderConfiguredContext) => boolean;
diff --git a/src/plugins/types.ts b/src/plugins/types.ts
index dbf2656dc4d2..7bce79b3e4dc 100644
--- a/src/plugins/types.ts
+++ b/src/plugins/types.ts
@@ -1830,6 +1830,8 @@ export type SpeechProviderPlugin = {
label: string;
aliases?: string[];
autoSelectOrder?: number;
+ /** Default provider operation timeout in milliseconds when caller/config omit timeoutMs. */
+ defaultTimeoutMs?: number;
models?: readonly string[];
voices?: readonly string[];
resolveConfig?: (ctx: SpeechProviderResolveConfigContext) => SpeechProviderConfig;
diff --git a/src/plugins/web-provider-types.ts b/src/plugins/web-provider-types.ts
index e09a0306bbd5..8ff2f8bb8446 100644
--- a/src/plugins/web-provider-types.ts
+++ b/src/plugins/web-provider-types.ts
@@ -94,6 +94,8 @@ export type WebSearchProviderPlugin = {
requiresCredential?: boolean;
credentialLabel?: string;
envVars: string[];
+ /** Optional model-provider auth profile id that can satisfy this web provider without a tool-specific API key. */
+ authProviderId?: string;
placeholder: string;
signupUrl: string;
docsUrl?: string;
diff --git a/src/test-utils/web-provider-runtime.test-helpers.ts b/src/test-utils/web-provider-runtime.test-helpers.ts
index ca367478cc9a..a90b9afeb5f4 100644
--- a/src/test-utils/web-provider-runtime.test-helpers.ts
+++ b/src/test-utils/web-provider-runtime.test-helpers.ts
@@ -10,6 +10,7 @@ type CommonWebProviderTestParams = {
credentialPath: string;
autoDetectOrder?: number;
requiresCredential?: boolean;
+ authProviderId?: string;
getCredentialValue?: (config?: Record) => unknown;
getConfiguredCredentialValue?: (config?: OpenClawConfig) => unknown;
getConfiguredCredentialFallback?:
@@ -39,6 +40,7 @@ function createCommonProviderFields(params: CommonWebProviderTestParams) {
credentialPath: params.credentialPath,
autoDetectOrder: params.autoDetectOrder,
requiresCredential: params.requiresCredential,
+ authProviderId: params.authProviderId,
getCredentialValue: params.getCredentialValue ?? (() => undefined),
setCredentialValue: () => {},
getConfiguredCredentialValue: params.getConfiguredCredentialValue,
diff --git a/src/tts/tts-types.ts b/src/tts/tts-types.ts
index 8638a2a80675..ed74e0cb26ca 100644
--- a/src/tts/tts-types.ts
+++ b/src/tts/tts-types.ts
@@ -23,6 +23,7 @@ export type ResolvedTtsConfig = {
prefsPath?: string;
maxTextLength: number;
timeoutMs: number;
+ timeoutMsSource?: "config" | "default";
rawConfig?: TtsConfig;
sourceConfig?: OpenClawConfig;
};
diff --git a/src/video-generation/runtime.test.ts b/src/video-generation/runtime.test.ts
index 0fc0c4eed928..d25aadd809ac 100644
--- a/src/video-generation/runtime.test.ts
+++ b/src/video-generation/runtime.test.ts
@@ -147,6 +147,37 @@ describe("video-generation runtime", () => {
expect(seenTimeoutMs).toBe(300_000);
});
+ it("uses provider default video-generation timeout when the call and config omit timeoutMs", async () => {
+ let seenTimeoutMs: number | undefined;
+ providers = [
+ {
+ id: "video-plugin",
+ defaultTimeoutMs: 600_000,
+ capabilities: {},
+ async generateVideo(req: { timeoutMs?: number }) {
+ seenTimeoutMs = req.timeoutMs;
+ return {
+ videos: [{ buffer: Buffer.from("mp4-bytes"), mimeType: "video/mp4" }],
+ model: "vid-v1",
+ };
+ },
+ },
+ ];
+
+ await runGenerateVideo({
+ cfg: {
+ agents: {
+ defaults: {
+ videoGenerationModel: { primary: "video-plugin/vid-v1" },
+ },
+ },
+ } as OpenClawConfig,
+ prompt: "animate a cat",
+ });
+
+ expect(seenTimeoutMs).toBe(600_000);
+ });
+
it("does not list providers when explicit config disables auto provider fallback", async () => {
const provider: VideoGenerationProvider = {
id: "video-plugin",
diff --git a/src/video-generation/runtime.ts b/src/video-generation/runtime.ts
index 88bb4a8fffb8..0f13e55f3d90 100644
--- a/src/video-generation/runtime.ts
+++ b/src/video-generation/runtime.ts
@@ -7,6 +7,7 @@ import {
buildNoCapabilityModelConfiguredMessage,
recordCapabilityCandidateFailure,
resolveCapabilityModelCandidates,
+ resolveMediaProviderRequestTimeoutMs,
throwCapabilityGenerationFailure,
} from "../media-generation/runtime-shared.js";
import { getProviderEnvVars } from "../secrets/provider-env-vars.js";
@@ -117,7 +118,7 @@ export async function generateVideo(
const getProvider = deps.getProvider ?? getVideoGenerationProvider;
const listProviders = deps.listProviders ?? listVideoGenerationProviders;
const logger = deps.log ?? log;
- const timeoutMs =
+ const requestedTimeoutMs =
params.timeoutMs ??
resolveAgentModelTimeoutMsValue(params.cfg.agents?.defaults?.videoGenerationModel);
const candidates = resolveCapabilityModelCandidates({
@@ -159,6 +160,10 @@ export async function generateVideo(
lastError = new Error(error);
continue;
}
+ const timeoutMs = resolveMediaProviderRequestTimeoutMs({
+ timeoutMs: requestedTimeoutMs,
+ providerDefaultTimeoutMs: provider.defaultTimeoutMs,
+ });
const activeProvider = await resolveProviderWithModelCapabilities({
provider,
providerId: candidate.provider,
diff --git a/src/video-generation/types.ts b/src/video-generation/types.ts
index 7d5ad5a6c230..001c4161be33 100644
--- a/src/video-generation/types.ts
+++ b/src/video-generation/types.ts
@@ -160,6 +160,8 @@ export type VideoGenerationProvider = {
aliases?: string[];
label?: string;
defaultModel?: string;
+ /** Default provider operation timeout in milliseconds when caller/config omit timeoutMs. */
+ defaultTimeoutMs?: number;
models?: string[];
capabilities: VideoGenerationProviderCapabilities;
isConfigured?: (ctx: VideoGenerationProviderConfiguredContext) => boolean;
diff --git a/src/web-search/runtime.test.ts b/src/web-search/runtime.test.ts
index ec73fc058b53..f548226f77dd 100644
--- a/src/web-search/runtime.test.ts
+++ b/src/web-search/runtime.test.ts
@@ -1,3 +1,6 @@
+import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
+import os from "node:os";
+import path from "node:path";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import type { PluginWebSearchProviderEntry } from "../plugins/web-provider-types.js";
@@ -139,6 +142,7 @@ describe("web search runtime", () => {
let activateSecretsRuntimeSnapshot: typeof import("../secrets/runtime.js").activateSecretsRuntimeSnapshot;
let clearSecretsRuntimeSnapshot: typeof import("../secrets/runtime.js").clearSecretsRuntimeSnapshot;
let setRuntimeConfigSnapshot: typeof import("../config/config.js").setRuntimeConfigSnapshot;
+ const tempDirs: string[] = [];
beforeAll(async () => {
({ runWebSearch } = await import("./runtime.js"));
@@ -158,6 +162,9 @@ describe("web search runtime", () => {
afterEach(() => {
clearSecretsRuntimeSnapshot();
+ for (const tempDir of tempDirs.splice(0)) {
+ rmSync(tempDir, { recursive: true, force: true });
+ }
});
it("executes searches through the active plugin registry", async () => {
@@ -276,6 +283,53 @@ describe("web search runtime", () => {
});
});
+ it("auto-detects a provider from a model-provider auth profile", async () => {
+ const agentDir = mkdtempSync(path.join(os.tmpdir(), "openclaw-web-search-auth-"));
+ tempDirs.push(agentDir);
+ mkdirSync(agentDir, { recursive: true });
+ writeFileSync(
+ path.join(agentDir, "auth-profiles.json"),
+ JSON.stringify({
+ version: 1,
+ profiles: {
+ "xai:default": {
+ type: "oauth",
+ provider: "xai",
+ access: "xai-oauth-access-token",
+ refresh: "xai-oauth-refresh-token",
+ expires: Date.now() + 3_600_000,
+ },
+ },
+ }),
+ );
+
+ const provider = createCustomSearchProvider({
+ pluginId: "xai",
+ id: "grok",
+ authProviderId: "xai",
+ credentialPath: "plugins.entries.xai.config.webSearch.apiKey",
+ });
+ resolveRuntimeWebSearchProvidersMock.mockReturnValue([provider]);
+ resolvePluginWebSearchProvidersMock.mockReturnValue([
+ provider,
+ createDuckDuckGoSearchProvider(),
+ ]);
+
+ await expect(
+ runWebSearch({
+ config: {
+ agents: {
+ list: [{ id: "main", agentDir }],
+ },
+ },
+ args: { query: "oauth-backed web search" },
+ }),
+ ).resolves.toEqual({
+ provider: "grok",
+ result: { query: "oauth-backed web search", ok: true },
+ });
+ });
+
it("uses the active resolved runtime config for matching source config callers", async () => {
const provider = createCustomSearchProvider({
createTool: ({ config }) => ({
diff --git a/src/web-search/runtime.ts b/src/web-search/runtime.ts
index c8958f905529..0396e9fe4887 100644
--- a/src/web-search/runtime.ts
+++ b/src/web-search/runtime.ts
@@ -1,3 +1,5 @@
+import { resolveDefaultAgentDir } from "../agents/agent-scope-config.js";
+import { hasAuthProfileForProvider } from "../agents/tools/model-config.helpers.js";
import {
getRuntimeConfigSnapshot,
getRuntimeConfigSourceSnapshot,
@@ -81,6 +83,7 @@ function hasEntryCredential(
PluginWebSearchProviderEntry,
| "credentialPath"
| "id"
+ | "authProviderId"
| "envVars"
| "getConfiguredCredentialValue"
| "getConfiguredCredentialFallback"
@@ -101,6 +104,11 @@ function hasEntryCredential(
resolveEnvValue: ({ provider: currentProvider, configuredEnvVarId }) =>
(configuredEnvVarId ? readWebProviderEnvValue([configuredEnvVarId]) : undefined) ??
readWebProviderEnvValue(currentProvider.envVars),
+ resolveProviderAuthValue: (providerId) =>
+ hasAuthProfileForProvider({
+ provider: providerId,
+ agentDir: resolveDefaultAgentDir(config ?? {}),
+ }),
});
}
@@ -109,6 +117,7 @@ export function isWebSearchProviderConfigured(params: {
PluginWebSearchProviderEntry,
| "credentialPath"
| "id"
+ | "authProviderId"
| "envVars"
| "getConfiguredCredentialValue"
| "getConfiguredCredentialFallback"
@@ -176,7 +185,7 @@ export function resolveWebSearchProviderId(params: {
continue;
}
logVerbose(
- `web_search: no provider configured, auto-detected "${provider.id}" from available API keys`,
+ `web_search: no provider configured, auto-detected "${provider.id}" from available credentials`,
);
return provider.id;
}
diff --git a/src/web/provider-runtime-shared.ts b/src/web/provider-runtime-shared.ts
index 73d06a402780..f241fd45ba6b 100644
--- a/src/web/provider-runtime-shared.ts
+++ b/src/web/provider-runtime-shared.ts
@@ -9,6 +9,7 @@ type RuntimeWebProviderMetadata = {
type ProviderWithCredential = {
envVars: string[];
+ authProviderId?: string;
requiresCredential?: boolean;
};
@@ -67,6 +68,7 @@ export function hasWebProviderEntryCredential<
provider: TProvider;
configuredEnvVarId?: string;
}) => string | undefined;
+ resolveProviderAuthValue?: (providerId: string) => boolean;
}): boolean {
if (!providerRequiresCredential(params.provider)) {
return true;
@@ -86,6 +88,12 @@ export function hasWebProviderEntryCredential<
if (fromConfig) {
return true;
}
+ if (
+ params.provider.authProviderId &&
+ params.resolveProviderAuthValue?.(params.provider.authProviderId)
+ ) {
+ return true;
+ }
if (
params.resolveEnvValue({
provider: params.provider,