feat(tts,config,types): migrate to providerRef/providerType model and add centralized TTS provider policy

Transition all TTS-related logic, types, and UI to use the new providerRef/providerType model in place of legacy ttsProvider fields. Introduce a centralized tts-provider-policy module to encapsulate provider/model capability checks, default value resolution, and compatibility logic. Update all API routes, contexts, hooks, components, and tests to use providerRef and providerType, ensuring consistent handling of built-in and shared TTS providers. Remove legacy defaultTtsModel config in favor of per-provider defaults and shared provider admin control. Add the showAllProviderModels runtime flag to restrict users to provider default models when desired.

BREAKING CHANGE: ttsProvider fields are replaced by providerRef/providerType throughout the codebase; defaultTtsModel config is removed in favor of per-provider defaults.
This commit is contained in:
Richard R 2026-05-13 10:11:05 -06:00
parent dae97b2afc
commit b4f4d43d6a
43 changed files with 1225 additions and 470 deletions

View file

@ -54,7 +54,7 @@ On first boot, if `admin_providers` is empty and the legacy `API_KEY` env var is
- slug `default-openai`, displayName `Default (from env)`, providerType `custom-openai`
- baseUrl from `API_BASE`, apiKey from `API_KEY` (encrypted)
- defaultModel from `NEXT_PUBLIC_DEFAULT_TTS_MODEL` if set
- 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.
@ -65,10 +65,10 @@ Runtime-editable settings, one row per key:
| Key | What it controls |
| --- | --- |
| `defaultTtsProvider` | Default provider id new users start with (built-in id or shared slug). |
| `defaultTtsModel` | Default model id new users start with. |
| `restrictUserApiKeys` | Restrict user-supplied API keys/base URLs; when `true`, only admin shared providers are allowed. |
| `enableTtsProvidersTab` | Whether the user-facing TTS Provider tab in Settings is shown. |
| `showAllDeepInfraModels` | Show the full DeepInfra catalog vs. the Kokoro-only subset. |
| `showAllProviderModels` | When `false`, users are restricted to each provider's default model (shared provider `defaultModel` or built-in provider default). |
| `enableWordHighlight` | Enable whisper.cpp word-by-word highlighting during TTS playback. |
| `enableAudiobookExport` | Show the audiobook export entry points on PDF/EPUB pages. |
| `enableDocxConversion` | Accept .docx uploads (converted to PDF server-side). |

View file

@ -17,9 +17,10 @@ Use Replicate's hosted TTS models as your provider.
```env
API_KEY=r8_...
NEXT_PUBLIC_DEFAULT_TTS_MODEL=alphanumericuser/kokoro-82m:89b6fa84e4fa2dd6bd3a96be3e1f12827a3516c9fda8fddbac7a0be131c9a6f5
```
Then update the shared provider's **Default model** in **Settings → Admin → Shared providers**.
**Per-user Settings → TTS Provider (only when `restrictUserApiKeys=false`):**
1. Set provider to `Replicate`.

View file

@ -55,7 +55,7 @@ After the first successful deploy and admin login, open **Settings → Admin** a
- `enableTtsProvidersTab=false` if you want shared-provider-only UX.
- `restrictUserApiKeys=true` to block user BYOK through the hosted server.
- `defaultTtsProvider=replicate` (or your preferred shared slug).
- `defaultTtsModel=alphanumericuser/kokoro-82m:89b6fa84e4fa2dd6bd3a96be3e1f12827a3516c9fda8fddbac7a0be131c9a6f5`.
- `showAllProviderModels=false` if you want users locked to each provider's default model.
- `showAllDeepInfraModels=false` if you want a narrower default catalog.
- `enableAudiobookExport=true`.
- `enableWordHighlight=false` unless your timestamp stack is configured.

View file

@ -403,13 +403,7 @@ Sets the default TTS provider for new users.
- Example values: `replicate`, `deepinfra`, `openai`, `custom-openai`, or an admin-defined shared provider slug (e.g. `kokoro-prod`)
- Runtime key: `defaultTtsProvider`
### NEXT_PUBLIC_DEFAULT_TTS_MODEL
Sets the default TTS model for new users.
- Default: `kokoro`
- Example values: `hexgrad/Kokoro-82M`, `tts-1`
- Runtime key: `defaultTtsModel`
`showAllProviderModels` is a runtime-only admin setting (no env seed). Configure it in **Settings → Admin → Site features**.
### NEXT_PUBLIC_SHOW_ALL_DEEPINFRA_MODELS

View file

@ -32,7 +32,8 @@ import { generateTTSBuffer } from '@/lib/server/tts/generate';
import { resolveTtsCredentials } from '@/lib/server/admin/resolve-credentials';
import { resolveEffectiveTtsInstructions } from '@/lib/server/admin/tts-instructions';
import { getUpstreamRetryAfterSeconds, getUpstreamStatus } from '@/lib/server/tts/upstream-response';
import { supportsNativeModelSpeed, supportsTtsInstructions } from '@/lib/shared/tts-provider-catalog';
import { defaultVoiceForProviderType, resolveTtsModelForProvider, resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy';
import { isBuiltInTtsProviderId, isTtsProviderType } from '@/lib/shared/tts-provider-catalog';
import { getResolvedRuntimeConfig } from '@/lib/server/runtime-config';
import type { AudiobookGenerationSettings } from '@/types/client';
import type { TTSAudiobookFormat } from '@/types/tts';
@ -99,7 +100,11 @@ function s3NotConfiguredResponse(): NextResponse {
}
function normalizeNativeSpeedForSettings(settings: AudiobookGenerationSettings): AudiobookGenerationSettings {
return supportsNativeModelSpeed(settings.ttsProvider, settings.ttsModel)
return resolveTtsProviderModelPolicy({
providerRef: settings.providerRef,
providerType: settings.providerType,
model: settings.ttsModel,
}).supportsNativeModelSpeed
? settings
: { ...settings, nativeSpeed: 1 };
}
@ -118,7 +123,8 @@ function isAudiobookGenerationSettings(value: unknown): value is AudiobookGenera
}
const record = value as Record<string, unknown>;
return typeof record.ttsProvider === 'string'
return typeof record.providerRef === 'string'
&& isTtsProviderType(record.providerType)
&& typeof record.ttsModel === 'string'
&& typeof record.voice === 'string'
&& isFiniteNumber(record.nativeSpeed)
@ -364,7 +370,8 @@ export async function POST(request: NextRequest) {
if (normalizedExistingSettings && hasChapters && incomingSettings) {
const mismatch =
normalizedExistingSettings.ttsProvider !== incomingSettings.ttsProvider ||
normalizedExistingSettings.providerRef !== incomingSettings.providerRef ||
normalizedExistingSettings.providerType !== incomingSettings.providerType ||
normalizedExistingSettings.ttsModel !== incomingSettings.ttsModel ||
normalizedExistingSettings.voice !== incomingSettings.voice ||
normalizedExistingSettings.nativeSpeed !== incomingSettings.nativeSpeed ||
@ -409,7 +416,7 @@ export async function POST(request: NextRequest) {
}
const requestedProvider = request.headers.get('x-tts-provider')
|| mergedSettings?.ttsProvider
|| mergedSettings?.providerRef
|| 'openai';
providerForError = requestedProvider;
const runtimeConfig = await getResolvedRuntimeConfig();
@ -433,15 +440,21 @@ export async function POST(request: NextRequest) {
);
}
const provider = credResolved.provider;
if (!isBuiltInTtsProviderId(provider)) {
return NextResponse.json({ error: `Unsupported TTS provider type: ${provider}` }, { status: 500 });
}
const openApiKey = credResolved.apiKey || 'none';
const openApiBaseUrl = credResolved.baseUrl;
const model = mergedSettings?.ttsModel;
const voice = mergedSettings?.voice
|| (provider === 'openai'
? 'alloy'
: provider === 'deepinfra'
? 'af_bella'
: 'af_sarah');
const effectiveProviderRef = credResolved.adminRecord?.slug || requestedProvider;
const model = resolveTtsModelForProvider({
providerRef: effectiveProviderRef,
providerType: provider,
model: mergedSettings?.ttsModel,
sharedProviders: credResolved.adminRecord ? [credResolved.adminRecord] : [],
fallbackProviderRef: runtimeConfig.defaultTtsProvider,
showAllProviderModels: runtimeConfig.showAllProviderModels,
});
const voice = mergedSettings?.voice || defaultVoiceForProviderType(provider);
const rawNativeSpeed = mergedSettings?.nativeSpeed ?? 1;
const nativeSpeed = Number.isFinite(Number(rawNativeSpeed)) ? Number(rawNativeSpeed) : 1;
const instructions = resolveEffectiveTtsInstructions({
@ -586,7 +599,14 @@ export async function POST(request: NextRequest) {
if (!normalizedExistingSettings && incomingSettings) {
const settingsToPersist: AudiobookGenerationSettings = {
...incomingSettings,
...(supportsTtsInstructions(incomingSettings.ttsModel)
providerRef: effectiveProviderRef,
providerType: provider,
ttsModel: model,
...(resolveTtsProviderModelPolicy({
providerRef: effectiveProviderRef,
providerType: provider,
model,
}).supportsInstructions
? { ttsInstructions: instructions ?? '' }
: { ttsInstructions: '' }),
};

View file

@ -21,6 +21,7 @@ import {
projectSegmentLocator,
probeAudioDurationMsFromBuffer,
} from '@/lib/server/tts/segments';
import { isBuiltInTtsProviderId, isTtsProviderType } from '@/lib/shared/tts-provider-catalog';
import { resolveSegmentDocumentScope } from '@/lib/server/tts/segments-auth';
import { rateLimiter, isTtsRateLimitEnabled } from '@/lib/server/rate-limit/rate-limiter';
import { resolveTtsCredentials } from '@/lib/server/admin/resolve-credentials';
@ -31,6 +32,7 @@ import { buildDailyQuotaExceededResponse } from '@/lib/server/rate-limit/problem
import { getUpstreamRetryAfterSeconds, getUpstreamStatus } from '@/lib/server/tts/upstream-response';
import { alignAudioWithText } from '@/lib/server/whisper/alignment';
import { getResolvedRuntimeConfig } from '@/lib/server/runtime-config';
import { resolveTtsModelForProvider } from '@/lib/shared/tts-provider-policy';
import type {
TTSSegmentInput,
TTSSegmentManifestItem,
@ -50,14 +52,16 @@ function attachDeviceIdCookie(response: NextResponse, deviceId: string | null, d
function parseSettings(value: unknown): TTSSegmentSettings | null {
if (!value || typeof value !== 'object') return null;
const rec = value as Record<string, unknown>;
if (typeof rec.ttsProvider !== 'string') return null;
if (typeof rec.providerRef !== 'string') return null;
if (!isTtsProviderType(rec.providerType)) return null;
if (typeof rec.ttsModel !== 'string') return null;
if (typeof rec.voice !== 'string') return null;
if (!Number.isFinite(Number(rec.nativeSpeed))) return null;
if (rec.ttsInstructions !== undefined && typeof rec.ttsInstructions !== 'string') return null;
return {
ttsProvider: rec.ttsProvider,
providerRef: rec.providerRef,
providerType: rec.providerType,
ttsModel: rec.ttsModel,
voice: rec.voice,
nativeSpeed: Number(rec.nativeSpeed),
@ -169,7 +173,7 @@ export async function POST(request: NextRequest) {
if (scope instanceof Response) return scope;
const runtimeConfig = await getResolvedRuntimeConfig();
const requestCreds = await resolveTtsCredentials({
providerHeader: parsed.settings.ttsProvider,
providerHeader: parsed.settings.providerRef,
apiKeyHeader: request.headers.get('x-openai-key'),
baseUrlHeader: request.headers.get('x-openai-base-url'),
fallbackProvider: runtimeConfig.defaultTtsProvider,
@ -189,13 +193,28 @@ export async function POST(request: NextRequest) {
// Normalize request settings to the effective generation settings so cache
// keys and persisted metadata match what we actually synthesize.
const effectiveInstructions = resolveEffectiveTtsInstructions({
const effectiveProviderRef = requestCreds.adminRecord?.slug || parsed.settings.providerRef;
const effectiveModel = resolveTtsModelForProvider({
providerRef: effectiveProviderRef,
providerType: isBuiltInTtsProviderId(requestCreds.provider) ? requestCreds.provider : 'unknown',
model: parsed.settings.ttsModel,
sharedProviders: requestCreds.adminRecord ? [requestCreds.adminRecord] : [],
fallbackProviderRef: runtimeConfig.defaultTtsProvider,
showAllProviderModels: runtimeConfig.showAllProviderModels,
});
const effectiveInstructions = resolveEffectiveTtsInstructions({
model: effectiveModel,
requestInstructions: parsed.settings.ttsInstructions,
sharedDefaultInstructions: requestCreds.adminRecord?.defaultInstructions,
}) ?? '';
const resolvedProviderType = isBuiltInTtsProviderId(requestCreds.provider)
? requestCreds.provider
: 'unknown';
const effectiveSettings: TTSSegmentSettings = {
...parsed.settings,
providerRef: effectiveProviderRef,
providerType: resolvedProviderType,
ttsModel: effectiveModel,
ttsInstructions: effectiveInstructions,
};

View file

@ -17,6 +17,8 @@ import type {
TTSSegmentVariant,
TTSSegmentsManifestResponse,
} from '@/types/client';
import { isTtsProviderType } from '@/lib/shared/tts-provider-catalog';
import { resolveEffectiveProviderType } from '@/lib/shared/tts-provider-policy';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
@ -44,20 +46,31 @@ function parseSettingsValue(value: unknown): TTSSegmentSettings | null {
if (!raw || typeof raw !== 'object') return null;
const rec = raw as Record<string, unknown>;
const ttsProvider = typeof rec.ttsProvider === 'string'
? rec.ttsProvider
: typeof rec.provider === 'string' ? rec.provider : null;
// Accept both current settings shape and canonicalized legacy keys from
// persisted rows (e.g., SQLite stringified settings with `model/speed`).
const providerRef = typeof rec.providerRef === 'string'
? rec.providerRef
: typeof rec.ttsProvider === 'string'
? rec.ttsProvider
: typeof rec.provider === 'string'
? rec.provider
: null;
const providerType = isTtsProviderType(rec.providerType)
? rec.providerType
: resolveEffectiveProviderType({ providerRef });
const ttsModel = typeof rec.ttsModel === 'string'
? rec.ttsModel
: typeof rec.model === 'string' ? rec.model : null;
: typeof rec.model === 'string'
? rec.model
: null;
const voice = typeof rec.voice === 'string' ? rec.voice : null;
const speedSource = rec.nativeSpeed ?? rec.speed;
const nativeSpeed = Number.isFinite(Number(speedSource)) ? Number(speedSource) : 1;
const instructionsSource = rec.ttsInstructions ?? rec.instructions;
const ttsInstructions = typeof instructionsSource === 'string' ? instructionsSource : '';
if (!ttsProvider || !ttsModel || !voice) return null;
return { ttsProvider, ttsModel, voice, nativeSpeed, ttsInstructions };
if (!providerRef || !providerType || !ttsModel || !voice) return null;
return { providerRef, providerType, ttsModel, voice, nativeSpeed, ttsInstructions };
}
function locatorFromProjection(row: ManifestGroupRow): TTSSegmentLocator | null {

View file

@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { auth } from '@/lib/server/auth/auth';
import { getDefaultVoices } from '@/lib/shared/tts-provider-catalog';
import { isBuiltInTtsProviderId } from '@/lib/shared/tts-provider-catalog';
import { defaultModelForProviderType, resolveTtsModelForProvider, resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy';
import { resolveVoices } from '@/lib/server/tts/voice-resolution';
import { resolveTtsCredentials } from '@/lib/server/admin/resolve-credentials';
import { getResolvedRuntimeConfig } from '@/lib/server/runtime-config';
@ -38,9 +39,20 @@ export async function GET(req: NextRequest) {
);
}
const requestedModel = req.headers.get('x-tts-model')
|| resolved.adminRecord?.defaultModel
|| 'tts-1';
if (!isBuiltInTtsProviderId(resolved.provider)) {
return NextResponse.json({ error: `Unsupported provider type: ${resolved.provider}` }, { status: 500 });
}
const effectiveProviderRef = resolved.adminRecord?.slug
?? req.headers.get('x-tts-provider')
?? runtimeConfig.defaultTtsProvider;
const requestedModel = resolveTtsModelForProvider({
providerRef: effectiveProviderRef,
providerType: resolved.provider,
model: req.headers.get('x-tts-model'),
sharedProviders: resolved.adminRecord ? [resolved.adminRecord] : [],
fallbackProviderRef: runtimeConfig.defaultTtsProvider,
showAllProviderModels: runtimeConfig.showAllProviderModels,
}) || defaultModelForProviderType(resolved.provider);
const voices = await resolveVoices({
provider: resolved.provider,
model: requestedModel,
@ -50,10 +62,18 @@ export async function GET(req: NextRequest) {
return NextResponse.json({ voices });
} catch (error) {
console.error('Error in voices endpoint:', error);
const provider = req.headers.get('x-tts-provider') || 'openai';
const providerRef = req.headers.get('x-tts-provider') || 'openai';
const model = req.headers.get('x-tts-model') || 'tts-1';
const provider = isBuiltInTtsProviderId(providerRef) ? providerRef : 'openai';
return NextResponse.json(
{ error: 'Failed to resolve voices', fallbackVoices: getDefaultVoices(provider, model) },
{
error: 'Failed to resolve voices',
fallbackVoices: resolveTtsProviderModelPolicy({
providerRef,
providerType: provider,
model,
}).defaultVoices,
},
{ status: 500 },
);
}

View file

@ -5,6 +5,10 @@ import { userPreferences } from '@/db/schema';
import { SYNCED_PREFERENCE_KEYS, 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';
export const dynamic = 'force-dynamic';
@ -17,17 +21,54 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
function parseStoredPreferences(value: unknown): SyncedPreferencesPatch {
if (!value) return {};
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,
sharedProviders: providers
.filter((entry) => entry.enabled)
.map((entry) => ({
slug: entry.slug,
providerType: entry.providerType,
defaultModel: entry.defaultModel,
defaultInstructions: entry.defaultInstructions,
})),
};
}
function parseStoredPreferences(
value: unknown,
context: PreferenceNormalizationContext,
): { patch: SyncedPreferencesPatch; migrated: boolean } {
if (!value) return { patch: {}, migrated: false };
if (typeof value === 'string') {
try {
const parsed = JSON.parse(value);
return isRecord(parsed) ? sanitizePreferencesPatch(parsed) : {};
return isRecord(parsed)
? sanitizePreferencesPatch(parsed, context, { fillMissingProvider: true })
: { patch: {}, migrated: false };
} catch {
return {};
return { patch: {}, migrated: false };
}
}
return isRecord(value) ? sanitizePreferencesPatch(value) : {};
return isRecord(value)
? sanitizePreferencesPatch(value, context, { fillMissingProvider: true })
: { patch: {}, migrated: false };
}
function sanitizeSavedVoices(value: unknown): Record<string, string> {
@ -41,25 +82,52 @@ function sanitizeSavedVoices(value: unknown): Record<string, string> {
return out;
}
function sanitizePreferencesPatch(input: unknown): SyncedPreferencesPatch {
if (!isRecord(input)) return {};
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 input)) continue;
const value = input[key];
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 'ttsProvider':
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':
@ -88,7 +156,40 @@ function sanitizePreferencesPatch(input: unknown): SyncedPreferencesPatch {
}
}
return out;
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 {
@ -99,6 +200,7 @@ function normalizeClientUpdatedAtMs(value: unknown): number {
export async function GET(req: NextRequest) {
try {
const normalizationContext = await loadPreferenceNormalizationContext();
const scope = await resolveUserStateScope(req);
if (scope instanceof Response) return scope;
@ -112,9 +214,29 @@ export async function GET(req: NextRequest) {
.limit(1);
const row = rows[0];
const storedPatch = parseStoredPreferences(row?.dataJson);
const stored = parseStoredPreferences(row?.dataJson, normalizationContext);
const storedPatch = stored.patch;
const clientUpdatedAtMs = Number(row?.clientUpdatedAtMs ?? 0);
if (row && stored.migrated) {
const updatedAt = nowTimestampMs();
await db
.insert(userPreferences)
.values({
userId: scope.ownerUserId,
dataJson: serializePreferencesForDb(storedPatch),
clientUpdatedAtMs: clientUpdatedAtMs > 0 ? clientUpdatedAtMs : updatedAt,
updatedAt,
})
.onConflictDoUpdate({
target: [userPreferences.userId],
set: {
dataJson: serializePreferencesForDb(storedPatch),
updatedAt,
},
});
}
return NextResponse.json({
preferences: storedPatch,
clientUpdatedAtMs,
@ -128,13 +250,18 @@ export async function GET(req: NextRequest) {
export async function PUT(req: NextRequest) {
try {
const normalizationContext = await loadPreferenceNormalizationContext();
const scope = await resolveUserStateScope(req);
if (scope instanceof Response) return scope;
const body = (await req.json().catch(() => null)) as
| { patch?: unknown; clientUpdatedAtMs?: unknown }
| null;
const patch = sanitizePreferencesPatch(body?.patch);
const patch = sanitizePreferencesPatch(
body?.patch,
normalizationContext,
{ fillMissingProvider: false },
).patch;
const clientUpdatedAtMs = normalizeClientUpdatedAtMs(body?.clientUpdatedAtMs);
if (Object.keys(patch).length === 0) {
@ -151,7 +278,7 @@ export async function PUT(req: NextRequest) {
.limit(1);
const existing = existingRows[0];
const existingUpdated = Number(existing?.clientUpdatedAtMs ?? 0);
const existingPatch = parseStoredPreferences(existing?.dataJson);
const existingPatch = parseStoredPreferences(existing?.dataJson, normalizationContext).patch;
if (existing && clientUpdatedAtMs < existingUpdated) {
return NextResponse.json({

View file

@ -12,7 +12,7 @@ import { useConfig } from '@/contexts/ConfigContext';
import { useTTS } from '@/contexts/TTSContext';
import { VoicesControlBase } from '@/components/player/VoicesControlBase';
import { ReaderSidebarShell } from '@/components/reader/ReaderSidebarShell';
import { supportsNativeModelSpeed, supportsTtsInstructions } from '@/lib/shared/tts-provider-catalog';
import { resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy';
import type { TTSAudiobookChapter, TTSAudiobookFormat } from '@/types/tts';
import {
getAudiobookStatus,
@ -49,7 +49,7 @@ export function AudiobookExportModal({
onGenerateAudiobook,
onRegenerateChapter
}: AudiobookExportModalProps) {
const { isLoading, isDBReady, ttsProvider, ttsModel, ttsInstructions, voice: configVoice, voiceSpeed, audioPlayerSpeed } = useConfig();
const { isLoading, isDBReady, providerRef, providerType, ttsModel, ttsInstructions, voice: configVoice, voiceSpeed, audioPlayerSpeed } = useConfig();
const { availableVoices } = useTTS();
const { progress, setProgress, estimatedTimeRemaining } = useTimeEstimation();
const [isGenerating, setIsGenerating] = useState(false);
@ -74,7 +74,11 @@ export function AudiobookExportModal({
const formatSpeed = useCallback((speed: number) => {
return Number.isInteger(speed) ? speed.toString() : speed.toFixed(1);
}, []);
const nativeSpeedSupported = useMemo(() => supportsNativeModelSpeed(ttsProvider, ttsModel), [ttsProvider, ttsModel]);
const providerModelPolicy = useMemo(
() => resolveTtsProviderModelPolicy({ providerRef, providerType, model: ttsModel }),
[providerRef, providerType, ttsModel],
);
const nativeSpeedSupported = providerModelPolicy.supportsNativeModelSpeed;
const effectiveNativeSpeed = nativeSpeedSupported ? nativeSpeed : 1;
const hasExistingAudiobook = Boolean(bookId) || chapters.length > 0;
@ -113,15 +117,16 @@ export function AudiobookExportModal({
const nextVoice = audiobookVoice || configVoice || availableVoices[0] || '';
if (!nextVoice) return null;
return {
ttsProvider,
providerRef,
providerType,
ttsModel,
voice: nextVoice,
nativeSpeed: effectiveNativeSpeed,
postSpeed,
format,
ttsInstructions: supportsTtsInstructions(ttsModel) ? ttsInstructions : undefined,
ttsInstructions: providerModelPolicy.supportsInstructions ? ttsInstructions : undefined,
};
}, [savedSettings, audiobookVoice, configVoice, availableVoices, ttsProvider, ttsModel, ttsInstructions, effectiveNativeSpeed, postSpeed, format]);
}, [savedSettings, audiobookVoice, configVoice, availableVoices, providerRef, providerType, ttsModel, ttsInstructions, effectiveNativeSpeed, postSpeed, format, providerModelPolicy.supportsInstructions]);
const fetchExistingChapters = useCallback(async (soft: boolean = false) => {
if (soft) {
@ -514,7 +519,11 @@ export function AudiobookExportModal({
<div className="rounded-lg bg-base p-3">
<div className="text-[11px] uppercase tracking-wider text-muted mb-1">Native speed</div>
<div className="text-sm font-medium text-foreground">
{supportsNativeModelSpeed(savedSettings.ttsProvider, savedSettings.ttsModel)
{resolveTtsProviderModelPolicy({
providerRef: savedSettings.providerRef,
providerType: savedSettings.providerType,
model: savedSettings.ttsModel,
}).supportsNativeModelSpeed
? `${formatSpeed(savedSettings.nativeSpeed)}x`
: 'Not supported'}
</div>
@ -538,7 +547,7 @@ export function AudiobookExportModal({
availableVoices={availableVoices}
voice={audiobookVoice}
onChangeVoice={setAudiobookVoice}
ttsProvider={ttsProvider}
providerType={providerType}
ttsModel={ttsModel}
dropdownDirection="down"
variant="field"

View file

@ -36,7 +36,17 @@ import { deleteDocuments, mimeTypeForDoc, uploadDocuments } from '@/lib/client/a
import { cacheStoredDocumentFromBytes, clearDocumentCache } from '@/lib/client/cache/documents';
import { clearAllDocumentPreviewCaches, clearInMemoryDocumentPreviewCache } from '@/lib/client/cache/previews';
import { resolveTtsSettingsViewModel } from '@/lib/client/settings/tts-settings';
import { REPLICATE_KOKORO_82M_VERSIONED_MODEL, supportsTtsInstructions } from '@/lib/shared/tts-provider-catalog';
import {
isBuiltInTtsProviderId,
type TtsProviderType,
} from '@/lib/shared/tts-provider-catalog';
import {
defaultBaseUrlForProviderType,
defaultModelForProviderType,
resolveProviderDefaults,
resolveEffectiveProviderType,
resolveTtsProviderModelPolicy,
} from '@/lib/shared/tts-provider-policy';
import { useRuntimeConfig } from '@/contexts/RuntimeConfigContext';
import { AdminProvidersPanel } from '@/components/admin/AdminProvidersPanel';
import { AdminFeaturesPanel } from '@/components/admin/AdminFeaturesPanel';
@ -105,6 +115,7 @@ export function SettingsModal({ className = '' }: { className?: string }) {
const runtimeConfig = useRuntimeConfig();
const enableDestructiveDelete = runtimeConfig.enableDestructiveDeleteActions;
const showAllDeepInfra = runtimeConfig.showAllDeepInfraModels;
const showAllProviderModels = runtimeConfig.showAllProviderModels;
const enableTTSProvidersTab = runtimeConfig.enableTtsProvidersTab;
const restrictUserApiKeys = runtimeConfig.restrictUserApiKeys;
const [isOpen, setIsOpen] = useState(false);
@ -113,11 +124,12 @@ export function SettingsModal({ className = '' }: { className?: string }) {
const { theme, setTheme, applyCustomColors } = useTheme();
const [customColors, setCustomColors] = useState<CustomThemeColors>(getCustomThemeColors);
const [isCustomExpanded, setIsCustomExpanded] = useState(false);
const { apiKey, baseUrl, ttsProvider, ttsModel, ttsInstructions, updateConfig, updateConfigKey } = useConfig();
const { apiKey, baseUrl, providerRef, providerType, ttsModel, ttsInstructions, updateConfig, updateConfigKey } = useConfig();
const { refreshDocuments } = useDocuments();
const [localApiKey, setLocalApiKey] = useState(apiKey);
const [localBaseUrl, setLocalBaseUrl] = useState(baseUrl);
const [localTTSProvider, setLocalTTSProvider] = useState(ttsProvider);
const [localProviderRef, setLocalProviderRef] = useState(providerRef);
const [localProviderType, setLocalProviderType] = useState<TtsProviderType>(providerType);
const [modelValue, setModelValue] = useState(ttsModel);
const [customModelInput, setCustomModelInput] = useState('');
const [localTTSInstructions, setLocalTTSInstructions] = useState(ttsInstructions);
@ -154,17 +166,21 @@ export function SettingsModal({ className = '' }: { className?: string }) {
selectedModelId,
canSubmit,
selectedSharedProvider,
selectedProviderRef,
selectedProviderType,
} = useMemo(() => resolveTtsSettingsViewModel({
provider: localTTSProvider,
providerRef: localProviderRef,
providerType: localProviderType,
apiKey: localApiKey,
modelValue,
customModelInput,
showAllDeepInfra,
showAllProviderModels,
sharedProviders,
allowBuiltInProviders: !restrictUserApiKeys,
}), [localTTSProvider, localApiKey, modelValue, customModelInput, showAllDeepInfra, sharedProviders, restrictUserApiKeys]);
}), [localProviderRef, localProviderType, localApiKey, modelValue, customModelInput, showAllDeepInfra, showAllProviderModels, sharedProviders, restrictUserApiKeys]);
const isSharedSelected = Boolean(selectedSharedProvider);
const selectedProviderOption = ttsProviders.find((p) => p.id === localTTSProvider) ?? ttsProviders[0];
const selectedProviderOption = ttsProviders.find((p) => p.id === localProviderRef) ?? ttsProviders[0];
const checkFirstVist = useCallback(async () => {
const appConfig = await getAppConfig();
@ -187,10 +203,11 @@ export function SettingsModal({ className = '' }: { className?: string }) {
useEffect(() => {
setLocalApiKey(apiKey);
setLocalBaseUrl(baseUrl);
setLocalTTSProvider(ttsProvider);
setLocalProviderRef(providerRef);
setLocalProviderType(providerType);
setModelValue(ttsModel);
setLocalTTSInstructions(ttsInstructions);
}, [apiKey, baseUrl, ttsProvider, ttsModel, ttsInstructions]);
}, [apiKey, baseUrl, providerRef, providerType, ttsModel, ttsInstructions]);
useEffect(() => {
if (!authEnabled) {
@ -216,12 +233,31 @@ export function SettingsModal({ className = '' }: { className?: string }) {
}, [modelValue, ttsModels]);
useEffect(() => {
if (!restrictUserApiKeys) return;
if (selectedProviderOption) return;
if (ttsProviders.length > 0) {
setLocalTTSProvider(ttsProviders[0].id);
if (ttsProviders.length === 0) return;
const fallback = ttsProviders[0];
setLocalProviderRef(fallback.id);
setLocalProviderType(fallback.providerType);
if (fallback.shared) {
const shared = sharedProviders.find((p) => p.slug === fallback.id);
if (shared?.defaultModel) {
setModelValue(shared.defaultModel);
}
setLocalTTSInstructions(shared?.defaultInstructions ?? '');
setLocalApiKey('');
setLocalBaseUrl('');
setCustomModelInput('');
return;
}
}, [restrictUserApiKeys, selectedProviderOption, ttsProviders]);
if (isBuiltInTtsProviderId(fallback.providerType)) {
setModelValue(defaultModelForProviderType(fallback.providerType));
setLocalBaseUrl(defaultBaseUrlForProviderType(fallback.providerType));
setCustomModelInput('');
}
}, [selectedProviderOption, ttsProviders, sharedProviders]);
const handleRefresh = async () => {
try {
@ -364,7 +400,8 @@ export function SettingsModal({ className = '' }: { className?: string }) {
setIsOpen(false);
setLocalApiKey(apiKey);
setLocalBaseUrl(baseUrl);
setLocalTTSProvider(ttsProvider);
setLocalProviderRef(providerRef);
setLocalProviderType(providerType);
setModelValue(ttsModel);
setLocalTTSInstructions(ttsInstructions);
if (!ttsModels.some(m => m.id === ttsModel) && ttsModel !== '') {
@ -372,7 +409,7 @@ export function SettingsModal({ className = '' }: { className?: string }) {
} else {
setCustomModelInput('');
}
}, [apiKey, baseUrl, ttsProvider, ttsModel, ttsInstructions, ttsModels]);
}, [apiKey, baseUrl, providerRef, providerType, ttsModel, ttsInstructions, ttsModels]);
const [systemIsDark, setSystemIsDark] = useState(
typeof window !== 'undefined' && window.matchMedia('(prefers-color-scheme: dark)').matches
@ -428,11 +465,22 @@ export function SettingsModal({ className = '' }: { className?: string }) {
const btnPrimary = `${btnBase} bg-accent text-background hover:bg-secondary-accent hover:scale-[1.04]`;
const btnSecondary = `${btnBase} bg-background text-foreground hover:bg-offbase hover:text-accent hover:scale-[1.04]`;
const btnOutline = `${btnBase} bg-background border border-offbase text-foreground hover:bg-offbase hover:text-accent hover:scale-[1.02]`;
const effectiveProviderType = selectedSharedProvider?.providerType ?? localTTSProvider;
const effectiveProviderType = resolveEffectiveProviderType({
providerRef: selectedProviderRef,
providerType: localProviderType,
sharedProviders,
});
const providerModelPolicy = resolveTtsProviderModelPolicy({
providerRef: selectedProviderRef,
providerType: effectiveProviderType,
model: modelValue,
sharedProviders,
});
const shouldShowBaseUrl = !restrictUserApiKeys
&& !isSharedSelected
&& effectiveProviderType !== 'replicate'
&& (effectiveProviderType === 'custom-openai' || !localBaseUrl || localBaseUrl === '');
&& providerModelPolicy.isResolvedProviderType
&& providerModelPolicy.providerType !== 'replicate'
&& (providerModelPolicy.providerType === 'custom-openai' || !localBaseUrl || localBaseUrl === '');
const shouldShowApiKey = !restrictUserApiKeys && !isSharedSelected;
const selectedModel = ttsModels.find(m => m.id === selectedModelId) || ttsModels[0];
const selectedModelVersion = selectedModel?.id?.includes(':')
@ -551,26 +599,21 @@ export function SettingsModal({ className = '' }: { className?: string }) {
<Listbox
value={selectedProviderOption!}
onChange={(provider) => {
setLocalTTSProvider(provider.id);
const defaults = resolveProviderDefaults({
providerRef: provider.id,
providerType: provider.providerType,
sharedProviders,
});
setLocalProviderRef(provider.id);
setLocalProviderType(defaults.providerType);
setModelValue(defaults.defaultModel);
setLocalTTSInstructions(defaults.defaultInstructions);
if (provider.shared) {
// Shared admin provider — credentials live on the server.
setLocalApiKey('');
setLocalBaseUrl('');
const shared = sharedProviders.find((p) => p.slug === provider.id);
if (shared?.defaultModel) setModelValue(shared.defaultModel);
setLocalTTSInstructions(shared?.defaultInstructions ?? '');
} else if (provider.id === 'openai') {
setModelValue('tts-1');
setLocalBaseUrl('https://api.openai.com/v1');
} else if (provider.id === 'custom-openai') {
setModelValue('kokoro');
setLocalBaseUrl('');
} else if (provider.id === 'replicate') {
setModelValue(REPLICATE_KOKORO_82M_VERSIONED_MODEL);
setLocalBaseUrl('');
} else if (provider.id === 'deepinfra') {
setModelValue('hexgrad/Kokoro-82M');
setLocalBaseUrl('https://api.deepinfra.com/v1/openai');
} else if (isBuiltInTtsProviderId(provider.providerType)) {
setLocalBaseUrl(defaultBaseUrlForProviderType(provider.providerType));
}
setCustomModelInput('');
}}
@ -652,7 +695,7 @@ export function SettingsModal({ className = '' }: { className?: string }) {
type="password"
value={localApiKey}
onChange={(e) => handleInputChange('apiKey', e.target.value)}
placeholder={!showAllDeepInfra && localTTSProvider === 'deepinfra' ? "Deepinfra free or use your API key" : "Using environment variable"}
placeholder={!showAllDeepInfra && providerModelPolicy.providerType === 'deepinfra' ? "Deepinfra free or use your API key" : "Using environment variable"}
className="w-full rounded-lg bg-background py-2 px-3 text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent"
/>
</div>
@ -665,6 +708,11 @@ export function SettingsModal({ className = '' }: { className?: string }) {
<div className="space-y-1.5">
<label className="block text-sm font-medium text-foreground">TTS Model</label>
{!showAllProviderModels && (
<p className="text-xs text-muted">
This instance restricts model selection to each provider&apos;s default model.
</p>
)}
<div className="flex flex-col gap-2">
<Listbox
value={ttsModels.find(m => m.id === selectedModelId) || ttsModels[0]}
@ -752,7 +800,7 @@ export function SettingsModal({ className = '' }: { className?: string }) {
</div>
</div>
{supportsTtsInstructions(modelValue) && (
{providerModelPolicy.supportsInstructions && (
<div className="space-y-1.5">
<label className="block text-sm font-medium text-foreground">TTS Instructions</label>
<textarea
@ -769,15 +817,18 @@ export function SettingsModal({ className = '' }: { className?: string }) {
type="button"
className={`${btnSecondary} px-4 py-2`}
onClick={async () => {
const sharedDefault = sharedProviders.find(
(p) => p.slug === runtimeConfig.defaultTtsProvider,
);
const defaults = resolveProviderDefaults({
providerRef: runtimeConfig.defaultTtsProvider,
sharedProviders,
fallbackProviderRef: 'custom-openai',
});
setLocalApiKey('');
setLocalBaseUrl('');
setLocalTTSProvider(runtimeConfig.defaultTtsProvider);
setModelValue(runtimeConfig.defaultTtsModel);
setLocalProviderRef(defaults.providerRef);
setLocalProviderType(defaults.providerType);
setModelValue(defaults.defaultModel);
setCustomModelInput('');
setLocalTTSInstructions(sharedDefault?.defaultInstructions ?? '');
setLocalTTSInstructions(defaults.defaultInstructions);
}}
>
Reset
@ -788,12 +839,20 @@ export function SettingsModal({ className = '' }: { className?: string }) {
className={`${btnPrimary} px-4 py-2`}
disabled={!canSubmit}
onClick={async () => {
const defaults = resolveProviderDefaults({
providerRef: selectedProviderRef,
providerType: selectedProviderType,
sharedProviders,
});
await updateConfig({
apiKey: restrictUserApiKeys ? '' : (localApiKey || ''),
baseUrl: restrictUserApiKeys ? '' : (localBaseUrl || ''),
});
await updateConfigKey('ttsProvider', localTTSProvider);
const finalModel = selectedModelId === 'custom' ? customModelInput.trim() : modelValue;
await updateConfigKey('providerRef', selectedProviderRef);
await updateConfigKey('providerType', selectedProviderType);
const finalModel = showAllProviderModels
? (selectedModelId === 'custom' ? customModelInput.trim() : modelValue)
: defaults.defaultModel;
await updateConfigKey('ttsModel', finalModel);
await updateConfigKey('ttsInstructions', localTTSInstructions);
setIsOpen(false);

View file

@ -3,7 +3,6 @@
import { Fragment, useCallback, useEffect, useMemo, useState } from 'react';
import {
Button,
Input,
Listbox,
ListboxButton,
ListboxOption,
@ -19,13 +18,8 @@ import {
ToggleRow,
btnPrimary,
btnSecondary,
inputClass,
} from '@/components/admin/ui';
import {
providerSupportsCustomModel,
resolveProviderModels,
type TtsProviderId,
} from '@/lib/shared/tts-provider-catalog';
import { type TtsProviderId } from '@/lib/shared/tts-provider-catalog';
import { useSharedProviders, type SharedProviderEntry } from '@/hooks/useSharedProviders';
type RuntimeConfigSource = 'env-seed' | 'admin' | 'default';
@ -47,7 +41,6 @@ export function AdminFeaturesPanel() {
const [draft, setDraft] = useState<Record<string, unknown>>({});
const [dirty, setDirty] = useState<Set<string>>(new Set());
const [saving, setSaving] = useState(false);
const [customModelInput, setCustomModelInput] = useState('');
const { providers: sharedProviders } = useSharedProviders();
const refresh = useCallback(async () => {
@ -87,7 +80,6 @@ export function AdminFeaturesPanel() {
});
if (!res.ok && res.status !== 207) throw new Error(`HTTP ${res.status}`);
toast.success('Reset to env default');
setCustomModelInput('');
await refresh();
} catch (error) {
console.error(error);
@ -123,10 +115,9 @@ export function AdminFeaturesPanel() {
if (!data) return;
setDraft({ ...data.values });
setDirty(new Set());
setCustomModelInput('');
};
// --- Provider + model option resolution (mirrors the user-facing TTS tab) ---
// --- Provider option resolution ---
const providerOptions = useMemo<ProviderOption[]>(() => {
return sharedProviders.map((entry) => ({
@ -153,71 +144,10 @@ export function AdminFeaturesPanel() {
shared: true,
} as ProviderOption
: fallbackShared;
const effectiveProviderType: TtsProviderId =
effectiveSelectedProvider?.providerType ?? 'custom-openai';
const showAllDeepInfra = Boolean(draft.showAllDeepInfraModels);
const modelDefinitions = useMemo(
() => resolveProviderModels(effectiveProviderType, { showAllDeepInfra }),
[effectiveProviderType, showAllDeepInfra],
);
const supportsCustomModel = providerSupportsCustomModel(effectiveProviderType);
const currentModelId =
typeof draft.defaultTtsModel === 'string' ? draft.defaultTtsModel : '';
const modelIsPreset = modelDefinitions.some((m) => m.id === currentModelId);
const selectedModelDropdownId = modelIsPreset
? currentModelId
: supportsCustomModel && currentModelId
? 'custom'
: modelDefinitions[0]?.id ?? '';
// Seed the custom input from the stored value the first time we land on a
// provider whose stored model isn't in the preset list.
useEffect(() => {
if (!modelIsPreset && supportsCustomModel && currentModelId && !customModelInput) {
setCustomModelInput(currentModelId);
}
}, [modelIsPreset, supportsCustomModel, currentModelId, customModelInput]);
const selectedProviderOption = effectiveSelectedProvider;
const selectedModelDefinition = modelDefinitions.find(
(m) => m.id === selectedModelDropdownId,
);
const handleProviderChange = (opt: ProviderOption) => {
updateDraft('defaultTtsProvider', opt.id);
// Reset the model selection when the provider changes — try to keep the
// current id if it happens to exist in the new provider's catalog,
// otherwise fall back to the new provider's first preset.
const nextModels = resolveProviderModels(opt.providerType, { showAllDeepInfra });
const keepCurrent = nextModels.some((m) => m.id === currentModelId);
if (!keepCurrent) {
const sharedDefault = opt.shared ? sharedProviders.find((p) => p.slug === opt.id)?.defaultModel : null;
const fallback =
(sharedDefault && nextModels.some((m) => m.id === sharedDefault)
? sharedDefault
: nextModels[0]?.id) ?? '';
updateDraft('defaultTtsModel', fallback);
setCustomModelInput('');
}
};
const handleModelChange = (modelId: string) => {
if (modelId === 'custom') {
// Switching to custom keeps whatever's already in the input
updateDraft('defaultTtsModel', customModelInput.trim());
return;
}
updateDraft('defaultTtsModel', modelId);
setCustomModelInput('');
};
const handleCustomModelInput = (value: string) => {
setCustomModelInput(value);
updateDraft('defaultTtsModel', value);
};
// --- Renderers ---
@ -248,7 +178,7 @@ export function AdminFeaturesPanel() {
<div className="space-y-4">
<Section
title="TTS defaults"
subtitle="What new users start with, and how they can override it."
subtitle="What new users start with."
>
{/* Provider picker */}
<Card className="space-y-1.5">
@ -256,7 +186,7 @@ export function AdminFeaturesPanel() {
<div className="min-w-0">
<p className="text-sm font-medium text-foreground">Default TTS provider</p>
<p className="text-xs text-muted mt-0.5">
Initial selection for new users. Only admin shared providers are selectable.
Initial selection for new users. Model and instructions come from that shared provider configuration.
</p>
</div>
<div className="shrink-0">{renderSource('defaultTtsProvider')}</div>
@ -311,80 +241,6 @@ export function AdminFeaturesPanel() {
)}
</Card>
{/* Model picker */}
<Card className="space-y-1.5">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-sm font-medium text-foreground">Default TTS model</p>
<p className="text-xs text-muted mt-0.5">
Model selected when a new user lands on the chosen provider.
</p>
</div>
<div className="shrink-0">{renderSource('defaultTtsModel')}</div>
</div>
<Listbox
value={selectedModelDropdownId}
onChange={handleModelChange}
>
<ListboxButton className="relative w-full cursor-pointer rounded-lg bg-base border border-offbase py-1.5 pl-3 pr-10 text-left text-sm text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent hover:bg-offbase hover:text-accent transition-colors">
<span className="block truncate">
{selectedModelDefinition?.name ?? 'Select model'}
</span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon className="h-4 w-4 text-muted" />
</span>
</ListboxButton>
<Transition
as={Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<ListboxOptions
anchor="bottom start"
className="z-50 w-[var(--button-width)] max-h-60 overflow-y-auto overscroll-contain rounded-md bg-background py-1 shadow-lg ring-1 ring-offbase focus:outline-none [--anchor-gap:0.25rem]"
>
{modelDefinitions.map((model) => (
<ListboxOption
key={model.id}
value={model.id}
className={({ active }) =>
`relative cursor-pointer select-none py-2 pl-10 pr-4 ${active ? 'bg-offbase text-accent' : 'text-foreground'}`
}
>
{({ selected }) => (
<>
<span className={`block ${selected ? 'font-medium' : 'font-normal'}`}>
<span className="block truncate">{model.name}</span>
{model.id.includes(':') && (
<span className="block truncate text-xs text-muted">
{model.id.slice(model.id.indexOf(':'))}
</span>
)}
</span>
{selected && (
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-accent">
<CheckIcon className="h-5 w-5" />
</span>
)}
</>
)}
</ListboxOption>
))}
</ListboxOptions>
</Transition>
</Listbox>
{supportsCustomModel && selectedModelDropdownId === 'custom' && (
<Input
type="text"
value={customModelInput}
onChange={(e) => handleCustomModelInput(e.target.value)}
placeholder="Enter custom model id"
className={inputClass}
/>
)}
</Card>
{/* Boolean TTS toggles */}
<ToggleRow
label="Restrict user API keys (recommended)"
@ -415,6 +271,13 @@ export function AdminFeaturesPanel() {
onChange={(checked) => updateDraft('showAllDeepInfraModels', checked)}
right={renderSource('showAllDeepInfraModels')}
/>
<ToggleRow
label="Show all provider models"
description="When off, users are restricted to each provider's default model."
checked={Boolean(draft.showAllProviderModels)}
onChange={(checked) => updateDraft('showAllProviderModels', checked)}
right={renderSource('showAllProviderModels')}
/>
</Section>
<Section

View file

@ -8,11 +8,14 @@ import { ChevronUpDownIcon, CheckIcon, PlusIcon } from '@/components/icons/Icons
import {
providerSupportsCustomModel,
resolveProviderModels,
REPLICATE_KOKORO_82M_VERSIONED_MODEL,
supportsTtsInstructions,
type TtsModelDefinition,
type TtsProviderId,
} from '@/lib/shared/tts-provider-catalog';
import {
defaultBaseUrlForProviderType,
defaultModelForProviderType,
resolveTtsProviderModelPolicy,
} from '@/lib/shared/tts-provider-policy';
import { useRuntimeConfig } from '@/contexts/RuntimeConfigContext';
import {
Badge,
@ -61,12 +64,7 @@ interface FormState {
enabled: boolean;
}
function providerDefaultModel(providerType: ProviderType): string {
if (providerType === 'openai') return 'tts-1';
if (providerType === 'deepinfra') return 'hexgrad/Kokoro-82M';
if (providerType === 'replicate') return REPLICATE_KOKORO_82M_VERSIONED_MODEL;
return 'kokoro';
}
const providerDefaultModel = defaultModelForProviderType;
function createEmptyForm(): FormState {
return {
@ -215,13 +213,16 @@ export function AdminProvidersPanel() {
? 'custom'
: modelDefinitions[0]?.id ?? '';
const selectedModelDefinition = modelDefinitions.find((model) => model.id === selectedModelId);
const baseUrlPlaceholder = form.providerType === 'openai'
? 'https://api.openai.com/v1'
: form.providerType === 'deepinfra'
? 'https://api.deepinfra.com/v1/openai'
: 'https://your-tts-host/v1';
const modelSupportsInstructions = (model: string) => resolveTtsProviderModelPolicy({
providerRef: form.slug,
providerType: form.providerType,
model,
}).supportsInstructions;
const baseUrlPlaceholder = form.providerType === 'custom-openai'
? 'https://your-tts-host/v1'
: defaultBaseUrlForProviderType(form.providerType);
const shouldShowBaseUrl = form.providerType === 'custom-openai';
const shouldShowInstructions = supportsTtsInstructions(form.defaultModel);
const shouldShowInstructions = modelSupportsInstructions(form.defaultModel);
useEffect(() => {
if (!supportsCustomModel) {
@ -237,10 +238,10 @@ export function AdminProvidersPanel() {
}, [supportsCustomModel, modelIsPreset, form.defaultModel, customModelInput]);
useEffect(() => {
if (supportsTtsInstructions(form.defaultModel)) return;
if (modelSupportsInstructions(form.defaultModel)) return;
if (!form.defaultInstructions) return;
setForm((prev) => ({ ...prev, defaultInstructions: '' }));
}, [form.defaultModel, form.defaultInstructions]);
}, [form.defaultModel, form.defaultInstructions, form.providerType, form.slug]);
return (
<Section
@ -299,7 +300,7 @@ export function AdminProvidersPanel() {
providerType: opt.value,
baseUrl: opt.value === 'custom-openai' ? form.baseUrl : '',
defaultModel: nextModel,
defaultInstructions: supportsTtsInstructions(nextModel) ? form.defaultInstructions : '',
defaultInstructions: modelSupportsInstructions(nextModel) ? form.defaultInstructions : '',
});
setCustomModelInput('');
}}
@ -356,14 +357,14 @@ export function AdminProvidersPanel() {
setForm({
...form,
defaultModel: nextModel,
defaultInstructions: supportsTtsInstructions(nextModel) ? form.defaultInstructions : '',
defaultInstructions: modelSupportsInstructions(nextModel) ? form.defaultInstructions : '',
});
return;
}
setForm({
...form,
defaultModel: modelId,
defaultInstructions: supportsTtsInstructions(modelId) ? form.defaultInstructions : '',
defaultInstructions: modelSupportsInstructions(modelId) ? form.defaultInstructions : '',
});
setCustomModelInput('');
}}
@ -426,7 +427,7 @@ export function AdminProvidersPanel() {
setForm({
...form,
defaultModel: nextModel,
defaultInstructions: supportsTtsInstructions(nextModel) ? form.defaultInstructions : '',
defaultInstructions: modelSupportsInstructions(nextModel) ? form.defaultInstructions : '',
});
}}
placeholder="Enter custom model id"

View file

@ -4,7 +4,7 @@ import { Input, Popover, PopoverButton, PopoverPanel } from '@headlessui/react';
import { ChevronUpDownIcon, SpeedometerIcon } from '@/components/icons/Icons';
import { useConfig } from '@/contexts/ConfigContext';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { supportsNativeModelSpeed } from '@/lib/shared/tts-provider-catalog';
import { resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy';
export const SpeedControl = ({
setSpeedAndRestart,
@ -13,8 +13,12 @@ export const SpeedControl = ({
setSpeedAndRestart: (speed: number) => void;
setAudioPlayerSpeedAndRestart: (speed: number) => void;
}) => {
const { voiceSpeed, audioPlayerSpeed, ttsProvider, ttsModel } = useConfig();
const nativeSpeedSupported = supportsNativeModelSpeed(ttsProvider, ttsModel);
const { voiceSpeed, audioPlayerSpeed, providerType, ttsModel } = useConfig();
const nativeSpeedSupported = resolveTtsProviderModelPolicy({
providerRef: '',
providerType,
model: ttsModel,
}).supportsNativeModelSpeed;
const [localVoiceSpeed, setLocalVoiceSpeed] = useState(voiceSpeed);
const [localAudioSpeed, setLocalAudioSpeed] = useState(audioPlayerSpeed);

View file

@ -9,7 +9,7 @@ export const VoicesControl = ({ availableVoices, setVoiceAndRestart }: {
availableVoices: string[];
setVoiceAndRestart: (voice: string) => void;
}) => {
const { ttsModel, ttsProvider } = useConfig();
const { ttsModel, providerType } = useConfig();
const { voice } = useTTS();
const onChangeVoice = useCallback((nextVoice: string) => setVoiceAndRestart(nextVoice), [setVoiceAndRestart]);
@ -18,7 +18,7 @@ export const VoicesControl = ({ availableVoices, setVoiceAndRestart }: {
availableVoices={availableVoices}
voice={voice || ''}
onChangeVoice={onChangeVoice}
ttsProvider={ttsProvider}
providerType={providerType}
ttsModel={ttsModel}
/>
);

View file

@ -8,13 +8,15 @@ import {
} from '@headlessui/react';
import { ChevronUpDownIcon, AudioWaveIcon, CheckIcon } from '@/components/icons/Icons';
import { useEffect, useMemo, useState } from 'react';
import { buildKokoroVoiceString, getMaxVoicesForProvider, isKokoroModel, parseKokoroVoiceNames } from '@/lib/shared/kokoro';
import { buildKokoroVoiceString, parseKokoroVoiceNames } from '@/lib/shared/kokoro';
import { type TtsProviderType } from '@/lib/shared/tts-provider-catalog';
import { resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy';
export function VoicesControlBase({
availableVoices,
voice,
onChangeVoice,
ttsProvider,
providerType,
ttsModel,
dropdownDirection = 'up',
variant = 'compact',
@ -22,7 +24,7 @@ export function VoicesControlBase({
availableVoices: string[];
voice: string;
onChangeVoice: (voice: string) => void;
ttsProvider: string;
providerType: TtsProviderType;
ttsModel: string;
dropdownDirection?: 'up' | 'down';
variant?: 'compact' | 'field';
@ -43,11 +45,16 @@ export function VoicesControlBase({
? 'h-4 w-4 text-muted'
: 'h-2.5 w-2.5 sm:h-3 sm:w-3';
const providerModelPolicy = resolveTtsProviderModelPolicy({
providerRef: '',
providerType,
model: ttsModel,
});
const dropdownWidth = variant === 'field'
? 'w-full'
: isKokoroModel(ttsModel) && getMaxVoicesForProvider(ttsProvider, ttsModel) > 1 ? 'w-40 sm:w-44' : 'w-28 sm:w-32';
const isKokoro = isKokoroModel(ttsModel);
const maxVoices = getMaxVoicesForProvider(ttsProvider, ttsModel);
: providerModelPolicy.isKokoroModel && providerModelPolicy.maxVoices > 1 ? 'w-40 sm:w-44' : 'w-28 sm:w-32';
const isKokoro = providerModelPolicy.isKokoroModel;
const maxVoices = providerModelPolicy.maxVoices;
const [selectedVoices, setSelectedVoices] = useState<string[]>([]);

View file

@ -88,7 +88,8 @@ function formatVoiceLabel(settings: TTSSegmentSettings | null): string {
function settingsAreEqual(a: TTSSegmentSettings | null, b: TTSSegmentSettings | null): boolean {
if (!a || !b) return false;
return (
a.ttsProvider === b.ttsProvider
a.providerRef === b.providerRef
&& a.providerType === b.providerType
&& a.ttsModel === b.ttsModel
&& a.voice === b.voice
&& Number(a.nativeSpeed) === Number(b.nativeSpeed)
@ -182,7 +183,8 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId }: SegmentsSideb
activeReaderType,
} = useTTS();
const {
ttsProvider,
providerRef,
providerType,
ttsModel,
voice,
voiceSpeed,
@ -241,12 +243,13 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId }: SegmentsSideb
const didAutoScrollOnOpenRef = useRef(false);
const activeSettings = useMemo<TTSSegmentSettings>(() => ({
ttsProvider,
providerRef,
providerType,
ttsModel,
voice,
nativeSpeed: Number.isFinite(Number(voiceSpeed)) ? Number(voiceSpeed) : 1,
ttsInstructions: ttsInstructions || '',
}), [ttsProvider, ttsModel, voice, voiceSpeed, ttsInstructions]);
}), [providerRef, providerType, ttsModel, voice, voiceSpeed, ttsInstructions]);
const loadManifest = useCallback(async (
mode: 'reset' | 'append' = 'reset',
@ -381,7 +384,8 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId }: SegmentsSideb
const handleSelectVariant = useCallback(async (settings: TTSSegmentSettings | null) => {
if (!settings) return;
await Promise.all([
updateConfigKey('ttsProvider', settings.ttsProvider),
updateConfigKey('providerRef', settings.providerRef),
updateConfigKey('providerType', settings.providerType),
updateConfigKey('ttsModel', settings.ttsModel),
updateConfigKey('voice', settings.voice),
updateConfigKey('voiceSpeed', Number.isFinite(Number(settings.nativeSpeed)) ? Number(settings.nativeSpeed) : 1),
@ -768,7 +772,7 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId }: SegmentsSideb
onClick={() => void handleSelectVariant(variant.settings)}
title={
variant.settings
? `${variant.settings.ttsProvider} · ${variant.settings.ttsModel} · ${variant.settings.voice}${variant.settings.nativeSpeed && variant.settings.nativeSpeed !== 1 ? ` · ${variant.settings.nativeSpeed}×` : ''}`
? `${variant.settings.providerRef} · ${variant.settings.ttsModel} · ${variant.settings.voice}${variant.settings.nativeSpeed && variant.settings.nativeSpeed !== 1 ? ` · ${variant.settings.nativeSpeed}×` : ''}`
: 'Unknown variant'
}
className={[
@ -858,7 +862,7 @@ function SegmentMetadataPopover({ row }: { row: TTSSegmentRow }) {
<Row label="settings">
<span className="font-mono text-[10px] text-foreground">
{v.settings
? `${v.settings.ttsProvider} · ${v.settings.ttsModel} · ${formatVoiceLabel(v.settings)}`
? `${v.settings.providerRef} · ${v.settings.ttsModel} · ${formatVoiceLabel(v.settings)}`
: 'unknown'}
</span>
</Row>

View file

@ -4,6 +4,8 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, use
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 { 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';
@ -11,6 +13,7 @@ import { useAuthConfig } from '@/contexts/AuthRateLimitContext';
import { useFeatureFlag } from '@/contexts/RuntimeConfigContext';
import { buildSyncedPreferencePatch } from '@/lib/client/config/preferences';
import { applyConfigUpdate } from '@/lib/client/config/updates';
import { useSharedProviders } from '@/hooks/useSharedProviders';
import toast from 'react-hot-toast';
export type { ViewType } from '@/types/config';
@ -34,7 +37,8 @@ interface ConfigContextType {
footerMargin: number;
leftMargin: number;
rightMargin: number;
ttsProvider: string;
providerRef: string;
providerType: AppConfigValues['providerType'];
ttsModel: string;
ttsInstructions: string;
savedVoices: SavedVoices;
@ -61,12 +65,22 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
const [isDBReady, setIsDBReady] = useState(false);
const ttsProvidersTabDisabled = !useFeatureFlag('enableTtsProvidersTab');
const restrictUserApiKeys = useFeatureFlag('restrictUserApiKeys');
const showAllProviderModels = useFeatureFlag('showAllProviderModels');
const didRunStartupMigrations = useRef(false);
const didAttemptInitialPreferenceSeedForSession = useRef<string | null>(null);
const syncedPreferenceKeys = useMemo(() => new Set<string>(SYNCED_PREFERENCE_KEYS), []);
const { authEnabled } = useAuthConfig();
const { providers: sharedProviders } = 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,
fallbackProviderRef: 'custom-openai',
});
}, [sharedProviders]);
const queueSyncedPreferencePatch = useCallback((patch: Partial<AppConfigValues>) => {
if (!authEnabled || sessionKey === 'no-session') return;
@ -211,14 +225,17 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
if (appConfig.baseUrl !== APP_CONFIG_DEFAULTS.baseUrl) {
resetPatch.baseUrl = APP_CONFIG_DEFAULTS.baseUrl;
}
if (appConfig.ttsProvider !== APP_CONFIG_DEFAULTS.ttsProvider) {
resetPatch.ttsProvider = APP_CONFIG_DEFAULTS.ttsProvider;
if (appConfig.providerRef !== providerResetDefaults.providerRef) {
resetPatch.providerRef = providerResetDefaults.providerRef;
}
if (appConfig.ttsModel !== APP_CONFIG_DEFAULTS.ttsModel) {
resetPatch.ttsModel = APP_CONFIG_DEFAULTS.ttsModel;
if (appConfig.providerType !== providerResetDefaults.providerType) {
resetPatch.providerType = providerResetDefaults.providerType;
}
if (appConfig.ttsInstructions !== APP_CONFIG_DEFAULTS.ttsInstructions) {
resetPatch.ttsInstructions = APP_CONFIG_DEFAULTS.ttsInstructions;
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
@ -230,13 +247,12 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
console.warn('Failed to clear hidden TTS provider settings:', error);
});
queueSyncedPreferencePatch(resetPatch);
}, [ttsProvidersTabDisabled, isDBReady, appConfig, queueSyncedPreferencePatch]);
}, [ttsProvidersTabDisabled, isDBReady, appConfig, queueSyncedPreferencePatch, providerResetDefaults]);
useEffect(() => {
if (!restrictUserApiKeys || !isDBReady || !appConfig) return;
const resetPatch: Partial<AppConfigRow> = {};
const builtInProviderIds = new Set(['custom-openai', 'replicate', 'deepinfra', 'openai']);
if (appConfig.apiKey !== APP_CONFIG_DEFAULTS.apiKey) {
resetPatch.apiKey = APP_CONFIG_DEFAULTS.apiKey;
@ -244,15 +260,18 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
if (appConfig.baseUrl !== APP_CONFIG_DEFAULTS.baseUrl) {
resetPatch.baseUrl = APP_CONFIG_DEFAULTS.baseUrl;
}
if (builtInProviderIds.has(appConfig.ttsProvider)) {
if (appConfig.ttsProvider !== APP_CONFIG_DEFAULTS.ttsProvider) {
resetPatch.ttsProvider = APP_CONFIG_DEFAULTS.ttsProvider;
if (isBuiltInTtsProviderId(appConfig.providerRef)) {
if (appConfig.providerRef !== providerResetDefaults.providerRef) {
resetPatch.providerRef = providerResetDefaults.providerRef;
}
if (appConfig.ttsModel !== APP_CONFIG_DEFAULTS.ttsModel) {
resetPatch.ttsModel = APP_CONFIG_DEFAULTS.ttsModel;
if (appConfig.providerType !== providerResetDefaults.providerType) {
resetPatch.providerType = providerResetDefaults.providerType;
}
if (appConfig.ttsInstructions !== APP_CONFIG_DEFAULTS.ttsInstructions) {
resetPatch.ttsInstructions = APP_CONFIG_DEFAULTS.ttsInstructions;
if (appConfig.ttsModel !== providerResetDefaults.defaultModel) {
resetPatch.ttsModel = providerResetDefaults.defaultModel;
}
if (appConfig.ttsInstructions !== providerResetDefaults.defaultInstructions) {
resetPatch.ttsInstructions = providerResetDefaults.defaultInstructions;
}
}
@ -262,7 +281,24 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
console.warn('Failed to enforce restricted user API key mode:', error);
});
queueSyncedPreferencePatch(resetPatch);
}, [restrictUserApiKeys, isDBReady, appConfig, queueSyncedPreferencePatch]);
}, [restrictUserApiKeys, isDBReady, appConfig, queueSyncedPreferencePatch, providerResetDefaults]);
useEffect(() => {
if (showAllProviderModels || !isDBReady || !appConfig) return;
const providerDefaults = resolveProviderDefaults({
providerRef: appConfig.providerRef,
providerType: appConfig.providerType,
sharedProviders,
fallbackProviderRef: providerResetDefaults.providerRef,
});
if (!providerDefaults.defaultModel) return;
if (appConfig.ttsModel === providerDefaults.defaultModel) return;
const patch: Partial<AppConfigRow> = { ttsModel: providerDefaults.defaultModel };
updateAppConfig(patch).catch((error) => {
console.warn('Failed to enforce provider default model restriction:', error);
});
queueSyncedPreferencePatch(patch);
}, [showAllProviderModels, isDBReady, appConfig, sharedProviders, providerResetDefaults.providerRef, queueSyncedPreferencePatch]);
useEffect(() => {
if (!isDBReady || !authEnabled || !appConfig || isSessionPending) return;
@ -310,7 +346,8 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
footerMargin,
leftMargin,
rightMargin,
ttsProvider,
providerRef,
providerType: _persistedProviderType,
ttsModel,
ttsInstructions,
savedVoices,
@ -323,6 +360,46 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
epubHighlightEnabled,
epubWordHighlightEnabled,
} = config || APP_CONFIG_DEFAULTS;
const providerType = useMemo(
() => resolveEffectiveProviderType({
providerRef,
providerType: _persistedProviderType,
sharedProviders,
}),
[providerRef, _persistedProviderType, sharedProviders],
);
useEffect(() => {
if (!isDBReady || !appConfig) 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]);
void _persistedProviderType;
useEffect(() => {
if (!isDBReady || !appConfig) return;
const providerDefaults = resolveProviderDefaults({
providerRef: appConfig.providerRef,
providerType: appConfig.providerType,
sharedProviders,
fallbackProviderRef: providerResetDefaults.providerRef,
});
if (!providerDefaults.defaultModel) return;
if (appConfig.ttsModel === providerDefaults.defaultModel) return;
// Heal stale fallback model values that were written while the provider UI
// was disabled and shared provider context was unavailable.
if (appConfig.ttsModel !== APP_CONFIG_DEFAULTS.ttsModel) return;
const patch: Partial<AppConfigRow> = { ttsModel: providerDefaults.defaultModel };
updateAppConfig(patch).catch((error) => {
console.warn('Failed to normalize shared-provider default model:', error);
});
queueSyncedPreferencePatch(patch);
}, [isDBReady, appConfig, sharedProviders, queueSyncedPreferencePatch, providerResetDefaults.providerRef]);
/**
* Updates multiple configuration values simultaneously
@ -360,7 +437,8 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
try {
setIsLoading(true);
const { storagePatch, syncPatch } = applyConfigUpdate({
ttsProvider,
providerRef,
providerType,
ttsModel,
savedVoices,
}, key, value);
@ -368,7 +446,8 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
await updateAppConfig(storagePatch);
if (
key === 'voice' ||
key === 'ttsProvider' ||
key === 'providerRef' ||
key === 'providerType' ||
key === 'ttsModel' ||
key === 'savedVoices' ||
syncedPreferenceKeys.has(String(key))
@ -401,7 +480,8 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
footerMargin,
leftMargin,
rightMargin,
ttsProvider,
providerRef,
providerType,
ttsModel,
ttsInstructions,
savedVoices,

View file

@ -539,7 +539,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
const {
apiKey,
baseUrl,
ttsProvider,
providerRef,
ttsSegmentMaxBlockLength,
smartSentenceSplitting,
epubTheme,
@ -878,7 +878,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
adapter: audiobookAdapter,
apiKey,
baseUrl,
defaultProvider: ttsProvider,
defaultProvider: providerRef,
onProgress,
signal,
onChapterComplete,
@ -890,7 +890,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
console.error('Error creating audiobook:', error);
throw error;
}
}, [audiobookAdapter, apiKey, baseUrl, ttsProvider]);
}, [audiobookAdapter, apiKey, baseUrl, providerRef]);
/**
* Regenerates a specific chapter of the audiobook
@ -911,7 +911,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
signal,
apiKey,
baseUrl,
defaultProvider: ttsProvider,
defaultProvider: providerRef,
settings,
});
} catch (error) {
@ -921,7 +921,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
console.error('Error regenerating chapter:', error);
throw error;
}
}, [audiobookAdapter, apiKey, baseUrl, ttsProvider]);
}, [audiobookAdapter, apiKey, baseUrl, providerRef]);
const setRendition = useCallback((rendition: Rendition) => {
bookRef.current = rendition.book;

View file

@ -125,7 +125,7 @@ export function PDFProvider({ children }: { children: ReactNode }) {
rightMargin,
apiKey,
baseUrl,
ttsProvider,
providerRef,
smartSentenceSplitting,
segmentPreloadDepthPages,
ttsSegmentMaxBlockLength,
@ -442,7 +442,7 @@ export function PDFProvider({ children }: { children: ReactNode }) {
adapter: audiobookAdapter,
apiKey,
baseUrl,
defaultProvider: ttsProvider,
defaultProvider: providerRef,
onProgress,
signal,
onChapterComplete,
@ -454,7 +454,7 @@ export function PDFProvider({ children }: { children: ReactNode }) {
console.error('Error creating audiobook:', error);
throw error;
}
}, [audiobookAdapter, apiKey, baseUrl, ttsProvider]);
}, [audiobookAdapter, apiKey, baseUrl, providerRef]);
/**
* Regenerates a specific chapter (page) of the PDF audiobook
@ -475,7 +475,7 @@ export function PDFProvider({ children }: { children: ReactNode }) {
signal,
apiKey,
baseUrl,
defaultProvider: ttsProvider,
defaultProvider: providerRef,
settings,
});
} catch (error) {
@ -485,7 +485,7 @@ export function PDFProvider({ children }: { children: ReactNode }) {
console.error('Error regenerating page:', error);
throw error;
}
}, [audiobookAdapter, apiKey, baseUrl, ttsProvider]);
}, [audiobookAdapter, apiKey, baseUrl, providerRef]);
/**
* Effect hook to initialize TTS as non-EPUB mode

View file

@ -13,7 +13,6 @@ import { createContext, useContext, useMemo, type ReactNode } from 'react';
*/
export interface RuntimeConfig {
defaultTtsProvider: string;
defaultTtsModel: string;
restrictUserApiKeys: boolean;
enableTtsProvidersTab: boolean;
enableWordHighlight: boolean;
@ -21,11 +20,11 @@ export interface RuntimeConfig {
enableDocxConversion: boolean;
enableDestructiveDeleteActions: boolean;
showAllDeepInfraModels: boolean;
showAllProviderModels: boolean;
}
const RUNTIME_DEFAULTS: RuntimeConfig = {
defaultTtsProvider: 'custom-openai',
defaultTtsModel: 'kokoro',
restrictUserApiKeys: true,
enableTtsProvidersTab: true,
enableWordHighlight: true,
@ -33,6 +32,7 @@ const RUNTIME_DEFAULTS: RuntimeConfig = {
enableDocxConversion: true,
enableDestructiveDeleteActions: true,
showAllDeepInfraModels: true,
showAllProviderModels: true,
};
declare global {

View file

@ -54,8 +54,7 @@ import {
type CompletedEpubBoundarySegment,
} from '@/lib/client/epub/tts-epub-handoff';
import { normalizeTtsLocationKey } from '@/lib/shared/tts-locator';
import { isKokoroModel } from '@/lib/shared/kokoro';
import { supportsNativeModelSpeed, supportsTtsInstructions } from '@/lib/shared/tts-provider-catalog';
import { resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy';
import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext';
import type {
EpubRenderedLocationWalker,
@ -355,7 +354,8 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
voiceSpeed,
audioPlayerSpeed,
voice: configVoice,
ttsProvider: configTTSProvider,
providerRef: configProviderRef,
providerType: configProviderType,
ttsModel: configTTSModel,
ttsInstructions: configTTSInstructions,
updateConfigKey,
@ -372,7 +372,13 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
// Audio and voice management hooks
const audioContext = useAudioContext();
const { availableVoices, fetchVoices } = useVoiceManagement(openApiKey, openApiBaseUrl, configTTSProvider, configTTSModel);
const { availableVoices, fetchVoices } = useVoiceManagement(
openApiKey,
openApiBaseUrl,
configProviderRef,
configProviderType,
configTTSModel,
);
const {
authEnabled,
onTTSStart,
@ -536,9 +542,25 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
const [voice, setVoice] = useState(configVoice);
const [ttsModel, setTTSModel] = useState(configTTSModel);
const [ttsInstructions, setTTSInstructions] = useState(configTTSInstructions);
const providerModelPolicy = useMemo(
() => resolveTtsProviderModelPolicy({
providerRef: configProviderRef,
providerType: configProviderType,
model: ttsModel,
}),
[configProviderRef, configProviderType, ttsModel],
);
const configModelPolicy = useMemo(
() => resolveTtsProviderModelPolicy({
providerRef: configProviderRef,
providerType: configProviderType,
model: configTTSModel,
}),
[configProviderRef, configProviderType, configTTSModel],
);
const effectiveNativeSpeed = useMemo(
() => (supportsNativeModelSpeed(configTTSProvider, ttsModel) ? speed : 1),
[configTTSProvider, ttsModel, speed],
() => (providerModelPolicy.supportsNativeModelSpeed ? speed : 1),
[providerModelPolicy.supportsNativeModelSpeed, speed],
);
// Track pending preload requests
@ -1328,11 +1350,11 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
useEffect(() => {
const signature = [
documentId,
configTTSProvider,
configProviderRef,
ttsModel,
voice,
effectiveNativeSpeed,
supportsTtsInstructions(ttsModel) ? ttsInstructions : '',
providerModelPolicy.supportsInstructions ? ttsInstructions : '',
ttsSegmentMaxBlockLength,
].join('|');
@ -1347,7 +1369,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
bumpEpubPreloadGeneration();
}, [
documentId,
configTTSProvider,
configProviderRef,
ttsModel,
voice,
effectiveNativeSpeed,
@ -1364,8 +1386,17 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
useEffect(() => {
if (availableVoices.length > 0) {
// Allow Kokoro multi-voice strings (e.g., "voice1(0.5)+voice2(0.5)") for any provider
const isKokoro = isKokoroModel(configTTSModel);
const isKokoro = configModelPolicy.isKokoroModel;
const fallbackVoice = configVoice || availableVoices[0];
const providerUnresolved = !configModelPolicy.isResolvedProviderType;
if (isKokoro && providerUnresolved && voice.includes('+')) {
const firstVoice = voice.split('+')[0]?.replace(/\([^)]*\)/g, '').trim();
if (firstVoice) {
setVoice(firstVoice);
}
return;
}
if (isKokoro) {
// If Kokoro and we have any voice string (including plus/weights), don't override it.
@ -1389,7 +1420,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
// Don't save to config - just use it temporarily until user explicitly selects one
}
}
}, [availableVoices, voice, configVoice, configTTSModel]);
}, [availableVoices, voice, configVoice, configModelPolicy]);
const getSegmentPlaybackSource = useCallback(async (
sentence: string,
@ -1412,7 +1443,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
sentence,
voice,
effectiveNativeSpeed,
configTTSProvider,
configProviderRef,
ttsModel,
segmentKey,
);
@ -1445,7 +1476,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
const reqHeaders: TTSRequestHeaders = {
'Content-Type': 'application/json',
'x-openai-key': openApiKey || '',
'x-tts-provider': configTTSProvider,
'x-tts-provider': configProviderRef,
};
if (openApiBaseUrl) {
reqHeaders['x-openai-base-url'] = openApiBaseUrl;
@ -1473,14 +1504,15 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
return undefined;
}
const ensured = await withRetry(
async () => ensureTtsSegments({
async () => ensureTtsSegments({
documentId,
settings: {
ttsProvider: configTTSProvider,
providerRef: configProviderRef,
providerType: configProviderType,
ttsModel,
voice,
nativeSpeed: effectiveNativeSpeed,
...(supportsTtsInstructions(ttsModel) && ttsInstructions ? { ttsInstructions } : {}),
...(providerModelPolicy.supportsInstructions && ttsInstructions ? { ttsInstructions } : {}),
},
segments: persistSegments,
}, reqHeaders, controller.signal),
@ -1559,7 +1591,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
ttsInstructions,
openApiKey,
openApiBaseUrl,
configTTSProvider,
configProviderRef,
isEPUB,
pdfHighlightEnabled,
pdfWordHighlightEnabled,
@ -2027,7 +2059,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
sentence,
voice,
effectiveNativeSpeed,
configTTSProvider,
configProviderRef,
ttsModel,
playbackSegment?.key,
);
@ -2061,7 +2093,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
playSentenceWithHowl,
voice,
effectiveNativeSpeed,
configTTSProvider,
configProviderRef,
ttsModel,
isEPUB,
currDocPage,
@ -2133,11 +2165,11 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
if (isEPUB) {
const generationAtStart = epubPreloadGenerationRef.current;
const settingsHash = [
configTTSProvider,
configProviderRef,
ttsModel,
voice,
effectiveNativeSpeed,
supportsTtsInstructions(ttsModel) ? ttsInstructions : '',
providerModelPolicy.supportsInstructions ? ttsInstructions : '',
].join('|');
const walkStartKey = `${currDocPage}|${settingsHash}|${ttsSegmentMaxBlockLength}|${maxDepth}`;
@ -2157,7 +2189,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
nextSentence,
voice,
effectiveNativeSpeed,
configTTSProvider,
configProviderRef,
ttsModel,
nextSegment?.key,
);
@ -2204,7 +2236,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
const reqHeaders: TTSRequestHeaders = {
'Content-Type': 'application/json',
'x-openai-key': openApiKey || '',
'x-tts-provider': configTTSProvider,
'x-tts-provider': configProviderRef,
};
if (openApiBaseUrl) {
reqHeaders['x-openai-base-url'] = openApiBaseUrl;
@ -2283,7 +2315,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
segment.text,
voice,
effectiveNativeSpeed,
configTTSProvider,
configProviderRef,
ttsModel,
segment.key,
);
@ -2321,11 +2353,12 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
async () => ensureTtsSegments({
documentId,
settings: {
ttsProvider: configTTSProvider,
providerRef: configProviderRef,
providerType: configProviderType,
ttsModel,
voice,
nativeSpeed: effectiveNativeSpeed,
...(supportsTtsInstructions(ttsModel) && ttsInstructions ? { ttsInstructions } : {}),
...(providerModelPolicy.supportsInstructions && ttsInstructions ? { ttsInstructions } : {}),
},
segments: persistPayload,
}, reqHeaders, controller.signal),
@ -2417,7 +2450,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
sentence,
voice,
effectiveNativeSpeed,
configTTSProvider,
configProviderRef,
ttsModel,
plannedSegment?.key,
);
@ -2446,7 +2479,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
sentence,
voice,
effectiveNativeSpeed,
configTTSProvider,
configProviderRef,
ttsModel,
segment.key,
);
@ -2471,7 +2504,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
const reqHeaders: TTSRequestHeaders = {
'Content-Type': 'application/json',
'x-openai-key': openApiKey || '',
'x-tts-provider': configTTSProvider,
'x-tts-provider': configProviderRef,
};
if (openApiBaseUrl) {
reqHeaders['x-openai-base-url'] = openApiBaseUrl;
@ -2504,11 +2537,12 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
async () => ensureTtsSegments({
documentId,
settings: {
ttsProvider: configTTSProvider,
providerRef: configProviderRef,
providerType: configProviderType,
ttsModel,
voice,
nativeSpeed: effectiveNativeSpeed,
...(supportsTtsInstructions(ttsModel) && ttsInstructions ? { ttsInstructions } : {}),
...(providerModelPolicy.supportsInstructions && ttsInstructions ? { ttsInstructions } : {}),
},
segments: persistPayload,
}, reqHeaders, controller.signal),
@ -2587,7 +2621,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
playbackSegments,
voice,
effectiveNativeSpeed,
configTTSProvider,
configProviderRef,
ttsModel,
openApiKey,
openApiBaseUrl,

View file

@ -2,20 +2,23 @@
import { useState, useCallback, useRef } from 'react';
import { getVoices } from '@/lib/client/api/audiobooks';
import { getDefaultVoices } from '@/lib/shared/tts-provider-catalog';
import { type TtsProviderType } from '@/lib/shared/tts-provider-catalog';
import { resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy';
/**
* Custom hook for managing TTS voices
* @param apiKey OpenAI API key
* @param baseUrl OpenAI API base URL
* @param ttsProvider TTS provider (openai, custom-openai, deepinfra)
* @param providerRef TTS provider routing reference (built-in id or shared slug)
* @param providerType Resolved provider type for capability/default logic
* @param ttsModel TTS model name
* @returns Object containing available voices and fetch function
*/
export function useVoiceManagement(
apiKey: string | undefined,
baseUrl: string | undefined,
ttsProvider: string | undefined,
providerRef: string | undefined,
providerType: TtsProviderType | undefined,
ttsModel: string | undefined
) {
const [availableVoices, setAvailableVoices] = useState<string[]>([]);
@ -28,20 +31,32 @@ export function useVoiceManagement(
const data = await getVoices({
'x-openai-key': apiKey || '',
'x-openai-base-url': baseUrl || '',
'x-tts-provider': ttsProvider || 'openai',
'x-tts-provider': providerRef || 'openai',
'x-tts-model': ttsModel || 'tts-1',
'Content-Type': 'application/json',
});
// Ignore stale responses from older provider/model fetches.
if (fetchSeq !== fetchSeqRef.current) return;
setAvailableVoices(data.voices || getDefaultVoices(ttsProvider || 'openai', ttsModel || 'tts-1'));
if (data.voices && data.voices.length > 0) {
setAvailableVoices(data.voices);
return;
}
setAvailableVoices(resolveTtsProviderModelPolicy({
providerRef: providerRef || '',
providerType,
model: ttsModel || 'tts-1',
}).defaultVoices);
} catch (error) {
console.error('Error fetching voices:', error);
if (fetchSeq !== fetchSeqRef.current) return;
setAvailableVoices(getDefaultVoices(ttsProvider || 'openai', ttsModel || 'tts-1'));
setAvailableVoices(resolveTtsProviderModelPolicy({
providerRef: providerRef || '',
providerType,
model: ttsModel || 'tts-1',
}).defaultVoices);
}
}, [apiKey, baseUrl, ttsProvider, ttsModel]);
}, [apiKey, baseUrl, providerRef, providerType, ttsModel]);
return { availableVoices, fetchVoices };
}

View file

@ -54,7 +54,7 @@ interface RegenerateAudiobookChapterOptions {
}
interface ResolvedAudiobookRequestSettings {
effectiveProvider: string;
effectiveProviderRef: string;
effectiveFormat: TTSAudiobookFormat;
}
@ -64,7 +64,7 @@ function resolveAudiobookRequestSettings(
format: TTSAudiobookFormat,
): ResolvedAudiobookRequestSettings {
return {
effectiveProvider: settings?.ttsProvider ?? defaultProvider,
effectiveProviderRef: settings?.providerRef ?? defaultProvider,
effectiveFormat: settings?.format ?? format,
};
}
@ -115,8 +115,8 @@ export async function runAudiobookGeneration({
throw new Error(adapter.noContentMessage);
}
const { effectiveProvider, effectiveFormat } = resolveAudiobookRequestSettings(settings, defaultProvider, format);
const reqHeaders = buildAudiobookRequestHeaders(apiKey, baseUrl, effectiveProvider);
const { effectiveProviderRef, effectiveFormat } = resolveAudiobookRequestSettings(settings, defaultProvider, format);
const reqHeaders = buildAudiobookRequestHeaders(apiKey, baseUrl, effectiveProviderRef);
let processedLength = 0;
let bookId = providedBookId;
@ -233,8 +233,8 @@ export async function regenerateAudiobookChapter({
throw new Error(adapter.noContentMessage);
}
const { effectiveProvider, effectiveFormat } = resolveAudiobookRequestSettings(settings, defaultProvider, format);
const reqHeaders = buildAudiobookRequestHeaders(apiKey, baseUrl, effectiveProvider);
const { effectiveProviderRef, effectiveFormat } = resolveAudiobookRequestSettings(settings, defaultProvider, format);
const reqHeaders = buildAudiobookRequestHeaders(apiKey, baseUrl, effectiveProviderRef);
return withRetry(
async () => {

View file

@ -1,11 +1,11 @@
import type { AppConfigRow, AppConfigValues, SavedVoices } from '@/types/config';
export function getVoicePreferenceKey(provider: string, model: string): string {
return `${provider}:${model}`;
export function getVoicePreferenceKey(providerRef: string, model: string): string {
return `${providerRef}:${model}`;
}
export function applyConfigUpdate<K extends keyof AppConfigValues>(
currentConfig: Pick<AppConfigValues, 'ttsProvider' | 'ttsModel' | 'savedVoices'>,
currentConfig: Pick<AppConfigValues, 'providerRef' | 'providerType' | 'ttsModel' | 'savedVoices'>,
key: K,
value: AppConfigValues[K],
): {
@ -13,7 +13,7 @@ export function applyConfigUpdate<K extends keyof AppConfigValues>(
syncPatch: Partial<AppConfigValues>;
} {
if (key === 'voice') {
const voiceKey = getVoicePreferenceKey(currentConfig.ttsProvider, currentConfig.ttsModel);
const voiceKey = getVoicePreferenceKey(currentConfig.providerRef, currentConfig.ttsModel);
const updatedSavedVoices = { ...currentConfig.savedVoices, [voiceKey]: value as string };
return {
storagePatch: {
@ -27,10 +27,10 @@ export function applyConfigUpdate<K extends keyof AppConfigValues>(
};
}
if (key === 'ttsProvider' || key === 'ttsModel') {
const newProvider = key === 'ttsProvider' ? (value as string) : currentConfig.ttsProvider;
if (key === 'providerRef' || key === 'ttsModel' || key === 'providerType') {
const newProviderRef = key === 'providerRef' ? (value as string) : currentConfig.providerRef;
const newModel = key === 'ttsModel' ? (value as string) : currentConfig.ttsModel;
const voiceKey = getVoicePreferenceKey(newProvider, newModel);
const voiceKey = getVoicePreferenceKey(newProviderRef, newModel);
const restoredVoice = currentConfig.savedVoices[voiceKey] || '';
return {

View file

@ -1,5 +1,10 @@
import Dexie, { type EntityTable } from 'dexie';
import { APP_CONFIG_DEFAULTS, getAppConfigDefaults, type ViewType, type SavedVoices, type AppConfigRow } from '@/types/config';
import {
isTtsProviderType,
type TtsProviderType,
} from '@/lib/shared/tts-provider-catalog';
import { defaultModelForProviderType, normalizeLegacyProviderRef, resolveEffectiveProviderType } from '@/lib/shared/tts-provider-policy';
import {
PDFDocument,
EPUBDocument,
@ -14,7 +19,7 @@ import { cacheStoredDocumentFromBytes } from '@/lib/client/cache/documents';
const DB_NAME = 'openreader-db';
// Managed via Dexie (version bumped from the original manual IndexedDB)
const DB_VERSION = 8;
const DB_VERSION = 9;
const PDF_TABLE = 'pdf-documents' as const;
const EPUB_TABLE = 'epub-documents' as const;
@ -108,50 +113,56 @@ const PROVIDER_DEFAULT_BASE_URL: Record<string, string> = {
type RawConfigMap = Record<string, string | undefined>;
function inferProviderAndBaseUrl(raw: RawConfigMap): { provider: string; baseUrl: string } {
function inferProviderRefAndBaseUrl(raw: RawConfigMap): { providerRef: string; baseUrl: string } {
const cachedApiKey = raw.apiKey;
const cachedBaseUrl = raw.baseUrl;
let inferredProvider = raw.ttsProvider || '';
let inferredProviderRef = raw.providerRef || raw.ttsProvider || raw.provider || '';
inferredProviderRef = normalizeLegacyProviderRef(inferredProviderRef, getAppConfigDefaults().providerRef);
if (!raw.ttsProvider) {
inferredProvider = getAppConfigDefaults().ttsProvider;
} else if (!inferredProvider) {
if (!raw.providerRef && !raw.ttsProvider && !raw.provider) {
inferredProviderRef = getAppConfigDefaults().providerRef;
} else if (!inferredProviderRef) {
if (cachedBaseUrl) {
const baseUrlLower = cachedBaseUrl.toLowerCase();
if (baseUrlLower.includes('deepinfra.com')) {
inferredProvider = 'deepinfra';
inferredProviderRef = 'deepinfra';
} else if (baseUrlLower.includes('openai.com')) {
inferredProvider = 'openai';
inferredProviderRef = 'openai';
} else if (
baseUrlLower.includes('localhost') ||
baseUrlLower.includes('127.0.0.1') ||
baseUrlLower.includes('internal')
) {
inferredProvider = 'custom-openai';
inferredProviderRef = 'custom-openai';
} else {
inferredProvider = cachedApiKey ? 'openai' : 'custom-openai';
inferredProviderRef = cachedApiKey ? 'openai' : 'custom-openai';
}
} else {
inferredProvider = cachedApiKey ? 'openai' : 'custom-openai';
inferredProviderRef = cachedApiKey ? 'openai' : 'custom-openai';
}
}
let baseUrl = cachedBaseUrl || '';
if (!baseUrl) {
if (inferredProvider === 'openai') {
if (inferredProviderRef === 'openai') {
baseUrl = PROVIDER_DEFAULT_BASE_URL.openai;
} else if (inferredProvider === 'deepinfra') {
} else if (inferredProviderRef === 'deepinfra') {
baseUrl = PROVIDER_DEFAULT_BASE_URL.deepinfra;
} else {
baseUrl = PROVIDER_DEFAULT_BASE_URL['custom-openai'];
}
}
return { provider: inferredProvider, baseUrl };
return { providerRef: inferredProviderRef, baseUrl };
}
function buildAppConfigFromRaw(raw: RawConfigMap): AppConfigRow {
const { provider, baseUrl } = inferProviderAndBaseUrl(raw);
const { providerRef, baseUrl } = inferProviderRefAndBaseUrl(raw);
const providerTypeRaw = raw.providerType;
const providerType: TtsProviderType =
isTtsProviderType(providerTypeRaw)
? providerTypeRaw
: resolveEffectiveProviderType({ providerRef });
let savedVoices: SavedVoices = {};
if (raw.savedVoices) {
@ -188,14 +199,9 @@ function buildAppConfigFromRaw(raw: RawConfigMap): AppConfigRow {
footerMargin: raw.footerMargin ? parseFloat(raw.footerMargin) : APP_CONFIG_DEFAULTS.footerMargin,
leftMargin: raw.leftMargin ? parseFloat(raw.leftMargin) : APP_CONFIG_DEFAULTS.leftMargin,
rightMargin: raw.rightMargin ? parseFloat(raw.rightMargin) : APP_CONFIG_DEFAULTS.rightMargin,
ttsProvider: provider || APP_CONFIG_DEFAULTS.ttsProvider,
ttsModel:
raw.ttsModel ||
(provider === 'openai'
? 'tts-1'
: provider === 'deepinfra'
? 'hexgrad/Kokoro-82M'
: APP_CONFIG_DEFAULTS.ttsModel),
providerRef: providerRef || APP_CONFIG_DEFAULTS.providerRef,
providerType,
ttsModel: raw.ttsModel || (providerType === 'unknown' ? APP_CONFIG_DEFAULTS.ttsModel : defaultModelForProviderType(providerType)),
ttsInstructions: raw.ttsInstructions ?? APP_CONFIG_DEFAULTS.ttsInstructions,
savedVoices,
pdfHighlightEnabled:
@ -210,13 +216,13 @@ function buildAppConfigFromRaw(raw: RawConfigMap): AppConfigRow {
documentListState,
};
const voiceKey = `${config.ttsProvider}:${config.ttsModel}`;
const voiceKey = `${config.providerRef}:${config.ttsModel}`;
config.voice = config.savedVoices[voiceKey] || '';
return config;
}
// Version 8: add local cache metadata/indexes so document + preview caches can be bounded via LRU pruning.
// Version 9: normalize app-config provider fields to providerRef/providerType.
db.version(DB_VERSION).stores({
[PDF_TABLE]: 'id, type, name, lastModified, size, folderId, cacheAccessedAt',
[EPUB_TABLE]: 'id, type, name, lastModified, size, folderId, cacheAccessedAt',
@ -229,8 +235,31 @@ db.version(DB_VERSION).stores({
// but Dexie still lets us read it inside the upgrade transaction.
[CONFIG_TABLE]: null,
}).upgrade(async (trans) => {
const appConfig = await trans.table<AppConfigRow, string>(APP_CONFIG_TABLE).get('singleton');
const appConfigTable = trans.table<AppConfigRow, string>(APP_CONFIG_TABLE);
const appConfig = await appConfigTable.get('singleton');
if (appConfig) {
const legacyProviderRef = (() => {
const providerRefRaw = (appConfig as unknown as { providerRef?: unknown }).providerRef;
if (typeof providerRefRaw === 'string' && providerRefRaw.trim()) return providerRefRaw;
const ttsProviderRaw = (appConfig as unknown as { ttsProvider?: unknown }).ttsProvider;
if (typeof ttsProviderRaw === 'string' && ttsProviderRaw.trim()) return ttsProviderRaw;
const providerRaw = (appConfig as unknown as { provider?: unknown }).provider;
if (typeof providerRaw === 'string' && providerRaw.trim()) return providerRaw;
return '';
})();
const providerRef = normalizeLegacyProviderRef(legacyProviderRef, APP_CONFIG_DEFAULTS.providerRef) || APP_CONFIG_DEFAULTS.providerRef;
const providerType = resolveEffectiveProviderType({ providerRef });
const savedVoices = appConfig.savedVoices && typeof appConfig.savedVoices === 'object'
? appConfig.savedVoices
: {};
const resolvedVoiceKey = `${providerRef}:${appConfig.ttsModel || APP_CONFIG_DEFAULTS.ttsModel}`;
const resolvedVoice = savedVoices[resolvedVoiceKey] || '';
await appConfigTable.put({
...appConfig,
providerRef,
providerType,
voice: appConfig.voice || resolvedVoice,
});
return;
}
@ -242,7 +271,7 @@ db.version(DB_VERSION).stores({
}
const built = buildAppConfigFromRaw(raw);
await trans.table<AppConfigRow, string>(APP_CONFIG_TABLE).put(built);
await appConfigTable.put(built);
// Migrate any legacy lastLocation_* keys into the dedicated last-locations table.
const locationTable = trans.table<LastLocationRow, string>(LAST_LOCATION_TABLE);

View file

@ -1,19 +1,29 @@
import {
isBuiltInTtsProviderId,
TTS_PROVIDER_DEFINITIONS,
providerSupportsCustomModel,
resolveProviderModels,
type TtsModelDefinition,
type TtsProviderDefinition,
type TtsProviderId,
type TtsProviderType,
} from '@/lib/shared/tts-provider-catalog';
import {
normalizeLegacyProviderRef,
resolveEffectiveProviderType,
resolveProviderDefaults,
resolveTtsModelForProvider,
resolveTtsProviderModelPolicy,
} from '@/lib/shared/tts-provider-policy';
import type { SharedProviderEntry } from '@/hooks/useSharedProviders';
export interface ResolveTtsSettingsViewModelOptions {
provider: string;
providerRef: string;
providerType: TtsProviderType;
apiKey?: string;
modelValue: string;
customModelInput: string;
showAllDeepInfra: boolean;
showAllProviderModels: boolean;
sharedProviders?: SharedProviderEntry[];
allowBuiltInProviders?: boolean;
}
@ -35,6 +45,8 @@ export interface TtsSettingsViewModel {
canSubmit: boolean;
/** The matched shared provider entry, if the current selection is a shared slug. */
selectedSharedProvider: SharedProviderEntry | null;
selectedProviderRef: string;
selectedProviderType: TtsProviderType;
}
const BUILT_IN_DEFINITION_BY_ID: Map<string, TtsProviderDefinition> = new Map(
@ -42,11 +54,13 @@ const BUILT_IN_DEFINITION_BY_ID: Map<string, TtsProviderDefinition> = new Map(
);
export function resolveTtsSettingsViewModel({
provider,
providerRef,
providerType,
apiKey,
modelValue,
customModelInput,
showAllDeepInfra,
showAllProviderModels,
sharedProviders = [],
allowBuiltInProviders = true,
}: ResolveTtsSettingsViewModelOptions): TtsSettingsViewModel {
@ -65,24 +79,69 @@ export function resolveTtsSettingsViewModel({
shared: true,
}));
const providers = [...sharedOptions, ...builtInOptions];
const selectedProviderId = providers.some((opt) => opt.id === provider)
? provider
const normalizedInputProviderRef = normalizeLegacyProviderRef(providerRef);
const selectedProviderRef = providers.some((opt) => opt.id === normalizedInputProviderRef)
? normalizedInputProviderRef
: providers[0]?.id ?? '';
const providerSelectionChanged = selectedProviderRef !== normalizedInputProviderRef;
// Determine the *effective* built-in provider type used for model resolution.
const selectedShared = sharedProviders.find((p) => p.slug === selectedProviderId) ?? null;
const effectiveProvider = selectedShared ? selectedShared.providerType : selectedProviderId;
const selectedShared = sharedProviders.find((p) => p.slug === selectedProviderRef) ?? null;
const selectedProviderType = resolveEffectiveProviderType({
providerRef: selectedProviderRef,
providerType,
sharedProviders,
});
const effectiveProviderType = selectedProviderType;
const knownProviderType = isBuiltInTtsProviderId(effectiveProviderType)
? effectiveProviderType
: null;
const models = resolveProviderModels(effectiveProvider, {
const catalogModels = resolveProviderModels(knownProviderType ?? 'custom-openai', {
apiKey,
showAllDeepInfra,
});
const supportsCustomModel =
BUILT_IN_DEFINITION_BY_ID.has(effectiveProvider) &&
providerSupportsCustomModel(effectiveProvider);
const isPreset = models.some((model) => model.id === modelValue);
const providerDefaults = resolveProviderDefaults({
providerRef: selectedProviderRef,
providerType: selectedProviderType,
sharedProviders,
});
const defaultModel = resolveTtsModelForProvider({
providerRef: selectedProviderRef,
providerType: selectedProviderType,
model: providerDefaults.defaultModel,
sharedProviders,
showAllProviderModels: false,
});
const models = (!showAllProviderModels && defaultModel)
? [{
id: defaultModel,
name: catalogModels.find((model) => model.id === defaultModel)?.name ?? defaultModel,
}]
: catalogModels;
const supportsCustomModel = showAllProviderModels && knownProviderType
? BUILT_IN_DEFINITION_BY_ID.has(knownProviderType) && resolveTtsProviderModelPolicy({
providerRef: selectedProviderRef,
providerType: knownProviderType,
model: modelValue,
sharedProviders,
}).supportsCustomModel
: false;
const normalizedModelValue = (() => {
if (!showAllProviderModels) {
return defaultModel;
}
const trimmedModel = modelValue.trim();
const sharedDefault = selectedShared?.defaultModel?.trim() || providerDefaults.defaultModel;
if (!sharedDefault) return trimmedModel;
if (!trimmedModel) return sharedDefault;
if ((providerSelectionChanged || normalizedInputProviderRef === 'default-openai') && trimmedModel === 'kokoro') {
return sharedDefault;
}
return trimmedModel;
})();
const isPreset = models.some((model) => model.id === normalizedModelValue);
const selectedModelId = isPreset
? modelValue
? normalizedModelValue
: supportsCustomModel
? 'custom'
: models[0]?.id ?? '';
@ -98,5 +157,7 @@ export function resolveTtsSettingsViewModel({
selectedModelId,
canSubmit,
selectedSharedProvider: selectedShared,
selectedProviderRef,
selectedProviderType,
};
}

View file

@ -3,7 +3,8 @@ import { and, eq } from 'drizzle-orm';
import { db } from '@/db';
import { adminProviders } from '@/db/schema';
import { apiKeyLast4, decryptSecret, encryptSecret } from '@/lib/server/crypto/secrets';
import { supportsTtsInstructions, type TtsProviderId } from '@/lib/shared/tts-provider-catalog';
import { type TtsProviderId } from '@/lib/shared/tts-provider-catalog';
import { resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy';
export const BUILT_IN_PROVIDER_IDS: readonly TtsProviderId[] = [
'custom-openai',
@ -170,7 +171,11 @@ function normalizeOptionalText(value: string | null | undefined): string | null
function assertInstructionsCompatibility(model: string | null, instructions: string | null): void {
if (!instructions) return;
if (!supportsTtsInstructions(model)) {
if (!resolveTtsProviderModelPolicy({
providerRef: '',
providerType: 'custom-openai',
model,
}).supportsInstructions) {
throw new AdminProviderError(
'defaultInstructions is only supported for models that support TTS instructions.',
400,

View file

@ -47,6 +47,7 @@ async function runSeed(): Promise<void> {
await seedRuntimeConfigFromEnv();
await seedDefaultAdminProvider();
await cleanupLegacyDefaultTtsProviderSeedRow();
await cleanupLegacyDefaultTtsModelRows();
}
async function seedDefaultAdminProvider(): Promise<void> {
@ -64,17 +65,15 @@ async function seedDefaultAdminProvider(): Promise<void> {
const baseUrl = process.env.API_BASE?.trim() || null;
const now = Date.now();
const defaultModel = (() => {
const raw = process.env[RUNTIME_CONFIG_SCHEMA.defaultTtsModel.envVar];
return raw && raw.trim() ? raw.trim() : null;
})();
let enc: ReturnType<typeof encryptSecret>;
try {
enc = encryptSecret(apiKey);
} catch (error) {
const hasExplicitRestriction =
Boolean(process.env[RUNTIME_CONFIG_SCHEMA.restrictUserApiKeys.envVar]?.trim());
Boolean(
RUNTIME_CONFIG_SCHEMA.restrictUserApiKeys.envVar
&& process.env[RUNTIME_CONFIG_SCHEMA.restrictUserApiKeys.envVar]?.trim(),
);
if (!hasExplicitRestriction) {
try {
await db
@ -110,7 +109,7 @@ async function seedDefaultAdminProvider(): Promise<void> {
apiKeyCiphertext: enc.ciphertext,
apiKeyIv: enc.iv,
apiKeyLast4: apiKeyLast4(apiKey),
defaultModel,
defaultModel: 'kokoro',
enabled: 1,
createdAt: now,
updatedAt: now,
@ -123,7 +122,9 @@ async function seedDefaultAdminProvider(): Promise<void> {
async function cleanupLegacyDefaultTtsProviderSeedRow(): Promise<void> {
// If an explicit env default exists, keep env-seeded behavior.
const explicit = process.env[RUNTIME_CONFIG_SCHEMA.defaultTtsProvider.envVar];
const explicit = RUNTIME_CONFIG_SCHEMA.defaultTtsProvider.envVar
? process.env[RUNTIME_CONFIG_SCHEMA.defaultTtsProvider.envVar]
: undefined;
if (explicit && explicit.trim()) return;
const key = 'defaultTtsProvider';
@ -142,3 +143,13 @@ async function cleanupLegacyDefaultTtsProviderSeedRow(): Promise<void> {
console.warn('[admin-seed] failed to cleanup legacy defaultTtsProvider seed row', error);
}
}
async function cleanupLegacyDefaultTtsModelRows(): Promise<void> {
try {
await db
.delete(adminSettings)
.where(eq(adminSettings.key, 'defaultTtsModel'));
} catch (error) {
console.warn('[admin-seed] failed to cleanup legacy defaultTtsModel rows', error);
}
}

View file

@ -20,8 +20,8 @@ export type RuntimeConfigSource = 'env-seed' | 'admin';
export interface RuntimeConfigKeyDef<T> {
/** TS-level default. Used when neither DB nor env have a value. */
default: T;
/** Env var name to seed from on first run. */
envVar: string;
/** Env var name to seed from on first run. Omit for DB/admin-only keys. */
envVar?: string;
/** Parse a string env value to T. Returns undefined to skip seeding. */
parseEnv(raw: string): T | undefined;
/** Validate an incoming admin-supplied value. */
@ -46,6 +46,19 @@ function booleanFlag(defaultValue: boolean, envVar: string): RuntimeConfigKeyDef
};
}
function runtimeBoolean(defaultValue: boolean): RuntimeConfigKeyDef<boolean> {
return {
default: defaultValue,
parseEnv() {
return undefined;
},
validate(value) {
if (typeof value === 'boolean') return value;
return undefined;
},
};
}
function stringValue(defaultValue: string, envVar: string): RuntimeConfigKeyDef<string> {
return {
default: defaultValue,
@ -63,7 +76,6 @@ function stringValue(defaultValue: string, envVar: string): RuntimeConfigKeyDef<
export const RUNTIME_CONFIG_SCHEMA = {
defaultTtsProvider: stringValue('custom-openai', 'NEXT_PUBLIC_DEFAULT_TTS_PROVIDER'),
defaultTtsModel: stringValue('kokoro', 'NEXT_PUBLIC_DEFAULT_TTS_MODEL'),
restrictUserApiKeys: booleanFlag(true, 'NEXT_PUBLIC_RESTRICT_USER_API_KEYS'),
// Historically the env semantics were "true unless explicitly 'false'",
// i.e. the feature defaults to ON.
@ -73,6 +85,7 @@ export const RUNTIME_CONFIG_SCHEMA = {
enableDocxConversion: booleanFlag(true, 'NEXT_PUBLIC_ENABLE_DOCX_CONVERSION'),
enableDestructiveDeleteActions: booleanFlag(true, 'NEXT_PUBLIC_ENABLE_DESTRUCTIVE_DELETE_ACTIONS'),
showAllDeepInfraModels: booleanFlag(true, 'NEXT_PUBLIC_SHOW_ALL_DEEPINFRA_MODELS'),
showAllProviderModels: runtimeBoolean(true),
} as const satisfies Record<string, RuntimeConfigKeyDef<unknown>>;
export type RuntimeConfigKey = keyof typeof RUNTIME_CONFIG_SCHEMA;
@ -267,6 +280,7 @@ export async function seedRuntimeConfigFromEnv(): Promise<{ seeded: RuntimeConfi
for (const key of RUNTIME_KEYS) {
if (existing.has(key)) continue;
const def = RUNTIME_CONFIG_SCHEMA[key];
if (!def.envVar) continue;
const raw = process.env[def.envVar];
if (raw === undefined || raw === null || raw === '') continue;
const parsed = def.parseEnv(raw);

View file

@ -1,4 +1,4 @@
import { supportsTtsInstructions } from '@/lib/shared/tts-provider-catalog';
import { resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy';
function normalizeInstructionCandidate(value: string | null | undefined): string | undefined {
if (value === null || value === undefined) return undefined;
@ -21,11 +21,14 @@ export function resolveEffectiveTtsInstructions(opts: {
requestInstructions?: string | null;
sharedDefaultInstructions?: string | null;
}): string | undefined {
if (!supportsTtsInstructions(opts.model)) {
if (!resolveTtsProviderModelPolicy({
providerRef: '',
providerType: 'custom-openai',
model: opts.model,
}).supportsInstructions) {
return undefined;
}
return normalizeInstructionCandidate(opts.requestInstructions)
?? normalizeInstructionCandidate(opts.sharedDefaultInstructions);
}

View file

@ -1,12 +1,11 @@
import OpenAI from 'openai';
import Replicate from 'replicate';
import { SpeechCreateParams } from 'openai/resources/audio/speech.mjs';
import { isKokoroModel } from '@/lib/shared/kokoro';
import {
isBuiltInTtsProviderId,
REPLICATE_KOKORO_82M_VERSIONED_MODEL,
supportsNativeModelSpeed,
supportsTtsInstructions,
} from '@/lib/shared/tts-provider-catalog';
import { resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy';
import { resolveReplicateVoiceInputKey } from '@/lib/server/tts/voice-resolution';
import { getUpstreamRetryAfterSeconds, getUpstreamStatus } from '@/lib/server/tts/upstream-response';
import { LRUCache } from 'lru-cache';
@ -224,21 +223,27 @@ export function extractReplicateAudioUrl(output: unknown): string | null {
function resolveTTSRequest(input: ServerTTSRequest): ResolvedServerTTSRequest {
const provider = input.provider || 'openai';
const providerType = isBuiltInTtsProviderId(provider) ? provider : 'openai';
const rawModel = provider === 'deepinfra' && !input.model ? 'hexgrad/Kokoro-82M'
: provider === 'replicate' && !input.model ? REPLICATE_KOKORO_82M_VERSIONED_MODEL
: input.model;
const model = (rawModel ?? 'gpt-4o-mini-tts') as SpeechCreateParams['model'];
const providerModelPolicy = resolveTtsProviderModelPolicy({
providerRef: provider,
providerType,
model: model as string,
});
const normalizedVoice = (
(provider === 'replicate' || !isKokoroModel(model as string)) && input.voice.includes('+')
(providerType === 'replicate' || !providerModelPolicy.isKokoroModel) && input.voice.includes('+')
? input.voice.split('+')[0].trim()
: input.voice
) as string;
const format = input.format || 'mp3';
const requestedSpeed = Number.isFinite(Number(input.speed)) ? Number(input.speed) : 1;
const speed = supportsNativeModelSpeed(provider, model as string) ? requestedSpeed : 1;
const instructions = supportsTtsInstructions(model as string) && input.instructions
const speed = providerModelPolicy.supportsNativeModelSpeed ? requestedSpeed : 1;
const instructions = providerModelPolicy.supportsInstructions && input.instructions
? input.instructions
: undefined;

View file

@ -26,7 +26,8 @@ function stableStringify(value: unknown): string {
function settingsCanonical(settings: TTSSegmentSettings): string {
return stableStringify({
provider: settings.ttsProvider,
providerRef: settings.providerRef,
providerType: settings.providerType,
model: settings.ttsModel,
voice: settings.voice,
speed: Number.isFinite(Number(settings.nativeSpeed)) ? Number(settings.nativeSpeed) : 1,
@ -41,7 +42,8 @@ export function buildTtsSegmentSettingsHash(settings: TTSSegmentSettings): strin
export function buildTtsSegmentSettingsJson(settings: TTSSegmentSettings): TTSSegmentSettings | string {
const canonical: TTSSegmentSettings = {
ttsProvider: settings.ttsProvider,
providerRef: settings.providerRef,
providerType: settings.providerType,
ttsModel: settings.ttsModel,
voice: settings.voice,
nativeSpeed: Number.isFinite(Number(settings.nativeSpeed)) ? Number(settings.nativeSpeed) : 1,

View file

@ -1,11 +1,10 @@
import { LRUCache } from 'lru-cache';
import {
getDefaultVoices,
resolveProviderModels,
resolveVoiceSource,
type ReplicateVoiceInputKey,
type ResolveVoicesOptions,
} from '@/lib/shared/tts-provider-catalog';
import { resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy';
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
@ -324,8 +323,13 @@ async function fetchCustomOpenAiVoices(baseUrl: string, apiKey: string): Promise
}
export async function resolveVoices({ provider, model, apiKey = '', baseUrl = '' }: ResolveVoicesOptions): Promise<string[]> {
const defaultVoices = getDefaultVoices(provider, model);
const voiceSource = resolveVoiceSource(provider, model);
const providerModelPolicy = resolveTtsProviderModelPolicy({
providerRef: provider,
providerType: provider,
model,
});
const defaultVoices = providerModelPolicy.defaultVoices;
const voiceSource = providerModelPolicy.voiceSource;
if (voiceSource === 'deepinfra-api') {
const apiVoices = await fetchDeepinfraVoices(apiKey);

View file

@ -52,7 +52,7 @@ export const isKokoroModel = (modelName: string | undefined): boolean => {
* @param model - TTS model name
* @returns Maximum number of voices (Infinity for unlimited)
*/
export const getMaxVoicesForProvider = (provider: string, model: string): number => {
export const getMaxVoicesForProvider = (provider: TtsProviderId, model: string): number => {
if (!isKokoroModel(model)) return 1;
// Deepinfra and Replicate Kokoro do not support multiple voices.
@ -63,3 +63,4 @@ export const getMaxVoicesForProvider = (provider: string, model: string): number
// Other providers with Kokoro support unlimited voices
return Infinity;
};
import type { TtsProviderId } from '@/lib/shared/tts-provider-catalog';

View file

@ -1,9 +1,17 @@
import { isKokoroModel } from '@/lib/shared/kokoro';
export type TtsProviderId = 'custom-openai' | 'replicate' | 'deepinfra' | 'openai';
export type TtsProviderType = TtsProviderId | 'unknown';
export type TtsVoiceSource = 'static' | 'deepinfra-api' | 'custom-openai-api' | 'replicate-api';
export type ReplicateVoiceInputKey = 'voice' | 'voice_id' | 'speaker';
export interface SharedProviderTypeResolverEntry {
slug: string;
providerType: TtsProviderId;
defaultModel?: string | null;
defaultInstructions?: string | null;
}
export interface TtsModelDefinition {
id: string;
name: string;
@ -22,7 +30,7 @@ export interface ResolveProviderModelsContext {
}
export interface ResolveVoicesOptions {
provider: string;
provider: TtsProviderId;
model: string;
apiKey?: string;
baseUrl?: string;
@ -155,6 +163,14 @@ export const TTS_PROVIDER_DEFINITIONS: TtsProviderDefinition[] = [
},
];
const BUILT_IN_PROVIDER_ID_SET: ReadonlySet<TtsProviderId> = new Set(
TTS_PROVIDER_DEFINITIONS.map((definition) => definition.id),
);
const TTS_PROVIDER_TYPE_SET: ReadonlySet<TtsProviderType> = new Set([
...TTS_PROVIDER_DEFINITIONS.map((definition) => definition.id),
'unknown',
]);
const MODELS_WITH_INSTRUCTIONS = new Set([
'gpt-4o-mini-tts',
'google/gemini-3.1-flash-tts',
@ -170,7 +186,31 @@ export function supportsTtsInstructions(model: string | null | undefined): boole
return !!model && MODELS_WITH_INSTRUCTIONS.has(model);
}
export function supportsNativeModelSpeed(provider: string | null | undefined, model: string | null | undefined): boolean {
export function isBuiltInTtsProviderId(value: string | null | undefined): value is TtsProviderId {
return typeof value === 'string' && BUILT_IN_PROVIDER_ID_SET.has(value as TtsProviderId);
}
export function isTtsProviderType(value: unknown): value is TtsProviderType {
return typeof value === 'string' && TTS_PROVIDER_TYPE_SET.has(value as TtsProviderType);
}
export function resolveProviderType(
providerRef: string | null | undefined,
sharedProviders: readonly SharedProviderTypeResolverEntry[] = [],
): TtsProviderType {
if (isBuiltInTtsProviderId(providerRef)) {
return providerRef;
}
if (!providerRef) {
return 'unknown';
}
const shared = sharedProviders.find((entry) => entry.slug === providerRef);
return shared ? shared.providerType : 'unknown';
}
export function supportsNativeModelSpeed(provider: TtsProviderId, model: string | null | undefined): boolean {
if (!model) {
return true;
}
@ -183,18 +223,20 @@ export function supportsNativeModelSpeed(provider: string | null | undefined, mo
}
export function getProviderDefinition(provider: string | null | undefined): TtsProviderDefinition | undefined {
return TTS_PROVIDER_DEFINITIONS.find((definition) => definition.id === provider);
return isBuiltInTtsProviderId(provider)
? TTS_PROVIDER_DEFINITIONS.find((definition) => definition.id === provider)
: undefined;
}
export function resolveProviderModels(provider: string | null | undefined, context?: ResolveProviderModelsContext): TtsModelDefinition[] {
export function resolveProviderModels(provider: TtsProviderId, context?: ResolveProviderModelsContext): TtsModelDefinition[] {
return getProviderDefinition(provider)?.models(context) ?? DEFAULT_MODELS;
}
export function providerSupportsCustomModel(provider: string | null | undefined): boolean {
export function providerSupportsCustomModel(provider: TtsProviderId): boolean {
return getProviderDefinition(provider)?.supportsCustomModel ?? false;
}
export function getDefaultVoices(provider: string, model: string): string[] {
export function getDefaultVoices(provider: TtsProviderId, model: string): string[] {
if (provider === 'openai') {
return supportsTtsInstructions(model) ? [...GPT4O_MINI_DEFAULT_VOICES] : [...OPENAI_DEFAULT_VOICES];
}
@ -237,7 +279,7 @@ function parseReplicateModelIdentifier(model: string): {
: parsed;
}
export function resolveVoiceSource(provider: string, model: string): TtsVoiceSource {
export function resolveVoiceSource(provider: TtsProviderId, model: string): TtsVoiceSource {
if (provider === 'deepinfra' && DEEPINFRA_API_VOICE_MODELS.has(model)) {
return 'deepinfra-api';
}

View file

@ -0,0 +1,175 @@
import { getMaxVoicesForProvider, isKokoroModel } from '@/lib/shared/kokoro';
import {
getDefaultVoices,
isBuiltInTtsProviderId,
providerSupportsCustomModel,
resolveProviderType,
resolveVoiceSource,
supportsNativeModelSpeed,
supportsTtsInstructions,
REPLICATE_KOKORO_82M_VERSIONED_MODEL,
type SharedProviderTypeResolverEntry,
type TtsProviderId,
type TtsProviderType,
type TtsVoiceSource,
} from '@/lib/shared/tts-provider-catalog';
export interface ResolveTtsPolicyInput {
providerRef: string | null | undefined;
providerType?: TtsProviderType | null | undefined;
model: string | null | undefined;
sharedProviders?: readonly SharedProviderTypeResolverEntry[];
}
export interface ProviderDefaultResolverEntry extends SharedProviderTypeResolverEntry {
defaultModel?: string | null;
defaultInstructions?: string | null;
}
export interface TtsProviderModelPolicy {
providerRef: string;
providerType: TtsProviderType;
isResolvedProviderType: boolean;
model: string;
isKokoroModel: boolean;
maxVoices: number;
supportsNativeModelSpeed: boolean;
supportsInstructions: boolean;
supportsCustomModel: boolean;
defaultVoices: string[];
voiceSource: TtsVoiceSource;
}
export interface ResolvedProviderDefaults {
providerRef: string;
providerType: TtsProviderType;
defaultModel: string;
defaultVoice: string;
defaultInstructions: string;
}
export interface ResolveTtsModelForProviderInput {
providerRef: string | null | undefined;
providerType?: TtsProviderType | null | undefined;
model: string | null | undefined;
sharedProviders?: readonly ProviderDefaultResolverEntry[];
fallbackProviderRef?: string | null | undefined;
showAllProviderModels?: boolean;
}
export function defaultModelForProviderType(providerType: TtsProviderId): string {
if (providerType === 'openai') return 'tts-1';
if (providerType === 'deepinfra') return 'hexgrad/Kokoro-82M';
if (providerType === 'replicate') return REPLICATE_KOKORO_82M_VERSIONED_MODEL;
return 'kokoro';
}
export function defaultBaseUrlForProviderType(providerType: TtsProviderId): string {
if (providerType === 'openai') return 'https://api.openai.com/v1';
if (providerType === 'deepinfra') return 'https://api.deepinfra.com/v1/openai';
return '';
}
export function defaultVoiceForProviderType(providerType: TtsProviderId): string {
if (providerType === 'openai') return 'alloy';
if (providerType === 'deepinfra') return 'af_bella';
return 'af_sarah';
}
export function normalizeLegacyProviderRef(
providerRef: string | null | undefined,
fallbackProviderRef?: string | null | undefined,
): string {
const raw = typeof providerRef === 'string' ? providerRef.trim() : '';
if (!raw) return '';
if (raw !== 'default-openai') return raw;
const fallback = typeof fallbackProviderRef === 'string' ? fallbackProviderRef.trim() : '';
return fallback || 'custom-openai';
}
export function resolveProviderDefaults(input: {
providerRef: string | null | undefined;
providerType?: TtsProviderType | null | undefined;
sharedProviders?: readonly ProviderDefaultResolverEntry[];
fallbackProviderRef?: string | null | undefined;
}): ResolvedProviderDefaults {
const normalizedProviderRef = normalizeLegacyProviderRef(input.providerRef, input.fallbackProviderRef);
const fallbackRef = typeof input.fallbackProviderRef === 'string' ? input.fallbackProviderRef.trim() : '';
const providerRef = normalizedProviderRef || fallbackRef;
const sharedProviders = input.sharedProviders ?? [];
const providerType = resolveEffectiveProviderType({
providerRef,
providerType: input.providerType,
sharedProviders,
});
const shared = sharedProviders.find((entry) => entry.slug === providerRef);
const sharedDefaultModel = typeof shared?.defaultModel === 'string'
? shared.defaultModel.trim()
: '';
const sharedDefaultInstructions = typeof shared?.defaultInstructions === 'string'
? shared.defaultInstructions
: '';
return {
providerRef,
providerType,
defaultModel: sharedDefaultModel || (isBuiltInTtsProviderId(providerType) ? defaultModelForProviderType(providerType) : ''),
defaultVoice: isBuiltInTtsProviderId(providerType) ? defaultVoiceForProviderType(providerType) : '',
defaultInstructions: sharedDefaultInstructions,
};
}
export function resolveEffectiveProviderType(input: {
providerRef: string | null | undefined;
providerType?: TtsProviderType | null | undefined;
sharedProviders?: readonly SharedProviderTypeResolverEntry[];
}): TtsProviderType {
const fromRef = resolveProviderType(input.providerRef, input.sharedProviders ?? []);
if (fromRef !== 'unknown') return fromRef;
return isBuiltInTtsProviderId(input.providerType) ? input.providerType : 'unknown';
}
export function resolveTtsModelForProvider(input: ResolveTtsModelForProviderInput): string {
const providerDefaults = resolveProviderDefaults({
providerRef: input.providerRef,
providerType: input.providerType,
sharedProviders: input.sharedProviders,
fallbackProviderRef: input.fallbackProviderRef,
});
const requested = typeof input.model === 'string' ? input.model.trim() : '';
if (input.showAllProviderModels === false) {
return providerDefaults.defaultModel || requested;
}
return requested || providerDefaults.defaultModel;
}
export function resolveTtsProviderModelPolicy(input: ResolveTtsPolicyInput): TtsProviderModelPolicy {
const providerRef = input.providerRef || '';
const providerType = resolveEffectiveProviderType({
providerRef,
providerType: input.providerType,
sharedProviders: input.sharedProviders,
});
const model = input.model || '';
const isResolvedProviderType = isBuiltInTtsProviderId(providerType);
const isKokoro = isKokoroModel(model);
const maxVoices = isResolvedProviderType ? getMaxVoicesForProvider(providerType, model) : 1;
return {
providerRef,
providerType,
isResolvedProviderType,
model,
isKokoroModel: isKokoro,
maxVoices,
supportsNativeModelSpeed: isResolvedProviderType
? supportsNativeModelSpeed(providerType, model)
: false,
supportsInstructions: supportsTtsInstructions(model),
supportsCustomModel: isResolvedProviderType
? providerSupportsCustomModel(providerType)
: false,
defaultVoices: isResolvedProviderType ? getDefaultVoices(providerType, model) : [],
voiceSource: isResolvedProviderType ? resolveVoiceSource(providerType, model) : 'static',
};
}

View file

@ -4,6 +4,7 @@ import type {
TTSAudioBytes,
TTSAudiobookFormat,
} from '@/types/tts';
import type { TtsProviderType } from '@/lib/shared/tts-provider-catalog';
// --- TTS Client Request Types ---
@ -37,7 +38,8 @@ export interface AudiobookStatusResponse {
}
export interface AudiobookGenerationSettings {
ttsProvider: string;
providerRef: string;
providerType: TtsProviderType;
ttsModel: string;
voice: string;
nativeSpeed: number;
@ -74,7 +76,8 @@ export interface AlignmentResponse {
}
export interface TTSSegmentSettings {
ttsProvider: string;
providerRef: string;
providerType: TtsProviderType;
ttsModel: string;
voice: string;
nativeSpeed: number;

View file

@ -1,4 +1,6 @@
import type { DocumentListState } from '@/types/documents';
import { isBuiltInTtsProviderId, type TtsProviderType } from '@/lib/shared/tts-provider-catalog';
import { defaultModelForProviderType, normalizeLegacyProviderRef } 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
@ -64,7 +66,8 @@ export interface AppConfigValues {
footerMargin: number;
leftMargin: number;
rightMargin: number;
ttsProvider: string;
providerRef: string;
providerType: TtsProviderType;
ttsModel: string;
ttsInstructions: string;
savedVoices: SavedVoices;
@ -92,6 +95,12 @@ export interface AppConfigValues {
*/
export function getAppConfigDefaults(): AppConfigValues {
const wordHighlightEnabledByDefault = readRuntimeFlag('enableWordHighlight', true);
const runtimeProviderRef = readRuntimeString('defaultTtsProvider', 'custom-openai');
const defaultProviderRef = normalizeLegacyProviderRef(runtimeProviderRef, 'custom-openai');
const defaultProviderType = isBuiltInTtsProviderId(defaultProviderRef) ? defaultProviderRef : 'unknown';
const defaultModel = isBuiltInTtsProviderId(defaultProviderType)
? defaultModelForProviderType(defaultProviderType)
: 'kokoro';
return {
apiKey: '',
baseUrl: '',
@ -105,8 +114,9 @@ export function getAppConfigDefaults(): AppConfigValues {
footerMargin: 0,
leftMargin: 0,
rightMargin: 0,
ttsProvider: readRuntimeString('defaultTtsProvider', 'custom-openai'),
ttsModel: readRuntimeString('defaultTtsModel', 'kokoro'),
providerRef: defaultProviderRef,
providerType: defaultProviderType,
ttsModel: defaultModel,
ttsInstructions: '',
savedVoices: {},
smartSentenceSplitting: true,

View file

@ -15,7 +15,8 @@ export const SYNCED_PREFERENCE_KEYS = [
'footerMargin',
'leftMargin',
'rightMargin',
'ttsProvider',
'providerRef',
'providerType',
'ttsModel',
'ttsInstructions',
'savedVoices',

View file

@ -8,6 +8,7 @@ import {
supportsNativeModelSpeed,
supportsTtsInstructions,
} from '../../src/lib/shared/tts-provider-catalog';
import { normalizeLegacyProviderRef, resolveProviderDefaults, resolveTtsModelForProvider } from '../../src/lib/shared/tts-provider-policy';
import { resolveReplicateVoiceInputKey, resolveVoices } from '../../src/lib/server/tts/voice-resolution';
import { applyConfigUpdate, getVoicePreferenceKey } from '../../src/lib/client/config/updates';
import { buildSyncedPreferencePatch } from '../../src/lib/client/config/preferences';
@ -59,6 +60,77 @@ test.describe('tts provider catalog', () => {
expect(supportsNativeModelSpeed('replicate', 'qwen/qwen3-tts')).toBe(false);
});
test('normalizes legacy default-openai provider ref to fallback', () => {
expect(normalizeLegacyProviderRef('default-openai', 'shared-replicate')).toBe('shared-replicate');
expect(normalizeLegacyProviderRef('default-openai', '')).toBe('custom-openai');
});
test('resolves shared-provider default model as authoritative', () => {
const defaults = resolveProviderDefaults({
providerRef: 'shared-replicate',
providerType: 'unknown',
fallbackProviderRef: 'shared-replicate',
sharedProviders: [
{
slug: 'shared-replicate',
providerType: 'replicate',
defaultModel: REPLICATE_KOKORO_82M_VERSIONED_MODEL,
defaultInstructions: 'Narrate warmly',
},
],
});
expect(defaults.providerRef).toBe('shared-replicate');
expect(defaults.providerType).toBe('replicate');
expect(defaults.defaultModel).toBe(REPLICATE_KOKORO_82M_VERSIONED_MODEL);
expect(defaults.defaultInstructions).toBe('Narrate warmly');
});
test('resolveTtsModelForProvider enforces shared default model when showAllProviderModels is false', () => {
const resolved = resolveTtsModelForProvider({
providerRef: 'shared-replicate',
providerType: 'unknown',
model: 'acme/custom-model',
showAllProviderModels: false,
sharedProviders: [
{
slug: 'shared-replicate',
providerType: 'replicate',
defaultModel: REPLICATE_KOKORO_82M_VERSIONED_MODEL,
defaultInstructions: null,
},
],
});
expect(resolved).toBe(REPLICATE_KOKORO_82M_VERSIONED_MODEL);
});
test('resolveTtsModelForProvider keeps requested model when showAllProviderModels is true', () => {
const resolved = resolveTtsModelForProvider({
providerRef: 'shared-replicate',
providerType: 'unknown',
model: 'acme/custom-model',
showAllProviderModels: true,
sharedProviders: [
{
slug: 'shared-replicate',
providerType: 'replicate',
defaultModel: REPLICATE_KOKORO_82M_VERSIONED_MODEL,
defaultInstructions: null,
},
],
});
expect(resolved).toBe('acme/custom-model');
});
test('resolveTtsModelForProvider falls back to built-in provider default model when locked', () => {
const resolved = resolveTtsModelForProvider({
providerRef: 'openai',
providerType: 'openai',
model: 'gpt-4o-mini-tts',
showAllProviderModels: false,
});
expect(resolved).toBe('tts-1');
});
test('keeps explicit empty custom-openai voices response', async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = async () => ({
@ -319,7 +391,8 @@ test.describe('config helpers', () => {
expect(getVoicePreferenceKey('openai', 'tts-1')).toBe('openai:tts-1');
const voiceUpdate = applyConfigUpdate({
ttsProvider: 'openai',
providerRef: 'openai',
providerType: 'openai',
ttsModel: 'tts-1',
savedVoices: {},
}, 'voice', 'alloy');
@ -329,14 +402,15 @@ test.describe('config helpers', () => {
});
const providerUpdate = applyConfigUpdate({
ttsProvider: 'openai',
providerRef: 'openai',
providerType: 'openai',
ttsModel: 'tts-1',
savedVoices: {
'deepinfra:hexgrad/Kokoro-82M': 'af_sarah',
},
}, 'ttsProvider', 'deepinfra');
}, 'providerRef', 'deepinfra');
expect(providerUpdate.storagePatch).toEqual({
ttsProvider: 'deepinfra',
providerRef: 'deepinfra',
voice: '',
});
});

View file

@ -14,14 +14,16 @@ import {
test.describe('tts segment helpers', () => {
test('builds stable settings hash', () => {
const a = buildTtsSegmentSettingsHash({
ttsProvider: 'openai',
providerRef: 'openai',
providerType: 'openai',
ttsModel: 'gpt-4o-mini-tts',
voice: 'alloy',
nativeSpeed: 1,
ttsInstructions: 'calm',
});
const b = buildTtsSegmentSettingsHash({
ttsProvider: 'openai',
providerRef: 'openai',
providerType: 'openai',
ttsModel: 'gpt-4o-mini-tts',
voice: 'alloy',
nativeSpeed: 1,

View file

@ -23,10 +23,12 @@ const SHARED: SharedProviderEntry[] = [
test.describe('resolveTtsSettingsViewModel (admin/shared modes)', () => {
test('restrict mode exposes only shared providers', () => {
const vm = resolveTtsSettingsViewModel({
provider: 'shared-openai',
providerRef: 'shared-openai',
providerType: 'unknown',
modelValue: 'gpt-4o-mini-tts',
customModelInput: '',
showAllDeepInfra: false,
showAllProviderModels: true,
sharedProviders: SHARED,
allowBuiltInProviders: false,
});
@ -37,23 +39,28 @@ test.describe('resolveTtsSettingsViewModel (admin/shared modes)', () => {
test('invalid provider falls back to first available option', () => {
const vm = resolveTtsSettingsViewModel({
provider: 'missing-provider',
modelValue: 'gpt-4o-mini-tts',
providerRef: 'missing-provider',
providerType: 'unknown',
modelValue: 'kokoro',
customModelInput: '',
showAllDeepInfra: false,
showAllProviderModels: true,
sharedProviders: SHARED,
allowBuiltInProviders: false,
});
expect(vm.selectedSharedProvider?.slug).toBe('shared-openai');
expect(vm.selectedModelId).toBe('gpt-4o-mini-tts');
});
test('custom-model capable providers use custom mode for unknown model ids', () => {
const vm = resolveTtsSettingsViewModel({
provider: 'shared-replicate',
providerRef: 'shared-replicate',
providerType: 'unknown',
modelValue: 'my-custom-model',
customModelInput: '',
showAllDeepInfra: false,
showAllProviderModels: true,
sharedProviders: SHARED,
allowBuiltInProviders: false,
});
@ -65,10 +72,12 @@ test.describe('resolveTtsSettingsViewModel (admin/shared modes)', () => {
test('non-custom providers fall back to preset model selection', () => {
const vm = resolveTtsSettingsViewModel({
provider: 'shared-openai',
providerRef: 'shared-openai',
providerType: 'unknown',
modelValue: 'not-in-presets',
customModelInput: '',
showAllDeepInfra: false,
showAllProviderModels: true,
sharedProviders: SHARED,
allowBuiltInProviders: false,
});
@ -77,4 +86,38 @@ test.describe('resolveTtsSettingsViewModel (admin/shared modes)', () => {
expect(vm.selectedModelId).not.toBe('custom');
expect(vm.models.length).toBeGreaterThan(0);
});
test('legacy default-openai provider ref normalizes to first shared provider', () => {
const vm = resolveTtsSettingsViewModel({
providerRef: 'default-openai',
providerType: 'unknown',
modelValue: 'kokoro',
customModelInput: '',
showAllDeepInfra: false,
showAllProviderModels: true,
sharedProviders: SHARED,
allowBuiltInProviders: false,
});
expect(vm.selectedProviderRef).toBe('shared-openai');
expect(vm.selectedModelId).toBe('gpt-4o-mini-tts');
});
test('showAllProviderModels=false locks model picker to provider default', () => {
const vm = resolveTtsSettingsViewModel({
providerRef: 'shared-replicate',
providerType: 'unknown',
modelValue: 'my-custom-model',
customModelInput: 'my-custom-model',
showAllDeepInfra: false,
showAllProviderModels: false,
sharedProviders: SHARED,
allowBuiltInProviders: false,
});
expect(vm.models).toEqual([{ id: 'owner/model:ver', name: 'owner/model:ver' }]);
expect(vm.supportsCustomModel).toBe(false);
expect(vm.selectedModelId).toBe('owner/model:ver');
expect(vm.canSubmit).toBe(true);
});
});