feat(free): per-model isFree for custom models (catalog + UI) (#11843)

Adds an opt-in customModels[].isFree flag so a self-hosted local model can be marked free-tier without touching the curated free-model catalog (providerHasFreeModels stays curated). 9/9 focused tests passing across the DB round-trip, schema tri-state validation, and free-model detection. Thanks!
This commit is contained in:
Dizzle
2026-08-28 17:37:44 +02:00
committed by GitHub
parent b7102140d5
commit 51ca7edd97
15 changed files with 194 additions and 20 deletions

View File

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

View File

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

View File

@@ -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")}`}
</label>
<label
htmlFor="custom-model-is-free"
className="flex items-center gap-1.5 text-xs text-text-main cursor-pointer whitespace-nowrap"
title="Mark as free-tier (shown even when hide paid models is on)"
>
<input
id="custom-model-is-free"
type="checkbox"
checked={newIsFree}
onChange={(e) => setNewIsFree(e.target.checked)}
className="rounded border-border"
/>
FREE
</label>
</div>
</div>
</div>
</div>
{/* List */}
{loading ? (
@@ -599,6 +618,11 @@ export default function CustomModelsSection({
{`👁️ ${t("visionCapableLabel")}`}
</span>
)}
{model.isFree === true && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-green-500/15 text-green-400 font-medium">
FREE
</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">
{`📐 ${t("supportedEndpointEmbeddings")}`}
@@ -728,6 +752,20 @@ export default function CustomModelsSection({
/>
{`👁️ ${t("visionCapableLabel")}`}
</label>
<label
htmlFor={`custom-model-edit-free-${model.id}`}
className="flex items-center gap-1.5 text-xs text-text-main cursor-pointer whitespace-nowrap px-2.5 py-2"
title="Mark as free-tier"
>
<input
id={`custom-model-edit-free-${model.id}`}
type="checkbox"
checked={editingIsFree}
onChange={(e) => setEditingIsFree(e.target.checked)}
className="rounded border-border"
/>
FREE
</label>
</div>
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-x-3 gap-y-1 overflow-x-auto overflow-y-visible [scrollbar-width:thin]">
<span className="text-xs text-text-muted shrink-0">

View File

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

View File

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

View File

@@ -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<string, CompatModelRow>;

View File

@@ -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<string, unknown>;
@@ -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) =>

View File

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

View File

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

View File

@@ -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;

View File

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

View File

@@ -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(),

View File

@@ -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);
});
});

View File

@@ -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);
});
});

View File

@@ -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);
});
});