mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 13:38:31 +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:
@@ -497,6 +497,13 @@ export async function getAccessToken(provider, credentials, log) {
|
||||
log
|
||||
);
|
||||
|
||||
case "vertex":
|
||||
case "vertex-partner": {
|
||||
const saJson = parseVertexSaJson(credentials.apiKey);
|
||||
if (!saJson) return null;
|
||||
return await refreshVertexToken(saJson, log);
|
||||
}
|
||||
|
||||
default:
|
||||
log?.warn?.("TOKEN_REFRESH", `Unsupported provider for token refresh: ${provider}`);
|
||||
return null;
|
||||
@@ -534,6 +541,12 @@ export async function refreshTokenByProvider(provider, credentials, log) {
|
||||
credentials.providerSpecificData,
|
||||
log
|
||||
);
|
||||
case "vertex":
|
||||
case "vertex-partner": {
|
||||
const saJson = parseVertexSaJson(credentials.apiKey);
|
||||
if (!saJson) return null;
|
||||
return refreshVertexToken(saJson, log);
|
||||
}
|
||||
default:
|
||||
return refreshAccessToken(provider, credentials.refreshToken, credentials, log);
|
||||
}
|
||||
@@ -613,6 +626,81 @@ export async function getAllAccessTokens(userInfo, log) {
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Vertex AI Service Account JSON from apiKey string
|
||||
*/
|
||||
export function parseVertexSaJson(apiKey) {
|
||||
if (typeof apiKey !== "string") return null;
|
||||
try {
|
||||
const parsed = JSON.parse(apiKey);
|
||||
if (parsed.type === "service_account" && parsed.client_email && parsed.private_key && parsed.project_id) {
|
||||
return parsed;
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Cache Vertex tokens keyed by service account email { token, expiresAt }
|
||||
const vertexTokenCache = new Map();
|
||||
|
||||
/**
|
||||
* Mint a short-lived OAuth2 Bearer token for Google Cloud Vertex AI
|
||||
* using Service Account JSON + jose (RS256 JWT assertion flow).
|
||||
* Token is cached until 5 minutes before expiry.
|
||||
*/
|
||||
export async function refreshVertexToken(saJson, log) {
|
||||
const cacheKey = saJson.client_email;
|
||||
const cached = vertexTokenCache.get(cacheKey);
|
||||
|
||||
// Return cached token if still valid (5-min buffer)
|
||||
if (cached && cached.expiresAt - Date.now() > 5 * 60 * 1000) {
|
||||
return { accessToken: cached.token, expiresAt: cached.expiresAt };
|
||||
}
|
||||
|
||||
try {
|
||||
const { SignJWT, importPKCS8 } = await import("jose");
|
||||
log?.debug?.("TOKEN_REFRESH", `Vertex minting token for ${saJson.client_email}`);
|
||||
const privateKey = await importPKCS8(saJson.private_key.replace(/\\n/g, "\n"), "RS256");
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
const jwt = await new SignJWT({ scope: "https://www.googleapis.com/auth/cloud-platform" })
|
||||
.setProtectedHeader({ alg: "RS256" })
|
||||
.setIssuer(saJson.client_email)
|
||||
.setAudience("https://oauth2.googleapis.com/token")
|
||||
.setIssuedAt(now)
|
||||
.setExpirationTime(now + 3600)
|
||||
.sign(privateKey);
|
||||
|
||||
const res = await fetch("https://oauth2.googleapis.com/token", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
|
||||
assertion: jwt,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
log?.error?.("TOKEN_REFRESH", `Vertex token mint failed: ${err}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const { access_token, expires_in } = await res.json();
|
||||
const expiresAt = Date.now() + (expires_in ?? 3600) * 1000;
|
||||
|
||||
vertexTokenCache.set(cacheKey, { token: access_token, expiresAt });
|
||||
log?.info?.("TOKEN_REFRESH", `Vertex token minted for ${saJson.client_email}`);
|
||||
|
||||
return { accessToken: access_token, expiresAt };
|
||||
} catch (error) {
|
||||
log?.error?.("TOKEN_REFRESH", `Vertex token error: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh token with retry and exponential backoff
|
||||
* Retries on failure with increasing delay: 1s, 2s, 3s...
|
||||
|
||||
Reference in New Issue
Block a user