mirror of
https://github.com/moltbot/moltbot.git
synced 2026-04-27 00:17:29 +00:00
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:
@@ -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: [
|
||||
|
||||
39
extensions/google/oauth-token-shared.test.ts
Normal file
39
extensions/google/oauth-token-shared.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
40
extensions/google/oauth-token-shared.ts
Normal file
40
extensions/google/oauth-token-shared.ts
Normal 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;
|
||||
}
|
||||
@@ -3,4 +3,5 @@ export {
|
||||
normalizeGoogleApiBaseUrl,
|
||||
normalizeGoogleModelId,
|
||||
parseGeminiAuth,
|
||||
resolveGoogleGenerativeAiHttpRequestConfig,
|
||||
} from "./api.js";
|
||||
|
||||
Reference in New Issue
Block a user