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

@@ -36,6 +36,10 @@ export const PROVIDER_MODELS = {
{ id: "gpt-5.1", name: "GPT 5.1" },
{ id: "gpt-5-codex", name: "GPT 5 Codex" },
{ id: "gpt-5-codex-mini", name: "GPT 5 Codex Mini" },
// Image models (uses image_generation tool, requires Plus/Pro plan)
{ id: "gpt-5.4-image", name: "GPT 5.4 Image", type: "image", capabilities: ["text2img", "edit"] },
{ id: "gpt-5.3-image", name: "GPT 5.3 Image", type: "image", capabilities: ["text2img", "edit"] },
{ id: "gpt-5.2-image", name: "GPT 5.2 Image", type: "image", capabilities: ["text2img", "edit"] },
],
gc: [ // Gemini CLI
{ id: "gemini-3-flash-preview", name: "Gemini 3 Flash Preview" },
@@ -206,9 +210,9 @@ export const PROVIDER_MODELS = {
{ id: "tts-1-hd", name: "TTS-1 HD", type: "tts" },
{ id: "gpt-4o-mini-tts", name: "GPT-4o Mini TTS", type: "tts" },
// Image models
{ id: "gpt-image-1", name: "GPT Image 1", type: "image" },
{ id: "dall-e-3", name: "DALL-E 3", type: "image" },
{ id: "dall-e-2", name: "DALL-E 2", type: "image" },
{ id: "gpt-image-1", name: "GPT Image 1", type: "image", params: ["n", "size", "quality", "response_format"] },
{ id: "dall-e-3", name: "DALL-E 3", type: "image", params: ["size", "quality", "style", "response_format"] },
{ id: "dall-e-2", name: "DALL-E 2", type: "image", params: ["n", "size", "response_format"] },
],
anthropic: [
{ id: "claude-sonnet-4-20250514", name: "Claude Sonnet 4" },
@@ -236,9 +240,9 @@ export const PROVIDER_MODELS = {
{ id: "text-embedding-005", name: "Text Embedding 005", type: "embedding" },
{ id: "text-embedding-004", name: "Text Embedding 004 (Legacy)", type: "embedding" },
// Image models (Nano Banana)
{ id: "gemini-3.1-flash-image-preview", name: "Gemini 3.1 Flash Image (Nano Banana 2)", type: "image" },
{ id: "gemini-3-pro-image-preview", name: "Gemini 3 Pro Image (Nano Banana Pro)", type: "image" },
{ id: "gemini-2.5-flash-image", name: "Gemini 2.5 Flash Image (Nano Banana)", type: "image" },
{ id: "gemini-3.1-flash-image-preview", name: "Gemini 3.1 Flash Image (Nano Banana 2)", type: "image", params: [] },
{ id: "gemini-3-pro-image-preview", name: "Gemini 3 Pro Image (Nano Banana Pro)", type: "image", params: [] },
{ id: "gemini-2.5-flash-image", name: "Gemini 2.5 Flash Image (Nano Banana)", type: "image", params: [] },
],
openrouter: [
// Embedding models
@@ -254,10 +258,10 @@ export const PROVIDER_MODELS = {
{ id: "openai/tts-1-hd", name: "TTS-1 HD", type: "tts" },
{ id: "openai/tts-1", name: "TTS-1", type: "tts" },
// Image models
{ id: "openai/dall-e-3", name: "DALL-E 3 (via OpenRouter)", type: "image" },
{ id: "openai/gpt-image-1", name: "GPT Image 1 (via OpenRouter)", type: "image" },
{ id: "google/imagen-3.0-generate-002", name: "Imagen 3 (via OpenRouter)", type: "image" },
{ id: "black-forest-labs/FLUX.1-schnell", name: "FLUX.1 Schnell (via OpenRouter)", type: "image" },
{ id: "openai/dall-e-3", name: "DALL-E 3 (via OpenRouter)", type: "image", params: ["size", "quality", "style", "response_format"] },
{ id: "openai/gpt-image-1", name: "GPT Image 1 (via OpenRouter)", type: "image", params: ["n", "size", "quality", "response_format"] },
{ id: "google/imagen-3.0-generate-002", name: "Imagen 3 (via OpenRouter)", type: "image", params: ["n", "size"] },
{ id: "black-forest-labs/FLUX.1-schnell", name: "FLUX.1 Schnell (via OpenRouter)", type: "image", params: ["n", "size"] },
],
glm: [
{ id: "glm-5.1", name: "GLM 5.1" },
@@ -282,7 +286,7 @@ export const PROVIDER_MODELS = {
{ id: "MiniMax-M2.5", name: "MiniMax M2.5" },
{ id: "MiniMax-M2.1", name: "MiniMax M2.1" },
// Image models
{ id: "minimax-image-01", name: "MiniMax Image 01", type: "image" },
{ id: "minimax-image-01", name: "MiniMax Image 01", type: "image", params: ["n", "size", "response_format"] },
],
blackbox: [
{ id: "gpt-4o", name: "GPT-4o" },
@@ -468,20 +472,20 @@ export const PROVIDER_MODELS = {
// Image providers
nanobanana: [
{ id: "nanobanana-flash", name: "NanoBanana Flash", type: "image" },
{ id: "nanobanana-pro", name: "NanoBanana Pro", type: "image" },
{ id: "nanobanana-flash", name: "NanoBanana Flash", type: "image", params: ["n", "size"] },
{ id: "nanobanana-pro", name: "NanoBanana Pro", type: "image", params: ["n", "size"] },
],
sdwebui: [
{ id: "stable-diffusion-v1-5", name: "Stable Diffusion v1.5", type: "image" },
{ id: "sdxl-base-1.0", name: "SDXL Base 1.0", type: "image" },
{ id: "stable-diffusion-v1-5", name: "Stable Diffusion v1.5", type: "image", params: ["n", "size"] },
{ id: "sdxl-base-1.0", name: "SDXL Base 1.0", type: "image", params: ["n", "size"] },
],
comfyui: [
{ id: "flux-dev", name: "FLUX Dev", type: "image" },
{ id: "sdxl", name: "SDXL", type: "image" },
{ id: "flux-dev", name: "FLUX Dev", type: "image", params: ["n", "size"] },
{ id: "sdxl", name: "SDXL", type: "image", params: ["n", "size"] },
],
huggingface: [
{ id: "black-forest-labs/FLUX.1-schnell", name: "FLUX.1 Schnell", type: "image" },
{ id: "stabilityai/stable-diffusion-xl-base-1.0", name: "SDXL Base 1.0", type: "image" },
{ id: "black-forest-labs/FLUX.1-schnell", name: "FLUX.1 Schnell", type: "image", params: [] },
{ id: "stabilityai/stable-diffusion-xl-base-1.0", name: "SDXL Base 1.0", type: "image", params: [] },
],
};

View File

@@ -23,7 +23,7 @@ function isGeminiProvider(provider) {
* - Single input → embedContent body: { model, content: { parts: [{ text }] } }
* - Batch input → batchEmbedContents body: { requests: [{ model, content: { parts: [{ text }] } }] }
*/
function buildEmbeddingsBody(provider, model, input, encodingFormat) {
function buildEmbeddingsBody(provider, model, input, encodingFormat, dimensions) {
if (isGeminiProvider(provider)) {
// Normalize model name: Gemini API expects "models/<model>" prefix
const geminiModel = model.startsWith("models/") ? model : `models/${model}`;
@@ -50,6 +50,10 @@ function buildEmbeddingsBody(provider, model, input, encodingFormat) {
if (encodingFormat) {
body.encoding_format = encodingFormat;
}
if (dimensions != null && dimensions !== "") {
const dim = Number(dimensions);
if (Number.isFinite(dim) && dim > 0) body.dimensions = dim;
}
return body;
}
@@ -79,10 +83,12 @@ function buildEmbeddingsUrl(provider, model, credentials, input) {
case "openrouter":
return "https://openrouter.ai/api/v1/embeddings";
default:
// openai-compatible providers: use their baseUrl + /embeddings
if (provider?.startsWith?.("openai-compatible-")) {
const baseUrl = credentials?.providerSpecificData?.baseUrl || "https://api.openai.com/v1";
return `${baseUrl.replace(/\/$/, "")}/embeddings`;
// openai-compatible & custom-embedding providers: use their baseUrl + /embeddings
if (provider?.startsWith?.("openai-compatible-") || provider?.startsWith?.("custom-embedding-")) {
const rawBaseUrl = credentials?.providerSpecificData?.baseUrl || "https://api.openai.com/v1";
// Defensive: strip trailing slash and accidental /embeddings to avoid double-append
const baseUrl = rawBaseUrl.replace(/\/$/, "").replace(/\/embeddings$/, "");
return `${baseUrl}/embeddings`;
}
// For other providers, attempt to use their base URL pattern with /embeddings path
return null;
@@ -211,7 +217,7 @@ export async function handleEmbeddingsCore({
}
const headers = buildEmbeddingsHeaders(provider, credentials);
const requestBody = buildEmbeddingsBody(provider, model, input, encodingFormat);
const requestBody = buildEmbeddingsBody(provider, model, input, encodingFormat, body.dimensions);
log?.debug?.("EMBEDDINGS", `${provider.toUpperCase()} | ${model} | input_type=${Array.isArray(input) ? `array[${input.length}]` : "string"}`);

View File

@@ -1,8 +1,16 @@
import { randomUUID } from "node:crypto";
import { createErrorResult, parseUpstreamError, formatProviderError } from "../utils/error.js";
import { HTTP_STATUS } from "../config/runtimeConfig.js";
import { refreshWithRetry } from "../services/tokenRefresh.js";
import { getExecutor } from "../executors/index.js";
const CODEX_RESPONSES_URL = "https://chatgpt.com/backend-api/codex/responses";
const CODEX_USER_AGENT = "codex-imagen/0.2.6";
const CODEX_VERSION = "0.122.0";
const CODEX_ORIGINATOR = "codex_cli_rs";
const CODEX_MODEL_SUFFIX = "-image";
const CODEX_REF_DETAIL = "high";
// Image provider configurations
const IMAGE_PROVIDERS = {
openai: {
@@ -37,8 +45,161 @@ const IMAGE_PROVIDERS = {
baseUrl: "https://api-inference.huggingface.co/models",
format: "huggingface",
},
codex: {
baseUrl: CODEX_RESPONSES_URL,
format: "codex",
stream: true,
},
};
// Decode codex chatgpt account id from idToken if not stored
function decodeCodexAccountId(idToken) {
try {
const parts = String(idToken || "").split(".");
if (parts.length !== 3) return null;
const b64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
const pad = (4 - (b64.length % 4)) % 4;
const payload = JSON.parse(Buffer.from(b64 + "=".repeat(pad), "base64").toString("utf8"));
return payload?.["https://api.openai.com/auth"]?.chatgpt_account_id || null;
} catch {
return null;
}
}
// Strip "-image" suffix to get the underlying chat model
function stripCodexImageModel(model) {
return model.endsWith(CODEX_MODEL_SUFFIX)
? model.slice(0, -CODEX_MODEL_SUFFIX.length)
: model;
}
// Normalize a single ref image input to a data URL
function toCodexDataUrl(input) {
if (!input) return null;
if (typeof input !== "string") return null;
if (/^data:image\//i.test(input) || /^https?:\/\//i.test(input)) return input;
// assume raw base64 PNG
return `data:image/png;base64,${input}`;
}
// Build content array with optional reference images, mirroring codex-imagen tagging
function buildCodexContent(prompt, refs) {
const content = [];
refs.forEach((url, index) => {
content.push({ type: "input_text", text: `<image name=image${index + 1}>` });
content.push({ type: "input_image", image_url: url, detail: CODEX_REF_DETAIL });
content.push({ type: "input_text", text: "</image>" });
});
content.push({ type: "input_text", text: prompt });
return content;
}
// Parse Codex SSE stream, log progress, return final base64 image.
// Optional callbacks let caller forward events to client (SSE pipe).
async function parseCodexImageStream(response, log, callbacks = {}) {
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let imageB64 = null;
let lastEvent = null;
let bytesReceived = 0;
let lastProgressLogMs = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
bytesReceived += value?.byteLength || 0;
buffer += decoder.decode(value, { stream: true });
// SSE events separated by blank line
let sepIdx;
while ((sepIdx = buffer.indexOf("\n\n")) !== -1) {
const block = buffer.slice(0, sepIdx);
buffer = buffer.slice(sepIdx + 2);
const lines = block.split("\n");
let eventName = null;
let dataStr = "";
for (const line of lines) {
if (line.startsWith("event:")) eventName = line.slice(6).trim();
else if (line.startsWith("data:")) dataStr += line.slice(5).trim();
}
if (!eventName) continue;
if (eventName !== lastEvent) {
log?.info?.("IMAGE", `codex progress: ${eventName}`);
lastEvent = eventName;
}
// Notify caller about progress (throttled to ~5/s to avoid flooding)
const now = Date.now();
if (callbacks.onProgress && now - lastProgressLogMs > 200) {
lastProgressLogMs = now;
callbacks.onProgress({ stage: eventName, bytesReceived });
}
if (eventName === "response.image_generation_call.partial_image" && dataStr) {
try {
const data = JSON.parse(dataStr);
if (callbacks.onPartialImage && data?.partial_image_b64) {
callbacks.onPartialImage({ b64_json: data.partial_image_b64, index: data.partial_image_index });
}
} catch {}
}
if (eventName === "response.output_item.done" && dataStr) {
try {
const data = JSON.parse(dataStr);
const item = data?.item;
if (item?.type === "image_generation_call" && item.result) {
imageB64 = item.result;
}
} catch {}
}
}
}
return imageB64;
}
// Build SSE Response that pipes codex progress + partial + done events to client
function buildCodexSseResponse(providerResponse, log, onSuccess) {
const stream = new ReadableStream({
async start(controller) {
const enc = new TextEncoder();
const send = (event, data) => {
controller.enqueue(enc.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`));
};
try {
const b64 = await parseCodexImageStream(providerResponse, log, {
onProgress: (info) => send("progress", info),
onPartialImage: (info) => send("partial_image", info),
});
if (!b64) {
send("error", { message: "Codex did not return an image. Account may not be entitled (Plus/Pro required)." });
} else {
if (onSuccess) await onSuccess();
send("done", {
created: Math.floor(Date.now() / 1000),
data: [{ b64_json: b64 }],
});
}
} catch (err) {
send("error", { message: err?.message || "Stream failed" });
} finally {
controller.close();
}
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
"Access-Control-Allow-Origin": "*",
},
});
}
/**
* Build image generation URL
*/
@@ -54,6 +215,8 @@ function buildImageUrl(provider, model, credentials) {
}
case "huggingface":
return `${config.baseUrl}/${model}`;
case "codex":
return CODEX_RESPONSES_URL;
default:
return config.baseUrl;
}
@@ -69,6 +232,23 @@ function buildImageHeaders(provider, credentials) {
return headers;
}
if (provider === "codex") {
const accountId =
credentials?.providerSpecificData?.chatgptAccountId ||
decodeCodexAccountId(credentials?.idToken);
return {
"accept": "text/event-stream, application/json",
"authorization": `Bearer ${credentials?.accessToken || ""}`,
"chatgpt-account-id": accountId || "",
"content-type": "application/json",
"originator": CODEX_ORIGINATOR,
"session_id": randomUUID(),
"user-agent": CODEX_USER_AGENT,
"version": CODEX_VERSION,
"x-client-request-id": randomUUID(),
};
}
if (provider === "openrouter") {
headers["Authorization"] = `Bearer ${credentials?.apiKey || credentials?.accessToken}`;
headers["HTTP-Referer"] = "https://endpoint-proxy.local";
@@ -92,9 +272,28 @@ function buildImageHeaders(provider, credentials) {
* Build request body based on provider format
*/
function buildImageBody(provider, model, body) {
const { prompt, n = 1, size = "1024x1024", quality, style, response_format } = body;
const { prompt, n = 1, size = "1024x1024", quality, style, response_format, image, images } = body;
switch (provider) {
case "codex": {
const refs = [];
if (Array.isArray(images)) images.forEach((i) => { const u = toCodexDataUrl(i); if (u) refs.push(u); });
const single = toCodexDataUrl(image);
if (single) refs.push(single);
return {
model: stripCodexImageModel(model),
instructions: "",
input: [{ type: "message", role: "user", content: buildCodexContent(prompt, refs) }],
tools: [{ type: "image_generation", output_format: "png" }],
tool_choice: "auto",
parallel_tool_calls: false,
prompt_cache_key: randomUUID(),
stream: true,
store: false,
reasoning: null,
};
}
case "gemini":
return {
contents: [{ parts: [{ text: prompt }] }],
@@ -204,6 +403,7 @@ export async function handleImageGenerationCore({
modelInfo,
credentials,
log,
streamToClient = false,
onCredentialsRefreshed,
onRequestSuccess,
}) {
@@ -285,7 +485,6 @@ export async function handleImageGenerationCore({
let responseBody;
try {
// HuggingFace returns binary image data
if (provider === "huggingface") {
const buffer = await providerResponse.arrayBuffer();
const base64 = Buffer.from(buffer).toString("base64");
@@ -293,6 +492,25 @@ export async function handleImageGenerationCore({
created: Math.floor(Date.now() / 1000),
data: [{ b64_json: base64 }],
};
} else if (provider === "codex") {
// SSE pipe to client (progress + partial_image + done)
if (streamToClient) {
return {
success: true,
response: buildCodexSseResponse(providerResponse, log, onRequestSuccess),
};
}
const b64 = await parseCodexImageStream(providerResponse, log);
if (!b64) {
return createErrorResult(
HTTP_STATUS.BAD_GATEWAY,
"Codex did not return an image. Account may not be entitled (Plus/Pro required)."
);
}
responseBody = {
created: Math.floor(Date.now() / 1000),
data: [{ b64_json: b64 }],
};
} else {
responseBody = await providerResponse.json();
}

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

View File

@@ -139,9 +139,22 @@ export async function killAppProcesses() {
}
}
// Resolve npx/9router binary to relaunch after update (cross-platform)
function resolveRelaunchCommand() {
const isWin = process.platform === "win32";
// Prefer `npx 9router` — works regardless of global bin path changes after npm i -g
const npx = isWin ? "npx.cmd" : "npx";
return { cmd: npx, args: [UPDATER_CONFIG.npmPackageName] };
}
// Spawn detached headless updater (Node process) then exit current server
export function spawnUpdaterAndExit(packageName = UPDATER_CONFIG.npmPackageName) {
const updaterPath = ensureRuntimeUpdater(resolveBundledUpdaterPath());
const isTray = process.env.TRAY_MODE === "1";
const relaunch = resolveRelaunchCommand();
// Only relaunch in tray/background mode — foreground CLI loses TTY on exit
const relaunchArgs = isTray ? [...relaunch.args, "--tray", "--skip-update"] : [];
spawn(process.execPath, [updaterPath], {
detached: true,
stdio: "ignore",
@@ -158,6 +171,9 @@ export function spawnUpdaterAndExit(packageName = UPDATER_CONFIG.npmPackageName)
UPDATER_WAIT_MAX_MS: String(UPDATER_CONFIG.waitForExitMaxMs),
UPDATER_WAIT_CHECK_MS: String(UPDATER_CONFIG.waitForExitCheckMs),
UPDATER_APP_PORT: String(UPDATER_CONFIG.appPort),
UPDATER_RELAUNCH: isTray ? "1" : "0",
UPDATER_RELAUNCH_CMD: relaunch.cmd,
UPDATER_RELAUNCH_ARGS: JSON.stringify(relaunchArgs),
},
}).unref();

View File

@@ -131,11 +131,11 @@ function sleep(ms) {
function runInstall() {
state.attempt += 1;
setPhase("installing");
pushLog(`[updater] attempt ${state.attempt}/${maxRetries} — npm i -g ${packageName}`);
pushLog(`[updater] attempt ${state.attempt}/${maxRetries} — npm i -g ${packageName} --prefer-online`);
const isWin = process.platform === "win32";
const cmd = isWin ? "npm.cmd" : "npm";
const args = ["i", "-g", packageName];
const args = ["i", "-g", packageName, "--prefer-online"];
const child = spawn(cmd, args, {
stdio: ["ignore", "pipe", "pipe"],
@@ -172,6 +172,28 @@ function runInstall() {
});
}
function relaunchApp() {
if (process.env.UPDATER_RELAUNCH !== "1") return;
const cmd = process.env.UPDATER_RELAUNCH_CMD;
if (!cmd) return;
let args = [];
try { args = JSON.parse(process.env.UPDATER_RELAUNCH_ARGS || "[]"); } catch { /* noop */ }
const isWin = process.platform === "win32";
try {
const child = spawn(cmd, args, {
detached: true,
stdio: "ignore",
windowsHide: true,
shell: isWin,
env: { ...process.env, UPDATER_RELAUNCH: "", UPDATER_RELAUNCH_CMD: "", UPDATER_RELAUNCH_ARGS: "" },
});
child.unref();
pushLog(`[updater] relaunched: ${cmd} ${args.join(" ")} (pid=${child.pid})`);
} catch (e) {
pushLog(`[updater] relaunch failed: ${e.message}`);
}
}
function finalize(success, exitCode, error) {
state.done = true;
state.success = success;
@@ -179,6 +201,7 @@ function finalize(success, exitCode, error) {
state.error = error;
state.finishedAt = Date.now();
setPhase(success ? "done" : "error");
if (success) relaunchApp();
// Linger so browser can poll final status, then exit & close the port
setTimeout(() => {
try { server.close(); } catch { /* ignore */ }

View File

@@ -0,0 +1,183 @@
"use client";
import { useState, useEffect } from "react";
import PropTypes from "prop-types";
import { Modal, Input, Button, Badge } from "@/shared/components";
const DEFAULT_BASE_URL = "https://api.openai.com/v1";
// Dual-mode modal: edit when `node` provided, add otherwise
export default function AddCustomEmbeddingModal({ isOpen, onClose, onCreated, onSaved, node }) {
const isEdit = !!node;
const [formData, setFormData] = useState({
name: "",
prefix: "",
baseUrl: DEFAULT_BASE_URL,
});
const [submitting, setSubmitting] = useState(false);
const [checkKey, setCheckKey] = useState("");
const [checkModelId, setCheckModelId] = useState("");
const [validating, setValidating] = useState(false);
const [validationResult, setValidationResult] = useState(null);
useEffect(() => {
if (!isOpen) return;
setValidationResult(null);
setCheckKey("");
setCheckModelId("");
if (isEdit) {
setFormData({
name: node.name || "",
prefix: node.prefix || "",
baseUrl: node.baseUrl || DEFAULT_BASE_URL,
});
} else {
setFormData({ name: "", prefix: "", baseUrl: DEFAULT_BASE_URL });
}
}, [isOpen, isEdit, node]);
const handleSubmit = async () => {
if (!formData.name.trim() || !formData.prefix.trim() || !formData.baseUrl.trim()) return;
setSubmitting(true);
try {
const url = isEdit ? `/api/provider-nodes/${node.id}` : "/api/provider-nodes";
const method = isEdit ? "PUT" : "POST";
const payload = {
name: formData.name,
prefix: formData.prefix,
baseUrl: formData.baseUrl,
};
if (!isEdit) payload.type = "custom-embedding";
const res = await fetch(url, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const data = await res.json();
if (res.ok) {
if (isEdit) onSaved?.(data.node);
else onCreated?.(data.node);
}
} catch (error) {
console.log("Error saving custom embedding node:", error);
} finally {
setSubmitting(false);
}
};
const handleValidate = async () => {
setValidating(true);
try {
const res = await fetch("/api/provider-nodes/validate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
baseUrl: formData.baseUrl,
apiKey: checkKey,
type: "custom-embedding",
modelId: checkModelId.trim() || undefined,
}),
});
const data = await res.json();
setValidationResult(data);
} catch {
setValidationResult({ valid: false, error: "Network error" });
} finally {
setValidating(false);
}
};
const renderValidationResult = () => {
if (!validationResult) return null;
const { valid, error, dimensions } = validationResult;
if (valid) {
return (
<>
<Badge variant="success">Valid</Badge>
{dimensions && <span className="text-sm text-text-muted">{dimensions} dims</span>}
</>
);
}
return (
<div className="flex flex-col gap-1">
<Badge variant="error">Invalid</Badge>
{error && <span className="text-sm text-red-500">{error}</span>}
</div>
);
};
return (
<Modal isOpen={isOpen} title={isEdit ? "Edit Custom Embedding" : "Add Custom Embedding"} onClose={onClose}>
<div className="flex flex-col gap-4">
<Input
label="Name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="Voyage AI"
hint="Required. A friendly label for this embedding provider."
/>
<Input
label="Prefix"
value={formData.prefix}
onChange={(e) => setFormData({ ...formData, prefix: e.target.value })}
placeholder="voyage"
hint="Required. Used as the provider prefix for model IDs (e.g. voyage/voyage-3)."
/>
<Input
label="Base URL"
value={formData.baseUrl}
onChange={(e) => setFormData({ ...formData, baseUrl: e.target.value })}
placeholder="https://api.voyageai.com/v1"
hint="Most embedding APIs are OpenAI-compatible: Voyage, Cohere, Jina, Mistral, Together..."
/>
<Input
label="API Key (for Check)"
type="password"
value={checkKey}
onChange={(e) => setCheckKey(e.target.value)}
/>
<Input
label="Model ID (for Check)"
value={checkModelId}
onChange={(e) => setCheckModelId(e.target.value)}
placeholder="e.g. voyage-3, embed-english-v3.0, text-embedding-3-small"
hint="Required for validation. Will send a test embeddings request."
/>
<div className="flex items-center gap-3">
<Button
onClick={handleValidate}
disabled={!checkKey || !checkModelId.trim() || validating || !formData.baseUrl.trim()}
variant="secondary"
>
{validating ? "Checking..." : "Check"}
</Button>
{renderValidationResult()}
</div>
<div className="flex gap-2">
<Button
onClick={handleSubmit}
fullWidth
disabled={!formData.name.trim() || !formData.prefix.trim() || !formData.baseUrl.trim() || submitting}
>
{submitting ? (isEdit ? "Saving..." : "Creating...") : (isEdit ? "Save" : "Create")}
</Button>
<Button onClick={onClose} variant="ghost" fullWidth>Cancel</Button>
</div>
</div>
</Modal>
);
}
AddCustomEmbeddingModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onClose: PropTypes.func.isRequired,
onCreated: PropTypes.func,
onSaved: PropTypes.func,
node: PropTypes.shape({
id: PropTypes.string,
name: PropTypes.string,
prefix: PropTypes.string,
baseUrl: PropTypes.string,
}),
};

View File

@@ -29,6 +29,7 @@ export { default as CursorAuthModal } from "./CursorAuthModal";
export { default as IFlowCookieModal } from "./IFlowCookieModal";
export { default as GitLabAuthModal } from "./GitLabAuthModal";
export { default as EditConnectionModal } from "./EditConnectionModal";
export { default as AddCustomEmbeddingModal } from "./AddCustomEmbeddingModal";
export { default as SegmentedControl } from "./SegmentedControl";
export { default as Tooltip } from "./Tooltip";

View File

@@ -42,7 +42,7 @@ export const THINKING_CONFIG = {
export const OAUTH_PROVIDERS = {
claude: { id: "claude", alias: "cc", name: "Claude Code", icon: "smart_toy", color: "#D97757" },
antigravity: { id: "antigravity", alias: "ag", name: "Antigravity", icon: "rocket_launch", color: "#F59E0B", deprecated: true, deprecationNotice: "AG is designed exclusively for Antigravity IDE. Using it with other tools (OpenClaw, Claude, Codex...) may result in account restrictions or bans." },
codex: { id: "codex", alias: "cx", name: "OpenAI Codex", icon: "code", color: "#3B82F6", thinkingConfig: THINKING_CONFIG.effort },
codex: { id: "codex", alias: "cx", name: "OpenAI Codex", icon: "code", color: "#3B82F6", thinkingConfig: THINKING_CONFIG.effort, serviceKinds: ["llm", "image"], kindNotice: { image: "Requires a ChatGPT Plus (or higher) account. Free accounts are not supported for image generation." } },
github: { id: "github", alias: "gh", name: "GitHub Copilot", icon: "code", color: "#333333" },
cursor: { id: "cursor", alias: "cu", name: "Cursor IDE", icon: "edit_note", color: "#00D4AA" },
// "kimi-coding": { id: "kimi-coding", alias: "kmc", name: "Kimi Coding", icon: "psychology", color: "#1E40AF", textIcon: "KC" },
@@ -122,6 +122,7 @@ export const MEDIA_PROVIDER_KINDS = [
export const OPENAI_COMPATIBLE_PREFIX = "openai-compatible-";
export const ANTHROPIC_COMPATIBLE_PREFIX = "anthropic-compatible-";
export const CUSTOM_EMBEDDING_PREFIX = "custom-embedding-";
export function isOpenAICompatibleProvider(providerId) {
return typeof providerId === "string" && providerId.startsWith(OPENAI_COMPATIBLE_PREFIX);
@@ -131,6 +132,10 @@ export function isAnthropicCompatibleProvider(providerId) {
return typeof providerId === "string" && providerId.startsWith(ANTHROPIC_COMPATIBLE_PREFIX);
}
export function isCustomEmbeddingProvider(providerId) {
return typeof providerId === "string" && providerId.startsWith(CUSTOM_EMBEDDING_PREFIX);
}
// All providers (combined)
export const AI_PROVIDERS = { ...FREE_PROVIDERS, ...FREE_TIER_PROVIDERS, ...OAUTH_PROVIDERS, ...APIKEY_PROVIDERS, ...WEB_COOKIE_PROVIDERS };

View File

@@ -10,7 +10,6 @@ import { getModelInfo } from "../services/model.js";
import { handleImageGenerationCore } from "open-sse/handlers/imageGenerationCore.js";
import { errorResponse, unavailableResponse } from "open-sse/utils/error.js";
import { HTTP_STATUS } from "open-sse/config/runtimeConfig.js";
import * as log from "../utils/logger.js";
import { updateProviderCredentials, checkAndRefreshToken } from "../services/tokenRefresh.js";
// Providers that don't require credentials (noAuth)
@@ -25,66 +24,35 @@ export async function handleImageGeneration(request) {
try {
body = await request.json();
} catch {
log.warn("IMAGE", "Invalid JSON body");
return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid JSON body");
}
const url = new URL(request.url);
const preferredConnectionId = request.headers.get("x-connection-id") || null;
const wantsStream = (request.headers.get("accept") || "").includes("text/event-stream");
const modelStr = body.model;
log.request("POST", `${url.pathname} | ${modelStr}`);
const apiKey = extractApiKey(request);
if (apiKey) {
log.debug("AUTH", `API Key: ${log.maskKey(apiKey)}`);
} else {
log.debug("AUTH", "No API key provided (local mode)");
}
const settings = await getSettings();
if (settings.requireApiKey) {
if (!apiKey) {
log.warn("AUTH", "Missing API key (requireApiKey=true)");
return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Missing API key");
}
if (!apiKey) return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Missing API key");
const valid = await isValidApiKey(apiKey);
if (!valid) {
log.warn("AUTH", "Invalid API key (requireApiKey=true)");
return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Invalid API key");
}
if (!valid) return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Invalid API key");
}
if (!modelStr) {
log.warn("IMAGE", "Missing model");
return errorResponse(HTTP_STATUS.BAD_REQUEST, "Missing model");
}
if (!body.prompt) {
log.warn("IMAGE", "Missing prompt");
return errorResponse(HTTP_STATUS.BAD_REQUEST, "Missing required field: prompt");
}
if (!modelStr) return errorResponse(HTTP_STATUS.BAD_REQUEST, "Missing model");
if (!body.prompt) return errorResponse(HTTP_STATUS.BAD_REQUEST, "Missing required field: prompt");
const modelInfo = await getModelInfo(modelStr);
if (!modelInfo.provider) {
log.warn("IMAGE", "Invalid model format", { model: modelStr });
return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid model format");
}
if (!modelInfo.provider) return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid model format");
const { provider, model } = modelInfo;
if (modelStr !== `${provider}/${model}`) {
log.info("ROUTING", `${modelStr}${provider}/${model}`);
} else {
log.info("ROUTING", `Provider: ${provider}, Model: ${model}`);
}
// noAuth providers — no credential needed
if (NO_AUTH_PROVIDERS.has(provider)) {
const result = await handleImageGenerationCore({
body,
modelInfo: { provider, model },
credentials: null,
log,
});
if (result.success) return result.response;
return errorResponse(result.status || HTTP_STATUS.BAD_GATEWAY, result.error || "Image generation failed");
@@ -96,32 +64,27 @@ export async function handleImageGeneration(request) {
let lastStatus = null;
while (true) {
const credentials = await getProviderCredentials(provider, excludeConnectionIds, model);
const credentials = await getProviderCredentials(provider, excludeConnectionIds, model, { preferredConnectionId });
if (!credentials || credentials.allRateLimited) {
if (credentials?.allRateLimited) {
const errorMsg = lastError || credentials.lastError || "Unavailable";
const status = lastStatus || Number(credentials.lastErrorCode) || HTTP_STATUS.SERVICE_UNAVAILABLE;
log.warn("IMAGE", `[${provider}/${model}] ${errorMsg} (${credentials.retryAfterHuman})`);
return unavailableResponse(status, `[${provider}/${model}] ${errorMsg}`, credentials.retryAfter, credentials.retryAfterHuman);
}
if (excludeConnectionIds.size === 0) {
log.error("AUTH", `No credentials for provider: ${provider}`);
return errorResponse(HTTP_STATUS.BAD_REQUEST, `No credentials for provider: ${provider}`);
}
log.warn("IMAGE", "No more accounts available", { provider });
return errorResponse(lastStatus || HTTP_STATUS.SERVICE_UNAVAILABLE, lastError || "All accounts unavailable");
}
log.info("AUTH", `\x1b[32mUsing ${provider} account: ${credentials.connectionName}\x1b[0m`);
const refreshedCredentials = await checkAndRefreshToken(provider, credentials);
const result = await handleImageGenerationCore({
body,
modelInfo: { provider, model },
credentials: refreshedCredentials,
log,
streamToClient: wantsStream,
onCredentialsRefreshed: async (newCreds) => {
await updateProviderCredentials(credentials.connectionId, {
accessToken: newCreds.accessToken,
@@ -140,7 +103,6 @@ export async function handleImageGeneration(request) {
const { shouldFallback } = await markAccountUnavailable(credentials.connectionId, result.status, result.error, provider, model);
if (shouldFallback) {
log.warn("AUTH", `Account ${credentials.connectionName} unavailable (${result.status}), trying fallback`);
excludeConnectionIds.add(credentials.connectionId);
lastError = result.error;
lastStatus = result.status;

View File

@@ -15,11 +15,12 @@ let selectionMutex = Promise.resolve();
* @param {Set<string>|string|null} excludeConnectionIds - Connection ID(s) to exclude (for retry with next account)
* @param {string|null} model - Model name for per-model rate limit filtering
*/
export async function getProviderCredentials(provider, excludeConnectionIds = null, model = null) {
export async function getProviderCredentials(provider, excludeConnectionIds = null, model = null, options = {}) {
// Normalize to Set for consistent handling
const excludeSet = excludeConnectionIds instanceof Set
? excludeConnectionIds
: (excludeConnectionIds ? new Set([excludeConnectionIds]) : new Set());
const preferredConnectionId = options?.preferredConnectionId || null;
// Acquire mutex to prevent race conditions
const currentMutex = selectionMutex;
let resolveMutex;
@@ -87,7 +88,16 @@ export async function getProviderCredentials(provider, excludeConnectionIds = nu
const strategy = providerOverride.fallbackStrategy || settings.fallbackStrategy || "fill-first";
let connection;
if (strategy === "round-robin") {
// Pin to preferred connection if specified and available
if (preferredConnectionId) {
connection = availableConnections.find((c) => c.id === preferredConnectionId);
if (connection) {
log.info("AUTH", `${provider} | pinned to ${connection.id?.slice(0, 8)} (${connection.name || connection.email || "unnamed"})`);
}
}
if (connection) {
// skip strategy
} else if (strategy === "round-robin") {
const stickyLimit = providerOverride.stickyRoundRobinLimit || settings.stickyRoundRobinLimit || 3;
// Sort by lastUsed (most recent first) to find current candidate

View File

@@ -33,6 +33,13 @@ export async function getModelInfo(modelStr) {
if (matchedAnthropic) {
return { provider: matchedAnthropic.id, model: parsed.model };
}
// Check Custom Embedding nodes
const embeddingNodes = await getProviderNodes({ type: "custom-embedding" });
const matchedEmbedding = embeddingNodes.find((node) => node.prefix === parsed.providerAlias);
if (matchedEmbedding) {
return { provider: matchedEmbedding.id, model: parsed.model };
}
}
return {
provider: parsed.provider,