mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
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:
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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" },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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" },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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', ?, ?)"
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user