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

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