From 39f651f5be8fee1ebf72a4f85fb6cbb36112decc Mon Sep 17 00:00:00 2001 From: Quan Date: Sat, 14 Mar 2026 11:37:23 +0700 Subject: [PATCH] feat: Add Google Cloud Vertex AI provider support (vertex, vertex-partner) Co-authored-by: Quan 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 --- open-sse/config/providerModels.js | 17 +++ open-sse/config/providers.js | 12 ++ open-sse/executors/index.js | 6 +- open-sse/executors/vertex.js | 120 ++++++++++++++++++ open-sse/services/model.js | 4 + open-sse/services/provider.js | 6 + open-sse/services/tokenRefresh.js | 88 +++++++++++++ public/providers/vertex-partner.png | Bin 0 -> 10060 bytes public/providers/vertex.png | Bin 0 -> 10060 bytes .../dashboard/providers/[id]/page.js | 1 + src/app/api/providers/[id]/models/route.js | 48 ++++++- src/app/api/providers/validate/route.js | 32 +++++ src/shared/constants/providers.js | 2 + src/sse/services/tokenRefresh.js | 6 +- 14 files changed, 333 insertions(+), 9 deletions(-) create mode 100644 open-sse/executors/vertex.js create mode 100644 public/providers/vertex-partner.png create mode 100644 public/providers/vertex.png diff --git a/open-sse/config/providerModels.js b/open-sse/config/providerModels.js index a9cb3df..1cc4ee5 100644 --- a/open-sse/config/providerModels.js +++ b/open-sse/config/providerModels.js @@ -166,7 +166,10 @@ export const PROVIDER_MODELS = { { id: "claude-3-5-sonnet-20241022", name: "Claude 3.5 Sonnet" }, ], gemini: [ + { id: "gemini-3.1-pro-preview", name: "Gemini 3.1 Pro Preview" }, + { id: "gemini-3.1-flash-lite-preview", name: "Gemini 3.1 Flash Lite Preview" }, { id: "gemini-3-pro-preview", name: "Gemini 3 Pro Preview" }, + { id: "gemini-3-flash-preview", name: "Gemini 3 Flash Preview" }, { id: "gemini-2.5-pro", name: "Gemini 2.5 Pro" }, { id: "gemini-2.5-flash", name: "Gemini 2.5 Flash" }, { id: "gemini-2.5-flash-lite", name: "Gemini 2.5 Flash Lite" }, @@ -311,6 +314,18 @@ export const PROVIDER_MODELS = { { id: "glm-4.7-flash", name: "GLM 4.7 Flash" }, { id: "qwen3.5", name: "Qwen3.5" }, ], + vertex: [ + { id: "gemini-3.1-pro-preview", name: "Gemini 3.1 Pro Preview" }, + { id: "gemini-3.1-flash-lite-preview", name: "Gemini 3.1 Flash Lite Preview" }, + { id: "gemini-3-flash-preview", name: "Gemini 3 Flash Preview" }, + { id: "gemini-2.5-flash", name: "Gemini 2.5 Flash" }, + ], + "vertex-partner": [ + { id: "deepseek-ai/deepseek-v3.2-maas", name: "DeepSeek V3.2 (Vertex)" }, + { id: "qwen/qwen3-next-80b-a3b-thinking-maas", name: "Qwen3 Next 80B Thinking (Vertex)" }, + { id: "qwen/qwen3-next-80b-a3b-instruct-maas", name: "Qwen3 Next 80B Instruct (Vertex)" }, + { id: "zai-org/glm-5-maas", name: "GLM-5 (Vertex)" }, + ], }; // Helper functions @@ -358,6 +373,8 @@ const OAUTH_ALIASES = { "kimi-coding": "kmc", kilocode: "kc", cline: "cl", + vertex: "vertex", + "vertex-partner": "vertex-partner", }; // Derived from PROVIDERS — no need to maintain manually diff --git a/open-sse/config/providers.js b/open-sse/config/providers.js index 9cb1cb0..f945bcc 100644 --- a/open-sse/config/providers.js +++ b/open-sse/config/providers.js @@ -298,4 +298,16 @@ export const PROVIDERS = { baseUrl: "http://localhost:11434/api/chat", format: "ollama" }, + // Vertex AI - Gemini models via Service Account JSON + // baseUrl is not used; VertexExecutor.buildUrl() constructs it dynamically + vertex: { + baseUrl: "https://aiplatform.googleapis.com", + format: "gemini" + }, + // Vertex AI - Partner models (Claude, Llama, Mistral, GLM) via SA JSON + // Uses OpenAI-compatible global endpoint (or rawPredict for Anthropic) + "vertex-partner": { + baseUrl: "https://aiplatform.googleapis.com", + format: "openai" + }, }; diff --git a/open-sse/executors/index.js b/open-sse/executors/index.js index 49a4c27..ac76eb7 100644 --- a/open-sse/executors/index.js +++ b/open-sse/executors/index.js @@ -5,6 +5,7 @@ import { IFlowExecutor } from "./iflow.js"; import { KiroExecutor } from "./kiro.js"; import { CodexExecutor } from "./codex.js"; import { CursorExecutor } from "./cursor.js"; +import { VertexExecutor } from "./vertex.js"; import { DefaultExecutor } from "./default.js"; const executors = { @@ -15,7 +16,9 @@ const executors = { kiro: new KiroExecutor(), codex: new CodexExecutor(), cursor: new CursorExecutor(), - cu: new CursorExecutor() // Alias for cursor + cu: new CursorExecutor(), // Alias for cursor + vertex: new VertexExecutor("vertex"), + "vertex-partner": new VertexExecutor("vertex-partner"), }; const defaultCache = new Map(); @@ -38,4 +41,5 @@ export { IFlowExecutor } from "./iflow.js"; export { KiroExecutor } from "./kiro.js"; export { CodexExecutor } from "./codex.js"; export { CursorExecutor } from "./cursor.js"; +export { VertexExecutor } from "./vertex.js"; export { DefaultExecutor } from "./default.js"; diff --git a/open-sse/executors/vertex.js b/open-sse/executors/vertex.js new file mode 100644 index 0000000..8928faf --- /dev/null +++ b/open-sse/executors/vertex.js @@ -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; diff --git a/open-sse/services/model.js b/open-sse/services/model.js index 6b2f7f3..119d726 100644 --- a/open-sse/services/model.js +++ b/open-sse/services/model.js @@ -46,6 +46,10 @@ const ALIAS_TO_PROVIDER_ID = { ch: "chutes", chutes: "chutes", cursor: "cursor", + vx: "vertex", + vertex: "vertex", + vxp: "vertex-partner", + "vertex-partner": "vertex-partner", }; /** diff --git a/open-sse/services/provider.js b/open-sse/services/provider.js index 0949c64..286ed44 100644 --- a/open-sse/services/provider.js +++ b/open-sse/services/provider.js @@ -298,6 +298,12 @@ export function buildProviderHeaders(provider, credentials, stream = true, body // Claude-compatible API providers use x-api-key headers["x-api-key"] = credentials.apiKey; break; + + case "vertex": + case "vertex-partner": + // Vertex uses async token minting — headers are set by VertexExecutor._buildHeadersAsync() + // Do NOT set Authorization here; it would leak the raw SA JSON as Bearer token + break; default: headers["Authorization"] = `Bearer ${credentials.apiKey || credentials.accessToken}`; diff --git a/open-sse/services/tokenRefresh.js b/open-sse/services/tokenRefresh.js index 1cdd93e..60423f7 100644 --- a/open-sse/services/tokenRefresh.js +++ b/open-sse/services/tokenRefresh.js @@ -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... diff --git a/public/providers/vertex-partner.png b/public/providers/vertex-partner.png new file mode 100644 index 0000000000000000000000000000000000000000..00ee1a40a90a400fe017f1a90cf6f3360f77d6a7 GIT binary patch literal 10060 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 { try { const p = JSON.parse(apiKey); return p.type === "service_account" ? p : null; } catch { return null; } })(); + if (saJson) { + // Validate SA JSON has required fields + isValid = !!(saJson.client_email && saJson.private_key && saJson.project_id); + } else { + // Raw key: probe Vertex — 404 means key is valid (model just doesn't exist), 401 means invalid key + const probeRes = await fetch( + `https://aiplatform.googleapis.com/v1/publishers/google/models/__probe__:generateContent?key=${apiKey}`, + { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" } + ); + isValid = probeRes.status !== 401 && probeRes.status !== 403; + } + break; + } + + case "vertex-partner": { + const saJson = (() => { try { const p = JSON.parse(apiKey); return p.type === "service_account" ? p : null; } catch { return null; } })(); + if (saJson) { + isValid = !!(saJson.client_email && saJson.private_key && saJson.project_id); + } else { + const probeRes = await fetch( + `https://aiplatform.googleapis.com/v1/publishers/google/models/__probe__:generateContent?key=${apiKey}`, + { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" } + ); + isValid = probeRes.status !== 401 && probeRes.status !== 403; + } + break; + } + default: return NextResponse.json({ error: "Provider validation not supported" }, { status: 400 }); } diff --git a/src/shared/constants/providers.js b/src/shared/constants/providers.js index c87758e..f1dc0ee 100644 --- a/src/shared/constants/providers.js +++ b/src/shared/constants/providers.js @@ -51,6 +51,8 @@ export const APIKEY_PROVIDERS = { chutes: { id: "chutes", alias: "ch", name: "Chutes AI", icon: "water_drop", color: "#ffffffff", textIcon: "CH", website: "https://chutes.ai" }, ollama: { id: "ollama", alias: "ollama", name: "Ollama Cloud", icon: "cloud", color: "#ffffffff", textIcon: "OL", website: "https://ollama.com" }, "ollama-local": { id: "ollama-local", alias: "ollama-local", name: "Ollama Local", icon: "cloud", color: "#ffffffff", textIcon: "OL", website: "https://ollama.com" }, + vertex: { id: "vertex", alias: "vx", name: "Vertex AI", icon: "cloud", color: "#4285F4", textIcon: "VX", website: "https://cloud.google.com/vertex-ai" }, + "vertex-partner": { id: "vertex-partner", alias: "vxp", name: "Vertex Partner", icon: "cloud", color: "#34A853", textIcon: "VP", website: "https://cloud.google.com/vertex-ai/generative-ai/docs/partner-models/use-partner-models" }, }; export const OPENAI_COMPATIBLE_PREFIX = "openai-compatible-"; diff --git a/src/sse/services/tokenRefresh.js b/src/sse/services/tokenRefresh.js index ebcf896..759178a 100644 --- a/src/sse/services/tokenRefresh.js +++ b/src/sse/services/tokenRefresh.js @@ -19,7 +19,8 @@ import { getAccessToken as _getAccessToken, refreshTokenByProvider as _refreshTokenByProvider, formatProviderCredentials as _formatProviderCredentials, - getAllAccessTokens as _getAllAccessTokens + getAllAccessTokens as _getAllAccessTokens, + refreshKiroToken as _refreshKiroToken } from "open-sse/services/tokenRefresh.js"; export const TOKEN_EXPIRY_BUFFER_MS = BUFFER_MS; @@ -50,6 +51,9 @@ export const refreshGitHubToken = (refreshToken) => export const refreshCopilotToken = (githubAccessToken) => _refreshCopilotToken(githubAccessToken, log); +export const refreshKiroToken = (refreshToken, providerSpecificData) => + _refreshKiroToken(refreshToken, providerSpecificData, log); + export const getAccessToken = (provider, credentials) => _getAccessToken(provider, credentials, log);