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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(/\/$/, "");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user