import type { ModelPreferences } from "@t3tools/contracts/settings"; import { DEFAULT_MODEL_BY_PROVIDER, DEFAULT_TEXT_GENERATION_MODEL, DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER, defaultInstanceIdForDriver, globalIdleCompaction, type IdleCompactionMode, isProviderAvailable, isUnconfiguredDefaultInstanceEnabled, resolveProjectProviderInstanceEnabled, resolveProviderInstanceEnabled, isProviderTextGenerationCapable, type ModelSelection, type ProjectId, type ProjectScopedServerSettingKey, type ProjectSettingsOverrides, ProviderDriverKind, ProviderInstanceId, type ServerProvider, ServerSettings, type ServerSettingsPatch, withIdleCompaction, } from "@t3tools/contracts"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import { deepMerge } from "./Struct.ts"; import { fromLenientJson } from "./schemaJson.ts"; import { createModelSelection, readCustomModelEntries } from "./model.ts"; import { getBackgroundActivityBaseProfile, normalizeBackgroundActivitySettings, normalizeServerBackgroundActivitySettings, resolveBackgroundActivitySettings, } from "./backgroundActivitySettings.ts"; const ServerSettingsJson = fromLenientJson(ServerSettings); const decodeServerSettingsJson = Schema.decodeUnknownOption(ServerSettingsJson); export function isModelSelectionProviderEnabled( settings: ServerSettings, selection: ModelSelection, ): boolean { const instanceConfig = settings.providerInstances[selection.instanceId]; if (instanceConfig !== undefined) { return resolveProviderInstanceEnabled(instanceConfig); } return isUnconfiguredDefaultInstanceEnabled(selection.instanceId); } const TEXT_GENERATION_FALLBACK_DRIVERS = [ "codex", "claudeAgent", "cursor", "grok", "muse", "pi", "opencode", "antigravity", ].map((driver) => ProviderDriverKind.make(driver)); /** Only project-scoped callers may include configured instances when falling back; machine-level callers leave it unset. */ export function fallbackTextGenerationModelSelection( providerInstances: ServerSettings["providerInstances"], isEnabled: (instanceId: ProviderInstanceId, driver: ProviderDriverKind) => boolean, options?: { readonly includeConfiguredInstances?: boolean; readonly defaultModelSelection?: ModelSelection | null; }, ): ModelSelection | null { const candidates = [ ...TEXT_GENERATION_FALLBACK_DRIVERS.map( (driver) => [defaultInstanceIdForDriver(driver), driver] as const, ), ...(options?.includeConfiguredInstances === true ? Object.entries(providerInstances).flatMap(([instanceId, instance]) => TEXT_GENERATION_FALLBACK_DRIVERS.includes(instance.driver) ? [[ProviderInstanceId.make(instanceId), instance.driver] as const] : [], ) : []), ]; const match = candidates.find(([instanceId, driver]) => isEnabled(instanceId, driver)); if (match === undefined) return null; const [instanceId, driver] = match; const threadDefault = options?.defaultModelSelection; const config = providerInstances[instanceId]?.config as { customModels?: unknown } | undefined; if ( threadDefault?.instanceId === instanceId && readCustomModelEntries(config?.customModels).some((entry) => entry.slug === threadDefault.model) ) { return threadDefault; } return { instanceId, model: defaultTextGenerationModel(driver) }; } export function defaultTextGenerationModel(driver: ProviderDriverKind): string { return ( DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER[driver] ?? DEFAULT_MODEL_BY_PROVIDER[driver] ?? DEFAULT_TEXT_GENERATION_MODEL ); } export function resolveSourceControlWriterModelSelection( settings: ServerSettings, providers: ReadonlyArray | undefined, projectId: ProjectId | null, ): ModelSelection { const selection = settings.sourceControlWriterModelSelection; if ( !selection || !resolveProjectProviderInstanceEnabled(settings, projectId, selection.instanceId) ) { return settings.textGenerationModelSelection; } if (providers === undefined) { return selection; } const provider = providers.find((candidate) => candidate.instanceId === selection.instanceId); return provider?.enabled === true && isProviderAvailable(provider) && isProviderTextGenerationCapable(provider) ? selection : settings.textGenerationModelSelection; } export function resolveReviewGuideModelSelection( settings: ServerSettings, providers: ReadonlyArray | undefined, projectId: ProjectId | null, ): ModelSelection { const selection = settings.reviewGuideModelSelection; if ( selection && resolveProjectProviderInstanceEnabled(settings, projectId, selection.instanceId) ) { const provider = providers?.find((candidate) => candidate.instanceId === selection.instanceId); if ( providers === undefined || (provider?.enabled === true && isProviderAvailable(provider) && isProviderTextGenerationCapable(provider)) ) { return selection; } } return resolveSourceControlWriterModelSelection(settings, providers, projectId); } export interface PersistedServerObservabilitySettings { readonly otlpTracesUrl: string | undefined; readonly otlpMetricsUrl: string | undefined; readonly otlpLogsUrl: string | undefined; } function normalizePersistedServerSettingString( value: string | null | undefined, ): string | undefined { const trimmed = value?.trim(); return trimmed && trimmed.length > 0 ? trimmed : undefined; } function extractPersistedServerObservabilitySettings(input: { readonly observability?: { readonly otlpTracesUrl?: string; readonly otlpMetricsUrl?: string; readonly otlpLogsUrl?: string; }; }): PersistedServerObservabilitySettings { return { otlpTracesUrl: normalizePersistedServerSettingString(input.observability?.otlpTracesUrl), otlpMetricsUrl: normalizePersistedServerSettingString(input.observability?.otlpMetricsUrl), otlpLogsUrl: normalizePersistedServerSettingString(input.observability?.otlpLogsUrl), }; } export function parsePersistedServerObservabilitySettings( raw: string, ): PersistedServerObservabilitySettings { const decoded = decodeServerSettingsJson(raw); if (Option.isSome(decoded)) { return extractPersistedServerObservabilitySettings(decoded.value); } return { otlpTracesUrl: undefined, otlpMetricsUrl: undefined, otlpLogsUrl: undefined }; } function shouldReplaceTextGenerationModelSelection( patch: ServerSettingsPatch["textGenerationModelSelection"] | undefined, ): boolean { return Boolean(patch && (patch.instanceId !== undefined || patch.model !== undefined)); } function mergeModelSelectionOptionsById(input: { current: ReadonlyArray<{ readonly id: string; readonly value: string | boolean }> | undefined; patch: ReadonlyArray<{ readonly id: string; readonly value: string | boolean }> | undefined; }): Array<{ id: string; value: string | boolean }> | undefined { if (input.patch === undefined) { return input.current ? [...input.current] : undefined; } if (input.patch.length === 0) { return undefined; } const merged = new Map((input.current ?? []).map((selection) => [selection.id, selection.value])); for (const selection of input.patch) { merged.set(selection.id, selection.value); } return [...merged.entries()].map(([id, value]) => ({ id, value })); } function mergeSettingsEntries( current: Readonly>, patch: Readonly>, ): Record { const next = new Map(Object.entries(current)); for (const [id, config] of Object.entries(patch)) { if (config === null) { next.delete(id); } else { next.set(id, config); } } return Object.fromEntries(next); } // Older clients omit providerInstances during whole-entry replacement; preserve it to avoid wiping overrides. function mergeProjectSettingsOverridesPatch( current: Readonly>, patch: Readonly>, ): Record { const next = new Map(Object.entries(current)); for (const [projectId, entry] of Object.entries(patch)) { if (entry === null) { next.delete(projectId); continue; } const storedProviderInstances = current[projectId]?.providerInstances; next.set(projectId, { ...entry, ...(Object.hasOwn(entry, "providerInstances") || storedProviderInstances === undefined ? {} : { providerInstances: storedProviderInstances }), }); } return Object.fromEntries(next); } export function deriveLegacyProjectOverrides( settings: Pick, ): Pick< ServerSettings, "projectAgentBrowserAccessOverrides" | "projectAutoPullOverrides" | "projectScriptOverrides" > { const projectAgentBrowserAccessOverrides: Record = {}; const projectAutoPullOverrides: Record = {}; const projectScriptOverrides: Record = {}; for (const [projectId, entry] of Object.entries(settings.projectSettingsOverrides)) { if (entry.enableAgentBrowserAccess !== undefined) { projectAgentBrowserAccessOverrides[projectId] = entry.enableAgentBrowserAccess; } if (entry.defaultAutoPull !== undefined) { projectAutoPullOverrides[projectId] = entry.defaultAutoPull; } if (entry.defaultProjectScripts !== undefined) { projectScriptOverrides[projectId] = entry.defaultProjectScripts; } } return { projectAgentBrowserAccessOverrides, projectAutoPullOverrides, projectScriptOverrides }; } function translateLegacyProjectOverridePatch( current: Pick, patch: ServerSettingsPatch, ): ServerSettingsPatch { const { projectAgentBrowserAccessOverrides, projectAutoPullOverrides, projectScriptOverrides, ...rest } = patch; if ( projectAgentBrowserAccessOverrides === undefined && projectAutoPullOverrides === undefined && projectScriptOverrides === undefined ) { return patch; } const currentEntries: Readonly> = current.projectSettingsOverrides; const entries = new Map( Object.entries(rest.projectSettingsOverrides ?? {}), ); // Canonical entries outrank legacy maps, including deliberately omitted keys. const canonicalProjectIds = new Set(Object.keys(rest.projectSettingsOverrides ?? {})); const applyKey = ( map: Readonly> | undefined, key: K, ) => { if (map === undefined) return; for (const [projectId, value] of Object.entries(map)) { if (canonicalProjectIds.has(projectId)) continue; const entry: ProjectSettingsOverrides = { ...(entries.get(projectId) ?? currentEntries[projectId]), }; if (value === null || value === undefined) { delete entry[key]; } else { entry[key] = value; } entries.set(projectId, Object.keys(entry).length === 0 ? null : entry); } }; applyKey(projectAgentBrowserAccessOverrides, "enableAgentBrowserAccess"); applyKey(projectAutoPullOverrides, "defaultAutoPull"); applyKey(projectScriptOverrides, "defaultProjectScripts"); return { ...rest, projectSettingsOverrides: Object.fromEntries(entries), } as ServerSettingsPatch; } /** * A client from before `idleCompaction` patches the old boolean switch. Turning * it off keeps "Never" rather than reverting to the default. */ function patchedIdleCompaction( current: ServerSettings, patch: ServerSettingsPatch, ): IdleCompactionMode { if (patch.idleCompaction !== undefined) return patch.idleCompaction; const legacy = patch.compactIdleThreads; if (legacy === undefined) return globalIdleCompaction(current); if (typeof legacy === "string") return legacy; if (legacy) return "beforeCacheExpires"; return globalIdleCompaction(current) === "off" ? "off" : "onReturn"; } export function applyServerSettingsPatch( current: ServerSettings, rawPatch: ServerSettingsPatch, ): ServerSettings { const patch = translateLegacyProjectOverridePatch(current, rawPatch); const selectionPatch = patch.textGenerationModelSelection; const { migrateModelPreferences, setModelFavorites, setModelsHidden, setProviderModelOrder, providerModelPreferences, automaticGitFetchInterval, providerHealthRefreshInterval, backgroundActivityProfile, backgroundActivity, worktreeCleanup: worktreeCleanupPatch, // Merged per entry below; its `null` removals must not reach deepMerge. usageLimitSources: usageLimitSourcesPatch, sourceControlHosts: sourceControlHostsPatch, usagePriceOverrides: usagePriceOverridesPatch, usageModelAliases: usageModelAliasesPatch, // Entry replacement: deepMerge would keep keys the client meant to clear. projectSettingsOverrides: projectSettingsOverridesPatch, projectAgentBrowserAccessOverrides: _legacyBrowserAccess, projectAutoPullOverrides: _legacyAutoPull, projectScriptOverrides: _legacyScripts, ...patchForMerge } = patch; const currentBackgroundActivity = normalizeServerBackgroundActivitySettings(current); const backgroundActivityPatch = backgroundActivityProfile !== undefined ? { schemaVersion: 1 as const, profile: automaticGitFetchInterval !== undefined || providerHealthRefreshInterval !== undefined ? ("custom" as const) : backgroundActivityProfile, ...(automaticGitFetchInterval !== undefined || providerHealthRefreshInterval !== undefined ? { baseProfile: backgroundActivityProfile } : {}), overrides: { ...(automaticGitFetchInterval !== undefined ? { automaticGitFetchInterval } : {}), ...(providerHealthRefreshInterval !== undefined ? { providerHealthRefreshInterval } : {}), }, } : automaticGitFetchInterval !== undefined || providerHealthRefreshInterval !== undefined ? { schemaVersion: 1 as const, profile: "custom" as const, baseProfile: getBackgroundActivityBaseProfile(currentBackgroundActivity), overrides: { ...(currentBackgroundActivity.profile === "custom" ? currentBackgroundActivity.overrides : {}), ...(automaticGitFetchInterval !== undefined ? { automaticGitFetchInterval } : {}), ...(providerHealthRefreshInterval !== undefined ? { providerHealthRefreshInterval } : {}), }, } : undefined; const initialFavorites = patch.favorites ?? current.favorites ?? migrateModelPreferences?.favorites ?? null; const favorites = setModelFavorites?.reduce( (current, change) => { const exists = current.some( (favorite) => favorite.provider === change.provider && favorite.model === change.model, ); if (change.favorite) { if (!exists) current.push({ provider: change.provider, model: change.model }); return current; } return current.filter( (favorite) => favorite.provider !== change.provider || favorite.model !== change.model, ); }, [...(initialFavorites ?? [])], ) ?? initialFavorites; const initialModelPreferences = providerModelPreferences ?? current.providerModelPreferences ?? migrateModelPreferences?.providerModelPreferences ?? null; let nextModelPreferences = initialModelPreferences; if (setModelsHidden?.length) { const updated = { ...initialModelPreferences }; for (const change of setModelsHidden) { const preferences = (Object.hasOwn(updated, change.provider) ? updated[change.provider] : undefined) ?? { hiddenModels: [], modelOrder: [], }; const hiddenModels = preferences.hiddenModels.filter((model) => model !== change.model); if (change.hidden) hiddenModels.push(change.model); updated[change.provider] = { ...preferences, hiddenModels }; } nextModelPreferences = updated; } if (setProviderModelOrder) { const preferences = nextModelPreferences?.[setProviderModelOrder.provider] ?? { hiddenModels: [], modelOrder: [], }; nextModelPreferences = { ...nextModelPreferences, [setProviderModelOrder.provider]: { ...preferences, modelOrder: [...setProviderModelOrder.modelOrder], }, }; } const next = deepMerge(current, patchForMerge); const nextWithReplacementsBase = { ...next, favorites, providerModelPreferences: nextModelPreferences, ...(worktreeCleanupPatch === undefined ? {} : { worktreeCleanup: worktreeCleanupPatch?.mode === "custom" ? { mode: "custom" as const, rules: { worktreeKeepWhen: next.storageCleanup.worktreeKeepWhen, worktreeAfterDays: next.storageCleanup.worktreeAfterDays, worktreeSettledAfterDays: next.storageCleanup.worktreeSettledAfterDays, worktreeOnMerge: next.storageCleanup.worktreeOnMerge, worktreeOnDelete: next.storageCleanup.worktreeOnDelete, worktreeUnchanged: next.storageCleanup.worktreeUnchanged, ...(current.worktreeCleanup?.mode === "custom" ? current.worktreeCleanup.rules : {}), ...worktreeCleanupPatch.rules, }, } : worktreeCleanupPatch, }), ...(backgroundActivity !== undefined ? { backgroundActivity: { ...deepMerge(currentBackgroundActivity, backgroundActivity), ...(backgroundActivity.overrides !== undefined ? { overrides: backgroundActivity.overrides } : {}), }, } : { backgroundActivity: currentBackgroundActivity }), ...(backgroundActivity === undefined && backgroundActivityPatch !== undefined ? { backgroundActivity: backgroundActivityPatch } : {}), ...(patch.providerInstances !== undefined ? { providerInstances: patch.providerInstances } : {}), ...(patch.worktreesDirectory !== undefined && patch.worktreesDirectory !== current.worktreesDirectory ? { previousWorktreesDirectories: [ ...current.previousWorktreesDirectories.filter( (directory) => directory !== patch.worktreesDirectory, ), ...(current.worktreesDirectory !== "" && !current.previousWorktreesDirectories.includes(current.worktreesDirectory) ? [current.worktreesDirectory] : []), ], } : {}), // Per host, a patched field replaces the saved one: deepMerge would keep a cleared account pin. ...(sourceControlHostsPatch !== undefined ? { sourceControlHosts: { ...current.sourceControlHosts, ...Object.fromEntries( Object.entries(sourceControlHostsPatch).map(([kind, fields]) => [ kind, { ...current.sourceControlHosts[kind], ...fields }, ]), ), }, } : {}), ...(projectSettingsOverridesPatch !== undefined ? { projectSettingsOverrides: Object.fromEntries( Object.entries( mergeProjectSettingsOverridesPatch( current.projectSettingsOverrides, projectSettingsOverridesPatch, ), ).filter(([, entry]) => Object.keys(entry).length > 0), ), } : {}), ...(patch.defaultModelSelection !== undefined ? { defaultModelSelection: patch.defaultModelSelection } : {}), ...(patch.fallbackModelSelection !== undefined ? { fallbackModelSelection: patch.fallbackModelSelection } : {}), ...(patch.defaultProjectScripts !== undefined ? { defaultProjectScripts: patch.defaultProjectScripts } : {}), ...(usageLimitSourcesPatch !== undefined ? { usageLimitSources: mergeSettingsEntries( current.usageLimitSources, usageLimitSourcesPatch, ), } : {}), ...(usagePriceOverridesPatch !== undefined ? { usagePriceOverrides: mergeSettingsEntries( current.usagePriceOverrides, usagePriceOverridesPatch, ), } : {}), ...(usageModelAliasesPatch !== undefined ? { usageModelAliases: mergeSettingsEntries( current.usageModelAliases, usageModelAliasesPatch, ), } : {}), ...(patch.sourceControlWriterModelSelection !== undefined ? { sourceControlWriterModelSelection: patch.sourceControlWriterModelSelection } : {}), ...(patch.reviewGuideModelSelection !== undefined ? { reviewGuideModelSelection: patch.reviewGuideModelSelection } : {}), ...(automaticGitFetchInterval !== undefined ? { automaticGitFetchInterval } : {}), ...(providerHealthRefreshInterval !== undefined ? { providerHealthRefreshInterval } : {}), }; const normalizedBackgroundActivity = normalizeBackgroundActivitySettings( nextWithReplacementsBase.backgroundActivity, ); const resolvedBackgroundActivity = resolveBackgroundActivitySettings( normalizedBackgroundActivity, ); const nextWithReplacements = { ...withIdleCompaction(nextWithReplacementsBase, patchedIdleCompaction(current, patch)), ...deriveLegacyProjectOverrides(nextWithReplacementsBase), backgroundActivity: normalizedBackgroundActivity, automaticGitFetchInterval: resolvedBackgroundActivity.automaticGitFetchInterval, providerHealthRefreshInterval: resolvedBackgroundActivity.providerHealthRefreshInterval, backgroundActivityProfile: resolvedBackgroundActivity.profile, }; if (!selectionPatch) { return nextWithReplacements; } const instanceId = selectionPatch.instanceId ?? current.textGenerationModelSelection.instanceId; const model = selectionPatch.model ?? current.textGenerationModelSelection.model; const options = shouldReplaceTextGenerationModelSelection(selectionPatch) ? selectionPatch.options : mergeModelSelectionOptionsById({ current: current.textGenerationModelSelection.options, patch: selectionPatch.options, }); return { ...nextWithReplacements, textGenerationModelSelection: createModelSelection(instanceId, model, options), }; } export function resolveModelPreferences( settings: Pick, legacy: Partial = {}, ): ModelPreferences { return { favorites: settings.favorites ?? legacy.favorites ?? [], providerModelPreferences: settings.providerModelPreferences ?? legacy.providerModelPreferences ?? {}, }; } /** Empty legacy values do not claim ownership before another client can migrate its saved list. */ export function modelPreferencesMigrationPatch( settings: Pick, legacy: Partial, ): ServerSettingsPatch | null { const favorites = settings.favorites === null && (legacy.favorites?.length ?? 0) > 0 ? legacy.favorites : undefined; const providerModelPreferences = settings.providerModelPreferences === null && Object.keys(legacy.providerModelPreferences ?? {}).length > 0 ? legacy.providerModelPreferences : undefined; return favorites === undefined && providerModelPreferences === undefined ? null : { migrateModelPreferences: { ...(favorites === undefined ? {} : { favorites }), ...(providerModelPreferences === undefined ? {} : { providerModelPreferences }), }, }; }