{`๐ ${t("supportedEndpointEmbeddings")}`}
@@ -728,6 +752,20 @@ export default function CustomModelsSection({
/>
{`๐๏ธ ${t("visionCapableLabel")}`}
+
diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/PassthroughModelsSection.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/PassthroughModelsSection.tsx
index 1648fcd879..c8d181577d 100644
--- a/src/app/(dashboard)/dashboard/providers/[id]/components/PassthroughModelsSection.tsx
+++ b/src/app/(dashboard)/dashboard/providers/[id]/components/PassthroughModelsSection.tsx
@@ -258,7 +258,7 @@ export default function PassthroughModelsSection({
Boolean((model as any).free) ||
model.id.endsWith(":free") ||
/\bgr[aรก]tis\b|\bfree\b/i.test(model.name || "") ||
- isFreeModel(providerId, { id: model.id }),
+ isFreeModel(providerId, { id: model.id, isFree: (model as any).isFree }),
isHidden: isModelHidden(model.id),
});
seenModelIds.add(model.id);
@@ -296,7 +296,7 @@ export default function PassthroughModelsSection({
modelId.endsWith(":free") ||
Boolean((customModel as any)?.free) ||
/\bgr[aรก]tis\b|\bfree\b/i.test(customModel?.name || alias || "") ||
- isFreeModel(providerId, { id: modelId }),
+ isFreeModel(providerId, { id: modelId, isFree: (customModel as any)?.isFree }),
isHidden: isModelHidden(modelId),
});
seenModelIds.add(modelId);
diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/ProviderModelsSection.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/ProviderModelsSection.tsx
index 439f86e562..0914b269a6 100644
--- a/src/app/(dashboard)/dashboard/providers/[id]/components/ProviderModelsSection.tsx
+++ b/src/app/(dashboard)/dashboard/providers/[id]/components/ProviderModelsSection.tsx
@@ -426,7 +426,7 @@ export default function ProviderModelsSection({
const modelsWithVisibility = models.map((model) => ({
...model,
isHidden: effectiveModelHidden(model.id),
- isFree: isFreeModel(providerId, { id: model.id }),
+ isFree: isFreeModel(providerId, { id: model.id, isFree: (model as any).isFree }),
}));
const filteredModels = modelsWithVisibility.filter((model) => {
const matchesQuery = matchesModelCatalogQuery(modelFilter, {
diff --git a/src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts b/src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts
index fc52c914ad..f5c0e22a4c 100644
--- a/src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts
+++ b/src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts
@@ -92,6 +92,7 @@ export type CompatModelRow = {
/** #1904: manual vision-capability override for custom models whose upstream
* discovery metadata doesn't self-report an image input modality. */
supportsVision?: boolean;
+ isFree?: boolean;
};
export type CompatModelMap = Map;
diff --git a/src/app/api/provider-models/route.ts b/src/app/api/provider-models/route.ts
index d6b672aae6..007ab7673a 100644
--- a/src/app/api/provider-models/route.ts
+++ b/src/app/api/provider-models/route.ts
@@ -149,6 +149,7 @@ export async function POST(request) {
supportsVision,
// #9820: optional video-generation job preset (job/poll path).
generationConfig,
+ isFree,
} = validation.data;
const model = await addCustomModel(
@@ -164,7 +165,8 @@ export async function POST(request) {
...(maxOutputTokens != null ? { outputTokenLimit: maxOutputTokens } : {}),
},
typeof supportsVision === "boolean" ? supportsVision : undefined,
- generationConfig
+ generationConfig,
+ typeof isFree === "boolean" ? isFree : undefined
);
return Response.json({ model });
} catch (error) {
@@ -218,6 +220,7 @@ export async function PUT(request) {
contextWindowOverride,
supportsVision,
generationConfig,
+ isFree,
} = validation.data;
const raw = rawBody as Record;
@@ -230,8 +233,8 @@ export async function PUT(request) {
if ("preserveOpenAIDeveloperRole" in raw)
updates.preserveOpenAIDeveloperRole = preserveOpenAIDeveloperRole;
if ("upstreamHeaders" in raw) updates.upstreamHeaders = upstreamHeaders;
- // #1904: manual vision-capability override โ null clears back to heuristic.
if ("supportsVision" in raw) updates.supportsVision = supportsVision;
+ if ("isFree" in raw) updates.isFree = isFree;
// #9820: video-generation job preset โ schema is non-nullable optional, so
// presence implies a well-formed { preset } object; null is rejected by Zod.
if ("generationConfig" in raw && generationConfig !== undefined) {
@@ -259,6 +262,8 @@ export async function PUT(request) {
if (!model) {
const rawKeys = Object.keys(raw);
+ // isFree is intentionally excluded: it has no compat-override home (customModels row only),
+ // so a PUT with isFree against a missing row must 404 rather than enter the compat branch.
const compatOnly =
rawKeys.length > 0 &&
rawKeys.every((k) =>
diff --git a/src/app/api/v1/models/catalog.ts b/src/app/api/v1/models/catalog.ts
index 7b55a283fd..42ef23e931 100644
--- a/src/app/api/v1/models/catalog.ts
+++ b/src/app/api/v1/models/catalog.ts
@@ -121,7 +121,7 @@ import {
} from "./catalogRequest";
import { incrementCcDiscoveryHitCount } from "@/lib/db/ccDiscoveryMetrics";
import { isUnifiedChatSourceModelSelectable } from "./catalogModelPolicy";
-import { isFreeModel, providerHasFreeModels } from "@/shared/utils/freeModels";
+import { isFreeModel } from "@/shared/utils/freeModels";
import { isCodexDiscoveryModelExcluded } from "@/shared/services/codexDiscoveryPolicy";
import { buildErrorBody } from "@omniroute/open-sse/utils/error";
@@ -328,11 +328,15 @@ async function buildUnifiedModelsResponseCore(
// explicitly โ a disabled router rejects every auto/* id with a 400, so
// listing them offers the client a choice that cannot succeed.
const hideAuto = settings.hideAutoCombos === true || settings.autoRoutingEnabled === false;
- const shouldHidePaid = (providerKey: string, modelId: string, pricing?: unknown): boolean => {
+ const shouldHidePaid = (providerKey: string, modelId: string, pricing?: unknown, isFree?: boolean): boolean => {
if (!hidePaid) return false;
const provider = aliasToProviderId[providerKey] || providerKey;
- if (!providerHasFreeModels(provider)) return true;
- return !isFreeModel(provider, { id: modelId, pricing: pricing as any });
+ // isFree:true is the first door โ custom row kept even when its provider is outside FREE_MODEL_BUDGETS.
+ if (isFreeModel(provider, { id: modelId, pricing: pricing as any, isFree })) return false;
+ // hidePaid is on and model is non-free โ hidden. No need to consult FREE_MODEL_BUDGETS
+ // separately: paid on a free-capable provider stays hidden, free on a non-budget provider
+ // already returned above.
+ return true;
};
// Get active provider connections
@@ -1159,10 +1163,10 @@ async function buildUnifiedModelsResponseCore(
continue;
}
// #6328: apply hidePaidModels to synced provider rows too. Synced rows
- // rarely carry pricing metadata, so shouldHidePaid() falls through to
- // the FREE_MODEL_IDS_BY_PROVIDER catalog โ providers with a curated
- // free roster show only those; providers with none fall through to
- // hide-all via providerHasFreeModels() === false.
+ // rarely carry pricing metadata, so shouldHidePaid() keeps only
+ // free-tier rows (catalog + isFree). Custom rows with isFree:true are
+ // already exempt via the isFreeModel gate; other non-free synced rows
+ // are hidden when hidePaid is on.
if (shouldHidePaid(canonicalProviderId, sm.id, (sm as { pricing?: unknown }).pricing))
continue;
@@ -1561,7 +1565,7 @@ async function buildUnifiedModelsResponseCore(
// Custom entries do not carry pricing, so shouldHidePaid() decides
// via FREE_MODEL_IDS_BY_PROVIDER โ matches synced/PROVIDER_MODELS.
if (
- shouldHidePaid(canonicalProviderId, modelId, (model as { pricing?: unknown }).pricing)
+ shouldHidePaid(canonicalProviderId, modelId, (model as { pricing?: unknown }).pricing, (model as any).isFree)
)
continue;
// noAuth providers have no connection rows; keep auth providers gated. (#2798/#3200)
diff --git a/src/app/api/v1/models/catalogHelpers.ts b/src/app/api/v1/models/catalogHelpers.ts
index 40ecf25e85..02a84e32d1 100644
--- a/src/app/api/v1/models/catalogHelpers.ts
+++ b/src/app/api/v1/models/catalogHelpers.ts
@@ -24,6 +24,7 @@ export interface CustomModelEntry {
// `capabilities.vision: true` even when their id does not match the
// conservative isVisionModelId heuristic.
supportsVision?: boolean;
+ isFree?: boolean;
}
export type ComboCatalogTarget = {
diff --git a/src/lib/db/models.ts b/src/lib/db/models.ts
index 24b7707730..4ec8b75069 100644
--- a/src/lib/db/models.ts
+++ b/src/lib/db/models.ts
@@ -206,7 +206,8 @@ export async function addCustomModel(
// #9820: optional video-generation job preset (e.g. "agnes-video-job") for
// custom OpenAI-compatible video models. Persisted on the model row; the
// /v1/videos/generations handler reads it back to pick the job/poll path.
- generationConfig?: { preset: string }
+ generationConfig?: { preset: string },
+ isFree?: boolean
) {
const db = getDbInstance();
const row = db
@@ -232,6 +233,7 @@ export async function addCustomModel(
? { outputTokenLimit: tokenLimits.outputTokenLimit }
: {}),
...(typeof supportsVision === "boolean" ? { supportsVision } : {}),
+ ...(typeof isFree === "boolean" ? { isFree } : {}),
...(generationConfig && generationConfig.preset ? { generationConfig } : {}),
};
models.push(model);
@@ -260,6 +262,7 @@ export async function replaceCustomModels(
supportsThinking?: boolean;
targetFormat?: string;
generationConfig?: { preset?: string };
+ isFree?: boolean;
}>,
{ allowEmpty = false }: { allowEmpty?: boolean } = {}
) {
@@ -809,6 +812,7 @@ export async function updateCustomModel(
// #1904: manual vision-capability override โ `null` clears back to the
// id-based heuristic in getCustomVisionCapabilityFields().
applyTriStateBooleanOverride(next, updates, "supportsVision");
+ applyTriStateBooleanOverride(next, updates, "isFree");
if (updates.compatByProtocol !== undefined) {
if (mergedCompat && compatByProtocolHasEntries(mergedCompat)) {
next.compatByProtocol = mergedCompat;
diff --git a/src/shared/utils/freeModels.ts b/src/shared/utils/freeModels.ts
index 564565fe5f..4bba4928e2 100644
--- a/src/shared/utils/freeModels.ts
+++ b/src/shared/utils/freeModels.ts
@@ -60,10 +60,12 @@ function isZeroPrice(value: unknown): boolean {
export interface FreeModelCandidate {
id?: string;
pricing?: { prompt?: string | number; completion?: string | number };
+ isFree?: boolean;
}
/** Whether a single fetched model qualifies as free for the given provider (id or alias). */
export function isFreeModel(provider: string, model: FreeModelCandidate): boolean {
+ if (model.isFree === true) return true;
if (typeof model.id === "string" && model.id.endsWith(":free")) return true;
if (isZeroPrice(model.pricing?.prompt) && isZeroPrice(model.pricing?.completion)) return true;
if (typeof model.id === "string") {
diff --git a/src/shared/validation/schemas/provider.ts b/src/shared/validation/schemas/provider.ts
index e568845ff1..004cdd5aa0 100644
--- a/src/shared/validation/schemas/provider.ts
+++ b/src/shared/validation/schemas/provider.ts
@@ -271,6 +271,7 @@ export const providerModelMutationSchema = z.object({
// the same flag flows through `getCustomVisionCapabilityFields()` in the /v1/models
// catalog. `null` clears a manual override back to the id-based heuristic.
supportsVision: z.boolean().nullable().optional(),
+ isFree: z.boolean().nullable().optional(),
normalizeToolCallId: z.boolean().optional(),
preserveOpenAIDeveloperRole: z.boolean().nullable().optional(),
upstreamHeaders: upstreamHeadersRecordSchema.nullable().optional(),
diff --git a/tests/unit/free-models-isfree.test.ts b/tests/unit/free-models-isfree.test.ts
new file mode 100644
index 0000000000..7657a69c92
--- /dev/null
+++ b/tests/unit/free-models-isfree.test.ts
@@ -0,0 +1,25 @@
+import { describe, it } from "node:test";
+import assert from "node:assert/strict";
+import { isFreeModel, providerHasFreeModels } from "../../src/shared/utils/freeModels.ts";
+
+describe("isFreeModel isFree opt-in", () => {
+ it("isFree:true โ free even without :free/pricing/catalog", () => {
+ assert.equal(isFreeModel("any", { id: "x", isFree: true }), true);
+ assert.equal(isFreeModel("openai", { id: "gpt-4o", isFree: true }), true);
+ assert.equal(isFreeModel("local", { id: "my-model", isFree: true }), true);
+ });
+ it("isFree:false/null/undefined/1/'true' โ not free (strict ===true)", () => {
+ for (const v of [false, null, undefined, 1, "true" as any]) {
+ assert.equal(isFreeModel("any", { id: "x", isFree: v as any }), false, `isFree=${String(v)} should be false`);
+ }
+ });
+ it("providerHasFreeModels unchanged by custom isFree", () => {
+ assert.equal(providerHasFreeModels("local"), false);
+ assert.equal(providerHasFreeModels("openai"), providerHasFreeModels("openai"));
+ });
+ it(":free and pricing 0 still work when isFree absent", () => {
+ assert.equal(isFreeModel("any", { id: "foo:free" }), true);
+ assert.equal(isFreeModel("any", { id: "foo", pricing: { prompt: 0, completion: 0 } }), true);
+ assert.equal(isFreeModel("any", { id: "foo", pricing: { prompt: 0, completion: 1 } }), false);
+ });
+});
diff --git a/tests/unit/models-db-isfree.test.ts b/tests/unit/models-db-isfree.test.ts
new file mode 100644
index 0000000000..7e1234d097
--- /dev/null
+++ b/tests/unit/models-db-isfree.test.ts
@@ -0,0 +1,72 @@
+import { describe, it, beforeEach, afterEach } from "node:test";
+import assert from "node:assert/strict";
+import { addCustomModel, replaceCustomModels, updateCustomModel, getCustomModels } from "../../src/lib/db/models.ts";
+import { resetDbInstance } from "../../src/lib/db/core.ts";
+import { rmSync, mkdtempSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+
+describe("custom isFree tri-state (DB)", () => {
+ let dir: string;
+ let prevDataDir: string | undefined;
+ beforeEach(() => {
+ dir = mkdtempSync(join(tmpdir(), "omni-isfree-"));
+ prevDataDir = process.env.DATA_DIR;
+ process.env.DATA_DIR = dir;
+ // force DB to re-create in this DATA_DIR
+ resetDbInstance();
+ });
+ afterEach(() => {
+ resetDbInstance();
+ if (prevDataDir === undefined) delete process.env.DATA_DIR;
+ else process.env.DATA_DIR = prevDataDir;
+ try { rmSync(dir, { recursive: true, force: true }); } catch {}
+ });
+
+ it("addCustomModel round-trip isFree:true โ kept, isFree absent โ not set", async () => {
+ await addCustomModel("p", "m1", "M1", "manual", "chat-completions", ["chat"], undefined, {}, undefined, undefined, true);
+ const rows: any = await getCustomModels("p");
+ const r = rows.find((x: any) => x.id === "m1");
+ assert.equal(r.isFree, true);
+ await addCustomModel("p", "m2", "M2", "manual", "chat-completions", ["chat"], undefined, {}, undefined, undefined, undefined);
+ const rows2: any = await getCustomModels("p");
+ const r2 = rows2.find((x: any) => x.id === "m2");
+ assert.equal(r2.isFree, undefined);
+ });
+
+ it("updateCustomModel isFree:null โ delete key (tri-state clear)", async () => {
+ await addCustomModel("p", "m", "M", "manual", "chat-completions", ["chat"], undefined, {}, undefined, undefined, true);
+ await updateCustomModel("p", "m", { isFree: null } as any);
+ const rows: any = await getCustomModels("p");
+ const r = rows.find((x: any) => x.id === "m");
+ assert.equal(r.isFree, undefined);
+ });
+
+ it("updateCustomModel isFree:true โ set, then false-effective via tri-state (Boolean) ", async () => {
+ await addCustomModel("p", "m", "M", "manual", "chat-completions", ["chat"], undefined, {}, undefined, undefined, undefined);
+ await updateCustomModel("p", "m", { isFree: true } as any);
+ let rows: any = await getCustomModels("p");
+ assert.equal(rows.find((x: any) => x.id === "m").isFree, true);
+ // tri-state helper treats false as Boolean(false) โ stored as false (falsy free), but only true is free per isFree guard
+ await updateCustomModel("p", "m", { isFree: false } as any);
+ rows = await getCustomModels("p");
+ assert.equal(rows.find((x: any) => x.id === "m").isFree, false);
+ });
+
+ it("replaceCustomModels preserves isFree (new wins else prev)", async () => {
+ await addCustomModel("p", "keep", "K", "manual", "chat-completions", ["chat"], undefined, {}, undefined, undefined, true);
+ await addCustomModel("p", "override", "O", "manual", "chat-completions", ["chat"], undefined, {}, undefined, undefined, undefined);
+ // replace with new truth for override, omit for keep (prev should win)
+ await replaceCustomModels("p", [{ id: "keep", name: "keep" }, { id: "override", name: "override", isFree: true } as any]);
+ const rows: any = await getCustomModels("p");
+ assert.equal(rows.find((x: any) => x.id === "keep").isFree, true, "prev isFree preserved when new omits");
+ assert.equal(rows.find((x: any) => x.id === "override").isFree, true, "new isFree wins");
+ });
+
+ it("allowEmpty:false intact (no destructive clear)", async () => {
+ await addCustomModel("p", "m", "M", "manual", "chat-completions", ["chat"], undefined, {}, undefined, undefined, true);
+ const before: any = await getCustomModels("p");
+ const after: any = await replaceCustomModels("p", [], { allowEmpty: false });
+ assert.equal(after.length, before.length);
+ });
+});
diff --git a/tests/unit/providerModelMutationSchema-isfree.test.ts b/tests/unit/providerModelMutationSchema-isfree.test.ts
new file mode 100644
index 0000000000..63ee499a8d
--- /dev/null
+++ b/tests/unit/providerModelMutationSchema-isfree.test.ts
@@ -0,0 +1,20 @@
+import { describe, it } from "node:test";
+import assert from "node:assert/strict";
+import { providerModelMutationSchema } from "../../src/shared/validation/schemas/provider.ts";
+
+describe("providerModelMutationSchema isFree", () => {
+ it("isFree:true accepted", () => {
+ assert.equal(providerModelMutationSchema.safeParse({ provider: "p", modelId: "m", isFree: true }).success, true);
+ });
+ it("old payload without isFree still valid", () => {
+ assert.equal(providerModelMutationSchema.safeParse({ provider: "p", modelId: "m" }).success, true);
+ });
+ it("rejects isFree:0 and isFree:\"yes\"", () => {
+ assert.equal(providerModelMutationSchema.safeParse({ provider: "p", modelId: "m", isFree: 0 as any }).success, false);
+ assert.equal(providerModelMutationSchema.safeParse({ provider: "p", modelId: "m", isFree: "yes" as any }).success, false);
+ });
+ it("nullable true/false/null accepted", () => {
+ assert.equal(providerModelMutationSchema.safeParse({ provider: "p", modelId: "m", isFree: null }).success, true);
+ assert.equal(providerModelMutationSchema.safeParse({ provider: "p", modelId: "m", isFree: false }).success, true);
+ });
+});