Add model catalog name feature flag (#3464)

Integrated into release/v3.8.17
This commit is contained in:
Randi
2026-06-08 22:58:28 -04:00
committed by GitHub
parent ee0fdcb6c8
commit 500197846d
8 changed files with 114 additions and 19 deletions

View File

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

View File

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

View File

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

View File

@@ -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<T extends Record<string, unknown>>(
model: T,
includeNames: boolean
): T | Omit<T, "name"> {
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<boolean> {
@@ -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(

View File

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

View File

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

View File

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

View File

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