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. # remove them once the seed has run. Auth must be configured for this seed path.
# See Settings → Admin → Shared providers. # See Settings → Admin → Shared providers.
API_BASE=http://localhost:8880/v1 API_BASE=http://localhost:8880/v1
API_KEY=api_key_optional API_KEY=
# Auth configuration (required in v4+) # 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) 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. # (Optional) v4 JSON seed for first-boot runtime config + shared providers.
# If both are set, RUNTIME_SEED_JSON_PATH is used. # If both are set, RUNTIME_SEED_JSON_PATH is used.
# RUNTIME_SEED_JSON_PATH=/absolute/path/to/openreader-seed.json # 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" ### 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` - 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) - 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. 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. 1. Deploy this version with your existing env values in place.
2. Boot the app once. Open Settings → Admin and verify: 2. Boot the app once. Open Settings → Admin and verify:
- Seeded settings appear as **from seed** (if you supplied a runtime JSON seed). - 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`. 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. 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 ```env
API_BASE=http://your-tts-server/v1 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:** **Or in-app via Settings → TTS Provider:**

View file

@ -249,7 +249,6 @@ Use one of these `.env` mode templates:
```env ```env
API_BASE=http://host.docker.internal:8880/v1 API_BASE=http://host.docker.internal:8880/v1
API_KEY=none
BASE_URL=http://localhost:3003 BASE_URL=http://localhost:3003
AUTH_SECRET=<generate-with-openssl-rand-hex-32> AUTH_SECRET=<generate-with-openssl-rand-hex-32>
# Optional when you need multiple local origins: # 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"> <TabItem value="auth-with-admin" label="Auth + Admin Panel">
```env ```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. # on first boot, then no longer read. Manage them in Settings → Admin afterwards.
API_BASE=http://host.docker.internal:8880/v1 API_BASE=http://host.docker.internal:8880/v1
API_KEY=none
BASE_URL=http://localhost:3003 BASE_URL=http://localhost:3003
AUTH_SECRET=<generate-with-openssl-rand-hex-32> AUTH_SECRET=<generate-with-openssl-rand-hex-32>
# Comma-separated emails to auto-promote to admin on signin. # Comma-separated emails to auto-promote to admin on signin.
@ -275,7 +273,6 @@ ADMIN_EMAILS=you@example.com
```env ```env
API_BASE=http://host.docker.internal:8880/v1 API_BASE=http://host.docker.internal:8880/v1
API_KEY=none
USE_EMBEDDED_WEED_MINI=false USE_EMBEDDED_WEED_MINI=false
BASE_URL=http://localhost:3003 BASE_URL=http://localhost:3003
AUTH_SECRET=<generate-with-openssl-rand-hex-32> AUTH_SECRET=<generate-with-openssl-rand-hex-32>
@ -293,7 +290,6 @@ S3_SECRET_ACCESS_KEY=your-secret-key
```env ```env
API_BASE=http://host.docker.internal:8880/v1 API_BASE=http://host.docker.internal:8880/v1
API_KEY=none
BASE_URL=http://localhost:3003 BASE_URL=http://localhost:3003
AUTH_SECRET=<generate-with-openssl-rand-hex-32> AUTH_SECRET=<generate-with-openssl-rand-hex-32>
COMPUTE_WORKER_URL=http://localhost:8081 COMPUTE_WORKER_URL=http://localhost:8081

View file

@ -42,7 +42,6 @@ docker run --name openreader \
-p 8333:8333 \ -p 8333:8333 \
-v openreader_docstore:/app/docstore \ -v openreader_docstore:/app/docstore \
-e API_BASE=http://host.docker.internal:8880/v1 \ -e API_BASE=http://host.docker.internal:8880/v1 \
-e API_KEY=none \
-e BASE_URL=http://localhost:3003 \ -e BASE_URL=http://localhost:3003 \
-e AUTH_SECRET=$(openssl rand -hex 32) \ -e AUTH_SECRET=$(openssl rand -hex 32) \
-e ADMIN_EMAILS=you@example.com \ -e ADMIN_EMAILS=you@example.com \
@ -54,7 +53,7 @@ What this command enables:
- `-p 3003:3003`: exposes the OpenReader web app/API. - `-p 3003:3003`: exposes the OpenReader web app/API.
- `-p 8333:8333`: exposes embedded SeaweedFS S3 endpoint for direct browser presigned upload/download. - `-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. - `-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 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. - `-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 \ -p 8333:8333 \
-v openreader_docstore:/app/docstore \ -v openreader_docstore:/app/docstore \
-e API_BASE=http://host.docker.internal:8880/v1 \ -e API_BASE=http://host.docker.internal:8880/v1 \
-e API_KEY=none \
-e BASE_URL=http://<YOUR_LAN_IP>:3003 \ -e BASE_URL=http://<YOUR_LAN_IP>:3003 \
-e AUTH_SECRET=$(openssl rand -hex 32) \ -e AUTH_SECRET=$(openssl rand -hex 32) \
-e AUTH_TRUSTED_ORIGINS=http://localhost:3003,http://127.0.0.1:3003 \ -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. - `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. - `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_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. - `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. - `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. - 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. - 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. - 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> </TabItem>
</Tabs> </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. Optional first-boot bootstrap base URL for the auto-created `default-openai` shared provider.
- Example: `http://host.docker.internal:8880/v1` - 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**. - After bootstrap, provider configuration is DB-backed and managed in **Settings → Admin → Shared providers**.
### API_KEY ### 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. - Stored encrypted at rest after bootstrap.
- After bootstrap, provider configuration is DB-backed and managed in **Settings → Admin → Shared providers**. - 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 ## Auth and Identity
### BASE_URL ### BASE_URL
@ -339,19 +318,6 @@ External compute worker URL.
Shared token for app-to-external-worker requests. 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 ## Audio Runtime
### FFMPEG_BIN ### FFMPEG_BIN
@ -446,7 +412,6 @@ Example:
"displayName": "Default (seeded)", "displayName": "Default (seeded)",
"providerType": "custom-openai", "providerType": "custom-openai",
"baseUrl": "http://localhost:8880/v1", "baseUrl": "http://localhost:8880/v1",
"apiKey": "api_key_optional",
"defaultModel": "kokoro", "defaultModel": "kokoro",
"enabled": true "enabled": true
} }
@ -457,7 +422,7 @@ Example:
Provider fallback behavior: Provider fallback behavior:
- If the JSON seed includes `providers` (including an empty array), `API_BASE` / `API_KEY` fallback is skipped. - 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: Precedence summary:

View file

@ -2,16 +2,15 @@
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { ConfigProvider } from '@/contexts/ConfigContext';
import { DocumentProvider } from '@/contexts/DocumentContext'; import { DocumentProvider } from '@/contexts/DocumentContext';
import { OnboardingFlowProvider } from '@/contexts/OnboardingFlowContext'; 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 }) { export default function AppHomeLayout({ children }: { children: ReactNode }) {
return ( return (
<ConfigProvider> <DocumentProvider>
<DocumentProvider> <OnboardingFlowProvider>{children}</OnboardingFlowProvider>
<OnboardingFlowProvider>{children}</OnboardingFlowProvider> </DocumentProvider>
</DocumentProvider>
</ConfigProvider>
); );
} }

View file

@ -2,15 +2,14 @@
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { ConfigProvider } from '@/contexts/ConfigContext';
import { TTSProvider } from '@/contexts/TTSContext'; 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 }) { export default function EpubReaderLayout({ children }: { children: ReactNode }) {
return ( return (
<ConfigProvider> <TTSProvider>
<TTSProvider> {children}
{children} </TTSProvider>
</TTSProvider>
</ConfigProvider>
); );
} }

View file

@ -2,13 +2,12 @@
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { ConfigProvider } from '@/contexts/ConfigContext';
import { TTSProvider } from '@/contexts/TTSContext'; 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 }) { export default function HtmlReaderLayout({ children }: { children: ReactNode }) {
return ( return (
<ConfigProvider> <TTSProvider>{children}</TTSProvider>
<TTSProvider>{children}</TTSProvider>
</ConfigProvider>
); );
} }

View file

@ -3,6 +3,7 @@ import type { ReactNode } from 'react';
import { Toaster } from 'react-hot-toast'; import { Toaster } from 'react-hot-toast';
import { Providers } from '@/app/providers'; import { Providers } from '@/app/providers';
import { ConfigProvider } from '@/contexts/ConfigContext';
import { AppMain, AppShell } from '@/components/layout'; import { AppMain, AppShell } from '@/components/layout';
import { getAuthBaseUrl, isAnonymousAuthSessionsEnabled, isGithubAuthEnabled } from '@/lib/server/auth/config'; import { getAuthBaseUrl, isAnonymousAuthSessionsEnabled, isGithubAuthEnabled } from '@/lib/server/auth/config';
@ -33,9 +34,17 @@ export default function AppLayout({ children }: { children: ReactNode }) {
allowAnonymousAuthSessions={allowAnonymousAuthSessions} allowAnonymousAuthSessions={allowAnonymousAuthSessions}
githubAuthEnabled={githubAuthEnabled} githubAuthEnabled={githubAuthEnabled}
> >
<AppShell> {/* ConfigProvider lives here, in the shared (app) layout, so it stays
<AppMain>{children}</AppMain> mounted across library <-> reader navigation. Mounting it per-route
</AppShell> 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 <Toaster
toastOptions={{ toastOptions={{
style: { style: {

View file

@ -2,13 +2,12 @@
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { ConfigProvider } from '@/contexts/ConfigContext';
import { TTSProvider } from '@/contexts/TTSContext'; 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 }) { export default function PdfReaderLayout({ children }: { children: ReactNode }) {
return ( return (
<ConfigProvider> <TTSProvider>{children}</TTSProvider>
<TTSProvider>{children}</TTSProvider>
</ConfigProvider>
); );
} }

View file

@ -501,7 +501,7 @@ export async function POST(request: NextRequest) {
normalize: { code: 'AUDIOBOOK_CHAPTER_UNSUPPORTED_PROVIDER', errorClass: 'validation', httpStatus: 500 }, normalize: { code: 'AUDIOBOOK_CHAPTER_UNSUPPORTED_PROVIDER', errorClass: 'validation', httpStatus: 500 },
}); });
} }
const openApiKey = credResolved.apiKey || 'none'; const openApiKey = credResolved.apiKey;
const openApiBaseUrl = credResolved.baseUrl; const openApiBaseUrl = credResolved.baseUrl;
const effectiveProviderRef = credResolved.adminRecord?.slug || requestedProvider; const effectiveProviderRef = credResolved.adminRecord?.slug || requestedProvider;
const model = resolveTtsModelForProvider({ const model = resolveTtsModelForProvider({

View file

@ -644,7 +644,7 @@ export async function POST(request: NextRequest) {
instructions: effectiveSettings.ttsInstructions, instructions: effectiveSettings.ttsInstructions,
language: effectiveSettings.language, language: effectiveSettings.language,
provider: requestCreds.provider, provider: requestCreds.provider,
apiKey: requestCreds.apiKey || 'none', apiKey: requestCreds.apiKey,
baseUrl: requestCreds.baseUrl, baseUrl: requestCreds.baseUrl,
testNamespace: scope.testNamespace, testNamespace: scope.testNamespace,
}, request.signal, { }, request.signal, {

View file

@ -62,7 +62,7 @@ export async function GET(req: NextRequest) {
const voices = await resolveVoices({ const voices = await resolveVoices({
provider: resolved.provider, provider: resolved.provider,
model: requestedModel, model: requestedModel,
apiKey: resolved.apiKey || 'none', apiKey: resolved.apiKey,
baseUrl: resolved.baseUrl, baseUrl: resolved.baseUrl,
}); });
return NextResponse.json({ voices }); return NextResponse.json({ voices });

View file

@ -2,15 +2,17 @@ import { NextRequest, NextResponse } from 'next/server';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { db } from '@/db'; import { db } from '@/db';
import { userPreferences } from '@/db/schema'; 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 { resolveUserStateScope } from '@/lib/server/user/resolve-state-scope';
import { coerceTimestampMs, nowTimestampMs } from '@/lib/shared/timestamps'; 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 { listAdminProviders } from '@/lib/server/admin/providers';
import { getResolvedRuntimeConfig } from '@/lib/server/runtime-config'; import { getResolvedRuntimeConfig } from '@/lib/server/runtime-config';
import { normalizeLegacyProviderRef, resolveProviderDefaults } from '@/lib/shared/tts-provider-policy';
import { errorToLog, serverLogger } from '@/lib/server/logger'; import { errorToLog, serverLogger } from '@/lib/server/logger';
import { errorResponse } from '@/lib/server/errors/next-response'; import { errorResponse } from '@/lib/server/errors/next-response';
import {
sanitizePreferencesPatch,
type PreferenceNormalizationContext,
} from '@/lib/server/user/preferences-normalize';
import { import {
deserializeUserPreferencesPayload, deserializeUserPreferencesPayload,
extractUserPreferencesMeta, extractUserPreferencesMeta,
@ -25,29 +27,14 @@ function serializePreferencesForDb(payload: Record<string, unknown>): Record<str
return JSON.stringify(payload); 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> { async function loadPreferenceNormalizationContext(): Promise<PreferenceNormalizationContext> {
const [runtimeConfig, providers] = await Promise.all([ const [runtimeConfig, providers] = await Promise.all([
getResolvedRuntimeConfig(), getResolvedRuntimeConfig(),
listAdminProviders(), listAdminProviders(),
]); ]);
return { return {
defaultProviderRef: runtimeConfig.defaultTtsProvider,
showAllProviderModels: runtimeConfig.showAllProviderModels, showAllProviderModels: runtimeConfig.showAllProviderModels,
restrictUserApiKeys: runtimeConfig.restrictUserApiKeys,
sharedProviders: providers sharedProviders: providers
.filter((entry) => entry.enabled) .filter((entry) => entry.enabled)
.map((entry) => ({ .map((entry) => ({
@ -69,128 +56,6 @@ function parseStoredPreferences(
return { ...sanitized, meta }; 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 { function normalizeClientUpdatedAtMs(value: unknown): number {
const normalized = coerceTimestampMs(value, nowTimestampMs()); const normalized = coerceTimestampMs(value, nowTimestampMs());
if (normalized <= 0) return nowTimestampMs(); if (normalized <= 0) return nowTimestampMs();

View file

@ -37,6 +37,8 @@ import { useRuntimeConfig } from '@/contexts/RuntimeConfigContext';
import { AdminProvidersPanel } from '@/components/admin/AdminProvidersPanel'; import { AdminProvidersPanel } from '@/components/admin/AdminProvidersPanel';
import { AdminFeaturesPanel } from '@/components/admin/AdminFeaturesPanel'; import { AdminFeaturesPanel } from '@/components/admin/AdminFeaturesPanel';
import { useSharedProviders } from '@/hooks/useSharedProviders'; 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 { useLibraryDocumentsQuery } from '@/hooks/useLibraryDocumentsQuery';
import { import {
SidebarNav, SidebarNav,
@ -265,7 +267,7 @@ export function SettingsModal({
prefetch: prefetchLibraryDocuments, prefetch: prefetchLibraryDocuments,
} = useLibraryDocumentsQuery(isSelectionModalOpen); } = useLibraryDocumentsQuery(isSelectionModalOpen);
const { providers: sharedProviders } = useSharedProviders(); const { providers: sharedProviders, isLoading: sharedProvidersLoading } = useSharedProviders();
const { const {
providers: ttsProviders, providers: ttsProviders,
models: ttsModels, models: ttsModels,
@ -295,13 +297,18 @@ export function SettingsModal({
}, [changelogOpenSignal, setIsOpen]); }, [changelogOpenSignal, setIsOpen]);
useEffect(() => { 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); setLocalApiKey(apiKey);
setLocalBaseUrl(baseUrl); setLocalBaseUrl(baseUrl);
setLocalProviderRef(providerRef); setLocalProviderRef(providerRef);
setLocalProviderType(providerType); setLocalProviderType(providerType);
setModelValue(ttsModel); setModelValue(ttsModel);
setLocalTTSInstructions(ttsInstructions); setLocalTTSInstructions(ttsInstructions);
}, [apiKey, baseUrl, providerRef, providerType, ttsModel, ttsInstructions]); }, [isOpen, apiKey, baseUrl, providerRef, providerType, ttsModel, ttsInstructions]);
useEffect(() => { useEffect(() => {
if (!ttsModels.some(m => m.id === modelValue) && modelValue !== '') { if (!ttsModels.some(m => m.id === modelValue) && modelValue !== '') {
@ -643,7 +650,9 @@ export function SettingsModal({
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className={fieldLabelClass}>TTS Provider</label> <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"> <p className="text-xs text-accent">
User API keys are restricted and no shared provider is configured. Ask an admin to add one. User API keys are restricted and no shared provider is configured. Ask an admin to add one.
</p> </p>
@ -815,7 +824,7 @@ export function SettingsModal({
type="button" type="button"
variant="primary" variant="primary"
size="md" size="md"
disabled={!canSubmit} disabled={!canSubmit || sharedProvidersLoading}
onClick={async () => { onClick={async () => {
const defaults = resolveProviderDefaults({ const defaults = resolveProviderDefaults({
providerRef: selectedProviderRef, providerRef: selectedProviderRef,
@ -833,6 +842,16 @@ export function SettingsModal({
: defaults.defaultModel; : defaults.defaultModel;
await updateConfigKey('ttsModel', finalModel); await updateConfigKey('ttsModel', finalModel);
await updateConfigKey('ttsInstructions', localTTSInstructions); 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); setIsOpen(false);
}} }}
> >

View file

@ -5,11 +5,11 @@ import { useLiveQuery } from 'dexie-react-hooks';
import { db, initDB, migrateLegacyDexieDocumentIdsToSha, updateAppConfig } from '@/lib/client/dexie'; 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 { APP_CONFIG_DEFAULTS, type ViewType, type SavedVoices, type AppConfigValues, type AppConfigRow } from '@/types/config';
import { isBuiltInTtsProviderId } from '@/lib/shared/tts-provider-catalog'; 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 { scheduleUserPreferencesSync, cancelPendingPreferenceSync, getUserPreferences, putUserPreferences } from '@/lib/client/api/user-state';
import { SYNCED_PREFERENCE_KEYS, type SyncedPreferenceKey, type SyncedPreferencesPatch } from '@/types/user-state'; import { SYNCED_PREFERENCE_KEYS, type SyncedPreferenceKey, type SyncedPreferencesPatch } from '@/types/user-state';
import { useAuthSession } from '@/hooks/useAuthSession'; import { useAuthSession } from '@/hooks/useAuthSession';
import { useFeatureFlag } from '@/contexts/RuntimeConfigContext'; import { useFeatureFlag, useRuntimeConfig } from '@/contexts/RuntimeConfigContext';
import { buildSyncedPreferencePatch } from '@/lib/client/config/preferences'; import { buildSyncedPreferencePatch } from '@/lib/client/config/preferences';
import { applyConfigUpdate } from '@/lib/client/config/updates'; import { applyConfigUpdate } from '@/lib/client/config/updates';
import { useSharedProviders } from '@/hooks/useSharedProviders'; import { useSharedProviders } from '@/hooks/useSharedProviders';
@ -69,16 +69,12 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
const didRunStartupMigrations = useRef(false); const didRunStartupMigrations = useRef(false);
const didAttemptInitialPreferenceSeedForSession = useRef<string | null>(null); const didAttemptInitialPreferenceSeedForSession = useRef<string | null>(null);
const syncedPreferenceKeys = useMemo(() => new Set<string>(SYNCED_PREFERENCE_KEYS), []); 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 { data: sessionData, isPending: isSessionPending } = useAuthSession();
const sessionKey = sessionData?.user?.id ?? 'no-session'; const sessionKey = sessionData?.user?.id ?? 'no-session';
const providerResetDefaults = useMemo(() => { // The instance/admin default provider. An empty user providerRef "inherits"
return resolveProviderDefaults({ // this, resolved (admin slug -> concrete provider) where the value is used.
providerRef: APP_CONFIG_DEFAULTS.providerRef, const adminDefaultProviderRef = useRuntimeConfig().defaultTtsProvider;
providerType: APP_CONFIG_DEFAULTS.providerType,
sharedProviders,
});
}, [sharedProviders]);
const queueSyncedPreferencePatch = useCallback((patch: Partial<AppConfigValues>) => { const queueSyncedPreferencePatch = useCallback((patch: Partial<AppConfigValues>) => {
if (sessionKey === 'no-session') return; if (sessionKey === 'no-session') return;
@ -213,81 +209,66 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
}, [appConfig]); }, [appConfig]);
useEffect(() => { 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) { if (Object.keys(resetPatch).length === 0) return;
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; updateAppConfig(resetPatch).catch((error) => {
console.warn('Failed to clear hidden TTS provider settings:', error);
updateAppConfig(resetPatch).catch((error) => { });
console.warn('Failed to clear hidden TTS provider settings:', error); queueSyncedPreferencePatch(resetPatch);
}); }
queueSyncedPreferencePatch(resetPatch); }, [ttsProvidersTabDisabled, isDBReady, appConfig, queueSyncedPreferencePatch, sharedProvidersLoading]);
}, [ttsProvidersTabDisabled, isDBReady, appConfig, queueSyncedPreferencePatch, providerResetDefaults]);
useEffect(() => { 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) { if (Object.keys(resetPatch).length === 0) return;
resetPatch.apiKey = APP_CONFIG_DEFAULTS.apiKey;
updateAppConfig(resetPatch).catch((error) => {
console.warn('Failed to enforce restricted user API key mode:', error);
});
queueSyncedPreferencePatch(resetPatch);
} }
if (appConfig.baseUrl !== APP_CONFIG_DEFAULTS.baseUrl) { }, [restrictUserApiKeys, isDBReady, appConfig, queueSyncedPreferencePatch, sharedProvidersLoading]);
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]);
useEffect(() => { 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({ const providerDefaults = resolveProviderDefaults({
providerRef: appConfig.providerRef, providerRef: appConfig.providerRef,
providerType: appConfig.providerType, providerType: appConfig.providerType,
sharedProviders, sharedProviders,
fallbackProviderRef: providerResetDefaults.providerRef, fallbackProviderRef: adminDefaultProviderRef,
}); });
if (!providerDefaults.defaultModel) return; if (!providerDefaults.defaultModel) return;
if (appConfig.ttsModel === 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); console.warn('Failed to enforce provider default model restriction:', error);
}); });
queueSyncedPreferencePatch(patch); queueSyncedPreferencePatch(patch);
}, [showAllProviderModels, isDBReady, appConfig, sharedProviders, providerResetDefaults.providerRef, queueSyncedPreferencePatch]); }, [showAllProviderModels, isDBReady, appConfig, sharedProviders, adminDefaultProviderRef, queueSyncedPreferencePatch, sharedProvidersLoading]);
useEffect(() => { useEffect(() => {
if (!isDBReady || !appConfig || isSessionPending) return; if (!isDBReady || !appConfig || isSessionPending) return;
@ -359,33 +340,48 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
htmlHighlightEnabled, htmlHighlightEnabled,
htmlWordHighlightEnabled, htmlWordHighlightEnabled,
} = config || APP_CONFIG_DEFAULTS; } = config || APP_CONFIG_DEFAULTS;
const providerType = useMemo( // Resolve the effective provider for consumers. An empty stored providerRef
() => resolveEffectiveProviderType({ // 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, providerRef,
providerType: _persistedProviderType, providerType: _persistedProviderType,
sharedProviders, 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(() => { 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; if (appConfig.providerType === providerType) return;
const patch: Partial<AppConfigRow> = { providerType }; const patch: Partial<AppConfigRow> = { providerType };
updateAppConfig(patch).catch((error) => { updateAppConfig(patch).catch((error) => {
console.warn('Failed to persist resolved providerType:', error); console.warn('Failed to persist resolved providerType:', error);
}); });
queueSyncedPreferencePatch(patch); queueSyncedPreferencePatch(patch);
}, [isDBReady, appConfig, providerType, queueSyncedPreferencePatch]); }, [isDBReady, appConfig, providerType, queueSyncedPreferencePatch, sharedProvidersLoading]);
void _persistedProviderType; void _persistedProviderType;
useEffect(() => { 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({ const providerDefaults = resolveProviderDefaults({
providerRef: appConfig.providerRef, providerRef: appConfig.providerRef,
providerType: appConfig.providerType, providerType: appConfig.providerType,
sharedProviders, sharedProviders,
fallbackProviderRef: providerResetDefaults.providerRef, fallbackProviderRef: adminDefaultProviderRef,
}); });
if (!providerDefaults.defaultModel) return; if (!providerDefaults.defaultModel) return;
if (appConfig.ttsModel === 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); console.warn('Failed to normalize shared-provider default model:', error);
}); });
queueSyncedPreferencePatch(patch); queueSyncedPreferencePatch(patch);
}, [isDBReady, appConfig, sharedProviders, queueSyncedPreferencePatch, providerResetDefaults.providerRef]); }, [isDBReady, appConfig, sharedProviders, queueSyncedPreferencePatch, adminDefaultProviderRef, sharedProvidersLoading]);
/** /**
* Updates multiple configuration values simultaneously * Updates multiple configuration values simultaneously
@ -436,9 +432,9 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
try { try {
setIsLoading(true); setIsLoading(true);
const { storagePatch, syncPatch } = applyConfigUpdate({ const { storagePatch, syncPatch } = applyConfigUpdate({
providerRef, providerRef: effectiveProviderRef,
providerType, providerType,
ttsModel, ttsModel: effectiveTtsModel,
savedVoices, savedVoices,
}, key, value); }, key, value);
@ -478,10 +474,10 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
footerMargin, footerMargin,
leftMargin, leftMargin,
rightMargin, rightMargin,
providerRef, providerRef: effectiveProviderRef,
providerType, providerType,
ttsModel, ttsModel: effectiveTtsModel,
ttsInstructions, ttsInstructions: effectiveTtsInstructions,
savedVoices, savedVoices,
updateConfig, updateConfig,
updateConfigKey, updateConfigKey,

View file

@ -148,6 +148,32 @@ export function scheduleUserPreferencesSync(
}, debounceMs); }, 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( export async function getDocumentProgress(
documentId: string, documentId: string,
options?: { signal?: AbortSignal }, options?: { signal?: AbortSignal },

View file

@ -32,7 +32,7 @@ type SeedProviderInput = {
slug: string; slug: string;
displayName: string; displayName: string;
providerType: TtsProviderId; providerType: TtsProviderId;
apiKey: string; apiKey?: string;
baseUrl?: string | null; baseUrl?: string | null;
defaultModel?: string | null; defaultModel?: string | null;
defaultInstructions?: string | null; defaultInstructions?: string | null;
@ -187,8 +187,8 @@ function validateSeedProviderEntry(value: unknown, index: number): SeedProviderI
if (typeof rec.providerType !== 'string') { if (typeof rec.providerType !== 'string') {
throw new Error(`Seed JSON providers[${index}].providerType must be a string`); throw new Error(`Seed JSON providers[${index}].providerType must be a string`);
} }
if (typeof rec.apiKey !== 'string' || !rec.apiKey.trim()) { if (rec.apiKey !== undefined && typeof rec.apiKey !== 'string') {
throw new Error(`Seed JSON providers[${index}].apiKey must be a non-empty 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') { 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), slug: validateSlug(rec.slug),
displayName: rec.displayName.trim(), displayName: rec.displayName.trim(),
providerType: validateProviderType(rec.providerType), providerType: validateProviderType(rec.providerType),
apiKey: rec.apiKey.trim(), ...(rec.apiKey !== undefined ? { apiKey: rec.apiKey.trim() } : {}),
...(rec.baseUrl !== undefined ? { baseUrl: normalizeOptionalString(rec.baseUrl) } : {}), ...(rec.baseUrl !== undefined ? { baseUrl: normalizeOptionalString(rec.baseUrl) } : {}),
...(rec.defaultModel !== undefined ? { defaultModel: normalizeOptionalString(rec.defaultModel) } : {}), ...(rec.defaultModel !== undefined ? { defaultModel: normalizeOptionalString(rec.defaultModel) } : {}),
...(rec.defaultInstructions !== undefined ? { defaultInstructions: normalizeOptionalString(rec.defaultInstructions) } : {}), ...(rec.defaultInstructions !== undefined ? { defaultInstructions: normalizeOptionalString(rec.defaultInstructions) } : {}),
@ -246,7 +246,8 @@ async function seedAdminProvidersFromJson(providers: SeedProviderInput[]): Promi
if (existing.length > 0) continue; if (existing.length > 0) continue;
try { try {
const encrypted = encryptSecret(provider.apiKey); const apiKey = provider.apiKey ?? '';
const encrypted = encryptSecret(apiKey);
await db.insert(adminProviders).values({ await db.insert(adminProviders).values({
id: randomUUID(), id: randomUUID(),
slug: provider.slug, slug: provider.slug,
@ -255,7 +256,7 @@ async function seedAdminProvidersFromJson(providers: SeedProviderInput[]): Promi
baseUrl: provider.baseUrl ?? null, baseUrl: provider.baseUrl ?? null,
apiKeyCiphertext: encrypted.ciphertext, apiKeyCiphertext: encrypted.ciphertext,
apiKeyIv: encrypted.iv, apiKeyIv: encrypted.iv,
apiKeyLast4: apiKeyLast4(provider.apiKey), apiKeyLast4: apiKeyLast4(apiKey),
defaultModel: provider.defaultModel ?? null, defaultModel: provider.defaultModel ?? null,
defaultInstructions: provider.defaultInstructions ?? null, defaultInstructions: provider.defaultInstructions ?? null,
enabled: provider.enabled === false ? 0 : 1, enabled: provider.enabled === false ? 0 : 1,
@ -278,8 +279,9 @@ async function seedAdminProvidersFromJson(providers: SeedProviderInput[]): Promi
} }
async function seedDefaultAdminProviderFromEnvFallback(): Promise<void> { async function seedDefaultAdminProviderFromEnvFallback(): Promise<void> {
const apiKey = process.env.API_KEY; const apiKey = process.env.API_KEY?.trim() ?? '';
if (!apiKey || !apiKey.trim()) return; const baseUrl = process.env.API_BASE?.trim() || null;
if (!apiKey && !baseUrl) return;
let existing: Array<unknown>; let existing: Array<unknown>;
try { try {
@ -295,7 +297,6 @@ async function seedDefaultAdminProviderFromEnvFallback(): Promise<void> {
} }
if (existing.length > 0) return; if (existing.length > 0) return;
const baseUrl = process.env.API_BASE?.trim() || null;
const now = Date.now(); const now = Date.now();
let enc: ReturnType<typeof encryptSecret>; let enc: ReturnType<typeof encryptSecret>;
try { 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 { db } from '@/db';
import { adminProviders, adminSettings } from '@/db/schema'; import { adminProviders, adminSettings } from '@/db/schema';
import { serverLogger } from '@/lib/server/logger'; import { serverLogger } from '@/lib/server/logger';
@ -116,9 +116,19 @@ async function resolveImplicitDefaultTtsProvider(): Promise<string | undefined>
const rows = await db const rows = await db
.select({ slug: adminProviders.slug }) .select({ slug: adminProviders.slug })
.from(adminProviders) .from(adminProviders)
.where(and(eq(adminProviders.slug, 'default-openai'), eq(adminProviders.enabled, 1))) .where(eq(adminProviders.enabled, 1))
.limit(1); .orderBy(
return rows[0]?.slug; 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) { } catch (error) {
logDegraded(serverLogger, { logDegraded(serverLogger, {
event: 'admin.runtime_config.default_provider_lookup.failed', event: 'admin.runtime_config.default_provider_lookup.failed',

View file

@ -15,7 +15,11 @@ import { LRUCache } from 'lru-cache';
import { createHash } from 'crypto'; import { createHash } from 'crypto';
import { access, readFile } from 'fs/promises'; import { access, readFile } from 'fs/promises';
import { resolve } from 'path'; import { resolve } from 'path';
import { getLanguageDisplayName, toBaseLanguageCode } from '@/lib/shared/language'; import {
getLanguageDisplayName,
resolveReplicateKokoroLanguageCode,
toBaseLanguageCode,
} from '@/lib/shared/language';
export interface ServerTTSRequest { export interface ServerTTSRequest {
text: string; 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; const model = request.model as string;
if (model === 'google/gemini-3.1-flash-tts') { if (model === 'google/gemini-3.1-flash-tts') {
@ -517,6 +521,16 @@ async function addReplicateLanguageInput(
input: Record<string, unknown>, input: Record<string, unknown>,
request: ResolvedServerTTSRequest, request: ResolvedServerTTSRequest,
): Promise<Record<string, unknown>> { ): 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; if (!request.language) return input;
const languageInput = await resolveReplicateLanguageInput({ const languageInput = await resolveReplicateLanguageInput({
provider: 'replicate', provider: 'replicate',
@ -627,6 +641,7 @@ async function runProviderRequest(
const openai = new OpenAI({ const openai = new OpenAI({
apiKey: request.apiKey, apiKey: request.apiKey,
baseURL: request.baseUrl, baseURL: request.baseUrl,
defaultHeaders: request.apiKey ? undefined : { Authorization: null },
maxRetries: 0, maxRetries: 0,
timeout: upstreamSettings.ttsUpstreamTimeoutMs, timeout: upstreamSettings.ttsUpstreamTimeoutMs,
}); });

View file

@ -311,7 +311,7 @@ export async function resolveReplicateLanguageInput({
model, model,
apiKey = '', apiKey = '',
}: ResolveVoicesOptions): Promise<ReplicateLanguageInput | null> { }: 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); const cached = replicateLanguageInputCache.get(model);
if (cached) return cached; if (cached) return cached;
@ -377,7 +377,7 @@ async function fetchCustomOpenAiVoices(baseUrl: string, apiKey: string): Promise
const response = await fetch(`${normalizedBaseUrl}/audio/voices`, { const response = await fetch(`${normalizedBaseUrl}/audio/voices`, {
signal: controller.signal, signal: controller.signal,
headers: { headers: {
Authorization: `Bearer ${apiKey}`, ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
'Content-Type': 'application/json', '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', 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 = [ export const KOKORO_SUPPORTED_LANGUAGES = [
'en', 'en',
'es', 'es',
@ -79,6 +101,26 @@ export function inferKokoroLanguageFromVoice(voice: string | null | undefined):
return languages.size === 1 ? [...languages][0] : null; 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[] { export function getKokoroVoiceLanguages(voice: string | null | undefined): string[] {
if (!voice?.trim()) return []; if (!voice?.trim()) return [];
return Array.from(new Set( return Array.from(new Set(

View file

@ -1,21 +1,5 @@
import type { DocumentListState } from '@/types/documents'; import type { DocumentListState } from '@/types/documents';
import { isBuiltInTtsProviderId, type TtsProviderType } from '@/lib/shared/tts-provider-catalog'; import 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;
}
export type ViewType = 'single' | 'dual' | 'scroll'; export type ViewType = 'single' | 'dual' | 'scroll';
@ -78,20 +62,18 @@ export interface AppConfigValues {
} }
/** /**
* Build defaults lazily so we can read SSR-injected admin overrides * Build the static app-config defaults. These no longer read any SSR/admin
* (`window.__RUNTIME_CONFIG__`). Modules that need the defaults * runtime config: the user's TTS provider defaults to empty ("inherit the
* statically should call `getAppConfigDefaults()` at use time. The exported * admin default"), which is resolved to a concrete provider where it is used
* `APP_CONFIG_DEFAULTS` is a Proxy that re-resolves on each access so * (ConfigContext on the client, credential resolution on the server).
* mutations to the runtime config (admin edits) are picked up by anything
* that reads through it.
*/ */
export function getAppConfigDefaults(): AppConfigValues { export function getAppConfigDefaults(): AppConfigValues {
const runtimeProviderRef = readRuntimeString('defaultTtsProvider', 'custom-openai'); // The user's TTS provider is intentionally left empty by default. An empty
const defaultProviderRef = runtimeProviderRef.trim(); // providerRef means "inherit the instance/admin default" and is resolved to a
const defaultProviderType = isBuiltInTtsProviderId(defaultProviderRef) ? defaultProviderRef : 'unknown'; // concrete provider at read time (see ConfigContext) and at generation time
const defaultModel = isBuiltInTtsProviderId(defaultProviderType) // (server-side credential resolution). We no longer bake a placeholder
? defaultModelForProviderType(defaultProviderType) // provider id (the old 'custom-openai') into every user's config, since that
: 'kokoro'; // value isn't actually selectable in shared-provider mode.
return { return {
apiKey: '', apiKey: '',
baseUrl: '', baseUrl: '',
@ -105,9 +87,9 @@ export function getAppConfigDefaults(): AppConfigValues {
footerMargin: 0, footerMargin: 0,
leftMargin: 0, leftMargin: 0,
rightMargin: 0, rightMargin: 0,
providerRef: defaultProviderRef, providerRef: '',
providerType: defaultProviderType, providerType: 'unknown',
ttsModel: defaultModel, ttsModel: '',
ttsInstructions: '', ttsInstructions: '',
savedVoices: {}, savedVoices: {},
segmentPreloadDepthPages: 1, segmentPreloadDepthPages: 1,
@ -134,17 +116,13 @@ export function getAppConfigDefaults(): AppConfigValues {
} }
/** /**
* Static defaults snapshot resolved at first access. For callers that need * Static defaults snapshot, resolved once at first access. The values are
* fresh values after admin edits, prefer `getAppConfigDefaults()` directly. * constant (no SSR/admin overrides are read here anymore), so the lazy Proxy
* Most consumers just need a stable defaults object for spreads, so this is * just avoids building the object until something first reads it.
* resolved once per process admin overrides take effect on next page load
* (which is the SSR-injected behavior we want anyway).
*/ */
let cachedDefaults: AppConfigValues | null = null; let cachedDefaults: AppConfigValues | null = null;
export const APP_CONFIG_DEFAULTS: AppConfigValues = (() => { export const APP_CONFIG_DEFAULTS: AppConfigValues = (() => {
// Return a getter-backed object that resolves on first access. On the // Getter-backed object that builds the defaults lazily on first access.
// server, this resolves to built-in defaults; on the client, to the
// SSR-injected admin values.
const handler: ProxyHandler<AppConfigValues> = { const handler: ProxyHandler<AppConfigValues> = {
get(_target, prop) { get(_target, prop) {
if (!cachedDefaults) cachedDefaults = getAppConfigDefaults(); if (!cachedDefaults) cachedDefaults = getAppConfigDefaults();

View file

@ -5,6 +5,8 @@ import { adminProviders } from '../../src/db/schema';
import { import {
AdminProviderError, AdminProviderError,
createAdminProvider,
decryptedKeyFor,
listAdminProviders, listAdminProviders,
toMasked, toMasked,
validateProviderType, validateProviderType,
@ -53,6 +55,23 @@ describe('admin provider validation', () => {
expect(toMasked(record).apiKeyMask).toBe('••••abcd'); 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 () => { test('lists providers in deterministic updated/created/slug order', async () => {
const suffix = `${Date.now()}${Math.random().toString(36).slice(2, 8)}`; const suffix = `${Date.now()}${Math.random().toString(36).slice(2, 8)}`;
const inserted = [ const inserted = [

View file

@ -8,6 +8,7 @@ import {
keepKokoroVoicesInOneLanguage, keepKokoroVoicesInOneLanguage,
normalizeOptionalLanguageTag, normalizeOptionalLanguageTag,
normalizeUnicodeToken, normalizeUnicodeToken,
resolveReplicateKokoroLanguageCode,
resolveTtsLanguage, resolveTtsLanguage,
segmentSentences, segmentSentences,
segmentWords, segmentWords,
@ -95,6 +96,13 @@ describe('multilingual language utilities', () => {
expect(toBaseLanguageCode('zh-CN')).toBe('zh'); 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', () => { test('normalizes Unicode tokens without dropping non-Latin scripts', () => {
expect(normalizeUnicodeToken('École!')).toBe('école'); expect(normalizeUnicodeToken('École!')).toBe('école');
expect(normalizeUnicodeToken('日本語。')).toBe('日本語'); expect(normalizeUnicodeToken('日本語。')).toBe('日本語');

View file

@ -160,6 +160,26 @@ describe('runtime seed JSON parsing', () => {
expect(__seedInternals.shouldUseEnvProviderFallback(false)).toBe(true); expect(__seedInternals.shouldUseEnvProviderFallback(false)).toBe(true);
expect(__seedInternals.shouldUseEnvProviderFallback(true)).toBe(false); 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', () => { 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 () => { test('does not use API_BASE/API_KEY fallback when providers key is present but empty', async () => {
const providerSnapshot = await snapshotProvidersBySlug(testSlugs); const providerSnapshot = await snapshotProvidersBySlug(testSlugs);
const runtimeSeed = JSON.stringify({ version: 1, providers: [] }); const runtimeSeed = JSON.stringify({ version: 1, providers: [] });

View file

@ -1,10 +1,12 @@
import { describe, expect, test } from 'vitest'; import { describe, expect, test } from 'vitest';
import { import {
buildReplicateInput,
buildTTSCacheKey, buildTTSCacheKey,
extractReplicateAudioUrl, extractReplicateAudioUrl,
resolveReplicateLanguageValue, resolveReplicateLanguageValue,
} from '../../src/lib/server/tts/generate'; } from '../../src/lib/server/tts/generate';
import { REPLICATE_KOKORO_82M_VERSIONED_MODEL } from '../../src/lib/shared/tts-provider-catalog';
describe('replicate output URL extraction', () => { describe('replicate output URL extraction', () => {
test('returns direct URL string output', () => { 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', 'Japanese'])).toBe('Japanese');
expect(resolveReplicateLanguageValue('ja-JP', ['English', 'French'])).toBeNull(); 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; 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', () => { describe('config helpers', () => {
@ -476,9 +520,13 @@ describe('config helpers', () => {
ttsModel: 'kokoro', 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({ expect(buildSyncedPreferencePatch({
voiceSpeed: 1, voiceSpeed: 1,
ttsModel: 'kokoro', providerRef: '',
providerType: 'unknown',
ttsModel: '',
}, { nonDefaultOnly: true })).toEqual({}); }, { 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();
});
});