From d180ac12448098201b701e1af4ba982c643c08ec Mon Sep 17 00:00:00 2001 From: Chirag Singhal <76880977+chirag127@users.noreply.github.com> Date: Wed, 8 Jul 2026 04:49:17 +0530 Subject: [PATCH] fix(dashboard-api): apply hidePaidModels to /api/models + openrouter-catalog + test endpoints (#6328) (#6552) fix(dashboard-api): apply hidePaidModels to /api/models + openrouter-catalog + test endpoints (#6328). Integrated into release/v3.8.47. (thanks @chirag127) --- config/quality/complexity-baseline.json | 7 ++- .../api/models/openrouter-catalog/route.ts | 22 +++++-- src/app/api/models/route.ts | 19 +++++- src/app/api/models/test-all/route.ts | 26 +++++++++ src/app/api/models/test/route.ts | 23 ++++++++ tests/unit/api-models-hide-paid-6328.test.ts | 58 +++++++++++++++++++ 6 files changed, 147 insertions(+), 8 deletions(-) create mode 100644 tests/unit/api-models-hide-paid-6328.test.ts diff --git a/config/quality/complexity-baseline.json b/config/quality/complexity-baseline.json index 7a26300e35..3d9b6e2e63 100644 --- a/config/quality/complexity-baseline.json +++ b/config/quality/complexity-baseline.json @@ -1,6 +1,6 @@ { "_comment": "Catraca de complexidade (check-complexity.mjs, ESLint core rules complexity>=15 e max-lines-per-function>80 sobre src+open-sse+electron+bin via eslint.complexity.config.mjs). Conta total de violacoes; so pode cair. --update ratcheta.", - "count": 2050, + "count": 2052, "_rebaseline_2026_07_07_v3846_release_close": "2035->2050 (+15). v3.8.46 release close (generate-release Phase 0 pre-flight): drift herdado do merge burst do ciclo (39 commits do dia + campanha /review-*). Trust-but-verify: os fixes de base-red do captain (agentSkills path.resolve #6366, catalogo cache #6408, tipagem de teste no-explicit-any, MitmProxyTab suppression) sao complexity-net-zero — check:complexity mede 2050 identico com e sem os fixes (a catraca NAO roda no fast-path PR->release, entao o ramo acumulou sem rebaselinar). Tighten via --update next cycle.", "_rebaseline_2026_07_04_v3844_release_close": "2026->2028 (+2). v3.8.44 release close (generate-release Phase 0/1): drift residual do fim do ciclo medido no tip pos-#6155 (merge burst final: #6155 cooling-panel + #6104 Kenari + #6139/#6128 provider-limits). Trust-but-verify: os 2 fixes de codigo do release-captain (model.ts alias boundary, auggie.ts stdin error handlers) adicionam 0 violacoes NOVAS — eslint.complexity direto nos 2 arquivos flagra apenas funcoes que ja estouravam o limite antes (runStreaming/start ja >80 linhas; resolveModelByProviderInference/getModelInfoCore pre-existentes de #5918), e resolveProviderAlias segue abaixo de 15. Logo o +2 e drift herdado do burst. Tighten via --update next cycle.", "_rebaseline_2026_07_03_v3844_ipfilter_release_green": "2015->2026 (+11). v3.8.44 cycle drift measured on release tip 32e4c906e during the #6131/#5975 release-green rebaseline. Inherited from the merge burst (Quality Ratchet does not run on PR->release fast-gates). route-edge-coverage +7 is my #5975 test comment; the rest is parallel-session drift. Tighten via --update next cycle.", @@ -35,5 +35,6 @@ "_rebaseline_2026_06_10": "Re-baseline consciente: 1739 foi medido na branch das Fases 0-6 (base ~v3.8.17); a v3.8.18 publicada ja carrega 1746 (provado: o commit-base 5f2722bd6, anterior a qualquer commit do ciclo v3.8.19, mede 1746 — funcoes complexas dos reworks RequestLoggerV2/stream/combo). Mesma familia dos re-baselines de eslintWarnings/file-size. Reducao = Fase 6A (2026-06-16).", "_rebaseline_2026_06_13_6a11": "Re-baseline consciente Task 6A.11: escopo ampliado para src+open-sse+electron+bin (electron/bin contribuem 0 violacoes novas — todos os 4 arquivos .ts em bin/ estao abaixo dos thresholds). Drift 1746→1794 pre-existente de features mergeadas nos ciclos v3.8.22/v3.8.23 (nao causado por esta task). Congelado no valor real medido para destrancar o gate.", "_rebaseline_2026_06_26_v3837_release": "1950->1963 (+13). v3.8.37 cycle drift surfaced by the release-green pre-flight (the Quality Ratchet does NOT run on PR->release fast-gates, so warnings/complexity accrued unmeasured across this cycle's 76 commits — provider adds DGrid/Pioneer/xAI, headroom proxy lifecycle #4649, ~50 SSE/translator fixes, Engine Combos #5062). Trust-but-verify: this release-finalize working tree touches ONLY CHANGELOG.md, docs/i18n/*/CHANGELOG.md mirrors, and these baselines — 0 production-code change, so all drift is inherited cycle drift (`any` warn-allowed in open-sse/ + tests/). Tighten via --require-tighten next cycle.", - "_rebaseline_2026_07_06_v3845_release_close": "2028->2035 (+7). v3.8.45 release close (generate-release Phase 0): drift herdado do merge burst final do ciclo (#6216 streaming fixes, #6251/#6253 dashboard UX, #6292 zero-width, fixes do pre-flight ce897453 — todos test/config/workflow-neutros em complexidade nova, verificado pelo validador no tip 5ecca12aa5). Tighten via --update next cycle." -} + "_rebaseline_2026_07_06_v3845_release_close": "2028->2035 (+7). v3.8.45 release close (generate-release Phase 0): drift herdado do merge burst final do ciclo (#6216 streaming fixes, #6251/#6253 dashboard UX, #6292 zero-width, fixes do pre-flight ce897453 — todos test/config/workflow-neutros em complexidade nova, verificado pelo validador no tip 5ecca12aa5). Tighten via --update next cycle.", + "_rebaseline_2026_07_07_6552_chirag_api_models_filter": "2050->2052 (+2). PR #6552 (@chirag127, #6328): hidePaidModels filter across the 4 dashboard /api/models endpoints adds 2 functions over the complexity threshold. Owner-approved rebaseline (contributor own-growth). Tighten via --update next cycle." +} \ No newline at end of file diff --git a/src/app/api/models/openrouter-catalog/route.ts b/src/app/api/models/openrouter-catalog/route.ts index dee7e8f13e..dcb8656cd1 100644 --- a/src/app/api/models/openrouter-catalog/route.ts +++ b/src/app/api/models/openrouter-catalog/route.ts @@ -9,6 +9,8 @@ import { NextRequest, NextResponse } from "next/server"; import { isAuthenticated } from "@/shared/utils/apiAuth"; import { getOpenRouterCatalog, refreshOpenRouterCatalog } from "@/lib/catalog/openrouterCatalog"; +import { getSettings } from "@/lib/db/settings"; +import { isFreeModel } from "@/shared/utils/freeModels"; export async function GET(req: NextRequest) { // Require authentication (dashboard/API key) @@ -19,30 +21,42 @@ export async function GET(req: NextRequest) { ); } + // #6328 (follow-up to #6495): REMOVE — not just hide — paid models from the + // OpenRouter catalog echo when hidePaidModels is on. Fail open on settings read. + let hidePaid = false; + try { + const settings = await getSettings(); + hidePaid = settings?.hidePaidModels === true; + } catch {} + const applyFilter = (data: T[]): T[] => + hidePaid ? data.filter((m) => isFreeModel("or", m as { id: string; pricing?: unknown })) : data; + const forceRefresh = req.nextUrl.searchParams.get("refresh") === "true"; if (forceRefresh) { const result = await refreshOpenRouterCatalog(); + const data = applyFilter(result.data); return NextResponse.json({ object: "list", - data: result.data, + data, meta: { source: result.ok ? "fresh" : "error", - count: result.data.length, + count: data.length, error: result.error ?? undefined, }, }); } const result = await getOpenRouterCatalog(); + const data = applyFilter(result.data); return NextResponse.json({ object: "list", - data: result.data, + data, meta: { source: result.fromCache ? (result.stale ? "stale-cache" : "cache") : "fresh", cachedAt: result.cachedAt ?? undefined, stale: result.stale, - count: result.data.length, + count: data.length, }, }); } diff --git a/src/app/api/models/route.ts b/src/app/api/models/route.ts index 92cf976126..9c3d078229 100644 --- a/src/app/api/models/route.ts +++ b/src/app/api/models/route.ts @@ -4,6 +4,8 @@ import { AI_MODELS, PROVIDER_ID_TO_ALIAS } from "@/shared/constants/models"; import { updateModelAliasSchema } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; import { hasEligibleConnectionForModel } from "@/domain/connectionModelRules"; +import { getSettings } from "@/lib/db/settings"; +import { isFreeModel, providerHasFreeModels } from "@/shared/utils/freeModels"; // GET /api/models - Get models with aliases (only from active providers by default) export async function GET(request: Request) { @@ -85,7 +87,22 @@ export async function GET(request: Request) { }; }).filter((m: any) => showAll || m.available); - return NextResponse.json({ models }); + // #6328 (follow-up to #6495): REMOVE — not just hide — paid models from the + // dashboard model picker when the operator opts into hidePaidModels. Mirrors + // the `shouldHidePaid` guard in `src/app/api/v1/models/catalog.ts` (public + // catalog). Settings read fails open: on error we preserve pre-patch behavior. + let hidePaid = false; + try { + const settings = await getSettings(); + hidePaid = settings?.hidePaidModels === true; + } catch {} + const filtered = hidePaid + ? models.filter( + (m: { provider: string; model: string }) => providerHasFreeModels(m.provider) && isFreeModel(m.provider, { id: m.model }) + ) + : models; + + return NextResponse.json({ models: filtered }); } catch (error) { console.log("Error fetching models:", error); return NextResponse.json({ error: "Failed to fetch models" }, { status: 500 }); diff --git a/src/app/api/models/test-all/route.ts b/src/app/api/models/test-all/route.ts index bd0913df41..67a410145d 100644 --- a/src/app/api/models/test-all/route.ts +++ b/src/app/api/models/test-all/route.ts @@ -16,6 +16,8 @@ import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; import { runSingleModelTest } from "@/lib/api/modelTestRunner"; import { setModelIsHidden } from "@/lib/localDb"; import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; +import { getSettings } from "@/lib/db/settings"; +import { isFreeModel, providerHasFreeModels } from "@/shared/utils/freeModels"; import * as log from "@/sse/utils/logger"; const PER_MODEL_TIMEOUT_MS = 20_000; @@ -80,6 +82,16 @@ export async function POST(request: Request) { } const { providerId, modelIds, connectionId, respectRateLimit, autoHideFailed } = validation.data; + // #6328 (follow-up to #6495): REMOVE — not just hide — paid Test-all dispatches + // when hidePaidModels is on. Paid ids are skipped inside the loop with a + // "Skipped" entry; the skip does NOT touch the consecutive-rate-limit halt + // counter (skips are not upstream failures). Fail open on settings read. + let hidePaid = false; + try { + const settings = await getSettings(); + hidePaid = settings?.hidePaidModels === true; + } catch {} + log.info( "MODEL_TEST_ALL", `Starting batch test for ${modelIds.length} model(s) on provider ${providerId}`, @@ -98,6 +110,20 @@ export async function POST(request: Request) { let stopReason: "consecutive_rate_limits" | undefined; for (const modelId of modelIds) { + // #6328: skip paid ids without dispatching; do not increment + // consecutiveRateLimits — skip is not a rate-limited failure. + if ( + hidePaid && + !(providerHasFreeModels(providerId) && isFreeModel(providerId, { id: modelId })) + ) { + results[modelId] = { + status: "error", + latencyMs: 0, + error: "Skipped: paid model with hidePaidModels enabled", + }; + continue; + } + let entry: BatchTestResultEntry; try { // `runSingleModelTest` only engages the Bottleneck rate limiter when diff --git a/src/app/api/models/test/route.ts b/src/app/api/models/test/route.ts index 8044d51d20..c8974707a1 100644 --- a/src/app/api/models/test/route.ts +++ b/src/app/api/models/test/route.ts @@ -3,6 +3,8 @@ import { z } from "zod"; import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; import { runSingleModelTest } from "@/lib/api/modelTestRunner"; import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts"; +import { getSettings } from "@/lib/db/settings"; +import { isFreeModel, providerHasFreeModels } from "@/shared/utils/freeModels"; const testModelSchema = z.object({ providerId: z.string().min(1), @@ -40,6 +42,27 @@ export async function POST(request: Request) { } const { providerId, modelId, connectionId } = validation.data; + // #6328 (follow-up to #6495): REMOVE — not just hide — paid Test dispatches + // when hidePaidModels is on. 403 (distinct from validation-400) so callers + // can tell policy-blocked apart from bad-request. Fail open on settings read. + let hidePaid = false; + try { + const settings = await getSettings(); + hidePaid = settings?.hidePaidModels === true; + } catch {} + if ( + hidePaid && + !(providerHasFreeModels(providerId) && isFreeModel(providerId, { id: modelId })) + ) { + return NextResponse.json( + { + status: "error", + error: "Paid model blocked while hidePaidModels is enabled", + }, + { status: 403 } + ); + } + const result = await runSingleModelTest({ providerId, modelId, diff --git a/tests/unit/api-models-hide-paid-6328.test.ts b/tests/unit/api-models-hide-paid-6328.test.ts new file mode 100644 index 0000000000..a0e060843c --- /dev/null +++ b/tests/unit/api-models-hide-paid-6328.test.ts @@ -0,0 +1,58 @@ +/** + * #6328 (follow-up to #6495) — the dashboard `/api/models` endpoint must also + * REMOVE paid models when `hidePaidModels` is on (the public `/v1/models` + * catalog already did via #6495). `openai` has no curated free roster, so its + * chat models are paid-tier and must disappear when the toggle is on. + * Rule #18 regression guard for the added filter in src/app/api/models/route.ts. + */ +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-api-models-hidepaid-")); +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 modelsRoute = await import("../../src/app/api/models/route.ts"); + +async function fetchModels(): Promise> { + const res = await modelsRoute.GET(new Request("http://localhost/api/models?all=true")); + const body = (await res.json()) as { models: Array<{ provider: string; model: string }> }; + return body.models; +} + +test.after(() => { + core.resetDbInstance(); + try { + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + } catch { + /* best-effort */ + } +}); + +test("#6328 /api/models removes paid models when hidePaidModels is on", async () => { + await providersDb.createProviderConnection({ + provider: "openai", + authType: "apikey", + name: "openai-main", + apiKey: "sk-test", + isActive: true, + }); + + const hasPaidOpenAi = (list: Array<{ provider: string; model: string }>) => + list.some((m) => m.provider === "openai" && /^gpt-/.test(m.model)); + + await settingsDb.updateSettings({ hidePaidModels: false }); + assert.equal(hasPaidOpenAi(await fetchModels()), true, "paid OpenAI models visible when toggle is off"); + + await settingsDb.updateSettings({ hidePaidModels: true }); + assert.equal( + hasPaidOpenAi(await fetchModels()), + false, + "paid OpenAI models must be removed when hidePaidModels is on (#6328)" + ); +});