/ src / server / settings / persist.ts
persist.ts
  1  import type {
  2    AgentBackendSettingsEntry,
  3    GlobalDefaults,
  4    ProviderCatalogItem,
  5    ProviderProfileId,
  6    SessionSettingsSummary,
  7    SettingsUpdatePayload,
  8  } from '@/lib/shared/chat'
  9  import type { ProviderWithSettings } from '@/lib/shared/providers'
 10  import { getRuntimeConfig } from '@/server/config/runtime'
 11  import {
 12    clearSessionProviderOverride,
 13    getSessionProviderOverride,
 14    setSessionProviderOverride,
 15  } from '@/server/providers/session-overrides'
 16  import type { SessionProviderOverride } from '@/server/providers/session-overrides'
 17  import {
 18    ensureSession,
 19    getSessionChatModelOverride,
 20    getSessionReasoningLevelOverride,
 21    getSessionThinkingLevelOverride,
 22    setSessionChatModelOverride,
 23    setSessionReasoningLevelOverride,
 24    setSessionThinkingLevelOverride,
 25  } from '@/server/storage/chat-store'
 26  import {
 27    getAppSettings,
 28    resolveDefaultProviderProfileId,
 29    resolveProviderScopedDefaultChatModel,
 30    updateAppSettings,
 31  } from '@/server/storage/app-settings'
 32  import {
 33    listProviderProfiles,
 34    normalizeModelReferenceForProvider,
 35    replaceProviderProfiles,
 36  } from '@/server/storage/providers'
 37  import { listAgentBackendSettingsEntries } from '@/server/settings/agent-backends'
 38  import { buildProviderCatalog } from '@/server/settings/catalog'
 39  import { normalizeDefaultsProviderPatch } from '@/server/settings/validation'
 40  import {
 41    normalizeReasoningLevel,
 42    normalizeThinkingLevel,
 43  } from '@/server/settings/resolve'
 44  
 45  type SettingsSnapshot = {
 46    session: SessionSettingsSummary
 47    defaults: GlobalDefaults
 48    providerCatalog: ProviderCatalogItem[]
 49    providers: ProviderWithSettings[]
 50    agentBackends: AgentBackendSettingsEntry[]
 51  }
 52  
 53  export function getSettingsSnapshot(sessionId?: string): SettingsSnapshot {
 54    const session = ensureSession(sessionId)
 55    const defaults = getAppSettings()
 56    const providers = listProviderProfiles()
 57    return {
 58      session: buildSessionSettingsSummary(session.id, defaults, providers),
 59      defaults,
 60      providerCatalog: buildProviderCatalog(defaults, providers),
 61      providers,
 62      agentBackends: listAgentBackendSettingsEntries(),
 63    }
 64  }
 65  
 66  export async function updateSettings(
 67    payload: SettingsUpdatePayload,
 68  ): Promise<SettingsSnapshot> {
 69    const session = ensureSession(payload.sessionId)
 70    let defaults = getAppSettings()
 71    let providers = listProviderProfiles()
 72  
 73    if (payload.defaults) {
 74      const defaultsUpdate = await applyDefaultsUpdate({
 75        patch: payload.defaults,
 76        defaults,
 77        providers,
 78      })
 79      defaults = defaultsUpdate.defaults
 80      providers = defaultsUpdate.providers
 81    }
 82  
 83    if (payload.session) {
 84      await applySessionUpdate({
 85        sessionId: session.id,
 86        patch: payload.session,
 87        defaults,
 88        providers,
 89      })
 90    }
 91  
 92    providers = listProviderProfiles()
 93    return {
 94      session: buildSessionSettingsSummary(session.id, defaults, providers),
 95      defaults,
 96      providerCatalog: buildProviderCatalog(defaults, providers),
 97      providers,
 98      agentBackends: listAgentBackendSettingsEntries(),
 99    }
100  }
101  
102  async function applyDefaultsUpdate(input: {
103    patch: NonNullable<SettingsUpdatePayload['defaults']>
104    defaults: GlobalDefaults
105    providers: ProviderWithSettings[]
106  }): Promise<{ defaults: GlobalDefaults; providers: ProviderWithSettings[] }> {
107    const providers = input.patch.providers
108      ? replaceProviderProfiles(input.patch.providers)
109      : input.providers
110    const normalizedDefaultsPatch = await normalizeDefaultsProviderPatch(input.patch)
111    const defaultProviderId = resolveDefaultProviderId({
112      patchDefaultProviderId: normalizedDefaultsPatch.defaultProviderId,
113      currentDefaults: input.defaults,
114      providers,
115    })
116  
117    const defaults = updateAppSettings({
118      chatModel: normalizeModelForProvider(
119        defaultProviderId,
120        normalizedDefaultsPatch.chatModel,
121      ),
122      embeddingModel: normalizeModelForProvider(
123        normalizedDefaultsPatch.embeddingProviderId ?? input.defaults.embeddingProviderId,
124        normalizedDefaultsPatch.embeddingModel,
125      ),
126      transcriptionModel: normalizeModelForProvider(
127        normalizedDefaultsPatch.transcriptionProviderId ?? input.defaults.transcriptionProviderId,
128        normalizedDefaultsPatch.transcriptionModel,
129      ),
130      embeddingProviderId: normalizedDefaultsPatch.embeddingProviderId,
131      transcriptionProviderId: normalizedDefaultsPatch.transcriptionProviderId,
132      providerModelDefaults: normalizedDefaultsPatch.providerModelDefaults,
133      thinkingLevel: normalizedDefaultsPatch.thinkingLevel,
134      reasoningLevel: normalizedDefaultsPatch.reasoningLevel,
135      defaultProviderId,
136      providerEndpointMode: normalizedDefaultsPatch.providerEndpointMode,
137      webSearchProvider: normalizedDefaultsPatch.webSearchProvider,
138      webSearchMaxResults: normalizedDefaultsPatch.webSearchMaxResults,
139      webFetchMaxBytes: normalizedDefaultsPatch.webFetchMaxBytes,
140      agentBackendsEnabled: normalizedDefaultsPatch.agentBackendsEnabled,
141      toolCallLogRetentionDays: normalizedDefaultsPatch.toolCallLogRetentionDays,
142    })
143  
144    return { defaults, providers }
145  }
146  
147  async function applySessionUpdate(input: {
148    sessionId: string
149    patch: NonNullable<SettingsUpdatePayload['session']>
150    defaults: GlobalDefaults
151    providers: ProviderWithSettings[]
152  }): Promise<void> {
153    if (input.patch.chatModelOverride !== undefined) {
154      const providerId = resolveProviderProfileIdForSession(
155        input.sessionId,
156        input.defaults,
157        input.providers,
158      )
159      setSessionChatModelOverride(
160        input.sessionId,
161        normalizeModelForProvider(providerId, input.patch.chatModelOverride),
162      )
163    }
164    if (input.patch.thinkingLevelOverride !== undefined) {
165      setSessionThinkingLevelOverride(input.sessionId, input.patch.thinkingLevelOverride)
166    }
167    if (input.patch.reasoningLevelOverride !== undefined) {
168      setSessionReasoningLevelOverride(input.sessionId, input.patch.reasoningLevelOverride)
169    }
170  
171    if (input.patch.provider !== undefined) {
172      applySessionProviderPatch(input.sessionId, input.patch.provider)
173    }
174  
175    if (
176      input.patch.chatModelOverride !== undefined &&
177      input.patch.chatModelOverride !== null &&
178      input.patch.provider === undefined
179    ) {
180      pinSessionProviderToCurrentDefault(
181        input.sessionId,
182        input.defaults,
183        input.providers,
184      )
185    }
186  }
187  
188  export function buildSessionSettingsSummary(
189    sessionId: string,
190    defaults = getAppSettings(),
191    providers: ProviderWithSettings[] = listProviderProfiles(),
192  ): SessionSettingsSummary {
193    const config = getRuntimeConfig()
194    const sessionChatModel = getSessionChatModelOverride(sessionId)
195    const sessionThinking = normalizeThinkingLevel(
196      getSessionThinkingLevelOverride(sessionId),
197    )
198    const sessionReasoning = normalizeReasoningLevel(
199      getSessionReasoningLevelOverride(sessionId),
200    )
201    const providerProfileId = resolveProviderProfileIdForSession(
202      sessionId,
203      defaults,
204      providers,
205    )
206    const normalizedSessionChatModel = normalizeModelForProvider(
207      providerProfileId,
208      sessionChatModel,
209    )
210    const provider = providers.find((candidate) => candidate.id === providerProfileId)
211    const activeProviderModelDefault = resolveProviderScopedDefaultChatModel(
212      defaults,
213      providerProfileId,
214    )
215    const effectiveProvider = resolveEffectiveProviderOverride(
216      sessionId,
217      defaults,
218      providers,
219    )
220  
221    return {
222      sessionId,
223      chatModelOverride: normalizedSessionChatModel,
224      activeChatModel:
225        normalizedSessionChatModel ??
226        activeProviderModelDefault ??
227        config.llmChatModel,
228      activeProviderModelDefault,
229      thinkingLevelOverride: sessionThinking,
230      activeThinkingLevel: sessionThinking ?? defaults.thinkingLevel,
231      reasoningLevelOverride: sessionReasoning,
232      activeReasoningLevel: sessionReasoning ?? defaults.reasoningLevel ?? 'off',
233      providerOverrideActive: Boolean(getSessionProviderOverride(sessionId)),
234      providerProfileId,
235      providerEndpointMode:
236        effectiveProvider?.chatEndpointMode ?? defaults.providerEndpointMode ?? 'auto',
237      providerApiKeyConfigured: Boolean(
238        effectiveProvider?.apiKey ?? provider?.settings.apiKey,
239      ),
240      providerLabel: provider?.name ?? null,
241      providerSourceLabel: getProviderSourceLabel(sessionId, defaults, providers),
242    }
243  }
244  
245  export function resolveEffectiveProviderOverride(
246    sessionId: string,
247    defaults = getAppSettings(),
248    providers: ProviderWithSettings[] = listProviderProfiles(),
249  ): SessionProviderOverride | null {
250    const sessionOverride = getSessionProviderOverride(sessionId)
251    if (sessionOverride) {
252      const provider = providers.find(
253        (candidate) => candidate.id === sessionOverride.providerId,
254      )
255      return {
256        ...sessionOverride,
257        baseUrl: provider?.settings.apiHost ?? null,
258      }
259    }
260  
261    const defaultProviderId = resolveDefaultProviderProfileId(defaults)
262    const provider = providers.find((candidate) => candidate.id === defaultProviderId)
263    if (!provider) return null
264    const baseUrl = provider.settings.apiHost?.trim()
265    if (!baseUrl) return null
266  
267    const timestamp = defaults.updatedAt ?? new Date().toISOString()
268    return {
269      providerId: provider.id,
270      baseUrl,
271      apiKey: provider.settings.apiKey ?? null,
272      chatEndpointMode: defaults.providerEndpointMode ?? 'auto',
273      createdAt: timestamp,
274      updatedAt: timestamp,
275      lastProbeAt: null,
276    }
277  }
278  
279  export function pinSessionProviderToCurrentDefault(
280    sessionId: string,
281    defaults = getAppSettings(),
282    providers: ProviderWithSettings[] = listProviderProfiles(),
283  ): boolean {
284    if (getSessionProviderOverride(sessionId)) return false
285    const effective = resolveEffectiveProviderOverride(sessionId, defaults, providers)
286    if (!effective?.providerId || !effective.baseUrl) return false
287    setSessionProviderOverride(sessionId, {
288      providerId: effective.providerId,
289      apiKey: effective.apiKey,
290      chatEndpointMode: effective.chatEndpointMode,
291    })
292    return true
293  }
294  
295  function applySessionProviderPatch(
296    sessionId: string,
297    patch: NonNullable<NonNullable<SettingsUpdatePayload['session']>['provider']>,
298  ): void {
299    if (patch.clear || patch.providerId === null) {
300      clearSessionProviderOverride(sessionId)
301      return
302    }
303  
304    const providers = listProviderProfiles()
305    const explicitProviderId = patch.providerId?.trim() ?? ''
306    if (!explicitProviderId) {
307      throw new Error('Provider ID is required.')
308    }
309  
310    const selectedProvider = providers.find((provider) => provider.id === explicitProviderId)
311    if (!selectedProvider) {
312      throw new Error(`Unknown provider profile: ${explicitProviderId}`)
313    }
314  
315    setSessionProviderOverride(sessionId, {
316      providerId: selectedProvider.id,
317      apiKey: patch.apiKey ?? selectedProvider.settings.apiKey ?? null,
318      chatEndpointMode: patch.endpointMode ?? 'auto',
319      lastProbeAt: new Date().toISOString(),
320    })
321  }
322  
323  function getProviderSourceLabel(
324    sessionId: string,
325    defaults: GlobalDefaults,
326    providers: ProviderWithSettings[],
327  ): string {
328    const sessionOverride = getSessionProviderOverride(sessionId)
329    if (sessionOverride) {
330      const provider = providers.find(
331        (candidate) => candidate.id === sessionOverride.providerId,
332      )
333      if (provider) return `session override (${provider.name})`
334      return 'session override'
335    }
336  
337    const defaultProvider = providers.find(
338      (candidate) => candidate.id === resolveDefaultProviderProfileId(defaults),
339    )
340    return defaultProvider
341      ? `global default (${defaultProvider.name})`
342      : 'global default (openai)'
343  }
344  
345  function resolveProviderProfileIdForSession(
346    sessionId: string,
347    defaults: GlobalDefaults,
348    providers: ProviderWithSettings[],
349  ): ProviderProfileId {
350    const sessionOverride = getSessionProviderOverride(sessionId)
351    if (sessionOverride) {
352      const providerExists = providers.some(
353        (provider) => provider.id === sessionOverride.providerId,
354      )
355      if (providerExists) {
356        return sessionOverride.providerId
357      }
358    }
359    return resolveDefaultProviderProfileId(defaults)
360  }
361  
362  function resolveDefaultProviderId(input: {
363    patchDefaultProviderId: string | null | undefined
364    currentDefaults: GlobalDefaults
365    providers: ProviderWithSettings[]
366  }): string {
367    const explicitDefaultProviderId = input.patchDefaultProviderId?.trim()
368    if (
369      explicitDefaultProviderId &&
370      input.providers.some((provider) => provider.id === explicitDefaultProviderId)
371    ) {
372      return explicitDefaultProviderId
373    }
374  
375    const currentDefault = resolveDefaultProviderProfileId(input.currentDefaults)
376    if (input.providers.some((provider) => provider.id === currentDefault)) {
377      return currentDefault
378    }
379  
380    const openAiProvider = input.providers.find((provider) => provider.id === 'openai')
381    if (openAiProvider) return openAiProvider.id
382    if (input.providers.length > 0) return input.providers[0].id
383    return 'openai'
384  }
385  
386  function normalizeModelForProvider(
387    providerId: string | null,
388    modelIdOrRef: string | null | undefined,
389  ): string | null {
390    const normalizedModel = modelIdOrRef?.trim()
391    if (!normalizedModel) return modelIdOrRef ?? null
392    if (!providerId) return normalizedModel
393    return normalizeModelReferenceForProvider(providerId, normalizedModel)
394  }