feat: add xai oauth web search and provider timeouts

This commit is contained in:
fuller-stack-dev
2026-05-21 20:17:09 -06:00
committed by Peter Steinberger
parent 014b527e23
commit 65471a2da6
54 changed files with 1233 additions and 114 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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.

View File

@@ -217,7 +217,7 @@ openclaw onboard --non-interactive \
<Accordion title="Web-search follow-ups">
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.
</Accordion>

View File

@@ -333,7 +333,7 @@ See [/providers/kilocode](/providers/kilocode) for setup details.
Model ids use a `nvidia/<vendor>/<model>` namespace (for example `nvidia/nvidia/nemotron-...` alongside `nvidia/moonshotai/kimi-k2.5`); pickers preserve the literal `<provider>/<model-id>` composition while the canonical key sent to the API stays single-prefixed.
</Accordion>
<Accordion title="xAI">
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/<model>"].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/<model>"].params.tool_stream=false`.
</Accordion>
<Accordion title="Cerebras">
Ships as the bundled `cerebras` provider plugin. GLM uses `zai-glm-4.7`; OpenAI-compatible base URL is `https://api.cerebras.ai/v1`.

View File

@@ -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`

View File

@@ -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: {

View File

@@ -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.
<Tip>
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.
</Tip>
## 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:
<AccordionGroup>
<Accordion title="Web search">
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
<Warning>
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

View File

@@ -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

View File

@@ -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)

View File

@@ -156,7 +156,7 @@ What you set:
<Accordion title="xAI (Grok) OAuth">
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`.
</Accordion>
<Accordion title="xAI (Grok) device code">
Remote-friendly browser sign-in with a short code instead of a localhost

View File

@@ -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
<Steps>
<Step title="Create a key">
Get an API key from [xAI](https://console.x.ai/).
<Step title="Use xAI OAuth">
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
```
</Step>
<Step title="Use an API key fallback">
Get an API key from [xAI](https://console.x.ai/) when OAuth is unavailable
or you intentionally want key-backed web-search config.
</Step>
<Step title="Store the key">
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

View File

@@ -235,11 +235,12 @@ from each attempt.
<Accordion title="Timeouts">
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.
</Accordion>
<Accordion title="Inspect at runtime">
Use `action: "list"` to inspect the currently registered providers,

View File

@@ -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

View File

@@ -223,7 +223,7 @@ dimensions). Providers that do not declare it surface the value via
</ParamField>
<ParamField path="model" type="string">Provider/model override (e.g. `runway/gen4.5`).</ParamField>
<ParamField path="filename" type="string">Output filename hint.</ParamField>
<ParamField path="timeoutMs" type="number">Optional provider operation timeout in milliseconds. When omitted, OpenClaw uses `agents.defaults.videoGenerationModel.timeoutMs` if configured.</ParamField>
<ParamField path="timeoutMs" type="number">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.</ParamField>
<ParamField path="providerOptions" type="object">
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

View File

@@ -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.

View File

@@ -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) => {

View File

@@ -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<SpeechProviderPlugin, "defaultTimeoutMs">;
}): 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<string, unknown> | 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({

View File

@@ -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,
}),
);
});

View File

@@ -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;

View File

@@ -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<string>([
"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<string>([
"grok-code-fast-1",
"grok-code-fast",
"grok-code-fast-1-0825",
]);
const XAI_RETIRED_BUILTIN_MODEL_IDS = new Set<string>(
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,
);

View File

@@ -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");

View File

@@ -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";
}

View File

@@ -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",

View File

@@ -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": {

View File

@@ -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;
}

View File

@@ -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<string, unknown>; 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<string, unknown>):
});
}
function resolveConfiguredXaiWebSearchCredential(
searchConfig?: Record<string, unknown>,
): string | undefined {
return resolveWebSearchProviderCredential({
credentialValue: getScopedCredentialValue(searchConfig, "grok"),
path: "tools.web.search.grok.apiKey",
envVars: [],
});
}
function hasConfiguredXaiWebSearchCredentialRef(searchConfig?: Record<string, unknown>): 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<string, unknown>;
agentDir?: string;
credentialPrecedence?: "profile-first" | "env-first";
forceRefresh?: boolean;
profileId?: string;
}): Promise<XaiResolvedWebSearchAuth | undefined> {
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<string, unknown>;
}): Promise<XaiResolvedWebSearchAuth | undefined> {
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<string, unknown> },
searchConfig?: Record<string, unknown>,
options?: { forceRefresh?: boolean; profileId?: string },
): Promise<XaiResolvedWebSearchAuth | undefined> {
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<string, unknown> },
searchConfig?: Record<string, unknown>,
): Promise<XaiResolvedWebSearchAuth | undefined> {
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<string, unknown>): number {
return resolveTimeoutSeconds(
searchConfig?.timeoutSeconds,
@@ -191,13 +349,13 @@ export async function executeXaiWebSearchProviderTool(
args: Record<string, unknown>,
): Promise<Record<string, unknown>> {
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,

View File

@@ -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");

View File

@@ -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({

View File

@@ -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",

View File

@@ -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<typeof import("openclaw/plugin-sdk/provider-auth")>();
return {
...original,
ensureAuthProfileStore: providerAuthMocks.ensureAuthProfileStore,
listUsableProviderAuthProfileIds: providerAuthMocks.listUsableProviderAuthProfileIds,
};
});
vi.mock("openclaw/plugin-sdk/provider-auth-runtime", async (importOriginal) => {
const original =
await importOriginal<typeof import("openclaw/plugin-sdk/provider-auth-runtime")>();
return {
...original,
resolveApiKeyForProvider: providerAuthRuntimeMocks.resolveApiKeyForProvider,
};
});
vi.mock("openclaw/plugin-sdk/provider-web-search", async (importOriginal) => {
const original = await importOriginal<typeof import("openclaw/plugin-sdk/provider-web-search")>();
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<typeof installXaiWebSearchFetch>) {
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<string, string | undefined>)[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);
});

View File

@@ -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",

View File

@@ -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(
{

View File

@@ -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<ResolvedProviderAuth> {
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(
[

View File

@@ -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 {

View File

@@ -1410,6 +1410,8 @@ export const FIELD_HELP: Record<string, string> = {
"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":

View File

@@ -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");

View File

@@ -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<PluginWebSearchProviderEntry, "id" | "envVars" | "requiresCredential">,
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) {

View File

@@ -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({

View File

@@ -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 = [
{

View File

@@ -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,

View File

@@ -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;

View File

@@ -60,6 +60,21 @@ export function hasMediaNormalizationEntry<TValue extends MediaNormalizationValu
const IMAGE_RESOLUTION_ORDER = ["1K", "2K", "4K"] as const;
export function resolveMediaProviderDefaultTimeoutMs(
timeoutMs: number | undefined,
): number | undefined {
return typeof timeoutMs === "number" && Number.isFinite(timeoutMs) && timeoutMs > 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[];

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -10,6 +10,7 @@ type CommonWebProviderTestParams = {
credentialPath: string;
autoDetectOrder?: number;
requiresCredential?: boolean;
authProviderId?: string;
getCredentialValue?: (config?: Record<string, unknown>) => 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,

View File

@@ -23,6 +23,7 @@ export type ResolvedTtsConfig = {
prefsPath?: string;
maxTextLength: number;
timeoutMs: number;
timeoutMsSource?: "config" | "default";
rawConfig?: TtsConfig;
sourceConfig?: OpenClawConfig;
};

View File

@@ -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",

View File

@@ -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,

View File

@@ -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;

View File

@@ -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 }) => ({

View File

@@ -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;
}

View File

@@ -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,