diff --git a/packages/shared/src/projectSettings.test.ts b/packages/shared/src/projectSettings.test.ts index 1cd6baf133bcc..a5edf854c3aec 100644 --- a/packages/shared/src/projectSettings.test.ts +++ b/packages/shared/src/projectSettings.test.ts @@ -119,6 +119,32 @@ describe("resolveProjectSettings", () => { expect(resolved.sources.defaultModelSelection).toBe("environment"); }); + // regression: #17556 a hidden project default silently became the catalog's first model + it.each([ + ["hidden", ["gpt-5.6-terra", "gpt-5.6-sol"], [], "gpt-6.1-sol", "environment"], + ["visible", ["gpt-5.6-terra"], [], "gpt-5.6-sol", "project"], + ["custom", ["gpt-5.6-sol"], ["gpt-5.6-sol"], "gpt-5.6-sol", "project"], + ] as const)( + "resolves a %s project default model against the environment default", + (_visibility, hiddenModels, customModels, expectedModel, expectedSource) => { + const codex = ProviderInstanceId.make("codex"); + const settings = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { + defaultModelSelection: createModelSelection(codex, "gpt-6.1-sol"), + providerInstances: { + [codex]: { driver: ProviderDriverKind.make("codex"), config: { customModels } }, + }, + providerModelPreferences: { [codex]: { hiddenModels, modelOrder: [] } }, + projectSettingsOverrides: { + [projectId]: { defaultModelSelection: createModelSelection(codex, "gpt-5.6-sol") }, + }, + }); + const resolved = resolveProjectSettings(settings, projectId); + expect(resolved.settings.defaultModelSelection?.model).toBe(expectedModel); + expect(resolved.sources.defaultModelSelection).toBe(expectedSource); + expect(resolved.overrides.defaultModelSelection?.model).toBe("gpt-5.6-sol"); + }, + ); + it("moves text generation off an instance the project turned off", () => { const codex = ProviderInstanceId.make("codex"); const settings = { diff --git a/packages/shared/src/projectSettings.ts b/packages/shared/src/projectSettings.ts index bde695fb21386..e6a9ac22418a0 100644 --- a/packages/shared/src/projectSettings.ts +++ b/packages/shared/src/projectSettings.ts @@ -14,6 +14,7 @@ import { type ThreadEnvMode, type WorktreeCleanupRules, } from "@t3tools/contracts"; +import { readCustomModelEntries } from "./model.ts"; import { normalizeProjectPathForComparison } from "./path.ts"; import { fallbackTextGenerationModelSelection } from "./serverSettings.ts"; @@ -148,6 +149,21 @@ function resolveProjectOverrides( ) { continue; } + // Clients resolve a hidden model to the catalog's first visible one, so a + // hidden project default would silently pick a model nobody chose. + // Custom models stay visible even when listed as hidden, as in the clients. + if (key === "defaultModelSelection" && value !== null) { + const { instanceId, model } = value as ModelSelection; + const config = settings.providerInstances[instanceId]?.config as + | { customModels?: unknown } + | undefined; + if ( + settings.providerModelPreferences?.[instanceId]?.hiddenModels.includes(model) && + !readCustomModelEntries(config?.customModels).some((entry) => entry.slug === model) + ) { + continue; + } + } effective[key] = value; sources[key] = "project"; }