mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-04 14:22:09 +03:00
feat(quota): /v1/models lists only quotaShared-* models for quota-exclusive keys (Phase B3)
This commit is contained in:
@@ -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 => {
|
||||
|
||||
@@ -171,6 +171,33 @@ export async function syncQuotaCombos(poolId: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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<T extends { id: string }>(
|
||||
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.
|
||||
*
|
||||
|
||||
56
tests/unit/quota-catalog-filter.test.ts
Normal file
56
tests/unit/quota-catalog-filter.test.ts
Normal file
@@ -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" }]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user