Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +1079 to +1081

# ── NanoBanana (Image Generation) ──
# Polling config for async image generation jobs.
# Used by: open-sse/handlers/imageGeneration.ts
Expand Down
1 change: 1 addition & 0 deletions docs/reference/ENVIRONMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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). |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
29 changes: 23 additions & 6 deletions src/app/api/v1/models/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
}
Comment on lines +94 to +103
Comment on lines +94 to +103

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using the delete operator on objects in JavaScript/TypeScript can lead to performance de-optimization in the V8 engine because it alters the object's hidden class (shape) and forces it into dictionary mode. Additionally, mutating the object and casting it back to T is a type lie if T expects name to be present.

Using object destructuring to omit the name property is the modern, idiomatic, and more performant way to achieve this while maintaining better type safety.

function maybeOmitCatalogModelName<T extends Record<string, unknown>>(
  model: T,
  includeNames: boolean
): Omit<T, "name"> | T {
  if (includeNames || !("name" in model)) return model;

  const { name, ...nextModel } = model;
  return nextModel;
}


function intersectStringArrays(arrays: string[][]): string[] {
if (arrays.length === 0 || arrays.some((values) => values.length === 0)) return [];
const [first, ...rest] = arrays;
Expand Down Expand Up @@ -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> {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
17 changes: 15 additions & 2 deletions src/shared/constants/featureFlagDefinitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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) ────────────────
{
Expand Down Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions src/shared/utils/featureFlags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
Comment on lines +75 to +77
30 changes: 25 additions & 5 deletions tests/unit/feature-flags-settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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);
});
});

// ──────────────────────────────────────────────────────
Expand Down Expand Up @@ -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'", () => {
Expand Down Expand Up @@ -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");
}
});
Comment on lines +291 to +299
});
});

Expand Down
32 changes: 32 additions & 0 deletions tests/unit/models-catalog-route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down Expand Up @@ -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");
}
});
Comment on lines +180 to +197

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",
Expand Down