mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:10:44 +00:00
fix(web-search): support provider base url overrides
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user