From 8407a24aef36fcc0530e1395b1cc921d94d19e33 Mon Sep 17 00:00:00 2001 From: Chirag Singhal <76880977+chirag127@users.noreply.github.com> Date: Wed, 8 Jul 2026 04:40:41 +0530 Subject: [PATCH] fix(backup): exclude paid models from JSON export/backup when hidePaidModels=true (#6328) (#6551) fix(backup): exclude paid models from JSON export/backup when hidePaidModels=true (#6328) Integrated into release/v3.8.47. (thanks @chirag127) --- src/app/api/settings/export-json/route.ts | 39 ++++++++++++++++++- .../export-json-hide-paid-combos-6328.test.ts | 34 ++++++++++++++++ 2 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 tests/unit/export-json-hide-paid-combos-6328.test.ts diff --git a/src/app/api/settings/export-json/route.ts b/src/app/api/settings/export-json/route.ts index 51dc429414..942a18caaf 100644 --- a/src/app/api/settings/export-json/route.ts +++ b/src/app/api/settings/export-json/route.ts @@ -12,6 +12,37 @@ import { getAllDomainCostHistory, getAllDomainBudgets, } from "@/lib/db/usageAnalytics"; +import { isFreeModel, providerHasFreeModels } from "@/shared/utils/freeModels"; + +/** + * When `settings.hidePaidModels === true`, exports must not leak paid model ids + * via combos — otherwise a round-trip (export → share/store → import) silently + * re-materialises the paid targets the operator asked to REMOVE (#6328). + * Filter combo model steps in place; keep combo-ref steps untouched. + */ +export function filterPaidComboSteps(combos: T[]): T[] { + return combos.map((combo) => { + if (!Array.isArray(combo.models)) return combo; + const filtered = combo.models.filter((step) => { + if (!step || typeof step !== "object") return true; + const s = step as Record; + if (s.kind === "combo-ref") return true; + const rawModel = typeof s.model === "string" ? s.model.trim() : ""; + if (!rawModel) return true; + const provider = + (typeof s.providerId === "string" && s.providerId.trim()) || + (typeof s.provider === "string" && s.provider.trim()) || + (rawModel.includes("/") ? rawModel.split("/")[0] : ""); + if (!provider) return true; + if (!providerHasFreeModels(provider)) return false; + const modelId = rawModel.startsWith(`${provider}/`) + ? rawModel.slice(provider.length + 1) + : rawModel; + return isFreeModel(provider, { id: modelId }); + }); + return { ...combo, models: filtered }; + }); +} /** * GET /api/settings/export-json @@ -39,9 +70,15 @@ export async function GET(request: Request) { const providerConnections = await getProviderConnections(); const providerNodes = await getProviderNodes(); - const combos = await getCombos(); + const combosRaw = await getCombos(); const apiKeys = await getApiKeys(); + // #6328: honor hidePaidModels at the export boundary so backup files + // cannot silently smuggle paid model ids back in on import. + const combos = rawSettings.hidePaidModels === true + ? filterPaidComboSteps(combosRaw as Array<{ models?: unknown }>) + : combosRaw; + const exportData: Record = { settings: safeSettings, providerConnections, diff --git a/tests/unit/export-json-hide-paid-combos-6328.test.ts b/tests/unit/export-json-hide-paid-combos-6328.test.ts new file mode 100644 index 0000000000..adb2130bd4 --- /dev/null +++ b/tests/unit/export-json-hide-paid-combos-6328.test.ts @@ -0,0 +1,34 @@ +/** + * #6328 (export/backup boundary) — when `hidePaidModels` is on, the settings + * JSON export must strip paid combo model steps so a round-trip (export → + * import) does not silently re-materialise the paid targets the operator asked + * to REMOVE. `filterPaidComboSteps` is the pure filter wired into the export + * route; `combo-ref` steps and pricing-less combos are left untouched. + * Rule #18 regression guard. + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { filterPaidComboSteps } from "../../src/app/api/settings/export-json/route.ts"; + +test("#6328 export filter drops paid model steps but keeps combo-ref steps", () => { + // `openai` has no curated free roster, so `openai/gpt-4o` is paid-tier. + const combos = [ + { + id: "c1", + models: [ + { kind: "combo-ref", ref: "other-combo" }, + { model: "openai/gpt-4o" }, + ], + }, + ]; + const [out] = filterPaidComboSteps(combos); + const kinds = out.models.map((m) => (m as { kind?: string; model?: string }).kind ?? (m as { model?: string }).model); + assert.deepEqual(kinds, ["combo-ref"], "paid model dropped, combo-ref kept"); +}); + +test("#6328 export filter leaves a combo without a models array untouched", () => { + const combos = [{ id: "c2", name: "no-models" }]; + const out = filterPaidComboSteps(combos); + assert.deepEqual(out, combos); +});