From 500197846d960142c783b1fdf98ba7342d792111 Mon Sep 17 00:00:00 2001 From: Randi <55005611+rdself@users.noreply.github.com> Date: Mon, 8 Jun 2026 22:58:28 -0400 Subject: [PATCH] Add model catalog name feature flag (#3464) Integrated into release/v3.8.17 --- .env.example | 8 +++++ docs/reference/ENVIRONMENT.md | 1 + .../settings/components/FeatureFlagsGrid.tsx | 12 +++---- src/app/api/v1/models/catalog.ts | 29 +++++++++++++---- .../constants/featureFlagDefinitions.ts | 17 ++++++++-- src/shared/utils/featureFlags.ts | 4 +++ tests/unit/feature-flags-settings.test.ts | 30 ++++++++++++++--- tests/unit/models-catalog-route.test.ts | 32 +++++++++++++++++++ 8 files changed, 114 insertions(+), 19 deletions(-) diff --git a/.env.example b/.env.example index f77e0f2f37..8090071870 100644 --- a/.env.example +++ b/.env.example @@ -1072,6 +1072,14 @@ APP_LOG_TO_FILE=true # Default: 86400000 (24 hours) # OPENROUTER_CATALOG_TTL_MS=86400000 +# ── Model catalog response shape ── +# Include display-friendly name fields in /v1/models responses. +# Disable for clients that expect model IDs only. +# Defined in: src/shared/constants/featureFlagDefinitions.ts +# Used by: src/app/api/v1/models/catalog.ts +# Default: true +# MODEL_CATALOG_INCLUDE_NAMES=true + # ── NanoBanana (Image Generation) ── # Polling config for async image generation jobs. # Used by: open-sse/handlers/imageGeneration.ts diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index e7403eb26f..14e9542c54 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -695,6 +695,7 @@ Automatic model pricing data synchronization from external sources. | Variable | Default | Source File | Description | | ----------------------------------------- | ------------------ | --------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | | `OPENROUTER_CATALOG_TTL_MS` | `86400000` (24h) | `src/lib/catalog/openrouterCatalog.ts` | OpenRouter model catalog cache TTL. | +| `MODEL_CATALOG_INCLUDE_NAMES` | `true` | `src/shared/constants/featureFlagDefinitions.ts` | Include display-friendly `name` fields in `/v1/models` responses. Disable for clients that expect IDs only. | | `NANOBANANA_POLL_TIMEOUT_MS` | `120000` | `open-sse/handlers/imageGeneration.ts` | Max wait for NanoBanana image generation jobs. | | `NANOBANANA_POLL_INTERVAL_MS` | `2500` | `open-sse/handlers/imageGeneration.ts` | NanoBanana job polling frequency. | | `AWS_REGION` | _(unset)_ | `src/lib/providers/validation.ts`, `open-sse/handlers/audioSpeech.ts` | Region used to construct AWS Bedrock endpoints (Kiro, audio). | diff --git a/src/app/(dashboard)/dashboard/settings/components/FeatureFlagsGrid.tsx b/src/app/(dashboard)/dashboard/settings/components/FeatureFlagsGrid.tsx index 0eb0217fc4..b3deabb0fd 100644 --- a/src/app/(dashboard)/dashboard/settings/components/FeatureFlagsGrid.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/FeatureFlagsGrid.tsx @@ -28,12 +28,12 @@ interface Summary { const CATEGORIES = [ { value: "all", label: "All" }, - { value: "security", label: "Security (6)" }, - { value: "network", label: "Network (5)" }, - { value: "policies", label: "Policies (3)" }, - { value: "runtime", label: "Runtime (7)" }, - { value: "cli", label: "CLI (3)" }, - { value: "health", label: "Health (3)" }, + { value: "security", label: "Security" }, + { value: "network", label: "Network" }, + { value: "policies", label: "Policies" }, + { value: "runtime", label: "Runtime" }, + { value: "cli", label: "CLI" }, + { value: "health", label: "Health" }, // Synthetic "category" that filters by requiresRestart=true regardless of // real category — surfaces flags that need a process restart to take effect. { value: "__restart", label: "Requires Restart" }, diff --git a/src/app/api/v1/models/catalog.ts b/src/app/api/v1/models/catalog.ts index 541286c94f..5f7342141a 100644 --- a/src/app/api/v1/models/catalog.ts +++ b/src/app/api/v1/models/catalog.ts @@ -31,6 +31,7 @@ import { import { getSyncedCapability } from "@/lib/modelsDevSync"; import { getModelSpec } from "@/shared/constants/modelSpecs"; import { isAuthRequired, isDashboardSessionAuthenticated } from "@/shared/utils/apiAuth"; +import { isModelCatalogNamesEnabled } from "@/shared/utils/featureFlags"; import { parseModel } from "@omniroute/open-sse/services/model"; import { getTokenLimit } from "@omniroute/open-sse/services/contextManager"; import { extractApiKey } from "@/sse/services/auth"; @@ -90,6 +91,17 @@ function parseJsonStringArray(value: unknown): string[] { } } +function maybeOmitCatalogModelName>( + model: T, + includeNames: boolean +): T | Omit { + if (includeNames || !Object.prototype.hasOwnProperty.call(model, "name")) return model; + + const { name: omittedName, ...nextModel } = model; + void omittedName; + return nextModel; +} + function intersectStringArrays(arrays: string[][]): string[] { if (arrays.length === 0 || arrays.some((values) => values.length === 0)) return []; const [first, ...rest] = arrays; @@ -185,9 +197,7 @@ function getOpenRouterDisplayName(model: { pricing?: { prompt?: string; completion?: string }; }) { const name = model.name || model.id || "OpenRouter model"; - return isOpenRouterFreeModel(model) && !/\bgr[aá]tis\b/i.test(name) - ? `${name} (Grátis)` - : name; + return isOpenRouterFreeModel(model) && !/\bgr[aá]tis\b/i.test(name) ? `${name} (Grátis)` : name; } async function validateCatalogApiKey(apiKey: string): Promise { @@ -496,7 +506,10 @@ export async function getUnifiedModelsResponse( const specContext = isPositiveFiniteNumber(spec?.contextWindow) ? spec.contextWindow : undefined; - const contextLength = syncedContext ?? registryContext ?? specContext ?? + const contextLength = + syncedContext ?? + registryContext ?? + specContext ?? (getTokenLimit(providerId, modelId) || undefined); const maxInputTokens = isPositiveFiniteNumber(synced?.limit_input) ? synced.limit_input @@ -1342,13 +1355,17 @@ export async function getUnifiedModelsResponse( return modelId ? getTokenLimit(canonicalId, modelId) : getTokenLimit(canonicalId); }; + const includeModelNames = isModelCatalogNamesEnabled(); const enrichedModels = finalModels.map((model) => { - if (model.owned_by === "combo") return model; + if (model.owned_by === "combo") { + return maybeOmitCatalogModelName(model, includeModelNames); + } const enriched = enrichCatalogModelEntry(model); const fallbackContextLength = getDefaultContextFallback(enriched); - return fallbackContextLength + const listedModel = fallbackContextLength ? { ...enriched, context_length: fallbackContextLength } : enriched; + return maybeOmitCatalogModelName(listedModel, includeModelNames); }); return Response.json( diff --git a/src/shared/constants/featureFlagDefinitions.ts b/src/shared/constants/featureFlagDefinitions.ts index 49be1e6ef6..b404d7831c 100644 --- a/src/shared/constants/featureFlagDefinitions.ts +++ b/src/shared/constants/featureFlagDefinitions.ts @@ -199,7 +199,7 @@ export const FEATURE_FLAG_DEFINITIONS: FeatureFlagDefinition[] = [ warningLevel: "info", }, - // ──────────────── Runtime (7) ──────────────── + // ──────────────── Runtime (8) ──────────────── { key: "OMNIROUTE_MCP_ENFORCE_SCOPES", label: "MCP Enforce Scopes", @@ -279,6 +279,18 @@ export const FEATURE_FLAG_DEFINITIONS: FeatureFlagDefinition[] = [ requiresRestart: false, warningLevel: "info", }, + { + key: "MODEL_CATALOG_INCLUDE_NAMES", + label: "Model Catalog Names", + description: + "Include display-friendly name fields in /v1/models responses. Disable for clients that expect model IDs only.", + descriptionI18nKey: "settings.featureFlags.modelCatalogIncludeNames", + category: "runtime", + defaultValue: "true", + type: "boolean", + requiresRestart: false, + warningLevel: "info", + }, // ──────────────── CLI (3) ──────────────── { @@ -306,7 +318,8 @@ export const FEATURE_FLAG_DEFINITIONS: FeatureFlagDefinition[] = [ { key: "PRICING_SYNC_ENABLED", label: "Pricing Sync", - description: "Enable automatic pricing data synchronization (requires the PRICING_SYNC_ENABLED environment variable to be set to true)", + description: + "Enable automatic pricing data synchronization (requires the PRICING_SYNC_ENABLED environment variable to be set to true)", descriptionI18nKey: "featureFlagPricingSyncEnabledDescription", category: "cli", defaultValue: "false", diff --git a/src/shared/utils/featureFlags.ts b/src/shared/utils/featureFlags.ts index e3c5829255..65d90a3a2a 100644 --- a/src/shared/utils/featureFlags.ts +++ b/src/shared/utils/featureFlags.ts @@ -71,3 +71,7 @@ export function isRequireApiKeyEnabled(): boolean { export function isCcCompatibleProviderEnabled(): boolean { return isFeatureFlagEnabled("ENABLE_CC_COMPATIBLE_PROVIDER"); } + +export function isModelCatalogNamesEnabled(): boolean { + return isFeatureFlagEnabled("MODEL_CATALOG_INCLUDE_NAMES"); +} diff --git a/tests/unit/feature-flags-settings.test.ts b/tests/unit/feature-flags-settings.test.ts index cc4cc4f563..9a3ccc424a 100644 --- a/tests/unit/feature-flags-settings.test.ts +++ b/tests/unit/feature-flags-settings.test.ts @@ -25,19 +25,20 @@ const { resolveAllFeatureFlags, isRequireApiKeyEnabled, isCcCompatibleProviderEnabled, + isModelCatalogNamesEnabled, } = await import("../../src/shared/utils/featureFlags.ts"); // ────────────────────────────────────────────────────── // Test group 1 — Flag definitions registry // ────────────────────────────────────────────────────── describe("featureFlagDefinitions", () => { - it("has exactly 29 flag definitions", () => { - assert.strictEqual(FEATURE_FLAG_DEFINITIONS.length, 29); + it("has exactly 30 flag definitions", () => { + assert.strictEqual(FEATURE_FLAG_DEFINITIONS.length, 30); }); it("has unique keys for all flags", () => { const keys = FEATURE_FLAG_DEFINITIONS.map((d) => d.key); - assert.strictEqual(new Set(keys).size, 29); + assert.strictEqual(new Set(keys).size, 30); }); it("has valid categories for all flags", () => { @@ -85,6 +86,15 @@ describe("featureFlagDefinitions", () => { } } }); + + it("defines model catalog names as a runtime boolean flag enabled by default", () => { + const def = FEATURE_FLAG_DEFINITIONS.find((d) => d.key === "MODEL_CATALOG_INCLUDE_NAMES"); + assert.ok(def, "MODEL_CATALOG_INCLUDE_NAMES should exist"); + assert.strictEqual(def.category, "runtime"); + assert.strictEqual(def.type, "boolean"); + assert.strictEqual(def.defaultValue, "true"); + assert.strictEqual(def.requiresRestart, false); + }); }); // ────────────────────────────────────────────────────── @@ -222,9 +232,9 @@ describe("resolveFeatureFlag", () => { }); describe("resolveAllFeatureFlags", () => { - it("returns all 29 flags", () => { + it("returns all 30 flags", () => { const all = resolveAllFeatureFlags(); - assert.strictEqual(all.length, 29); + assert.strictEqual(all.length, 30); }); it("marks DB-overridden flags with source 'db'", () => { @@ -277,6 +287,16 @@ describe("resolveFeatureFlag", () => { const result = isCcCompatibleProviderEnabled(); assert.strictEqual(typeof result, "boolean"); }); + + it("isModelCatalogNamesEnabled defaults on and follows overrides", () => { + assert.strictEqual(isModelCatalogNamesEnabled(), true); + try { + setFeatureFlagOverride("MODEL_CATALOG_INCLUDE_NAMES", "false"); + assert.strictEqual(isModelCatalogNamesEnabled(), false); + } finally { + removeFeatureFlagOverride("MODEL_CATALOG_INCLUDE_NAMES"); + } + }); }); }); diff --git a/tests/unit/models-catalog-route.test.ts b/tests/unit/models-catalog-route.test.ts index a1dce71407..14d88393ca 100644 --- a/tests/unit/models-catalog-route.test.ts +++ b/tests/unit/models-catalog-route.test.ts @@ -14,6 +14,7 @@ const modelsDb = await import("../../src/lib/db/models.ts"); const combosDb = await import("../../src/lib/db/combos.ts"); const settingsDb = await import("../../src/lib/db/settings.ts"); const apiKeysDb = await import("../../src/lib/db/apiKeys.ts"); +const featureFlagsDb = await import("../../src/lib/db/featureFlags.ts"); const modelsDevSync = await import("../../src/lib/modelsDevSync.ts"); const v1ModelsCatalog = await import("../../src/app/api/v1/models/catalog.ts"); @@ -164,6 +165,37 @@ test("v1 models catalog accepts API keys embedded in vscode path aliases when au assert.ok(body.data.length > 0); }); +test("v1 models catalog includes display names by default", async () => { + const response = await v1ModelsCatalog.getUnifiedModelsResponse( + new Request("http://localhost/api/v1/models") + ); + const body = (await response.json()) as any; + const model = body.data.find((item) => item.id === "tllm/claude_sonnet_4"); + + assert.equal(response.status, 200); + assert.ok(model); + assert.equal(model.name, "Claude Sonnet 4 (The Old LLM 🆓)"); +}); + +test("v1 models catalog omits display names when the feature flag is disabled", async () => { + featureFlagsDb.setFeatureFlagOverride("MODEL_CATALOG_INCLUDE_NAMES", "false"); + + try { + const response = await v1ModelsCatalog.getUnifiedModelsResponse( + new Request("http://localhost/api/v1/models") + ); + const body = (await response.json()) as any; + const model = body.data.find((item) => item.id === "tllm/claude_sonnet_4"); + + assert.equal(response.status, 200); + assert.ok(model); + assert.equal("name" in model, false); + assert.equal(model.root, "claude_sonnet_4"); + } finally { + featureFlagsDb.removeFeatureFlagOverride("MODEL_CATALOG_INCLUDE_NAMES"); + } +}); + test("v1 models catalog hides models excluded by every active connection while keeping models served by at least one account", async () => { const first = await seedConnection("openai", { name: "openai-first",