mirror of
https://github.com/decolua/9router.git
synced 2026-05-08 12:01:28 +00:00
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:
@@ -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: [] },
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -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"}`);
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useParams, notFound } from "next/navigation";
|
||||
import { useParams, notFound, useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { useState, useEffect } from "react";
|
||||
import { Card, Badge } from "@/shared/components";
|
||||
import { Card, Badge, Button, AddCustomEmbeddingModal } from "@/shared/components";
|
||||
import ProviderIcon from "@/shared/components/ProviderIcon";
|
||||
import { MEDIA_PROVIDER_KINDS, AI_PROVIDERS, getProviderAlias } from "@/shared/constants/providers";
|
||||
import { MEDIA_PROVIDER_KINDS, AI_PROVIDERS, getProviderAlias, isCustomEmbeddingProvider } from "@/shared/constants/providers";
|
||||
import { getModelsByProviderId } from "@/shared/constants/models";
|
||||
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
|
||||
import ConnectionsCard from "@/app/(dashboard)/dashboard/providers/components/ConnectionsCard";
|
||||
@@ -63,6 +63,13 @@ const KIND_EXAMPLE_CONFIG = {
|
||||
defaultInput: "A cute cat wearing a hat",
|
||||
bodyKey: "prompt",
|
||||
defaultResponse: `{\n "data": [\n { "url": "...", "b64_json": "..." }\n ]\n}`,
|
||||
extraFields: [
|
||||
{ key: "n", label: "n", type: "number", default: 1, min: 1, max: 4 },
|
||||
{ key: "size", label: "Size", type: "select", default: "1024x1024", options: ["1024x1024", "1024x1792", "1792x1024", "auto"] },
|
||||
{ key: "quality", label: "Quality", type: "select", default: "", options: ["", "standard", "hd", "high", "low", "auto"] },
|
||||
{ key: "style", label: "Style", type: "select", default: "", options: ["", "vivid", "natural"] },
|
||||
{ key: "response_format", label: "Format", type: "select", default: "", options: ["", "url", "b64_json"] },
|
||||
],
|
||||
},
|
||||
imageToText: {
|
||||
inputLabel: "Image URL",
|
||||
@@ -96,12 +103,14 @@ const KIND_EXAMPLE_CONFIG = {
|
||||
};
|
||||
|
||||
// EmbeddingExampleCard
|
||||
function EmbeddingExampleCard({ providerId }) {
|
||||
const providerAlias = getProviderAlias(providerId);
|
||||
const embeddingModels = getModelsByProviderId(providerId).filter((m) => m.type === "embedding");
|
||||
function EmbeddingExampleCard({ providerId, customAlias }) {
|
||||
const isCustom = isCustomEmbeddingProvider(providerId);
|
||||
const providerAlias = isCustom ? (customAlias || providerId) : getProviderAlias(providerId);
|
||||
const embeddingModels = isCustom ? [] : getModelsByProviderId(providerId).filter((m) => m.type === "embedding");
|
||||
|
||||
const [selectedModel, setSelectedModel] = useState(embeddingModels[0]?.id ?? "");
|
||||
const [input, setInput] = useState("The quick brown fox jumps over the lazy dog");
|
||||
const [dimensions, setDimensions] = useState("");
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [useTunnel, setUseTunnel] = useState(false);
|
||||
const [localEndpoint, setLocalEndpoint] = useState("");
|
||||
@@ -127,10 +136,18 @@ function EmbeddingExampleCard({ providerId }) {
|
||||
const endpoint = useTunnel ? tunnelEndpoint : localEndpoint;
|
||||
const modelFull = selectedModel ? `${providerAlias}/${selectedModel}` : "";
|
||||
|
||||
// Build request body — include dimensions only if user provided a positive number
|
||||
const buildBody = () => {
|
||||
const body = { model: modelFull, input: input.trim() };
|
||||
const dim = Number(dimensions);
|
||||
if (dimensions && Number.isFinite(dim) && dim > 0) body.dimensions = dim;
|
||||
return body;
|
||||
};
|
||||
|
||||
const curlSnippet = `curl -X POST ${endpoint}/v1/embeddings \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-H "Authorization: Bearer ${apiKey || "YOUR_KEY"}" \\
|
||||
-d '{"model": "${modelFull}", "input": "${input}"}'`;
|
||||
-d '${JSON.stringify(buildBody())}'`;
|
||||
|
||||
const handleRun = async () => {
|
||||
if (!input.trim() || !modelFull) return;
|
||||
@@ -144,7 +161,7 @@ function EmbeddingExampleCard({ providerId }) {
|
||||
const res = await fetch("/api/v1/embeddings", {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ model: modelFull, input: input.trim() }),
|
||||
body: JSON.stringify(buildBody()),
|
||||
});
|
||||
const latencyMs = Date.now() - start;
|
||||
const data = await res.json();
|
||||
@@ -176,17 +193,26 @@ function EmbeddingExampleCard({ providerId }) {
|
||||
<h2 className="text-lg font-semibold mb-4">Example</h2>
|
||||
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{/* Model */}
|
||||
{/* Model — text input for custom node, dropdown otherwise */}
|
||||
<Row label="Model">
|
||||
<select
|
||||
value={selectedModel}
|
||||
onChange={(e) => setSelectedModel(e.target.value)}
|
||||
className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
|
||||
>
|
||||
{embeddingModels.map((m) => (
|
||||
<option key={m.id} value={m.id}>{m.name || m.id}</option>
|
||||
))}
|
||||
</select>
|
||||
{isCustom ? (
|
||||
<input
|
||||
value={selectedModel}
|
||||
onChange={(e) => setSelectedModel(e.target.value)}
|
||||
placeholder="e.g. voyage-3, embed-english-v3.0, text-embedding-3-small"
|
||||
className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary font-mono"
|
||||
/>
|
||||
) : (
|
||||
<select
|
||||
value={selectedModel}
|
||||
onChange={(e) => setSelectedModel(e.target.value)}
|
||||
className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
|
||||
>
|
||||
{embeddingModels.map((m) => (
|
||||
<option key={m.id} value={m.id}>{m.name || m.id}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</Row>
|
||||
|
||||
{/* Endpoint */}
|
||||
@@ -245,6 +271,18 @@ function EmbeddingExampleCard({ providerId }) {
|
||||
</div>
|
||||
</Row>
|
||||
|
||||
{/* Dimensions (optional) — truncate embedding vector length */}
|
||||
<Row label="Dimensions">
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={dimensions}
|
||||
onChange={(e) => setDimensions(e.target.value)}
|
||||
placeholder="optional, e.g. 512, 1024 (leave empty for default)"
|
||||
className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
|
||||
/>
|
||||
</Row>
|
||||
|
||||
{/* Curl + Run */}
|
||||
<div className="mt-1">
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
@@ -821,20 +859,30 @@ function GenericExampleCard({ providerId, kind }) {
|
||||
const providerAlias = getProviderAlias(providerId);
|
||||
const kindConfig = MEDIA_PROVIDER_KINDS.find((k) => k.id === kind);
|
||||
const exConfig = KIND_EXAMPLE_CONFIG[kind];
|
||||
if (!kindConfig || !exConfig) return null;
|
||||
const safeExConfig = exConfig || {};
|
||||
|
||||
// Get models for this kind (e.g., type="image")
|
||||
const kindModels = getModelsByProviderId(providerId).filter((m) => m.type === kind);
|
||||
const [selectedModel, setSelectedModel] = useState(kindModels[0]?.id ?? "");
|
||||
const selectedModelObj = kindModels.find((m) => m.id === selectedModel);
|
||||
const supportsEdit = !!selectedModelObj?.capabilities?.includes("edit");
|
||||
|
||||
const [input, setInput] = useState(exConfig.defaultInput);
|
||||
const [input, setInput] = useState(safeExConfig.defaultInput || "");
|
||||
const [refImage, setRefImage] = useState("");
|
||||
const [extraValues, setExtraValues] = useState(() =>
|
||||
(safeExConfig.extraFields || []).reduce((acc, f) => { acc[f.key] = f.default ?? ""; return acc; }, {})
|
||||
);
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [useTunnel, setUseTunnel] = useState(false);
|
||||
const [localEndpoint, setLocalEndpoint] = useState("");
|
||||
const [tunnelEndpoint, setTunnelEndpoint] = useState("");
|
||||
const [result, setResult] = useState(null);
|
||||
const [progress, setProgress] = useState(null); // { stage, bytesReceived }
|
||||
const [partialImage, setPartialImage] = useState(null);
|
||||
const [running, setRunning] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [connections, setConnections] = useState([]);
|
||||
const [pinnedConnectionId, setPinnedConnectionId] = useState("");
|
||||
const { copied: copiedCurl, copy: copyCurl } = useCopyToClipboard();
|
||||
const { copied: copiedRes, copy: copyRes } = useCopyToClipboard();
|
||||
|
||||
@@ -848,21 +896,43 @@ function GenericExampleCard({ providerId, kind }) {
|
||||
.then((r) => r.json())
|
||||
.then((d) => { if (d.publicUrl) setTunnelEndpoint(d.publicUrl); })
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
// Load active connections of this provider for pinning
|
||||
fetch("/api/providers/client")
|
||||
.then((r) => r.json())
|
||||
.then((d) => {
|
||||
const conns = (d.connections || []).filter((c) => c.provider === providerId && c.isActive !== false);
|
||||
setConnections(conns);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [providerId]);
|
||||
|
||||
// Safe to early-return now that all hooks are declared
|
||||
if (!kindConfig || !exConfig) return null;
|
||||
|
||||
const endpoint = useTunnel ? tunnelEndpoint : localEndpoint;
|
||||
const apiPath = kindConfig.endpoint.path;
|
||||
const modelFull = selectedModel ? `${providerAlias}/${selectedModel}` : "";
|
||||
|
||||
// Build request body with optional extra fields (only non-empty values)
|
||||
const extraBodyFromFields = Object.entries(extraValues).reduce((acc, [k, v]) => {
|
||||
if (v === "" || v === null || v === undefined) return acc;
|
||||
if (typeof v === "number" && Number.isNaN(v)) return acc;
|
||||
acc[k] = v;
|
||||
return acc;
|
||||
}, {});
|
||||
const requestBody = {
|
||||
model: modelFull,
|
||||
[exConfig.bodyKey]: input,
|
||||
...exConfig.extraBody,
|
||||
...extraBodyFromFields,
|
||||
...(supportsEdit && refImage.trim() ? { image: refImage.trim() } : {}),
|
||||
};
|
||||
|
||||
// Streaming supported for codex image (Plus/Pro accounts)
|
||||
const useStreaming = kind === "image" && providerId === "codex";
|
||||
const headersPreview = `-H "Content-Type: application/json" \\\n -H "Authorization: Bearer ${apiKey || "YOUR_KEY"}"${pinnedConnectionId ? ` \\\n -H "x-connection-id: ${pinnedConnectionId}"` : ""}${useStreaming ? ` \\\n -H "Accept: text/event-stream"` : ""}`;
|
||||
const curlSnippet = `curl -X ${kindConfig.endpoint.method} ${endpoint}${apiPath} \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-H "Authorization: Bearer ${apiKey || "YOUR_KEY"}" \\
|
||||
${headersPreview.replace(/\\\n /g, "\\\n ")} \\
|
||||
-d '${JSON.stringify(requestBody)}'`;
|
||||
|
||||
const handleRun = async () => {
|
||||
@@ -870,20 +940,64 @@ function GenericExampleCard({ providerId, kind }) {
|
||||
setRunning(true);
|
||||
setError("");
|
||||
setResult(null);
|
||||
setProgress(null);
|
||||
setPartialImage(null);
|
||||
const start = Date.now();
|
||||
try {
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
|
||||
if (pinnedConnectionId) headers["x-connection-id"] = pinnedConnectionId;
|
||||
if (useStreaming) headers["Accept"] = "text/event-stream";
|
||||
const body = { ...requestBody, model: modelFull };
|
||||
const res = await fetch(`/api${apiPath}`, {
|
||||
method: kindConfig.endpoint.method,
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const latencyMs = Date.now() - start;
|
||||
const data = await res.json();
|
||||
if (!res.ok) { setError(data?.error?.message || data?.error || `HTTP ${res.status}`); return; }
|
||||
setResult({ data, latencyMs });
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
setError(data?.error?.message || data?.error || `HTTP ${res.status}`);
|
||||
return;
|
||||
}
|
||||
const isSse = (res.headers.get("content-type") || "").includes("text/event-stream");
|
||||
if (isSse && res.body) {
|
||||
// Parse SSE: progress / partial_image / done / error
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buf = "";
|
||||
let finalData = null;
|
||||
let streamErr = null;
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buf += decoder.decode(value, { stream: true });
|
||||
let sep;
|
||||
while ((sep = buf.indexOf("\n\n")) !== -1) {
|
||||
const block = buf.slice(0, sep);
|
||||
buf = buf.slice(sep + 2);
|
||||
let evt = null, dataStr = "";
|
||||
for (const line of block.split("\n")) {
|
||||
if (line.startsWith("event:")) evt = line.slice(6).trim();
|
||||
else if (line.startsWith("data:")) dataStr += line.slice(5).trim();
|
||||
}
|
||||
if (!evt) continue;
|
||||
try {
|
||||
const payload = dataStr ? JSON.parse(dataStr) : {};
|
||||
if (evt === "progress") setProgress(payload);
|
||||
else if (evt === "partial_image") setPartialImage(payload);
|
||||
else if (evt === "done") finalData = payload;
|
||||
else if (evt === "error") streamErr = payload?.message || "Stream error";
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
const latencyMs = Date.now() - start;
|
||||
if (streamErr) { setError(streamErr); return; }
|
||||
if (finalData) setResult({ data: finalData, latencyMs });
|
||||
} else {
|
||||
const data = await res.json();
|
||||
const latencyMs = Date.now() - start;
|
||||
setResult({ data, latencyMs });
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e.message || "Network error");
|
||||
} finally {
|
||||
@@ -891,7 +1005,19 @@ function GenericExampleCard({ providerId, kind }) {
|
||||
}
|
||||
};
|
||||
|
||||
const resultJson = result ? JSON.stringify(result.data, null, 2) : "";
|
||||
// Mask large b64_json strings in JSON view to keep it readable
|
||||
const maskB64 = (obj) => {
|
||||
if (!obj || typeof obj !== "object") return obj;
|
||||
if (Array.isArray(obj)) return obj.map(maskB64);
|
||||
const out = {};
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
out[k] = (k === "b64_json" && typeof v === "string" && v.length > 100)
|
||||
? `<${v.length} chars base64>`
|
||||
: maskB64(v);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
const resultJson = result ? JSON.stringify(maskB64(result.data), null, 2) : "";
|
||||
|
||||
return (
|
||||
<Card>
|
||||
@@ -940,6 +1066,28 @@ function GenericExampleCard({ providerId, kind }) {
|
||||
</span>
|
||||
</Row>
|
||||
|
||||
{/* Connection picker - only show when 2+ connections (or any with email) */}
|
||||
{connections.length > 0 && (
|
||||
<Row label="Connection">
|
||||
<select
|
||||
value={pinnedConnectionId}
|
||||
onChange={(e) => setPinnedConnectionId(e.target.value)}
|
||||
className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
|
||||
>
|
||||
<option value="">Auto (by priority)</option>
|
||||
{connections.map((c) => {
|
||||
const plan = c.providerSpecificData?.chatgptPlanType;
|
||||
const label = c.email || c.name || c.id.slice(0, 8);
|
||||
return (
|
||||
<option key={c.id} value={c.id}>
|
||||
{label}{plan ? ` [${plan}]` : ""}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{/* Input */}
|
||||
<Row label={exConfig.inputLabel}>
|
||||
<div className="relative">
|
||||
@@ -961,6 +1109,68 @@ function GenericExampleCard({ providerId, kind }) {
|
||||
</div>
|
||||
</Row>
|
||||
|
||||
{/* Reference image (only for edit-capable image models) */}
|
||||
{supportsEdit && (
|
||||
<Row label="Ref Image (URL)">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="relative">
|
||||
<input
|
||||
value={refImage}
|
||||
onChange={(e) => setRefImage(e.target.value)}
|
||||
placeholder="https://example.com/source.png"
|
||||
className="w-full px-3 py-1.5 pr-7 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
|
||||
/>
|
||||
{refImage && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRefImage("")}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-text-muted hover:text-primary transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">close</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{refImage.trim() && (
|
||||
<img
|
||||
src={refImage.trim()}
|
||||
alt="Reference"
|
||||
className="max-h-40 rounded-lg border border-border object-contain bg-sidebar"
|
||||
onError={(e) => { e.currentTarget.style.display = "none"; }}
|
||||
onLoad={(e) => { e.currentTarget.style.display = "block"; }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{/* Extra fields (filtered by model.params; if undefined → none shown) */}
|
||||
{(exConfig.extraFields || [])
|
||||
.filter((f) => Array.isArray(selectedModelObj?.params) && selectedModelObj.params.includes(f.key))
|
||||
.map((f) => (
|
||||
<Row key={f.key} label={f.label}>
|
||||
{f.type === "select" ? (
|
||||
<select
|
||||
value={extraValues[f.key] ?? ""}
|
||||
onChange={(e) => setExtraValues((s) => ({ ...s, [f.key]: e.target.value }))}
|
||||
className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
|
||||
>
|
||||
{(f.options || []).map((opt) => (
|
||||
<option key={opt} value={opt}>{opt === "" ? "(default)" : opt}</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
type="number"
|
||||
value={extraValues[f.key] ?? ""}
|
||||
min={f.min}
|
||||
max={f.max}
|
||||
onChange={(e) => setExtraValues((s) => ({ ...s, [f.key]: e.target.value === "" ? "" : Number(e.target.value) }))}
|
||||
className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
|
||||
/>
|
||||
)}
|
||||
</Row>
|
||||
))}
|
||||
|
||||
{/* Curl + Run */}
|
||||
<div className="mt-1">
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
@@ -988,6 +1198,31 @@ function GenericExampleCard({ providerId, kind }) {
|
||||
<pre className="bg-sidebar rounded-lg px-3 py-2.5 text-xs font-mono text-text-main overflow-x-auto whitespace-pre">{curlSnippet}</pre>
|
||||
</div>
|
||||
|
||||
{/* Streaming progress */}
|
||||
{(running || progress) && useStreaming && (
|
||||
<div className="flex items-center gap-3 px-3 py-2 rounded-lg bg-sidebar border border-border">
|
||||
<span className="material-symbols-outlined text-[16px] text-primary" style={running ? { animation: "spin 1s linear infinite" } : undefined}>
|
||||
{running ? "progress_activity" : "check_circle"}
|
||||
</span>
|
||||
<span className="text-xs text-text-muted">
|
||||
{progress?.stage || "starting"}
|
||||
{progress?.bytesReceived ? ` · ${(progress.bytesReceived / 1024).toFixed(1)} KB` : ""}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Partial image preview (codex stream) */}
|
||||
{partialImage?.b64_json && !result && (
|
||||
<div>
|
||||
<span className="text-xs font-semibold text-text-muted uppercase tracking-wider">Partial preview</span>
|
||||
<img
|
||||
src={`data:image/png;base64,${partialImage.b64_json}`}
|
||||
alt="Partial"
|
||||
className="max-w-full rounded-lg border border-border mt-1.5 opacity-80"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && <p className="text-xs text-red-500 break-words">{error}</p>}
|
||||
|
||||
@@ -1026,14 +1261,56 @@ function GenericExampleCard({ providerId, kind }) {
|
||||
// MediaProviderDetailPage
|
||||
export default function MediaProviderDetailPage() {
|
||||
const { kind, id } = useParams();
|
||||
const router = useRouter();
|
||||
const kindConfig = MEDIA_PROVIDER_KINDS.find((k) => k.id === kind);
|
||||
const isCustom = isCustomEmbeddingProvider(id) && kind === "embedding";
|
||||
|
||||
const handleDeleteCustom = async () => {
|
||||
if (!confirm("Delete this Custom Embedding node?")) return;
|
||||
try {
|
||||
const res = await fetch(`/api/provider-nodes/${id}`, { method: "DELETE" });
|
||||
if (res.ok) router.push(`/dashboard/media-providers/${kind}`);
|
||||
} catch (error) {
|
||||
console.log("Error deleting custom embedding node:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const [customNode, setCustomNode] = useState(null);
|
||||
const [customLoading, setCustomLoading] = useState(isCustom);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
|
||||
// Fetch custom node info from API for custom embedding nodes
|
||||
useEffect(() => {
|
||||
if (!isCustom) return;
|
||||
let cancelled = false;
|
||||
fetch("/api/provider-nodes", { cache: "no-store" })
|
||||
.then((r) => r.json())
|
||||
.then((d) => {
|
||||
if (cancelled) return;
|
||||
setCustomNode((d.nodes || []).find((n) => n.id === id) || null);
|
||||
setCustomLoading(false);
|
||||
})
|
||||
.catch(() => { if (!cancelled) setCustomLoading(false); });
|
||||
return () => { cancelled = true; };
|
||||
}, [id, isCustom]);
|
||||
|
||||
if (!kindConfig) return notFound();
|
||||
|
||||
const provider = AI_PROVIDERS[id];
|
||||
if (!provider) return notFound();
|
||||
const builtInProvider = AI_PROVIDERS[id];
|
||||
|
||||
const kinds = provider.serviceKinds ?? ["llm"];
|
||||
if (!kinds.includes(kind)) return notFound();
|
||||
// For custom embedding nodes, build a synthetic provider object
|
||||
const provider = isCustom
|
||||
? (customNode ? { id, name: customNode.name || "Custom Embedding", color: "#6366F1", textIcon: "CE" } : null)
|
||||
: builtInProvider;
|
||||
|
||||
if (!isCustom && !builtInProvider) return notFound();
|
||||
if (isCustom && !customLoading && !customNode) return notFound();
|
||||
if (isCustom && customLoading) {
|
||||
return <div className="text-text-muted text-sm py-12 text-center">Loading...</div>;
|
||||
}
|
||||
|
||||
const kinds = isCustom ? ["embedding"] : (provider.serviceKinds ?? ["llm"]);
|
||||
if (!isCustom && !kinds.includes(kind)) return notFound();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
@@ -1059,9 +1336,10 @@ export default function MediaProviderDetailPage() {
|
||||
fallbackColor={provider.color}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex-1">
|
||||
<h1 className="text-3xl font-semibold tracking-tight">{provider.name}</h1>
|
||||
<div className="flex items-center gap-1.5 mt-1 flex-wrap">
|
||||
{isCustom && <Badge variant="default" size="sm">Custom · {customNode?.prefix}</Badge>}
|
||||
{kinds.map((k) => (
|
||||
<Badge key={k} variant={k === kind ? "primary" : "default"} size="sm">
|
||||
{k.toUpperCase()}
|
||||
@@ -1069,11 +1347,29 @@ export default function MediaProviderDetailPage() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{isCustom && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="secondary" icon="edit" onClick={() => setShowEditModal(true)}>
|
||||
Edit
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" icon="delete" onClick={handleDeleteCustom}>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Kind-specific notice (e.g. codex/image requires Plus) */}
|
||||
{!isCustom && provider.kindNotice?.[kind] && (
|
||||
<div className="flex items-start gap-3 px-4 py-3 rounded-lg bg-amber-500/10 border border-amber-500/30 text-amber-700 dark:text-amber-400">
|
||||
<span className="material-symbols-outlined text-[20px] mt-0.5">warning</span>
|
||||
<p className="text-sm">{provider.kindNotice[kind]}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Connections */}
|
||||
{provider.noAuth ? (
|
||||
{!isCustom && provider.noAuth ? (
|
||||
<Card>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="inline-flex items-center justify-center w-10 h-10 rounded-full bg-green-500/10 text-green-500">
|
||||
@@ -1089,13 +1385,33 @@ export default function MediaProviderDetailPage() {
|
||||
<ConnectionsCard providerId={id} isOAuth={false} />
|
||||
)}
|
||||
|
||||
{/* Models - only for non-tts kinds */}
|
||||
{kind !== "tts" && <ModelsCard providerId={id} kindFilter={kind} />}
|
||||
{/* Models - only for non-tts kinds; custom uses prefix as alias */}
|
||||
{kind !== "tts" && (
|
||||
<ModelsCard
|
||||
providerId={id}
|
||||
kindFilter={kind}
|
||||
providerAliasOverride={isCustom ? customNode?.prefix : undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Example — per kind */}
|
||||
{kind === "embedding" && <EmbeddingExampleCard providerId={id} />}
|
||||
{kind === "embedding" && (
|
||||
<EmbeddingExampleCard providerId={id} customAlias={customNode?.prefix} />
|
||||
)}
|
||||
{kind === "tts" && <TtsExampleCard providerId={id} />}
|
||||
{KIND_EXAMPLE_CONFIG[kind] && <GenericExampleCard providerId={id} kind={kind} />}
|
||||
{!isCustom && KIND_EXAMPLE_CONFIG[kind] && <GenericExampleCard providerId={id} kind={kind} />}
|
||||
|
||||
{isCustom && (
|
||||
<AddCustomEmbeddingModal
|
||||
isOpen={showEditModal}
|
||||
node={customNode}
|
||||
onClose={() => setShowEditModal(false)}
|
||||
onSaved={(updated) => {
|
||||
setCustomNode(updated);
|
||||
setShowEditModal(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useParams, notFound } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Card, Badge } from "@/shared/components";
|
||||
import { Card, Badge, Button, AddCustomEmbeddingModal } from "@/shared/components";
|
||||
import ProviderIcon from "@/shared/components/ProviderIcon";
|
||||
import { MEDIA_PROVIDER_KINDS, AI_PROVIDERS, getProvidersByKind } from "@/shared/constants/providers";
|
||||
|
||||
@@ -14,7 +14,7 @@ function getEffectiveStatus(conn) {
|
||||
return conn.testStatus === "unavailable" && !isCooldown ? "active" : conn.testStatus;
|
||||
}
|
||||
|
||||
function MediaProviderCard({ provider, kind, connections }) {
|
||||
function MediaProviderCard({ provider, kind, connections, isCustom }) {
|
||||
const providerInfo = AI_PROVIDERS[provider.id];
|
||||
const isNoAuth = !!providerInfo?.noAuth;
|
||||
|
||||
@@ -60,6 +60,7 @@ function MediaProviderCard({ provider, kind, connections }) {
|
||||
<div>
|
||||
<h3 className="font-semibold text-sm">{provider.name}</h3>
|
||||
<div className="flex items-center gap-2 mt-0.5 flex-wrap">
|
||||
{isCustom && <Badge variant="default" size="sm">Custom</Badge>}
|
||||
{renderStatus()}
|
||||
</div>
|
||||
</div>
|
||||
@@ -72,22 +73,51 @@ function MediaProviderCard({ provider, kind, connections }) {
|
||||
export default function MediaProviderKindPage() {
|
||||
const { kind } = useParams();
|
||||
const [connections, setConnections] = useState([]);
|
||||
const [customNodes, setCustomNodes] = useState([]);
|
||||
const [showAddCustomEmbedding, setShowAddCustomEmbedding] = useState(false);
|
||||
|
||||
const kindConfig = MEDIA_PROVIDER_KINDS.find((k) => k.id === kind);
|
||||
if (!kindConfig) return notFound();
|
||||
|
||||
const providers = getProvidersByKind(kind);
|
||||
const isEmbedding = kind === "embedding";
|
||||
|
||||
useEffect(() => {
|
||||
if (!kindConfig) return;
|
||||
fetch("/api/providers", { cache: "no-store" })
|
||||
.then((r) => r.json())
|
||||
.then((d) => setConnections(d.connections || []))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
if (isEmbedding) {
|
||||
fetch("/api/provider-nodes", { cache: "no-store" })
|
||||
.then((r) => r.json())
|
||||
.then((d) => setCustomNodes((d.nodes || []).filter((n) => n.type === "custom-embedding")))
|
||||
.catch(() => {});
|
||||
}
|
||||
}, [isEmbedding, kindConfig]);
|
||||
|
||||
if (!kindConfig) return notFound();
|
||||
|
||||
const providers = getProvidersByKind(kind);
|
||||
|
||||
// Map custom nodes to MediaProviderCard shape
|
||||
const customProviders = customNodes.map((n) => ({
|
||||
id: n.id,
|
||||
name: n.name || "Custom Embedding",
|
||||
color: "#6366F1",
|
||||
textIcon: "CE",
|
||||
}));
|
||||
|
||||
const allProviders = [...providers, ...customProviders];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{providers.length === 0 ? (
|
||||
{isEmbedding && (
|
||||
<div className="flex items-center justify-end">
|
||||
<Button size="sm" icon="add" onClick={() => setShowAddCustomEmbedding(true)}>
|
||||
Add Custom Embedding
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{allProviders.length === 0 ? (
|
||||
<div className="text-center py-12 border border-dashed border-border rounded-xl text-text-muted text-sm">
|
||||
No providers support <strong>{kindConfig.label}</strong> yet.
|
||||
</div>
|
||||
@@ -101,8 +131,28 @@ export default function MediaProviderKindPage() {
|
||||
connections={connections}
|
||||
/>
|
||||
))}
|
||||
{customProviders.map((provider) => (
|
||||
<MediaProviderCard
|
||||
key={provider.id}
|
||||
provider={provider}
|
||||
kind={kind}
|
||||
connections={connections}
|
||||
isCustom
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isEmbedding && (
|
||||
<AddCustomEmbeddingModal
|
||||
isOpen={showAddCustomEmbedding}
|
||||
onClose={() => setShowAddCustomEmbedding(false)}
|
||||
onCreated={(node) => {
|
||||
setCustomNodes((prev) => [...prev, node]);
|
||||
setShowAddCustomEmbedding(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -108,7 +108,7 @@ AddCustomModelModal.propTypes = {
|
||||
// ── ModelsCard ─────────────────────────────────────────────────
|
||||
// Self-contained card: shows models for a provider, filtered by optional `kindFilter`.
|
||||
// kindFilter: if provided, only shows models with matching type/kinds field.
|
||||
export default function ModelsCard({ providerId, kindFilter }) {
|
||||
export default function ModelsCard({ providerId, kindFilter, providerAliasOverride }) {
|
||||
const { copied, copy } = useCopyToClipboard();
|
||||
const [modelAliases, setModelAliases] = useState({});
|
||||
const [customModels, setCustomModels] = useState([]);
|
||||
@@ -118,7 +118,7 @@ export default function ModelsCard({ providerId, kindFilter }) {
|
||||
const [showAddCustomModel, setShowAddCustomModel] = useState(false);
|
||||
const [connections, setConnections] = useState([]);
|
||||
|
||||
const providerAlias = getProviderAlias(providerId);
|
||||
const providerAlias = providerAliasOverride || getProviderAlias(providerId);
|
||||
const effectiveType = kindFilter || "llm";
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
@@ -284,4 +284,5 @@ export default function ModelsCard({ providerId, kindFilter }) {
|
||||
ModelsCard.propTypes = {
|
||||
providerId: PropTypes.string.isRequired,
|
||||
kindFilter: PropTypes.string, // e.g. "tts", "embedding" — filters models shown
|
||||
providerAliasOverride: PropTypes.string, // override alias (e.g. for custom-embedding nodes using prefix)
|
||||
};
|
||||
|
||||
@@ -40,6 +40,14 @@ export async function PUT(request, { params }) {
|
||||
}
|
||||
}
|
||||
|
||||
// Sanitize Base URL for Custom Embedding (strip trailing slash and /embeddings)
|
||||
if (node.type === "custom-embedding") {
|
||||
sanitizedBaseUrl = sanitizedBaseUrl.replace(/\/$/, "");
|
||||
if (sanitizedBaseUrl.endsWith("/embeddings")) {
|
||||
sanitizedBaseUrl = sanitizedBaseUrl.slice(0, -"/embeddings".length);
|
||||
}
|
||||
}
|
||||
|
||||
const updates = {
|
||||
name: name.trim(),
|
||||
prefix: prefix.trim(),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createProviderNode, getProviderNodes } from "@/models";
|
||||
import { OPENAI_COMPATIBLE_PREFIX, ANTHROPIC_COMPATIBLE_PREFIX } from "@/shared/constants/providers";
|
||||
import { OPENAI_COMPATIBLE_PREFIX, ANTHROPIC_COMPATIBLE_PREFIX, CUSTOM_EMBEDDING_PREFIX } from "@/shared/constants/providers";
|
||||
import { generateId } from "@/shared/utils";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -13,6 +13,10 @@ const ANTHROPIC_COMPATIBLE_DEFAULTS = {
|
||||
baseUrl: "https://api.anthropic.com/v1",
|
||||
};
|
||||
|
||||
const CUSTOM_EMBEDDING_DEFAULTS = {
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
};
|
||||
|
||||
// GET /api/provider-nodes - List all provider nodes
|
||||
export async function GET() {
|
||||
try {
|
||||
@@ -57,6 +61,23 @@ export async function POST(request) {
|
||||
return NextResponse.json({ node }, { status: 201 });
|
||||
}
|
||||
|
||||
if (nodeType === "custom-embedding") {
|
||||
// Strip trailing slash and /embeddings if user pasted full endpoint
|
||||
let sanitizedBaseUrl = (baseUrl || CUSTOM_EMBEDDING_DEFAULTS.baseUrl).trim().replace(/\/$/, "");
|
||||
if (sanitizedBaseUrl.endsWith("/embeddings")) {
|
||||
sanitizedBaseUrl = sanitizedBaseUrl.slice(0, -"/embeddings".length);
|
||||
}
|
||||
|
||||
const node = await createProviderNode({
|
||||
id: `${CUSTOM_EMBEDDING_PREFIX}${generateId()}`,
|
||||
type: "custom-embedding",
|
||||
prefix: prefix.trim(),
|
||||
baseUrl: sanitizedBaseUrl,
|
||||
name: name.trim(),
|
||||
});
|
||||
return NextResponse.json({ node }, { status: 201 });
|
||||
}
|
||||
|
||||
if (nodeType === "anthropic-compatible") {
|
||||
// Sanitize Base URL: remove trailing slash, and remove trailing /messages if user added it
|
||||
// This prevents double-appending /messages at runtime
|
||||
|
||||
@@ -64,6 +64,36 @@ export async function POST(request) {
|
||||
return NextResponse.json({ error: "Invalid URL format" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Custom Embedding Validation - test POST /embeddings directly
|
||||
if (type === "custom-embedding") {
|
||||
const normalizedBase = baseUrl.trim().replace(/\/$/, "");
|
||||
if (!modelId?.trim()) {
|
||||
return NextResponse.json({ valid: false, error: "Model ID required for embedding validation" });
|
||||
}
|
||||
const embedRes = await fetchWithTimeout(`${normalizedBase}/embeddings`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${apiKey}`,
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({ model: modelId.trim(), input: "ping" })
|
||||
});
|
||||
if (embedRes.ok) {
|
||||
const data = await embedRes.json().catch(() => null);
|
||||
const dims = Array.isArray(data?.data?.[0]?.embedding) ? data.data[0].embedding.length : null;
|
||||
return NextResponse.json({ valid: true, method: "embeddings", dimensions: dims });
|
||||
}
|
||||
if (embedRes.status === 401 || embedRes.status === 403) {
|
||||
return NextResponse.json({ valid: false, error: "API key unauthorized" });
|
||||
}
|
||||
const errBody = await embedRes.text().catch(() => "");
|
||||
return NextResponse.json({
|
||||
valid: false,
|
||||
error: `Embeddings request failed (${embedRes.status})${errBody ? `: ${errBody.slice(0, 200)}` : ""}`,
|
||||
method: "embeddings"
|
||||
});
|
||||
}
|
||||
|
||||
// Anthropic Compatible Validation
|
||||
if (type === "anthropic-compatible") {
|
||||
let normalizedBase = baseUrl.trim().replace(/\/$/, "");
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
getProxyPoolById,
|
||||
} from "@/models";
|
||||
import { APIKEY_PROVIDERS } from "@/shared/constants/config";
|
||||
import { FREE_TIER_PROVIDERS, WEB_COOKIE_PROVIDERS, isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers";
|
||||
import { FREE_TIER_PROVIDERS, WEB_COOKIE_PROVIDERS, isOpenAICompatibleProvider, isAnthropicCompatibleProvider, isCustomEmbeddingProvider } from "@/shared/constants/providers";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -104,7 +104,8 @@ export async function POST(request) {
|
||||
FREE_TIER_PROVIDERS[provider] ||
|
||||
isWebCookieProvider ||
|
||||
isOpenAICompatibleProvider(provider) ||
|
||||
isAnthropicCompatibleProvider(provider);
|
||||
isAnthropicCompatibleProvider(provider) ||
|
||||
isCustomEmbeddingProvider(provider);
|
||||
|
||||
if (!provider || !isValidProvider) {
|
||||
return NextResponse.json({ error: "Invalid provider" }, { status: 400 });
|
||||
@@ -146,6 +147,22 @@ export async function POST(request) {
|
||||
return NextResponse.json({ error: "Only one connection is allowed for this Anthropic Compatible node" }, { status: 400 });
|
||||
}
|
||||
|
||||
providerSpecificData = {
|
||||
prefix: node.prefix,
|
||||
baseUrl: node.baseUrl,
|
||||
nodeName: node.name,
|
||||
};
|
||||
} else if (isCustomEmbeddingProvider(provider)) {
|
||||
const node = await getProviderNodeById(provider);
|
||||
if (!node) {
|
||||
return NextResponse.json({ error: "Custom Embedding node not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const existingConnections = await getProviderConnections({ provider });
|
||||
if (existingConnections.length > 0) {
|
||||
return NextResponse.json({ error: "Only one connection is allowed for this Custom Embedding node" }, { status: 400 });
|
||||
}
|
||||
|
||||
providerSpecificData = {
|
||||
prefix: node.prefix,
|
||||
baseUrl: node.baseUrl,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getProviderNodeById } from "@/models";
|
||||
import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers";
|
||||
import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider, isCustomEmbeddingProvider } from "@/shared/constants/providers";
|
||||
import { getDefaultModel } from "open-sse/config/providerModels.js";
|
||||
import { resolveOllamaLocalHost } from "open-sse/config/providers.js";
|
||||
|
||||
@@ -35,6 +35,37 @@ export async function POST(request) {
|
||||
});
|
||||
}
|
||||
|
||||
// Custom Embedding nodes: probe /models (most embedding APIs are OpenAI-compatible)
|
||||
if (isCustomEmbeddingProvider(provider)) {
|
||||
const node = await getProviderNodeById(provider);
|
||||
if (!node) {
|
||||
return NextResponse.json({ error: "Custom Embedding node not found" }, { status: 404 });
|
||||
}
|
||||
const baseUrl = node.baseUrl?.replace(/\/$/, "");
|
||||
const modelsRes = await fetch(`${baseUrl}/models`, {
|
||||
headers: { "Authorization": `Bearer ${apiKey}` },
|
||||
});
|
||||
if (modelsRes.ok) {
|
||||
return NextResponse.json({ valid: true });
|
||||
}
|
||||
// Auth errors are definitive
|
||||
if (modelsRes.status === 401 || modelsRes.status === 403) {
|
||||
return NextResponse.json({ valid: false, error: "Invalid API key" });
|
||||
}
|
||||
// Fallback: probe /embeddings with a common test model — many providers lack /models
|
||||
const embedRes = await fetch(`${baseUrl}/embeddings`, {
|
||||
method: "POST",
|
||||
headers: { "Authorization": `Bearer ${apiKey}`, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ model: "test", input: "ping" }),
|
||||
});
|
||||
// 401/403 = bad key; anything else (including 400 "model not found") means key works
|
||||
isValid = embedRes.status !== 401 && embedRes.status !== 403;
|
||||
return NextResponse.json({
|
||||
valid: isValid,
|
||||
error: isValid ? null : "Invalid API key",
|
||||
});
|
||||
}
|
||||
|
||||
if (isAnthropicCompatibleProvider(provider)) {
|
||||
const node = await getProviderNodeById(provider);
|
||||
if (!node) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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 */ }
|
||||
|
||||
183
src/shared/components/AddCustomEmbeddingModal.js
Normal file
183
src/shared/components/AddCustomEmbeddingModal.js
Normal 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,
|
||||
}),
|
||||
};
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user