diff --git a/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/page.js b/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/page.js new file mode 100644 index 0000000..110a766 --- /dev/null +++ b/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/page.js @@ -0,0 +1,93 @@ +"use client"; + +import { useParams, notFound } from "next/navigation"; +import Link from "next/link"; +import Image from "next/image"; +import { useState } from "react"; +import { Card, Badge } from "@/shared/components"; +import { MEDIA_PROVIDER_KINDS, AI_PROVIDERS } from "@/shared/constants/providers"; +import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; +import ConnectionsCard from "@/app/(dashboard)/dashboard/providers/components/ConnectionsCard"; +import ModelsCard from "@/app/(dashboard)/dashboard/providers/components/ModelsCard"; + +export default function MediaProviderDetailPage() { + const { kind, id } = useParams(); + const { copied, copy } = useCopyToClipboard(); + const [headerImgError, setHeaderImgError] = useState(false); + + const kindConfig = MEDIA_PROVIDER_KINDS.find((k) => k.id === kind); + if (!kindConfig) return notFound(); + + const provider = AI_PROVIDERS[id]; + if (!provider) return notFound(); + + const kinds = provider.serviceKinds ?? ["llm"]; + if (!kinds.includes(kind)) return notFound(); + + const endpointText = `${kindConfig.endpoint.method} ${kindConfig.endpoint.path}`; + + return ( +
+ {/* Back */} +
+ + arrow_back + {kindConfig.label} + + + {/* Header */} +
+
+ {headerImgError ? ( + + {provider.textIcon || provider.id.slice(0, 2).toUpperCase()} + + ) : ( + {provider.name} setHeaderImgError(true)} + /> + )} +
+
+

{provider.name}

+
+ {kinds.map((k) => ( + + {k.toUpperCase()} + + ))} +
+
+
+
+ + {/* Endpoint block */} + +

{kindConfig.label} Endpoint

+
+ + {kindConfig.endpoint.method} + + {kindConfig.endpoint.path} + +
+
+ + {/* Connections — reuse shared component */} + + + {/* Models — filtered by current kind */} + +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/media-providers/[kind]/page.js b/src/app/(dashboard)/dashboard/media-providers/[kind]/page.js new file mode 100644 index 0000000..6d86b0a --- /dev/null +++ b/src/app/(dashboard)/dashboard/media-providers/[kind]/page.js @@ -0,0 +1,78 @@ +"use client"; + +import { useParams, notFound } from "next/navigation"; +import Link from "next/link"; +import { Card } from "@/shared/components"; +import ProviderIcon from "@/shared/components/ProviderIcon"; +import { MEDIA_PROVIDER_KINDS, AI_PROVIDERS, getProvidersByKind } from "@/shared/constants/providers"; +import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; + +export default function MediaProviderKindPage() { + const { kind } = useParams(); + const { copied, copy } = useCopyToClipboard(); + + const kindConfig = MEDIA_PROVIDER_KINDS.find((k) => k.id === kind); + if (!kindConfig) return notFound(); + + const providers = getProvidersByKind(kind); + const endpointText = `${kindConfig.endpoint.method} ${kindConfig.endpoint.path}`; + + return ( +
+ {/* Endpoint block */} + +

Endpoint

+
+ + {kindConfig.endpoint.method} + + {kindConfig.endpoint.path} + +
+
+ + {/* Provider list */} + {providers.length === 0 ? ( +
+ No providers support {kindConfig.label} yet. +
+ ) : ( +
+ {providers.map((provider) => ( + + +
+
7 ? provider.color : (provider.color ?? "#888") + "15"}` }} + > + +
+
+

{provider.name}

+

{(provider.serviceKinds ?? ["llm"]).join(", ")}

+
+
+
+ + ))} +
+ )} +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/providers/components/ConnectionsCard.js b/src/app/(dashboard)/dashboard/providers/components/ConnectionsCard.js new file mode 100644 index 0000000..a80f19e --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/components/ConnectionsCard.js @@ -0,0 +1,480 @@ +"use client"; + +import { useState, useEffect, useCallback, useRef } from "react"; +import PropTypes from "prop-types"; +import { Card, Badge, Button, Modal, Select, Toggle, EditConnectionModal } from "@/shared/components"; + +// ── CooldownTimer ────────────────────────────────────────────── +function CooldownTimer({ until }) { + const [remaining, setRemaining] = useState(""); + + useEffect(() => { + const update = () => { + const diff = new Date(until).getTime() - Date.now(); + if (diff <= 0) { setRemaining(""); return; } + const s = Math.floor(diff / 1000); + if (s < 60) setRemaining(`${s}s`); + else if (s < 3600) setRemaining(`${Math.floor(s / 60)}m ${s % 60}s`); + else setRemaining(`${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`); + }; + update(); + const t = setInterval(update, 1000); + return () => clearInterval(t); + }, [until]); + + if (!remaining) return null; + return ⏱ {remaining}; +} + +CooldownTimer.propTypes = { until: PropTypes.string.isRequired }; + +// ── ConnectionRow ────────────────────────────────────────────── +function ConnectionRow({ connection, proxyPools, isOAuth, isFirst, isLast, onMoveUp, onMoveDown, onToggleActive, onUpdateProxy, onEdit, onDelete }) { + const [showProxyDropdown, setShowProxyDropdown] = useState(false); + const [updatingProxy, setUpdatingProxy] = useState(false); + const [isCooldown, setIsCooldown] = useState(false); + const proxyDropdownRef = useRef(null); + + const proxyPoolMap = new Map((proxyPools || []).map((p) => [p.id, p])); + const boundProxyPoolId = connection.providerSpecificData?.proxyPoolId || null; + const boundProxyPool = boundProxyPoolId ? proxyPoolMap.get(boundProxyPoolId) : null; + const hasLegacyProxy = connection.providerSpecificData?.connectionProxyEnabled === true && !!connection.providerSpecificData?.connectionProxyUrl; + const hasAnyProxy = !!boundProxyPoolId || hasLegacyProxy; + + const proxyDisplayText = boundProxyPool + ? `Pool: ${boundProxyPool.name}` + : boundProxyPoolId ? `Pool: ${boundProxyPoolId} (inactive/missing)` + : hasLegacyProxy ? `Legacy: ${connection.providerSpecificData?.connectionProxyUrl}` : ""; + + let maskedProxyUrl = ""; + const rawProxyUrl = boundProxyPool?.proxyUrl || connection.providerSpecificData?.connectionProxyUrl; + if (rawProxyUrl) { + try { + const p = new URL(rawProxyUrl); + maskedProxyUrl = `${p.protocol}//${p.hostname}${p.port ? `:${p.port}` : ""}`; + } catch { maskedProxyUrl = rawProxyUrl; } + } + + const noProxyText = boundProxyPool?.noProxy || connection.providerSpecificData?.connectionNoProxy || ""; + const proxyBadgeVariant = boundProxyPool?.isActive === true ? "success" : (boundProxyPoolId || hasLegacyProxy) ? "error" : "default"; + + const modelLockUntil = Object.entries(connection) + .filter(([k]) => k.startsWith("modelLock_")) + .map(([, v]) => v).filter(Boolean).sort()[0] || null; + + useEffect(() => { + const check = () => { + const until = Object.entries(connection) + .filter(([k]) => k.startsWith("modelLock_")) + .map(([, v]) => v).filter(v => v && new Date(v).getTime() > Date.now()).sort()[0] || null; + setIsCooldown(!!until); + }; + check(); + const t = modelLockUntil ? setInterval(check, 1000) : null; + return () => { if (t) clearInterval(t); }; + }, [modelLockUntil]); + + useEffect(() => { + if (!showProxyDropdown) return; + const handler = (e) => { + if (proxyDropdownRef.current && !proxyDropdownRef.current.contains(e.target)) + setShowProxyDropdown(false); + }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, [showProxyDropdown]); + + const effectiveStatus = connection.testStatus === "unavailable" && !isCooldown ? "active" : connection.testStatus; + + const getStatusVariant = () => { + if (connection.isActive === false) return "default"; + if (effectiveStatus === "active" || effectiveStatus === "success") return "success"; + if (effectiveStatus === "error" || effectiveStatus === "expired" || effectiveStatus === "unavailable") return "error"; + return "default"; + }; + + const displayName = isOAuth + ? connection.name || connection.email || connection.displayName || "OAuth Account" + : connection.name; + + const handleSelectProxy = async (poolId) => { + setUpdatingProxy(true); + try { await onUpdateProxy(poolId === "__none__" ? null : poolId); } + finally { setUpdatingProxy(false); setShowProxyDropdown(false); } + }; + + return ( +
+
+
+ + +
+ {isOAuth ? "lock" : "key"} +
+

{displayName}

+
+ + {connection.isActive === false ? "disabled" : (effectiveStatus || "Unknown")} + + {hasAnyProxy && Proxy} + {isCooldown && connection.isActive !== false && } + {connection.lastError && connection.isActive !== false && ( + {connection.lastError} + )} + #{connection.priority} +
+ {hasAnyProxy && ( +
+ {proxyDisplayText} + {maskedProxyUrl && {maskedProxyUrl}} + {noProxyText && no_proxy: {noProxyText}} +
+ )} +
+
+
+
+ {(proxyPools || []).length > 0 && ( +
+ + {showProxyDropdown && ( +
+ + {(proxyPools || []).map((pool) => ( + + ))} +
+ )} +
+ )} + + +
+ +
+
+ ); +} + +ConnectionRow.propTypes = { + connection: PropTypes.shape({ + id: PropTypes.string, + name: PropTypes.string, + email: PropTypes.string, + displayName: PropTypes.string, + testStatus: PropTypes.string, + isActive: PropTypes.bool, + lastError: PropTypes.string, + priority: PropTypes.number, + }).isRequired, + proxyPools: PropTypes.array, + isOAuth: PropTypes.bool.isRequired, + isFirst: PropTypes.bool.isRequired, + isLast: PropTypes.bool.isRequired, + onMoveUp: PropTypes.func.isRequired, + onMoveDown: PropTypes.func.isRequired, + onToggleActive: PropTypes.func.isRequired, + onUpdateProxy: PropTypes.func, + onEdit: PropTypes.func.isRequired, + onDelete: PropTypes.func.isRequired, +}; + +// ── AddApiKeyModal ───────────────────────────────────────────── +function AddApiKeyModal({ isOpen, provider, providerName, proxyPools, onSave, onClose }) { + const NONE = "__none__"; + const [formData, setFormData] = useState({ name: "", apiKey: "", priority: 1, proxyPoolId: NONE }); + const [validating, setValidating] = useState(false); + const [validationResult, setValidationResult] = useState(null); + const [saving, setSaving] = useState(false); + + const handleValidate = async () => { + setValidating(true); + try { + const res = await fetch("/api/providers/validate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ provider, apiKey: formData.apiKey }), + }); + const data = await res.json(); + setValidationResult(data.valid ? "success" : "failed"); + } catch { setValidationResult("failed"); } + finally { setValidating(false); } + }; + + const handleSubmit = async () => { + if (!provider || !formData.apiKey) return; + setSaving(true); + try { + let isValid = false; + try { + setValidating(true); setValidationResult(null); + const res = await fetch("/api/providers/validate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ provider, apiKey: formData.apiKey }), + }); + const data = await res.json(); + isValid = !!data.valid; + setValidationResult(isValid ? "success" : "failed"); + } catch { setValidationResult("failed"); } + finally { setValidating(false); } + await onSave({ + name: formData.name, + apiKey: formData.apiKey, + priority: formData.priority, + proxyPoolId: formData.proxyPoolId === NONE ? null : formData.proxyPoolId, + testStatus: isValid ? "active" : "unknown", + }); + } finally { setSaving(false); } + }; + + if (!provider) return null; + + return ( + +
+
+ + setFormData({ ...formData, name: e.target.value })} placeholder="Production Key" /> +
+
+
+ + setFormData({ ...formData, apiKey: e.target.value })} /> +
+
+ +
+
+ {validationResult && ( + + {validationResult === "success" ? "Valid" : "Invalid"} + + )} +
+ + setFormData({ ...formData, priority: Number.parseInt(e.target.value) || 1 })} /> +
+ { setProviderStickyLimit(e.target.value); saveStrategy("round-robin", e.target.value); }} + className="w-14 px-2 py-1 text-xs border border-border rounded-md bg-background focus:outline-none focus:border-primary" + /> +
+ )} + + + + {connections.length === 0 ? ( +
+

No connections yet

+ +
+ ) : ( + <> +
+ {connections.map((conn, idx) => ( + handleSwapPriority(idx, idx - 1)} + onMoveDown={() => handleSwapPriority(idx, idx + 1)} + onToggleActive={(isActive) => handleToggleActive(conn.id, isActive)} + onUpdateProxy={(poolId) => handleUpdateProxy(conn.id, poolId)} + onEdit={() => { setSelectedConnection(conn); setShowEditModal(true); }} + onDelete={() => handleDelete(conn.id)} + /> + ))} +
+
+ +
+ + )} + + + setShowAddModal(false)} + /> + setShowEditModal(false)} + /> + + ); +} + +ConnectionsCard.propTypes = { + providerId: PropTypes.string.isRequired, + isOAuth: PropTypes.bool, +}; diff --git a/src/app/(dashboard)/dashboard/providers/components/ModelsCard.js b/src/app/(dashboard)/dashboard/providers/components/ModelsCard.js new file mode 100644 index 0000000..64ce13c --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/components/ModelsCard.js @@ -0,0 +1,266 @@ +"use client"; + +import { useState, useCallback, useEffect } from "react"; +import PropTypes from "prop-types"; +import { Card, Button, Modal } from "@/shared/components"; +import { getModelsByProviderId } from "@/shared/constants/models"; +import { getProviderAlias } from "@/shared/constants/providers"; +import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; + +// ── ModelRow ─────────────────────────────────────────────────── +export function ModelRow({ model, fullModel, copied, onCopy, testStatus, isCustom, isFree, onDeleteAlias, onTest, isTesting }) { + const borderColor = testStatus === "ok" ? "border-green-500/40" : testStatus === "error" ? "border-red-500/40" : "border-border"; + const iconColor = testStatus === "ok" ? "#22c55e" : testStatus === "error" ? "#ef4444" : undefined; + + return ( +
+
+ + {testStatus === "ok" ? "check_circle" : testStatus === "error" ? "cancel" : "smart_toy"} + + {fullModel} + {onTest && ( +
+ + + {isTesting ? "Testing..." : "Test"} + +
+ )} +
+ + + {copied === `model-${model.id}` ? "Copied!" : "Copy"} + +
+ {isFree && FREE} + {isCustom && ( + + )} +
+
+ ); +} + +ModelRow.propTypes = { + model: PropTypes.shape({ id: PropTypes.string.isRequired }).isRequired, + fullModel: PropTypes.string.isRequired, + copied: PropTypes.string, + onCopy: PropTypes.func.isRequired, + testStatus: PropTypes.oneOf(["ok", "error"]), + isCustom: PropTypes.bool, + isFree: PropTypes.bool, + onDeleteAlias: PropTypes.func, + onTest: PropTypes.func, + isTesting: PropTypes.bool, +}; + +// ── AddCustomModelModal ──────────────────────────────────────── +function AddCustomModelModal({ isOpen, onSave, onClose }) { + const [modelId, setModelId] = useState(""); + + const handleSave = () => { + if (!modelId.trim()) return; + onSave(modelId.trim()); + setModelId(""); + }; + + return ( + +
+
+ + setModelId(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleSave()} + placeholder="e.g. tts-1-hd" + autoFocus + /> +
+
+ + +
+
+
+ ); +} + +AddCustomModelModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onSave: PropTypes.func.isRequired, + onClose: PropTypes.func.isRequired, +}; + +// ── 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 }) { + const { copied, copy } = useCopyToClipboard(); + const [modelAliases, setModelAliases] = useState({}); + const [modelTestResults, setModelTestResults] = useState({}); + const [testingModelId, setTestingModelId] = useState(null); + const [testError, setTestError] = useState(""); + const [showAddCustomModel, setShowAddCustomModel] = useState(false); + const [connections, setConnections] = useState([]); + + const providerAlias = getProviderAlias(providerId); + + const fetchData = useCallback(async () => { + try { + const [aliasRes, connRes] = await Promise.all([ + fetch("/api/models/alias"), + fetch("/api/providers", { cache: "no-store" }), + ]); + const aliasData = await aliasRes.json(); + const connData = await connRes.json(); + if (aliasRes.ok) setModelAliases(aliasData.aliases || {}); + if (connRes.ok) setConnections((connData.connections || []).filter((c) => c.provider === providerId)); + } catch (e) { console.log("ModelsCard fetch error:", e); } + }, [providerId]); + + useEffect(() => { fetchData(); }, [fetchData]); + + const handleSetAlias = async (modelId, alias) => { + const fullModel = `${providerAlias}/${modelId}`; + try { + const res = await fetch("/api/models/alias", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: fullModel, alias }), + }); + if (res.ok) await fetchData(); + } catch (e) { console.log("set alias error:", e); } + }; + + const handleDeleteAlias = async (alias) => { + try { + const res = await fetch(`/api/models/alias?alias=${encodeURIComponent(alias)}`, { method: "DELETE" }); + if (res.ok) await fetchData(); + } catch (e) { console.log("delete alias error:", e); } + }; + + const handleTestModel = async (modelId) => { + if (testingModelId) return; + setTestingModelId(modelId); + try { + const res = await fetch("/api/models/test", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: `${providerAlias}/${modelId}` }), + }); + const data = await res.json(); + setModelTestResults((prev) => ({ ...prev, [modelId]: data.ok ? "ok" : "error" })); + setTestError(data.ok ? "" : (data.error || "Model not reachable")); + } catch { + setModelTestResults((prev) => ({ ...prev, [modelId]: "error" })); + setTestError("Network error"); + } finally { setTestingModelId(null); } + }; + + // Get models — filter by kindFilter if provided + const allModels = getModelsByProviderId(providerId); + const displayModels = kindFilter + ? allModels.filter((m) => { + if (m.kinds) return m.kinds.includes(kindFilter); + if (m.type) return m.type === kindFilter; + return kindFilter === "llm"; + }) + : allModels; + + // Custom models added via alias + const customModels = Object.entries(modelAliases) + .filter(([alias, fullModel]) => { + const prefix = `${providerAlias}/`; + if (!fullModel.startsWith(prefix)) return false; + const modelId = fullModel.slice(prefix.length); + return !displayModels.some((m) => m.id === modelId) && alias === modelId; + }) + .map(([alias, fullModel]) => ({ + id: fullModel.slice(`${providerAlias}/`.length), + alias, + })); + + return ( + <> + +
+

Models{kindFilter ? ` — ${kindFilter.toUpperCase()}` : ""}

+
+ {testError &&

{testError}

} + +
+ {displayModels.map((model) => { + const fullModel = `${providerAlias}/${model.id}`; + const existingAlias = Object.entries(modelAliases).find(([, m]) => m === fullModel)?.[0]; + return ( + handleSetAlias(model.id, alias)} + onDeleteAlias={() => handleDeleteAlias(existingAlias)} + testStatus={modelTestResults[model.id]} + onTest={connections.length > 0 ? () => handleTestModel(model.id) : undefined} + isTesting={testingModelId === model.id} + isFree={model.isFree} + /> + ); + })} + + {customModels.map((model) => ( + {}} + onDeleteAlias={() => handleDeleteAlias(model.alias)} + testStatus={modelTestResults[model.id]} + onTest={connections.length > 0 ? () => handleTestModel(model.id) : undefined} + isTesting={testingModelId === model.id} + isCustom + /> + ))} + + +
+
+ + { + await handleSetAlias(modelId, modelId); + setShowAddCustomModel(false); + }} + onClose={() => setShowAddCustomModel(false)} + /> + + ); +} + +ModelsCard.propTypes = { + providerId: PropTypes.string.isRequired, + kindFilter: PropTypes.string, // e.g. "tts", "embedding" — filters models shown +}; diff --git a/src/app/(dashboard)/dashboard/providers/page.js b/src/app/(dashboard)/dashboard/providers/page.js index 2bafb3c..ab8bd07 100644 --- a/src/app/(dashboard)/dashboard/providers/page.js +++ b/src/app/(dashboard)/dashboard/providers/page.js @@ -362,16 +362,18 @@ export default function ProvidersPage() {
- {Object.entries(APIKEY_PROVIDERS).map(([key, info]) => ( - handleToggleProvider(key, "apikey", active)} - /> - ))} + {Object.entries(APIKEY_PROVIDERS) + .filter(([, info]) => (info.serviceKinds ?? ["llm"]).includes("llm")) + .map(([key, info]) => ( + handleToggleProvider(key, "apikey", active)} + /> + ))}
diff --git a/src/shared/components/Sidebar.js b/src/shared/components/Sidebar.js index 8e5f9c7..61362d4 100644 --- a/src/shared/components/Sidebar.js +++ b/src/shared/components/Sidebar.js @@ -6,6 +6,7 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; import { cn } from "@/shared/utils/cn"; import { APP_CONFIG } from "@/shared/constants/config"; +import { MEDIA_PROVIDER_KINDS } from "@/shared/constants/providers"; import Button from "./Button"; import { ConfirmModal } from "./Modal"; @@ -31,6 +32,7 @@ const systemItems = [ export default function Sidebar({ onClose }) { const pathname = usePathname(); + const [mediaOpen, setMediaOpen] = useState(false); const [showShutdownModal, setShowShutdownModal] = useState(false); const [isShuttingDown, setIsShuttingDown] = useState(false); const [isDisconnected, setIsDisconnected] = useState(false); @@ -132,6 +134,43 @@ export default function Sidebar({ onClose }) { ))} + {/* Media Providers accordion */} + {/* */} + {mediaOpen && ( +
+ {MEDIA_PROVIDER_KINDS.map((kind) => ( + + {kind.icon} + {kind.label} + + ))} +
+ )} + {/* Debug section */}

diff --git a/src/shared/constants/providers.js b/src/shared/constants/providers.js index 6ae85ce..9f94c63 100644 --- a/src/shared/constants/providers.js +++ b/src/shared/constants/providers.js @@ -40,9 +40,9 @@ export const APIKEY_PROVIDERS = { "minimax-cn": { id: "minimax-cn", alias: "minimax-cn", name: "Minimax (China)", icon: "memory", color: "#DC2626", textIcon: "MC", website: "https://www.minimaxi.com" }, alicode: { id: "alicode", alias: "alicode", name: "Alibaba", icon: "cloud", color: "#FF6A00", textIcon: "ALi" }, "alicode-intl": { id: "alicode-intl", alias: "alicode-intl", name: "Alibaba Intl", icon: "cloud", color: "#FF6A00", textIcon: "ALi" }, - openai: { id: "openai", alias: "openai", name: "OpenAI", icon: "auto_awesome", color: "#10A37F", textIcon: "OA", website: "https://platform.openai.com" }, - anthropic: { id: "anthropic", alias: "anthropic", name: "Anthropic", icon: "smart_toy", color: "#D97757", textIcon: "AN", website: "https://console.anthropic.com" }, - gemini: { id: "gemini", alias: "gemini", name: "Gemini", icon: "diamond", color: "#4285F4", textIcon: "GE", website: "https://ai.google.dev" }, + openai: { id: "openai", alias: "openai", name: "OpenAI", icon: "auto_awesome", color: "#10A37F", textIcon: "OA", website: "https://platform.openai.com", serviceKinds: ["llm", "embedding", "tts"] }, + anthropic: { id: "anthropic", alias: "anthropic", name: "Anthropic", icon: "smart_toy", color: "#D97757", textIcon: "AN", website: "https://console.anthropic.com", serviceKinds: ["llm"] }, + gemini: { id: "gemini", alias: "gemini", name: "Gemini", icon: "diamond", color: "#4285F4", textIcon: "GE", website: "https://ai.google.dev", serviceKinds: ["llm", "embedding"] }, deepseek: { id: "deepseek", alias: "ds", name: "DeepSeek", icon: "bolt", color: "#4D6BFE", textIcon: "DS", website: "https://deepseek.com" }, groq: { id: "groq", alias: "groq", name: "Groq", icon: "speed", color: "#F55036", textIcon: "GQ", website: "https://groq.com" }, xai: { id: "xai", alias: "xai", name: "xAI (Grok)", icon: "auto_awesome", color: "#1DA1F2", textIcon: "XA", website: "https://x.ai" }, @@ -55,14 +55,30 @@ export const APIKEY_PROVIDERS = { nebius: { id: "nebius", alias: "nebius", name: "Nebius AI", icon: "cloud", color: "#6C5CE7", textIcon: "NB", website: "https://nebius.com" }, siliconflow: { id: "siliconflow", alias: "siliconflow", name: "SiliconFlow", icon: "cloud_queue", color: "#5B6EF5", textIcon: "SF", website: "https://cloud.siliconflow.com" }, hyperbolic: { id: "hyperbolic", alias: "hyp", name: "Hyperbolic", icon: "bolt", color: "#00D4FF", textIcon: "HY", website: "https://hyperbolic.xyz" }, - deepgram: { id: "deepgram", alias: "dg", name: "Deepgram", icon: "mic", color: "#13EF93", textIcon: "DG", website: "https://deepgram.com" }, - assemblyai: { id: "assemblyai", alias: "aai", name: "AssemblyAI", icon: "record_voice_over", color: "#0062FF", textIcon: "AA", website: "https://assemblyai.com" }, - nanobanana: { id: "nanobanana", alias: "nb", name: "NanoBanana", icon: "image", color: "#FFD700", textIcon: "NB", website: "https://nanobananaapi.ai" }, + deepgram: { id: "deepgram", alias: "dg", name: "Deepgram", icon: "mic", color: "#13EF93", textIcon: "DG", website: "https://deepgram.com", serviceKinds: ["stt"] }, + assemblyai: { id: "assemblyai", alias: "aai", name: "AssemblyAI", icon: "record_voice_over", color: "#0062FF", textIcon: "AA", website: "https://assemblyai.com", serviceKinds: ["stt"] }, + nanobanana: { id: "nanobanana", alias: "nb", name: "NanoBanana", icon: "image", color: "#FFD700", textIcon: "NB", website: "https://nanobananaapi.ai", serviceKinds: ["image"] }, + elevenlabs: { id: "elevenlabs", alias: "el", name: "ElevenLabs", icon: "record_voice_over", color: "#6C47FF", textIcon: "EL", website: "https://elevenlabs.io", serviceKinds: ["tts"] }, + cartesia: { id: "cartesia", alias: "cartesia", name: "Cartesia", icon: "spatial_audio", color: "#FF4F8B", textIcon: "CA", website: "https://cartesia.ai", serviceKinds: ["tts"] }, + playht: { id: "playht", alias: "playht", name: "PlayHT", icon: "play_circle", color: "#00B4D8", textIcon: "PH", website: "https://play.ht", serviceKinds: ["tts"] }, + sdwebui: { id: "sdwebui", alias: "sdwebui", name: "SD WebUI", icon: "brush", color: "#FF7043", textIcon: "SD", website: "https://github.com/AUTOMATIC1111/stable-diffusion-webui", serviceKinds: ["image"] }, + comfyui: { id: "comfyui", alias: "comfyui", name: "ComfyUI", icon: "account_tree", color: "#4CAF50", textIcon: "CF", website: "https://github.com/comfyanonymous/ComfyUI", serviceKinds: ["image"] }, + huggingface: { id: "huggingface", alias: "hf", name: "HuggingFace", icon: "face", color: "#FFD21E", textIcon: "HF", website: "https://huggingface.co", serviceKinds: ["embedding", "image", "tts"] }, chutes: { id: "chutes", alias: "ch", name: "Chutes AI", icon: "water_drop", color: "#ffffffff", textIcon: "CH", website: "https://chutes.ai" }, "ollama-local": { id: "ollama-local", alias: "ollama-local", name: "Ollama Local", icon: "cloud", color: "#ffffffff", textIcon: "OL", website: "https://ollama.com" }, "vertex-partner": { id: "vertex-partner", alias: "vxp", name: "Vertex Partner", icon: "cloud", color: "#34A853", textIcon: "VP", website: "https://cloud.google.com/vertex-ai/generative-ai/docs/partner-models/use-partner-models" }, }; +// Media provider kinds — each kind maps to a route and endpoint config +export const MEDIA_PROVIDER_KINDS = [ + { id: "embedding", label: "Embedding", icon: "data_array", endpoint: { method: "POST", path: "/v1/embeddings" } }, + { id: "image", label: "Image", icon: "image", endpoint: { method: "POST", path: "/v1/images/generations" } }, + { id: "tts", label: "TTS", icon: "record_voice_over", endpoint: { method: "POST", path: "/v1/audio/speech" } }, + { id: "stt", label: "STT", icon: "mic", endpoint: { method: "POST", path: "/v1/audio/transcriptions" } }, + { id: "video", label: "Video", icon: "movie", endpoint: { method: "POST", path: "/v1/video/generations" } }, + { id: "music", label: "Music", icon: "music_note", endpoint: { method: "POST", path: "/v1/audio/music" } }, +]; + export const OPENAI_COMPATIBLE_PREFIX = "openai-compatible-"; export const ANTHROPIC_COMPATIBLE_PREFIX = "anthropic-compatible-"; @@ -117,6 +133,15 @@ export const ID_TO_ALIAS = Object.values(AI_PROVIDERS).reduce((acc, p) => { return acc; }, {}); +// Helper: Get providers by service kind (e.g. "tts", "embedding", "image") +// Providers without serviceKinds default to ["llm"] +export function getProvidersByKind(kind) { + return Object.values(AI_PROVIDERS).filter((p) => { + const kinds = p.serviceKinds ?? ["llm"]; + return kinds.includes(kind); + }); +} + // Providers that support usage/quota API export const USAGE_SUPPORTED_PROVIDERS = [ "claude",