Enhance image and embedding provider support

- Added new image models for GPT 5.2, 5.3, and 5.4, including capabilities for text-to-image and editing.
- Updated embedding handling to include optional dimensions in requests.
- Introduced support for custom embedding providers, allowing dynamic fetching and validation of custom nodes.
- Improved image generation handling with Codex integration, including progress tracking and error handling.
- Enhanced UI components to support adding custom embeddings and displaying their status.
This commit is contained in:
decolua
2026-04-25 16:22:30 +07:00
parent cca615eaff
commit 0b8bed5793
19 changed files with 1039 additions and 130 deletions

View File

@@ -40,6 +40,14 @@ export async function PUT(request, { params }) {
}
}
// Sanitize Base URL for Custom Embedding (strip trailing slash and /embeddings)
if (node.type === "custom-embedding") {
sanitizedBaseUrl = sanitizedBaseUrl.replace(/\/$/, "");
if (sanitizedBaseUrl.endsWith("/embeddings")) {
sanitizedBaseUrl = sanitizedBaseUrl.slice(0, -"/embeddings".length);
}
}
const updates = {
name: name.trim(),
prefix: prefix.trim(),

View File

@@ -1,6 +1,6 @@
import { NextResponse } from "next/server";
import { createProviderNode, getProviderNodes } from "@/models";
import { OPENAI_COMPATIBLE_PREFIX, ANTHROPIC_COMPATIBLE_PREFIX } from "@/shared/constants/providers";
import { OPENAI_COMPATIBLE_PREFIX, ANTHROPIC_COMPATIBLE_PREFIX, CUSTOM_EMBEDDING_PREFIX } from "@/shared/constants/providers";
import { generateId } from "@/shared/utils";
export const dynamic = "force-dynamic";
@@ -13,6 +13,10 @@ const ANTHROPIC_COMPATIBLE_DEFAULTS = {
baseUrl: "https://api.anthropic.com/v1",
};
const CUSTOM_EMBEDDING_DEFAULTS = {
baseUrl: "https://api.openai.com/v1",
};
// GET /api/provider-nodes - List all provider nodes
export async function GET() {
try {
@@ -57,6 +61,23 @@ export async function POST(request) {
return NextResponse.json({ node }, { status: 201 });
}
if (nodeType === "custom-embedding") {
// Strip trailing slash and /embeddings if user pasted full endpoint
let sanitizedBaseUrl = (baseUrl || CUSTOM_EMBEDDING_DEFAULTS.baseUrl).trim().replace(/\/$/, "");
if (sanitizedBaseUrl.endsWith("/embeddings")) {
sanitizedBaseUrl = sanitizedBaseUrl.slice(0, -"/embeddings".length);
}
const node = await createProviderNode({
id: `${CUSTOM_EMBEDDING_PREFIX}${generateId()}`,
type: "custom-embedding",
prefix: prefix.trim(),
baseUrl: sanitizedBaseUrl,
name: name.trim(),
});
return NextResponse.json({ node }, { status: 201 });
}
if (nodeType === "anthropic-compatible") {
// Sanitize Base URL: remove trailing slash, and remove trailing /messages if user added it
// This prevents double-appending /messages at runtime

View File

@@ -64,6 +64,36 @@ export async function POST(request) {
return NextResponse.json({ error: "Invalid URL format" }, { status: 400 });
}
// Custom Embedding Validation - test POST /embeddings directly
if (type === "custom-embedding") {
const normalizedBase = baseUrl.trim().replace(/\/$/, "");
if (!modelId?.trim()) {
return NextResponse.json({ valid: false, error: "Model ID required for embedding validation" });
}
const embedRes = await fetchWithTimeout(`${normalizedBase}/embeddings`, {
method: "POST",
headers: {
"Authorization": `Bearer ${apiKey}`,
"Content-Type": "application/json"
},
body: JSON.stringify({ model: modelId.trim(), input: "ping" })
});
if (embedRes.ok) {
const data = await embedRes.json().catch(() => null);
const dims = Array.isArray(data?.data?.[0]?.embedding) ? data.data[0].embedding.length : null;
return NextResponse.json({ valid: true, method: "embeddings", dimensions: dims });
}
if (embedRes.status === 401 || embedRes.status === 403) {
return NextResponse.json({ valid: false, error: "API key unauthorized" });
}
const errBody = await embedRes.text().catch(() => "");
return NextResponse.json({
valid: false,
error: `Embeddings request failed (${embedRes.status})${errBody ? `: ${errBody.slice(0, 200)}` : ""}`,
method: "embeddings"
});
}
// Anthropic Compatible Validation
if (type === "anthropic-compatible") {
let normalizedBase = baseUrl.trim().replace(/\/$/, "");

View File

@@ -7,7 +7,7 @@ import {
getProxyPoolById,
} from "@/models";
import { APIKEY_PROVIDERS } from "@/shared/constants/config";
import { FREE_TIER_PROVIDERS, WEB_COOKIE_PROVIDERS, isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers";
import { FREE_TIER_PROVIDERS, WEB_COOKIE_PROVIDERS, isOpenAICompatibleProvider, isAnthropicCompatibleProvider, isCustomEmbeddingProvider } from "@/shared/constants/providers";
export const dynamic = "force-dynamic";
@@ -104,7 +104,8 @@ export async function POST(request) {
FREE_TIER_PROVIDERS[provider] ||
isWebCookieProvider ||
isOpenAICompatibleProvider(provider) ||
isAnthropicCompatibleProvider(provider);
isAnthropicCompatibleProvider(provider) ||
isCustomEmbeddingProvider(provider);
if (!provider || !isValidProvider) {
return NextResponse.json({ error: "Invalid provider" }, { status: 400 });
@@ -146,6 +147,22 @@ export async function POST(request) {
return NextResponse.json({ error: "Only one connection is allowed for this Anthropic Compatible node" }, { status: 400 });
}
providerSpecificData = {
prefix: node.prefix,
baseUrl: node.baseUrl,
nodeName: node.name,
};
} else if (isCustomEmbeddingProvider(provider)) {
const node = await getProviderNodeById(provider);
if (!node) {
return NextResponse.json({ error: "Custom Embedding 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 Custom Embedding node" }, { status: 400 });
}
providerSpecificData = {
prefix: node.prefix,
baseUrl: node.baseUrl,

View File

@@ -1,6 +1,6 @@
import { NextResponse } from "next/server";
import { getProviderNodeById } from "@/models";
import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers";
import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider, isCustomEmbeddingProvider } from "@/shared/constants/providers";
import { getDefaultModel } from "open-sse/config/providerModels.js";
import { resolveOllamaLocalHost } from "open-sse/config/providers.js";
@@ -35,6 +35,37 @@ export async function POST(request) {
});
}
// Custom Embedding nodes: probe /models (most embedding APIs are OpenAI-compatible)
if (isCustomEmbeddingProvider(provider)) {
const node = await getProviderNodeById(provider);
if (!node) {
return NextResponse.json({ error: "Custom Embedding node not found" }, { status: 404 });
}
const baseUrl = node.baseUrl?.replace(/\/$/, "");
const modelsRes = await fetch(`${baseUrl}/models`, {
headers: { "Authorization": `Bearer ${apiKey}` },
});
if (modelsRes.ok) {
return NextResponse.json({ valid: true });
}
// Auth errors are definitive
if (modelsRes.status === 401 || modelsRes.status === 403) {
return NextResponse.json({ valid: false, error: "Invalid API key" });
}
// Fallback: probe /embeddings with a common test model — many providers lack /models
const embedRes = await fetch(`${baseUrl}/embeddings`, {
method: "POST",
headers: { "Authorization": `Bearer ${apiKey}`, "Content-Type": "application/json" },
body: JSON.stringify({ model: "test", input: "ping" }),
});
// 401/403 = bad key; anything else (including 400 "model not found") means key works
isValid = embedRes.status !== 401 && embedRes.status !== 403;
return NextResponse.json({
valid: isValid,
error: isValid ? null : "Invalid API key",
});
}
if (isAnthropicCompatibleProvider(provider)) {
const node = await getProviderNodeById(provider);
if (!node) {