diff --git a/open-sse/config/constants.js b/open-sse/config/constants.js
index 89530aa..19d0d46 100644
--- a/open-sse/config/constants.js
+++ b/open-sse/config/constants.js
@@ -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"
}
};
diff --git a/open-sse/config/providerModels.js b/open-sse/config/providerModels.js
index bbafb3f..12763fe 100644
--- a/open-sse/config/providerModels.js
+++ b/open-sse/config/providerModels.js
@@ -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) {
diff --git a/open-sse/executors/default.js b/open-sse/executors/default.js
index 02a38af..97ea4bc 100644
--- a/open-sse/executors/default.js
+++ b/open-sse/executors/default.js
@@ -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;
diff --git a/public/providers/cerebras.png b/public/providers/cerebras.png
new file mode 100644
index 0000000..a4ace23
Binary files /dev/null and b/public/providers/cerebras.png differ
diff --git a/public/providers/cohere.png b/public/providers/cohere.png
new file mode 100644
index 0000000..60a0faf
Binary files /dev/null and b/public/providers/cohere.png differ
diff --git a/public/providers/deepseek.png b/public/providers/deepseek.png
new file mode 100644
index 0000000..5df2f50
Binary files /dev/null and b/public/providers/deepseek.png differ
diff --git a/public/providers/fireworks.png b/public/providers/fireworks.png
new file mode 100644
index 0000000..f7233ca
Binary files /dev/null and b/public/providers/fireworks.png differ
diff --git a/public/providers/groq.png b/public/providers/groq.png
new file mode 100644
index 0000000..b6bb68a
Binary files /dev/null and b/public/providers/groq.png differ
diff --git a/public/providers/kilocode.png b/public/providers/kilocode.png
new file mode 100644
index 0000000..147dac0
Binary files /dev/null and b/public/providers/kilocode.png differ
diff --git a/public/providers/kimi-coding.png b/public/providers/kimi-coding.png
new file mode 100644
index 0000000..422b7f9
Binary files /dev/null and b/public/providers/kimi-coding.png differ
diff --git a/public/providers/mistral.png b/public/providers/mistral.png
new file mode 100644
index 0000000..68001b9
Binary files /dev/null and b/public/providers/mistral.png differ
diff --git a/public/providers/nebius.png b/public/providers/nebius.png
new file mode 100644
index 0000000..14c878e
Binary files /dev/null and b/public/providers/nebius.png differ
diff --git a/public/providers/nvidia.png b/public/providers/nvidia.png
new file mode 100644
index 0000000..9215a38
Binary files /dev/null and b/public/providers/nvidia.png differ
diff --git a/public/providers/perplexity.png b/public/providers/perplexity.png
new file mode 100644
index 0000000..4e9d56f
Binary files /dev/null and b/public/providers/perplexity.png differ
diff --git a/public/providers/siliconflow.png b/public/providers/siliconflow.png
new file mode 100644
index 0000000..a735814
Binary files /dev/null and b/public/providers/siliconflow.png differ
diff --git a/public/providers/together.png b/public/providers/together.png
new file mode 100644
index 0000000..7a53d4c
Binary files /dev/null and b/public/providers/together.png differ
diff --git a/public/providers/xai.png b/public/providers/xai.png
new file mode 100644
index 0000000..e75ae25
Binary files /dev/null and b/public/providers/xai.png differ
diff --git a/src/app/(dashboard)/dashboard/providers/components/ModelAvailabilityBadge.js b/src/app/(dashboard)/dashboard/providers/components/ModelAvailabilityBadge.js
new file mode 100644
index 0000000..9150bef
--- /dev/null
+++ b/src/app/(dashboard)/dashboard/providers/components/ModelAvailabilityBadge.js
@@ -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 (
+
+ {/*
*/}
+
+ {expanded && (
+
+
+
+
+ {isHealthy ? "verified" : "warning"}
+
+ Model Status
+
+
+
+
+
+ {isHealthy ? (
+
+ All models are responding normally.
+
+ ) : (
+
+ {Object.entries(byProvider).map(([provider, provModels]) => (
+
+
{provider}
+
+ {provModels.map((m) => {
+ const status = STATUS_CONFIG[m.status] || STATUS_CONFIG.unknown;
+ const isClearing = clearing === `${m.provider}:${m.model}`;
+ return (
+
+
+
+ {status.icon}
+
+ {m.model}
+
+ {m.status === "cooldown" && (
+
+ )}
+
+ );
+ })}
+
+
+ ))}
+
+ )}
+
+
+ )}
+
+ );
+}
diff --git a/src/app/(dashboard)/dashboard/providers/page.js b/src/app/(dashboard)/dashboard/providers/page.js
index 0ff2031..17e883b 100644
--- a/src/app/(dashboard)/dashboard/providers/page.js
+++ b/src/app/(dashboard)/dashboard/providers/page.js
@@ -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 (
@@ -138,7 +204,30 @@ export default function ProvidersPage() {
{/* OAuth Providers */}
-
OAuth Providers
+
+
+ OAuth Providers
+
+
+
+
+
+
{Object.entries(OAUTH_PROVIDERS).map(([key, info]) => (
handleToggleProvider(key, "oauth", active)}
/>
))}
@@ -153,7 +244,27 @@ export default function ProvidersPage() {
{/* Free Providers */}
-
Free Providers
+
+
+ Free Providers
+
+
+
{Object.entries(FREE_PROVIDERS).map(([key, info]) => (
handleToggleProvider(key, "oauth", active)}
/>
))}
- {/* API Key Providers */}
+ {/* API Key Providers — fixed list */}
-
API Key Providers
+
+ API Key Providers{" "}
+
+
+
+
+ {Object.entries(APIKEY_PROVIDERS).map(([key, info]) => (
+
handleToggleProvider(key, "apikey", active)}
+ />
+ ))}
+
+
+
+ {/* API Key Compatible Providers — dynamic (OpenAI/Anthropic compatible) */}
+
+
+
+ API Key Compatible Providers{" "}
+
+ {(compatibleProviders.length > 0 || anthropicCompatibleProviders.length > 0) && (
+
+ )}
@@ -185,17 +354,30 @@ export default function ProvidersPage() {
-
- {Object.entries(apiKeyProviders).map(([key, info]) => (
-
- ))}
-
+ {compatibleProviders.length === 0 && anthropicCompatibleProviders.length === 0 ? (
+
+
extension
+
No compatible providers added yet
+
+ Use the buttons above to add OpenAI or Anthropic compatible endpoints
+
+
+ ) : (
+
+ {[...compatibleProviders, ...anthropicCompatibleProviders].map((info) => (
+
handleToggleProvider(info.id, "apikey", active)}
+ />
+ ))}
+
+ )}
+
setShowAddCompatibleModal(false)}
@@ -212,17 +394,56 @@ export default function ProvidersPage() {
setShowAddAnthropicCompatibleModal(false);
}}
/>
+
+ {/* Test Results Modal */}
+ {testResults && (
+ setTestResults(null)}
+ >
+
+
e.stopPropagation()}
+ >
+
+
Test Results
+
+
+
+
+
+ )}
);
}
-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 (
-
+
{imgError ? (
-
+
{provider.textIcon || provider.id.slice(0, 2).toUpperCase()}
) : (
@@ -249,16 +467,45 @@ function ProviderCard({ providerId, provider, stats }) {
)}
-
{provider.name}
+
+ {provider.name}
+
- {getStatusDisplay(connected, error, errorCode)}
- {errorTime && • {errorTime}}
+ {allDisabled ? (
+
+
+ pause_circle
+ Disabled
+
+
+ ) : (
+ <>
+ {getStatusDisplay(connected, error, errorCode)}
+ {errorTime && {errorTime}}
+ >
+ )}
-
- chevron_right
-
+
+ {stats.total > 0 && (
+
{
+ e.preventDefault();
+ e.stopPropagation();
+ onToggle(!allDisabled ? false : true);
+ }}
+ >
+ {}}
+ title={allDisabled ? "Enable provider" : "Disable provider"}
+ />
+
+ )}
+
@@ -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 (
-
+
{imgError ? (
-
+
{provider.textIcon || provider.id.slice(0, 2).toUpperCase()}
) : (
@@ -328,26 +579,53 @@ function ApiKeyProviderCard({ providerId, provider, stats }) {
)}
-
{provider.name}
+
+ {provider.name}
+
- {getStatusDisplay(connected, error, errorCode)}
- {isCompatible && (
+ {allDisabled ? (
- {provider.apiType === "responses" ? "Responses" : "Chat"}
+
+ pause_circle
+ Disabled
+
+ ) : (
+ <>
+ {getStatusDisplay(connected, error, errorCode)}
+ {isCompatible && (
+
+ {provider.apiType === "responses" ? "Responses" : "Chat"}
+
+ )}
+ {isAnthropicCompatible && (
+ Messages
+ )}
+ {errorTime && {errorTime}}
+ >
)}
- {isAnthropicCompatible && (
-
- Messages
-
- )}
- {errorTime && • {errorTime}}
-
- chevron_right
-
+
+ {stats.total > 0 && (
+
{
+ e.preventDefault();
+ e.stopPropagation();
+ onToggle(!allDisabled ? false : true);
+ }}
+ >
+ {}}
+ title={allDisabled ? "Enable provider" : "Disable provider"}
+ />
+
+ )}
+
@@ -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 }) {
-
+
@@ -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 }) {
-
+
@@ -648,3 +907,79 @@ AddAnthropicCompatibleModal.propTypes = {
onClose: PropTypes.func.isRequired,
onCreated: PropTypes.func.isRequired,
};
+
+function ProviderTestResultsView({ results }) {
+ if (results.error && !results.results) {
+ return (
+
+
error
+
{results.error}
+
+ );
+ }
+
+ const { summary, mode } = results;
+ const items = results.results || [];
+ const modeLabel = { oauth: "OAuth", free: "Free", apikey: "API Key", provider: "Provider", all: "All" }[mode] || mode;
+
+ return (
+
+ {summary && (
+
+ {modeLabel} Test
+
+ {summary.passed} passed
+
+ {summary.failed > 0 && (
+
+ {summary.failed} failed
+
+ )}
+ {summary.total} tested
+
+ )}
+ {items.map((r, i) => (
+
+
+ {r.valid ? "check_circle" : "error"}
+
+
+ {r.connectionName}
+ ({r.provider})
+
+ {r.latencyMs !== undefined && (
+
{r.latencyMs}ms
+ )}
+
+ {r.valid ? "OK" : r.diagnosis?.type || "ERROR"}
+
+
+ ))}
+ {items.length === 0 && (
+
+ No active connections found for this group.
+
+ )}
+
+ );
+}
+
+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,
+};
diff --git a/src/app/api/oauth/[provider]/[action]/route.js b/src/app/api/oauth/[provider]/[action]/route.js
index 7382af7..8d7a548 100644
--- a/src/app/api/oauth/[provider]/[action]/route.js
+++ b/src/app/api/oauth/[provider]/[action]/route.js
@@ -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 });
}
diff --git a/src/app/api/providers/[id]/test/route.js b/src/app/api/providers/[id]/test/route.js
index 26abcf0..0fa641a 100644
--- a/src/app/api/providers/[id]/test/route.js
+++ b/src/app/api/providers/[id]/test/route.js
@@ -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,
diff --git a/src/app/api/providers/[id]/test/testUtils.js b/src/app/api/providers/[id]/test/testUtils.js
new file mode 100644
index 0000000..7f16593
--- /dev/null
+++ b/src/app/api/providers/[id]/test/testUtils.js
@@ -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() };
+}
diff --git a/src/app/api/providers/test-batch/route.js b/src/app/api/providers/test-batch/route.js
new file mode 100644
index 0000000..da020cc
--- /dev/null
+++ b/src/app/api/providers/test-batch/route.js
@@ -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 });
+ }
+}
diff --git a/src/lib/oauth/constants/oauth.js b/src/lib/oauth/constants/oauth.js
index fd1329c..3e52c5d 100644
--- a/src/lib/oauth/constants/oauth.js
+++ b/src/lib/oauth/constants/oauth.js
@@ -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",
};
diff --git a/src/lib/oauth/providers.js b/src/lib/oauth/providers.js
index 8124553..99f8b7c 100644
--- a/src/lib/oauth/providers.js
+++ b/src/lib/oauth/providers.js
@@ -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 },
+ }),
+ },
};
/**
diff --git a/src/shared/components/OAuthModal.js b/src/shared/components/OAuthModal.js
index b847f2b..43d4912 100644
--- a/src/shared/components/OAuthModal.js
+++ b/src/shared/components/OAuthModal.js
@@ -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);
}
diff --git a/src/shared/constants/providers.js b/src/shared/constants/providers.js
index 936504f..fa37766 100644
--- a/src/shared/constants/providers.js
+++ b/src/shared/constants/providers.js
@@ -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"];
diff --git a/src/sse/services/auth.js b/src/sse/services/auth.js
index 83de798..275ac9b 100644
--- a/src/sse/services/auth.js
+++ b/src/sse/services/auth.js
@@ -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);
diff --git a/src/store/index.js b/src/store/index.js
index de81f82..e057a0e 100644
--- a/src/store/index.js
+++ b/src/store/index.js
@@ -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";
diff --git a/src/store/notificationStore.js b/src/store/notificationStore.js
new file mode 100644
index 0000000..ea51715
--- /dev/null
+++ b/src/store/notificationStore.js
@@ -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 }),
+}));