Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
55 changes: 52 additions & 3 deletions src/codex/catalog/provider-fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,11 @@ interface CapturedProviderGather {
readonly policy: CatalogProviderDiscoveryPolicySnapshot;
readonly request: CapturedModelsRequest;
readonly observedAuth?: ModelsAuthResolution;
/**
* Configured model ids this provider must keep even when live discovery omits
* them — currently every combo target on this provider (OCX-111 / #1308).
*/
readonly retainConfiguredModelIds?: ReadonlySet<string>;
}

interface GatherFlightCapture {
Expand Down Expand Up @@ -381,6 +386,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 +428,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 @@ -998,7 +1033,17 @@ async function fetchProviderModelsWithAuth(
&& prov.googleMode === "vertex"
&& (prov.models?.length ?? 0) === 0
&& Boolean(prov.defaultModel);
const configuredIds = seedVertexDefault && prov.defaultModel ? [prov.defaultModel] : (prov.models ?? []);
const listedConfiguredIds = seedVertexDefault && prov.defaultModel
? [prov.defaultModel]
: [...(prov.models ?? [])];
// Combo targets may exist only under `combos.*.targets` (not in providers.*.models).
// Seed those ids here so the live-discovery retain loop can keep them (OCX-111).
const configuredIdSet = new Set(listedConfiguredIds);
for (const id of captured.retainConfiguredModelIds ?? []) configuredIdSet.add(id);
const configuredIds = [
...listedConfiguredIds,
...[...configuredIdSet].filter(id => !listedConfiguredIds.includes(id)),
];
const configured: CatalogModel[] = configuredIds.map(id => ({
id,
provider: name,
Expand Down Expand Up @@ -1249,7 +1294,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
88 changes: 88 additions & 0 deletions tests/codex-catalog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1338,7 +1338,7 @@
expect(combo!.inputModalities).toEqual(["text"]);
expect(combo!.reasoningEfforts).toEqual(["low", "medium"]);
// Synthesized member must not leak as a standalone routed row.
expect(rows.some(r => r.provider === "a" && r.id === "unlisted")).toBe(false);

Check failure on line 1341 in tests/codex-catalog.test.ts

View workflow job for this annotation

GitHub Actions / macos

error: expect(received).toBe(expected)

Expected: false Received: true at <anonymous> (/Users/runner/work/opencodex/opencodex/tests/codex-catalog.test.ts:1341:73)

Check failure on line 1341 in tests/codex-catalog.test.ts

View workflow job for this annotation

GitHub Actions / test 4/4

error: expect(received).toBe(expected)

Expected: false Received: true at <anonymous> (/home/runner/work/opencodex/opencodex/tests/codex-catalog.test.ts:1341:73)
const { getLastComboCatalogOmissions } = await import("../src/codex/catalog");
expect(getLastComboCatalogOmissions().some(item => item.id === "recovered")).toBe(false);
} finally {
Expand Down Expand Up @@ -1517,6 +1517,94 @@
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. Combo-only targets (not listed in providers.*.models)
// must still be retained via provider hints so the failover combo catalogs.
clearModelCache("openrouter");
clearModelCache("opencode-go");
clearModelCache("command-code");
const warning = spyOn(console, "warn").mockImplementation(() => {});
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (input: RequestInfo | URL) => {
const url = String(input);
const id = url.includes("openrouter")
? "openrouter/other-model"
: url.includes("opencode")
? "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: "openrouter",
providers: {
openrouter: {
adapter: "openai-chat",
baseUrl: "https://openrouter.example.test/v1",
authMode: "key",
apiKey: "sk-test",
liveModels: true,
models: ["openai/gpt-5.6-luna"],
modelContextWindows: { "openai/gpt-5.6-luna": 200_000 },
},
"opencode-go": {
adapter: "openai-chat",
baseUrl: "https://opencode.example.test/go/v1",
authMode: "key",
apiKey: "sk-test",
liveModels: true,
// Combo-only target: listed in combos but not providers.*.models.
models: [],
modelContextWindows: { "deepseek-v4-flash": 128_000 },
},
"command-code": {
adapter: "openai-chat",
baseUrl: "https://command-code.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: "openrouter", model: "openai/gpt-5.6-luna", weight: 1 },
{ provider: "opencode-go", model: "deepseek-v4-flash", weight: 1 },
{ provider: "command-code", 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 === "openrouter" && r.id === "openai/gpt-5.6-luna")).toBe(true);
expect(rows.some(r => r.provider === "opencode-go" && r.id === "deepseek-v4-flash")).toBe(true);
expect(rows.some(r => r.provider === "command-code" && r.id === "xiaomi/mimo-v2.5-pro")).toBe(true);
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("openrouter");
clearModelCache("opencode-go");
clearModelCache("command-code");
}
}, 15_000);
});

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