diff --git a/open-sse/config/constants.js b/open-sse/config/constants.js index 89530aa..19d0d46 100644 --- a/open-sse/config/constants.js +++ b/open-sse/config/constants.js @@ -250,6 +250,33 @@ export const PROVIDERS = { "User-Agent": "connect-es/1.6.1" }, clientVersion: "1.1.3" + }, + "kimi-coding": { + baseUrl: "https://api.kimi.com/coding/v1/messages", + format: "claude", + headers: { + "Anthropic-Version": "2023-06-01", + "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14" + }, + clientId: "17e5f671-d194-4dfb-9706-5516cb48c098", + tokenUrl: "https://auth.kimi.com/api/oauth/token", + refreshUrl: "https://auth.kimi.com/api/oauth/token" + }, + kilocode: { + baseUrl: "https://api.kilo.ai/api/openrouter/chat/completions", + format: "openrouter", + headers: {} + }, + cline: { + baseUrl: "https://api.cline.bot/api/v1/messages", + format: "claude", + headers: { + "HTTP-Referer": "https://cline.bot", + "X-Title": "Cline", + "Anthropic-Version": "2023-06-01" + }, + tokenUrl: "https://api.cline.bot/api/v1/auth/token", + refreshUrl: "https://api.cline.bot/api/v1/auth/refresh" } }; diff --git a/open-sse/config/providerModels.js b/open-sse/config/providerModels.js index bbafb3f..12763fe 100644 --- a/open-sse/config/providerModels.js +++ b/open-sse/config/providerModels.js @@ -116,6 +116,30 @@ export const PROVIDER_MODELS = { { id: "claude-4.5-opus", name: "Claude 4.5 Opus" }, { id: "gpt-5.2-codex", name: "GPT 5.2 Codex" }, ], + kmc: [ // Kimi Coding + { id: "kimi-k2.5", name: "Kimi K2.5" }, + { id: "kimi-k2.5-thinking", name: "Kimi K2.5 Thinking" }, + { id: "kimi-latest", name: "Kimi Latest" }, + ], + kc: [ // KiloCode + { id: "anthropic/claude-sonnet-4-20250514", name: "Claude Sonnet 4" }, + { id: "anthropic/claude-opus-4-20250514", name: "Claude Opus 4" }, + { id: "google/gemini-2.5-pro", name: "Gemini 2.5 Pro" }, + { id: "google/gemini-2.5-flash", name: "Gemini 2.5 Flash" }, + { id: "openai/gpt-4.1", name: "GPT-4.1" }, + { id: "openai/o3", name: "o3" }, + { id: "deepseek/deepseek-chat", name: "DeepSeek Chat" }, + { id: "deepseek/deepseek-reasoner", name: "DeepSeek Reasoner" }, + ], + cl: [ // Cline + { id: "anthropic/claude-sonnet-4-20250514", name: "Claude Sonnet 4" }, + { id: "anthropic/claude-opus-4-20250514", name: "Claude Opus 4" }, + { id: "google/gemini-2.5-pro", name: "Gemini 2.5 Pro" }, + { id: "google/gemini-2.5-flash", name: "Gemini 2.5 Flash" }, + { id: "openai/gpt-4.1", name: "GPT-4.1" }, + { id: "openai/o3", name: "o3" }, + { id: "deepseek/deepseek-chat", name: "DeepSeek Chat" }, + ], // API Key Providers (alias = id) openai: [ @@ -167,6 +191,88 @@ export const PROVIDER_MODELS = { { id: "MiniMax-M2.5", name: "MiniMax M2.5" }, { id: "MiniMax-M2.1", name: "MiniMax M2.1" }, ], + deepseek: [ + { id: "deepseek-chat", name: "DeepSeek V3.2 Chat" }, + { id: "deepseek-reasoner", name: "DeepSeek V3.2 Reasoner" }, + ], + groq: [ + { id: "llama-3.3-70b-versatile", name: "Llama 3.3 70B" }, + { id: "meta-llama/llama-4-maverick-17b-128e-instruct", name: "Llama 4 Maverick" }, + { id: "qwen/qwen3-32b", name: "Qwen3 32B" }, + { id: "openai/gpt-oss-120b", name: "GPT-OSS 120B" }, + ], + xai: [ + { id: "grok-4", name: "Grok 4" }, + { id: "grok-4-fast-reasoning", name: "Grok 4 Fast Reasoning" }, + { id: "grok-code-fast-1", name: "Grok Code Fast" }, + { id: "grok-3", name: "Grok 3" }, + ], + mistral: [ + { id: "mistral-large-latest", name: "Mistral Large 3" }, + { id: "codestral-latest", name: "Codestral" }, + { id: "mistral-medium-latest", name: "Mistral Medium 3" }, + ], + perplexity: [ + { id: "sonar-pro", name: "Sonar Pro" }, + { id: "sonar", name: "Sonar" }, + ], + together: [ + { id: "meta-llama/Llama-3.3-70B-Instruct-Turbo", name: "Llama 3.3 70B Turbo" }, + { id: "deepseek-ai/DeepSeek-R1", name: "DeepSeek R1" }, + { id: "Qwen/Qwen3-235B-A22B", name: "Qwen3 235B" }, + { id: "meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8", name: "Llama 4 Maverick" }, + ], + fireworks: [ + { id: "accounts/fireworks/models/deepseek-v3p1", name: "DeepSeek V3.1" }, + { id: "accounts/fireworks/models/llama-v3p3-70b-instruct", name: "Llama 3.3 70B" }, + { id: "accounts/fireworks/models/qwen3-235b-a22b", name: "Qwen3 235B" }, + ], + cerebras: [ + { id: "gpt-oss-120b", name: "GPT OSS 120B" }, + { id: "zai-glm-4.7", name: "ZAI GLM 4.7" }, + { id: "llama-3.3-70b", name: "Llama 3.3 70B" }, + { id: "llama-4-scout-17b-16e-instruct", name: "Llama 4 Scout" }, + { id: "qwen-3-235b-a22b-instruct-2507", name: "Qwen3 235B A22B" }, + { id: "qwen-3-32b", name: "Qwen3 32B" }, + ], + cohere: [ + { id: "command-r-plus-08-2024", name: "Command R+ (Aug 2024)" }, + { id: "command-r-08-2024", name: "Command R (Aug 2024)" }, + { id: "command-a-03-2025", name: "Command A (Mar 2025)" }, + ], + nvidia: [ + { id: "moonshotai/kimi-k2.5", name: "Kimi K2.5" }, + { id: "z-ai/glm4.7", name: "GLM 4.7" }, + { id: "deepseek-ai/deepseek-v3.2", name: "DeepSeek V3.2" }, + { id: "nvidia/llama-3.3-70b-instruct", name: "Llama 3.3 70B" }, + { id: "meta/llama-4-maverick-17b-128e-instruct", name: "Llama 4 Maverick" }, + { id: "deepseek/deepseek-r1", name: "DeepSeek R1" }, + ], + nebius: [ + { id: "meta-llama/Llama-3.3-70B-Instruct", name: "Llama 3.3 70B Instruct" }, + ], + siliconflow: [ + { id: "deepseek-ai/DeepSeek-V3.2", name: "DeepSeek V3.2" }, + { id: "deepseek-ai/DeepSeek-V3.1", name: "DeepSeek V3.1" }, + { id: "deepseek-ai/DeepSeek-R1", name: "DeepSeek R1" }, + { id: "Qwen/Qwen3-235B-A22B-Instruct-2507", name: "Qwen3 235B" }, + { id: "Qwen/Qwen3-Coder-480B-A35B-Instruct", name: "Qwen3 Coder 480B" }, + { id: "Qwen/Qwen3-32B", name: "Qwen3 32B" }, + { id: "moonshotai/Kimi-K2.5", name: "Kimi K2.5" }, + { id: "zai-org/GLM-4.7", name: "GLM 4.7" }, + { id: "openai/gpt-oss-120b", name: "GPT OSS 120B" }, + { id: "baidu/ERNIE-4.5-300B-A47B", name: "ERNIE 4.5 300B" }, + ], + hyperbolic: [ + { id: "Qwen/QwQ-32B", name: "QwQ 32B" }, + { id: "deepseek-ai/DeepSeek-R1", name: "DeepSeek R1" }, + { id: "deepseek-ai/DeepSeek-V3", name: "DeepSeek V3" }, + { id: "meta-llama/Llama-3.3-70B-Instruct", name: "Llama 3.3 70B" }, + { id: "meta-llama/Llama-3.2-3B-Instruct", name: "Llama 3.2 3B" }, + { id: "Qwen/Qwen2.5-72B-Instruct", name: "Qwen 2.5 72B" }, + { id: "Qwen/Qwen2.5-Coder-32B-Instruct", name: "Qwen 2.5 Coder 32B" }, + { id: "NousResearch/Hermes-3-Llama-3.1-70B", name: "Hermes 3 70B" }, + ], }; // Helper functions @@ -211,6 +317,9 @@ export const PROVIDER_ID_TO_ALIAS = { github: "gh", kiro: "kr", cursor: "cu", + "kimi-coding": "kmc", + kilocode: "kc", + cline: "cl", openai: "openai", anthropic: "anthropic", gemini: "gemini", @@ -220,6 +329,19 @@ export const PROVIDER_ID_TO_ALIAS = { kimi: "kimi", minimax: "minimax", "minimax-cn": "minimax-cn", + deepseek: "deepseek", + groq: "groq", + xai: "xai", + mistral: "mistral", + perplexity: "perplexity", + together: "together", + fireworks: "fireworks", + cerebras: "cerebras", + cohere: "cohere", + nvidia: "nvidia", + nebius: "nebius", + siliconflow: "siliconflow", + hyperbolic: "hyperbolic", }; export function getModelsByProviderId(providerId) { diff --git a/open-sse/executors/default.js b/open-sse/executors/default.js index 02a38af..97ea4bc 100644 --- a/open-sse/executors/default.js +++ b/open-sse/executors/default.js @@ -22,6 +22,8 @@ export class DefaultExecutor extends BaseExecutor { case "claude": case "glm": case "kimi": + case "kimi-coding": + case "cline": case "minimax": case "minimax-cn": return `${this.config.baseUrl}?beta=true`; @@ -44,9 +46,11 @@ export class DefaultExecutor extends BaseExecutor { break; case "glm": case "kimi": + case "kimi-coding": + case "cline": case "minimax": case "minimax-cn": - headers["x-api-key"] = credentials.apiKey; + headers["x-api-key"] = credentials.apiKey || credentials.accessToken; break; default: if (this.provider?.startsWith?.("anthropic-compatible-")) { @@ -76,7 +80,10 @@ export class DefaultExecutor extends BaseExecutor { qwen: () => this.refreshWithForm(OAUTH_ENDPOINTS.qwen.token, { grant_type: "refresh_token", refresh_token: credentials.refreshToken, client_id: PROVIDERS.qwen.clientId }), iflow: () => this.refreshIflow(credentials.refreshToken), gemini: () => this.refreshGoogle(credentials.refreshToken), - kiro: () => this.refreshKiro(credentials.refreshToken) + kiro: () => this.refreshKiro(credentials.refreshToken), + cline: () => this.refreshCline(credentials.refreshToken), + "kimi-coding": () => this.refreshKimiCoding(credentials.refreshToken), + kilocode: () => this.refreshKilocode(credentials.refreshToken) }; const refresher = refreshers[this.provider]; @@ -147,6 +154,44 @@ export class DefaultExecutor extends BaseExecutor { const tokens = await response.json(); return { accessToken: tokens.accessToken, refreshToken: tokens.refreshToken || refreshToken, expiresIn: tokens.expiresIn }; } + + async refreshCline(refreshToken) { + console.log('[DEBUG] Refreshing Cline token, refreshToken length:', refreshToken?.length); + const response = await fetch("https://api.cline.bot/api/v1/auth/refresh", { + method: "POST", + headers: { "Content-Type": "application/json", "Accept": "application/json" }, + body: JSON.stringify({ refreshToken, grantType: "refresh_token", clientType: "extension" }) + }); + console.log('[DEBUG] Cline refresh response status:', response.status); + if (!response.ok) { + const errorText = await response.text(); + console.log('[DEBUG] Cline refresh error:', errorText); + return null; + } + const payload = await response.json(); + console.log('[DEBUG] Cline refresh payload:', JSON.stringify(payload).substring(0, 200)); + const data = payload?.data || payload; + const expiresAtIso = data?.expiresAt; + const expiresIn = expiresAtIso ? Math.max(1, Math.floor((new Date(expiresAtIso).getTime() - Date.now()) / 1000)) : undefined; + console.log('[DEBUG] Cline refresh success, expiresIn:', expiresIn); + return { accessToken: data?.accessToken, refreshToken: data?.refreshToken || refreshToken, expiresIn }; + } + + async refreshKimiCoding(refreshToken) { + const response = await fetch("https://auth.kimi.com/api/oauth/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json" }, + body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: refreshToken, client_id: "17e5f671-d194-4dfb-9706-5516cb48c098" }) + }); + if (!response.ok) return null; + const tokens = await response.json(); + return { accessToken: tokens.access_token, refreshToken: tokens.refresh_token || refreshToken, expiresIn: tokens.expires_in }; + } + + async refreshKilocode(refreshToken) { + // Kilocode uses device code flow, no refresh token support + return null; + } } export default DefaultExecutor; diff --git a/public/providers/cerebras.png b/public/providers/cerebras.png new file mode 100644 index 0000000..a4ace23 Binary files /dev/null and b/public/providers/cerebras.png differ diff --git a/public/providers/cohere.png b/public/providers/cohere.png new file mode 100644 index 0000000..60a0faf Binary files /dev/null and b/public/providers/cohere.png differ diff --git a/public/providers/deepseek.png b/public/providers/deepseek.png new file mode 100644 index 0000000..5df2f50 Binary files /dev/null and b/public/providers/deepseek.png differ diff --git a/public/providers/fireworks.png b/public/providers/fireworks.png new file mode 100644 index 0000000..f7233ca Binary files /dev/null and b/public/providers/fireworks.png differ diff --git a/public/providers/groq.png b/public/providers/groq.png new file mode 100644 index 0000000..b6bb68a Binary files /dev/null and b/public/providers/groq.png differ diff --git a/public/providers/kilocode.png b/public/providers/kilocode.png new file mode 100644 index 0000000..147dac0 Binary files /dev/null and b/public/providers/kilocode.png differ diff --git a/public/providers/kimi-coding.png b/public/providers/kimi-coding.png new file mode 100644 index 0000000..422b7f9 Binary files /dev/null and b/public/providers/kimi-coding.png differ diff --git a/public/providers/mistral.png b/public/providers/mistral.png new file mode 100644 index 0000000..68001b9 Binary files /dev/null and b/public/providers/mistral.png differ diff --git a/public/providers/nebius.png b/public/providers/nebius.png new file mode 100644 index 0000000..14c878e Binary files /dev/null and b/public/providers/nebius.png differ diff --git a/public/providers/nvidia.png b/public/providers/nvidia.png new file mode 100644 index 0000000..9215a38 Binary files /dev/null and b/public/providers/nvidia.png differ diff --git a/public/providers/perplexity.png b/public/providers/perplexity.png new file mode 100644 index 0000000..4e9d56f Binary files /dev/null and b/public/providers/perplexity.png differ diff --git a/public/providers/siliconflow.png b/public/providers/siliconflow.png new file mode 100644 index 0000000..a735814 Binary files /dev/null and b/public/providers/siliconflow.png differ diff --git a/public/providers/together.png b/public/providers/together.png new file mode 100644 index 0000000..7a53d4c Binary files /dev/null and b/public/providers/together.png differ diff --git a/public/providers/xai.png b/public/providers/xai.png new file mode 100644 index 0000000..e75ae25 Binary files /dev/null and b/public/providers/xai.png differ diff --git a/src/app/(dashboard)/dashboard/providers/components/ModelAvailabilityBadge.js b/src/app/(dashboard)/dashboard/providers/components/ModelAvailabilityBadge.js new file mode 100644 index 0000000..9150bef --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/components/ModelAvailabilityBadge.js @@ -0,0 +1,185 @@ +"use client"; + +/** + * ModelAvailabilityBadge — compact inline status indicator + * + * Shows green when all models are operational, or amber/red when there are + * issues, with a hover popover for details and cooldown clearing. + */ + +import { useState, useEffect, useCallback, useRef } from "react"; +import { Button } from "@/shared/components"; +import { useNotificationStore } from "@/store/notificationStore"; + +const STATUS_CONFIG = { + available: { icon: "check_circle", color: "#22c55e", label: "Available" }, + cooldown: { icon: "schedule", color: "#f59e0b", label: "Cooldown" }, + unavailable: { icon: "error", color: "#ef4444", label: "Unavailable" }, + unknown: { icon: "help", color: "#6b7280", label: "Unknown" }, +}; + +export default function ModelAvailabilityBadge() { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [expanded, setExpanded] = useState(false); + const [clearing, setClearing] = useState(null); + const ref = useRef(null); + const notify = useNotificationStore(); + + const fetchStatus = useCallback(async () => { + try { + const res = await fetch("/api/models/availability"); + if (res.ok) { + const json = await res.json(); + setData(json); + } + } catch { + // silent fail — will retry + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchStatus(); + const interval = setInterval(fetchStatus, 30000); + return () => clearInterval(interval); + }, [fetchStatus]); + + // Close popover on outside click + useEffect(() => { + const handleClick = (e) => { + if (ref.current && !ref.current.contains(e.target)) setExpanded(false); + }; + if (expanded) document.addEventListener("mousedown", handleClick); + return () => document.removeEventListener("mousedown", handleClick); + }, [expanded]); + + const handleClearCooldown = async (provider, model) => { + setClearing(`${provider}:${model}`); + try { + const res = await fetch("/api/models/availability", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action: "clearCooldown", provider, model }), + }); + if (res.ok) { + notify.success(`Cooldown cleared for ${model}`); + await fetchStatus(); + } else { + notify.error("Failed to clear cooldown"); + } + } catch { + notify.error("Failed to clear cooldown"); + } finally { + setClearing(null); + } + }; + + if (loading) return null; + + const models = data?.models || []; + const unavailableCount = data?.unavailableCount || models.filter((m) => m.status !== "available").length; + const isHealthy = unavailableCount === 0; + + // Group unhealthy models by provider + const byProvider = {}; + models.forEach((m) => { + if (m.status === "available") return; + const key = m.provider || "unknown"; + if (!byProvider[key]) byProvider[key] = []; + byProvider[key].push(m); + }); + + return ( +
+ {/* */} + + {expanded && ( +
+
+
+ + {isHealthy ? "verified" : "warning"} + + Model Status +
+ +
+ +
+ {isHealthy ? ( +

+ All models are responding normally. +

+ ) : ( +
+ {Object.entries(byProvider).map(([provider, provModels]) => ( +
+

{provider}

+
+ {provModels.map((m) => { + const status = STATUS_CONFIG[m.status] || STATUS_CONFIG.unknown; + const isClearing = clearing === `${m.provider}:${m.model}`; + return ( +
+
+ + {status.icon} + + {m.model} +
+ {m.status === "cooldown" && ( + + )} +
+ ); + })} +
+
+ ))} +
+ )} +
+
+ )} +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/providers/page.js b/src/app/(dashboard)/dashboard/providers/page.js index 0ff2031..17e883b 100644 --- a/src/app/(dashboard)/dashboard/providers/page.js +++ b/src/app/(dashboard)/dashboard/providers/page.js @@ -3,13 +3,14 @@ import { useState, useEffect } from "react"; import Image from "next/image"; import PropTypes from "prop-types"; -import { Card, CardSkeleton, Badge, Button, Input, Modal, Select } from "@/shared/components"; +import { Card, CardSkeleton, Badge, Button, Input, Modal, Select, Toggle } from "@/shared/components"; import { OAUTH_PROVIDERS, APIKEY_PROVIDERS } from "@/shared/constants/config"; import { FREE_PROVIDERS, OPENAI_COMPATIBLE_PREFIX, ANTHROPIC_COMPATIBLE_PREFIX } from "@/shared/constants/providers"; import Link from "next/link"; import { getErrorCode, getRelativeTime } from "@/shared/utils"; +import { useNotificationStore } from "@/store/notificationStore"; +import ModelAvailabilityBadge from "./components/ModelAvailabilityBadge"; -// Shared helper function to avoid code duplication between ProviderCard and ApiKeyProviderCard function getStatusDisplay(connected, error, errorCode) { const parts = []; if (connected > 0) { @@ -33,12 +34,44 @@ function getStatusDisplay(connected, error, errorCode) { return parts; } +function getConnectionErrorTag(connection) { + if (!connection) return null; + + const explicitType = connection.lastErrorType; + if (explicitType === "runtime_error") return "RUNTIME"; + if ( + explicitType === "upstream_auth_error" || + explicitType === "auth_missing" || + explicitType === "token_refresh_failed" || + explicitType === "token_expired" + ) return "AUTH"; + if (explicitType === "upstream_rate_limited") return "429"; + if (explicitType === "upstream_unavailable") return "5XX"; + if (explicitType === "network_error") return "NET"; + + const numericCode = Number(connection.errorCode); + if (Number.isFinite(numericCode) && numericCode >= 400) return String(numericCode); + + const fromMessage = getErrorCode(connection.lastError); + if (fromMessage === "401" || fromMessage === "403") return "AUTH"; + if (fromMessage && fromMessage !== "ERR") return fromMessage; + + const msg = (connection.lastError || "").toLowerCase(); + if (msg.includes("runtime") || msg.includes("not runnable") || msg.includes("not installed")) return "RUNTIME"; + if (msg.includes("invalid api key") || msg.includes("token invalid") || msg.includes("revoked") || msg.includes("unauthorized")) return "AUTH"; + + return "ERR"; +} + export default function ProvidersPage() { const [connections, setConnections] = useState([]); const [providerNodes, setProviderNodes] = useState([]); const [loading, setLoading] = useState(true); const [showAddCompatibleModal, setShowAddCompatibleModal] = useState(false); const [showAddAnthropicCompatibleModal, setShowAddAnthropicCompatibleModal] = useState(false); + const [testingMode, setTestingMode] = useState(null); + const [testResults, setTestResults] = useState(null); + const notify = useNotificationStore(); useEffect(() => { const fetchData = async () => { @@ -62,36 +95,81 @@ export default function ProvidersPage() { const getProviderStats = (providerId, authType) => { const providerConnections = connections.filter( - c => c.provider === providerId && c.authType === authType + (c) => c.provider === providerId && c.authType === authType ); - // Helper: check if connection is effectively active (cooldown expired) const getEffectiveStatus = (conn) => { const isCooldown = conn.rateLimitedUntil && new Date(conn.rateLimitedUntil).getTime() > Date.now(); - return (conn.testStatus === "unavailable" && !isCooldown) ? "active" : conn.testStatus; + return conn.testStatus === "unavailable" && !isCooldown ? "active" : conn.testStatus; }; - const connected = providerConnections.filter(c => { + const connected = providerConnections.filter((c) => { const status = getEffectiveStatus(c); return status === "active" || status === "success"; }).length; - const errorConns = providerConnections.filter(c => { + const errorConns = providerConnections.filter((c) => { const status = getEffectiveStatus(c); return status === "error" || status === "expired" || status === "unavailable"; }); const error = errorConns.length; const total = providerConnections.length; + const allDisabled = total > 0 && providerConnections.every((c) => c.isActive === false); - // Get latest error info - const latestError = errorConns.sort((a, b) => - new Date(b.lastErrorAt || 0) - new Date(a.lastErrorAt || 0) + const latestError = errorConns.sort( + (a, b) => new Date(b.lastErrorAt || 0) - new Date(a.lastErrorAt || 0) )[0]; - const errorCode = latestError ? getErrorCode(latestError.lastError) : null; + const errorCode = latestError ? getConnectionErrorTag(latestError) : null; const errorTime = latestError?.lastErrorAt ? getRelativeTime(latestError.lastErrorAt) : null; - return { connected, error, total, errorCode, errorTime }; + return { connected, error, total, errorCode, errorTime, allDisabled }; + }; + + // Toggle all connections for a provider on/off + const handleToggleProvider = async (providerId, authType, newActive) => { + const providerConns = connections.filter( + (c) => c.provider === providerId && c.authType === authType + ); + setConnections((prev) => + prev.map((c) => + c.provider === providerId && c.authType === authType ? { ...c, isActive: newActive } : c + ) + ); + await Promise.allSettled( + providerConns.map((c) => + fetch(`/api/providers/${c.id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ isActive: newActive }), + }) + ) + ); + }; + + const handleBatchTest = async (mode, providerId = null) => { + if (testingMode) return; + setTestingMode(mode === "provider" ? providerId : mode); + setTestResults(null); + try { + const res = await fetch("/api/providers/test-batch", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ mode, providerId }), + }); + const data = await res.json(); + setTestResults(data); + if (data.summary) { + const { passed, failed, total } = data.summary; + if (failed === 0) notify.success(`All ${total} tests passed`); + else notify.warning(`${passed}/${total} passed, ${failed} failed`); + } + } catch (error) { + setTestResults({ error: "Test request failed" }); + notify.error("Provider test failed"); + } finally { + setTestingMode(null); + } }; const compatibleProviders = providerNodes @@ -113,18 +191,6 @@ export default function ProvidersPage() { textIcon: "AC", })); - const apiKeyProviders = { - ...APIKEY_PROVIDERS, - ...compatibleProviders.reduce((acc, provider) => { - acc[provider.id] = provider; - return acc; - }, {}), - ...anthropicCompatibleProviders.reduce((acc, provider) => { - acc[provider.id] = provider; - return acc; - }, {}), - }; - if (loading) { return (
@@ -138,7 +204,30 @@ export default function ProvidersPage() {
{/* OAuth Providers */}
-

OAuth Providers

+
+

+ OAuth Providers +

+
+ + +
+
{Object.entries(OAUTH_PROVIDERS).map(([key, info]) => ( handleToggleProvider(key, "oauth", active)} /> ))}
@@ -153,7 +244,27 @@ export default function ProvidersPage() { {/* Free Providers */}
-

Free Providers

+
+

+ Free Providers +

+ +
{Object.entries(FREE_PROVIDERS).map(([key, info]) => ( handleToggleProvider(key, "oauth", active)} /> ))}
- {/* API Key Providers */} + {/* API Key Providers — fixed list */}
-

API Key Providers

+

+ API Key Providers{" "} +

+ +
+
+ {Object.entries(APIKEY_PROVIDERS).map(([key, info]) => ( + handleToggleProvider(key, "apikey", active)} + /> + ))} +
+
+ + {/* API Key Compatible Providers — dynamic (OpenAI/Anthropic compatible) */} +
+
+

+ API Key Compatible Providers{" "} +

+ {(compatibleProviders.length > 0 || anthropicCompatibleProviders.length > 0) && ( + + )} @@ -185,17 +354,30 @@ export default function ProvidersPage() {
-
- {Object.entries(apiKeyProviders).map(([key, info]) => ( - - ))} -
+ {compatibleProviders.length === 0 && anthropicCompatibleProviders.length === 0 ? ( +
+ extension +

No compatible providers added yet

+

+ Use the buttons above to add OpenAI or Anthropic compatible endpoints +

+
+ ) : ( +
+ {[...compatibleProviders, ...anthropicCompatibleProviders].map((info) => ( + handleToggleProvider(info.id, "apikey", active)} + /> + ))} +
+ )}
+ setShowAddCompatibleModal(false)} @@ -212,17 +394,56 @@ export default function ProvidersPage() { setShowAddAnthropicCompatibleModal(false); }} /> + + {/* Test Results Modal */} + {testResults && ( +
setTestResults(null)} + > +
+
e.stopPropagation()} + > +
+

Test Results

+ +
+
+ +
+
+
+ )}
); } -function ProviderCard({ providerId, provider, stats }) { - const { connected, error, errorCode, errorTime } = stats; +function ProviderCard({ providerId, provider, stats, authType, onToggle }) { + const { connected, error, errorCode, errorTime, allDisabled } = stats; const [imgError, setImgError] = useState(false); + const dotColors = { + free: "bg-green-500", + oauth: "bg-blue-500", + apikey: "bg-amber-500", + compatible: "bg-orange-500", + }; + const dotLabels = { free: "Free", oauth: "OAuth", apikey: "API Key", compatible: "Compatible" }; + return ( - +
{imgError ? ( - + {provider.textIcon || provider.id.slice(0, 2).toUpperCase()} ) : ( @@ -249,16 +467,45 @@ function ProviderCard({ providerId, provider, stats }) { )}
-

{provider.name}

+

+ {provider.name} +

- {getStatusDisplay(connected, error, errorCode)} - {errorTime && • {errorTime}} + {allDisabled ? ( + + + pause_circle + Disabled + + + ) : ( + <> + {getStatusDisplay(connected, error, errorCode)} + {errorTime && {errorTime}} + + )}
- - chevron_right - +
+ {stats.total > 0 && ( +
{ + e.preventDefault(); + e.stopPropagation(); + onToggle(!allDisabled ? false : true); + }} + > + {}} + title={allDisabled ? "Enable provider" : "Disable provider"} + /> +
+ )} +
@@ -279,29 +526,36 @@ ProviderCard.propTypes = { errorCode: PropTypes.string, errorTime: PropTypes.string, }).isRequired, + authType: PropTypes.string, + onToggle: PropTypes.func, }; -// API Key providers - use image with textIcon fallback (same as OAuth providers) -function ApiKeyProviderCard({ providerId, provider, stats }) { - const { connected, error, errorCode, errorTime } = stats; +function ApiKeyProviderCard({ providerId, provider, stats, authType, onToggle }) { + const { connected, error, errorCode, errorTime, allDisabled } = stats; const isCompatible = providerId.startsWith(OPENAI_COMPATIBLE_PREFIX); const isAnthropicCompatible = providerId.startsWith(ANTHROPIC_COMPATIBLE_PREFIX); const [imgError, setImgError] = useState(false); - // Determine icon path: OpenAI Compatible providers use specialized icons + const dotColors = { + free: "bg-green-500", + oauth: "bg-blue-500", + apikey: "bg-amber-500", + compatible: "bg-orange-500", + }; + const dotLabels = { free: "Free", oauth: "OAuth", apikey: "API Key", compatible: "Compatible" }; + const getIconPath = () => { - if (isCompatible) { - return provider.apiType === "responses" ? "/providers/oai-r.png" : "/providers/oai-cc.png"; - } - if (isAnthropicCompatible) { - return "/providers/anthropic-m.png"; // Use Anthropic icon as base - } + if (isCompatible) return provider.apiType === "responses" ? "/providers/oai-r.png" : "/providers/oai-cc.png"; + if (isAnthropicCompatible) return "/providers/anthropic-m.png"; return `/providers/${provider.id}.png`; }; return ( - +
{imgError ? ( - + {provider.textIcon || provider.id.slice(0, 2).toUpperCase()} ) : ( @@ -328,26 +579,53 @@ function ApiKeyProviderCard({ providerId, provider, stats }) { )}
-

{provider.name}

+

+ {provider.name} +

- {getStatusDisplay(connected, error, errorCode)} - {isCompatible && ( + {allDisabled ? ( - {provider.apiType === "responses" ? "Responses" : "Chat"} + + pause_circle + Disabled + + ) : ( + <> + {getStatusDisplay(connected, error, errorCode)} + {isCompatible && ( + + {provider.apiType === "responses" ? "Responses" : "Chat"} + + )} + {isAnthropicCompatible && ( + Messages + )} + {errorTime && {errorTime}} + )} - {isAnthropicCompatible && ( - - Messages - - )} - {errorTime && • {errorTime}}
- - chevron_right - +
+ {stats.total > 0 && ( +
{ + e.preventDefault(); + e.stopPropagation(); + onToggle(!allDisabled ? false : true); + }} + > + {}} + title={allDisabled ? "Enable provider" : "Disable provider"} + /> +
+ )} +
@@ -369,6 +647,8 @@ ApiKeyProviderCard.propTypes = { errorCode: PropTypes.string, errorTime: PropTypes.string, }).isRequired, + authType: PropTypes.string, + onToggle: PropTypes.func, }; function AddOpenAICompatibleModal({ isOpen, onClose, onCreated }) { @@ -390,10 +670,7 @@ function AddOpenAICompatibleModal({ isOpen, onClose, onCreated }) { useEffect(() => { const defaultBaseUrl = "https://api.openai.com/v1"; - setFormData((prev) => ({ - ...prev, - baseUrl: defaultBaseUrl, - })); + setFormData((prev) => ({ ...prev, baseUrl: defaultBaseUrl })); }, [formData.apiType]); const handleSubmit = async () => { @@ -414,12 +691,7 @@ function AddOpenAICompatibleModal({ isOpen, onClose, onCreated }) { const data = await res.json(); if (res.ok) { onCreated(data.node); - setFormData({ - name: "", - prefix: "", - apiType: "chat", - baseUrl: "https://api.openai.com/v1", - }); + setFormData({ name: "", prefix: "", apiType: "chat", baseUrl: "https://api.openai.com/v1" }); setCheckKey(""); setValidationResult(null); } @@ -500,9 +772,7 @@ function AddOpenAICompatibleModal({ isOpen, onClose, onCreated }) { - +
@@ -527,7 +797,6 @@ function AddAnthropicCompatibleModal({ isOpen, onClose, onCreated }) { const [validationResult, setValidationResult] = useState(null); useEffect(() => { - // Reset validation when modal opens if (isOpen) { setValidationResult(null); setCheckKey(""); @@ -551,11 +820,7 @@ function AddAnthropicCompatibleModal({ isOpen, onClose, onCreated }) { const data = await res.json(); if (res.ok) { onCreated(data.node); - setFormData({ - name: "", - prefix: "", - baseUrl: "https://api.anthropic.com/v1", - }); + setFormData({ name: "", prefix: "", baseUrl: "https://api.anthropic.com/v1" }); setCheckKey(""); setValidationResult(null); } @@ -572,11 +837,7 @@ function AddAnthropicCompatibleModal({ isOpen, onClose, onCreated }) { const res = await fetch("/api/provider-nodes/validate", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - baseUrl: formData.baseUrl, - apiKey: checkKey, - type: "anthropic-compatible" - }), + body: JSON.stringify({ baseUrl: formData.baseUrl, apiKey: checkKey, type: "anthropic-compatible" }), }); const data = await res.json(); setValidationResult(data.valid ? "success" : "failed"); @@ -634,9 +895,7 @@ function AddAnthropicCompatibleModal({ isOpen, onClose, onCreated }) { - +
@@ -648,3 +907,79 @@ AddAnthropicCompatibleModal.propTypes = { onClose: PropTypes.func.isRequired, onCreated: PropTypes.func.isRequired, }; + +function ProviderTestResultsView({ results }) { + if (results.error && !results.results) { + return ( +
+ error +

{results.error}

+
+ ); + } + + const { summary, mode } = results; + const items = results.results || []; + const modeLabel = { oauth: "OAuth", free: "Free", apikey: "API Key", provider: "Provider", all: "All" }[mode] || mode; + + return ( +
+ {summary && ( +
+ {modeLabel} Test + + {summary.passed} passed + + {summary.failed > 0 && ( + + {summary.failed} failed + + )} + {summary.total} tested +
+ )} + {items.map((r, i) => ( +
+ + {r.valid ? "check_circle" : "error"} + +
+ {r.connectionName} + ({r.provider}) +
+ {r.latencyMs !== undefined && ( + {r.latencyMs}ms + )} + + {r.valid ? "OK" : r.diagnosis?.type || "ERROR"} + +
+ ))} + {items.length === 0 && ( +
+ No active connections found for this group. +
+ )} +
+ ); +} + +ProviderTestResultsView.propTypes = { + results: PropTypes.shape({ + mode: PropTypes.string, + results: PropTypes.array, + summary: PropTypes.shape({ + total: PropTypes.number, + passed: PropTypes.number, + failed: PropTypes.number, + }), + error: PropTypes.string, + }).isRequired, +}; diff --git a/src/app/api/oauth/[provider]/[action]/route.js b/src/app/api/oauth/[provider]/[action]/route.js index 7382af7..8d7a548 100644 --- a/src/app/api/oauth/[provider]/[action]/route.js +++ b/src/app/api/oauth/[provider]/[action]/route.js @@ -36,13 +36,13 @@ export async function GET(request, { params }) { const authData = generateAuthData(provider, null); - // For providers that don't use PKCE (like GitHub), don't pass codeChallenge + // Providers that don't use PKCE for device code + const noPkceDeviceProviders = ["github", "kiro", "kimi-coding", "kilocode"]; let deviceData; - if (provider === "github" || provider === "kiro") { - // GitHub and Kiro don't use PKCE for device code + if (noPkceDeviceProviders.includes(provider)) { deviceData = await requestDeviceCode(provider); } else { - // Qwen and other providers use PKCE + // Qwen and other PKCE providers deviceData = await requestDeviceCode(provider, authData.codeChallenge); } @@ -69,7 +69,9 @@ export async function POST(request, { params }) { if (action === "exchange") { const { code, redirectUri, codeVerifier, state } = body; - if (!code || !redirectUri || !codeVerifier) { + // Cline uses authorization_code without PKCE + const noPkceExchangeProviders = ["cline"]; + if (!code || !redirectUri || (!codeVerifier && !noPkceExchangeProviders.includes(provider))) { return NextResponse.json({ error: "Missing required fields" }, { status: 400 }); } @@ -108,15 +110,16 @@ export async function POST(request, { params }) { return NextResponse.json({ error: "Missing device code" }, { status: 400 }); } - // For providers that don't use PKCE (like GitHub, Kiro), don't pass codeVerifier + // Providers that don't use PKCE for device code + const noPkceProviders = ["github", "kimi-coding", "kilocode"]; let result; - if (provider === "github") { + if (noPkceProviders.includes(provider)) { result = await pollForToken(provider, deviceCode); } else if (provider === "kiro") { // Kiro needs extraData (clientId, clientSecret) from device code response result = await pollForToken(provider, deviceCode, null, extraData); } else { - // Qwen and other providers use PKCE + // Qwen and other PKCE providers if (!codeVerifier) { return NextResponse.json({ error: "Missing code verifier" }, { status: 400 }); } diff --git a/src/app/api/providers/[id]/test/route.js b/src/app/api/providers/[id]/test/route.js index 26abcf0..0fa641a 100644 --- a/src/app/api/providers/[id]/test/route.js +++ b/src/app/api/providers/[id]/test/route.js @@ -1,549 +1,16 @@ import { NextResponse } from "next/server"; -import { getProviderConnectionById, updateProviderConnection, isCloudEnabled } from "@/lib/localDb"; -import { getConsistentMachineId } from "@/shared/utils/machineId"; -import { syncToCloud } from "@/app/api/sync/cloud/route"; -import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers"; -import { - GEMINI_CONFIG, - ANTIGRAVITY_CONFIG, - CODEX_CONFIG, - KIRO_CONFIG, -} from "@/lib/oauth/constants/oauth"; - -// OAuth provider test endpoints -const OAUTH_TEST_CONFIG = { - claude: { - // Claude doesn't have userinfo, we verify token exists and not expired - checkExpiry: true, - }, - codex: { - checkExpiry: true, - refreshable: true, - }, - "gemini-cli": { - url: "https://www.googleapis.com/oauth2/v1/userinfo?alt=json", - method: "GET", - authHeader: "Authorization", - authPrefix: "Bearer ", - refreshable: true, - }, - antigravity: { - url: "https://www.googleapis.com/oauth2/v1/userinfo?alt=json", - method: "GET", - authHeader: "Authorization", - authPrefix: "Bearer ", - refreshable: true, - }, - github: { - url: "https://api.github.com/user", - method: "GET", - authHeader: "Authorization", - authPrefix: "Bearer ", - extraHeaders: { "User-Agent": "9Router", "Accept": "application/vnd.github+json" }, - }, - iflow: { - url: "https://iflow.cn/api/oauth/getUserInfo", - method: "GET", - authHeader: "Authorization", - authPrefix: "Bearer ", - }, - qwen: { - url: "https://portal.qwen.ai/v1/models", - method: "GET", - authHeader: "Authorization", - authPrefix: "Bearer ", - }, - kiro: { - checkExpiry: true, - refreshable: true, - }, -}; - -/** - * Refresh OAuth token using refresh_token - * @returns {object} { accessToken, expiresIn, refreshToken } or null if failed - */ -async function refreshOAuthToken(connection) { - const provider = connection.provider; - const refreshToken = connection.refreshToken; - - if (!refreshToken) return null; - - try { - // Google-based providers (gemini-cli, antigravity) - if (provider === "gemini-cli" || provider === "antigravity") { - const config = provider === "gemini-cli" ? GEMINI_CONFIG : ANTIGRAVITY_CONFIG; - const response = await fetch("https://oauth2.googleapis.com/token", { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams({ - client_id: config.clientId, - client_secret: config.clientSecret, - grant_type: "refresh_token", - refresh_token: refreshToken, - }), - }); - - if (!response.ok) return null; - - const data = await response.json(); - return { - accessToken: data.access_token, - expiresIn: data.expires_in, - refreshToken: data.refresh_token || refreshToken, - }; - } - - // OpenAI/Codex - if (provider === "codex") { - const response = await fetch(CODEX_CONFIG.tokenUrl, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams({ - grant_type: "refresh_token", - client_id: CODEX_CONFIG.clientId, - refresh_token: refreshToken, - }), - }); - - if (!response.ok) return null; - - const data = await response.json(); - return { - accessToken: data.access_token, - expiresIn: data.expires_in, - refreshToken: data.refresh_token || refreshToken, - }; - } - - // Kiro (AWS SSO or Social auth) - if (provider === "kiro") { - const { clientId, clientSecret, region } = connection; - - // AWS SSO OIDC refresh (Builder ID or IDC) - if (clientId && clientSecret) { - const endpoint = `https://oidc.${region || "us-east-1"}.amazonaws.com/token`; - const response = await fetch(endpoint, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - clientId, - clientSecret, - refreshToken, - grantType: "refresh_token", - }), - }); - - if (!response.ok) { - const errText = await response.text(); - console.log(`Kiro AWS SSO refresh failed: ${response.status} - ${errText}`); - return null; - } - - const data = await response.json(); - return { - accessToken: data.accessToken, - expiresIn: data.expiresIn || 3600, - refreshToken: data.refreshToken || refreshToken, - }; - } - - // Social auth refresh (Google/GitHub) - const response = await fetch(KIRO_CONFIG.socialRefreshUrl, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ refreshToken }), - }); - - if (!response.ok) { - const errText = await response.text(); - console.log(`Kiro social refresh failed: ${response.status} - ${errText}`); - return null; - } - - const data = await response.json(); - return { - accessToken: data.accessToken, - expiresIn: data.expiresIn || 3600, - refreshToken: data.refreshToken || refreshToken, - }; - } - - return null; - } catch (err) { - console.log(`Error refreshing ${provider} token:`, err.message); - return null; - } -} - -/** - * Check if token is expired or about to expire (within 5 minutes) - */ -function isTokenExpired(connection) { - if (!connection.expiresAt) return false; - const expiresAt = new Date(connection.expiresAt).getTime(); - const buffer = 5 * 60 * 1000; // 5 minutes - return expiresAt <= Date.now() + buffer; -} - -/** - * Sync to cloud if enabled - */ -async function syncToCloudIfEnabled() { - try { - const cloudEnabled = await isCloudEnabled(); - if (!cloudEnabled) return; - - const machineId = await getConsistentMachineId(); - await syncToCloud(machineId); - } catch (error) { - console.log("Error syncing to cloud after token refresh:", error); - } -} - -/** - * Test OAuth connection by calling provider API - * Auto-refreshes token if expired - * @returns {{ valid: boolean, error: string|null, refreshed: boolean, newTokens: object|null }} - */ -async function testOAuthConnection(connection) { - const config = OAUTH_TEST_CONFIG[connection.provider]; - - if (!config) { - return { valid: false, error: "Provider test not supported", refreshed: false }; - } - - // Check if token exists - if (!connection.accessToken) { - return { valid: false, error: "No access token", refreshed: false }; - } - - let accessToken = connection.accessToken; - let refreshed = false; - let newTokens = null; - - // Auto-refresh if token is expired and provider supports refresh - const tokenExpired = isTokenExpired(connection); - if (config.refreshable && tokenExpired && connection.refreshToken) { - const tokens = await refreshOAuthToken(connection); - if (tokens) { - accessToken = tokens.accessToken; - refreshed = true; - newTokens = tokens; - } else { - // Refresh failed - return { valid: false, error: "Token expired and refresh failed", refreshed: false }; - } - } - - // For providers that only check expiry (no test endpoint available) - if (config.checkExpiry) { - // If we already refreshed successfully, token is valid - if (refreshed) { - return { valid: true, error: null, refreshed, newTokens }; - } - // Check if token is expired (no refresh available) - if (tokenExpired) { - return { valid: false, error: "Token expired", refreshed: false }; - } - return { valid: true, error: null, refreshed: false, newTokens: null }; - } - - // Call test endpoint - try { - const headers = { - [config.authHeader]: `${config.authPrefix}${accessToken}`, - ...config.extraHeaders, - }; - - const res = await fetch(config.url, { - method: config.method, - headers, - }); - - if (res.ok) { - return { valid: true, error: null, refreshed, newTokens }; - } - - // If 401 and we haven't tried refresh yet, try refresh now - if (res.status === 401 && config.refreshable && !refreshed && connection.refreshToken) { - const tokens = await refreshOAuthToken(connection); - if (tokens) { - // Retry with new token - const retryRes = await fetch(config.url, { - method: config.method, - headers: { - [config.authHeader]: `${config.authPrefix}${tokens.accessToken}`, - ...config.extraHeaders, - }, - }); - - if (retryRes.ok) { - return { valid: true, error: null, refreshed: true, newTokens: tokens }; - } - } - return { valid: false, error: "Token invalid or revoked", refreshed: false }; - } - - if (res.status === 401) { - return { valid: false, error: "Token invalid or revoked", refreshed }; - } - if (res.status === 403) { - return { valid: false, error: "Access denied", refreshed }; - } - - return { valid: false, error: `API returned ${res.status}`, refreshed }; - } catch (err) { - return { valid: false, error: err.message, refreshed }; - } -} - -/** - * Test API key connection - */ -async function testApiKeyConnection(connection) { - // OpenAI Compatible providers - test via /models endpoint - if (isOpenAICompatibleProvider(connection.provider)) { - const modelsBase = connection.providerSpecificData?.baseUrl; - if (!modelsBase) { - return { valid: false, error: "Missing base URL" }; - } - try { - const modelsUrl = `${modelsBase.replace(/\/$/, "")}/models`; - const res = await fetch(modelsUrl, { - headers: { "Authorization": `Bearer ${connection.apiKey}` }, - }); - return { valid: res.ok, error: res.ok ? null : "Invalid API key or base URL" }; - } catch (err) { - return { valid: false, error: err.message }; - } - } - - // Anthropic Compatible providers - test via /models endpoint - if (isAnthropicCompatibleProvider(connection.provider)) { - let modelsBase = connection.providerSpecificData?.baseUrl; - if (!modelsBase) { - return { valid: false, error: "Missing base URL" }; - } - try { - modelsBase = modelsBase.replace(/\/$/, ""); - if (modelsBase.endsWith("/messages")) { - modelsBase = modelsBase.slice(0, -9); - } - - const modelsUrl = `${modelsBase}/models`; - const res = await fetch(modelsUrl, { - headers: { - "x-api-key": connection.apiKey, - "anthropic-version": "2023-06-01", - "Authorization": `Bearer ${connection.apiKey}` - }, - }); - return { valid: res.ok, error: res.ok ? null : "Invalid API key or base URL" }; - } catch (err) { - return { valid: false, error: err.message }; - } - } - - try { - switch (connection.provider) { - case "openai": { - const res = await fetch("https://api.openai.com/v1/models", { - headers: { Authorization: `Bearer ${connection.apiKey}` }, - }); - return { valid: res.ok, error: res.ok ? null : "Invalid API key" }; - } - - case "anthropic": { - const res = await fetch("https://api.anthropic.com/v1/messages", { - method: "POST", - headers: { - "x-api-key": connection.apiKey, - "anthropic-version": "2023-06-01", - "content-type": "application/json", - }, - body: JSON.stringify({ - model: "claude-3-haiku-20240307", - max_tokens: 1, - messages: [{ role: "user", content: "test" }], - }), - }); - const valid = res.status !== 401; - return { valid, error: valid ? null : "Invalid API key" }; - } - - case "gemini": { - const res = await fetch(`https://generativelanguage.googleapis.com/v1/models?key=${connection.apiKey}`); - return { valid: res.ok, error: res.ok ? null : "Invalid API key" }; - } - - case "openrouter": { - const res = await fetch("https://openrouter.ai/api/v1/auth/key", { - headers: { Authorization: `Bearer ${connection.apiKey}` }, - }); - return { valid: res.ok, error: res.ok ? null : "Invalid API key" }; - } - - case "glm": { - // GLM uses Claude-compatible API at api.z.ai - const res = await fetch("https://api.z.ai/api/anthropic/v1/messages", { - method: "POST", - headers: { - "x-api-key": connection.apiKey, - "anthropic-version": "2023-06-01", - "content-type": "application/json", - }, - body: JSON.stringify({ - model: "glm-4.7", - max_tokens: 1, - messages: [{ role: "user", content: "test" }], - }), - }); - const valid = res.status !== 401 && res.status !== 403; - return { valid, error: valid ? null : "Invalid API key" }; - } - - case "glm-cn": { - // GLM Coding (China) uses OpenAI-compatible API - const res = await fetch("https://open.bigmodel.cn/api/coding/paas/v4/chat/completions", { - method: "POST", - headers: { - "Authorization": `Bearer ${connection.apiKey}`, - "content-type": "application/json", - }, - body: JSON.stringify({ - model: "glm-4.7", - max_tokens: 1, - messages: [{ role: "user", content: "test" }], - }), - }); - const valid = res.status !== 401 && res.status !== 403; - return { valid, error: valid ? null : "Invalid API key" }; - } - - case "minimax": - case "minimax-cn": { - // MiniMax uses Claude-compatible API - const minimaxEndpoints = { - minimax: "https://api.minimax.io/anthropic/v1/messages", - "minimax-cn": "https://api.minimaxi.com/anthropic/v1/messages", - }; - const res = await fetch(minimaxEndpoints[connection.provider], { - method: "POST", - headers: { - "x-api-key": connection.apiKey, - "anthropic-version": "2023-06-01", - "content-type": "application/json", - }, - body: JSON.stringify({ - model: "minimax-m2", - max_tokens: 1, - messages: [{ role: "user", content: "test" }], - }), - }); - const valid = res.status !== 401 && res.status !== 403; - return { valid, error: valid ? null : "Invalid API key" }; - } - - case "kimi": { - // Kimi uses Claude-compatible API - const res = await fetch("https://api.kimi.com/coding/v1/messages", { - method: "POST", - headers: { - "x-api-key": connection.apiKey, - "anthropic-version": "2023-06-01", - "content-type": "application/json", - }, - body: JSON.stringify({ - model: "kimi-latest", - max_tokens: 1, - messages: [{ role: "user", content: "test" }], - }), - }); - const valid = res.status !== 401 && res.status !== 403; - return { valid, error: valid ? null : "Invalid API key" }; - } - - case "deepseek": { - const res = await fetch("https://api.deepseek.com/models", { - headers: { Authorization: `Bearer ${connection.apiKey}` }, - }); - return { valid: res.ok, error: res.ok ? null : "Invalid API key" }; - } - - case "groq": { - const res = await fetch("https://api.groq.com/openai/v1/models", { - headers: { Authorization: `Bearer ${connection.apiKey}` }, - }); - return { valid: res.ok, error: res.ok ? null : "Invalid API key" }; - } - - case "mistral": { - const res = await fetch("https://api.mistral.ai/v1/models", { - headers: { Authorization: `Bearer ${connection.apiKey}` }, - }); - return { valid: res.ok, error: res.ok ? null : "Invalid API key" }; - } - - case "xai": { - const res = await fetch("https://api.x.ai/v1/models", { - headers: { Authorization: `Bearer ${connection.apiKey}` }, - }); - return { valid: res.ok, error: res.ok ? null : "Invalid API key" }; - } - - default: - return { valid: false, error: "Provider test not supported" }; - } - } catch (err) { - return { valid: false, error: err.message }; - } -} +import { testSingleConnection } from "./testUtils.js"; // POST /api/providers/[id]/test - Test connection export async function POST(request, { params }) { try { const { id } = await params; - const connection = await getProviderConnectionById(id); + const result = await testSingleConnection(id); - if (!connection) { + if (result.error === "Connection not found") { return NextResponse.json({ error: "Connection not found" }, { status: 404 }); } - let result; - - if (connection.authType === "apikey") { - result = await testApiKeyConnection(connection); - } else { - result = await testOAuthConnection(connection); - } - - // Build update data - const updateData = { - testStatus: result.valid ? "active" : "error", - lastError: result.valid ? null : result.error, - lastErrorAt: result.valid ? null : new Date().toISOString(), - }; - - // If token was refreshed, update tokens in DB - if (result.refreshed && result.newTokens) { - updateData.accessToken = result.newTokens.accessToken; - if (result.newTokens.refreshToken) { - updateData.refreshToken = result.newTokens.refreshToken; - } - if (result.newTokens.expiresIn) { - updateData.expiresAt = new Date(Date.now() + result.newTokens.expiresIn * 1000).toISOString(); - } - } - - // Update status in db - await updateProviderConnection(id, updateData); - - // Sync to cloud if token was refreshed - if (result.refreshed) { - await syncToCloudIfEnabled(); - } - return NextResponse.json({ valid: result.valid, error: result.error, diff --git a/src/app/api/providers/[id]/test/testUtils.js b/src/app/api/providers/[id]/test/testUtils.js new file mode 100644 index 0000000..7f16593 --- /dev/null +++ b/src/app/api/providers/[id]/test/testUtils.js @@ -0,0 +1,341 @@ +import { getProviderConnectionById, updateProviderConnection, isCloudEnabled } from "@/lib/localDb"; +import { getConsistentMachineId } from "@/shared/utils/machineId"; +import { syncToCloud } from "@/app/api/sync/cloud/route"; +import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers"; +import { + GEMINI_CONFIG, + ANTIGRAVITY_CONFIG, + CODEX_CONFIG, + KIRO_CONFIG, +} from "@/lib/oauth/constants/oauth"; + +// OAuth provider test endpoints +const OAUTH_TEST_CONFIG = { + claude: { checkExpiry: true }, + codex: { checkExpiry: true, refreshable: true }, + "gemini-cli": { + url: "https://www.googleapis.com/oauth2/v1/userinfo?alt=json", + method: "GET", + authHeader: "Authorization", + authPrefix: "Bearer ", + refreshable: true, + }, + antigravity: { + url: "https://www.googleapis.com/oauth2/v1/userinfo?alt=json", + method: "GET", + authHeader: "Authorization", + authPrefix: "Bearer ", + refreshable: true, + }, + github: { + url: "https://api.github.com/user", + method: "GET", + authHeader: "Authorization", + authPrefix: "Bearer ", + extraHeaders: { "User-Agent": "9Router", "Accept": "application/vnd.github+json" }, + }, + iflow: { + url: "https://iflow.cn/api/oauth/getUserInfo", + method: "GET", + authHeader: "Authorization", + authPrefix: "Bearer ", + }, + qwen: { + url: "https://portal.qwen.ai/v1/models", + method: "GET", + authHeader: "Authorization", + authPrefix: "Bearer ", + }, + kiro: { checkExpiry: true, refreshable: true }, +}; + +async function refreshOAuthToken(connection) { + const provider = connection.provider; + const refreshToken = connection.refreshToken; + if (!refreshToken) return null; + + try { + if (provider === "gemini-cli" || provider === "antigravity") { + const config = provider === "gemini-cli" ? GEMINI_CONFIG : ANTIGRAVITY_CONFIG; + const response = await fetch("https://oauth2.googleapis.com/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: config.clientId, + client_secret: config.clientSecret, + grant_type: "refresh_token", + refresh_token: refreshToken, + }), + }); + if (!response.ok) return null; + const data = await response.json(); + return { accessToken: data.access_token, expiresIn: data.expires_in, refreshToken: data.refresh_token || refreshToken }; + } + + if (provider === "codex") { + const response = await fetch(CODEX_CONFIG.tokenUrl, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "refresh_token", + client_id: CODEX_CONFIG.clientId, + refresh_token: refreshToken, + }), + }); + if (!response.ok) return null; + const data = await response.json(); + return { accessToken: data.access_token, expiresIn: data.expires_in, refreshToken: data.refresh_token || refreshToken }; + } + + if (provider === "kiro") { + const { clientId, clientSecret, region } = connection; + if (clientId && clientSecret) { + const endpoint = `https://oidc.${region || "us-east-1"}.amazonaws.com/token`; + const response = await fetch(endpoint, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ clientId, clientSecret, refreshToken, grantType: "refresh_token" }), + }); + if (!response.ok) return null; + const data = await response.json(); + return { accessToken: data.accessToken, expiresIn: data.expiresIn || 3600, refreshToken: data.refreshToken || refreshToken }; + } + const response = await fetch(KIRO_CONFIG.socialRefreshUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ refreshToken }), + }); + if (!response.ok) return null; + const data = await response.json(); + return { accessToken: data.accessToken, expiresIn: data.expiresIn || 3600, refreshToken: data.refreshToken || refreshToken }; + } + + return null; + } catch (err) { + console.log(`Error refreshing ${provider} token:`, err.message); + return null; + } +} + +function isTokenExpired(connection) { + if (!connection.expiresAt) return false; + const expiresAt = new Date(connection.expiresAt).getTime(); + const buffer = 5 * 60 * 1000; + return expiresAt <= Date.now() + buffer; +} + +async function testOAuthConnection(connection) { + const config = OAUTH_TEST_CONFIG[connection.provider]; + if (!config) return { valid: false, error: "Provider test not supported", refreshed: false }; + if (!connection.accessToken) return { valid: false, error: "No access token", refreshed: false }; + + let accessToken = connection.accessToken; + let refreshed = false; + let newTokens = null; + + const tokenExpired = isTokenExpired(connection); + if (config.refreshable && tokenExpired && connection.refreshToken) { + const tokens = await refreshOAuthToken(connection); + if (tokens) { + accessToken = tokens.accessToken; + refreshed = true; + newTokens = tokens; + } else { + return { valid: false, error: "Token expired and refresh failed", refreshed: false }; + } + } + + if (config.checkExpiry) { + if (refreshed) return { valid: true, error: null, refreshed, newTokens }; + if (tokenExpired) return { valid: false, error: "Token expired", refreshed: false }; + return { valid: true, error: null, refreshed: false, newTokens: null }; + } + + try { + const headers = { [config.authHeader]: `${config.authPrefix}${accessToken}`, ...config.extraHeaders }; + const res = await fetch(config.url, { method: config.method, headers }); + + if (res.ok) return { valid: true, error: null, refreshed, newTokens }; + + if (res.status === 401 && config.refreshable && !refreshed && connection.refreshToken) { + const tokens = await refreshOAuthToken(connection); + if (tokens) { + const retryRes = await fetch(config.url, { + method: config.method, + headers: { [config.authHeader]: `${config.authPrefix}${tokens.accessToken}`, ...config.extraHeaders }, + }); + if (retryRes.ok) return { valid: true, error: null, refreshed: true, newTokens: tokens }; + } + return { valid: false, error: "Token invalid or revoked", refreshed: false }; + } + + if (res.status === 401) return { valid: false, error: "Token invalid or revoked", refreshed }; + if (res.status === 403) return { valid: false, error: "Access denied", refreshed }; + return { valid: false, error: `API returned ${res.status}`, refreshed }; + } catch (err) { + return { valid: false, error: err.message, refreshed }; + } +} + +async function testApiKeyConnection(connection) { + if (isOpenAICompatibleProvider(connection.provider)) { + const modelsBase = connection.providerSpecificData?.baseUrl; + if (!modelsBase) return { valid: false, error: "Missing base URL" }; + try { + const res = await fetch(`${modelsBase.replace(/\/$/, "")}/models`, { + headers: { "Authorization": `Bearer ${connection.apiKey}` }, + }); + return { valid: res.ok, error: res.ok ? null : "Invalid API key or base URL" }; + } catch (err) { + return { valid: false, error: err.message }; + } + } + + if (isAnthropicCompatibleProvider(connection.provider)) { + let modelsBase = connection.providerSpecificData?.baseUrl; + if (!modelsBase) return { valid: false, error: "Missing base URL" }; + try { + modelsBase = modelsBase.replace(/\/$/, ""); + if (modelsBase.endsWith("/messages")) modelsBase = modelsBase.slice(0, -9); + const res = await fetch(`${modelsBase}/models`, { + headers: { "x-api-key": connection.apiKey, "anthropic-version": "2023-06-01", "Authorization": `Bearer ${connection.apiKey}` }, + }); + return { valid: res.ok, error: res.ok ? null : "Invalid API key or base URL" }; + } catch (err) { + return { valid: false, error: err.message }; + } + } + + try { + switch (connection.provider) { + case "openai": { + const res = await fetch("https://api.openai.com/v1/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } }); + return { valid: res.ok, error: res.ok ? null : "Invalid API key" }; + } + case "anthropic": { + const res = await fetch("https://api.anthropic.com/v1/messages", { + method: "POST", + headers: { "x-api-key": connection.apiKey, "anthropic-version": "2023-06-01", "content-type": "application/json" }, + body: JSON.stringify({ model: "claude-3-haiku-20240307", max_tokens: 1, messages: [{ role: "user", content: "test" }] }), + }); + const valid = res.status !== 401; + return { valid, error: valid ? null : "Invalid API key" }; + } + case "gemini": { + const res = await fetch(`https://generativelanguage.googleapis.com/v1/models?key=${connection.apiKey}`); + return { valid: res.ok, error: res.ok ? null : "Invalid API key" }; + } + case "openrouter": { + const res = await fetch("https://openrouter.ai/api/v1/auth/key", { headers: { Authorization: `Bearer ${connection.apiKey}` } }); + return { valid: res.ok, error: res.ok ? null : "Invalid API key" }; + } + case "glm": { + const res = await fetch("https://api.z.ai/api/anthropic/v1/messages", { + method: "POST", + headers: { "x-api-key": connection.apiKey, "anthropic-version": "2023-06-01", "content-type": "application/json" }, + body: JSON.stringify({ model: "glm-4.7", max_tokens: 1, messages: [{ role: "user", content: "test" }] }), + }); + const valid = res.status !== 401 && res.status !== 403; + return { valid, error: valid ? null : "Invalid API key" }; + } + case "glm-cn": { + const res = await fetch("https://open.bigmodel.cn/api/coding/paas/v4/chat/completions", { + method: "POST", + headers: { "Authorization": `Bearer ${connection.apiKey}`, "content-type": "application/json" }, + body: JSON.stringify({ model: "glm-4.7", max_tokens: 1, messages: [{ role: "user", content: "test" }] }), + }); + const valid = res.status !== 401 && res.status !== 403; + return { valid, error: valid ? null : "Invalid API key" }; + } + case "minimax": + case "minimax-cn": { + const endpoints = { minimax: "https://api.minimax.io/anthropic/v1/messages", "minimax-cn": "https://api.minimaxi.com/anthropic/v1/messages" }; + const res = await fetch(endpoints[connection.provider], { + method: "POST", + headers: { "x-api-key": connection.apiKey, "anthropic-version": "2023-06-01", "content-type": "application/json" }, + body: JSON.stringify({ model: "minimax-m2", max_tokens: 1, messages: [{ role: "user", content: "test" }] }), + }); + const valid = res.status !== 401 && res.status !== 403; + return { valid, error: valid ? null : "Invalid API key" }; + } + case "kimi": { + const res = await fetch("https://api.kimi.com/coding/v1/messages", { + method: "POST", + headers: { "x-api-key": connection.apiKey, "anthropic-version": "2023-06-01", "content-type": "application/json" }, + body: JSON.stringify({ model: "kimi-latest", max_tokens: 1, messages: [{ role: "user", content: "test" }] }), + }); + const valid = res.status !== 401 && res.status !== 403; + return { valid, error: valid ? null : "Invalid API key" }; + } + case "deepseek": { + const res = await fetch("https://api.deepseek.com/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } }); + return { valid: res.ok, error: res.ok ? null : "Invalid API key" }; + } + case "groq": { + const res = await fetch("https://api.groq.com/openai/v1/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } }); + return { valid: res.ok, error: res.ok ? null : "Invalid API key" }; + } + case "mistral": { + const res = await fetch("https://api.mistral.ai/v1/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } }); + return { valid: res.ok, error: res.ok ? null : "Invalid API key" }; + } + case "xai": { + const res = await fetch("https://api.x.ai/v1/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } }); + return { valid: res.ok, error: res.ok ? null : "Invalid API key" }; + } + default: + return { valid: false, error: "Provider test not supported" }; + } + } catch (err) { + return { valid: false, error: err.message }; + } +} + +/** + * Test a single connection by ID, update DB, and return result. + */ +export async function testSingleConnection(id) { + const connection = await getProviderConnectionById(id); + if (!connection) return { valid: false, error: "Connection not found", latencyMs: 0, testedAt: new Date().toISOString() }; + + const start = Date.now(); + let result; + + if (connection.authType === "apikey") { + result = await testApiKeyConnection(connection); + } else { + result = await testOAuthConnection(connection); + } + + const latencyMs = Date.now() - start; + + const updateData = { + testStatus: result.valid ? "active" : "error", + lastError: result.valid ? null : result.error, + lastErrorAt: result.valid ? null : new Date().toISOString(), + }; + + if (result.refreshed && result.newTokens) { + updateData.accessToken = result.newTokens.accessToken; + if (result.newTokens.refreshToken) updateData.refreshToken = result.newTokens.refreshToken; + if (result.newTokens.expiresIn) { + updateData.expiresAt = new Date(Date.now() + result.newTokens.expiresIn * 1000).toISOString(); + } + } + + await updateProviderConnection(id, updateData); + + if (result.refreshed) { + try { + const cloudEnabled = await isCloudEnabled(); + if (cloudEnabled) { + const machineId = await getConsistentMachineId(); + await syncToCloud(machineId); + } + } catch (err) { + console.log("Error syncing to cloud after token refresh:", err); + } + } + + return { valid: result.valid, error: result.error, latencyMs, testedAt: new Date().toISOString() }; +} diff --git a/src/app/api/providers/test-batch/route.js b/src/app/api/providers/test-batch/route.js new file mode 100644 index 0000000..da020cc --- /dev/null +++ b/src/app/api/providers/test-batch/route.js @@ -0,0 +1,131 @@ +import { NextResponse } from "next/server"; +import { getProviderConnections } from "@/models"; +import { + FREE_PROVIDERS, + OAUTH_PROVIDERS, + APIKEY_PROVIDERS, + OPENAI_COMPATIBLE_PREFIX, + ANTHROPIC_COMPATIBLE_PREFIX, +} from "@/shared/constants/providers"; +import { testSingleConnection } from "../[id]/test/testUtils.js"; + +function getAuthGroup(providerId, connection = null) { + // Prioritize authType from connection if available + if (connection?.authType) { + if (connection.authType === "oauth") { + // Check if it's a free provider + if (FREE_PROVIDERS[providerId]) return "free"; + return "oauth"; + } + return connection.authType; + } + + // Fallback to constants + if (FREE_PROVIDERS[providerId]) return "free"; + if (OAUTH_PROVIDERS[providerId]) return "oauth"; + if (APIKEY_PROVIDERS[providerId]) return "apikey"; + if ( + typeof providerId === "string" && + (providerId.startsWith(OPENAI_COMPATIBLE_PREFIX) || providerId.startsWith(ANTHROPIC_COMPATIBLE_PREFIX)) + ) + return "compatible"; + return "apikey"; +} + +function isCompatibleProvider(providerId) { + return ( + typeof providerId === "string" && + (providerId.startsWith(OPENAI_COMPATIBLE_PREFIX) || providerId.startsWith(ANTHROPIC_COMPATIBLE_PREFIX)) + ); +} + +// POST /api/providers/test-batch - Test multiple connections by group +export async function POST(request) { + try { + const body = await request.json(); + const { mode, providerId } = body; + + if (!mode) { + return NextResponse.json({ error: "mode is required" }, { status: 400 }); + } + + const allConnections = await getProviderConnections({ isActive: true }); + + let connectionsToTest = []; + if (mode === "provider" && providerId) { + connectionsToTest = allConnections.filter((c) => c.provider === providerId); + } else if (mode === "oauth") { + connectionsToTest = allConnections.filter((c) => getAuthGroup(c.provider, c) === "oauth"); + } else if (mode === "free") { + connectionsToTest = allConnections.filter((c) => getAuthGroup(c.provider, c) === "free"); + } else if (mode === "apikey") { + connectionsToTest = allConnections.filter((c) => getAuthGroup(c.provider, c) === "apikey"); + } else if (mode === "compatible") { + connectionsToTest = allConnections.filter((c) => isCompatibleProvider(c.provider)); + } else if (mode === "all") { + connectionsToTest = allConnections; + } else { + return NextResponse.json( + { error: "Invalid mode. Use: provider, oauth, free, apikey, compatible, all" }, + { status: 400 } + ); + } + + if (connectionsToTest.length === 0) { + return NextResponse.json({ + mode, + providerId: providerId || null, + results: [], + summary: { total: 0, passed: 0, failed: 0 }, + testedAt: new Date().toISOString(), + }); + } + + const results = []; + for (const conn of connectionsToTest) { + try { + const data = await testSingleConnection(conn.id); + results.push({ + provider: conn.provider, + connectionId: conn.id, + connectionName: conn.name || conn.email || conn.provider, + authType: conn.authType || getAuthGroup(conn.provider, conn), + valid: data.valid, + latencyMs: data.latencyMs || 0, + error: data.error || null, + diagnosis: data.diagnosis || null, + statusCode: data.statusCode || null, + testedAt: data.testedAt || new Date().toISOString(), + }); + } catch (error) { + results.push({ + provider: conn.provider, + connectionId: conn.id, + connectionName: conn.name || conn.email || conn.provider, + authType: conn.authType || getAuthGroup(conn.provider, conn), + valid: false, + latencyMs: 0, + error: error.message, + diagnosis: { type: "network_error", source: "local", code: null, message: error.message }, + statusCode: null, + testedAt: new Date().toISOString(), + }); + } + } + + return NextResponse.json({ + mode, + providerId: providerId || null, + results, + testedAt: new Date().toISOString(), + summary: { + total: results.length, + passed: results.filter((r) => r.valid).length, + failed: results.filter((r) => !r.valid).length, + }, + }); + } catch (error) { + console.log("Error in batch test:", error); + return NextResponse.json({ error: "Batch test failed" }, { status: 500 }); + } +} diff --git a/src/lib/oauth/constants/oauth.js b/src/lib/oauth/constants/oauth.js index fd1329c..3e52c5d 100644 --- a/src/lib/oauth/constants/oauth.js +++ b/src/lib/oauth/constants/oauth.js @@ -192,6 +192,29 @@ export const CURSOR_CONFIG = { }, }; +// Kimi Coding OAuth Configuration (Device Code Flow) +export const KIMI_CODING_CONFIG = { + clientId: process.env.KIMI_CODING_OAUTH_CLIENT_ID || "17e5f671-d194-4dfb-9706-5516cb48c098", + deviceCodeUrl: "https://auth.kimi.com/api/oauth/device_authorization", + tokenUrl: "https://auth.kimi.com/api/oauth/token", +}; + +// KiloCode OAuth Configuration (Custom Device Auth Flow) +export const KILOCODE_CONFIG = { + apiBaseUrl: "https://api.kilo.ai", + initiateUrl: "https://api.kilo.ai/api/device-auth/codes", + pollUrlBase: "https://api.kilo.ai/api/device-auth/codes", +}; + +// Cline OAuth Configuration (Local Callback Flow via app.cline.bot) +export const CLINE_CONFIG = { + appBaseUrl: "https://app.cline.bot", + apiBaseUrl: "https://api.cline.bot", + authorizeUrl: "https://api.cline.bot/api/v1/auth/authorize", + tokenExchangeUrl: "https://api.cline.bot/api/v1/auth/token", + refreshUrl: "https://api.cline.bot/api/v1/auth/refresh", +}; + // OAuth timeout (5 minutes) export const OAUTH_TIMEOUT = 300000; @@ -207,4 +230,7 @@ export const PROVIDERS = { GITHUB: "github", KIRO: "kiro", CURSOR: "cursor", + KIMI_CODING: "kimi-coding", + KILOCODE: "kilocode", + CLINE: "cline", }; diff --git a/src/lib/oauth/providers.js b/src/lib/oauth/providers.js index 8124553..99f8b7c 100644 --- a/src/lib/oauth/providers.js +++ b/src/lib/oauth/providers.js @@ -14,6 +14,9 @@ import { GITHUB_CONFIG, KIRO_CONFIG, CURSOR_CONFIG, + KIMI_CODING_CONFIG, + KILOCODE_CONFIG, + CLINE_CONFIG, getOAuthClientMetadata, } from "./constants/oauth"; @@ -675,6 +678,161 @@ const PROVIDERS = { }, }), }, + + "kimi-coding": { + config: KIMI_CODING_CONFIG, + flowType: "device_code", + requestDeviceCode: async (config) => { + const response = await fetch(config.deviceCodeUrl, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" }, + body: new URLSearchParams({ client_id: config.clientId }), + }); + if (!response.ok) { + const error = await response.text(); + throw new Error(`Device code request failed: ${error}`); + } + const data = await response.json(); + return { + device_code: data.device_code, + user_code: data.user_code, + verification_uri: data.verification_uri || "https://www.kimi.com/code/authorize_device", + verification_uri_complete: + data.verification_uri_complete || + `https://www.kimi.com/code/authorize_device?user_code=${data.user_code}`, + expires_in: data.expires_in, + interval: data.interval || 5, + }; + }, + pollToken: async (config, deviceCode) => { + const response = await fetch(config.tokenUrl, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" }, + body: new URLSearchParams({ + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + client_id: config.clientId, + device_code: deviceCode, + }), + }); + let data; + try { + data = await response.json(); + } catch (e) { + const text = await response.text(); + data = { error: "invalid_response", error_description: text }; + } + return { ok: response.ok, data }; + }, + mapTokens: (tokens) => ({ + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresIn: tokens.expires_in, + }), + }, + + kilocode: { + config: KILOCODE_CONFIG, + flowType: "device_code", + requestDeviceCode: async (config) => { + const response = await fetch(config.initiateUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + }); + if (!response.ok) { + if (response.status === 429) { + throw new Error("Too many pending authorization requests. Please try again later."); + } + const error = await response.text(); + throw new Error(`Device auth initiation failed: ${error}`); + } + const data = await response.json(); + return { + device_code: data.code, + user_code: data.code, + verification_uri: data.verificationUrl, + verification_uri_complete: data.verificationUrl, + expires_in: data.expiresIn || 300, + interval: 3, + }; + }, + pollToken: async (config, deviceCode) => { + const response = await fetch(`${config.pollUrlBase}/${deviceCode}`); + if (response.status === 202) return { ok: false, data: { error: "authorization_pending" } }; + if (response.status === 403) return { ok: false, data: { error: "access_denied", error_description: "Authorization denied by user" } }; + if (response.status === 410) return { ok: false, data: { error: "expired_token", error_description: "Authorization code expired" } }; + if (!response.ok) return { ok: false, data: { error: "poll_failed", error_description: `Poll failed: ${response.status}` } }; + const data = await response.json(); + if (data.status === "approved" && data.token) { + return { ok: true, data: { access_token: data.token, _userEmail: data.userEmail } }; + } + return { ok: false, data: { error: "authorization_pending" } }; + }, + mapTokens: (tokens) => ({ + accessToken: tokens.access_token, + refreshToken: null, + expiresIn: null, + email: tokens._userEmail, + }), + }, + + cline: { + config: CLINE_CONFIG, + flowType: "authorization_code", + buildAuthUrl: (config, redirectUri) => { + const params = new URLSearchParams({ + client_type: "extension", + callback_url: redirectUri, + redirect_uri: redirectUri, + }); + return `${config.authorizeUrl}?${params.toString()}`; + }, + exchangeToken: async (config, code, redirectUri) => { + try { + // Cline encodes token data as base64 in the code param + let base64 = code; + const padding = 4 - (base64.length % 4); + if (padding !== 4) base64 += "=".repeat(padding); + const decoded = Buffer.from(base64, "base64").toString("utf-8"); + const lastBrace = decoded.lastIndexOf("}"); + if (lastBrace === -1) throw new Error("No JSON found in decoded code"); + const tokenData = JSON.parse(decoded.substring(0, lastBrace + 1)); + return { + access_token: tokenData.accessToken, + refresh_token: tokenData.refreshToken, + email: tokenData.email, + firstName: tokenData.firstName, + lastName: tokenData.lastName, + expires_at: tokenData.expiresAt, + }; + } catch (e) { + const response = await fetch(config.tokenExchangeUrl, { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "application/json" }, + body: JSON.stringify({ grant_type: "authorization_code", code, client_type: "extension", redirect_uri: redirectUri }), + }); + if (!response.ok) { + const error = await response.text(); + throw new Error(`Cline token exchange failed: ${error}`); + } + const data = await response.json(); + return { + access_token: data.data?.accessToken || data.accessToken, + refresh_token: data.data?.refreshToken || data.refreshToken, + email: data.data?.userInfo?.email || "", + expires_at: data.data?.expiresAt || data.expiresAt, + }; + } + }, + mapTokens: (tokens) => ({ + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresIn: tokens.expires_at + ? Math.floor((new Date(tokens.expires_at).getTime() - Date.now()) / 1000) + : 3600, + email: tokens.email, + providerSpecificData: { firstName: tokens.firstName, lastName: tokens.lastName }, + }), + }, }; /** diff --git a/src/shared/components/OAuthModal.js b/src/shared/components/OAuthModal.js index b847f2b..43d4912 100644 --- a/src/shared/components/OAuthModal.js +++ b/src/shared/components/OAuthModal.js @@ -114,8 +114,9 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, try { setError(null); - // Device code flow (GitHub, Qwen, Kiro) - if (provider === "github" || provider === "qwen" || provider === "kiro") { + // Device code flow providers + const deviceCodeProviders = ["github", "qwen", "kiro", "kimi-coding", "kilocode"]; + if (deviceCodeProviders.includes(provider)) { setIsDeviceCode(true); setStep("waiting"); @@ -129,7 +130,7 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, const verifyUrl = data.verification_uri_complete || data.verification_uri; if (verifyUrl) window.open(verifyUrl, "_blank"); - // Start polling - pass extraData for Kiro (contains _clientId, _clientSecret) + // Pass extraData for Kiro (contains _clientId, _clientSecret) const extraData = provider === "kiro" ? { _clientId: data._clientId, _clientSecret: data._clientSecret } : null; startPolling(data.device_code, data.codeVerifier, data.interval || 5, extraData); return; @@ -212,7 +213,11 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, // Method 1: postMessage from popup const handleMessage = (event) => { - if (event.origin !== window.location.origin) return; + // Allow messages from same origin or localhost (any port) + const isLocalhost = event.origin.includes("localhost") || event.origin.includes("127.0.0.1"); + const isSameOrigin = event.origin === window.location.origin; + if (!isLocalhost && !isSameOrigin) return; + if (event.data?.type === "oauth_callback") { handleCallback(event.data.data); } diff --git a/src/shared/constants/providers.js b/src/shared/constants/providers.js index 936504f..fa37766 100644 --- a/src/shared/constants/providers.js +++ b/src/shared/constants/providers.js @@ -4,6 +4,8 @@ export const FREE_PROVIDERS = { iflow: { id: "iflow", alias: "if", name: "iFlow AI", icon: "water_drop", color: "#6366F1" }, qwen: { id: "qwen", alias: "qw", name: "Qwen Code", icon: "psychology", color: "#10B981" }, + "gemini-cli": { id: "gemini-cli", alias: "gc", name: "Gemini CLI", icon: "terminal", color: "#4285F4" }, + kiro: { id: "kiro", alias: "kr", name: "Kiro AI", icon: "psychology_alt", color: "#FF6B35" }, }; // OAuth Providers @@ -11,22 +13,38 @@ export const OAUTH_PROVIDERS = { claude: { id: "claude", alias: "cc", name: "Claude Code", icon: "smart_toy", color: "#D97757" }, antigravity: { id: "antigravity", alias: "ag", name: "Antigravity", icon: "rocket_launch", color: "#F59E0B" }, codex: { id: "codex", alias: "cx", name: "OpenAI Codex", icon: "code", color: "#3B82F6" }, - "gemini-cli": { id: "gemini-cli", alias: "gc", name: "Gemini CLI", icon: "terminal", color: "#4285F4" }, github: { id: "github", alias: "gh", name: "GitHub Copilot", icon: "code", color: "#333333" }, - kiro: { id: "kiro", alias: "kr", name: "Kiro AI", icon: "psychology_alt", color: "#FF6B35" }, cursor: { id: "cursor", alias: "cu", name: "Cursor IDE", icon: "edit_note", color: "#00D4AA" }, + // "kimi-coding": { id: "kimi-coding", alias: "kmc", name: "Kimi Coding", icon: "psychology", color: "#1E40AF", textIcon: "KC" }, + // kilocode: { id: "kilocode", alias: "kc", name: "Kilo Code", icon: "code", color: "#FF6B35", textIcon: "KC" }, + // cline: { id: "cline", alias: "cl", name: "Cline", icon: "smart_toy", color: "#5B9BD5", textIcon: "CL" }, }; export const APIKEY_PROVIDERS = { - openrouter: { id: "openrouter", alias: "openrouter", name: "OpenRouter", icon: "router", color: "#6366F1", textIcon: "OR" , passthroughModels: true }, - glm: { id: "glm", alias: "glm", name: "GLM Coding", icon: "code", color: "#2563EB", textIcon: "GL" }, - "glm-cn": { id: "glm-cn", alias: "glm-cn", name: "GLM Coding (China)", icon: "code", color: "#DC2626", textIcon: "GC" }, - kimi: { id: "kimi", alias: "kimi", name: "Kimi Coding", icon: "psychology", color: "#1E3A8A", textIcon: "KM" }, - minimax: { id: "minimax", alias: "minimax", name: "Minimax Coding", icon: "memory", color: "#7C3AED", textIcon: "MM" }, - "minimax-cn": { id: "minimax-cn", alias: "minimax-cn", name: "Minimax (China)", icon: "memory", color: "#DC2626", textIcon: "MC" }, - openai: { id: "openai", alias: "openai", name: "OpenAI", icon: "auto_awesome", color: "#10A37F", textIcon: "OA" }, - anthropic: { id: "anthropic", alias: "anthropic", name: "Anthropic", icon: "smart_toy", color: "#D97757", textIcon: "AN" }, - gemini: { id: "gemini", alias: "gemini", name: "Gemini", icon: "diamond", color: "#4285F4", textIcon: "GE" }, + openrouter: { id: "openrouter", alias: "openrouter", name: "OpenRouter", icon: "router", color: "#F97316", textIcon: "OR", passthroughModels: true, website: "https://openrouter.ai" }, + glm: { id: "glm", alias: "glm", name: "GLM Coding", icon: "code", color: "#2563EB", textIcon: "GL", website: "https://open.bigmodel.cn" }, + kimi: { id: "kimi", alias: "kimi", name: "Kimi", icon: "psychology", color: "#1E3A8A", textIcon: "KM", website: "https://kimi.moonshot.cn" }, + minimax: { id: "minimax", alias: "minimax", name: "Minimax Coding", icon: "memory", color: "#7C3AED", textIcon: "MM", website: "https://www.minimaxi.com" }, + "minimax-cn": { id: "minimax-cn", alias: "minimax-cn", name: "Minimax (China)", icon: "memory", color: "#DC2626", textIcon: "MC", website: "https://www.minimaxi.com" }, + openai: { id: "openai", alias: "openai", name: "OpenAI", icon: "auto_awesome", color: "#10A37F", textIcon: "OA", website: "https://platform.openai.com" }, + anthropic: { id: "anthropic", alias: "anthropic", name: "Anthropic", icon: "smart_toy", color: "#D97757", textIcon: "AN", website: "https://console.anthropic.com" }, + gemini: { id: "gemini", alias: "gemini", name: "Gemini", icon: "diamond", color: "#4285F4", textIcon: "GE", website: "https://ai.google.dev" }, + deepseek: { id: "deepseek", alias: "ds", name: "DeepSeek", icon: "bolt", color: "#4D6BFE", textIcon: "DS", website: "https://deepseek.com" }, + groq: { id: "groq", alias: "groq", name: "Groq", icon: "speed", color: "#F55036", textIcon: "GQ", website: "https://groq.com" }, + xai: { id: "xai", alias: "xai", name: "xAI (Grok)", icon: "auto_awesome", color: "#1DA1F2", textIcon: "XA", website: "https://x.ai" }, + mistral: { id: "mistral", alias: "mistral", name: "Mistral", icon: "air", color: "#FF7000", textIcon: "MI", website: "https://mistral.ai" }, + perplexity: { id: "perplexity", alias: "pplx", name: "Perplexity", icon: "search", color: "#20808D", textIcon: "PP", website: "https://www.perplexity.ai" }, + together: { id: "together", alias: "together", name: "Together AI", icon: "group_work", color: "#0F6FFF", textIcon: "TG", website: "https://www.together.ai" }, + fireworks: { id: "fireworks", alias: "fireworks", name: "Fireworks AI", icon: "local_fire_department", color: "#7B2EF2", textIcon: "FW", website: "https://fireworks.ai" }, + cerebras: { id: "cerebras", alias: "cerebras", name: "Cerebras", icon: "memory", color: "#FF4F00", textIcon: "CB", website: "https://www.cerebras.ai" }, + cohere: { id: "cohere", alias: "cohere", name: "Cohere", icon: "hub", color: "#39594D", textIcon: "CO", website: "https://cohere.com" }, + nvidia: { id: "nvidia", alias: "nvidia", name: "NVIDIA NIM", icon: "developer_board", color: "#76B900", textIcon: "NV", website: "https://developer.nvidia.com/nim" }, + nebius: { id: "nebius", alias: "nebius", name: "Nebius AI", icon: "cloud", color: "#6C5CE7", textIcon: "NB", website: "https://nebius.com" }, + siliconflow: { id: "siliconflow", alias: "siliconflow", name: "SiliconFlow", icon: "cloud_queue", color: "#5B6EF5", textIcon: "SF", website: "https://cloud.siliconflow.com" }, + hyperbolic: { id: "hyperbolic", alias: "hyp", name: "Hyperbolic", icon: "bolt", color: "#00D4FF", textIcon: "HY", website: "https://hyperbolic.xyz" }, + deepgram: { id: "deepgram", alias: "dg", name: "Deepgram", icon: "mic", color: "#13EF93", textIcon: "DG", website: "https://deepgram.com" }, + assemblyai: { id: "assemblyai", alias: "aai", name: "AssemblyAI", icon: "record_voice_over", color: "#0062FF", textIcon: "AA", website: "https://assemblyai.com" }, + nanobanana: { id: "nanobanana", alias: "nb", name: "NanoBanana", icon: "image", color: "#FFD700", textIcon: "NB", website: "https://nanobananaapi.ai" }, }; export const OPENAI_COMPATIBLE_PREFIX = "openai-compatible-"; @@ -84,4 +102,4 @@ export const ID_TO_ALIAS = Object.values(AI_PROVIDERS).reduce((acc, p) => { }, {}); // Providers that support usage/quota API -export const USAGE_SUPPORTED_PROVIDERS = ["antigravity", "kiro", "github"]; +export const USAGE_SUPPORTED_PROVIDERS = ["antigravity", "kiro", "github", "codex", "claude"]; diff --git a/src/sse/services/auth.js b/src/sse/services/auth.js index 83de798..275ac9b 100644 --- a/src/sse/services/auth.js +++ b/src/sse/services/auth.js @@ -1,5 +1,6 @@ import { getProviderConnections, validateApiKey, updateProviderConnection, getSettings } from "@/lib/localDb"; import { isAccountUnavailable, getUnavailableUntil, getEarliestRateLimitedUntil, formatRetryAfter, checkFallbackError } from "open-sse/services/accountFallback.js"; +import { resolveProviderId } from "@/shared/constants/providers.js"; import * as log from "../utils/logger.js"; // Mutex to prevent race conditions during account selection @@ -77,12 +78,15 @@ export async function getProviderCredentials(provider, excludeConnectionId = nul try { await currentMutex; - const connections = await getProviderConnections({ provider, isActive: true }); + // Resolve alias to provider ID (e.g., "kc" -> "kilocode") + const providerId = resolveProviderId(provider); + + const connections = await getProviderConnections({ provider: providerId, isActive: true }); log.debug("AUTH", `${provider} | total connections: ${connections.length}, excludeId: ${excludeConnectionId || "none"}, model: ${model || "any"}`); if (connections.length === 0) { // Check all connections (including inactive) to see if rate limited - const allConnections = await getProviderConnections({ provider }); + const allConnections = await getProviderConnections({ provider: providerId }); log.debug("AUTH", `${provider} | all connections (incl inactive): ${allConnections.length}`); if (allConnections.length > 0) { const earliest = getEarliestRateLimitedUntil(allConnections); diff --git a/src/store/index.js b/src/store/index.js index de81f82..e057a0e 100644 --- a/src/store/index.js +++ b/src/store/index.js @@ -2,4 +2,5 @@ export { default as useThemeStore } from "./themeStore"; export { default as useUserStore } from "./userStore"; export { default as useProviderStore } from "./providerStore"; +export { useNotificationStore } from "./notificationStore"; diff --git a/src/store/notificationStore.js b/src/store/notificationStore.js new file mode 100644 index 0000000..ea51715 --- /dev/null +++ b/src/store/notificationStore.js @@ -0,0 +1,45 @@ +/** + * Notification Store — Zustand-based global toast notification system. + * Centralized feedback for dashboard actions. + */ + +import { create } from "zustand"; + +let idCounter = 0; + +export const useNotificationStore = create((set, get) => ({ + notifications: [], + + addNotification: (notification) => { + const id = ++idCounter; + const entry = { + id, + type: notification.type || "info", + message: notification.message, + title: notification.title || null, + duration: notification.duration ?? 5000, + dismissible: notification.dismissible ?? true, + createdAt: Date.now(), + }; + + set((s) => ({ notifications: [...s.notifications, entry] })); + + // Auto-dismiss + if (entry.duration > 0) { + setTimeout(() => get().removeNotification(id), entry.duration); + } + + return id; + }, + + removeNotification: (id) => { + set((s) => ({ notifications: s.notifications.filter((n) => n.id !== id) })); + }, + + clearAll: () => set({ notifications: [] }), + + success: (message, title) => get().addNotification({ type: "success", message, title }), + error: (message, title) => get().addNotification({ type: "error", message, title, duration: 8000 }), + warning: (message, title) => get().addNotification({ type: "warning", message, title }), + info: (message, title) => get().addNotification({ type: "info", message, title }), +}));