mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 20:00:47 +00:00
feat: Add Google Cloud Vertex AI provider support (vertex, vertex-partner)
Co-authored-by: Quan <quanle96@outlook.com> PR: https://github.com/decolua/9router/pull/298 Thanks to @kwanLeeFrmVi for the original implementation. Here is a summary of changes made during review integration: - Replaced google-auth-library with jose (already a project dependency) for SA JSON -> OAuth2 Bearer token minting (RS256 JWT assertion flow) - Moved auth logic (parseSaJson, refreshVertexToken, token cache) from executor into open-sse/services/tokenRefresh.js to match project pattern - Fixed executor to use proxyAwareFetch instead of raw fetch (proxy support) - Simplified buildUrl: use global aiplatform.googleapis.com endpoint for both vertex (Gemini) and vertex-partner; removed region/modelFamily fields - Added auto-detection of GCP project_id from raw API key via probe request (vertex-partner only, cached per key) - Added vertex/vertex-partner cases to /api/providers/validate/route.js - Updated model lists based on live testing: - vertex: gemini-3.1-pro-preview, gemini-3.1-flash-lite-preview, gemini-3-flash-preview, gemini-2.5-flash (removed gemini-2.5-pro: 404) - vertex-partner: deepseek-v3.2, qwen3-next-80b (instruct+thinking), glm-5 (removed Mistral/Llama: not enabled in test project) - gemini provider: added gemini-3.1-pro-preview, gemini-3.1-flash-lite-preview - Removed bun.lock (project uses npm/package-lock.json) - Removed region and modelFamily UI fields (global endpoint, auto-detect) - Kiro token auto-refresh on AccessDeniedException (from commit 2) Made-with: Cursor
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
import { BaseExecutor } from "./base.js";
|
||||
import { PROVIDERS } from "../config/providers.js";
|
||||
import { parseVertexSaJson, refreshVertexToken } from "../services/tokenRefresh.js";
|
||||
import { proxyAwareFetch } from "../utils/proxyFetch.js";
|
||||
|
||||
// Cache project IDs resolved from raw API keys { apiKey → projectId }
|
||||
const projectIdCache = new Map();
|
||||
|
||||
/**
|
||||
* Resolve GCP project ID from a raw Vertex API key.
|
||||
* Sends a dummy 404 request and parses "projects/{id}" from the error message.
|
||||
*/
|
||||
async function resolveProjectId(apiKey) {
|
||||
if (projectIdCache.has(apiKey)) return projectIdCache.get(apiKey);
|
||||
|
||||
const res = await fetch(
|
||||
`https://aiplatform.googleapis.com/v1/publishers/google/models/__probe__:generateContent?key=${apiKey}`,
|
||||
{ method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" }
|
||||
);
|
||||
const json = await res.json().catch(() => null);
|
||||
const msg = json?.[0]?.error?.message || json?.error?.message || "";
|
||||
const match = msg.match(/projects\/([^/]+)\//);
|
||||
const projectId = match?.[1] || null;
|
||||
|
||||
if (projectId) projectIdCache.set(apiKey, projectId);
|
||||
return projectId;
|
||||
}
|
||||
|
||||
/**
|
||||
* VertexExecutor - Google Cloud Vertex AI
|
||||
*
|
||||
* "vertex" → Gemini models via regional/global Vertex endpoint
|
||||
* "vertex-partner" → Partner models (Llama, Mistral, GLM, DeepSeek, Qwen)
|
||||
* via global OpenAI-compatible endpoint
|
||||
*
|
||||
* Auth: SA JSON (stored as apiKey) → JWT assertion → Bearer token (via jose)
|
||||
* Token is minted/cached in tokenRefresh.js, not here.
|
||||
*/
|
||||
export class VertexExecutor extends BaseExecutor {
|
||||
constructor(providerId = "vertex") {
|
||||
super(providerId, PROVIDERS[providerId] || {});
|
||||
}
|
||||
|
||||
buildUrl(model, stream, urlIndex = 0, credentials = null) {
|
||||
const saJson = parseVertexSaJson(credentials?.apiKey);
|
||||
const rawKey = !saJson ? credentials?.apiKey : null;
|
||||
const projectId = saJson?.project_id || credentials?.providerSpecificData?.projectId;
|
||||
|
||||
if (this.provider === "vertex-partner") {
|
||||
// Partner models require project_id in path regardless of auth method
|
||||
if (!projectId) throw new Error("Vertex partner models require a project_id. Add it in providerSpecificData or use Service Account JSON.");
|
||||
const url = `https://aiplatform.googleapis.com/v1/projects/${projectId}/locations/global/endpoints/openapi/chat/completions`;
|
||||
return rawKey ? `${url}?key=${rawKey}` : url;
|
||||
}
|
||||
|
||||
// Gemini on Vertex: always use global publishers endpoint
|
||||
const action = stream ? "streamGenerateContent" : "generateContent";
|
||||
let url = `https://aiplatform.googleapis.com/v1/publishers/google/models/${model}:${action}`;
|
||||
|
||||
if (rawKey) url += `?key=${rawKey}`;
|
||||
return url;
|
||||
}
|
||||
|
||||
buildHeaders(credentials, stream = true) {
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
|
||||
// Only set Bearer token if using SA JSON flow (raw key goes in URL ?key=)
|
||||
if (credentials.accessToken) {
|
||||
headers["Authorization"] = `Bearer ${credentials.accessToken}`;
|
||||
}
|
||||
|
||||
if (stream) headers["Accept"] = "text/event-stream";
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
async refreshCredentials(credentials, log) {
|
||||
const saJson = parseVertexSaJson(credentials?.apiKey);
|
||||
if (!saJson) return null;
|
||||
|
||||
const result = await refreshVertexToken(saJson, log);
|
||||
if (!result) return null;
|
||||
|
||||
return { accessToken: result.accessToken, expiresAt: result.expiresAt };
|
||||
}
|
||||
|
||||
async execute({ model, body, stream, credentials, signal, log, proxyOptions = null }) {
|
||||
const saJson = parseVertexSaJson(credentials?.apiKey);
|
||||
|
||||
// SA JSON flow: mint Bearer token (cached)
|
||||
if (saJson) {
|
||||
const result = await refreshVertexToken(saJson, log);
|
||||
if (!result?.accessToken) throw new Error("Vertex: failed to mint access token from Service Account JSON");
|
||||
credentials.accessToken = result.accessToken;
|
||||
}
|
||||
|
||||
// vertex-partner with raw key: auto-resolve project_id if not provided
|
||||
if (this.provider === "vertex-partner" && !saJson && !credentials?.providerSpecificData?.projectId) {
|
||||
const projectId = await resolveProjectId(credentials.apiKey);
|
||||
if (!projectId) throw new Error("Vertex: could not resolve project_id from API key. Please add it manually in provider settings.");
|
||||
log?.debug?.("VERTEX", `Resolved project_id: ${projectId}`);
|
||||
credentials.providerSpecificData = { ...credentials.providerSpecificData, projectId };
|
||||
}
|
||||
|
||||
const url = this.buildUrl(model, stream, 0, credentials);
|
||||
const headers = this.buildHeaders(credentials, stream);
|
||||
const transformedBody = this.transformRequest(model, body, stream, credentials);
|
||||
|
||||
const response = await proxyAwareFetch(url, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(transformedBody),
|
||||
signal,
|
||||
}, proxyOptions);
|
||||
|
||||
return { response, url, headers, transformedBody };
|
||||
}
|
||||
}
|
||||
|
||||
export default VertexExecutor;
|
||||
Reference in New Issue
Block a user