Save
@@ -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 (
+
+
+
+ );
+}
+
+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
+ setShowAddCompatibleModal(true)}>
+ Add OpenAI Compatible
+
+
- {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 (
+
+
+
+ );
+}
+
+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);
}