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)
This commit is contained in:
Chirag Singhal
2026-07-08 04:49:17 +05:30
committed by GitHub
parent e8b5aefb5a
commit d180ac1244
6 changed files with 147 additions and 8 deletions

View File

@@ -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."
}

View File

@@ -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 = <T extends { id?: string }>(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,
},
});
}

View File

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

View File

@@ -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

View File

@@ -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,

View File

@@ -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<Array<{ provider: string; model: string }>> {
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)"
);
});