mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-04 22:50:22 +00:00
refactor: unify Google Generative AI normalization
This commit is contained in:
82
src/agents/google-generative-ai.test.ts
Normal file
82
src/agents/google-generative-ai.test.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
isGoogleGenerativeAiApi,
|
||||
normalizeGoogleGenerativeAiBaseUrl,
|
||||
resolveGoogleGenerativeAiApiOrigin,
|
||||
resolveGoogleGenerativeAiTransport,
|
||||
shouldNormalizeGoogleGenerativeAiProviderConfig,
|
||||
} from "./google-generative-ai.js";
|
||||
|
||||
describe("google-generative-ai helpers", () => {
|
||||
it("detects the Google Generative AI transport id", () => {
|
||||
expect(isGoogleGenerativeAiApi("google-generative-ai")).toBe(true);
|
||||
expect(isGoogleGenerativeAiApi("google-gemini-cli")).toBe(false);
|
||||
expect(isGoogleGenerativeAiApi(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it("normalizes only explicit Google Generative AI baseUrls", () => {
|
||||
expect(normalizeGoogleGenerativeAiBaseUrl("https://generativelanguage.googleapis.com")).toBe(
|
||||
"https://generativelanguage.googleapis.com/v1beta",
|
||||
);
|
||||
expect(normalizeGoogleGenerativeAiBaseUrl("https://proxy.example.com/google/v1beta")).toBe(
|
||||
"https://proxy.example.com/google/v1beta",
|
||||
);
|
||||
expect(normalizeGoogleGenerativeAiBaseUrl()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("normalizes Google provider configs by provider key, provider api, or model api", () => {
|
||||
expect(
|
||||
shouldNormalizeGoogleGenerativeAiProviderConfig("google", {
|
||||
models: [{ api: "openai-completions" }],
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldNormalizeGoogleGenerativeAiProviderConfig("custom", {
|
||||
api: "google-generative-ai",
|
||||
models: [{ api: "openai-completions" }],
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldNormalizeGoogleGenerativeAiProviderConfig("custom", {
|
||||
models: [{ api: "google-generative-ai" }],
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldNormalizeGoogleGenerativeAiProviderConfig("custom", {
|
||||
api: "openai-completions",
|
||||
models: [{ api: "openai-completions" }],
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("normalizes transport baseUrls only for Google Generative AI", () => {
|
||||
expect(
|
||||
resolveGoogleGenerativeAiTransport({
|
||||
api: "google-generative-ai",
|
||||
baseUrl: "https://generativelanguage.googleapis.com",
|
||||
}),
|
||||
).toEqual({
|
||||
api: "google-generative-ai",
|
||||
baseUrl: "https://generativelanguage.googleapis.com/v1beta",
|
||||
});
|
||||
expect(
|
||||
resolveGoogleGenerativeAiTransport({
|
||||
api: "openai-completions",
|
||||
baseUrl: "https://generativelanguage.googleapis.com",
|
||||
}),
|
||||
).toEqual({
|
||||
api: "openai-completions",
|
||||
baseUrl: "https://generativelanguage.googleapis.com",
|
||||
});
|
||||
});
|
||||
|
||||
it("derives the Gemini API origin without duplicating /v1beta", () => {
|
||||
expect(resolveGoogleGenerativeAiApiOrigin()).toBe("https://generativelanguage.googleapis.com");
|
||||
expect(resolveGoogleGenerativeAiApiOrigin("https://generativelanguage.googleapis.com")).toBe(
|
||||
"https://generativelanguage.googleapis.com",
|
||||
);
|
||||
expect(
|
||||
resolveGoogleGenerativeAiApiOrigin("https://generativelanguage.googleapis.com/v1beta"),
|
||||
).toBe("https://generativelanguage.googleapis.com");
|
||||
});
|
||||
});
|
||||
46
src/agents/google-generative-ai.ts
Normal file
46
src/agents/google-generative-ai.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { normalizeGoogleApiBaseUrl } from "../infra/google-api-base-url.js";
|
||||
|
||||
type GoogleApiCarrier = {
|
||||
api?: string | null;
|
||||
};
|
||||
|
||||
type GoogleProviderConfigLike = GoogleApiCarrier & {
|
||||
models?: ReadonlyArray<GoogleApiCarrier | null | undefined> | null;
|
||||
};
|
||||
|
||||
export function isGoogleGenerativeAiApi(api?: string | null): boolean {
|
||||
return api === "google-generative-ai";
|
||||
}
|
||||
|
||||
export function normalizeGoogleGenerativeAiBaseUrl(baseUrl?: string): string | undefined {
|
||||
return baseUrl ? normalizeGoogleApiBaseUrl(baseUrl) : baseUrl;
|
||||
}
|
||||
|
||||
export function resolveGoogleGenerativeAiTransport<TApi extends string | null | undefined>(params: {
|
||||
api: TApi;
|
||||
baseUrl?: string;
|
||||
}): { api: TApi; baseUrl?: string } {
|
||||
return {
|
||||
api: params.api,
|
||||
baseUrl: isGoogleGenerativeAiApi(params.api)
|
||||
? normalizeGoogleGenerativeAiBaseUrl(params.baseUrl)
|
||||
: params.baseUrl,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveGoogleGenerativeAiApiOrigin(baseUrl?: string): string {
|
||||
return normalizeGoogleApiBaseUrl(baseUrl).replace(/\/v1beta$/i, "");
|
||||
}
|
||||
|
||||
export function shouldNormalizeGoogleGenerativeAiProviderConfig(
|
||||
providerKey: string,
|
||||
provider: GoogleProviderConfigLike,
|
||||
): boolean {
|
||||
if (providerKey === "google" || providerKey === "google-vertex") {
|
||||
return true;
|
||||
}
|
||||
if (isGoogleGenerativeAiApi(provider.api)) {
|
||||
return true;
|
||||
}
|
||||
return provider.models?.some((model) => isGoogleGenerativeAiApi(model?.api)) ?? false;
|
||||
}
|
||||
@@ -21,6 +21,10 @@ import { normalizeOptionalSecretInput } from "../utils/normalize-secret-input.js
|
||||
import { hasAnthropicVertexAvailableAuth } from "./anthropic-vertex-provider.js";
|
||||
import { ensureAuthProfileStore, listProfilesForProvider } from "./auth-profiles.js";
|
||||
import { discoverBedrockModels } from "./bedrock-discovery.js";
|
||||
import {
|
||||
normalizeGoogleGenerativeAiBaseUrl,
|
||||
shouldNormalizeGoogleGenerativeAiProviderConfig,
|
||||
} from "./google-generative-ai.js";
|
||||
import { normalizeGoogleModelId, normalizeXaiModelId } from "./model-id-normalization.js";
|
||||
import { resolveOllamaApiBase } from "./models-config.providers.discovery.js";
|
||||
export {
|
||||
@@ -331,21 +335,9 @@ function normalizeProviderModels(
|
||||
return mutated ? { ...provider, models } : provider;
|
||||
}
|
||||
|
||||
function shouldNormalizeGoogleProvider(providerKey: string, provider: ProviderConfig): boolean {
|
||||
if (providerKey === "google" || providerKey === "google-vertex") {
|
||||
return true;
|
||||
}
|
||||
if (provider.api === "google-generative-ai") {
|
||||
return true;
|
||||
}
|
||||
return provider.models.some((model) => model.api === "google-generative-ai");
|
||||
}
|
||||
|
||||
function normalizeGoogleProvider(provider: ProviderConfig): ProviderConfig {
|
||||
const modelNormalized = normalizeProviderModels(provider, normalizeGoogleModelId);
|
||||
const normalizedBaseUrl = modelNormalized.baseUrl
|
||||
? normalizeGoogleApiBaseUrl(modelNormalized.baseUrl)
|
||||
: modelNormalized.baseUrl;
|
||||
const normalizedBaseUrl = normalizeGoogleGenerativeAiBaseUrl(modelNormalized.baseUrl);
|
||||
if (normalizedBaseUrl !== modelNormalized.baseUrl) {
|
||||
return { ...modelNormalized, baseUrl: normalizedBaseUrl ?? modelNormalized.baseUrl };
|
||||
}
|
||||
@@ -626,7 +618,7 @@ export function normalizeProviders(params: {
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldNormalizeGoogleProvider(normalizedKey, normalizedProvider)) {
|
||||
if (shouldNormalizeGoogleGenerativeAiProviderConfig(normalizedKey, normalizedProvider)) {
|
||||
const googleNormalized = normalizeGoogleProvider(normalizedProvider);
|
||||
if (googleNormalized !== normalizedProvider) {
|
||||
mutated = true;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { isGoogleGenerativeAiApi } from "../google-generative-ai.js";
|
||||
import { sanitizeGoogleTurnOrdering } from "./bootstrap.js";
|
||||
|
||||
export function isGoogleModelApi(api?: string | null): boolean {
|
||||
return api === "google-gemini-cli" || api === "google-generative-ai";
|
||||
return api === "google-gemini-cli" || isGoogleGenerativeAiApi(api);
|
||||
}
|
||||
|
||||
export { sanitizeGoogleTurnOrdering };
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from "../../plugins/provider-runtime.js";
|
||||
import { resolveOpenClawAgentDir } from "../agent-paths.js";
|
||||
import { DEFAULT_CONTEXT_TOKENS } from "../defaults.js";
|
||||
import { resolveGoogleGenerativeAiTransport } from "../google-generative-ai.js";
|
||||
import { buildModelAliasLines } from "../model-alias-lines.js";
|
||||
import { isSecretRefHeaderValueMarker } from "../model-auth-markers.js";
|
||||
import { normalizeModelCompat } from "../model-compat.js";
|
||||
@@ -22,10 +23,6 @@ import {
|
||||
import { discoverAuthStorage, discoverModels } from "../pi-model-discovery.js";
|
||||
import { normalizeResolvedProviderModel } from "./model.provider-normalization.js";
|
||||
|
||||
function normalizeGoogleGenerativeAiBaseUrl(baseUrl: string | undefined): string | undefined {
|
||||
return baseUrl ? normalizeGoogleApiBaseUrl(baseUrl) : baseUrl;
|
||||
}
|
||||
|
||||
type InlineModelEntry = ModelDefinitionConfig & {
|
||||
provider: string;
|
||||
baseUrl?: string;
|
||||
@@ -180,15 +177,14 @@ function applyConfiguredProviderOverrides(params: {
|
||||
? resolvedInput.filter((item) => item === "text" || item === "image")
|
||||
: (["text"] as Array<"text" | "image">);
|
||||
|
||||
const resolvedApi = configuredModel?.api ?? providerConfig.api ?? discoveredModel.api;
|
||||
let resolvedBaseUrl = providerConfig.baseUrl ?? discoveredModel.baseUrl;
|
||||
if (resolvedApi === "google-generative-ai") {
|
||||
resolvedBaseUrl = normalizeGoogleGenerativeAiBaseUrl(resolvedBaseUrl) ?? resolvedBaseUrl;
|
||||
}
|
||||
const resolvedTransport = resolveGoogleGenerativeAiTransport({
|
||||
api: configuredModel?.api ?? providerConfig.api ?? discoveredModel.api,
|
||||
baseUrl: providerConfig.baseUrl ?? discoveredModel.baseUrl,
|
||||
});
|
||||
return {
|
||||
...discoveredModel,
|
||||
api: resolvedApi,
|
||||
baseUrl: resolvedBaseUrl,
|
||||
api: resolvedTransport.api,
|
||||
baseUrl: resolvedTransport.baseUrl ?? discoveredModel.baseUrl,
|
||||
reasoning: configuredModel?.reasoning ?? discoveredModel.reasoning,
|
||||
input: normalizedInput,
|
||||
cost: configuredModel?.cost ?? discoveredModel.cost,
|
||||
@@ -218,16 +214,15 @@ export function buildInlineProviderModels(
|
||||
stripSecretRefMarkers: true,
|
||||
});
|
||||
return (entry?.models ?? []).map((model) => {
|
||||
const modelApi = model.api ?? entry?.api;
|
||||
let baseUrl = entry?.baseUrl;
|
||||
if (modelApi === "google-generative-ai") {
|
||||
baseUrl = normalizeGoogleGenerativeAiBaseUrl(baseUrl) ?? baseUrl;
|
||||
}
|
||||
const transport = resolveGoogleGenerativeAiTransport({
|
||||
api: model.api ?? entry?.api,
|
||||
baseUrl: entry?.baseUrl,
|
||||
});
|
||||
return {
|
||||
...model,
|
||||
provider: trimmed,
|
||||
baseUrl,
|
||||
api: modelApi,
|
||||
baseUrl: transport.baseUrl,
|
||||
api: transport.api,
|
||||
headers: (() => {
|
||||
const modelHeaders = sanitizeModelHeaders((model as InlineModelEntry).headers, {
|
||||
stripSecretRefMarkers: true,
|
||||
@@ -375,11 +370,10 @@ function resolveConfiguredFallbackModel(params: {
|
||||
if (!providerConfig && !modelId.startsWith("mock-")) {
|
||||
return undefined;
|
||||
}
|
||||
const fallbackApi = providerConfig?.api ?? "openai-responses";
|
||||
let fallbackBaseUrl = providerConfig?.baseUrl;
|
||||
if (fallbackApi === "google-generative-ai") {
|
||||
fallbackBaseUrl = normalizeGoogleGenerativeAiBaseUrl(fallbackBaseUrl) ?? fallbackBaseUrl;
|
||||
}
|
||||
const fallbackTransport = resolveGoogleGenerativeAiTransport({
|
||||
api: providerConfig?.api ?? "openai-responses",
|
||||
baseUrl: providerConfig?.baseUrl,
|
||||
});
|
||||
return normalizeResolvedModel({
|
||||
provider,
|
||||
cfg,
|
||||
@@ -387,9 +381,9 @@ function resolveConfiguredFallbackModel(params: {
|
||||
model: {
|
||||
id: modelId,
|
||||
name: modelId,
|
||||
api: fallbackApi,
|
||||
api: fallbackTransport.api,
|
||||
provider,
|
||||
baseUrl: fallbackBaseUrl,
|
||||
baseUrl: fallbackTransport.baseUrl,
|
||||
reasoning: configuredModel?.reasoning ?? false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
import { isRecord } from "../../utils.js";
|
||||
import { normalizeSecretInput } from "../../utils/normalize-secret-input.js";
|
||||
import { resolveGoogleGenerativeAiApiOrigin } from "../google-generative-ai.js";
|
||||
|
||||
type PdfInput = {
|
||||
base64: string;
|
||||
@@ -137,9 +138,7 @@ export async function geminiAnalyzePdf(params: {
|
||||
}
|
||||
parts.push({ text: params.prompt });
|
||||
|
||||
const baseUrl = (params.baseUrl ?? "https://generativelanguage.googleapis.com")
|
||||
.replace(/\/+$/, "")
|
||||
.replace(/\/v1beta$/, "");
|
||||
const baseUrl = resolveGoogleGenerativeAiApiOrigin(params.baseUrl);
|
||||
const url = `${baseUrl}/v1beta/models/${encodeURIComponent(params.modelId)}:generateContent?key=${encodeURIComponent(apiKey)}`;
|
||||
|
||||
const res = await fetch(url, {
|
||||
|
||||
@@ -748,6 +748,26 @@ describe("native PDF provider API calls", () => {
|
||||
expect(url).toContain("/v1beta/models/");
|
||||
expect(url).not.toContain("/v1beta/v1beta");
|
||||
});
|
||||
|
||||
it("geminiAnalyzePdf normalizes bare Google API hosts to a single /v1beta root", async () => {
|
||||
const { geminiAnalyzePdf } = await import("./pdf-native-providers.js");
|
||||
const fetchMock = mockFetchResponse({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
candidates: [{ content: { parts: [{ text: "ok" }] } }],
|
||||
}),
|
||||
});
|
||||
|
||||
await geminiAnalyzePdf(
|
||||
makeGeminiAnalyzeParams({
|
||||
baseUrl: "https://generativelanguage.googleapis.com",
|
||||
}),
|
||||
);
|
||||
|
||||
const [url] = fetchMock.mock.calls[0];
|
||||
expect(url).toContain("https://generativelanguage.googleapis.com/v1beta/models/");
|
||||
expect(url).not.toContain("/v1beta/v1beta");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user