fix(web-search): support provider base url overrides

This commit is contained in:
Peter Steinberger
2026-05-02 03:44:40 +01:00
parent 6b1821b0e1
commit b813183bfd
21 changed files with 370 additions and 28 deletions

View File

@@ -29,6 +29,7 @@ Docs: https://docs.openclaw.ai
- Slack/message tool: let `read` fetch an exact Slack message timestamp, including a specific thread reply when paired with `threadId`, instead of returning only the parent thread or recent channel history. Fixes #53943. Thanks @zomars.
- Web search: point missing-key errors to `web_fetch` for known URLs and the browser tool for interactive pages. Thanks @zhaoyang97.
- Web search: late-bind managed agent `web_search` calls to the current runtime config snapshot, so existing sessions do not keep stale unresolved SecretRefs after secrets reload. Fixes #75420. Thanks @richardmqq.
- Web search: honor `baseUrl` overrides for Gemini, Grok, and x_search provider-owned config, so proxy-backed search tools no longer dial hardcoded public endpoints. Supersedes #61972. Thanks @Lanfei.
- Heartbeat: strip legacy `[TOOL_CALL]...[/TOOL_CALL]` and `[TOOL_RESULT]...[/TOOL_RESULT]` pseudo-call blocks from heartbeat replies before channel delivery. Fixes #54138. Thanks @Deniable9570.
- macOS/Voice Wake: send wake-word and Push-to-Talk transcripts through the selected macOS session target instead of always falling back to main WebChat. Fixes #51040. Thanks @carl-jeffrolc.
- Providers/xAI: give Grok `web_search` a 60s default timeout, harden malformed xAI Responses parsing, and return structured timeout errors instead of aborting the tool call. Fixes #58063 and #58733. Thanks @dnishimura, @marvcasasola-svg, and @Nanako0129.

View File

@@ -1,4 +1,4 @@
1d9157a39ad18841d666af90c58e0539d6427cbd2ad0c1ce29047a5a2131ba7e config-baseline.json
6666a7f876a31658b3e2f2a6564619cfaf2b282104fd6d7799656389431eb996 config-baseline.json
80e6e8dce647aef2d1310de55a81d27de52cca47fc24bd7ad81b80f43a72b84c config-baseline.core.json
1cec599c3d27c258b9df3446baa547cb164e502afa9b30c052bba8737183f551 config-baseline.channel.json
8346667910d2b3a3884efce8f96591adebc4f7ea99ce18337b80e4d70bf8e4d2 config-baseline.plugin.json
1b2cb7fec6752245bc2a3da4a835f0bf9d31e6a468e777a5bdb91820398f44d0 config-baseline.plugin.json

View File

@@ -144,6 +144,33 @@ Choose your preferred auth method and follow the setup steps.
| Thinking/reasoning | Yes (Gemini 2.5+ / Gemini 3+) |
| Gemma 4 models | Yes |
## Web search
The bundled `gemini` web-search provider uses Gemini Google Search grounding.
Configure it under `plugins.entries.google.config.webSearch`:
```json5
{
plugins: {
entries: {
google: {
config: {
webSearch: {
apiKey: "AIza...", // optional if GEMINI_API_KEY is set
baseUrl: "https://generativelanguage.googleapis.com/v1beta",
model: "gemini-2.5-flash",
},
},
},
},
},
}
```
`webSearch.baseUrl` is optional and exists for operator proxies or compatible
Gemini API endpoints. See [Gemini search](/tools/gemini-search) for the
provider-specific tool behavior.
<Tip>
Gemini 3 models use `thinkingLevel` rather than `thinkingBudget`. OpenClaw maps
Gemini 3, Gemini 3.1, and `gemini-*-latest` alias reasoning controls to

View File

@@ -37,6 +37,8 @@ OpenClaw uses the xAI Responses API as the bundled xAI transport. The same
and remote `code_execution`.
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`
and, by default, `x_search` through an operator xAI Responses proxy.
`code_execution` tuning lives under `plugins.entries.xai.config.codeExecution`.
</Note>
@@ -343,6 +345,7 @@ Legacy aliases still normalize to the canonical bundled ids:
| ------------------ | ------- | ------------------ | ------------------------------------ |
| `enabled` | boolean | — | Enable or disable x_search |
| `model` | string | `grok-4-1-fast` | Model used for x_search requests |
| `baseUrl` | string | — | xAI Responses base URL override |
| `inlineCitations` | boolean | — | Include inline citations in results |
| `maxTurns` | number | — | Maximum conversation turns |
| `timeoutSeconds` | number | — | Request timeout in seconds |
@@ -357,6 +360,7 @@ Legacy aliases still normalize to the canonical bundled ids:
xSearch: {
enabled: true,
model: "grok-4-1-fast",
baseUrl: "https://api.x.ai/v1",
inlineCitations: true,
},
},
@@ -429,6 +433,9 @@ Legacy aliases still normalize to the canonical bundled ids:
- `web_search`, `x_search`, and `code_execution` are exposed as OpenClaw
tools. OpenClaw enables the specific xAI built-in it needs inside each tool
request instead of attaching all native tools to every chat turn.
- Grok `web_search` reads `plugins.entries.xai.config.webSearch.baseUrl`.
`x_search` reads `plugins.entries.xai.config.xSearch.baseUrl`, then
falls back to the Grok web-search base URL.
- `x_search` and `code_execution` are owned by the bundled xAI plugin rather
than hardcoded into the core model runtime.
- `code_execution` is remote xAI sandbox execution, not local

View File

@@ -39,6 +39,7 @@ citations.
config: {
webSearch: {
apiKey: "AIza...", // optional if GEMINI_API_KEY is set
baseUrl: "https://generativelanguage.googleapis.com/v1beta", // optional proxy/base URL override
model: "gemini-2.5-flash", // default
},
},
@@ -89,6 +90,14 @@ The default model is `gemini-2.5-flash` (fast and cost-effective). Any Gemini
model that supports grounding can be used via
`plugins.entries.google.config.webSearch.model`.
## Base URL overrides
Set `plugins.entries.google.config.webSearch.baseUrl` when Gemini web search
must route through an operator proxy or custom Gemini-compatible endpoint. A
plain `https://generativelanguage.googleapis.com` value is normalized to
`https://generativelanguage.googleapis.com/v1beta`; custom proxy paths are kept
as provided after trimming trailing slashes.
## Related
- [Web Search overview](/tools/web) -- all providers and auto-detection

View File

@@ -61,6 +61,7 @@ If you skip it, you can enable or change `x_search` later in config.
config: {
webSearch: {
apiKey: "xai-...", // optional if XAI_API_KEY is set
baseUrl: "https://api.x.ai/v1", // optional Responses API proxy/base URL override
},
},
},
@@ -97,6 +98,14 @@ Grok uses a provider-specific 60 second default timeout because xAI Responses
web-grounded searches can run longer than the shared `web_search` default. Set
`tools.web.search.timeoutSeconds` to override it.
## Base URL overrides
Set `plugins.entries.xai.config.webSearch.baseUrl` when Grok web search should
route through an operator proxy or xAI-compatible Responses endpoint. OpenClaw
posts to `<baseUrl>/responses` after trimming trailing slashes. `x_search`
uses the same `webSearch.baseUrl` fallback unless
`plugins.entries.xai.config.xSearch.baseUrl` is set.
## Related
- [Web Search overview](/tools/web) -- all providers and auto-detection

View File

@@ -334,6 +334,7 @@ tool on the request that serves this tool call.
xSearch: {
enabled: true,
model: "grok-4-1-fast-non-reasoning",
baseUrl: "https://api.x.ai/v1", // optional, overrides webSearch.baseUrl
inlineCitations: false,
maxTurns: 2,
timeoutSeconds: 30,
@@ -341,6 +342,7 @@ tool on the request that serves this tool call.
},
webSearch: {
apiKey: "xai-...", // optional if XAI_API_KEY is set
baseUrl: "https://api.x.ai/v1", // optional shared xAI Responses base URL
},
},
},
@@ -349,6 +351,11 @@ tool on the request that serves this tool call.
}
```
`x_search` posts to `<baseUrl>/responses` when
`plugins.entries.xai.config.xSearch.baseUrl` is set. If that field is omitted,
it falls back to `plugins.entries.xai.config.webSearch.baseUrl`, then the
legacy `tools.web.search.grok.baseUrl`, and finally the public xAI endpoint.
### x_search parameters
| Parameter | Description |

View File

@@ -133,6 +133,10 @@
"webSearch.model": {
"label": "Gemini Search Model",
"help": "Gemini model override for web search grounding."
},
"webSearch.baseUrl": {
"label": "Gemini Search Base URL",
"help": "Optional Gemini API base URL for web search grounding proxies."
}
},
"contracts": {
@@ -177,6 +181,9 @@
},
"model": {
"type": "string"
},
"baseUrl": {
"type": "string"
}
}
}

View File

@@ -20,15 +20,13 @@ import {
wrapWebContent,
writeCachedSearchPayload,
} from "openclaw/plugin-sdk/provider-web-search";
import { DEFAULT_GOOGLE_API_BASE_URL } from "../api.js";
import {
resolveGeminiConfig,
resolveGeminiBaseUrl,
resolveGeminiModel,
type GeminiConfig,
} from "./gemini-web-search-provider.shared.js";
const GEMINI_API_BASE = DEFAULT_GOOGLE_API_BASE_URL;
type GeminiGroundingResponse = {
candidates?: Array<{
content?: {
@@ -62,10 +60,11 @@ export function resolveGeminiRuntimeApiKey(gemini?: GeminiConfig): string | unde
async function runGeminiSearch(params: {
query: string;
apiKey: string;
baseUrl: string;
model: string;
timeoutSeconds: number;
}): Promise<{ content: string; citations: Array<{ url: string; title?: string }> }> {
const endpoint = `${GEMINI_API_BASE}/models/${params.model}:generateContent`;
const endpoint = `${params.baseUrl}/models/${params.model}:generateContent`;
return withTrustedWebSearchEndpoint(
{
@@ -161,10 +160,12 @@ export async function executeGeminiSearch(
const count =
readNumberParam(args, "count", { integer: true }) ?? searchConfig?.maxResults ?? undefined;
const model = resolveGeminiModel(geminiConfig);
const baseUrl = resolveGeminiBaseUrl(geminiConfig);
const cacheKey = buildSearchCacheKey([
"gemini",
query,
resolveSearchCount(count, DEFAULT_SEARCH_COUNT),
baseUrl,
model,
]);
const cached = readCachedSearchPayload(cacheKey);
@@ -176,6 +177,7 @@ export async function executeGeminiSearch(
const result = await runGeminiSearch({
query,
apiKey,
baseUrl,
model,
timeoutSeconds: resolveSearchTimeoutSeconds(searchConfig),
});

View File

@@ -1,7 +1,10 @@
import { normalizeGoogleApiBaseUrl } from "../api.js";
const DEFAULT_GEMINI_WEB_SEARCH_MODEL = "gemini-2.5-flash";
export type GeminiConfig = {
apiKey?: unknown;
baseUrl?: unknown;
model?: unknown;
};
@@ -28,3 +31,7 @@ export function resolveGeminiApiKey(
export function resolveGeminiModel(gemini?: GeminiConfig): string {
return trimToUndefined(gemini?.model) ?? DEFAULT_GEMINI_WEB_SEARCH_MODEL;
}
export function resolveGeminiBaseUrl(gemini?: GeminiConfig): string {
return normalizeGoogleApiBaseUrl(trimToUndefined(gemini?.baseUrl));
}

View File

@@ -5,7 +5,11 @@ import {
type WebSearchProviderPlugin,
type WebSearchProviderToolDefinition,
} from "openclaw/plugin-sdk/provider-web-search-config-contract";
import { resolveGeminiApiKey, resolveGeminiModel } from "./gemini-web-search-provider.shared.js";
import {
resolveGeminiApiKey,
resolveGeminiBaseUrl,
resolveGeminiModel,
} from "./gemini-web-search-provider.shared.js";
const GEMINI_CREDENTIAL_PATH = "plugins.entries.google.config.webSearch.apiKey";
@@ -82,5 +86,6 @@ export function createGeminiWebSearchProvider(): WebSearchProviderPlugin {
export const __testing = {
resolveGeminiApiKey,
resolveGeminiBaseUrl,
resolveGeminiModel,
} as const;

View File

@@ -1,8 +1,33 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
import { withEnv, withEnvAsync } from "openclaw/plugin-sdk/test-env";
import { describe, expect, it } from "vitest";
import { withEnv, withEnvAsync, withFetchPreconnect } from "openclaw/plugin-sdk/test-env";
import { afterEach, describe, expect, it, vi } from "vitest";
import { __testing, createGeminiWebSearchProvider } from "./src/gemini-web-search-provider.js";
function installGeminiFetch() {
const mockFetch = vi.fn((_input?: unknown, _init?: unknown) =>
Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
candidates: [
{
content: { parts: [{ text: "Grounded answer" }] },
groundingMetadata: {
groundingChunks: [{ web: { uri: "https://example.com", title: "Example" } }],
},
},
],
}),
} as Response),
);
global.fetch = withFetchPreconnect(mockFetch);
return mockFetch;
}
afterEach(() => {
vi.restoreAllMocks();
});
describe("google web search provider", () => {
it("points missing-key users to fetch/browser alternatives", async () => {
await withEnvAsync({ GEMINI_API_KEY: undefined }, async () => {
@@ -47,4 +72,38 @@ describe("google web search provider", () => {
expect(__testing.resolveGeminiModel()).toBe("gemini-2.5-flash");
expect(__testing.resolveGeminiModel({ model: " gemini-2.5-pro " })).toBe("gemini-2.5-pro");
});
it("routes Gemini web search through plugin webSearch.baseUrl", async () => {
const mockFetch = installGeminiFetch();
const provider = createGeminiWebSearchProvider();
const tool = provider.createTool({
config: {
plugins: {
entries: {
google: {
config: {
webSearch: {
apiKey: "AIza-plugin-test",
baseUrl: "https://generativelanguage.googleapis.com/proxy/v1beta/",
},
},
},
},
},
},
searchConfig: { provider: "gemini" },
});
await tool?.execute({ query: "OpenClaw docs" });
expect(String(mockFetch.mock.calls[0]?.[0])).toBe(
"https://generativelanguage.googleapis.com/proxy/v1beta/models/gemini-2.5-flash:generateContent",
);
});
it("normalizes Gemini shorthand base URLs", () => {
expect(
__testing.resolveGeminiBaseUrl({ baseUrl: "https://generativelanguage.googleapis.com" }),
).toBe("https://generativelanguage.googleapis.com/v1beta");
});
});

View File

@@ -62,6 +62,10 @@
"label": "Grok Search Model",
"help": "Grok model override for web search."
},
"webSearch.baseUrl": {
"label": "Grok Search Base URL",
"help": "Optional xAI Responses API base URL for Grok web_search and x_search fallbacks."
},
"webSearch.inlineCitations": {
"label": "Inline Citations",
"help": "Include inline markdown citations in Grok responses."
@@ -78,6 +82,10 @@
"label": "X Search Model",
"help": "xAI model override for x_search."
},
"xSearch.baseUrl": {
"label": "X Search Base URL",
"help": "Optional xAI Responses API base URL for x_search requests."
},
"xSearch.inlineCitations": {
"label": "X Search Inline Citations",
"help": "Keep inline markdown citations from xAI in x_search responses when available."
@@ -144,6 +152,9 @@
"model": {
"type": "string"
},
"baseUrl": {
"type": "string"
},
"inlineCitations": {
"type": "boolean"
}
@@ -159,6 +170,9 @@
"model": {
"type": "string"
},
"baseUrl": {
"type": "string"
},
"inlineCitations": {
"type": "boolean"
},

View File

@@ -18,7 +18,16 @@ function extractUrlCitations(annotations: unknown): string[] {
.map((annotation) => annotation.url as string);
}
export const XAI_RESPONSES_ENDPOINT = "https://api.x.ai/v1/responses";
export const XAI_RESPONSES_BASE_URL = "https://api.x.ai/v1";
export const XAI_RESPONSES_ENDPOINT = `${XAI_RESPONSES_BASE_URL}/responses`;
function trimString(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
export function resolveXaiResponsesEndpoint(baseUrl?: unknown): string {
return `${(trimString(baseUrl) ?? XAI_RESPONSES_BASE_URL).replace(/\/+$/, "")}/responses`;
}
export function buildXaiResponsesToolBody(params: {
model: string;
@@ -105,5 +114,7 @@ export const __testing = {
extractXaiWebSearchContent,
resolveXaiResponseTextCitationsAndInline,
resolveXaiResponseTextAndCitations,
resolveXaiResponsesEndpoint,
XAI_RESPONSES_BASE_URL,
XAI_RESPONSES_ENDPOINT,
} as const;

View File

@@ -19,6 +19,7 @@ import {
extractXaiWebSearchContent,
requestXaiWebSearch,
resolveXaiInlineCitations,
resolveXaiWebSearchEndpoint,
resolveXaiWebSearchModel,
} from "./web-search-shared.js";
import { resolveEffectiveXSearchConfig, setPluginXSearchConfigValue } from "./x-search-config.js";
@@ -120,13 +121,14 @@ export async function runXaiSearchProviderSetup(
function runXaiWebSearch(params: {
query: string;
model: string;
endpoint: string;
apiKey: string;
timeoutSeconds: number;
inlineCitations: boolean;
cacheTtlMs: number;
}): Promise<Record<string, unknown>> {
const cacheKey = normalizeCacheKey(
`grok:${params.model}:${String(params.inlineCitations)}:${params.query}`,
`grok:${params.endpoint}:${params.model}:${String(params.inlineCitations)}:${params.query}`,
);
const cached = readCache(XAI_WEB_SEARCH_CACHE, cacheKey);
if (cached) {
@@ -139,6 +141,7 @@ function runXaiWebSearch(params: {
query: params.query,
model: params.model,
apiKey: params.apiKey,
endpoint: params.endpoint,
timeoutSeconds: params.timeoutSeconds,
inlineCitations: params.inlineCitations,
});
@@ -205,6 +208,7 @@ export async function executeXaiWebSearchProviderTool(
return await runXaiWebSearch({
query,
model: resolveXaiWebSearchModel(searchConfig),
endpoint: resolveXaiWebSearchEndpoint(searchConfig),
apiKey,
timeoutSeconds: resolveXaiWebSearchTimeoutSeconds(searchConfig),
inlineCitations: resolveXaiInlineCitations(searchConfig),
@@ -218,6 +222,7 @@ export const __testing = {
resolveXaiToolSearchConfig,
resolveXaiInlineCitations,
resolveXaiWebSearchCredential,
resolveXaiWebSearchEndpoint,
resolveXaiWebSearchModel,
resolveXaiWebSearchTimeoutSeconds,
requestXaiWebSearch,

View File

@@ -4,17 +4,17 @@ import {
buildXaiResponsesToolBody,
extractXaiWebSearchContent,
resolveXaiResponseTextCitationsAndInline,
XAI_RESPONSES_ENDPOINT,
resolveXaiResponsesEndpoint,
} from "./responses-tool-shared.js";
import { isRecord } from "./tool-config-shared.js";
import type { XaiWebSearchResponse } from "./web-search-response.types.js";
export { extractXaiWebSearchContent } from "./responses-tool-shared.js";
export type { XaiWebSearchResponse } from "./web-search-response.types.js";
const XAI_WEB_SEARCH_ENDPOINT = XAI_RESPONSES_ENDPOINT;
const XAI_DEFAULT_WEB_SEARCH_MODEL = "grok-4-1-fast";
type XaiWebSearchConfig = Record<string, unknown> & {
baseUrl?: unknown;
model?: unknown;
inlineCitations?: unknown;
};
@@ -64,6 +64,10 @@ export function resolveXaiWebSearchModel(searchConfig?: Record<string, unknown>)
: XAI_DEFAULT_WEB_SEARCH_MODEL;
}
export function resolveXaiWebSearchEndpoint(searchConfig?: Record<string, unknown>): string {
return resolveXaiResponsesEndpoint(resolveXaiSearchConfig(searchConfig).baseUrl);
}
export function resolveXaiInlineCitations(searchConfig?: Record<string, unknown>): boolean {
return resolveXaiSearchConfig(searchConfig).inlineCitations === true;
}
@@ -89,12 +93,13 @@ export async function requestXaiWebSearch(params: {
query: string;
model: string;
apiKey: string;
endpoint: string;
timeoutSeconds: number;
inlineCitations: boolean;
}): Promise<XaiWebSearchResult> {
return await postTrustedWebToolsJson(
{
url: XAI_WEB_SEARCH_ENDPOINT,
url: params.endpoint,
timeoutSeconds: params.timeoutSeconds,
apiKey: params.apiKey,
body: buildXaiResponsesToolBody({

View File

@@ -24,19 +24,44 @@ function resolvePluginXSearchConfig(config?: OpenClawConfig): JsonRecord | undef
return cloneRecord(pluginConfig.xSearch);
}
function resolveLegacyGrokWebSearchConfig(config?: OpenClawConfig): JsonRecord | undefined {
const web = config?.tools?.web as Record<string, unknown> | undefined;
const search = web?.search;
if (!isRecord(search) || !isRecord(search.grok)) {
return undefined;
}
return cloneRecord(search.grok);
}
function resolvePluginWebSearchConfig(config?: OpenClawConfig): JsonRecord | undefined {
const pluginConfig = config?.plugins?.entries?.xai?.config;
if (!isRecord(pluginConfig?.webSearch)) {
return undefined;
}
return cloneRecord(pluginConfig.webSearch);
}
function baseUrlFallback(config?: JsonRecord): JsonRecord | undefined {
return typeof config?.baseUrl === "string" && config.baseUrl.trim()
? { baseUrl: config.baseUrl }
: undefined;
}
export function resolveEffectiveXSearchConfig(config?: OpenClawConfig): JsonRecord | undefined {
const legacyGrokBaseUrl = baseUrlFallback(resolveLegacyGrokWebSearchConfig(config));
const pluginWebSearchBaseUrl = baseUrlFallback(resolvePluginWebSearchConfig(config));
const legacy = resolveLegacyXSearchConfig(config);
const pluginOwned = resolvePluginXSearchConfig(config);
if (!legacy) {
return pluginOwned;
}
if (!pluginOwned) {
return legacy;
}
return {
...legacy,
...pluginOwned,
const merged = {
...(legacyGrokBaseUrl ?? {}),
...(pluginWebSearchBaseUrl ?? {}),
...(legacy ?? {}),
...(pluginOwned ?? {}),
};
if (Object.keys(merged).length === 0) {
return undefined;
}
return merged;
}
export function setPluginXSearchConfigValue(

View File

@@ -2,7 +2,7 @@ import { postTrustedWebToolsJson, wrapWebContent } from "openclaw/plugin-sdk/pro
import {
buildXaiResponsesToolBody,
resolveXaiResponseTextCitationsAndInline,
XAI_RESPONSES_ENDPOINT,
resolveXaiResponsesEndpoint,
} from "./responses-tool-shared.js";
import {
coerceXaiToolConfig,
@@ -11,11 +11,11 @@ import {
} from "./tool-config-shared.js";
import { type XaiWebSearchResponse } from "./web-search-shared.js";
const XAI_X_SEARCH_ENDPOINT = XAI_RESPONSES_ENDPOINT;
export const XAI_DEFAULT_X_SEARCH_MODEL = "grok-4-1-fast-non-reasoning";
type XaiXSearchConfig = {
apiKey?: unknown;
baseUrl?: unknown;
model?: unknown;
inlineCitations?: unknown;
maxTurns?: unknown;
@@ -48,6 +48,10 @@ export function resolveXaiXSearchModel(config?: Record<string, unknown>): string
});
}
export function resolveXaiXSearchEndpoint(config?: Record<string, unknown>): string {
return resolveXaiResponsesEndpoint(resolveXaiXSearchConfig(config).baseUrl);
}
export function resolveXaiXSearchInlineCitations(config?: Record<string, unknown>): boolean {
return resolveXaiXSearchConfig(config).inlineCitations === true;
}
@@ -106,6 +110,7 @@ export function buildXaiXSearchPayload(params: {
export async function requestXaiXSearch(params: {
apiKey: string;
endpoint: string;
model: string;
timeoutSeconds: number;
inlineCitations: boolean;
@@ -114,7 +119,7 @@ export async function requestXaiXSearch(params: {
}): Promise<XaiXSearchResult> {
return await postTrustedWebToolsJson(
{
url: XAI_X_SEARCH_ENDPOINT,
url: params.endpoint,
timeoutSeconds: params.timeoutSeconds,
apiKey: params.apiKey,
body: buildXaiResponsesToolBody({

View File

@@ -1,8 +1,8 @@
import { createTestWizardPrompter } from "openclaw/plugin-sdk/plugin-test-runtime";
import { NON_ENV_SECRETREF_MARKER } from "openclaw/plugin-sdk/provider-auth-runtime";
import { createNonExitingRuntime } from "openclaw/plugin-sdk/runtime-env";
import { withEnv, withEnvAsync } from "openclaw/plugin-sdk/test-env";
import { describe, expect, it, vi } from "vitest";
import { withEnv, withEnvAsync, withFetchPreconnect } from "openclaw/plugin-sdk/test-env";
import { afterEach, describe, expect, it, vi } from "vitest";
import { resolveXaiCatalogEntry } from "./model-definitions.js";
import { isModernXaiModel, resolveXaiForwardCompatModel } from "./provider-models.js";
import { resolveFallbackXaiAuth } from "./src/tool-auth-shared.js";
@@ -19,6 +19,29 @@ const {
resolveXaiWebSearchTimeoutSeconds,
} = __testing;
function installXaiWebSearchFetch() {
const mockFetch = vi.fn((_input?: unknown, _init?: unknown) =>
Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
output: [
{
type: "message",
content: [{ type: "output_text", text: "Grounded Grok answer" }],
},
],
}),
} as Response),
);
global.fetch = withFetchPreconnect(mockFetch);
return mockFetch;
}
afterEach(() => {
vi.restoreAllMocks();
});
describe("xai web search config resolution", () => {
it("prefers configured api keys and resolves grok scoped defaults", () => {
expect(resolveXaiWebSearchCredential({ grok: { apiKey: "xai-secret" } })).toBe("xai-secret");
@@ -268,6 +291,32 @@ describe("xai web search config resolution", () => {
);
});
it("routes Grok web search through plugin webSearch.baseUrl", async () => {
const mockFetch = installXaiWebSearchFetch();
const provider = createXaiWebSearchProvider();
const tool = provider.createTool({
config: {
plugins: {
entries: {
xai: {
config: {
webSearch: {
apiKey: "xai-config-test",
baseUrl: "https://api.x.ai/proxy/v1/",
},
},
},
},
},
},
searchConfig: { provider: "grok" },
});
await tool?.execute({ query: "OpenClaw Grok proxy test" });
expect(String(mockFetch.mock.calls[0]?.[0])).toBe("https://api.x.ai/proxy/v1/responses");
});
it("normalizes deprecated grok 4.20 beta model ids to GA ids", () => {
expect(
resolveXaiWebSearchModel({

View File

@@ -136,6 +136,88 @@ describe("xai x_search tool", () => {
]);
});
it("routes x_search through plugin-owned xSearch.baseUrl", async () => {
const mockFetch = installXSearchFetch();
const tool = createXSearchTool({
config: {
plugins: {
entries: {
xai: {
config: {
webSearch: {
apiKey: "xai-config-test", // pragma: allowlist secret
},
xSearch: {
enabled: true,
baseUrl: "https://api.x.ai/xai-search/v1/",
},
},
},
},
},
},
});
await tool?.execute?.("x-search:plugin-base-url", {
query: "base url route",
});
expect(String(mockFetch.mock.calls[0]?.[0])).toBe("https://api.x.ai/xai-search/v1/responses");
});
it("falls back to Grok web search baseUrl for x_search", async () => {
const mockFetch = installXSearchFetch();
const tool = createXSearchTool({
config: {
tools: {
web: {
search: {
grok: {
apiKey: "xai-legacy-key", // pragma: allowlist secret
baseUrl: "https://api.x.ai/legacy/v1/",
},
},
},
},
},
});
await tool?.execute?.("x-search:legacy-grok-base-url", {
query: "legacy base url route",
});
expect(String(mockFetch.mock.calls[0]?.[0])).toBe("https://api.x.ai/legacy/v1/responses");
});
it("shares plugin webSearch.baseUrl with x_search when xSearch.baseUrl is unset", async () => {
const mockFetch = installXSearchFetch();
const tool = createXSearchTool({
config: {
plugins: {
entries: {
xai: {
config: {
webSearch: {
apiKey: "xai-plugin-key", // pragma: allowlist secret
baseUrl: "https://api.x.ai/shared/v1/",
},
xSearch: {
enabled: true,
},
},
},
},
},
},
});
await tool?.execute?.("x-search:web-search-base-url", {
query: "shared base url route",
});
expect(String(mockFetch.mock.calls[0]?.[0])).toBe("https://api.x.ai/shared/v1/responses");
});
it("reuses the xAI plugin web search key for x_search requests", async () => {
const mockFetch = installXSearchFetch();
const tool = createXSearchTool({

View File

@@ -13,6 +13,7 @@ import { resolveEffectiveXSearchConfig } from "./src/x-search-config.js";
import {
buildXaiXSearchPayload,
requestXaiXSearch,
resolveXaiXSearchEndpoint,
resolveXaiXSearchInlineCitations,
resolveXaiXSearchMaxTurns,
resolveXaiXSearchModel,
@@ -100,6 +101,7 @@ function normalizeOptionalIsoDate(value: string | undefined, label: string): str
function buildXSearchCacheKey(params: {
query: string;
model: string;
endpoint: string;
inlineCitations: boolean;
maxTurns?: number;
options: Omit<XaiXSearchOptions, "query">;
@@ -107,6 +109,7 @@ function buildXSearchCacheKey(params: {
return JSON.stringify([
"x_search",
params.model,
params.endpoint,
params.query,
params.inlineCitations,
params.maxTurns ?? null,
@@ -164,11 +167,13 @@ export function createXSearchTool(options?: {
};
const xSearchConfigRecord = xSearchConfig;
const model = resolveXaiXSearchModel(xSearchConfigRecord);
const endpoint = resolveXaiXSearchEndpoint(xSearchConfigRecord);
const inlineCitations = resolveXaiXSearchInlineCitations(xSearchConfigRecord);
const maxTurns = resolveXaiXSearchMaxTurns(xSearchConfigRecord);
const cacheKey = buildXSearchCacheKey({
query,
model,
endpoint,
inlineCitations,
maxTurns,
options: {
@@ -188,6 +193,7 @@ export function createXSearchTool(options?: {
const startedAt = Date.now();
const result = await requestXaiXSearch({
apiKey,
endpoint,
model,
timeoutSeconds: resolveTimeoutSeconds(xSearchConfig?.timeoutSeconds, 30),
inlineCitations,