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:
parent
05fc8e9ed9
commit
39f651f5be
14 changed files with 333 additions and 9 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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-";
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue