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,