mirror of
https://github.com/moltbot/moltbot.git
synced 2026-04-24 23:21:30 +00:00
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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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?: {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user