9router/src/app/api/providers/validate/route.js
Quan 39f651f5be 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
2026-03-14 11:37:23 +07:00

255 lines
9.5 KiB
JavaScript

import { NextResponse } from "next/server";
import { getProviderNodeById } from "@/models";
import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers";
import { getDefaultModel } from "open-sse/config/providerModels.js";
// POST /api/providers/validate - Validate API key with provider
export async function POST(request) {
try {
const body = await request.json();
const { provider, apiKey } = body;
if (!provider || !apiKey) {
return NextResponse.json({ error: "Provider and API key required" }, { status: 400 });
}
let isValid = false;
let error = null;
// Validate with each provider
try {
if (isOpenAICompatibleProvider(provider)) {
const node = await getProviderNodeById(provider);
if (!node) {
return NextResponse.json({ error: "OpenAI Compatible node not found" }, { status: 404 });
}
const modelsUrl = `${node.baseUrl?.replace(/\/$/, "")}/models`;
const res = await fetch(modelsUrl, {
headers: { "Authorization": `Bearer ${apiKey}` },
});
isValid = res.ok;
return NextResponse.json({
valid: isValid,
error: isValid ? null : "Invalid API key",
});
}
if (isAnthropicCompatibleProvider(provider)) {
const node = await getProviderNodeById(provider);
if (!node) {
return NextResponse.json({ error: "Anthropic Compatible node not found" }, { status: 404 });
}
let normalizedBase = node.baseUrl?.trim().replace(/\/$/, "") || "";
if (normalizedBase.endsWith("/messages")) {
normalizedBase = normalizedBase.slice(0, -9); // remove /messages
}
const modelsUrl = `${normalizedBase}/models`;
const res = await fetch(modelsUrl, {
headers: {
"x-api-key": apiKey,
"anthropic-version": "2023-06-01",
"Authorization": `Bearer ${apiKey}`
},
});
isValid = res.ok;
return NextResponse.json({
valid: isValid,
error: isValid ? null : "Invalid API key",
});
}
switch (provider) {
case "openai":
const openaiRes = await fetch("https://api.openai.com/v1/models", {
headers: { "Authorization": `Bearer ${apiKey}` },
});
isValid = openaiRes.ok;
break;
case "anthropic":
const anthropicRes = await fetch("https://api.anthropic.com/v1/messages", {
method: "POST",
headers: {
"x-api-key": apiKey,
"anthropic-version": "2023-06-01",
"content-type": "application/json",
},
body: JSON.stringify({
model: "claude-3-haiku-20240307",
max_tokens: 1,
messages: [{ role: "user", content: "test" }],
}),
});
isValid = anthropicRes.status !== 401;
break;
case "gemini":
const geminiRes = await fetch(`https://generativelanguage.googleapis.com/v1/models?key=${apiKey}`);
isValid = geminiRes.ok;
break;
case "openrouter":
const openrouterRes = await fetch("https://openrouter.ai/api/v1/models", {
headers: { "Authorization": `Bearer ${apiKey}` },
});
isValid = openrouterRes.ok;
break;
case "glm":
case "glm-cn":
case "kimi":
case "minimax":
case "minimax-cn":
case "alicode-intl":
case "alicode": {
const claudeBaseUrls = {
glm: "https://api.z.ai/api/anthropic/v1/messages",
"glm-cn": "https://open.bigmodel.cn/api/coding/paas/v4/chat/completions",
kimi: "https://api.kimi.com/coding/v1/messages",
minimax: "https://api.minimax.io/anthropic/v1/messages",
"minimax-cn": "https://api.minimaxi.com/anthropic/v1/messages",
alicode: "https://coding.dashscope.aliyuncs.com/v1/chat/completions",
"alicode-intl": "https://coding-intl.dashscope.aliyuncs.com/v1/chat/completions",
};
// glm-cn, alicode and alicode-intl use OpenAI format
if (provider === "glm-cn" || provider === "alicode" || provider === "alicode-intl") {
const testModel = getDefaultModel(provider);
const glmCnRes = await fetch(claudeBaseUrls[provider], {
method: "POST",
headers: {
"Authorization": `Bearer ${apiKey}`,
"content-type": "application/json",
},
body: JSON.stringify({
model: testModel,
max_tokens: 1,
messages: [{ role: "user", content: "test" }],
}),
});
isValid = glmCnRes.status !== 401 && glmCnRes.status !== 403;
} else {
const claudeRes = await fetch(claudeBaseUrls[provider], {
method: "POST",
headers: {
"x-api-key": apiKey,
"anthropic-version": "2023-06-01",
"content-type": "application/json",
},
body: JSON.stringify({
model: "claude-sonnet-4-20250514",
max_tokens: 1,
messages: [{ role: "user", content: "test" }],
}),
});
isValid = claudeRes.status !== 401;
}
break;
}
case "deepseek":
case "groq":
case "xai":
case "mistral":
case "perplexity":
case "together":
case "fireworks":
case "cerebras":
case "cohere":
case "nebius":
case "siliconflow":
case "hyperbolic":
case "ollama":
case "ollama-local":
case "assemblyai":
case "nanobanana":
case "chutes":
case "nvidia": {
const endpoints = {
deepseek: "https://api.deepseek.com/models",
groq: "https://api.groq.com/openai/v1/models",
xai: "https://api.x.ai/v1/models",
mistral: "https://api.mistral.ai/v1/models",
perplexity: "https://api.perplexity.ai/models",
together: "https://api.together.xyz/v1/models",
fireworks: "https://api.fireworks.ai/inference/v1/models",
cerebras: "https://api.cerebras.ai/v1/models",
cohere: "https://api.cohere.ai/v1/models",
nebius: "https://api.studio.nebius.ai/v1/models",
siliconflow: "https://api.siliconflow.cn/v1/models",
hyperbolic: "https://api.hyperbolic.xyz/v1/models",
ollama: "https://ollama.com/api/tags",
"ollama-local": "http://localhost:11434/api/tags",
assemblyai: "https://api.assemblyai.com/v1/account",
nanobanana: "https://api.nanobananaapi.ai/v1/models",
chutes: "https://llm.chutes.ai/v1/models",
nvidia: "https://integrate.api.nvidia.com/v1/models"
};
const res = await fetch(endpoints[provider], {
headers: { "Authorization": `Bearer ${apiKey}` },
});
isValid = res.ok;
break;
}
case "deepgram": {
const res = await fetch("https://api.deepgram.com/v1/projects", {
headers: { "Authorization": `Token ${apiKey}` },
});
isValid = res.ok;
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 });
}
} catch (err) {
error = err.message;
isValid = false;
}
return NextResponse.json({
valid: isValid,
error: isValid ? null : (error || "Invalid API key"),
});
} catch (error) {
console.log("Error validating API key:", error);
return NextResponse.json({ error: "Validation failed" }, { status: 500 });
}
}