mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
feat(api): add hidePaidModels setting to filter paid-only models from /v1/models catalog (#6495)
feat(api): add hidePaidModels setting (net +1/-0, test OK). Integrated into release/v3.8.46.
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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 = [];
|
||||
@@ -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);
|
||||
@@ -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
|
||||
: [];
|
||||
|
||||
@@ -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);
|
||||
|
||||
66
tests/unit/models-catalog-hide-paid.test.ts
Normal file
66
tests/unit/models-catalog-hide-paid.test.ts
Normal file
@@ -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(", ")}`);
|
||||
});
|
||||
Reference in New Issue
Block a user