import { ProviderId, ProviderConfig, ProviderModel, CodexAuthData, HFAuthData, GitHubAuthData } from '@/lib/llm/providers/types'; import { getDefaultModel } from '@/lib/llm/providers/registry'; import { getCustomProviders, setCustomProviders, saveCustomProvider as saveCustomProviderConfig, removeCustomProvider as removeCustomProviderConfig, generateCustomProviderId as generateCustomProviderIdHelper, buildCustomProviderConfig as buildCustomProviderConfigHelper, } from '@/lib/llm/providers/custom-providers'; import { UsageInfo } from '@/lib/llm/types'; import type { ModelTemplate, ModelAssignment } from '@/lib/llm/models/assignment'; import { BUILT_IN_MODEL_TEMPLATES, isBuiltInTemplateId } from '@/lib/llm/models/registry'; import { logger } from '@/lib/utils'; import type { PermissionMode, GateDecision } from '@/lib/llm/permissions'; export interface SessionCost { sessionId: string; startTime: Date; totalCost: number; messageCount: number; providerBreakdown: Record; } export interface CostSettings { showCosts?: boolean; dailyLimit?: number; projectLimit?: number; warningThreshold?: number; } interface ModelCacheEntry { models: ProviderModel[]; timestamp: string; expiresAt: string; } export interface ProviderPricingEntry { input: number; output: number; reasoning?: number; } export interface AppSettings { openRouterApiKey?: string; defaultModel?: string; selectedProvider?: ProviderId; providerKeys?: Partial>; providerModels?: Partial>; theme?: 'light' | 'dark' | 'system'; costSettings?: CostSettings; currentSession?: SessionCost; lifetimeCosts?: { total: number; byProvider: Record; lastReset?: Date; }; hasSeenAboutModal?: boolean; hasSeenGuidedTour?: boolean; /** When true, the large-file-write pacing notice is permanently dismissed. */ pacingNoticeDismissed?: boolean; modelCache?: Partial>; modelPricing?: Partial>>; reasoningEnabled?: Record; // Per-model reasoning toggle (model ID -> enabled) /** Per-provider auto-compaction toggle. Default: true (enabled). */ compactionEnabled?: Partial>; /** Per-provider compaction limit override (tokens). Empty = automatic. */ compactionLimits?: Partial>; codexAuth?: CodexAuthData; hfAuth?: HFAuthData; githubAuth?: GitHubAuthData; telemetryOptIn?: boolean; /** When true, emit llm_request and stream_raw_chunk debug events (ephemeral, not persisted). */ debugStreamEnabled?: boolean; modelTemplates?: Record; defaultTemplateId?: string; /** Global working/effective model selection. Unset falls back to the active template's assignment. */ activeAssignment?: ModelAssignment; permissionMode?: 'auto' | 'ask' | 'custom'; permissionOverrides?: Record; webSearch?: { provider?: 'tavily' | 'firecrawl' | 'brave' | 'searxng'; keys?: Partial>; searxngUrl?: string; }; /** Provider group ids collapsed in the model picker. */ modelPickerCollapsed?: string[]; } /** * Rehydrate a stored model template: dates round-trip through JSON as strings, * so convert them back to Date, and force builtin:false (only registry ids are * read-only built-ins). */ function hydrateDate(value: Date | string | undefined): Date | undefined { if (!value) return undefined; const d = new Date(value); return isNaN(d.getTime()) ? undefined : d; } function hydrateModelTemplate(t: ModelTemplate): ModelTemplate { return { ...t, builtin: false, updatedAt: hydrateDate(t.updatedAt), lastSyncedAt: hydrateDate(t.lastSyncedAt), serverUpdatedAt: hydrateDate(t.serverUpdatedAt), }; } class ConfigManager { private readonly STORAGE_KEY = 'osw-studio-settings'; getSettings(): AppSettings { if (typeof window === 'undefined') { return {}; } const stored = localStorage.getItem(this.STORAGE_KEY); if (!stored) return {}; const settings = JSON.parse(stored); if ('autoSave' in settings || 'autoSaveInterval' in settings) { delete settings.autoSave; delete settings.autoSaveInterval; localStorage.setItem(this.STORAGE_KEY, JSON.stringify(settings)); } return settings; } setSetting( key: K, value: AppSettings[K] ): void { if (typeof window === 'undefined') { return; } const settings = this.getSettings(); settings[key] = value; localStorage.setItem(this.STORAGE_KEY, JSON.stringify(settings)); } hasSeenTour(): boolean { return Boolean(this.getSettings().hasSeenGuidedTour); } setHasSeenTour(seen: boolean): void { this.setSetting('hasSeenGuidedTour', seen); } hasDismissedPacingNotice(): boolean { return this.getSettings().pacingNoticeDismissed === true; } setPacingNoticeDismissed(): void { this.setSetting('pacingNoticeDismissed', true); } getApiKey(): string | null { const provider = this.getSelectedProvider(); if (provider) { return this.getProviderApiKey(provider); } return this.getSettings().openRouterApiKey || null; } setApiKey(key: string): void { const provider = this.getSelectedProvider(); if (provider) { this.setProviderApiKey(provider, key); } this.setSetting('openRouterApiKey', key); } getDefaultModel(): string { const provider = this.getSelectedProvider(); if (provider) { return this.getProviderModel(provider) || this.getProviderDefaultModel(provider); } return this.getSettings().defaultModel || 'deepseek/deepseek-chat'; } setDefaultModel(model: string): void { const provider = this.getSelectedProvider(); if (provider) { this.setProviderModel(provider, model); } this.setSetting('defaultModel', model); } getSelectedProvider(): ProviderId { return this.getSettings().selectedProvider || (process.env.NEXT_PUBLIC_DEFAULT_PROVIDER as ProviderId) || 'openrouter'; } setSelectedProvider(provider: ProviderId): void { this.setSetting('selectedProvider', provider); this.emitModelConfigChanged(); } getProviderApiKey(provider: ProviderId): string | null { const settings = this.getSettings(); if (settings.providerKeys?.[provider]) { return settings.providerKeys[provider]; } if (provider === 'openrouter' && settings.openRouterApiKey) { return settings.openRouterApiKey; } return null; } setProviderApiKey(provider: ProviderId, key: string): void { const settings = this.getSettings(); const providerKeys = settings.providerKeys || {}; providerKeys[provider] = key; this.setSetting('providerKeys', providerKeys); if (provider === 'openrouter') { this.setSetting('openRouterApiKey', key); } } getProviderModel(provider: ProviderId): string | null { const settings = this.getSettings(); if (settings.providerModels?.[provider]) { return settings.providerModels[provider]; } if (provider === 'openrouter' && settings.defaultModel) { return settings.defaultModel; } return null; } setProviderModel(provider: ProviderId, model: string): void { const settings = this.getSettings(); const providerModels = settings.providerModels || {}; providerModels[provider] = model; this.setSetting('providerModels', providerModels); if (provider === 'openrouter') { this.setSetting('defaultModel', model); } this.emitModelConfigChanged(); } getModelPricing(provider: ProviderId, model: string): ProviderPricingEntry | null { const settings = this.getSettings(); const providerPricing = settings.modelPricing?.[provider]; if (!providerPricing) { return null; } return ( providerPricing[model] || providerPricing[`${provider}/${model}`] || (model.includes('/') ? providerPricing[model.split('/').pop() ?? ''] : null) ) || null; } setProviderPricing(provider: ProviderId, pricingMap: Record): void { if (typeof window === 'undefined') { return; } if (!pricingMap || Object.keys(pricingMap).length === 0) { return; } const settings = this.getSettings(); const modelPricing = { ...(settings.modelPricing || {}) }; const providerPricing = { ...(modelPricing[provider] || {}) }; for (const [model, pricing] of Object.entries(pricingMap)) { providerPricing[model] = pricing; } modelPricing[provider] = providerPricing; this.setSetting('modelPricing', modelPricing); } private getProviderDefaultModel(provider: ProviderId): string { return getDefaultModel(provider); } getTheme(): 'light' | 'dark' | 'system' { return this.getSettings().theme || 'dark'; } setTheme(theme: 'light' | 'dark' | 'system'): void { this.setSetting('theme', theme); } clearSettings(): void { if (typeof window !== 'undefined') { localStorage.removeItem(this.STORAGE_KEY); } } getCostSettings(): CostSettings { return this.getSettings().costSettings || { showCosts: true, warningThreshold: 80 }; } setCostSettings(settings: CostSettings): void { this.setSetting('costSettings', settings); // Broadcast the change to reactive components if (typeof window !== 'undefined') { window.dispatchEvent(new CustomEvent('osw-studio-cost-settings-changed')); } } getCurrentSession(): SessionCost | null { const session = this.getSettings().currentSession; if (!session) { return null; } return { ...session, startTime: new Date(session.startTime) }; } startNewSession(): SessionCost { const session: SessionCost = { sessionId: Date.now().toString(), startTime: new Date(), totalCost: 0, messageCount: 0, providerBreakdown: {} }; this.setSetting('currentSession', session); return session; } updateSessionCost(usage: UsageInfo, cost: number): void { let session = this.getCurrentSession(); if (!session) { session = this.startNewSession(); } session.totalCost += cost; session.messageCount += 1; const provider = usage.provider || 'unknown'; if (!session.providerBreakdown[provider]) { session.providerBreakdown[provider] = { cost: 0, tokenUsage: { input: 0, output: 0 }, requestCount: 0 }; } session.providerBreakdown[provider].cost += cost; session.providerBreakdown[provider].tokenUsage.input += usage.promptTokens; session.providerBreakdown[provider].tokenUsage.output += usage.completionTokens; session.providerBreakdown[provider].requestCount += 1; const lifetimeCosts = this.getSettings().lifetimeCosts || { total: 0, byProvider: {} }; lifetimeCosts.total += cost; lifetimeCosts.byProvider[provider] = (lifetimeCosts.byProvider[provider] || 0) + cost; this.setSetting('currentSession', session); this.setSetting('lifetimeCosts', lifetimeCosts); } getLifetimeCosts() { return this.getSettings().lifetimeCosts || { total: 0, byProvider: {} }; } resetLifetimeCosts(): void { this.setSetting('lifetimeCosts', { total: 0, byProvider: {}, lastReset: new Date() }); } // Model cache management getCachedModels(provider: ProviderId): ModelCacheEntry | null { const settings = this.getSettings(); const cache = settings.modelCache?.[provider]; if (!cache) return null; // Check if cache is expired const now = new Date(); const expiresAt = new Date(cache.expiresAt); if (now > expiresAt) { // Cache expired, remove it this.clearModelCache(provider); return null; } return cache; } setCachedModels(provider: ProviderId, models: ProviderModel[]): void { const settings = this.getSettings(); const now = new Date(); const expiresAt = new Date(now.getTime() + 24 * 60 * 60 * 1000); // 24 hours const cache = settings.modelCache || {}; cache[provider] = { models, timestamp: now.toISOString(), expiresAt: expiresAt.toISOString() }; this.setSetting('modelCache', cache); } clearModelCache(provider?: ProviderId): void { if (provider) { const settings = this.getSettings(); const cache = settings.modelCache || {}; delete cache[provider]; this.setSetting('modelCache', cache); } else { // Clear all cache this.setSetting('modelCache', {}); } } // Codex auth management getCodexAuth(): CodexAuthData | null { return this.getSettings().codexAuth || null; } setCodexAuth(auth: CodexAuthData): void { this.setSetting('codexAuth', auth); // Also write access_token into providerKeys so getProviderApiKey() works this.setProviderApiKey('openai-codex', auth.access_token); } clearCodexAuth(): void { const settings = this.getSettings(); delete settings.codexAuth; if (typeof window !== 'undefined') { localStorage.setItem(this.STORAGE_KEY, JSON.stringify(settings)); } // Also clear the provider key const providerKeys = settings.providerKeys || {}; delete providerKeys['openai-codex']; this.setSetting('providerKeys', providerKeys); } isCodexTokenExpired(): boolean { const auth = this.getCodexAuth(); if (!auth) return true; // Expired if within 60s of expiry return Date.now() / 1000 >= auth.expires_at - 60; } // HuggingFace auth management getHFAuth(): HFAuthData | null { return this.getSettings().hfAuth || null; } setHFAuth(auth: HFAuthData): void { this.setSetting('hfAuth', auth); // Also write access_token into providerKeys so getProviderApiKey() works this.setProviderApiKey('huggingface', auth.access_token); } clearHFAuth(): void { const settings = this.getSettings(); delete settings.hfAuth; if (typeof window !== 'undefined') { localStorage.setItem(this.STORAGE_KEY, JSON.stringify(settings)); } // Also clear the provider key const providerKeys = settings.providerKeys || {}; delete providerKeys['huggingface']; this.setSetting('providerKeys', providerKeys); } // GitHub auth management getGitHubAuth(): GitHubAuthData | null { const stored = this.getSettings().githubAuth; if (stored) return stored; // Support reading from environment variable for self-hosted deployments const envToken = process.env.GITHUB_TOKEN || process.env.NEXT_PUBLIC_GITHUB_TOKEN; if (envToken) { return { access_token: envToken }; } return null; } setGitHubAuth(auth: GitHubAuthData): void { this.setSetting('githubAuth', auth); // Also write access_token into providerKeys so getProviderApiKey() works this.setProviderApiKey('github', auth.access_token); } clearGitHubAuth(): void { const settings = this.getSettings(); delete settings.githubAuth; if (typeof window !== 'undefined') { localStorage.setItem(this.STORAGE_KEY, JSON.stringify(settings)); } // Also clear the provider key const providerKeys = settings.providerKeys || {}; delete providerKeys['github']; this.setSetting('providerKeys', providerKeys); } getModelContextLengthFromCache(provider: ProviderId, modelId: string): number | undefined { const cache = this.getCachedModels(provider); if (!cache?.models) return undefined; const model = cache.models.find(m => m.id === modelId); return model?.contextLength; } isCompactionEnabled(provider: ProviderId): boolean { const settings = this.getSettings(); return settings.compactionEnabled?.[provider] ?? true; } setCompactionEnabled(provider: ProviderId, enabled: boolean): void { const settings = this.getSettings(); const map = { ...settings.compactionEnabled }; if (enabled) { delete map[provider]; } else { map[provider] = false; } this.setSetting('compactionEnabled', map); } getCompactionLimit(provider: ProviderId): number | undefined { const settings = this.getSettings(); return settings.compactionLimits?.[provider]; } setCompactionLimit(provider: ProviderId, limit: number | undefined): void { const settings = this.getSettings(); const limits = { ...settings.compactionLimits }; if (limit === undefined) { delete limits[provider]; } else { limits[provider] = limit; } this.setSetting('compactionLimits', limits); } // Reasoning toggle management getReasoningEnabled(modelId: string): boolean { const settings = this.getSettings(); return settings.reasoningEnabled?.[modelId] ?? false; } getDebugStreamEnabled(): boolean { const settings = this.getSettings(); return settings.debugStreamEnabled ?? false; } setDebugStreamEnabled(enabled: boolean): void { this.setSetting('debugStreamEnabled', enabled); } setReasoningEnabled(modelId: string, enabled: boolean): void { const settings = this.getSettings(); const reasoningEnabled = { ...(settings.reasoningEnabled || {}) }; reasoningEnabled[modelId] = enabled; this.setSetting('reasoningEnabled', reasoningEnabled); // Broadcast the change if (typeof window !== 'undefined') { window.dispatchEvent(new CustomEvent('osw-studio-reasoning-changed', { detail: { modelId, enabled } })); } } migrateModels(): void { if (this.getSettings().modelTemplates) return; // idempotent const provider = this.getSelectedProvider(); let model = this.getProviderModel(provider) || this.getDefaultModel(); if (typeof window !== 'undefined') { if (localStorage.getItem(`osw-studio-use-separate-chat-model-${provider}`) === 'true') { model = localStorage.getItem(`osw-studio-code-model-${provider}`) || model; } const stale: string[] = []; for (let i = 0; i < localStorage.length; i++) { const k = localStorage.key(i); if (k && /^osw-studio-(use-separate-chat-model|chat-model|code-model)-/.test(k)) stale.push(k); } stale.forEach((k) => localStorage.removeItem(k)); } const def: ModelTemplate = { id: 'default', name: 'Default', builtin: false, assignment: { agent: { provider, model }, imageGen: null, voiceInput: null, autoCompact: this.isCompactionEnabled(provider), compactLimit: this.getCompactionLimit(provider) ?? null, }, }; this.saveModelTemplate(def); this.setDefaultTemplateId('default'); } // Model template management. // Built-in templates live in code (registry) and are merged in at read time — // never persisted. Built-in ids win over stored ones and cannot be saved or // deleted; "Save as" clones one into an editable (non-builtin) template. getModelTemplates(): Record { const merged: Record = {}; const stored = this.getSettings().modelTemplates || {}; for (const [id, t] of Object.entries(stored)) { // Only registry templates are read-only built-ins. Anything in storage is a // user template (incl. the migrated "Default") — force builtin:false so a // stale flag from an older build doesn't make it appear uneditable. if (!isBuiltInTemplateId(id)) merged[id] = hydrateModelTemplate(t); } for (const t of BUILT_IN_MODEL_TEMPLATES) merged[t.id] = t; return merged; } getModelTemplate(id: string): ModelTemplate | null { return this.getModelTemplates()[id] || null; } saveModelTemplate(t: ModelTemplate): void { if (isBuiltInTemplateId(t.id)) return; // built-ins are read-only const stored = this.getSettings().modelTemplates || {}; // A content edit — stamp updatedAt so sync can detect local changes. const saved: ModelTemplate = { ...t, builtin: false, updatedAt: new Date() }; this.setSetting('modelTemplates', { ...stored, [t.id]: saved }); // If this IS the active template, an in-place edit must take effect on the effective // model: reload the working selection to match. Direct setSetting (no dispatch) keeps // exactly one modelConfigChanged below. Non-active templates leave working untouched. if (t.id === this.getDefaultTemplateId()) this.setSetting('activeAssignment', t.assignment); if (typeof window !== 'undefined') { import('@/lib/vfs/auto-sync').then(({ autoSyncModelTemplate }) => autoSyncModelTemplate(saved)).catch((e) => logger.debug('[ConfigManager] model-template auto-sync trigger failed', e)); this.emitModelConfigChanged(); } } deleteModelTemplate(id: string): void { if (isBuiltInTemplateId(id)) return; // built-ins cannot be deleted const all = { ...(this.getSettings().modelTemplates || {}) }; delete all[id]; this.setSetting('modelTemplates', all); if (typeof window !== 'undefined') { import('@/lib/vfs/auto-sync').then(({ autoDeleteModelTemplate }) => autoDeleteModelTemplate(id)).catch((e) => logger.debug('[ConfigManager] model-template auto-delete trigger failed', e)); } } /** Store a template pulled from the server, preserving its server timestamp and marking it synced. */ importModelTemplateFromServer(t: ModelTemplate): void { if (isBuiltInTemplateId(t.id)) return; const stored = this.getSettings().modelTemplates || {}; const now = new Date(); const serverUpdated = t.updatedAt ? new Date(t.updatedAt) : now; this.setSetting('modelTemplates', { ...stored, [t.id]: { ...t, builtin: false, updatedAt: serverUpdated, lastSyncedAt: now, serverUpdatedAt: serverUpdated }, }); // A sync pull can overwrite the active template's content; if it IS the active template, // reload the working selection so re-resolution reflects the synced change (otherwise the // dispatch below re-resolves against a stale working selection). Non-active: untouched. if (t.id === this.getDefaultTemplateId()) this.setSetting('activeAssignment', t.assignment); this.emitModelConfigChanged(); } /** Record sync metadata after a push, without bumping updatedAt (not a content edit). */ updateModelTemplateSyncMetadata(id: string, lastSyncedAt: Date, serverUpdatedAt: Date): void { const stored = this.getSettings().modelTemplates || {}; const t = stored[id]; if (!t) return; this.setSetting('modelTemplates', { ...stored, [id]: { ...t, lastSyncedAt, serverUpdatedAt } }); } getDefaultTemplateId(): string { return this.getSettings().defaultTemplateId || 'default'; } setDefaultTemplateId(id: string): void { this.setSetting('defaultTemplateId', id); // Selecting a template loads it as the working selection, so switching templates // changes the effective model. Write directly (not via setActiveAssignment) to keep // exactly one modelConfigChanged dispatch per call. const t = this.getModelTemplate(id); if (t) this.setSetting('activeAssignment', t.assignment); this.emitModelConfigChanged(); } /** The event every global model-config write emits so React consumers re-resolve. */ private emitModelConfigChanged(): void { if (typeof window !== 'undefined') window.dispatchEvent(new CustomEvent('modelConfigChanged')); } /** * Resolve the active template: the one at defaultTemplateId, with a migrateModels()-then-retry * step and a default -> or-recommended degrade so a dangling id never throws for callers. Single * source of the fallback chain, shared by getActiveTemplate (template-store) and getActiveAssignment. */ getActiveModelTemplate(): ModelTemplate { const id = this.getDefaultTemplateId(); let t = this.getModelTemplate(id); if (!t) { this.migrateModels(); t = this.getModelTemplate(id); } if (!t) t = this.getModelTemplate('default') ?? this.getModelTemplate('or-recommended'); if (!t) throw new Error('No model template available (default and built-ins missing)'); return t; } /** * The global working/effective model selection. Returns the persisted working selection if set, * else the active template's assignment. Never returns undefined. Returns a deep copy so callers * can't mutate stored state or the shared in-memory built-in template registry. */ getActiveAssignment(): ModelAssignment { const stored = this.getSettings().activeAssignment; const assignment = stored ?? this.getActiveModelTemplate().assignment; return JSON.parse(JSON.stringify(assignment)) as ModelAssignment; } setActiveAssignment(a: ModelAssignment): void { this.setSetting('activeAssignment', a); this.emitModelConfigChanged(); } // Permission mode and per-command overrides getPermissionMode(): PermissionMode { return this.getSettings().permissionMode ?? 'ask'; } setPermissionMode(mode: PermissionMode): void { this.setSetting('permissionMode', mode); } getPermissionOverrides(): Record { return this.getSettings().permissionOverrides ?? {}; } setPermissionOverride(key: string, decision: GateDecision): void { const next = { ...this.getPermissionOverrides(), [key]: decision }; this.setSetting('permissionOverrides', next); } setPermissionOverrides(map: Record): void { this.setSetting('permissionOverrides', map); } // Web search provider configuration getWebSearchProvider(): 'tavily' | 'firecrawl' | 'brave' | 'searxng' | null { return this.getSettings().webSearch?.provider ?? null; } setWebSearchProvider(p: 'tavily' | 'firecrawl' | 'brave' | 'searxng' | null): void { const ws = { ...this.getSettings().webSearch }; if (p === null) { delete ws.provider; } else { ws.provider = p; } this.setSetting('webSearch', ws); } getWebSearchKey(p: 'tavily' | 'firecrawl' | 'brave'): string | null { return this.getSettings().webSearch?.keys?.[p] ?? null; } setWebSearchKey(p: 'tavily' | 'firecrawl' | 'brave', key: string): void { const ws = this.getSettings().webSearch ?? {}; this.setSetting('webSearch', { ...ws, keys: { ...ws.keys, [p]: key } }); } getSearxngUrl(): string | null { return this.getSettings().webSearch?.searxngUrl ?? null; } setSearxngUrl(url: string): void { this.setSetting('webSearch', { ...this.getSettings().webSearch, searxngUrl: url }); } isWebSearchConfigured(): boolean { const ws = this.getSettings().webSearch; if (!ws?.provider) return false; if (ws.provider === 'searxng') return !!ws.searxngUrl; return !!ws.keys?.[ws.provider]; } getCollapsedModelGroups(): string[] { return this.getSettings().modelPickerCollapsed ?? []; } setModelGroupCollapsed(groupId: string, collapsed: boolean): void { const set = new Set(this.getCollapsedModelGroups()); if (collapsed) set.add(groupId); else set.delete(groupId); this.setSetting('modelPickerCollapsed', [...set]); } // Custom provider management getCustomProviders(): Record { return getCustomProviders(); } setCustomProviders(providers: Record): void { setCustomProviders(providers); } saveCustomProvider(id: string, config: ProviderConfig): void { saveCustomProviderConfig(id, config); if (typeof window !== 'undefined') { import('@/lib/vfs/auto-sync').then(({ autoSyncConnection }) => autoSyncConnection(config)).catch((e) => logger.debug('[ConfigManager] connection auto-sync trigger failed', e)); } } removeCustomProvider(id: string): void { removeCustomProviderConfig(id); if (typeof window !== 'undefined') { import('@/lib/vfs/auto-sync').then(({ autoDeleteConnection }) => autoDeleteConnection(id)).catch((e) => logger.debug('[ConfigManager] connection auto-delete trigger failed', e)); } } generateCustomProviderId(base: string): string { return generateCustomProviderIdHelper(base); } buildCustomProviderConfig( id: string, name: string, baseUrl: string, apiKeyRequired: boolean ): ProviderConfig { return buildCustomProviderConfigHelper(id, name, baseUrl, apiKeyRequired); } } export const configManager = new ConfigManager(); /** * Get the login URL — points to the external auth provider if configured, otherwise the local login page. */ export function getLoginUrl(): string { return process.env.NEXT_PUBLIC_GATEWAY_URL ? `${process.env.NEXT_PUBLIC_GATEWAY_URL}/login` : '/admin/login'; } /** * Migrate legacy 'osw-server-features-{id}' localStorage key to 'osw-backend-{id}' * Returns the current backend enabled state for the project. */ export function migrateBackendKey(projectId: string): boolean { if (typeof window === 'undefined') return true; const legacyKey = `osw-server-features-${projectId}`; const newKey = `osw-backend-${projectId}`; if (localStorage.getItem(legacyKey) && !localStorage.getItem(newKey)) { localStorage.setItem(newKey, localStorage.getItem(legacyKey)!); localStorage.removeItem(legacyKey); } return localStorage.getItem(newKey) !== 'false'; }