diff --git a/extensions/google/api.test.ts b/extensions/google/api.test.ts index 0fea0f766e2..77efae78390 100644 --- a/extensions/google/api.test.ts +++ b/extensions/google/api.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it } from "vitest"; import { isGoogleGenerativeAiApi, normalizeGoogleGenerativeAiBaseUrl, + parseGeminiAuth, + resolveGoogleGenerativeAiHttpRequestConfig, resolveGoogleGenerativeAiApiOrigin, resolveGoogleGenerativeAiTransport, shouldNormalizeGoogleGenerativeAiProviderConfig, @@ -91,4 +93,53 @@ describe("google generative ai helpers", () => { resolveGoogleGenerativeAiApiOrigin("https://generativelanguage.googleapis.com/v1beta"), ).toBe("https://generativelanguage.googleapis.com"); }); + + it("parses project-aware oauth auth payloads into bearer headers", () => { + expect(parseGeminiAuth(JSON.stringify({ token: "oauth-token", projectId: "project-1" }))).toEqual({ + headers: { + Authorization: "Bearer oauth-token", + "Content-Type": "application/json", + }, + }); + }); + + it("falls back to API key headers for raw tokens", () => { + expect(parseGeminiAuth("api-key-123")).toEqual({ + headers: { + "x-goog-api-key": "api-key-123", + "Content-Type": "application/json", + }, + }); + }); + + it("builds shared Google Generative AI HTTP request config", () => { + const oauthConfig = resolveGoogleGenerativeAiHttpRequestConfig({ + apiKey: JSON.stringify({ token: "oauth-token" }), + baseUrl: "https://generativelanguage.googleapis.com", + capability: "audio", + transport: "media-understanding", + }); + expect(oauthConfig).toMatchObject({ + baseUrl: "https://generativelanguage.googleapis.com/v1beta", + allowPrivateNetwork: true, + }); + expect(Object.fromEntries(new Headers(oauthConfig.headers).entries())).toEqual({ + authorization: "Bearer oauth-token", + "content-type": "application/json", + }); + + const apiKeyConfig = resolveGoogleGenerativeAiHttpRequestConfig({ + apiKey: "api-key-123", + capability: "image", + transport: "http", + }); + expect(apiKeyConfig).toMatchObject({ + baseUrl: "https://generativelanguage.googleapis.com/v1beta", + allowPrivateNetwork: false, + }); + expect(Object.fromEntries(new Headers(apiKeyConfig.headers).entries())).toEqual({ + "content-type": "application/json", + "x-goog-api-key": "api-key-123", + }); + }); }); diff --git a/extensions/google/api.ts b/extensions/google/api.ts index ad165fb7b97..efafc66a2f3 100644 --- a/extensions/google/api.ts +++ b/extensions/google/api.ts @@ -1,10 +1,15 @@ -import { resolveProviderEndpoint } from "openclaw/plugin-sdk/provider-http"; +import { + resolveProviderEndpoint, + resolveProviderHttpRequestConfig, + type ProviderRequestTransportOverrides, +} from "openclaw/plugin-sdk/provider-http"; import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-shared"; import { applyAgentDefaultModelPrimary, type OpenClawConfig, } from "openclaw/plugin-sdk/provider-onboard"; import { normalizeAntigravityModelId, normalizeGoogleModelId } from "./model-id.js"; +import { parseGoogleOauthApiKey } from "./oauth-token-shared.js"; export { normalizeAntigravityModelId, normalizeGoogleModelId }; type GoogleApiCarrier = { @@ -138,20 +143,14 @@ export function normalizeGoogleProviderConfig( } export function parseGeminiAuth(apiKey: string): { headers: Record } { - if (apiKey.startsWith("{")) { - try { - const parsed = JSON.parse(apiKey) as { token?: string; projectId?: string }; - if (typeof parsed.token === "string" && parsed.token) { - return { - headers: { - Authorization: `Bearer ${parsed.token}`, - "Content-Type": "application/json", - }, - }; - } - } catch { - // Fall back to API key mode. - } + const parsed = apiKey.startsWith("{") ? parseGoogleOauthApiKey(apiKey) : null; + if (parsed?.token) { + return { + headers: { + Authorization: `Bearer ${parsed.token}`, + "Content-Type": "application/json", + }, + }; } return { @@ -162,6 +161,28 @@ export function parseGeminiAuth(apiKey: string): { headers: Record; + request?: ProviderRequestTransportOverrides; + capability: "image" | "audio" | "video"; + transport: "http" | "media-understanding"; +}) { + return resolveProviderHttpRequestConfig({ + baseUrl: normalizeGoogleApiBaseUrl(params.baseUrl ?? DEFAULT_GOOGLE_API_BASE_URL), + defaultBaseUrl: DEFAULT_GOOGLE_API_BASE_URL, + allowPrivateNetwork: Boolean(params.baseUrl?.trim()), + headers: params.headers, + request: params.request, + defaultHeaders: parseGeminiAuth(params.apiKey).headers, + provider: "google", + api: "google-generative-ai", + capability: params.capability, + transport: params.transport, + }); +} + export const GOOGLE_GEMINI_DEFAULT_MODEL = "google/gemini-3.1-pro-preview"; export function applyGoogleGeminiModelDefault(cfg: OpenClawConfig): { diff --git a/extensions/google/gemini-cli-provider.ts b/extensions/google/gemini-cli-provider.ts index dff9d2a7eab..2c0360b7beb 100644 --- a/extensions/google/gemini-cli-provider.ts +++ b/extensions/google/gemini-cli-provider.ts @@ -5,6 +5,7 @@ import type { } from "openclaw/plugin-sdk/plugin-entry"; import { buildOauthProviderAuthResult } from "openclaw/plugin-sdk/provider-auth-result"; import { fetchGeminiUsage } from "openclaw/plugin-sdk/provider-usage"; +import { formatGoogleOauthApiKey, parseGoogleUsageToken } from "./oauth-token-shared.js"; import { isModernGoogleModel, resolveGoogle31ForwardCompatModel } from "./provider-models.js"; import { buildGoogleGeminiProviderHooks } from "./replay-policy.js"; @@ -22,32 +23,6 @@ const GOOGLE_GEMINI_CLI_PROVIDER_HOOKS = buildGoogleGeminiProviderHooks({ includeToolSchemaCompat: true, }); -function parseGoogleUsageToken(apiKey: string): string { - try { - const parsed = JSON.parse(apiKey) as { token?: unknown }; - if (typeof parsed?.token === "string") { - return parsed.token; - } - } catch { - // ignore - } - return apiKey; -} - -function formatGoogleOauthApiKey(cred: { - type?: string; - access?: string; - projectId?: string; -}): string { - if (cred.type !== "oauth" || typeof cred.access !== "string" || !cred.access.trim()) { - return ""; - } - return JSON.stringify({ - token: cred.access, - projectId: cred.projectId, - }); -} - async function fetchGeminiCliUsage(ctx: ProviderFetchUsageSnapshotContext) { return await fetchGeminiUsage(ctx.token, ctx.timeoutMs, ctx.fetchFn, PROVIDER_ID); } diff --git a/extensions/google/image-generation-provider.ts b/extensions/google/image-generation-provider.ts index f4393c4bfbe..4e10723509e 100644 --- a/extensions/google/image-generation-provider.ts +++ b/extensions/google/image-generation-provider.ts @@ -3,13 +3,10 @@ import { resolveApiKeyForProvider } from "openclaw/plugin-sdk/provider-auth-runt import { assertOkOrThrowHttpError, postJsonRequest, - resolveProviderHttpRequestConfig, } from "openclaw/plugin-sdk/provider-http"; import { - DEFAULT_GOOGLE_API_BASE_URL, - normalizeGoogleApiBaseUrl, normalizeGoogleModelId, - parseGeminiAuth, + resolveGoogleGenerativeAiHttpRequestConfig, } from "./api.js"; const DEFAULT_GOOGLE_IMAGE_MODEL = "gemini-3.1-flash-image-preview"; @@ -52,10 +49,6 @@ type GoogleGenerateImageResponse = { }>; }; -function resolveGoogleBaseUrl(cfg: Parameters[0]["cfg"]): string { - return normalizeGoogleApiBaseUrl(cfg?.models?.providers?.google?.baseUrl); -} - function normalizeGoogleImageModel(model: string | undefined): string { const trimmed = model?.trim(); return normalizeGoogleModelId(trimmed || DEFAULT_GOOGLE_IMAGE_MODEL); @@ -135,13 +128,9 @@ export function buildGoogleImageGenerationProvider(): ImageGenerationProvider { const model = normalizeGoogleImageModel(req.model); const { baseUrl, allowPrivateNetwork, headers, dispatcherPolicy } = - resolveProviderHttpRequestConfig({ - baseUrl: resolveGoogleBaseUrl(req.cfg), - defaultBaseUrl: DEFAULT_GOOGLE_API_BASE_URL, - allowPrivateNetwork: Boolean(req.cfg?.models?.providers?.google?.baseUrl?.trim()), - defaultHeaders: parseGeminiAuth(auth.apiKey).headers, - provider: "google", - api: "google-generative-ai", + resolveGoogleGenerativeAiHttpRequestConfig({ + apiKey: auth.apiKey, + baseUrl: req.cfg?.models?.providers?.google?.baseUrl, capability: "image", transport: "http", }); diff --git a/extensions/google/index.ts b/extensions/google/index.ts index 14238498289..238254270f4 100644 --- a/extensions/google/index.ts +++ b/extensions/google/index.ts @@ -16,6 +16,7 @@ import { normalizeGoogleModelId, } from "./api.js"; import { buildGoogleGeminiCliBackend } from "./cli-backend.js"; +import { formatGoogleOauthApiKey } from "./oauth-token-shared.js"; import { isModernGoogleModel, resolveGoogle31ForwardCompatModel } from "./provider-models.js"; import { buildGoogleGeminiProviderHooks } from "./replay-policy.js"; import { createGeminiWebSearchProvider } from "./src/gemini-web-search-provider.js"; @@ -30,12 +31,6 @@ const GOOGLE_GEMINI_CLI_ENV_VARS = [ "GEMINI_CLI_OAUTH_CLIENT_SECRET", ] as const; -type GoogleOauthApiKeyCredential = { - type?: string; - access?: string; - projectId?: string; -}; - let googleGeminiCliProviderPromise: Promise | null = null; let googleImageGenerationProviderPromise: Promise | null = null; let googleMediaUnderstandingProviderPromise: Promise | null = null; @@ -52,16 +47,6 @@ const GOOGLE_GEMINI_PROVIDER_HOOKS_WITH_TOOL_COMPAT = buildGoogleGeminiProviderH includeToolSchemaCompat: true, }); -function formatGoogleOauthApiKey(cred: GoogleOauthApiKeyCredential): string { - if (cred.type !== "oauth" || typeof cred.access !== "string" || !cred.access.trim()) { - return ""; - } - return JSON.stringify({ - token: cred.access, - projectId: cred.projectId, - }); -} - async function loadGoogleGeminiCliProvider(): Promise { if (!googleGeminiCliProviderPromise) { googleGeminiCliProviderPromise = import("./gemini-cli-provider.js").then((mod) => { @@ -147,7 +132,7 @@ function createLazyGoogleGeminiCliProvider(): ProviderPlugin { resolveGoogle31ForwardCompatModel({ providerId: GOOGLE_GEMINI_CLI_PROVIDER_ID, ctx }), ...GOOGLE_GEMINI_PROVIDER_HOOKS_WITH_TOOL_COMPAT, isModernModelRef: ({ modelId }) => isModernGoogleModel(modelId), - formatApiKey: (cred) => formatGoogleOauthApiKey(cred as GoogleOauthApiKeyCredential), + formatApiKey: (cred) => formatGoogleOauthApiKey(cred), resolveUsageAuth: async (ctx) => { const provider = await loadGoogleGeminiCliProvider(); return await provider.resolveUsageAuth?.(ctx); diff --git a/extensions/google/media-understanding-provider.ts b/extensions/google/media-understanding-provider.ts index 20ca3353fb8..744a3b60978 100644 --- a/extensions/google/media-understanding-provider.ts +++ b/extensions/google/media-understanding-provider.ts @@ -10,14 +10,12 @@ import { import { assertOkOrThrowHttpError, postJsonRequest, - resolveProviderHttpRequestConfig, type ProviderRequestTransportOverrides, } from "openclaw/plugin-sdk/provider-http"; import { DEFAULT_GOOGLE_API_BASE_URL, - normalizeGoogleApiBaseUrl, normalizeGoogleModelId, - parseGeminiAuth, + resolveGoogleGenerativeAiHttpRequestConfig, } from "./runtime-api.js"; export const DEFAULT_GOOGLE_AUDIO_BASE_URL = DEFAULT_GOOGLE_API_BASE_URL; @@ -54,19 +52,16 @@ async function generateGeminiInlineDataText(params: { return normalizeGoogleModelId(trimmed); })(); const { baseUrl, allowPrivateNetwork, headers, dispatcherPolicy } = - resolveProviderHttpRequestConfig({ - baseUrl: normalizeGoogleApiBaseUrl(params.baseUrl ?? params.defaultBaseUrl), - defaultBaseUrl: DEFAULT_GOOGLE_API_BASE_URL, - allowPrivateNetwork: Boolean(params.baseUrl?.trim()), + resolveGoogleGenerativeAiHttpRequestConfig({ + apiKey: params.apiKey, + baseUrl: params.baseUrl, headers: params.headers, request: params.request, - defaultHeaders: parseGeminiAuth(params.apiKey).headers, - provider: "google", - api: "google-generative-ai", capability: params.defaultMime.startsWith("audio/") ? "audio" : "video", transport: "media-understanding", }); - const url = `${baseUrl}/models/${model}:generateContent`; + const resolvedBaseUrl = baseUrl ?? params.defaultBaseUrl; + const url = `${resolvedBaseUrl}/models/${model}:generateContent`; const prompt = (() => { const trimmed = params.prompt?.trim(); diff --git a/extensions/google/media-understanding-provider.video.test.ts b/extensions/google/media-understanding-provider.video.test.ts index d4d903cc1b2..abf22c9b56f 100644 --- a/extensions/google/media-understanding-provider.video.test.ts +++ b/extensions/google/media-understanding-provider.video.test.ts @@ -62,6 +62,29 @@ describe("describeGeminiVideo", () => { expect(result.text).toBe("video ok"); }); + it("keeps private-network disabled for the default Google media endpoint", async () => { + const fetchFn = withFetchPreconnect(async () => { + return new Response( + JSON.stringify({ + candidates: [{ content: { parts: [{ text: "video ok" }] } }], + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + }); + + await describeGeminiVideo({ + buffer: Buffer.from("video"), + fileName: "clip.mp4", + apiKey: "test-key", + timeoutMs: 1000, + fetchFn, + }); + + expect(resolvePinnedHostnameWithPolicySpy).toHaveBeenCalled(); + const [, options] = resolvePinnedHostnameWithPolicySpy.mock.calls[0] ?? []; + expect(options?.policy?.allowPrivateNetwork).toBeUndefined(); + }); + it("builds the expected request payload", async () => { const { fetchFn, getRequest } = createRequestCaptureJsonFetch({ candidates: [ diff --git a/extensions/google/oauth-token-shared.test.ts b/extensions/google/oauth-token-shared.test.ts new file mode 100644 index 00000000000..8bb0aeb7ed4 --- /dev/null +++ b/extensions/google/oauth-token-shared.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { + formatGoogleOauthApiKey, + parseGoogleOauthApiKey, + parseGoogleUsageToken, +} from "./oauth-token-shared.js"; + +describe("google oauth token helpers", () => { + it("formats oauth credentials with project-aware payloads", () => { + expect( + formatGoogleOauthApiKey({ + type: "oauth", + access: "token-123", + projectId: "project-abc", + }), + ).toBe(JSON.stringify({ token: "token-123", projectId: "project-abc" })); + }); + + it("returns an empty string for non-oauth credentials", () => { + expect(formatGoogleOauthApiKey({ type: "token", access: "token-123" })).toBe(""); + }); + + it("parses project-aware oauth payloads for usage auth", () => { + expect(parseGoogleUsageToken(JSON.stringify({ token: "usage-token" }))).toBe("usage-token"); + }); + + it("parses structured oauth payload fields", () => { + expect( + parseGoogleOauthApiKey(JSON.stringify({ token: "usage-token", projectId: "proj-1" })), + ).toEqual({ + token: "usage-token", + projectId: "proj-1", + }); + }); + + it("falls back to the raw token when the payload is not JSON", () => { + expect(parseGoogleUsageToken("raw-token")).toBe("raw-token"); + }); +}); diff --git a/extensions/google/oauth-token-shared.ts b/extensions/google/oauth-token-shared.ts new file mode 100644 index 00000000000..1283723640b --- /dev/null +++ b/extensions/google/oauth-token-shared.ts @@ -0,0 +1,40 @@ +type GoogleOauthApiKeyCredential = { + type?: string; + access?: string; + projectId?: string; +}; + +export function parseGoogleOauthApiKey(apiKey: string): { + token?: string; + projectId?: string; +} | null { + try { + const parsed = JSON.parse(apiKey) as { token?: unknown; projectId?: unknown }; + return { + token: typeof parsed.token === "string" ? parsed.token : undefined, + projectId: typeof parsed.projectId === "string" ? parsed.projectId : undefined, + }; + } catch { + return null; + } +} + +export function formatGoogleOauthApiKey(cred: GoogleOauthApiKeyCredential): string { + if (cred.type !== "oauth" || typeof cred.access !== "string" || !cred.access.trim()) { + return ""; + } + return JSON.stringify({ + token: cred.access, + projectId: cred.projectId, + }); +} + +export function parseGoogleUsageToken(apiKey: string): string { + const parsed = parseGoogleOauthApiKey(apiKey); + if (parsed?.token) { + return parsed.token; + } + + // Keep the raw token when the stored credential is not a project-aware JSON payload. + return apiKey; +} diff --git a/extensions/google/runtime-api.ts b/extensions/google/runtime-api.ts index b1827bd4b58..8115792bfa9 100644 --- a/extensions/google/runtime-api.ts +++ b/extensions/google/runtime-api.ts @@ -3,4 +3,5 @@ export { normalizeGoogleApiBaseUrl, normalizeGoogleModelId, parseGeminiAuth, + resolveGoogleGenerativeAiHttpRequestConfig, } from "./api.js"; diff --git a/extensions/xai/code-execution.ts b/extensions/xai/code-execution.ts index 9560ee38359..de0f7fb9ddc 100644 --- a/extensions/xai/code-execution.ts +++ b/extensions/xai/code-execution.ts @@ -3,10 +3,7 @@ import { getRuntimeConfigSnapshot } from "openclaw/plugin-sdk/config-runtime"; import type { OpenClawConfig } from "openclaw/plugin-sdk/plugin-entry"; import { jsonResult, - readConfiguredSecretString, - readProviderEnvValue, readStringParam, - resolveProviderWebSearchPluginConfig, } from "openclaw/plugin-sdk/provider-web-search"; import { buildXaiCodeExecutionPayload, @@ -14,6 +11,7 @@ import { resolveXaiCodeExecutionMaxTurns, resolveXaiCodeExecutionModel, } from "./src/code-execution-shared.js"; +import { isXaiToolEnabled, resolveXaiToolApiKey } from "./src/tool-auth-shared.js"; type XaiPluginConfig = NonNullable< NonNullable["entries"] @@ -36,18 +34,6 @@ function readCodeExecutionConfigRecord( return config && typeof config === "object" ? (config as Record) : undefined; } -function readLegacyGrokApiKey(cfg?: OpenClawConfig): string | undefined { - const search = cfg?.tools?.web?.search; - if (!search || typeof search !== "object") { - return undefined; - } - const grok = (search as Record).grok; - return readConfiguredSecretString( - grok && typeof grok === "object" ? (grok as Record).apiKey : undefined, - "tools.web.search.grok.apiKey", - ); -} - function readPluginCodeExecutionConfig(cfg?: OpenClawConfig): CodeExecutionConfig | undefined { const entries = cfg?.plugins?.entries; if (!entries || typeof entries !== "object") { @@ -68,29 +54,16 @@ function readPluginCodeExecutionConfig(cfg?: OpenClawConfig): CodeExecutionConfi return codeExecution as CodeExecutionConfig; } -function resolveFallbackXaiApiKey(cfg?: OpenClawConfig): string | undefined { - return ( - readConfiguredSecretString( - resolveProviderWebSearchPluginConfig(cfg as Record | undefined, "xai") - ?.apiKey, - "plugins.entries.xai.config.webSearch.apiKey", - ) ?? readLegacyGrokApiKey(cfg) - ); -} - function resolveCodeExecutionEnabled(params: { sourceConfig?: OpenClawConfig; runtimeConfig?: OpenClawConfig; config?: CodeExecutionConfig; }): boolean { - if (readCodeExecutionConfigRecord(params.config)?.enabled === false) { - return false; - } - return Boolean( - resolveFallbackXaiApiKey(params.runtimeConfig) ?? - resolveFallbackXaiApiKey(params.sourceConfig) ?? - readProviderEnvValue(["XAI_API_KEY"]), - ); + return isXaiToolEnabled({ + enabled: readCodeExecutionConfigRecord(params.config)?.enabled as boolean | undefined, + runtimeConfig: params.runtimeConfig, + sourceConfig: params.sourceConfig, + }); } export function createCodeExecutionTool(options?: { @@ -123,10 +96,10 @@ export function createCodeExecutionTool(options?: { }), }), execute: async (_toolCallId: string, args: Record) => { - const apiKey = - resolveFallbackXaiApiKey(runtimeConfig ?? undefined) ?? - resolveFallbackXaiApiKey(options?.config) ?? - readProviderEnvValue(["XAI_API_KEY"]); + const apiKey = resolveXaiToolApiKey({ + runtimeConfig: runtimeConfig ?? undefined, + sourceConfig: options?.config, + }); if (!apiKey) { return jsonResult({ error: "missing_xai_api_key", diff --git a/extensions/xai/index.ts b/extensions/xai/index.ts index db8a1fb026b..f5d1b5fe53e 100644 --- a/extensions/xai/index.ts +++ b/extensions/xai/index.ts @@ -1,8 +1,5 @@ import { Type } from "@sinclair/typebox"; -import { - coerceSecretRef, - resolveNonEnvSecretRefApiKeyMarker, -} from "openclaw/plugin-sdk/provider-auth"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/plugin-entry"; import { defineSingleProviderPluginEntry } from "openclaw/plugin-sdk/provider-entry"; import { buildOpenAICompatibleReplayPolicy } from "openclaw/plugin-sdk/provider-model-shared"; import { @@ -12,9 +9,7 @@ import { import { jsonResult, readProviderEnvValue, - resolveProviderWebSearchPluginConfig, } from "openclaw/plugin-sdk/provider-web-search"; -import { normalizeSecretInputString } from "openclaw/plugin-sdk/secret-input"; import { applyXaiModelCompat, normalizeXaiModelId, @@ -31,64 +26,15 @@ import { createXaiToolCallArgumentDecodingWrapper, createXaiToolPayloadCompatibilityWrapper, } from "./stream.js"; +import { resolveFallbackXaiAuth } from "./src/tool-auth-shared.js"; import { createXaiWebSearchProvider } from "./web-search.js"; const PROVIDER_ID = "xai"; -function readConfiguredOrManagedApiKey(value: unknown): string | undefined { - const literal = normalizeSecretInputString(value); - if (literal) { - return literal; - } - const ref = coerceSecretRef(value); - return ref ? resolveNonEnvSecretRefApiKeyMarker(ref.source) : undefined; -} - -function readLegacyGrokFallback( - config: Record, -): { apiKey: string; source: string } | undefined { - const tools = config.tools; - if (!tools || typeof tools !== "object") { - return undefined; - } - const web = (tools as Record).web; - if (!web || typeof web !== "object") { - return undefined; - } - const search = (web as Record).search; - if (!search || typeof search !== "object") { - return undefined; - } - const grok = (search as Record).grok; - if (!grok || typeof grok !== "object") { - return undefined; - } - const apiKey = readConfiguredOrManagedApiKey((grok as Record).apiKey); - return apiKey ? { apiKey, source: "tools.web.search.grok.apiKey" } : undefined; -} - -function resolveXaiProviderFallbackAuth( - config: unknown, -): { apiKey: string; source: string } | undefined { - if (!config || typeof config !== "object") { - return undefined; - } - const record = config as Record; - const pluginApiKey = readConfiguredOrManagedApiKey( - resolveProviderWebSearchPluginConfig(record, PROVIDER_ID)?.apiKey, - ); - if (pluginApiKey) { - return { - apiKey: pluginApiKey, - source: "plugins.entries.xai.config.webSearch.apiKey", - }; - } - return readLegacyGrokFallback(record); -} - function hasResolvableXaiApiKey(config: unknown): boolean { return Boolean( - resolveXaiProviderFallbackAuth(config)?.apiKey || readProviderEnvValue(["XAI_API_KEY"]), + resolveFallbackXaiAuth(config as OpenClawConfig | undefined)?.apiKey || + readProviderEnvValue(["XAI_API_KEY"]), ); } @@ -283,7 +229,7 @@ export default defineSingleProviderPluginEntry({ // private config layout. Callers may receive a real key from the active // runtime snapshot or a non-secret SecretRef marker from source config. resolveSyntheticAuth: ({ config }) => { - const fallbackAuth = resolveXaiProviderFallbackAuth(config); + const fallbackAuth = resolveFallbackXaiAuth(config as OpenClawConfig | undefined); if (!fallbackAuth) { return undefined; } diff --git a/extensions/xai/src/code-execution-shared.ts b/extensions/xai/src/code-execution-shared.ts index 1c6f2d69926..f274348eba5 100644 --- a/extensions/xai/src/code-execution-shared.ts +++ b/extensions/xai/src/code-execution-shared.ts @@ -1,8 +1,17 @@ import { postTrustedWebToolsJson } from "openclaw/plugin-sdk/provider-web-search"; -import { normalizeXaiModelId } from "../model-id.js"; -import { extractXaiWebSearchContent, type XaiWebSearchResponse } from "./web-search-shared.js"; +import { + buildXaiResponsesToolBody, + resolveXaiResponseTextAndCitations, + XAI_RESPONSES_ENDPOINT, +} from "./responses-tool-shared.js"; +import { + coerceXaiToolConfig, + resolveNormalizedXaiToolModel, + resolvePositiveIntegerToolConfig, +} from "./tool-config-shared.js"; +import { type XaiWebSearchResponse } from "./web-search-shared.js"; -export const XAI_CODE_EXECUTION_ENDPOINT = "https://api.x.ai/v1/responses"; +export const XAI_CODE_EXECUTION_ENDPOINT = XAI_RESPONSES_ENDPOINT; export const XAI_DEFAULT_CODE_EXECUTION_MODEL = "grok-4-1-fast"; export type XaiCodeExecutionConfig = { @@ -24,32 +33,23 @@ export type XaiCodeExecutionResult = { outputTypes: string[]; }; -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - export function resolveXaiCodeExecutionConfig( config?: Record, ): XaiCodeExecutionConfig { - return isRecord(config) ? (config as XaiCodeExecutionConfig) : {}; + return coerceXaiToolConfig(config); } export function resolveXaiCodeExecutionModel(config?: Record): string { - const resolved = resolveXaiCodeExecutionConfig(config); - return typeof resolved.model === "string" && resolved.model.trim() - ? normalizeXaiModelId(resolved.model.trim()) - : XAI_DEFAULT_CODE_EXECUTION_MODEL; + return resolveNormalizedXaiToolModel({ + config, + defaultModel: XAI_DEFAULT_CODE_EXECUTION_MODEL, + }); } export function resolveXaiCodeExecutionMaxTurns( config?: Record, ): number | undefined { - const raw = resolveXaiCodeExecutionConfig(config).maxTurns; - if (typeof raw !== "number" || !Number.isFinite(raw)) { - return undefined; - } - const normalized = Math.trunc(raw); - return normalized > 0 ? normalized : undefined; + return resolvePositiveIntegerToolConfig(config, "maxTurns"); } export function buildXaiCodeExecutionPayload(params: { @@ -85,17 +85,17 @@ export async function requestXaiCodeExecution(params: { url: XAI_CODE_EXECUTION_ENDPOINT, timeoutSeconds: params.timeoutSeconds, apiKey: params.apiKey, - body: { + body: buildXaiResponsesToolBody({ model: params.model, - input: [{ role: "user", content: params.task }], + inputText: params.task, tools: [{ type: "code_interpreter" }], - ...(params.maxTurns ? { max_turns: params.maxTurns } : {}), - }, + maxTurns: params.maxTurns, + }), errorLabel: "xAI", }, async (response) => { const data = (await response.json()) as XaiCodeExecutionResponse; - const { text, annotationCitations } = extractXaiWebSearchContent(data); + const { content, citations } = resolveXaiResponseTextAndCitations(data); const outputTypes = Array.isArray(data.output) ? [ ...new Set( @@ -105,12 +105,8 @@ export async function requestXaiCodeExecution(params: { ), ] : []; - const citations = - Array.isArray(data.citations) && data.citations.length > 0 - ? data.citations - : annotationCitations; return { - content: text ?? "No response", + content, citations, usedCodeExecution: outputTypes.includes("code_interpreter_call"), outputTypes, diff --git a/extensions/xai/src/responses-tool-shared.test.ts b/extensions/xai/src/responses-tool-shared.test.ts new file mode 100644 index 00000000000..1bcd65a85e6 --- /dev/null +++ b/extensions/xai/src/responses-tool-shared.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { __testing } from "./responses-tool-shared.js"; + +describe("xai responses tool helpers", () => { + it("builds the shared xAI Responses tool body", () => { + expect( + __testing.buildXaiResponsesToolBody({ + model: "grok-4-1-fast", + inputText: "search for openclaw", + tools: [{ type: "x_search" }], + maxTurns: 2, + }), + ).toEqual({ + model: "grok-4-1-fast", + input: [{ role: "user", content: "search for openclaw" }], + tools: [{ type: "x_search" }], + max_turns: 2, + }); + }); + + it("falls back to annotation citations when the API omits top-level citations", () => { + expect( + __testing.resolveXaiResponseTextAndCitations({ + output: [ + { + type: "message", + content: [ + { + type: "output_text", + text: "Found it", + annotations: [{ type: "url_citation", url: "https://example.com/a" }], + }, + ], + }, + ], + }), + ).toEqual({ + content: "Found it", + citations: ["https://example.com/a"], + }); + }); + + it("prefers explicit top-level citations when present", () => { + expect( + __testing.resolveXaiResponseTextAndCitations({ + output_text: "Done", + citations: ["https://example.com/b"], + }), + ).toEqual({ + content: "Done", + citations: ["https://example.com/b"], + }); + }); +}); diff --git a/extensions/xai/src/responses-tool-shared.ts b/extensions/xai/src/responses-tool-shared.ts new file mode 100644 index 00000000000..4dd641b2d3e --- /dev/null +++ b/extensions/xai/src/responses-tool-shared.ts @@ -0,0 +1,73 @@ +import type { XaiWebSearchResponse } from "./web-search-shared.js"; + +export const XAI_RESPONSES_ENDPOINT = "https://api.x.ai/v1/responses"; + +export function buildXaiResponsesToolBody(params: { + model: string; + inputText: string; + tools: Array>; + maxTurns?: number; +}): Record { + return { + model: params.model, + input: [{ role: "user", content: params.inputText }], + tools: params.tools, + ...(params.maxTurns ? { max_turns: params.maxTurns } : {}), + }; +} + +export function extractXaiWebSearchContent(data: XaiWebSearchResponse): { + text: string | undefined; + annotationCitations: string[]; +} { + for (const output of data.output ?? []) { + if (output.type === "message") { + for (const block of output.content ?? []) { + if (block.type === "output_text" && typeof block.text === "string" && block.text) { + const urls = (block.annotations ?? []) + .filter( + (annotation) => + annotation.type === "url_citation" && typeof annotation.url === "string", + ) + .map((annotation) => annotation.url as string); + return { text: block.text, annotationCitations: [...new Set(urls)] }; + } + } + } + + if (output.type === "output_text" && typeof output.text === "string" && output.text) { + const urls = (output.annotations ?? []) + .filter( + (annotation) => annotation.type === "url_citation" && typeof annotation.url === "string", + ) + .map((annotation) => annotation.url as string); + return { text: output.text, annotationCitations: [...new Set(urls)] }; + } + } + + return { + text: typeof data.output_text === "string" ? data.output_text : undefined, + annotationCitations: [], + }; +} + +export function resolveXaiResponseTextAndCitations(data: XaiWebSearchResponse): { + content: string; + citations: string[]; +} { + const { text, annotationCitations } = extractXaiWebSearchContent(data); + return { + content: text ?? "No response", + citations: + Array.isArray(data.citations) && data.citations.length > 0 + ? data.citations + : annotationCitations, + }; +} + +export const __testing = { + buildXaiResponsesToolBody, + extractXaiWebSearchContent, + resolveXaiResponseTextAndCitations, + XAI_RESPONSES_ENDPOINT, +} as const; diff --git a/extensions/xai/src/tool-auth-shared.test.ts b/extensions/xai/src/tool-auth-shared.test.ts new file mode 100644 index 00000000000..4ab7ab1ae92 --- /dev/null +++ b/extensions/xai/src/tool-auth-shared.test.ts @@ -0,0 +1,140 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { NON_ENV_SECRETREF_MARKER } from "openclaw/plugin-sdk/provider-auth-runtime"; +import { + isXaiToolEnabled, + resolveFallbackXaiAuth, + resolveFallbackXaiApiKey, + resolveXaiToolApiKey, +} from "./tool-auth-shared.js"; + +describe("xai tool auth helpers", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("prefers plugin web search keys over legacy grok keys", () => { + expect( + resolveFallbackXaiApiKey({ + plugins: { + entries: { + xai: { + config: { + webSearch: { + apiKey: "plugin-key", // pragma: allowlist secret + }, + }, + }, + }, + }, + tools: { + web: { + search: { + grok: { + apiKey: "legacy-key", // pragma: allowlist secret + }, + }, + }, + }, + }), + ).toBe("plugin-key"); + }); + + it("returns source metadata and managed markers for fallback auth", () => { + expect( + resolveFallbackXaiAuth({ + plugins: { + entries: { + xai: { + config: { + webSearch: { + apiKey: { source: "file", provider: "vault", id: "/xai/tool-key" }, + }, + }, + }, + }, + }, + }), + ).toEqual({ + apiKey: NON_ENV_SECRETREF_MARKER, + source: "plugins.entries.xai.config.webSearch.apiKey", + }); + + expect( + resolveFallbackXaiAuth({ + tools: { + web: { + search: { + grok: { + apiKey: "legacy-key", // pragma: allowlist secret + }, + }, + }, + }, + }), + ).toEqual({ + apiKey: "legacy-key", + source: "tools.web.search.grok.apiKey", + }); + }); + + it("falls back to runtime, then source config, then env for tool auth", () => { + vi.stubEnv("XAI_API_KEY", "env-key"); + + expect( + resolveXaiToolApiKey({ + runtimeConfig: { + plugins: { + entries: { + xai: { + config: { + webSearch: { + apiKey: "runtime-key", // pragma: allowlist secret + }, + }, + }, + }, + }, + }, + sourceConfig: { + plugins: { + entries: { + xai: { + config: { + webSearch: { + apiKey: "source-key", // pragma: allowlist secret + }, + }, + }, + }, + }, + }, + }), + ).toBe("runtime-key"); + + expect( + resolveXaiToolApiKey({ + sourceConfig: { + plugins: { + entries: { + xai: { + config: { + webSearch: { + apiKey: "source-key", // pragma: allowlist secret + }, + }, + }, + }, + }, + }, + }), + ).toBe("source-key"); + + expect(resolveXaiToolApiKey({})).toBe("env-key"); + }); + + it("honors explicit disabled flags before auth fallback", () => { + vi.stubEnv("XAI_API_KEY", "env-key"); + expect(isXaiToolEnabled({ enabled: false })).toBe(false); + expect(isXaiToolEnabled({ enabled: true })).toBe(true); + }); +}); diff --git a/extensions/xai/src/tool-auth-shared.ts b/extensions/xai/src/tool-auth-shared.ts new file mode 100644 index 00000000000..0ef880bb673 --- /dev/null +++ b/extensions/xai/src/tool-auth-shared.ts @@ -0,0 +1,95 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/plugin-entry"; +import { + coerceSecretRef, + resolveNonEnvSecretRefApiKeyMarker, +} from "openclaw/plugin-sdk/provider-auth"; +import { + readProviderEnvValue, + readConfiguredSecretString, + resolveProviderWebSearchPluginConfig, +} from "openclaw/plugin-sdk/provider-web-search"; +import { normalizeSecretInputString } from "openclaw/plugin-sdk/secret-input"; + +export type XaiFallbackAuth = { + apiKey: string; + source: string; +}; + +function readConfiguredOrManagedApiKey(value: unknown): string | undefined { + const literal = normalizeSecretInputString(value); + if (literal) { + return literal; + } + const ref = coerceSecretRef(value); + return ref ? resolveNonEnvSecretRefApiKeyMarker(ref.source) : undefined; +} + +function readLegacyGrokFallbackAuth(cfg?: OpenClawConfig): XaiFallbackAuth | undefined { + const search = cfg?.tools?.web?.search; + if (!search || typeof search !== "object") { + return undefined; + } + const grok = (search as Record).grok; + const apiKey = readConfiguredOrManagedApiKey( + grok && typeof grok === "object" ? (grok as Record).apiKey : undefined, + ); + return apiKey ? { apiKey, source: "tools.web.search.grok.apiKey" } : undefined; +} + +export function readLegacyGrokApiKey(cfg?: OpenClawConfig): string | undefined { + const search = cfg?.tools?.web?.search; + if (!search || typeof search !== "object") { + return undefined; + } + const grok = (search as Record).grok; + return readConfiguredSecretString( + grok && typeof grok === "object" ? (grok as Record).apiKey : undefined, + "tools.web.search.grok.apiKey", + ); +} + +export function readPluginXaiWebSearchApiKey(cfg?: OpenClawConfig): string | undefined { + return readConfiguredSecretString( + resolveProviderWebSearchPluginConfig(cfg as Record | undefined, "xai")?.apiKey, + "plugins.entries.xai.config.webSearch.apiKey", + ); +} + +export function resolveFallbackXaiAuth(cfg?: OpenClawConfig): XaiFallbackAuth | undefined { + const pluginApiKey = readConfiguredOrManagedApiKey( + resolveProviderWebSearchPluginConfig(cfg as Record | undefined, "xai")?.apiKey, + ); + if (pluginApiKey) { + return { + apiKey: pluginApiKey, + source: "plugins.entries.xai.config.webSearch.apiKey", + }; + } + return readLegacyGrokFallbackAuth(cfg); +} + +export function resolveFallbackXaiApiKey(cfg?: OpenClawConfig): string | undefined { + return readPluginXaiWebSearchApiKey(cfg) ?? readLegacyGrokApiKey(cfg); +} + +export function resolveXaiToolApiKey(params: { + runtimeConfig?: OpenClawConfig; + sourceConfig?: OpenClawConfig; +}): string | undefined { + return ( + resolveFallbackXaiApiKey(params.runtimeConfig) ?? + resolveFallbackXaiApiKey(params.sourceConfig) ?? + readProviderEnvValue(["XAI_API_KEY"]) + ); +} + +export function isXaiToolEnabled(params: { + enabled?: boolean; + runtimeConfig?: OpenClawConfig; + sourceConfig?: OpenClawConfig; +}): boolean { + if (params.enabled === false) { + return false; + } + return Boolean(resolveXaiToolApiKey(params)); +} diff --git a/extensions/xai/src/tool-config-shared.test.ts b/extensions/xai/src/tool-config-shared.test.ts new file mode 100644 index 00000000000..ac93c409453 --- /dev/null +++ b/extensions/xai/src/tool-config-shared.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { + coerceXaiToolConfig, + resolveNormalizedXaiToolModel, + resolvePositiveIntegerToolConfig, +} from "./tool-config-shared.js"; + +describe("xai tool config helpers", () => { + it("coerces non-record config to an empty object", () => { + expect(coerceXaiToolConfig(undefined)).toEqual({}); + expect(coerceXaiToolConfig([] as unknown as Record)).toEqual({}); + }); + + it("normalizes configured model ids and falls back to the default model", () => { + expect( + resolveNormalizedXaiToolModel({ + config: { model: " grok-4.1-fast " }, + defaultModel: "grok-4-1-fast", + }), + ).toBe("grok-4.1-fast"); + + expect( + resolveNormalizedXaiToolModel({ + config: {}, + defaultModel: "grok-4-1-fast", + }), + ).toBe("grok-4-1-fast"); + }); + + it("accepts only positive finite numeric turn counts", () => { + expect(resolvePositiveIntegerToolConfig({ maxTurns: 2.9 }, "maxTurns")).toBe(2); + expect(resolvePositiveIntegerToolConfig({ maxTurns: 0 }, "maxTurns")).toBeUndefined(); + expect(resolvePositiveIntegerToolConfig({ maxTurns: Number.NaN }, "maxTurns")).toBeUndefined(); + expect(resolvePositiveIntegerToolConfig(undefined, "maxTurns")).toBeUndefined(); + }); +}); diff --git a/extensions/xai/src/tool-config-shared.ts b/extensions/xai/src/tool-config-shared.ts new file mode 100644 index 00000000000..c2ce3111c29 --- /dev/null +++ b/extensions/xai/src/tool-config-shared.ts @@ -0,0 +1,33 @@ +import { normalizeXaiModelId } from "../model-id.js"; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function coerceXaiToolConfig>( + config: Record | undefined, +): TConfig { + return isRecord(config) ? (config as TConfig) : ({} as TConfig); +} + +export function resolveNormalizedXaiToolModel(params: { + config?: Record; + defaultModel: string; +}): string { + const value = coerceXaiToolConfig<{ model?: unknown }>(params.config).model; + return typeof value === "string" && value.trim() + ? normalizeXaiModelId(value.trim()) + : params.defaultModel; +} + +export function resolvePositiveIntegerToolConfig( + config: Record | undefined, + key: string, +): number | undefined { + const raw = coerceXaiToolConfig>(config)[key]; + if (typeof raw !== "number" || !Number.isFinite(raw)) { + return undefined; + } + const normalized = Math.trunc(raw); + return normalized > 0 ? normalized : undefined; +} diff --git a/extensions/xai/src/web-search-shared.ts b/extensions/xai/src/web-search-shared.ts index b93b0fcaaaf..1c914e642fd 100644 --- a/extensions/xai/src/web-search-shared.ts +++ b/extensions/xai/src/web-search-shared.ts @@ -1,7 +1,14 @@ import { postTrustedWebToolsJson, wrapWebContent } from "openclaw/plugin-sdk/provider-web-search"; import { normalizeXaiModelId } from "../model-id.js"; +import { + buildXaiResponsesToolBody, + extractXaiWebSearchContent, + resolveXaiResponseTextAndCitations, + XAI_RESPONSES_ENDPOINT, +} from "./responses-tool-shared.js"; +export { extractXaiWebSearchContent } from "./responses-tool-shared.js"; -export const XAI_WEB_SEARCH_ENDPOINT = "https://api.x.ai/v1/responses"; +export const XAI_WEB_SEARCH_ENDPOINT = XAI_RESPONSES_ENDPOINT; export const XAI_DEFAULT_WEB_SEARCH_MODEL = "grok-4-1-fast"; export type XaiWebSearchResponse = { @@ -88,41 +95,6 @@ export function resolveXaiInlineCitations(searchConfig?: Record return resolveXaiSearchConfig(searchConfig).inlineCitations === true; } -export function extractXaiWebSearchContent(data: XaiWebSearchResponse): { - text: string | undefined; - annotationCitations: string[]; -} { - for (const output of data.output ?? []) { - if (output.type === "message") { - for (const block of output.content ?? []) { - if (block.type === "output_text" && typeof block.text === "string" && block.text) { - const urls = (block.annotations ?? []) - .filter( - (annotation) => - annotation.type === "url_citation" && typeof annotation.url === "string", - ) - .map((annotation) => annotation.url as string); - return { text: block.text, annotationCitations: [...new Set(urls)] }; - } - } - } - - if (output.type === "output_text" && typeof output.text === "string" && output.text) { - const urls = (output.annotations ?? []) - .filter( - (annotation) => annotation.type === "url_citation" && typeof annotation.url === "string", - ) - .map((annotation) => annotation.url as string); - return { text: output.text, annotationCitations: [...new Set(urls)] }; - } - } - - return { - text: typeof data.output_text === "string" ? data.output_text : undefined, - annotationCitations: [], - }; -} - export async function requestXaiWebSearch(params: { query: string; model: string; @@ -135,22 +107,18 @@ export async function requestXaiWebSearch(params: { url: XAI_WEB_SEARCH_ENDPOINT, timeoutSeconds: params.timeoutSeconds, apiKey: params.apiKey, - body: { + body: buildXaiResponsesToolBody({ model: params.model, - input: [{ role: "user", content: params.query }], + inputText: params.query, tools: [{ type: "web_search" }], - }, + }), errorLabel: "xAI", }, async (response) => { const data = (await response.json()) as XaiWebSearchResponse; - const { text, annotationCitations } = extractXaiWebSearchContent(data); - const citations = - Array.isArray(data.citations) && data.citations.length > 0 - ? data.citations - : annotationCitations; + const { content, citations } = resolveXaiResponseTextAndCitations(data); return { - content: text ?? "No response", + content, citations, inlineCitations: params.inlineCitations && Array.isArray(data.inline_citations) diff --git a/extensions/xai/src/x-search-shared.ts b/extensions/xai/src/x-search-shared.ts index 7f997a7360e..d4295063b80 100644 --- a/extensions/xai/src/x-search-shared.ts +++ b/extensions/xai/src/x-search-shared.ts @@ -1,8 +1,17 @@ import { postTrustedWebToolsJson, wrapWebContent } from "openclaw/plugin-sdk/provider-web-search"; -import { normalizeXaiModelId } from "../model-id.js"; -import { extractXaiWebSearchContent, type XaiWebSearchResponse } from "./web-search-shared.js"; +import { + buildXaiResponsesToolBody, + resolveXaiResponseTextAndCitations, + XAI_RESPONSES_ENDPOINT, +} from "./responses-tool-shared.js"; +import { + coerceXaiToolConfig, + resolveNormalizedXaiToolModel, + resolvePositiveIntegerToolConfig, +} from "./tool-config-shared.js"; +import { type XaiWebSearchResponse } from "./web-search-shared.js"; -export const XAI_X_SEARCH_ENDPOINT = "https://api.x.ai/v1/responses"; +export const XAI_X_SEARCH_ENDPOINT = XAI_RESPONSES_ENDPOINT; export const XAI_DEFAULT_X_SEARCH_MODEL = "grok-4-1-fast-non-reasoning"; export type XaiXSearchConfig = { @@ -28,19 +37,15 @@ export type XaiXSearchResult = { inlineCitations?: XaiWebSearchResponse["inline_citations"]; }; -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - export function resolveXaiXSearchConfig(config?: Record): XaiXSearchConfig { - return isRecord(config) ? (config as XaiXSearchConfig) : {}; + return coerceXaiToolConfig(config); } export function resolveXaiXSearchModel(config?: Record): string { - const resolved = resolveXaiXSearchConfig(config); - return typeof resolved.model === "string" && resolved.model.trim() - ? normalizeXaiModelId(resolved.model.trim()) - : XAI_DEFAULT_X_SEARCH_MODEL; + return resolveNormalizedXaiToolModel({ + config, + defaultModel: XAI_DEFAULT_X_SEARCH_MODEL, + }); } export function resolveXaiXSearchInlineCitations(config?: Record): boolean { @@ -48,12 +53,7 @@ export function resolveXaiXSearchInlineCitations(config?: Record): number | undefined { - const raw = resolveXaiXSearchConfig(config).maxTurns; - if (typeof raw !== "number" || !Number.isFinite(raw)) { - return undefined; - } - const normalized = Math.trunc(raw); - return normalized > 0 ? normalized : undefined; + return resolvePositiveIntegerToolConfig(config, "maxTurns"); } function buildXSearchTool(options: XaiXSearchOptions): Record { @@ -117,23 +117,19 @@ export async function requestXaiXSearch(params: { url: XAI_X_SEARCH_ENDPOINT, timeoutSeconds: params.timeoutSeconds, apiKey: params.apiKey, - body: { + body: buildXaiResponsesToolBody({ model: params.model, - input: [{ role: "user", content: params.options.query }], + inputText: params.options.query, tools: [buildXSearchTool(params.options)], - ...(params.maxTurns ? { max_turns: params.maxTurns } : {}), - }, + maxTurns: params.maxTurns, + }), errorLabel: "xAI", }, async (response) => { const data = (await response.json()) as XaiWebSearchResponse; - const { text, annotationCitations } = extractXaiWebSearchContent(data); - const citations = - Array.isArray(data.citations) && data.citations.length > 0 - ? data.citations - : annotationCitations; + const { content, citations } = resolveXaiResponseTextAndCitations(data); return { - content: text ?? "No response", + content, citations, inlineCitations: params.inlineCitations && Array.isArray(data.inline_citations) diff --git a/extensions/xai/x-search.ts b/extensions/xai/x-search.ts index 9de4f7b77c0..d47ab26367d 100644 --- a/extensions/xai/x-search.ts +++ b/extensions/xai/x-search.ts @@ -4,12 +4,9 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/plugin-entry"; import { jsonResult, readCache, - readConfiguredSecretString, - readProviderEnvValue, readStringArrayParam, readStringParam, resolveCacheTtlMs, - resolveProviderWebSearchPluginConfig, resolveTimeoutSeconds, writeCache, } from "openclaw/plugin-sdk/provider-web-search"; @@ -17,6 +14,7 @@ import { resolveEffectiveXSearchConfig, resolveLegacyXSearchConfig, } from "./src/x-search-config.js"; +import { isXaiToolEnabled, resolveXaiToolApiKey } from "./src/tool-auth-shared.js"; import { buildXaiXSearchPayload, requestXaiXSearch, @@ -54,29 +52,6 @@ function getSharedXSearchCache(): Map { const X_SEARCH_CACHE = getSharedXSearchCache(); -function readLegacyGrokApiKey(cfg?: OpenClawConfig): string | undefined { - const search = cfg?.tools?.web?.search; - if (!search || typeof search !== "object") { - return undefined; - } - const grok = (search as Record).grok; - return readConfiguredSecretString( - grok && typeof grok === "object" ? (grok as Record).apiKey : undefined, - "tools.web.search.grok.apiKey", - ); -} - -function readPluginXaiWebSearchApiKey(cfg?: OpenClawConfig): string | undefined { - return readConfiguredSecretString( - resolveProviderWebSearchPluginConfig(cfg as Record | undefined, "xai")?.apiKey, - "plugins.entries.xai.config.webSearch.apiKey", - ); -} - -function resolveFallbackXaiApiKey(cfg?: OpenClawConfig): string | undefined { - return readPluginXaiWebSearchApiKey(cfg) ?? readLegacyGrokApiKey(cfg); -} - function resolveXSearchConfig(cfg?: OpenClawConfig): Record | undefined { return resolveEffectiveXSearchConfig(cfg); } @@ -86,24 +61,18 @@ function resolveXSearchEnabled(params: { config?: Record; runtimeConfig?: OpenClawConfig; }): boolean { - if (params.config?.enabled === false) { - return false; - } - if (resolveFallbackXaiApiKey(params.runtimeConfig)) { - return true; - } - return Boolean(resolveFallbackXaiApiKey(params.cfg) || readProviderEnvValue(["XAI_API_KEY"])); + return isXaiToolEnabled({ + enabled: params.config?.enabled as boolean | undefined, + runtimeConfig: params.runtimeConfig, + sourceConfig: params.cfg, + }); } function resolveXSearchApiKey(params: { sourceConfig?: OpenClawConfig; runtimeConfig?: OpenClawConfig; }): string | undefined { - return ( - resolveFallbackXaiApiKey(params.runtimeConfig) ?? - resolveFallbackXaiApiKey(params.sourceConfig) ?? - readProviderEnvValue(["XAI_API_KEY"]) - ); + return resolveXaiToolApiKey(params); } function normalizeOptionalIsoDate(value: string | undefined, label: string): string | undefined {