Add Providers

This commit is contained in:
decolua 2026-02-20 17:05:46 +07:00
parent bd71298fb7
commit 3debf84b9a
30 changed files with 1583 additions and 670 deletions

View file

@ -36,13 +36,13 @@ export async function GET(request, { params }) {
const authData = generateAuthData(provider, null);
// For providers that don't use PKCE (like GitHub), don't pass codeChallenge
// Providers that don't use PKCE for device code
const noPkceDeviceProviders = ["github", "kiro", "kimi-coding", "kilocode"];
let deviceData;
if (provider === "github" || provider === "kiro") {
// GitHub and Kiro don't use PKCE for device code
if (noPkceDeviceProviders.includes(provider)) {
deviceData = await requestDeviceCode(provider);
} else {
// Qwen and other providers use PKCE
// Qwen and other PKCE providers
deviceData = await requestDeviceCode(provider, authData.codeChallenge);
}
@ -69,7 +69,9 @@ export async function POST(request, { params }) {
if (action === "exchange") {
const { code, redirectUri, codeVerifier, state } = body;
if (!code || !redirectUri || !codeVerifier) {
// Cline uses authorization_code without PKCE
const noPkceExchangeProviders = ["cline"];
if (!code || !redirectUri || (!codeVerifier && !noPkceExchangeProviders.includes(provider))) {
return NextResponse.json({ error: "Missing required fields" }, { status: 400 });
}
@ -108,15 +110,16 @@ export async function POST(request, { params }) {
return NextResponse.json({ error: "Missing device code" }, { status: 400 });
}
// For providers that don't use PKCE (like GitHub, Kiro), don't pass codeVerifier
// Providers that don't use PKCE for device code
const noPkceProviders = ["github", "kimi-coding", "kilocode"];
let result;
if (provider === "github") {
if (noPkceProviders.includes(provider)) {
result = await pollForToken(provider, deviceCode);
} else if (provider === "kiro") {
// Kiro needs extraData (clientId, clientSecret) from device code response
result = await pollForToken(provider, deviceCode, null, extraData);
} else {
// Qwen and other providers use PKCE
// Qwen and other PKCE providers
if (!codeVerifier) {
return NextResponse.json({ error: "Missing code verifier" }, { status: 400 });
}

View file

@ -1,549 +1,16 @@
import { NextResponse } from "next/server";
import { getProviderConnectionById, updateProviderConnection, isCloudEnabled } from "@/lib/localDb";
import { getConsistentMachineId } from "@/shared/utils/machineId";
import { syncToCloud } from "@/app/api/sync/cloud/route";
import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers";
import {
GEMINI_CONFIG,
ANTIGRAVITY_CONFIG,
CODEX_CONFIG,
KIRO_CONFIG,
} from "@/lib/oauth/constants/oauth";
// OAuth provider test endpoints
const OAUTH_TEST_CONFIG = {
claude: {
// Claude doesn't have userinfo, we verify token exists and not expired
checkExpiry: true,
},
codex: {
checkExpiry: true,
refreshable: true,
},
"gemini-cli": {
url: "https://www.googleapis.com/oauth2/v1/userinfo?alt=json",
method: "GET",
authHeader: "Authorization",
authPrefix: "Bearer ",
refreshable: true,
},
antigravity: {
url: "https://www.googleapis.com/oauth2/v1/userinfo?alt=json",
method: "GET",
authHeader: "Authorization",
authPrefix: "Bearer ",
refreshable: true,
},
github: {
url: "https://api.github.com/user",
method: "GET",
authHeader: "Authorization",
authPrefix: "Bearer ",
extraHeaders: { "User-Agent": "9Router", "Accept": "application/vnd.github+json" },
},
iflow: {
url: "https://iflow.cn/api/oauth/getUserInfo",
method: "GET",
authHeader: "Authorization",
authPrefix: "Bearer ",
},
qwen: {
url: "https://portal.qwen.ai/v1/models",
method: "GET",
authHeader: "Authorization",
authPrefix: "Bearer ",
},
kiro: {
checkExpiry: true,
refreshable: true,
},
};
/**
* Refresh OAuth token using refresh_token
* @returns {object} { accessToken, expiresIn, refreshToken } or null if failed
*/
async function refreshOAuthToken(connection) {
const provider = connection.provider;
const refreshToken = connection.refreshToken;
if (!refreshToken) return null;
try {
// Google-based providers (gemini-cli, antigravity)
if (provider === "gemini-cli" || provider === "antigravity") {
const config = provider === "gemini-cli" ? GEMINI_CONFIG : ANTIGRAVITY_CONFIG;
const response = await fetch("https://oauth2.googleapis.com/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
client_id: config.clientId,
client_secret: config.clientSecret,
grant_type: "refresh_token",
refresh_token: refreshToken,
}),
});
if (!response.ok) return null;
const data = await response.json();
return {
accessToken: data.access_token,
expiresIn: data.expires_in,
refreshToken: data.refresh_token || refreshToken,
};
}
// OpenAI/Codex
if (provider === "codex") {
const response = await fetch(CODEX_CONFIG.tokenUrl, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "refresh_token",
client_id: CODEX_CONFIG.clientId,
refresh_token: refreshToken,
}),
});
if (!response.ok) return null;
const data = await response.json();
return {
accessToken: data.access_token,
expiresIn: data.expires_in,
refreshToken: data.refresh_token || refreshToken,
};
}
// Kiro (AWS SSO or Social auth)
if (provider === "kiro") {
const { clientId, clientSecret, region } = connection;
// AWS SSO OIDC refresh (Builder ID or IDC)
if (clientId && clientSecret) {
const endpoint = `https://oidc.${region || "us-east-1"}.amazonaws.com/token`;
const response = await fetch(endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
clientId,
clientSecret,
refreshToken,
grantType: "refresh_token",
}),
});
if (!response.ok) {
const errText = await response.text();
console.log(`Kiro AWS SSO refresh failed: ${response.status} - ${errText}`);
return null;
}
const data = await response.json();
return {
accessToken: data.accessToken,
expiresIn: data.expiresIn || 3600,
refreshToken: data.refreshToken || refreshToken,
};
}
// Social auth refresh (Google/GitHub)
const response = await fetch(KIRO_CONFIG.socialRefreshUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refreshToken }),
});
if (!response.ok) {
const errText = await response.text();
console.log(`Kiro social refresh failed: ${response.status} - ${errText}`);
return null;
}
const data = await response.json();
return {
accessToken: data.accessToken,
expiresIn: data.expiresIn || 3600,
refreshToken: data.refreshToken || refreshToken,
};
}
return null;
} catch (err) {
console.log(`Error refreshing ${provider} token:`, err.message);
return null;
}
}
/**
* Check if token is expired or about to expire (within 5 minutes)
*/
function isTokenExpired(connection) {
if (!connection.expiresAt) return false;
const expiresAt = new Date(connection.expiresAt).getTime();
const buffer = 5 * 60 * 1000; // 5 minutes
return expiresAt <= Date.now() + buffer;
}
/**
* Sync to cloud if enabled
*/
async function syncToCloudIfEnabled() {
try {
const cloudEnabled = await isCloudEnabled();
if (!cloudEnabled) return;
const machineId = await getConsistentMachineId();
await syncToCloud(machineId);
} catch (error) {
console.log("Error syncing to cloud after token refresh:", error);
}
}
/**
* Test OAuth connection by calling provider API
* Auto-refreshes token if expired
* @returns {{ valid: boolean, error: string|null, refreshed: boolean, newTokens: object|null }}
*/
async function testOAuthConnection(connection) {
const config = OAUTH_TEST_CONFIG[connection.provider];
if (!config) {
return { valid: false, error: "Provider test not supported", refreshed: false };
}
// Check if token exists
if (!connection.accessToken) {
return { valid: false, error: "No access token", refreshed: false };
}
let accessToken = connection.accessToken;
let refreshed = false;
let newTokens = null;
// Auto-refresh if token is expired and provider supports refresh
const tokenExpired = isTokenExpired(connection);
if (config.refreshable && tokenExpired && connection.refreshToken) {
const tokens = await refreshOAuthToken(connection);
if (tokens) {
accessToken = tokens.accessToken;
refreshed = true;
newTokens = tokens;
} else {
// Refresh failed
return { valid: false, error: "Token expired and refresh failed", refreshed: false };
}
}
// For providers that only check expiry (no test endpoint available)
if (config.checkExpiry) {
// If we already refreshed successfully, token is valid
if (refreshed) {
return { valid: true, error: null, refreshed, newTokens };
}
// Check if token is expired (no refresh available)
if (tokenExpired) {
return { valid: false, error: "Token expired", refreshed: false };
}
return { valid: true, error: null, refreshed: false, newTokens: null };
}
// Call test endpoint
try {
const headers = {
[config.authHeader]: `${config.authPrefix}${accessToken}`,
...config.extraHeaders,
};
const res = await fetch(config.url, {
method: config.method,
headers,
});
if (res.ok) {
return { valid: true, error: null, refreshed, newTokens };
}
// If 401 and we haven't tried refresh yet, try refresh now
if (res.status === 401 && config.refreshable && !refreshed && connection.refreshToken) {
const tokens = await refreshOAuthToken(connection);
if (tokens) {
// Retry with new token
const retryRes = await fetch(config.url, {
method: config.method,
headers: {
[config.authHeader]: `${config.authPrefix}${tokens.accessToken}`,
...config.extraHeaders,
},
});
if (retryRes.ok) {
return { valid: true, error: null, refreshed: true, newTokens: tokens };
}
}
return { valid: false, error: "Token invalid or revoked", refreshed: false };
}
if (res.status === 401) {
return { valid: false, error: "Token invalid or revoked", refreshed };
}
if (res.status === 403) {
return { valid: false, error: "Access denied", refreshed };
}
return { valid: false, error: `API returned ${res.status}`, refreshed };
} catch (err) {
return { valid: false, error: err.message, refreshed };
}
}
/**
* Test API key connection
*/
async function testApiKeyConnection(connection) {
// OpenAI Compatible providers - test via /models endpoint
if (isOpenAICompatibleProvider(connection.provider)) {
const modelsBase = connection.providerSpecificData?.baseUrl;
if (!modelsBase) {
return { valid: false, error: "Missing base URL" };
}
try {
const modelsUrl = `${modelsBase.replace(/\/$/, "")}/models`;
const res = await fetch(modelsUrl, {
headers: { "Authorization": `Bearer ${connection.apiKey}` },
});
return { valid: res.ok, error: res.ok ? null : "Invalid API key or base URL" };
} catch (err) {
return { valid: false, error: err.message };
}
}
// Anthropic Compatible providers - test via /models endpoint
if (isAnthropicCompatibleProvider(connection.provider)) {
let modelsBase = connection.providerSpecificData?.baseUrl;
if (!modelsBase) {
return { valid: false, error: "Missing base URL" };
}
try {
modelsBase = modelsBase.replace(/\/$/, "");
if (modelsBase.endsWith("/messages")) {
modelsBase = modelsBase.slice(0, -9);
}
const modelsUrl = `${modelsBase}/models`;
const res = await fetch(modelsUrl, {
headers: {
"x-api-key": connection.apiKey,
"anthropic-version": "2023-06-01",
"Authorization": `Bearer ${connection.apiKey}`
},
});
return { valid: res.ok, error: res.ok ? null : "Invalid API key or base URL" };
} catch (err) {
return { valid: false, error: err.message };
}
}
try {
switch (connection.provider) {
case "openai": {
const res = await fetch("https://api.openai.com/v1/models", {
headers: { Authorization: `Bearer ${connection.apiKey}` },
});
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
}
case "anthropic": {
const res = await fetch("https://api.anthropic.com/v1/messages", {
method: "POST",
headers: {
"x-api-key": connection.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" }],
}),
});
const valid = res.status !== 401;
return { valid, error: valid ? null : "Invalid API key" };
}
case "gemini": {
const res = await fetch(`https://generativelanguage.googleapis.com/v1/models?key=${connection.apiKey}`);
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
}
case "openrouter": {
const res = await fetch("https://openrouter.ai/api/v1/auth/key", {
headers: { Authorization: `Bearer ${connection.apiKey}` },
});
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
}
case "glm": {
// GLM uses Claude-compatible API at api.z.ai
const res = await fetch("https://api.z.ai/api/anthropic/v1/messages", {
method: "POST",
headers: {
"x-api-key": connection.apiKey,
"anthropic-version": "2023-06-01",
"content-type": "application/json",
},
body: JSON.stringify({
model: "glm-4.7",
max_tokens: 1,
messages: [{ role: "user", content: "test" }],
}),
});
const valid = res.status !== 401 && res.status !== 403;
return { valid, error: valid ? null : "Invalid API key" };
}
case "glm-cn": {
// GLM Coding (China) uses OpenAI-compatible API
const res = await fetch("https://open.bigmodel.cn/api/coding/paas/v4/chat/completions", {
method: "POST",
headers: {
"Authorization": `Bearer ${connection.apiKey}`,
"content-type": "application/json",
},
body: JSON.stringify({
model: "glm-4.7",
max_tokens: 1,
messages: [{ role: "user", content: "test" }],
}),
});
const valid = res.status !== 401 && res.status !== 403;
return { valid, error: valid ? null : "Invalid API key" };
}
case "minimax":
case "minimax-cn": {
// MiniMax uses Claude-compatible API
const minimaxEndpoints = {
minimax: "https://api.minimax.io/anthropic/v1/messages",
"minimax-cn": "https://api.minimaxi.com/anthropic/v1/messages",
};
const res = await fetch(minimaxEndpoints[connection.provider], {
method: "POST",
headers: {
"x-api-key": connection.apiKey,
"anthropic-version": "2023-06-01",
"content-type": "application/json",
},
body: JSON.stringify({
model: "minimax-m2",
max_tokens: 1,
messages: [{ role: "user", content: "test" }],
}),
});
const valid = res.status !== 401 && res.status !== 403;
return { valid, error: valid ? null : "Invalid API key" };
}
case "kimi": {
// Kimi uses Claude-compatible API
const res = await fetch("https://api.kimi.com/coding/v1/messages", {
method: "POST",
headers: {
"x-api-key": connection.apiKey,
"anthropic-version": "2023-06-01",
"content-type": "application/json",
},
body: JSON.stringify({
model: "kimi-latest",
max_tokens: 1,
messages: [{ role: "user", content: "test" }],
}),
});
const valid = res.status !== 401 && res.status !== 403;
return { valid, error: valid ? null : "Invalid API key" };
}
case "deepseek": {
const res = await fetch("https://api.deepseek.com/models", {
headers: { Authorization: `Bearer ${connection.apiKey}` },
});
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
}
case "groq": {
const res = await fetch("https://api.groq.com/openai/v1/models", {
headers: { Authorization: `Bearer ${connection.apiKey}` },
});
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
}
case "mistral": {
const res = await fetch("https://api.mistral.ai/v1/models", {
headers: { Authorization: `Bearer ${connection.apiKey}` },
});
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
}
case "xai": {
const res = await fetch("https://api.x.ai/v1/models", {
headers: { Authorization: `Bearer ${connection.apiKey}` },
});
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
}
default:
return { valid: false, error: "Provider test not supported" };
}
} catch (err) {
return { valid: false, error: err.message };
}
}
import { testSingleConnection } from "./testUtils.js";
// POST /api/providers/[id]/test - Test connection
export async function POST(request, { params }) {
try {
const { id } = await params;
const connection = await getProviderConnectionById(id);
const result = await testSingleConnection(id);
if (!connection) {
if (result.error === "Connection not found") {
return NextResponse.json({ error: "Connection not found" }, { status: 404 });
}
let result;
if (connection.authType === "apikey") {
result = await testApiKeyConnection(connection);
} else {
result = await testOAuthConnection(connection);
}
// Build update data
const updateData = {
testStatus: result.valid ? "active" : "error",
lastError: result.valid ? null : result.error,
lastErrorAt: result.valid ? null : new Date().toISOString(),
};
// If token was refreshed, update tokens in DB
if (result.refreshed && result.newTokens) {
updateData.accessToken = result.newTokens.accessToken;
if (result.newTokens.refreshToken) {
updateData.refreshToken = result.newTokens.refreshToken;
}
if (result.newTokens.expiresIn) {
updateData.expiresAt = new Date(Date.now() + result.newTokens.expiresIn * 1000).toISOString();
}
}
// Update status in db
await updateProviderConnection(id, updateData);
// Sync to cloud if token was refreshed
if (result.refreshed) {
await syncToCloudIfEnabled();
}
return NextResponse.json({
valid: result.valid,
error: result.error,

View file

@ -0,0 +1,341 @@
import { getProviderConnectionById, updateProviderConnection, isCloudEnabled } from "@/lib/localDb";
import { getConsistentMachineId } from "@/shared/utils/machineId";
import { syncToCloud } from "@/app/api/sync/cloud/route";
import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers";
import {
GEMINI_CONFIG,
ANTIGRAVITY_CONFIG,
CODEX_CONFIG,
KIRO_CONFIG,
} from "@/lib/oauth/constants/oauth";
// OAuth provider test endpoints
const OAUTH_TEST_CONFIG = {
claude: { checkExpiry: true },
codex: { checkExpiry: true, refreshable: true },
"gemini-cli": {
url: "https://www.googleapis.com/oauth2/v1/userinfo?alt=json",
method: "GET",
authHeader: "Authorization",
authPrefix: "Bearer ",
refreshable: true,
},
antigravity: {
url: "https://www.googleapis.com/oauth2/v1/userinfo?alt=json",
method: "GET",
authHeader: "Authorization",
authPrefix: "Bearer ",
refreshable: true,
},
github: {
url: "https://api.github.com/user",
method: "GET",
authHeader: "Authorization",
authPrefix: "Bearer ",
extraHeaders: { "User-Agent": "9Router", "Accept": "application/vnd.github+json" },
},
iflow: {
url: "https://iflow.cn/api/oauth/getUserInfo",
method: "GET",
authHeader: "Authorization",
authPrefix: "Bearer ",
},
qwen: {
url: "https://portal.qwen.ai/v1/models",
method: "GET",
authHeader: "Authorization",
authPrefix: "Bearer ",
},
kiro: { checkExpiry: true, refreshable: true },
};
async function refreshOAuthToken(connection) {
const provider = connection.provider;
const refreshToken = connection.refreshToken;
if (!refreshToken) return null;
try {
if (provider === "gemini-cli" || provider === "antigravity") {
const config = provider === "gemini-cli" ? GEMINI_CONFIG : ANTIGRAVITY_CONFIG;
const response = await fetch("https://oauth2.googleapis.com/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
client_id: config.clientId,
client_secret: config.clientSecret,
grant_type: "refresh_token",
refresh_token: refreshToken,
}),
});
if (!response.ok) return null;
const data = await response.json();
return { accessToken: data.access_token, expiresIn: data.expires_in, refreshToken: data.refresh_token || refreshToken };
}
if (provider === "codex") {
const response = await fetch(CODEX_CONFIG.tokenUrl, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "refresh_token",
client_id: CODEX_CONFIG.clientId,
refresh_token: refreshToken,
}),
});
if (!response.ok) return null;
const data = await response.json();
return { accessToken: data.access_token, expiresIn: data.expires_in, refreshToken: data.refresh_token || refreshToken };
}
if (provider === "kiro") {
const { clientId, clientSecret, region } = connection;
if (clientId && clientSecret) {
const endpoint = `https://oidc.${region || "us-east-1"}.amazonaws.com/token`;
const response = await fetch(endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ clientId, clientSecret, refreshToken, grantType: "refresh_token" }),
});
if (!response.ok) return null;
const data = await response.json();
return { accessToken: data.accessToken, expiresIn: data.expiresIn || 3600, refreshToken: data.refreshToken || refreshToken };
}
const response = await fetch(KIRO_CONFIG.socialRefreshUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refreshToken }),
});
if (!response.ok) return null;
const data = await response.json();
return { accessToken: data.accessToken, expiresIn: data.expiresIn || 3600, refreshToken: data.refreshToken || refreshToken };
}
return null;
} catch (err) {
console.log(`Error refreshing ${provider} token:`, err.message);
return null;
}
}
function isTokenExpired(connection) {
if (!connection.expiresAt) return false;
const expiresAt = new Date(connection.expiresAt).getTime();
const buffer = 5 * 60 * 1000;
return expiresAt <= Date.now() + buffer;
}
async function testOAuthConnection(connection) {
const config = OAUTH_TEST_CONFIG[connection.provider];
if (!config) return { valid: false, error: "Provider test not supported", refreshed: false };
if (!connection.accessToken) return { valid: false, error: "No access token", refreshed: false };
let accessToken = connection.accessToken;
let refreshed = false;
let newTokens = null;
const tokenExpired = isTokenExpired(connection);
if (config.refreshable && tokenExpired && connection.refreshToken) {
const tokens = await refreshOAuthToken(connection);
if (tokens) {
accessToken = tokens.accessToken;
refreshed = true;
newTokens = tokens;
} else {
return { valid: false, error: "Token expired and refresh failed", refreshed: false };
}
}
if (config.checkExpiry) {
if (refreshed) return { valid: true, error: null, refreshed, newTokens };
if (tokenExpired) return { valid: false, error: "Token expired", refreshed: false };
return { valid: true, error: null, refreshed: false, newTokens: null };
}
try {
const headers = { [config.authHeader]: `${config.authPrefix}${accessToken}`, ...config.extraHeaders };
const res = await fetch(config.url, { method: config.method, headers });
if (res.ok) return { valid: true, error: null, refreshed, newTokens };
if (res.status === 401 && config.refreshable && !refreshed && connection.refreshToken) {
const tokens = await refreshOAuthToken(connection);
if (tokens) {
const retryRes = await fetch(config.url, {
method: config.method,
headers: { [config.authHeader]: `${config.authPrefix}${tokens.accessToken}`, ...config.extraHeaders },
});
if (retryRes.ok) return { valid: true, error: null, refreshed: true, newTokens: tokens };
}
return { valid: false, error: "Token invalid or revoked", refreshed: false };
}
if (res.status === 401) return { valid: false, error: "Token invalid or revoked", refreshed };
if (res.status === 403) return { valid: false, error: "Access denied", refreshed };
return { valid: false, error: `API returned ${res.status}`, refreshed };
} catch (err) {
return { valid: false, error: err.message, refreshed };
}
}
async function testApiKeyConnection(connection) {
if (isOpenAICompatibleProvider(connection.provider)) {
const modelsBase = connection.providerSpecificData?.baseUrl;
if (!modelsBase) return { valid: false, error: "Missing base URL" };
try {
const res = await fetch(`${modelsBase.replace(/\/$/, "")}/models`, {
headers: { "Authorization": `Bearer ${connection.apiKey}` },
});
return { valid: res.ok, error: res.ok ? null : "Invalid API key or base URL" };
} catch (err) {
return { valid: false, error: err.message };
}
}
if (isAnthropicCompatibleProvider(connection.provider)) {
let modelsBase = connection.providerSpecificData?.baseUrl;
if (!modelsBase) return { valid: false, error: "Missing base URL" };
try {
modelsBase = modelsBase.replace(/\/$/, "");
if (modelsBase.endsWith("/messages")) modelsBase = modelsBase.slice(0, -9);
const res = await fetch(`${modelsBase}/models`, {
headers: { "x-api-key": connection.apiKey, "anthropic-version": "2023-06-01", "Authorization": `Bearer ${connection.apiKey}` },
});
return { valid: res.ok, error: res.ok ? null : "Invalid API key or base URL" };
} catch (err) {
return { valid: false, error: err.message };
}
}
try {
switch (connection.provider) {
case "openai": {
const res = await fetch("https://api.openai.com/v1/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } });
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
}
case "anthropic": {
const res = await fetch("https://api.anthropic.com/v1/messages", {
method: "POST",
headers: { "x-api-key": connection.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" }] }),
});
const valid = res.status !== 401;
return { valid, error: valid ? null : "Invalid API key" };
}
case "gemini": {
const res = await fetch(`https://generativelanguage.googleapis.com/v1/models?key=${connection.apiKey}`);
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
}
case "openrouter": {
const res = await fetch("https://openrouter.ai/api/v1/auth/key", { headers: { Authorization: `Bearer ${connection.apiKey}` } });
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
}
case "glm": {
const res = await fetch("https://api.z.ai/api/anthropic/v1/messages", {
method: "POST",
headers: { "x-api-key": connection.apiKey, "anthropic-version": "2023-06-01", "content-type": "application/json" },
body: JSON.stringify({ model: "glm-4.7", max_tokens: 1, messages: [{ role: "user", content: "test" }] }),
});
const valid = res.status !== 401 && res.status !== 403;
return { valid, error: valid ? null : "Invalid API key" };
}
case "glm-cn": {
const res = await fetch("https://open.bigmodel.cn/api/coding/paas/v4/chat/completions", {
method: "POST",
headers: { "Authorization": `Bearer ${connection.apiKey}`, "content-type": "application/json" },
body: JSON.stringify({ model: "glm-4.7", max_tokens: 1, messages: [{ role: "user", content: "test" }] }),
});
const valid = res.status !== 401 && res.status !== 403;
return { valid, error: valid ? null : "Invalid API key" };
}
case "minimax":
case "minimax-cn": {
const endpoints = { minimax: "https://api.minimax.io/anthropic/v1/messages", "minimax-cn": "https://api.minimaxi.com/anthropic/v1/messages" };
const res = await fetch(endpoints[connection.provider], {
method: "POST",
headers: { "x-api-key": connection.apiKey, "anthropic-version": "2023-06-01", "content-type": "application/json" },
body: JSON.stringify({ model: "minimax-m2", max_tokens: 1, messages: [{ role: "user", content: "test" }] }),
});
const valid = res.status !== 401 && res.status !== 403;
return { valid, error: valid ? null : "Invalid API key" };
}
case "kimi": {
const res = await fetch("https://api.kimi.com/coding/v1/messages", {
method: "POST",
headers: { "x-api-key": connection.apiKey, "anthropic-version": "2023-06-01", "content-type": "application/json" },
body: JSON.stringify({ model: "kimi-latest", max_tokens: 1, messages: [{ role: "user", content: "test" }] }),
});
const valid = res.status !== 401 && res.status !== 403;
return { valid, error: valid ? null : "Invalid API key" };
}
case "deepseek": {
const res = await fetch("https://api.deepseek.com/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } });
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
}
case "groq": {
const res = await fetch("https://api.groq.com/openai/v1/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } });
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
}
case "mistral": {
const res = await fetch("https://api.mistral.ai/v1/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } });
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
}
case "xai": {
const res = await fetch("https://api.x.ai/v1/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } });
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
}
default:
return { valid: false, error: "Provider test not supported" };
}
} catch (err) {
return { valid: false, error: err.message };
}
}
/**
* Test a single connection by ID, update DB, and return result.
*/
export async function testSingleConnection(id) {
const connection = await getProviderConnectionById(id);
if (!connection) return { valid: false, error: "Connection not found", latencyMs: 0, testedAt: new Date().toISOString() };
const start = Date.now();
let result;
if (connection.authType === "apikey") {
result = await testApiKeyConnection(connection);
} else {
result = await testOAuthConnection(connection);
}
const latencyMs = Date.now() - start;
const updateData = {
testStatus: result.valid ? "active" : "error",
lastError: result.valid ? null : result.error,
lastErrorAt: result.valid ? null : new Date().toISOString(),
};
if (result.refreshed && result.newTokens) {
updateData.accessToken = result.newTokens.accessToken;
if (result.newTokens.refreshToken) updateData.refreshToken = result.newTokens.refreshToken;
if (result.newTokens.expiresIn) {
updateData.expiresAt = new Date(Date.now() + result.newTokens.expiresIn * 1000).toISOString();
}
}
await updateProviderConnection(id, updateData);
if (result.refreshed) {
try {
const cloudEnabled = await isCloudEnabled();
if (cloudEnabled) {
const machineId = await getConsistentMachineId();
await syncToCloud(machineId);
}
} catch (err) {
console.log("Error syncing to cloud after token refresh:", err);
}
}
return { valid: result.valid, error: result.error, latencyMs, testedAt: new Date().toISOString() };
}

View file

@ -0,0 +1,131 @@
import { NextResponse } from "next/server";
import { getProviderConnections } from "@/models";
import {
FREE_PROVIDERS,
OAUTH_PROVIDERS,
APIKEY_PROVIDERS,
OPENAI_COMPATIBLE_PREFIX,
ANTHROPIC_COMPATIBLE_PREFIX,
} from "@/shared/constants/providers";
import { testSingleConnection } from "../[id]/test/testUtils.js";
function getAuthGroup(providerId, connection = null) {
// Prioritize authType from connection if available
if (connection?.authType) {
if (connection.authType === "oauth") {
// Check if it's a free provider
if (FREE_PROVIDERS[providerId]) return "free";
return "oauth";
}
return connection.authType;
}
// Fallback to constants
if (FREE_PROVIDERS[providerId]) return "free";
if (OAUTH_PROVIDERS[providerId]) return "oauth";
if (APIKEY_PROVIDERS[providerId]) return "apikey";
if (
typeof providerId === "string" &&
(providerId.startsWith(OPENAI_COMPATIBLE_PREFIX) || providerId.startsWith(ANTHROPIC_COMPATIBLE_PREFIX))
)
return "compatible";
return "apikey";
}
function isCompatibleProvider(providerId) {
return (
typeof providerId === "string" &&
(providerId.startsWith(OPENAI_COMPATIBLE_PREFIX) || providerId.startsWith(ANTHROPIC_COMPATIBLE_PREFIX))
);
}
// POST /api/providers/test-batch - Test multiple connections by group
export async function POST(request) {
try {
const body = await request.json();
const { mode, providerId } = body;
if (!mode) {
return NextResponse.json({ error: "mode is required" }, { status: 400 });
}
const allConnections = await getProviderConnections({ isActive: true });
let connectionsToTest = [];
if (mode === "provider" && providerId) {
connectionsToTest = allConnections.filter((c) => c.provider === providerId);
} else if (mode === "oauth") {
connectionsToTest = allConnections.filter((c) => getAuthGroup(c.provider, c) === "oauth");
} else if (mode === "free") {
connectionsToTest = allConnections.filter((c) => getAuthGroup(c.provider, c) === "free");
} else if (mode === "apikey") {
connectionsToTest = allConnections.filter((c) => getAuthGroup(c.provider, c) === "apikey");
} else if (mode === "compatible") {
connectionsToTest = allConnections.filter((c) => isCompatibleProvider(c.provider));
} else if (mode === "all") {
connectionsToTest = allConnections;
} else {
return NextResponse.json(
{ error: "Invalid mode. Use: provider, oauth, free, apikey, compatible, all" },
{ status: 400 }
);
}
if (connectionsToTest.length === 0) {
return NextResponse.json({
mode,
providerId: providerId || null,
results: [],
summary: { total: 0, passed: 0, failed: 0 },
testedAt: new Date().toISOString(),
});
}
const results = [];
for (const conn of connectionsToTest) {
try {
const data = await testSingleConnection(conn.id);
results.push({
provider: conn.provider,
connectionId: conn.id,
connectionName: conn.name || conn.email || conn.provider,
authType: conn.authType || getAuthGroup(conn.provider, conn),
valid: data.valid,
latencyMs: data.latencyMs || 0,
error: data.error || null,
diagnosis: data.diagnosis || null,
statusCode: data.statusCode || null,
testedAt: data.testedAt || new Date().toISOString(),
});
} catch (error) {
results.push({
provider: conn.provider,
connectionId: conn.id,
connectionName: conn.name || conn.email || conn.provider,
authType: conn.authType || getAuthGroup(conn.provider, conn),
valid: false,
latencyMs: 0,
error: error.message,
diagnosis: { type: "network_error", source: "local", code: null, message: error.message },
statusCode: null,
testedAt: new Date().toISOString(),
});
}
}
return NextResponse.json({
mode,
providerId: providerId || null,
results,
testedAt: new Date().toISOString(),
summary: {
total: results.length,
passed: results.filter((r) => r.valid).length,
failed: results.filter((r) => !r.valid).length,
},
});
} catch (error) {
console.log("Error in batch test:", error);
return NextResponse.json({ error: "Batch test failed" }, { status: 500 });
}
}