diff --git a/open-sse/config/providerModels.js b/open-sse/config/providerModels.js index 409eabf8..9fe764cb 100644 --- a/open-sse/config/providerModels.js +++ b/open-sse/config/providerModels.js @@ -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: [] }, ], }; diff --git a/open-sse/handlers/embeddingsCore.js b/open-sse/handlers/embeddingsCore.js index 453f74aa..e6e98fc4 100644 --- a/open-sse/handlers/embeddingsCore.js +++ b/open-sse/handlers/embeddingsCore.js @@ -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/" 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"}`); diff --git a/open-sse/handlers/imageGenerationCore.js b/open-sse/handlers/imageGenerationCore.js index 2189f5f7..853eaf9f 100644 --- a/open-sse/handlers/imageGenerationCore.js +++ b/open-sse/handlers/imageGenerationCore.js @@ -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: `` }); + content.push({ type: "input_image", image_url: url, detail: CODEX_REF_DETAIL }); + content.push({ type: "input_text", text: "" }); + }); + 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(); } diff --git a/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/page.js b/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/page.js index ef8fd0cf..dbd3e02f 100644 --- a/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/page.js +++ b/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/page.js @@ -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 }) {

Example

- {/* Model */} + {/* Model — text input for custom node, dropdown otherwise */} - + {isCustom ? ( + 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" + /> + ) : ( + + )} {/* Endpoint */} @@ -245,6 +271,18 @@ function EmbeddingExampleCard({ providerId }) {
+ {/* Dimensions (optional) — truncate embedding vector length */} + + 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" + /> + + {/* Curl + Run */}
@@ -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 ( @@ -940,6 +1066,28 @@ function GenericExampleCard({ providerId, kind }) { + {/* Connection picker - only show when 2+ connections (or any with email) */} + {connections.length > 0 && ( + + + + )} + {/* Input */}
@@ -961,6 +1109,68 @@ function GenericExampleCard({ providerId, kind }) {
+ {/* Reference image (only for edit-capable image models) */} + {supportsEdit && ( + +
+
+ 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 && ( + + )} +
+ {refImage.trim() && ( + Reference { e.currentTarget.style.display = "none"; }} + onLoad={(e) => { e.currentTarget.style.display = "block"; }} + /> + )} +
+
+ )} + + {/* 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) => ( + + {f.type === "select" ? ( + + ) : ( + 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" + /> + )} + + ))} + {/* Curl + Run */}
@@ -988,6 +1198,31 @@ function GenericExampleCard({ providerId, kind }) {
{curlSnippet}
+ {/* Streaming progress */} + {(running || progress) && useStreaming && ( +
+ + {running ? "progress_activity" : "check_circle"} + + + {progress?.stage || "starting"} + {progress?.bytesReceived ? ` · ${(progress.bytesReceived / 1024).toFixed(1)} KB` : ""} + +
+ )} + + {/* Partial image preview (codex stream) */} + {partialImage?.b64_json && !result && ( +
+ Partial preview + Partial +
+ )} + {/* Error */} {error &&

{error}

} @@ -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
Loading...
; + } + + const kinds = isCustom ? ["embedding"] : (provider.serviceKinds ?? ["llm"]); + if (!isCustom && !kinds.includes(kind)) return notFound(); return (
@@ -1059,9 +1336,10 @@ export default function MediaProviderDetailPage() { fallbackColor={provider.color} />
-
+

{provider.name}

+ {isCustom && Custom · {customNode?.prefix}} {kinds.map((k) => ( {k.toUpperCase()} @@ -1069,11 +1347,29 @@ export default function MediaProviderDetailPage() { ))}
+ {isCustom && ( +
+ + +
+ )}
+ {/* Kind-specific notice (e.g. codex/image requires Plus) */} + {!isCustom && provider.kindNotice?.[kind] && ( +
+ warning +

{provider.kindNotice[kind]}

+
+ )} + {/* Connections */} - {provider.noAuth ? ( + {!isCustom && provider.noAuth ? (
@@ -1089,13 +1385,33 @@ export default function MediaProviderDetailPage() { )} - {/* Models - only for non-tts kinds */} - {kind !== "tts" && } + {/* Models - only for non-tts kinds; custom uses prefix as alias */} + {kind !== "tts" && ( + + )} {/* Example — per kind */} - {kind === "embedding" && } + {kind === "embedding" && ( + + )} {kind === "tts" && } - {KIND_EXAMPLE_CONFIG[kind] && } + {!isCustom && KIND_EXAMPLE_CONFIG[kind] && } + + {isCustom && ( + setShowEditModal(false)} + onSaved={(updated) => { + setCustomNode(updated); + setShowEditModal(false); + }} + /> + )}
); } diff --git a/src/app/(dashboard)/dashboard/media-providers/[kind]/page.js b/src/app/(dashboard)/dashboard/media-providers/[kind]/page.js index ed7775a5..842c11e1 100644 --- a/src/app/(dashboard)/dashboard/media-providers/[kind]/page.js +++ b/src/app/(dashboard)/dashboard/media-providers/[kind]/page.js @@ -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 }) {

{provider.name}

+ {isCustom && Custom} {renderStatus()}
@@ -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 (
- {providers.length === 0 ? ( + {isEmbedding && ( +
+ +
+ )} + + {allProviders.length === 0 ? (
No providers support {kindConfig.label} yet.
@@ -101,8 +131,28 @@ export default function MediaProviderKindPage() { connections={connections} /> ))} + {customProviders.map((provider) => ( + + ))}
)} + + {isEmbedding && ( + setShowAddCustomEmbedding(false)} + onCreated={(node) => { + setCustomNodes((prev) => [...prev, node]); + setShowAddCustomEmbedding(false); + }} + /> + )}
); } diff --git a/src/app/(dashboard)/dashboard/providers/components/ModelsCard.js b/src/app/(dashboard)/dashboard/providers/components/ModelsCard.js index a9f66d2d..14f4f28f 100644 --- a/src/app/(dashboard)/dashboard/providers/components/ModelsCard.js +++ b/src/app/(dashboard)/dashboard/providers/components/ModelsCard.js @@ -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) }; diff --git a/src/app/api/provider-nodes/[id]/route.js b/src/app/api/provider-nodes/[id]/route.js index 35ea6a0d..a758d0ab 100644 --- a/src/app/api/provider-nodes/[id]/route.js +++ b/src/app/api/provider-nodes/[id]/route.js @@ -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(), diff --git a/src/app/api/provider-nodes/route.js b/src/app/api/provider-nodes/route.js index 6a32cd9c..92c89196 100644 --- a/src/app/api/provider-nodes/route.js +++ b/src/app/api/provider-nodes/route.js @@ -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 diff --git a/src/app/api/provider-nodes/validate/route.js b/src/app/api/provider-nodes/validate/route.js index 3f852599..0d7882ae 100644 --- a/src/app/api/provider-nodes/validate/route.js +++ b/src/app/api/provider-nodes/validate/route.js @@ -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(/\/$/, ""); diff --git a/src/app/api/providers/route.js b/src/app/api/providers/route.js index 308051ff..7bcfb0e1 100644 --- a/src/app/api/providers/route.js +++ b/src/app/api/providers/route.js @@ -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, diff --git a/src/app/api/providers/validate/route.js b/src/app/api/providers/validate/route.js index a6003d56..688e32e8 100644 --- a/src/app/api/providers/validate/route.js +++ b/src/app/api/providers/validate/route.js @@ -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) { diff --git a/src/lib/appUpdater.js b/src/lib/appUpdater.js index b9cf04c2..a0e13ed4 100644 --- a/src/lib/appUpdater.js +++ b/src/lib/appUpdater.js @@ -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(); diff --git a/src/lib/updater/updater.js b/src/lib/updater/updater.js index 6c69dc93..ec3f4d03 100644 --- a/src/lib/updater/updater.js +++ b/src/lib/updater/updater.js @@ -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 */ } diff --git a/src/shared/components/AddCustomEmbeddingModal.js b/src/shared/components/AddCustomEmbeddingModal.js new file mode 100644 index 00000000..db17fe73 --- /dev/null +++ b/src/shared/components/AddCustomEmbeddingModal.js @@ -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 ( + <> + Valid + {dimensions && {dimensions} dims} + + ); + } + return ( +
+ Invalid + {error && {error}} +
+ ); + }; + + return ( + +
+ setFormData({ ...formData, name: e.target.value })} + placeholder="Voyage AI" + hint="Required. A friendly label for this embedding provider." + /> + setFormData({ ...formData, prefix: e.target.value })} + placeholder="voyage" + hint="Required. Used as the provider prefix for model IDs (e.g. voyage/voyage-3)." + /> + setFormData({ ...formData, baseUrl: e.target.value })} + placeholder="https://api.voyageai.com/v1" + hint="Most embedding APIs are OpenAI-compatible: Voyage, Cohere, Jina, Mistral, Together..." + /> + setCheckKey(e.target.value)} + /> + 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." + /> +
+ + {renderValidationResult()} +
+
+ + +
+
+
+ ); +} + +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, + }), +}; diff --git a/src/shared/components/index.js b/src/shared/components/index.js index 5c616b81..6bc76a78 100644 --- a/src/shared/components/index.js +++ b/src/shared/components/index.js @@ -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"; diff --git a/src/shared/constants/providers.js b/src/shared/constants/providers.js index f8aad446..84ec8990 100644 --- a/src/shared/constants/providers.js +++ b/src/shared/constants/providers.js @@ -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 }; diff --git a/src/sse/handlers/imageGeneration.js b/src/sse/handlers/imageGeneration.js index 47911958..87ecd417 100644 --- a/src/sse/handlers/imageGeneration.js +++ b/src/sse/handlers/imageGeneration.js @@ -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; diff --git a/src/sse/services/auth.js b/src/sse/services/auth.js index bfa2c2d9..89e6315e 100644 --- a/src/sse/services/auth.js +++ b/src/sse/services/auth.js @@ -15,11 +15,12 @@ let selectionMutex = Promise.resolve(); * @param {Set|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 diff --git a/src/sse/services/model.js b/src/sse/services/model.js index 838966bb..a883cbfb 100644 --- a/src/sse/services/model.js +++ b/src/sse/services/model.js @@ -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,