From 925c995274721005a3fbf4126166440e0e388f63 Mon Sep 17 00:00:00 2001 From: Richard R Date: Sat, 6 Jun 2026 15:25:10 -0600 Subject: [PATCH 1/6] refactor(config): centralize provider default resolution and simplify user preference inheritance Move provider normalization logic into a dedicated module to ensure consistent handling of provider defaults and user preference inheritance across client and server. Update layout components to mount ConfigProvider only at the shared layout level, preventing unnecessary remounts and hydration issues during navigation. Adjust app config defaults so user provider settings are empty by default, always inheriting the admin-configured provider unless explicitly set. Add tests for normalization and sync logic to ensure robust provider resolution. --- src/app/(app)/app/layout.tsx | 11 +- src/app/(app)/epub/[id]/layout.tsx | 11 +- src/app/(app)/html/[id]/layout.tsx | 7 +- src/app/(app)/layout.tsx | 15 +- src/app/(app)/pdf/[id]/layout.tsx | 7 +- src/app/api/user/state/preferences/route.ts | 147 +--------------- src/components/SettingsModal.tsx | 27 ++- src/contexts/ConfigContext.tsx | 128 +++++++------- src/lib/client/api/user-state.ts | 26 +++ src/lib/server/admin/settings.ts | 18 +- src/lib/server/user/preferences-normalize.ts | 161 ++++++++++++++++++ src/types/config.ts | 58 ++----- .../unit/tts-provider-catalog.vitest.spec.ts | 6 +- .../user-preferences-normalize.vitest.spec.ts | 99 +++++++++++ .../unit/user-preferences-sync.vitest.spec.ts | 81 +++++++++ 15 files changed, 523 insertions(+), 279 deletions(-) create mode 100644 src/lib/server/user/preferences-normalize.ts create mode 100644 tests/unit/user-preferences-normalize.vitest.spec.ts create mode 100644 tests/unit/user-preferences-sync.vitest.spec.ts diff --git a/src/app/(app)/app/layout.tsx b/src/app/(app)/app/layout.tsx index 947ab4f..3691ee2 100644 --- a/src/app/(app)/app/layout.tsx +++ b/src/app/(app)/app/layout.tsx @@ -2,16 +2,15 @@ import type { ReactNode } from 'react'; -import { ConfigProvider } from '@/contexts/ConfigContext'; import { DocumentProvider } from '@/contexts/DocumentContext'; import { OnboardingFlowProvider } from '@/contexts/OnboardingFlowContext'; +// ConfigProvider is mounted once in the shared (app) layout so it survives +// library <-> reader navigation; do not re-wrap it here. export default function AppHomeLayout({ children }: { children: ReactNode }) { return ( - - - {children} - - + + {children} + ); } diff --git a/src/app/(app)/epub/[id]/layout.tsx b/src/app/(app)/epub/[id]/layout.tsx index 2f41494..f0b39fa 100644 --- a/src/app/(app)/epub/[id]/layout.tsx +++ b/src/app/(app)/epub/[id]/layout.tsx @@ -2,15 +2,14 @@ import type { ReactNode } from 'react'; -import { ConfigProvider } from '@/contexts/ConfigContext'; import { TTSProvider } from '@/contexts/TTSContext'; +// ConfigProvider is mounted once in the shared (app) layout so it survives +// library <-> reader navigation; do not re-wrap it here. export default function EpubReaderLayout({ children }: { children: ReactNode }) { return ( - - - {children} - - + + {children} + ); } diff --git a/src/app/(app)/html/[id]/layout.tsx b/src/app/(app)/html/[id]/layout.tsx index 3cadc04..9401b15 100644 --- a/src/app/(app)/html/[id]/layout.tsx +++ b/src/app/(app)/html/[id]/layout.tsx @@ -2,13 +2,12 @@ import type { ReactNode } from 'react'; -import { ConfigProvider } from '@/contexts/ConfigContext'; import { TTSProvider } from '@/contexts/TTSContext'; +// ConfigProvider is mounted once in the shared (app) layout so it survives +// library <-> reader navigation; do not re-wrap it here. export default function HtmlReaderLayout({ children }: { children: ReactNode }) { return ( - - {children} - + {children} ); } diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx index c048fbf..ebb1972 100644 --- a/src/app/(app)/layout.tsx +++ b/src/app/(app)/layout.tsx @@ -3,6 +3,7 @@ import type { ReactNode } from 'react'; import { Toaster } from 'react-hot-toast'; import { Providers } from '@/app/providers'; +import { ConfigProvider } from '@/contexts/ConfigContext'; import { AppMain, AppShell } from '@/components/layout'; import { getAuthBaseUrl, isAnonymousAuthSessionsEnabled, isGithubAuthEnabled } from '@/lib/server/auth/config'; @@ -33,9 +34,17 @@ export default function AppLayout({ children }: { children: ReactNode }) { allowAnonymousAuthSessions={allowAnonymousAuthSessions} githubAuthEnabled={githubAuthEnabled} > - - {children} - + {/* ConfigProvider lives here, in the shared (app) layout, so it stays + mounted across library <-> reader navigation. Mounting it per-route + re-ran the Dexie/server hydration race on every navigation, causing + the reader to briefly use the admin-default provider until a full + page refresh. A single shared instance keeps the user's saved + provider hydrated the whole time. */} + + + {children} + + reader navigation; do not re-wrap it here. export default function PdfReaderLayout({ children }: { children: ReactNode }) { return ( - - {children} - + {children} ); } diff --git a/src/app/api/user/state/preferences/route.ts b/src/app/api/user/state/preferences/route.ts index 78f38ee..9eeae34 100644 --- a/src/app/api/user/state/preferences/route.ts +++ b/src/app/api/user/state/preferences/route.ts @@ -2,15 +2,17 @@ import { NextRequest, NextResponse } from 'next/server'; import { eq } from 'drizzle-orm'; import { db } from '@/db'; import { userPreferences } from '@/db/schema'; -import { SYNCED_PREFERENCE_KEYS, type SyncedPreferencesPatch } from '@/types/user-state'; +import { type SyncedPreferencesPatch } from '@/types/user-state'; import { resolveUserStateScope } from '@/lib/server/user/resolve-state-scope'; import { coerceTimestampMs, nowTimestampMs } from '@/lib/shared/timestamps'; -import { isTtsProviderType, type TtsProviderId } from '@/lib/shared/tts-provider-catalog'; import { listAdminProviders } from '@/lib/server/admin/providers'; import { getResolvedRuntimeConfig } from '@/lib/server/runtime-config'; -import { normalizeLegacyProviderRef, resolveProviderDefaults } from '@/lib/shared/tts-provider-policy'; import { errorToLog, serverLogger } from '@/lib/server/logger'; import { errorResponse } from '@/lib/server/errors/next-response'; +import { + sanitizePreferencesPatch, + type PreferenceNormalizationContext, +} from '@/lib/server/user/preferences-normalize'; import { deserializeUserPreferencesPayload, extractUserPreferencesMeta, @@ -25,29 +27,14 @@ function serializePreferencesForDb(payload: Record): Record { - return typeof value === 'object' && value !== null; -} - -interface PreferenceNormalizationContext { - defaultProviderRef: string; - showAllProviderModels: boolean; - sharedProviders: Array<{ - slug: string; - providerType: TtsProviderId; - defaultModel: string | null; - defaultInstructions: string | null; - }>; -} - async function loadPreferenceNormalizationContext(): Promise { const [runtimeConfig, providers] = await Promise.all([ getResolvedRuntimeConfig(), listAdminProviders(), ]); return { - defaultProviderRef: runtimeConfig.defaultTtsProvider, showAllProviderModels: runtimeConfig.showAllProviderModels, + restrictUserApiKeys: runtimeConfig.restrictUserApiKeys, sharedProviders: providers .filter((entry) => entry.enabled) .map((entry) => ({ @@ -69,128 +56,6 @@ function parseStoredPreferences( return { ...sanitized, meta }; } -function sanitizeSavedVoices(value: unknown): Record { - if (!isRecord(value)) return {}; - const out: Record = {}; - for (const [key, val] of Object.entries(value)) { - if (typeof key !== 'string' || key.length === 0) continue; - if (typeof val !== 'string') continue; - out[key] = val; - } - return out; -} - -function sanitizePreferencesPatch( - input: unknown, - context: PreferenceNormalizationContext, - options: { fillMissingProvider: boolean }, -): { patch: SyncedPreferencesPatch; migrated: boolean } { - if (!isRecord(input)) return { patch: {}, migrated: false }; - - const rec = input as Record; - const out: SyncedPreferencesPatch = {}; - let migrated = false; - - const legacyProviderRef = typeof rec.ttsProvider === 'string' - ? rec.ttsProvider - : typeof rec.provider === 'string' - ? rec.provider - : ''; - const rawProviderRef = typeof rec.providerRef === 'string' ? rec.providerRef : legacyProviderRef; - const normalizedProviderRef = normalizeLegacyProviderRef(rawProviderRef, context.defaultProviderRef); - const providerDefaults = resolveProviderDefaults({ - providerRef: normalizedProviderRef || context.defaultProviderRef, - providerType: isTtsProviderType(rec.providerType) ? rec.providerType : 'unknown', - sharedProviders: context.sharedProviders, - fallbackProviderRef: context.defaultProviderRef, - }); - const hasLegacyProviderKey = typeof rec.ttsProvider === 'string' || typeof rec.provider === 'string'; - if (hasLegacyProviderKey || rawProviderRef !== providerDefaults.providerRef) migrated = true; - - for (const key of SYNCED_PREFERENCE_KEYS) { - if (!(key in rec)) continue; - const value = rec[key]; - - switch (key) { - case 'viewType': - if (value === 'single' || value === 'dual' || value === 'scroll') out[key] = value; - break; - case 'voice': - case 'ttsModel': - case 'ttsInstructions': - if (typeof value === 'string') out[key] = value; - break; - case 'providerRef': - out[key] = providerDefaults.providerRef; - break; - case 'providerType': - out[key] = providerDefaults.providerType; - break; - case 'voiceSpeed': - case 'audioPlayerSpeed': - case 'segmentPreloadDepthPages': - case 'segmentPreloadSentenceLookahead': - case 'ttsSegmentMaxBlockLength': - case 'headerMargin': - case 'footerMargin': - case 'leftMargin': - case 'rightMargin': - if (Number.isFinite(value)) out[key] = Number(value); - break; - case 'skipBlank': - case 'epubTheme': - case 'pdfHighlightEnabled': - case 'pdfWordHighlightEnabled': - case 'epubHighlightEnabled': - case 'epubWordHighlightEnabled': - case 'htmlHighlightEnabled': - case 'htmlWordHighlightEnabled': - if (typeof value === 'boolean') out[key] = value; - break; - case 'savedVoices': - out[key] = sanitizeSavedVoices(value); - break; - default: - break; - } - } - - if ('providerRef' in out && !('providerType' in out)) { - out.providerType = providerDefaults.providerType; - migrated = true; - } - - if (options.fillMissingProvider && !('providerRef' in out)) { - out.providerRef = providerDefaults.providerRef; - migrated = true; - } - if (options.fillMissingProvider && !('providerType' in out)) { - out.providerType = providerDefaults.providerType; - migrated = true; - } - - const rawModel = typeof rec.ttsModel === 'string' ? rec.ttsModel.trim() : ''; - const shouldNormalizeSharedDefaultModel = - !!providerDefaults.defaultModel - && context.sharedProviders.some((entry) => entry.slug === providerDefaults.providerRef) - && (rawModel.length === 0 || rawModel === 'kokoro') - && (hasLegacyProviderKey || rawProviderRef === 'default-openai'); - - if (options.fillMissingProvider && !('ttsModel' in out) && providerDefaults.defaultModel) { - out.ttsModel = providerDefaults.defaultModel; - migrated = true; - } else if (shouldNormalizeSharedDefaultModel && out.ttsModel !== providerDefaults.defaultModel) { - out.ttsModel = providerDefaults.defaultModel; - migrated = true; - } - if (!context.showAllProviderModels && providerDefaults.defaultModel && out.ttsModel !== providerDefaults.defaultModel) { - out.ttsModel = providerDefaults.defaultModel; - migrated = true; - } - - return { patch: out, migrated }; -} - function normalizeClientUpdatedAtMs(value: unknown): number { const normalized = coerceTimestampMs(value, nowTimestampMs()); if (normalized <= 0) return nowTimestampMs(); diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index b9465ea..d730dc8 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -37,6 +37,8 @@ import { useRuntimeConfig } from '@/contexts/RuntimeConfigContext'; import { AdminProvidersPanel } from '@/components/admin/AdminProvidersPanel'; import { AdminFeaturesPanel } from '@/components/admin/AdminFeaturesPanel'; import { useSharedProviders } from '@/hooks/useSharedProviders'; +import { flushUserPreferencesSync } from '@/lib/client/api/user-state'; +import toast from 'react-hot-toast'; import { useLibraryDocumentsQuery } from '@/hooks/useLibraryDocumentsQuery'; import { SidebarNav, @@ -265,7 +267,7 @@ export function SettingsModal({ prefetch: prefetchLibraryDocuments, } = useLibraryDocumentsQuery(isSelectionModalOpen); - const { providers: sharedProviders } = useSharedProviders(); + const { providers: sharedProviders, isLoading: sharedProvidersLoading } = useSharedProviders(); const { providers: ttsProviders, models: ttsModels, @@ -295,13 +297,18 @@ export function SettingsModal({ }, [changelogOpenSignal, setIsOpen]); useEffect(() => { + // Only mirror persisted config into the local form while the modal is closed. + // While it's open the user may be mid-edit, and a background refresh (e.g. the + // focus refetch in ConfigContext, or async shared-provider loading) must not + // stomp their in-progress selection. On close, resetToCurrent re-syncs. + if (isOpen) return; setLocalApiKey(apiKey); setLocalBaseUrl(baseUrl); setLocalProviderRef(providerRef); setLocalProviderType(providerType); setModelValue(ttsModel); setLocalTTSInstructions(ttsInstructions); - }, [apiKey, baseUrl, providerRef, providerType, ttsModel, ttsInstructions]); + }, [isOpen, apiKey, baseUrl, providerRef, providerType, ttsModel, ttsInstructions]); useEffect(() => { if (!ttsModels.some(m => m.id === modelValue) && modelValue !== '') { @@ -643,7 +650,9 @@ export function SettingsModal({
- {ttsProviders.length === 0 ? ( + {sharedProvidersLoading ? ( +

Loading providers…

+ ) : ttsProviders.length === 0 ? (

User API keys are restricted and no shared provider is configured. Ask an admin to add one.

@@ -815,7 +824,7 @@ export function SettingsModal({ type="button" variant="primary" size="md" - disabled={!canSubmit} + disabled={!canSubmit || sharedProvidersLoading} onClick={async () => { const defaults = resolveProviderDefaults({ providerRef: selectedProviderRef, @@ -833,6 +842,16 @@ export function SettingsModal({ : defaults.defaultModel; await updateConfigKey('ttsModel', finalModel); await updateConfigKey('ttsInstructions', localTTSInstructions); + // Push the change to the server immediately rather than waiting on + // the debounce, so a quick reload can't lose the save. Surface + // failures instead of silently dropping them. + try { + await flushUserPreferencesSync(); + } catch (error) { + console.error('Failed to save TTS provider settings:', error); + toast.error('Failed to save provider settings. Please try again.'); + return; + } setIsOpen(false); }} > diff --git a/src/contexts/ConfigContext.tsx b/src/contexts/ConfigContext.tsx index b6a6c74..cf8ce0a 100644 --- a/src/contexts/ConfigContext.tsx +++ b/src/contexts/ConfigContext.tsx @@ -5,11 +5,11 @@ import { useLiveQuery } from 'dexie-react-hooks'; import { db, initDB, migrateLegacyDexieDocumentIdsToSha, updateAppConfig } from '@/lib/client/dexie'; import { APP_CONFIG_DEFAULTS, type ViewType, type SavedVoices, type AppConfigValues, type AppConfigRow } from '@/types/config'; import { isBuiltInTtsProviderId } from '@/lib/shared/tts-provider-catalog'; -import { resolveEffectiveProviderType, resolveProviderDefaults } from '@/lib/shared/tts-provider-policy'; +import { resolveProviderDefaults } from '@/lib/shared/tts-provider-policy'; import { scheduleUserPreferencesSync, cancelPendingPreferenceSync, getUserPreferences, putUserPreferences } from '@/lib/client/api/user-state'; import { SYNCED_PREFERENCE_KEYS, type SyncedPreferenceKey, type SyncedPreferencesPatch } from '@/types/user-state'; import { useAuthSession } from '@/hooks/useAuthSession'; -import { useFeatureFlag } from '@/contexts/RuntimeConfigContext'; +import { useFeatureFlag, useRuntimeConfig } from '@/contexts/RuntimeConfigContext'; import { buildSyncedPreferencePatch } from '@/lib/client/config/preferences'; import { applyConfigUpdate } from '@/lib/client/config/updates'; import { useSharedProviders } from '@/hooks/useSharedProviders'; @@ -69,16 +69,12 @@ export function ConfigProvider({ children }: { children: ReactNode }) { const didRunStartupMigrations = useRef(false); const didAttemptInitialPreferenceSeedForSession = useRef(null); const syncedPreferenceKeys = useMemo(() => new Set(SYNCED_PREFERENCE_KEYS), []); - const { providers: sharedProviders } = useSharedProviders(); + const { providers: sharedProviders, isLoading: sharedProvidersLoading } = useSharedProviders(); const { data: sessionData, isPending: isSessionPending } = useAuthSession(); const sessionKey = sessionData?.user?.id ?? 'no-session'; - const providerResetDefaults = useMemo(() => { - return resolveProviderDefaults({ - providerRef: APP_CONFIG_DEFAULTS.providerRef, - providerType: APP_CONFIG_DEFAULTS.providerType, - sharedProviders, - }); - }, [sharedProviders]); + // The instance/admin default provider. An empty user providerRef "inherits" + // this, resolved (admin slug -> concrete provider) where the value is used. + const adminDefaultProviderRef = useRuntimeConfig().defaultTtsProvider; const queueSyncedPreferencePatch = useCallback((patch: Partial) => { if (sessionKey === 'no-session') return; @@ -213,28 +209,18 @@ export function ConfigProvider({ children }: { children: ReactNode }) { }, [appConfig]); useEffect(() => { - if (!ttsProvidersTabDisabled || !isDBReady || !appConfig) return; + if (!ttsProvidersTabDisabled || !isDBReady || !appConfig || sharedProvidersLoading) return; const resetPatch: Partial = {}; - if (appConfig.apiKey !== APP_CONFIG_DEFAULTS.apiKey) { - resetPatch.apiKey = APP_CONFIG_DEFAULTS.apiKey; - } - if (appConfig.baseUrl !== APP_CONFIG_DEFAULTS.baseUrl) { - resetPatch.baseUrl = APP_CONFIG_DEFAULTS.baseUrl; - } - if (appConfig.providerRef !== providerResetDefaults.providerRef) { - resetPatch.providerRef = providerResetDefaults.providerRef; - } - if (appConfig.providerType !== providerResetDefaults.providerType) { - resetPatch.providerType = providerResetDefaults.providerType; - } - if (appConfig.ttsModel !== providerResetDefaults.defaultModel) { - resetPatch.ttsModel = providerResetDefaults.defaultModel; - } - if (appConfig.ttsInstructions !== providerResetDefaults.defaultInstructions) { - resetPatch.ttsInstructions = providerResetDefaults.defaultInstructions; - } + // When the provider tab is hidden, clear any user-set provider config back to + // "inherit the admin default" (empty) rather than baking in a concrete value. + if (appConfig.apiKey !== '') resetPatch.apiKey = ''; + if (appConfig.baseUrl !== '') resetPatch.baseUrl = ''; + if (appConfig.providerRef !== '') resetPatch.providerRef = ''; + if (appConfig.providerType !== 'unknown') resetPatch.providerType = 'unknown'; + if (appConfig.ttsModel !== '') resetPatch.ttsModel = ''; + if (appConfig.ttsInstructions !== '') resetPatch.ttsInstructions = ''; // Keep voice selection state intact so player/Audiobook voice pickers still // work when the TTS providers tab is hidden. This reset is only for provider // configuration fields. @@ -245,32 +231,24 @@ export function ConfigProvider({ children }: { children: ReactNode }) { console.warn('Failed to clear hidden TTS provider settings:', error); }); queueSyncedPreferencePatch(resetPatch); - }, [ttsProvidersTabDisabled, isDBReady, appConfig, queueSyncedPreferencePatch, providerResetDefaults]); + }, [ttsProvidersTabDisabled, isDBReady, appConfig, queueSyncedPreferencePatch, sharedProvidersLoading]); useEffect(() => { - if (!restrictUserApiKeys || !isDBReady || !appConfig) return; + if (!restrictUserApiKeys || !isDBReady || !appConfig || sharedProvidersLoading) return; const resetPatch: Partial = {}; - if (appConfig.apiKey !== APP_CONFIG_DEFAULTS.apiKey) { - resetPatch.apiKey = APP_CONFIG_DEFAULTS.apiKey; - } - if (appConfig.baseUrl !== APP_CONFIG_DEFAULTS.baseUrl) { - resetPatch.baseUrl = APP_CONFIG_DEFAULTS.baseUrl; - } + if (appConfig.apiKey !== '') resetPatch.apiKey = ''; + if (appConfig.baseUrl !== '') resetPatch.baseUrl = ''; + // Built-in providers aren't selectable in restricted mode. Clear any stale + // built-in selection (including the old 'custom-openai' default that used to + // be baked into every config) back to "inherit the admin default" so the + // user follows whatever shared provider the admin has configured. if (isBuiltInTtsProviderId(appConfig.providerRef)) { - if (appConfig.providerRef !== providerResetDefaults.providerRef) { - resetPatch.providerRef = providerResetDefaults.providerRef; - } - if (appConfig.providerType !== providerResetDefaults.providerType) { - resetPatch.providerType = providerResetDefaults.providerType; - } - if (appConfig.ttsModel !== providerResetDefaults.defaultModel) { - resetPatch.ttsModel = providerResetDefaults.defaultModel; - } - if (appConfig.ttsInstructions !== providerResetDefaults.defaultInstructions) { - resetPatch.ttsInstructions = providerResetDefaults.defaultInstructions; - } + resetPatch.providerRef = ''; + resetPatch.providerType = 'unknown'; + resetPatch.ttsModel = ''; + resetPatch.ttsInstructions = ''; } if (Object.keys(resetPatch).length === 0) return; @@ -279,15 +257,18 @@ export function ConfigProvider({ children }: { children: ReactNode }) { console.warn('Failed to enforce restricted user API key mode:', error); }); queueSyncedPreferencePatch(resetPatch); - }, [restrictUserApiKeys, isDBReady, appConfig, queueSyncedPreferencePatch, providerResetDefaults]); + }, [restrictUserApiKeys, isDBReady, appConfig, queueSyncedPreferencePatch, sharedProvidersLoading]); useEffect(() => { - if (showAllProviderModels || !isDBReady || !appConfig) return; + if (showAllProviderModels || !isDBReady || !appConfig || sharedProvidersLoading) return; + // Inheriting (empty providerRef): the effective model is resolved at read + // time, so there is nothing to persist/enforce here. + if (!appConfig.providerRef) return; const providerDefaults = resolveProviderDefaults({ providerRef: appConfig.providerRef, providerType: appConfig.providerType, sharedProviders, - fallbackProviderRef: providerResetDefaults.providerRef, + fallbackProviderRef: adminDefaultProviderRef, }); if (!providerDefaults.defaultModel) return; if (appConfig.ttsModel === providerDefaults.defaultModel) return; @@ -296,7 +277,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) { console.warn('Failed to enforce provider default model restriction:', error); }); queueSyncedPreferencePatch(patch); - }, [showAllProviderModels, isDBReady, appConfig, sharedProviders, providerResetDefaults.providerRef, queueSyncedPreferencePatch]); + }, [showAllProviderModels, isDBReady, appConfig, sharedProviders, adminDefaultProviderRef, queueSyncedPreferencePatch, sharedProvidersLoading]); useEffect(() => { if (!isDBReady || !appConfig || isSessionPending) return; @@ -359,33 +340,48 @@ export function ConfigProvider({ children }: { children: ReactNode }) { htmlHighlightEnabled, htmlWordHighlightEnabled, } = config || APP_CONFIG_DEFAULTS; - const providerType = useMemo( - () => resolveEffectiveProviderType({ + // Resolve the effective provider for consumers. An empty stored providerRef + // means "inherit the admin default", which we resolve here so the reader, + // voice pickers, and settings UI all see a concrete, usable provider without + // mutating the stored ("inherit") value. + const effectiveProvider = useMemo( + () => resolveProviderDefaults({ providerRef, providerType: _persistedProviderType, sharedProviders, + fallbackProviderRef: adminDefaultProviderRef, }), - [providerRef, _persistedProviderType, sharedProviders], + [providerRef, _persistedProviderType, sharedProviders, adminDefaultProviderRef], ); + const effectiveProviderRef = effectiveProvider.providerRef; + const providerType = effectiveProvider.providerType; + const effectiveTtsModel = ttsModel || effectiveProvider.defaultModel; + const effectiveTtsInstructions = ttsInstructions || effectiveProvider.defaultInstructions; useEffect(() => { - if (!isDBReady || !appConfig) return; + if (!isDBReady || !appConfig || sharedProvidersLoading) return; + // Only persist a resolved providerType for an explicitly chosen provider. + // While inheriting (empty providerRef) the type stays unset in storage. + if (!appConfig.providerRef) return; if (appConfig.providerType === providerType) return; const patch: Partial = { providerType }; updateAppConfig(patch).catch((error) => { console.warn('Failed to persist resolved providerType:', error); }); queueSyncedPreferencePatch(patch); - }, [isDBReady, appConfig, providerType, queueSyncedPreferencePatch]); + }, [isDBReady, appConfig, providerType, queueSyncedPreferencePatch, sharedProvidersLoading]); void _persistedProviderType; useEffect(() => { - if (!isDBReady || !appConfig) return; + if (!isDBReady || !appConfig || sharedProvidersLoading) return; + // Inheriting (empty providerRef): the effective model is resolved at read + // time; don't write a concrete model into the "inherit" state. + if (!appConfig.providerRef) return; const providerDefaults = resolveProviderDefaults({ providerRef: appConfig.providerRef, providerType: appConfig.providerType, sharedProviders, - fallbackProviderRef: providerResetDefaults.providerRef, + fallbackProviderRef: adminDefaultProviderRef, }); if (!providerDefaults.defaultModel) return; if (appConfig.ttsModel === providerDefaults.defaultModel) return; @@ -398,7 +394,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) { console.warn('Failed to normalize shared-provider default model:', error); }); queueSyncedPreferencePatch(patch); - }, [isDBReady, appConfig, sharedProviders, queueSyncedPreferencePatch, providerResetDefaults.providerRef]); + }, [isDBReady, appConfig, sharedProviders, queueSyncedPreferencePatch, adminDefaultProviderRef, sharedProvidersLoading]); /** * Updates multiple configuration values simultaneously @@ -436,9 +432,9 @@ export function ConfigProvider({ children }: { children: ReactNode }) { try { setIsLoading(true); const { storagePatch, syncPatch } = applyConfigUpdate({ - providerRef, + providerRef: effectiveProviderRef, providerType, - ttsModel, + ttsModel: effectiveTtsModel, savedVoices, }, key, value); @@ -478,10 +474,10 @@ export function ConfigProvider({ children }: { children: ReactNode }) { footerMargin, leftMargin, rightMargin, - providerRef, + providerRef: effectiveProviderRef, providerType, - ttsModel, - ttsInstructions, + ttsModel: effectiveTtsModel, + ttsInstructions: effectiveTtsInstructions, savedVoices, updateConfig, updateConfigKey, diff --git a/src/lib/client/api/user-state.ts b/src/lib/client/api/user-state.ts index a3d0948..d9e8cf1 100644 --- a/src/lib/client/api/user-state.ts +++ b/src/lib/client/api/user-state.ts @@ -148,6 +148,32 @@ export function scheduleUserPreferencesSync( }, debounceMs); } +/** + * Immediately send any pending debounced preference patch and await the result. + * Use this when the user explicitly saves: we want the server write to complete + * (and surface failures) instead of silently relying on the debounce timer, which + * can be lost if the page is reloaded inside the debounce window. + */ +export async function flushUserPreferencesSync(): Promise { + if (pendingPreferenceSync.timer) { + clearTimeout(pendingPreferenceSync.timer); + pendingPreferenceSync.timer = null; + } + + const payload = { ...pendingPreferenceSync.patch }; + pendingPreferenceSync.patch = {}; + if (Object.keys(payload).length === 0) return; + + if (activeSyncController) activeSyncController.abort(); + activeSyncController = new AbortController(); + + try { + await putUserPreferences(payload, { signal: activeSyncController.signal }); + } finally { + activeSyncController = null; + } +} + export async function getDocumentProgress( documentId: string, options?: { signal?: AbortSignal }, diff --git a/src/lib/server/admin/settings.ts b/src/lib/server/admin/settings.ts index 5a87363..9f65e4a 100644 --- a/src/lib/server/admin/settings.ts +++ b/src/lib/server/admin/settings.ts @@ -1,4 +1,4 @@ -import { and, eq } from 'drizzle-orm'; +import { asc, desc, eq } from 'drizzle-orm'; import { db } from '@/db'; import { adminProviders, adminSettings } from '@/db/schema'; import { serverLogger } from '@/lib/server/logger'; @@ -116,9 +116,19 @@ async function resolveImplicitDefaultTtsProvider(): Promise const rows = await db .select({ slug: adminProviders.slug }) .from(adminProviders) - .where(and(eq(adminProviders.slug, 'default-openai'), eq(adminProviders.enabled, 1))) - .limit(1); - return rows[0]?.slug; + .where(eq(adminProviders.enabled, 1)) + .orderBy( + desc(adminProviders.updatedAt), + desc(adminProviders.createdAt), + asc(adminProviders.slug), + ); + const slugs = (rows as Array<{ slug: string }>).map((row) => row.slug); + // Prefer the conventional 'default-openai' slug when present, otherwise fall + // back to the first enabled shared provider so a fresh instance with any + // configured provider resolves to a real, usable provider rather than the + // built-in 'custom-openai' placeholder. + if (slugs.includes('default-openai')) return 'default-openai'; + return slugs[0]; } catch (error) { logDegraded(serverLogger, { event: 'admin.runtime_config.default_provider_lookup.failed', diff --git a/src/lib/server/user/preferences-normalize.ts b/src/lib/server/user/preferences-normalize.ts new file mode 100644 index 0000000..4555330 --- /dev/null +++ b/src/lib/server/user/preferences-normalize.ts @@ -0,0 +1,161 @@ +import { SYNCED_PREFERENCE_KEYS, type SyncedPreferencesPatch } from '@/types/user-state'; +import { isBuiltInTtsProviderId, isTtsProviderType, type TtsProviderId } from '@/lib/shared/tts-provider-catalog'; +import { resolveProviderDefaults } from '@/lib/shared/tts-provider-policy'; + +export interface PreferenceNormalizationContext { + showAllProviderModels: boolean; + restrictUserApiKeys: boolean; + sharedProviders: Array<{ + slug: string; + providerType: TtsProviderId; + defaultModel: string | null; + defaultInstructions: string | null; + }>; +} + +export function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +export function sanitizeSavedVoices(value: unknown): Record { + if (!isRecord(value)) return {}; + const out: Record = {}; + for (const [key, val] of Object.entries(value)) { + if (typeof key !== 'string' || key.length === 0) continue; + if (typeof val !== 'string') continue; + out[key] = val; + } + return out; +} + +/** + * Normalize a stored/incoming preferences blob into a clean synced-preferences + * patch. + * + * Provider handling follows an "inherit the admin default" model: an empty + * `providerRef` means the user has made no explicit choice and should follow the + * instance default. We deliberately preserve empty rather than collapsing it to + * a concrete provider, so inheriting users track whatever the admin configures. + * Built-in provider ids under restricted mode (and the legacy `default-openai` + * sentinel that isn't a real shared provider) are mapped back to inherit. + */ +export function sanitizePreferencesPatch( + input: unknown, + context: PreferenceNormalizationContext, + options: { fillMissingProvider: boolean }, +): { patch: SyncedPreferencesPatch; migrated: boolean } { + if (!isRecord(input)) return { patch: {}, migrated: false }; + + const rec = input as Record; + const out: SyncedPreferencesPatch = {}; + let migrated = false; + + const legacyProviderRef = typeof rec.ttsProvider === 'string' + ? rec.ttsProvider + : typeof rec.provider === 'string' + ? rec.provider + : ''; + const hasLegacyProviderKey = typeof rec.ttsProvider === 'string' || typeof rec.provider === 'string'; + const hasProviderRefKey = typeof rec.providerRef === 'string'; + const rawProviderRef = hasProviderRefKey ? (rec.providerRef as string) : legacyProviderRef; + + const sharedSlugs = new Set(context.sharedProviders.map((entry) => entry.slug)); + let providerRefIntent = typeof rawProviderRef === 'string' ? rawProviderRef.trim() : ''; + // Legacy 'default-openai' sentinel: only honored when it's a real configured + // shared provider; otherwise it historically meant "use the default" → inherit. + if (providerRefIntent === 'default-openai' && !sharedSlugs.has('default-openai')) { + providerRefIntent = ''; + } + // Built-in providers aren't selectable under restricted mode → inherit. This + // also migrates the old baked-in 'custom-openai' default off existing rows. + if (context.restrictUserApiKeys && isBuiltInTtsProviderId(providerRefIntent)) { + providerRefIntent = ''; + } + const providerRefIsExplicit = providerRefIntent.length > 0; + const providerDefaults = providerRefIsExplicit + ? resolveProviderDefaults({ + providerRef: providerRefIntent, + providerType: isTtsProviderType(rec.providerType) ? rec.providerType : 'unknown', + sharedProviders: context.sharedProviders, + }) + : null; + + if (hasLegacyProviderKey || (hasProviderRefKey && (rec.providerRef as string) !== providerRefIntent)) { + migrated = true; + } + + for (const key of SYNCED_PREFERENCE_KEYS) { + if (!(key in rec)) continue; + const value = rec[key]; + + switch (key) { + case 'viewType': + if (value === 'single' || value === 'dual' || value === 'scroll') out[key] = value; + break; + case 'voice': + case 'ttsInstructions': + if (typeof value === 'string') out[key] = value; + break; + // providerRef / providerType / ttsModel are resolved together below. + case 'providerRef': + case 'providerType': + case 'ttsModel': + break; + case 'voiceSpeed': + case 'audioPlayerSpeed': + case 'segmentPreloadDepthPages': + case 'segmentPreloadSentenceLookahead': + case 'ttsSegmentMaxBlockLength': + case 'headerMargin': + case 'footerMargin': + case 'leftMargin': + case 'rightMargin': + if (Number.isFinite(value)) out[key] = Number(value); + break; + case 'skipBlank': + case 'epubTheme': + case 'pdfHighlightEnabled': + case 'pdfWordHighlightEnabled': + case 'epubHighlightEnabled': + case 'epubWordHighlightEnabled': + case 'htmlHighlightEnabled': + case 'htmlWordHighlightEnabled': + if (typeof value === 'boolean') out[key] = value; + break; + case 'savedVoices': + out[key] = sanitizeSavedVoices(value); + break; + default: + break; + } + } + + // Persist concrete provider/type/model only for an explicit selection. An + // inheriting user keeps these empty so resolution happens against the live + // admin default at read/generation time. + const shouldWriteProvider = hasProviderRefKey || hasLegacyProviderKey || options.fillMissingProvider; + if (shouldWriteProvider) { + out.providerRef = providerRefIntent; + out.providerType = providerRefIsExplicit ? providerDefaults!.providerType : 'unknown'; + } + + if (providerRefIsExplicit) { + const rawModel = typeof rec.ttsModel === 'string' ? rec.ttsModel.trim() : ''; + const lockedToDefault = !context.showAllProviderModels && !!providerDefaults!.defaultModel; + const model = lockedToDefault + ? providerDefaults!.defaultModel + : (rawModel || providerDefaults!.defaultModel); + if (model) { + out.ttsModel = model; + if (model !== rawModel) migrated = true; + } else if (typeof rec.ttsModel === 'string') { + out.ttsModel = rec.ttsModel; + } + } else if (shouldWriteProvider || 'ttsModel' in rec) { + // Inheriting: the model inherits too. + if (typeof rec.ttsModel === 'string' && rec.ttsModel !== '') migrated = true; + out.ttsModel = ''; + } + + return { patch: out, migrated }; +} diff --git a/src/types/config.ts b/src/types/config.ts index 53db98c..711b634 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -1,21 +1,5 @@ import type { DocumentListState } from '@/types/documents'; -import { isBuiltInTtsProviderId, type TtsProviderType } from '@/lib/shared/tts-provider-catalog'; -import { defaultModelForProviderType } from '@/lib/shared/tts-provider-policy'; - -// Runtime config (admin-controlled) is layered on top of the static defaults -// below. We resolve it lazily so this module stays importable from non-React -// contexts (Dexie, server routes). The actual values come from -// `window.__RUNTIME_CONFIG__` (SSR-injected) on the client, and -// from the built-in defaults during SSR. - -function readRuntimeString(key: string, defaultValue: string): string { - if (typeof window === 'undefined') return defaultValue; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const injected = (window as any).__RUNTIME_CONFIG__; - if (!injected || typeof injected !== 'object') return defaultValue; - const value = injected[key]; - return typeof value === 'string' && value ? value : defaultValue; -} +import type { TtsProviderType } from '@/lib/shared/tts-provider-catalog'; export type ViewType = 'single' | 'dual' | 'scroll'; @@ -78,20 +62,18 @@ export interface AppConfigValues { } /** - * Build defaults lazily so we can read SSR-injected admin overrides - * (`window.__RUNTIME_CONFIG__`). Modules that need the defaults - * statically should call `getAppConfigDefaults()` at use time. The exported - * `APP_CONFIG_DEFAULTS` is a Proxy that re-resolves on each access so - * mutations to the runtime config (admin edits) are picked up by anything - * that reads through it. + * Build the static app-config defaults. These no longer read any SSR/admin + * runtime config: the user's TTS provider defaults to empty ("inherit the + * admin default"), which is resolved to a concrete provider where it is used + * (ConfigContext on the client, credential resolution on the server). */ export function getAppConfigDefaults(): AppConfigValues { - const runtimeProviderRef = readRuntimeString('defaultTtsProvider', 'custom-openai'); - const defaultProviderRef = runtimeProviderRef.trim(); - const defaultProviderType = isBuiltInTtsProviderId(defaultProviderRef) ? defaultProviderRef : 'unknown'; - const defaultModel = isBuiltInTtsProviderId(defaultProviderType) - ? defaultModelForProviderType(defaultProviderType) - : 'kokoro'; + // The user's TTS provider is intentionally left empty by default. An empty + // providerRef means "inherit the instance/admin default" and is resolved to a + // concrete provider at read time (see ConfigContext) and at generation time + // (server-side credential resolution). We no longer bake a placeholder + // provider id (the old 'custom-openai') into every user's config, since that + // value isn't actually selectable in shared-provider mode. return { apiKey: '', baseUrl: '', @@ -105,9 +87,9 @@ export function getAppConfigDefaults(): AppConfigValues { footerMargin: 0, leftMargin: 0, rightMargin: 0, - providerRef: defaultProviderRef, - providerType: defaultProviderType, - ttsModel: defaultModel, + providerRef: '', + providerType: 'unknown', + ttsModel: '', ttsInstructions: '', savedVoices: {}, segmentPreloadDepthPages: 1, @@ -134,17 +116,13 @@ export function getAppConfigDefaults(): AppConfigValues { } /** - * Static defaults snapshot resolved at first access. For callers that need - * fresh values after admin edits, prefer `getAppConfigDefaults()` directly. - * Most consumers just need a stable defaults object for spreads, so this is - * resolved once per process — admin overrides take effect on next page load - * (which is the SSR-injected behavior we want anyway). + * Static defaults snapshot, resolved once at first access. The values are + * constant (no SSR/admin overrides are read here anymore), so the lazy Proxy + * just avoids building the object until something first reads it. */ let cachedDefaults: AppConfigValues | null = null; export const APP_CONFIG_DEFAULTS: AppConfigValues = (() => { - // Return a getter-backed object that resolves on first access. On the - // server, this resolves to built-in defaults; on the client, to the - // SSR-injected admin values. + // Getter-backed object that builds the defaults lazily on first access. const handler: ProxyHandler = { get(_target, prop) { if (!cachedDefaults) cachedDefaults = getAppConfigDefaults(); diff --git a/tests/unit/tts-provider-catalog.vitest.spec.ts b/tests/unit/tts-provider-catalog.vitest.spec.ts index 8996617..acd5b94 100644 --- a/tests/unit/tts-provider-catalog.vitest.spec.ts +++ b/tests/unit/tts-provider-catalog.vitest.spec.ts @@ -476,9 +476,13 @@ describe('config helpers', () => { ttsModel: 'kokoro', }); + // The user's provider/model now default to empty ("inherit the admin + // default"), so empty values are treated as defaults and filtered out. expect(buildSyncedPreferencePatch({ voiceSpeed: 1, - ttsModel: 'kokoro', + providerRef: '', + providerType: 'unknown', + ttsModel: '', }, { nonDefaultOnly: true })).toEqual({}); }); }); diff --git a/tests/unit/user-preferences-normalize.vitest.spec.ts b/tests/unit/user-preferences-normalize.vitest.spec.ts new file mode 100644 index 0000000..16975ea --- /dev/null +++ b/tests/unit/user-preferences-normalize.vitest.spec.ts @@ -0,0 +1,99 @@ +import { describe, expect, test } from 'vitest'; + +import { + sanitizePreferencesPatch, + type PreferenceNormalizationContext, +} from '../../src/lib/server/user/preferences-normalize'; + +function makeContext(overrides: Partial = {}): PreferenceNormalizationContext { + return { + showAllProviderModels: true, + restrictUserApiKeys: true, + sharedProviders: [ + { slug: 'shared-a', providerType: 'openai', defaultModel: 'gpt-4o-mini-tts', defaultInstructions: 'hi' }, + { slug: 'shared-b', providerType: 'custom-openai', defaultModel: 'kokoro', defaultInstructions: null }, + ], + ...overrides, + }; +} + +describe('sanitizePreferencesPatch — inherit-by-default provider model', () => { + test('preserves an empty providerRef as "inherit" rather than collapsing to a concrete provider', () => { + const { patch, migrated } = sanitizePreferencesPatch( + { providerRef: '', voiceSpeed: 1.5 }, + makeContext(), + { fillMissingProvider: true }, + ); + expect(patch.providerRef).toBe(''); + expect(patch.providerType).toBe('unknown'); + expect(patch.ttsModel).toBe(''); + expect(patch.voiceSpeed).toBe(1.5); + expect(migrated).toBe(false); + }); + + test('migrates a stale built-in (custom-openai) selection to inherit under restricted mode', () => { + const { patch, migrated } = sanitizePreferencesPatch( + { providerRef: 'custom-openai', providerType: 'custom-openai', ttsModel: 'kokoro' }, + makeContext({ restrictUserApiKeys: true }), + { fillMissingProvider: true }, + ); + expect(patch.providerRef).toBe(''); + expect(patch.providerType).toBe('unknown'); + expect(patch.ttsModel).toBe(''); + expect(migrated).toBe(true); + }); + + test('keeps an explicit built-in selection when API keys are NOT restricted', () => { + const { patch } = sanitizePreferencesPatch( + { providerRef: 'openai', providerType: 'openai', ttsModel: 'tts-1' }, + makeContext({ restrictUserApiKeys: false }), + { fillMissingProvider: false }, + ); + expect(patch.providerRef).toBe('openai'); + expect(patch.providerType).toBe('openai'); + expect(patch.ttsModel).toBe('tts-1'); + }); + + test('preserves an explicit shared-provider selection and resolves its type', () => { + const { patch } = sanitizePreferencesPatch( + { providerRef: 'shared-b', ttsModel: 'my-model' }, + makeContext(), + { fillMissingProvider: false }, + ); + expect(patch.providerRef).toBe('shared-b'); + expect(patch.providerType).toBe('custom-openai'); + expect(patch.ttsModel).toBe('my-model'); + }); + + test('locks the model to the shared provider default when showAllProviderModels is false', () => { + const { patch } = sanitizePreferencesPatch( + { providerRef: 'shared-a', ttsModel: 'something-else' }, + makeContext({ showAllProviderModels: false }), + { fillMissingProvider: false }, + ); + expect(patch.providerRef).toBe('shared-a'); + expect(patch.ttsModel).toBe('gpt-4o-mini-tts'); + }); + + test('treats the legacy "default-openai" sentinel as inherit when it is not a real shared provider', () => { + const { patch, migrated } = sanitizePreferencesPatch( + { providerRef: 'default-openai' }, + makeContext(), + { fillMissingProvider: true }, + ); + expect(patch.providerRef).toBe(''); + expect(migrated).toBe(true); + }); + + test('does not force provider fields on a partial PUT patch that omits them', () => { + const { patch } = sanitizePreferencesPatch( + { voice: 'af_sarah' }, + makeContext(), + { fillMissingProvider: false }, + ); + expect(patch.voice).toBe('af_sarah'); + expect('providerRef' in patch).toBe(false); + expect('providerType' in patch).toBe(false); + expect('ttsModel' in patch).toBe(false); + }); +}); diff --git a/tests/unit/user-preferences-sync.vitest.spec.ts b/tests/unit/user-preferences-sync.vitest.spec.ts new file mode 100644 index 0000000..aae3c76 --- /dev/null +++ b/tests/unit/user-preferences-sync.vitest.spec.ts @@ -0,0 +1,81 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +import { + cancelPendingPreferenceSync, + flushUserPreferencesSync, + scheduleUserPreferencesSync, +} from '../../src/lib/client/api/user-state'; + +type FetchMock = ReturnType & { calls: Array<{ url: string; init?: RequestInit }> }; + +function installFetchMock(responder: () => Response): FetchMock { + const mock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + mock.calls.push({ url: String(input), init }); + return responder(); + }) as unknown as FetchMock; + mock.calls = []; + global.fetch = mock as unknown as typeof fetch; + return mock; +} + +function okResponse(): Response { + return new Response( + JSON.stringify({ preferences: {}, clientUpdatedAtMs: Date.now(), applied: true }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); +} + +describe('flushUserPreferencesSync', () => { + const originalFetch = global.fetch; + + beforeEach(() => { + vi.useFakeTimers(); + cancelPendingPreferenceSync(); + }); + + afterEach(() => { + cancelPendingPreferenceSync(); + vi.useRealTimers(); + global.fetch = originalFetch; + }); + + test('sends a pending debounced patch immediately instead of waiting for the timer', async () => { + const fetchMock = installFetchMock(okResponse); + + scheduleUserPreferencesSync({ providerRef: 'shared-b', providerType: 'openai' }, 'user-1'); + + // Nothing should have fired yet — it's still debounced. + expect(fetchMock.calls).toHaveLength(0); + + await flushUserPreferencesSync(); + + expect(fetchMock.calls).toHaveLength(1); + const { url, init } = fetchMock.calls[0]; + expect(url).toContain('/api/user/state/preferences'); + expect(init?.method).toBe('PUT'); + const body = JSON.parse(String(init?.body)); + expect(body.patch.providerRef).toBe('shared-b'); + expect(body.patch.providerType).toBe('openai'); + + // The debounce timer must not also fire a duplicate request afterwards. + await vi.runAllTimersAsync(); + expect(fetchMock.calls).toHaveLength(1); + }); + + test('is a no-op when there is no pending patch', async () => { + const fetchMock = installFetchMock(okResponse); + await flushUserPreferencesSync(); + expect(fetchMock.calls).toHaveLength(0); + }); + + test('rejects when the server write fails so callers can surface the error', async () => { + installFetchMock(() => new Response(JSON.stringify({ error: 'boom' }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + })); + + scheduleUserPreferencesSync({ providerRef: 'shared-b' }, 'user-1'); + + await expect(flushUserPreferencesSync()).rejects.toThrow(); + }); +}); From f8182c97dfb079dd2504545683e2ef5a515fceac Mon Sep 17 00:00:00 2001 From: Richard R Date: Sat, 6 Jun 2026 15:51:02 -0600 Subject: [PATCH 2/6] feat(tts): add Replicate Kokoro language code resolution and input mapping Implement logic to resolve and inject Replicate Kokoro model-specific language codes based on language tag and voice, using new mapping utilities. Update `buildReplicateInput` to set `language_code` for the built-in Kokoro model. Expand language utilities with code mapping and expose `resolveReplicateKokoroLanguageCode`. Enhance tests to cover language code resolution and input mapping for Replicate Kokoro, and verify language input key discovery for built-in models. --- src/lib/server/tts/generate.ts | 18 +++++++- src/lib/server/tts/voice-resolution.ts | 2 +- src/lib/shared/language.ts | 42 ++++++++++++++++++ tests/unit/language.vitest.spec.ts | 8 ++++ tests/unit/tts-generate.vitest.spec.ts | 36 +++++++++++++++ .../unit/tts-provider-catalog.vitest.spec.ts | 44 +++++++++++++++++++ 6 files changed, 147 insertions(+), 3 deletions(-) diff --git a/src/lib/server/tts/generate.ts b/src/lib/server/tts/generate.ts index cfca3d3..fdeb583 100644 --- a/src/lib/server/tts/generate.ts +++ b/src/lib/server/tts/generate.ts @@ -15,7 +15,11 @@ import { LRUCache } from 'lru-cache'; import { createHash } from 'crypto'; import { access, readFile } from 'fs/promises'; import { resolve } from 'path'; -import { getLanguageDisplayName, toBaseLanguageCode } from '@/lib/shared/language'; +import { + getLanguageDisplayName, + resolveReplicateKokoroLanguageCode, + toBaseLanguageCode, +} from '@/lib/shared/language'; export interface ServerTTSRequest { text: string; @@ -434,7 +438,7 @@ async function fetchTTSBufferWithRetry( } } -async function buildReplicateInput(request: ResolvedServerTTSRequest): Promise> { +export async function buildReplicateInput(request: ResolvedServerTTSRequest): Promise> { const model = request.model as string; if (model === 'google/gemini-3.1-flash-tts') { @@ -518,6 +522,16 @@ async function addReplicateLanguageInput( request: ResolvedServerTTSRequest, ): Promise> { if (!request.language) return input; + if (request.model === REPLICATE_KOKORO_82M_VERSIONED_MODEL) { + const languageCode = resolveReplicateKokoroLanguageCode({ + language: request.language, + voice: request.voice, + }); + if (languageCode) { + input.language_code = languageCode; + } + return input; + } const languageInput = await resolveReplicateLanguageInput({ provider: 'replicate', model: request.model as string, diff --git a/src/lib/server/tts/voice-resolution.ts b/src/lib/server/tts/voice-resolution.ts index 7d80a13..1fd9420 100644 --- a/src/lib/server/tts/voice-resolution.ts +++ b/src/lib/server/tts/voice-resolution.ts @@ -311,7 +311,7 @@ export async function resolveReplicateLanguageInput({ model, apiKey = '', }: ResolveVoicesOptions): Promise { - if (provider !== 'replicate' || REPLICATE_BUILT_IN_MODELS.has(model) || !apiKey) return null; + if (provider !== 'replicate' || !apiKey) return null; const cached = replicateLanguageInputCache.get(model); if (cached) return cached; diff --git a/src/lib/shared/language.ts b/src/lib/shared/language.ts index b278bd0..4ce259e 100644 --- a/src/lib/shared/language.ts +++ b/src/lib/shared/language.ts @@ -28,6 +28,28 @@ const KOKORO_LANGUAGE_BY_PREFIX: Readonly> = { zm: 'zh-CN', }; +const REPLICATE_KOKORO_LANGUAGE_CODE_BY_TAG: Readonly> = { + 'en-US': 'a', + 'en-GB': 'b', + es: 'e', + fr: 'f', + hi: 'h', + it: 'i', + ja: 'j', + 'pt-BR': 'p', + 'zh-CN': 'z', +}; + +const REPLICATE_KOKORO_LANGUAGE_CODE_BY_BASE_TAG: Readonly> = { + es: 'e', + fr: 'f', + hi: 'h', + it: 'i', + ja: 'j', + pt: 'p', + zh: 'z', +}; + export const KOKORO_SUPPORTED_LANGUAGES = [ 'en', 'es', @@ -79,6 +101,26 @@ export function inferKokoroLanguageFromVoice(voice: string | null | undefined): return languages.size === 1 ? [...languages][0] : null; } +export function resolveReplicateKokoroLanguageCode(input: { + language?: string | null; + voice?: string | null; +}): string | null { + const normalizedLanguage = input.language ? normalizeLanguageTag(input.language) : null; + const voiceLanguage = inferKokoroLanguageFromVoice(input.voice); + + for (const candidate of [normalizedLanguage, voiceLanguage]) { + if (!candidate) continue; + + const exactCode = REPLICATE_KOKORO_LANGUAGE_CODE_BY_TAG[candidate]; + if (exactCode) return exactCode; + + const baseCode = REPLICATE_KOKORO_LANGUAGE_CODE_BY_BASE_TAG[toBaseLanguageCode(candidate)]; + if (baseCode) return baseCode; + } + + return null; +} + export function getKokoroVoiceLanguages(voice: string | null | undefined): string[] { if (!voice?.trim()) return []; return Array.from(new Set( diff --git a/tests/unit/language.vitest.spec.ts b/tests/unit/language.vitest.spec.ts index b50552c..4fd9e40 100644 --- a/tests/unit/language.vitest.spec.ts +++ b/tests/unit/language.vitest.spec.ts @@ -8,6 +8,7 @@ import { keepKokoroVoicesInOneLanguage, normalizeOptionalLanguageTag, normalizeUnicodeToken, + resolveReplicateKokoroLanguageCode, resolveTtsLanguage, segmentSentences, segmentWords, @@ -95,6 +96,13 @@ describe('multilingual language utilities', () => { expect(toBaseLanguageCode('zh-CN')).toBe('zh'); }); + test('maps Kokoro voices and normalized language tags to Replicate language codes', () => { + expect(resolveReplicateKokoroLanguageCode({ language: 'en', voice: 'af_sarah' })).toBe('a'); + expect(resolveReplicateKokoroLanguageCode({ language: 'en', voice: 'bf_emma' })).toBe('b'); + expect(resolveReplicateKokoroLanguageCode({ language: 'ja-JP', voice: 'jf_alpha' })).toBe('j'); + expect(resolveReplicateKokoroLanguageCode({ language: 'zh-TW', voice: 'zf_xiaobei' })).toBe('z'); + }); + test('normalizes Unicode tokens without dropping non-Latin scripts', () => { expect(normalizeUnicodeToken('École!')).toBe('école'); expect(normalizeUnicodeToken('日本語。')).toBe('日本語'); diff --git a/tests/unit/tts-generate.vitest.spec.ts b/tests/unit/tts-generate.vitest.spec.ts index 4b35d02..d5e79ec 100644 --- a/tests/unit/tts-generate.vitest.spec.ts +++ b/tests/unit/tts-generate.vitest.spec.ts @@ -1,10 +1,12 @@ import { describe, expect, test } from 'vitest'; import { + buildReplicateInput, buildTTSCacheKey, extractReplicateAudioUrl, resolveReplicateLanguageValue, } from '../../src/lib/server/tts/generate'; +import { REPLICATE_KOKORO_82M_VERSIONED_MODEL } from '../../src/lib/shared/tts-provider-catalog'; describe('replicate output URL extraction', () => { test('returns direct URL string output', () => { @@ -70,4 +72,38 @@ describe('Replicate language schema values', () => { expect(resolveReplicateLanguageValue('ja-JP', ['English', 'Japanese'])).toBe('Japanese'); expect(resolveReplicateLanguageValue('ja-JP', ['English', 'French'])).toBeNull(); }); + + test('includes language_code for the built-in Replicate Kokoro model', async () => { + await expect(buildReplicateInput({ + text: 'Hello world', + voice: 'af_sarah', + speed: 1, + format: 'mp3', + model: REPLICATE_KOKORO_82M_VERSIONED_MODEL, + language: 'en', + provider: 'replicate', + apiKey: 'r8_token', + testNamespace: null, + })).resolves.toEqual({ + text: 'Hello world', + voice: 'af_sarah', + language_code: 'a', + }); + + await expect(buildReplicateInput({ + text: 'Hello world', + voice: 'bf_emma', + speed: 1, + format: 'mp3', + model: REPLICATE_KOKORO_82M_VERSIONED_MODEL, + language: 'en', + provider: 'replicate', + apiKey: 'r8_token', + testNamespace: null, + })).resolves.toEqual({ + text: 'Hello world', + voice: 'bf_emma', + language_code: 'b', + }); + }); }); diff --git a/tests/unit/tts-provider-catalog.vitest.spec.ts b/tests/unit/tts-provider-catalog.vitest.spec.ts index acd5b94..02e72af 100644 --- a/tests/unit/tts-provider-catalog.vitest.spec.ts +++ b/tests/unit/tts-provider-catalog.vitest.spec.ts @@ -435,6 +435,50 @@ describe('tts provider catalog', () => { globalThis.fetch = originalFetch; } }); + + test('discovers Replicate language input key for built-in models too', async () => { + const originalFetch = globalThis.fetch; + let calls = 0; + globalThis.fetch = async () => { + calls += 1; + return { + ok: true, + json: async () => ({ + latest_version: { + openapi_schema: { + components: { + schemas: { + Input: { + type: 'object', + properties: { + text: { type: 'string' }, + language: { type: 'string', enum: ['auto', 'en', 'ja'] }, + }, + }, + }, + }, + }, + }, + }), + } as Response; + }; + + try { + await expect(resolveReplicateLanguageInputKey({ + provider: 'replicate', + model: 'minimax/speech-2.8-turbo', + apiKey: 'r8_token', + })).resolves.toBe('language'); + await expect(resolveReplicateLanguageInputKey({ + provider: 'replicate', + model: 'minimax/speech-2.8-turbo', + apiKey: 'r8_token', + })).resolves.toBe('language'); + expect(calls).toBe(1); + } finally { + globalThis.fetch = originalFetch; + } + }); }); describe('config helpers', () => { From 23b9417567d2c89573ffbbd749d5beedaede49df Mon Sep 17 00:00:00 2001 From: Richard R Date: Sat, 6 Jun 2026 15:55:54 -0600 Subject: [PATCH 3/6] fix(config): prevent arrays from being treated as records in preference normalization Update preference normalization logic to explicitly reject arrays when validating record types, ensuring only plain objects are accepted. Adjust ConfigProvider effect conditions for clarity and correctness. Add unit tests verifying that arrays are not accepted as valid preference records or saved voices. --- src/contexts/ConfigContext.tsx | 78 +++++++++---------- src/lib/server/user/preferences-normalize.ts | 2 +- .../user-preferences-normalize.vitest.spec.ts | 9 +++ 3 files changed, 49 insertions(+), 40 deletions(-) diff --git a/src/contexts/ConfigContext.tsx b/src/contexts/ConfigContext.tsx index cf8ce0a..a397c0f 100644 --- a/src/contexts/ConfigContext.tsx +++ b/src/contexts/ConfigContext.tsx @@ -209,54 +209,54 @@ export function ConfigProvider({ children }: { children: ReactNode }) { }, [appConfig]); useEffect(() => { - if (!ttsProvidersTabDisabled || !isDBReady || !appConfig || sharedProvidersLoading) return; + if (ttsProvidersTabDisabled && isDBReady && appConfig && !sharedProvidersLoading) { + const resetPatch: Partial = {}; - const resetPatch: Partial = {}; + // When the provider tab is hidden, clear any user-set provider config back to + // "inherit the admin default" (empty) rather than baking in a concrete value. + if (appConfig.apiKey !== '') resetPatch.apiKey = ''; + if (appConfig.baseUrl !== '') resetPatch.baseUrl = ''; + if (appConfig.providerRef !== '') resetPatch.providerRef = ''; + if (appConfig.providerType !== 'unknown') resetPatch.providerType = 'unknown'; + if (appConfig.ttsModel !== '') resetPatch.ttsModel = ''; + if (appConfig.ttsInstructions !== '') resetPatch.ttsInstructions = ''; + // Keep voice selection state intact so player/Audiobook voice pickers still + // work when the TTS providers tab is hidden. This reset is only for provider + // configuration fields. - // When the provider tab is hidden, clear any user-set provider config back to - // "inherit the admin default" (empty) rather than baking in a concrete value. - if (appConfig.apiKey !== '') resetPatch.apiKey = ''; - if (appConfig.baseUrl !== '') resetPatch.baseUrl = ''; - if (appConfig.providerRef !== '') resetPatch.providerRef = ''; - if (appConfig.providerType !== 'unknown') resetPatch.providerType = 'unknown'; - if (appConfig.ttsModel !== '') resetPatch.ttsModel = ''; - if (appConfig.ttsInstructions !== '') resetPatch.ttsInstructions = ''; - // Keep voice selection state intact so player/Audiobook voice pickers still - // work when the TTS providers tab is hidden. This reset is only for provider - // configuration fields. + if (Object.keys(resetPatch).length === 0) return; - if (Object.keys(resetPatch).length === 0) return; - - updateAppConfig(resetPatch).catch((error) => { - console.warn('Failed to clear hidden TTS provider settings:', error); - }); - queueSyncedPreferencePatch(resetPatch); + updateAppConfig(resetPatch).catch((error) => { + console.warn('Failed to clear hidden TTS provider settings:', error); + }); + queueSyncedPreferencePatch(resetPatch); + } }, [ttsProvidersTabDisabled, isDBReady, appConfig, queueSyncedPreferencePatch, sharedProvidersLoading]); useEffect(() => { - if (!restrictUserApiKeys || !isDBReady || !appConfig || sharedProvidersLoading) return; + if (restrictUserApiKeys && isDBReady && appConfig && !sharedProvidersLoading) { + const resetPatch: Partial = {}; - const resetPatch: Partial = {}; + if (appConfig.apiKey !== '') resetPatch.apiKey = ''; + if (appConfig.baseUrl !== '') resetPatch.baseUrl = ''; + // Built-in providers aren't selectable in restricted mode. Clear any stale + // built-in selection (including the old 'custom-openai' default that used to + // be baked into every config) back to "inherit the admin default" so the + // user follows whatever shared provider the admin has configured. + if (isBuiltInTtsProviderId(appConfig.providerRef)) { + resetPatch.providerRef = ''; + resetPatch.providerType = 'unknown'; + resetPatch.ttsModel = ''; + resetPatch.ttsInstructions = ''; + } - if (appConfig.apiKey !== '') resetPatch.apiKey = ''; - if (appConfig.baseUrl !== '') resetPatch.baseUrl = ''; - // Built-in providers aren't selectable in restricted mode. Clear any stale - // built-in selection (including the old 'custom-openai' default that used to - // be baked into every config) back to "inherit the admin default" so the - // user follows whatever shared provider the admin has configured. - if (isBuiltInTtsProviderId(appConfig.providerRef)) { - resetPatch.providerRef = ''; - resetPatch.providerType = 'unknown'; - resetPatch.ttsModel = ''; - resetPatch.ttsInstructions = ''; + if (Object.keys(resetPatch).length === 0) return; + + updateAppConfig(resetPatch).catch((error) => { + console.warn('Failed to enforce restricted user API key mode:', error); + }); + queueSyncedPreferencePatch(resetPatch); } - - if (Object.keys(resetPatch).length === 0) return; - - updateAppConfig(resetPatch).catch((error) => { - console.warn('Failed to enforce restricted user API key mode:', error); - }); - queueSyncedPreferencePatch(resetPatch); }, [restrictUserApiKeys, isDBReady, appConfig, queueSyncedPreferencePatch, sharedProvidersLoading]); useEffect(() => { diff --git a/src/lib/server/user/preferences-normalize.ts b/src/lib/server/user/preferences-normalize.ts index 4555330..51d0a4e 100644 --- a/src/lib/server/user/preferences-normalize.ts +++ b/src/lib/server/user/preferences-normalize.ts @@ -14,7 +14,7 @@ export interface PreferenceNormalizationContext { } export function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null; + return typeof value === 'object' && value !== null && !Array.isArray(value); } export function sanitizeSavedVoices(value: unknown): Record { diff --git a/tests/unit/user-preferences-normalize.vitest.spec.ts b/tests/unit/user-preferences-normalize.vitest.spec.ts index 16975ea..13f5478 100644 --- a/tests/unit/user-preferences-normalize.vitest.spec.ts +++ b/tests/unit/user-preferences-normalize.vitest.spec.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from 'vitest'; import { sanitizePreferencesPatch, + sanitizeSavedVoices, type PreferenceNormalizationContext, } from '../../src/lib/server/user/preferences-normalize'; @@ -96,4 +97,12 @@ describe('sanitizePreferencesPatch — inherit-by-default provider model', () => expect('providerType' in patch).toBe(false); expect('ttsModel' in patch).toBe(false); }); + + test('rejects arrays where preference records are expected', () => { + expect(sanitizePreferencesPatch(['voice'], makeContext(), { fillMissingProvider: false })).toEqual({ + patch: {}, + migrated: false, + }); + expect(sanitizeSavedVoices(['af_sarah'])).toEqual({}); + }); }); From a224efd9e3e2a39dac54542f4d92fcf3b14a4631 Mon Sep 17 00:00:00 2001 From: Richard R Date: Sat, 6 Jun 2026 16:02:36 -0600 Subject: [PATCH 4/6] refactor(admin): support keyless provider seeding and clarify API_KEY usage Update provider seeding logic and documentation to allow creation of admin/shared providers without requiring an API key. Adjust environment variable handling so that a blank or missing API_KEY is valid when API_BASE is set, enabling support for upstream TTS providers that do not require authentication. Remove defaulting to 'none' for API keys in API routes and ensure headers are omitted when no key is present. Add and update tests to verify correct handling of keyless providers. BREAKING CHANGE: Providers can now be seeded without an API key; API_KEY is no longer required if API_BASE is set. Existing deployments relying on a non-empty API_KEY for seeding should review their environment configuration. --- .env.example | 4 +- docs-site/docs/configure/admin-panel.md | 6 +- .../configure/tts-provider-guides/other.md | 2 +- docs-site/docs/deploy/local-development.md | 6 +- docs-site/docs/docker-quick-start.md | 8 +-- .../docs/reference/environment-variables.md | 5 +- src/app/api/audiobook/chapter/route.ts | 2 +- src/app/api/tts/segments/ensure/route.ts | 2 +- src/app/api/tts/voices/route.ts | 2 +- src/lib/server/admin/seed.ts | 19 +++--- src/lib/server/tts/generate.ts | 1 + src/lib/server/tts/voice-resolution.ts | 2 +- .../admin-providers-validation.vitest.spec.ts | 19 ++++++ tests/unit/runtime-seed-json.vitest.spec.ts | 58 +++++++++++++++++++ 14 files changed, 104 insertions(+), 32 deletions(-) diff --git a/.env.example b/.env.example index 02c3e57..173fc65 100644 --- a/.env.example +++ b/.env.example @@ -14,7 +14,7 @@ # remove them once the seed has run. Auth must be configured for this seed path. # See Settings → Admin → Shared providers. API_BASE=http://localhost:8880/v1 -API_KEY=api_key_optional +API_KEY= # Auth configuration (required in v4+) BASE_URL=http://localhost:3003 # Externally facing URL for this app (set to LAN IP for access from other devices on the network) @@ -88,4 +88,4 @@ S3_BUCKET= # (Optional) v4 JSON seed for first-boot runtime config + shared providers. # If both are set, RUNTIME_SEED_JSON_PATH is used. # RUNTIME_SEED_JSON_PATH=/absolute/path/to/openreader-seed.json -# RUNTIME_SEED_JSON={"version":1,"runtimeConfig":{"enableUserSignups":true,"restrictUserApiKeys":true,"defaultTtsProvider":"custom-openai","enableTtsProvidersTab":true,"enableAudiobookExport":true,"enableDocxConversion":true,"showAllProviderModels":true,"disableTtsRateLimit":true,"ttsDailyLimitAnonymous":50000,"ttsDailyLimitAuthenticated":500000,"ttsIpDailyLimitAnonymous":100000,"ttsIpDailyLimitAuthenticated":1000000,"ttsCacheMaxSizeBytes":268435456,"ttsCacheTtlMs":1800000,"ttsUpstreamMaxRetries":2,"ttsUpstreamTimeoutMs":285000,"disableComputeRateLimit":true,"computeParseBurstMax":8,"computeParseBurstWindowSec":60,"computeParseSustainedMax":24,"computeParseSustainedWindowSec":600,"maxUploadMb":200,"changelogFeedUrl":"https://docs.openreader.richardr.dev/changelog/manifest.json"},"providers":[{"slug":"default-openai","displayName":"Default (seeded)","providerType":"custom-openai","baseUrl":"http://localhost:8880/v1","apiKey":"api_key_optional","defaultModel":"kokoro","enabled":true}]} +# RUNTIME_SEED_JSON={"version":1,"runtimeConfig":{"enableUserSignups":true,"restrictUserApiKeys":true,"defaultTtsProvider":"custom-openai","enableTtsProvidersTab":true,"enableAudiobookExport":true,"enableDocxConversion":true,"showAllProviderModels":true,"disableTtsRateLimit":true,"ttsDailyLimitAnonymous":50000,"ttsDailyLimitAuthenticated":500000,"ttsIpDailyLimitAnonymous":100000,"ttsIpDailyLimitAuthenticated":1000000,"ttsCacheMaxSizeBytes":268435456,"ttsCacheTtlMs":1800000,"ttsUpstreamMaxRetries":2,"ttsUpstreamTimeoutMs":285000,"disableComputeRateLimit":true,"computeParseBurstMax":8,"computeParseBurstWindowSec":60,"computeParseSustainedMax":24,"computeParseSustainedWindowSec":600,"maxUploadMb":200,"changelogFeedUrl":"https://docs.openreader.richardr.dev/changelog/manifest.json"},"providers":[{"slug":"default-openai","displayName":"Default (seeded)","providerType":"custom-openai","baseUrl":"http://localhost:8880/v1","defaultModel":"kokoro","enabled":true}]} diff --git a/docs-site/docs/configure/admin-panel.md b/docs-site/docs/configure/admin-panel.md index 181071a..82a70c4 100644 --- a/docs-site/docs/configure/admin-panel.md +++ b/docs-site/docs/configure/admin-panel.md @@ -50,10 +50,10 @@ Whether users can supply their own personal built-in provider keys is controlled ### Auto-seeded "default-openai" -On first boot, if `admin_providers` is empty and the legacy `API_KEY` env var is set, OpenReader creates a single shared provider with: +On first boot, if `admin_providers` is empty and `API_BASE` or `API_KEY` is set, OpenReader creates a single shared provider with: - slug `default-openai`, displayName `Default (from env)`, providerType `custom-openai` -- baseUrl from `API_BASE`, apiKey from `API_KEY` (encrypted) +- baseUrl from `API_BASE`, apiKey from `API_KEY` when provided (blank keys are supported) - defaultModel set to `kokoro` (you can edit it in Admin → Shared providers) After this seed runs, the legacy `API_KEY` / `API_BASE` env vars are no longer read by the TTS routes — the DB row is authoritative. You can rename, edit, disable, or delete this row like any other from the admin UI, and remove the env vars from your `.env` when convenient. @@ -124,7 +124,7 @@ In v4, runtime site features are managed by admin settings and optional JSON see 1. Deploy this version with your existing env values in place. 2. Boot the app once. Open Settings → Admin and verify: - Seeded settings appear as **from seed** (if you supplied a runtime JSON seed). - - A `default-openai` row exists in **Shared providers** (if you had `API_KEY` set). + - A `default-openai` row exists in **Shared providers** (if you had `API_BASE` or `API_KEY` set). 3. Remove any bootstrap env vars you no longer need from `.env`. 4. Redeploy. Behavior is unchanged — the DB is now the source of truth. diff --git a/docs-site/docs/configure/tts-provider-guides/other.md b/docs-site/docs/configure/tts-provider-guides/other.md index bcb3a0a..4e3aafc 100644 --- a/docs-site/docs/configure/tts-provider-guides/other.md +++ b/docs-site/docs/configure/tts-provider-guides/other.md @@ -26,7 +26,7 @@ Known compatible implementations: [Kokoro-FastAPI](./kokoro-fastapi), [KittenTTS ```env API_BASE=http://your-tts-server/v1 -API_KEY=optional-key-if-required +# API_KEY=optional-key-if-required ``` **Or in-app via Settings → TTS Provider:** diff --git a/docs-site/docs/deploy/local-development.md b/docs-site/docs/deploy/local-development.md index 7454b7b..3f5f0b1 100644 --- a/docs-site/docs/deploy/local-development.md +++ b/docs-site/docs/deploy/local-development.md @@ -249,7 +249,6 @@ Use one of these `.env` mode templates: ```env API_BASE=http://host.docker.internal:8880/v1 -API_KEY=none BASE_URL=http://localhost:3003 AUTH_SECRET= # Optional when you need multiple local origins: @@ -260,10 +259,9 @@ AUTH_SECRET= ```env -# API_BASE / API_KEY are seeded into the admin "default-openai" shared provider +# API_BASE and optional API_KEY are seeded into the admin "default-openai" shared provider # on first boot, then no longer read. Manage them in Settings → Admin afterwards. API_BASE=http://host.docker.internal:8880/v1 -API_KEY=none BASE_URL=http://localhost:3003 AUTH_SECRET= # Comma-separated emails to auto-promote to admin on signin. @@ -275,7 +273,6 @@ ADMIN_EMAILS=you@example.com ```env API_BASE=http://host.docker.internal:8880/v1 -API_KEY=none USE_EMBEDDED_WEED_MINI=false BASE_URL=http://localhost:3003 AUTH_SECRET= @@ -293,7 +290,6 @@ S3_SECRET_ACCESS_KEY=your-secret-key ```env API_BASE=http://host.docker.internal:8880/v1 -API_KEY=none BASE_URL=http://localhost:3003 AUTH_SECRET= COMPUTE_WORKER_URL=http://localhost:8081 diff --git a/docs-site/docs/docker-quick-start.md b/docs-site/docs/docker-quick-start.md index 06cf6fa..e9e1cc0 100644 --- a/docs-site/docs/docker-quick-start.md +++ b/docs-site/docs/docker-quick-start.md @@ -42,7 +42,6 @@ docker run --name openreader \ -p 8333:8333 \ -v openreader_docstore:/app/docstore \ -e API_BASE=http://host.docker.internal:8880/v1 \ - -e API_KEY=none \ -e BASE_URL=http://localhost:3003 \ -e AUTH_SECRET=$(openssl rand -hex 32) \ -e ADMIN_EMAILS=you@example.com \ @@ -54,7 +53,7 @@ What this command enables: - `-p 3003:3003`: exposes the OpenReader web app/API. - `-p 8333:8333`: exposes embedded SeaweedFS S3 endpoint for direct browser presigned upload/download. - `-v openreader_docstore:/app/docstore`: persists SQLite metadata, SeaweedFS blob data, and migration/runtime state. -- `-e API_BASE=...` / `-e API_KEY=...`: **first-boot seed only.** On the first container start, these are auto-migrated into a `default-openai` admin shared provider stored in the DB (key encrypted at rest). After that, the running app no longer reads them — manage the provider from **Settings → Admin → Shared providers**. See [Admin Panel](./configure/admin-panel). +- `-e API_BASE=...` / optional `-e API_KEY=...`: **first-boot seed only.** On the first container start, these are auto-migrated into a `default-openai` admin shared provider stored in the DB (key encrypted at rest when provided). After that, the running app no longer reads them — manage the provider from **Settings → Admin → Shared providers**. See [Admin Panel](./configure/admin-panel). - `-e BASE_URL=...` and `-e AUTH_SECRET=...`: required for v4+ auth/session startup. - `-e ADMIN_EMAILS=...`: (optional, requires auth) comma-separated emails auto-promoted to admin. Admins see the **Admin** tab in Settings. @@ -70,7 +69,6 @@ docker run --name openreader \ -p 8333:8333 \ -v openreader_docstore:/app/docstore \ -e API_BASE=http://host.docker.internal:8880/v1 \ - -e API_KEY=none \ -e BASE_URL=http://:3003 \ -e AUTH_SECRET=$(openssl rand -hex 32) \ -e AUTH_TRUSTED_ORIGINS=http://localhost:3003,http://127.0.0.1:3003 \ @@ -88,7 +86,7 @@ What this command enables: - `AUTH_TRUSTED_ORIGINS` allows localhost loopback origins in addition to your primary LAN origin. - `USE_ANONYMOUS_AUTH_SESSIONS=true` allows guest sessions while auth is enabled. - `API_BASE` seeds the default TTS endpoint into the admin-managed `default-openai` shared provider on first boot. Edit it from **Settings → Admin → Shared providers** after that. -- `API_KEY` seeds the default provider's key (encrypted at rest). After first boot, manage keys from the admin panel or set per-user keys if `restrictUserApiKeys=false`. Always needed for seeding the default provider. +- `API_KEY` optionally seeds the default provider's key (encrypted at rest). Omit it for an upstream that does not require authentication. - `ADMIN_EMAILS=...` (optional) auto-promotes the listed email(s) to admin so they can manage shared providers and site feature flags from the UI. - `openreader_docstore` volume keeps data persistent across restarts. @@ -112,7 +110,7 @@ What this command enables: - Fast startup with only the required auth env vars. - No persistent volume (`/app/docstore` stays container-local), so data is ephemeral unless you add a mount. - The app still requires `BASE_URL` + `AUTH_SECRET` in v4+, so include them even in minimal mode. -- No TTS provider preset by default. Configure `API_BASE`/`API_KEY` on first boot if you want a seeded shared provider, or run auth+admin mode and manage providers from the admin panel. +- No TTS provider preset by default. Configure `API_BASE` and, when required, `API_KEY` on first boot if you want a seeded shared provider, or run auth+admin mode and manage providers from the admin panel. diff --git a/docs-site/docs/reference/environment-variables.md b/docs-site/docs/reference/environment-variables.md index d80bd8e..71fc981 100644 --- a/docs-site/docs/reference/environment-variables.md +++ b/docs-site/docs/reference/environment-variables.md @@ -85,7 +85,7 @@ App server log level. Optional first-boot bootstrap base URL for the auto-created `default-openai` shared provider. - Example: `http://host.docker.internal:8880/v1` -- Read only for provider bootstrap when shared providers are empty and `API_KEY` is set. +- Read only for provider bootstrap when shared providers are empty. Setting `API_BASE` is sufficient; `API_KEY` may be blank. - After bootstrap, provider configuration is DB-backed and managed in **Settings → Admin → Shared providers**. ### API_KEY @@ -446,7 +446,6 @@ Example: "displayName": "Default (seeded)", "providerType": "custom-openai", "baseUrl": "http://localhost:8880/v1", - "apiKey": "api_key_optional", "defaultModel": "kokoro", "enabled": true } @@ -457,7 +456,7 @@ Example: Provider fallback behavior: - If the JSON seed includes `providers` (including an empty array), `API_BASE` / `API_KEY` fallback is skipped. -- If the JSON seed does not include a `providers` key, the legacy `API_BASE` / `API_KEY` bootstrap fallback can still create `default-openai` when provider rows are empty. +- If the JSON seed does not include a `providers` key, the legacy `API_BASE` / `API_KEY` bootstrap fallback can still create `default-openai` when provider rows are empty. `API_BASE` alone is sufficient for an upstream that does not require authentication. Precedence summary: diff --git a/src/app/api/audiobook/chapter/route.ts b/src/app/api/audiobook/chapter/route.ts index e63d045..7aa881a 100644 --- a/src/app/api/audiobook/chapter/route.ts +++ b/src/app/api/audiobook/chapter/route.ts @@ -501,7 +501,7 @@ export async function POST(request: NextRequest) { normalize: { code: 'AUDIOBOOK_CHAPTER_UNSUPPORTED_PROVIDER', errorClass: 'validation', httpStatus: 500 }, }); } - const openApiKey = credResolved.apiKey || 'none'; + const openApiKey = credResolved.apiKey; const openApiBaseUrl = credResolved.baseUrl; const effectiveProviderRef = credResolved.adminRecord?.slug || requestedProvider; const model = resolveTtsModelForProvider({ diff --git a/src/app/api/tts/segments/ensure/route.ts b/src/app/api/tts/segments/ensure/route.ts index f089231..4dfb6d9 100644 --- a/src/app/api/tts/segments/ensure/route.ts +++ b/src/app/api/tts/segments/ensure/route.ts @@ -644,7 +644,7 @@ export async function POST(request: NextRequest) { instructions: effectiveSettings.ttsInstructions, language: effectiveSettings.language, provider: requestCreds.provider, - apiKey: requestCreds.apiKey || 'none', + apiKey: requestCreds.apiKey, baseUrl: requestCreds.baseUrl, testNamespace: scope.testNamespace, }, request.signal, { diff --git a/src/app/api/tts/voices/route.ts b/src/app/api/tts/voices/route.ts index 0e1b5f5..1c31b39 100644 --- a/src/app/api/tts/voices/route.ts +++ b/src/app/api/tts/voices/route.ts @@ -62,7 +62,7 @@ export async function GET(req: NextRequest) { const voices = await resolveVoices({ provider: resolved.provider, model: requestedModel, - apiKey: resolved.apiKey || 'none', + apiKey: resolved.apiKey, baseUrl: resolved.baseUrl, }); return NextResponse.json({ voices }); diff --git a/src/lib/server/admin/seed.ts b/src/lib/server/admin/seed.ts index c06cc48..0409299 100644 --- a/src/lib/server/admin/seed.ts +++ b/src/lib/server/admin/seed.ts @@ -32,7 +32,7 @@ type SeedProviderInput = { slug: string; displayName: string; providerType: TtsProviderId; - apiKey: string; + apiKey?: string; baseUrl?: string | null; defaultModel?: string | null; defaultInstructions?: string | null; @@ -187,8 +187,8 @@ function validateSeedProviderEntry(value: unknown, index: number): SeedProviderI if (typeof rec.providerType !== 'string') { throw new Error(`Seed JSON providers[${index}].providerType must be a string`); } - if (typeof rec.apiKey !== 'string' || !rec.apiKey.trim()) { - throw new Error(`Seed JSON providers[${index}].apiKey must be a non-empty string`); + if (rec.apiKey !== undefined && typeof rec.apiKey !== 'string') { + throw new Error(`Seed JSON providers[${index}].apiKey must be a string when provided`); } if (rec.baseUrl !== undefined && rec.baseUrl !== null && typeof rec.baseUrl !== 'string') { @@ -208,7 +208,7 @@ function validateSeedProviderEntry(value: unknown, index: number): SeedProviderI slug: validateSlug(rec.slug), displayName: rec.displayName.trim(), providerType: validateProviderType(rec.providerType), - apiKey: rec.apiKey.trim(), + ...(rec.apiKey !== undefined ? { apiKey: rec.apiKey.trim() } : {}), ...(rec.baseUrl !== undefined ? { baseUrl: normalizeOptionalString(rec.baseUrl) } : {}), ...(rec.defaultModel !== undefined ? { defaultModel: normalizeOptionalString(rec.defaultModel) } : {}), ...(rec.defaultInstructions !== undefined ? { defaultInstructions: normalizeOptionalString(rec.defaultInstructions) } : {}), @@ -246,7 +246,8 @@ async function seedAdminProvidersFromJson(providers: SeedProviderInput[]): Promi if (existing.length > 0) continue; try { - const encrypted = encryptSecret(provider.apiKey); + const apiKey = provider.apiKey ?? ''; + const encrypted = encryptSecret(apiKey); await db.insert(adminProviders).values({ id: randomUUID(), slug: provider.slug, @@ -255,7 +256,7 @@ async function seedAdminProvidersFromJson(providers: SeedProviderInput[]): Promi baseUrl: provider.baseUrl ?? null, apiKeyCiphertext: encrypted.ciphertext, apiKeyIv: encrypted.iv, - apiKeyLast4: apiKeyLast4(provider.apiKey), + apiKeyLast4: apiKeyLast4(apiKey), defaultModel: provider.defaultModel ?? null, defaultInstructions: provider.defaultInstructions ?? null, enabled: provider.enabled === false ? 0 : 1, @@ -278,8 +279,9 @@ async function seedAdminProvidersFromJson(providers: SeedProviderInput[]): Promi } async function seedDefaultAdminProviderFromEnvFallback(): Promise { - const apiKey = process.env.API_KEY; - if (!apiKey || !apiKey.trim()) return; + const apiKey = process.env.API_KEY?.trim() ?? ''; + const baseUrl = process.env.API_BASE?.trim() || null; + if (!apiKey && !baseUrl) return; let existing: Array; try { @@ -295,7 +297,6 @@ async function seedDefaultAdminProviderFromEnvFallback(): Promise { } if (existing.length > 0) return; - const baseUrl = process.env.API_BASE?.trim() || null; const now = Date.now(); let enc: ReturnType; try { diff --git a/src/lib/server/tts/generate.ts b/src/lib/server/tts/generate.ts index fdeb583..bbbbd48 100644 --- a/src/lib/server/tts/generate.ts +++ b/src/lib/server/tts/generate.ts @@ -641,6 +641,7 @@ async function runProviderRequest( const openai = new OpenAI({ apiKey: request.apiKey, baseURL: request.baseUrl, + defaultHeaders: request.apiKey ? undefined : { Authorization: null }, maxRetries: 0, timeout: upstreamSettings.ttsUpstreamTimeoutMs, }); diff --git a/src/lib/server/tts/voice-resolution.ts b/src/lib/server/tts/voice-resolution.ts index 1fd9420..4a517c4 100644 --- a/src/lib/server/tts/voice-resolution.ts +++ b/src/lib/server/tts/voice-resolution.ts @@ -377,7 +377,7 @@ async function fetchCustomOpenAiVoices(baseUrl: string, apiKey: string): Promise const response = await fetch(`${normalizedBaseUrl}/audio/voices`, { signal: controller.signal, headers: { - Authorization: `Bearer ${apiKey}`, + ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}), 'Content-Type': 'application/json', }, }); diff --git a/tests/unit/admin-providers-validation.vitest.spec.ts b/tests/unit/admin-providers-validation.vitest.spec.ts index e977cc7..6511e0d 100644 --- a/tests/unit/admin-providers-validation.vitest.spec.ts +++ b/tests/unit/admin-providers-validation.vitest.spec.ts @@ -5,6 +5,8 @@ import { adminProviders } from '../../src/db/schema'; import { AdminProviderError, + createAdminProvider, + decryptedKeyFor, listAdminProviders, toMasked, validateProviderType, @@ -53,6 +55,23 @@ describe('admin provider validation', () => { expect(toMasked(record).apiKeyMask).toBe('••••abcd'); }); + test('creates providers without an API key', async () => { + const suffix = `${Date.now()}${Math.random().toString(36).slice(2, 8)}`; + const created = await createAdminProvider({ + slug: `keyless-${suffix}`, + displayName: 'Keyless Provider', + providerType: 'custom-openai', + baseUrl: 'http://localhost:8880/v1', + }); + + try { + expect(toMasked(created).apiKeyMask).toBe('(not set)'); + await expect(decryptedKeyFor(created)).resolves.toBe(''); + } finally { + await db.delete(adminProviders).where(inArray(adminProviders.id, [created.id])); + } + }); + test('lists providers in deterministic updated/created/slug order', async () => { const suffix = `${Date.now()}${Math.random().toString(36).slice(2, 8)}`; const inserted = [ diff --git a/tests/unit/runtime-seed-json.vitest.spec.ts b/tests/unit/runtime-seed-json.vitest.spec.ts index e62cefc..fd710bf 100644 --- a/tests/unit/runtime-seed-json.vitest.spec.ts +++ b/tests/unit/runtime-seed-json.vitest.spec.ts @@ -160,6 +160,26 @@ describe('runtime seed JSON parsing', () => { expect(__seedInternals.shouldUseEnvProviderFallback(false)).toBe(true); expect(__seedInternals.shouldUseEnvProviderFallback(true)).toBe(false); }); + + test('accepts seeded providers without an API key', () => { + const parsed = __seedInternals.parseRuntimeSeedDocument(JSON.stringify({ + version: 1, + providers: [{ + slug: 'keyless-provider', + displayName: 'Keyless Provider', + providerType: 'custom-openai', + baseUrl: 'http://localhost:8880/v1', + }], + })); + + expect(parsed.seed.providers?.[0]).toMatchObject({ + slug: 'keyless-provider', + displayName: 'Keyless Provider', + providerType: 'custom-openai', + baseUrl: 'http://localhost:8880/v1', + }); + expect(parsed.seed.providers?.[0]).not.toHaveProperty('apiKey'); + }); }); describe('runtime config JSON seeding', () => { @@ -380,6 +400,44 @@ describe('provider seeding and fallback precedence', () => { } }); + test('falls back to API_BASE with a blank API_KEY', async () => { + const providerSnapshot = await snapshotProvidersBySlug(testSlugs); + const runtimeSeed = JSON.stringify({ version: 1, runtimeConfig: { enableUserSignups: true } }); + + try { + await db.delete(adminProviders).where(inArray(adminProviders.slug, testSlugs)); + const blockedByOtherRows = await hasNonTestProviderRows(testSlugs); + if (blockedByOtherRows) return; + + await withEnv({ + RUNTIME_SEED_JSON: runtimeSeed, + RUNTIME_SEED_JSON_PATH: undefined, + API_BASE: 'http://localhost:8880/v1', + API_KEY: '', + AUTH_SECRET: 'seed-test-auth-secret-keyless', + }, async () => { + await __seedInternals.runSeed(); + }); + + const rows = await db + .select({ + slug: adminProviders.slug, + baseUrl: adminProviders.baseUrl, + apiKeyLast4: adminProviders.apiKeyLast4, + }) + .from(adminProviders); + + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + slug: 'default-openai', + baseUrl: 'http://localhost:8880/v1', + apiKeyLast4: '', + }); + } finally { + await restoreProvidersBySlug(testSlugs, providerSnapshot); + } + }); + test('does not use API_BASE/API_KEY fallback when providers key is present but empty', async () => { const providerSnapshot = await snapshotProvidersBySlug(testSlugs); const runtimeSeed = JSON.stringify({ version: 1, providers: [] }); From 9c685d75906df5355861d95873dc54b1706c75f2 Mon Sep 17 00:00:00 2001 From: Richard R Date: Sat, 6 Jun 2026 16:02:42 -0600 Subject: [PATCH 5/6] docs(env): remove runtime-only TTS and compute rate limit settings from env var docs Eliminate documentation of TTS and compute PDF parsing rate limiting options from the environment variables reference, as these are managed exclusively via runtime configuration in the admin UI and do not correspond to environment variables. This clarifies the distinction between environment-based and runtime-managed settings. --- .../docs/reference/environment-variables.md | 34 ------------------- 1 file changed, 34 deletions(-) diff --git a/docs-site/docs/reference/environment-variables.md b/docs-site/docs/reference/environment-variables.md index 71fc981..dfe985c 100644 --- a/docs-site/docs/reference/environment-variables.md +++ b/docs-site/docs/reference/environment-variables.md @@ -96,27 +96,6 @@ Optional first-boot bootstrap API key for the auto-created `default-openai` shar - Stored encrypted at rest after bootstrap. - After bootstrap, provider configuration is DB-backed and managed in **Settings → Admin → Shared providers**. -### TTS Daily Rate Limiting (Runtime Settings) - -Managed as runtime config in **Settings → Admin → Site features**. - -- `disableTtsRateLimit` default: `true` (daily TTS limits disabled) -- `ttsDailyLimitAnonymous` default: `50000` -- `ttsDailyLimitAuthenticated` default: `500000` -- `ttsIpDailyLimitAnonymous` default: `100000` -- `ttsIpDailyLimitAuthenticated` default: `1000000` - -### TTS Upstream Settings (Runtime Settings) - -Managed as runtime config in **Settings → Admin → Site features → TTS upstream**. - -- `ttsUpstreamMaxRetries` default: `2` -- `ttsUpstreamTimeoutMs` default: `285000` -- `ttsCacheMaxSizeBytes` default: `268435456` (256 MB) -- `ttsCacheTtlMs` default: `1800000` (30 minutes) - -There are no dedicated env vars for these runtime settings. - ## Auth and Identity ### BASE_URL @@ -339,19 +318,6 @@ External compute worker URL. Shared token for app-to-external-worker requests. -## Compute PDF Parsing Rate Limiting (Runtime Settings) - -Managed as runtime config in **Settings → Admin → Site features**. - -- `disableComputeRateLimit` default: `true` -- `computeParseBurstMax` default: `8` -- `computeParseBurstWindowSec` default: `60` -- `computeParseSustainedMax` default: `24` -- `computeParseSustainedWindowSec` default: `600` -- `maxUploadMb` default: `200` - -There are no dedicated env vars for these runtime settings. - ## Audio Runtime ### FFMPEG_BIN From 92df3f907c5d811a64fcc990a2f0af01a5bd31ea Mon Sep 17 00:00:00 2001 From: Richard R Date: Sat, 6 Jun 2026 16:18:40 -0600 Subject: [PATCH 6/6] fix(tts): correct language input handling for Replicate Kokoro model Adjust language input resolution in TTS generation to ensure Replicate Kokoro model receives the appropriate language code mapping even when the language property is absent or falsy. Update tests to cover this scenario and verify correct input construction. --- src/lib/server/tts/generate.ts | 2 +- tests/unit/tts-generate.vitest.spec.ts | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/lib/server/tts/generate.ts b/src/lib/server/tts/generate.ts index bbbbd48..0b2f719 100644 --- a/src/lib/server/tts/generate.ts +++ b/src/lib/server/tts/generate.ts @@ -521,7 +521,6 @@ async function addReplicateLanguageInput( input: Record, request: ResolvedServerTTSRequest, ): Promise> { - if (!request.language) return input; if (request.model === REPLICATE_KOKORO_82M_VERSIONED_MODEL) { const languageCode = resolveReplicateKokoroLanguageCode({ language: request.language, @@ -532,6 +531,7 @@ async function addReplicateLanguageInput( } return input; } + if (!request.language) return input; const languageInput = await resolveReplicateLanguageInput({ provider: 'replicate', model: request.model as string, diff --git a/tests/unit/tts-generate.vitest.spec.ts b/tests/unit/tts-generate.vitest.spec.ts index d5e79ec..e32f6d3 100644 --- a/tests/unit/tts-generate.vitest.spec.ts +++ b/tests/unit/tts-generate.vitest.spec.ts @@ -105,5 +105,20 @@ describe('Replicate language schema values', () => { voice: 'bf_emma', language_code: 'b', }); + + await expect(buildReplicateInput({ + text: 'Hello world', + voice: 'af_sarah', + speed: 1, + format: 'mp3', + model: REPLICATE_KOKORO_82M_VERSIONED_MODEL, + provider: 'replicate', + apiKey: 'r8_token', + testNamespace: null, + })).resolves.toEqual({ + text: 'Hello world', + voice: 'af_sarah', + language_code: 'a', + }); }); });