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 }