Add Providers
This commit is contained in:
parent
bd71298fb7
commit
3debf84b9a
30 changed files with 1583 additions and 670 deletions
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
341
src/app/api/providers/[id]/test/testUtils.js
Normal file
341
src/app/api/providers/[id]/test/testUtils.js
Normal 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() };
|
||||
}
|
||||
131
src/app/api/providers/test-batch/route.js
Normal file
131
src/app/api/providers/test-batch/route.js
Normal 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 });
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue