diff --git a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx index 182b8bc5b1fd..4327e950a9b2 100644 --- a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx +++ b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx @@ -111,7 +111,12 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting ? resolveProjectSettings(target.settings, null, null, null).settings : null; - const modelDisabledReason = useScopedModelDisabledReason(settings, entries); + const modelDisabledReason = useScopedModelDisabledReason( + settings, + entries, + false, + "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 26cf6eb3dcbb..66dee2784b6d 100644 --- a/apps/web/src/components/settings/SourceControlWritingSettings.tsx +++ b/apps/web/src/components/settings/SourceControlWritingSettings.tsx @@ -158,6 +158,12 @@ export function SourceControlWritingSettingsSection() { activeSelection.model, ); const writerModelDisabledReason = useScopedModelDisabledReason(settings, writerEntries, true); + const reviewGuideModelDisabledReason = useScopedModelDisabledReason( + settings, + instanceEntries, + true, + "reviewGuideModelSelection", + ); const writerInstanceEntry = writerEntries.find( (entry) => entry.instanceId === activeSelection.instanceId, ); @@ -353,9 +359,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", @@ -405,7 +411,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 new file mode 100644 index 000000000000..9e67a552f1ab --- /dev/null +++ b/apps/web/src/components/settings/scopedModelSelection.ts @@ -0,0 +1,108 @@ +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 = [ + "defaultModelSelection", + "fallbackModelSelection", + "textGenerationModelSelection", + "sourceControlWriterModelSelection", + "reviewGuideModelSelection", + "speechPostProcessingModelSelection", +] 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" || + 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[]; + readonly settings: Pick; +} + +/** Instance IDs and names are machine-local; only a reported ChatGPT login identifies the account. */ +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, + key: ScopedModelSettingKey = "defaultModelSelection", +): ProviderInstanceId | null { + if ( + key === "reviewGuideModelSelection" && + scopedTextGenerator(destination.settings, instanceId, key) + ) + return null; + 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); + 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 && + scopedTextGenerator(destination.settings, match.instanceId, key) === undefined + ? match.instanceId + : null; +} + +export function scopedModelSelectionsEqual( + first: ModelSelection | null, + firstEnvironment: ScopedModelEnvironment, + second: ModelSelection | null, + secondEnvironment: ScopedModelEnvironment, + key: ScopedModelSettingKey = "defaultModelSelection", +): boolean { + if (first === null || second === null) return first === second; + 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) + ); + } + const sameInstance = + 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 fb76500ecded..a2211daf8af0 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"; @@ -13,6 +15,7 @@ import { resolveWorktreeCleanup } from "@t3tools/shared/projectSettings"; import type { SidebarProjectSnapshot } from "../../sidebarProjectGrouping"; import { + isProjectScopedSettingKey, persistScopedSettingsPatch, planProjectOverridesClear, planScopedSettingsClear, @@ -24,6 +27,11 @@ import { selectScopedSettingsEnvironments, } from "./scopedSettings"; import { resolveSettingsScope } from "./settingsScope"; +import { + resolveScopedModelInstanceId, + SCOPED_MODEL_SETTING_KEYS, + scopedModelSelectionsEqual, +} from "./scopedModelSelection"; function environment( id: string, @@ -809,3 +817,206 @@ describe("model selection patches at project scope", () => { ).toEqual({ ...saved, options }); }); }); + +describe("model selections across environments' provider instances", () => { + 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 from = ( + providersByEnvironment: ReadonlyMap, + { environmentId, serverConfig } = laptop, + ) => ({ + modelSource: { + target: { environmentId, settings: serverConfig!.settings }, + providersByEnvironment, + }, + }); + const luna = { instanceId: laptopCodex.instanceId, model: "gpt-6-luna" }; + const keys = SCOPED_MODEL_SETTING_KEYS; + 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 plan = planScopedSettingsPatch(all, environments, forKeys(luna), from(matching)); + expect(plan.serverWrites.map((write) => write.patch)).toEqual([ + forKeys(luna), + forKeys({ ...luna, instanceId: serverCodex.instanceId }), + ]); + const pending = planScopedSettingsPatch( + all, + environments, + forKeys(luna), + from(new Map([[laptop.environmentId, [laptopCodex]]])), + ); + expect(pending.serverWrites).toEqual([]); + expect(pending.unavailableReason).toContain("Server"); + }); + + it("maps project overrides to each member's own instance", () => { + const projectKeys = keys.filter(isProjectScopedSettingKey); + const plan = planScopedSettingsPatch( + project, + environments, + forKeys(luna, projectKeys), + from(matching), + ); + expect(plan.serverWrites.map((write) => write.patch.projectSettingsOverrides)).toEqual([ + { [projectId]: forKeys({ ...luna, instanceId: serverCodex.instanceId }, projectKeys) }, + { [laptopProjectId]: forKeys(luna, projectKeys) }, + ]); + }); + + const a = account; + const other = { email: "other@example.test" }; + const lower = { email: "gmail@example.test" }; + const team = { workspaceId: "team" }; + const apiKey = { type: "apiKey" }; + const nameOnly = (id: string) => ({ ...a(id, { workspaceId: undefined }), displayName: "Gmail" }); + const claude = "claudeAgent"; + it.each([ + ["same ID takes precedence", [a("x"), a("l")], [a("s"), a("x")], "x"], + ["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], + ["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 destination = { providers: serverProviders, settings: DEFAULT_SERVER_SETTINGS }; + expect( + resolveScopedModelInstanceId(instanceId, { providers: laptopProviders }, destination), + ).toBe(serverId); + }); + + it("keeps a text generator's ID only where it reaches the same API", () => { + const id = ProviderInstanceId.make("openrouter"); + const openrouter: ServerSettings["textGenerators"][ProviderInstanceId] = { + kind: "openaiCompatible", + baseUrl: "https://openrouter.ai/api/v1", + apiKey: "", + enabled: true, + }; + const withGenerator = (target: typeof laptop, config?: typeof openrouter, generatorId = id) => + environment(target.label, { + settings: { textGenerators: config ? { [generatorId]: config } : {} }, + }); + 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)], + }, + "textGenerationModelSelection", + ), + ).toBe(true); + 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, + [source, withGenerator(server, serverGenerator)], + patch, + from(providers([], []), source), + ); + expect(plan(openrouter).serverWrites.map((write) => write.patch)).toEqual([patch, patch]); + for (const serverGenerator of [ + undefined, + { ...openrouter, enabled: false }, + { ...openrouter, baseUrl: "https://api.openai.com/v1" }, + ]) { + 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("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]); + // 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( + all, + environments, + forKeys(x), + 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 89affccbdd4d..2d0ee428ae51 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,14 @@ import * as Equal from "effect/Equal"; import * as Cause from "effect/Cause"; import type { ResolvedSettingsScope } from "./settingsScope"; +import { + isScopedModelSettingKey, + resolveScopedModelInstanceId, + SCOPED_MODEL_SETTING_KEYS, + scopedModelSelectionsEqual, + scopedTextGenerator, + supportsScopedTextGenerator, +} from "./scopedModelSelection"; export type ScopedSettingsPatch = ServerSettingsPatch & ClientSettingsPatch; @@ -125,16 +134,33 @@ export function resolveScopedSettingsTargets( ); } +type ProvidersByEnvironment = ReadonlyMap; + 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])), - ) + const at = ({ environmentId, settings }: (typeof targets)[number]) => ({ + providers: providersByEnvironment?.get(environmentId) ?? [], + settings, + }); + return targets.some((reference, index) => + targets + .slice(index + 1) + .some((candidate) => + keys.some((key) => + providersByEnvironment && isScopedModelSettingKey(key) + ? !scopedModelSelectionsEqual( + reference.settings[key], + at(reference), + candidate.settings[key], + at(candidate), + key, + ) + : !Equal.equals(reference.settings[key], candidate.settings[key]), + ), + ), ); } @@ -183,6 +209,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 +220,8 @@ function projectOverrideWrites( continue; } const settings = environment.serverConfig.settings; - const entry = update(settings.projectSettingsOverrides[member.id] ?? {}, settings, member.id); + 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, @@ -217,11 +245,57 @@ function forDestination(previous: ServerSettings, patch: ServerSettingsPatch): S return { ...rest, ...idleCompactionPatch(previous, idleCompaction) }; } +function forDestinationModels( + patch: ServerSettingsPatch, + 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; + if (!settings) return null; + 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 + ) { + return null; + } + continue; + } + if (!providers) return null; + const instanceId = resolveScopedModelInstanceId( + selection.instanceId, + { providers: providersByEnvironment.get(source.environmentId) ?? [] }, + { providers, settings }, + key, + ); + if (instanceId === null) return null; + Object.assign(next, { [key]: { ...selection, instanceId } }); + } + return next; +} + +export interface ScopedModelSource { + /** The target whose provider instances and text generators the picker offered. */ + readonly target: Pick; + readonly providersByEnvironment: ProvidersByEnvironment; +} + export function planScopedSettingsPatch( scope: ResolvedSettingsScope, environments: readonly ScopedSettingsEnvironment[], patch: ScopedSettingsPatch, - options?: { readonly deviceOnly?: boolean }, + options?: { readonly deviceOnly?: boolean; readonly modelSource?: ScopedModelSource | null }, ) { const isProjectScope = scope.kind === "project" || scope.kind === "checkout"; const refusesClientKeys = isProjectScope && options?.deviceOnly !== true; @@ -243,13 +317,20 @@ export function planScopedSettingsPatch( const clientPatch = Object.fromEntries( refusesClientKeys ? [] : Object.entries(patch).filter(([key]) => clientKeys.includes(key)), ) as ClientSettingsPatch; + const unmatched = new Set(); + const serverPatchFor = (environment: ScopedSettingsEnvironment) => { + const mapped = forDestinationModels(serverPatch, options?.modelSource, environment); + if (mapped === null) unmatched.add(environment.label); + return mapped ?? serverPatch; + }; 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; @@ -296,6 +377,7 @@ export function planScopedSettingsPatch( ? connectedEnvironments.flatMap((environment) => { const previous = environment.serverConfig?.settings; if (!previous) return []; + const serverPatch = serverPatchFor(environment); return [ { environmentId: environment.environmentId, @@ -328,6 +410,14 @@ export function planScopedSettingsPatch( ]; }) : []; + if (unmatched.size > 0) { + return { + clientPatch, + hasClientWrite: false, + serverWrites: [], + 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; 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 e5d555bf8f7c..fabb8caeb5aa 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"; @@ -348,6 +352,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 = @@ -370,7 +375,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 a5d89813cae4..43f62cc7e436 100644 --- a/apps/web/src/components/settings/useScopedModelAvailability.ts +++ b/apps/web/src/components/settings/useScopedModelAvailability.ts @@ -1,8 +1,4 @@ -import { - type ProviderInstanceId, - textGeneratorForSelection, - type UnifiedSettings, -} from "@t3tools/contracts"; +import { type ProviderInstanceId, type UnifiedSettings } from "@t3tools/contracts"; import { mergeEnvironmentSettings } from "../../hooks/useSettings"; import { @@ -15,28 +11,39 @@ import { type ProviderInstanceEntry, } from "../../providerInstances"; import { useEnvironmentProviders, useEnvironments } from "../../state/environments"; +import { + resolveScopedModelInstanceId, + scopedTextGenerator, + supportsScopedTextGenerator, + type ScopedModelSettingKey, +} from "./scopedModelSelection"; import { useSettingsScope } from "./SettingsScopeContext"; export function useScopedModelDisabledReason( settings: UnifiedSettings, entries: readonly ProviderInstanceEntry[], textGeneration = false, + key: ScopedModelSettingKey = "textGenerationModelSelection", ) { - 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 shownGenerator = textGeneratorForSelection(settings, instanceId); + const sourceEnvironment = { + providers: (source && providersByEnvironment.get(source.environmentId)) ?? [], + }; + const shownGenerator = scopedTextGenerator(settings, instanceId, key); 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 @@ -47,18 +54,27 @@ export function useScopedModelDisabledReason( } const providers = providersByEnvironment.get(candidate.environmentId); if (!environment?.serverConfig || !providers) continue; + const localId = resolveScopedModelInstanceId( + instanceId, + sourceEnvironment, + { providers, settings: candidate.settings }, + key, + ); + 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 optionsForInstance = textGeneration ? getTextGenerationModelOptionsByInstance : getCustomModelOptionsByInstance; const options = optionsForInstance( 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 e95bc6647e59..c2f8c3ba2869 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,18 @@ function useRunScopedPlan() { } export function useUpdateScopedSettings() { - const { scope, environments, deviceOnly } = useSettingsScope(); + const { scope, environments, deviceOnly, 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, { deviceOnly }), options?.onFailed); + run( + planScopedSettingsPatch(scope, environments, patch, { + deviceOnly, + modelSource: target && { target, providersByEnvironment }, + }), + options?.onFailed, + ); } export function useClearScopedSettings() { diff --git a/apps/web/src/modelSelection.ts b/apps/web/src/modelSelection.ts index 3f566afe1922..d9d2ec0a6bc7 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"); @@ -347,7 +347,21 @@ export function defaultModelSelectionsAreMixed( : null, }; }); - return selections.some((selection) => !Equal.equals(selection, selections[0])); + 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]!, + ), + ), + ), + ); } export function getCustomModelOptionsByInstance(