mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
feat: add xai oauth web search and provider timeouts
This commit is contained in:
committed by
Peter Steinberger
parent
014b527e23
commit
65471a2da6
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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(
|
||||
[
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 = [
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -23,6 +23,7 @@ export type ResolvedTtsConfig = {
|
||||
prefsPath?: string;
|
||||
maxTextLength: number;
|
||||
timeoutMs: number;
|
||||
timeoutMsSource?: "config" | "default";
|
||||
rawConfig?: TtsConfig;
|
||||
sourceConfig?: OpenClawConfig;
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }) => ({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user