diff --git a/src/app/api/v1/models/catalog.ts b/src/app/api/v1/models/catalog.ts index 229a8d7a2c..9ff3385149 100644 --- a/src/app/api/v1/models/catalog.ts +++ b/src/app/api/v1/models/catalog.ts @@ -1138,20 +1138,31 @@ export async function getUnifiedModelsResponse( const apiKey = extractBearer(request.headers); let finalModels = models; if (apiKey) { - const { isModelAllowedForKey } = await import("@/lib/db/apiKeys"); + const { isModelAllowedForKey, getApiKeyMetadata } = await import("@/lib/db/apiKeys"); - const filtered = []; - for (const m of models) { - // m.id is the full identifier (e.g. openai/gpt-4o), m.root is the raw model string - // check either one as the config could use either patterns - if ( - (await isModelAllowedForKey(apiKey, m.id)) || - (await isModelAllowedForKey(apiKey, m.root)) - ) { - filtered.push(m); + // Quota-exclusive keys (allowedQuotas non-empty): show only the + // quotaShared-* virtual models for the key's assigned pools (Phase B3). + // This takes precedence over the normal allowedModels filter. + const keyMeta = await getApiKeyMetadata(apiKey); + if (keyMeta && keyMeta.allowedQuotas && keyMeta.allowedQuotas.length > 0) { + const { resolveQuotaKeyScope } = await import("@/lib/quota/quotaKey"); + const { filterModelsToQuotaPools } = await import("@/lib/quota/quotaCombos"); + const scope = await resolveQuotaKeyScope(keyMeta.allowedQuotas); + finalModels = filterModelsToQuotaPools(models, scope.poolSlugs); + } else { + const filtered = []; + for (const m of models) { + // m.id is the full identifier (e.g. openai/gpt-4o), m.root is the raw model string + // check either one as the config could use either patterns + if ( + (await isModelAllowedForKey(apiKey, m.id)) || + (await isModelAllowedForKey(apiKey, m.root)) + ) { + filtered.push(m); + } } + finalModels = filtered; } - finalModels = filtered; } const getDefaultContextFallback = (model: any): number | undefined => { diff --git a/src/lib/quota/quotaCombos.ts b/src/lib/quota/quotaCombos.ts index 3bd4004a2d..0e76dcd96a 100644 --- a/src/lib/quota/quotaCombos.ts +++ b/src/lib/quota/quotaCombos.ts @@ -171,6 +171,33 @@ export async function syncQuotaCombos(poolId: string): Promise { } } +// --------------------------------------------------------------------------- +// Catalog filter helper (Phase B3) +// --------------------------------------------------------------------------- + +/** + * Given a flat model list and a set of pool slugs, return only the entries + * whose `id` is a `quotaShared-*` virtual model name AND whose parsed + * `poolSlug` is in `poolSlugs`. + * + * Fail-closed: an empty `poolSlugs` array returns an empty list — a + * quota-exclusive API key with no resolvable pools sees NO models. + * + * Pure function — no I/O, easily unit-tested. + */ +export function filterModelsToQuotaPools( + models: T[], + poolSlugs: string[] +): T[] { + if (poolSlugs.length === 0) return []; + const slugSet = new Set(poolSlugs); + return models.filter((m) => { + if (!isQuotaModelName(m.id)) return false; + const parsed = parseQuotaModelName(m.id); + return parsed !== null && slugSet.has(parsed.poolSlug); + }); +} + /** * Delete ALL `quotaShared-*` combos that belong to the given pool. * diff --git a/tests/unit/quota-catalog-filter.test.ts b/tests/unit/quota-catalog-filter.test.ts new file mode 100644 index 0000000000..488120d2db --- /dev/null +++ b/tests/unit/quota-catalog-filter.test.ts @@ -0,0 +1,56 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { filterModelsToQuotaPools } from "../../src/lib/quota/quotaCombos.js"; + +describe("filterModelsToQuotaPools", () => { + const models = [ + { id: "quotaShared-times-codex/gpt-5.5" }, + { id: "quotaShared-times-codex/gpt-5.4" }, + { id: "cx/gpt-5.5" }, + { id: "quotaShared-other-codex/m" }, + ]; + + it("returns only quotaShared-* entries whose poolSlug is in the given pool slugs", () => { + const result = filterModelsToQuotaPools(models, ["times"]); + assert.deepEqual(result, [ + { id: "quotaShared-times-codex/gpt-5.5" }, + { id: "quotaShared-times-codex/gpt-5.4" }, + ]); + }); + + it("returns empty array when poolSlugs is empty (fail-closed)", () => { + const result = filterModelsToQuotaPools(models, []); + assert.deepEqual(result, []); + }); + + it("returns empty array when no quota models are present in the list", () => { + const plainModels = [{ id: "cx/gpt-5.5" }, { id: "openai/gpt-4o" }]; + const result = filterModelsToQuotaPools(plainModels, ["times"]); + assert.deepEqual(result, []); + }); + + it("matches multiple pool slugs simultaneously", () => { + const result = filterModelsToQuotaPools(models, ["times", "other"]); + assert.deepEqual(result, [ + { id: "quotaShared-times-codex/gpt-5.5" }, + { id: "quotaShared-times-codex/gpt-5.4" }, + { id: "quotaShared-other-codex/m" }, + ]); + }); + + it("preserves extra fields on model entries (generic T extends { id })", () => { + const richModels = [ + { id: "quotaShared-times-cx/gpt-5.5", object: "model", owned_by: "combo" }, + { id: "cx/gpt-5.5", object: "model", owned_by: "cx" }, + ]; + const result = filterModelsToQuotaPools(richModels, ["times"]); + assert.deepEqual(result, [ + { id: "quotaShared-times-cx/gpt-5.5", object: "model", owned_by: "combo" }, + ]); + }); + + it("does not match a model from a different pool when only one slug is provided", () => { + const result = filterModelsToQuotaPools(models, ["other"]); + assert.deepEqual(result, [{ id: "quotaShared-other-codex/m" }]); + }); +});