From e0fd933b23c68a9e7e2fdf90548e79fd31706b95 Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Tue, 3 Nov 2026 20:10:17 +0000 Subject: [PATCH 1/8] fix(web): all-environments model settings match one account across instance IDs Bulk model pickers looked up the representative's provider instance ID on every environment, so the same signed-in Codex ChatGPT or Claude subscription added separately on two machines showed as unavailable and its saved values as mixed. Exact instance-and-driver matches still win. Otherwise a selection resolves to the one authenticated subscription with the same driver, auth type, case-insensitive email and workspace, unique in both environments. Picker availability, all-scope and project-override writes, and mixed comparisons share that resolver. A write that cannot resolve on any destination is refused for every environment. Co-Authored-By: Claude Opus 5.5 Co-authored-by: george-agent --- .../settings/scopedModelSelection.ts | 72 ++++++ .../settings/scopedSettings.test.ts | 241 ++++++++++++++++++ .../src/components/settings/scopedSettings.ts | 90 ++++++- .../components/settings/settingsLayout.tsx | 11 +- .../settings/useScopedModelAvailability.ts | 12 +- .../components/settings/useScopedSettings.ts | 17 +- apps/web/src/modelSelection.test.ts | 34 +++ apps/web/src/modelSelection.ts | 16 +- 8 files changed, 477 insertions(+), 16 deletions(-) create mode 100644 apps/web/src/components/settings/scopedModelSelection.ts diff --git a/apps/web/src/components/settings/scopedModelSelection.ts b/apps/web/src/components/settings/scopedModelSelection.ts new file mode 100644 index 000000000000..2a6800c34edc --- /dev/null +++ b/apps/web/src/components/settings/scopedModelSelection.ts @@ -0,0 +1,72 @@ +import type { ModelSelection, ProviderInstanceId, ServerProvider } from "@t3tools/contracts"; +import * as Equal from "effect/Equal"; + +export const SCOPED_MODEL_SETTING_KEYS = [ + "defaultModelSelection", + "fallbackModelSelection", + "textGenerationModelSelection", + "sourceControlWriterModelSelection", + "reviewGuideModelSelection", +] as const; + +const NON_SUBSCRIPTION_CLAUDE_AUTH = new Set(["apiKey", "bedrock", "customEndpoint"]); + +/** Only a verified subscription login identifies who pays; names and instance IDs are machine-local. */ +function subscriptionAccount(provider: ServerProvider): string | null { + const { driver, auth } = provider; + if (auth.status !== "authenticated" || !auth.type || !auth.email || !auth.workspaceId) { + return null; + } + const subscription = + driver === "codex" + ? auth.type === "chatgpt" + : driver === "claudeAgent" && !NON_SUBSCRIPTION_CLAUDE_AUTH.has(auth.type); + return subscription + ? JSON.stringify([driver, auth.type, auth.email.toLowerCase(), auth.workspaceId]) + : null; +} + +/** + * The destination instance for a source instance: the same ID and driver, else the one + * subscription account that matches it uniquely in both environments. + */ +export function resolveScopedModelInstanceId( + instanceId: ProviderInstanceId, + sourceProviders: readonly ServerProvider[], + destinationProviders: readonly ServerProvider[], +): ProviderInstanceId | null { + const source = sourceProviders.find((provider) => provider.instanceId === instanceId); + if (!source) return null; + if ( + destinationProviders.some( + (provider) => provider.instanceId === instanceId && provider.driver === source.driver, + ) + ) { + return instanceId; + } + const account = subscriptionAccount(source); + if (account === null) return null; + const matches = (providers: readonly ServerProvider[]) => + providers.filter((provider) => subscriptionAccount(provider) === account); + const destination = matches(destinationProviders); + return destination.length === 1 && matches(sourceProviders).length === 1 + ? destination[0]!.instanceId + : null; +} + +export function scopedModelSelectionsEqual( + first: ModelSelection | null, + firstProviders: readonly ServerProvider[], + second: ModelSelection | null, + secondProviders: readonly ServerProvider[], +): boolean { + if (Equal.equals(first, second)) return true; + if (first === null || second === null || first.instanceId === second.instanceId) return false; + return ( + resolveScopedModelInstanceId(first.instanceId, firstProviders, secondProviders) === + second.instanceId && + resolveScopedModelInstanceId(second.instanceId, secondProviders, firstProviders) === + first.instanceId && + Equal.equals({ ...first, instanceId: second.instanceId }, second) + ); +} diff --git a/apps/web/src/components/settings/scopedSettings.test.ts b/apps/web/src/components/settings/scopedSettings.test.ts index bbe1f1fd00f8..a199c45a36cb 100644 --- a/apps/web/src/components/settings/scopedSettings.test.ts +++ b/apps/web/src/components/settings/scopedSettings.test.ts @@ -3,7 +3,9 @@ import { EnvironmentAuthorizationError, EnvironmentId, ProjectId, + ProviderDriverKind, ProviderInstanceId, + type ServerProvider, type ServerSettings, } from "@t3tools/contracts"; import { describe, expect, it, vi } from "vite-plus/test"; @@ -784,3 +786,242 @@ describe("model selection patches at project scope", () => { ).toEqual({ ...saved, options }); }); }); + +describe("model selections for one account under distinct instance IDs", () => { + const account = ( + instanceId: string, + auth: Partial = {}, + driver = "codex", + ): ServerProvider => ({ + instanceId: ProviderInstanceId.make(instanceId), + driver: ProviderDriverKind.make(driver), + enabled: true, + installed: true, + version: null, + status: "ready", + auth: { + status: "authenticated", + type: driver === "codex" ? "chatgpt" : "max", + email: "Gmail@Example.test", + workspaceId: "personal", + ...auth, + }, + checkedAt: "2026-11-03T00:00:00.000Z", + models: [], + slashCommands: [], + skills: [], + }); + const laptopCodex = account("codex_laptop_gmail"); + const serverCodex = account("codex_server_gmail", { email: "gmail@example.test" }); + const providers = (laptopProviders: ServerProvider[], serverProviders: ServerProvider[]) => + new Map([ + [laptop.environmentId, laptopProviders], + [server.environmentId, serverProviders], + ]); + const matching = providers([laptopCodex], [serverCodex]); + const fromLaptop = (providersByEnvironment: ReturnType) => ({ + sourceEnvironmentId: laptop.environmentId, + providersByEnvironment, + }); + const luna = { instanceId: laptopCodex.instanceId, model: "gpt-6-luna" }; + const keys = [ + "defaultModelSelection", + "fallbackModelSelection", + "textGenerationModelSelection", + "sourceControlWriterModelSelection", + "reviewGuideModelSelection", + ] as const; + + it("writes each environment's own instance for every model setting", () => { + const patch = Object.fromEntries(keys.map((key) => [key, luna])); + const plan = planScopedSettingsPatch(all, environments, patch, fromLaptop(matching)); + expect(plan.unavailableReason).toBeNull(); + expect(plan.serverWrites.map((write) => write.patch)).toEqual([ + patch, + Object.fromEntries(keys.map((key) => [key, { ...luna, instanceId: serverCodex.instanceId }])), + ]); + const loading = new Map(matching); + loading.delete(server.environmentId); + const pending = planScopedSettingsPatch(all, environments, patch, fromLaptop(loading)); + expect(pending.serverWrites).toEqual([]); + expect(pending.unavailableReason).toContain("Server"); + }); + + it("maps project overrides and keeps each member's own instance for option-only edits", () => { + const projectKeys = keys.filter((key) => key !== "fallbackModelSelection"); + const plan = planScopedSettingsPatch( + project, + environments, + Object.fromEntries(projectKeys.map((key) => [key, luna])), + fromLaptop(matching), + ); + expect(plan.serverWrites.map((write) => write.patch.projectSettingsOverrides)).toEqual([ + { + [projectId]: Object.fromEntries( + projectKeys.map((key) => [key, { ...luna, instanceId: serverCodex.instanceId }]), + ), + }, + { [laptopProjectId]: Object.fromEntries(projectKeys.map((key) => [key, luna])) }, + ]); + + const serverSelection = { instanceId: serverCodex.instanceId, model: "gpt-6-luna" }; + const serverWithOverride = environment("Server", { + settings: { + providerInstances: { + [serverCodex.instanceId]: { driver: serverCodex.driver, enabled: true }, + }, + projectSettingsOverrides: { + [projectId]: { textGenerationModelSelection: serverSelection }, + }, + }, + }); + const options = [{ id: "reasoningEffort", value: "low" }]; + expect( + planScopedSettingsPatch( + project, + [laptop, serverWithOverride], + { textGenerationModelSelection: { options } }, + fromLaptop(matching), + ).serverWrites[0]?.patch.projectSettingsOverrides?.[projectId]?.textGenerationModelSelection, + ).toEqual({ ...serverSelection, options }); + }); + + it("keeps exact instance and driver matches and other providers exact-ID only", () => { + const shared = account("codex_shared"); + expect( + planScopedSettingsPatch( + all, + environments, + { defaultModelSelection: { instanceId: shared.instanceId, model: "gpt-6-luna" } }, + fromLaptop(providers([shared, laptopCodex], [serverCodex, shared])), + ).serverWrites.map((write) => write.patch.defaultModelSelection?.instanceId), + ).toEqual([shared.instanceId, shared.instanceId]); + }); + + it.each([ + [ + "an API key account", + [account("codex_laptop_key", { type: "apiKey" })], + [account("codex_server_key", { type: "apiKey" })], + ], + [ + "a different workspace", + [laptopCodex], + [account("codex_server_gmail", { workspaceId: "team" })], + ], + [ + "a missing workspace", + [account("codex_laptop_gmail", { workspaceId: undefined })], + [account("codex_server_gmail", { workspaceId: undefined })], + ], + [ + "an unauthenticated destination", + [laptopCodex], + [account("codex_server_gmail", { status: "unknown" })], + ], + [ + "two matching destination accounts", + [laptopCodex], + [serverCodex, account("codex_server_other")], + ], + ["two matching source accounts", [laptopCodex, account("codex_laptop_other")], [serverCodex]], + ["missing from the source snapshot", [], [serverCodex]], + ["missing from the destination snapshot", [laptopCodex], []], + ["a different driver", [laptopCodex], [account("claude_server_gmail", {}, "claudeAgent")]], + [ + "a display name alone", + [{ ...laptopCodex, displayName: "Gmail", auth: { status: "authenticated" as const } }], + [{ ...serverCodex, displayName: "Gmail", auth: { status: "authenticated" as const } }], + ], + ])("refuses every write when the account is %s", (_name, laptopProviders, serverProviders) => { + const instanceId = laptopProviders[0]?.instanceId ?? laptopCodex.instanceId; + const plan = planScopedSettingsPatch( + all, + environments, + { textGenerationModelSelection: { instanceId, model: "gpt-6-luna" } }, + fromLaptop(providers(laptopProviders, serverProviders)), + ); + expect(plan.serverWrites).toEqual([]); + expect(plan.unavailableReason).toContain("Server"); + const targets = [ + { + environmentId: laptop.environmentId, + settings: { + ...DEFAULT_SERVER_SETTINGS, + textGenerationModelSelection: { instanceId, model: "gpt-6-luna" }, + }, + }, + { + environmentId: server.environmentId, + settings: { + ...DEFAULT_SERVER_SETTINGS, + textGenerationModelSelection: { + instanceId: serverProviders[0]?.instanceId ?? serverCodex.instanceId, + model: "gpt-6-luna", + }, + }, + }, + ]; + expect( + scopedSettingsAreMixed( + targets, + ["textGenerationModelSelection"], + providers(laptopProviders, serverProviders), + ), + ).toBe(true); + }); + + it("treats the same account and model as one value in either order", () => { + const targets = [ + { + environmentId: laptop.environmentId, + settings: { + ...DEFAULT_SERVER_SETTINGS, + ...Object.fromEntries(keys.map((key) => [key, luna])), + }, + }, + { + environmentId: server.environmentId, + settings: { + ...DEFAULT_SERVER_SETTINGS, + ...Object.fromEntries( + keys.map((key) => [key, { ...luna, instanceId: serverCodex.instanceId }]), + ), + }, + }, + ]; + for (const ordered of [targets, [...targets].reverse()]) { + expect(scopedSettingsAreMixed(ordered, keys, matching)).toBe(false); + expect( + scopedSettingsAreMixed( + ordered, + keys, + providers( + [laptopCodex], + [serverCodex, account(laptopCodex.instanceId, { email: "other@example.test" })], + ), + ), + ).toBe(true); + expect( + scopedSettingsAreMixed( + [ + ordered[0]!, + { + ...ordered[1]!, + settings: { + ...ordered[1]!.settings, + reviewGuideModelSelection: { + ...luna, + instanceId: serverCodex.instanceId, + model: "gpt-6-sol", + }, + }, + }, + ], + ["reviewGuideModelSelection"], + matching, + ), + ).toBe(true); + } + }); +}); diff --git a/apps/web/src/components/settings/scopedSettings.ts b/apps/web/src/components/settings/scopedSettings.ts index 2c771db70f15..519dbf741931 100644 --- a/apps/web/src/components/settings/scopedSettings.ts +++ b/apps/web/src/components/settings/scopedSettings.ts @@ -12,6 +12,7 @@ import { type ProjectSettingsOverrides, ServerSettings, type T3ProjectFile, + type ServerProvider, type ServerSettingsPatch, } from "@t3tools/contracts"; import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; @@ -25,6 +26,11 @@ import * as Equal from "effect/Equal"; import * as Cause from "effect/Cause"; import type { ResolvedSettingsScope } from "./settingsScope"; +import { + resolveScopedModelInstanceId, + SCOPED_MODEL_SETTING_KEYS, + scopedModelSelectionsEqual, +} from "./scopedModelSelection"; export type ScopedSettingsPatch = ServerSettingsPatch & ClientSettingsPatch; @@ -125,15 +131,30 @@ export function resolveScopedSettingsTargets( ); } +type ProvidersByEnvironment = ReadonlyMap; + +const isScopedModelKey = (key: string): key is (typeof SCOPED_MODEL_SETTING_KEYS)[number] => + (SCOPED_MODEL_SETTING_KEYS as readonly string[]).includes(key); + export function scopedSettingsAreMixed( - targets: readonly Pick[], + targets: readonly Pick[], keys: readonly (keyof ServerSettings)[], + providersByEnvironment?: ProvidersByEnvironment, ): boolean { const first = targets[0]; return ( first !== undefined && targets.some((candidate) => - keys.some((key) => !Equal.equals(first.settings[key], candidate.settings[key])), + keys.some((key) => + providersByEnvironment && isScopedModelKey(key) + ? !scopedModelSelectionsEqual( + first.settings[key], + providersByEnvironment.get(first.environmentId) ?? [], + candidate.settings[key], + providersByEnvironment.get(candidate.environmentId) ?? [], + ) + : !Equal.equals(first.settings[key], candidate.settings[key]), + ), ) ); } @@ -183,6 +204,7 @@ function projectOverrideWrites( current: ProjectSettingsOverrides, settings: ServerSettings, projectId: ProjectId, + environment: ScopedSettingsEnvironment, ) => ProjectSettingsOverrides | null, ): ScopedServerWrite[] { const byId = new Map(environments.map((environment) => [environment.environmentId, environment])); @@ -193,7 +215,12 @@ function projectOverrideWrites( continue; } const settings = environment.serverConfig.settings; - const entry = update(settings.projectSettingsOverrides[member.id] ?? {}, settings, member.id); + const entry = update( + settings.projectSettingsOverrides[member.id] ?? {}, + settings, + member.id, + environment, + ); const existing = writes.get(member.environmentId); writes.set(member.environmentId, { environmentId: member.environmentId, @@ -217,30 +244,72 @@ function forDestination(previous: ServerSettings, patch: ServerSettingsPatch): S return { ...rest, ...idleCompactionPatch(previous, idleCompaction) }; } +/** + * A model chosen from the source environment's instances, rewritten to each destination's + * instance for the same account. Null refuses an unresolved write. + */ +function forDestinationModels( + patch: ServerSettingsPatch, + source: ScopedModelSource, + destinationId: EnvironmentId, +): ServerSettingsPatch | null { + const next = { ...patch }; + for (const key of SCOPED_MODEL_SETTING_KEYS) { + const selection = patch[key]; + if (selection?.instanceId === undefined) continue; + const destination = source.providersByEnvironment.get(destinationId); + if (!destination) return null; + const instanceId = resolveScopedModelInstanceId( + selection.instanceId, + source.providersByEnvironment.get(source.sourceEnvironmentId) ?? [], + destination, + ); + if (instanceId === null) return null; + Object.assign(next, { [key]: { ...selection, instanceId } }); + } + return next; +} + +export interface ScopedModelSource { + /** The environment whose provider instances the picker offered. */ + readonly sourceEnvironmentId: EnvironmentId; + readonly providersByEnvironment: ProvidersByEnvironment; +} + export function planScopedSettingsPatch( scope: ResolvedSettingsScope, environments: readonly ScopedSettingsEnvironment[], patch: ScopedSettingsPatch, + modelSource?: ScopedModelSource, ) { const clientPatch = Object.fromEntries( Object.entries(patch).filter(([key]) => CLIENT_KEYS.has(key) && !SERVER_KEYS.has(key)), ) as ClientSettingsPatch; - const serverPatch = Object.fromEntries( + const rawServerPatch = Object.fromEntries( Object.entries(patch).filter(([key]) => SERVER_KEYS.has(key)), ) as ServerSettingsPatch; - const serverKeys = Object.keys(serverPatch); + const serverKeys = Object.keys(rawServerPatch); const { connectedEnvironments } = selectScopedSettingsEnvironments(scope, environments, null); const isProjectScope = scope.kind === "project" || scope.kind === "checkout"; const unscopableKeys = isProjectScope ? serverKeys.filter((key) => !isProjectScopedSettingKey(key)) : []; + const unmatchedModelEnvironments = new Set(); + const serverPatchFor = (environment: ScopedSettingsEnvironment) => { + const mapped = modelSource + ? forDestinationModels(rawServerPatch, modelSource, environment.environmentId) + : rawServerPatch; + if (mapped === null) unmatchedModelEnvironments.add(environment.label); + return mapped ?? rawServerPatch; + }; const serverWrites: ScopedServerWrite[] = serverKeys.length === 0 ? [] : isProjectScope ? unscopableKeys.length > 0 ? [] - : projectOverrideWrites(scope, environments, (current, settings, projectId) => { + : projectOverrideWrites(scope, environments, (current, settings, projectId, target) => { + const serverPatch = serverPatchFor(target); // Override entries store whole objects; complete partial patches from each target's effective // value. const effective = resolveProjectSettings(settings, projectId).settings; @@ -287,6 +356,7 @@ export function planScopedSettingsPatch( ? connectedEnvironments.flatMap((environment) => { const previous = environment.serverConfig?.settings; if (!previous) return []; + const serverPatch = serverPatchFor(environment); return [ { environmentId: environment.environmentId, @@ -319,6 +389,14 @@ export function planScopedSettingsPatch( ]; }) : []; + if (unmatchedModelEnvironments.size > 0) { + return { + clientPatch, + hasClientWrite: false, + serverWrites: [], + unavailableReason: `This account is not uniquely signed in on ${[...unmatchedModelEnvironments].join(", ")}. Select that environment to choose its model separately.`, + }; + } const hasClientWrite = Object.keys(clientPatch).length > 0; const hasWrite = hasClientWrite || serverWrites.length > 0; const unavailableReason = diff --git a/apps/web/src/components/settings/settingsLayout.tsx b/apps/web/src/components/settings/settingsLayout.tsx index cf2aae4cf534..26ced9853d64 100644 --- a/apps/web/src/components/settings/settingsLayout.tsx +++ b/apps/web/src/components/settings/settingsLayout.tsx @@ -1,6 +1,10 @@ import { SettingsGroup } from "./SettingsGroup"; import { AuthSettingsWriteScope } from "@t3tools/contracts"; -import { usePrimaryEnvironmentId, usePrimaryEnvironment } from "../../state/environments"; +import { + useEnvironmentProviders, + usePrimaryEnvironment, + usePrimaryEnvironmentId, +} from "../../state/environments"; import { useEnvironmentScope, useEnvironmentsWithScope } from "../../state/session"; import { useServerConfigs } from "../../state/entities"; import { InfoIcon, Undo2Icon } from "lucide-react"; @@ -346,6 +350,7 @@ export function SettingsRow({ context.connectedEnvironments.every((target) => writableIds.has(target.environmentId)) : primaryCanWrite; const serverConfigs = useServerConfigs(); + const providersByEnvironment = useEnvironmentProviders(); // An administrator's lock on any target wins over every other reason a control is inert. const lockablePaths = [...settingKeys, ...managedPaths]; const managedBy = @@ -368,7 +373,9 @@ export function SettingsRow({ const scopedKeys = settingKeys.filter(isProjectScopedSettingKey); const environmentWide = isProjectScope && serverScoped && scopedKeys.length === 0; const mixed = - mixedOverride ?? (context !== null && scopedSettingsAreMixed(context.targets, settingKeys)); + mixedOverride ?? + (context !== null && + scopedSettingsAreMixed(context.targets, settingKeys, providersByEnvironment)); const source = context && isProjectScope ? scopedSettingsSource(context.targets, scopedKeys) : null; const unavailable = diff --git a/apps/web/src/components/settings/useScopedModelAvailability.ts b/apps/web/src/components/settings/useScopedModelAvailability.ts index abf1e1e1afc0..1971d0f57081 100644 --- a/apps/web/src/components/settings/useScopedModelAvailability.ts +++ b/apps/web/src/components/settings/useScopedModelAvailability.ts @@ -8,32 +8,38 @@ import { type ProviderInstanceEntry, } from "../../providerInstances"; import { useEnvironmentProviders, useEnvironments } from "../../state/environments"; +import { resolveScopedModelInstanceId } from "./scopedModelSelection"; import { useSettingsScope } from "./SettingsScopeContext"; export function useScopedModelDisabledReason( settings: UnifiedSettings, entries: readonly ProviderInstanceEntry[], ) { - const { targets } = useSettingsScope(); + const { targets, target: source } = useSettingsScope(); const { environments } = useEnvironments(); const providersByEnvironment = useEnvironmentProviders(); return (instanceId: ProviderInstanceId, model: string): string | null => { const sourceEntry = entries.find((entry) => entry.instanceId === instanceId); + const sourceProviders = (source && providersByEnvironment.get(source.environmentId)) ?? []; for (const candidate of targets) { const environment = environments.find( (entry) => entry.environmentId === candidate.environmentId, ); const providers = providersByEnvironment.get(candidate.environmentId); if (!environment?.serverConfig || !providers) continue; + const localId = resolveScopedModelInstanceId(instanceId, sourceProviders, providers); + if (localId === null) { + return `A unique matching account could not be verified on ${environment.label}. Select that environment to choose its model separately.`; + } const entry = applyProviderInstanceSettings( deriveProviderInstanceEntries(providers), candidate.settings, candidate.projectId, - ).find((option) => option.instanceId === instanceId); + ).find((option) => option.instanceId === localId); const options = getCustomModelOptionsByInstance( mergeEnvironmentSettings(candidate.settings, settings), providers, - ).get(instanceId); + ).get(localId); if ( !entry?.enabled || !entry.isAvailable || diff --git a/apps/web/src/components/settings/useScopedSettings.ts b/apps/web/src/components/settings/useScopedSettings.ts index 5fb9d048a9e3..8e951ff2639e 100644 --- a/apps/web/src/components/settings/useScopedSettings.ts +++ b/apps/web/src/components/settings/useScopedSettings.ts @@ -17,6 +17,7 @@ import { import { persistClientSettingsPatch, useMergedSettings } from "../../hooks/useSettings"; import { serverEnvironment } from "../../state/server"; import { useAtomCommand } from "../../state/use-atom-command"; +import { useEnvironmentProviders } from "../../state/environments"; import { toastManager } from "../ui/toast"; import { useOptionalSettingsScope, useSettingsScope } from "./SettingsScopeContext"; import { @@ -39,7 +40,8 @@ export function useScopedSettings( export function useScopedSettingsMixed(keys: readonly (keyof ServerSettings)[]): boolean { const { targets } = useSettingsScope(); - return scopedSettingsAreMixed(targets, keys); + const providersByEnvironment = useEnvironmentProviders(); + return scopedSettingsAreMixed(targets, keys, providersByEnvironment); } export function useScopedSettingSource(keys: readonly (keyof ServerSettings)[]) { @@ -98,11 +100,20 @@ function useRunScopedPlan() { } export function useUpdateScopedSettings() { - const { scope, environments } = useSettingsScope(); + const { scope, environments, target } = useSettingsScope(); + const providersByEnvironment = useEnvironmentProviders(); const run = useRunScopedPlan(); /** `onFailed` runs when any target rejects or drops the patch, so a draft can fall back. */ return (patch: ScopedSettingsPatch, options?: { readonly onFailed?: () => void }) => - run(planScopedSettingsPatch(scope, environments, patch), options?.onFailed); + run( + planScopedSettingsPatch( + scope, + environments, + patch, + target ? { sourceEnvironmentId: target.environmentId, providersByEnvironment } : undefined, + ), + options?.onFailed, + ); } export function useClearScopedSettings() { diff --git a/apps/web/src/modelSelection.test.ts b/apps/web/src/modelSelection.test.ts index 435854d54b39..3c4eb7d4e8e9 100644 --- a/apps/web/src/modelSelection.test.ts +++ b/apps/web/src/modelSelection.test.ts @@ -142,6 +142,40 @@ describe("scoped default model selections", () => { ]), ).toBe(true); + const stored = (instanceId: string, email = "gmail@example.test") => { + const account = provider({ instanceId, models: ["gpt-6-luna"] }); + return { + projectId: null, + settings: { + ...DEFAULT_SERVER_SETTINGS, + defaultModelSelection: { instanceId: account.instanceId, model: "gpt-6-luna" }, + }, + providers: [ + { + ...account, + auth: { + status: "authenticated" as const, + type: "chatgpt", + email, + workspaceId: "personal", + }, + }, + ], + }; + }; + expect( + defaultModelSelectionsAreMixed(DEFAULT_CLIENT_SETTINGS, [ + stored("codex_laptop_gmail"), + stored("codex_server_gmail"), + ]), + ).toBe(false); + expect( + defaultModelSelectionsAreMixed(DEFAULT_CLIENT_SETTINGS, [ + stored("codex_laptop_gmail"), + stored("codex_server_gmail", "other@example.test"), + ]), + ).toBe(true); + const configured = { projectId: null, settings: { diff --git a/apps/web/src/modelSelection.ts b/apps/web/src/modelSelection.ts index ec2816cc2849..cdc1346ccfde 100644 --- a/apps/web/src/modelSelection.ts +++ b/apps/web/src/modelSelection.ts @@ -21,7 +21,6 @@ import { getComposerProviderState } from "./components/chat/composerProviderStat 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, @@ -38,6 +37,7 @@ import { } from "./providerInstances"; import { sortModelsForProviderInstance } from "./modelOrdering"; import { resolveModelShortName } from "@t3tools/client-runtime/provider-clients"; +import { scopedModelSelectionsEqual } from "./components/settings/scopedModelSelection"; export const MAX_CUSTOM_MODEL_LENGTH = 256; const DEFAULT_TEXT_GENERATION_INSTANCE_ID = ProviderInstanceId.make("codex"); @@ -343,7 +343,19 @@ export function defaultModelSelectionsAreMixed( : null, }; }); - return selections.some((selection) => !Equal.equals(selection, selections[0])); + const [first] = selections; + const firstProviders = targets[0]?.providers ?? []; + return selections.some( + (selection, index) => + !(["stored", "effective"] as const).every((key) => + scopedModelSelectionsEqual( + first![key], + firstProviders, + selection[key], + targets[index]!.providers, + ), + ), + ); } export function getCustomModelOptionsByInstance( From 9b8e305555b48734b5314ef0eff930e1e37ca8ef Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Sat, 7 Nov 2026 14:48:46 +0000 Subject: [PATCH 2/8] fix(web): map the voice cleanup model across environments too, trim repeated tests Co-Authored-By: Claude Opus 5.5 --- .../settings/scopedModelSelection.ts | 1 + .../settings/scopedSettings.test.ts | 73 ++++--------------- apps/web/src/modelSelection.test.ts | 34 --------- 3 files changed, 16 insertions(+), 92 deletions(-) diff --git a/apps/web/src/components/settings/scopedModelSelection.ts b/apps/web/src/components/settings/scopedModelSelection.ts index 2a6800c34edc..f418ecc06c64 100644 --- a/apps/web/src/components/settings/scopedModelSelection.ts +++ b/apps/web/src/components/settings/scopedModelSelection.ts @@ -7,6 +7,7 @@ export const SCOPED_MODEL_SETTING_KEYS = [ "textGenerationModelSelection", "sourceControlWriterModelSelection", "reviewGuideModelSelection", + "speechPostProcessingModelSelection", ] as const; const NON_SUBSCRIPTION_CLAUDE_AUTH = new Set(["apiKey", "bedrock", "customEndpoint"]); diff --git a/apps/web/src/components/settings/scopedSettings.test.ts b/apps/web/src/components/settings/scopedSettings.test.ts index a199c45a36cb..54079efba2ad 100644 --- a/apps/web/src/components/settings/scopedSettings.test.ts +++ b/apps/web/src/components/settings/scopedSettings.test.ts @@ -830,6 +830,7 @@ describe("model selections for one account under distinct instance IDs", () => { "textGenerationModelSelection", "sourceControlWriterModelSelection", "reviewGuideModelSelection", + "speechPostProcessingModelSelection", ] as const; it("writes each environment's own instance for every model setting", () => { @@ -847,8 +848,13 @@ describe("model selections for one account under distinct instance IDs", () => { expect(pending.unavailableReason).toContain("Server"); }); - it("maps project overrides and keeps each member's own instance for option-only edits", () => { - const projectKeys = keys.filter((key) => key !== "fallbackModelSelection"); + it("maps project overrides to each member's own instance", () => { + const projectKeys = [ + "defaultModelSelection", + "textGenerationModelSelection", + "sourceControlWriterModelSelection", + "reviewGuideModelSelection", + ] as const; const plan = planScopedSettingsPatch( project, environments, @@ -863,30 +869,9 @@ describe("model selections for one account under distinct instance IDs", () => { }, { [laptopProjectId]: Object.fromEntries(projectKeys.map((key) => [key, luna])) }, ]); - - const serverSelection = { instanceId: serverCodex.instanceId, model: "gpt-6-luna" }; - const serverWithOverride = environment("Server", { - settings: { - providerInstances: { - [serverCodex.instanceId]: { driver: serverCodex.driver, enabled: true }, - }, - projectSettingsOverrides: { - [projectId]: { textGenerationModelSelection: serverSelection }, - }, - }, - }); - const options = [{ id: "reasoningEffort", value: "low" }]; - expect( - planScopedSettingsPatch( - project, - [laptop, serverWithOverride], - { textGenerationModelSelection: { options } }, - fromLaptop(matching), - ).serverWrites[0]?.patch.projectSettingsOverrides?.[projectId]?.textGenerationModelSelection, - ).toEqual({ ...serverSelection, options }); }); - it("keeps exact instance and driver matches and other providers exact-ID only", () => { + it("prefers the same instance ID and driver over account matching", () => { const shared = account("codex_shared"); expect( planScopedSettingsPatch( @@ -910,9 +895,9 @@ describe("model selections for one account under distinct instance IDs", () => { [account("codex_server_gmail", { workspaceId: "team" })], ], [ - "a missing workspace", - [account("codex_laptop_gmail", { workspaceId: undefined })], - [account("codex_server_gmail", { workspaceId: undefined })], + "a matching display name with no workspace", + [{ ...account("codex_laptop_gmail", { workspaceId: undefined }), displayName: "Gmail" }], + [{ ...account("codex_server_gmail", { workspaceId: undefined }), displayName: "Gmail" }], ], [ "an unauthenticated destination", @@ -926,12 +911,10 @@ describe("model selections for one account under distinct instance IDs", () => { ], ["two matching source accounts", [laptopCodex, account("codex_laptop_other")], [serverCodex]], ["missing from the source snapshot", [], [serverCodex]], - ["missing from the destination snapshot", [laptopCodex], []], - ["a different driver", [laptopCodex], [account("claude_server_gmail", {}, "claudeAgent")]], [ - "a display name alone", - [{ ...laptopCodex, displayName: "Gmail", auth: { status: "authenticated" as const } }], - [{ ...serverCodex, displayName: "Gmail", auth: { status: "authenticated" as const } }], + "the same ID and login on another driver", + [laptopCodex], + [account(laptopCodex.instanceId, { type: "chatgpt" }, "claudeAgent")], ], ])("refuses every write when the account is %s", (_name, laptopProviders, serverProviders) => { const instanceId = laptopProviders[0]?.instanceId ?? laptopCodex.instanceId; @@ -943,32 +926,6 @@ describe("model selections for one account under distinct instance IDs", () => { ); expect(plan.serverWrites).toEqual([]); expect(plan.unavailableReason).toContain("Server"); - const targets = [ - { - environmentId: laptop.environmentId, - settings: { - ...DEFAULT_SERVER_SETTINGS, - textGenerationModelSelection: { instanceId, model: "gpt-6-luna" }, - }, - }, - { - environmentId: server.environmentId, - settings: { - ...DEFAULT_SERVER_SETTINGS, - textGenerationModelSelection: { - instanceId: serverProviders[0]?.instanceId ?? serverCodex.instanceId, - model: "gpt-6-luna", - }, - }, - }, - ]; - expect( - scopedSettingsAreMixed( - targets, - ["textGenerationModelSelection"], - providers(laptopProviders, serverProviders), - ), - ).toBe(true); }); it("treats the same account and model as one value in either order", () => { diff --git a/apps/web/src/modelSelection.test.ts b/apps/web/src/modelSelection.test.ts index 3c4eb7d4e8e9..435854d54b39 100644 --- a/apps/web/src/modelSelection.test.ts +++ b/apps/web/src/modelSelection.test.ts @@ -142,40 +142,6 @@ describe("scoped default model selections", () => { ]), ).toBe(true); - const stored = (instanceId: string, email = "gmail@example.test") => { - const account = provider({ instanceId, models: ["gpt-6-luna"] }); - return { - projectId: null, - settings: { - ...DEFAULT_SERVER_SETTINGS, - defaultModelSelection: { instanceId: account.instanceId, model: "gpt-6-luna" }, - }, - providers: [ - { - ...account, - auth: { - status: "authenticated" as const, - type: "chatgpt", - email, - workspaceId: "personal", - }, - }, - ], - }; - }; - expect( - defaultModelSelectionsAreMixed(DEFAULT_CLIENT_SETTINGS, [ - stored("codex_laptop_gmail"), - stored("codex_server_gmail"), - ]), - ).toBe(false); - expect( - defaultModelSelectionsAreMixed(DEFAULT_CLIENT_SETTINGS, [ - stored("codex_laptop_gmail"), - stored("codex_server_gmail", "other@example.test"), - ]), - ).toBe(true); - const configured = { projectId: null, settings: { From fd15492e517440682899fe185ec9773cbaf4e54e Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Mon, 9 Nov 2026 10:10:49 +0000 Subject: [PATCH 3/8] fix(web): scoped model settings refuse conflicting accounts and generator-shadowed matches A same instance ID with a conflicting reported driver, login, email or workspace no longer saves or compares as one model. A matched account is refused when a destination text generator owns its ID. Cross-ID matching is limited to Codex ChatGPT logins, and the source environment's own selection is written without a provider snapshot. Co-Authored-By: Claude Opus 5.5 --- .../settings/scopedModelSelection.ts | 108 +++++--- .../settings/scopedSettings.test.ts | 244 ++++++++---------- .../src/components/settings/scopedSettings.ts | 64 +++-- .../settings/useScopedModelAvailability.ts | 9 +- .../components/settings/useScopedSettings.ts | 2 +- apps/web/src/modelSelection.ts | 8 +- 6 files changed, 207 insertions(+), 228 deletions(-) diff --git a/apps/web/src/components/settings/scopedModelSelection.ts b/apps/web/src/components/settings/scopedModelSelection.ts index f418ecc06c64..61c6d3b3ecc8 100644 --- a/apps/web/src/components/settings/scopedModelSelection.ts +++ b/apps/web/src/components/settings/scopedModelSelection.ts @@ -1,4 +1,10 @@ -import type { ModelSelection, ProviderInstanceId, ServerProvider } from "@t3tools/contracts"; +import { + type ModelSelection, + type ProviderInstanceId, + type ServerProvider, + type ServerSettings, + textGeneratorForSelection, +} from "@t3tools/contracts"; import * as Equal from "effect/Equal"; export const SCOPED_MODEL_SETTING_KEYS = [ @@ -10,64 +16,80 @@ export const SCOPED_MODEL_SETTING_KEYS = [ "speechPostProcessingModelSelection", ] as const; -const NON_SUBSCRIPTION_CLAUDE_AUTH = new Set(["apiKey", "bedrock", "customEndpoint"]); +export interface ScopedModelEnvironment { + readonly providers: readonly ServerProvider[]; + readonly settings: Pick; +} + +const identity = ({ auth }: ServerProvider) => [ + auth.type, + auth.email?.toLowerCase(), + auth.workspaceId, +]; + +/** Unreported login, email, or workspace is unknown, not a conflict. */ +const knownConflict = (first: ServerProvider, second: ServerProvider) => + first.driver !== second.driver || + identity(first).some((value, index) => { + const other = identity(second)[index]; + return value && other && value !== other; + }); -/** Only a verified subscription login identifies who pays; names and instance IDs are machine-local. */ -function subscriptionAccount(provider: ServerProvider): string | null { - const { driver, auth } = provider; - if (auth.status !== "authenticated" || !auth.type || !auth.email || !auth.workspaceId) { - return null; - } - const subscription = - driver === "codex" - ? auth.type === "chatgpt" - : driver === "claudeAgent" && !NON_SUBSCRIPTION_CLAUDE_AUTH.has(auth.type); - return subscription - ? JSON.stringify([driver, auth.type, auth.email.toLowerCase(), auth.workspaceId]) +/** Instance IDs and names are machine-local; only a reported ChatGPT login identifies the account. */ +const chatGptAccount = (provider: ServerProvider) => + provider.driver === "codex" && + provider.auth.status === "authenticated" && + provider.auth.type === "chatgpt" && + provider.auth.email && + provider.auth.workspaceId + ? JSON.stringify(identity(provider)) : null; -} + +const find = (providers: readonly ServerProvider[], instanceId: ProviderInstanceId) => + providers.find((provider) => provider.instanceId === instanceId); /** - * The destination instance for a source instance: the same ID and driver, else the one - * subscription account that matches it uniquely in both environments. + * The destination instance for a source instance: the same ID unless its reported identity + * conflicts, else the one ChatGPT account that matches uniquely in both environments. */ export function resolveScopedModelInstanceId( instanceId: ProviderInstanceId, - sourceProviders: readonly ServerProvider[], - destinationProviders: readonly ServerProvider[], + sourceEnvironment: Pick, + destination: ScopedModelEnvironment, ): ProviderInstanceId | null { - const source = sourceProviders.find((provider) => provider.instanceId === instanceId); + const source = find(sourceEnvironment.providers, instanceId); if (!source) return null; - if ( - destinationProviders.some( - (provider) => provider.instanceId === instanceId && provider.driver === source.driver, - ) - ) { - return instanceId; - } - const account = subscriptionAccount(source); + const sameId = find(destination.providers, instanceId); + if (sameId && !knownConflict(source, sameId)) return instanceId; + const account = chatGptAccount(source); if (account === null) return null; const matches = (providers: readonly ServerProvider[]) => - providers.filter((provider) => subscriptionAccount(provider) === account); - const destination = matches(destinationProviders); - return destination.length === 1 && matches(sourceProviders).length === 1 - ? destination[0]!.instanceId + providers.filter((provider) => chatGptAccount(provider) === account); + const [match, ...rest] = matches(destination.providers); + // A text generator takes precedence over a provider instance with its ID. + return match && + rest.length === 0 && + matches(sourceEnvironment.providers).length === 1 && + textGeneratorForSelection(destination.settings, match.instanceId) === undefined + ? match.instanceId : null; } export function scopedModelSelectionsEqual( first: ModelSelection | null, - firstProviders: readonly ServerProvider[], + firstEnvironment: ScopedModelEnvironment, second: ModelSelection | null, - secondProviders: readonly ServerProvider[], + secondEnvironment: ScopedModelEnvironment, ): boolean { - if (Equal.equals(first, second)) return true; - if (first === null || second === null || first.instanceId === second.instanceId) return false; - return ( - resolveScopedModelInstanceId(first.instanceId, firstProviders, secondProviders) === - second.instanceId && - resolveScopedModelInstanceId(second.instanceId, secondProviders, firstProviders) === - first.instanceId && - Equal.equals({ ...first, instanceId: second.instanceId }, second) - ); + if (first === null || second === null) return first === second; + const firstProvider = find(firstEnvironment.providers, first.instanceId); + const secondProvider = find(secondEnvironment.providers, second.instanceId); + const sameInstance = + first.instanceId === second.instanceId + ? !(firstProvider && secondProvider && knownConflict(firstProvider, secondProvider)) + : resolveScopedModelInstanceId(first.instanceId, firstEnvironment, secondEnvironment) === + second.instanceId && + resolveScopedModelInstanceId(second.instanceId, secondEnvironment, firstEnvironment) === + first.instanceId; + return sameInstance && Equal.equals({ ...first, instanceId: second.instanceId }, second); } diff --git a/apps/web/src/components/settings/scopedSettings.test.ts b/apps/web/src/components/settings/scopedSettings.test.ts index e31c4ea85efa..18db39b986ca 100644 --- a/apps/web/src/components/settings/scopedSettings.test.ts +++ b/apps/web/src/components/settings/scopedSettings.test.ts @@ -15,6 +15,7 @@ import { resolveWorktreeCleanup } from "@t3tools/shared/projectSettings"; import type { SidebarProjectSnapshot } from "../../sidebarProjectGrouping"; import { + isProjectScopedSettingKey, persistScopedSettingsPatch, planProjectOverridesClear, planScopedSettingsClear, @@ -787,7 +788,7 @@ describe("model selection patches at project scope", () => { }); }); -describe("model selections for one account under distinct instance IDs", () => { +describe("model selections across environments' provider instances", () => { const account = ( instanceId: string, auth: Partial = {}, @@ -819,10 +820,10 @@ describe("model selections for one account under distinct instance IDs", () => { [server.environmentId, serverProviders], ]); const matching = providers([laptopCodex], [serverCodex]); - const fromLaptop = (providersByEnvironment: ReturnType) => ({ - sourceEnvironmentId: laptop.environmentId, - providersByEnvironment, - }); + const from = ( + providersByEnvironment: ReadonlyMap, + { environmentId, serverConfig } = laptop, + ) => ({ target: { environmentId, settings: serverConfig!.settings }, providersByEnvironment }); const luna = { instanceId: laptopCodex.instanceId, model: "gpt-6-luna" }; const keys = [ "defaultModelSelection", @@ -832,100 +833,88 @@ describe("model selections for one account under distinct instance IDs", () => { "reviewGuideModelSelection", "speechPostProcessingModelSelection", ] as const; + const forKeys = (value: T, only: readonly string[] = keys) => + Object.fromEntries(only.map((key) => [key, value])); it("writes each environment's own instance for every model setting", () => { - const patch = Object.fromEntries(keys.map((key) => [key, luna])); - const plan = planScopedSettingsPatch(all, environments, patch, fromLaptop(matching)); - expect(plan.unavailableReason).toBeNull(); + const plan = planScopedSettingsPatch(all, environments, forKeys(luna), from(matching)); expect(plan.serverWrites.map((write) => write.patch)).toEqual([ - patch, - Object.fromEntries(keys.map((key) => [key, { ...luna, instanceId: serverCodex.instanceId }])), + forKeys(luna), + forKeys({ ...luna, instanceId: serverCodex.instanceId }), ]); - const loading = new Map(matching); - loading.delete(server.environmentId); - const pending = planScopedSettingsPatch(all, environments, patch, fromLaptop(loading)); + const pending = planScopedSettingsPatch( + all, + environments, + forKeys(luna), + from(new Map([[laptop.environmentId, [laptopCodex]]])), + ); expect(pending.serverWrites).toEqual([]); expect(pending.unavailableReason).toContain("Server"); + // The source environment's own selection needs no snapshot. + expect( + planScopedSettingsPatch( + named, + environments, + forKeys(luna), + from(new Map(), server), + ).serverWrites.map((write) => write.patch), + ).toEqual([forKeys(luna)]); }); it("maps project overrides to each member's own instance", () => { - const projectKeys = [ - "defaultModelSelection", - "textGenerationModelSelection", - "sourceControlWriterModelSelection", - "reviewGuideModelSelection", - ] as const; + const projectKeys = keys.filter(isProjectScopedSettingKey); const plan = planScopedSettingsPatch( project, environments, - Object.fromEntries(projectKeys.map((key) => [key, luna])), - fromLaptop(matching), + forKeys(luna, projectKeys), + from(matching), ); expect(plan.serverWrites.map((write) => write.patch.projectSettingsOverrides)).toEqual([ - { - [projectId]: Object.fromEntries( - projectKeys.map((key) => [key, { ...luna, instanceId: serverCodex.instanceId }]), - ), - }, - { [laptopProjectId]: Object.fromEntries(projectKeys.map((key) => [key, luna])) }, + { [projectId]: forKeys({ ...luna, instanceId: serverCodex.instanceId }, projectKeys) }, + { [laptopProjectId]: forKeys(luna, projectKeys) }, ]); }); - it("prefers the same instance ID and driver over account matching", () => { - const shared = account("codex_shared"); - expect( - planScopedSettingsPatch( - all, - environments, - { defaultModelSelection: { instanceId: shared.instanceId, model: "gpt-6-luna" } }, - fromLaptop(providers([shared, laptopCodex], [serverCodex, shared])), - ).serverWrites.map((write) => write.patch.defaultModelSelection?.instanceId), - ).toEqual([shared.instanceId, shared.instanceId]); - }); - + const a = account; + const other = { email: "other@example.test" }; + const lower = { email: "gmail@example.test" }; + const team = { workspaceId: "team" }; + const apiKey = { type: "apiKey" }; + const none = { type: undefined, email: undefined, workspaceId: undefined }; + const nameOnly = (id: string) => ({ ...a(id, { workspaceId: undefined }), displayName: "Gmail" }); + const claude = "claudeAgent"; + const cursor = "cursor"; it.each([ - [ - "an API key account", - [account("codex_laptop_key", { type: "apiKey" })], - [account("codex_server_key", { type: "apiKey" })], - ], - [ - "a different workspace", - [laptopCodex], - [account("codex_server_gmail", { workspaceId: "team" })], - ], - [ - "a matching display name with no workspace", - [{ ...account("codex_laptop_gmail", { workspaceId: undefined }), displayName: "Gmail" }], - [{ ...account("codex_server_gmail", { workspaceId: undefined }), displayName: "Gmail" }], - ], - [ - "an unauthenticated destination", - [laptopCodex], - [account("codex_server_gmail", { status: "unknown" })], - ], - [ - "two matching destination accounts", - [laptopCodex], - [serverCodex, account("codex_server_other")], - ], - ["two matching source accounts", [laptopCodex, account("codex_laptop_other")], [serverCodex]], - ["missing from the source snapshot", [], [serverCodex]], - [ - "the same ID and login on another driver", - [laptopCodex], - [account(laptopCodex.instanceId, { type: "chatgpt" }, "claudeAgent")], - ], - ])("refuses every write when the account is %s", (_name, laptopProviders, serverProviders) => { - const instanceId = laptopProviders[0]?.instanceId ?? laptopCodex.instanceId; + ["one ChatGPT account under another ID", [a("l")], [a("s", lower)], "s"], + ["the same ID before account matching", [a("x"), a("l")], [a("s"), a("x")], "x"], + ["the same ID with an email differing in case", [a("x")], [a("x", lower)], "x"], + ["the same ID with an unreported identity", [a("x", {}, cursor)], [a("x", none, cursor)], "x"], + ["a conflicting same ID beside the matching account", [a("l")], [a("l", other), a("s")], "s"], + ["the same ID on another account", [a("x")], [a("x", other)], null], + ["the same ID in another workspace", [a("x")], [a("x", team)], null], + ["the same ID with another login", [a("x")], [a("x", apiKey)], null], + ["the same ID on another driver", [a("x")], [a("x", {}, claude)], null], + ["the same Claude ID on another account", [a("x", {}, claude)], [a("x", other, claude)], null], + ["one Claude subscription under another ID", [a("l", {}, claude)], [a("s", {}, claude)], null], + ["an API key account", [a("l", apiKey)], [a("s", apiKey)], null], + ["another workspace", [a("l")], [a("s", team)], null], + ["a matching display name with no workspace", [nameOnly("l")], [nameOnly("s")], null], + ["an unauthenticated destination", [a("l")], [a("s", { status: "unknown" })], null], + ["two matching destination accounts", [a("l")], [a("s"), a("s2")], null], + ["two matching source accounts", [a("l"), a("l2")], [a("s")], null], + ["missing from the source snapshot", [], [a("s")], null], + ] as const)("resolves %s", (_name, laptopProviders, serverProviders, serverId) => { + const instanceId = laptopProviders[0]?.instanceId ?? ProviderInstanceId.make("l"); const plan = planScopedSettingsPatch( all, environments, { textGenerationModelSelection: { instanceId, model: "gpt-6-luna" } }, - fromLaptop(providers(laptopProviders, serverProviders)), + from(providers([...laptopProviders], [...serverProviders])), ); - expect(plan.serverWrites).toEqual([]); - expect(plan.unavailableReason).toContain("Server"); + expect( + plan.serverWrites.map((write) => write.patch.textGenerationModelSelection?.instanceId), + ).toEqual(serverId === null ? [] : [instanceId, serverId]); + if (serverId === null) expect(plan.unavailableReason).toContain("Server"); }); it("keeps a text generator's ID only where it reaches the same API", () => { @@ -936,25 +925,22 @@ describe("model selections for one account under distinct instance IDs", () => { apiKey: "", enabled: true, }; - const withGenerator = ( - target: typeof laptop, - config: ServerSettings["textGenerators"][ProviderInstanceId] | undefined, - ) => - config - ? environment(target.label, { settings: { textGenerators: { [id]: config } } }) - : target; - const selection = { instanceId: id, model: "openai/gpt-6-mini" }; - const patch = { - textGenerationModelSelection: selection, - speechPostProcessingModelSelection: selection, - }; + const withGenerator = (target: typeof laptop, config?: typeof openrouter, generatorId = id) => + environment(target.label, { + settings: { textGenerators: config ? { [generatorId]: config } : {} }, + }); + const patch = forKeys({ instanceId: id, model: "openai/gpt-6-mini" }, [ + "textGenerationModelSelection", + "speechPostProcessingModelSelection", + ]); // Generators have no provider snapshots to match accounts through. + const source = withGenerator(laptop, openrouter); const plan = (serverGenerator?: typeof openrouter) => planScopedSettingsPatch( all, - [withGenerator(laptop, openrouter), withGenerator(server, serverGenerator)], + [source, withGenerator(server, serverGenerator)], patch, - fromLaptop(providers([], [])), + from(providers([], []), source), ); expect(plan(openrouter).serverWrites.map((write) => write.patch)).toEqual([patch, patch]); for (const serverGenerator of [ @@ -965,59 +951,33 @@ describe("model selections for one account under distinct instance IDs", () => { expect(plan(serverGenerator).serverWrites).toEqual([]); expect(plan(serverGenerator).unavailableReason).toContain("Server"); } + // A generator wins over a provider instance with its ID, so it cannot receive a matched account. + expect( + planScopedSettingsPatch( + all, + [laptop, withGenerator(server, openrouter, serverCodex.instanceId)], + { textGenerationModelSelection: luna }, + from(matching), + ).serverWrites, + ).toEqual([]); }); - it("treats the same account and model as one value in either order", () => { - const targets = [ - { - environmentId: laptop.environmentId, - settings: { - ...DEFAULT_SERVER_SETTINGS, - ...Object.fromEntries(keys.map((key) => [key, luna])), - }, - }, - { - environmentId: server.environmentId, - settings: { - ...DEFAULT_SERVER_SETTINGS, - ...Object.fromEntries( - keys.map((key) => [key, { ...luna, instanceId: serverCodex.instanceId }]), - ), - }, - }, - ]; - for (const ordered of [targets, [...targets].reverse()]) { - expect(scopedSettingsAreMixed(ordered, keys, matching)).toBe(false); - expect( - scopedSettingsAreMixed( - ordered, - keys, - providers( - [laptopCodex], - [serverCodex, account(laptopCodex.instanceId, { email: "other@example.test" })], - ), - ), - ).toBe(true); - expect( - scopedSettingsAreMixed( - [ - ordered[0]!, - { - ...ordered[1]!, - settings: { - ...ordered[1]!.settings, - reviewGuideModelSelection: { - ...luna, - instanceId: serverCodex.instanceId, - model: "gpt-6-sol", - }, - }, - }, - ], - ["reviewGuideModelSelection"], - matching, - ), - ).toBe(true); - } + it("compares selections by account in either order", () => { + const target = ({ environmentId }: typeof laptop, selection: object) => ({ + environmentId, + settings: { ...DEFAULT_SERVER_SETTINGS, ...forKeys(selection) }, + }); + const x = { ...luna, instanceId: ProviderInstanceId.make("x") }; + const onServer = { ...luna, instanceId: serverCodex.instanceId }; + const mixed = (onLaptop: object, onServer: object, providersByEnvironment = matching) => { + const targets = [target(laptop, onLaptop), target(server, onServer)]; + return [targets, [...targets].reverse()].map((ordered) => + scopedSettingsAreMixed(ordered, keys, providersByEnvironment), + ); + }; + expect.soft(mixed(luna, onServer)).toEqual([false, false]); + expect.soft(mixed(luna, { ...onServer, model: "gpt-6-sol" })).toEqual([true, true]); + expect.soft(mixed(x, x, new Map())).toEqual([false, false]); + expect.soft(mixed(x, x, providers([a("x")], [a("x", other)]))).toEqual([true, true]); }); }); diff --git a/apps/web/src/components/settings/scopedSettings.ts b/apps/web/src/components/settings/scopedSettings.ts index b421c7eccfd2..dcb335f4bc68 100644 --- a/apps/web/src/components/settings/scopedSettings.ts +++ b/apps/web/src/components/settings/scopedSettings.ts @@ -143,6 +143,10 @@ export function scopedSettingsAreMixed( providersByEnvironment?: ProvidersByEnvironment, ): boolean { const first = targets[0]; + const at = ({ environmentId, settings }: (typeof targets)[number]) => ({ + providers: providersByEnvironment?.get(environmentId) ?? [], + settings, + }); return ( first !== undefined && targets.some((candidate) => @@ -150,9 +154,9 @@ export function scopedSettingsAreMixed( providersByEnvironment && isScopedModelKey(key) ? !scopedModelSelectionsEqual( first.settings[key], - providersByEnvironment.get(first.environmentId) ?? [], + at(first), candidate.settings[key], - providersByEnvironment.get(candidate.environmentId) ?? [], + at(candidate), ) : !Equal.equals(first.settings[key], candidate.settings[key]), ), @@ -216,12 +220,8 @@ function projectOverrideWrites( continue; } const settings = environment.serverConfig.settings; - const entry = update( - settings.projectSettingsOverrides[member.id] ?? {}, - settings, - member.id, - environment, - ); + const current = settings.projectSettingsOverrides[member.id] ?? {}; + const entry = update(current, settings, member.id, environment); const existing = writes.get(member.environmentId); writes.set(member.environmentId, { environmentId: member.environmentId, @@ -252,20 +252,20 @@ function forDestination(previous: ServerSettings, patch: ServerSettingsPatch): S */ function forDestinationModels( patch: ServerSettingsPatch, - source: ScopedModelSource, - sourceSettings: ServerSettings | undefined, + modelSource: ScopedModelSource | null | undefined, destination: ScopedSettingsEnvironment, ): ServerSettingsPatch | null { + if (!modelSource || destination.environmentId === modelSource.target.environmentId) return patch; + const { target: source, providersByEnvironment } = modelSource; + const settings = destination.serverConfig?.settings; + const providers = providersByEnvironment.get(destination.environmentId); const next = { ...patch }; for (const key of SCOPED_MODEL_SETTING_KEYS) { const selection = patch[key]; if (selection?.instanceId === undefined) continue; - const shownGenerator = sourceSettings - ? textGeneratorForSelection(sourceSettings, selection.instanceId) - : undefined; - const generator = destination.serverConfig - ? textGeneratorForSelection(destination.serverConfig.settings, selection.instanceId) - : undefined; + if (!settings) return null; + const shownGenerator = textGeneratorForSelection(source.settings, selection.instanceId); + const generator = textGeneratorForSelection(settings, selection.instanceId); if (shownGenerator !== undefined || generator !== undefined) { if ( !generator?.enabled || @@ -276,12 +276,11 @@ function forDestinationModels( } continue; } - const providers = source.providersByEnvironment.get(destination.environmentId); if (!providers) return null; const instanceId = resolveScopedModelInstanceId( selection.instanceId, - source.providersByEnvironment.get(source.sourceEnvironmentId) ?? [], - providers, + { providers: providersByEnvironment.get(source.environmentId) ?? [] }, + { providers, settings }, ); if (instanceId === null) return null; Object.assign(next, { [key]: { ...selection, instanceId } }); @@ -290,8 +289,8 @@ function forDestinationModels( } export interface ScopedModelSource { - /** The environment whose provider instances the picker offered. */ - readonly sourceEnvironmentId: EnvironmentId; + /** The target whose provider instances and text generators the picker offered. */ + readonly target: Pick; readonly providersByEnvironment: ProvidersByEnvironment; } @@ -299,30 +298,25 @@ export function planScopedSettingsPatch( scope: ResolvedSettingsScope, environments: readonly ScopedSettingsEnvironment[], patch: ScopedSettingsPatch, - modelSource?: ScopedModelSource, + modelSource?: ScopedModelSource | null, ) { const clientPatch = Object.fromEntries( Object.entries(patch).filter(([key]) => CLIENT_KEYS.has(key) && !SERVER_KEYS.has(key)), ) as ClientSettingsPatch; - const rawServerPatch = Object.fromEntries( + const serverPatch = Object.fromEntries( Object.entries(patch).filter(([key]) => SERVER_KEYS.has(key)), ) as ServerSettingsPatch; - const serverKeys = Object.keys(rawServerPatch); + const serverKeys = Object.keys(serverPatch); const { connectedEnvironments } = selectScopedSettingsEnvironments(scope, environments, null); const isProjectScope = scope.kind === "project" || scope.kind === "checkout"; const unscopableKeys = isProjectScope ? serverKeys.filter((key) => !isProjectScopedSettingKey(key)) : []; - const unmatchedModelEnvironments = new Set(); - const sourceSettings = environments.find( - (environment) => environment.environmentId === modelSource?.sourceEnvironmentId, - )?.serverConfig?.settings; + const unmatched = new Set(); const serverPatchFor = (environment: ScopedSettingsEnvironment) => { - const mapped = modelSource - ? forDestinationModels(rawServerPatch, modelSource, sourceSettings, environment) - : rawServerPatch; - if (mapped === null) unmatchedModelEnvironments.add(environment.label); - return mapped ?? rawServerPatch; + const mapped = forDestinationModels(serverPatch, modelSource, environment); + if (mapped === null) unmatched.add(environment.label); + return mapped ?? serverPatch; }; const serverWrites: ScopedServerWrite[] = serverKeys.length === 0 @@ -411,12 +405,12 @@ export function planScopedSettingsPatch( ]; }) : []; - if (unmatchedModelEnvironments.size > 0) { + if (unmatched.size > 0) { return { clientPatch, hasClientWrite: false, serverWrites: [], - unavailableReason: `This model's account or text generator could not be matched on ${[...unmatchedModelEnvironments].join(", ")}. Select that environment to choose its model separately.`, + unavailableReason: `This model's account or text generator could not be matched on ${[...unmatched].join(", ")}. Select that environment to choose its model separately.`, }; } const hasClientWrite = Object.keys(clientPatch).length > 0; diff --git a/apps/web/src/components/settings/useScopedModelAvailability.ts b/apps/web/src/components/settings/useScopedModelAvailability.ts index f32d436fd3cd..bad84807450c 100644 --- a/apps/web/src/components/settings/useScopedModelAvailability.ts +++ b/apps/web/src/components/settings/useScopedModelAvailability.ts @@ -24,7 +24,9 @@ export function useScopedModelDisabledReason( const providersByEnvironment = useEnvironmentProviders(); return (instanceId: ProviderInstanceId, model: string): string | null => { const sourceEntry = entries.find((entry) => entry.instanceId === instanceId); - const sourceProviders = (source && providersByEnvironment.get(source.environmentId)) ?? []; + const sourceEnvironment = { + providers: (source && providersByEnvironment.get(source.environmentId)) ?? [], + }; const shownGenerator = textGeneratorForSelection(settings, instanceId); for (const candidate of targets) { const environment = environments.find( @@ -45,7 +47,10 @@ export function useScopedModelDisabledReason( } const providers = providersByEnvironment.get(candidate.environmentId); if (!environment?.serverConfig || !providers) continue; - const localId = resolveScopedModelInstanceId(instanceId, sourceProviders, providers); + const localId = resolveScopedModelInstanceId(instanceId, sourceEnvironment, { + providers, + settings: candidate.settings, + }); if (localId === null) { return `A unique matching account could not be verified on ${environment.label}. Select that environment to choose its model separately.`; } diff --git a/apps/web/src/components/settings/useScopedSettings.ts b/apps/web/src/components/settings/useScopedSettings.ts index 8e951ff2639e..628059d6c59d 100644 --- a/apps/web/src/components/settings/useScopedSettings.ts +++ b/apps/web/src/components/settings/useScopedSettings.ts @@ -110,7 +110,7 @@ export function useUpdateScopedSettings() { scope, environments, patch, - target ? { sourceEnvironmentId: target.environmentId, providersByEnvironment } : undefined, + target && { target, providersByEnvironment }, ), options?.onFailed, ); diff --git a/apps/web/src/modelSelection.ts b/apps/web/src/modelSelection.ts index b21c4380d32b..df69b33a9c7c 100644 --- a/apps/web/src/modelSelection.ts +++ b/apps/web/src/modelSelection.ts @@ -343,16 +343,14 @@ export function defaultModelSelectionsAreMixed( : null, }; }); - const [first] = selections; - const firstProviders = targets[0]?.providers ?? []; return selections.some( (selection, index) => !(["stored", "effective"] as const).every((key) => scopedModelSelectionsEqual( - first![key], - firstProviders, + selections[0]![key], + targets[0]!, selection[key], - targets[index]!.providers, + targets[index]!, ), ), ); From d17cd59c5e64dfc7d3966794ae0568b5750f6493 Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Mon, 9 Nov 2026 10:27:46 +0000 Subject: [PATCH 4/8] fix(web): reject conflicting accounts across all scoped targets --- .../settings/scopedModelSelection.ts | 10 ++-- .../settings/scopedSettings.test.ts | 54 ++++++++++--------- .../src/components/settings/scopedSettings.ts | 40 +++++++------- .../settings/useScopedModelAvailability.ts | 13 +++-- apps/web/src/modelSelection.ts | 22 ++++---- 5 files changed, 75 insertions(+), 64 deletions(-) diff --git a/apps/web/src/components/settings/scopedModelSelection.ts b/apps/web/src/components/settings/scopedModelSelection.ts index 61c6d3b3ecc8..d99e9a3da3e0 100644 --- a/apps/web/src/components/settings/scopedModelSelection.ts +++ b/apps/web/src/components/settings/scopedModelSelection.ts @@ -48,19 +48,18 @@ const chatGptAccount = (provider: ServerProvider) => const find = (providers: readonly ServerProvider[], instanceId: ProviderInstanceId) => providers.find((provider) => provider.instanceId === instanceId); -/** - * The destination instance for a source instance: the same ID unless its reported identity - * conflicts, else the one ChatGPT account that matches uniquely in both environments. - */ export function resolveScopedModelInstanceId( instanceId: ProviderInstanceId, sourceEnvironment: Pick, destination: ScopedModelEnvironment, + peers: readonly ServerProvider[] = [], ): ProviderInstanceId | null { const source = find(sourceEnvironment.providers, instanceId); if (!source) return null; const sameId = find(destination.providers, instanceId); - if (sameId && !knownConflict(source, sameId)) return instanceId; + const compatible = (provider: ServerProvider) => + !peers.some((peer) => knownConflict(peer, provider)); + if (sameId && !knownConflict(source, sameId) && compatible(sameId)) return instanceId; const account = chatGptAccount(source); if (account === null) return null; const matches = (providers: readonly ServerProvider[]) => @@ -70,6 +69,7 @@ export function resolveScopedModelInstanceId( return match && rest.length === 0 && matches(sourceEnvironment.providers).length === 1 && + compatible(match) && textGeneratorForSelection(destination.settings, match.instanceId) === undefined ? match.instanceId : null; diff --git a/apps/web/src/components/settings/scopedSettings.test.ts b/apps/web/src/components/settings/scopedSettings.test.ts index 18db39b986ca..e9f388cdfa08 100644 --- a/apps/web/src/components/settings/scopedSettings.test.ts +++ b/apps/web/src/components/settings/scopedSettings.test.ts @@ -27,6 +27,7 @@ import { selectScopedSettingsEnvironments, } from "./scopedSettings"; import { resolveSettingsScope } from "./settingsScope"; +import { resolveScopedModelInstanceId } from "./scopedModelSelection"; function environment( id: string, @@ -850,15 +851,6 @@ describe("model selections across environments' provider instances", () => { ); expect(pending.serverWrites).toEqual([]); expect(pending.unavailableReason).toContain("Server"); - // The source environment's own selection needs no snapshot. - expect( - planScopedSettingsPatch( - named, - environments, - forKeys(luna), - from(new Map(), server), - ).serverWrites.map((write) => write.patch), - ).toEqual([forKeys(luna)]); }); it("maps project overrides to each member's own instance", () => { @@ -885,36 +877,29 @@ describe("model selections across environments' provider instances", () => { const claude = "claudeAgent"; const cursor = "cursor"; it.each([ - ["one ChatGPT account under another ID", [a("l")], [a("s", lower)], "s"], - ["the same ID before account matching", [a("x"), a("l")], [a("s"), a("x")], "x"], - ["the same ID with an email differing in case", [a("x")], [a("x", lower)], "x"], - ["the same ID with an unreported identity", [a("x", {}, cursor)], [a("x", none, cursor)], "x"], - ["a conflicting same ID beside the matching account", [a("l")], [a("l", other), a("s")], "s"], + ["same ID takes precedence", [a("x"), a("l")], [a("s"), a("x")], "x"], + ["same ID, email case differs", [a("x")], [a("x", lower)], "x"], + ["same ID, unknown identity", [a("x", {}, cursor)], [a("x", none, cursor)], "x"], + ["conflicting ID beside matching account", [a("l")], [a("l", other), a("s")], "s"], ["the same ID on another account", [a("x")], [a("x", other)], null], ["the same ID in another workspace", [a("x")], [a("x", team)], null], ["the same ID with another login", [a("x")], [a("x", apiKey)], null], ["the same ID on another driver", [a("x")], [a("x", {}, claude)], null], - ["the same Claude ID on another account", [a("x", {}, claude)], [a("x", other, claude)], null], - ["one Claude subscription under another ID", [a("l", {}, claude)], [a("s", {}, claude)], null], + ["Claude ID, different account", [a("x", {}, claude)], [a("x", other, claude)], null], + ["Claude, different IDs", [a("l", {}, claude)], [a("s", {}, claude)], null], ["an API key account", [a("l", apiKey)], [a("s", apiKey)], null], ["another workspace", [a("l")], [a("s", team)], null], - ["a matching display name with no workspace", [nameOnly("l")], [nameOnly("s")], null], + ["same display name, no workspace", [nameOnly("l")], [nameOnly("s")], null], ["an unauthenticated destination", [a("l")], [a("s", { status: "unknown" })], null], ["two matching destination accounts", [a("l")], [a("s"), a("s2")], null], ["two matching source accounts", [a("l"), a("l2")], [a("s")], null], ["missing from the source snapshot", [], [a("s")], null], ] as const)("resolves %s", (_name, laptopProviders, serverProviders, serverId) => { const instanceId = laptopProviders[0]?.instanceId ?? ProviderInstanceId.make("l"); - const plan = planScopedSettingsPatch( - all, - environments, - { textGenerationModelSelection: { instanceId, model: "gpt-6-luna" } }, - from(providers([...laptopProviders], [...serverProviders])), - ); + const destination = { providers: serverProviders, settings: DEFAULT_SERVER_SETTINGS }; expect( - plan.serverWrites.map((write) => write.patch.textGenerationModelSelection?.instanceId), - ).toEqual(serverId === null ? [] : [instanceId, serverId]); - if (serverId === null) expect(plan.unavailableReason).toContain("Server"); + resolveScopedModelInstanceId(instanceId, { providers: laptopProviders }, destination), + ).toBe(serverId); }); it("keeps a text generator's ID only where it reaches the same API", () => { @@ -979,5 +964,22 @@ describe("model selections across environments' provider instances", () => { expect.soft(mixed(luna, { ...onServer, model: "gpt-6-sol" })).toEqual([true, true]); expect.soft(mixed(x, x, new Map())).toEqual([false, false]); expect.soft(mixed(x, x, providers([a("x")], [a("x", other)]))).toEqual([true, true]); + const third = environment("Third"); + const three = [target(laptop, x), target(server, x), target(third, x)]; + const identities = new Map([ + [laptop.environmentId, [a("x", none)]], + [server.environmentId, [a("x")]], + [third.environmentId, [a("x", other)]], + ]); + expect.soft(scopedSettingsAreMixed(three, keys, identities)).toBe(true); + const connected = [laptop, server, third]; + expect( + planScopedSettingsPatch( + resolveSettingsScope({}, [], connected), + connected, + forKeys(x), + from(identities), + ).serverWrites, + ).toEqual([]); }); }); diff --git a/apps/web/src/components/settings/scopedSettings.ts b/apps/web/src/components/settings/scopedSettings.ts index dcb335f4bc68..49d3d61b60a9 100644 --- a/apps/web/src/components/settings/scopedSettings.ts +++ b/apps/web/src/components/settings/scopedSettings.ts @@ -142,25 +142,25 @@ export function scopedSettingsAreMixed( keys: readonly (keyof ServerSettings)[], providersByEnvironment?: ProvidersByEnvironment, ): boolean { - const first = targets[0]; const at = ({ environmentId, settings }: (typeof targets)[number]) => ({ providers: providersByEnvironment?.get(environmentId) ?? [], settings, }); - return ( - first !== undefined && - targets.some((candidate) => - keys.some((key) => - providersByEnvironment && isScopedModelKey(key) - ? !scopedModelSelectionsEqual( - first.settings[key], - at(first), - candidate.settings[key], - at(candidate), - ) - : !Equal.equals(first.settings[key], candidate.settings[key]), + return targets.some((reference, index) => + targets + .slice(index + 1) + .some((candidate) => + keys.some((key) => + providersByEnvironment && isScopedModelKey(key) + ? !scopedModelSelectionsEqual( + reference.settings[key], + at(reference), + candidate.settings[key], + at(candidate), + ) + : !Equal.equals(reference.settings[key], candidate.settings[key]), + ), ), - ) ); } @@ -245,15 +245,11 @@ function forDestination(previous: ServerSettings, patch: ServerSettingsPatch): S return { ...rest, ...idleCompactionPatch(previous, idleCompaction) }; } -/** - * A model chosen from the source environment's instances, rewritten to each destination's - * instance for the same account. A text generator keeps its ID and must reach the same API. - * Null refuses an unresolved write. - */ function forDestinationModels( patch: ServerSettingsPatch, modelSource: ScopedModelSource | null | undefined, destination: ScopedSettingsEnvironment, + matched: Map, ): ServerSettingsPatch | null { if (!modelSource || destination.environmentId === modelSource.target.environmentId) return patch; const { target: source, providersByEnvironment } = modelSource; @@ -277,12 +273,15 @@ function forDestinationModels( continue; } if (!providers) return null; + const peers = matched.get(key) ?? []; const instanceId = resolveScopedModelInstanceId( selection.instanceId, { providers: providersByEnvironment.get(source.environmentId) ?? [] }, { providers, settings }, + peers, ); if (instanceId === null) return null; + matched.set(key, [...peers, providers.find((provider) => provider.instanceId === instanceId)!]); Object.assign(next, { [key]: { ...selection, instanceId } }); } return next; @@ -313,8 +312,9 @@ export function planScopedSettingsPatch( ? serverKeys.filter((key) => !isProjectScopedSettingKey(key)) : []; const unmatched = new Set(); + const matched = new Map(); const serverPatchFor = (environment: ScopedSettingsEnvironment) => { - const mapped = forDestinationModels(serverPatch, modelSource, environment); + const mapped = forDestinationModels(serverPatch, modelSource, environment, matched); if (mapped === null) unmatched.add(environment.label); return mapped ?? serverPatch; }; diff --git a/apps/web/src/components/settings/useScopedModelAvailability.ts b/apps/web/src/components/settings/useScopedModelAvailability.ts index bad84807450c..c464f7064c1c 100644 --- a/apps/web/src/components/settings/useScopedModelAvailability.ts +++ b/apps/web/src/components/settings/useScopedModelAvailability.ts @@ -1,5 +1,6 @@ import { type ProviderInstanceId, + type ServerProvider, textGeneratorForSelection, type UnifiedSettings, } from "@t3tools/contracts"; @@ -28,6 +29,7 @@ export function useScopedModelDisabledReason( providers: (source && providersByEnvironment.get(source.environmentId)) ?? [], }; const shownGenerator = textGeneratorForSelection(settings, instanceId); + const peers: ServerProvider[] = []; for (const candidate of targets) { const environment = environments.find( (entry) => entry.environmentId === candidate.environmentId, @@ -47,13 +49,16 @@ export function useScopedModelDisabledReason( } const providers = providersByEnvironment.get(candidate.environmentId); if (!environment?.serverConfig || !providers) continue; - const localId = resolveScopedModelInstanceId(instanceId, sourceEnvironment, { - providers, - settings: candidate.settings, - }); + const localId = resolveScopedModelInstanceId( + instanceId, + sourceEnvironment, + { providers, settings: candidate.settings }, + peers, + ); if (localId === null) { return `A unique matching account could not be verified on ${environment.label}. Select that environment to choose its model separately.`; } + peers.push(providers.find((provider) => provider.instanceId === localId)!); const entry = applyProviderInstanceSettings( deriveProviderInstanceEntries(providers), candidate.settings, diff --git a/apps/web/src/modelSelection.ts b/apps/web/src/modelSelection.ts index df69b33a9c7c..6b509907dff2 100644 --- a/apps/web/src/modelSelection.ts +++ b/apps/web/src/modelSelection.ts @@ -343,15 +343,19 @@ export function defaultModelSelectionsAreMixed( : null, }; }); - return selections.some( - (selection, index) => - !(["stored", "effective"] as const).every((key) => - scopedModelSelectionsEqual( - selections[0]![key], - targets[0]!, - selection[key], - targets[index]!, - ), + return selections.some((reference, index) => + selections + .slice(index + 1) + .some( + (selection, offset) => + !(["stored", "effective"] as const).every((key) => + scopedModelSelectionsEqual( + reference[key], + targets[index]!, + selection[key], + targets[index + offset + 1]!, + ), + ), ), ); } From ea06907507649d1cf89450f9a4c4faf2c3991dee Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Mon, 9 Nov 2026 10:41:34 +0000 Subject: [PATCH 5/8] fix(web): compare text generators before provider identities --- .../src/components/settings/scopedModelSelection.ts | 9 +++++++++ .../src/components/settings/scopedSettings.test.ts | 12 ++++++++++-- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/settings/scopedModelSelection.ts b/apps/web/src/components/settings/scopedModelSelection.ts index d99e9a3da3e0..52303090bfa4 100644 --- a/apps/web/src/components/settings/scopedModelSelection.ts +++ b/apps/web/src/components/settings/scopedModelSelection.ts @@ -82,6 +82,15 @@ export function scopedModelSelectionsEqual( secondEnvironment: ScopedModelEnvironment, ): boolean { if (first === null || second === null) return first === second; + const firstGenerator = textGeneratorForSelection(firstEnvironment.settings, first.instanceId); + const secondGenerator = textGeneratorForSelection(secondEnvironment.settings, second.instanceId); + if (firstGenerator || secondGenerator) { + return ( + firstGenerator?.kind === secondGenerator?.kind && + firstGenerator?.baseUrl === secondGenerator?.baseUrl && + Equal.equals(first, second) + ); + } const firstProvider = find(firstEnvironment.providers, first.instanceId); const secondProvider = find(secondEnvironment.providers, second.instanceId); const sameInstance = diff --git a/apps/web/src/components/settings/scopedSettings.test.ts b/apps/web/src/components/settings/scopedSettings.test.ts index e9f388cdfa08..387e0dca3508 100644 --- a/apps/web/src/components/settings/scopedSettings.test.ts +++ b/apps/web/src/components/settings/scopedSettings.test.ts @@ -27,7 +27,7 @@ import { selectScopedSettingsEnvironments, } from "./scopedSettings"; import { resolveSettingsScope } from "./settingsScope"; -import { resolveScopedModelInstanceId } from "./scopedModelSelection"; +import { resolveScopedModelInstanceId, scopedModelSelectionsEqual } from "./scopedModelSelection"; function environment( id: string, @@ -914,12 +914,20 @@ describe("model selections across environments' provider instances", () => { environment(target.label, { settings: { textGenerators: config ? { [generatorId]: config } : {} }, }); - const patch = forKeys({ instanceId: id, model: "openai/gpt-6-mini" }, [ + const selection = { instanceId: id, model: "openai/gpt-6-mini" }; + const patch = forKeys(selection, [ "textGenerationModelSelection", "speechPostProcessingModelSelection", ]); // Generators have no provider snapshots to match accounts through. const source = withGenerator(laptop, openrouter); + const generator = { providers: [a(id)], settings: source.serverConfig!.settings }; + expect( + scopedModelSelectionsEqual(selection, generator, selection, { + ...generator, + providers: [a(id, other)], + }), + ).toBe(true); const plan = (serverGenerator?: typeof openrouter) => planScopedSettingsPatch( all, From 493b3f893ff0a25b36ae2e243e38832b3ac79ca6 Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Mon, 9 Nov 2026 10:49:46 +0000 Subject: [PATCH 6/8] fix(web): scope generator matching to supported model settings --- .../settings/ProjectDefaultsSettings.tsx | 6 ++- .../settings/SourceControlWritingSettings.tsx | 11 +++-- .../settings/scopedModelSelection.ts | 47 ++++++++++++++++--- .../settings/scopedSettings.test.ts | 26 +++++++++- .../src/components/settings/scopedSettings.ts | 10 ++-- .../settings/useScopedModelAvailability.ts | 15 ++++-- 6 files changed, 96 insertions(+), 19 deletions(-) diff --git a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx index b091d0d58f3f..e29f3a9e6666 100644 --- a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx +++ b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx @@ -115,7 +115,11 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting ? resolveProjectSettings(target.settings, null, null, null).settings : null; - const modelDisabledReason = useScopedModelDisabledReason(settings, entries); + const modelDisabledReason = useScopedModelDisabledReason( + settings, + entries, + "defaultModelSelection", + ); const effortDisabledReason = selection ? modelDisabledReason(selection.instanceId, selection.model) : null; diff --git a/apps/web/src/components/settings/SourceControlWritingSettings.tsx b/apps/web/src/components/settings/SourceControlWritingSettings.tsx index af5b0f292517..5b3eba5e5b01 100644 --- a/apps/web/src/components/settings/SourceControlWritingSettings.tsx +++ b/apps/web/src/components/settings/SourceControlWritingSettings.tsx @@ -157,6 +157,11 @@ export function SourceControlWritingSettingsSection() { activeSelection.model, ); const writerModelDisabledReason = useScopedModelDisabledReason(settings, writerEntries); + const reviewGuideModelDisabledReason = useScopedModelDisabledReason( + settings, + instanceEntries, + "reviewGuideModelSelection", + ); const writerInstanceEntry = writerEntries.find( (entry) => entry.instanceId === activeSelection.instanceId, ); @@ -352,9 +357,9 @@ export function SourceControlWritingSettingsSection() { triggerClassName={SETTINGS_PICKER_TRIGGER_CLASSNAME} triggerAriaLabel="Review guide model" {...(mixedReviewGuideModel ? { triggerLabel: "Mixed" } : {})} - getModelDisabledReason={writerModelDisabledReason} + getModelDisabledReason={reviewGuideModelDisabledReason} onInstanceModelChange={(instanceId, model) => { - const reason = writerModelDisabledReason(instanceId, model); + const reason = reviewGuideModelDisabledReason(instanceId, model); if (reason) { toastManager.add({ type: "error", @@ -404,7 +409,7 @@ export function SourceControlWritingSettingsSection() { const seed = textGeneratorForSelection(settings, activeSelection.instanceId) ? reviewGuideSelection : activeSelection; - const reason = writerModelDisabledReason(seed.instanceId, seed.model); + const reason = reviewGuideModelDisabledReason(seed.instanceId, seed.model); if (reason) { toastManager.add({ type: "error", diff --git a/apps/web/src/components/settings/scopedModelSelection.ts b/apps/web/src/components/settings/scopedModelSelection.ts index 52303090bfa4..8ec3a562bad0 100644 --- a/apps/web/src/components/settings/scopedModelSelection.ts +++ b/apps/web/src/components/settings/scopedModelSelection.ts @@ -15,6 +15,21 @@ export const SCOPED_MODEL_SETTING_KEYS = [ "reviewGuideModelSelection", "speechPostProcessingModelSelection", ] as const; +export type ScopedModelSettingKey = (typeof SCOPED_MODEL_SETTING_KEYS)[number]; + +export const supportsScopedTextGenerator = (key: ScopedModelSettingKey) => + key === "textGenerationModelSelection" || + key === "sourceControlWriterModelSelection" || + key === "speechPostProcessingModelSelection"; + +export const scopedTextGenerator = ( + settings: Pick, + instanceId: ProviderInstanceId, + key: ScopedModelSettingKey, +) => + key === "defaultModelSelection" || key === "fallbackModelSelection" + ? undefined + : textGeneratorForSelection(settings, instanceId); export interface ScopedModelEnvironment { readonly providers: readonly ServerProvider[]; @@ -53,9 +68,15 @@ export function resolveScopedModelInstanceId( sourceEnvironment: Pick, destination: ScopedModelEnvironment, peers: readonly ServerProvider[] = [], + key: ScopedModelSettingKey = "defaultModelSelection", ): ProviderInstanceId | null { const source = find(sourceEnvironment.providers, instanceId); if (!source) return null; + if ( + key === "reviewGuideModelSelection" && + scopedTextGenerator(destination.settings, instanceId, key) + ) + return null; const sameId = find(destination.providers, instanceId); const compatible = (provider: ServerProvider) => !peers.some((peer) => knownConflict(peer, provider)); @@ -70,7 +91,7 @@ export function resolveScopedModelInstanceId( rest.length === 0 && matches(sourceEnvironment.providers).length === 1 && compatible(match) && - textGeneratorForSelection(destination.settings, match.instanceId) === undefined + scopedTextGenerator(destination.settings, match.instanceId, key) === undefined ? match.instanceId : null; } @@ -80,12 +101,14 @@ export function scopedModelSelectionsEqual( firstEnvironment: ScopedModelEnvironment, second: ModelSelection | null, secondEnvironment: ScopedModelEnvironment, + key: ScopedModelSettingKey = "defaultModelSelection", ): boolean { if (first === null || second === null) return first === second; - const firstGenerator = textGeneratorForSelection(firstEnvironment.settings, first.instanceId); - const secondGenerator = textGeneratorForSelection(secondEnvironment.settings, second.instanceId); + const firstGenerator = scopedTextGenerator(firstEnvironment.settings, first.instanceId, key); + const secondGenerator = scopedTextGenerator(secondEnvironment.settings, second.instanceId, key); if (firstGenerator || secondGenerator) { return ( + supportsScopedTextGenerator(key) && firstGenerator?.kind === secondGenerator?.kind && firstGenerator?.baseUrl === secondGenerator?.baseUrl && Equal.equals(first, second) @@ -96,9 +119,19 @@ export function scopedModelSelectionsEqual( const sameInstance = first.instanceId === second.instanceId ? !(firstProvider && secondProvider && knownConflict(firstProvider, secondProvider)) - : resolveScopedModelInstanceId(first.instanceId, firstEnvironment, secondEnvironment) === - second.instanceId && - resolveScopedModelInstanceId(second.instanceId, secondEnvironment, firstEnvironment) === - first.instanceId; + : resolveScopedModelInstanceId( + first.instanceId, + firstEnvironment, + secondEnvironment, + [], + key, + ) === second.instanceId && + resolveScopedModelInstanceId( + second.instanceId, + secondEnvironment, + firstEnvironment, + [], + key, + ) === first.instanceId; return sameInstance && Equal.equals({ ...first, instanceId: second.instanceId }, second); } diff --git a/apps/web/src/components/settings/scopedSettings.test.ts b/apps/web/src/components/settings/scopedSettings.test.ts index 387e0dca3508..296db342a401 100644 --- a/apps/web/src/components/settings/scopedSettings.test.ts +++ b/apps/web/src/components/settings/scopedSettings.test.ts @@ -922,12 +922,36 @@ describe("model selections across environments' provider instances", () => { // Generators have no provider snapshots to match accounts through. const source = withGenerator(laptop, openrouter); const generator = { providers: [a(id)], settings: source.serverConfig!.settings }; + expect( + scopedModelSelectionsEqual( + selection, + generator, + selection, + { + ...generator, + providers: [a(id, other)], + }, + "textGenerationModelSelection", + ), + ).toBe(true); expect( scopedModelSelectionsEqual(selection, generator, selection, { ...generator, providers: [a(id, other)], }), - ).toBe(true); + ).toBe(false); + expect( + planScopedSettingsPatch( + all, + [source, withGenerator(server, openrouter)], + forKeys(selection, [ + "defaultModelSelection", + "fallbackModelSelection", + "reviewGuideModelSelection", + ]), + from(providers([a(id)], [a(id, other)]), source), + ).serverWrites, + ).toEqual([]); const plan = (serverGenerator?: typeof openrouter) => planScopedSettingsPatch( all, diff --git a/apps/web/src/components/settings/scopedSettings.ts b/apps/web/src/components/settings/scopedSettings.ts index 49d3d61b60a9..a9703c3cba8e 100644 --- a/apps/web/src/components/settings/scopedSettings.ts +++ b/apps/web/src/components/settings/scopedSettings.ts @@ -12,7 +12,6 @@ import { type ProjectSettingsOverrides, ServerSettings, type T3ProjectFile, - textGeneratorForSelection, type ServerProvider, type ServerSettingsPatch, } from "@t3tools/contracts"; @@ -31,6 +30,8 @@ import { resolveScopedModelInstanceId, SCOPED_MODEL_SETTING_KEYS, scopedModelSelectionsEqual, + scopedTextGenerator, + supportsScopedTextGenerator, } from "./scopedModelSelection"; export type ScopedSettingsPatch = ServerSettingsPatch & ClientSettingsPatch; @@ -157,6 +158,7 @@ export function scopedSettingsAreMixed( at(reference), candidate.settings[key], at(candidate), + key, ) : !Equal.equals(reference.settings[key], candidate.settings[key]), ), @@ -260,10 +262,11 @@ function forDestinationModels( const selection = patch[key]; if (selection?.instanceId === undefined) continue; if (!settings) return null; - const shownGenerator = textGeneratorForSelection(source.settings, selection.instanceId); - const generator = textGeneratorForSelection(settings, selection.instanceId); + const shownGenerator = scopedTextGenerator(source.settings, selection.instanceId, key); + const generator = scopedTextGenerator(settings, selection.instanceId, key); if (shownGenerator !== undefined || generator !== undefined) { if ( + !supportsScopedTextGenerator(key) || !generator?.enabled || generator.kind !== shownGenerator?.kind || generator.baseUrl !== shownGenerator.baseUrl @@ -279,6 +282,7 @@ function forDestinationModels( { providers: providersByEnvironment.get(source.environmentId) ?? [] }, { providers, settings }, peers, + key, ); if (instanceId === null) return null; matched.set(key, [...peers, providers.find((provider) => provider.instanceId === instanceId)!]); diff --git a/apps/web/src/components/settings/useScopedModelAvailability.ts b/apps/web/src/components/settings/useScopedModelAvailability.ts index c464f7064c1c..e65d383e20d7 100644 --- a/apps/web/src/components/settings/useScopedModelAvailability.ts +++ b/apps/web/src/components/settings/useScopedModelAvailability.ts @@ -1,7 +1,6 @@ import { type ProviderInstanceId, type ServerProvider, - textGeneratorForSelection, type UnifiedSettings, } from "@t3tools/contracts"; @@ -13,12 +12,18 @@ import { type ProviderInstanceEntry, } from "../../providerInstances"; import { useEnvironmentProviders, useEnvironments } from "../../state/environments"; -import { resolveScopedModelInstanceId } from "./scopedModelSelection"; +import { + resolveScopedModelInstanceId, + scopedTextGenerator, + supportsScopedTextGenerator, + type ScopedModelSettingKey, +} from "./scopedModelSelection"; import { useSettingsScope } from "./SettingsScopeContext"; export function useScopedModelDisabledReason( settings: UnifiedSettings, entries: readonly ProviderInstanceEntry[], + key: ScopedModelSettingKey = "textGenerationModelSelection", ) { const { targets, target: source } = useSettingsScope(); const { environments } = useEnvironments(); @@ -28,17 +33,18 @@ export function useScopedModelDisabledReason( const sourceEnvironment = { providers: (source && providersByEnvironment.get(source.environmentId)) ?? [], }; - const shownGenerator = textGeneratorForSelection(settings, instanceId); + const shownGenerator = scopedTextGenerator(settings, instanceId, key); const peers: ServerProvider[] = []; for (const candidate of targets) { const environment = environments.find( (entry) => entry.environmentId === candidate.environmentId, ); const unavailable = `This model is unavailable on ${environment?.label ?? "a selected environment"}. Select that environment to choose its model separately.`; - const generator = textGeneratorForSelection(candidate.settings, instanceId); + const generator = scopedTextGenerator(candidate.settings, instanceId, key); if (shownGenerator !== undefined || generator !== undefined) { // Only the shown environment's model list is loaded; elsewhere it must reach the same API. if ( + !supportsScopedTextGenerator(key) || !generator?.enabled || generator.kind !== shownGenerator?.kind || generator.baseUrl !== shownGenerator.baseUrl @@ -54,6 +60,7 @@ export function useScopedModelDisabledReason( sourceEnvironment, { providers, settings: candidate.settings }, peers, + key, ); if (localId === null) { return `A unique matching account could not be verified on ${environment.label}. Select that environment to choose its model separately.`; From b045fbb5bc792c933090f9c9244edf47c58a1d0d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 03:22:59 +0000 Subject: [PATCH 7/8] refactor(web): share the scoped model key guard, drop redundant cases AI-assisted; verified with the tests and tools listed above. --- .../components/settings/scopedModelSelection.ts | 3 +++ .../components/settings/scopedSettings.test.ts | 17 ++++++----------- .../src/components/settings/scopedSettings.ts | 6 ++---- 3 files changed, 11 insertions(+), 15 deletions(-) diff --git a/apps/web/src/components/settings/scopedModelSelection.ts b/apps/web/src/components/settings/scopedModelSelection.ts index 8ec3a562bad0..0b228482a2a0 100644 --- a/apps/web/src/components/settings/scopedModelSelection.ts +++ b/apps/web/src/components/settings/scopedModelSelection.ts @@ -17,6 +17,9 @@ export const SCOPED_MODEL_SETTING_KEYS = [ ] as const; export type ScopedModelSettingKey = (typeof SCOPED_MODEL_SETTING_KEYS)[number]; +export const isScopedModelSettingKey = (key: string): key is ScopedModelSettingKey => + (SCOPED_MODEL_SETTING_KEYS as readonly string[]).includes(key); + export const supportsScopedTextGenerator = (key: ScopedModelSettingKey) => key === "textGenerationModelSelection" || key === "sourceControlWriterModelSelection" || diff --git a/apps/web/src/components/settings/scopedSettings.test.ts b/apps/web/src/components/settings/scopedSettings.test.ts index 296db342a401..26d122aef94f 100644 --- a/apps/web/src/components/settings/scopedSettings.test.ts +++ b/apps/web/src/components/settings/scopedSettings.test.ts @@ -27,7 +27,11 @@ import { selectScopedSettingsEnvironments, } from "./scopedSettings"; import { resolveSettingsScope } from "./settingsScope"; -import { resolveScopedModelInstanceId, scopedModelSelectionsEqual } from "./scopedModelSelection"; +import { + resolveScopedModelInstanceId, + SCOPED_MODEL_SETTING_KEYS, + scopedModelSelectionsEqual, +} from "./scopedModelSelection"; function environment( id: string, @@ -826,14 +830,7 @@ describe("model selections across environments' provider instances", () => { { environmentId, serverConfig } = laptop, ) => ({ target: { environmentId, settings: serverConfig!.settings }, providersByEnvironment }); const luna = { instanceId: laptopCodex.instanceId, model: "gpt-6-luna" }; - const keys = [ - "defaultModelSelection", - "fallbackModelSelection", - "textGenerationModelSelection", - "sourceControlWriterModelSelection", - "reviewGuideModelSelection", - "speechPostProcessingModelSelection", - ] as const; + const keys = SCOPED_MODEL_SETTING_KEYS; const forKeys = (value: T, only: readonly string[] = keys) => Object.fromEntries(only.map((key) => [key, value])); @@ -882,10 +879,8 @@ describe("model selections across environments' provider instances", () => { ["same ID, unknown identity", [a("x", {}, cursor)], [a("x", none, cursor)], "x"], ["conflicting ID beside matching account", [a("l")], [a("l", other), a("s")], "s"], ["the same ID on another account", [a("x")], [a("x", other)], null], - ["the same ID in another workspace", [a("x")], [a("x", team)], null], ["the same ID with another login", [a("x")], [a("x", apiKey)], null], ["the same ID on another driver", [a("x")], [a("x", {}, claude)], null], - ["Claude ID, different account", [a("x", {}, claude)], [a("x", other, claude)], null], ["Claude, different IDs", [a("l", {}, claude)], [a("s", {}, claude)], null], ["an API key account", [a("l", apiKey)], [a("s", apiKey)], null], ["another workspace", [a("l")], [a("s", team)], null], diff --git a/apps/web/src/components/settings/scopedSettings.ts b/apps/web/src/components/settings/scopedSettings.ts index a9703c3cba8e..6381bf518bce 100644 --- a/apps/web/src/components/settings/scopedSettings.ts +++ b/apps/web/src/components/settings/scopedSettings.ts @@ -27,6 +27,7 @@ import * as Cause from "effect/Cause"; import type { ResolvedSettingsScope } from "./settingsScope"; import { + isScopedModelSettingKey, resolveScopedModelInstanceId, SCOPED_MODEL_SETTING_KEYS, scopedModelSelectionsEqual, @@ -135,9 +136,6 @@ export function resolveScopedSettingsTargets( type ProvidersByEnvironment = ReadonlyMap; -const isScopedModelKey = (key: string): key is (typeof SCOPED_MODEL_SETTING_KEYS)[number] => - (SCOPED_MODEL_SETTING_KEYS as readonly string[]).includes(key); - export function scopedSettingsAreMixed( targets: readonly Pick[], keys: readonly (keyof ServerSettings)[], @@ -152,7 +150,7 @@ export function scopedSettingsAreMixed( .slice(index + 1) .some((candidate) => keys.some((key) => - providersByEnvironment && isScopedModelKey(key) + providersByEnvironment && isScopedModelSettingKey(key) ? !scopedModelSelectionsEqual( reference.settings[key], at(reference), From edaf9c075f5a889ee7748983a357a691e9cfbbef Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 03:43:28 +0000 Subject: [PATCH 8/8] fix(web): keep a shared instance ID even when environments report other accounts AI-assisted; verified with the tests and tools listed above. --- .../settings/scopedModelSelection.ts | 64 +++++-------------- .../settings/scopedSettings.test.ts | 38 +++-------- .../src/components/settings/scopedSettings.ts | 7 +- .../settings/useScopedModelAvailability.ts | 9 +-- 4 files changed, 27 insertions(+), 91 deletions(-) diff --git a/apps/web/src/components/settings/scopedModelSelection.ts b/apps/web/src/components/settings/scopedModelSelection.ts index 0b228482a2a0..9e67a552f1ab 100644 --- a/apps/web/src/components/settings/scopedModelSelection.ts +++ b/apps/web/src/components/settings/scopedModelSelection.ts @@ -39,52 +39,34 @@ export interface ScopedModelEnvironment { readonly settings: Pick; } -const identity = ({ auth }: ServerProvider) => [ - auth.type, - auth.email?.toLowerCase(), - auth.workspaceId, -]; - -/** Unreported login, email, or workspace is unknown, not a conflict. */ -const knownConflict = (first: ServerProvider, second: ServerProvider) => - first.driver !== second.driver || - identity(first).some((value, index) => { - const other = identity(second)[index]; - return value && other && value !== other; - }); - /** Instance IDs and names are machine-local; only a reported ChatGPT login identifies the account. */ -const chatGptAccount = (provider: ServerProvider) => - provider.driver === "codex" && - provider.auth.status === "authenticated" && - provider.auth.type === "chatgpt" && - provider.auth.email && - provider.auth.workspaceId - ? JSON.stringify(identity(provider)) +const chatGptAccount = ({ driver, auth }: ServerProvider) => + driver === "codex" && + auth.status === "authenticated" && + auth.type === "chatgpt" && + auth.email && + auth.workspaceId + ? `${auth.email.toLowerCase()}\n${auth.workspaceId}` : null; const find = (providers: readonly ServerProvider[], instanceId: ProviderInstanceId) => providers.find((provider) => provider.instanceId === instanceId); +/** Keeps an ID the destination has; remaps an absent one only through a unique ChatGPT account. */ export function resolveScopedModelInstanceId( instanceId: ProviderInstanceId, sourceEnvironment: Pick, destination: ScopedModelEnvironment, - peers: readonly ServerProvider[] = [], key: ScopedModelSettingKey = "defaultModelSelection", ): ProviderInstanceId | null { - const source = find(sourceEnvironment.providers, instanceId); - if (!source) return null; if ( key === "reviewGuideModelSelection" && scopedTextGenerator(destination.settings, instanceId, key) ) return null; - const sameId = find(destination.providers, instanceId); - const compatible = (provider: ServerProvider) => - !peers.some((peer) => knownConflict(peer, provider)); - if (sameId && !knownConflict(source, sameId) && compatible(sameId)) return instanceId; - const account = chatGptAccount(source); + if (find(destination.providers, instanceId)) return instanceId; + const source = find(sourceEnvironment.providers, instanceId); + const account = source ? chatGptAccount(source) : null; if (account === null) return null; const matches = (providers: readonly ServerProvider[]) => providers.filter((provider) => chatGptAccount(provider) === account); @@ -93,7 +75,6 @@ export function resolveScopedModelInstanceId( return match && rest.length === 0 && matches(sourceEnvironment.providers).length === 1 && - compatible(match) && scopedTextGenerator(destination.settings, match.instanceId, key) === undefined ? match.instanceId : null; @@ -117,24 +98,11 @@ export function scopedModelSelectionsEqual( Equal.equals(first, second) ); } - const firstProvider = find(firstEnvironment.providers, first.instanceId); - const secondProvider = find(secondEnvironment.providers, second.instanceId); const sameInstance = - first.instanceId === second.instanceId - ? !(firstProvider && secondProvider && knownConflict(firstProvider, secondProvider)) - : resolveScopedModelInstanceId( - first.instanceId, - firstEnvironment, - secondEnvironment, - [], - key, - ) === second.instanceId && - resolveScopedModelInstanceId( - second.instanceId, - secondEnvironment, - firstEnvironment, - [], - key, - ) === first.instanceId; + first.instanceId === second.instanceId || + (resolveScopedModelInstanceId(first.instanceId, firstEnvironment, secondEnvironment, key) === + second.instanceId && + resolveScopedModelInstanceId(second.instanceId, secondEnvironment, firstEnvironment, key) === + first.instanceId); return sameInstance && Equal.equals({ ...first, instanceId: second.instanceId }, second); } diff --git a/apps/web/src/components/settings/scopedSettings.test.ts b/apps/web/src/components/settings/scopedSettings.test.ts index 26d122aef94f..1ef324f7296c 100644 --- a/apps/web/src/components/settings/scopedSettings.test.ts +++ b/apps/web/src/components/settings/scopedSettings.test.ts @@ -869,18 +869,12 @@ describe("model selections across environments' provider instances", () => { const lower = { email: "gmail@example.test" }; const team = { workspaceId: "team" }; const apiKey = { type: "apiKey" }; - const none = { type: undefined, email: undefined, workspaceId: undefined }; const nameOnly = (id: string) => ({ ...a(id, { workspaceId: undefined }), displayName: "Gmail" }); const claude = "claudeAgent"; - const cursor = "cursor"; it.each([ ["same ID takes precedence", [a("x"), a("l")], [a("s"), a("x")], "x"], - ["same ID, email case differs", [a("x")], [a("x", lower)], "x"], - ["same ID, unknown identity", [a("x", {}, cursor)], [a("x", none, cursor)], "x"], - ["conflicting ID beside matching account", [a("l")], [a("l", other), a("s")], "s"], - ["the same ID on another account", [a("x")], [a("x", other)], null], - ["the same ID with another login", [a("x")], [a("x", apiKey)], null], - ["the same ID on another driver", [a("x")], [a("x", {}, claude)], null], + ["the same ID on another account", [a("x", {}, claude)], [a("x", other, claude)], "x"], + ["case-insensitive email", [a("l")], [a("s", lower)], "s"], ["Claude, different IDs", [a("l", {}, claude)], [a("s", {}, claude)], null], ["an API key account", [a("l", apiKey)], [a("s", apiKey)], null], ["another workspace", [a("l")], [a("s", team)], null], @@ -929,12 +923,6 @@ describe("model selections across environments' provider instances", () => { "textGenerationModelSelection", ), ).toBe(true); - expect( - scopedModelSelectionsEqual(selection, generator, selection, { - ...generator, - providers: [a(id, other)], - }), - ).toBe(false); expect( planScopedSettingsPatch( all, @@ -990,23 +978,15 @@ describe("model selections across environments' provider instances", () => { expect.soft(mixed(luna, onServer)).toEqual([false, false]); expect.soft(mixed(luna, { ...onServer, model: "gpt-6-sol" })).toEqual([true, true]); expect.soft(mixed(x, x, new Map())).toEqual([false, false]); - expect.soft(mixed(x, x, providers([a("x")], [a("x", other)]))).toEqual([true, true]); - const third = environment("Third"); - const three = [target(laptop, x), target(server, x), target(third, x)]; - const identities = new Map([ - [laptop.environmentId, [a("x", none)]], - [server.environmentId, [a("x")]], - [third.environmentId, [a("x", other)]], - ]); - expect.soft(scopedSettingsAreMixed(three, keys, identities)).toBe(true); - const connected = [laptop, server, third]; + // Main writes a shared ID everywhere even when each machine reports another account. + expect.soft(mixed(x, x, providers([a("x")], [a("x", other)]))).toEqual([false, false]); expect( planScopedSettingsPatch( - resolveSettingsScope({}, [], connected), - connected, + all, + environments, forKeys(x), - from(identities), - ).serverWrites, - ).toEqual([]); + from(providers([a("x")], [a("x", other)])), + ).serverWrites.map((write) => write.patch), + ).toEqual([forKeys(x), forKeys(x)]); }); }); diff --git a/apps/web/src/components/settings/scopedSettings.ts b/apps/web/src/components/settings/scopedSettings.ts index 6381bf518bce..22f955981a26 100644 --- a/apps/web/src/components/settings/scopedSettings.ts +++ b/apps/web/src/components/settings/scopedSettings.ts @@ -249,7 +249,6 @@ function forDestinationModels( patch: ServerSettingsPatch, modelSource: ScopedModelSource | null | undefined, destination: ScopedSettingsEnvironment, - matched: Map, ): ServerSettingsPatch | null { if (!modelSource || destination.environmentId === modelSource.target.environmentId) return patch; const { target: source, providersByEnvironment } = modelSource; @@ -274,16 +273,13 @@ function forDestinationModels( continue; } if (!providers) return null; - const peers = matched.get(key) ?? []; const instanceId = resolveScopedModelInstanceId( selection.instanceId, { providers: providersByEnvironment.get(source.environmentId) ?? [] }, { providers, settings }, - peers, key, ); if (instanceId === null) return null; - matched.set(key, [...peers, providers.find((provider) => provider.instanceId === instanceId)!]); Object.assign(next, { [key]: { ...selection, instanceId } }); } return next; @@ -314,9 +310,8 @@ export function planScopedSettingsPatch( ? serverKeys.filter((key) => !isProjectScopedSettingKey(key)) : []; const unmatched = new Set(); - const matched = new Map(); const serverPatchFor = (environment: ScopedSettingsEnvironment) => { - const mapped = forDestinationModels(serverPatch, modelSource, environment, matched); + const mapped = forDestinationModels(serverPatch, modelSource, environment); if (mapped === null) unmatched.add(environment.label); return mapped ?? serverPatch; }; diff --git a/apps/web/src/components/settings/useScopedModelAvailability.ts b/apps/web/src/components/settings/useScopedModelAvailability.ts index e65d383e20d7..76f10af7c08a 100644 --- a/apps/web/src/components/settings/useScopedModelAvailability.ts +++ b/apps/web/src/components/settings/useScopedModelAvailability.ts @@ -1,8 +1,4 @@ -import { - type ProviderInstanceId, - type ServerProvider, - type UnifiedSettings, -} from "@t3tools/contracts"; +import { type ProviderInstanceId, type UnifiedSettings } from "@t3tools/contracts"; import { mergeEnvironmentSettings } from "../../hooks/useSettings"; import { getCustomModelOptionsByInstance } from "../../modelSelection"; @@ -34,7 +30,6 @@ export function useScopedModelDisabledReason( providers: (source && providersByEnvironment.get(source.environmentId)) ?? [], }; const shownGenerator = scopedTextGenerator(settings, instanceId, key); - const peers: ServerProvider[] = []; for (const candidate of targets) { const environment = environments.find( (entry) => entry.environmentId === candidate.environmentId, @@ -59,13 +54,11 @@ export function useScopedModelDisabledReason( instanceId, sourceEnvironment, { providers, settings: candidate.settings }, - peers, key, ); if (localId === null) { return `A unique matching account could not be verified on ${environment.label}. Select that environment to choose its model separately.`; } - peers.push(providers.find((provider) => provider.instanceId === localId)!); const entry = applyProviderInstanceSettings( deriveProviderInstanceEntries(providers), candidate.settings,