Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
215 changes: 172 additions & 43 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 All @@ -460,6 +497,9 @@ function captureGatherFlight(
// It is the one member of a provider row that is legitimately a function,
// so it is dropped here rather than allowed to break every encode.
provider: omitProviderTransportExecutor(provider.provider),
// Combo retention is capture-time state, not a provider-row field. Two
// gathers that share providers but differ in combo targets must not join.
retainConfiguredModelIds: [...(provider.retainConfiguredModelIds ?? [])].sort(),
}))),
discoveryPolicySnapshots,
providers: Object.freeze(providers),
Expand Down Expand Up @@ -1004,6 +1044,30 @@ async function fetchProviderModelsWithAuth(
provider: name,
...catalogHintsFromProviderConfig(name, prov, id, contextCap),
}));
const withConfiguredRetention = (
models: CatalogModel[],
options?: { retainComboTargets?: boolean; warnDrops?: boolean },
): CatalogModel[] => {
const { models: merged, droppedConfiguredIds } = mergeConfiguredModelsIntoLiveCatalog({
name,
provider: prov,
models,
configured,
retainConfiguredModelIds: captured.retainConfiguredModelIds,
contextCap,
seedVertexDefault,
retainComboTargets: options?.retainComboTargets,
});
if (
options?.warnDrops === true
&& droppedConfiguredIds.length > 0
&& name !== OPENAI_API_PROVIDER_ID
&& !QUIET_AUTHORITATIVE_CATALOG_PROVIDERS.has(name)
) {
warnDroppedConfiguredIdsOnce(name, droppedConfiguredIds);
}
return merged;
};
// Static catalogs never need an OAuth refresh or an upstream model request. Clear any
// discovery failure left by an older live configuration even when the account is logged out.
if (prov.liveModels === false) {
Expand Down Expand Up @@ -1047,23 +1111,32 @@ async function fetchProviderModelsWithAuth(
// plan (e.g. claude-fable-5) drop out instead of failing ERROR_BAD_MODEL_NAME. Fall back to the seed.
const cachedCursor = getFreshCached(name, ttlMs);
if (cachedCursor) {
return observed(applyConfigHintsToCachedModels(name, prov, cachedCursor), "authoritative");
return observed(
withConfiguredRetention(applyConfigHintsToCachedModels(name, prov, cachedCursor)),
"authoritative",
);
}
if (isModelsFetchCoolingDown(name)) {
const cooling = getStaleCached(name);
return observed(
cooling ? applyConfigHintsToCachedModels(name, prov, cooling) : configured,
withConfiguredRetention(
cooling ? applyConfigHintsToCachedModels(name, prov, cooling) : configured,
),
"degraded",
);
}
const liveResult = await fetchCursorUsableModels({ apiKey, baseUrl: prov.baseUrl });
if (liveResult.ok) {
const available = filterCursorConfiguredModelsByLiveDiscovery(configured, liveResult.models);
const result = available.length > 0 ? available : configured;
// Count what discovery actually returned, not the configured rows we fall back to.
if (!setCached(name, result, Date.now(), cacheGeneration)) return observed(configured, "degraded");
// Cache the discovery-filtered roster without combo retention so a later
// gather can re-apply the current capture's retain set on read.
const forCache = withConfiguredRetention(result, { retainComboTargets: false });
if (!setCached(name, forCache, Date.now(), cacheGeneration)) {
return observed(withConfiguredRetention(configured), "degraded");
}
markProviderDiscoveryOk(name, liveResult.models.length);
return observed(result, "authoritative");
return observed(withConfiguredRetention(forCache, { warnDrops: true }), "authoritative");
}
if (isCurrentCacheGeneration()) {
markModelsFetchFailure(name);
Expand All @@ -1074,7 +1147,9 @@ async function fetchProviderModelsWithAuth(
}
const staleCursor = getStaleCached(name);
return observed(
staleCursor ? applyConfigHintsToCachedModels(name, prov, staleCursor) : configured,
withConfiguredRetention(
staleCursor ? applyConfigHintsToCachedModels(name, prov, staleCursor) : configured,
),
"degraded",
);
}
Expand All @@ -1090,7 +1165,9 @@ async function fetchProviderModelsWithAuth(
const fresh = getFreshCached(name, ttlMs);
if (fresh) {
return observed(
withVertexDefaultSeed(applyConfigHintsToCachedModels(name, prov, fresh, contextCap)),
withConfiguredRetention(
withVertexDefaultSeed(applyConfigHintsToCachedModels(name, prov, fresh, contextCap)),
),
"authoritative",
); // dedups Codex's frequent /v1/models polling within the TTL
}
Expand All @@ -1099,9 +1176,11 @@ async function fetchProviderModelsWithAuth(
// fetch timeout on every catalog poll — the dashboard polls this path per page load.
const stale = getStaleCached(name);
return observed(
stale
? withVertexDefaultSeed(applyConfigHintsToCachedModels(name, prov, stale, contextCap))
: failedDiscoveryConfigured,
withConfiguredRetention(
stale
? withVertexDefaultSeed(applyConfigHintsToCachedModels(name, prov, stale, contextCap))
: failedDiscoveryConfigured,
),
"degraded",
);
}
Expand All @@ -1114,7 +1193,11 @@ async function fetchProviderModelsWithAuth(
failure: ProviderModelDiscoveryFailure,
): { models: CatalogModel[]; fallback: "stale" | "configured"; shouldLog: boolean } => {
if (!isCurrentCacheGeneration()) {
return { models: failedDiscoveryConfigured, fallback: "configured", shouldLog: false };
return {
models: withConfiguredRetention(failedDiscoveryConfigured),
fallback: "configured",
shouldLog: false,
};
}
// Decide logging BEFORE recording the new status, so we can compare against the prior one and
// suppress an identical repeated failure (#395 log flood). The failure stays observable via the
Expand All @@ -1124,9 +1207,11 @@ async function fetchProviderModelsWithAuth(
markProviderDiscoveryFailed(name, failure);
const stale = getStaleCached(name);
return {
models: stale
? withVertexDefaultSeed(applyConfigHintsToCachedModels(name, prov, stale, contextCap))
: failedDiscoveryConfigured,
models: withConfiguredRetention(
stale
? withVertexDefaultSeed(applyConfigHintsToCachedModels(name, prov, stale, contextCap))
: failedDiscoveryConfigured,
),
fallback: stale ? "stale" : "configured",
shouldLog,
};
Expand Down Expand Up @@ -1202,9 +1287,12 @@ async function fetchProviderModelsWithAuth(
...(model.contextWindow ? { contextWindow: model.contextWindow } : {}),
...(model.inputModalities ? { inputModalities: model.inputModalities } : {}),
}, contextCap));
if (!setCached(name, live, Date.now(), cacheGeneration)) return observed(configured, "degraded");
const forCache = withConfiguredRetention(live, { retainComboTargets: false });
if (!setCached(name, forCache, Date.now(), cacheGeneration)) {
return observed(withConfiguredRetention(configured), "degraded");
}
markProviderDiscoveryOk(name, live.length);
return observed(live, "authoritative");
return observed(withConfiguredRetention(forCache, { warnDrops: true }), "authoritative");
}
const extracted = extractProviderModelItems(bounded.value, discovery);
if (!extracted.ok) {
Expand Down Expand Up @@ -1236,37 +1324,24 @@ async function fetchProviderModelsWithAuth(
// Capture the count BEFORE the alias/configured augmentation below pushes extra rows into
// `live`; otherwise configured entries would be reported as discovered ones.
const liveModelCount = live.length;
const liveIds = new Set(live.map(m => m.id));
// Dated-release aliases (Anthropic pattern): older models may appear in the live catalog
// ONLY under their dated id (claude-haiku-4-5-20251001) while the config names the
// API-valid alias (claude-haiku-4-5). Such aliases are real, callable models — keep them
// in the authoritative catalog (alias id, hints from the dated live entry) instead of
// dropping them and warning on every poll.
const droppedConfiguredIds: string[] = [];
for (const m of configured) {
if (liveIds.has(m.id)) continue;
const dated = live.find(l => isDatedVariantId(l.id, m.id));
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)) {
live.push(m);
} else {
droppedConfiguredIds.push(m.id);
}
}
if (live.length === 0 && name !== OPENAI_API_PROVIDER_ID) {
// Dated-release aliases + configured retention (compat allow-list, combo targets,
// Vertex default). Cache without combo retention so a later gather re-applies the
// current capture's retain set on read (warm-cache OCX-111 / #1308).
const forCache = withConfiguredRetention(live, { retainComboTargets: false });
const returned = withConfiguredRetention(forCache, { warnDrops: true });
const droppedConfiguredIds = configured
.map(model => model.id)
.filter(id => !returned.some(model => model.id === id));
if (returned.length === 0 && name !== OPENAI_API_PROVIDER_ID) {
console.warn(
`[opencodex] Provider model discovery for "${name}" returned an authoritative empty catalog; ${droppedConfiguredIds.length > 0 ? `dropping configured model ids: ${droppedConfiguredIds.join(", ")}` : "no models will be exposed"}.`,
);
} else if (droppedConfiguredIds.length > 0
&& name !== OPENAI_API_PROVIDER_ID
&& !QUIET_AUTHORITATIVE_CATALOG_PROVIDERS.has(name)) {
warnDroppedConfiguredIdsOnce(name, droppedConfiguredIds);
}
if (!setCached(name, live, Date.now(), cacheGeneration)) return observed(configured, "degraded");
if (!setCached(name, forCache, Date.now(), cacheGeneration)) {
return observed(withConfiguredRetention(configured), "degraded");
}
markProviderDiscoveryOk(name, liveModelCount);
return observed(live, "authoritative");
return observed(returned, "authoritative");
} catch (error) {
if (error instanceof ProviderOutboundPolicyError) {
const { models, fallback, shouldLog } = failedDiscoveryFallback({ reason: "blocked" });
Expand Down Expand Up @@ -1313,6 +1388,60 @@ export function shouldRetainConfiguredProviderModel(providerName: string, modelI
return false;
}

/**
* Fold dated-release aliases and retain configured rows that must survive an
* authoritative live roster (compatibility allow-list, combo targets, Vertex
* default). Used on every discovery return — live, fresh cache, stale, and
* failure fallback — so a warm cache captured before a combo existed still
* surfaces the configured target (OCX-111 / #1308).
*
* Cache writes should pass `retainComboTargets: false` so combo retention is
* re-applied on read against the current capture, not frozen into the TTL entry.
*/
export function mergeConfiguredModelsIntoLiveCatalog(opts: {
name: string;
provider: OcxProviderConfig;
models: readonly CatalogModel[];
configured: readonly CatalogModel[];
retainConfiguredModelIds?: ReadonlySet<string>;
contextCap?: number;
seedVertexDefault?: boolean;
retainComboTargets?: boolean;
}): { models: CatalogModel[]; droppedConfiguredIds: string[] } {
const {
name,
provider: prov,
configured,
retainConfiguredModelIds,
contextCap,
seedVertexDefault,
retainComboTargets = true,
} = opts;
const out = [...opts.models];
const present = new Set(out.map(model => model.id));
const droppedConfiguredIds: string[] = [];
for (const candidate of configured) {
if (present.has(candidate.id)) continue;
const dated = out.find(live => isDatedVariantId(live.id, candidate.id));
if (dated) {
out.push(applyProviderConfigHints(name, prov, { ...dated, id: candidate.id }, contextCap));
present.add(candidate.id);
continue;
}
if (
seedVertexDefault === true
|| shouldRetainConfiguredProviderModel(name, candidate.id)
|| (retainComboTargets && retainConfiguredModelIds?.has(candidate.id) === true)
) {
out.push(candidate);
present.add(candidate.id);
continue;
}
droppedConfiguredIds.push(candidate.id);
}
return { models: out, droppedConfiguredIds };
}

export function filterCatalogVisibleModels(
models: CatalogModel[],
config: Pick<OcxConfig, "disabledModels" | "providers">,
Expand Down
Loading
Loading