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
This commit is contained in:
diegosouzapw
2026-03-05 18:49:07 -03:00
parent d2b6624de4
commit 228ebf436e
11 changed files with 265 additions and 81 deletions

View File

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

View File

@@ -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 }) {
<p className="text-xs text-text-muted mb-3">{t("customModelsHint")}</p>
{/* Add form */}
<div className="flex items-end gap-2 mb-3">
<div className="flex-1">
<label htmlFor="custom-model-id" className="text-xs text-text-muted mb-1 block">
{t("modelId")}
</label>
<input
id="custom-model-id"
type="text"
value={newModelId}
onChange={(e) => 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"
/>
<div className="flex flex-col gap-3 mb-3">
<div className="flex items-end gap-2">
<div className="flex-1">
<label htmlFor="custom-model-id" className="text-xs text-text-muted mb-1 block">
{t("modelId")}
</label>
<input
id="custom-model-id"
type="text"
value={newModelId}
onChange={(e) => 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"
/>
</div>
<div className="w-40">
<label htmlFor="custom-model-name" className="text-xs text-text-muted mb-1 block">
{t("displayName")}
</label>
<input
id="custom-model-name"
type="text"
value={newModelName}
onChange={(e) => 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"
/>
</div>
<Button size="sm" icon="add" onClick={handleAdd} disabled={!newModelId.trim() || adding}>
{adding ? t("adding") : t("add")}
</Button>
</div>
<div className="w-40">
<label htmlFor="custom-model-name" className="text-xs text-text-muted mb-1 block">
{t("displayName")}
</label>
<input
id="custom-model-name"
type="text"
value={newModelName}
onChange={(e) => 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 */}
<div className="flex items-end gap-4 flex-wrap">
<div className="w-48">
<label htmlFor="custom-api-format" className="text-xs text-text-muted mb-1 block">
API Format
</label>
<select
id="custom-api-format"
value={newApiFormat}
onChange={(e) => setNewApiFormat(e.target.value)}
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
>
<option value="chat-completions">Chat Completions</option>
<option value="responses">Responses API</option>
</select>
</div>
<div className="flex-1">
<span className="text-xs text-text-muted mb-1 block">Supported Endpoints</span>
<div className="flex items-center gap-3">
{["chat", "embeddings", "images", "audio"].map((ep) => (
<label
key={ep}
className="flex items-center gap-1.5 text-xs text-text-main cursor-pointer"
>
<input
type="checkbox"
checked={newEndpoints.includes(ep)}
onChange={(e) => {
if (e.target.checked) {
setNewEndpoints((prev) => [...prev, ep]);
} else {
setNewEndpoints((prev) => prev.filter((x) => x !== ep));
}
}}
className="rounded border-border"
/>
{ep === "chat"
? "💬 Chat"
: ep === "embeddings"
? "📐 Embeddings"
: ep === "images"
? "🖼️ Images"
: "🔊 Audio"}
</label>
))}
</div>
</div>
</div>
<Button size="sm" icon="add" onClick={handleAdd} disabled={!newModelId.trim() || adding}>
{adding ? t("adding") : t("add")}
</Button>
</div>
{/* List */}
@@ -1457,7 +1514,7 @@ function CustomModelsSection({ providerId, providerAlias, copied, onCopy }) {
<span className="material-symbols-outlined text-base text-primary">tune</span>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{model.name || model.id}</p>
<div className="flex items-center gap-1 mt-1">
<div className="flex items-center gap-1 mt-1 flex-wrap">
<code className="text-xs text-text-muted font-mono bg-sidebar px-1.5 py-0.5 rounded">
{fullModel}
</code>
@@ -1470,6 +1527,26 @@ function CustomModelsSection({ providerId, providerAlias, copied, onCopy }) {
{copied === copyKey ? "check" : "content_copy"}
</span>
</button>
{model.apiFormat === "responses" && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-blue-500/15 text-blue-400 font-medium">
Responses
</span>
)}
{model.supportedEndpoints?.includes("embeddings") && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-purple-500/15 text-purple-400 font-medium">
📐 Embed
</span>
)}
{model.supportedEndpoints?.includes("images") && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-amber-500/15 text-amber-400 font-medium">
🖼 Images
</span>
)}
{model.supportedEndpoints?.includes("audio") && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-green-500/15 text-green-400 font-medium">
🔊 Audio
</span>
)}
</div>
</div>
<button

View File

@@ -64,9 +64,16 @@ export async function POST(request) {
if (isValidationFailure(validation)) {
return Response.json({ error: validation.error }, { status: 400 });
}
const { provider, modelId, modelName, source } = validation.data;
const { provider, modelId, modelName, source, apiFormat, supportedEndpoints } = validation.data;
const model = await addCustomModel(provider, modelId, modelName, source || "manual");
const model = await addCustomModel(
provider,
modelId,
modelName,
source || "manual",
apiFormat,
supportedEndpoints
);
return Response.json({ model });
} catch (error) {
console.error("Error adding provider model:", error);

View File

@@ -13,6 +13,8 @@ import { enforceApiKeyPolicy } from "@/shared/utils/apiKeyPolicy";
import { v1EmbeddingsSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import { getAllCustomModels } from "@/lib/localDb";
/**
* Handle CORS preflight
*/
@@ -30,23 +32,43 @@ export async function OPTIONS() {
* GET /v1/embeddings — list available embedding models
*/
export async function GET() {
const models = getAllEmbeddingModels();
return new Response(
JSON.stringify({
object: "list",
data: models.map((m) => ({
id: m.id,
object: "model",
created: Math.floor(Date.now() / 1000),
owned_by: m.provider,
type: "embedding",
dimensions: m.dimensions,
})),
}),
{
headers: { "Content-Type": "application/json" },
const builtInModels = getAllEmbeddingModels();
const timestamp = Math.floor(Date.now() / 1000);
const data = builtInModels.map((m) => ({
id: m.id,
object: "model",
created: timestamp,
owned_by: m.provider,
type: "embedding",
dimensions: m.dimensions,
}));
// Include custom models tagged for embeddings
try {
const customModelsMap = (await getAllCustomModels()) as Record<string, any>;
for (const [providerId, models] of Object.entries(customModelsMap)) {
if (!Array.isArray(models)) continue;
for (const model of models) {
if (!model?.id || !Array.isArray(model.supportedEndpoints)) continue;
if (!model.supportedEndpoints.includes("embeddings")) continue;
const fullId = `${providerId}/${model.id}`;
if (data.some((d) => d.id === fullId)) continue;
data.push({
id: fullId,
object: "model",
created: timestamp,
owned_by: providerId,
type: "embedding",
dimensions: null,
});
}
}
);
} catch {}
return new Response(JSON.stringify({ object: "list", data }), {
headers: { "Content-Type": "application/json" },
});
}
/**

View File

@@ -14,6 +14,8 @@ import { enforceApiKeyPolicy } from "@/shared/utils/apiKeyPolicy";
import { v1ImageGenerationSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import { getAllCustomModels } from "@/lib/localDb";
/**
* Handle CORS preflight
*/
@@ -31,23 +33,43 @@ export async function OPTIONS() {
* GET /v1/images/generations — list available image models
*/
export async function GET() {
const models = getAllImageModels();
return new Response(
JSON.stringify({
object: "list",
data: models.map((m) => ({
id: m.id,
object: "model",
created: Math.floor(Date.now() / 1000),
owned_by: m.provider,
type: "image",
supported_sizes: m.supportedSizes,
})),
}),
{
headers: { "Content-Type": "application/json" },
const builtInModels = getAllImageModels();
const timestamp = Math.floor(Date.now() / 1000);
const data = builtInModels.map((m) => ({
id: m.id,
object: "model",
created: timestamp,
owned_by: m.provider,
type: "image",
supported_sizes: m.supportedSizes,
}));
// Include custom models tagged for images
try {
const customModelsMap = (await getAllCustomModels()) as Record<string, any>;
for (const [providerId, models] of Object.entries(customModelsMap)) {
if (!Array.isArray(models)) continue;
for (const model of models) {
if (!model?.id || !Array.isArray(model.supportedEndpoints)) continue;
if (!model.supportedEndpoints.includes("images")) continue;
const fullId = `${providerId}/${model.id}`;
if (data.some((d) => d.id === fullId)) continue;
data.push({
id: fullId,
object: "model",
created: timestamp,
owned_by: providerId,
type: "image",
supported_sizes: null,
});
}
}
);
} catch {}
return new Response(JSON.stringify({ object: "list", data }), {
headers: { "Content-Type": "application/json" },
});
}
/**

View File

@@ -364,6 +364,17 @@ export async function getUnifiedModelsResponse(
const aliasId = `${alias}/${modelId}`;
if (models.some((m) => m.id === aliasId)) continue;
// Determine type from supportedEndpoints
const endpoints = Array.isArray(model.supportedEndpoints)
? model.supportedEndpoints
: ["chat"];
const apiFormat =
typeof model.apiFormat === "string" ? model.apiFormat : "chat-completions";
let modelType: string | undefined;
if (endpoints.includes("embeddings")) modelType = "embedding";
else if (endpoints.includes("images")) modelType = "image";
else if (endpoints.includes("audio")) modelType = "audio";
models.push({
id: aliasId,
object: "model",
@@ -373,6 +384,11 @@ export async function getUnifiedModelsResponse(
root: modelId,
parent: null,
custom: true,
...(modelType ? { type: modelType } : {}),
...(apiFormat !== "chat-completions" ? { api_format: apiFormat } : {}),
...(endpoints.length > 1 || !endpoints.includes("chat")
? { supported_endpoints: endpoints }
: {}),
});
// Only add provider-prefixed version if different from alias
@@ -388,6 +404,7 @@ export async function getUnifiedModelsResponse(
root: modelId,
parent: aliasId,
custom: true,
...(modelType ? { type: modelType } : {}),
});
}
}

View File

@@ -8,22 +8,27 @@
* @see https://nextjs.org/docs/app/building-your-application/optimizing/instrumentation
*/
function ensureJwtSecret(): void {
function ensureSecrets(): void {
// eslint-disable-next-line no-eval
const crypto = eval("require")("crypto");
if (!process.env.JWT_SECRET || process.env.JWT_SECRET.trim() === "") {
// Use eval to hide require from webpack's static analysis
// This code only runs in Node.js runtime (guarded by NEXT_RUNTIME check)
// eslint-disable-next-line no-eval
const crypto = eval("require")("crypto");
const generated = crypto.randomBytes(48).toString("base64");
process.env.JWT_SECRET = generated;
console.log("[STARTUP] JWT_SECRET auto-generated (random 64-char secret)");
}
if (!process.env.API_KEY_SECRET || process.env.API_KEY_SECRET.trim() === "") {
const generated = crypto.randomBytes(32).toString("hex");
process.env.API_KEY_SECRET = generated;
console.log("[STARTUP] API_KEY_SECRET auto-generated (random 64-char hex secret)");
}
}
export async function register() {
// Only run on the server (not during build or in Edge runtime)
if (process.env.NEXT_RUNTIME === "nodejs") {
ensureJwtSecret();
ensureSecrets();
// Console log file capture (must be first — before any logging occurs)
const { initConsoleInterceptor } = await import("@/lib/consoleInterceptor");
initConsoleInterceptor();

View File

@@ -115,7 +115,14 @@ export async function getAllCustomModels() {
return result;
}
export async function addCustomModel(providerId, modelId, modelName, source = "manual") {
export async function addCustomModel(
providerId: string,
modelId: string,
modelName?: string,
source = "manual",
apiFormat: "chat-completions" | "responses" = "chat-completions",
supportedEndpoints: string[] = ["chat"]
) {
const db = getDbInstance();
const row = db
.prepare("SELECT value FROM key_value WHERE namespace = 'customModels' AND key = ?")
@@ -126,7 +133,13 @@ export async function addCustomModel(providerId, modelId, modelName, source = "m
const exists = models.find((m) => m.id === modelId);
if (exists) return exists;
const model = { id: modelId, name: modelName || modelId, source };
const model = {
id: modelId,
name: modelName || modelId,
source,
apiFormat,
supportedEndpoints,
};
models.push(model);
db.prepare(
"INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES ('customModels', ?, ?)"

View File

@@ -307,11 +307,29 @@ async function validateOpenAICompatibleProvider({ apiKey, providerSpecificData =
if (chatRes.status >= 500) {
return { valid: false, error: `Provider unavailable (${chatRes.status})` };
}
} catch {
// Chat test also failed — fall through to simple connectivity check
}
// Step 3: Final fallback — simple connectivity check
// For local providers (Ollama, LM Studio, etc.) that may not respond to
// standard OpenAI endpoints but are still reachable
try {
const pingRes = await fetch(baseUrl, {
method: "GET",
headers: buildBearerHeaders(apiKey),
signal: AbortSignal.timeout(5000),
});
// If the server responds at all (even with an error page), it's reachable
if (pingRes.status < 500) {
return { valid: true, error: null };
}
return { valid: false, error: `Provider unavailable (${pingRes.status})` };
} catch (error: any) {
return { valid: false, error: error.message || "Connection failed" };
}
return { valid: false, error: "Validation failed" };
}
async function validateAnthropicCompatibleProvider({ apiKey, providerSpecificData = {} }: any) {

View File

@@ -37,6 +37,7 @@ export const comboNodeSchema = z.object({
export const comboSchema = z.object({
name: z.string().min(1, "Combo name is required").max(100),
model: z.string().min(1, "Model pattern is required"),
endpoint: z.enum(["chat", "embeddings", "images"]).default("chat"),
strategy: z
.enum(["priority", "weighted", "round-robin", "random", "least-used", "cost-optimized"])
.default("priority"),

View File

@@ -300,6 +300,8 @@ export const providerModelMutationSchema = z.object({
modelId: z.string().trim().min(1, "modelId is required").max(240),
modelName: z.string().trim().max(240).optional(),
source: z.string().trim().max(80).optional(),
apiFormat: z.enum(["chat-completions", "responses"]).default("chat-completions"),
supportedEndpoints: z.array(z.enum(["chat", "embeddings", "images", "audio"])).default(["chat"]),
});
const pricingFieldsSchema = z