Add Providers

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

View File

@@ -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"
}
};

View File

@@ -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) {

View File

@@ -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;

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

BIN
public/providers/cohere.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

BIN
public/providers/groq.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 314 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

BIN
public/providers/nebius.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

BIN
public/providers/nvidia.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

BIN
public/providers/xai.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@@ -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>
);
}

View File

@@ -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,
};

View File

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

View File

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

View File

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

View File

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

View File

@@ -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",
};

View File

@@ -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 },
}),
},
};
/**

View File

@@ -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);
}

View File

@@ -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"];

View File

@@ -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);

View File

@@ -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";

View 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 }),
}));