From 1a76ad5d9203e80ae7af1308927ef7dce6840da3 Mon Sep 17 00:00:00 2001 From: Yeachan-Heo Date: Thu, 8 Oct 2026 15:30:36 +0900 Subject: [PATCH] fix(models): stop discovery from deleting bundled catalog entries A live discovery catalog reflects what a provider -- or the signed-in ChatGPT plan -- currently lists, not what exists. Treating it as authoritative deleted bundled entries the provider omitted, so a ChatGPT Plus account lost selectable ids such as openai-codex/gpt-6.1-sol from /model, profile activation, and preset availability even though the model was bundled. Discovery now only enriches the bundled catalog: it adds unknown ids and refreshes metadata, and never removes a bundled model. Access decisions defer to the provider, which returns a typed entitlement error. Lore-id: available-catalog-keeps-bundled-models Constraint: the bundled catalog is authoritative for catalog visibility Rejected: keep hiding for plan-scoped providers only | no reliable signal separates plan entitlement from a deprecation, and codex was the only observed case Directive: do not reintroduce discovery-driven deletion of bundled models Confidence: high Scope-risk: wide Reversibility: easy Tested: model-registry, model-profile-activation, codex-profile-pinned-discovery, cli-args-mpreset, model-selector-profiles, sdk-q27-model-profiles, sdk-model-selection, model-registry-*, discovery and autorouting suites (bun test) Not-tested: spawn-based broker/e2e suites (EBADF posix_spawn in this environment) --- .../available-catalog-keeps-bundled-models.md | 7 + .../src/config/model-profile-activation.ts | 20 +-- .../coding-agent/src/config/model-registry.ts | 138 +----------------- .../src/modes/components/model-selector.ts | 5 +- .../src/sdk/host/session-runtime.ts | 7 +- packages/coding-agent/src/sdk/session.ts | 15 +- .../test/cli-args-mpreset.test.ts | 6 +- .../codex-profile-pinned-discovery.test.ts | 10 +- .../model-preset-landing-redteam-qa.test.ts | 8 +- .../test/model-profile-activation.test.ts | 83 ++++------- .../coding-agent/test/model-registry.test.ts | 9 +- .../test/model-selector-profiles.test.ts | 10 +- .../test/sdk-model-selection.test.ts | 61 -------- .../test/sdk-q27-model-profiles.test.ts | 7 +- 14 files changed, 70 insertions(+), 316 deletions(-) create mode 100644 packages/coding-agent/changelog.d/available-catalog-keeps-bundled-models.md diff --git a/packages/coding-agent/changelog.d/available-catalog-keeps-bundled-models.md b/packages/coding-agent/changelog.d/available-catalog-keeps-bundled-models.md new file mode 100644 index 00000000000..1d45fd7c1f9 --- /dev/null +++ b/packages/coding-agent/changelog.d/available-catalog-keeps-bundled-models.md @@ -0,0 +1,7 @@ +### Changed + +- The selectable model catalog is no longer narrowed by live provider discovery. A discovered catalog now only enriches the bundled catalog — it adds newly discovered ids and refreshes metadata — and never deletes a bundled entry a provider omits. A model the provider (or the signed-in plan) does not list stays selectable, and the provider's own typed error surfaces when it cannot be used. For `openai-codex` this ends the plan-scoped hiding that removed bundled ids such as `openai-codex/gpt-6.1-sol` from `/model`, profile activation, and preset availability on lower-tier ChatGPT accounts. + +### Removed + +- Removed `ModelRegistry.getAvailableForProfileActivation()` and the live-catalog "authoritative ids" filter behind it. Profile activation, preset landing, startup fallback resolution, and model materialization now read `ModelRegistry.getAvailable()` directly. diff --git a/packages/coding-agent/src/config/model-profile-activation.ts b/packages/coding-agent/src/config/model-profile-activation.ts index b1cf9efee0a..1b8ae414f58 100644 --- a/packages/coding-agent/src/config/model-profile-activation.ts +++ b/packages/coding-agent/src/config/model-profile-activation.ts @@ -79,8 +79,7 @@ type ModelProfileActivationSession = Pick< | "resolveModelByLookupAlias" | "authStorage" | "isCredentiallessProvider" - > & - Partial>; + >; getConfiguredModelChainState?: (role: string) => ConfiguredModelChainState | undefined; }; @@ -109,7 +108,6 @@ export interface PrepareModelProfileActivationOptions { Pick< ModelRegistry, | "getAvailable" - | "getAvailableForProfileActivation" | "resolveModelByLookupAlias" | "lookupAliasExists" | "clearCanonicalVariant" @@ -267,10 +265,8 @@ function concretizeMaterializedAssignmentValues( if (!modelRegistry || !sessionId) return assignments; // Materialized assignments are persisted into `modelRoles` and // `task.agentModelOverrides` and later consumed by profile execution, so they - // must resolve against the descriptor-backed profile-activation catalog, not - // the broadened general one: otherwise a bare assignment can persist a - // bundled model that fresh live profile evidence excludes. - const availableModels = modelRegistry.getAvailableForProfileActivation?.() ?? modelRegistry.getAvailable(); + // resolve against the same catalog profile activation uses. + const availableModels = modelRegistry.getAvailable(); const authenticatedModels = availableModels.filter(model => { const isCredentiallessProvider = modelRegistry.isCredentiallessProvider?.bind(modelRegistry); const hasUsableAuth = modelRegistry.authStorage?.hasUsableAuth?.bind(modelRegistry.authStorage); @@ -888,10 +884,7 @@ export async function resolveModelProfileDefaultChain(options: { allRoutable && proxyProvider !== undefined ? [proxyProvider] : [...group], ); } - const availableModels = - options.modelRegistry.getAvailableForProfileActivation?.() ?? - options.modelRegistry.getAvailable?.() ?? - options.modelRegistry.getAll(); + const availableModels = options.modelRegistry.getAvailable?.() ?? options.modelRegistry.getAll(); let bindings = resolveProfileBindings(profile); if (alternativeGroups.length > 0) bindings = rewriteBindingsProviders(bindings, authenticatedProviders, alternativeGroups); @@ -1493,10 +1486,7 @@ export async function prepareModelProfileActivation( ); } - const availableModels = - options.modelRegistry.getAvailableForProfileActivation?.() ?? - options.modelRegistry.getAvailable?.() ?? - options.modelRegistry.getAll(); + const availableModels = options.modelRegistry.getAvailable?.() ?? options.modelRegistry.getAll(); const roleCatalogModels = options.modelRegistry.getAll(); let bindings = resolveProfileBindings(profile); if (alternativeGroups.length > 0) { diff --git a/packages/coding-agent/src/config/model-registry.ts b/packages/coding-agent/src/config/model-registry.ts index 14bae83d5a1..a4dca0d121a 100644 --- a/packages/coding-agent/src/config/model-registry.ts +++ b/packages/coding-agent/src/config/model-registry.ts @@ -1774,9 +1774,6 @@ export class ModelRegistry { { fresh: boolean; modelIds: ReadonlySet; - profileModelIds?: ReadonlySet; - profileFresh?: boolean; - profileEndpoint?: string; authGeneration: string; endpoint: string; } @@ -3332,9 +3329,6 @@ export class ModelRegistry { | { fresh: boolean; modelIds: ReadonlySet; - profileModelIds?: ReadonlySet; - profileFresh?: boolean; - profileEndpoint?: string; authGeneration: string; endpoint: string; } @@ -3479,12 +3473,9 @@ export class ModelRegistry { .map(([provider, evidence]) => [ provider, evidence.fresh, - evidence.profileFresh, evidence.authGeneration, evidence.endpoint, - evidence.profileEndpoint, [...evidence.modelIds].sort(), - evidence.profileModelIds === undefined ? undefined : [...evidence.profileModelIds].sort(), ]), configured: [...this.#configuredDiscoveryEvidence.entries()] .sort(([left], [right]) => left.localeCompare(right)) @@ -4085,13 +4076,6 @@ export class ModelRegistry { this.#descriptorDiscoveryEvidence.set(options.providerId, { fresh: result.fetched, modelIds: new Set(models.map(model => model.id)), - ...(result.dynamicModelIds === undefined - ? {} - : { - profileModelIds: new Set(result.dynamicModelIds), - profileFresh: !result.stale, - profileEndpoint: endpoint, - }), authGeneration, endpoint: this.#normalizeDiscoveryEvidenceEndpoint(models[0]?.baseUrl ?? endpoint), }); @@ -5670,9 +5654,10 @@ export class ModelRegistry { /** * Get selectable models with auth configured. - * This is a fast check that doesn't refresh OAuth tokens. A current, - * authoritative live catalog also limits each provider to its enrolled ids; - * bundled entries remain the fallback until that evidence exists. + * This is a fast check that doesn't refresh OAuth tokens. The bundled catalog + * is authoritative for visibility: live discovery only enriches entries and + * adds unknown ids, and never removes a bundled model. A provider that cannot + * serve a model reports its own error when the model is used. */ getAvailable(): Model[] { this.#synchronizeEnvironmentCredentials(); @@ -5686,82 +5671,12 @@ export class ModelRegistry { ) { return this.#availableModelsCache; } - const authoritativeDiscoveryIds = new Map | undefined>(); - const bundledIdsByProvider = new Map>(); - this.#availableModelsCache = this.#models.filter(model => { - if (!this.#isModelAvailable(model, disabledProviders)) return false; - - let liveIds = authoritativeDiscoveryIds.get(model.provider); - if (!authoritativeDiscoveryIds.has(model.provider)) { - liveIds = this.#getAuthoritativeDiscoveredModelIds(model.provider); - authoritativeDiscoveryIds.set(model.provider, liveIds); - } - // Undefined means discovery has not run successfully for the current - // provider context. Keep the bundled catalog as the explicit fallback. - if (liveIds === undefined) return true; - if (this.#hasCustomModelOverlay(model.provider, model.id)) return true; - - const activity = this.#providerActivity.get(model.provider); - if (!activity?.staticModelIds.has(model.id)) return liveIds.has(model.id); - - let bundledIds = bundledIdsByProvider.get(model.provider); - if (!bundledIds) { - bundledIds = new Set( - (getBundledModels(model.provider as Parameters[0]) as Model[]).map( - candidate => candidate.id, - ), - ); - bundledIdsByProvider.set(model.provider, bundledIds); - } - return !bundledIds.has(model.id) || liveIds.has(model.id); - }); + this.#availableModelsCache = this.#models.filter(model => this.#isModelAvailable(model, disabledProviders)); this.#availableModelsDisabledProviders = disabledProviderKey; this.#availableModelsEnvFingerprint = envFingerprint; return this.#availableModelsCache; } - /** - * Return the current live model ids only when discovery produced an - * authoritative catalog for the provider and endpoint. An unavailable or - * failed discovery returns undefined so callers retain the bundled fallback. - */ - #getAuthoritativeDiscoveredModelIds(provider: string): ReadonlySet | undefined { - const descriptorEvidence = this.#descriptorDiscoveryEvidence.get(provider); - if (descriptorEvidence?.profileFresh && descriptorEvidence.profileModelIds !== undefined) { - try { - if ( - descriptorEvidence.authGeneration === this.#getProviderEvidenceGeneration(provider) && - descriptorEvidence.profileEndpoint === - this.#normalizeDiscoveryEvidenceEndpoint(this.#getProviderBaseUrlForDiscovery(provider) ?? "") - ) { - return descriptorEvidence.profileModelIds; - } - } catch { - // A provider context that can no longer be verified must use its fallback. - } - } - - const configuredEvidence = this.#configuredDiscoveryEvidence.get(provider); - if (!configuredEvidence) return undefined; - const discoveryState = this.#discoveryManager.getState(provider); - if ( - discoveryState?.status !== "ok" && - discoveryState?.status !== "cached" && - discoveryState?.status !== "empty" - ) { - return undefined; - } - try { - return configuredEvidence.authGeneration === this.#getProviderEvidenceGeneration(provider) && - configuredEvidence.endpoint === - this.#normalizeDiscoveryEvidenceEndpoint(this.#getProviderBaseUrlForDiscovery(provider) ?? "") - ? configuredEvidence.modelIds - : undefined; - } catch { - return undefined; - } - } - #synchronizeEnvironmentCredentials(): void { // Runtime registrations own a provider while their credential is present. // Refresh them first so a missing runtime env key can hand ownership back to @@ -5775,49 +5690,6 @@ export class ModelRegistry { } } - /** - * Get authenticated models, excluding bundled entries that a fresh provider - * catalog has positively shown to be unavailable. Bundled entries remain - * usable until live catalog evidence exists so offline startup is unchanged. - */ - getAvailableForProfileActivation(): Model[] { - const bundledIdsByProvider = new Map>(); - // Evidence staleness depends only on the provider, and resolving it scans the - // catalog for the provider base URL. Decide it once per provider per call so a - // multi-thousand-model catalog is not rescanned for every model. - const staleEvidenceByProvider = new Map(); - return this.getAvailable().filter(model => { - const evidence = this.#descriptorDiscoveryEvidence.get(model.provider); - if (!evidence?.profileFresh || evidence.profileModelIds === undefined) return true; - if (this.#hasCustomModelOverlay(model.provider, model.id)) return true; - let bundledModelIds = bundledIdsByProvider.get(model.provider); - if (!bundledModelIds) { - bundledModelIds = new Set( - (getBundledModels(model.provider as Parameters[0]) as Model[]).map( - candidate => candidate.id, - ), - ); - bundledIdsByProvider.set(model.provider, bundledModelIds); - } - if (!bundledModelIds.has(model.id)) return true; - let staleEvidence = staleEvidenceByProvider.get(model.provider); - if (staleEvidence === undefined) { - staleEvidence = - evidence.authGeneration !== this.#getProviderEvidenceGeneration(model.provider) || - evidence.profileEndpoint !== - this.#normalizeDiscoveryEvidenceEndpoint(this.#getProviderBaseUrlForDiscovery(model.provider) ?? ""); - staleEvidenceByProvider.set(model.provider, staleEvidence); - } - if (staleEvidence) return true; - return evidence.profileModelIds.has(model.id); - }); - } - - #hasCustomModelOverlay(provider: string, id: string): boolean { - const matches = (overlay: CustomModelOverlay) => overlay.provider === provider && overlay.id === id; - return this.#customModelOverlays.some(matches) || this.#runtimeModelOverlays.some(matches); - } - #hasFreshOrStaticModelEvidence(model: Model): boolean { const evidence = this.#providerActivity.get(model.provider); if ( diff --git a/packages/coding-agent/src/modes/components/model-selector.ts b/packages/coding-agent/src/modes/components/model-selector.ts index d626cd96df7..dfcfe1334e8 100644 --- a/packages/coding-agent/src/modes/components/model-selector.ts +++ b/packages/coding-agent/src/modes/components/model-selector.ts @@ -1369,10 +1369,7 @@ export class ModelSelectorComponent extends Container { } #getProfileAvailableModels(): Model[] { - const getAvailableForProfileActivation = this.#modelRegistry.getAvailableForProfileActivation; - return typeof getAvailableForProfileActivation === "function" - ? getAvailableForProfileActivation.call(this.#modelRegistry) - : this.#modelRegistry.getAvailable(); + return this.#modelRegistry.getAvailable(); } #createProfileResolutionRegistry(availableModels: readonly Model[]) { diff --git a/packages/coding-agent/src/sdk/host/session-runtime.ts b/packages/coding-agent/src/sdk/host/session-runtime.ts index 82a541037aa..7f4348ed0ec 100644 --- a/packages/coding-agent/src/sdk/host/session-runtime.ts +++ b/packages/coding-agent/src/sdk/host/session-runtime.ts @@ -2109,12 +2109,7 @@ function createQuerySurface( }; const getProfileCredentialSessionId = () => ctx.credentialSessionId ?? id; const profileSettings = (options.settings ?? ctx.settings) as Pick | undefined; - const getProfileAvailableModels = (): Model[] => { - const getAvailableForProfileActivation = ctx.modelRegistry.getAvailableForProfileActivation; - return typeof getAvailableForProfileActivation === "function" - ? getAvailableForProfileActivation.call(ctx.modelRegistry) - : ctx.modelRegistry.getAvailable(); - }; + const getProfileAvailableModels = (): Model[] => ctx.modelRegistry.getAvailable(); const resolveProfileAvailability = async ( profile: ModelProfileDefinition, authenticatedProviders: ReadonlySet, diff --git a/packages/coding-agent/src/sdk/session.ts b/packages/coding-agent/src/sdk/session.ts index ebe56be8150..2e7b49bfb34 100644 --- a/packages/coding-agent/src/sdk/session.ts +++ b/packages/coding-agent/src/sdk/session.ts @@ -4471,19 +4471,6 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} // Re-resolve the allowed set: extension factories above may have // registered providers/models that weren't visible at startup. const allowedFallbackCandidates = await resolveAllowedModels(modelRegistry, settings, modelMatchPreferences); - // A fresh provider discovery can disprove a bundled model while the - // general available catalog retains it for offline/profile compatibility. - // Exclude only those positively disproved bundled entries from the - // unconfigured startup path; explicit model/profile resolution above keeps - // its existing precedence and semantics. - const profileAvailableKeys = new Set( - modelRegistry - .getAvailableForProfileActivation() - .map(candidate => `${candidate.provider}\u0000${candidate.id}`), - ); - const fallbackCandidates = allowedFallbackCandidates.filter(candidate => - profileAvailableKeys.has(`${candidate.provider}\u0000${candidate.id}`), - ); // Candidate order is not a quality signal: catalogs sort retired models // ahead of current ones whenever their IDs carry older date suffixes, so // an unconfigured install would otherwise start on a model its provider @@ -4491,7 +4478,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} // first — the same table `findInitialModel` consults — and only then fall // back to catalog order. for (const candidate of orderByProviderDefaultFirst( - fallbackCandidates, + allowedFallbackCandidates, modelRegistry.automaticProviderOrder(credentialSessionId), )) { if (await hasModelApiKey(candidate)) { diff --git a/packages/coding-agent/test/cli-args-mpreset.test.ts b/packages/coding-agent/test/cli-args-mpreset.test.ts index 91cfaabc189..ea93231577f 100644 --- a/packages/coding-agent/test/cli-args-mpreset.test.ts +++ b/packages/coding-agent/test/cli-args-mpreset.test.ts @@ -46,7 +46,7 @@ function fakeRegistry( getError: () => undefined, getApiKeyForProvider: async () => "key", getAll: () => activeModels, - getAvailableForProfileActivation: () => + getAvailable: () => options.excludeModelsFromProfileActivationUntilRefresh && !modelsRefreshed ? [] : activeModels, async refresh(strategy = "online-if-uncached", credentialSessionId?: string) { registry.refreshCalls.push(strategy); @@ -1236,7 +1236,7 @@ test("interactive retry skips later profile preparation after a newer selection" const models = [model("default-provider", "default"), model("explicit-provider", "default")]; const base = fakeRegistry([defaultProfile, explicitProfile]); base.getAll = () => models; - base.getAvailableForProfileActivation = () => models; + base.getAvailable = () => models; let credentialAvailable = false; let retryProbeStarted = false; let explicitProviderRequests = 0; @@ -1308,7 +1308,7 @@ test("serializes replacement profile preparation after stale recovery rollback", ]; const registry = fakeRegistry([startupProfile, replacementProfile]); registry.getAll = () => models; - registry.getAvailableForProfileActivation = () => models; + registry.getAvailable = () => models; const settings = Settings.isolated(); const flushStarted = Promise.withResolvers(); const allowFlush = Promise.withResolvers(); diff --git a/packages/coding-agent/test/codex-profile-pinned-discovery.test.ts b/packages/coding-agent/test/codex-profile-pinned-discovery.test.ts index 340528323a2..5443aeb095c 100644 --- a/packages/coding-agent/test/codex-profile-pinned-discovery.test.ts +++ b/packages/coding-agent/test/codex-profile-pinned-discovery.test.ts @@ -4,7 +4,7 @@ import { AuthStorage, closeModelCache, SqliteAuthCredentialStore } from "@gajae- import { getAgentDir, hookFetch, setAgentDir, TempDir } from "@gajae-code/utils"; import { ModelRegistry } from "../src/config/model-registry"; -test("a paid Codex pin replaces a cached free catalog before profile activation", async () => { +test("Codex discovery follows the pinned account without plan-hiding bundled models", async () => { using tempDir = TempDir.createSync("@gjc-codex-pin-catalog-"); const previousAgentDir = getAgentDir(); closeModelCache(); @@ -48,13 +48,15 @@ test("a paid Codex pin replaces a cached free catalog before profile activation" const registry = new ModelRegistry(authStorage, path.join(tempDir.path(), "models.yml")); const availableIds = () => registry - .getAvailableForProfileActivation() + .getAvailable() .filter(model => model.provider === "openai-codex") .map(model => model.id); try { await registry.refreshProvider("openai-codex", "online", "free-session"); - expect(availableIds()).not.toContain("gpt-6-astra"); - expect(availableIds()).not.toContain("gpt-5.6-sol"); + // A free account's plan-scoped catalog omits these ids, but GJC never deletes + // a bundled entry because the signed-in plan lacks it. + expect(availableIds()).toContain("gpt-6-astra"); + expect(availableIds()).toContain("gpt-5.6-sol"); await registry.refreshProvider("openai-codex", "online-if-uncached", "paid-session"); expect(requests).toEqual(["free", "paid"]); diff --git a/packages/coding-agent/test/model-preset-landing-redteam-qa.test.ts b/packages/coding-agent/test/model-preset-landing-redteam-qa.test.ts index 76641d40fce..4291fa1b041 100644 --- a/packages/coding-agent/test/model-preset-landing-redteam-qa.test.ts +++ b/packages/coding-agent/test/model-preset-landing-redteam-qa.test.ts @@ -190,9 +190,7 @@ function cursorRowLabel(selector: ModelSelectorComponent): string | undefined { describe("preset landing adversarial QA", () => { test("defers browser catalog materialization until model browsing starts", async () => { - const registry = Object.assign(createRegistry(["openai-codex"]), { - getAvailableForProfileActivation: vi.fn(() => [codexModel, ...builtinCodexModels]), - }); + const registry = createRegistry(["openai-codex"]); const ui = { requestRender: vi.fn() } as unknown as TUI; const selector = new ModelSelectorComponent( ui, @@ -207,14 +205,12 @@ describe("preset landing adversarial QA", () => { await rendered(selector); expect(registry.refreshStatic).toHaveBeenCalledTimes(1); expect(registry.refresh).not.toHaveBeenCalled(); - expect(registry.getAvailable).not.toHaveBeenCalled(); expect(registry.getCanonicalModelSelections).not.toHaveBeenCalled(); - expect(registry.getAvailableForProfileActivation).toHaveBeenCalled(); + expect(registry.getAvailable).toHaveBeenCalled(); selector.handleInput("g"); const text = await rendered(selector); expect(registry.refresh).toHaveBeenCalledTimes(1); - expect(registry.getAvailable).toHaveBeenCalledTimes(1); expect(registry.getCanonicalModelSelections).toHaveBeenCalledTimes(1); expect(text).toContain("gpt-5.5"); }); diff --git a/packages/coding-agent/test/model-profile-activation.test.ts b/packages/coding-agent/test/model-profile-activation.test.ts index 9d52dcf8d21..42929bb4005 100644 --- a/packages/coding-agent/test/model-profile-activation.test.ts +++ b/packages/coding-agent/test/model-profile-activation.test.ts @@ -412,8 +412,7 @@ describe("model profile activation", () => { session, modelRegistry: { ...baseRegistry, - getAvailable: baseRegistry.getAll, - getAvailableForProfileActivation: () => available, + getAvailable: () => available, } as unknown as ModelRegistry, settings: Settings.isolated(), profileName: "claude-opus", @@ -445,7 +444,7 @@ describe("model profile activation", () => { expect(prepared.defaultThinkingLevel).toBe(ThinkingLevel.Medium); }); - test("built-in claude-opus skips a bundled Opus 5.5 absent from fresh live catalog evidence", async () => { + test("built-in claude-opus keeps a bundled Opus 5.5 omitted from fresh live catalog evidence", async () => { const tempDir = TempDir.createSync("@gjc-profile-live-catalog-"); const authStorage = await AuthStorage.create(`${tempDir.path()}/auth.db`); try { @@ -473,31 +472,17 @@ describe("model profile activation", () => { await registry.refreshProvider("anthropic", "online"); expect(requests.some(url => url.endsWith("/models"))).toBe(true); - // Fresh, authoritative live evidence makes the live catalog the selectable - // list, so a bundled Opus 5.5 the provider did not enroll is not selectable - // either. The bundled catalog is only the fallback when that evidence is - // unavailable (#5720, #5746). - expect( - registry - .getAvailable() - .filter(candidate => candidate.provider === "anthropic") - .map(candidate => candidate.id) - .sort(), - ).toEqual(["claude-opus-4-6", "claude-sonnet-5"]); - expect( - registry - .getAvailableForProfileActivation() - .filter(candidate => candidate.provider === "anthropic") - .map(candidate => candidate.id), - ).not.toContain("claude-opus-5-5"); - const expectedIds = new Set(["claude-opus-4-6", "claude-sonnet-5"]); - expect( - registry.getAvailableForProfileActivation().filter(candidate => candidate.provider === "anthropic"), - ).toEqual( - registry - .getAvailable() - .filter(candidate => candidate.provider === "anthropic" && expectedIds.has(candidate.id)), - ); + // Live discovery enriches the catalog but never removes a bundled entry: + // Opus 5.5 stays selectable even though the live response omitted it, and + // the provider reports its own error if the account cannot use it. + const anthropicIds = registry + .getAvailable() + .filter(candidate => candidate.provider === "anthropic") + .map(candidate => candidate.id) + .sort(); + expect(anthropicIds).toContain("claude-opus-5-5"); + expect(anthropicIds).toContain("claude-opus-4-6"); + expect(anthropicIds).toContain("claude-sonnet-5"); const session = fakeSession(); session.model = undefined; session.thinkingLevel = undefined; @@ -509,10 +494,8 @@ describe("model profile activation", () => { profileName: "claude-opus", }); - expect(prepared.defaultModel).toMatchObject({ provider: "anthropic", id: "claude-opus-4-6" }); - expect(prepared.defaultResolutionSkips).toEqual([ - { selector: "anthropic/claude-opus-5-5:medium", reason: "unknown_model" }, - ]); + expect(prepared.defaultModel).toMatchObject({ provider: "anthropic", id: "claude-opus-5-5" }); + expect(prepared.defaultResolutionSkips).toEqual([]); expect(prepared.agentModelOverrides).toMatchObject({ executor: "anthropic/claude-sonnet-5-5", planner: ["anthropic/claude-opus-5-5:medium", "anthropic/claude-opus-4-6:low"], @@ -525,17 +508,15 @@ describe("model profile activation", () => { } }); - test("materialization resolves a bare assignment against the profile-activation catalog, not the broadened general one", () => { + test("materialization resolves a bare assignment against the available catalog, not the full catalog", () => { const opus5 = model("anthropic", "claude-opus-5-5"); const opus46 = model("anthropic", "claude-opus-4-6"); const baseRegistry = fakeRegistry(); const registry = { ...baseRegistry, getAll: () => [opus5, opus46, ...baseRegistry.getAll()], - // Fresh live descriptor evidence omitted the bundled Opus 5.5, so the - // profile-activation catalog is narrower than the general catalog. - getAvailable: () => [opus5, opus46], - getAvailableForProfileActivation: () => [opus46], + // Only Opus 4.6 is usable; the full catalog still lists Opus 5.5. + getAvailable: () => [opus46], lookupAliasExists: (alias: string) => alias === "opus", resolveModelByLookupAlias: (alias: string, lookupOptions?: { candidates?: readonly Model[] }) => alias === "opus" @@ -553,13 +534,12 @@ describe("model profile activation", () => { }); expect(materialized).toBe(true); - // The broadened general catalog still lists Opus 5.5, but the assignment is - // persisted for later profile execution, so it must resolve against the - // catalog that fresh live profile evidence narrowed. + // The full catalog still lists Opus 5.5, but the assignment is persisted for + // later profile execution, so it must resolve against the usable catalog. expect(settings.get("modelRoles")).toMatchObject({ default: "anthropic/claude-opus-4-6" }); }); - test("durable default recovery excludes a bundled default absent from the activation catalog", async () => { + test("durable default recovery excludes a bundled default absent from the available catalog", async () => { const profile: ModelProfileDefinition = { name: "excluded-bundled-default", requiredProviders: ["anthropic"], @@ -567,14 +547,12 @@ describe("model profile activation", () => { source: "builtin", }; const baseRegistry = fakeRegistry({ profiles: [profile] }); - const getAvailableForProfileActivation = vi.fn(() => [] as Model[]); const registry = { ...baseRegistry, - getAvailable: baseRegistry.getAll, - getAvailableForProfileActivation, + getAvailable: () => baseRegistry.getAll().filter(candidate => candidate.id !== "claude-opus-5-5"), } as unknown as ModelRegistry; - expect(registry.getAvailable().some(candidate => candidate.id === "claude-opus-5-5")).toBe(true); + expect(registry.getAvailable().some(candidate => candidate.id === "claude-opus-5-5")).toBe(false); const recovery = await resolveModelProfileDefaultChain({ modelRegistry: registry, settings: Settings.isolated(), @@ -582,7 +560,6 @@ describe("model profile activation", () => { credentialSessionId: "resume-session", }); - expect(getAvailableForProfileActivation).toHaveBeenCalledTimes(1); expect(recovery).toMatchObject({ profileName: profile.name, entries: ["anthropic/claude-opus-5-5"], @@ -859,7 +836,6 @@ describe("model profile activation", () => { ...baseRegistry, getAll: () => [...baseRegistry.getAll(), proxyModel], getAvailable: () => [proxyModel], - getAvailableForProfileActivation: () => [proxyModel], getConfiguredProviderIds: () => [], isKnownProvider: (provider: string) => provider === "provider-a" || provider === "opencodex", getApiKeyForProvider: async (provider: string) => (provider === "opencodex" ? kNoAuth : "key-provider-a"), @@ -1020,18 +996,20 @@ describe("model profile activation", () => { await registry.refreshProvider("anthropic", "online"); expect( registry - .getAvailableForProfileActivation() + .getAvailable() .some(candidate => candidate.provider === "anthropic" && candidate.id === "claude-opus-5-5"), ).toBe(true); const notificationsAfterNonEmpty = catalogNotifications; await registry.refreshProvider("anthropic", "online"); expect(catalogNotifications).toBeGreaterThan(notificationsAfterNonEmpty); + // The bundled Opus 5.5 survives the now-empty live catalog, and the + // catalog-change notification still fires on the evidence change. expect( registry - .getAvailableForProfileActivation() + .getAvailable() .some(candidate => candidate.provider === "anthropic" && candidate.id === "claude-opus-5-5"), - ).toBe(false); + ).toBe(true); } finally { authStorage.close(); tempDir.removeSync(); @@ -1114,7 +1092,7 @@ describe("model profile activation", () => { ); expect( registry - .getAvailableForProfileActivation() + .getAvailable() .some(candidate => candidate.provider === "anthropic" && candidate.id === "claude-opus-5-5"), ).toBe(true); @@ -1139,7 +1117,7 @@ describe("model profile activation", () => { ); expect( registry - .getAvailableForProfileActivation() + .getAvailable() .some(candidate => candidate.provider === "anthropic" && candidate.id === "claude-opus-5-5"), ).toBe(true); } finally { @@ -2920,7 +2898,6 @@ describe("model profile activation", () => { getApiKeyForProvider: runtimeRegistry.getApiKeyForProvider.bind(runtimeRegistry), getAll: runtimeRegistry.getAll.bind(runtimeRegistry), getAvailable: runtimeRegistry.getAvailable.bind(runtimeRegistry), - getAvailableForProfileActivation: runtimeRegistry.getAvailableForProfileActivation.bind(runtimeRegistry), } as unknown as ModelRegistry; const prepared = await prepareModelProfileActivation({ diff --git a/packages/coding-agent/test/model-registry.test.ts b/packages/coding-agent/test/model-registry.test.ts index 5adfe404bc5..42c229388c6 100644 --- a/packages/coding-agent/test/model-registry.test.ts +++ b/packages/coding-agent/test/model-registry.test.ts @@ -1223,7 +1223,7 @@ describe("ModelRegistry", () => { expect(registry.getAvailable()).not.toBe(initial); }); - test("uses successful LiteLLM discovery as authoritative for available models", async () => { + test("adds successful LiteLLM discovery to the available models without dropping bundled ids", async () => { const liveIds = ["openai/gpt-5.4", "proxy-only-model"]; const bundledIds = getBundledModels("litellm").map(model => model.id); expect(bundledIds).toContain("openai/gpt-5.4"); @@ -1246,7 +1246,7 @@ describe("ModelRegistry", () => { .filter(model => model.provider === "litellm") .map(model => model.id) .sort(); - expect(availableIds).toEqual([...liveIds].sort()); + expect(availableIds).toEqual([...new Set([...bundledIds, ...liveIds])].sort()); } finally { await registry.dispose(); } @@ -7170,7 +7170,8 @@ describe("ModelRegistry", () => { }); const hasAvailableOpenAiModel = () => registry.getAvailable().some(model => model.provider === provider && model.id === bundledModel.id); - expect(hasAvailableOpenAiModel()).toBe(false); + // Empty discovery no longer deletes bundled entries from the catalog. + expect(hasAvailableOpenAiModel()).toBe(true); let modelsListRequests = 0; using _hook = hookFetch(() => { @@ -7186,7 +7187,7 @@ describe("ModelRegistry", () => { models: [], fetchedAt: expect.any(Number), }); - expect(hasAvailableOpenAiModel()).toBe(false); + expect(hasAvailableOpenAiModel()).toBe(true); await registry.dispose(); }); diff --git a/packages/coding-agent/test/model-selector-profiles.test.ts b/packages/coding-agent/test/model-selector-profiles.test.ts index 5b73772f72b..8c4bb72f064 100644 --- a/packages/coding-agent/test/model-selector-profiles.test.ts +++ b/packages/coding-agent/test/model-selector-profiles.test.ts @@ -404,14 +404,11 @@ describe("model selector profiles", () => { modelMapping: { default: "provider-a/default", executor: "provider-a/missing" }, source: "registry", }; - const registry = createRegistry() as unknown as TestModelRegistry & { - getAvailableForProfileActivation: () => Model[]; - }; + const registry = createRegistry() as unknown as TestModelRegistry; registry.getModelProfiles = () => new Map([[registryProfile.name, registryProfile]]); registry.getModelProfile = (name: string) => (name === registryProfile.name ? registryProfile : undefined); - registry.getAvailable = () => [defaultModel, model("provider-a", "missing")]; + registry.getAvailable = () => [defaultModel]; registry.getAll = registry.getAvailable; - registry.getAvailableForProfileActivation = () => [defaultModel]; const selector = createSelector(() => {}, { registry }); await Bun.sleep(10); selector.handleInput("\x1b[C"); @@ -514,13 +511,12 @@ describe("model selector profiles", () => { const refreshGate = Promise.withResolvers<"key">(); let delayProviderAuth = false; const registry = createRegistry() as unknown as TestModelRegistry & { - getAvailableForProfileActivation: () => Model[]; getApiKeyForProvider: (provider: string) => Promise; }; registry.getModelProfiles = () => new Map(profiles); registry.getModelProfile = (name: string) => profiles.get(name); registry.getAvailableModelProfileNames = () => [...profiles.keys()]; - registry.getAvailableForProfileActivation = () => + registry.getAvailable = () => settings.get("disabledProviders").includes("provider-a") ? [] : [defaultModel, alternateModel]; registry.getApiKeyForProvider = async provider => { if (delayProviderAuth && provider === "provider-a") return refreshGate.promise; diff --git a/packages/coding-agent/test/sdk-model-selection.test.ts b/packages/coding-agent/test/sdk-model-selection.test.ts index be7d8cc0339..fa162533b81 100644 --- a/packages/coding-agent/test/sdk-model-selection.test.ts +++ b/packages/coding-agent/test/sdk-model-selection.test.ts @@ -1067,67 +1067,6 @@ describe("createAgentSession deferred model pattern resolution", () => { } }); - test("skips a curated default disproved by fresh provider discovery", async () => { - const staleAuth = await AuthStorage.create(path.join(tempDir, `stale-default-${Snowflake.next()}.db`)); - const staleRegistry = new ModelRegistry( - staleAuth, - path.join(tempDir, `stale-default-${Snowflake.next()}.yml`), - undefined, - { automaticRefresh: false }, - ); - const currentModelId = "claude-opus-4-6"; - try { - staleAuth.setRuntimeApiKey("anthropic", "anthropic-test-key"); - using _hook = hookFetch(input => { - const url = String(input); - if (url === "https://models.dev/api.json") { - return new Response(JSON.stringify({ anthropic: { models: {} } }), { - headers: { "Content-Type": "application/json" }, - }); - } - if (!url.endsWith("/models")) throw new Error(`Unexpected model discovery request: ${input}`); - return new Response(JSON.stringify({ data: [{ id: currentModelId }] }), { - headers: { "Content-Type": "application/json" }, - }); - }); - await staleRegistry.refreshProvider("anthropic", "online"); - - const curatedDefault = DEFAULT_MODEL_PER_PROVIDER.anthropic; - // Fresh, authoritative live evidence makes the live catalog the selectable - // list, so a curated default the provider did not enroll is not selectable - // either. The curated default is only the fallback while that evidence is - // absent (#5720, #5746). - expect( - staleRegistry - .getAvailable() - .filter(model => model.provider === "anthropic") - .map(model => model.id) - .sort(), - ).toEqual([currentModelId]); - expect(staleRegistry.getAvailableForProfileActivation().some(model => model.id === curatedDefault)).toBe( - false, - ); - - const settings = Settings.isolated({ - enabledModels: [`anthropic/${curatedDefault}`, `anthropic/${currentModelId}`], - }); - const { session } = await createAgentSession({ - ...buildSessionOptions(), - authStorage: staleAuth, - modelRegistry: staleRegistry, - settings, - }); - try { - expect(session.model).toMatchObject({ provider: "anthropic", id: currentModelId }); - } finally { - await session.dispose(); - } - } finally { - staleRegistry.dispose(); - staleAuth.close(); - } - }); - test( "same-provider sibling registry overrides do not block startup or session pin validation", async () => { diff --git a/packages/coding-agent/test/sdk-q27-model-profiles.test.ts b/packages/coding-agent/test/sdk-q27-model-profiles.test.ts index 687303fc4d1..15d9fb1b4b4 100644 --- a/packages/coding-agent/test/sdk-q27-model-profiles.test.ts +++ b/packages/coding-agent/test/sdk-q27-model-profiles.test.ts @@ -195,8 +195,7 @@ describe("Q27 models.profiles.list", () => { getError: () => undefined, getApiKeyForProvider: async () => "provider-a-key", getConfiguredProviderIds: () => [], - getAvailable: () => [defaultModel, model("provider-a", "missing")], - getAvailableForProfileActivation: () => [defaultModel], + getAvailable: () => [defaultModel], getApiKey: async () => "provider-a-key", resolveCanonicalModel: () => undefined, getCanonicalVariants: () => [], @@ -259,7 +258,6 @@ describe("Q27 models.profiles.list", () => { getApiKeyForProvider: async (provider: string) => credentials.get(provider), getConfiguredProviderIds: () => [], getAvailable: () => [defaultModel, fallbackModel], - getAvailableForProfileActivation: () => [defaultModel, fallbackModel], getApiKey: async (candidate: Model) => credentials.get(candidate.provider), resolveCanonicalModel: () => undefined, getCanonicalVariants: () => [], @@ -320,7 +318,6 @@ describe("Q27 models.profiles.list", () => { getConfiguredProviderIds: () => [], getApiKeyForProvider: async (id: string) => (id === provider ? apiKey : undefined), getAvailable: () => [proxyModel], - getAvailableForProfileActivation: () => [proxyModel], resolveCanonicalModel: () => undefined, getCanonicalVariants: () => [], getCanonicalId: () => undefined, @@ -368,7 +365,6 @@ describe("Q27 models.profiles.list", () => { getApiKeyForProvider: async (provider: string) => (provider === "provider-a" ? "provider-a-key" : undefined), getConfiguredProviderIds: () => ["litellm"], getAvailable: () => [model("provider-a", "default")], - getAvailableForProfileActivation: () => [model("provider-a", "default")], getApiKey: async () => "provider-a-key", resolveCanonicalModel: () => undefined, getCanonicalVariants: () => [], @@ -419,7 +415,6 @@ describe("Q27 models.profiles.list", () => { getApiKeyForProvider: async (provider: string) => (provider === "provider-a" ? "provider-a-key" : undefined), getConfiguredProviderIds: () => [], getAvailable: () => [model("provider-a", "default")], - getAvailableForProfileActivation: () => [model("provider-a", "default")], getApiKey: async () => "provider-a-key", resolveCanonicalModel: () => undefined, getCanonicalVariants: () => [],