Merge pull request #103 from richardr1126/fix/provider-resolution

fix(tts): make user/shared provider selection reliable and default to inheriting the admin provider
This commit is contained in:
Richard R 2026-06-06 16:45:47 -06:00 committed by GitHub
commit 5185974eb3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 817 additions and 367 deletions

View file

@ -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}]}

View file

@ -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.

View file

@ -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:**

View file

@ -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=<generate-with-openssl-rand-hex-32>
# Optional when you need multiple local origins:
@ -260,10 +259,9 @@ AUTH_SECRET=<generate-with-openssl-rand-hex-32>
<TabItem value="auth-with-admin" label="Auth + Admin Panel">
```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=<generate-with-openssl-rand-hex-32>
# 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=<generate-with-openssl-rand-hex-32>
@ -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=<generate-with-openssl-rand-hex-32>
COMPUTE_WORKER_URL=http://localhost:8081

View file

@ -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://<YOUR_LAN_IP>: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.
</TabItem>
</Tabs>

View file

@ -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
@ -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
@ -446,7 +412,6 @@ Example:
"displayName": "Default (seeded)",
"providerType": "custom-openai",
"baseUrl": "http://localhost:8880/v1",
"apiKey": "api_key_optional",
"defaultModel": "kokoro",
"enabled": true
}
@ -457,7 +422,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:

View file

@ -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 (
<ConfigProvider>
<DocumentProvider>
<OnboardingFlowProvider>{children}</OnboardingFlowProvider>
</DocumentProvider>
</ConfigProvider>
<DocumentProvider>
<OnboardingFlowProvider>{children}</OnboardingFlowProvider>
</DocumentProvider>
);
}

View file

@ -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 (
<ConfigProvider>
<TTSProvider>
{children}
</TTSProvider>
</ConfigProvider>
<TTSProvider>
{children}
</TTSProvider>
);
}

View file

@ -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 (
<ConfigProvider>
<TTSProvider>{children}</TTSProvider>
</ConfigProvider>
<TTSProvider>{children}</TTSProvider>
);
}

View file

@ -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}
>
<AppShell>
<AppMain>{children}</AppMain>
</AppShell>
{/* 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. */}
<ConfigProvider>
<AppShell>
<AppMain>{children}</AppMain>
</AppShell>
</ConfigProvider>
<Toaster
toastOptions={{
style: {

View file

@ -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 PdfReaderLayout({ children }: { children: ReactNode }) {
return (
<ConfigProvider>
<TTSProvider>{children}</TTSProvider>
</ConfigProvider>
<TTSProvider>{children}</TTSProvider>
);
}

View file

@ -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({

View file

@ -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, {

View file

@ -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 });

View file

@ -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<string, unknown>): Record<str
return JSON.stringify(payload);
}
function isRecord(value: unknown): value is Record<string, unknown> {
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<PreferenceNormalizationContext> {
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<string, string> {
if (!isRecord(value)) return {};
const out: Record<string, string> = {};
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<string, unknown>;
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();

View file

@ -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({
<div className="space-y-4">
<div className="space-y-1.5">
<label className={fieldLabelClass}>TTS Provider</label>
{ttsProviders.length === 0 ? (
{sharedProvidersLoading ? (
<p className="text-xs text-soft">Loading providers</p>
) : ttsProviders.length === 0 ? (
<p className="text-xs text-accent">
User API keys are restricted and no shared provider is configured. Ask an admin to add one.
</p>
@ -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);
}}
>

View file

@ -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<string | null>(null);
const syncedPreferenceKeys = useMemo(() => new Set<string>(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<AppConfigValues>) => {
if (sessionKey === 'no-session') return;
@ -213,81 +209,66 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
}, [appConfig]);
useEffect(() => {
if (!ttsProvidersTabDisabled || !isDBReady || !appConfig) return;
if (ttsProvidersTabDisabled && isDBReady && appConfig && !sharedProvidersLoading) {
const resetPatch: Partial<AppConfigRow> = {};
const resetPatch: Partial<AppConfigRow> = {};
// 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 (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;
}
// 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);
}, [ttsProvidersTabDisabled, isDBReady, appConfig, queueSyncedPreferencePatch, providerResetDefaults]);
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) return;
if (restrictUserApiKeys && isDBReady && appConfig && !sharedProvidersLoading) {
const resetPatch: Partial<AppConfigRow> = {};
const resetPatch: Partial<AppConfigRow> = {};
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 !== APP_CONFIG_DEFAULTS.apiKey) {
resetPatch.apiKey = APP_CONFIG_DEFAULTS.apiKey;
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 (appConfig.baseUrl !== APP_CONFIG_DEFAULTS.baseUrl) {
resetPatch.baseUrl = APP_CONFIG_DEFAULTS.baseUrl;
}
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;
}
}
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, 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<AppConfigRow> = { 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,

View file

@ -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<void> {
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 },

View file

@ -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<void> {
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<unknown>;
try {
@ -295,7 +297,6 @@ async function seedDefaultAdminProviderFromEnvFallback(): Promise<void> {
}
if (existing.length > 0) return;
const baseUrl = process.env.API_BASE?.trim() || null;
const now = Date.now();
let enc: ReturnType<typeof encryptSecret>;
try {

View file

@ -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<string | undefined>
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',

View file

@ -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<Record<string, unknown>> {
export async function buildReplicateInput(request: ResolvedServerTTSRequest): Promise<Record<string, unknown>> {
const model = request.model as string;
if (model === 'google/gemini-3.1-flash-tts') {
@ -517,6 +521,16 @@ async function addReplicateLanguageInput(
input: Record<string, unknown>,
request: ResolvedServerTTSRequest,
): Promise<Record<string, unknown>> {
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;
}
if (!request.language) return input;
const languageInput = await resolveReplicateLanguageInput({
provider: 'replicate',
@ -627,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,
});

View file

@ -311,7 +311,7 @@ export async function resolveReplicateLanguageInput({
model,
apiKey = '',
}: ResolveVoicesOptions): Promise<ReplicateLanguageInput | null> {
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;
@ -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',
},
});

View file

@ -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<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
export function sanitizeSavedVoices(value: unknown): Record<string, string> {
if (!isRecord(value)) return {};
const out: Record<string, string> = {};
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<string, unknown>;
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 };
}

View file

@ -28,6 +28,28 @@ const KOKORO_LANGUAGE_BY_PREFIX: Readonly<Record<string, string>> = {
zm: 'zh-CN',
};
const REPLICATE_KOKORO_LANGUAGE_CODE_BY_TAG: Readonly<Record<string, string>> = {
'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<Record<string, string>> = {
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(

View file

@ -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<AppConfigValues> = {
get(_target, prop) {
if (!cachedDefaults) cachedDefaults = getAppConfigDefaults();

View file

@ -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 = [

View file

@ -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('日本語');

View file

@ -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: [] });

View file

@ -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,53 @@ 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',
});
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',
});
});
});

View file

@ -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', () => {
@ -476,9 +520,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({});
});
});

View file

@ -0,0 +1,108 @@
import { describe, expect, test } from 'vitest';
import {
sanitizePreferencesPatch,
sanitizeSavedVoices,
type PreferenceNormalizationContext,
} from '../../src/lib/server/user/preferences-normalize';
function makeContext(overrides: Partial<PreferenceNormalizationContext> = {}): 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);
});
test('rejects arrays where preference records are expected', () => {
expect(sanitizePreferencesPatch(['voice'], makeContext(), { fillMissingProvider: false })).toEqual({
patch: {},
migrated: false,
});
expect(sanitizeSavedVoices(['af_sarah'])).toEqual({});
});
});

View file

@ -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<typeof vi.fn> & { 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();
});
});