From c8f26c99e8bdbc2404ce19376d25c778bd14b245 Mon Sep 17 00:00:00 2001 From: "R.D." Date: Mon, 8 Jun 2026 22:18:23 -0400 Subject: [PATCH 1/2] Add model catalog name feature flag --- .env.example | 7 +++++ docs/reference/ENVIRONMENT.md | 1 + .../settings/components/FeatureFlagsGrid.tsx | 2 +- src/app/api/v1/models/catalog.ts | 29 +++++++++++++++---- .../constants/featureFlagDefinitions.ts | 17 +++++++++-- src/shared/utils/featureFlags.ts | 12 ++++++++ tests/unit/feature-flags-settings.test.ts | 26 +++++++++++++---- tests/unit/models-catalog-route.test.ts | 28 ++++++++++++++++++ 8 files changed, 108 insertions(+), 14 deletions(-) diff --git a/.env.example b/.env.example index ccabb13ebcb0..ff49e52d5894 100644 --- a/.env.example +++ b/.env.example @@ -1072,6 +1072,13 @@ 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. +# 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 7fae6bd3a1a4..490d71e85d95 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/app/api/v1/models/catalog.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 0eb0217fc455..d21ce97f2177 100644 --- a/src/app/(dashboard)/dashboard/settings/components/FeatureFlagsGrid.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/FeatureFlagsGrid.tsx @@ -31,7 +31,7 @@ const CATEGORIES = [ { value: "security", label: "Security (6)" }, { value: "network", label: "Network (5)" }, { value: "policies", label: "Policies (3)" }, - { value: "runtime", label: "Runtime (7)" }, + { value: "runtime", label: "Runtime (8)" }, { value: "cli", label: "CLI (3)" }, { value: "health", label: "Health (3)" }, // Synthetic "category" that filters by requiresRestart=true regardless of diff --git a/src/app/api/v1/models/catalog.ts b/src/app/api/v1/models/catalog.ts index 17596a0e7c96..06aa13b74012 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 { + if (includeNames || !Object.prototype.hasOwnProperty.call(model, "name")) return model; + + const nextModel = { ...model }; + delete nextModel.name; + return nextModel as T; +} + 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 @@ -1331,13 +1344,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 49be1e6ef606..b404d7831cea 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 e3c582925540..5ec10555f1de 100644 --- a/src/shared/utils/featureFlags.ts +++ b/src/shared/utils/featureFlags.ts @@ -71,3 +71,15 @@ export function isRequireApiKeyEnabled(): boolean { export function isCcCompatibleProviderEnabled(): boolean { return isFeatureFlagEnabled("ENABLE_CC_COMPATIBLE_PROVIDER"); } + +export function isModelCatalogNamesEnabled(): boolean { + try { + return isFeatureFlagEnabled("MODEL_CATALOG_INCLUDE_NAMES"); + } catch (error) { + console.error( + "[featureFlags] Failed to resolve MODEL_CATALOG_INCLUDE_NAMES, defaulting to enabled:", + error instanceof Error ? error.message : error + ); + return true; + } +} diff --git a/tests/unit/feature-flags-settings.test.ts b/tests/unit/feature-flags-settings.test.ts index cc4cc4f563ee..6be1c8498459 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,12 @@ describe("resolveFeatureFlag", () => { const result = isCcCompatibleProviderEnabled(); assert.strictEqual(typeof result, "boolean"); }); + + it("isModelCatalogNamesEnabled defaults on and follows overrides", () => { + assert.strictEqual(isModelCatalogNamesEnabled(), true); + setFeatureFlagOverride("MODEL_CATALOG_INCLUDE_NAMES", "false"); + assert.strictEqual(isModelCatalogNamesEnabled(), false); + }); }); }); diff --git a/tests/unit/models-catalog-route.test.ts b/tests/unit/models-catalog-route.test.ts index a1dce714074d..3484ed549661 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,33 @@ 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"); + + 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"); +}); + 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", From ebfc65257fbe07b5d2a4a8fc907d74aaeccc6d81 Mon Sep 17 00:00:00 2001 From: "R.D." Date: Mon, 8 Jun 2026 22:39:20 -0400 Subject: [PATCH 2/2] Address model catalog flag review feedback --- .env.example | 1 + docs/reference/ENVIRONMENT.md | 2 +- .../settings/components/FeatureFlagsGrid.tsx | 12 +++++----- src/app/api/v1/models/catalog.ts | 8 +++---- src/shared/utils/featureFlags.ts | 10 +-------- tests/unit/feature-flags-settings.test.ts | 8 +++++-- tests/unit/models-catalog-route.test.ts | 22 +++++++++++-------- 7 files changed, 32 insertions(+), 31 deletions(-) diff --git a/.env.example b/.env.example index ff49e52d5894..2b5c482b222b 100644 --- a/.env.example +++ b/.env.example @@ -1075,6 +1075,7 @@ APP_LOG_TO_FILE=true # ── 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 diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index 490d71e85d95..faacf4572d66 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -695,7 +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/app/api/v1/models/catalog.ts` | Include display-friendly `name` fields in `/v1/models` responses. Disable for clients that expect IDs only. | +| `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 d21ce97f2177..b3deabb0fd19 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 (8)" }, - { 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 06aa13b74012..0aa5c018a165 100644 --- a/src/app/api/v1/models/catalog.ts +++ b/src/app/api/v1/models/catalog.ts @@ -94,12 +94,12 @@ function parseJsonStringArray(value: unknown): string[] { function maybeOmitCatalogModelName>( model: T, includeNames: boolean -): T { +): T | Omit { if (includeNames || !Object.prototype.hasOwnProperty.call(model, "name")) return model; - const nextModel = { ...model }; - delete nextModel.name; - return nextModel as T; + const { name: omittedName, ...nextModel } = model; + void omittedName; + return nextModel; } function intersectStringArrays(arrays: string[][]): string[] { diff --git a/src/shared/utils/featureFlags.ts b/src/shared/utils/featureFlags.ts index 5ec10555f1de..65d90a3a2a4c 100644 --- a/src/shared/utils/featureFlags.ts +++ b/src/shared/utils/featureFlags.ts @@ -73,13 +73,5 @@ export function isCcCompatibleProviderEnabled(): boolean { } export function isModelCatalogNamesEnabled(): boolean { - try { - return isFeatureFlagEnabled("MODEL_CATALOG_INCLUDE_NAMES"); - } catch (error) { - console.error( - "[featureFlags] Failed to resolve MODEL_CATALOG_INCLUDE_NAMES, defaulting to enabled:", - error instanceof Error ? error.message : error - ); - return true; - } + 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 6be1c8498459..9a3ccc424a5e 100644 --- a/tests/unit/feature-flags-settings.test.ts +++ b/tests/unit/feature-flags-settings.test.ts @@ -290,8 +290,12 @@ describe("resolveFeatureFlag", () => { it("isModelCatalogNamesEnabled defaults on and follows overrides", () => { assert.strictEqual(isModelCatalogNamesEnabled(), true); - setFeatureFlagOverride("MODEL_CATALOG_INCLUDE_NAMES", "false"); - assert.strictEqual(isModelCatalogNamesEnabled(), false); + 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 3484ed549661..14d88393ca31 100644 --- a/tests/unit/models-catalog-route.test.ts +++ b/tests/unit/models-catalog-route.test.ts @@ -180,16 +180,20 @@ test("v1 models catalog includes display names by default", async () => { test("v1 models catalog omits display names when the feature flag is disabled", async () => { featureFlagsDb.setFeatureFlagOverride("MODEL_CATALOG_INCLUDE_NAMES", "false"); - 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"); + 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"); + 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 () => {