diff --git a/changelog.d/features/11843-custom-models-isfree.md b/changelog.d/features/11843-custom-models-isfree.md new file mode 100644 index 0000000000..3422e76145 --- /dev/null +++ b/changelog.d/features/11843-custom-models-isfree.md @@ -0,0 +1 @@ +- **feat(free):** custom models can be marked free-tier via `customModels[].isFree`; `isFreeModel()` is the first door and `hidePaidModels` respects it even for providers outside the free budget ([#11843](https://github.com/diegosouzapw/OmniRoute/pull/11843)) diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/CompatibleModelsSection.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/CompatibleModelsSection.tsx index 2a7e8d7bbe..45f0301c28 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/CompatibleModelsSection.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/CompatibleModelsSection.tsx @@ -168,7 +168,7 @@ export default function CompatibleModelsSection({ Boolean((model as any).free) || model.id.endsWith(":free") || /\bgr[aรก]tis\b|\bfree\b/i.test(model.name || "") || - isFreeModel(providerStorageAlias, { id: model.id }), + isFreeModel(providerStorageAlias, { id: model.id, isFree: (model as any).isFree }), isHidden: isModelHidden(model.id), }); seenModelIds.add(model.id); @@ -205,7 +205,7 @@ export default function CompatibleModelsSection({ modelId.endsWith(":free") || Boolean((customModel as any)?.free) || /\bgr[aรก]tis\b|\bfree\b/i.test(customModel?.name || alias || "") || - isFreeModel(providerStorageAlias, { id: modelId }), + isFreeModel(providerStorageAlias, { id: modelId, isFree: (customModel as any)?.isFree }), isHidden: isModelHidden(modelId), }); seenModelIds.add(modelId); diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/CustomModelsSection.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/CustomModelsSection.tsx index 18801dcb0e..59297a1d27 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/CustomModelsSection.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/CustomModelsSection.tsx @@ -133,6 +133,8 @@ export default function CustomModelsSection({ // the model as vision-capable by hand (read back by getCustomVisionCapabilityFields()). const [newSupportsVision, setNewSupportsVision] = useState(false); const [editingSupportsVision, setEditingSupportsVision] = useState(false); + const [newIsFree, setNewIsFree] = useState(false); + const [editingIsFree, setEditingIsFree] = useState(false); const customMap = useMemo(() => buildCompatMap(customModels), [customModels]); const overrideMap = useMemo(() => buildCompatMap(modelCompatOverrides), [modelCompatOverrides]); @@ -172,6 +174,7 @@ export default function CustomModelsSection({ supportedEndpoints: newEndpoints, ...(newTargetFormat ? { targetFormat: newTargetFormat } : {}), ...(newSupportsVision ? { supportsVision: true } : {}), + ...(newIsFree ? { isFree: true } : {}), }), }); if (res.ok) { @@ -181,6 +184,7 @@ export default function CustomModelsSection({ setNewEndpoints(["chat"]); setNewTargetFormat(""); setNewSupportsVision(false); + setNewIsFree(false); await fetchCustomModels(); onModelsChanged?.(); } @@ -267,6 +271,7 @@ export default function CustomModelsSection({ typeof model.contextWindowOverride === "number" ? String(model.contextWindowOverride) : "" ); setEditingSupportsVision(model.supportsVision === true); + setEditingIsFree(model.isFree === true); }; const cancelEdit = () => { @@ -276,6 +281,7 @@ export default function CustomModelsSection({ setEditingTargetFormat(""); setEditingContextWindowOverride(""); setEditingSupportsVision(false); + setEditingIsFree(false); setSavingModelId(null); }; @@ -334,9 +340,8 @@ export default function CustomModelsSection({ ...(editingTargetFormat ? { targetFormat: editingTargetFormat } : {}), // #4125: manual context-window override โ€” number to set, null to clear. contextWindowOverride, - // #1904: manual vision-capability override โ€” true/false to set, null to - // clear back to the id-based heuristic. supportsVision: editingSupportsVision ? true : null, + isFree: editingIsFree ? true : null, }), }); @@ -520,9 +525,23 @@ export default function CustomModelsSection({ /> {`๐Ÿ‘๏ธ ${t("visionCapableLabel")}`} + + - {/* List */} {loading ? ( @@ -599,6 +618,11 @@ export default function CustomModelsSection({ {`๐Ÿ‘๏ธ ${t("visionCapableLabel")}`} )} + {model.isFree === true && ( + + FREE + + )} {model.supportedEndpoints?.includes("embeddings") && ( {`๐Ÿ“ ${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); + }); +});