Skip to content

Commit 65842aa

Browse files
authored
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
1 parent c87903a commit 65842aa

22 files changed

Lines changed: 717 additions & 319 deletions

extensions/google/api.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { describe, expect, it } from "vitest";
22
import {
33
isGoogleGenerativeAiApi,
44
normalizeGoogleGenerativeAiBaseUrl,
5+
parseGeminiAuth,
6+
resolveGoogleGenerativeAiHttpRequestConfig,
57
resolveGoogleGenerativeAiApiOrigin,
68
resolveGoogleGenerativeAiTransport,
79
shouldNormalizeGoogleGenerativeAiProviderConfig,
@@ -91,4 +93,53 @@ describe("google generative ai helpers", () => {
9193
resolveGoogleGenerativeAiApiOrigin("https://generativelanguage.googleapis.com/v1beta"),
9294
).toBe("https://generativelanguage.googleapis.com");
9395
});
96+
97+
it("parses project-aware oauth auth payloads into bearer headers", () => {
98+
expect(parseGeminiAuth(JSON.stringify({ token: "oauth-token", projectId: "project-1" }))).toEqual({
99+
headers: {
100+
Authorization: "Bearer oauth-token",
101+
"Content-Type": "application/json",
102+
},
103+
});
104+
});
105+
106+
it("falls back to API key headers for raw tokens", () => {
107+
expect(parseGeminiAuth("api-key-123")).toEqual({
108+
headers: {
109+
"x-goog-api-key": "api-key-123",
110+
"Content-Type": "application/json",
111+
},
112+
});
113+
});
114+
115+
it("builds shared Google Generative AI HTTP request config", () => {
116+
const oauthConfig = resolveGoogleGenerativeAiHttpRequestConfig({
117+
apiKey: JSON.stringify({ token: "oauth-token" }),
118+
baseUrl: "https://generativelanguage.googleapis.com",
119+
capability: "audio",
120+
transport: "media-understanding",
121+
});
122+
expect(oauthConfig).toMatchObject({
123+
baseUrl: "https://generativelanguage.googleapis.com/v1beta",
124+
allowPrivateNetwork: true,
125+
});
126+
expect(Object.fromEntries(new Headers(oauthConfig.headers).entries())).toEqual({
127+
authorization: "Bearer oauth-token",
128+
"content-type": "application/json",
129+
});
130+
131+
const apiKeyConfig = resolveGoogleGenerativeAiHttpRequestConfig({
132+
apiKey: "api-key-123",
133+
capability: "image",
134+
transport: "http",
135+
});
136+
expect(apiKeyConfig).toMatchObject({
137+
baseUrl: "https://generativelanguage.googleapis.com/v1beta",
138+
allowPrivateNetwork: false,
139+
});
140+
expect(Object.fromEntries(new Headers(apiKeyConfig.headers).entries())).toEqual({
141+
"content-type": "application/json",
142+
"x-goog-api-key": "api-key-123",
143+
});
144+
});
94145
});

extensions/google/api.ts

Lines changed: 36 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
1-
import { resolveProviderEndpoint } from "openclaw/plugin-sdk/provider-http";
1+
import {
2+
resolveProviderEndpoint,
3+
resolveProviderHttpRequestConfig,
4+
type ProviderRequestTransportOverrides,
5+
} from "openclaw/plugin-sdk/provider-http";
26
import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-shared";
37
import {
48
applyAgentDefaultModelPrimary,
59
type OpenClawConfig,
610
} from "openclaw/plugin-sdk/provider-onboard";
711
import { normalizeAntigravityModelId, normalizeGoogleModelId } from "./model-id.js";
12+
import { parseGoogleOauthApiKey } from "./oauth-token-shared.js";
813
export { normalizeAntigravityModelId, normalizeGoogleModelId };
914

1015
type GoogleApiCarrier = {
@@ -138,20 +143,14 @@ export function normalizeGoogleProviderConfig(
138143
}
139144

140145
export function parseGeminiAuth(apiKey: string): { headers: Record<string, string> } {
141-
if (apiKey.startsWith("{")) {
142-
try {
143-
const parsed = JSON.parse(apiKey) as { token?: string; projectId?: string };
144-
if (typeof parsed.token === "string" && parsed.token) {
145-
return {
146-
headers: {
147-
Authorization: `Bearer ${parsed.token}`,
148-
"Content-Type": "application/json",
149-
},
150-
};
151-
}
152-
} catch {
153-
// Fall back to API key mode.
154-
}
146+
const parsed = apiKey.startsWith("{") ? parseGoogleOauthApiKey(apiKey) : null;
147+
if (parsed?.token) {
148+
return {
149+
headers: {
150+
Authorization: `Bearer ${parsed.token}`,
151+
"Content-Type": "application/json",
152+
},
153+
};
155154
}
156155

157156
return {
@@ -162,6 +161,28 @@ export function parseGeminiAuth(apiKey: string): { headers: Record<string, strin
162161
};
163162
}
164163

164+
export function resolveGoogleGenerativeAiHttpRequestConfig(params: {
165+
apiKey: string;
166+
baseUrl?: string;
167+
headers?: Record<string, string>;
168+
request?: ProviderRequestTransportOverrides;
169+
capability: "image" | "audio" | "video";
170+
transport: "http" | "media-understanding";
171+
}) {
172+
return resolveProviderHttpRequestConfig({
173+
baseUrl: normalizeGoogleApiBaseUrl(params.baseUrl ?? DEFAULT_GOOGLE_API_BASE_URL),
174+
defaultBaseUrl: DEFAULT_GOOGLE_API_BASE_URL,
175+
allowPrivateNetwork: Boolean(params.baseUrl?.trim()),
176+
headers: params.headers,
177+
request: params.request,
178+
defaultHeaders: parseGeminiAuth(params.apiKey).headers,
179+
provider: "google",
180+
api: "google-generative-ai",
181+
capability: params.capability,
182+
transport: params.transport,
183+
});
184+
}
185+
165186
export const GOOGLE_GEMINI_DEFAULT_MODEL = "google/gemini-3.1-pro-preview";
166187

167188
export function applyGoogleGeminiModelDefault(cfg: OpenClawConfig): {

extensions/google/gemini-cli-provider.ts

Lines changed: 1 addition & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type {
55
} from "openclaw/plugin-sdk/plugin-entry";
66
import { buildOauthProviderAuthResult } from "openclaw/plugin-sdk/provider-auth-result";
77
import { fetchGeminiUsage } from "openclaw/plugin-sdk/provider-usage";
8+
import { formatGoogleOauthApiKey, parseGoogleUsageToken } from "./oauth-token-shared.js";
89
import { isModernGoogleModel, resolveGoogle31ForwardCompatModel } from "./provider-models.js";
910
import { buildGoogleGeminiProviderHooks } from "./replay-policy.js";
1011

@@ -22,32 +23,6 @@ const GOOGLE_GEMINI_CLI_PROVIDER_HOOKS = buildGoogleGeminiProviderHooks({
2223
includeToolSchemaCompat: true,
2324
});
2425

25-
function parseGoogleUsageToken(apiKey: string): string {
26-
try {
27-
const parsed = JSON.parse(apiKey) as { token?: unknown };
28-
if (typeof parsed?.token === "string") {
29-
return parsed.token;
30-
}
31-
} catch {
32-
// ignore
33-
}
34-
return apiKey;
35-
}
36-
37-
function formatGoogleOauthApiKey(cred: {
38-
type?: string;
39-
access?: string;
40-
projectId?: string;
41-
}): string {
42-
if (cred.type !== "oauth" || typeof cred.access !== "string" || !cred.access.trim()) {
43-
return "";
44-
}
45-
return JSON.stringify({
46-
token: cred.access,
47-
projectId: cred.projectId,
48-
});
49-
}
50-
5126
async function fetchGeminiCliUsage(ctx: ProviderFetchUsageSnapshotContext) {
5227
return await fetchGeminiUsage(ctx.token, ctx.timeoutMs, ctx.fetchFn, PROVIDER_ID);
5328
}

extensions/google/image-generation-provider.ts

Lines changed: 4 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,10 @@ import { resolveApiKeyForProvider } from "openclaw/plugin-sdk/provider-auth-runt
33
import {
44
assertOkOrThrowHttpError,
55
postJsonRequest,
6-
resolveProviderHttpRequestConfig,
76
} from "openclaw/plugin-sdk/provider-http";
87
import {
9-
DEFAULT_GOOGLE_API_BASE_URL,
10-
normalizeGoogleApiBaseUrl,
118
normalizeGoogleModelId,
12-
parseGeminiAuth,
9+
resolveGoogleGenerativeAiHttpRequestConfig,
1310
} from "./api.js";
1411

1512
const DEFAULT_GOOGLE_IMAGE_MODEL = "gemini-3.1-flash-image-preview";
@@ -52,10 +49,6 @@ type GoogleGenerateImageResponse = {
5249
}>;
5350
};
5451

55-
function resolveGoogleBaseUrl(cfg: Parameters<typeof resolveApiKeyForProvider>[0]["cfg"]): string {
56-
return normalizeGoogleApiBaseUrl(cfg?.models?.providers?.google?.baseUrl);
57-
}
58-
5952
function normalizeGoogleImageModel(model: string | undefined): string {
6053
const trimmed = model?.trim();
6154
return normalizeGoogleModelId(trimmed || DEFAULT_GOOGLE_IMAGE_MODEL);
@@ -135,13 +128,9 @@ export function buildGoogleImageGenerationProvider(): ImageGenerationProvider {
135128

136129
const model = normalizeGoogleImageModel(req.model);
137130
const { baseUrl, allowPrivateNetwork, headers, dispatcherPolicy } =
138-
resolveProviderHttpRequestConfig({
139-
baseUrl: resolveGoogleBaseUrl(req.cfg),
140-
defaultBaseUrl: DEFAULT_GOOGLE_API_BASE_URL,
141-
allowPrivateNetwork: Boolean(req.cfg?.models?.providers?.google?.baseUrl?.trim()),
142-
defaultHeaders: parseGeminiAuth(auth.apiKey).headers,
143-
provider: "google",
144-
api: "google-generative-ai",
131+
resolveGoogleGenerativeAiHttpRequestConfig({
132+
apiKey: auth.apiKey,
133+
baseUrl: req.cfg?.models?.providers?.google?.baseUrl,
145134
capability: "image",
146135
transport: "http",
147136
});

extensions/google/index.ts

Lines changed: 2 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
normalizeGoogleModelId,
1717
} from "./api.js";
1818
import { buildGoogleGeminiCliBackend } from "./cli-backend.js";
19+
import { formatGoogleOauthApiKey } from "./oauth-token-shared.js";
1920
import { isModernGoogleModel, resolveGoogle31ForwardCompatModel } from "./provider-models.js";
2021
import { buildGoogleGeminiProviderHooks } from "./replay-policy.js";
2122
import { createGeminiWebSearchProvider } from "./src/gemini-web-search-provider.js";
@@ -30,12 +31,6 @@ const GOOGLE_GEMINI_CLI_ENV_VARS = [
3031
"GEMINI_CLI_OAUTH_CLIENT_SECRET",
3132
] as const;
3233

33-
type GoogleOauthApiKeyCredential = {
34-
type?: string;
35-
access?: string;
36-
projectId?: string;
37-
};
38-
3934
let googleGeminiCliProviderPromise: Promise<ProviderPlugin> | null = null;
4035
let googleImageGenerationProviderPromise: Promise<ImageGenerationProvider> | null = null;
4136
let googleMediaUnderstandingProviderPromise: Promise<MediaUnderstandingProvider> | null = null;
@@ -52,16 +47,6 @@ const GOOGLE_GEMINI_PROVIDER_HOOKS_WITH_TOOL_COMPAT = buildGoogleGeminiProviderH
5247
includeToolSchemaCompat: true,
5348
});
5449

55-
function formatGoogleOauthApiKey(cred: GoogleOauthApiKeyCredential): string {
56-
if (cred.type !== "oauth" || typeof cred.access !== "string" || !cred.access.trim()) {
57-
return "";
58-
}
59-
return JSON.stringify({
60-
token: cred.access,
61-
projectId: cred.projectId,
62-
});
63-
}
64-
6550
async function loadGoogleGeminiCliProvider(): Promise<ProviderPlugin> {
6651
if (!googleGeminiCliProviderPromise) {
6752
googleGeminiCliProviderPromise = import("./gemini-cli-provider.js").then((mod) => {
@@ -147,7 +132,7 @@ function createLazyGoogleGeminiCliProvider(): ProviderPlugin {
147132
resolveGoogle31ForwardCompatModel({ providerId: GOOGLE_GEMINI_CLI_PROVIDER_ID, ctx }),
148133
...GOOGLE_GEMINI_PROVIDER_HOOKS_WITH_TOOL_COMPAT,
149134
isModernModelRef: ({ modelId }) => isModernGoogleModel(modelId),
150-
formatApiKey: (cred) => formatGoogleOauthApiKey(cred as GoogleOauthApiKeyCredential),
135+
formatApiKey: (cred) => formatGoogleOauthApiKey(cred),
151136
resolveUsageAuth: async (ctx) => {
152137
const provider = await loadGoogleGeminiCliProvider();
153138
return await provider.resolveUsageAuth?.(ctx);

extensions/google/media-understanding-provider.ts

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,12 @@ import {
1010
import {
1111
assertOkOrThrowHttpError,
1212
postJsonRequest,
13-
resolveProviderHttpRequestConfig,
1413
type ProviderRequestTransportOverrides,
1514
} from "openclaw/plugin-sdk/provider-http";
1615
import {
1716
DEFAULT_GOOGLE_API_BASE_URL,
18-
normalizeGoogleApiBaseUrl,
1917
normalizeGoogleModelId,
20-
parseGeminiAuth,
18+
resolveGoogleGenerativeAiHttpRequestConfig,
2119
} from "./runtime-api.js";
2220

2321
export const DEFAULT_GOOGLE_AUDIO_BASE_URL = DEFAULT_GOOGLE_API_BASE_URL;
@@ -54,19 +52,16 @@ async function generateGeminiInlineDataText(params: {
5452
return normalizeGoogleModelId(trimmed);
5553
})();
5654
const { baseUrl, allowPrivateNetwork, headers, dispatcherPolicy } =
57-
resolveProviderHttpRequestConfig({
58-
baseUrl: normalizeGoogleApiBaseUrl(params.baseUrl ?? params.defaultBaseUrl),
59-
defaultBaseUrl: DEFAULT_GOOGLE_API_BASE_URL,
60-
allowPrivateNetwork: Boolean(params.baseUrl?.trim()),
55+
resolveGoogleGenerativeAiHttpRequestConfig({
56+
apiKey: params.apiKey,
57+
baseUrl: params.baseUrl,
6158
headers: params.headers,
6259
request: params.request,
63-
defaultHeaders: parseGeminiAuth(params.apiKey).headers,
64-
provider: "google",
65-
api: "google-generative-ai",
6660
capability: params.defaultMime.startsWith("audio/") ? "audio" : "video",
6761
transport: "media-understanding",
6862
});
69-
const url = `${baseUrl}/models/${model}:generateContent`;
63+
const resolvedBaseUrl = baseUrl ?? params.defaultBaseUrl;
64+
const url = `${resolvedBaseUrl}/models/${model}:generateContent`;
7065

7166
const prompt = (() => {
7267
const trimmed = params.prompt?.trim();

extensions/google/media-understanding-provider.video.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,29 @@ describe("describeGeminiVideo", () => {
6262
expect(result.text).toBe("video ok");
6363
});
6464

65+
it("keeps private-network disabled for the default Google media endpoint", async () => {
66+
const fetchFn = withFetchPreconnect(async () => {
67+
return new Response(
68+
JSON.stringify({
69+
candidates: [{ content: { parts: [{ text: "video ok" }] } }],
70+
}),
71+
{ status: 200, headers: { "content-type": "application/json" } },
72+
);
73+
});
74+
75+
await describeGeminiVideo({
76+
buffer: Buffer.from("video"),
77+
fileName: "clip.mp4",
78+
apiKey: "test-key",
79+
timeoutMs: 1000,
80+
fetchFn,
81+
});
82+
83+
expect(resolvePinnedHostnameWithPolicySpy).toHaveBeenCalled();
84+
const [, options] = resolvePinnedHostnameWithPolicySpy.mock.calls[0] ?? [];
85+
expect(options?.policy?.allowPrivateNetwork).toBeUndefined();
86+
});
87+
6588
it("builds the expected request payload", async () => {
6689
const { fetchFn, getRequest } = createRequestCaptureJsonFetch({
6790
candidates: [
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { describe, expect, it } from "vitest";
2+
import {
3+
formatGoogleOauthApiKey,
4+
parseGoogleOauthApiKey,
5+
parseGoogleUsageToken,
6+
} from "./oauth-token-shared.js";
7+
8+
describe("google oauth token helpers", () => {
9+
it("formats oauth credentials with project-aware payloads", () => {
10+
expect(
11+
formatGoogleOauthApiKey({
12+
type: "oauth",
13+
access: "token-123",
14+
projectId: "project-abc",
15+
}),
16+
).toBe(JSON.stringify({ token: "token-123", projectId: "project-abc" }));
17+
});
18+
19+
it("returns an empty string for non-oauth credentials", () => {
20+
expect(formatGoogleOauthApiKey({ type: "token", access: "token-123" })).toBe("");
21+
});
22+
23+
it("parses project-aware oauth payloads for usage auth", () => {
24+
expect(parseGoogleUsageToken(JSON.stringify({ token: "usage-token" }))).toBe("usage-token");
25+
});
26+
27+
it("parses structured oauth payload fields", () => {
28+
expect(
29+
parseGoogleOauthApiKey(JSON.stringify({ token: "usage-token", projectId: "proj-1" })),
30+
).toEqual({
31+
token: "usage-token",
32+
projectId: "proj-1",
33+
});
34+
});
35+
36+
it("falls back to the raw token when the payload is not JSON", () => {
37+
expect(parseGoogleUsageToken("raw-token")).toBe("raw-token");
38+
});
39+
});

0 commit comments

Comments
 (0)