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:
Quan 2026-03-14 11:37:23 +07:00 committed by decolua
parent 05fc8e9ed9
commit 39f651f5be
14 changed files with 333 additions and 9 deletions

View file

@ -1685,6 +1685,7 @@ function AddApiKeyModal({ isOpen, provider, providerName, isCompatible, isAnthro
priority: formData.priority,
proxyPoolId: formData.proxyPoolId === NONE_PROXY_POOL_VALUE ? null : formData.proxyPoolId,
testStatus: isValid ? "active" : "unknown",
providerSpecificData: undefined
});
} finally {
setSaving(false);

View file

@ -3,7 +3,7 @@ import { getProviderConnectionById } from "@/models";
import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers";
import { KiroService } from "@/lib/oauth/services/kiro";
import { GEMINI_CONFIG } from "@/lib/oauth/constants/oauth";
import { refreshGoogleToken, updateProviderCredentials } from "@/sse/services/tokenRefresh";
import { refreshGoogleToken, updateProviderCredentials, refreshKiroToken } from "@/sse/services/tokenRefresh";
const GEMINI_CLI_MODELS_URL = "https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels";
@ -258,22 +258,56 @@ export async function GET(request, { params }) {
// Kiro: Try dynamic model fetching first
if (connection.provider === "kiro") {
let warning;
try {
const kiroService = new KiroService();
const profileArn = connection.providerSpecificData?.profileArn;
const accessToken = connection.accessToken;
const refreshToken = connection.refreshToken;
if (accessToken && profileArn) {
const models = await kiroService.listAvailableModels(accessToken, profileArn);
return NextResponse.json({
provider: connection.provider,
connectionId: connection.id,
models
});
try {
const models = await kiroService.listAvailableModels(accessToken, profileArn);
return NextResponse.json({
provider: connection.provider,
connectionId: connection.id,
models
});
} catch (error) {
if (error.message.includes("AccessDeniedException") && refreshToken) {
console.log("Kiro token invalid/expired. Attempting refresh...");
const refreshed = await refreshKiroToken(refreshToken, connection.providerSpecificData);
if (refreshed?.accessToken) {
await updateProviderCredentials(connection.id, {
accessToken: refreshed.accessToken,
refreshToken: refreshed.refreshToken || refreshToken,
expiresIn: refreshed.expiresIn,
});
const models = await kiroService.listAvailableModels(refreshed.accessToken, profileArn);
return NextResponse.json({
provider: connection.provider,
connectionId: connection.id,
models
});
}
}
throw error; // Let outer catch handle it
}
}
} catch (error) {
warning = `Failed to fetch Kiro models: ${error.message}`;
console.log("Failed to fetch Kiro models dynamically, falling back to static:", error.message);
}
// Return empty dynamic list so UI falls back to static provider models.
return NextResponse.json({
provider: connection.provider,
connectionId: connection.id,
models: [],
warning,
});
}
if (connection.provider === "gemini-cli") {

View file

@ -204,6 +204,38 @@ export async function POST(request) {
break;
}
case "vertex": {
// Raw key: probe global endpoint (always 404 for unknown model, never 401)
// SA JSON: attempt token mint via JWT assertion
const saJson = (() => { 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 });
}

View file

@ -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-";

View file

@ -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);