| 1 | import type { ModelPreferences } from "@t3tools/contracts/settings"; |
| 2 | import { |
| 3 | DEFAULT_MODEL_BY_PROVIDER, |
| 4 | DEFAULT_TEXT_GENERATION_MODEL, |
| 5 | DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER, |
| 6 | defaultInstanceIdForDriver, |
| 7 | isProviderAvailable, |
| 8 | isUnconfiguredDefaultInstanceEnabled, |
| 9 | resolveProjectProviderInstanceEnabled, |
| 10 | resolveProviderInstanceEnabled, |
| 11 | isProviderTextGenerationCapable, |
| 12 | type ModelSelection, |
| 13 | type ProjectId, |
| 14 | type ProjectScopedServerSettingKey, |
| 15 | type ProjectSettingsOverrides, |
| 16 | ProviderDriverKind, |
| 17 | ProviderInstanceId, |
| 18 | type ServerProvider, |
| 19 | ServerSettings, |
| 20 | type ServerSettingsPatch, |
| 21 | } from "@t3tools/contracts"; |
| 22 | import * as Option from "effect/Option"; |
| 23 | import * as Schema from "effect/Schema"; |
| 24 | import { deepMerge } from "./Struct.ts"; |
| 25 | import { fromLenientJson } from "./schemaJson.ts"; |
| 26 | import { createModelSelection, readCustomModelEntries } from "./model.ts"; |
| 27 | import { |
| 28 | getBackgroundActivityBaseProfile, |
| 29 | normalizeBackgroundActivitySettings, |
| 30 | normalizeServerBackgroundActivitySettings, |
| 31 | resolveBackgroundActivitySettings, |
| 32 | } from "./backgroundActivitySettings.ts"; |
| 33 | |
| 34 | const ServerSettingsJson = fromLenientJson(ServerSettings); |
| 35 | const decodeServerSettingsJson = Schema.decodeUnknownOption(ServerSettingsJson); |
| 36 | |
| 37 | /** @deprecated Read `resolveProjectSettings(...).settings.enableAgentBrowserAccess`. */ |
| 38 | export function resolveProjectAgentBrowserAccess( |
| 39 | settings: Pick< |
| 40 | ServerSettings, |
| 41 | "enableAgentBrowserAccess" | "projectAgentBrowserAccessOverrides" | "projectSettingsOverrides" |
| 42 | >, |
| 43 | projectId: ProjectId, |
| 44 | ): boolean { |
| 45 | return ( |
| 46 | settings.projectSettingsOverrides[projectId]?.enableAgentBrowserAccess ?? |
| 47 | settings.projectAgentBrowserAccessOverrides[projectId] ?? |
| 48 | settings.enableAgentBrowserAccess |
| 49 | ); |
| 50 | } |
| 51 | |
| 52 | /** @deprecated Read `resolveProjectSettings(...).settings.defaultAutoPull`. */ |
| 53 | export function resolveProjectAutoPull( |
| 54 | settings: Pick< |
| 55 | ServerSettings, |
| 56 | "defaultAutoPull" | "projectAutoPullOverrides" | "projectSettingsOverrides" |
| 57 | >, |
| 58 | projectId: ProjectId, |
| 59 | legacyAutoPull: boolean | undefined, |
| 60 | ): boolean { |
| 61 | // Existing opt-ins stay enabled until explicitly overridden or reset. |
| 62 | return ( |
| 63 | settings.projectSettingsOverrides[projectId]?.defaultAutoPull ?? |
| 64 | settings.projectAutoPullOverrides[projectId] ?? |
| 65 | (legacyAutoPull === true || settings.defaultAutoPull) |
| 66 | ); |
| 67 | } |
| 68 | |
| 69 | export function isModelSelectionProviderEnabled( |
| 70 | settings: ServerSettings, |
| 71 | selection: ModelSelection, |
| 72 | ): boolean { |
| 73 | const instanceConfig = settings.providerInstances[selection.instanceId]; |
| 74 | if (instanceConfig !== undefined) { |
| 75 | return resolveProviderInstanceEnabled(instanceConfig); |
| 76 | } |
| 77 | |
| 78 | return isUnconfiguredDefaultInstanceEnabled(selection.instanceId); |
| 79 | } |
| 80 | |
| 81 | const TEXT_GENERATION_FALLBACK_DRIVERS = [ |
| 82 | "codex", |
| 83 | "claudeAgent", |
| 84 | "cursor", |
| 85 | "grok", |
| 86 | "muse", |
| 87 | "pi", |
| 88 | "opencode", |
| 89 | "antigravity", |
| 90 | ].map((driver) => ProviderDriverKind.make(driver)); |
| 91 | |
| 92 | /** |
| 93 | * The text generation model on the first instance `isEnabled` accepts: the |
| 94 | * built-in default instances in preference order, then, when |
| 95 | * `includeConfiguredInstances` is set, configured instances of those |
| 96 | * drivers. `null` when none is. |
| 97 | * |
| 98 | * The model is the driver's cheap default, unless `defaultModelSelection` |
| 99 | * (the thread default) is one of that instance's custom models. A custom |
| 100 | * model usually means a backend that may refuse the built-in default, and |
| 101 | * the thread default, with its options, is known to work there. |
| 102 | * |
| 103 | * `includeConfiguredInstances` is for the project-scoped caller only: a |
| 104 | * project that disabled every built-in instance should still be able to |
| 105 | * fall back to one it configured itself. The machine-level caller leaves it |
| 106 | * unset, since widening the candidates there would change behavior for |
| 107 | * every user regardless of project scope. |
| 108 | */ |
| 109 | export function fallbackTextGenerationModelSelection( |
| 110 | providerInstances: ServerSettings["providerInstances"], |
| 111 | isEnabled: (instanceId: ProviderInstanceId, driver: ProviderDriverKind) => boolean, |
| 112 | options?: { |
| 113 | readonly includeConfiguredInstances?: boolean; |
| 114 | readonly defaultModelSelection?: ModelSelection | null; |
| 115 | }, |
| 116 | ): ModelSelection | null { |
| 117 | const candidates = [ |
| 118 | ...TEXT_GENERATION_FALLBACK_DRIVERS.map( |
| 119 | (driver) => [defaultInstanceIdForDriver(driver), driver] as const, |
| 120 | ), |
| 121 | ...(options?.includeConfiguredInstances === true |
| 122 | ? Object.entries(providerInstances).flatMap(([instanceId, instance]) => |
| 123 | TEXT_GENERATION_FALLBACK_DRIVERS.includes(instance.driver) |
| 124 | ? [[ProviderInstanceId.make(instanceId), instance.driver] as const] |
| 125 | : [], |
| 126 | ) |
| 127 | : []), |
| 128 | ]; |
| 129 | const match = candidates.find(([instanceId, driver]) => isEnabled(instanceId, driver)); |
| 130 | if (match === undefined) return null; |
| 131 | const [instanceId, driver] = match; |
| 132 | const threadDefault = options?.defaultModelSelection; |
| 133 | const config = providerInstances[instanceId]?.config as { customModels?: unknown } | undefined; |
| 134 | if ( |
| 135 | threadDefault?.instanceId === instanceId && |
| 136 | readCustomModelEntries(config?.customModels).some((entry) => entry.slug === threadDefault.model) |
| 137 | ) { |
| 138 | return threadDefault; |
| 139 | } |
| 140 | return { |
| 141 | instanceId, |
| 142 | model: |
| 143 | DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER[driver] ?? |
| 144 | DEFAULT_MODEL_BY_PROVIDER[driver] ?? |
| 145 | DEFAULT_TEXT_GENERATION_MODEL, |
| 146 | }; |
| 147 | } |
| 148 | |
| 149 | /** |
| 150 | * `projectId` has no default: a caller that forgets it would otherwise fall |
| 151 | * back to the machine's own writer choice, silently ignoring a project that |
| 152 | * turned the writer's instance off. Pass `null` explicitly outside a |
| 153 | * project's scope. |
| 154 | */ |
| 155 | export function resolveSourceControlWriterModelSelection( |
| 156 | settings: ServerSettings, |
| 157 | providers: ReadonlyArray<ServerProvider> | undefined, |
| 158 | projectId: ProjectId | null, |
| 159 | ): ModelSelection { |
| 160 | const selection = settings.sourceControlWriterModelSelection; |
| 161 | if ( |
| 162 | !selection || |
| 163 | !resolveProjectProviderInstanceEnabled(settings, projectId, selection.instanceId) |
| 164 | ) { |
| 165 | return settings.textGenerationModelSelection; |
| 166 | } |
| 167 | if (providers === undefined) { |
| 168 | return selection; |
| 169 | } |
| 170 | |
| 171 | const provider = providers.find((candidate) => candidate.instanceId === selection.instanceId); |
| 172 | return provider?.enabled === true && |
| 173 | isProviderAvailable(provider) && |
| 174 | isProviderTextGenerationCapable(provider) |
| 175 | ? selection |
| 176 | : settings.textGenerationModelSelection; |
| 177 | } |
| 178 | |
| 179 | /** |
| 180 | * The model that writes review guides: its own setting when that provider can still run it, |
| 181 | * otherwise whatever writes source control text. |
| 182 | */ |
| 183 | export function resolveReviewGuideModelSelection( |
| 184 | settings: ServerSettings, |
| 185 | providers: ReadonlyArray<ServerProvider> | undefined, |
| 186 | projectId: ProjectId | null, |
| 187 | ): ModelSelection { |
| 188 | const selection = settings.reviewGuideModelSelection; |
| 189 | if ( |
| 190 | selection && |
| 191 | resolveProjectProviderInstanceEnabled(settings, projectId, selection.instanceId) |
| 192 | ) { |
| 193 | const provider = providers?.find((candidate) => candidate.instanceId === selection.instanceId); |
| 194 | if ( |
| 195 | providers === undefined || |
| 196 | (provider?.enabled === true && |
| 197 | isProviderAvailable(provider) && |
| 198 | isProviderTextGenerationCapable(provider)) |
| 199 | ) { |
| 200 | return selection; |
| 201 | } |
| 202 | } |
| 203 | return resolveSourceControlWriterModelSelection(settings, providers, projectId); |
| 204 | } |
| 205 | |
| 206 | export interface PersistedServerObservabilitySettings { |
| 207 | readonly otlpTracesUrl: string | undefined; |
| 208 | readonly otlpMetricsUrl: string | undefined; |
| 209 | readonly otlpLogsUrl: string | undefined; |
| 210 | } |
| 211 | |
| 212 | function normalizePersistedServerSettingString( |
| 213 | value: string | null | undefined, |
| 214 | ): string | undefined { |
| 215 | const trimmed = value?.trim(); |
| 216 | return trimmed && trimmed.length > 0 ? trimmed : undefined; |
| 217 | } |
| 218 | |
| 219 | function extractPersistedServerObservabilitySettings(input: { |
| 220 | readonly observability?: { |
| 221 | readonly otlpTracesUrl?: string; |
| 222 | readonly otlpMetricsUrl?: string; |
| 223 | readonly otlpLogsUrl?: string; |
| 224 | }; |
| 225 | }): PersistedServerObservabilitySettings { |
| 226 | return { |
| 227 | otlpTracesUrl: normalizePersistedServerSettingString(input.observability?.otlpTracesUrl), |
| 228 | otlpMetricsUrl: normalizePersistedServerSettingString(input.observability?.otlpMetricsUrl), |
| 229 | otlpLogsUrl: normalizePersistedServerSettingString(input.observability?.otlpLogsUrl), |
| 230 | }; |
| 231 | } |
| 232 | |
| 233 | export function parsePersistedServerObservabilitySettings( |
| 234 | raw: string, |
| 235 | ): PersistedServerObservabilitySettings { |
| 236 | const decoded = decodeServerSettingsJson(raw); |
| 237 | if (Option.isSome(decoded)) { |
| 238 | return extractPersistedServerObservabilitySettings(decoded.value); |
| 239 | } |
| 240 | return { otlpTracesUrl: undefined, otlpMetricsUrl: undefined, otlpLogsUrl: undefined }; |
| 241 | } |
| 242 | |
| 243 | function shouldReplaceTextGenerationModelSelection( |
| 244 | patch: ServerSettingsPatch["textGenerationModelSelection"] | undefined, |
| 245 | ): boolean { |
| 246 | return Boolean(patch && (patch.instanceId !== undefined || patch.model !== undefined)); |
| 247 | } |
| 248 | |
| 249 | function mergeModelSelectionOptionsById(input: { |
| 250 | current: ReadonlyArray<{ readonly id: string; readonly value: string | boolean }> | undefined; |
| 251 | patch: ReadonlyArray<{ readonly id: string; readonly value: string | boolean }> | undefined; |
| 252 | }): Array<{ id: string; value: string | boolean }> | undefined { |
| 253 | if (input.patch === undefined) { |
| 254 | return input.current ? [...input.current] : undefined; |
| 255 | } |
| 256 | if (input.patch.length === 0) { |
| 257 | return undefined; |
| 258 | } |
| 259 | |
| 260 | const merged = new Map((input.current ?? []).map((selection) => [selection.id, selection.value])); |
| 261 | for (const selection of input.patch) { |
| 262 | merged.set(selection.id, selection.value); |
| 263 | } |
| 264 | return [...merged.entries()].map(([id, value]) => ({ id, value })); |
| 265 | } |
| 266 | |
| 267 | /** Upsert each patched entry; `null` removes it. Entries the patch omits are untouched. */ |
| 268 | function mergeSettingsEntries<Value>( |
| 269 | current: Readonly<Record<string, Value>>, |
| 270 | patch: Readonly<Record<string, Value | null>>, |
| 271 | ): Record<string, Value> { |
| 272 | const next = new Map(Object.entries(current)); |
| 273 | for (const [id, config] of Object.entries(patch)) { |
| 274 | if (config === null) { |
| 275 | next.delete(id); |
| 276 | } else { |
| 277 | next.set(id, config); |
| 278 | } |
| 279 | } |
| 280 | return Object.fromEntries(next); |
| 281 | } |
| 282 | |
| 283 | /** |
| 284 | * `projectSettingsOverrides` entries otherwise replace whole (see the field's |
| 285 | * doc comment in contracts), but `providerInstances` was added after clients |
| 286 | * already round-trip a project's entry on every edit. A client built before |
| 287 | * that addition sends an entry that never mentions the key, and a whole |
| 288 | * replace would silently wipe it. When an incoming entry omits the key, keep |
| 289 | * what is stored; a client that means to clear every provider override for |
| 290 | * the project says so explicitly with `providerInstances: {}`. |
| 291 | */ |
| 292 | function mergeProjectSettingsOverridesPatch( |
| 293 | current: Readonly<Record<string, ProjectSettingsOverrides>>, |
| 294 | patch: Readonly<Record<string, ProjectSettingsOverrides | null>>, |
| 295 | ): Record<string, ProjectSettingsOverrides> { |
| 296 | const next = new Map(Object.entries(current)); |
| 297 | for (const [projectId, entry] of Object.entries(patch)) { |
| 298 | if (entry === null) { |
| 299 | next.delete(projectId); |
| 300 | continue; |
| 301 | } |
| 302 | const storedProviderInstances = current[projectId]?.providerInstances; |
| 303 | next.set(projectId, { |
| 304 | ...entry, |
| 305 | ...(Object.hasOwn(entry, "providerInstances") || storedProviderInstances === undefined |
| 306 | ? {} |
| 307 | : { providerInstances: storedProviderInstances }), |
| 308 | }); |
| 309 | } |
| 310 | return Object.fromEntries(next); |
| 311 | } |
| 312 | |
| 313 | /** |
| 314 | * Derived views of `projectSettingsOverrides` for clients that still read |
| 315 | * the legacy per-key maps. Recomputed on every patch and load so they |
| 316 | * cannot drift from the generic record. |
| 317 | */ |
| 318 | export function deriveLegacyProjectOverrides( |
| 319 | settings: Pick<ServerSettings, "projectSettingsOverrides">, |
| 320 | ): Pick< |
| 321 | ServerSettings, |
| 322 | "projectAgentBrowserAccessOverrides" | "projectAutoPullOverrides" | "projectScriptOverrides" |
| 323 | > { |
| 324 | const projectAgentBrowserAccessOverrides: Record<string, boolean> = {}; |
| 325 | const projectAutoPullOverrides: Record<string, boolean> = {}; |
| 326 | const projectScriptOverrides: Record<string, ServerSettings["defaultProjectScripts"] | null> = {}; |
| 327 | for (const [projectId, entry] of Object.entries(settings.projectSettingsOverrides)) { |
| 328 | if (entry.enableAgentBrowserAccess !== undefined) { |
| 329 | projectAgentBrowserAccessOverrides[projectId] = entry.enableAgentBrowserAccess; |
| 330 | } |
| 331 | if (entry.defaultAutoPull !== undefined) { |
| 332 | projectAutoPullOverrides[projectId] = entry.defaultAutoPull; |
| 333 | } |
| 334 | if (entry.defaultProjectScripts !== undefined) { |
| 335 | projectScriptOverrides[projectId] = entry.defaultProjectScripts; |
| 336 | } |
| 337 | } |
| 338 | return { projectAgentBrowserAccessOverrides, projectAutoPullOverrides, projectScriptOverrides }; |
| 339 | } |
| 340 | |
| 341 | /** |
| 342 | * Rewrite a patch that still uses the legacy per-key project maps into |
| 343 | * entries of `projectSettingsOverrides`, so older clients keep editing the |
| 344 | * values the server actually reads. `null` in a legacy map clears that one |
| 345 | * override. |
| 346 | */ |
| 347 | function translateLegacyProjectOverridePatch( |
| 348 | current: Pick<ServerSettings, "projectSettingsOverrides">, |
| 349 | patch: ServerSettingsPatch, |
| 350 | ): ServerSettingsPatch { |
| 351 | const { |
| 352 | projectAgentBrowserAccessOverrides, |
| 353 | projectAutoPullOverrides, |
| 354 | projectScriptOverrides, |
| 355 | ...rest |
| 356 | } = patch; |
| 357 | if ( |
| 358 | projectAgentBrowserAccessOverrides === undefined && |
| 359 | projectAutoPullOverrides === undefined && |
| 360 | projectScriptOverrides === undefined |
| 361 | ) { |
| 362 | return patch; |
| 363 | } |
| 364 | const currentEntries: Readonly<Record<string, ProjectSettingsOverrides>> = |
| 365 | current.projectSettingsOverrides; |
| 366 | const entries = new Map<string, ProjectSettingsOverrides | null>( |
| 367 | Object.entries(rest.projectSettingsOverrides ?? {}), |
| 368 | ); |
| 369 | // A canonical entry in the same patch is the newer representation; a legacy |
| 370 | // map must not resurrect a key that entry deliberately omits. |
| 371 | const canonicalProjectIds = new Set(Object.keys(rest.projectSettingsOverrides ?? {})); |
| 372 | const applyKey = <K extends ProjectScopedServerSettingKey>( |
| 373 | map: Readonly<Record<string, ProjectSettingsOverrides[K] | null>> | undefined, |
| 374 | key: K, |
| 375 | ) => { |
| 376 | if (map === undefined) return; |
| 377 | for (const [projectId, value] of Object.entries(map)) { |
| 378 | if (canonicalProjectIds.has(projectId)) continue; |
| 379 | const entry: ProjectSettingsOverrides = { |
| 380 | ...(entries.get(projectId) ?? currentEntries[projectId] ?? {}), |
| 381 | }; |
| 382 | if (value === null || value === undefined) { |
| 383 | delete entry[key]; |
| 384 | } else { |
| 385 | entry[key] = value; |
| 386 | } |
| 387 | entries.set(projectId, Object.keys(entry).length === 0 ? null : entry); |
| 388 | } |
| 389 | }; |
| 390 | applyKey(projectAgentBrowserAccessOverrides, "enableAgentBrowserAccess"); |
| 391 | applyKey(projectAutoPullOverrides, "defaultAutoPull"); |
| 392 | applyKey(projectScriptOverrides, "defaultProjectScripts"); |
| 393 | return { |
| 394 | ...rest, |
| 395 | projectSettingsOverrides: Object.fromEntries(entries), |
| 396 | } as ServerSettingsPatch; |
| 397 | } |
| 398 | |
| 399 | export function applyServerSettingsPatch( |
| 400 | current: ServerSettings, |
| 401 | rawPatch: ServerSettingsPatch, |
| 402 | ): ServerSettings { |
| 403 | const patch = translateLegacyProjectOverridePatch(current, rawPatch); |
| 404 | const selectionPatch = patch.textGenerationModelSelection; |
| 405 | const { |
| 406 | migrateModelPreferences, |
| 407 | setModelFavorites, |
| 408 | setModelsHidden, |
| 409 | setProviderModelOrder, |
| 410 | providerModelPreferences, |
| 411 | automaticGitFetchInterval, |
| 412 | providerHealthRefreshInterval, |
| 413 | backgroundActivityProfile, |
| 414 | backgroundActivity, |
| 415 | worktreeCleanup: worktreeCleanupPatch, |
| 416 | // Merged per entry below; its `null` removals must not reach deepMerge. |
| 417 | usageLimitSources: usageLimitSourcesPatch, |
| 418 | sourceControlHosts: sourceControlHostsPatch, |
| 419 | usagePriceOverrides: usagePriceOverridesPatch, |
| 420 | usageModelAliases: usageModelAliasesPatch, |
| 421 | // Entry replacement: deepMerge would keep keys the client meant to clear. |
| 422 | projectSettingsOverrides: projectSettingsOverridesPatch, |
| 423 | // Already translated into `projectSettingsOverrides` above; the legacy |
| 424 | // maps are derived views and must never be merged directly. |
| 425 | projectAgentBrowserAccessOverrides: _legacyBrowserAccess, |
| 426 | projectAutoPullOverrides: _legacyAutoPull, |
| 427 | projectScriptOverrides: _legacyScripts, |
| 428 | ...patchForMerge |
| 429 | } = patch; |
| 430 | const currentBackgroundActivity = normalizeServerBackgroundActivitySettings(current); |
| 431 | const backgroundActivityPatch = |
| 432 | backgroundActivityProfile !== undefined |
| 433 | ? { |
| 434 | schemaVersion: 1 as const, |
| 435 | profile: |
| 436 | automaticGitFetchInterval !== undefined || providerHealthRefreshInterval !== undefined |
| 437 | ? ("custom" as const) |
| 438 | : backgroundActivityProfile, |
| 439 | ...(automaticGitFetchInterval !== undefined || providerHealthRefreshInterval !== undefined |
| 440 | ? { baseProfile: backgroundActivityProfile } |
| 441 | : {}), |
| 442 | overrides: { |
| 443 | ...(automaticGitFetchInterval !== undefined ? { automaticGitFetchInterval } : {}), |
| 444 | ...(providerHealthRefreshInterval !== undefined |
| 445 | ? { providerHealthRefreshInterval } |
| 446 | : {}), |
| 447 | }, |
| 448 | } |
| 449 | : automaticGitFetchInterval !== undefined || providerHealthRefreshInterval !== undefined |
| 450 | ? { |
| 451 | schemaVersion: 1 as const, |
| 452 | profile: "custom" as const, |
| 453 | baseProfile: getBackgroundActivityBaseProfile(currentBackgroundActivity), |
| 454 | overrides: { |
| 455 | ...(currentBackgroundActivity.profile === "custom" |
| 456 | ? currentBackgroundActivity.overrides |
| 457 | : {}), |
| 458 | ...(automaticGitFetchInterval !== undefined ? { automaticGitFetchInterval } : {}), |
| 459 | ...(providerHealthRefreshInterval !== undefined |
| 460 | ? { providerHealthRefreshInterval } |
| 461 | : {}), |
| 462 | }, |
| 463 | } |
| 464 | : undefined; |
| 465 | const initialFavorites = |
| 466 | patch.favorites ?? current.favorites ?? migrateModelPreferences?.favorites ?? null; |
| 467 | const favorites = |
| 468 | setModelFavorites?.reduce((current, change) => { |
| 469 | const exists = current.some( |
| 470 | (favorite) => favorite.provider === change.provider && favorite.model === change.model, |
| 471 | ); |
| 472 | return change.favorite |
| 473 | ? exists |
| 474 | ? current |
| 475 | : [...current, { provider: change.provider, model: change.model }] |
| 476 | : current.filter( |
| 477 | (favorite) => favorite.provider !== change.provider || favorite.model !== change.model, |
| 478 | ); |
| 479 | }, initialFavorites ?? []) ?? initialFavorites; |
| 480 | const initialModelPreferences = |
| 481 | providerModelPreferences ?? |
| 482 | current.providerModelPreferences ?? |
| 483 | migrateModelPreferences?.providerModelPreferences ?? |
| 484 | null; |
| 485 | let nextModelPreferences = initialModelPreferences; |
| 486 | for (const change of setModelsHidden ?? []) { |
| 487 | const preferences = nextModelPreferences?.[change.provider] ?? { |
| 488 | hiddenModels: [], |
| 489 | modelOrder: [], |
| 490 | }; |
| 491 | const hiddenModels = preferences.hiddenModels.filter((model) => model !== change.model); |
| 492 | if (change.hidden) hiddenModels.push(change.model); |
| 493 | nextModelPreferences = { |
| 494 | ...nextModelPreferences, |
| 495 | [change.provider]: { ...preferences, hiddenModels }, |
| 496 | }; |
| 497 | } |
| 498 | if (setProviderModelOrder) { |
| 499 | const preferences = nextModelPreferences?.[setProviderModelOrder.provider] ?? { |
| 500 | hiddenModels: [], |
| 501 | modelOrder: [], |
| 502 | }; |
| 503 | nextModelPreferences = { |
| 504 | ...nextModelPreferences, |
| 505 | [setProviderModelOrder.provider]: { |
| 506 | ...preferences, |
| 507 | modelOrder: [...setProviderModelOrder.modelOrder], |
| 508 | }, |
| 509 | }; |
| 510 | } |
| 511 | const next = deepMerge(current, patchForMerge); |
| 512 | const nextWithReplacementsBase = { |
| 513 | ...next, |
| 514 | favorites, |
| 515 | providerModelPreferences: nextModelPreferences, |
| 516 | ...(worktreeCleanupPatch === undefined |
| 517 | ? {} |
| 518 | : { |
| 519 | worktreeCleanup: |
| 520 | worktreeCleanupPatch?.mode === "custom" |
| 521 | ? { |
| 522 | mode: "custom" as const, |
| 523 | rules: { |
| 524 | worktreeKeepWhen: next.storageCleanup.worktreeKeepWhen, |
| 525 | worktreeAfterDays: next.storageCleanup.worktreeAfterDays, |
| 526 | worktreeSettledAfterDays: next.storageCleanup.worktreeSettledAfterDays, |
| 527 | worktreeOnMerge: next.storageCleanup.worktreeOnMerge, |
| 528 | worktreeOnDelete: next.storageCleanup.worktreeOnDelete, |
| 529 | worktreeUnchanged: next.storageCleanup.worktreeUnchanged, |
| 530 | ...(current.worktreeCleanup?.mode === "custom" |
| 531 | ? current.worktreeCleanup.rules |
| 532 | : {}), |
| 533 | ...worktreeCleanupPatch.rules, |
| 534 | }, |
| 535 | } |
| 536 | : worktreeCleanupPatch, |
| 537 | }), |
| 538 | ...(backgroundActivity !== undefined |
| 539 | ? { |
| 540 | backgroundActivity: { |
| 541 | ...deepMerge(currentBackgroundActivity, backgroundActivity), |
| 542 | ...(backgroundActivity.overrides !== undefined |
| 543 | ? { overrides: backgroundActivity.overrides } |
| 544 | : {}), |
| 545 | }, |
| 546 | } |
| 547 | : { backgroundActivity: currentBackgroundActivity }), |
| 548 | ...(backgroundActivity === undefined && backgroundActivityPatch !== undefined |
| 549 | ? { backgroundActivity: backgroundActivityPatch } |
| 550 | : {}), |
| 551 | ...(patch.providerInstances !== undefined |
| 552 | ? { providerInstances: patch.providerInstances } |
| 553 | : {}), |
| 554 | ...(patch.worktreesDirectory !== undefined && |
| 555 | patch.worktreesDirectory !== current.worktreesDirectory |
| 556 | ? { |
| 557 | previousWorktreesDirectories: [ |
| 558 | ...current.previousWorktreesDirectories.filter( |
| 559 | (directory) => directory !== patch.worktreesDirectory, |
| 560 | ), |
| 561 | ...(current.worktreesDirectory !== "" && |
| 562 | !current.previousWorktreesDirectories.includes(current.worktreesDirectory) |
| 563 | ? [current.worktreesDirectory] |
| 564 | : []), |
| 565 | ], |
| 566 | } |
| 567 | : {}), |
| 568 | // Per host, a patched field replaces the saved one: deepMerge would keep a cleared account pin. |
| 569 | ...(sourceControlHostsPatch !== undefined |
| 570 | ? { |
| 571 | sourceControlHosts: { |
| 572 | ...current.sourceControlHosts, |
| 573 | ...Object.fromEntries( |
| 574 | Object.entries(sourceControlHostsPatch).map(([kind, fields]) => [ |
| 575 | kind, |
| 576 | { ...current.sourceControlHosts[kind], ...fields }, |
| 577 | ]), |
| 578 | ), |
| 579 | }, |
| 580 | } |
| 581 | : {}), |
| 582 | ...(projectSettingsOverridesPatch !== undefined |
| 583 | ? { |
| 584 | projectSettingsOverrides: Object.fromEntries( |
| 585 | Object.entries( |
| 586 | mergeProjectSettingsOverridesPatch( |
| 587 | current.projectSettingsOverrides, |
| 588 | projectSettingsOverridesPatch, |
| 589 | ), |
| 590 | ).filter(([, entry]) => Object.keys(entry).length > 0), |
| 591 | ), |
| 592 | } |
| 593 | : {}), |
| 594 | ...(patch.defaultModelSelection !== undefined |
| 595 | ? { defaultModelSelection: patch.defaultModelSelection } |
| 596 | : {}), |
| 597 | ...(patch.defaultProjectScripts !== undefined |
| 598 | ? { defaultProjectScripts: patch.defaultProjectScripts } |
| 599 | : {}), |
| 600 | ...(usageLimitSourcesPatch !== undefined |
| 601 | ? { |
| 602 | usageLimitSources: mergeSettingsEntries( |
| 603 | current.usageLimitSources, |
| 604 | usageLimitSourcesPatch, |
| 605 | ), |
| 606 | } |
| 607 | : {}), |
| 608 | ...(usagePriceOverridesPatch !== undefined |
| 609 | ? { |
| 610 | usagePriceOverrides: mergeSettingsEntries( |
| 611 | current.usagePriceOverrides, |
| 612 | usagePriceOverridesPatch, |
| 613 | ), |
| 614 | } |
| 615 | : {}), |
| 616 | ...(usageModelAliasesPatch !== undefined |
| 617 | ? { |
| 618 | usageModelAliases: mergeSettingsEntries( |
| 619 | current.usageModelAliases, |
| 620 | usageModelAliasesPatch, |
| 621 | ), |
| 622 | } |
| 623 | : {}), |
| 624 | ...(patch.sourceControlWriterModelSelection !== undefined |
| 625 | ? { sourceControlWriterModelSelection: patch.sourceControlWriterModelSelection } |
| 626 | : {}), |
| 627 | ...(patch.reviewGuideModelSelection !== undefined |
| 628 | ? { reviewGuideModelSelection: patch.reviewGuideModelSelection } |
| 629 | : {}), |
| 630 | ...(automaticGitFetchInterval !== undefined ? { automaticGitFetchInterval } : {}), |
| 631 | ...(providerHealthRefreshInterval !== undefined ? { providerHealthRefreshInterval } : {}), |
| 632 | }; |
| 633 | const normalizedBackgroundActivity = normalizeBackgroundActivitySettings( |
| 634 | nextWithReplacementsBase.backgroundActivity, |
| 635 | ); |
| 636 | const resolvedBackgroundActivity = resolveBackgroundActivitySettings( |
| 637 | normalizedBackgroundActivity, |
| 638 | ); |
| 639 | const nextWithReplacements = { |
| 640 | ...nextWithReplacementsBase, |
| 641 | ...deriveLegacyProjectOverrides(nextWithReplacementsBase), |
| 642 | backgroundActivity: normalizedBackgroundActivity, |
| 643 | automaticGitFetchInterval: resolvedBackgroundActivity.automaticGitFetchInterval, |
| 644 | providerHealthRefreshInterval: resolvedBackgroundActivity.providerHealthRefreshInterval, |
| 645 | backgroundActivityProfile: resolvedBackgroundActivity.profile, |
| 646 | }; |
| 647 | if (!selectionPatch) { |
| 648 | return nextWithReplacements; |
| 649 | } |
| 650 | |
| 651 | const instanceId = selectionPatch.instanceId ?? current.textGenerationModelSelection.instanceId; |
| 652 | const model = selectionPatch.model ?? current.textGenerationModelSelection.model; |
| 653 | const options = shouldReplaceTextGenerationModelSelection(selectionPatch) |
| 654 | ? selectionPatch.options |
| 655 | : mergeModelSelectionOptionsById({ |
| 656 | current: current.textGenerationModelSelection.options, |
| 657 | patch: selectionPatch.options, |
| 658 | }); |
| 659 | |
| 660 | return { |
| 661 | ...nextWithReplacements, |
| 662 | textGenerationModelSelection: createModelSelection(instanceId, model, options), |
| 663 | }; |
| 664 | } |
| 665 | |
| 666 | /** Resolve legacy local preferences only until the environment has a saved value. */ |
| 667 | export function resolveModelPreferences( |
| 668 | settings: Pick<ServerSettings, "favorites" | "providerModelPreferences">, |
| 669 | legacy: Partial<ModelPreferences> = {}, |
| 670 | ): ModelPreferences { |
| 671 | return { |
| 672 | favorites: settings.favorites ?? legacy.favorites ?? [], |
| 673 | providerModelPreferences: |
| 674 | settings.providerModelPreferences ?? legacy.providerModelPreferences ?? {}, |
| 675 | }; |
| 676 | } |
| 677 | |
| 678 | /** Empty legacy values do not claim ownership before another client can migrate its saved list. */ |
| 679 | export function modelPreferencesMigrationPatch( |
| 680 | settings: Pick<ServerSettings, "favorites" | "providerModelPreferences">, |
| 681 | legacy: Partial<ModelPreferences>, |
| 682 | ): ServerSettingsPatch | null { |
| 683 | const favorites = |
| 684 | settings.favorites === null && (legacy.favorites?.length ?? 0) > 0 |
| 685 | ? legacy.favorites |
| 686 | : undefined; |
| 687 | const providerModelPreferences = |
| 688 | settings.providerModelPreferences === null && |
| 689 | Object.keys(legacy.providerModelPreferences ?? {}).length > 0 |
| 690 | ? legacy.providerModelPreferences |
| 691 | : undefined; |
| 692 | return favorites === undefined && providerModelPreferences === undefined |
| 693 | ? null |
| 694 | : { |
| 695 | migrateModelPreferences: { |
| 696 | ...(favorites === undefined ? {} : { favorites }), |
| 697 | ...(providerModelPreferences === undefined ? {} : { providerModelPreferences }), |
| 698 | }, |
| 699 | }; |
| 700 | } |