From 3debf84b9a9ede68c63b30ca1d754db49ccc37d3 Mon Sep 17 00:00:00 2001 From: decolua Date: Fri, 20 Feb 2026 17:05:46 +0700 Subject: [PATCH] Add Providers --- open-sse/config/constants.js | 27 + open-sse/config/providerModels.js | 122 ++++ open-sse/executors/default.js | 49 +- public/providers/cerebras.png | Bin 0 -> 2295 bytes public/providers/cohere.png | Bin 0 -> 2791 bytes public/providers/deepseek.png | Bin 0 -> 2393 bytes public/providers/fireworks.png | Bin 0 -> 2612 bytes public/providers/groq.png | Bin 0 -> 2882 bytes public/providers/kilocode.png | Bin 0 -> 314 bytes public/providers/kimi-coding.png | Bin 0 -> 18477 bytes public/providers/mistral.png | Bin 0 -> 2106 bytes public/providers/nebius.png | Bin 0 -> 2335 bytes public/providers/nvidia.png | Bin 0 -> 2582 bytes public/providers/perplexity.png | Bin 0 -> 2108 bytes public/providers/siliconflow.png | Bin 0 -> 47179 bytes public/providers/together.png | Bin 0 -> 2024 bytes public/providers/xai.png | Bin 0 -> 2651 bytes .../components/ModelAvailabilityBadge.js | 185 ++++++ .../(dashboard)/dashboard/providers/page.js | 547 ++++++++++++++---- .../api/oauth/[provider]/[action]/route.js | 19 +- src/app/api/providers/[id]/test/route.js | 539 +---------------- src/app/api/providers/[id]/test/testUtils.js | 341 +++++++++++ src/app/api/providers/test-batch/route.js | 131 +++++ src/lib/oauth/constants/oauth.js | 26 + src/lib/oauth/providers.js | 158 +++++ src/shared/components/OAuthModal.js | 13 +- src/shared/constants/providers.js | 42 +- src/sse/services/auth.js | 8 +- src/store/index.js | 1 + src/store/notificationStore.js | 45 ++ 30 files changed, 1583 insertions(+), 670 deletions(-) create mode 100644 public/providers/cerebras.png create mode 100644 public/providers/cohere.png create mode 100644 public/providers/deepseek.png create mode 100644 public/providers/fireworks.png create mode 100644 public/providers/groq.png create mode 100644 public/providers/kilocode.png create mode 100644 public/providers/kimi-coding.png create mode 100644 public/providers/mistral.png create mode 100644 public/providers/nebius.png create mode 100644 public/providers/nvidia.png create mode 100644 public/providers/perplexity.png create mode 100644 public/providers/siliconflow.png create mode 100644 public/providers/together.png create mode 100644 public/providers/xai.png create mode 100644 src/app/(dashboard)/dashboard/providers/components/ModelAvailabilityBadge.js create mode 100644 src/app/api/providers/[id]/test/testUtils.js create mode 100644 src/app/api/providers/test-batch/route.js create mode 100644 src/store/notificationStore.js 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 0000000000000000000000000000000000000000..a4ace23a6c6f1ef74437d81d5faad91f89fe0375 GIT binary patch literal 2295 zcmb`J`#;nD8^_-}7-q96W+8_Zl4C*R3xV`_U%NJ4asELt;gg0CwzZ+U9ao${NeS(^~39NWjGzN$D)x9DN=Zt^d#;f$=EGq#X# zJIkBS__c1xWR^?AEMnMRp>nxWggUqu#8WO&*C17tTwu*Jgrh7Ssfu(V>9ao>35!D$ z)u2HeXY`qUv}mv3Vv{rMhgLDsZyZ~ndYs}XO-A&NH!O!|z;({uUZj>QVicYgpCFDR zZObC3)iLD#6?9&_2lL0k(i17;mjS)!y9}240tQrNv5aB`B_l_2t4b=0MyOUyxb-Hj zc)QUfzw_GOjoy1ybnRkha$9+e6<8G|blOi(0mJPM87DlnN6F`FDYZSOTt?*=IrDBL zG}WiuG5vtrW_~U|X?FXSQJJ(4`q-z2n9UD(?d69L7O*zRVh@{&V4-^h(Xr=`hOFih zFvkPo0?zB5LyrUx%S|wO6)U-(g86sexW@5)B9+j44-;RWtp3y)7Xeh10-ve7X;rIY zB6f@B2DUepl)}_MMo2`>jP!LxTv9-|7Ax%i&$(|euGW0lv=QVDkC7_h59X-mzI;?7 zDXTDWM%-9-dJBqW+cPfsI3Sl=`CPKpW(VCVQ*VZ3svWlrY;?q(-2`osL6z);tL}Dn zFr9j}4#_wbW-kbnQ%gtNsD_;YYyCi{56gZV3mtOt|L0rMh1DPu%yRyxkt~3_# zpdz>Eeg_AvkrEuZCf+$j(r7_L2M~yi^n;fFw`g^RjVM2f<{#>UZddAidk*tC=L?@P zSOlBg3gNWC#zlynZ^~zGJj=WjWz73(2|1f1Y->;pZ}2#iMN86L2>^=!$c-bI?MHClK2F3sjo42# zIU%y>x^-72eJ3(X|H(nk>9p-iW2DtfPtGwaOX5$4n7ON6$D7?(@*@r+fk znvUJ#VeJIq;r30OoaTqCS{+S(HRj$!S4M-@*q+I*%%1TTGOeb&cNTZ6$Qoc^R+K2U zJuQ`C>}K{5qK~cV6xY|;;X^8-^)F6j5a1l9)x@fjTi(K&7mjoDHqZMV>JGnUy&X7< zak8|*nuB0%qvQBjvzN`}f#BecNpmAEzeoxXlD-s~$i)f| z#`)PIIBmyTu)k^E^`N}wtYVd~8f=`_*vu{lymy->Qs;hcI}TL1*usXR6MnO>u2OH# z+vnHV6s`e3Dp;Gb_ubVZ>Tm${O*e`EbMDT-j%&bjxScIDJN5nf3yf3zdVHKUqCd0>1po2mNX(-%oR^hrb-p@0AmTYxbJ-OOSjm zW0rbrzqF|H?+=2y7n^o@HS?_l(#7*eYi!3BsCFWn@-Y(IqGbw_L@M>mRRPbr>B%Uw zMdO&Dy8H1T%;MG$4>fTvH3YlN?`e9yZEjZasdR$nSV735oFG-#4W#H4DwooYcW44C zH^!iD>KM}31-QC3+IN;EVzEHacV)+KebG9B=g@mrH(3CW6-C5N_X~;;*Ag=J`J9N+ z2aivG+uL1P&{gKu^n@j z))4iuUR=$69slxTRl~1JE1l%+Qmvo_UdWX1*s4MltP!iO2(_`_s3oq!+0NX(&_XfKDi|K8J*ekJF0J%Am@2R(~Mx2}&YDTamH~p+FuBHZwg8MOy*4A}1PlDdlI0 zazP9Rn9>;81dLN)|ID*GR67NpML9zaE=widK>JGveUm0_NYR_Y*ai|X?t$`6EQG zfwg*>V3veGWNqP)OjKZgC3`*`Yt=?_zq%w^u#CtaZ8CDU{6SicgfP-uO%x`>%g8z;XW( JyBZs6%D=o<2ZI0r literal 0 HcmV?d00001 diff --git a/public/providers/cohere.png b/public/providers/cohere.png new file mode 100644 index 0000000000000000000000000000000000000000..60a0fafe80f9805ecf2d4abd7edb9fb8719a85c6 GIT binary patch literal 2791 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 RZMJd+4D<{!MLIUY{{a%MG|m73 literal 0 HcmV?d00001 diff --git a/public/providers/deepseek.png b/public/providers/deepseek.png new file mode 100644 index 0000000000000000000000000000000000000000..5df2f5040f93f48f5c90c03b2a10c6b078012bcb GIT binary patch literal 2393 zcmb_e`B&139{nPUg1e+3ZiMB&o@r7iQ!?Cgqq0}N1lN+u4XsQ?5g95qGs;{>Np!T_ zUTTY`xi?6T`(&vh3ZuD{yQPAtJ^ShX1Mi3Xx#!$_&gZAkx%ZQf_jXl;pZT2qg7A4?Nzv) znWS{Mef6Sc&PD6f_hniSMEuOD%0px5)+kP#6B?(DXCb_*$0iyuNLM@#0+us5%_-@s z)HgZ^lB#?`d9=l#a`}=ch3}JV2mHcQdYkQ{R|w@*-XD@rFtPq+F^MEAcg^w2%q7^} ziN~1}2owdUNvHM9qTdGPdd$8w?S00@&DxVSHz4HtqtXl%2)=8+c~AqG1# z{i2YHCCsHonw-z)iYVUZZC+evVa89cULvjw>q3~7`H@Z9S4%T2&KZg44m1^O8XB5V z3Gjh)yeRlo<*B}~S40xo8#8qqlGjsFUUiZbA+Y`JRtD!h>g>Y?S3-&=C?}j=GE`jI za=+I4Tv|EGTCbqjSaNb1j;JUzIRV+|%H4hOut9P&smd6<{)A&ZKcC0bX1k=rtp0eVE3p2?j656{P_dw!%A4Qs2eZ%3+HN zhBntV$V15(sTn#rQd!o?At4fLO^dOlBhfNABcFrn9gT<+3QQ@?!*V7pkbegaaRaIL zoOP42T6R_em(k>AjLW2uTbMM^87yP{z!^{`MU?{>qn#39l>oO@E|_*erNsWGFECA% zw#LHJ@I_80|JU_Fhx%APjdRbY$Zyr0#&;PDD+hNHM#h+S)L()Ieq+qks!V={G;9Fu zWSOLw@?(uU@yW+m=V5OamEJraZALVGG3j`1ZgfmZ;gfX2wt&(1=˜&4VMRsitr zjKR4fbRE^SPFQ)Zpk!9T%07+?;!A8xEYA9Q-}&xhdx5^H@BICQ@Z$&aK<#;mt;X>H zV0hWWl3h+L1%iQs*ZGw3P80r630oNT*RKyE7+VWanX&nh5N^T)zeQe{ z@~^+p!m8UQ2pt9A_g2ISHBCkg=te(-P0i9+ zpKF_MN>~^WLEw(#^|YKno|Mx_5U#m7d7R2qXDDBi6$9d51Skj^k@7qiQBZ>!;tPr{?*VgIdQ3IP-! zm6dU$*@H6MC&SQT=Uk=-Y}1TiHO`HAfLG~@q(1Gv;64kqQj%2T)f;OD!b*ckStlY= zcq?H|B6dCRn;UKHBGeBV-AN3iz1>rdrhSw*9sJ$(s6bvwNr~wc^#9yGsVS_EE@j)q zOMeWXbf2M4@I^&nfmLk({&5KOoV_tMel8~_So%j423FbYgPGb_iCVI|?l*45D- z5&iRIe$@0cAtHcqhxkFg_?_sRN8r1@H+C<(ki~o>-|HNplmrwF2Ke1s9CzqanaHgJ zwMi_W%X^H{DSn_vuVdAmsDO)fi14N$0M^35K-|WFo!P-cE#~h1Te01(Uo?8!Mjb2{%&hGF#+V?RxdjV^+%37ej@J9A}j+W8+mr|B{}bNmi~ImI6QcIb3~=E z3iGosaDXt%zbNQfIm3An!A!Z!?@`)N3FWdwQoA`#UBlFmr;Q;Jwd@z z4}PZQ(?V5^4A38C(x7e)sr!<{zEq}q$N>FWIs@wV@6KSUo>+7kh`X}+4Eet-m2--e zB^Vw=+h~(0ws_vMI;i_BoJXty*v&q;Vr{Yts@W9JjY|hUl|$tx1Ter{QCgV zUL$k4$s3O??M>xoqD@d*F~Jo}8mg&RhSPX)FSz$1-TwmJ{n2}m3{9~Qv>9C8itsm0 z&!+SOl#xdmT%~k5U${%C`GUfi3^m8-<3sbm#DETWq&^-J7Chh#7Aie4ioBPh;#;rp z_&y)ys}=;=Xt_?_y(I06lI1O1HZ8h#HEOWTo55AO@GIl}cf{zwY}<7V-)nkiU%?vR z(%G+aO+i*R{qKxhF07JFf)U~5so}Jh^E`{R#nrnmpOlzP=-mnQ7PPyIWa#itQL&Hx zWLi13mqdC~83cigu*OH?u9G5^1xIsD=W^s9%`lseG#K`pUuAVl&)P$m_GDuhnpvlZ S$IAA9CBWU;8&~5LNdGSqX=~E} literal 0 HcmV?d00001 diff --git a/public/providers/fireworks.png b/public/providers/fireworks.png new file mode 100644 index 0000000000000000000000000000000000000000..f7233caac7a1a0580e724980c917691e330cbaec GIT binary patch literal 2612 zcmcJR`9IVP7stOd24frK=Gu*vX%K^Y$Uc^&?5QXj(xS+?jrERY(1R?KnyiCK*~c0} z3?to3SJxzawvjE2tqd|_=5e3D;dy@eoX_j^dA-hgouAJ8Ja%!ilMq!91pq+8{=ALb zp%eZQROqmFoBQ@1nn>_@PaFV<$^0V_ke4ram@V4dpxq<#*RErO-G`6Hs#YF&->nos zIIl(eKr3=wv*7UgNh5Kev+CD89+)tb-Az8{wYSxwhi;}qhOXSdta(Z^#jPUYlc-;p z6$tc)1tIQ!iEUuox*68+!yUtou>Qo1jOg%P+O3A&*Sq<-1|&)B#!C*|&C`1Id0o4$O2;0Kuo^-j0&vfNv-R$$p^vUhhTP(B>AnWLE zwsI?&bx!~x_2awptfQt4Kz+I5=DjQqf#F-ifZNd%lvn9x=-0F%~weIgJf!I>Ihv&eUM!Kn}>1pVezSy&ZC|JW&vqeKEo%_-P8mSd` za^%IhZ%RL&ropLHMavD8WH z=NxWYW^w@n#c#?Vu?yC)?lILY{{_l=GuHUcmm5)pm^|-*$+kat88^N zDmG-xPW{HZ97vueZ+uWaj*Rd7w3pa>Ioc22y|c))stq9}#n(=zp;0|rH}v#7q#ycf z2o+2(n?ymt;rc@4dcZWO>s)W^Q@Iddf$}5(K!|)Z7*97Nv@fS;BoxW@#yEf=K)9ih z4G^RPIYtnSlOh@;0Ygc!G(FxDYFF?Jfh-&c^+CfQy>b$z|D9k7se?zxV?SAh4v}v%^pcmf!MblS2>f4y1&SjR7BPC-oG^C{@6?T zKCe&9Yu_HVn$q+40Bh4uyCmdkyJ(*r-l~r%+nH8fc4pPBMTfw5u;z`IYcHtwh%Fwt?1av^bTEw-mtU>-@!tIGGB;bLiEC2pVPa>yU-dbhMz@4K;W z4HhFR7>jbmfPd;GnlAtME1aJ%OtfhwWXgA#)*k9GL-yE8-(QMFKsR?e+o5zB!>i_@2+3>` zVnU|UY*qNkufOeGx0c}L8d6C~P*gv>Dz_|qYe}nj=M*JfNStittexJ!Kn@y~xxd!5 zAFVWYOM`Dx1nhNShKi@domVh3b-Jpz8IxpPHvbiCp-gPf?WK|6acLhgI^uxP0c%;) zLcffcNODllww#|pW6xncI^JM9lYW;Z(+IwBlA^ccw02~(OdJ8X6~{|oj2iHSW(A7H4=7`#@IEi=YP z_`asz>qpWr^>KzN*p<`;qZav%;Tm3`$6IO=!?am%-+eAT?S^D+{A^}jeWJ~E?$e^X zArJhxUe(?>#Vnu+=2FsEK)rKY3l?bHDL+;9SV25}U6{L6^6jgP#a)G{CFqNQSy(60 zcfx2zL7wYuQPpK`^}J}??+bE%ZdWxKAUP0hRTH00+Km&>L^kaUx4!RIm|7T&t7KcAh0VuhzdKW#gad61^qM(#m5>yTWw@U-*!wC(^x;pi(;*-OTTb}eERTEPLo|5yh7<(^WsahX+Cl>sCl_n4Uy?1H>}+_Vtg{jtiN2X2T=h&B8aDRL|mDhKSq^ zZII>xe#GPYPDup5JY?3du^?ig@Sk`+r+S0&N@YP6-aaFmVQyR+tOK z`&q@FKRDD%zNDJMh*&8)ybzEh{HnzzE!I_YJh6`aRJqi5Csdmee6ll$0&}kwZz5i8 zK&-YIhHYQw21Q*uzki^M)pGZeAv>0Wnp1#zAJhNG1utaWp5E5zJ!JH)&L!M8HGpn^ zWZxl79YoBXhMvtyL1!-+j9mZ-)Z>#Z5h1Gf81$^+kpw5_x{nJGz{sAylf`WF#^ZvE znEIz_@(;^#;7J}5!t%35+n*j|Fk}VjdknqSs z_V7=T72~?H>0Wb+h^sPB;>4qcEbJjXsK|V#IY|%>TZc8h;w(wuC3$jtK}$ zVfYG$N_45*&0xz<)~szhCeEU|u>D@E278Dp1Ry}qUJqu;YUfab{%U6!+`FgJj@k5q z!JbD6Isa55G9UQ8edsOVxukFL%SEI3dS!(#f;iM-6MdZzr{c<1XDTs=Zy;cA>ts`7 Hm&~2qQej95g-DdkHY1nhe#s^BaVvLS+|4Fr zDf6w5ORg)ch4iuITAO`+{($dq-ydG*{BRzRbNS&sUe63yXA~SJ4FdoGZf9%lc9^mM ziWhnqUz-JtA11y?+l$cvz%Tl*KtNfABmls+?5r(3;wm|5{ph#Qj_k<(Gad~MYy3v_@B=%e1^6vKPALCfJXVNL3hFZze zH(rKp8iurM*ZZSt{ppT)dL0!>wu+t{IfkG|XD&$hzZXe38ddp0N-o(~Mem2R`{sbXXJ=)(cvypHpw{`qsMLJL@FMyh9|D8nmGQzq#j*ettUP2 z<#8nAvlQz{;zTDl;gUN8Q3mzVg5Swar<;x>@W_$~o}!Gpyzu&l%3Wg6OKs!LSNh%R4hel7)m2BE z@niIXw^-wxb=hgoygj4(Yp+&FCnY}m=bNtyPe6{OZ=-;6-P2$d>Wh<{C55?3iP%D0 zU}+uAeXSl-OTYXvXyc{ zfyflc>u%gazt1T`hOxY*leI9#tp`YGI!IWz+D;7lT>>W{1W^jIEYSS_4p7H)#oP1K zyxyg;dJU+AUaZ#OG~XY*5d(K;cl4 zec9Q;-nu~9on|j}dCZ`y(82(MC~aho=;|;+wr(VC28oACEd?v!Wv5+*vsq<^K$q)f zSvfkf)Z7#ChM|vCh3{{NJ(17D+>5#Tr7f}lq;W5i*87d8cR#xvw5DQql`7#vmcU8JDdOd=Ttv4dCmo0!TpK3Nb>^1SQPt-mmLcz zTwVL!fw>ffVVVy0iG!aVS}-9|Wg6Ijg*l(^o8SiraWFpkx{KcSLUdtN^M0^`QzZyiJ`L3iyo{^8T$4mqAbXWfZ@dO~ z#5K4;Ej5xh{kB)+&UlAlhrSJ}O#<^q+yzN%DbG(SIzBb@?WdUYk49a`Y_GhpEmA8_ z#YEUwf)-5Y@pk5Sj4wfg@Fm!vGJT=CelBIC<&NNZFuXn5p&evqBHaR-sNxr=QgDX=lWIg2Px)@waTrkBt4C~^;bKuJuu$s#0x|=I<`SaI@WIbSKhug+X;u$FO zjUdm#<6Q>IGe_)5S|N0H@5W*4Z?V<6)&|z+eeqE{KDy9x5$$`hO^oA zXPK>J8+R82U?RyA=WBXi68U?c^ry(Xom=~FN%UURy{z?V*c~4Xe9${119aJTaA{_6zMPnS5#G`VqK%QLj}bM)$=)pt zt8r0I$(6z;5rO`m+9J#bRK4cPXpZGl1l;}3*Pssv8|9Nwp@n}=oD_>Yo6d$<*t9nY zrm^lf*rPRX`L+jVsX}g>_HZ7=e4;kYksGlZZ{NOPS=yyWS;O$BbuX}+yE%DgKnH4Ml5bVNn{?}|X5;yJ_%t!1x zy%Qx#1v77@Vjo-86$hg$>#pP|_!*9jy5Bg~B<=7^F;bkuj5u9%Y0h^Vh)|eJf;^>5 zQFR-zag-y7>kMdHm6k-zHp^7!BcQrXVmpH(m$@77I@>=b_X0No2a z%O70GYO(4+o9*D^D;Xl9zUH8l`9Q+A=;HfRrTL&O^VR1(YX95wtcT$K(k2ZuTq-j| zIF0Utd?>;JyjfeeDd2^zISp89-1Y$OaA^_6MX+igvC!odu_sOlA^S-WP;tWv5-JZc z$y0sFB~$st%#ga*82Wh;ob2 z??OK`-6IREsTn#-p%2SqWr!F!Z>Nuuj=j7X&k?jTfg>$BH(AmMxM2A%HXMRh&oJOy zU}1MsPYM=`gpzR~?Ke;# zv0+7DVktaj-lO`D9F#Ww0<6*F^|#$Geu*td)~c;RoykoS=tzqGY>LMt9r$!8%uDfD z93_znH`6HGTPz|{!6kydJXT~a8aBqmX9qtdReYe@A{a+GT}_(1rCZf_l}II_^OQ^< zH-d5^$PzyGcx`O)daL7<*1Lb9#((_!Ozs=IxR3gKABb{bJ=-ew0`k9 a_7U3X;nUyrwmXDk!0w#0b*rU+@_zvlO;hXu literal 0 HcmV?d00001 diff --git a/public/providers/kilocode.png b/public/providers/kilocode.png new file mode 100644 index 0000000000000000000000000000000000000000..147dac0c5bfef136ad1e65b31b805bf7351cf82d GIT binary patch literal 314 zcmV-A0mc4_P)|nM+kY1!826vZWJk6mW28!L8IUMM5T!$G7890Pqmh zNpkT&005lhfU{=zMK7v-UVonhlR`@1<;+q%jtzYStrZJk7YE9!!#G+wfE)-mz(ut) zt2q#BB5PnxVV=XClMA^y1vz==RhJIxZkeq zm%JkqpsqC=V(1(4-gJggwUev?E`-)936YX`D;D1cGY5{AxC7z<53%-z3Z%7wa{vGU M07*qoM6N<$f}|LD5dZ)H literal 0 HcmV?d00001 diff --git a/public/providers/kimi-coding.png b/public/providers/kimi-coding.png new file mode 100644 index 0000000000000000000000000000000000000000..422b7f96289368f94a5e85a25dd31e36288acaed GIT binary patch literal 18477 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 literal 0 HcmV?d00001 diff --git a/public/providers/mistral.png b/public/providers/mistral.png new file mode 100644 index 0000000000000000000000000000000000000000..68001b97820988a6528e38a8da2f1cbaefd70e57 GIT binary patch literal 2106 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 B7x(}G literal 0 HcmV?d00001 diff --git a/public/providers/nvidia.png b/public/providers/nvidia.png new file mode 100644 index 0000000000000000000000000000000000000000..9215a386ff3c981fe7522bd24363d233dea3228b GIT binary patch literal 2582 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 literal 0 HcmV?d00001 diff --git a/public/providers/perplexity.png b/public/providers/perplexity.png new file mode 100644 index 0000000000000000000000000000000000000000..4e9d56fab25c1b52c352fa22d3361096f1dbbde9 GIT binary patch literal 2108 zcmd5;`#;nBAAirrY$Zb_VvXb$!bvBW@v)pT+ZVYWm(Won6~dW9W=3>X>9{MA=3eeG z=TM?zwnT0@y0C`XhD^DB=kfg~zCS!)&*$U$d_5ko-(Ig*nzQ3EIkYMo03b)Sw{wv& zbrZ5OlKRs6WVeJ+;r3pU0Blj(1PtWl?E(NPAlliw#pKQA`kZt7O|6ri^r=OQ=3wFK zz5S9%QPDrX4_PEbPsNx8yD>H7ukn(4>gwp-t3GIt5KRX};lqmxdKI#oxW+!hb!Yv9 z7w?l2p#yz=HGx5~3eQ@`#sq>qLEOeftX1~Z(wf*NGRP5s0QRcmqXwB{r|qtAPrt3$ zqEq_BNy6Bty9ezeVlnfa7`=gKj$t>ulx}=0U^P=dys-7<#FEP{BFG-NGc<8TX-SrW z%g(rlEPMtkhyD2MQPIS0fi@$JU1qw{n%5CvS4?NPsWJbrbVHCMHU zB4nWDc^k@>hero{+jJOHHp=!~5B72~F5f}kwD|J3aq;c_CuUWtZQ#a}4J8^*<{IYHSozM_~^+00V&uZ946(m63b?Vj47(g5(}? zg}0qXK#qjhS_83Um-5*(=oAtZdiPx2BMUXpz(BhA2msc`G)w&`5MqM>pWu6&iU`R6 zGaH{Jn(q@Xj+TEo_SiGC`W-u=-o>V#7rMs#$X-6h8+x_>5q4>DN9f`flE>5&(qGeE z{hBi;OWcu+))-=!&zb5&yqdiUJjy?7$K@ZG+muAW&APr|8~-l#3j0~)n83VJi!~2T zjF!)r{v>v`_Eok=fkux&Qn`oEpVJk2QZs%Qa*$``#u}ry*O06NPSgu0RMt3rr^=Nn zD^TL0{sTV=vPL4mMD>sqfPFZUr(b)CWClvO=Z8b^{5mhGw@-AkUybv5(pMwY(i z0Hu~Ku;2<#MA@#eCT3)ky(M@RO{zW;EcMiA-^=R9^g zw5n#3`sjR%5*i5mNu+HR3J51&J~o#2ls)Lj zXxvP9-S@zvSD0Rn_oUnStkJy%#3Av33b;n$-)zbz?F5o3=#@B$qo;o0AGIzkjWfIx2 zPnxQ8J=4zW`j4Q5`_caIvD8M7^@q9MK6|A^c5`>>aAQHYOMF$K$(JVX@=!x{2(0a! z<qG zH0>tW;ZS-2=(!sx&^@;@WdJH?XLB0XcUoX?1$~5Qph;zGO0VY8i5*@`eMF$@?M;AL z{T&cN{wlL9OEsUJ88H|U8=r?A_Dk>*R#*;K&UQJ`1>8)2Wxx6E1XQa^M+;X*69iZv zYK`{Y8!4)-U^;x;_+EZxZblflah<}|GWL^Rj!_&MM*uDG)KS9%6v6{gI0M)a93uup z2*pE*F!sG7)eK#6_%flo~Ak9>%ov)+KTob<^&G+n$!8Y9gRp z1O{3TDDj;TUpy?J_65J7_0@J?29AJhgtqGk*DR4uY*#9rWcx}Np;>YBHIkV$EZr4j zY8ys@hYKr`lbRlCkKUu{nqg(nnr2hroMCCS0(cH9bbi~?n!qdM7j2Ma<&N(!OKDpDc_^o0FtGGERp$6l`JK%3N|M3Nq z8GLP$v&+cMlJM|5cy~&#zxH_Yc~gi-ONb zidf`yHOcWf;C26wXSlqw8`bs5{A~+Cgf1WDZ~E3(`xN?eCTb)}h(lt zNkKPv*LuC|V75GW`)X;F(SKCC|M)_^iM6>fmZOd>>&`E@!2ce@oU>9GUW5B;E(G&d SWi%x303iP6XjerDp#2vbqQYMQ literal 0 HcmV?d00001 diff --git a/public/providers/siliconflow.png b/public/providers/siliconflow.png new file mode 100644 index 0000000000000000000000000000000000000000..a73581450d79e1bc312eb7f6c1972fe907e2cee6 GIT binary patch literal 47179 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 G+ { + 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 }), +}));