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)
This commit is contained in:
Chirag Singhal
2026-07-08 04:40:41 +05:30
committed by GitHub
parent 1438bfb509
commit 8407a24aef
2 changed files with 72 additions and 1 deletions

View File

@@ -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<T extends { models?: unknown }>(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<string, unknown>;
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<string, unknown> = {
settings: safeSettings,
providerConnections,

View File

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