mirror of https://github.com/openclaw/openclaw.git
refactor(providers): share google and xai provider helpers (#60722)
* refactor(google): share oauth token helpers * refactor(xai): share tool auth fallback helpers * refactor(xai): share tool auth resolution * refactor(xai): share tool config helpers * refactor(xai): share fallback auth helpers * refactor(xai): share responses tool helpers * refactor(google): share http request config helper * fix(xai): re-export shared web search extractor * fix(xai): import plugin config type * fix(providers): preserve default google network guard
This commit is contained in:
parent
c87903a4c6
commit
65842aabad
|
|
@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<string, string> } {
|
||||
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<string, strin
|
|||
};
|
||||
}
|
||||
|
||||
export function resolveGoogleGenerativeAiHttpRequestConfig(params: {
|
||||
apiKey: string;
|
||||
baseUrl?: string;
|
||||
headers?: Record<string, string>;
|
||||
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): {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<typeof resolveApiKeyForProvider>[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",
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<ProviderPlugin> | null = null;
|
||||
let googleImageGenerationProviderPromise: Promise<ImageGenerationProvider> | null = null;
|
||||
let googleMediaUnderstandingProviderPromise: Promise<MediaUnderstandingProvider> | 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<ProviderPlugin> {
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -3,4 +3,5 @@ export {
|
|||
normalizeGoogleApiBaseUrl,
|
||||
normalizeGoogleModelId,
|
||||
parseGeminiAuth,
|
||||
resolveGoogleGenerativeAiHttpRequestConfig,
|
||||
} from "./api.js";
|
||||
|
|
|
|||
|
|
@ -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<OpenClawConfig["plugins"]>["entries"]
|
||||
|
|
@ -36,18 +34,6 @@ function readCodeExecutionConfigRecord(
|
|||
return config && typeof config === "object" ? (config as Record<string, unknown>) : 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<string, unknown>).grok;
|
||||
return readConfiguredSecretString(
|
||||
grok && typeof grok === "object" ? (grok as Record<string, unknown>).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<string, unknown> | 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<string, unknown>) => {
|
||||
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",
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>,
|
||||
): { apiKey: string; source: string } | undefined {
|
||||
const tools = config.tools;
|
||||
if (!tools || typeof tools !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
const web = (tools as Record<string, unknown>).web;
|
||||
if (!web || typeof web !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
const search = (web as Record<string, unknown>).search;
|
||||
if (!search || typeof search !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
const grok = (search as Record<string, unknown>).grok;
|
||||
if (!grok || typeof grok !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
const apiKey = readConfiguredOrManagedApiKey((grok as Record<string, unknown>).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<string, unknown>;
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function resolveXaiCodeExecutionConfig(
|
||||
config?: Record<string, unknown>,
|
||||
): XaiCodeExecutionConfig {
|
||||
return isRecord(config) ? (config as XaiCodeExecutionConfig) : {};
|
||||
return coerceXaiToolConfig<XaiCodeExecutionConfig>(config);
|
||||
}
|
||||
|
||||
export function resolveXaiCodeExecutionModel(config?: Record<string, unknown>): 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<string, unknown>,
|
||||
): 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,
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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<Record<string, unknown>>;
|
||||
maxTurns?: number;
|
||||
}): Record<string, unknown> {
|
||||
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;
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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<string, unknown>).grok;
|
||||
const apiKey = readConfiguredOrManagedApiKey(
|
||||
grok && typeof grok === "object" ? (grok as Record<string, unknown>).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<string, unknown>).grok;
|
||||
return readConfiguredSecretString(
|
||||
grok && typeof grok === "object" ? (grok as Record<string, unknown>).apiKey : undefined,
|
||||
"tools.web.search.grok.apiKey",
|
||||
);
|
||||
}
|
||||
|
||||
export function readPluginXaiWebSearchApiKey(cfg?: OpenClawConfig): string | undefined {
|
||||
return readConfiguredSecretString(
|
||||
resolveProviderWebSearchPluginConfig(cfg as Record<string, unknown> | undefined, "xai")?.apiKey,
|
||||
"plugins.entries.xai.config.webSearch.apiKey",
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveFallbackXaiAuth(cfg?: OpenClawConfig): XaiFallbackAuth | undefined {
|
||||
const pluginApiKey = readConfiguredOrManagedApiKey(
|
||||
resolveProviderWebSearchPluginConfig(cfg as Record<string, unknown> | 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));
|
||||
}
|
||||
|
|
@ -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<string, unknown>)).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();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
import { normalizeXaiModelId } from "../model-id.js";
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function coerceXaiToolConfig<TConfig extends Record<string, unknown>>(
|
||||
config: Record<string, unknown> | undefined,
|
||||
): TConfig {
|
||||
return isRecord(config) ? (config as TConfig) : ({} as TConfig);
|
||||
}
|
||||
|
||||
export function resolveNormalizedXaiToolModel(params: {
|
||||
config?: Record<string, unknown>;
|
||||
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<string, unknown> | undefined,
|
||||
key: string,
|
||||
): number | undefined {
|
||||
const raw = coerceXaiToolConfig<Record<string, unknown>>(config)[key];
|
||||
if (typeof raw !== "number" || !Number.isFinite(raw)) {
|
||||
return undefined;
|
||||
}
|
||||
const normalized = Math.trunc(raw);
|
||||
return normalized > 0 ? normalized : undefined;
|
||||
}
|
||||
|
|
@ -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<string, unknown>
|
|||
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)
|
||||
|
|
|
|||
|
|
@ -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<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function resolveXaiXSearchConfig(config?: Record<string, unknown>): XaiXSearchConfig {
|
||||
return isRecord(config) ? (config as XaiXSearchConfig) : {};
|
||||
return coerceXaiToolConfig<XaiXSearchConfig>(config);
|
||||
}
|
||||
|
||||
export function resolveXaiXSearchModel(config?: Record<string, unknown>): 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<string, unknown>): boolean {
|
||||
|
|
@ -48,12 +53,7 @@ export function resolveXaiXSearchInlineCitations(config?: Record<string, unknown
|
|||
}
|
||||
|
||||
export function resolveXaiXSearchMaxTurns(config?: Record<string, unknown>): 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<string, unknown> {
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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<string, XSearchCacheEntry> {
|
|||
|
||||
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<string, unknown>).grok;
|
||||
return readConfiguredSecretString(
|
||||
grok && typeof grok === "object" ? (grok as Record<string, unknown>).apiKey : undefined,
|
||||
"tools.web.search.grok.apiKey",
|
||||
);
|
||||
}
|
||||
|
||||
function readPluginXaiWebSearchApiKey(cfg?: OpenClawConfig): string | undefined {
|
||||
return readConfiguredSecretString(
|
||||
resolveProviderWebSearchPluginConfig(cfg as Record<string, unknown> | 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<string, unknown> | undefined {
|
||||
return resolveEffectiveXSearchConfig(cfg);
|
||||
}
|
||||
|
|
@ -86,24 +61,18 @@ function resolveXSearchEnabled(params: {
|
|||
config?: Record<string, unknown>;
|
||||
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 {
|
||||
|
|
|
|||
Loading…
Reference in New Issue