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