Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@

### 🐛 Bug Fixes

- **feat(api):** add a `hidePaidModels` setting that filters paid-only models out of the `/v1/models` catalog. Regression guard: `tests/unit/models-catalog-hide-paid.test.ts`. (thanks @chirag127)
- **fix(api-manager):** the fallback model picker now preserves combos instead of dropping them when a primary model is unavailable. Regression guard: `tests/unit/api-manager-page-static.test.ts`. (thanks @jmengit)
- **fix(providers):** recoverable Antigravity / Cloudflare `403` responses are now classified as retryable instead of terminal, so a transient WAF block no longer bans the connection. Regression guard: `tests/unit/errorclassifier-antigravity-403.test.ts`. (thanks @developerjillur)
- **fix(mitm):** `sanitizeHeaders` now redacts `Set-Cookie` response headers so upstream session cookies never leak into logs / diagnostics. Regression guard: `tests/unit/mitm-sanitize-headers.test.ts`. (thanks @developerjillur)
Expand Down
16 changes: 16 additions & 0 deletions src/app/api/v1/models/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ import {
import { getVisionCapabilityFields, getCustomVisionCapabilityFields } from "./catalogVision";
import { FALLBACK_ALIAS_TO_PROVIDER, buildAliasMaps } from "./catalogProviderMaps";
import { getModelCatalogAuthRejection, isCodexModelCatalogClient } from "./catalogRequest";
import { isFreeModel, providerHasFreeModels } from "@/shared/utils/freeModels";

// Public API of this module is preserved after the catalog helper extraction:
// `isVisionModelId` (vision-detection-consistency.test.ts) and
Expand Down Expand Up @@ -234,6 +235,19 @@ async function buildUnifiedModelsResponseCore(
aliasOrProviderId;
// Issue #96: Allow blocking specific providers from the models list
const blockedProviders = normalizeBlockedProviderSet(settings.blockedProviders);
// #6316: Opt-in filter — hide paid-only models via `isFreeModel()`. Only applied to
// PROVIDER_MODELS + OpenRouter loops (where pricing metadata / :free suffix / catalog
// membership is available). Modality registries (embedding/image/rerank/audio/
// moderation/video/music) represent local capabilities without pricing, so they are
// exempt. Combos + auto/* + synced/custom/alias-backed rows also stay unfiltered —
// extending v1 scope to those requires per-entry pricing lookup not available today.
const hidePaid = settings.hidePaidModels === true;
const shouldHidePaid = (providerKey: string, modelId: string, pricing?: unknown): boolean => {
if (!hidePaid) return false;
const provider = aliasToProviderId[providerKey] || providerKey;
if (!providerHasFreeModels(provider)) return true;
return !isFreeModel(provider, { id: modelId, pricing: pricing as any });
};

// Get active provider connections
let connections = [];
Expand Down Expand Up @@ -678,6 +692,7 @@ async function buildUnifiedModelsResponseCore(
if (!providerSupportsModel(canonicalProviderId, model.id)) continue;
const aliasId = `${alias}/${model.id}`;
if (getModelIsHidden(canonicalProviderId, model.id)) continue;
if (shouldHidePaid(canonicalProviderId, model.id, (model as any).pricing)) continue;

const visionFields =
getVisionCapabilityFields(aliasId) || getVisionCapabilityFields(model.id);
Expand Down Expand Up @@ -887,6 +902,7 @@ async function buildUnifiedModelsResponseCore(
);
const modelType = getOpenRouterModelType(inputModalities, outputModalities);
const isFree = isOpenRouterFreeModel(openRouterModel);
if (hidePaid && !isFree) continue;
const supportedParameters = Array.isArray(openRouterModel.supported_parameters)
? openRouterModel.supported_parameters
: [];
Expand Down
5 changes: 5 additions & 0 deletions src/lib/db/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,11 @@ export async function getSettings() {
perKeyProxyEnabled: false,
customSystemPromptEnabled: false,
customSystemPrompt: "",
// #6316: Opt-in filter that hides paid-only models from the /v1/models catalog.
// Uses isFreeModel() from src/shared/utils/freeModels.ts to detect free entries
// (`:free` suffix, zero-price pricing, or FREE_MODEL_BUDGETS membership). Default
// false preserves prior behaviour; opt-in only.
hidePaidModels: false,
};
for (const row of rows) {
const record = toRecord(row);
Expand Down
66 changes: 66 additions & 0 deletions tests/unit/models-catalog-hide-paid.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/**
* #6316 — `hidePaidModels` settings toggle filters paid-only models from the
* unified `/v1/models` catalog. Uses `isFreeModel()` from
* `src/shared/utils/freeModels.ts` (`:free` suffix, zero-price pricing, or
* FREE_MODEL_BUDGETS membership). Modality registries are exempt (no pricing).
* Rule #18 regression guard for the toggle.
*/
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";

const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-hide-paid-"));
process.env.DATA_DIR = TEST_DATA_DIR;

const core = await import("../../src/lib/db/core.ts");
const settingsDb = await import("../../src/lib/db/settings.ts");
const providersDb = await import("../../src/lib/db/providers.ts");
const v1ModelsCatalog = await import("../../src/app/api/v1/models/catalog.ts");

async function fetchCatalog(): Promise<Array<{ id: string; type?: string }>> {
const res = await v1ModelsCatalog.getUnifiedModelsResponse(
new Request("http://localhost/api/v1/models", { method: "GET" })
);
assert.equal(res.status, 200);
const body = (await res.json()) as { data: Array<{ id: string; type?: string }> };
return body.data;
}

test.after(() => {
core.resetDbInstance();
try {
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
} catch {
/* best-effort */
}
});

test("hidePaidModels default is false + toggles the catalog filter", async () => {
const defaults = await settingsDb.getSettings();
assert.equal(defaults.hidePaidModels, false);

await providersDb.createProviderConnection({
provider: "openai",
authType: "apikey",
name: "openai-main",
apiKey: "sk-test",
isActive: true,
});

// Chat-only assertion. Modality registries (embedding/image/audio/moderation)
// are exempt from the paid filter by design (no pricing metadata).
const isPaidChat = (m: { id: string; type?: string }) =>
(m.type === undefined || m.type === "chat") &&
(/^(openai|oa)\/gpt-/.test(m.id) || /^(openai|oa)\/o[1-9]/.test(m.id));

await settingsDb.updateSettings({ hidePaidModels: false });
const off = await fetchCatalog();
assert.equal(off.some(isPaidChat), true, "expected paid OpenAI chat models when toggle is off");

await settingsDb.updateSettings({ hidePaidModels: true });
const on = await fetchCatalog();
const leaked = on.filter(isPaidChat).map((m) => m.id);
assert.deepEqual(leaked, [], `paid OpenAI chat aliases leaked: ${leaked.join(", ")}`);
});