Add Providers
@@ -250,6 +250,33 @@ export const PROVIDERS = {
|
||||
"User-Agent": "connect-es/1.6.1"
|
||||
},
|
||||
clientVersion: "1.1.3"
|
||||
},
|
||||
"kimi-coding": {
|
||||
baseUrl: "https://api.kimi.com/coding/v1/messages",
|
||||
format: "claude",
|
||||
headers: {
|
||||
"Anthropic-Version": "2023-06-01",
|
||||
"Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14"
|
||||
},
|
||||
clientId: "17e5f671-d194-4dfb-9706-5516cb48c098",
|
||||
tokenUrl: "https://auth.kimi.com/api/oauth/token",
|
||||
refreshUrl: "https://auth.kimi.com/api/oauth/token"
|
||||
},
|
||||
kilocode: {
|
||||
baseUrl: "https://api.kilo.ai/api/openrouter/chat/completions",
|
||||
format: "openrouter",
|
||||
headers: {}
|
||||
},
|
||||
cline: {
|
||||
baseUrl: "https://api.cline.bot/api/v1/messages",
|
||||
format: "claude",
|
||||
headers: {
|
||||
"HTTP-Referer": "https://cline.bot",
|
||||
"X-Title": "Cline",
|
||||
"Anthropic-Version": "2023-06-01"
|
||||
},
|
||||
tokenUrl: "https://api.cline.bot/api/v1/auth/token",
|
||||
refreshUrl: "https://api.cline.bot/api/v1/auth/refresh"
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -116,6 +116,30 @@ export const PROVIDER_MODELS = {
|
||||
{ id: "claude-4.5-opus", name: "Claude 4.5 Opus" },
|
||||
{ id: "gpt-5.2-codex", name: "GPT 5.2 Codex" },
|
||||
],
|
||||
kmc: [ // Kimi Coding
|
||||
{ id: "kimi-k2.5", name: "Kimi K2.5" },
|
||||
{ id: "kimi-k2.5-thinking", name: "Kimi K2.5 Thinking" },
|
||||
{ id: "kimi-latest", name: "Kimi Latest" },
|
||||
],
|
||||
kc: [ // KiloCode
|
||||
{ id: "anthropic/claude-sonnet-4-20250514", name: "Claude Sonnet 4" },
|
||||
{ id: "anthropic/claude-opus-4-20250514", name: "Claude Opus 4" },
|
||||
{ id: "google/gemini-2.5-pro", name: "Gemini 2.5 Pro" },
|
||||
{ id: "google/gemini-2.5-flash", name: "Gemini 2.5 Flash" },
|
||||
{ id: "openai/gpt-4.1", name: "GPT-4.1" },
|
||||
{ id: "openai/o3", name: "o3" },
|
||||
{ id: "deepseek/deepseek-chat", name: "DeepSeek Chat" },
|
||||
{ id: "deepseek/deepseek-reasoner", name: "DeepSeek Reasoner" },
|
||||
],
|
||||
cl: [ // Cline
|
||||
{ id: "anthropic/claude-sonnet-4-20250514", name: "Claude Sonnet 4" },
|
||||
{ id: "anthropic/claude-opus-4-20250514", name: "Claude Opus 4" },
|
||||
{ id: "google/gemini-2.5-pro", name: "Gemini 2.5 Pro" },
|
||||
{ id: "google/gemini-2.5-flash", name: "Gemini 2.5 Flash" },
|
||||
{ id: "openai/gpt-4.1", name: "GPT-4.1" },
|
||||
{ id: "openai/o3", name: "o3" },
|
||||
{ id: "deepseek/deepseek-chat", name: "DeepSeek Chat" },
|
||||
],
|
||||
|
||||
// API Key Providers (alias = id)
|
||||
openai: [
|
||||
@@ -167,6 +191,88 @@ export const PROVIDER_MODELS = {
|
||||
{ id: "MiniMax-M2.5", name: "MiniMax M2.5" },
|
||||
{ id: "MiniMax-M2.1", name: "MiniMax M2.1" },
|
||||
],
|
||||
deepseek: [
|
||||
{ id: "deepseek-chat", name: "DeepSeek V3.2 Chat" },
|
||||
{ id: "deepseek-reasoner", name: "DeepSeek V3.2 Reasoner" },
|
||||
],
|
||||
groq: [
|
||||
{ id: "llama-3.3-70b-versatile", name: "Llama 3.3 70B" },
|
||||
{ id: "meta-llama/llama-4-maverick-17b-128e-instruct", name: "Llama 4 Maverick" },
|
||||
{ id: "qwen/qwen3-32b", name: "Qwen3 32B" },
|
||||
{ id: "openai/gpt-oss-120b", name: "GPT-OSS 120B" },
|
||||
],
|
||||
xai: [
|
||||
{ id: "grok-4", name: "Grok 4" },
|
||||
{ id: "grok-4-fast-reasoning", name: "Grok 4 Fast Reasoning" },
|
||||
{ id: "grok-code-fast-1", name: "Grok Code Fast" },
|
||||
{ id: "grok-3", name: "Grok 3" },
|
||||
],
|
||||
mistral: [
|
||||
{ id: "mistral-large-latest", name: "Mistral Large 3" },
|
||||
{ id: "codestral-latest", name: "Codestral" },
|
||||
{ id: "mistral-medium-latest", name: "Mistral Medium 3" },
|
||||
],
|
||||
perplexity: [
|
||||
{ id: "sonar-pro", name: "Sonar Pro" },
|
||||
{ id: "sonar", name: "Sonar" },
|
||||
],
|
||||
together: [
|
||||
{ id: "meta-llama/Llama-3.3-70B-Instruct-Turbo", name: "Llama 3.3 70B Turbo" },
|
||||
{ id: "deepseek-ai/DeepSeek-R1", name: "DeepSeek R1" },
|
||||
{ id: "Qwen/Qwen3-235B-A22B", name: "Qwen3 235B" },
|
||||
{ id: "meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8", name: "Llama 4 Maverick" },
|
||||
],
|
||||
fireworks: [
|
||||
{ id: "accounts/fireworks/models/deepseek-v3p1", name: "DeepSeek V3.1" },
|
||||
{ id: "accounts/fireworks/models/llama-v3p3-70b-instruct", name: "Llama 3.3 70B" },
|
||||
{ id: "accounts/fireworks/models/qwen3-235b-a22b", name: "Qwen3 235B" },
|
||||
],
|
||||
cerebras: [
|
||||
{ id: "gpt-oss-120b", name: "GPT OSS 120B" },
|
||||
{ id: "zai-glm-4.7", name: "ZAI GLM 4.7" },
|
||||
{ id: "llama-3.3-70b", name: "Llama 3.3 70B" },
|
||||
{ id: "llama-4-scout-17b-16e-instruct", name: "Llama 4 Scout" },
|
||||
{ id: "qwen-3-235b-a22b-instruct-2507", name: "Qwen3 235B A22B" },
|
||||
{ id: "qwen-3-32b", name: "Qwen3 32B" },
|
||||
],
|
||||
cohere: [
|
||||
{ id: "command-r-plus-08-2024", name: "Command R+ (Aug 2024)" },
|
||||
{ id: "command-r-08-2024", name: "Command R (Aug 2024)" },
|
||||
{ id: "command-a-03-2025", name: "Command A (Mar 2025)" },
|
||||
],
|
||||
nvidia: [
|
||||
{ id: "moonshotai/kimi-k2.5", name: "Kimi K2.5" },
|
||||
{ id: "z-ai/glm4.7", name: "GLM 4.7" },
|
||||
{ id: "deepseek-ai/deepseek-v3.2", name: "DeepSeek V3.2" },
|
||||
{ id: "nvidia/llama-3.3-70b-instruct", name: "Llama 3.3 70B" },
|
||||
{ id: "meta/llama-4-maverick-17b-128e-instruct", name: "Llama 4 Maverick" },
|
||||
{ id: "deepseek/deepseek-r1", name: "DeepSeek R1" },
|
||||
],
|
||||
nebius: [
|
||||
{ id: "meta-llama/Llama-3.3-70B-Instruct", name: "Llama 3.3 70B Instruct" },
|
||||
],
|
||||
siliconflow: [
|
||||
{ id: "deepseek-ai/DeepSeek-V3.2", name: "DeepSeek V3.2" },
|
||||
{ id: "deepseek-ai/DeepSeek-V3.1", name: "DeepSeek V3.1" },
|
||||
{ id: "deepseek-ai/DeepSeek-R1", name: "DeepSeek R1" },
|
||||
{ id: "Qwen/Qwen3-235B-A22B-Instruct-2507", name: "Qwen3 235B" },
|
||||
{ id: "Qwen/Qwen3-Coder-480B-A35B-Instruct", name: "Qwen3 Coder 480B" },
|
||||
{ id: "Qwen/Qwen3-32B", name: "Qwen3 32B" },
|
||||
{ id: "moonshotai/Kimi-K2.5", name: "Kimi K2.5" },
|
||||
{ id: "zai-org/GLM-4.7", name: "GLM 4.7" },
|
||||
{ id: "openai/gpt-oss-120b", name: "GPT OSS 120B" },
|
||||
{ id: "baidu/ERNIE-4.5-300B-A47B", name: "ERNIE 4.5 300B" },
|
||||
],
|
||||
hyperbolic: [
|
||||
{ id: "Qwen/QwQ-32B", name: "QwQ 32B" },
|
||||
{ id: "deepseek-ai/DeepSeek-R1", name: "DeepSeek R1" },
|
||||
{ id: "deepseek-ai/DeepSeek-V3", name: "DeepSeek V3" },
|
||||
{ id: "meta-llama/Llama-3.3-70B-Instruct", name: "Llama 3.3 70B" },
|
||||
{ id: "meta-llama/Llama-3.2-3B-Instruct", name: "Llama 3.2 3B" },
|
||||
{ id: "Qwen/Qwen2.5-72B-Instruct", name: "Qwen 2.5 72B" },
|
||||
{ id: "Qwen/Qwen2.5-Coder-32B-Instruct", name: "Qwen 2.5 Coder 32B" },
|
||||
{ id: "NousResearch/Hermes-3-Llama-3.1-70B", name: "Hermes 3 70B" },
|
||||
],
|
||||
};
|
||||
|
||||
// Helper functions
|
||||
@@ -211,6 +317,9 @@ export const PROVIDER_ID_TO_ALIAS = {
|
||||
github: "gh",
|
||||
kiro: "kr",
|
||||
cursor: "cu",
|
||||
"kimi-coding": "kmc",
|
||||
kilocode: "kc",
|
||||
cline: "cl",
|
||||
openai: "openai",
|
||||
anthropic: "anthropic",
|
||||
gemini: "gemini",
|
||||
@@ -220,6 +329,19 @@ export const PROVIDER_ID_TO_ALIAS = {
|
||||
kimi: "kimi",
|
||||
minimax: "minimax",
|
||||
"minimax-cn": "minimax-cn",
|
||||
deepseek: "deepseek",
|
||||
groq: "groq",
|
||||
xai: "xai",
|
||||
mistral: "mistral",
|
||||
perplexity: "perplexity",
|
||||
together: "together",
|
||||
fireworks: "fireworks",
|
||||
cerebras: "cerebras",
|
||||
cohere: "cohere",
|
||||
nvidia: "nvidia",
|
||||
nebius: "nebius",
|
||||
siliconflow: "siliconflow",
|
||||
hyperbolic: "hyperbolic",
|
||||
};
|
||||
|
||||
export function getModelsByProviderId(providerId) {
|
||||
|
||||
@@ -22,6 +22,8 @@ export class DefaultExecutor extends BaseExecutor {
|
||||
case "claude":
|
||||
case "glm":
|
||||
case "kimi":
|
||||
case "kimi-coding":
|
||||
case "cline":
|
||||
case "minimax":
|
||||
case "minimax-cn":
|
||||
return `${this.config.baseUrl}?beta=true`;
|
||||
@@ -44,9 +46,11 @@ export class DefaultExecutor extends BaseExecutor {
|
||||
break;
|
||||
case "glm":
|
||||
case "kimi":
|
||||
case "kimi-coding":
|
||||
case "cline":
|
||||
case "minimax":
|
||||
case "minimax-cn":
|
||||
headers["x-api-key"] = credentials.apiKey;
|
||||
headers["x-api-key"] = credentials.apiKey || credentials.accessToken;
|
||||
break;
|
||||
default:
|
||||
if (this.provider?.startsWith?.("anthropic-compatible-")) {
|
||||
@@ -76,7 +80,10 @@ export class DefaultExecutor extends BaseExecutor {
|
||||
qwen: () => this.refreshWithForm(OAUTH_ENDPOINTS.qwen.token, { grant_type: "refresh_token", refresh_token: credentials.refreshToken, client_id: PROVIDERS.qwen.clientId }),
|
||||
iflow: () => this.refreshIflow(credentials.refreshToken),
|
||||
gemini: () => this.refreshGoogle(credentials.refreshToken),
|
||||
kiro: () => this.refreshKiro(credentials.refreshToken)
|
||||
kiro: () => this.refreshKiro(credentials.refreshToken),
|
||||
cline: () => this.refreshCline(credentials.refreshToken),
|
||||
"kimi-coding": () => this.refreshKimiCoding(credentials.refreshToken),
|
||||
kilocode: () => this.refreshKilocode(credentials.refreshToken)
|
||||
};
|
||||
|
||||
const refresher = refreshers[this.provider];
|
||||
@@ -147,6 +154,44 @@ export class DefaultExecutor extends BaseExecutor {
|
||||
const tokens = await response.json();
|
||||
return { accessToken: tokens.accessToken, refreshToken: tokens.refreshToken || refreshToken, expiresIn: tokens.expiresIn };
|
||||
}
|
||||
|
||||
async refreshCline(refreshToken) {
|
||||
console.log('[DEBUG] Refreshing Cline token, refreshToken length:', refreshToken?.length);
|
||||
const response = await fetch("https://api.cline.bot/api/v1/auth/refresh", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "Accept": "application/json" },
|
||||
body: JSON.stringify({ refreshToken, grantType: "refresh_token", clientType: "extension" })
|
||||
});
|
||||
console.log('[DEBUG] Cline refresh response status:', response.status);
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.log('[DEBUG] Cline refresh error:', errorText);
|
||||
return null;
|
||||
}
|
||||
const payload = await response.json();
|
||||
console.log('[DEBUG] Cline refresh payload:', JSON.stringify(payload).substring(0, 200));
|
||||
const data = payload?.data || payload;
|
||||
const expiresAtIso = data?.expiresAt;
|
||||
const expiresIn = expiresAtIso ? Math.max(1, Math.floor((new Date(expiresAtIso).getTime() - Date.now()) / 1000)) : undefined;
|
||||
console.log('[DEBUG] Cline refresh success, expiresIn:', expiresIn);
|
||||
return { accessToken: data?.accessToken, refreshToken: data?.refreshToken || refreshToken, expiresIn };
|
||||
}
|
||||
|
||||
async refreshKimiCoding(refreshToken) {
|
||||
const response = await fetch("https://auth.kimi.com/api/oauth/token", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json" },
|
||||
body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: refreshToken, client_id: "17e5f671-d194-4dfb-9706-5516cb48c098" })
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
const tokens = await response.json();
|
||||
return { accessToken: tokens.access_token, refreshToken: tokens.refresh_token || refreshToken, expiresIn: tokens.expires_in };
|
||||
}
|
||||
|
||||
async refreshKilocode(refreshToken) {
|
||||
// Kilocode uses device code flow, no refresh token support
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default DefaultExecutor;
|
||||
|
||||
BIN
public/providers/cerebras.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
public/providers/cohere.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
public/providers/deepseek.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
public/providers/fireworks.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
public/providers/groq.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
public/providers/kilocode.png
Normal file
|
After Width: | Height: | Size: 314 B |
BIN
public/providers/kimi-coding.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
public/providers/mistral.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
public/providers/nebius.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
public/providers/nvidia.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
public/providers/perplexity.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
public/providers/siliconflow.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
public/providers/together.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
public/providers/xai.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
@@ -0,0 +1,185 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* ModelAvailabilityBadge — compact inline status indicator
|
||||
*
|
||||
* Shows green when all models are operational, or amber/red when there are
|
||||
* issues, with a hover popover for details and cooldown clearing.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { Button } from "@/shared/components";
|
||||
import { useNotificationStore } from "@/store/notificationStore";
|
||||
|
||||
const STATUS_CONFIG = {
|
||||
available: { icon: "check_circle", color: "#22c55e", label: "Available" },
|
||||
cooldown: { icon: "schedule", color: "#f59e0b", label: "Cooldown" },
|
||||
unavailable: { icon: "error", color: "#ef4444", label: "Unavailable" },
|
||||
unknown: { icon: "help", color: "#6b7280", label: "Unknown" },
|
||||
};
|
||||
|
||||
export default function ModelAvailabilityBadge() {
|
||||
const [data, setData] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [clearing, setClearing] = useState(null);
|
||||
const ref = useRef(null);
|
||||
const notify = useNotificationStore();
|
||||
|
||||
const fetchStatus = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/models/availability");
|
||||
if (res.ok) {
|
||||
const json = await res.json();
|
||||
setData(json);
|
||||
}
|
||||
} catch {
|
||||
// silent fail — will retry
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchStatus();
|
||||
const interval = setInterval(fetchStatus, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchStatus]);
|
||||
|
||||
// Close popover on outside click
|
||||
useEffect(() => {
|
||||
const handleClick = (e) => {
|
||||
if (ref.current && !ref.current.contains(e.target)) setExpanded(false);
|
||||
};
|
||||
if (expanded) document.addEventListener("mousedown", handleClick);
|
||||
return () => document.removeEventListener("mousedown", handleClick);
|
||||
}, [expanded]);
|
||||
|
||||
const handleClearCooldown = async (provider, model) => {
|
||||
setClearing(`${provider}:${model}`);
|
||||
try {
|
||||
const res = await fetch("/api/models/availability", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "clearCooldown", provider, model }),
|
||||
});
|
||||
if (res.ok) {
|
||||
notify.success(`Cooldown cleared for ${model}`);
|
||||
await fetchStatus();
|
||||
} else {
|
||||
notify.error("Failed to clear cooldown");
|
||||
}
|
||||
} catch {
|
||||
notify.error("Failed to clear cooldown");
|
||||
} finally {
|
||||
setClearing(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return null;
|
||||
|
||||
const models = data?.models || [];
|
||||
const unavailableCount = data?.unavailableCount || models.filter((m) => m.status !== "available").length;
|
||||
const isHealthy = unavailableCount === 0;
|
||||
|
||||
// Group unhealthy models by provider
|
||||
const byProvider = {};
|
||||
models.forEach((m) => {
|
||||
if (m.status === "available") return;
|
||||
const key = m.provider || "unknown";
|
||||
if (!byProvider[key]) byProvider[key] = [];
|
||||
byProvider[key].push(m);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="relative" ref={ref}>
|
||||
{/* <button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium border transition-all ${
|
||||
isHealthy
|
||||
? "bg-emerald-500/10 border-emerald-500/20 text-emerald-500 hover:bg-emerald-500/15"
|
||||
: "bg-amber-500/10 border-amber-500/20 text-amber-500 hover:bg-amber-500/15"
|
||||
}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">
|
||||
{isHealthy ? "verified" : "warning"}
|
||||
</span>
|
||||
{isHealthy
|
||||
? "All models operational"
|
||||
: `${unavailableCount} model${unavailableCount !== 1 ? "s" : ""} with issues`}
|
||||
</button> */}
|
||||
|
||||
{expanded && (
|
||||
<div className="absolute top-full right-0 mt-2 w-80 bg-surface border border-border rounded-xl shadow-2xl z-50 overflow-hidden">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border bg-bg">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="material-symbols-outlined text-[16px]"
|
||||
style={{ color: isHealthy ? "#22c55e" : "#f59e0b" }}
|
||||
>
|
||||
{isHealthy ? "verified" : "warning"}
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-text-main">Model Status</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={fetchStatus}
|
||||
className="p-1 rounded-lg hover:bg-surface text-text-muted hover:text-text-main transition-colors"
|
||||
title="Refresh"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">refresh</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-3 max-h-60 overflow-y-auto">
|
||||
{isHealthy ? (
|
||||
<p className="text-sm text-text-muted text-center py-2">
|
||||
All models are responding normally.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{Object.entries(byProvider).map(([provider, provModels]) => (
|
||||
<div key={provider}>
|
||||
<p className="text-xs font-semibold text-text-main mb-1.5 capitalize">{provider}</p>
|
||||
<div className="flex flex-col gap-1">
|
||||
{provModels.map((m) => {
|
||||
const status = STATUS_CONFIG[m.status] || STATUS_CONFIG.unknown;
|
||||
const isClearing = clearing === `${m.provider}:${m.model}`;
|
||||
return (
|
||||
<div
|
||||
key={`${m.provider}-${m.model}`}
|
||||
className="flex items-center justify-between px-2.5 py-1.5 rounded-lg bg-surface/30"
|
||||
>
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<span
|
||||
className="material-symbols-outlined text-[14px] shrink-0"
|
||||
style={{ color: status.color }}
|
||||
>
|
||||
{status.icon}
|
||||
</span>
|
||||
<span className="font-mono text-xs text-text-main truncate">{m.model}</span>
|
||||
</div>
|
||||
{m.status === "cooldown" && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleClearCooldown(m.provider, m.model)}
|
||||
disabled={isClearing}
|
||||
className="text-[10px] px-1.5! py-0.5! ml-2"
|
||||
>
|
||||
{isClearing ? "..." : "Clear"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,13 +3,14 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import Image from "next/image";
|
||||
import PropTypes from "prop-types";
|
||||
import { Card, CardSkeleton, Badge, Button, Input, Modal, Select } from "@/shared/components";
|
||||
import { Card, CardSkeleton, Badge, Button, Input, Modal, Select, Toggle } from "@/shared/components";
|
||||
import { OAUTH_PROVIDERS, APIKEY_PROVIDERS } from "@/shared/constants/config";
|
||||
import { FREE_PROVIDERS, OPENAI_COMPATIBLE_PREFIX, ANTHROPIC_COMPATIBLE_PREFIX } from "@/shared/constants/providers";
|
||||
import Link from "next/link";
|
||||
import { getErrorCode, getRelativeTime } from "@/shared/utils";
|
||||
import { useNotificationStore } from "@/store/notificationStore";
|
||||
import ModelAvailabilityBadge from "./components/ModelAvailabilityBadge";
|
||||
|
||||
// Shared helper function to avoid code duplication between ProviderCard and ApiKeyProviderCard
|
||||
function getStatusDisplay(connected, error, errorCode) {
|
||||
const parts = [];
|
||||
if (connected > 0) {
|
||||
@@ -33,12 +34,44 @@ function getStatusDisplay(connected, error, errorCode) {
|
||||
return parts;
|
||||
}
|
||||
|
||||
function getConnectionErrorTag(connection) {
|
||||
if (!connection) return null;
|
||||
|
||||
const explicitType = connection.lastErrorType;
|
||||
if (explicitType === "runtime_error") return "RUNTIME";
|
||||
if (
|
||||
explicitType === "upstream_auth_error" ||
|
||||
explicitType === "auth_missing" ||
|
||||
explicitType === "token_refresh_failed" ||
|
||||
explicitType === "token_expired"
|
||||
) return "AUTH";
|
||||
if (explicitType === "upstream_rate_limited") return "429";
|
||||
if (explicitType === "upstream_unavailable") return "5XX";
|
||||
if (explicitType === "network_error") return "NET";
|
||||
|
||||
const numericCode = Number(connection.errorCode);
|
||||
if (Number.isFinite(numericCode) && numericCode >= 400) return String(numericCode);
|
||||
|
||||
const fromMessage = getErrorCode(connection.lastError);
|
||||
if (fromMessage === "401" || fromMessage === "403") return "AUTH";
|
||||
if (fromMessage && fromMessage !== "ERR") return fromMessage;
|
||||
|
||||
const msg = (connection.lastError || "").toLowerCase();
|
||||
if (msg.includes("runtime") || msg.includes("not runnable") || msg.includes("not installed")) return "RUNTIME";
|
||||
if (msg.includes("invalid api key") || msg.includes("token invalid") || msg.includes("revoked") || msg.includes("unauthorized")) return "AUTH";
|
||||
|
||||
return "ERR";
|
||||
}
|
||||
|
||||
export default function ProvidersPage() {
|
||||
const [connections, setConnections] = useState([]);
|
||||
const [providerNodes, setProviderNodes] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showAddCompatibleModal, setShowAddCompatibleModal] = useState(false);
|
||||
const [showAddAnthropicCompatibleModal, setShowAddAnthropicCompatibleModal] = useState(false);
|
||||
const [testingMode, setTestingMode] = useState(null);
|
||||
const [testResults, setTestResults] = useState(null);
|
||||
const notify = useNotificationStore();
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
@@ -62,36 +95,81 @@ export default function ProvidersPage() {
|
||||
|
||||
const getProviderStats = (providerId, authType) => {
|
||||
const providerConnections = connections.filter(
|
||||
c => c.provider === providerId && c.authType === authType
|
||||
(c) => c.provider === providerId && c.authType === authType
|
||||
);
|
||||
|
||||
// Helper: check if connection is effectively active (cooldown expired)
|
||||
const getEffectiveStatus = (conn) => {
|
||||
const isCooldown = conn.rateLimitedUntil && new Date(conn.rateLimitedUntil).getTime() > Date.now();
|
||||
return (conn.testStatus === "unavailable" && !isCooldown) ? "active" : conn.testStatus;
|
||||
return conn.testStatus === "unavailable" && !isCooldown ? "active" : conn.testStatus;
|
||||
};
|
||||
|
||||
const connected = providerConnections.filter(c => {
|
||||
const connected = providerConnections.filter((c) => {
|
||||
const status = getEffectiveStatus(c);
|
||||
return status === "active" || status === "success";
|
||||
}).length;
|
||||
|
||||
const errorConns = providerConnections.filter(c => {
|
||||
const errorConns = providerConnections.filter((c) => {
|
||||
const status = getEffectiveStatus(c);
|
||||
return status === "error" || status === "expired" || status === "unavailable";
|
||||
});
|
||||
|
||||
const error = errorConns.length;
|
||||
const total = providerConnections.length;
|
||||
const allDisabled = total > 0 && providerConnections.every((c) => c.isActive === false);
|
||||
|
||||
// Get latest error info
|
||||
const latestError = errorConns.sort((a, b) =>
|
||||
new Date(b.lastErrorAt || 0) - new Date(a.lastErrorAt || 0)
|
||||
const latestError = errorConns.sort(
|
||||
(a, b) => new Date(b.lastErrorAt || 0) - new Date(a.lastErrorAt || 0)
|
||||
)[0];
|
||||
const errorCode = latestError ? getErrorCode(latestError.lastError) : null;
|
||||
const errorCode = latestError ? getConnectionErrorTag(latestError) : null;
|
||||
const errorTime = latestError?.lastErrorAt ? getRelativeTime(latestError.lastErrorAt) : null;
|
||||
|
||||
return { connected, error, total, errorCode, errorTime };
|
||||
return { connected, error, total, errorCode, errorTime, allDisabled };
|
||||
};
|
||||
|
||||
// Toggle all connections for a provider on/off
|
||||
const handleToggleProvider = async (providerId, authType, newActive) => {
|
||||
const providerConns = connections.filter(
|
||||
(c) => c.provider === providerId && c.authType === authType
|
||||
);
|
||||
setConnections((prev) =>
|
||||
prev.map((c) =>
|
||||
c.provider === providerId && c.authType === authType ? { ...c, isActive: newActive } : c
|
||||
)
|
||||
);
|
||||
await Promise.allSettled(
|
||||
providerConns.map((c) =>
|
||||
fetch(`/api/providers/${c.id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ isActive: newActive }),
|
||||
})
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const handleBatchTest = async (mode, providerId = null) => {
|
||||
if (testingMode) return;
|
||||
setTestingMode(mode === "provider" ? providerId : mode);
|
||||
setTestResults(null);
|
||||
try {
|
||||
const res = await fetch("/api/providers/test-batch", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ mode, providerId }),
|
||||
});
|
||||
const data = await res.json();
|
||||
setTestResults(data);
|
||||
if (data.summary) {
|
||||
const { passed, failed, total } = data.summary;
|
||||
if (failed === 0) notify.success(`All ${total} tests passed`);
|
||||
else notify.warning(`${passed}/${total} passed, ${failed} failed`);
|
||||
}
|
||||
} catch (error) {
|
||||
setTestResults({ error: "Test request failed" });
|
||||
notify.error("Provider test failed");
|
||||
} finally {
|
||||
setTestingMode(null);
|
||||
}
|
||||
};
|
||||
|
||||
const compatibleProviders = providerNodes
|
||||
@@ -113,18 +191,6 @@ export default function ProvidersPage() {
|
||||
textIcon: "AC",
|
||||
}));
|
||||
|
||||
const apiKeyProviders = {
|
||||
...APIKEY_PROVIDERS,
|
||||
...compatibleProviders.reduce((acc, provider) => {
|
||||
acc[provider.id] = provider;
|
||||
return acc;
|
||||
}, {}),
|
||||
...anthropicCompatibleProviders.reduce((acc, provider) => {
|
||||
acc[provider.id] = provider;
|
||||
return acc;
|
||||
}, {}),
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
@@ -138,7 +204,30 @@ export default function ProvidersPage() {
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* OAuth Providers */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<h2 className="text-xl font-semibold">OAuth Providers</h2>
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold flex items-center gap-2">
|
||||
OAuth Providers
|
||||
</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<ModelAvailabilityBadge />
|
||||
<button
|
||||
onClick={() => handleBatchTest("oauth")}
|
||||
disabled={!!testingMode}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors ${
|
||||
testingMode === "oauth"
|
||||
? "bg-primary/20 border-primary/40 text-primary animate-pulse"
|
||||
: "bg-bg border-border text-text-muted hover:text-text-main hover:border-primary/40"
|
||||
}`}
|
||||
title="Test all OAuth connections"
|
||||
aria-label="Test all OAuth connections"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">
|
||||
{testingMode === "oauth" ? "sync" : "play_arrow"}
|
||||
</span>
|
||||
{testingMode === "oauth" ? "Testing..." : "Test All"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{Object.entries(OAUTH_PROVIDERS).map(([key, info]) => (
|
||||
<ProviderCard
|
||||
@@ -146,6 +235,8 @@ export default function ProvidersPage() {
|
||||
providerId={key}
|
||||
provider={info}
|
||||
stats={getProviderStats(key, "oauth")}
|
||||
authType="oauth"
|
||||
onToggle={(active) => handleToggleProvider(key, "oauth", active)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -153,7 +244,27 @@ export default function ProvidersPage() {
|
||||
|
||||
{/* Free Providers */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<h2 className="text-xl font-semibold">Free Providers</h2>
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold flex items-center gap-2">
|
||||
Free Providers
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => handleBatchTest("free")}
|
||||
disabled={!!testingMode}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors ${
|
||||
testingMode === "free"
|
||||
? "bg-primary/20 border-primary/40 text-primary animate-pulse"
|
||||
: "bg-bg border-border text-text-muted hover:text-text-main hover:border-primary/40"
|
||||
}`}
|
||||
title="Test all Free connections"
|
||||
aria-label="Test all Free provider connections"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">
|
||||
{testingMode === "free" ? "sync" : "play_arrow"}
|
||||
</span>
|
||||
{testingMode === "free" ? "Testing..." : "Test All"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{Object.entries(FREE_PROVIDERS).map(([key, info]) => (
|
||||
<ProviderCard
|
||||
@@ -161,16 +272,74 @@ export default function ProvidersPage() {
|
||||
providerId={key}
|
||||
provider={info}
|
||||
stats={getProviderStats(key, "oauth")}
|
||||
authType="free"
|
||||
onToggle={(active) => handleToggleProvider(key, "oauth", active)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* API Key Providers */}
|
||||
{/* API Key Providers — fixed list */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold">API Key Providers</h2>
|
||||
<h2 className="text-xl font-semibold flex items-center gap-2">
|
||||
API Key Providers{" "}
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => handleBatchTest("apikey")}
|
||||
disabled={!!testingMode}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors ${
|
||||
testingMode === "apikey"
|
||||
? "bg-primary/20 border-primary/40 text-primary animate-pulse"
|
||||
: "bg-bg border-border text-text-muted hover:text-text-main hover:border-primary/40"
|
||||
}`}
|
||||
title="Test all API Key connections"
|
||||
aria-label="Test all API Key connections"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">
|
||||
{testingMode === "apikey" ? "sync" : "play_arrow"}
|
||||
</span>
|
||||
{testingMode === "apikey" ? "Testing..." : "Test All"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{Object.entries(APIKEY_PROVIDERS).map(([key, info]) => (
|
||||
<ApiKeyProviderCard
|
||||
key={key}
|
||||
providerId={key}
|
||||
provider={info}
|
||||
stats={getProviderStats(key, "apikey")}
|
||||
authType="apikey"
|
||||
onToggle={(active) => handleToggleProvider(key, "apikey", active)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* API Key Compatible Providers — dynamic (OpenAI/Anthropic compatible) */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold flex items-center gap-2">
|
||||
API Key Compatible Providers{" "}
|
||||
</h2>
|
||||
<div className="flex gap-2">
|
||||
{(compatibleProviders.length > 0 || anthropicCompatibleProviders.length > 0) && (
|
||||
<button
|
||||
onClick={() => handleBatchTest("compatible")}
|
||||
disabled={!!testingMode}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors ${
|
||||
testingMode === "compatible"
|
||||
? "bg-primary/20 border-primary/40 text-primary animate-pulse"
|
||||
: "bg-bg border-border text-text-muted hover:text-text-main hover:border-primary/40"
|
||||
}`}
|
||||
title="Test all Compatible connections"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">
|
||||
{testingMode === "compatible" ? "sync" : "play_arrow"}
|
||||
</span>
|
||||
{testingMode === "compatible" ? "Testing..." : "Test All"}
|
||||
</button>
|
||||
)}
|
||||
<Button size="sm" icon="add" onClick={() => setShowAddAnthropicCompatibleModal(true)}>
|
||||
Add Anthropic Compatible
|
||||
</Button>
|
||||
@@ -185,17 +354,30 @@ export default function ProvidersPage() {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{compatibleProviders.length === 0 && anthropicCompatibleProviders.length === 0 ? (
|
||||
<div className="text-center py-8 border border-dashed border-border rounded-xl">
|
||||
<span className="material-symbols-outlined text-[32px] text-text-muted mb-2">extension</span>
|
||||
<p className="text-text-muted text-sm">No compatible providers added yet</p>
|
||||
<p className="text-text-muted text-xs mt-1">
|
||||
Use the buttons above to add OpenAI or Anthropic compatible endpoints
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{Object.entries(apiKeyProviders).map(([key, info]) => (
|
||||
{[...compatibleProviders, ...anthropicCompatibleProviders].map((info) => (
|
||||
<ApiKeyProviderCard
|
||||
key={key}
|
||||
providerId={key}
|
||||
key={info.id}
|
||||
providerId={info.id}
|
||||
provider={info}
|
||||
stats={getProviderStats(key, "apikey")}
|
||||
stats={getProviderStats(info.id, "apikey")}
|
||||
authType="compatible"
|
||||
onToggle={(active) => handleToggleProvider(info.id, "apikey", active)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AddOpenAICompatibleModal
|
||||
isOpen={showAddCompatibleModal}
|
||||
onClose={() => setShowAddCompatibleModal(false)}
|
||||
@@ -212,17 +394,56 @@ export default function ProvidersPage() {
|
||||
setShowAddAnthropicCompatibleModal(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Test Results Modal */}
|
||||
{testResults && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-start justify-center pt-[10vh]"
|
||||
onClick={() => setTestResults(null)}
|
||||
>
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" />
|
||||
<div
|
||||
className="relative bg-surface border border-border rounded-xl w-full max-w-[600px] max-h-[80vh] overflow-y-auto shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="sticky top-0 z-10 flex items-center justify-between px-5 py-3 border-b border-border bg-surface/95 backdrop-blur-sm rounded-t-xl">
|
||||
<h3 className="font-semibold">Test Results</h3>
|
||||
<button
|
||||
onClick={() => setTestResults(null)}
|
||||
className="p-1 rounded-lg hover:bg-bg text-text-muted hover:text-text-main transition-colors"
|
||||
aria-label="Close test results"
|
||||
>
|
||||
<span className="material-symbols-outlined text-lg">close</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-5">
|
||||
<ProviderTestResultsView results={testResults} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderCard({ providerId, provider, stats }) {
|
||||
const { connected, error, errorCode, errorTime } = stats;
|
||||
function ProviderCard({ providerId, provider, stats, authType, onToggle }) {
|
||||
const { connected, error, errorCode, errorTime, allDisabled } = stats;
|
||||
const [imgError, setImgError] = useState(false);
|
||||
|
||||
const dotColors = {
|
||||
free: "bg-green-500",
|
||||
oauth: "bg-blue-500",
|
||||
apikey: "bg-amber-500",
|
||||
compatible: "bg-orange-500",
|
||||
};
|
||||
const dotLabels = { free: "Free", oauth: "OAuth", apikey: "API Key", compatible: "Compatible" };
|
||||
|
||||
return (
|
||||
<Link href={`/dashboard/providers/${providerId}`} className="group">
|
||||
<Card padding="xs" className="h-full hover:bg-black/[0.01] dark:hover:bg-white/[0.01] transition-colors cursor-pointer">
|
||||
<Card
|
||||
padding="xs"
|
||||
className={`h-full hover:bg-black/[0.01] dark:hover:bg-white/[0.01] transition-colors cursor-pointer ${allDisabled ? "opacity-50" : ""}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
@@ -230,10 +451,7 @@ function ProviderCard({ providerId, provider, stats }) {
|
||||
style={{ backgroundColor: `${provider.color}15` }}
|
||||
>
|
||||
{imgError ? (
|
||||
<span
|
||||
className="text-xs font-bold"
|
||||
style={{ color: provider.color }}
|
||||
>
|
||||
<span className="text-xs font-bold" style={{ color: provider.color }}>
|
||||
{provider.textIcon || provider.id.slice(0, 2).toUpperCase()}
|
||||
</span>
|
||||
) : (
|
||||
@@ -249,16 +467,45 @@ function ProviderCard({ providerId, provider, stats }) {
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold">{provider.name}</h3>
|
||||
<h3 className="font-semibold">
|
||||
{provider.name}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2 text-xs flex-wrap">
|
||||
{getStatusDisplay(connected, error, errorCode)}
|
||||
{errorTime && <span className="text-text-muted">• {errorTime}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span className="material-symbols-outlined text-text-muted opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
chevron_right
|
||||
{allDisabled ? (
|
||||
<Badge variant="default" size="sm">
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="material-symbols-outlined text-[12px]">pause_circle</span>
|
||||
Disabled
|
||||
</span>
|
||||
</Badge>
|
||||
) : (
|
||||
<>
|
||||
{getStatusDisplay(connected, error, errorCode)}
|
||||
{errorTime && <span className="text-text-muted">{errorTime}</span>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{stats.total > 0 && (
|
||||
<div
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onToggle(!allDisabled ? false : true);
|
||||
}}
|
||||
>
|
||||
<Toggle
|
||||
size="sm"
|
||||
checked={!allDisabled}
|
||||
onChange={() => {}}
|
||||
title={allDisabled ? "Enable provider" : "Disable provider"}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Link>
|
||||
@@ -279,29 +526,36 @@ ProviderCard.propTypes = {
|
||||
errorCode: PropTypes.string,
|
||||
errorTime: PropTypes.string,
|
||||
}).isRequired,
|
||||
authType: PropTypes.string,
|
||||
onToggle: PropTypes.func,
|
||||
};
|
||||
|
||||
// API Key providers - use image with textIcon fallback (same as OAuth providers)
|
||||
function ApiKeyProviderCard({ providerId, provider, stats }) {
|
||||
const { connected, error, errorCode, errorTime } = stats;
|
||||
function ApiKeyProviderCard({ providerId, provider, stats, authType, onToggle }) {
|
||||
const { connected, error, errorCode, errorTime, allDisabled } = stats;
|
||||
const isCompatible = providerId.startsWith(OPENAI_COMPATIBLE_PREFIX);
|
||||
const isAnthropicCompatible = providerId.startsWith(ANTHROPIC_COMPATIBLE_PREFIX);
|
||||
const [imgError, setImgError] = useState(false);
|
||||
|
||||
// Determine icon path: OpenAI Compatible providers use specialized icons
|
||||
const dotColors = {
|
||||
free: "bg-green-500",
|
||||
oauth: "bg-blue-500",
|
||||
apikey: "bg-amber-500",
|
||||
compatible: "bg-orange-500",
|
||||
};
|
||||
const dotLabels = { free: "Free", oauth: "OAuth", apikey: "API Key", compatible: "Compatible" };
|
||||
|
||||
const getIconPath = () => {
|
||||
if (isCompatible) {
|
||||
return provider.apiType === "responses" ? "/providers/oai-r.png" : "/providers/oai-cc.png";
|
||||
}
|
||||
if (isAnthropicCompatible) {
|
||||
return "/providers/anthropic-m.png"; // Use Anthropic icon as base
|
||||
}
|
||||
if (isCompatible) return provider.apiType === "responses" ? "/providers/oai-r.png" : "/providers/oai-cc.png";
|
||||
if (isAnthropicCompatible) return "/providers/anthropic-m.png";
|
||||
return `/providers/${provider.id}.png`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Link href={`/dashboard/providers/${providerId}`} className="group">
|
||||
<Card padding="xs" className="h-full hover:bg-black/[0.01] dark:hover:bg-white/[0.01] transition-colors cursor-pointer">
|
||||
<Card
|
||||
padding="xs"
|
||||
className={`h-full hover:bg-black/[0.01] dark:hover:bg-white/[0.01] transition-colors cursor-pointer ${allDisabled ? "opacity-50" : ""}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
@@ -309,10 +563,7 @@ function ApiKeyProviderCard({ providerId, provider, stats }) {
|
||||
style={{ backgroundColor: `${provider.color}15` }}
|
||||
>
|
||||
{imgError ? (
|
||||
<span
|
||||
className="text-xs font-bold"
|
||||
style={{ color: provider.color }}
|
||||
>
|
||||
<span className="text-xs font-bold" style={{ color: provider.color }}>
|
||||
{provider.textIcon || provider.id.slice(0, 2).toUpperCase()}
|
||||
</span>
|
||||
) : (
|
||||
@@ -328,8 +579,19 @@ function ApiKeyProviderCard({ providerId, provider, stats }) {
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold">{provider.name}</h3>
|
||||
<h3 className="font-semibold">
|
||||
{provider.name}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2 text-xs flex-wrap">
|
||||
{allDisabled ? (
|
||||
<Badge variant="default" size="sm">
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="material-symbols-outlined text-[12px]">pause_circle</span>
|
||||
Disabled
|
||||
</span>
|
||||
</Badge>
|
||||
) : (
|
||||
<>
|
||||
{getStatusDisplay(connected, error, errorCode)}
|
||||
{isCompatible && (
|
||||
<Badge variant="default" size="sm">
|
||||
@@ -337,17 +599,33 @@ function ApiKeyProviderCard({ providerId, provider, stats }) {
|
||||
</Badge>
|
||||
)}
|
||||
{isAnthropicCompatible && (
|
||||
<Badge variant="default" size="sm">
|
||||
Messages
|
||||
</Badge>
|
||||
<Badge variant="default" size="sm">Messages</Badge>
|
||||
)}
|
||||
{errorTime && <span className="text-text-muted">{errorTime}</span>}
|
||||
</>
|
||||
)}
|
||||
{errorTime && <span className="text-text-muted">• {errorTime}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span className="material-symbols-outlined text-text-muted opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
chevron_right
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{stats.total > 0 && (
|
||||
<div
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onToggle(!allDisabled ? false : true);
|
||||
}}
|
||||
>
|
||||
<Toggle
|
||||
size="sm"
|
||||
checked={!allDisabled}
|
||||
onChange={() => {}}
|
||||
title={allDisabled ? "Enable provider" : "Disable provider"}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Link>
|
||||
@@ -369,6 +647,8 @@ ApiKeyProviderCard.propTypes = {
|
||||
errorCode: PropTypes.string,
|
||||
errorTime: PropTypes.string,
|
||||
}).isRequired,
|
||||
authType: PropTypes.string,
|
||||
onToggle: PropTypes.func,
|
||||
};
|
||||
|
||||
function AddOpenAICompatibleModal({ isOpen, onClose, onCreated }) {
|
||||
@@ -390,10 +670,7 @@ function AddOpenAICompatibleModal({ isOpen, onClose, onCreated }) {
|
||||
|
||||
useEffect(() => {
|
||||
const defaultBaseUrl = "https://api.openai.com/v1";
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
baseUrl: defaultBaseUrl,
|
||||
}));
|
||||
setFormData((prev) => ({ ...prev, baseUrl: defaultBaseUrl }));
|
||||
}, [formData.apiType]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
@@ -414,12 +691,7 @@ function AddOpenAICompatibleModal({ isOpen, onClose, onCreated }) {
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
onCreated(data.node);
|
||||
setFormData({
|
||||
name: "",
|
||||
prefix: "",
|
||||
apiType: "chat",
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
});
|
||||
setFormData({ name: "", prefix: "", apiType: "chat", baseUrl: "https://api.openai.com/v1" });
|
||||
setCheckKey("");
|
||||
setValidationResult(null);
|
||||
}
|
||||
@@ -500,9 +772,7 @@ function AddOpenAICompatibleModal({ isOpen, onClose, onCreated }) {
|
||||
<Button onClick={handleSubmit} fullWidth disabled={!formData.name.trim() || !formData.prefix.trim() || !formData.baseUrl.trim() || submitting}>
|
||||
{submitting ? "Creating..." : "Create"}
|
||||
</Button>
|
||||
<Button onClick={onClose} variant="ghost" fullWidth>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={onClose} variant="ghost" fullWidth>Cancel</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
@@ -527,7 +797,6 @@ function AddAnthropicCompatibleModal({ isOpen, onClose, onCreated }) {
|
||||
const [validationResult, setValidationResult] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Reset validation when modal opens
|
||||
if (isOpen) {
|
||||
setValidationResult(null);
|
||||
setCheckKey("");
|
||||
@@ -551,11 +820,7 @@ function AddAnthropicCompatibleModal({ isOpen, onClose, onCreated }) {
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
onCreated(data.node);
|
||||
setFormData({
|
||||
name: "",
|
||||
prefix: "",
|
||||
baseUrl: "https://api.anthropic.com/v1",
|
||||
});
|
||||
setFormData({ name: "", prefix: "", baseUrl: "https://api.anthropic.com/v1" });
|
||||
setCheckKey("");
|
||||
setValidationResult(null);
|
||||
}
|
||||
@@ -572,11 +837,7 @@ function AddAnthropicCompatibleModal({ isOpen, onClose, onCreated }) {
|
||||
const res = await fetch("/api/provider-nodes/validate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
baseUrl: formData.baseUrl,
|
||||
apiKey: checkKey,
|
||||
type: "anthropic-compatible"
|
||||
}),
|
||||
body: JSON.stringify({ baseUrl: formData.baseUrl, apiKey: checkKey, type: "anthropic-compatible" }),
|
||||
});
|
||||
const data = await res.json();
|
||||
setValidationResult(data.valid ? "success" : "failed");
|
||||
@@ -634,9 +895,7 @@ function AddAnthropicCompatibleModal({ isOpen, onClose, onCreated }) {
|
||||
<Button onClick={handleSubmit} fullWidth disabled={!formData.name.trim() || !formData.prefix.trim() || !formData.baseUrl.trim() || submitting}>
|
||||
{submitting ? "Creating..." : "Create"}
|
||||
</Button>
|
||||
<Button onClick={onClose} variant="ghost" fullWidth>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={onClose} variant="ghost" fullWidth>Cancel</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
@@ -648,3 +907,79 @@ AddAnthropicCompatibleModal.propTypes = {
|
||||
onClose: PropTypes.func.isRequired,
|
||||
onCreated: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
function ProviderTestResultsView({ results }) {
|
||||
if (results.error && !results.results) {
|
||||
return (
|
||||
<div className="text-center py-6">
|
||||
<span className="material-symbols-outlined text-red-500 text-[32px] mb-2 block">error</span>
|
||||
<p className="text-sm text-red-400">{results.error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { summary, mode } = results;
|
||||
const items = results.results || [];
|
||||
const modeLabel = { oauth: "OAuth", free: "Free", apikey: "API Key", provider: "Provider", all: "All" }[mode] || mode;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{summary && (
|
||||
<div className="flex items-center gap-3 text-xs mb-1">
|
||||
<span className="text-text-muted">{modeLabel} Test</span>
|
||||
<span className="px-2 py-0.5 rounded bg-emerald-500/15 text-emerald-400 font-medium">
|
||||
{summary.passed} passed
|
||||
</span>
|
||||
{summary.failed > 0 && (
|
||||
<span className="px-2 py-0.5 rounded bg-red-500/15 text-red-400 font-medium">
|
||||
{summary.failed} failed
|
||||
</span>
|
||||
)}
|
||||
<span className="text-text-muted ml-auto">{summary.total} tested</span>
|
||||
</div>
|
||||
)}
|
||||
{items.map((r, i) => (
|
||||
<div
|
||||
key={r.connectionId || i}
|
||||
className="flex items-center gap-2 text-xs px-3 py-2 rounded-lg bg-black/[0.03] dark:bg-white/[0.03]"
|
||||
>
|
||||
<span className={`material-symbols-outlined text-[16px] ${r.valid ? "text-emerald-500" : "text-red-500"}`}>
|
||||
{r.valid ? "check_circle" : "error"}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="font-medium">{r.connectionName}</span>
|
||||
<span className="text-text-muted ml-1.5">({r.provider})</span>
|
||||
</div>
|
||||
{r.latencyMs !== undefined && (
|
||||
<span className="text-text-muted font-mono tabular-nums">{r.latencyMs}ms</span>
|
||||
)}
|
||||
<span
|
||||
className={`text-[10px] uppercase font-bold px-1.5 py-0.5 rounded ${
|
||||
r.valid ? "bg-emerald-500/15 text-emerald-400" : "bg-red-500/15 text-red-400"
|
||||
}`}
|
||||
>
|
||||
{r.valid ? "OK" : r.diagnosis?.type || "ERROR"}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{items.length === 0 && (
|
||||
<div className="text-center py-4 text-text-muted text-sm">
|
||||
No active connections found for this group.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
ProviderTestResultsView.propTypes = {
|
||||
results: PropTypes.shape({
|
||||
mode: PropTypes.string,
|
||||
results: PropTypes.array,
|
||||
summary: PropTypes.shape({
|
||||
total: PropTypes.number,
|
||||
passed: PropTypes.number,
|
||||
failed: PropTypes.number,
|
||||
}),
|
||||
error: PropTypes.string,
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
@@ -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
@@ -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
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -192,6 +192,29 @@ export const CURSOR_CONFIG = {
|
||||
},
|
||||
};
|
||||
|
||||
// Kimi Coding OAuth Configuration (Device Code Flow)
|
||||
export const KIMI_CODING_CONFIG = {
|
||||
clientId: process.env.KIMI_CODING_OAUTH_CLIENT_ID || "17e5f671-d194-4dfb-9706-5516cb48c098",
|
||||
deviceCodeUrl: "https://auth.kimi.com/api/oauth/device_authorization",
|
||||
tokenUrl: "https://auth.kimi.com/api/oauth/token",
|
||||
};
|
||||
|
||||
// KiloCode OAuth Configuration (Custom Device Auth Flow)
|
||||
export const KILOCODE_CONFIG = {
|
||||
apiBaseUrl: "https://api.kilo.ai",
|
||||
initiateUrl: "https://api.kilo.ai/api/device-auth/codes",
|
||||
pollUrlBase: "https://api.kilo.ai/api/device-auth/codes",
|
||||
};
|
||||
|
||||
// Cline OAuth Configuration (Local Callback Flow via app.cline.bot)
|
||||
export const CLINE_CONFIG = {
|
||||
appBaseUrl: "https://app.cline.bot",
|
||||
apiBaseUrl: "https://api.cline.bot",
|
||||
authorizeUrl: "https://api.cline.bot/api/v1/auth/authorize",
|
||||
tokenExchangeUrl: "https://api.cline.bot/api/v1/auth/token",
|
||||
refreshUrl: "https://api.cline.bot/api/v1/auth/refresh",
|
||||
};
|
||||
|
||||
// OAuth timeout (5 minutes)
|
||||
export const OAUTH_TIMEOUT = 300000;
|
||||
|
||||
@@ -207,4 +230,7 @@ export const PROVIDERS = {
|
||||
GITHUB: "github",
|
||||
KIRO: "kiro",
|
||||
CURSOR: "cursor",
|
||||
KIMI_CODING: "kimi-coding",
|
||||
KILOCODE: "kilocode",
|
||||
CLINE: "cline",
|
||||
};
|
||||
|
||||
@@ -14,6 +14,9 @@ import {
|
||||
GITHUB_CONFIG,
|
||||
KIRO_CONFIG,
|
||||
CURSOR_CONFIG,
|
||||
KIMI_CODING_CONFIG,
|
||||
KILOCODE_CONFIG,
|
||||
CLINE_CONFIG,
|
||||
getOAuthClientMetadata,
|
||||
} from "./constants/oauth";
|
||||
|
||||
@@ -675,6 +678,161 @@ const PROVIDERS = {
|
||||
},
|
||||
}),
|
||||
},
|
||||
|
||||
"kimi-coding": {
|
||||
config: KIMI_CODING_CONFIG,
|
||||
flowType: "device_code",
|
||||
requestDeviceCode: async (config) => {
|
||||
const response = await fetch(config.deviceCodeUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
|
||||
body: new URLSearchParams({ client_id: config.clientId }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`Device code request failed: ${error}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
return {
|
||||
device_code: data.device_code,
|
||||
user_code: data.user_code,
|
||||
verification_uri: data.verification_uri || "https://www.kimi.com/code/authorize_device",
|
||||
verification_uri_complete:
|
||||
data.verification_uri_complete ||
|
||||
`https://www.kimi.com/code/authorize_device?user_code=${data.user_code}`,
|
||||
expires_in: data.expires_in,
|
||||
interval: data.interval || 5,
|
||||
};
|
||||
},
|
||||
pollToken: async (config, deviceCode) => {
|
||||
const response = await fetch(config.tokenUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
|
||||
body: new URLSearchParams({
|
||||
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
||||
client_id: config.clientId,
|
||||
device_code: deviceCode,
|
||||
}),
|
||||
});
|
||||
let data;
|
||||
try {
|
||||
data = await response.json();
|
||||
} catch (e) {
|
||||
const text = await response.text();
|
||||
data = { error: "invalid_response", error_description: text };
|
||||
}
|
||||
return { ok: response.ok, data };
|
||||
},
|
||||
mapTokens: (tokens) => ({
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token,
|
||||
expiresIn: tokens.expires_in,
|
||||
}),
|
||||
},
|
||||
|
||||
kilocode: {
|
||||
config: KILOCODE_CONFIG,
|
||||
flowType: "device_code",
|
||||
requestDeviceCode: async (config) => {
|
||||
const response = await fetch(config.initiateUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
if (!response.ok) {
|
||||
if (response.status === 429) {
|
||||
throw new Error("Too many pending authorization requests. Please try again later.");
|
||||
}
|
||||
const error = await response.text();
|
||||
throw new Error(`Device auth initiation failed: ${error}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
return {
|
||||
device_code: data.code,
|
||||
user_code: data.code,
|
||||
verification_uri: data.verificationUrl,
|
||||
verification_uri_complete: data.verificationUrl,
|
||||
expires_in: data.expiresIn || 300,
|
||||
interval: 3,
|
||||
};
|
||||
},
|
||||
pollToken: async (config, deviceCode) => {
|
||||
const response = await fetch(`${config.pollUrlBase}/${deviceCode}`);
|
||||
if (response.status === 202) return { ok: false, data: { error: "authorization_pending" } };
|
||||
if (response.status === 403) return { ok: false, data: { error: "access_denied", error_description: "Authorization denied by user" } };
|
||||
if (response.status === 410) return { ok: false, data: { error: "expired_token", error_description: "Authorization code expired" } };
|
||||
if (!response.ok) return { ok: false, data: { error: "poll_failed", error_description: `Poll failed: ${response.status}` } };
|
||||
const data = await response.json();
|
||||
if (data.status === "approved" && data.token) {
|
||||
return { ok: true, data: { access_token: data.token, _userEmail: data.userEmail } };
|
||||
}
|
||||
return { ok: false, data: { error: "authorization_pending" } };
|
||||
},
|
||||
mapTokens: (tokens) => ({
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: null,
|
||||
expiresIn: null,
|
||||
email: tokens._userEmail,
|
||||
}),
|
||||
},
|
||||
|
||||
cline: {
|
||||
config: CLINE_CONFIG,
|
||||
flowType: "authorization_code",
|
||||
buildAuthUrl: (config, redirectUri) => {
|
||||
const params = new URLSearchParams({
|
||||
client_type: "extension",
|
||||
callback_url: redirectUri,
|
||||
redirect_uri: redirectUri,
|
||||
});
|
||||
return `${config.authorizeUrl}?${params.toString()}`;
|
||||
},
|
||||
exchangeToken: async (config, code, redirectUri) => {
|
||||
try {
|
||||
// Cline encodes token data as base64 in the code param
|
||||
let base64 = code;
|
||||
const padding = 4 - (base64.length % 4);
|
||||
if (padding !== 4) base64 += "=".repeat(padding);
|
||||
const decoded = Buffer.from(base64, "base64").toString("utf-8");
|
||||
const lastBrace = decoded.lastIndexOf("}");
|
||||
if (lastBrace === -1) throw new Error("No JSON found in decoded code");
|
||||
const tokenData = JSON.parse(decoded.substring(0, lastBrace + 1));
|
||||
return {
|
||||
access_token: tokenData.accessToken,
|
||||
refresh_token: tokenData.refreshToken,
|
||||
email: tokenData.email,
|
||||
firstName: tokenData.firstName,
|
||||
lastName: tokenData.lastName,
|
||||
expires_at: tokenData.expiresAt,
|
||||
};
|
||||
} catch (e) {
|
||||
const response = await fetch(config.tokenExchangeUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||||
body: JSON.stringify({ grant_type: "authorization_code", code, client_type: "extension", redirect_uri: redirectUri }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`Cline token exchange failed: ${error}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
return {
|
||||
access_token: data.data?.accessToken || data.accessToken,
|
||||
refresh_token: data.data?.refreshToken || data.refreshToken,
|
||||
email: data.data?.userInfo?.email || "",
|
||||
expires_at: data.data?.expiresAt || data.expiresAt,
|
||||
};
|
||||
}
|
||||
},
|
||||
mapTokens: (tokens) => ({
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token,
|
||||
expiresIn: tokens.expires_at
|
||||
? Math.floor((new Date(tokens.expires_at).getTime() - Date.now()) / 1000)
|
||||
: 3600,
|
||||
email: tokens.email,
|
||||
providerSpecificData: { firstName: tokens.firstName, lastName: tokens.lastName },
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -114,8 +114,9 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
|
||||
try {
|
||||
setError(null);
|
||||
|
||||
// Device code flow (GitHub, Qwen, Kiro)
|
||||
if (provider === "github" || provider === "qwen" || provider === "kiro") {
|
||||
// Device code flow providers
|
||||
const deviceCodeProviders = ["github", "qwen", "kiro", "kimi-coding", "kilocode"];
|
||||
if (deviceCodeProviders.includes(provider)) {
|
||||
setIsDeviceCode(true);
|
||||
setStep("waiting");
|
||||
|
||||
@@ -129,7 +130,7 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
|
||||
const verifyUrl = data.verification_uri_complete || data.verification_uri;
|
||||
if (verifyUrl) window.open(verifyUrl, "_blank");
|
||||
|
||||
// Start polling - pass extraData for Kiro (contains _clientId, _clientSecret)
|
||||
// Pass extraData for Kiro (contains _clientId, _clientSecret)
|
||||
const extraData = provider === "kiro" ? { _clientId: data._clientId, _clientSecret: data._clientSecret } : null;
|
||||
startPolling(data.device_code, data.codeVerifier, data.interval || 5, extraData);
|
||||
return;
|
||||
@@ -212,7 +213,11 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
|
||||
|
||||
// Method 1: postMessage from popup
|
||||
const handleMessage = (event) => {
|
||||
if (event.origin !== window.location.origin) return;
|
||||
// Allow messages from same origin or localhost (any port)
|
||||
const isLocalhost = event.origin.includes("localhost") || event.origin.includes("127.0.0.1");
|
||||
const isSameOrigin = event.origin === window.location.origin;
|
||||
if (!isLocalhost && !isSameOrigin) return;
|
||||
|
||||
if (event.data?.type === "oauth_callback") {
|
||||
handleCallback(event.data.data);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
export const FREE_PROVIDERS = {
|
||||
iflow: { id: "iflow", alias: "if", name: "iFlow AI", icon: "water_drop", color: "#6366F1" },
|
||||
qwen: { id: "qwen", alias: "qw", name: "Qwen Code", icon: "psychology", color: "#10B981" },
|
||||
"gemini-cli": { id: "gemini-cli", alias: "gc", name: "Gemini CLI", icon: "terminal", color: "#4285F4" },
|
||||
kiro: { id: "kiro", alias: "kr", name: "Kiro AI", icon: "psychology_alt", color: "#FF6B35" },
|
||||
};
|
||||
|
||||
// OAuth Providers
|
||||
@@ -11,22 +13,38 @@ export const OAUTH_PROVIDERS = {
|
||||
claude: { id: "claude", alias: "cc", name: "Claude Code", icon: "smart_toy", color: "#D97757" },
|
||||
antigravity: { id: "antigravity", alias: "ag", name: "Antigravity", icon: "rocket_launch", color: "#F59E0B" },
|
||||
codex: { id: "codex", alias: "cx", name: "OpenAI Codex", icon: "code", color: "#3B82F6" },
|
||||
"gemini-cli": { id: "gemini-cli", alias: "gc", name: "Gemini CLI", icon: "terminal", color: "#4285F4" },
|
||||
github: { id: "github", alias: "gh", name: "GitHub Copilot", icon: "code", color: "#333333" },
|
||||
kiro: { id: "kiro", alias: "kr", name: "Kiro AI", icon: "psychology_alt", color: "#FF6B35" },
|
||||
cursor: { id: "cursor", alias: "cu", name: "Cursor IDE", icon: "edit_note", color: "#00D4AA" },
|
||||
// "kimi-coding": { id: "kimi-coding", alias: "kmc", name: "Kimi Coding", icon: "psychology", color: "#1E40AF", textIcon: "KC" },
|
||||
// kilocode: { id: "kilocode", alias: "kc", name: "Kilo Code", icon: "code", color: "#FF6B35", textIcon: "KC" },
|
||||
// cline: { id: "cline", alias: "cl", name: "Cline", icon: "smart_toy", color: "#5B9BD5", textIcon: "CL" },
|
||||
};
|
||||
|
||||
export const APIKEY_PROVIDERS = {
|
||||
openrouter: { id: "openrouter", alias: "openrouter", name: "OpenRouter", icon: "router", color: "#6366F1", textIcon: "OR" , passthroughModels: true },
|
||||
glm: { id: "glm", alias: "glm", name: "GLM Coding", icon: "code", color: "#2563EB", textIcon: "GL" },
|
||||
"glm-cn": { id: "glm-cn", alias: "glm-cn", name: "GLM Coding (China)", icon: "code", color: "#DC2626", textIcon: "GC" },
|
||||
kimi: { id: "kimi", alias: "kimi", name: "Kimi Coding", icon: "psychology", color: "#1E3A8A", textIcon: "KM" },
|
||||
minimax: { id: "minimax", alias: "minimax", name: "Minimax Coding", icon: "memory", color: "#7C3AED", textIcon: "MM" },
|
||||
"minimax-cn": { id: "minimax-cn", alias: "minimax-cn", name: "Minimax (China)", icon: "memory", color: "#DC2626", textIcon: "MC" },
|
||||
openai: { id: "openai", alias: "openai", name: "OpenAI", icon: "auto_awesome", color: "#10A37F", textIcon: "OA" },
|
||||
anthropic: { id: "anthropic", alias: "anthropic", name: "Anthropic", icon: "smart_toy", color: "#D97757", textIcon: "AN" },
|
||||
gemini: { id: "gemini", alias: "gemini", name: "Gemini", icon: "diamond", color: "#4285F4", textIcon: "GE" },
|
||||
openrouter: { id: "openrouter", alias: "openrouter", name: "OpenRouter", icon: "router", color: "#F97316", textIcon: "OR", passthroughModels: true, website: "https://openrouter.ai" },
|
||||
glm: { id: "glm", alias: "glm", name: "GLM Coding", icon: "code", color: "#2563EB", textIcon: "GL", website: "https://open.bigmodel.cn" },
|
||||
kimi: { id: "kimi", alias: "kimi", name: "Kimi", icon: "psychology", color: "#1E3A8A", textIcon: "KM", website: "https://kimi.moonshot.cn" },
|
||||
minimax: { id: "minimax", alias: "minimax", name: "Minimax Coding", icon: "memory", color: "#7C3AED", textIcon: "MM", website: "https://www.minimaxi.com" },
|
||||
"minimax-cn": { id: "minimax-cn", alias: "minimax-cn", name: "Minimax (China)", icon: "memory", color: "#DC2626", textIcon: "MC", website: "https://www.minimaxi.com" },
|
||||
openai: { id: "openai", alias: "openai", name: "OpenAI", icon: "auto_awesome", color: "#10A37F", textIcon: "OA", website: "https://platform.openai.com" },
|
||||
anthropic: { id: "anthropic", alias: "anthropic", name: "Anthropic", icon: "smart_toy", color: "#D97757", textIcon: "AN", website: "https://console.anthropic.com" },
|
||||
gemini: { id: "gemini", alias: "gemini", name: "Gemini", icon: "diamond", color: "#4285F4", textIcon: "GE", website: "https://ai.google.dev" },
|
||||
deepseek: { id: "deepseek", alias: "ds", name: "DeepSeek", icon: "bolt", color: "#4D6BFE", textIcon: "DS", website: "https://deepseek.com" },
|
||||
groq: { id: "groq", alias: "groq", name: "Groq", icon: "speed", color: "#F55036", textIcon: "GQ", website: "https://groq.com" },
|
||||
xai: { id: "xai", alias: "xai", name: "xAI (Grok)", icon: "auto_awesome", color: "#1DA1F2", textIcon: "XA", website: "https://x.ai" },
|
||||
mistral: { id: "mistral", alias: "mistral", name: "Mistral", icon: "air", color: "#FF7000", textIcon: "MI", website: "https://mistral.ai" },
|
||||
perplexity: { id: "perplexity", alias: "pplx", name: "Perplexity", icon: "search", color: "#20808D", textIcon: "PP", website: "https://www.perplexity.ai" },
|
||||
together: { id: "together", alias: "together", name: "Together AI", icon: "group_work", color: "#0F6FFF", textIcon: "TG", website: "https://www.together.ai" },
|
||||
fireworks: { id: "fireworks", alias: "fireworks", name: "Fireworks AI", icon: "local_fire_department", color: "#7B2EF2", textIcon: "FW", website: "https://fireworks.ai" },
|
||||
cerebras: { id: "cerebras", alias: "cerebras", name: "Cerebras", icon: "memory", color: "#FF4F00", textIcon: "CB", website: "https://www.cerebras.ai" },
|
||||
cohere: { id: "cohere", alias: "cohere", name: "Cohere", icon: "hub", color: "#39594D", textIcon: "CO", website: "https://cohere.com" },
|
||||
nvidia: { id: "nvidia", alias: "nvidia", name: "NVIDIA NIM", icon: "developer_board", color: "#76B900", textIcon: "NV", website: "https://developer.nvidia.com/nim" },
|
||||
nebius: { id: "nebius", alias: "nebius", name: "Nebius AI", icon: "cloud", color: "#6C5CE7", textIcon: "NB", website: "https://nebius.com" },
|
||||
siliconflow: { id: "siliconflow", alias: "siliconflow", name: "SiliconFlow", icon: "cloud_queue", color: "#5B6EF5", textIcon: "SF", website: "https://cloud.siliconflow.com" },
|
||||
hyperbolic: { id: "hyperbolic", alias: "hyp", name: "Hyperbolic", icon: "bolt", color: "#00D4FF", textIcon: "HY", website: "https://hyperbolic.xyz" },
|
||||
deepgram: { id: "deepgram", alias: "dg", name: "Deepgram", icon: "mic", color: "#13EF93", textIcon: "DG", website: "https://deepgram.com" },
|
||||
assemblyai: { id: "assemblyai", alias: "aai", name: "AssemblyAI", icon: "record_voice_over", color: "#0062FF", textIcon: "AA", website: "https://assemblyai.com" },
|
||||
nanobanana: { id: "nanobanana", alias: "nb", name: "NanoBanana", icon: "image", color: "#FFD700", textIcon: "NB", website: "https://nanobananaapi.ai" },
|
||||
};
|
||||
|
||||
export const OPENAI_COMPATIBLE_PREFIX = "openai-compatible-";
|
||||
@@ -84,4 +102,4 @@ export const ID_TO_ALIAS = Object.values(AI_PROVIDERS).reduce((acc, p) => {
|
||||
}, {});
|
||||
|
||||
// Providers that support usage/quota API
|
||||
export const USAGE_SUPPORTED_PROVIDERS = ["antigravity", "kiro", "github"];
|
||||
export const USAGE_SUPPORTED_PROVIDERS = ["antigravity", "kiro", "github", "codex", "claude"];
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { getProviderConnections, validateApiKey, updateProviderConnection, getSettings } from "@/lib/localDb";
|
||||
import { isAccountUnavailable, getUnavailableUntil, getEarliestRateLimitedUntil, formatRetryAfter, checkFallbackError } from "open-sse/services/accountFallback.js";
|
||||
import { resolveProviderId } from "@/shared/constants/providers.js";
|
||||
import * as log from "../utils/logger.js";
|
||||
|
||||
// Mutex to prevent race conditions during account selection
|
||||
@@ -77,12 +78,15 @@ export async function getProviderCredentials(provider, excludeConnectionId = nul
|
||||
try {
|
||||
await currentMutex;
|
||||
|
||||
const connections = await getProviderConnections({ provider, isActive: true });
|
||||
// Resolve alias to provider ID (e.g., "kc" -> "kilocode")
|
||||
const providerId = resolveProviderId(provider);
|
||||
|
||||
const connections = await getProviderConnections({ provider: providerId, isActive: true });
|
||||
log.debug("AUTH", `${provider} | total connections: ${connections.length}, excludeId: ${excludeConnectionId || "none"}, model: ${model || "any"}`);
|
||||
|
||||
if (connections.length === 0) {
|
||||
// Check all connections (including inactive) to see if rate limited
|
||||
const allConnections = await getProviderConnections({ provider });
|
||||
const allConnections = await getProviderConnections({ provider: providerId });
|
||||
log.debug("AUTH", `${provider} | all connections (incl inactive): ${allConnections.length}`);
|
||||
if (allConnections.length > 0) {
|
||||
const earliest = getEarliestRateLimitedUntil(allConnections);
|
||||
|
||||
@@ -2,4 +2,5 @@
|
||||
export { default as useThemeStore } from "./themeStore";
|
||||
export { default as useUserStore } from "./userStore";
|
||||
export { default as useProviderStore } from "./providerStore";
|
||||
export { useNotificationStore } from "./notificationStore";
|
||||
|
||||
|
||||
45
src/store/notificationStore.js
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Notification Store — Zustand-based global toast notification system.
|
||||
* Centralized feedback for dashboard actions.
|
||||
*/
|
||||
|
||||
import { create } from "zustand";
|
||||
|
||||
let idCounter = 0;
|
||||
|
||||
export const useNotificationStore = create((set, get) => ({
|
||||
notifications: [],
|
||||
|
||||
addNotification: (notification) => {
|
||||
const id = ++idCounter;
|
||||
const entry = {
|
||||
id,
|
||||
type: notification.type || "info",
|
||||
message: notification.message,
|
||||
title: notification.title || null,
|
||||
duration: notification.duration ?? 5000,
|
||||
dismissible: notification.dismissible ?? true,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
|
||||
set((s) => ({ notifications: [...s.notifications, entry] }));
|
||||
|
||||
// Auto-dismiss
|
||||
if (entry.duration > 0) {
|
||||
setTimeout(() => get().removeNotification(id), entry.duration);
|
||||
}
|
||||
|
||||
return id;
|
||||
},
|
||||
|
||||
removeNotification: (id) => {
|
||||
set((s) => ({ notifications: s.notifications.filter((n) => n.id !== id) }));
|
||||
},
|
||||
|
||||
clearAll: () => set({ notifications: [] }),
|
||||
|
||||
success: (message, title) => get().addNotification({ type: "success", message, title }),
|
||||
error: (message, title) => get().addNotification({ type: "error", message, title, duration: 8000 }),
|
||||
warning: (message, title) => get().addNotification({ type: "warning", message, title }),
|
||||
info: (message, title) => get().addNotification({ type: "info", message, title }),
|
||||
}));
|
||||