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
Original file line number Diff line number Diff line change
@@ -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.
20 changes: 5 additions & 15 deletions packages/coding-agent/src/config/model-profile-activation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,7 @@ type ModelProfileActivationSession = Pick<
| "resolveModelByLookupAlias"
| "authStorage"
| "isCredentiallessProvider"
> &
Partial<Pick<ModelRegistry, "getAvailableForProfileActivation">>;
>;
getConfiguredModelChainState?: (role: string) => ConfiguredModelChainState | undefined;
};

Expand Down Expand Up @@ -109,7 +108,6 @@ export interface PrepareModelProfileActivationOptions {
Pick<
ModelRegistry,
| "getAvailable"
| "getAvailableForProfileActivation"
| "resolveModelByLookupAlias"
| "lookupAliasExists"
| "clearCanonicalVariant"
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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) {
Expand Down
138 changes: 5 additions & 133 deletions packages/coding-agent/src/config/model-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1774,9 +1774,6 @@ export class ModelRegistry {
{
fresh: boolean;
modelIds: ReadonlySet<string>;
profileModelIds?: ReadonlySet<string>;
profileFresh?: boolean;
profileEndpoint?: string;
authGeneration: string;
endpoint: string;
}
Expand Down Expand Up @@ -3332,9 +3329,6 @@ export class ModelRegistry {
| {
fresh: boolean;
modelIds: ReadonlySet<string>;
profileModelIds?: ReadonlySet<string>;
profileFresh?: boolean;
profileEndpoint?: string;
authGeneration: string;
endpoint: string;
}
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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),
});
Expand Down Expand Up @@ -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<Api>[] {
this.#synchronizeEnvironmentCredentials();
Expand All @@ -5686,82 +5671,12 @@ export class ModelRegistry {
) {
return this.#availableModelsCache;
}
const authoritativeDiscoveryIds = new Map<string, ReadonlySet<string> | undefined>();
const bundledIdsByProvider = new Map<string, Set<string>>();
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<typeof getBundledModels>[0]) as Model<Api>[]).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));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep filtering stale dynamically discovered model IDs

When a descriptor-backed provider is refreshed under a different account or after its live catalog changes, #mergeDiscoveredModels() only merges the new snapshot into #models; it does not remove previously discovered IDs. The removed authoritative filter used profileModelIds to hide such non-bundled IDs once the current catalog omitted them, but this unconditional filter now exposes them indefinitely. For example, a paid Codex-only model remains selectable after switching to a free account, and a model unloaded from vLLM remains in /model and can be auto-selected. Preserve bundled/static entries as intended, but continue filtering or evicting dynamically learned IDs that are absent from current discovery evidence.

Useful? React with 👍 / 👎.

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<string> | 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
Expand All @@ -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<Api>[] {
const bundledIdsByProvider = new Map<string, Set<string>>();
// 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<string, boolean>();
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<typeof getBundledModels>[0]) as Model<Api>[]).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<Api>): boolean {
const evidence = this.#providerActivity.get(model.provider);
if (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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[]) {
Expand Down
7 changes: 1 addition & 6 deletions packages/coding-agent/src/sdk/host/session-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2109,12 +2109,7 @@ function createQuerySurface(
};
const getProfileCredentialSessionId = () => ctx.credentialSessionId ?? id;
const profileSettings = (options.settings ?? ctx.settings) as Pick<Settings, "get"> | undefined;
const getProfileAvailableModels = (): Model<Api>[] => {
const getAvailableForProfileActivation = ctx.modelRegistry.getAvailableForProfileActivation;
return typeof getAvailableForProfileActivation === "function"
? getAvailableForProfileActivation.call(ctx.modelRegistry)
: ctx.modelRegistry.getAvailable();
};
const getProfileAvailableModels = (): Model<Api>[] => ctx.modelRegistry.getAvailable();
const resolveProfileAvailability = async (
profile: ModelProfileDefinition,
authenticatedProviders: ReadonlySet<string>,
Expand Down
15 changes: 1 addition & 14 deletions packages/coding-agent/src/sdk/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4471,27 +4471,14 @@ 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
// has already withdrawn. Sweep each known provider's curated default
// 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)) {
Expand Down
6 changes: 3 additions & 3 deletions packages/coding-agent/test/cli-args-mpreset.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<void>();
const allowFlush = Promise.withResolvers<void>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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"]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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");
});
Expand Down
Loading
Loading