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:
Vincent Koc
2026-04-04 16:14:15 +09:00
committed by GitHub
parent c87903a4c6
commit 65842aabad
22 changed files with 717 additions and 319 deletions

View File

@@ -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",
});
});
});

View File

@@ -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): {

View File

@@ -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);
}

View File

@@ -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",
});

View File

@@ -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);

View File

@@ -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();

View File

@@ -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: [

View File

@@ -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");
});
});

View File

@@ -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;
}

View File

@@ -3,4 +3,5 @@ export {
normalizeGoogleApiBaseUrl,
normalizeGoogleModelId,
parseGeminiAuth,
resolveGoogleGenerativeAiHttpRequestConfig,
} from "./api.js";