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
This commit is contained in:
@@ -19,7 +19,13 @@ export class BaseExecutor {
|
|||||||
return this.getBaseUrls().length || 1;
|
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();
|
const baseUrls = this.getBaseUrls();
|
||||||
return baseUrls[urlIndex] || baseUrls[0] || this.config.baseUrl;
|
return baseUrls[urlIndex] || baseUrls[0] || this.config.baseUrl;
|
||||||
}
|
}
|
||||||
@@ -73,7 +79,7 @@ export class BaseExecutor {
|
|||||||
let lastStatus = 0;
|
let lastStatus = 0;
|
||||||
|
|
||||||
for (let urlIndex = 0; urlIndex < fallbackCount; urlIndex++) {
|
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 headers = this.buildHeaders(credentials, stream);
|
||||||
const transformedBody = this.transformRequest(model, body, stream, credentials);
|
const transformedBody = this.transformRequest(model, body, stream, credentials);
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,13 @@ export class DefaultExecutor extends BaseExecutor {
|
|||||||
super(provider, PROVIDERS[provider] || PROVIDERS.openai);
|
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) {
|
switch (this.provider) {
|
||||||
case "claude":
|
case "claude":
|
||||||
case "glm":
|
case "glm":
|
||||||
|
|||||||
@@ -189,10 +189,10 @@ function translateNonStreamingResponse(responseBody, targetFormat, sourceFormat)
|
|||||||
* Handles different provider response formats
|
* Handles different provider response formats
|
||||||
*/
|
*/
|
||||||
function extractUsageFromResponse(responseBody, provider) {
|
function extractUsageFromResponse(responseBody, provider) {
|
||||||
if (!responseBody) return null;
|
if (!responseBody || typeof responseBody !== 'object') return null;
|
||||||
|
|
||||||
// OpenAI format
|
// OpenAI format
|
||||||
if (responseBody.usage) {
|
if (responseBody.usage && typeof responseBody.usage === 'object') {
|
||||||
return {
|
return {
|
||||||
prompt_tokens: responseBody.usage.prompt_tokens || 0,
|
prompt_tokens: responseBody.usage.prompt_tokens || 0,
|
||||||
completion_tokens: responseBody.usage.completion_tokens || 0,
|
completion_tokens: responseBody.usage.completion_tokens || 0,
|
||||||
@@ -202,7 +202,7 @@ function extractUsageFromResponse(responseBody, provider) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Claude format
|
// 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 {
|
return {
|
||||||
prompt_tokens: responseBody.usage.input_tokens || 0,
|
prompt_tokens: responseBody.usage.input_tokens || 0,
|
||||||
completion_tokens: responseBody.usage.output_tokens || 0,
|
completion_tokens: responseBody.usage.output_tokens || 0,
|
||||||
@@ -212,7 +212,7 @@ function extractUsageFromResponse(responseBody, provider) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Gemini format
|
// Gemini format
|
||||||
if (responseBody.usageMetadata) {
|
if (responseBody.usageMetadata && typeof responseBody.usageMetadata === 'object') {
|
||||||
return {
|
return {
|
||||||
prompt_tokens: responseBody.usageMetadata.promptTokenCount || 0,
|
prompt_tokens: responseBody.usageMetadata.promptTokenCount || 0,
|
||||||
completion_tokens: responseBody.usageMetadata.candidatesTokenCount || 0,
|
completion_tokens: responseBody.usageMetadata.candidatesTokenCount || 0,
|
||||||
@@ -411,11 +411,11 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
|
|||||||
await onRequestSuccess();
|
await onRequestSuccess();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log usage for non-streaming responses
|
// Log usage for non-streaming responses
|
||||||
const usage = extractUsageFromResponse(responseBody, provider);
|
const usage = extractUsageFromResponse(responseBody, provider);
|
||||||
appendRequestLog({ model, provider, connectionId, tokens: usage, status: "200 OK" }).catch(() => { });
|
appendRequestLog({ model, provider, connectionId, tokens: usage, status: "200 OK" }).catch(() => { });
|
||||||
if (usage) {
|
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)}...` : ""}`;
|
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}`);
|
console.log(`${COLORS.green}${msg}${COLORS.reset}`);
|
||||||
|
|
||||||
saveRequestUsage({
|
saveRequestUsage({
|
||||||
|
|||||||
@@ -1,5 +1,25 @@
|
|||||||
import { PROVIDERS } from "../config/constants.js";
|
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
|
// Detect request format from body structure
|
||||||
export function detectFormat(body) {
|
export function detectFormat(body) {
|
||||||
// OpenAI Responses API: has input[] array instead of messages[]
|
// OpenAI Responses API: has input[] array instead of messages[]
|
||||||
@@ -76,6 +96,14 @@ export function detectFormat(body) {
|
|||||||
|
|
||||||
// Get provider config
|
// Get provider config
|
||||||
export function getProviderConfig(provider) {
|
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;
|
return PROVIDERS[provider] || PROVIDERS.openai;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,6 +115,11 @@ export function getProviderFallbackCount(provider) {
|
|||||||
|
|
||||||
// Build provider URL
|
// Build provider URL
|
||||||
export function buildProviderUrl(provider, model, stream = true, options = {}) {
|
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);
|
const config = getProviderConfig(provider);
|
||||||
|
|
||||||
switch (provider) {
|
switch (provider) {
|
||||||
@@ -215,6 +248,9 @@ export function buildProviderHeaders(provider, credentials, stream = true, body
|
|||||||
|
|
||||||
// Get target format for provider
|
// Get target format for provider
|
||||||
export function getTargetFormat(provider) {
|
export function getTargetFormat(provider) {
|
||||||
|
if (isOpenAICompatible(provider)) {
|
||||||
|
return getOpenAICompatibleType(provider) === "responses" ? "openai-responses" : "openai";
|
||||||
|
}
|
||||||
const config = getProviderConfig(provider);
|
const config = getProviderConfig(provider);
|
||||||
return config.format || "openai";
|
return config.format || "openai";
|
||||||
}
|
}
|
||||||
@@ -242,4 +278,3 @@ export function normalizeThinkingConfig(body) {
|
|||||||
}
|
}
|
||||||
return body;
|
return body;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -110,10 +110,10 @@ export function claudeToOpenAIResponse(chunk, state) {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case "message_stop": {
|
case "message_stop": {
|
||||||
if (!state.finishReasonSent) {
|
if (!state.finishReasonSent) {
|
||||||
const finishReason = state.finishReason || (state.toolCalls?.size > 0 ? "tool_calls" : "stop");
|
const finishReason = state.finishReason || (state.toolCalls?.size > 0 ? "tool_calls" : "stop");
|
||||||
const usageObj = state.usage ? {
|
const usageObj = (state.usage && typeof state.usage === 'object') ? {
|
||||||
usage: {
|
usage: {
|
||||||
prompt_tokens: state.usage.input_tokens || 0,
|
prompt_tokens: state.usage.input_tokens || 0,
|
||||||
completion_tokens: state.usage.output_tokens || 0,
|
completion_tokens: state.usage.output_tokens || 0,
|
||||||
|
|||||||
@@ -181,9 +181,9 @@ export function geminiToOpenAIResponse(chunk, state) {
|
|||||||
state.finishReason = finishReason;
|
state.finishReason = finishReason;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Usage metadata
|
// Usage metadata
|
||||||
const usage = response.usageMetadata || chunk.usageMetadata;
|
const usage = response.usageMetadata || chunk.usageMetadata;
|
||||||
if (usage) {
|
if (usage && typeof usage === 'object') {
|
||||||
const promptTokens = (usage.promptTokenCount || 0) + (usage.thoughtsTokenCount || 0);
|
const promptTokens = (usage.promptTokenCount || 0) + (usage.thoughtsTokenCount || 0);
|
||||||
state.usage = {
|
state.usage = {
|
||||||
prompt_tokens: promptTokens,
|
prompt_tokens: promptTokens,
|
||||||
|
|||||||
@@ -164,14 +164,16 @@ export function convertKiroToOpenAI(chunk, state) {
|
|||||||
return openaiChunk;
|
return openaiChunk;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle usage events
|
// Handle usage events
|
||||||
if (eventType === "usageEvent" || data.usageEvent) {
|
if (eventType === "usageEvent" || data.usageEvent) {
|
||||||
const usage = data.usageEvent || data;
|
const usage = data.usageEvent || data;
|
||||||
state.usage = {
|
if (usage && typeof usage === 'object') {
|
||||||
prompt_tokens: usage.inputTokens || 0,
|
state.usage = {
|
||||||
completion_tokens: usage.outputTokens || 0,
|
prompt_tokens: usage.inputTokens || 0,
|
||||||
total_tokens: (usage.inputTokens || 0) + (usage.outputTokens || 0)
|
completion_tokens: usage.outputTokens || 0,
|
||||||
};
|
total_tokens: (usage.inputTokens || 0) + (usage.outputTokens || 0)
|
||||||
|
};
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,45 +13,68 @@ function getTimeString() {
|
|||||||
|
|
||||||
// Extract usage from any format (Claude, OpenAI, Gemini, Responses API)
|
// Extract usage from any format (Claude, OpenAI, Gemini, Responses API)
|
||||||
function extractUsage(chunk) {
|
function extractUsage(chunk) {
|
||||||
|
if (!chunk || typeof chunk !== "object") return null;
|
||||||
|
|
||||||
// Claude format (message_delta event)
|
// Claude format (message_delta event)
|
||||||
if (chunk.type === "message_delta" && chunk.usage) {
|
if (chunk.type === "message_delta" && chunk.usage && typeof chunk.usage === 'object') {
|
||||||
return {
|
return normalizeUsage({
|
||||||
prompt_tokens: chunk.usage.input_tokens || 0,
|
prompt_tokens: chunk.usage.input_tokens || 0,
|
||||||
completion_tokens: chunk.usage.output_tokens || 0,
|
completion_tokens: chunk.usage.output_tokens || 0,
|
||||||
cache_read_input_tokens: chunk.usage.cache_read_input_tokens,
|
cache_read_input_tokens: chunk.usage.cache_read_input_tokens,
|
||||||
cache_creation_input_tokens: chunk.usage.cache_creation_input_tokens
|
cache_creation_input_tokens: chunk.usage.cache_creation_input_tokens
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
// OpenAI Responses API format (response.completed or response.done)
|
// 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;
|
const usage = chunk.response.usage;
|
||||||
return {
|
return normalizeUsage({
|
||||||
prompt_tokens: usage.input_tokens || usage.prompt_tokens || 0,
|
prompt_tokens: usage.input_tokens || usage.prompt_tokens || 0,
|
||||||
completion_tokens: usage.output_tokens || usage.completion_tokens || 0,
|
completion_tokens: usage.output_tokens || usage.completion_tokens || 0,
|
||||||
cached_tokens: usage.input_tokens_details?.cached_tokens,
|
cached_tokens: usage.input_tokens_details?.cached_tokens,
|
||||||
reasoning_tokens: usage.output_tokens_details?.reasoning_tokens
|
reasoning_tokens: usage.output_tokens_details?.reasoning_tokens
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
// OpenAI format
|
// OpenAI format
|
||||||
if (chunk.usage?.prompt_tokens !== undefined) {
|
if (chunk.usage && typeof chunk.usage === 'object' && chunk.usage.prompt_tokens !== undefined) {
|
||||||
return {
|
return normalizeUsage({
|
||||||
prompt_tokens: chunk.usage.prompt_tokens,
|
prompt_tokens: chunk.usage.prompt_tokens,
|
||||||
completion_tokens: chunk.usage.completion_tokens || 0,
|
completion_tokens: chunk.usage.completion_tokens || 0,
|
||||||
cached_tokens: chunk.usage.prompt_tokens_details?.cached_tokens,
|
cached_tokens: chunk.usage.prompt_tokens_details?.cached_tokens,
|
||||||
reasoning_tokens: chunk.usage.completion_tokens_details?.reasoning_tokens
|
reasoning_tokens: chunk.usage.completion_tokens_details?.reasoning_tokens
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
// Gemini format
|
// Gemini format
|
||||||
if (chunk.usageMetadata) {
|
if (chunk.usageMetadata && typeof chunk.usageMetadata === 'object') {
|
||||||
return {
|
return normalizeUsage({
|
||||||
prompt_tokens: chunk.usageMetadata.promptTokenCount || 0,
|
prompt_tokens: chunk.usageMetadata.promptTokenCount || 0,
|
||||||
completion_tokens: chunk.usageMetadata.candidatesTokenCount || 0,
|
completion_tokens: chunk.usageMetadata.candidatesTokenCount || 0,
|
||||||
reasoning_tokens: chunk.usageMetadata.thoughtsTokenCount
|
reasoning_tokens: chunk.usageMetadata.thoughtsTokenCount
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
return null;
|
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
|
// ANSI color codes
|
||||||
export const COLORS = {
|
export const COLORS = {
|
||||||
reset: "\x1b[0m",
|
reset: "\x1b[0m",
|
||||||
@@ -64,11 +87,11 @@ export const COLORS = {
|
|||||||
|
|
||||||
// Log usage with cache info (green color)
|
// Log usage with cache info (green color)
|
||||||
function logUsage(provider, usage, model = null, connectionId = null) {
|
function logUsage(provider, usage, model = null, connectionId = null) {
|
||||||
if (!usage) return;
|
if (!usage || typeof usage !== 'object') return;
|
||||||
|
|
||||||
const p = provider?.toUpperCase() || "UNKNOWN";
|
const p = provider?.toUpperCase() || "UNKNOWN";
|
||||||
const inTokens = usage.prompt_tokens || 0;
|
const inTokens = usage?.prompt_tokens || 0;
|
||||||
const outTokens = usage.completion_tokens || 0;
|
const outTokens = usage?.completion_tokens || 0;
|
||||||
|
|
||||||
let msg = `[${getTimeString()}] 📊 [USAGE] ${p} | in=${inTokens} | out=${outTokens}`;
|
let msg = `[${getTimeString()}] 📊 [USAGE] ${p} | in=${inTokens} | out=${outTokens}`;
|
||||||
if (connectionId) msg += ` | account=${connectionId.slice(0, 8)}...`;
|
if (connectionId) msg += ` | account=${connectionId.slice(0, 8)}...`;
|
||||||
@@ -274,7 +297,7 @@ export function createSSEStream(options = {}) {
|
|||||||
reqLogger?.appendConvertedChunk?.(output);
|
reqLogger?.appendConvertedChunk?.(output);
|
||||||
controller.enqueue(sharedEncoder.encode(output));
|
controller.enqueue(sharedEncoder.encode(output));
|
||||||
}
|
}
|
||||||
if (usage) {
|
if (usage && typeof usage === 'object') {
|
||||||
logUsage(provider, usage, model, connectionId);
|
logUsage(provider, usage, model, connectionId);
|
||||||
} else {
|
} else {
|
||||||
// No usage data available - still mark request as completed
|
// No usage data available - still mark request as completed
|
||||||
@@ -331,7 +354,7 @@ export function createSSEStream(options = {}) {
|
|||||||
reqLogger?.appendConvertedChunk?.(doneOutput);
|
reqLogger?.appendConvertedChunk?.(doneOutput);
|
||||||
controller.enqueue(sharedEncoder.encode(doneOutput));
|
controller.enqueue(sharedEncoder.encode(doneOutput));
|
||||||
|
|
||||||
if (state?.usage) {
|
if (state?.usage && typeof state.usage === 'object') {
|
||||||
logUsage(state.provider || targetFormat, state.usage, model, connectionId);
|
logUsage(state.provider || targetFormat, state.usage, model, connectionId);
|
||||||
} else {
|
} else {
|
||||||
// No usage data available - still mark request as completed
|
// No usage data available - still mark request as completed
|
||||||
|
|||||||
@@ -2,30 +2,47 @@
|
|||||||
|
|
||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
import PropTypes from "prop-types";
|
import PropTypes from "prop-types";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { Card, Button, Badge, Input, Modal, CardSkeleton, OAuthModal, KiroOAuthWrapper, Toggle } from "@/shared/components";
|
import { Card, Button, Badge, Input, Modal, CardSkeleton, OAuthModal, KiroOAuthWrapper, Toggle, Select } from "@/shared/components";
|
||||||
import { OAUTH_PROVIDERS, APIKEY_PROVIDERS, getProviderAlias } from "@/shared/constants/providers";
|
import { OAUTH_PROVIDERS, APIKEY_PROVIDERS, getProviderAlias, isOpenAICompatibleProvider } from "@/shared/constants/providers";
|
||||||
import { getModelsByProviderId } from "@/shared/constants/models";
|
import { getModelsByProviderId } from "@/shared/constants/models";
|
||||||
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
|
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
|
||||||
|
|
||||||
export default function ProviderDetailPage() {
|
export default function ProviderDetailPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
|
const router = useRouter();
|
||||||
const providerId = params.id;
|
const providerId = params.id;
|
||||||
const [connections, setConnections] = useState([]);
|
const [connections, setConnections] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [providerNode, setProviderNode] = useState(null);
|
||||||
const [showOAuthModal, setShowOAuthModal] = useState(false);
|
const [showOAuthModal, setShowOAuthModal] = useState(false);
|
||||||
const [showAddApiKeyModal, setShowAddApiKeyModal] = useState(false);
|
const [showAddApiKeyModal, setShowAddApiKeyModal] = useState(false);
|
||||||
const [showEditModal, setShowEditModal] = useState(false);
|
const [showEditModal, setShowEditModal] = useState(false);
|
||||||
|
const [showEditNodeModal, setShowEditNodeModal] = useState(false);
|
||||||
const [selectedConnection, setSelectedConnection] = useState(null);
|
const [selectedConnection, setSelectedConnection] = useState(null);
|
||||||
const [modelAliases, setModelAliases] = useState({});
|
const [modelAliases, setModelAliases] = useState({});
|
||||||
const { copied, copy } = useCopyToClipboard();
|
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 isOAuth = !!OAUTH_PROVIDERS[providerId];
|
||||||
const models = getModelsByProviderId(providerId);
|
const models = getModelsByProviderId(providerId);
|
||||||
const providerAlias = getProviderAlias(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
|
// Define callbacks BEFORE the useEffect that uses them
|
||||||
const fetchAliases = useCallback(async () => {
|
const fetchAliases = useCallback(async () => {
|
||||||
@@ -42,12 +59,20 @@ export default function ProviderDetailPage() {
|
|||||||
|
|
||||||
const fetchConnections = useCallback(async () => {
|
const fetchConnections = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/providers");
|
const [connectionsRes, nodesRes] = await Promise.all([
|
||||||
const data = await res.json();
|
fetch("/api/providers"),
|
||||||
if (res.ok) {
|
fetch("/api/provider-nodes"),
|
||||||
const filtered = (data.connections || []).filter(c => c.provider === providerId);
|
]);
|
||||||
|
const connectionsData = await connectionsRes.json();
|
||||||
|
const nodesData = await nodesRes.json();
|
||||||
|
if (connectionsRes.ok) {
|
||||||
|
const filtered = (connectionsData.connections || []).filter(c => c.provider === providerId);
|
||||||
setConnections(filtered);
|
setConnections(filtered);
|
||||||
}
|
}
|
||||||
|
if (nodesRes.ok) {
|
||||||
|
const node = (nodesData.nodes || []).find((entry) => entry.id === providerId) || null;
|
||||||
|
setProviderNode(node);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log("Error fetching connections:", error);
|
console.log("Error fetching connections:", error);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -55,13 +80,31 @@ export default function ProviderDetailPage() {
|
|||||||
}
|
}
|
||||||
}, [providerId]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
fetchConnections();
|
fetchConnections();
|
||||||
fetchAliases();
|
fetchAliases();
|
||||||
}, [fetchConnections, fetchAliases]);
|
}, [fetchConnections, fetchAliases]);
|
||||||
|
|
||||||
const handleSetAlias = async (modelId, alias) => {
|
const handleSetAlias = async (modelId, alias, providerAliasOverride = providerAlias) => {
|
||||||
const fullModel = `${providerAlias}/${modelId}`;
|
const fullModel = `${providerAliasOverride}/${modelId}`;
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/models/alias", {
|
const res = await fetch("/api/models/alias", {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
@@ -194,6 +237,20 @@ export default function ProviderDetailPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const renderModelsSection = () => {
|
const renderModelsSection = () => {
|
||||||
|
if (isOpenAICompatible) {
|
||||||
|
return (
|
||||||
|
<OpenAICompatibleModelsSection
|
||||||
|
providerStorageAlias={providerStorageAlias}
|
||||||
|
providerDisplayAlias={providerDisplayAlias}
|
||||||
|
modelAliases={modelAliases}
|
||||||
|
copied={copied}
|
||||||
|
onCopy={copy}
|
||||||
|
onSetAlias={handleSetAlias}
|
||||||
|
onDeleteAlias={handleDeleteAlias}
|
||||||
|
connections={connections}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
if (providerInfo.passthroughModels) {
|
if (providerInfo.passthroughModels) {
|
||||||
return (
|
return (
|
||||||
<PassthroughModelsSection
|
<PassthroughModelsSection
|
||||||
@@ -212,7 +269,7 @@ export default function ProviderDetailPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex flex-wrap gap-3">
|
||||||
{models.map((model) => {
|
{models.map((model) => {
|
||||||
const fullModel = `${providerAlias}/${model.id}`;
|
const fullModel = `${providerStorageAlias}/${model.id}`;
|
||||||
const oldFormatModel = `${providerId}/${model.id}`;
|
const oldFormatModel = `${providerId}/${model.id}`;
|
||||||
const existingAlias = Object.entries(modelAliases).find(
|
const existingAlias = Object.entries(modelAliases).find(
|
||||||
([, m]) => m === fullModel || m === oldFormatModel
|
([, m]) => m === fullModel || m === oldFormatModel
|
||||||
@@ -221,11 +278,11 @@ export default function ProviderDetailPage() {
|
|||||||
<ModelRow
|
<ModelRow
|
||||||
key={model.id}
|
key={model.id}
|
||||||
model={model}
|
model={model}
|
||||||
fullModel={fullModel}
|
fullModel={`${providerDisplayAlias}/${model.id}`}
|
||||||
alias={existingAlias}
|
alias={existingAlias}
|
||||||
copied={copied}
|
copied={copied}
|
||||||
onCopy={copy}
|
onCopy={copy}
|
||||||
onSetAlias={(alias) => handleSetAlias(model.id, alias)}
|
onSetAlias={(alias) => handleSetAlias(model.id, alias, providerStorageAlias)}
|
||||||
onDeleteAlias={() => handleDeleteAlias(existingAlias)}
|
onDeleteAlias={() => handleDeleteAlias(existingAlias)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -234,6 +291,15 @@ export default function ProviderDetailPage() {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-8">
|
||||||
|
<CardSkeleton />
|
||||||
|
<CardSkeleton />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!providerInfo) {
|
if (!providerInfo) {
|
||||||
return (
|
return (
|
||||||
<div className="text-center py-20">
|
<div className="text-center py-20">
|
||||||
@@ -245,15 +311,6 @@ export default function ProviderDetailPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-8">
|
|
||||||
<CardSkeleton />
|
|
||||||
<CardSkeleton />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-8">
|
<div className="flex flex-col gap-8">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -270,15 +327,21 @@ export default function ProviderDetailPage() {
|
|||||||
className="rounded-lg flex items-center justify-center"
|
className="rounded-lg flex items-center justify-center"
|
||||||
style={{ backgroundColor: `${providerInfo.color}15` }}
|
style={{ backgroundColor: `${providerInfo.color}15` }}
|
||||||
>
|
>
|
||||||
<Image
|
{providerInfo.textIcon ? (
|
||||||
src={`/providers/${providerInfo.id}.png`}
|
<span className="text-sm font-bold" style={{ color: providerInfo.color }}>
|
||||||
alt={providerInfo.name}
|
{providerInfo.textIcon}
|
||||||
width={48}
|
</span>
|
||||||
height={48}
|
) : (
|
||||||
className="object-contain rounded-lg max-w-[48px] max-h-[48px]"
|
<Image
|
||||||
sizes="48px"
|
src={`/providers/${providerInfo.id}.png`}
|
||||||
onError={(e) => { e.currentTarget.style.display = "none"; }}
|
alt={providerInfo.name}
|
||||||
/>
|
width={48}
|
||||||
|
height={48}
|
||||||
|
className="object-contain rounded-lg max-w-[48px] max-h-[48px]"
|
||||||
|
sizes="48px"
|
||||||
|
onError={(e) => { e.currentTarget.style.display = "none"; }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-semibold tracking-tight">{providerInfo.name}</h1>
|
<h1 className="text-3xl font-semibold tracking-tight">{providerInfo.name}</h1>
|
||||||
@@ -289,17 +352,74 @@ export default function ProviderDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{isOpenAICompatible && providerNode && (
|
||||||
|
<Card>
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold">OpenAI Compatible Details</h2>
|
||||||
|
<p className="text-sm text-text-muted">
|
||||||
|
{providerNode.apiType === "responses" ? "Responses API" : "Chat Completions"} · {(providerNode.baseUrl || "").replace(/\/$/, "")}/
|
||||||
|
{providerNode.apiType === "responses" ? "responses" : "chat/completions"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
icon="add"
|
||||||
|
onClick={() => setShowAddApiKeyModal(true)}
|
||||||
|
disabled={connections.length > 0}
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
icon="edit"
|
||||||
|
onClick={() => setShowEditNodeModal(true)}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
icon="delete"
|
||||||
|
onClick={async () => {
|
||||||
|
if (!confirm("Delete this OpenAI Compatible node?")) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/provider-nodes/${providerId}`, { method: "DELETE" });
|
||||||
|
if (res.ok) {
|
||||||
|
router.push("/dashboard/providers");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log("Error deleting provider node:", error);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{connections.length > 0 && (
|
||||||
|
<p className="text-sm text-text-muted">
|
||||||
|
Only one connection is allowed per OpenAI Compatible node. Add another node if you need more connections.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Connections */}
|
{/* Connections */}
|
||||||
<Card>
|
<Card>
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h2 className="text-lg font-semibold">Connections</h2>
|
<h2 className="text-lg font-semibold">Connections</h2>
|
||||||
<Button
|
{!isOpenAICompatible && (
|
||||||
size="sm"
|
<Button
|
||||||
icon="add"
|
size="sm"
|
||||||
onClick={() => isOAuth ? setShowOAuthModal(true) : setShowAddApiKeyModal(true)}
|
icon="add"
|
||||||
>
|
onClick={() => isOAuth ? setShowOAuthModal(true) : setShowAddApiKeyModal(true)}
|
||||||
Add
|
>
|
||||||
</Button>
|
Add
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{connections.length === 0 ? (
|
{connections.length === 0 ? (
|
||||||
@@ -309,9 +429,11 @@ export default function ProviderDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
<p className="text-text-main font-medium mb-1">No connections yet</p>
|
<p className="text-text-main font-medium mb-1">No connections yet</p>
|
||||||
<p className="text-sm text-text-muted mb-4">Add your first connection to get started</p>
|
<p className="text-sm text-text-muted mb-4">Add your first connection to get started</p>
|
||||||
<Button icon="add" onClick={() => isOAuth ? setShowOAuthModal(true) : setShowAddApiKeyModal(true)}>
|
{!isOpenAICompatible && (
|
||||||
Add Connection
|
<Button icon="add" onClick={() => isOAuth ? setShowOAuthModal(true) : setShowAddApiKeyModal(true)}>
|
||||||
</Button>
|
Add Connection
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col divide-y divide-black/[0.03] dark:divide-white/[0.03]">
|
<div className="flex flex-col divide-y divide-black/[0.03] dark:divide-white/[0.03]">
|
||||||
@@ -367,6 +489,8 @@ export default function ProviderDetailPage() {
|
|||||||
<AddApiKeyModal
|
<AddApiKeyModal
|
||||||
isOpen={showAddApiKeyModal}
|
isOpen={showAddApiKeyModal}
|
||||||
provider={providerId}
|
provider={providerId}
|
||||||
|
providerName={providerInfo.name}
|
||||||
|
isOpenAICompatible={isOpenAICompatible}
|
||||||
onSave={handleSaveApiKey}
|
onSave={handleSaveApiKey}
|
||||||
onClose={() => setShowAddApiKeyModal(false)}
|
onClose={() => setShowAddApiKeyModal(false)}
|
||||||
/>
|
/>
|
||||||
@@ -376,6 +500,12 @@ export default function ProviderDetailPage() {
|
|||||||
onSave={handleUpdateConnection}
|
onSave={handleUpdateConnection}
|
||||||
onClose={() => setShowEditModal(false)}
|
onClose={() => setShowEditModal(false)}
|
||||||
/>
|
/>
|
||||||
|
<EditOpenAICompatibleModal
|
||||||
|
isOpen={showEditNodeModal}
|
||||||
|
node={providerNode}
|
||||||
|
onSave={handleUpdateNode}
|
||||||
|
onClose={() => setShowEditNodeModal(false)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -546,6 +676,158 @@ PassthroughModelRow.propTypes = {
|
|||||||
onDeleteAlias: PropTypes.func.isRequired,
|
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 (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<p className="text-sm text-text-muted">
|
||||||
|
Add OpenAI-compatible models manually or import them from the /models endpoint.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex items-end gap-2 flex-wrap">
|
||||||
|
<div className="flex-1 min-w-[240px]">
|
||||||
|
<label htmlFor="new-compatible-model-input" className="text-xs text-text-muted mb-1 block">Model ID</label>
|
||||||
|
<input
|
||||||
|
id="new-compatible-model-input"
|
||||||
|
type="text"
|
||||||
|
value={newModel}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" icon="add" onClick={handleAdd} disabled={!newModel.trim() || adding}>
|
||||||
|
{adding ? "Adding..." : "Add"}
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="secondary" icon="download" onClick={handleImport} disabled={!canImport || importing}>
|
||||||
|
{importing ? "Importing..." : "Import from /models"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!canImport && (
|
||||||
|
<p className="text-xs text-text-muted">
|
||||||
|
Add a connection to enable importing models.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{allModels.length > 0 && (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{allModels.map(({ modelId, fullModel, alias }) => (
|
||||||
|
<PassthroughModelRow
|
||||||
|
key={fullModel}
|
||||||
|
modelId={modelId}
|
||||||
|
fullModel={`${providerDisplayAlias}/${modelId}`}
|
||||||
|
copied={copied}
|
||||||
|
onCopy={onCopy}
|
||||||
|
onDeleteAlias={() => onDeleteAlias(alias)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 }) {
|
function CooldownTimer({ until }) {
|
||||||
const [remaining, setRemaining] = useState("");
|
const [remaining, setRemaining] = useState("");
|
||||||
|
|
||||||
@@ -706,7 +988,7 @@ ConnectionRow.propTypes = {
|
|||||||
onDelete: PropTypes.func.isRequired,
|
onDelete: PropTypes.func.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
function AddApiKeyModal({ isOpen, provider, onSave, onClose }) {
|
function AddApiKeyModal({ isOpen, provider, providerName, isOpenAICompatible, onSave, onClose }) {
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
name: "",
|
name: "",
|
||||||
apiKey: "",
|
apiKey: "",
|
||||||
@@ -744,7 +1026,7 @@ function AddApiKeyModal({ isOpen, provider, onSave, onClose }) {
|
|||||||
if (!provider) return null;
|
if (!provider) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={isOpen} title={`Add ${provider} API Key`} onClose={onClose}>
|
<Modal isOpen={isOpen} title={`Add ${providerName || provider} API Key`} onClose={onClose}>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<Input
|
<Input
|
||||||
label="Name"
|
label="Name"
|
||||||
@@ -771,6 +1053,11 @@ function AddApiKeyModal({ isOpen, provider, onSave, onClose }) {
|
|||||||
{validationResult === "success" ? "Valid" : "Invalid"}
|
{validationResult === "success" ? "Valid" : "Invalid"}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
{isOpenAICompatible && (
|
||||||
|
<p className="text-xs text-text-muted">
|
||||||
|
Validation checks {providerName || "OpenAI Compatible"} via /models on your base URL.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<Input
|
<Input
|
||||||
label="Priority"
|
label="Priority"
|
||||||
type="number"
|
type="number"
|
||||||
@@ -793,6 +1080,8 @@ function AddApiKeyModal({ isOpen, provider, onSave, onClose }) {
|
|||||||
AddApiKeyModal.propTypes = {
|
AddApiKeyModal.propTypes = {
|
||||||
isOpen: PropTypes.bool.isRequired,
|
isOpen: PropTypes.bool.isRequired,
|
||||||
provider: PropTypes.string,
|
provider: PropTypes.string,
|
||||||
|
providerName: PropTypes.string,
|
||||||
|
isOpenAICompatible: PropTypes.bool,
|
||||||
onSave: PropTypes.func.isRequired,
|
onSave: PropTypes.func.isRequired,
|
||||||
onClose: PropTypes.func.isRequired,
|
onClose: PropTypes.func.isRequired,
|
||||||
};
|
};
|
||||||
@@ -801,17 +1090,22 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }) {
|
|||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
name: "",
|
name: "",
|
||||||
priority: 1,
|
priority: 1,
|
||||||
|
apiKey: "",
|
||||||
});
|
});
|
||||||
const [testing, setTesting] = useState(false);
|
const [testing, setTesting] = useState(false);
|
||||||
const [testResult, setTestResult] = useState(null);
|
const [testResult, setTestResult] = useState(null);
|
||||||
|
const [validating, setValidating] = useState(false);
|
||||||
|
const [validationResult, setValidationResult] = useState(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (connection) {
|
if (connection) {
|
||||||
setFormData({
|
setFormData({
|
||||||
name: connection.name || "",
|
name: connection.name || "",
|
||||||
priority: connection.priority || 1,
|
priority: connection.priority || 1,
|
||||||
|
apiKey: "",
|
||||||
});
|
});
|
||||||
setTestResult(null);
|
setTestResult(null);
|
||||||
|
setValidationResult(null);
|
||||||
}
|
}
|
||||||
}, [connection]);
|
}, [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 handleSubmit = () => {
|
||||||
const updates = { name: formData.name, priority: formData.priority };
|
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);
|
onSave(updates);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!connection) return null;
|
if (!connection) return null;
|
||||||
|
|
||||||
const isOAuth = connection.authType === "oauth";
|
const isOAuth = connection.authType === "oauth";
|
||||||
|
const isCompatible = isOpenAICompatibleProvider(connection.provider);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={isOpen} title="Edit Connection" onClose={onClose}>
|
<Modal isOpen={isOpen} title="Edit Connection" onClose={onClose}>
|
||||||
@@ -860,18 +1182,45 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }) {
|
|||||||
value={formData.priority}
|
value={formData.priority}
|
||||||
onChange={(e) => setFormData({ ...formData, priority: Number.parseInt(e.target.value) || 1 })}
|
onChange={(e) => setFormData({ ...formData, priority: Number.parseInt(e.target.value) || 1 })}
|
||||||
/>
|
/>
|
||||||
|
{!isOAuth && (
|
||||||
|
<>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
label="API Key"
|
||||||
|
type="password"
|
||||||
|
value={formData.apiKey}
|
||||||
|
onChange={(e) => setFormData({ ...formData, apiKey: e.target.value })}
|
||||||
|
placeholder="Enter new API key"
|
||||||
|
hint="Leave blank to keep the current API key."
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<div className="pt-6">
|
||||||
|
<Button onClick={handleValidate} disabled={!formData.apiKey || validating} variant="secondary">
|
||||||
|
{validating ? "Checking..." : "Check"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{validationResult && (
|
||||||
|
<Badge variant={validationResult === "success" ? "success" : "error"}>
|
||||||
|
{validationResult === "success" ? "Valid" : "Invalid"}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Test Connection */}
|
{/* Test Connection */}
|
||||||
<div className="flex items-center gap-3">
|
{!isCompatible && (
|
||||||
<Button onClick={handleTest} variant="secondary" disabled={testing}>
|
<div className="flex items-center gap-3">
|
||||||
{testing ? "Testing..." : "Test Connection"}
|
<Button onClick={handleTest} variant="secondary" disabled={testing}>
|
||||||
</Button>
|
{testing ? "Testing..." : "Test Connection"}
|
||||||
{testResult && (
|
</Button>
|
||||||
<Badge variant={testResult === "success" ? "success" : "error"}>
|
{testResult && (
|
||||||
{testResult === "success" ? "Valid" : "Failed"}
|
<Badge variant={testResult === "success" ? "success" : "error"}>
|
||||||
</Badge>
|
{testResult === "success" ? "Valid" : "Failed"}
|
||||||
)}
|
</Badge>
|
||||||
</div>
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button onClick={handleSubmit} fullWidth>Save</Button>
|
<Button onClick={handleSubmit} fullWidth>Save</Button>
|
||||||
@@ -895,3 +1244,140 @@ EditConnectionModal.propTypes = {
|
|||||||
onSave: PropTypes.func.isRequired,
|
onSave: PropTypes.func.isRequired,
|
||||||
onClose: 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 (
|
||||||
|
<Modal isOpen={isOpen} title="Edit OpenAI Compatible" onClose={onClose}>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<Input
|
||||||
|
label="Name"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
|
placeholder="OpenAI Compatible (Prod)"
|
||||||
|
hint="Required. A friendly label for this node."
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Prefix"
|
||||||
|
value={formData.prefix}
|
||||||
|
onChange={(e) => setFormData({ ...formData, prefix: e.target.value })}
|
||||||
|
placeholder="oc-prod"
|
||||||
|
hint="Required. Used as the provider prefix for model IDs."
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="API Type"
|
||||||
|
options={apiTypeOptions}
|
||||||
|
value={formData.apiType}
|
||||||
|
onChange={(e) => setFormData({ ...formData, apiType: e.target.value })}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Base URL"
|
||||||
|
value={formData.baseUrl}
|
||||||
|
onChange={(e) => 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."
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
label="API Key (for Check)"
|
||||||
|
type="password"
|
||||||
|
value={checkKey}
|
||||||
|
onChange={(e) => setCheckKey(e.target.value)}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<div className="pt-6">
|
||||||
|
<Button onClick={handleValidate} disabled={!checkKey || validating || !formData.baseUrl.trim()} variant="secondary">
|
||||||
|
{validating ? "Checking..." : "Check"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{validationResult && (
|
||||||
|
<Badge variant={validationResult === "success" ? "success" : "error"}>
|
||||||
|
{validationResult === "success" ? "Valid" : "Invalid"}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button onClick={handleSubmit} fullWidth disabled={!formData.name.trim() || !formData.prefix.trim() || !formData.baseUrl.trim() || saving}>
|
||||||
|
{saving ? "Saving..." : "Save"}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={onClose} variant="ghost" fullWidth>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
|||||||
@@ -3,8 +3,9 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import PropTypes from "prop-types";
|
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 { OAUTH_PROVIDERS, APIKEY_PROVIDERS } from "@/shared/constants/config";
|
||||||
|
import { OPENAI_COMPATIBLE_PREFIX } from "@/shared/constants/providers";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { getErrorCode, getRelativeTime } from "@/shared/utils";
|
import { getErrorCode, getRelativeTime } from "@/shared/utils";
|
||||||
|
|
||||||
@@ -34,14 +35,21 @@ function getStatusDisplay(connected, error, errorCode) {
|
|||||||
|
|
||||||
export default function ProvidersPage() {
|
export default function ProvidersPage() {
|
||||||
const [connections, setConnections] = useState([]);
|
const [connections, setConnections] = useState([]);
|
||||||
|
const [providerNodes, setProviderNodes] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [showAddCompatibleModal, setShowAddCompatibleModal] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/providers");
|
const [connectionsRes, nodesRes] = await Promise.all([
|
||||||
const data = await res.json();
|
fetch("/api/providers"),
|
||||||
if (res.ok) setConnections(data.connections || []);
|
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) {
|
} catch (error) {
|
||||||
console.log("Error fetching data:", error);
|
console.log("Error fetching data:", error);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -85,6 +93,24 @@ export default function ProvidersPage() {
|
|||||||
return { connected, error, total, errorCode, errorTime };
|
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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-8">
|
<div className="flex flex-col gap-8">
|
||||||
@@ -113,9 +139,14 @@ export default function ProvidersPage() {
|
|||||||
|
|
||||||
{/* API Key Providers */}
|
{/* API Key Providers */}
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<h2 className="text-xl font-semibold">API Key Providers</h2>
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-xl font-semibold">API Key Providers</h2>
|
||||||
|
<Button size="sm" icon="add" onClick={() => setShowAddCompatibleModal(true)}>
|
||||||
|
Add OpenAI Compatible
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||||
{Object.entries(APIKEY_PROVIDERS).map(([key, info]) => (
|
{Object.entries(apiKeyProviders).map(([key, info]) => (
|
||||||
<ApiKeyProviderCard
|
<ApiKeyProviderCard
|
||||||
key={key}
|
key={key}
|
||||||
providerId={key}
|
providerId={key}
|
||||||
@@ -125,6 +156,14 @@ export default function ProvidersPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<AddOpenAICompatibleModal
|
||||||
|
isOpen={showAddCompatibleModal}
|
||||||
|
onClose={() => setShowAddCompatibleModal(false)}
|
||||||
|
onCreated={(node) => {
|
||||||
|
setProviderNodes((prev) => [...prev, node]);
|
||||||
|
setShowAddCompatibleModal(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -197,6 +236,7 @@ ProviderCard.propTypes = {
|
|||||||
// API Key providers - only use textIcon, no image
|
// API Key providers - only use textIcon, no image
|
||||||
function ApiKeyProviderCard({ providerId, provider, stats }) {
|
function ApiKeyProviderCard({ providerId, provider, stats }) {
|
||||||
const { connected, error, errorCode, errorTime } = stats;
|
const { connected, error, errorCode, errorTime } = stats;
|
||||||
|
const isCompatible = providerId.startsWith(OPENAI_COMPATIBLE_PREFIX);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link href={`/dashboard/providers/${providerId}`} className="group">
|
<Link href={`/dashboard/providers/${providerId}`} className="group">
|
||||||
@@ -218,6 +258,11 @@ function ApiKeyProviderCard({ providerId, provider, stats }) {
|
|||||||
<h3 className="font-semibold">{provider.name}</h3>
|
<h3 className="font-semibold">{provider.name}</h3>
|
||||||
<div className="flex items-center gap-2 text-xs flex-wrap">
|
<div className="flex items-center gap-2 text-xs flex-wrap">
|
||||||
{getStatusDisplay(connected, error, errorCode)}
|
{getStatusDisplay(connected, error, errorCode)}
|
||||||
|
{isCompatible && (
|
||||||
|
<Badge variant="default" size="sm">
|
||||||
|
{provider.apiType === "responses" ? "Responses" : "Chat"}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
{errorTime && <span className="text-text-muted">• {errorTime}</span>}
|
{errorTime && <span className="text-text-muted">• {errorTime}</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -238,6 +283,7 @@ ApiKeyProviderCard.propTypes = {
|
|||||||
name: PropTypes.string.isRequired,
|
name: PropTypes.string.isRequired,
|
||||||
color: PropTypes.string,
|
color: PropTypes.string,
|
||||||
textIcon: PropTypes.string,
|
textIcon: PropTypes.string,
|
||||||
|
apiType: PropTypes.string,
|
||||||
}).isRequired,
|
}).isRequired,
|
||||||
stats: PropTypes.shape({
|
stats: PropTypes.shape({
|
||||||
connected: PropTypes.number,
|
connected: PropTypes.number,
|
||||||
@@ -246,3 +292,146 @@ ApiKeyProviderCard.propTypes = {
|
|||||||
errorTime: PropTypes.string,
|
errorTime: PropTypes.string,
|
||||||
}).isRequired,
|
}).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 (
|
||||||
|
<Modal isOpen={isOpen} title="Add OpenAI Compatible" onClose={onClose}>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<Input
|
||||||
|
label="Name"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
|
placeholder="OpenAI Compatible (Prod)"
|
||||||
|
hint="Required. A friendly label for this node."
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Prefix"
|
||||||
|
value={formData.prefix}
|
||||||
|
onChange={(e) => setFormData({ ...formData, prefix: e.target.value })}
|
||||||
|
placeholder="oc-prod"
|
||||||
|
hint="Required. Used as the provider prefix for model IDs."
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="API Type"
|
||||||
|
options={apiTypeOptions}
|
||||||
|
value={formData.apiType}
|
||||||
|
onChange={(e) => setFormData({ ...formData, apiType: e.target.value })}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Base URL"
|
||||||
|
value={formData.baseUrl}
|
||||||
|
onChange={(e) => 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."
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
label="API Key (for Check)"
|
||||||
|
type="password"
|
||||||
|
value={checkKey}
|
||||||
|
onChange={(e) => setCheckKey(e.target.value)}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<div className="pt-6">
|
||||||
|
<Button onClick={handleValidate} disabled={!checkKey || validating || !formData.baseUrl.trim()} variant="secondary">
|
||||||
|
{validating ? "Checking..." : "Check"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{validationResult && (
|
||||||
|
<Badge variant={validationResult === "success" ? "success" : "error"}>
|
||||||
|
{validationResult === "success" ? "Valid" : "Invalid"}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button onClick={handleSubmit} fullWidth disabled={!formData.name.trim() || !formData.prefix.trim() || !formData.baseUrl.trim() || submitting}>
|
||||||
|
{submitting ? "Creating..." : "Create"}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={onClose} variant="ghost" fullWidth>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
AddOpenAICompatibleModal.propTypes = {
|
||||||
|
isOpen: PropTypes.bool.isRequired,
|
||||||
|
onClose: PropTypes.func.isRequired,
|
||||||
|
onCreated: PropTypes.func.isRequired,
|
||||||
|
};
|
||||||
|
|||||||
77
src/app/api/provider-nodes/[id]/route.js
Normal file
77
src/app/api/provider-nodes/[id]/route.js
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
52
src/app/api/provider-nodes/route.js
Normal file
52
src/app/api/provider-nodes/route.js
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
23
src/app/api/provider-nodes/validate/route.js
Normal file
23
src/app/api/provider-nodes/validate/route.js
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { getProviderConnectionById } from "@/models";
|
import { getProviderConnectionById } from "@/models";
|
||||||
|
import { isOpenAICompatibleProvider } from "@/shared/constants/providers";
|
||||||
|
|
||||||
// Provider models endpoints configuration
|
// Provider models endpoints configuration
|
||||||
const PROVIDER_MODELS_CONFIG = {
|
const PROVIDER_MODELS_CONFIG = {
|
||||||
@@ -85,6 +86,39 @@ export async function GET(request, { params }) {
|
|||||||
return NextResponse.json({ error: "Connection not found" }, { status: 404 });
|
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];
|
const config = PROVIDER_MODELS_CONFIG[connection.provider];
|
||||||
if (!config) {
|
if (!config) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -145,4 +179,3 @@ export async function GET(request, { params }) {
|
|||||||
return NextResponse.json({ error: "Failed to fetch models" }, { status: 500 });
|
return NextResponse.json({ error: "Failed to fetch models" }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export async function PUT(request, { params }) {
|
|||||||
try {
|
try {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const body = await request.json();
|
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);
|
const existing = await getProviderConnectionById(id);
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
@@ -46,6 +46,9 @@ export async function PUT(request, { params }) {
|
|||||||
if (defaultModel !== undefined) updateData.defaultModel = defaultModel;
|
if (defaultModel !== undefined) updateData.defaultModel = defaultModel;
|
||||||
if (isActive !== undefined) updateData.isActive = isActive;
|
if (isActive !== undefined) updateData.isActive = isActive;
|
||||||
if (apiKey && existing.authType === "apikey") updateData.apiKey = apiKey;
|
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);
|
const updated = await updateProviderConnection(id, updateData);
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
|
|||||||
import { getProviderConnectionById, updateProviderConnection, isCloudEnabled } from "@/lib/localDb";
|
import { getProviderConnectionById, updateProviderConnection, isCloudEnabled } from "@/lib/localDb";
|
||||||
import { getConsistentMachineId } from "@/shared/utils/machineId";
|
import { getConsistentMachineId } from "@/shared/utils/machineId";
|
||||||
import { syncToCloud } from "@/app/api/sync/cloud/route";
|
import { syncToCloud } from "@/app/api/sync/cloud/route";
|
||||||
|
import { isOpenAICompatibleProvider } from "@/shared/constants/providers";
|
||||||
import {
|
import {
|
||||||
GEMINI_CONFIG,
|
GEMINI_CONFIG,
|
||||||
ANTIGRAVITY_CONFIG,
|
ANTIGRAVITY_CONFIG,
|
||||||
@@ -68,9 +69,9 @@ const OAUTH_TEST_CONFIG = {
|
|||||||
async function refreshOAuthToken(connection) {
|
async function refreshOAuthToken(connection) {
|
||||||
const provider = connection.provider;
|
const provider = connection.provider;
|
||||||
const refreshToken = connection.refreshToken;
|
const refreshToken = connection.refreshToken;
|
||||||
|
|
||||||
if (!refreshToken) return null;
|
if (!refreshToken) return null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Google-based providers (gemini-cli, antigravity)
|
// Google-based providers (gemini-cli, antigravity)
|
||||||
if (provider === "gemini-cli" || provider === "antigravity") {
|
if (provider === "gemini-cli" || provider === "antigravity") {
|
||||||
@@ -85,9 +86,9 @@ async function refreshOAuthToken(connection) {
|
|||||||
refresh_token: refreshToken,
|
refresh_token: refreshToken,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) return null;
|
if (!response.ok) return null;
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
return {
|
return {
|
||||||
accessToken: data.access_token,
|
accessToken: data.access_token,
|
||||||
@@ -95,7 +96,7 @@ async function refreshOAuthToken(connection) {
|
|||||||
refreshToken: data.refresh_token || refreshToken,
|
refreshToken: data.refresh_token || refreshToken,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// OpenAI/Codex
|
// OpenAI/Codex
|
||||||
if (provider === "codex") {
|
if (provider === "codex") {
|
||||||
const response = await fetch(CODEX_CONFIG.tokenUrl, {
|
const response = await fetch(CODEX_CONFIG.tokenUrl, {
|
||||||
@@ -107,9 +108,9 @@ async function refreshOAuthToken(connection) {
|
|||||||
refresh_token: refreshToken,
|
refresh_token: refreshToken,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) return null;
|
if (!response.ok) return null;
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
return {
|
return {
|
||||||
accessToken: data.access_token,
|
accessToken: data.access_token,
|
||||||
@@ -117,11 +118,11 @@ async function refreshOAuthToken(connection) {
|
|||||||
refreshToken: data.refresh_token || refreshToken,
|
refreshToken: data.refresh_token || refreshToken,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Kiro (AWS SSO or Social auth)
|
// Kiro (AWS SSO or Social auth)
|
||||||
if (provider === "kiro") {
|
if (provider === "kiro") {
|
||||||
const { clientId, clientSecret, region } = connection;
|
const { clientId, clientSecret, region } = connection;
|
||||||
|
|
||||||
// AWS SSO OIDC refresh (Builder ID or IDC)
|
// AWS SSO OIDC refresh (Builder ID or IDC)
|
||||||
if (clientId && clientSecret) {
|
if (clientId && clientSecret) {
|
||||||
const endpoint = `https://oidc.${region || "us-east-1"}.amazonaws.com/token`;
|
const endpoint = `https://oidc.${region || "us-east-1"}.amazonaws.com/token`;
|
||||||
@@ -135,13 +136,13 @@ async function refreshOAuthToken(connection) {
|
|||||||
grantType: "refresh_token",
|
grantType: "refresh_token",
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errText = await response.text();
|
const errText = await response.text();
|
||||||
console.log(`Kiro AWS SSO refresh failed: ${response.status} - ${errText}`);
|
console.log(`Kiro AWS SSO refresh failed: ${response.status} - ${errText}`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
return {
|
return {
|
||||||
accessToken: data.accessToken,
|
accessToken: data.accessToken,
|
||||||
@@ -149,20 +150,20 @@ async function refreshOAuthToken(connection) {
|
|||||||
refreshToken: data.refreshToken || refreshToken,
|
refreshToken: data.refreshToken || refreshToken,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Social auth refresh (Google/GitHub)
|
// Social auth refresh (Google/GitHub)
|
||||||
const response = await fetch(KIRO_CONFIG.socialRefreshUrl, {
|
const response = await fetch(KIRO_CONFIG.socialRefreshUrl, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ refreshToken }),
|
body: JSON.stringify({ refreshToken }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errText = await response.text();
|
const errText = await response.text();
|
||||||
console.log(`Kiro social refresh failed: ${response.status} - ${errText}`);
|
console.log(`Kiro social refresh failed: ${response.status} - ${errText}`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
return {
|
return {
|
||||||
accessToken: data.accessToken,
|
accessToken: data.accessToken,
|
||||||
@@ -170,7 +171,7 @@ async function refreshOAuthToken(connection) {
|
|||||||
refreshToken: data.refreshToken || refreshToken,
|
refreshToken: data.refreshToken || refreshToken,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.log(`Error refreshing ${provider} token:`, err.message);
|
console.log(`Error refreshing ${provider} token:`, err.message);
|
||||||
@@ -195,7 +196,7 @@ async function syncToCloudIfEnabled() {
|
|||||||
try {
|
try {
|
||||||
const cloudEnabled = await isCloudEnabled();
|
const cloudEnabled = await isCloudEnabled();
|
||||||
if (!cloudEnabled) return;
|
if (!cloudEnabled) return;
|
||||||
|
|
||||||
const machineId = await getConsistentMachineId();
|
const machineId = await getConsistentMachineId();
|
||||||
await syncToCloud(machineId);
|
await syncToCloud(machineId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -210,20 +211,20 @@ async function syncToCloudIfEnabled() {
|
|||||||
*/
|
*/
|
||||||
async function testOAuthConnection(connection) {
|
async function testOAuthConnection(connection) {
|
||||||
const config = OAUTH_TEST_CONFIG[connection.provider];
|
const config = OAUTH_TEST_CONFIG[connection.provider];
|
||||||
|
|
||||||
if (!config) {
|
if (!config) {
|
||||||
return { valid: false, error: "Provider test not supported", refreshed: false };
|
return { valid: false, error: "Provider test not supported", refreshed: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if token exists
|
// Check if token exists
|
||||||
if (!connection.accessToken) {
|
if (!connection.accessToken) {
|
||||||
return { valid: false, error: "No access token", refreshed: false };
|
return { valid: false, error: "No access token", refreshed: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
let accessToken = connection.accessToken;
|
let accessToken = connection.accessToken;
|
||||||
let refreshed = false;
|
let refreshed = false;
|
||||||
let newTokens = null;
|
let newTokens = null;
|
||||||
|
|
||||||
// Auto-refresh if token is expired and provider supports refresh
|
// Auto-refresh if token is expired and provider supports refresh
|
||||||
const tokenExpired = isTokenExpired(connection);
|
const tokenExpired = isTokenExpired(connection);
|
||||||
if (config.refreshable && tokenExpired && connection.refreshToken) {
|
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 };
|
return { valid: false, error: "Token expired and refresh failed", refreshed: false };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// For providers that only check expiry (no test endpoint available)
|
// For providers that only check expiry (no test endpoint available)
|
||||||
if (config.checkExpiry) {
|
if (config.checkExpiry) {
|
||||||
// If we already refreshed successfully, token is valid
|
// 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 };
|
return { valid: true, error: null, refreshed: false, newTokens: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Call test endpoint
|
// Call test endpoint
|
||||||
try {
|
try {
|
||||||
const headers = {
|
const headers = {
|
||||||
[config.authHeader]: `${config.authPrefix}${accessToken}`,
|
[config.authHeader]: `${config.authPrefix}${accessToken}`,
|
||||||
...config.extraHeaders,
|
...config.extraHeaders,
|
||||||
};
|
};
|
||||||
|
|
||||||
const res = await fetch(config.url, {
|
const res = await fetch(config.url, {
|
||||||
method: config.method,
|
method: config.method,
|
||||||
headers,
|
headers,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
return { valid: true, error: null, refreshed, newTokens };
|
return { valid: true, error: null, refreshed, newTokens };
|
||||||
}
|
}
|
||||||
|
|
||||||
// If 401 and we haven't tried refresh yet, try refresh now
|
// If 401 and we haven't tried refresh yet, try refresh now
|
||||||
if (res.status === 401 && config.refreshable && !refreshed && connection.refreshToken) {
|
if (res.status === 401 && config.refreshable && !refreshed && connection.refreshToken) {
|
||||||
const tokens = await refreshOAuthToken(connection);
|
const tokens = await refreshOAuthToken(connection);
|
||||||
@@ -279,21 +280,21 @@ async function testOAuthConnection(connection) {
|
|||||||
...config.extraHeaders,
|
...config.extraHeaders,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (retryRes.ok) {
|
if (retryRes.ok) {
|
||||||
return { valid: true, error: null, refreshed: true, newTokens: tokens };
|
return { valid: true, error: null, refreshed: true, newTokens: tokens };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { valid: false, error: "Token invalid or revoked", refreshed: false };
|
return { valid: false, error: "Token invalid or revoked", refreshed: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (res.status === 401) {
|
if (res.status === 401) {
|
||||||
return { valid: false, error: "Token invalid or revoked", refreshed };
|
return { valid: false, error: "Token invalid or revoked", refreshed };
|
||||||
}
|
}
|
||||||
if (res.status === 403) {
|
if (res.status === 403) {
|
||||||
return { valid: false, error: "Access denied", refreshed };
|
return { valid: false, error: "Access denied", refreshed };
|
||||||
}
|
}
|
||||||
|
|
||||||
return { valid: false, error: `API returned ${res.status}`, refreshed };
|
return { valid: false, error: `API returned ${res.status}`, refreshed };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return { valid: false, error: err.message, refreshed };
|
return { valid: false, error: err.message, refreshed };
|
||||||
@@ -304,6 +305,23 @@ async function testOAuthConnection(connection) {
|
|||||||
* Test API key connection
|
* Test API key connection
|
||||||
*/
|
*/
|
||||||
async function testApiKeyConnection(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 {
|
try {
|
||||||
switch (connection.provider) {
|
switch (connection.provider) {
|
||||||
case "openai": {
|
case "openai": {
|
||||||
@@ -312,7 +330,7 @@ async function testApiKeyConnection(connection) {
|
|||||||
});
|
});
|
||||||
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
|
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
|
||||||
}
|
}
|
||||||
|
|
||||||
case "anthropic": {
|
case "anthropic": {
|
||||||
const res = await fetch("https://api.anthropic.com/v1/messages", {
|
const res = await fetch("https://api.anthropic.com/v1/messages", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -330,19 +348,19 @@ async function testApiKeyConnection(connection) {
|
|||||||
const valid = res.status !== 401;
|
const valid = res.status !== 401;
|
||||||
return { valid, error: valid ? null : "Invalid API key" };
|
return { valid, error: valid ? null : "Invalid API key" };
|
||||||
}
|
}
|
||||||
|
|
||||||
case "gemini": {
|
case "gemini": {
|
||||||
const res = await fetch(`https://generativelanguage.googleapis.com/v1/models?key=${connection.apiKey}`);
|
const res = await fetch(`https://generativelanguage.googleapis.com/v1/models?key=${connection.apiKey}`);
|
||||||
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
|
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
|
||||||
}
|
}
|
||||||
|
|
||||||
case "openrouter": {
|
case "openrouter": {
|
||||||
const res = await fetch("https://openrouter.ai/api/v1/auth/key", {
|
const res = await fetch("https://openrouter.ai/api/v1/auth/key", {
|
||||||
headers: { Authorization: `Bearer ${connection.apiKey}` },
|
headers: { Authorization: `Bearer ${connection.apiKey}` },
|
||||||
});
|
});
|
||||||
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
|
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
|
||||||
}
|
}
|
||||||
|
|
||||||
case "glm": {
|
case "glm": {
|
||||||
// GLM uses Claude-compatible API at api.z.ai
|
// GLM uses Claude-compatible API at api.z.ai
|
||||||
const res = await fetch("https://api.z.ai/api/anthropic/v1/messages", {
|
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;
|
const valid = res.status !== 401 && res.status !== 403;
|
||||||
return { valid, error: valid ? null : "Invalid API key" };
|
return { valid, error: valid ? null : "Invalid API key" };
|
||||||
}
|
}
|
||||||
|
|
||||||
case "minimax": {
|
case "minimax": {
|
||||||
// MiniMax uses Claude-compatible API
|
// MiniMax uses Claude-compatible API
|
||||||
const res = await fetch("https://api.minimax.io/anthropic/v1/messages", {
|
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;
|
const valid = res.status !== 401 && res.status !== 403;
|
||||||
return { valid, error: valid ? null : "Invalid API key" };
|
return { valid, error: valid ? null : "Invalid API key" };
|
||||||
}
|
}
|
||||||
|
|
||||||
case "kimi": {
|
case "kimi": {
|
||||||
// Kimi uses Claude-compatible API
|
// Kimi uses Claude-compatible API
|
||||||
const res = await fetch("https://api.kimi.com/coding/v1/messages", {
|
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;
|
const valid = res.status !== 401 && res.status !== 403;
|
||||||
return { valid, error: valid ? null : "Invalid API key" };
|
return { valid, error: valid ? null : "Invalid API key" };
|
||||||
}
|
}
|
||||||
|
|
||||||
case "deepseek": {
|
case "deepseek": {
|
||||||
const res = await fetch("https://api.deepseek.com/models", {
|
const res = await fetch("https://api.deepseek.com/models", {
|
||||||
headers: { Authorization: `Bearer ${connection.apiKey}` },
|
headers: { Authorization: `Bearer ${connection.apiKey}` },
|
||||||
});
|
});
|
||||||
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
|
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
|
||||||
}
|
}
|
||||||
|
|
||||||
case "groq": {
|
case "groq": {
|
||||||
const res = await fetch("https://api.groq.com/openai/v1/models", {
|
const res = await fetch("https://api.groq.com/openai/v1/models", {
|
||||||
headers: { Authorization: `Bearer ${connection.apiKey}` },
|
headers: { Authorization: `Bearer ${connection.apiKey}` },
|
||||||
});
|
});
|
||||||
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
|
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
|
||||||
}
|
}
|
||||||
|
|
||||||
case "mistral": {
|
case "mistral": {
|
||||||
const res = await fetch("https://api.mistral.ai/v1/models", {
|
const res = await fetch("https://api.mistral.ai/v1/models", {
|
||||||
headers: { Authorization: `Bearer ${connection.apiKey}` },
|
headers: { Authorization: `Bearer ${connection.apiKey}` },
|
||||||
});
|
});
|
||||||
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
|
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
|
||||||
}
|
}
|
||||||
|
|
||||||
case "xai": {
|
case "xai": {
|
||||||
const res = await fetch("https://api.x.ai/v1/models", {
|
const res = await fetch("https://api.x.ai/v1/models", {
|
||||||
headers: { Authorization: `Bearer ${connection.apiKey}` },
|
headers: { Authorization: `Bearer ${connection.apiKey}` },
|
||||||
});
|
});
|
||||||
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
|
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
|
||||||
}
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return { valid: false, error: "Provider test not supported" };
|
return { valid: false, error: "Provider test not supported" };
|
||||||
}
|
}
|
||||||
@@ -447,7 +465,7 @@ export async function POST(request, { params }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let result;
|
let result;
|
||||||
|
|
||||||
if (connection.authType === "apikey") {
|
if (connection.authType === "apikey") {
|
||||||
result = await testApiKeyConnection(connection);
|
result = await testApiKeyConnection(connection);
|
||||||
} else {
|
} else {
|
||||||
@@ -460,7 +478,7 @@ export async function POST(request, { params }) {
|
|||||||
lastError: result.valid ? null : result.error,
|
lastError: result.valid ? null : result.error,
|
||||||
lastErrorAt: result.valid ? null : new Date().toISOString(),
|
lastErrorAt: result.valid ? null : new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// If token was refreshed, update tokens in DB
|
// If token was refreshed, update tokens in DB
|
||||||
if (result.refreshed && result.newTokens) {
|
if (result.refreshed && result.newTokens) {
|
||||||
updateData.accessToken = result.newTokens.accessToken;
|
updateData.accessToken = result.newTokens.accessToken;
|
||||||
@@ -474,7 +492,7 @@ export async function POST(request, { params }) {
|
|||||||
|
|
||||||
// Update status in db
|
// Update status in db
|
||||||
await updateProviderConnection(id, updateData);
|
await updateProviderConnection(id, updateData);
|
||||||
|
|
||||||
// Sync to cloud if token was refreshed
|
// Sync to cloud if token was refreshed
|
||||||
if (result.refreshed) {
|
if (result.refreshed) {
|
||||||
await syncToCloudIfEnabled();
|
await syncToCloudIfEnabled();
|
||||||
@@ -490,4 +508,3 @@ export async function POST(request, { params }) {
|
|||||||
return NextResponse.json({ error: "Test failed" }, { status: 500 });
|
return NextResponse.json({ error: "Test failed" }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { NextResponse } from "next/server";
|
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 { APIKEY_PROVIDERS } from "@/shared/constants/config";
|
||||||
|
import { isOpenAICompatibleProvider } from "@/shared/constants/providers";
|
||||||
import { getConsistentMachineId } from "@/shared/utils/machineId";
|
import { getConsistentMachineId } from "@/shared/utils/machineId";
|
||||||
import { syncToCloud } from "@/app/api/sync/cloud/route";
|
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;
|
const { provider, apiKey, name, priority, globalPriority, defaultModel, testStatus } = body;
|
||||||
|
|
||||||
// Validation
|
// Validation
|
||||||
if (!provider || !APIKEY_PROVIDERS[provider]) {
|
if (!provider || (!APIKEY_PROVIDERS[provider] && !isOpenAICompatibleProvider(provider))) {
|
||||||
return NextResponse.json({ error: "Invalid provider" }, { status: 400 });
|
return NextResponse.json({ error: "Invalid provider" }, { status: 400 });
|
||||||
}
|
}
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
@@ -42,6 +43,27 @@ export async function POST(request) {
|
|||||||
return NextResponse.json({ error: "Name is required" }, { status: 400 });
|
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({
|
const newConnection = await createProviderConnection({
|
||||||
provider,
|
provider,
|
||||||
authType: "apikey",
|
authType: "apikey",
|
||||||
@@ -50,6 +72,7 @@ export async function POST(request) {
|
|||||||
priority: priority || 1,
|
priority: priority || 1,
|
||||||
globalPriority: globalPriority || null,
|
globalPriority: globalPriority || null,
|
||||||
defaultModel: defaultModel || null,
|
defaultModel: defaultModel || null,
|
||||||
|
providerSpecificData,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
testStatus: testStatus || "unknown",
|
testStatus: testStatus || "unknown",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
import { getProviderNodeById } from "@/models";
|
||||||
|
import { isOpenAICompatibleProvider } from "@/shared/constants/providers";
|
||||||
|
|
||||||
// POST /api/providers/validate - Validate API key with provider
|
// POST /api/providers/validate - Validate API key with provider
|
||||||
export async function POST(request) {
|
export async function POST(request) {
|
||||||
@@ -15,6 +17,22 @@ export async function POST(request) {
|
|||||||
|
|
||||||
// Validate with each provider
|
// Validate with each provider
|
||||||
try {
|
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) {
|
switch (provider) {
|
||||||
case "openai":
|
case "openai":
|
||||||
const openaiRes = await fetch("https://api.openai.com/v1/models", {
|
const openaiRes = await fetch("https://api.openai.com/v1/models", {
|
||||||
@@ -77,8 +95,8 @@ export async function POST(request) {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return NextResponse.json({ error: "Provider validation not supported" }, { status: 400 });
|
return NextResponse.json({ error: "Provider validation not supported" }, { status: 400 });
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error = err.message;
|
error = err.message;
|
||||||
|
|||||||
@@ -31,7 +31,10 @@ export async function POST(request) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Build URL and headers using provider service
|
// 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)
|
console.log("🚀 ~ POST ~ url:", url)
|
||||||
const headers = buildProviderHeaders(provider, credentials, true, body);
|
const headers = buildProviderHeaders(provider, credentials, true, body);
|
||||||
console.log("🚀 ~ POST ~ headers:", headers)
|
console.log("🚀 ~ POST ~ headers:", headers)
|
||||||
|
|||||||
@@ -91,7 +91,10 @@ export async function POST(request) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Build URL and headers
|
// 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);
|
const headers = buildProviderHeaders(provider, credentials, true, actualBody);
|
||||||
|
|
||||||
result = {
|
result = {
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ if (!isCloud && !fs.existsSync(DATA_DIR)) {
|
|||||||
// Default data structure
|
// Default data structure
|
||||||
const defaultData = {
|
const defaultData = {
|
||||||
providerConnections: [],
|
providerConnections: [],
|
||||||
|
providerNodes: [],
|
||||||
modelAliases: {},
|
modelAliases: {},
|
||||||
combos: [],
|
combos: [],
|
||||||
apiKeys: [],
|
apiKeys: [],
|
||||||
@@ -116,6 +117,103 @@ export async function getProviderConnections(filter = {}) {
|
|||||||
return connections;
|
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
|
* Get provider connection by ID
|
||||||
*/
|
*/
|
||||||
@@ -699,4 +797,3 @@ export async function resetAllPricing() {
|
|||||||
await db.write();
|
await db.write();
|
||||||
return db.data.pricing;
|
return db.data.pricing;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,12 @@ export {
|
|||||||
createProviderConnection,
|
createProviderConnection,
|
||||||
updateProviderConnection,
|
updateProviderConnection,
|
||||||
deleteProviderConnection,
|
deleteProviderConnection,
|
||||||
|
getProviderNodes,
|
||||||
|
getProviderNodeById,
|
||||||
|
createProviderNode,
|
||||||
|
updateProviderNode,
|
||||||
|
deleteProviderNode,
|
||||||
|
deleteProviderConnectionsByProvider,
|
||||||
getModelAliases,
|
getModelAliases,
|
||||||
setModelAlias,
|
setModelAlias,
|
||||||
deleteModelAlias,
|
deleteModelAlias,
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export {
|
|||||||
getModelsByProviderId
|
getModelsByProviderId
|
||||||
} from "open-sse/config/providerModels.js";
|
} 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";
|
import { PROVIDER_MODELS as MODELS } from "open-sse/config/providerModels.js";
|
||||||
|
|
||||||
// Providers that accept any model (passthrough)
|
// Providers that accept any model (passthrough)
|
||||||
@@ -22,6 +22,7 @@ const PASSTHROUGH_PROVIDERS = new Set(
|
|||||||
|
|
||||||
// Wrap isValidModel with passthrough providers
|
// Wrap isValidModel with passthrough providers
|
||||||
export function isValidModel(aliasOrId, modelId) {
|
export function isValidModel(aliasOrId, modelId) {
|
||||||
|
if (isOpenAICompatibleProvider(aliasOrId)) return true;
|
||||||
if (PASSTHROUGH_PROVIDERS.has(aliasOrId)) return true;
|
if (PASSTHROUGH_PROVIDERS.has(aliasOrId)) return true;
|
||||||
const models = MODELS[aliasOrId];
|
const models = MODELS[aliasOrId];
|
||||||
if (!models) return false;
|
if (!models) return false;
|
||||||
|
|||||||
@@ -22,6 +22,12 @@ export const APIKEY_PROVIDERS = {
|
|||||||
gemini: { id: "gemini", alias: "gemini", name: "Gemini", icon: "diamond", color: "#4285F4", textIcon: "GE" },
|
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)
|
// All providers (combined)
|
||||||
export const AI_PROVIDERS = { ...OAUTH_PROVIDERS, ...APIKEY_PROVIDERS };
|
export const AI_PROVIDERS = { ...OAUTH_PROVIDERS, ...APIKEY_PROVIDERS };
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Re-export from open-sse with localDb integration
|
// 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";
|
import { parseModel, resolveModelAliasFromMap, getModelInfoCore } from "open-sse/services/model.js";
|
||||||
|
|
||||||
export { parseModel };
|
export { parseModel };
|
||||||
@@ -16,6 +16,22 @@ export async function resolveModelAlias(alias) {
|
|||||||
* Get full model info (parse or resolve)
|
* Get full model info (parse or resolve)
|
||||||
*/
|
*/
|
||||||
export async function getModelInfo(modelStr) {
|
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);
|
return getModelInfoCore(modelStr, getModelAliases);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user