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

@@ -1,11 +1,11 @@
"use client";
import { useParams, notFound } from "next/navigation";
import { useParams, notFound, useRouter } from "next/navigation";
import Link from "next/link";
import { useState, useEffect } from "react";
import { Card, Badge } from "@/shared/components";
import { Card, Badge, Button, AddCustomEmbeddingModal } from "@/shared/components";
import ProviderIcon from "@/shared/components/ProviderIcon";
import { MEDIA_PROVIDER_KINDS, AI_PROVIDERS, getProviderAlias } from "@/shared/constants/providers";
import { MEDIA_PROVIDER_KINDS, AI_PROVIDERS, getProviderAlias, isCustomEmbeddingProvider } from "@/shared/constants/providers";
import { getModelsByProviderId } from "@/shared/constants/models";
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
import ConnectionsCard from "@/app/(dashboard)/dashboard/providers/components/ConnectionsCard";
@@ -63,6 +63,13 @@ const KIND_EXAMPLE_CONFIG = {
defaultInput: "A cute cat wearing a hat",
bodyKey: "prompt",
defaultResponse: `{\n "data": [\n { "url": "...", "b64_json": "..." }\n ]\n}`,
extraFields: [
{ key: "n", label: "n", type: "number", default: 1, min: 1, max: 4 },
{ key: "size", label: "Size", type: "select", default: "1024x1024", options: ["1024x1024", "1024x1792", "1792x1024", "auto"] },
{ key: "quality", label: "Quality", type: "select", default: "", options: ["", "standard", "hd", "high", "low", "auto"] },
{ key: "style", label: "Style", type: "select", default: "", options: ["", "vivid", "natural"] },
{ key: "response_format", label: "Format", type: "select", default: "", options: ["", "url", "b64_json"] },
],
},
imageToText: {
inputLabel: "Image URL",
@@ -96,12 +103,14 @@ const KIND_EXAMPLE_CONFIG = {
};
// EmbeddingExampleCard
function EmbeddingExampleCard({ providerId }) {
const providerAlias = getProviderAlias(providerId);
const embeddingModels = getModelsByProviderId(providerId).filter((m) => m.type === "embedding");
function EmbeddingExampleCard({ providerId, customAlias }) {
const isCustom = isCustomEmbeddingProvider(providerId);
const providerAlias = isCustom ? (customAlias || providerId) : getProviderAlias(providerId);
const embeddingModels = isCustom ? [] : getModelsByProviderId(providerId).filter((m) => m.type === "embedding");
const [selectedModel, setSelectedModel] = useState(embeddingModels[0]?.id ?? "");
const [input, setInput] = useState("The quick brown fox jumps over the lazy dog");
const [dimensions, setDimensions] = useState("");
const [apiKey, setApiKey] = useState("");
const [useTunnel, setUseTunnel] = useState(false);
const [localEndpoint, setLocalEndpoint] = useState("");
@@ -127,10 +136,18 @@ function EmbeddingExampleCard({ providerId }) {
const endpoint = useTunnel ? tunnelEndpoint : localEndpoint;
const modelFull = selectedModel ? `${providerAlias}/${selectedModel}` : "";
// Build request body — include dimensions only if user provided a positive number
const buildBody = () => {
const body = { model: modelFull, input: input.trim() };
const dim = Number(dimensions);
if (dimensions && Number.isFinite(dim) && dim > 0) body.dimensions = dim;
return body;
};
const curlSnippet = `curl -X POST ${endpoint}/v1/embeddings \\
-H "Content-Type: application/json" \\
-H "Authorization: Bearer ${apiKey || "YOUR_KEY"}" \\
-d '{"model": "${modelFull}", "input": "${input}"}'`;
-d '${JSON.stringify(buildBody())}'`;
const handleRun = async () => {
if (!input.trim() || !modelFull) return;
@@ -144,7 +161,7 @@ function EmbeddingExampleCard({ providerId }) {
const res = await fetch("/api/v1/embeddings", {
method: "POST",
headers,
body: JSON.stringify({ model: modelFull, input: input.trim() }),
body: JSON.stringify(buildBody()),
});
const latencyMs = Date.now() - start;
const data = await res.json();
@@ -176,17 +193,26 @@ function EmbeddingExampleCard({ providerId }) {
<h2 className="text-lg font-semibold mb-4">Example</h2>
<div className="flex flex-col gap-2.5">
{/* Model */}
{/* Model — text input for custom node, dropdown otherwise */}
<Row label="Model">
<select
value={selectedModel}
onChange={(e) => setSelectedModel(e.target.value)}
className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
>
{embeddingModels.map((m) => (
<option key={m.id} value={m.id}>{m.name || m.id}</option>
))}
</select>
{isCustom ? (
<input
value={selectedModel}
onChange={(e) => setSelectedModel(e.target.value)}
placeholder="e.g. voyage-3, embed-english-v3.0, text-embedding-3-small"
className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary font-mono"
/>
) : (
<select
value={selectedModel}
onChange={(e) => setSelectedModel(e.target.value)}
className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
>
{embeddingModels.map((m) => (
<option key={m.id} value={m.id}>{m.name || m.id}</option>
))}
</select>
)}
</Row>
{/* Endpoint */}
@@ -245,6 +271,18 @@ function EmbeddingExampleCard({ providerId }) {
</div>
</Row>
{/* Dimensions (optional) — truncate embedding vector length */}
<Row label="Dimensions">
<input
type="number"
min="1"
value={dimensions}
onChange={(e) => setDimensions(e.target.value)}
placeholder="optional, e.g. 512, 1024 (leave empty for default)"
className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
/>
</Row>
{/* Curl + Run */}
<div className="mt-1">
<div className="flex items-center justify-between mb-1.5">
@@ -821,20 +859,30 @@ function GenericExampleCard({ providerId, kind }) {
const providerAlias = getProviderAlias(providerId);
const kindConfig = MEDIA_PROVIDER_KINDS.find((k) => k.id === kind);
const exConfig = KIND_EXAMPLE_CONFIG[kind];
if (!kindConfig || !exConfig) return null;
const safeExConfig = exConfig || {};
// Get models for this kind (e.g., type="image")
const kindModels = getModelsByProviderId(providerId).filter((m) => m.type === kind);
const [selectedModel, setSelectedModel] = useState(kindModels[0]?.id ?? "");
const selectedModelObj = kindModels.find((m) => m.id === selectedModel);
const supportsEdit = !!selectedModelObj?.capabilities?.includes("edit");
const [input, setInput] = useState(exConfig.defaultInput);
const [input, setInput] = useState(safeExConfig.defaultInput || "");
const [refImage, setRefImage] = useState("");
const [extraValues, setExtraValues] = useState(() =>
(safeExConfig.extraFields || []).reduce((acc, f) => { acc[f.key] = f.default ?? ""; return acc; }, {})
);
const [apiKey, setApiKey] = useState("");
const [useTunnel, setUseTunnel] = useState(false);
const [localEndpoint, setLocalEndpoint] = useState("");
const [tunnelEndpoint, setTunnelEndpoint] = useState("");
const [result, setResult] = useState(null);
const [progress, setProgress] = useState(null); // { stage, bytesReceived }
const [partialImage, setPartialImage] = useState(null);
const [running, setRunning] = useState(false);
const [error, setError] = useState("");
const [connections, setConnections] = useState([]);
const [pinnedConnectionId, setPinnedConnectionId] = useState("");
const { copied: copiedCurl, copy: copyCurl } = useCopyToClipboard();
const { copied: copiedRes, copy: copyRes } = useCopyToClipboard();
@@ -848,21 +896,43 @@ function GenericExampleCard({ providerId, kind }) {
.then((r) => r.json())
.then((d) => { if (d.publicUrl) setTunnelEndpoint(d.publicUrl); })
.catch(() => {});
}, []);
// Load active connections of this provider for pinning
fetch("/api/providers/client")
.then((r) => r.json())
.then((d) => {
const conns = (d.connections || []).filter((c) => c.provider === providerId && c.isActive !== false);
setConnections(conns);
})
.catch(() => {});
}, [providerId]);
// Safe to early-return now that all hooks are declared
if (!kindConfig || !exConfig) return null;
const endpoint = useTunnel ? tunnelEndpoint : localEndpoint;
const apiPath = kindConfig.endpoint.path;
const modelFull = selectedModel ? `${providerAlias}/${selectedModel}` : "";
// Build request body with optional extra fields (only non-empty values)
const extraBodyFromFields = Object.entries(extraValues).reduce((acc, [k, v]) => {
if (v === "" || v === null || v === undefined) return acc;
if (typeof v === "number" && Number.isNaN(v)) return acc;
acc[k] = v;
return acc;
}, {});
const requestBody = {
model: modelFull,
[exConfig.bodyKey]: input,
...exConfig.extraBody,
...extraBodyFromFields,
...(supportsEdit && refImage.trim() ? { image: refImage.trim() } : {}),
};
// Streaming supported for codex image (Plus/Pro accounts)
const useStreaming = kind === "image" && providerId === "codex";
const headersPreview = `-H "Content-Type: application/json" \\\n -H "Authorization: Bearer ${apiKey || "YOUR_KEY"}"${pinnedConnectionId ? ` \\\n -H "x-connection-id: ${pinnedConnectionId}"` : ""}${useStreaming ? ` \\\n -H "Accept: text/event-stream"` : ""}`;
const curlSnippet = `curl -X ${kindConfig.endpoint.method} ${endpoint}${apiPath} \\
-H "Content-Type: application/json" \\
-H "Authorization: Bearer ${apiKey || "YOUR_KEY"}" \\
${headersPreview.replace(/\\\n /g, "\\\n ")} \\
-d '${JSON.stringify(requestBody)}'`;
const handleRun = async () => {
@@ -870,20 +940,64 @@ function GenericExampleCard({ providerId, kind }) {
setRunning(true);
setError("");
setResult(null);
setProgress(null);
setPartialImage(null);
const start = Date.now();
try {
const headers = { "Content-Type": "application/json" };
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
if (pinnedConnectionId) headers["x-connection-id"] = pinnedConnectionId;
if (useStreaming) headers["Accept"] = "text/event-stream";
const body = { ...requestBody, model: modelFull };
const res = await fetch(`/api${apiPath}`, {
method: kindConfig.endpoint.method,
headers,
body: JSON.stringify(body),
});
const latencyMs = Date.now() - start;
const data = await res.json();
if (!res.ok) { setError(data?.error?.message || data?.error || `HTTP ${res.status}`); return; }
setResult({ data, latencyMs });
if (!res.ok) {
const data = await res.json().catch(() => ({}));
setError(data?.error?.message || data?.error || `HTTP ${res.status}`);
return;
}
const isSse = (res.headers.get("content-type") || "").includes("text/event-stream");
if (isSse && res.body) {
// Parse SSE: progress / partial_image / done / error
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = "";
let finalData = null;
let streamErr = null;
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
let sep;
while ((sep = buf.indexOf("\n\n")) !== -1) {
const block = buf.slice(0, sep);
buf = buf.slice(sep + 2);
let evt = null, dataStr = "";
for (const line of block.split("\n")) {
if (line.startsWith("event:")) evt = line.slice(6).trim();
else if (line.startsWith("data:")) dataStr += line.slice(5).trim();
}
if (!evt) continue;
try {
const payload = dataStr ? JSON.parse(dataStr) : {};
if (evt === "progress") setProgress(payload);
else if (evt === "partial_image") setPartialImage(payload);
else if (evt === "done") finalData = payload;
else if (evt === "error") streamErr = payload?.message || "Stream error";
} catch {}
}
}
const latencyMs = Date.now() - start;
if (streamErr) { setError(streamErr); return; }
if (finalData) setResult({ data: finalData, latencyMs });
} else {
const data = await res.json();
const latencyMs = Date.now() - start;
setResult({ data, latencyMs });
}
} catch (e) {
setError(e.message || "Network error");
} finally {
@@ -891,7 +1005,19 @@ function GenericExampleCard({ providerId, kind }) {
}
};
const resultJson = result ? JSON.stringify(result.data, null, 2) : "";
// Mask large b64_json strings in JSON view to keep it readable
const maskB64 = (obj) => {
if (!obj || typeof obj !== "object") return obj;
if (Array.isArray(obj)) return obj.map(maskB64);
const out = {};
for (const [k, v] of Object.entries(obj)) {
out[k] = (k === "b64_json" && typeof v === "string" && v.length > 100)
? `<${v.length} chars base64>`
: maskB64(v);
}
return out;
};
const resultJson = result ? JSON.stringify(maskB64(result.data), null, 2) : "";
return (
<Card>
@@ -940,6 +1066,28 @@ function GenericExampleCard({ providerId, kind }) {
</span>
</Row>
{/* Connection picker - only show when 2+ connections (or any with email) */}
{connections.length > 0 && (
<Row label="Connection">
<select
value={pinnedConnectionId}
onChange={(e) => setPinnedConnectionId(e.target.value)}
className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
>
<option value="">Auto (by priority)</option>
{connections.map((c) => {
const plan = c.providerSpecificData?.chatgptPlanType;
const label = c.email || c.name || c.id.slice(0, 8);
return (
<option key={c.id} value={c.id}>
{label}{plan ? ` [${plan}]` : ""}
</option>
);
})}
</select>
</Row>
)}
{/* Input */}
<Row label={exConfig.inputLabel}>
<div className="relative">
@@ -961,6 +1109,68 @@ function GenericExampleCard({ providerId, kind }) {
</div>
</Row>
{/* Reference image (only for edit-capable image models) */}
{supportsEdit && (
<Row label="Ref Image (URL)">
<div className="flex flex-col gap-2">
<div className="relative">
<input
value={refImage}
onChange={(e) => setRefImage(e.target.value)}
placeholder="https://example.com/source.png"
className="w-full px-3 py-1.5 pr-7 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
/>
{refImage && (
<button
type="button"
onClick={() => setRefImage("")}
className="absolute right-2 top-1/2 -translate-y-1/2 text-text-muted hover:text-primary transition-colors"
>
<span className="material-symbols-outlined text-[14px]">close</span>
</button>
)}
</div>
{refImage.trim() && (
<img
src={refImage.trim()}
alt="Reference"
className="max-h-40 rounded-lg border border-border object-contain bg-sidebar"
onError={(e) => { e.currentTarget.style.display = "none"; }}
onLoad={(e) => { e.currentTarget.style.display = "block"; }}
/>
)}
</div>
</Row>
)}
{/* Extra fields (filtered by model.params; if undefined → none shown) */}
{(exConfig.extraFields || [])
.filter((f) => Array.isArray(selectedModelObj?.params) && selectedModelObj.params.includes(f.key))
.map((f) => (
<Row key={f.key} label={f.label}>
{f.type === "select" ? (
<select
value={extraValues[f.key] ?? ""}
onChange={(e) => setExtraValues((s) => ({ ...s, [f.key]: e.target.value }))}
className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
>
{(f.options || []).map((opt) => (
<option key={opt} value={opt}>{opt === "" ? "(default)" : opt}</option>
))}
</select>
) : (
<input
type="number"
value={extraValues[f.key] ?? ""}
min={f.min}
max={f.max}
onChange={(e) => setExtraValues((s) => ({ ...s, [f.key]: e.target.value === "" ? "" : Number(e.target.value) }))}
className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
/>
)}
</Row>
))}
{/* Curl + Run */}
<div className="mt-1">
<div className="flex items-center justify-between mb-1.5">
@@ -988,6 +1198,31 @@ function GenericExampleCard({ providerId, kind }) {
<pre className="bg-sidebar rounded-lg px-3 py-2.5 text-xs font-mono text-text-main overflow-x-auto whitespace-pre">{curlSnippet}</pre>
</div>
{/* Streaming progress */}
{(running || progress) && useStreaming && (
<div className="flex items-center gap-3 px-3 py-2 rounded-lg bg-sidebar border border-border">
<span className="material-symbols-outlined text-[16px] text-primary" style={running ? { animation: "spin 1s linear infinite" } : undefined}>
{running ? "progress_activity" : "check_circle"}
</span>
<span className="text-xs text-text-muted">
{progress?.stage || "starting"}
{progress?.bytesReceived ? ` · ${(progress.bytesReceived / 1024).toFixed(1)} KB` : ""}
</span>
</div>
)}
{/* Partial image preview (codex stream) */}
{partialImage?.b64_json && !result && (
<div>
<span className="text-xs font-semibold text-text-muted uppercase tracking-wider">Partial preview</span>
<img
src={`data:image/png;base64,${partialImage.b64_json}`}
alt="Partial"
className="max-w-full rounded-lg border border-border mt-1.5 opacity-80"
/>
</div>
)}
{/* Error */}
{error && <p className="text-xs text-red-500 break-words">{error}</p>}
@@ -1026,14 +1261,56 @@ function GenericExampleCard({ providerId, kind }) {
// MediaProviderDetailPage
export default function MediaProviderDetailPage() {
const { kind, id } = useParams();
const router = useRouter();
const kindConfig = MEDIA_PROVIDER_KINDS.find((k) => k.id === kind);
const isCustom = isCustomEmbeddingProvider(id) && kind === "embedding";
const handleDeleteCustom = async () => {
if (!confirm("Delete this Custom Embedding node?")) return;
try {
const res = await fetch(`/api/provider-nodes/${id}`, { method: "DELETE" });
if (res.ok) router.push(`/dashboard/media-providers/${kind}`);
} catch (error) {
console.log("Error deleting custom embedding node:", error);
}
};
const [customNode, setCustomNode] = useState(null);
const [customLoading, setCustomLoading] = useState(isCustom);
const [showEditModal, setShowEditModal] = useState(false);
// Fetch custom node info from API for custom embedding nodes
useEffect(() => {
if (!isCustom) return;
let cancelled = false;
fetch("/api/provider-nodes", { cache: "no-store" })
.then((r) => r.json())
.then((d) => {
if (cancelled) return;
setCustomNode((d.nodes || []).find((n) => n.id === id) || null);
setCustomLoading(false);
})
.catch(() => { if (!cancelled) setCustomLoading(false); });
return () => { cancelled = true; };
}, [id, isCustom]);
if (!kindConfig) return notFound();
const provider = AI_PROVIDERS[id];
if (!provider) return notFound();
const builtInProvider = AI_PROVIDERS[id];
const kinds = provider.serviceKinds ?? ["llm"];
if (!kinds.includes(kind)) return notFound();
// For custom embedding nodes, build a synthetic provider object
const provider = isCustom
? (customNode ? { id, name: customNode.name || "Custom Embedding", color: "#6366F1", textIcon: "CE" } : null)
: builtInProvider;
if (!isCustom && !builtInProvider) return notFound();
if (isCustom && !customLoading && !customNode) return notFound();
if (isCustom && customLoading) {
return <div className="text-text-muted text-sm py-12 text-center">Loading...</div>;
}
const kinds = isCustom ? ["embedding"] : (provider.serviceKinds ?? ["llm"]);
if (!isCustom && !kinds.includes(kind)) return notFound();
return (
<div className="flex flex-col gap-8">
@@ -1059,9 +1336,10 @@ export default function MediaProviderDetailPage() {
fallbackColor={provider.color}
/>
</div>
<div>
<div className="flex-1">
<h1 className="text-3xl font-semibold tracking-tight">{provider.name}</h1>
<div className="flex items-center gap-1.5 mt-1 flex-wrap">
{isCustom && <Badge variant="default" size="sm">Custom · {customNode?.prefix}</Badge>}
{kinds.map((k) => (
<Badge key={k} variant={k === kind ? "primary" : "default"} size="sm">
{k.toUpperCase()}
@@ -1069,11 +1347,29 @@ export default function MediaProviderDetailPage() {
))}
</div>
</div>
{isCustom && (
<div className="flex items-center gap-2">
<Button size="sm" variant="secondary" icon="edit" onClick={() => setShowEditModal(true)}>
Edit
</Button>
<Button size="sm" variant="secondary" icon="delete" onClick={handleDeleteCustom}>
Delete
</Button>
</div>
)}
</div>
</div>
{/* Kind-specific notice (e.g. codex/image requires Plus) */}
{!isCustom && provider.kindNotice?.[kind] && (
<div className="flex items-start gap-3 px-4 py-3 rounded-lg bg-amber-500/10 border border-amber-500/30 text-amber-700 dark:text-amber-400">
<span className="material-symbols-outlined text-[20px] mt-0.5">warning</span>
<p className="text-sm">{provider.kindNotice[kind]}</p>
</div>
)}
{/* Connections */}
{provider.noAuth ? (
{!isCustom && provider.noAuth ? (
<Card>
<div className="flex items-center gap-3">
<div className="inline-flex items-center justify-center w-10 h-10 rounded-full bg-green-500/10 text-green-500">
@@ -1089,13 +1385,33 @@ export default function MediaProviderDetailPage() {
<ConnectionsCard providerId={id} isOAuth={false} />
)}
{/* Models - only for non-tts kinds */}
{kind !== "tts" && <ModelsCard providerId={id} kindFilter={kind} />}
{/* Models - only for non-tts kinds; custom uses prefix as alias */}
{kind !== "tts" && (
<ModelsCard
providerId={id}
kindFilter={kind}
providerAliasOverride={isCustom ? customNode?.prefix : undefined}
/>
)}
{/* Example — per kind */}
{kind === "embedding" && <EmbeddingExampleCard providerId={id} />}
{kind === "embedding" && (
<EmbeddingExampleCard providerId={id} customAlias={customNode?.prefix} />
)}
{kind === "tts" && <TtsExampleCard providerId={id} />}
{KIND_EXAMPLE_CONFIG[kind] && <GenericExampleCard providerId={id} kind={kind} />}
{!isCustom && KIND_EXAMPLE_CONFIG[kind] && <GenericExampleCard providerId={id} kind={kind} />}
{isCustom && (
<AddCustomEmbeddingModal
isOpen={showEditModal}
node={customNode}
onClose={() => setShowEditModal(false)}
onSaved={(updated) => {
setCustomNode(updated);
setShowEditModal(false);
}}
/>
)}
</div>
);
}

View File

@@ -3,7 +3,7 @@
import { useParams, notFound } from "next/navigation";
import Link from "next/link";
import { useEffect, useState } from "react";
import { Card, Badge } from "@/shared/components";
import { Card, Badge, Button, AddCustomEmbeddingModal } from "@/shared/components";
import ProviderIcon from "@/shared/components/ProviderIcon";
import { MEDIA_PROVIDER_KINDS, AI_PROVIDERS, getProvidersByKind } from "@/shared/constants/providers";
@@ -14,7 +14,7 @@ function getEffectiveStatus(conn) {
return conn.testStatus === "unavailable" && !isCooldown ? "active" : conn.testStatus;
}
function MediaProviderCard({ provider, kind, connections }) {
function MediaProviderCard({ provider, kind, connections, isCustom }) {
const providerInfo = AI_PROVIDERS[provider.id];
const isNoAuth = !!providerInfo?.noAuth;
@@ -60,6 +60,7 @@ function MediaProviderCard({ provider, kind, connections }) {
<div>
<h3 className="font-semibold text-sm">{provider.name}</h3>
<div className="flex items-center gap-2 mt-0.5 flex-wrap">
{isCustom && <Badge variant="default" size="sm">Custom</Badge>}
{renderStatus()}
</div>
</div>
@@ -72,22 +73,51 @@ function MediaProviderCard({ provider, kind, connections }) {
export default function MediaProviderKindPage() {
const { kind } = useParams();
const [connections, setConnections] = useState([]);
const [customNodes, setCustomNodes] = useState([]);
const [showAddCustomEmbedding, setShowAddCustomEmbedding] = useState(false);
const kindConfig = MEDIA_PROVIDER_KINDS.find((k) => k.id === kind);
if (!kindConfig) return notFound();
const providers = getProvidersByKind(kind);
const isEmbedding = kind === "embedding";
useEffect(() => {
if (!kindConfig) return;
fetch("/api/providers", { cache: "no-store" })
.then((r) => r.json())
.then((d) => setConnections(d.connections || []))
.catch(() => {});
}, []);
if (isEmbedding) {
fetch("/api/provider-nodes", { cache: "no-store" })
.then((r) => r.json())
.then((d) => setCustomNodes((d.nodes || []).filter((n) => n.type === "custom-embedding")))
.catch(() => {});
}
}, [isEmbedding, kindConfig]);
if (!kindConfig) return notFound();
const providers = getProvidersByKind(kind);
// Map custom nodes to MediaProviderCard shape
const customProviders = customNodes.map((n) => ({
id: n.id,
name: n.name || "Custom Embedding",
color: "#6366F1",
textIcon: "CE",
}));
const allProviders = [...providers, ...customProviders];
return (
<div className="flex flex-col gap-6">
{providers.length === 0 ? (
{isEmbedding && (
<div className="flex items-center justify-end">
<Button size="sm" icon="add" onClick={() => setShowAddCustomEmbedding(true)}>
Add Custom Embedding
</Button>
</div>
)}
{allProviders.length === 0 ? (
<div className="text-center py-12 border border-dashed border-border rounded-xl text-text-muted text-sm">
No providers support <strong>{kindConfig.label}</strong> yet.
</div>
@@ -101,8 +131,28 @@ export default function MediaProviderKindPage() {
connections={connections}
/>
))}
{customProviders.map((provider) => (
<MediaProviderCard
key={provider.id}
provider={provider}
kind={kind}
connections={connections}
isCustom
/>
))}
</div>
)}
{isEmbedding && (
<AddCustomEmbeddingModal
isOpen={showAddCustomEmbedding}
onClose={() => setShowAddCustomEmbedding(false)}
onCreated={(node) => {
setCustomNodes((prev) => [...prev, node]);
setShowAddCustomEmbedding(false);
}}
/>
)}
</div>
);
}

View File

@@ -108,7 +108,7 @@ AddCustomModelModal.propTypes = {
// ── ModelsCard ─────────────────────────────────────────────────
// Self-contained card: shows models for a provider, filtered by optional `kindFilter`.
// kindFilter: if provided, only shows models with matching type/kinds field.
export default function ModelsCard({ providerId, kindFilter }) {
export default function ModelsCard({ providerId, kindFilter, providerAliasOverride }) {
const { copied, copy } = useCopyToClipboard();
const [modelAliases, setModelAliases] = useState({});
const [customModels, setCustomModels] = useState([]);
@@ -118,7 +118,7 @@ export default function ModelsCard({ providerId, kindFilter }) {
const [showAddCustomModel, setShowAddCustomModel] = useState(false);
const [connections, setConnections] = useState([]);
const providerAlias = getProviderAlias(providerId);
const providerAlias = providerAliasOverride || getProviderAlias(providerId);
const effectiveType = kindFilter || "llm";
const fetchData = useCallback(async () => {
@@ -284,4 +284,5 @@ export default function ModelsCard({ providerId, kindFilter }) {
ModelsCard.propTypes = {
providerId: PropTypes.string.isRequired,
kindFilter: PropTypes.string, // e.g. "tts", "embedding" — filters models shown
providerAliasOverride: PropTypes.string, // override alias (e.g. for custom-embedding nodes using prefix)
};

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) {