mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-20 22:02:19 +03:00
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:
1
changelog.d/features/11843-custom-models-isfree.md
Normal file
1
changelog.d/features/11843-custom-models-isfree.md
Normal 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))
|
||||
@@ -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);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
25
tests/unit/free-models-isfree.test.ts
Normal file
25
tests/unit/free-models-isfree.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
72
tests/unit/models-db-isfree.test.ts
Normal file
72
tests/unit/models-db-isfree.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
20
tests/unit/providerModelMutationSchema-isfree.test.ts
Normal file
20
tests/unit/providerModelMutationSchema-isfree.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user