fix(providers): centralize Google endpoint classification (#59556)

* fix(providers): centralize Google endpoint classification

* fix(providers): tighten Google endpoint fallback parsing

* fix(security): harden provider endpoint fallback parsing
This commit is contained in:
Vincent Koc
2026-04-02 19:21:31 +09:00
committed by GitHub
parent 2fa4c7cc61
commit 0e9a9dae84
9 changed files with 176 additions and 58 deletions

View File

@@ -29,4 +29,10 @@ describe("anthropic vertex region helpers", () => {
"global",
);
});
it("does not infer a Vertex region from custom proxy hosts", () => {
expect(
resolveAnthropicVertexRegionFromBaseUrl("https://proxy.example.com/google/aiplatform"),
).toBeUndefined();
});
});

View File

@@ -1,6 +1,7 @@
import { existsSync, readFileSync } from "node:fs";
import { homedir, platform } from "node:os";
import { join } from "node:path";
import { resolveProviderEndpoint } from "openclaw/plugin-sdk/provider-http";
const ANTHROPIC_VERTEX_DEFAULT_REGION = "global";
const ANTHROPIC_VERTEX_REGION_RE = /^[a-z0-9-]+$/;
@@ -47,21 +48,8 @@ export function resolveAnthropicVertexProjectId(
}
export function resolveAnthropicVertexRegionFromBaseUrl(baseUrl?: string): string | undefined {
const trimmed = baseUrl?.trim();
if (!trimmed) {
return undefined;
}
try {
const host = new URL(trimmed).hostname.toLowerCase();
if (host === "aiplatform.googleapis.com") {
return "global";
}
const match = /^([a-z0-9-]+)-aiplatform\.googleapis\.com$/.exec(host);
return match?.[1];
} catch {
return undefined;
}
const endpoint = resolveProviderEndpoint(baseUrl);
return endpoint.endpointClass === "google-vertex" ? endpoint.googleVertexRegion : undefined;
}
export function resolveAnthropicVertexClientRegion(params?: {

View File

@@ -21,6 +21,18 @@ describe("google generative ai helpers", () => {
expect(normalizeGoogleGenerativeAiBaseUrl("https://proxy.example.com/google/v1beta")).toBe(
"https://proxy.example.com/google/v1beta",
);
expect(normalizeGoogleGenerativeAiBaseUrl("https://aiplatform.googleapis.com")).toBe(
"https://aiplatform.googleapis.com",
);
expect(normalizeGoogleGenerativeAiBaseUrl("proxy/generativelanguage.googleapis.com")).toBe(
"proxy/generativelanguage.googleapis.com",
);
expect(normalizeGoogleGenerativeAiBaseUrl("generativelanguage.googleapis.com")).toBe(
"generativelanguage.googleapis.com",
);
expect(normalizeGoogleGenerativeAiBaseUrl("https://xgenerativelanguage.googleapis.com")).toBe(
"https://xgenerativelanguage.googleapis.com",
);
expect(normalizeGoogleGenerativeAiBaseUrl()).toBeUndefined();
});

View File

@@ -1,3 +1,4 @@
import { resolveProviderEndpoint } from "openclaw/plugin-sdk/provider-http";
import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-shared";
import {
applyAgentDefaultModelPrimary,
@@ -14,14 +15,16 @@ type GoogleProviderConfigLike = GoogleApiCarrier & {
models?: ReadonlyArray<GoogleApiCarrier | null | undefined> | null;
};
const DEFAULT_GOOGLE_API_HOST = "generativelanguage.googleapis.com";
export const DEFAULT_GOOGLE_API_BASE_URL = "https://generativelanguage.googleapis.com/v1beta";
function trimTrailingSlashes(value: string): string {
return value.replace(/\/+$/, "");
}
function isCanonicalGoogleApiOriginShorthand(value: string): boolean {
return /^https:\/\/generativelanguage\.googleapis\.com\/?$/i.test(value);
}
export function normalizeGoogleApiBaseUrl(baseUrl?: string): string {
const raw = trimTrailingSlashes(baseUrl?.trim() || DEFAULT_GOOGLE_API_BASE_URL);
try {
@@ -29,14 +32,14 @@ export function normalizeGoogleApiBaseUrl(baseUrl?: string): string {
url.hash = "";
url.search = "";
if (
url.hostname.toLowerCase() === DEFAULT_GOOGLE_API_HOST &&
resolveProviderEndpoint(url.toString()).endpointClass === "google-generative-ai" &&
trimTrailingSlashes(url.pathname || "") === ""
) {
url.pathname = "/v1beta";
}
return trimTrailingSlashes(url.toString());
} catch {
if (/^https:\/\/generativelanguage\.googleapis\.com\/?$/i.test(raw)) {
if (isCanonicalGoogleApiOriginShorthand(raw)) {
return DEFAULT_GOOGLE_API_BASE_URL;
}
return raw;

View File

@@ -4,6 +4,7 @@ import {
resolveProviderAttributionHeaders,
resolveProviderAttributionIdentity,
resolveProviderAttributionPolicy,
resolveProviderEndpoint,
resolveProviderRequestAttributionHeaders,
resolveProviderRequestPolicy,
} from "./provider-attribution.js";
@@ -276,6 +277,71 @@ describe("provider attribution", () => {
});
});
it("classifies Google Gemini and Vertex endpoints separately from custom hosts", () => {
expect(resolveProviderEndpoint("https://generativelanguage.googleapis.com")).toMatchObject({
endpointClass: "google-generative-ai",
hostname: "generativelanguage.googleapis.com",
});
expect(
resolveProviderEndpoint("https://europe-west4-aiplatform.googleapis.com/v1/projects/test"),
).toMatchObject({
endpointClass: "google-vertex",
hostname: "europe-west4-aiplatform.googleapis.com",
googleVertexRegion: "europe-west4",
});
expect(resolveProviderEndpoint("https://aiplatform.googleapis.com")).toMatchObject({
endpointClass: "google-vertex",
hostname: "aiplatform.googleapis.com",
googleVertexRegion: "global",
});
expect(resolveProviderEndpoint("https://proxy.example.com/google")).toMatchObject({
endpointClass: "custom",
hostname: "proxy.example.com",
});
});
it("does not classify malformed or embedded Google host strings as native endpoints", () => {
expect(resolveProviderEndpoint("proxy/generativelanguage.googleapis.com")).toMatchObject({
endpointClass: "custom",
hostname: "proxy",
});
expect(resolveProviderEndpoint("https://xgenerativelanguage.googleapis.com")).toMatchObject({
endpointClass: "custom",
hostname: "xgenerativelanguage.googleapis.com",
});
expect(resolveProviderEndpoint("proxy/aiplatform.googleapis.com")).toMatchObject({
endpointClass: "custom",
hostname: "proxy",
});
expect(resolveProviderEndpoint("https://xaiplatform.googleapis.com")).toMatchObject({
endpointClass: "custom",
hostname: "xaiplatform.googleapis.com",
});
});
it("does not trust schemeless or embedded trusted-provider substrings", () => {
expect(resolveProviderEndpoint("api.openai.com.attacker.example")).toMatchObject({
endpointClass: "custom",
hostname: "api.openai.com.attacker.example",
});
expect(resolveProviderEndpoint("attacker.example/?target=api.openai.com")).toMatchObject({
endpointClass: "custom",
hostname: "attacker.example",
});
expect(resolveProviderEndpoint("openrouter.ai.attacker.example")).toMatchObject({
endpointClass: "custom",
hostname: "openrouter.ai.attacker.example",
});
});
it("requires the dedicated OpenAI audio transcription API for audio attribution", () => {
expect(
resolveProviderRequestPolicy({

View File

@@ -37,10 +37,18 @@ export type ProviderEndpointClass =
| "openai-codex"
| "azure-openai"
| "openrouter"
| "google-generative-ai"
| "google-vertex"
| "local"
| "custom"
| "invalid";
export type ProviderEndpointResolution = {
endpointClass: ProviderEndpointClass;
hostname?: string;
googleVertexRegion?: string;
};
export type ProviderRequestPolicyInput = {
provider?: string | null;
api?: string | null;
@@ -73,39 +81,32 @@ function formatOpenClawUserAgent(version: string): string {
return `${OPENCLAW_ATTRIBUTION_ORIGINATOR}/${version}`;
}
function tryParseHostname(value: string): string | undefined {
try {
return new URL(value).hostname.toLowerCase();
} catch {
return undefined;
}
}
function isSchemelessHostnameCandidate(value: string): boolean {
return /^[a-z0-9.[\]-]+(?::\d+)?(?:[/?#].*)?$/i.test(value);
}
function resolveUrlHostname(value: unknown): string | undefined {
if (typeof value !== "string" || !value.trim()) {
return undefined;
}
try {
return new URL(value).hostname.toLowerCase();
} catch {
const normalized = value.trim().toLowerCase();
if (normalized.includes("api.openai.com")) {
return "api.openai.com";
}
if (normalized.includes("chatgpt.com")) {
return "chatgpt.com";
}
if (normalized.includes(".openai.azure.com")) {
const suffixStart = normalized.indexOf(".openai.azure.com");
const prefix = normalized.slice(0, suffixStart).replace(/^https?:\/\//, "");
return `${prefix}.openai.azure.com`;
}
if (normalized.includes("openrouter.ai")) {
return "openrouter.ai";
}
if (
normalized.includes("localhost") ||
normalized.includes("127.0.0.1") ||
normalized.includes("[::1]") ||
normalized.includes("://::1")
) {
return "localhost";
}
const trimmed = value.trim();
const parsedHostname = tryParseHostname(trimmed);
if (parsedHostname) {
return parsedHostname;
}
if (!isSchemelessHostnameCandidate(trimmed)) {
return undefined;
}
return tryParseHostname(`https://${trimmed}`);
}
function isLocalEndpointHost(host: string): boolean {
@@ -117,31 +118,51 @@ function isLocalEndpointHost(host: string): boolean {
);
}
function classifyProviderEndpoint(baseUrl: string | null | undefined): ProviderEndpointClass {
export function resolveProviderEndpoint(
baseUrl: string | null | undefined,
): ProviderEndpointResolution {
if (typeof baseUrl !== "string" || !baseUrl.trim()) {
return "default";
return { endpointClass: "default" };
}
const host = resolveUrlHostname(baseUrl);
if (!host) {
return "invalid";
return { endpointClass: "invalid" };
}
if (host === "api.openai.com") {
return "openai-public";
return { endpointClass: "openai-public", hostname: host };
}
if (host === "chatgpt.com") {
return "openai-codex";
return { endpointClass: "openai-codex", hostname: host };
}
if (host === "openrouter.ai" || host.endsWith(".openrouter.ai")) {
return "openrouter";
return { endpointClass: "openrouter", hostname: host };
}
if (host.endsWith(".openai.azure.com")) {
return "azure-openai";
return { endpointClass: "azure-openai", hostname: host };
}
if (host === "generativelanguage.googleapis.com") {
return { endpointClass: "google-generative-ai", hostname: host };
}
if (host === "aiplatform.googleapis.com") {
return {
endpointClass: "google-vertex",
hostname: host,
googleVertexRegion: "global",
};
}
const googleVertexHost = /^([a-z0-9-]+)-aiplatform\.googleapis\.com$/.exec(host);
if (googleVertexHost) {
return {
endpointClass: "google-vertex",
hostname: host,
googleVertexRegion: googleVertexHost[1],
};
}
if (isLocalEndpointHost(host)) {
return "local";
return { endpointClass: "local", hostname: host };
}
return "custom";
return { endpointClass: "custom", hostname: host };
}
function resolveKnownProviderFamily(provider: string | undefined): string {
@@ -320,7 +341,8 @@ export function resolveProviderRequestPolicy(
): ProviderRequestPolicyResolution {
const provider = normalizeProviderId(input.provider ?? "");
const policy = resolveProviderAttributionPolicy(provider, env);
const endpointClass = classifyProviderEndpoint(input.baseUrl);
const endpointResolution = resolveProviderEndpoint(input.baseUrl);
const endpointClass = endpointResolution.endpointClass;
const api = input.api?.trim().toLowerCase();
const usesConfiguredBaseUrl = endpointClass !== "default";
const usesKnownNativeOpenAIEndpoint =

View File

@@ -27,6 +27,10 @@ describe("normalizeGoogleApiBaseUrl", () => {
value: "https://proxy.example.com/google/v1beta/",
expected: "https://proxy.example.com/google/v1beta",
},
{
value: "generativelanguage.googleapis.com",
expected: "generativelanguage.googleapis.com",
},
])("normalizes %s", ({ value, expected }) => {
expect(normalizeGoogleApiBaseUrl(value)).toBe(expected);
});

View File

@@ -1,4 +1,4 @@
const DEFAULT_GOOGLE_API_HOST = "generativelanguage.googleapis.com";
import { resolveProviderEndpoint } from "../agents/provider-attribution.js";
export const DEFAULT_GOOGLE_API_BASE_URL = "https://generativelanguage.googleapis.com/v1beta";
@@ -6,6 +6,10 @@ function trimTrailingSlashes(value: string): string {
return value.replace(/\/+$/, "");
}
function isCanonicalGoogleApiOriginShorthand(value: string): boolean {
return /^https:\/\/generativelanguage\.googleapis\.com\/?$/i.test(value);
}
export function normalizeGoogleApiBaseUrl(baseUrl?: string): string {
const raw = trimTrailingSlashes(baseUrl?.trim() || DEFAULT_GOOGLE_API_BASE_URL);
try {
@@ -13,14 +17,14 @@ export function normalizeGoogleApiBaseUrl(baseUrl?: string): string {
url.hash = "";
url.search = "";
if (
url.hostname.toLowerCase() === DEFAULT_GOOGLE_API_HOST &&
resolveProviderEndpoint(url.toString()).endpointClass === "google-generative-ai" &&
trimTrailingSlashes(url.pathname || "") === ""
) {
url.pathname = "/v1beta";
}
return trimTrailingSlashes(url.toString());
} catch {
if (/^https:\/\/generativelanguage\.googleapis\.com\/?$/i.test(raw)) {
if (isCanonicalGoogleApiOriginShorthand(raw)) {
return DEFAULT_GOOGLE_API_BASE_URL;
}
return raw;

View File

@@ -11,3 +11,16 @@ export {
resolveProviderHttpRequestConfig,
requireTranscriptionText,
} from "../media-understanding/shared.js";
export type {
ProviderAttributionPolicy,
ProviderEndpointClass,
ProviderEndpointResolution,
ProviderRequestCapability,
ProviderRequestPolicyInput,
ProviderRequestPolicyResolution,
ProviderRequestTransport,
} from "../agents/provider-attribution.js";
export {
resolveProviderEndpoint,
resolveProviderRequestPolicy,
} from "../agents/provider-attribution.js";