mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
Add model catalog name feature flag (#3464)
Integrated into release/v3.8.17
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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). |
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user