From 0a28f9f924d2dc53027a910679ee3cff3e63a59c Mon Sep 17 00:00:00 2001 From: decolua Date: Mon, 2 Feb 2026 19:45:12 +0700 Subject: [PATCH] feat: Add OpenAI-compatible provider nodes - Support multiple OpenAI-compatible providers with custom prefix/baseUrl - Add provider nodes CRUD (create/read/update/delete) - URL building: baseUrl + /chat/completions or /responses - Model import from /models endpoint - API key validation via /models - Usage type safety across all translators - OAuth token auto-refresh for expired tokens --- open-sse/executors/base.js | 10 +- open-sse/executors/default.js | 8 +- open-sse/handlers/chatCore.js | 14 +- open-sse/services/provider.js | 37 +- .../translator/response/claude-to-openai.js | 4 +- .../translator/response/gemini-to-openai.js | 4 +- .../translator/response/kiro-to-openai.js | 14 +- open-sse/utils/stream.js | 57 +- .../dashboard/providers/[id]/page.js | 592 ++++++++++++++++-- .../(dashboard)/dashboard/providers/page.js | 201 +++++- src/app/api/provider-nodes/[id]/route.js | 77 +++ src/app/api/provider-nodes/route.js | 52 ++ src/app/api/provider-nodes/validate/route.js | 23 + src/app/api/providers/[id]/models/route.js | 35 +- src/app/api/providers/[id]/route.js | 5 +- src/app/api/providers/[id]/test/route.js | 103 +-- src/app/api/providers/route.js | 27 +- src/app/api/providers/validate/route.js | 22 +- src/app/api/translator/send/route.js | 5 +- src/app/api/translator/translate/route.js | 5 +- src/lib/localDb.js | 99 ++- src/models/index.js | 6 + src/shared/constants/models.js | 3 +- src/shared/constants/providers.js | 6 + src/sse/services/model.js | 18 +- 25 files changed, 1276 insertions(+), 151 deletions(-) create mode 100644 src/app/api/provider-nodes/[id]/route.js create mode 100644 src/app/api/provider-nodes/route.js create mode 100644 src/app/api/provider-nodes/validate/route.js diff --git a/open-sse/executors/base.js b/open-sse/executors/base.js index 96a066d4..72860779 100644 --- a/open-sse/executors/base.js +++ b/open-sse/executors/base.js @@ -19,7 +19,13 @@ export class BaseExecutor { return this.getBaseUrls().length || 1; } - buildUrl(model, stream, urlIndex = 0) { + buildUrl(model, stream, urlIndex = 0, credentials = null) { + if (this.provider?.startsWith?.("openai-compatible-")) { + const baseUrl = credentials?.providerSpecificData?.baseUrl || "https://api.openai.com/v1"; + const normalized = baseUrl.replace(/\/$/, ""); + const path = this.provider.includes("responses") ? "/responses" : "/chat/completions"; + return `${normalized}${path}`; + } const baseUrls = this.getBaseUrls(); return baseUrls[urlIndex] || baseUrls[0] || this.config.baseUrl; } @@ -73,7 +79,7 @@ export class BaseExecutor { let lastStatus = 0; for (let urlIndex = 0; urlIndex < fallbackCount; urlIndex++) { - const url = this.buildUrl(model, stream, urlIndex); + const url = this.buildUrl(model, stream, urlIndex, credentials); const headers = this.buildHeaders(credentials, stream); const transformedBody = this.transformRequest(model, body, stream, credentials); diff --git a/open-sse/executors/default.js b/open-sse/executors/default.js index 63f8473a..7409f61c 100644 --- a/open-sse/executors/default.js +++ b/open-sse/executors/default.js @@ -6,7 +6,13 @@ export class DefaultExecutor extends BaseExecutor { super(provider, PROVIDERS[provider] || PROVIDERS.openai); } - buildUrl(model, stream, urlIndex = 0) { + buildUrl(model, stream, urlIndex = 0, credentials = null) { + if (this.provider?.startsWith?.("openai-compatible-")) { + const baseUrl = credentials?.providerSpecificData?.baseUrl || "https://api.openai.com/v1"; + const normalized = baseUrl.replace(/\/$/, ""); + const path = this.provider.includes("responses") ? "/responses" : "/chat/completions"; + return `${normalized}${path}`; + } switch (this.provider) { case "claude": case "glm": diff --git a/open-sse/handlers/chatCore.js b/open-sse/handlers/chatCore.js index abcdfd35..18f321c1 100644 --- a/open-sse/handlers/chatCore.js +++ b/open-sse/handlers/chatCore.js @@ -189,10 +189,10 @@ function translateNonStreamingResponse(responseBody, targetFormat, sourceFormat) * Handles different provider response formats */ function extractUsageFromResponse(responseBody, provider) { - if (!responseBody) return null; + if (!responseBody || typeof responseBody !== 'object') return null; // OpenAI format - if (responseBody.usage) { + if (responseBody.usage && typeof responseBody.usage === 'object') { return { prompt_tokens: responseBody.usage.prompt_tokens || 0, completion_tokens: responseBody.usage.completion_tokens || 0, @@ -202,7 +202,7 @@ function extractUsageFromResponse(responseBody, provider) { } // Claude format - if (responseBody.usage?.input_tokens !== undefined || responseBody.usage?.output_tokens !== undefined) { + if (responseBody.usage && typeof responseBody.usage === 'object' && (responseBody.usage.input_tokens !== undefined || responseBody.usage.output_tokens !== undefined)) { return { prompt_tokens: responseBody.usage.input_tokens || 0, completion_tokens: responseBody.usage.output_tokens || 0, @@ -212,7 +212,7 @@ function extractUsageFromResponse(responseBody, provider) { } // Gemini format - if (responseBody.usageMetadata) { + if (responseBody.usageMetadata && typeof responseBody.usageMetadata === 'object') { return { prompt_tokens: responseBody.usageMetadata.promptTokenCount || 0, completion_tokens: responseBody.usageMetadata.candidatesTokenCount || 0, @@ -411,11 +411,11 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred await onRequestSuccess(); } - // Log usage for non-streaming responses +// Log usage for non-streaming responses const usage = extractUsageFromResponse(responseBody, provider); appendRequestLog({ model, provider, connectionId, tokens: usage, status: "200 OK" }).catch(() => { }); - if (usage) { - const msg = `[${new Date().toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit" })}] ๐Ÿ“Š [USAGE] ${provider.toUpperCase()} | in=${usage.prompt_tokens || 0} | out=${usage.completion_tokens || 0}${connectionId ? ` | account=${connectionId.slice(0, 8)}...` : ""}`; + if (usage && typeof usage === 'object') { + const msg = `[${new Date().toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit" })}] ๐Ÿ“Š [USAGE] ${provider.toUpperCase()} | in=${usage?.prompt_tokens || 0} | out=${usage?.completion_tokens || 0}${connectionId ? ` | account=${connectionId.slice(0, 8)}...` : ""}`; console.log(`${COLORS.green}${msg}${COLORS.reset}`); saveRequestUsage({ diff --git a/open-sse/services/provider.js b/open-sse/services/provider.js index 09877836..828537a5 100644 --- a/open-sse/services/provider.js +++ b/open-sse/services/provider.js @@ -1,5 +1,25 @@ import { PROVIDERS } from "../config/constants.js"; +const OPENAI_COMPATIBLE_PREFIX = "openai-compatible-"; +const OPENAI_COMPATIBLE_DEFAULTS = { + baseUrl: "https://api.openai.com/v1", +}; + +function isOpenAICompatible(provider) { + return typeof provider === "string" && provider.startsWith(OPENAI_COMPATIBLE_PREFIX); +} + +function getOpenAICompatibleType(provider) { + if (!isOpenAICompatible(provider)) return "chat"; + return provider.includes("responses") ? "responses" : "chat"; +} + +function buildOpenAICompatibleUrl(baseUrl, apiType) { + const normalized = baseUrl.replace(/\/$/, ""); + const path = apiType === "responses" ? "/responses" : "/chat/completions"; + return `${normalized}${path}`; +} + // Detect request format from body structure export function detectFormat(body) { // OpenAI Responses API: has input[] array instead of messages[] @@ -76,6 +96,14 @@ export function detectFormat(body) { // Get provider config export function getProviderConfig(provider) { + if (isOpenAICompatible(provider)) { + const apiType = getOpenAICompatibleType(provider); + return { + ...PROVIDERS.openai, + format: apiType === "responses" ? "openai-responses" : "openai", + baseUrl: OPENAI_COMPATIBLE_DEFAULTS.baseUrl, + }; + } return PROVIDERS[provider] || PROVIDERS.openai; } @@ -87,6 +115,11 @@ export function getProviderFallbackCount(provider) { // Build provider URL export function buildProviderUrl(provider, model, stream = true, options = {}) { + if (isOpenAICompatible(provider)) { + const apiType = getOpenAICompatibleType(provider); + const baseUrl = options?.baseUrl || OPENAI_COMPATIBLE_DEFAULTS.baseUrl; + return buildOpenAICompatibleUrl(baseUrl, apiType); + } const config = getProviderConfig(provider); switch (provider) { @@ -215,6 +248,9 @@ export function buildProviderHeaders(provider, credentials, stream = true, body // Get target format for provider export function getTargetFormat(provider) { + if (isOpenAICompatible(provider)) { + return getOpenAICompatibleType(provider) === "responses" ? "openai-responses" : "openai"; + } const config = getProviderConfig(provider); return config.format || "openai"; } @@ -242,4 +278,3 @@ export function normalizeThinkingConfig(body) { } return body; } - diff --git a/open-sse/translator/response/claude-to-openai.js b/open-sse/translator/response/claude-to-openai.js index 09ead173..65c87713 100644 --- a/open-sse/translator/response/claude-to-openai.js +++ b/open-sse/translator/response/claude-to-openai.js @@ -110,10 +110,10 @@ export function claudeToOpenAIResponse(chunk, state) { break; } - case "message_stop": { +case "message_stop": { if (!state.finishReasonSent) { const finishReason = state.finishReason || (state.toolCalls?.size > 0 ? "tool_calls" : "stop"); - const usageObj = state.usage ? { + const usageObj = (state.usage && typeof state.usage === 'object') ? { usage: { prompt_tokens: state.usage.input_tokens || 0, completion_tokens: state.usage.output_tokens || 0, diff --git a/open-sse/translator/response/gemini-to-openai.js b/open-sse/translator/response/gemini-to-openai.js index 6abecaa6..b2361450 100644 --- a/open-sse/translator/response/gemini-to-openai.js +++ b/open-sse/translator/response/gemini-to-openai.js @@ -181,9 +181,9 @@ export function geminiToOpenAIResponse(chunk, state) { state.finishReason = finishReason; } - // Usage metadata +// Usage metadata const usage = response.usageMetadata || chunk.usageMetadata; - if (usage) { + if (usage && typeof usage === 'object') { const promptTokens = (usage.promptTokenCount || 0) + (usage.thoughtsTokenCount || 0); state.usage = { prompt_tokens: promptTokens, diff --git a/open-sse/translator/response/kiro-to-openai.js b/open-sse/translator/response/kiro-to-openai.js index 4fe0b624..a0e4aeb1 100644 --- a/open-sse/translator/response/kiro-to-openai.js +++ b/open-sse/translator/response/kiro-to-openai.js @@ -164,14 +164,16 @@ export function convertKiroToOpenAI(chunk, state) { return openaiChunk; } - // Handle usage events +// Handle usage events if (eventType === "usageEvent" || data.usageEvent) { const usage = data.usageEvent || data; - state.usage = { - prompt_tokens: usage.inputTokens || 0, - completion_tokens: usage.outputTokens || 0, - total_tokens: (usage.inputTokens || 0) + (usage.outputTokens || 0) - }; + if (usage && typeof usage === 'object') { + state.usage = { + prompt_tokens: usage.inputTokens || 0, + completion_tokens: usage.outputTokens || 0, + total_tokens: (usage.inputTokens || 0) + (usage.outputTokens || 0) + }; + } return null; } diff --git a/open-sse/utils/stream.js b/open-sse/utils/stream.js index 76ab5521..cebd863b 100644 --- a/open-sse/utils/stream.js +++ b/open-sse/utils/stream.js @@ -13,45 +13,68 @@ function getTimeString() { // Extract usage from any format (Claude, OpenAI, Gemini, Responses API) function extractUsage(chunk) { + if (!chunk || typeof chunk !== "object") return null; + // Claude format (message_delta event) - if (chunk.type === "message_delta" && chunk.usage) { - return { + if (chunk.type === "message_delta" && chunk.usage && typeof chunk.usage === 'object') { + return normalizeUsage({ prompt_tokens: chunk.usage.input_tokens || 0, completion_tokens: chunk.usage.output_tokens || 0, cache_read_input_tokens: chunk.usage.cache_read_input_tokens, cache_creation_input_tokens: chunk.usage.cache_creation_input_tokens - }; + }); } // OpenAI Responses API format (response.completed or response.done) - if ((chunk.type === "response.completed" || chunk.type === "response.done") && chunk.response?.usage) { + if ((chunk.type === "response.completed" || chunk.type === "response.done") && chunk.response?.usage && typeof chunk.response.usage === 'object') { const usage = chunk.response.usage; - return { + return normalizeUsage({ prompt_tokens: usage.input_tokens || usage.prompt_tokens || 0, completion_tokens: usage.output_tokens || usage.completion_tokens || 0, cached_tokens: usage.input_tokens_details?.cached_tokens, reasoning_tokens: usage.output_tokens_details?.reasoning_tokens - }; + }); } // OpenAI format - if (chunk.usage?.prompt_tokens !== undefined) { - return { + if (chunk.usage && typeof chunk.usage === 'object' && chunk.usage.prompt_tokens !== undefined) { + return normalizeUsage({ prompt_tokens: chunk.usage.prompt_tokens, completion_tokens: chunk.usage.completion_tokens || 0, cached_tokens: chunk.usage.prompt_tokens_details?.cached_tokens, reasoning_tokens: chunk.usage.completion_tokens_details?.reasoning_tokens - }; + }); } // Gemini format - if (chunk.usageMetadata) { - return { + if (chunk.usageMetadata && typeof chunk.usageMetadata === 'object') { + return normalizeUsage({ prompt_tokens: chunk.usageMetadata.promptTokenCount || 0, completion_tokens: chunk.usageMetadata.candidatesTokenCount || 0, reasoning_tokens: chunk.usageMetadata.thoughtsTokenCount - }; + }); } return null; } +function normalizeUsage(usage) { + if (!usage || typeof usage !== "object" || Array.isArray(usage)) return null; + + const normalized = {}; + const assignNumber = (key, value) => { + if (value === undefined || value === null) return; + const numeric = Number(value); + if (Number.isFinite(numeric)) normalized[key] = numeric; + }; + + assignNumber("prompt_tokens", usage?.prompt_tokens); + assignNumber("completion_tokens", usage?.completion_tokens); + assignNumber("cache_read_input_tokens", usage?.cache_read_input_tokens); + assignNumber("cache_creation_input_tokens", usage?.cache_creation_input_tokens); + assignNumber("cached_tokens", usage?.cached_tokens); + assignNumber("reasoning_tokens", usage?.reasoning_tokens); + + if (Object.keys(normalized).length === 0) return null; + return normalized; +} + // ANSI color codes export const COLORS = { reset: "\x1b[0m", @@ -64,11 +87,11 @@ export const COLORS = { // Log usage with cache info (green color) function logUsage(provider, usage, model = null, connectionId = null) { - if (!usage) return; + if (!usage || typeof usage !== 'object') return; const p = provider?.toUpperCase() || "UNKNOWN"; - const inTokens = usage.prompt_tokens || 0; - const outTokens = usage.completion_tokens || 0; + const inTokens = usage?.prompt_tokens || 0; + const outTokens = usage?.completion_tokens || 0; let msg = `[${getTimeString()}] ๐Ÿ“Š [USAGE] ${p} | in=${inTokens} | out=${outTokens}`; if (connectionId) msg += ` | account=${connectionId.slice(0, 8)}...`; @@ -274,7 +297,7 @@ export function createSSEStream(options = {}) { reqLogger?.appendConvertedChunk?.(output); controller.enqueue(sharedEncoder.encode(output)); } - if (usage) { +if (usage && typeof usage === 'object') { logUsage(provider, usage, model, connectionId); } else { // No usage data available - still mark request as completed @@ -331,7 +354,7 @@ export function createSSEStream(options = {}) { reqLogger?.appendConvertedChunk?.(doneOutput); controller.enqueue(sharedEncoder.encode(doneOutput)); - if (state?.usage) { +if (state?.usage && typeof state.usage === 'object') { logUsage(state.provider || targetFormat, state.usage, model, connectionId); } else { // No usage data available - still mark request as completed diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.js b/src/app/(dashboard)/dashboard/providers/[id]/page.js index d487f8f9..9ab80424 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.js +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.js @@ -2,30 +2,47 @@ import { useState, useEffect, useCallback } from "react"; import PropTypes from "prop-types"; -import { useParams } from "next/navigation"; +import { useParams, useRouter } from "next/navigation"; import Link from "next/link"; import Image from "next/image"; -import { Card, Button, Badge, Input, Modal, CardSkeleton, OAuthModal, KiroOAuthWrapper, Toggle } from "@/shared/components"; -import { OAUTH_PROVIDERS, APIKEY_PROVIDERS, getProviderAlias } from "@/shared/constants/providers"; +import { Card, Button, Badge, Input, Modal, CardSkeleton, OAuthModal, KiroOAuthWrapper, Toggle, Select } from "@/shared/components"; +import { OAUTH_PROVIDERS, APIKEY_PROVIDERS, getProviderAlias, isOpenAICompatibleProvider } from "@/shared/constants/providers"; import { getModelsByProviderId } from "@/shared/constants/models"; import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; export default function ProviderDetailPage() { const params = useParams(); + const router = useRouter(); const providerId = params.id; const [connections, setConnections] = useState([]); const [loading, setLoading] = useState(true); + const [providerNode, setProviderNode] = useState(null); const [showOAuthModal, setShowOAuthModal] = useState(false); const [showAddApiKeyModal, setShowAddApiKeyModal] = useState(false); const [showEditModal, setShowEditModal] = useState(false); + const [showEditNodeModal, setShowEditNodeModal] = useState(false); const [selectedConnection, setSelectedConnection] = useState(null); const [modelAliases, setModelAliases] = useState({}); const { copied, copy } = useCopyToClipboard(); - const providerInfo = OAUTH_PROVIDERS[providerId] || APIKEY_PROVIDERS[providerId]; + const providerInfo = providerNode + ? { + id: providerNode.id, + name: providerNode.name || "OpenAI Compatible", + color: "#10A37F", + textIcon: "OC", + apiType: providerNode.apiType, + baseUrl: providerNode.baseUrl, + } + : (OAUTH_PROVIDERS[providerId] || APIKEY_PROVIDERS[providerId]); const isOAuth = !!OAUTH_PROVIDERS[providerId]; const models = getModelsByProviderId(providerId); const providerAlias = getProviderAlias(providerId); + const isOpenAICompatible = isOpenAICompatibleProvider(providerId); + const providerStorageAlias = isOpenAICompatible ? providerId : providerAlias; + const providerDisplayAlias = isOpenAICompatible + ? (providerNode?.prefix || providerId) + : providerAlias; // Define callbacks BEFORE the useEffect that uses them const fetchAliases = useCallback(async () => { @@ -42,12 +59,20 @@ export default function ProviderDetailPage() { const fetchConnections = useCallback(async () => { try { - const res = await fetch("/api/providers"); - const data = await res.json(); - if (res.ok) { - const filtered = (data.connections || []).filter(c => c.provider === providerId); + const [connectionsRes, nodesRes] = await Promise.all([ + fetch("/api/providers"), + fetch("/api/provider-nodes"), + ]); + const connectionsData = await connectionsRes.json(); + const nodesData = await nodesRes.json(); + if (connectionsRes.ok) { + const filtered = (connectionsData.connections || []).filter(c => c.provider === providerId); setConnections(filtered); } + if (nodesRes.ok) { + const node = (nodesData.nodes || []).find((entry) => entry.id === providerId) || null; + setProviderNode(node); + } } catch (error) { console.log("Error fetching connections:", error); } finally { @@ -55,13 +80,31 @@ export default function ProviderDetailPage() { } }, [providerId]); + const handleUpdateNode = async (formData) => { + try { + const res = await fetch(`/api/provider-nodes/${providerId}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(formData), + }); + const data = await res.json(); + if (res.ok) { + setProviderNode(data.node); + await fetchConnections(); + setShowEditNodeModal(false); + } + } catch (error) { + console.log("Error updating provider node:", error); + } + }; + useEffect(() => { fetchConnections(); fetchAliases(); }, [fetchConnections, fetchAliases]); - const handleSetAlias = async (modelId, alias) => { - const fullModel = `${providerAlias}/${modelId}`; + const handleSetAlias = async (modelId, alias, providerAliasOverride = providerAlias) => { + const fullModel = `${providerAliasOverride}/${modelId}`; try { const res = await fetch("/api/models/alias", { method: "PUT", @@ -194,6 +237,20 @@ export default function ProviderDetailPage() { }; const renderModelsSection = () => { + if (isOpenAICompatible) { + return ( + + ); + } if (providerInfo.passthroughModels) { return ( {models.map((model) => { - const fullModel = `${providerAlias}/${model.id}`; + const fullModel = `${providerStorageAlias}/${model.id}`; const oldFormatModel = `${providerId}/${model.id}`; const existingAlias = Object.entries(modelAliases).find( ([, m]) => m === fullModel || m === oldFormatModel @@ -221,11 +278,11 @@ export default function ProviderDetailPage() { handleSetAlias(model.id, alias)} + onSetAlias={(alias) => handleSetAlias(model.id, alias, providerStorageAlias)} onDeleteAlias={() => handleDeleteAlias(existingAlias)} /> ); @@ -234,6 +291,15 @@ export default function ProviderDetailPage() { ); }; + if (loading) { + return ( +
+ + +
+ ); + } + if (!providerInfo) { return (
@@ -245,15 +311,6 @@ export default function ProviderDetailPage() { ); } - if (loading) { - return ( -
- - -
- ); - } - return (
{/* Header */} @@ -270,15 +327,21 @@ export default function ProviderDetailPage() { className="rounded-lg flex items-center justify-center" style={{ backgroundColor: `${providerInfo.color}15` }} > - {providerInfo.name} { e.currentTarget.style.display = "none"; }} - /> + {providerInfo.textIcon ? ( + + {providerInfo.textIcon} + + ) : ( + {providerInfo.name} { e.currentTarget.style.display = "none"; }} + /> + )}

{providerInfo.name}

@@ -289,17 +352,74 @@ export default function ProviderDetailPage() {
+ {isOpenAICompatible && providerNode && ( + +
+
+

OpenAI Compatible Details

+

+ {providerNode.apiType === "responses" ? "Responses API" : "Chat Completions"} ยท {(providerNode.baseUrl || "").replace(/\/$/, "")}/ + {providerNode.apiType === "responses" ? "responses" : "chat/completions"} +

+
+
+ + + +
+
+ {connections.length > 0 && ( +

+ Only one connection is allowed per OpenAI Compatible node. Add another node if you need more connections. +

+ )} +
+ )} + {/* Connections */}

Connections

- + {!isOpenAICompatible && ( + + )}
{connections.length === 0 ? ( @@ -309,9 +429,11 @@ export default function ProviderDetailPage() {

No connections yet

Add your first connection to get started

- + {!isOpenAICompatible && ( + + )} ) : (
@@ -367,6 +489,8 @@ export default function ProviderDetailPage() { setShowAddApiKeyModal(false)} /> @@ -376,6 +500,12 @@ export default function ProviderDetailPage() { onSave={handleUpdateConnection} onClose={() => setShowEditModal(false)} /> + setShowEditNodeModal(false)} + />
); } @@ -546,6 +676,158 @@ PassthroughModelRow.propTypes = { onDeleteAlias: PropTypes.func.isRequired, }; +function OpenAICompatibleModelsSection({ providerStorageAlias, providerDisplayAlias, modelAliases, copied, onCopy, onSetAlias, onDeleteAlias, connections }) { + const [newModel, setNewModel] = useState(""); + const [adding, setAdding] = useState(false); + const [importing, setImporting] = useState(false); + + const providerAliases = Object.entries(modelAliases).filter( + ([, model]) => model.startsWith(`${providerStorageAlias}/`) + ); + + const allModels = providerAliases.map(([alias, fullModel]) => ({ + modelId: fullModel.replace(`${providerStorageAlias}/`, ""), + fullModel, + alias, + })); + + const generateDefaultAlias = (modelId) => { + const parts = modelId.split("/"); + return parts[parts.length - 1]; + }; + + const resolveAlias = (modelId) => { + const baseAlias = generateDefaultAlias(modelId); + if (!modelAliases[baseAlias]) return baseAlias; + const prefixedAlias = `${providerDisplayAlias}-${baseAlias}`; + if (!modelAliases[prefixedAlias]) return prefixedAlias; + return null; + }; + + const handleAdd = async () => { + if (!newModel.trim() || adding) return; + const modelId = newModel.trim(); + const resolvedAlias = resolveAlias(modelId); + if (!resolvedAlias) { + alert("All suggested aliases already exist. Please choose a different model or remove conflicting aliases."); + return; + } + + setAdding(true); + try { + await onSetAlias(modelId, resolvedAlias, providerStorageAlias); + setNewModel(""); + } catch (error) { + console.log("Error adding model:", error); + } finally { + setAdding(false); + } + }; + + const handleImport = async () => { + if (importing) return; + const activeConnection = connections.find((conn) => conn.isActive !== false); + if (!activeConnection) return; + + setImporting(true); + try { + const res = await fetch(`/api/providers/${activeConnection.id}/models`); + const data = await res.json(); + if (!res.ok) { + alert(data.error || "Failed to import models"); + return; + } + const models = data.models || []; + if (models.length === 0) { + alert("No models returned from /models."); + return; + } + let importedCount = 0; + for (const model of models) { + const modelId = model.id || model.name || model.model; + if (!modelId) continue; + const resolvedAlias = resolveAlias(modelId); + if (!resolvedAlias) continue; + await onSetAlias(modelId, resolvedAlias, providerStorageAlias); + importedCount += 1; + } + if (importedCount === 0) { + alert("No new models were added."); + } + } catch (error) { + console.log("Error importing models:", error); + } finally { + setImporting(false); + } + }; + + const canImport = connections.some((conn) => conn.isActive !== false); + + return ( +
+

+ Add OpenAI-compatible models manually or import them from the /models endpoint. +

+ +
+
+ + setNewModel(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleAdd()} + placeholder="gpt-4o" + className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary" + /> +
+ + +
+ + {!canImport && ( +

+ Add a connection to enable importing models. +

+ )} + + {allModels.length > 0 && ( +
+ {allModels.map(({ modelId, fullModel, alias }) => ( + onDeleteAlias(alias)} + /> + ))} +
+ )} +
+ ); +} + +OpenAICompatibleModelsSection.propTypes = { + providerStorageAlias: PropTypes.string.isRequired, + providerDisplayAlias: PropTypes.string.isRequired, + modelAliases: PropTypes.object.isRequired, + copied: PropTypes.string, + onCopy: PropTypes.func.isRequired, + onSetAlias: PropTypes.func.isRequired, + onDeleteAlias: PropTypes.func.isRequired, + connections: PropTypes.arrayOf(PropTypes.shape({ + id: PropTypes.string, + isActive: PropTypes.bool, + })).isRequired, +}; + function CooldownTimer({ until }) { const [remaining, setRemaining] = useState(""); @@ -706,7 +988,7 @@ ConnectionRow.propTypes = { onDelete: PropTypes.func.isRequired, }; -function AddApiKeyModal({ isOpen, provider, onSave, onClose }) { +function AddApiKeyModal({ isOpen, provider, providerName, isOpenAICompatible, onSave, onClose }) { const [formData, setFormData] = useState({ name: "", apiKey: "", @@ -744,7 +1026,7 @@ function AddApiKeyModal({ isOpen, provider, onSave, onClose }) { if (!provider) return null; return ( - +
)} + {isOpenAICompatible && ( +

+ Validation checks {providerName || "OpenAI Compatible"} via /models on your base URL. +

+ )} { if (connection) { setFormData({ name: connection.name || "", priority: connection.priority || 1, + apiKey: "", }); setTestResult(null); + setValidationResult(null); } }, [connection]); @@ -830,14 +1124,42 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }) { } }; + const handleValidate = async () => { + if (!connection?.provider || !formData.apiKey) return; + setValidating(true); + setValidationResult(null); + try { + const res = await fetch("/api/providers/validate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ provider: connection.provider, apiKey: formData.apiKey }), + }); + const data = await res.json(); + setValidationResult(data.valid ? "success" : "failed"); + } catch { + setValidationResult("failed"); + } finally { + setValidating(false); + } + }; + const handleSubmit = () => { const updates = { name: formData.name, priority: formData.priority }; + if (!isOAuth && formData.apiKey) { + updates.apiKey = formData.apiKey; + if (validationResult === "success") { + updates.testStatus = "active"; + updates.lastError = null; + updates.lastErrorAt = null; + } + } onSave(updates); }; if (!connection) return null; const isOAuth = connection.authType === "oauth"; + const isCompatible = isOpenAICompatibleProvider(connection.provider); return ( @@ -860,18 +1182,45 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }) { value={formData.priority} onChange={(e) => setFormData({ ...formData, priority: Number.parseInt(e.target.value) || 1 })} /> + {!isOAuth && ( + <> +
+ setFormData({ ...formData, apiKey: e.target.value })} + placeholder="Enter new API key" + hint="Leave blank to keep the current API key." + className="flex-1" + /> +
+ +
+
+ {validationResult && ( + + {validationResult === "success" ? "Valid" : "Invalid"} + + )} + + )} {/* Test Connection */} -
- - {testResult && ( - - {testResult === "success" ? "Valid" : "Failed"} - - )} -
+ {!isCompatible && ( +
+ + {testResult && ( + + {testResult === "success" ? "Valid" : "Failed"} + + )} +
+ )}
@@ -895,3 +1244,140 @@ EditConnectionModal.propTypes = { onSave: PropTypes.func.isRequired, onClose: PropTypes.func.isRequired, }; + +function EditOpenAICompatibleModal({ isOpen, node, onSave, onClose }) { + const [formData, setFormData] = useState({ + name: "", + prefix: "", + apiType: "chat", + baseUrl: "https://api.openai.com/v1", + }); + const [saving, setSaving] = useState(false); + const [checkKey, setCheckKey] = useState(""); + const [validating, setValidating] = useState(false); + const [validationResult, setValidationResult] = useState(null); + + useEffect(() => { + if (node) { + setFormData({ + name: node.name || "", + prefix: node.prefix || "", + apiType: node.apiType || "chat", + baseUrl: node.baseUrl || "https://api.openai.com/v1", + }); + } + }, [node]); + + const apiTypeOptions = [ + { value: "chat", label: "Chat Completions" }, + { value: "responses", label: "Responses API" }, + ]; + + const handleSubmit = async () => { + if (!formData.name.trim() || !formData.prefix.trim() || !formData.baseUrl.trim()) return; + setSaving(true); + try { + await onSave({ + name: formData.name, + prefix: formData.prefix, + apiType: formData.apiType, + baseUrl: formData.baseUrl, + }); + } finally { + setSaving(false); + } + }; + + const handleValidate = async () => { + setValidating(true); + try { + const res = await fetch("/api/provider-nodes/validate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ baseUrl: formData.baseUrl, apiKey: checkKey }), + }); + const data = await res.json(); + setValidationResult(data.valid ? "success" : "failed"); + } catch { + setValidationResult("failed"); + } finally { + setValidating(false); + } + }; + + if (!node) return null; + + return ( + +
+ setFormData({ ...formData, name: e.target.value })} + placeholder="OpenAI Compatible (Prod)" + hint="Required. A friendly label for this node." + /> + setFormData({ ...formData, prefix: e.target.value })} + placeholder="oc-prod" + hint="Required. Used as the provider prefix for model IDs." + /> + setFormData({ ...formData, baseUrl: e.target.value })} + placeholder="https://api.openai.com/v1" + hint="Use the base URL (ending in /v1) for your OpenAI-compatible API." + /> +
+ setCheckKey(e.target.value)} + className="flex-1" + /> +
+ +
+
+ {validationResult && ( + + {validationResult === "success" ? "Valid" : "Invalid"} + + )} +
+ + +
+
+
+ ); +} + +EditOpenAICompatibleModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + node: PropTypes.shape({ + id: PropTypes.string, + name: PropTypes.string, + prefix: PropTypes.string, + apiType: PropTypes.string, + baseUrl: PropTypes.string, + }), + onSave: PropTypes.func.isRequired, + onClose: PropTypes.func.isRequired, +}; diff --git a/src/app/(dashboard)/dashboard/providers/page.js b/src/app/(dashboard)/dashboard/providers/page.js index 402b6510..2f7435d0 100644 --- a/src/app/(dashboard)/dashboard/providers/page.js +++ b/src/app/(dashboard)/dashboard/providers/page.js @@ -3,8 +3,9 @@ import { useState, useEffect } from "react"; import Image from "next/image"; import PropTypes from "prop-types"; -import { Card, CardSkeleton, Badge } from "@/shared/components"; +import { Card, CardSkeleton, Badge, Button, Input, Modal, Select } from "@/shared/components"; import { OAUTH_PROVIDERS, APIKEY_PROVIDERS } from "@/shared/constants/config"; +import { OPENAI_COMPATIBLE_PREFIX } from "@/shared/constants/providers"; import Link from "next/link"; import { getErrorCode, getRelativeTime } from "@/shared/utils"; @@ -34,14 +35,21 @@ function getStatusDisplay(connected, error, errorCode) { export default function ProvidersPage() { const [connections, setConnections] = useState([]); + const [providerNodes, setProviderNodes] = useState([]); const [loading, setLoading] = useState(true); + const [showAddCompatibleModal, setShowAddCompatibleModal] = useState(false); useEffect(() => { const fetchData = async () => { try { - const res = await fetch("/api/providers"); - const data = await res.json(); - if (res.ok) setConnections(data.connections || []); + const [connectionsRes, nodesRes] = await Promise.all([ + fetch("/api/providers"), + fetch("/api/provider-nodes"), + ]); + const connectionsData = await connectionsRes.json(); + const nodesData = await nodesRes.json(); + if (connectionsRes.ok) setConnections(connectionsData.connections || []); + if (nodesRes.ok) setProviderNodes(nodesData.nodes || []); } catch (error) { console.log("Error fetching data:", error); } finally { @@ -85,6 +93,24 @@ export default function ProvidersPage() { return { connected, error, total, errorCode, errorTime }; }; + const compatibleProviders = providerNodes + .filter((node) => node.type === "openai-compatible") + .map((node) => ({ + id: node.id, + name: node.name || "OpenAI Compatible", + color: "#10A37F", + textIcon: "OC", + apiType: node.apiType, + })); + + const apiKeyProviders = { + ...APIKEY_PROVIDERS, + ...compatibleProviders.reduce((acc, provider) => { + acc[provider.id] = provider; + return acc; + }, {}), + }; + if (loading) { return (
@@ -113,9 +139,14 @@ export default function ProvidersPage() { {/* API Key Providers */}
-

API Key Providers

+
+

API Key Providers

+ +
- {Object.entries(APIKEY_PROVIDERS).map(([key, info]) => ( + {Object.entries(apiKeyProviders).map(([key, info]) => (
+ setShowAddCompatibleModal(false)} + onCreated={(node) => { + setProviderNodes((prev) => [...prev, node]); + setShowAddCompatibleModal(false); + }} + />
); } @@ -197,6 +236,7 @@ ProviderCard.propTypes = { // API Key providers - only use textIcon, no image function ApiKeyProviderCard({ providerId, provider, stats }) { const { connected, error, errorCode, errorTime } = stats; + const isCompatible = providerId.startsWith(OPENAI_COMPATIBLE_PREFIX); return ( @@ -218,6 +258,11 @@ function ApiKeyProviderCard({ providerId, provider, stats }) {

{provider.name}

{getStatusDisplay(connected, error, errorCode)} + {isCompatible && ( + + {provider.apiType === "responses" ? "Responses" : "Chat"} + + )} {errorTime && โ€ข {errorTime}}
@@ -238,6 +283,7 @@ ApiKeyProviderCard.propTypes = { name: PropTypes.string.isRequired, color: PropTypes.string, textIcon: PropTypes.string, + apiType: PropTypes.string, }).isRequired, stats: PropTypes.shape({ connected: PropTypes.number, @@ -246,3 +292,146 @@ ApiKeyProviderCard.propTypes = { errorTime: PropTypes.string, }).isRequired, }; + +function AddOpenAICompatibleModal({ isOpen, onClose, onCreated }) { + const [formData, setFormData] = useState({ + name: "", + prefix: "", + apiType: "chat", + baseUrl: "https://api.openai.com/v1", + }); + const [submitting, setSubmitting] = useState(false); + const [checkKey, setCheckKey] = useState(""); + const [validating, setValidating] = useState(false); + const [validationResult, setValidationResult] = useState(null); + + const apiTypeOptions = [ + { value: "chat", label: "Chat Completions" }, + { value: "responses", label: "Responses API" }, + ]; + + useEffect(() => { + const defaultBaseUrl = "https://api.openai.com/v1"; + setFormData((prev) => ({ + ...prev, + baseUrl: defaultBaseUrl, + })); + }, [formData.apiType]); + + const handleSubmit = async () => { + if (!formData.name.trim() || !formData.prefix.trim() || !formData.baseUrl.trim()) return; + setSubmitting(true); + try { + const res = await fetch("/api/provider-nodes", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: formData.name, + prefix: formData.prefix, + apiType: formData.apiType, + baseUrl: formData.baseUrl, + }), + }); + const data = await res.json(); + if (res.ok) { + onCreated(data.node); + setFormData({ + name: "", + prefix: "", + apiType: "chat", + baseUrl: "https://api.openai.com/v1", + }); + setCheckKey(""); + setValidationResult(null); + } + } catch (error) { + console.log("Error creating OpenAI Compatible node:", error); + } finally { + setSubmitting(false); + } + }; + + const handleValidate = async () => { + setValidating(true); + try { + const res = await fetch("/api/provider-nodes/validate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ baseUrl: formData.baseUrl, apiKey: checkKey }), + }); + const data = await res.json(); + setValidationResult(data.valid ? "success" : "failed"); + } catch { + setValidationResult("failed"); + } finally { + setValidating(false); + } + }; + + return ( + +
+ setFormData({ ...formData, name: e.target.value })} + placeholder="OpenAI Compatible (Prod)" + hint="Required. A friendly label for this node." + /> + setFormData({ ...formData, prefix: e.target.value })} + placeholder="oc-prod" + hint="Required. Used as the provider prefix for model IDs." + /> + setFormData({ ...formData, baseUrl: e.target.value })} + placeholder="https://api.openai.com/v1" + hint="Use the base URL (ending in /v1) for your OpenAI-compatible API." + /> +
+ setCheckKey(e.target.value)} + className="flex-1" + /> +
+ +
+
+ {validationResult && ( + + {validationResult === "success" ? "Valid" : "Invalid"} + + )} +
+ + +
+
+
+ ); +} + +AddOpenAICompatibleModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onClose: PropTypes.func.isRequired, + onCreated: PropTypes.func.isRequired, +}; diff --git a/src/app/api/provider-nodes/[id]/route.js b/src/app/api/provider-nodes/[id]/route.js new file mode 100644 index 00000000..7d5027a7 --- /dev/null +++ b/src/app/api/provider-nodes/[id]/route.js @@ -0,0 +1,77 @@ +import { NextResponse } from "next/server"; +import { deleteProviderConnectionsByProvider, deleteProviderNode, getProviderConnections, getProviderNodeById, updateProviderConnection, updateProviderNode } from "@/models"; + +// PUT /api/provider-nodes/[id] - Update provider node +export async function PUT(request, { params }) { + try { + const { id } = await params; + const body = await request.json(); + const { name, prefix, apiType, baseUrl } = body; + const node = await getProviderNodeById(id); + + if (!node) { + return NextResponse.json({ error: "Provider node not found" }, { status: 404 }); + } + + if (!name?.trim()) { + return NextResponse.json({ error: "Name is required" }, { status: 400 }); + } + + if (!prefix?.trim()) { + return NextResponse.json({ error: "Prefix is required" }, { status: 400 }); + } + + if (!apiType || !["chat", "responses"].includes(apiType)) { + return NextResponse.json({ error: "Invalid OpenAI compatible API type" }, { status: 400 }); + } + + if (!baseUrl?.trim()) { + return NextResponse.json({ error: "Base URL is required" }, { status: 400 }); + } + + const updated = await updateProviderNode(id, { + name: name.trim(), + prefix: prefix.trim(), + apiType, + baseUrl: baseUrl.trim(), + }); + + const connections = await getProviderConnections({ provider: id }); + await Promise.all(connections.map((connection) => ( + updateProviderConnection(connection.id, { + providerSpecificData: { + ...(connection.providerSpecificData || {}), + prefix: prefix.trim(), + apiType, + baseUrl: baseUrl.trim(), + nodeName: updated.name, + } + }) + ))); + + return NextResponse.json({ node: updated }); + } catch (error) { + console.log("Error updating provider node:", error); + return NextResponse.json({ error: "Failed to update provider node" }, { status: 500 }); + } +} + +// DELETE /api/provider-nodes/[id] - Delete provider node and its connections +export async function DELETE(request, { params }) { + try { + const { id } = await params; + const node = await getProviderNodeById(id); + + if (!node) { + return NextResponse.json({ error: "Provider node not found" }, { status: 404 }); + } + + await deleteProviderConnectionsByProvider(id); + await deleteProviderNode(id); + + return NextResponse.json({ success: true }); + } catch (error) { + console.log("Error deleting provider node:", error); + return NextResponse.json({ error: "Failed to delete provider node" }, { status: 500 }); + } +} diff --git a/src/app/api/provider-nodes/route.js b/src/app/api/provider-nodes/route.js new file mode 100644 index 00000000..de02431b --- /dev/null +++ b/src/app/api/provider-nodes/route.js @@ -0,0 +1,52 @@ +import { NextResponse } from "next/server"; +import { createProviderNode, getProviderNodes } from "@/models"; +import { OPENAI_COMPATIBLE_PREFIX } from "@/shared/constants/providers"; + +const OPENAI_COMPATIBLE_DEFAULTS = { + baseUrl: "https://api.openai.com/v1", +}; + +// GET /api/provider-nodes - List all provider nodes +export async function GET() { + try { + const nodes = await getProviderNodes(); + return NextResponse.json({ nodes }); + } catch (error) { + console.log("Error fetching provider nodes:", error); + return NextResponse.json({ error: "Failed to fetch provider nodes" }, { status: 500 }); + } +} + +// POST /api/provider-nodes - Create provider node +export async function POST(request) { + try { + const body = await request.json(); + const { name, prefix, apiType, baseUrl } = body; + + if (!name?.trim()) { + return NextResponse.json({ error: "Name is required" }, { status: 400 }); + } + + if (!prefix?.trim()) { + return NextResponse.json({ error: "Prefix is required" }, { status: 400 }); + } + + if (!apiType || !["chat", "responses"].includes(apiType)) { + return NextResponse.json({ error: "Invalid OpenAI compatible API type" }, { status: 400 }); + } + + const node = await createProviderNode({ + id: `${OPENAI_COMPATIBLE_PREFIX}${apiType}-${crypto.randomUUID()}`, + type: "openai-compatible", + prefix: prefix.trim(), + apiType, + baseUrl: (baseUrl || OPENAI_COMPATIBLE_DEFAULTS.baseUrl).trim(), + name: name.trim(), + }); + + return NextResponse.json({ node }, { status: 201 }); + } catch (error) { + console.log("Error creating provider node:", error); + return NextResponse.json({ error: "Failed to create provider node" }, { status: 500 }); + } +} diff --git a/src/app/api/provider-nodes/validate/route.js b/src/app/api/provider-nodes/validate/route.js new file mode 100644 index 00000000..fdc12df0 --- /dev/null +++ b/src/app/api/provider-nodes/validate/route.js @@ -0,0 +1,23 @@ +import { NextResponse } from "next/server"; + +// POST /api/provider-nodes/validate - Validate API key against base URL /models +export async function POST(request) { + try { + const body = await request.json(); + const { baseUrl, apiKey } = body; + + if (!baseUrl || !apiKey) { + return NextResponse.json({ error: "Base URL and API key required" }, { status: 400 }); + } + + const modelsUrl = `${baseUrl.replace(/\/$/, "")}/models`; + const res = await fetch(modelsUrl, { + headers: { "Authorization": `Bearer ${apiKey}` }, + }); + + return NextResponse.json({ valid: res.ok, error: res.ok ? null : "Invalid API key" }); + } catch (error) { + console.log("Error validating OpenAI compatible base URL:", error); + return NextResponse.json({ error: "Validation failed" }, { status: 500 }); + } +} diff --git a/src/app/api/providers/[id]/models/route.js b/src/app/api/providers/[id]/models/route.js index a514eec6..7745b916 100644 --- a/src/app/api/providers/[id]/models/route.js +++ b/src/app/api/providers/[id]/models/route.js @@ -1,5 +1,6 @@ import { NextResponse } from "next/server"; import { getProviderConnectionById } from "@/models"; +import { isOpenAICompatibleProvider } from "@/shared/constants/providers"; // Provider models endpoints configuration const PROVIDER_MODELS_CONFIG = { @@ -85,6 +86,39 @@ export async function GET(request, { params }) { return NextResponse.json({ error: "Connection not found" }, { status: 404 }); } + if (isOpenAICompatibleProvider(connection.provider)) { + const baseUrl = connection.providerSpecificData?.baseUrl; + if (!baseUrl) { + return NextResponse.json({ error: "No base URL configured for OpenAI compatible provider" }, { status: 400 }); + } + const url = `${baseUrl.replace(/\/$/, "")}/models`; + const response = await fetch(url, { + method: "GET", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${connection.apiKey}`, + }, + }); + + if (!response.ok) { + const errorText = await response.text(); + console.log(`Error fetching models from ${connection.provider}:`, errorText); + return NextResponse.json( + { error: `Failed to fetch models: ${response.status}` }, + { status: response.status } + ); + } + + const data = await response.json(); + const models = data.data || data.models || []; + + return NextResponse.json({ + provider: connection.provider, + connectionId: connection.id, + models + }); + } + const config = PROVIDER_MODELS_CONFIG[connection.provider]; if (!config) { return NextResponse.json( @@ -145,4 +179,3 @@ export async function GET(request, { params }) { return NextResponse.json({ error: "Failed to fetch models" }, { status: 500 }); } } - diff --git a/src/app/api/providers/[id]/route.js b/src/app/api/providers/[id]/route.js index 5d1b1477..2951b833 100644 --- a/src/app/api/providers/[id]/route.js +++ b/src/app/api/providers/[id]/route.js @@ -32,7 +32,7 @@ export async function PUT(request, { params }) { try { const { id } = await params; const body = await request.json(); - const { name, priority, globalPriority, defaultModel, isActive, apiKey } = body; + const { name, priority, globalPriority, defaultModel, isActive, apiKey, testStatus, lastError, lastErrorAt } = body; const existing = await getProviderConnectionById(id); if (!existing) { @@ -46,6 +46,9 @@ export async function PUT(request, { params }) { if (defaultModel !== undefined) updateData.defaultModel = defaultModel; if (isActive !== undefined) updateData.isActive = isActive; if (apiKey && existing.authType === "apikey") updateData.apiKey = apiKey; + if (testStatus !== undefined) updateData.testStatus = testStatus; + if (lastError !== undefined) updateData.lastError = lastError; + if (lastErrorAt !== undefined) updateData.lastErrorAt = lastErrorAt; const updated = await updateProviderConnection(id, updateData); diff --git a/src/app/api/providers/[id]/test/route.js b/src/app/api/providers/[id]/test/route.js index c9ef8ed7..77abde05 100644 --- a/src/app/api/providers/[id]/test/route.js +++ b/src/app/api/providers/[id]/test/route.js @@ -2,6 +2,7 @@ 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 } from "@/shared/constants/providers"; import { GEMINI_CONFIG, ANTIGRAVITY_CONFIG, @@ -68,9 +69,9 @@ const OAUTH_TEST_CONFIG = { 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") { @@ -85,9 +86,9 @@ async function refreshOAuthToken(connection) { refresh_token: refreshToken, }), }); - + if (!response.ok) return null; - + const data = await response.json(); return { accessToken: data.access_token, @@ -95,7 +96,7 @@ async function refreshOAuthToken(connection) { refreshToken: data.refresh_token || refreshToken, }; } - + // OpenAI/Codex if (provider === "codex") { const response = await fetch(CODEX_CONFIG.tokenUrl, { @@ -107,9 +108,9 @@ async function refreshOAuthToken(connection) { refresh_token: refreshToken, }), }); - + if (!response.ok) return null; - + const data = await response.json(); return { accessToken: data.access_token, @@ -117,11 +118,11 @@ async function refreshOAuthToken(connection) { 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`; @@ -135,13 +136,13 @@ async function refreshOAuthToken(connection) { 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, @@ -149,20 +150,20 @@ async function refreshOAuthToken(connection) { 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, @@ -170,7 +171,7 @@ async function refreshOAuthToken(connection) { refreshToken: data.refreshToken || refreshToken, }; } - + return null; } catch (err) { console.log(`Error refreshing ${provider} token:`, err.message); @@ -195,7 +196,7 @@ async function syncToCloudIfEnabled() { try { const cloudEnabled = await isCloudEnabled(); if (!cloudEnabled) return; - + const machineId = await getConsistentMachineId(); await syncToCloud(machineId); } catch (error) { @@ -210,20 +211,20 @@ async function syncToCloudIfEnabled() { */ 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) { @@ -237,7 +238,7 @@ async function testOAuthConnection(connection) { 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 @@ -250,23 +251,23 @@ async function testOAuthConnection(connection) { } 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); @@ -279,21 +280,21 @@ async function testOAuthConnection(connection) { ...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 }; @@ -304,6 +305,23 @@ async function testOAuthConnection(connection) { * 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 }; + } + } + try { switch (connection.provider) { case "openai": { @@ -312,7 +330,7 @@ async function testApiKeyConnection(connection) { }); 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", @@ -330,19 +348,19 @@ async function testApiKeyConnection(connection) { 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", { @@ -361,7 +379,7 @@ async function testApiKeyConnection(connection) { const valid = res.status !== 401 && res.status !== 403; return { valid, error: valid ? null : "Invalid API key" }; } - + case "minimax": { // MiniMax uses Claude-compatible API const res = await fetch("https://api.minimax.io/anthropic/v1/messages", { @@ -380,7 +398,7 @@ async function testApiKeyConnection(connection) { 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", { @@ -399,35 +417,35 @@ async function testApiKeyConnection(connection) { 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" }; } @@ -447,7 +465,7 @@ export async function POST(request, { params }) { } let result; - + if (connection.authType === "apikey") { result = await testApiKeyConnection(connection); } else { @@ -460,7 +478,7 @@ export async function POST(request, { params }) { 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; @@ -474,7 +492,7 @@ export async function POST(request, { params }) { // Update status in db await updateProviderConnection(id, updateData); - + // Sync to cloud if token was refreshed if (result.refreshed) { await syncToCloudIfEnabled(); @@ -490,4 +508,3 @@ export async function POST(request, { params }) { return NextResponse.json({ error: "Test failed" }, { status: 500 }); } } - diff --git a/src/app/api/providers/route.js b/src/app/api/providers/route.js index 6b1fbc77..8f621d20 100644 --- a/src/app/api/providers/route.js +++ b/src/app/api/providers/route.js @@ -1,6 +1,7 @@ import { NextResponse } from "next/server"; -import { getProviderConnections, createProviderConnection, isCloudEnabled } from "@/models"; +import { getProviderConnections, createProviderConnection, getProviderNodeById, isCloudEnabled } from "@/models"; import { APIKEY_PROVIDERS } from "@/shared/constants/config"; +import { isOpenAICompatibleProvider } from "@/shared/constants/providers"; import { getConsistentMachineId } from "@/shared/utils/machineId"; import { syncToCloud } from "@/app/api/sync/cloud/route"; @@ -32,7 +33,7 @@ export async function POST(request) { const { provider, apiKey, name, priority, globalPriority, defaultModel, testStatus } = body; // Validation - if (!provider || !APIKEY_PROVIDERS[provider]) { + if (!provider || (!APIKEY_PROVIDERS[provider] && !isOpenAICompatibleProvider(provider))) { return NextResponse.json({ error: "Invalid provider" }, { status: 400 }); } if (!apiKey) { @@ -42,6 +43,27 @@ export async function POST(request) { return NextResponse.json({ error: "Name is required" }, { status: 400 }); } + let providerSpecificData = null; + + if (isOpenAICompatibleProvider(provider)) { + const node = await getProviderNodeById(provider); + if (!node) { + return NextResponse.json({ error: "OpenAI Compatible node not found" }, { status: 404 }); + } + + const existingConnections = await getProviderConnections({ provider }); + if (existingConnections.length > 0) { + return NextResponse.json({ error: "Only one connection is allowed for this OpenAI Compatible node" }, { status: 400 }); + } + + providerSpecificData = { + prefix: node.prefix, + apiType: node.apiType, + baseUrl: node.baseUrl, + nodeName: node.name, + }; + } + const newConnection = await createProviderConnection({ provider, authType: "apikey", @@ -50,6 +72,7 @@ export async function POST(request) { priority: priority || 1, globalPriority: globalPriority || null, defaultModel: defaultModel || null, + providerSpecificData, isActive: true, testStatus: testStatus || "unknown", }); diff --git a/src/app/api/providers/validate/route.js b/src/app/api/providers/validate/route.js index 94d5b23f..008db0bc 100644 --- a/src/app/api/providers/validate/route.js +++ b/src/app/api/providers/validate/route.js @@ -1,4 +1,6 @@ import { NextResponse } from "next/server"; +import { getProviderNodeById } from "@/models"; +import { isOpenAICompatibleProvider } from "@/shared/constants/providers"; // POST /api/providers/validate - Validate API key with provider export async function POST(request) { @@ -15,6 +17,22 @@ export async function POST(request) { // Validate with each provider try { + if (isOpenAICompatibleProvider(provider)) { + const node = await getProviderNodeById(provider); + if (!node) { + return NextResponse.json({ error: "OpenAI Compatible node not found" }, { status: 404 }); + } + const modelsUrl = `${node.baseUrl?.replace(/\/$/, "")}/models`; + const res = await fetch(modelsUrl, { + headers: { "Authorization": `Bearer ${apiKey}` }, + }); + isValid = res.ok; + return NextResponse.json({ + valid: isValid, + error: isValid ? null : "Invalid API key", + }); + } + switch (provider) { case "openai": const openaiRes = await fetch("https://api.openai.com/v1/models", { @@ -77,8 +95,8 @@ export async function POST(request) { break; } - default: - return NextResponse.json({ error: "Provider validation not supported" }, { status: 400 }); + default: + return NextResponse.json({ error: "Provider validation not supported" }, { status: 400 }); } } catch (err) { error = err.message; diff --git a/src/app/api/translator/send/route.js b/src/app/api/translator/send/route.js index 1c94a50a..30a71aea 100644 --- a/src/app/api/translator/send/route.js +++ b/src/app/api/translator/send/route.js @@ -31,7 +31,10 @@ export async function POST(request) { }; // Build URL and headers using provider service - const url = buildProviderUrl(provider, body.model || "test-model", true, { baseUrlIndex: 0 }); + const url = buildProviderUrl(provider, body.model || "test-model", true, { + baseUrlIndex: 0, + baseUrl: connection.providerSpecificData?.baseUrl + }); console.log("๐Ÿš€ ~ POST ~ url:", url) const headers = buildProviderHeaders(provider, credentials, true, body); console.log("๐Ÿš€ ~ POST ~ headers:", headers) diff --git a/src/app/api/translator/translate/route.js b/src/app/api/translator/translate/route.js index b725e63d..e38a9e46 100644 --- a/src/app/api/translator/translate/route.js +++ b/src/app/api/translator/translate/route.js @@ -91,7 +91,10 @@ export async function POST(request) { }; // Build URL and headers - const url = buildProviderUrl(provider, model, true, { baseUrlIndex: 0 }); + const url = buildProviderUrl(provider, model, true, { + baseUrlIndex: 0, + baseUrl: connection.providerSpecificData?.baseUrl + }); const headers = buildProviderHeaders(provider, credentials, true, actualBody); result = { diff --git a/src/lib/localDb.js b/src/lib/localDb.js index 17f0a425..4e2fa6d0 100644 --- a/src/lib/localDb.js +++ b/src/lib/localDb.js @@ -42,6 +42,7 @@ if (!isCloud && !fs.existsSync(DATA_DIR)) { // Default data structure const defaultData = { providerConnections: [], + providerNodes: [], modelAliases: {}, combos: [], apiKeys: [], @@ -116,6 +117,103 @@ export async function getProviderConnections(filter = {}) { return connections; } +// ============ Provider Nodes ============ + +/** + * Get provider nodes + */ +export async function getProviderNodes(filter = {}) { + const db = await getDb(); + let nodes = db.data.providerNodes || []; + + if (filter.type) { + nodes = nodes.filter((node) => node.type === filter.type); + } + + return nodes; +} + +/** + * Get provider node by ID + */ +export async function getProviderNodeById(id) { + const db = await getDb(); + return db.data.providerNodes.find((node) => node.id === id) || null; +} + +/** + * Create provider node + */ +export async function createProviderNode(data) { + const db = await getDb(); + const now = new Date().toISOString(); + + const node = { + id: data.id || uuidv4(), + type: data.type, + name: data.name, + prefix: data.prefix, + apiType: data.apiType, + baseUrl: data.baseUrl, + createdAt: now, + updatedAt: now, + }; + + db.data.providerNodes.push(node); + await db.write(); + + return node; +} + +/** + * Update provider node + */ +export async function updateProviderNode(id, data) { + const db = await getDb(); + const index = db.data.providerNodes.findIndex((node) => node.id === id); + + if (index === -1) return null; + + db.data.providerNodes[index] = { + ...db.data.providerNodes[index], + ...data, + updatedAt: new Date().toISOString(), + }; + + await db.write(); + + return db.data.providerNodes[index]; +} + +/** + * Delete provider node + */ +export async function deleteProviderNode(id) { + const db = await getDb(); + const index = db.data.providerNodes.findIndex((node) => node.id === id); + + if (index === -1) return null; + + const [removed] = db.data.providerNodes.splice(index, 1); + await db.write(); + + return removed; +} + +/** + * Delete all provider connections by provider ID + */ +export async function deleteProviderConnectionsByProvider(providerId) { + const db = await getDb(); + const beforeCount = db.data.providerConnections.length; + db.data.providerConnections = db.data.providerConnections.filter( + (connection) => connection.provider !== providerId + ); + const deletedCount = beforeCount - db.data.providerConnections.length; + await db.write(); + return deletedCount; +} + /** * Get provider connection by ID */ @@ -699,4 +797,3 @@ export async function resetAllPricing() { await db.write(); return db.data.pricing; } - diff --git a/src/models/index.js b/src/models/index.js index 4319e157..84fa2abc 100644 --- a/src/models/index.js +++ b/src/models/index.js @@ -5,6 +5,12 @@ export { createProviderConnection, updateProviderConnection, deleteProviderConnection, + getProviderNodes, + getProviderNodeById, + createProviderNode, + updateProviderNode, + deleteProviderNode, + deleteProviderConnectionsByProvider, getModelAliases, setModelAlias, deleteModelAlias, diff --git a/src/shared/constants/models.js b/src/shared/constants/models.js index 60a266da..4ccbf062 100644 --- a/src/shared/constants/models.js +++ b/src/shared/constants/models.js @@ -10,7 +10,7 @@ export { getModelsByProviderId } from "open-sse/config/providerModels.js"; -import { AI_PROVIDERS } from "./providers.js"; +import { AI_PROVIDERS, isOpenAICompatibleProvider } from "./providers.js"; import { PROVIDER_MODELS as MODELS } from "open-sse/config/providerModels.js"; // Providers that accept any model (passthrough) @@ -22,6 +22,7 @@ const PASSTHROUGH_PROVIDERS = new Set( // Wrap isValidModel with passthrough providers export function isValidModel(aliasOrId, modelId) { + if (isOpenAICompatibleProvider(aliasOrId)) return true; if (PASSTHROUGH_PROVIDERS.has(aliasOrId)) return true; const models = MODELS[aliasOrId]; if (!models) return false; diff --git a/src/shared/constants/providers.js b/src/shared/constants/providers.js index 0937d450..32247bef 100644 --- a/src/shared/constants/providers.js +++ b/src/shared/constants/providers.js @@ -22,6 +22,12 @@ export const APIKEY_PROVIDERS = { gemini: { id: "gemini", alias: "gemini", name: "Gemini", icon: "diamond", color: "#4285F4", textIcon: "GE" }, }; +export const OPENAI_COMPATIBLE_PREFIX = "openai-compatible-"; + +export function isOpenAICompatibleProvider(providerId) { + return typeof providerId === "string" && providerId.startsWith(OPENAI_COMPATIBLE_PREFIX); +} + // All providers (combined) export const AI_PROVIDERS = { ...OAUTH_PROVIDERS, ...APIKEY_PROVIDERS }; diff --git a/src/sse/services/model.js b/src/sse/services/model.js index e20b6892..e93d906b 100644 --- a/src/sse/services/model.js +++ b/src/sse/services/model.js @@ -1,5 +1,5 @@ // Re-export from open-sse with localDb integration -import { getModelAliases, getComboByName } from "@/lib/localDb"; +import { getModelAliases, getComboByName, getProviderNodes } from "@/lib/localDb"; import { parseModel, resolveModelAliasFromMap, getModelInfoCore } from "open-sse/services/model.js"; export { parseModel }; @@ -16,6 +16,22 @@ export async function resolveModelAlias(alias) { * Get full model info (parse or resolve) */ export async function getModelInfo(modelStr) { + const parsed = parseModel(modelStr); + + if (!parsed.isAlias) { + if (parsed.provider === parsed.providerAlias) { + const providerNodes = await getProviderNodes({ type: "openai-compatible" }); + const matchedNode = providerNodes.find((node) => node.prefix === parsed.providerAlias); + if (matchedNode) { + return { provider: matchedNode.id, model: parsed.model }; + } + } + return { + provider: parsed.provider, + model: parsed.model + }; + } + return getModelInfoCore(modelStr, getModelAliases); }