Skip to content

Commit 099faec

Browse files
lidge-junclaude
andcommitted
feat(catalog): per-provider selectedModels allowlist to trim oversized catalogs (#52)
A custom API proxy (or an aggregator like OpenRouter) exposing thousands of live models bloats the Codex catalog (50MB+) and the Models page. Add an opt-in per-provider `selectedModels` allowlist: when non-empty, only those ids ship to the Codex catalog and /v1/models — live discovery still runs, this only narrows what is emitted. Applied via a single choke point (filterCatalogVisibleModels) at both catalog emission points; the admin /api/models list stays unfiltered so the picker can show the full set. Adds /api/selected-models GET/PUT. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 685b7c4 commit 099faec

4 files changed

Lines changed: 116 additions & 5 deletions

File tree

src/codex-catalog.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -815,6 +815,30 @@ async function fetchProviderModels(name: string, prov: OcxProviderConfig, ttlMs:
815815
}
816816
}
817817

818+
/**
819+
* Narrow a raw routed-model list to what Codex's catalog / clients should see: drop the
820+
* `disabledModels` blocklist AND, for any provider with a non-empty `selectedModels` allowlist, keep
821+
* only those ids. This is the single choke point applied at every CATALOG emission point (on-disk
822+
* sync + /v1/models); the admin `/api/models` list stays unfiltered so the picker can show the full
823+
* set. Live discovery is unaffected — this only decides what ships. See issue_052.
824+
*/
825+
export function filterCatalogVisibleModels(
826+
models: CatalogModel[],
827+
config: Pick<OcxConfig, "disabledModels" | "providers">,
828+
): CatalogModel[] {
829+
const disabled = new Set(config.disabledModels ?? []);
830+
const allowByProvider = new Map<string, Set<string>>();
831+
for (const [name, prov] of Object.entries(config.providers)) {
832+
const sel = prov.selectedModels;
833+
if (Array.isArray(sel) && sel.length > 0) allowByProvider.set(name, new Set(sel));
834+
}
835+
return models.filter(m => {
836+
if (disabled.has(`${m.provider}/${m.id}`)) return false;
837+
const allow = allowByProvider.get(m.provider);
838+
return !allow || allow.has(m.id);
839+
});
840+
}
841+
818842
/**
819843
* Gather routed (non-forward) provider models across the config — the single source of truth for
820844
* the live model list, used by both the on-disk catalog sync and the proxy's /api/* + /v1/models
@@ -983,8 +1007,7 @@ export async function syncCatalogModels(config: OcxConfig): Promise<{ added: num
9831007

9841008
// Hide disabled models from Codex, then feature the chosen subagent models (native OR routed)
9851009
// by giving them the lowest priority — see buildCatalogEntries for why priority, not array order.
986-
const disabled = new Set(config.disabledModels ?? []);
987-
const enabledGo = goModels.filter(m => !disabled.has(`${m.provider}/${m.id}`));
1010+
const enabledGo = filterCatalogVisibleModels(goModels, config);
9881011
const featured = config.subagentModels ?? [];
9891012
const orderedGoModels = orderForSubagents(enabledGo, featured); // stable tie-break among equal priorities
9901013
const goEntries = buildCatalogEntries(template ? JSON.parse(JSON.stringify(template)) : null, [], orderedGoModels, featured, websocketsEnabled(config));

src/server.ts

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1981,6 +1981,38 @@ async function handleManagementAPI(req: Request, url: URL, config: OcxConfig): P
19811981
return jsonResponse({ ok: true, applied: chosen });
19821982
}
19831983

1984+
// Per-provider catalog allowlist (issue #52): when a provider has a non-empty selectedModels list,
1985+
// only those ids ship to Codex's catalog / /v1/models. GET returns the CURRENT selection plus the
1986+
// FULL available set per provider (unfiltered — the picker needs everything to choose from).
1987+
if (url.pathname === "/api/selected-models" && req.method === "GET") {
1988+
const models = await fetchAllModels(config);
1989+
const available: Record<string, string[]> = {};
1990+
for (const m of models) (available[m.provider] ??= []).push(m.id);
1991+
const selected: Record<string, string[]> = {};
1992+
for (const [name, prov] of Object.entries(config.providers)) {
1993+
if (Array.isArray(prov.selectedModels) && prov.selectedModels.length > 0) selected[name] = [...prov.selectedModels];
1994+
}
1995+
return jsonResponse({ selected, available });
1996+
}
1997+
if (url.pathname === "/api/selected-models" && req.method === "PUT") {
1998+
let body: { provider?: unknown; models?: unknown };
1999+
try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
2000+
const provider = typeof body.provider === "string" ? body.provider : "";
2001+
if (!provider || !hasOwnProvider(config.providers, provider)) {
2002+
return jsonResponse({ error: "unknown provider" }, provider ? 404 : 400);
2003+
}
2004+
const models = Array.isArray(body.models)
2005+
? [...new Set(body.models.filter((m): m is string => typeof m === "string"))]
2006+
: [];
2007+
// Empty list clears the allowlist (provider reverts to exposing all models).
2008+
if (models.length > 0) config.providers[provider].selectedModels = models;
2009+
else delete config.providers[provider].selectedModels;
2010+
const { saveConfig: save } = await import("./config");
2011+
save(config);
2012+
await refreshCodexCatalogBestEffort();
2013+
return jsonResponse({ ok: true, provider, selected: models });
2014+
}
2015+
19842016
// OAuth login (xai now; anthropic/kimi in cycle 2). Starts the flow and returns the auth URL;
19852017
// the provider's loopback callback server (inside this process) captures the redirect in the
19862018
// background, then the credential is persisted. The GUI opens the URL and polls /api/oauth/status.
@@ -2141,10 +2173,9 @@ export function startServer(port?: number) {
21412173
return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, config);
21422174
}
21432175
const goModels = await fetchAllModels(config);
2144-
const { buildCatalogEntries, loadCatalogTemplate, nativeOpenAiSlugs, orderForSubagents } = await import("./codex-catalog");
2176+
const { buildCatalogEntries, loadCatalogTemplate, nativeOpenAiSlugs, orderForSubagents, filterCatalogVisibleModels } = await import("./codex-catalog");
21452177
const nativeSlugs = nativeOpenAiSlugs();
2146-
const disabledSet = new Set(config.disabledModels ?? []);
2147-
const goEnabled = goModels.filter(m => !disabledSet.has(`${m.provider}/${m.id}`));
2178+
const goEnabled = filterCatalogVisibleModels(goModels, config);
21482179
const goOrdered = orderForSubagents(goEnabled, config.subagentModels);
21492180
if (url.searchParams.has("client_version")) {
21502181
// Codex client → Codex catalog shape: native gpt + namespaced routed models,

src/types.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,14 @@ export interface OcxProviderConfig {
339339
* or too flaky for startup/catalog sync.
340340
*/
341341
liveModels?: boolean;
342+
/**
343+
* Per-provider catalog allowlist. When non-empty, ONLY these model ids are emitted to Codex's
344+
* catalog and `/v1/models` — live discovery still runs, this just narrows what ships (so a proxy
345+
* exposing thousands of models, or an aggregator like OpenRouter, doesn't bloat the catalog).
346+
* Empty/undefined = expose all. The admin `/api/models` list is unaffected (it always shows the
347+
* full set so the user can pick). See devlog issue_052_provider-model-allowlist.
348+
*/
349+
selectedModels?: string[];
342350
/** Provider-wide Codex-visible context-window cap for routed catalog entries. */
343351
contextWindow?: number;
344352
/** Model-specific Codex-visible context-window caps. Values cap live metadata, never raise it. */

tests/selected-models.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { describe, expect, test } from "bun:test";
2+
import { filterCatalogVisibleModels, type CatalogModel } from "../src/codex-catalog";
3+
import type { OcxConfig, OcxProviderConfig } from "../src/types";
4+
5+
function m(provider: string, id: string): CatalogModel {
6+
return { provider, id, owned_by: provider };
7+
}
8+
9+
function cfg(providers: Record<string, Partial<OcxProviderConfig>>, disabledModels?: string[]): Pick<OcxConfig, "disabledModels" | "providers"> {
10+
const full: Record<string, OcxProviderConfig> = {};
11+
for (const [name, p] of Object.entries(providers)) full[name] = { adapter: "openai-chat", baseUrl: "https://x", ...p };
12+
return { providers: full, ...(disabledModels ? { disabledModels } : {}) };
13+
}
14+
15+
describe("filterCatalogVisibleModels — per-provider allowlist", () => {
16+
const models = [m("proxy", "a"), m("proxy", "b"), m("proxy", "c"), m("openai", "gpt-5.5")];
17+
18+
test("no selectedModels → all models pass", () => {
19+
const out = filterCatalogVisibleModels(models, cfg({ proxy: {}, openai: {} }));
20+
expect(out.map(x => x.id).sort()).toEqual(["a", "b", "c", "gpt-5.5"]);
21+
});
22+
23+
test("empty selectedModels array → treated as all", () => {
24+
const out = filterCatalogVisibleModels(models, cfg({ proxy: { selectedModels: [] }, openai: {} }));
25+
expect(out.map(x => x.id).sort()).toEqual(["a", "b", "c", "gpt-5.5"]);
26+
});
27+
28+
test("non-empty allowlist keeps only listed ids for that provider, others untouched", () => {
29+
const out = filterCatalogVisibleModels(models, cfg({ proxy: { selectedModels: ["a", "c"] }, openai: {} }));
30+
expect(out.map(x => `${x.provider}/${x.id}`).sort()).toEqual(["openai/gpt-5.5", "proxy/a", "proxy/c"]);
31+
});
32+
33+
test("allowlist is per-provider — an id present under another provider is not leaked", () => {
34+
const withDup = [...models, m("openai", "a")];
35+
const out = filterCatalogVisibleModels(withDup, cfg({ proxy: { selectedModels: ["a"] }, openai: {} }));
36+
expect(out.map(x => `${x.provider}/${x.id}`).sort()).toEqual(["openai/a", "openai/gpt-5.5", "proxy/a"]);
37+
});
38+
39+
test("disabledModels blocklist still applies alongside the allowlist", () => {
40+
const out = filterCatalogVisibleModels(models, cfg({ proxy: { selectedModels: ["a", "b"] }, openai: {} }, ["proxy/b"]));
41+
expect(out.map(x => `${x.provider}/${x.id}`).sort()).toEqual(["openai/gpt-5.5", "proxy/a"]);
42+
});
43+
44+
test("large list collapses to the few selected (the issue #52 shape)", () => {
45+
const big = Array.from({ length: 2000 }, (_, i) => m("proxy", `model-${i}`));
46+
const out = filterCatalogVisibleModels(big, cfg({ proxy: { selectedModels: ["model-7", "model-1999"] } }));
47+
expect(out.map(x => x.id).sort()).toEqual(["model-1999", "model-7"]);
48+
});
49+
});

0 commit comments

Comments
 (0)