From 228ebf436e3fed2f172cf249f7a26f0ea8ab505f Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Thu, 5 Mar 2026 18:49:07 -0300 Subject: [PATCH] feat: endpoint-aware model management + fix 3 bugs (#212, #213, #200) Bug Fixes: - #212: Auto-generate API_KEY_SECRET at startup (like JWT_SECRET) - #213: Circuit breaker now scoped per-model instead of per-provider - #200: Connectivity fallback for custom providers (Ollama, LM Studio) Features: - #204: API Format selector (Chat Completions / Responses API) for custom models - #205: Combo endpoint field (chat / embeddings / images) in schema - #206: Supported Endpoints checkboxes (chat, embeddings, images, audio) - Custom models with endpoint tags appear in /v1/embeddings and /v1/images/generations - Model catalog includes api_format, type, and supported_endpoints metadata - Provider detail page shows badges for non-default endpoint configurations Files changed: instrumentation.ts, combo.ts, validation.ts, models.ts, schemas.ts, provider-models/route.ts, providers/[id]/page.tsx, catalog.ts, embeddings/route.ts, images/generations/route.ts --- open-sse/services/combo.ts | 12 +- .../dashboard/providers/[id]/page.tsx | 139 ++++++++++++++---- src/app/api/provider-models/route.ts | 11 +- src/app/api/v1/embeddings/route.ts | 54 +++++-- src/app/api/v1/images/generations/route.ts | 54 +++++-- src/app/api/v1/models/catalog.ts | 17 +++ src/instrumentation.ts | 17 ++- src/lib/db/models.ts | 17 ++- src/lib/providers/validation.ts | 22 ++- src/shared/schemas/validation.ts | 1 + src/shared/validation/schemas.ts | 2 + 11 files changed, 265 insertions(+), 81 deletions(-) diff --git a/open-sse/services/combo.ts b/open-sse/services/combo.ts index 69f7ea86ad..46dc3953f3 100644 --- a/open-sse/services/combo.ts +++ b/open-sse/services/combo.ts @@ -310,7 +310,8 @@ export async function handleComboChat({ const parsed = parseModel(modelStr); const provider = parsed.provider || parsed.providerAlias || "unknown"; const profile = getProviderProfile(provider); - const breaker = getCircuitBreaker(`combo:${provider}`, { + const breakerKey = `combo:${modelStr}`; + const breaker = getCircuitBreaker(breakerKey, { failureThreshold: profile.circuitBreakerThreshold, resetTimeout: profile.circuitBreakerReset, }); @@ -440,8 +441,7 @@ export async function handleComboChat({ // Early exit: check if all models have breaker OPEN const allBreakersOpen = orderedModels.every((m) => { - const p = parseModel(m).provider || parseModel(m).providerAlias || "unknown"; - return !getCircuitBreaker(`combo:${p}`).canExecute(); + return !getCircuitBreaker(`combo:${m}`).canExecute(); }); // All models failed @@ -532,7 +532,8 @@ async function handleRoundRobinCombo({ const parsed = parseModel(modelStr); const provider = parsed.provider || parsed.providerAlias || "unknown"; const profile = getProviderProfile(provider); - const breaker = getCircuitBreaker(`combo:${provider}`, { + const breakerKey = `combo:${modelStr}`; + const breaker = getCircuitBreaker(breakerKey, { failureThreshold: profile.circuitBreakerThreshold, resetTimeout: profile.circuitBreakerReset, }); @@ -694,8 +695,7 @@ async function handleRoundRobinCombo({ // Early exit: check if all models have breaker OPEN const allBreakersOpen = orderedModels.every((m) => { - const p = parseModel(m).provider || parseModel(m).providerAlias || "unknown"; - return !getCircuitBreaker(`combo:${p}`).canExecute(); + return !getCircuitBreaker(`combo:${m}`).canExecute(); }); if (allBreakersOpen) { diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx index 1bebb37da0..a3c6d6ea1b 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx @@ -1338,6 +1338,8 @@ function CustomModelsSection({ providerId, providerAlias, copied, onCopy }) { const [customModels, setCustomModels] = useState([]); const [newModelId, setNewModelId] = useState(""); const [newModelName, setNewModelName] = useState(""); + const [newApiFormat, setNewApiFormat] = useState("chat-completions"); + const [newEndpoints, setNewEndpoints] = useState(["chat"]); const [adding, setAdding] = useState(false); const [loading, setLoading] = useState(true); @@ -1370,11 +1372,15 @@ function CustomModelsSection({ providerId, providerAlias, copied, onCopy }) { provider: providerId, modelId: newModelId.trim(), modelName: newModelName.trim() || undefined, + apiFormat: newApiFormat, + supportedEndpoints: newEndpoints, }), }); if (res.ok) { setNewModelId(""); setNewModelName(""); + setNewApiFormat("chat-completions"); + setNewEndpoints(["chat"]); await fetchCustomModels(); } } catch (e) { @@ -1407,38 +1413,89 @@ function CustomModelsSection({ providerId, providerAlias, copied, onCopy }) {

{t("customModelsHint")}

{/* Add form */} -
-
- - setNewModelId(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && handleAdd()} - placeholder={t("customModelPlaceholder")} - className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary" - /> +
+
+
+ + setNewModelId(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleAdd()} + placeholder={t("customModelPlaceholder")} + className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary" + /> +
+
+ + setNewModelName(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleAdd()} + placeholder={t("optional")} + className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary" + /> +
+
-
- - setNewModelName(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && handleAdd()} - placeholder={t("optional")} - className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary" - /> + + {/* API Format + Supported Endpoints */} +
+
+ + +
+
+ Supported Endpoints +
+ {["chat", "embeddings", "images", "audio"].map((ep) => ( + + ))} +
+
-
{/* List */} @@ -1457,7 +1514,7 @@ function CustomModelsSection({ providerId, providerAlias, copied, onCopy }) { tune

{model.name || model.id}

-
+
{fullModel} @@ -1470,6 +1527,26 @@ function CustomModelsSection({ providerId, providerAlias, copied, onCopy }) { {copied === copyKey ? "check" : "content_copy"} + {model.apiFormat === "responses" && ( + + Responses + + )} + {model.supportedEndpoints?.includes("embeddings") && ( + + 📐 Embed + + )} + {model.supportedEndpoints?.includes("images") && ( + + 🖼️ Images + + )} + {model.supportedEndpoints?.includes("audio") && ( + + 🔊 Audio + + )}