diff --git a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx index 43c4419962a02..b10af2fd1dda4 100644 --- a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx +++ b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx @@ -7,13 +7,16 @@ import { createModelSelection } from "@t3tools/shared/model"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { useNavigate } from "@tanstack/react-router"; +import { useClientSettings } from "../../hooks/useSettings"; import { + defaultModelSelectionsAreMixed, getCustomModelOptionsByInstance, resolveEffectiveDefaultModelSelection, } from "../../modelSelection"; import { providerInstanceEntriesFor, resolveDefaultProviderModelSelection, + resolveProjectDefaultProviderModelSelection, } from "../../providerInstances"; import { useEnvironmentProviders, useEnvironments } from "../../state/environments"; import { EMPTY_SERVER_PROVIDERS } from "../../state/server"; @@ -48,8 +51,9 @@ function isWorktreeSubmodules(value: string | null): value is WorktreeSubmodules } export function ProjectDefaultsSettings({ category }: { category: ProjectSettingsCategory }) { - const { scope, target, connectedEnvironments } = useSettingsScope(); + const { scope, target, targets, connectedEnvironments } = useSettingsScope(); const settings = useScopedSettings(); + const clientSettings = useClientSettings(); const updateSettings = useUpdateScopedSettings(); const navigate = useNavigate(); const { environments } = useEnvironments(); @@ -61,10 +65,10 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting (representative?.serverConfig ? providersByEnvironment.get(representative.environmentId) : undefined) ?? EMPTY_SERVER_PROVIDERS; - const storedSelection = resolveDefaultProviderModelSelection( - providers, - settings.defaultModelSelection, - ); + const storedSelection = + settings.defaultModelSelection === null + ? resolveProjectDefaultProviderModelSelection(providers, settings, target?.projectId ?? null) + : resolveDefaultProviderModelSelection(providers, settings.defaultModelSelection); const selection = storedSelection ? resolveEffectiveDefaultModelSelection(settings, providers, storedSelection) : null; @@ -76,7 +80,17 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting selection?.model, ); const activeEntry = entries.find((entry) => entry.instanceId === selection?.instanceId); - const mixedModel = useScopedSettingsMixed(["defaultModelSelection"]); + const mixedModel = defaultModelSelectionsAreMixed( + clientSettings, + targets.map((candidate) => ({ + projectId: candidate.projectId, + settings: candidate.settings, + providers: providersByEnvironment.get(candidate.environmentId) ?? EMPTY_SERVER_PROVIDERS, + })), + ); + const automaticModel = + targets.length > 0 && + targets.every((candidate) => candidate.settings.defaultModelSelection === null); const mixedFallback = useScopedSettingsMixed(["fallbackModelSelection"]); const fallbackSelection = settings.fallbackModelSelection ?? selection; const fallbackModelOptions = getCustomModelOptionsByInstance( @@ -99,6 +113,9 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting : null; const modelDisabledReason = useScopedModelDisabledReason(settings, entries); + const effortDisabledReason = selection + ? modelDisabledReason(selection.instanceId, selection.model) + : null; const setModel = (value: ModelSelection | null) => { const reason = value ? modelDisabledReason(value.instanceId, value.model) : null; @@ -119,13 +136,15 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting description={ isProjectScope ? "Model for new threads in this project." - : !unavailable && - !mixedModel && - modelSource !== "project" && - settings.defaultModelSelection === null + : !unavailable && modelSource !== "project" && automaticModel ? "Default model for new threads, selected automatically. Projects can override it." : "Default model for new threads. Projects can override it." } + status={ + mixedModel + ? `${automaticModel ? "Automatic defaults differ." : "Defaults differ."} Select one environment or choose a shared model to edit effort.` + : effortDisabledReason + } resetAction={ settings.defaultModelSelection !== null ? ( setModel(null)} /> @@ -142,7 +161,9 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting instanceEntries={entries} modelOptionsByInstance={modelOptions} triggerClassName={SETTINGS_PICKER_TRIGGER_CLASSNAME} - {...(mixedModel ? { triggerLabel: "Mixed" } : {})} + {...(mixedModel + ? { triggerLabel: automaticModel ? "Automatic (mixed)" : "Mixed" } + : {})} getModelDisabledReason={modelDisabledReason} onOpenProviderSetup={(instanceId) => { if (representative) @@ -155,7 +176,7 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting setModel(createModelSelection(instanceId, model)) } /> - {!mixedModel ? ( + {!mixedModel && !effortDisabledReason ? ( ) : ( - No providers available + + {mixedModel ? "Mixed" : "No providers available"} + ) } /> diff --git a/apps/web/src/modelSelection.test.ts b/apps/web/src/modelSelection.test.ts index b5e9be7d9e011..430188a7c99c0 100644 --- a/apps/web/src/modelSelection.test.ts +++ b/apps/web/src/modelSelection.test.ts @@ -1,11 +1,17 @@ import { ANTIGRAVITY_DEFAULT_MODEL, DEFAULT_TEXT_GENERATION_MODEL, + DEFAULT_SERVER_SETTINGS, + ProjectId, ProviderDriverKind, ProviderInstanceId, type ServerProvider, } from "@t3tools/contracts"; -import { DEFAULT_UNIFIED_SETTINGS, type UnifiedSettings } from "@t3tools/contracts/settings"; +import { + DEFAULT_CLIENT_SETTINGS, + DEFAULT_UNIFIED_SETTINGS, + type UnifiedSettings, +} from "@t3tools/contracts/settings"; import { describe, expect, it } from "vite-plus/test"; import { createModelSelection } from "@t3tools/shared/model"; import { deriveEffectiveComposerModelState } from "./composerDraftStore"; @@ -17,6 +23,7 @@ import { providerInstanceEntriesFor, } from "./providerInstances"; import { + defaultModelSelectionsAreMixed, getAppModelOptionsForInstance, resolveAppModelSelectionForInstance, resolveAppModelSelectionState, @@ -73,6 +80,142 @@ function settingsWithProviderInstances(): UnifiedSettings { }; } +describe("scoped default model selections", () => { + // regression: #27997 equal automatic defaults can resolve to different providers + it("compares automatic defaults using each environment's models", () => { + const codex = { + projectId: null, + settings: DEFAULT_UNIFIED_SETTINGS, + providers: [provider({ instanceId: "codex", models: ["gpt-6-astra"] })], + }; + const claude = { + projectId: null, + settings: DEFAULT_UNIFIED_SETTINGS, + providers: [ + provider({ + instanceId: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), + models: ["claude-sonnet-4-6"], + }), + ], + }; + + expect(defaultModelSelectionsAreMixed(DEFAULT_CLIENT_SETTINGS, [codex, claude])).toBe(true); + expect(defaultModelSelectionsAreMixed(DEFAULT_CLIENT_SETTINGS, [claude, codex])).toBe(true); + expect(defaultModelSelectionsAreMixed(DEFAULT_CLIENT_SETTINGS, [codex, codex])).toBe(false); + expect(defaultModelSelectionsAreMixed(DEFAULT_CLIENT_SETTINGS, [claude])).toBe(false); + expect(defaultModelSelectionsAreMixed(DEFAULT_CLIENT_SETTINGS, [])).toBe(false); + expect( + defaultModelSelectionsAreMixed(DEFAULT_CLIENT_SETTINGS, [codex, { ...codex, providers: [] }]), + ).toBe(true); + expect( + defaultModelSelectionsAreMixed(DEFAULT_CLIENT_SETTINGS, [ + codex, + { ...codex, providers: [provider({ instanceId: "codex", models: ["gpt-6-sol"] })] }, + ]), + ).toBe(true); + + const projectId = ProjectId.make("claude-only"); + const project = { + projectId, + settings: { + ...DEFAULT_SERVER_SETTINGS, + projectSettingsOverrides: { + [projectId]: { + providerInstances: { [ProviderInstanceId.make("codex")]: { enabled: false } }, + }, + }, + }, + providers: [...codex.providers, ...claude.providers], + }; + const remoteProject = { ...claude, projectId: ProjectId.make("remote-project") }; + expect(defaultModelSelectionsAreMixed(DEFAULT_CLIENT_SETTINGS, [project, remoteProject])).toBe( + false, + ); + expect(defaultModelSelectionsAreMixed(DEFAULT_CLIENT_SETTINGS, [remoteProject, project])).toBe( + false, + ); + expect( + defaultModelSelectionsAreMixed(DEFAULT_CLIENT_SETTINGS, [ + project, + { ...project, projectId: ProjectId.make("other-project") }, + ]), + ).toBe(true); + + const configured = { + projectId: null, + settings: { + ...DEFAULT_SERVER_SETTINGS, + providerModelPreferences: { + [ProviderInstanceId.make("codex")]: { + hiddenModels: ["gpt-6-astra"], + modelOrder: [], + }, + }, + }, + providers: [provider({ instanceId: "codex", models: ["gpt-6-astra", "gpt-6-sol"] })], + }; + const inherited = { ...configured, settings: DEFAULT_SERVER_SETTINGS }; + expect(defaultModelSelectionsAreMixed(DEFAULT_CLIENT_SETTINGS, [configured, inherited])).toBe( + true, + ); + expect(defaultModelSelectionsAreMixed(DEFAULT_CLIENT_SETTINGS, [inherited, configured])).toBe( + true, + ); + const clientSettings = { + ...DEFAULT_CLIENT_SETTINGS, + providerModelPreferences: configured.settings.providerModelPreferences, + }; + expect(defaultModelSelectionsAreMixed(clientSettings, [configured, inherited])).toBe(false); + expect(defaultModelSelectionsAreMixed(clientSettings, [inherited, configured])).toBe(false); + expect( + defaultModelSelectionsAreMixed(clientSettings, [ + { ...inherited, settings: { ...DEFAULT_SERVER_SETTINGS, providerModelPreferences: {} } }, + inherited, + ]), + ).toBe(true); + }); + + it("keeps automatic, explicit, and different saved options distinct", () => { + const providers = [provider({ instanceId: "codex", models: ["gpt-6-astra"] })]; + const selected = { + projectId: null, + providers, + settings: { + ...DEFAULT_UNIFIED_SETTINGS, + defaultModelSelection: createModelSelection( + ProviderInstanceId.make("codex"), + "gpt-6-astra", + ), + }, + }; + expect( + defaultModelSelectionsAreMixed(DEFAULT_CLIENT_SETTINGS, [selected, { ...selected }]), + ).toBe(false); + expect( + defaultModelSelectionsAreMixed(DEFAULT_CLIENT_SETTINGS, [ + selected, + { projectId: null, providers, settings: DEFAULT_UNIFIED_SETTINGS }, + ]), + ).toBe(true); + expect( + defaultModelSelectionsAreMixed(DEFAULT_CLIENT_SETTINGS, [ + selected, + { + ...selected, + settings: { + ...selected.settings, + defaultModelSelection: { + ...selected.settings.defaultModelSelection, + options: [{ id: "reasoningEffort", value: "xhigh" }], + }, + }, + }, + ]), + ).toBe(true); + }); +}); + describe("instance-scoped model selection", () => { it("keeps custom models on the provider instance that declared them", () => { const providers = [ diff --git a/apps/web/src/modelSelection.ts b/apps/web/src/modelSelection.ts index db52028c397e0..c94de128de97e 100644 --- a/apps/web/src/modelSelection.ts +++ b/apps/web/src/modelSelection.ts @@ -4,9 +4,11 @@ import { DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER, defaultInstanceIdForDriver, type ModelSelection, + type ProjectId, ProviderDriverKind, ProviderInstanceId, type ServerProvider, + type ServerSettings, } from "@t3tools/contracts"; import { type CustomModelDefinition, @@ -16,8 +18,10 @@ import { resolveSelectableModel, } from "@t3tools/shared/model"; import { getComposerProviderState } from "./components/chat/composerProviderState"; -import { UnifiedSettings } from "@t3tools/contracts/settings"; +import { type ClientSettings, UnifiedSettings } from "@t3tools/contracts/settings"; +import { resolveModelPreferences } from "@t3tools/shared/serverSettings"; import * as Arr from "effect/Array"; +import * as Equal from "effect/Equal"; import * as Result from "effect/Result"; import { getDefaultServerModel, @@ -29,6 +33,8 @@ import { type ProviderInstanceEntry, deriveProviderInstanceEntries, NO_PROVIDER_MODEL_SELECTION, + resolveDefaultProviderModelSelection, + resolveProjectDefaultProviderModelSelection, } from "./providerInstances"; import { sortModelsForProviderInstance } from "./modelOrdering"; import { resolveModelShortName } from "@t3tools/client-runtime/provider-clients"; @@ -308,6 +314,38 @@ export function resolveEffectiveDefaultModelSelection( return model === null || model === selection.model ? selection : { ...selection, model }; } +export function defaultModelSelectionsAreMixed( + clientSettings: ClientSettings, + targets: ReadonlyArray<{ + projectId: ProjectId | null; + settings: ServerSettings; + providers: ReadonlyArray; + }>, +): boolean { + const selections = targets.map(({ projectId, settings, providers }) => { + const stored = settings.defaultModelSelection; + const selection = + stored === null + ? resolveProjectDefaultProviderModelSelection(providers, settings, projectId) + : resolveDefaultProviderModelSelection(providers, stored); + return { + stored, + effective: selection + ? resolveEffectiveDefaultModelSelection( + { + ...clientSettings, + ...settings, + ...resolveModelPreferences(settings, clientSettings), + }, + providers, + selection, + ) + : null, + }; + }); + return selections.some((selection) => !Equal.equals(selection, selections[0])); +} + export function getCustomModelOptionsByInstance( settings: UnifiedSettings, providers: ReadonlyArray,