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:
decolua
2026-02-02 19:45:12 +07:00
parent 1b14c9d66b
commit 0a28f9f924
25 changed files with 1276 additions and 151 deletions

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View File

@@ -1,5 +1,6 @@
import { NextResponse } from "next/server";
import { getProviderConnectionById } from "@/models";
import { isOpenAICompatibleProvider } from "@/shared/constants/providers";
// Provider models endpoints configuration
const PROVIDER_MODELS_CONFIG = {
@@ -85,6 +86,39 @@ export async function GET(request, { params }) {
return NextResponse.json({ error: "Connection not found" }, { status: 404 });
}
if (isOpenAICompatibleProvider(connection.provider)) {
const baseUrl = connection.providerSpecificData?.baseUrl;
if (!baseUrl) {
return NextResponse.json({ error: "No base URL configured for OpenAI compatible provider" }, { status: 400 });
}
const url = `${baseUrl.replace(/\/$/, "")}/models`;
const response = await fetch(url, {
method: "GET",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${connection.apiKey}`,
},
});
if (!response.ok) {
const errorText = await response.text();
console.log(`Error fetching models from ${connection.provider}:`, errorText);
return NextResponse.json(
{ error: `Failed to fetch models: ${response.status}` },
{ status: response.status }
);
}
const data = await response.json();
const models = data.data || data.models || [];
return NextResponse.json({
provider: connection.provider,
connectionId: connection.id,
models
});
}
const config = PROVIDER_MODELS_CONFIG[connection.provider];
if (!config) {
return NextResponse.json(
@@ -145,4 +179,3 @@ export async function GET(request, { params }) {
return NextResponse.json({ error: "Failed to fetch models" }, { status: 500 });
}
}

View File

@@ -32,7 +32,7 @@ export async function PUT(request, { params }) {
try {
const { id } = await params;
const body = await request.json();
const { name, priority, globalPriority, defaultModel, isActive, apiKey } = body;
const { name, priority, globalPriority, defaultModel, isActive, apiKey, testStatus, lastError, lastErrorAt } = body;
const existing = await getProviderConnectionById(id);
if (!existing) {
@@ -46,6 +46,9 @@ export async function PUT(request, { params }) {
if (defaultModel !== undefined) updateData.defaultModel = defaultModel;
if (isActive !== undefined) updateData.isActive = isActive;
if (apiKey && existing.authType === "apikey") updateData.apiKey = apiKey;
if (testStatus !== undefined) updateData.testStatus = testStatus;
if (lastError !== undefined) updateData.lastError = lastError;
if (lastErrorAt !== undefined) updateData.lastErrorAt = lastErrorAt;
const updated = await updateProviderConnection(id, updateData);

View File

@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
import { getProviderConnectionById, updateProviderConnection, isCloudEnabled } from "@/lib/localDb";
import { getConsistentMachineId } from "@/shared/utils/machineId";
import { syncToCloud } from "@/app/api/sync/cloud/route";
import { isOpenAICompatibleProvider } from "@/shared/constants/providers";
import {
GEMINI_CONFIG,
ANTIGRAVITY_CONFIG,
@@ -68,9 +69,9 @@ const OAUTH_TEST_CONFIG = {
async function refreshOAuthToken(connection) {
const provider = connection.provider;
const refreshToken = connection.refreshToken;
if (!refreshToken) return null;
try {
// Google-based providers (gemini-cli, antigravity)
if (provider === "gemini-cli" || provider === "antigravity") {
@@ -85,9 +86,9 @@ async function refreshOAuthToken(connection) {
refresh_token: refreshToken,
}),
});
if (!response.ok) return null;
const data = await response.json();
return {
accessToken: data.access_token,
@@ -95,7 +96,7 @@ async function refreshOAuthToken(connection) {
refreshToken: data.refresh_token || refreshToken,
};
}
// OpenAI/Codex
if (provider === "codex") {
const response = await fetch(CODEX_CONFIG.tokenUrl, {
@@ -107,9 +108,9 @@ async function refreshOAuthToken(connection) {
refresh_token: refreshToken,
}),
});
if (!response.ok) return null;
const data = await response.json();
return {
accessToken: data.access_token,
@@ -117,11 +118,11 @@ async function refreshOAuthToken(connection) {
refreshToken: data.refresh_token || refreshToken,
};
}
// Kiro (AWS SSO or Social auth)
if (provider === "kiro") {
const { clientId, clientSecret, region } = connection;
// AWS SSO OIDC refresh (Builder ID or IDC)
if (clientId && clientSecret) {
const endpoint = `https://oidc.${region || "us-east-1"}.amazonaws.com/token`;
@@ -135,13 +136,13 @@ async function refreshOAuthToken(connection) {
grantType: "refresh_token",
}),
});
if (!response.ok) {
const errText = await response.text();
console.log(`Kiro AWS SSO refresh failed: ${response.status} - ${errText}`);
return null;
}
const data = await response.json();
return {
accessToken: data.accessToken,
@@ -149,20 +150,20 @@ async function refreshOAuthToken(connection) {
refreshToken: data.refreshToken || refreshToken,
};
}
// Social auth refresh (Google/GitHub)
const response = await fetch(KIRO_CONFIG.socialRefreshUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refreshToken }),
});
if (!response.ok) {
const errText = await response.text();
console.log(`Kiro social refresh failed: ${response.status} - ${errText}`);
return null;
}
const data = await response.json();
return {
accessToken: data.accessToken,
@@ -170,7 +171,7 @@ async function refreshOAuthToken(connection) {
refreshToken: data.refreshToken || refreshToken,
};
}
return null;
} catch (err) {
console.log(`Error refreshing ${provider} token:`, err.message);
@@ -195,7 +196,7 @@ async function syncToCloudIfEnabled() {
try {
const cloudEnabled = await isCloudEnabled();
if (!cloudEnabled) return;
const machineId = await getConsistentMachineId();
await syncToCloud(machineId);
} catch (error) {
@@ -210,20 +211,20 @@ async function syncToCloudIfEnabled() {
*/
async function testOAuthConnection(connection) {
const config = OAUTH_TEST_CONFIG[connection.provider];
if (!config) {
return { valid: false, error: "Provider test not supported", refreshed: false };
}
// Check if token exists
if (!connection.accessToken) {
return { valid: false, error: "No access token", refreshed: false };
}
let accessToken = connection.accessToken;
let refreshed = false;
let newTokens = null;
// Auto-refresh if token is expired and provider supports refresh
const tokenExpired = isTokenExpired(connection);
if (config.refreshable && tokenExpired && connection.refreshToken) {
@@ -237,7 +238,7 @@ async function testOAuthConnection(connection) {
return { valid: false, error: "Token expired and refresh failed", refreshed: false };
}
}
// For providers that only check expiry (no test endpoint available)
if (config.checkExpiry) {
// If we already refreshed successfully, token is valid
@@ -250,23 +251,23 @@ async function testOAuthConnection(connection) {
}
return { valid: true, error: null, refreshed: false, newTokens: null };
}
// Call test endpoint
try {
const headers = {
[config.authHeader]: `${config.authPrefix}${accessToken}`,
...config.extraHeaders,
};
const res = await fetch(config.url, {
method: config.method,
headers,
});
if (res.ok) {
return { valid: true, error: null, refreshed, newTokens };
}
// If 401 and we haven't tried refresh yet, try refresh now
if (res.status === 401 && config.refreshable && !refreshed && connection.refreshToken) {
const tokens = await refreshOAuthToken(connection);
@@ -279,21 +280,21 @@ async function testOAuthConnection(connection) {
...config.extraHeaders,
},
});
if (retryRes.ok) {
return { valid: true, error: null, refreshed: true, newTokens: tokens };
}
}
return { valid: false, error: "Token invalid or revoked", refreshed: false };
}
if (res.status === 401) {
return { valid: false, error: "Token invalid or revoked", refreshed };
}
if (res.status === 403) {
return { valid: false, error: "Access denied", refreshed };
}
return { valid: false, error: `API returned ${res.status}`, refreshed };
} catch (err) {
return { valid: false, error: err.message, refreshed };
@@ -304,6 +305,23 @@ async function testOAuthConnection(connection) {
* Test API key connection
*/
async function testApiKeyConnection(connection) {
// OpenAI Compatible providers - test via /models endpoint
if (isOpenAICompatibleProvider(connection.provider)) {
const modelsBase = connection.providerSpecificData?.baseUrl;
if (!modelsBase) {
return { valid: false, error: "Missing base URL" };
}
try {
const modelsUrl = `${modelsBase.replace(/\/$/, "")}/models`;
const res = await fetch(modelsUrl, {
headers: { "Authorization": `Bearer ${connection.apiKey}` },
});
return { valid: res.ok, error: res.ok ? null : "Invalid API key or base URL" };
} catch (err) {
return { valid: false, error: err.message };
}
}
try {
switch (connection.provider) {
case "openai": {
@@ -312,7 +330,7 @@ async function testApiKeyConnection(connection) {
});
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
}
case "anthropic": {
const res = await fetch("https://api.anthropic.com/v1/messages", {
method: "POST",
@@ -330,19 +348,19 @@ async function testApiKeyConnection(connection) {
const valid = res.status !== 401;
return { valid, error: valid ? null : "Invalid API key" };
}
case "gemini": {
const res = await fetch(`https://generativelanguage.googleapis.com/v1/models?key=${connection.apiKey}`);
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
}
case "openrouter": {
const res = await fetch("https://openrouter.ai/api/v1/auth/key", {
headers: { Authorization: `Bearer ${connection.apiKey}` },
});
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
}
case "glm": {
// GLM uses Claude-compatible API at api.z.ai
const res = await fetch("https://api.z.ai/api/anthropic/v1/messages", {
@@ -361,7 +379,7 @@ async function testApiKeyConnection(connection) {
const valid = res.status !== 401 && res.status !== 403;
return { valid, error: valid ? null : "Invalid API key" };
}
case "minimax": {
// MiniMax uses Claude-compatible API
const res = await fetch("https://api.minimax.io/anthropic/v1/messages", {
@@ -380,7 +398,7 @@ async function testApiKeyConnection(connection) {
const valid = res.status !== 401 && res.status !== 403;
return { valid, error: valid ? null : "Invalid API key" };
}
case "kimi": {
// Kimi uses Claude-compatible API
const res = await fetch("https://api.kimi.com/coding/v1/messages", {
@@ -399,35 +417,35 @@ async function testApiKeyConnection(connection) {
const valid = res.status !== 401 && res.status !== 403;
return { valid, error: valid ? null : "Invalid API key" };
}
case "deepseek": {
const res = await fetch("https://api.deepseek.com/models", {
headers: { Authorization: `Bearer ${connection.apiKey}` },
});
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
}
case "groq": {
const res = await fetch("https://api.groq.com/openai/v1/models", {
headers: { Authorization: `Bearer ${connection.apiKey}` },
});
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
}
case "mistral": {
const res = await fetch("https://api.mistral.ai/v1/models", {
headers: { Authorization: `Bearer ${connection.apiKey}` },
});
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
}
case "xai": {
const res = await fetch("https://api.x.ai/v1/models", {
headers: { Authorization: `Bearer ${connection.apiKey}` },
});
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
}
default:
return { valid: false, error: "Provider test not supported" };
}
@@ -447,7 +465,7 @@ export async function POST(request, { params }) {
}
let result;
if (connection.authType === "apikey") {
result = await testApiKeyConnection(connection);
} else {
@@ -460,7 +478,7 @@ export async function POST(request, { params }) {
lastError: result.valid ? null : result.error,
lastErrorAt: result.valid ? null : new Date().toISOString(),
};
// If token was refreshed, update tokens in DB
if (result.refreshed && result.newTokens) {
updateData.accessToken = result.newTokens.accessToken;
@@ -474,7 +492,7 @@ export async function POST(request, { params }) {
// Update status in db
await updateProviderConnection(id, updateData);
// Sync to cloud if token was refreshed
if (result.refreshed) {
await syncToCloudIfEnabled();
@@ -490,4 +508,3 @@ export async function POST(request, { params }) {
return NextResponse.json({ error: "Test failed" }, { status: 500 });
}
}

View File

@@ -1,6 +1,7 @@
import { NextResponse } from "next/server";
import { getProviderConnections, createProviderConnection, isCloudEnabled } from "@/models";
import { getProviderConnections, createProviderConnection, getProviderNodeById, isCloudEnabled } from "@/models";
import { APIKEY_PROVIDERS } from "@/shared/constants/config";
import { isOpenAICompatibleProvider } from "@/shared/constants/providers";
import { getConsistentMachineId } from "@/shared/utils/machineId";
import { syncToCloud } from "@/app/api/sync/cloud/route";
@@ -32,7 +33,7 @@ export async function POST(request) {
const { provider, apiKey, name, priority, globalPriority, defaultModel, testStatus } = body;
// Validation
if (!provider || !APIKEY_PROVIDERS[provider]) {
if (!provider || (!APIKEY_PROVIDERS[provider] && !isOpenAICompatibleProvider(provider))) {
return NextResponse.json({ error: "Invalid provider" }, { status: 400 });
}
if (!apiKey) {
@@ -42,6 +43,27 @@ export async function POST(request) {
return NextResponse.json({ error: "Name is required" }, { status: 400 });
}
let providerSpecificData = null;
if (isOpenAICompatibleProvider(provider)) {
const node = await getProviderNodeById(provider);
if (!node) {
return NextResponse.json({ error: "OpenAI Compatible node not found" }, { status: 404 });
}
const existingConnections = await getProviderConnections({ provider });
if (existingConnections.length > 0) {
return NextResponse.json({ error: "Only one connection is allowed for this OpenAI Compatible node" }, { status: 400 });
}
providerSpecificData = {
prefix: node.prefix,
apiType: node.apiType,
baseUrl: node.baseUrl,
nodeName: node.name,
};
}
const newConnection = await createProviderConnection({
provider,
authType: "apikey",
@@ -50,6 +72,7 @@ export async function POST(request) {
priority: priority || 1,
globalPriority: globalPriority || null,
defaultModel: defaultModel || null,
providerSpecificData,
isActive: true,
testStatus: testStatus || "unknown",
});

View File

@@ -1,4 +1,6 @@
import { NextResponse } from "next/server";
import { getProviderNodeById } from "@/models";
import { isOpenAICompatibleProvider } from "@/shared/constants/providers";
// POST /api/providers/validate - Validate API key with provider
export async function POST(request) {
@@ -15,6 +17,22 @@ export async function POST(request) {
// Validate with each provider
try {
if (isOpenAICompatibleProvider(provider)) {
const node = await getProviderNodeById(provider);
if (!node) {
return NextResponse.json({ error: "OpenAI Compatible node not found" }, { status: 404 });
}
const modelsUrl = `${node.baseUrl?.replace(/\/$/, "")}/models`;
const res = await fetch(modelsUrl, {
headers: { "Authorization": `Bearer ${apiKey}` },
});
isValid = res.ok;
return NextResponse.json({
valid: isValid,
error: isValid ? null : "Invalid API key",
});
}
switch (provider) {
case "openai":
const openaiRes = await fetch("https://api.openai.com/v1/models", {
@@ -77,8 +95,8 @@ export async function POST(request) {
break;
}
default:
return NextResponse.json({ error: "Provider validation not supported" }, { status: 400 });
default:
return NextResponse.json({ error: "Provider validation not supported" }, { status: 400 });
}
} catch (err) {
error = err.message;

View File

@@ -31,7 +31,10 @@ export async function POST(request) {
};
// Build URL and headers using provider service
const url = buildProviderUrl(provider, body.model || "test-model", true, { baseUrlIndex: 0 });
const url = buildProviderUrl(provider, body.model || "test-model", true, {
baseUrlIndex: 0,
baseUrl: connection.providerSpecificData?.baseUrl
});
console.log("🚀 ~ POST ~ url:", url)
const headers = buildProviderHeaders(provider, credentials, true, body);
console.log("🚀 ~ POST ~ headers:", headers)

View File

@@ -91,7 +91,10 @@ export async function POST(request) {
};
// Build URL and headers
const url = buildProviderUrl(provider, model, true, { baseUrlIndex: 0 });
const url = buildProviderUrl(provider, model, true, {
baseUrlIndex: 0,
baseUrl: connection.providerSpecificData?.baseUrl
});
const headers = buildProviderHeaders(provider, credentials, true, actualBody);
result = {