1import type { ModelPreferences } from "@t3tools/contracts/settings";
2import {
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";
22import * as Option from "effect/Option";
23import * as Schema from "effect/Schema";
24import { deepMerge } from "./Struct.ts";
25import { fromLenientJson } from "./schemaJson.ts";
26import { createModelSelection, readCustomModelEntries } from "./model.ts";
27import {
28 getBackgroundActivityBaseProfile,
29 normalizeBackgroundActivitySettings,
30 normalizeServerBackgroundActivitySettings,
31 resolveBackgroundActivitySettings,
32} from "./backgroundActivitySettings.ts";
33
34const ServerSettingsJson = fromLenientJson(ServerSettings);
35const decodeServerSettingsJson = Schema.decodeUnknownOption(ServerSettingsJson);
36
37/** @deprecated Read `resolveProjectSettings(...).settings.enableAgentBrowserAccess`. */
38export 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`. */
53export 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
69export 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
81const 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 */
109export 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 */
155export 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 */
183export 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
206export interface PersistedServerObservabilitySettings {
207 readonly otlpTracesUrl: string | undefined;
208 readonly otlpMetricsUrl: string | undefined;
209 readonly otlpLogsUrl: string | undefined;
210}
211
212function normalizePersistedServerSettingString(
213 value: string | null | undefined,
214): string | undefined {
215 const trimmed = value?.trim();
216 return trimmed && trimmed.length > 0 ? trimmed : undefined;
217}
218
219function 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
233export 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
243function shouldReplaceTextGenerationModelSelection(
244 patch: ServerSettingsPatch["textGenerationModelSelection"] | undefined,
245): boolean {
246 return Boolean(patch && (patch.instanceId !== undefined || patch.model !== undefined));
247}
248
249function 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. */
268function 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 */
292function 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 */
318export 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 */
347function 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
399export 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. */
667export 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. */
679export 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}