Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
2 changes: 1 addition & 1 deletion src/codex/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ export type { CatalogModel, MultiAgentMode } from "./catalog/parsing";
export { CODEX_NATIVE_ALIAS_CATALOG_KIND, NATIVE_OPENAI_MODELS, configuredNativeAliasSlugs, desktopAllowlistSuppressedNativeSlugs, isNativeAliasCatalogEntry, nativeOpenAiContextWindow, disabledNativeSlugs, visibleNativeSlugs, desktopVisibleNativeSlugs, nativeModelRows, applyNativeVisibility, upstreamNativeEntry, nativeOpenAiSlugs, listCatalogNativeSlugs, nativeReasoningEfforts, nativeDefaultReasoningEffort, shouldIncludeAccountBoundNativeOpenAi, shouldIncludeNativeOpenAi } from "./catalog/metadata";
export { isSpawnableCodexCandidate, codexExecInvocation, loadBundledCodexCatalog, materializeBundledCodexCatalog, loadCatalogTemplate } from "./catalog/bundled";
export { nativeEffortClamp, shouldApplyNativeEffortClamp, catalogModelEfforts, codexSupportedReasoningEfforts, clampedDefaultEffort, clampEntryToCodexSupportedEfforts, clampCatalogModelsToCodexSupport } from "./catalog/effort";
export { applyProviderConfigHints, isDatedVariantId, filterCatalogVisibleModels, gatherRoutedModels, clearGatherRoutedModelsInflight, augmentRoutedModelsWithRegistryOpenAiApiRows, augmentRoutedModelsWithMetadata, resolveComboCatalogMember } from "./catalog/provider-fetch";
export { applyProviderConfigHints, isDatedVariantId, filterCatalogVisibleModels, gatherRoutedModels, clearGatherRoutedModelsInflight, augmentRoutedModelsWithRegistryOpenAiApiRows, augmentRoutedModelsWithMetadata, resolveComboCatalogMember, configuredComboTargetModelsByProvider } from "./catalog/provider-fetch";
export { deriveComboCatalogModel, exactComboCatalogSlugs, getLastComboCatalogOmissions, resetOpenAiApiCatalogWarningStateForTests, uniqueCatalogModelsForPublicList, uniqueCatalogModelsForRawPublicList, buildComboCatalogOmission, comboCatalogOmissionReason, summarizeComboCatalogOmissions } from "./catalog/aggregation";
export type { ComboCatalogOmission, ComboCatalogOmissionReason } from "./catalog/aggregation";
export { MAX_SPAWN_AGENT_MODEL_OVERRIDES, CANONICAL_NATIVE_CATALOG_CONTENT_POLICY, effectiveSubagentRoster, buildCatalogEntries, mergeCatalogEntriesFromObservedState, resetCatalogRuntimeStateForTests, orderForSubagents, mergeCatalogEntriesForSync, syncCatalogModels, restoreCodexCatalog, invalidateCodexModelsCache } from "./catalog/sync";
Expand Down
45 changes: 43 additions & 2 deletions src/codex/catalog/provider-fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,13 @@ interface CapturedProviderGather {
readonly policy: CatalogProviderDiscoveryPolicySnapshot;
readonly request: CapturedModelsRequest;
readonly observedAuth?: ModelsAuthResolution;
/**
* Configured model ids this provider must keep even when live discovery omits
* them — combo targets that are also listed in providers.*.models (OCX-111).
* Combo-only ids (not in models[]) stay out of the public catalog and are
* synthesized for combo derivation instead (#1305).
*/
readonly retainConfiguredModelIds?: ReadonlySet<string>;
}

interface GatherFlightCapture {
Expand Down Expand Up @@ -381,6 +388,7 @@ function captureProviderGather(
name: string,
configured: OcxProviderConfig,
authResolver: ModelsAuthResolver,
retainConfiguredModelIds?: ReadonlySet<string>,
): CapturedProviderGather {
const enriched = detachedClone(configured);
enrichProviderFromRegistry(name, enriched);
Expand Down Expand Up @@ -422,18 +430,47 @@ function captureProviderGather(
policy,
request,
...(observedAuth ? { observedAuth: Object.freeze({ ...observedAuth }) } : {}),
...(retainConfiguredModelIds && retainConfiguredModelIds.size > 0
? { retainConfiguredModelIds }
: {}),
});
}

/** Model ids each provider must retain for combo catalog derivation (OCX-111). */
export function configuredComboTargetModelsByProvider(
config: Pick<OcxConfig, "combos">,
): Map<string, ReadonlySet<string>> {
const byProvider = new Map<string, Set<string>>();
for (const id of listComboIds(config)) {
const combo = getCombo(config, id);
if (!combo) continue;
for (const target of combo.targets) {
let models = byProvider.get(target.provider);
if (!models) {
models = new Set();
byProvider.set(target.provider, models);
}
models.add(target.model);
}
}
return byProvider;
}

function captureGatherFlight(
config: OcxConfig,
createAuthResolver: ModelsAuthResolverFactory,
): GatherFlightCapture {
const providerAuthOutcomes: CatalogGatherProviderAuthOutcome[] = [];
const authResolver = createAuthResolver(providerAuthOutcomes);
const comboTargetsByProvider = configuredComboTargetModelsByProvider(config);
const providers = Object.entries(config.providers)
.filter(([, provider]) => provider.disabled !== true)
.map(([name, provider]) => captureProviderGather(name, provider, authResolver));
.map(([name, provider]) => captureProviderGather(
name,
provider,
authResolver,
comboTargetsByProvider.get(name),
));
const discoveryPolicySnapshots = Object.freeze(providers.map(provider => provider.policy));
return Object.freeze({
discoveryPolicyIdentity: keyedGatherIdentity("catalog-discovery-policy-v1", discoveryPolicySnapshots),
Expand Down Expand Up @@ -1249,7 +1286,11 @@ async function fetchProviderModelsWithAuth(
if (dated) {
// Reapply config hints so alias-keyed overrides (modelContextWindows etc.) win.
live.push(applyProviderConfigHints(name, prov, { ...dated, id: m.id }, contextCap));
} else if (seedVertexDefault || shouldRetainConfiguredProviderModel(name, m.id)) {
} else if (
seedVertexDefault
|| shouldRetainConfiguredProviderModel(name, m.id)
|| captured.retainConfiguredModelIds?.has(m.id) === true
) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
live.push(m);
} else {
droppedConfiguredIds.push(m.id);
Expand Down
91 changes: 91 additions & 0 deletions tests/codex-catalog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1517,6 +1517,97 @@ describe("combo catalog capability intersection", () => {
warn.mockRestore();
}
}, 15_000);

test("retains configured combo targets when authoritative live discovery omits them (OCX-111)", async () => {
// Repro from #1308 / OCX-111: live /models returns a different roster than the
// configured combo targets. Ids listed in providers.*.models are retained when
// they are combo targets. Combo-only ids (not in models[]) still catalog the
// combo via synthesis without leaking a standalone provider row (#1305).
// Use non-registry provider names so enrichProviderFromRegistry cannot seed models[].
clearModelCache("or-test");
clearModelCache("go-test");
clearModelCache("cc-test");
const warning = spyOn(console, "warn").mockImplementation(() => {});
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (input: RequestInfo | URL) => {
const url = String(input);
const id = url.includes("or-test")
? "openrouter/other-model"
: url.includes("go-test")
? "other-flash"
: "other-pro";
return new Response(JSON.stringify({ data: [{ id, owned_by: "provider" }] }), {
status: 200,
headers: { "content-type": "application/json" },
});
}) as typeof fetch;
try {
resetCatalogRuntimeStateForTests();
const rows = await gatherRoutedModels({
port: 10100,
defaultProvider: "or-test",
providers: {
"or-test": {
adapter: "openai-chat",
baseUrl: "https://or-test.example.test/v1",
authMode: "key",
apiKey: "sk-test",
liveModels: true,
models: ["openai/gpt-5.6-luna"],
modelContextWindows: { "openai/gpt-5.6-luna": 200_000 },
},
"go-test": {
adapter: "openai-chat",
baseUrl: "https://go-test.example.test/v1",
authMode: "key",
apiKey: "sk-test",
liveModels: true,
// Combo-only target: not listed in providers.*.models — synthesis only.
models: [],
modelContextWindows: { "deepseek-v4-flash": 128_000 },
},
"cc-test": {
adapter: "openai-chat",
baseUrl: "https://cc-test.example.test/v1",
authMode: "key",
apiKey: "sk-test",
liveModels: true,
models: ["xiaomi/mimo-v2.5-pro"],
modelContextWindows: { "xiaomi/mimo-v2.5-pro": 160_000 },
},
},
combos: {
failover: {
strategy: "failover",
targets: [
{ provider: "or-test", model: "openai/gpt-5.6-luna", weight: 1 },
{ provider: "go-test", model: "deepseek-v4-flash", weight: 1 },
{ provider: "cc-test", model: "xiaomi/mimo-v2.5-pro", weight: 1 },
],
},
},
});

const combo = rows.find(r => r.provider === "combo" && r.id === "failover");
expect(combo).toBeDefined();
expect(combo!.contextWindow).toBe(128_000);
expect(rows.some(r => r.provider === "or-test" && r.id === "openai/gpt-5.6-luna")).toBe(true);
expect(rows.some(r => r.provider === "cc-test" && r.id === "xiaomi/mimo-v2.5-pro")).toBe(true);
// Combo-only member must not leak as a standalone routed row.
expect(rows.some(r => r.provider === "go-test" && r.id === "deepseek-v4-flash")).toBe(false);
const warningText = warning.mock.calls.flat().join(" ");
expect(warningText).not.toContain("member capabilities are incomplete");
expect(warningText).not.toContain("omitted configured model ids");
const { getLastComboCatalogOmissions } = await import("../src/codex/catalog");
expect(getLastComboCatalogOmissions().some(item => item.id === "failover")).toBe(false);
} finally {
warning.mockRestore();
globalThis.fetch = originalFetch;
clearModelCache("or-test");
clearModelCache("go-test");
clearModelCache("cc-test");
}
}, 15_000);
});

describe("Google Gemini catalog metadata", () => {
Expand Down
Loading