refactor(tts,config,admin): update provider resolution logic and ordering for consistency

Remove fallbackProviderRef usage from settings and config context to streamline
provider resolution. Adjust normalizeLegacyProviderRef to retain 'default-openai'
unless a non-default fallback is explicitly provided. Update provider listing to
enforce deterministic ordering by updatedAt, createdAt, and slug. Expand unit tests
to verify provider ordering, legacy ref normalization, and selection logic.
This commit is contained in:
Richard R 2026-05-13 12:08:39 -06:00
parent b4f4d43d6a
commit 913c6d5d76
8 changed files with 139 additions and 10 deletions

View file

@ -820,7 +820,6 @@ export function SettingsModal({ className = '' }: { className?: string }) {
const defaults = resolveProviderDefaults({
providerRef: runtimeConfig.defaultTtsProvider,
sharedProviders,
fallbackProviderRef: 'custom-openai',
});
setLocalApiKey('');
setLocalBaseUrl('');

View file

@ -78,7 +78,6 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
providerRef: APP_CONFIG_DEFAULTS.providerRef,
providerType: APP_CONFIG_DEFAULTS.providerType,
sharedProviders,
fallbackProviderRef: 'custom-openai',
});
}, [sharedProviders]);

View file

@ -1,5 +1,5 @@
import { randomUUID } from 'node:crypto';
import { and, eq } from 'drizzle-orm';
import { and, asc, desc, eq } from 'drizzle-orm';
import { db } from '@/db';
import { adminProviders } from '@/db/schema';
import { apiKeyLast4, decryptSecret, encryptSecret } from '@/lib/server/crypto/secrets';
@ -184,7 +184,14 @@ function assertInstructionsCompatibility(model: string | null, instructions: str
}
export async function listAdminProviders(): Promise<AdminProviderRecord[]> {
const rows = await db.select().from(adminProviders);
const rows = await db
.select()
.from(adminProviders)
.orderBy(
desc(adminProviders.updatedAt),
desc(adminProviders.createdAt),
asc(adminProviders.slug),
);
return (rows as Array<Record<string, unknown>>).map(rowToRecord);
}

View file

@ -84,7 +84,8 @@ export function normalizeLegacyProviderRef(
if (!raw) return '';
if (raw !== 'default-openai') return raw;
const fallback = typeof fallbackProviderRef === 'string' ? fallbackProviderRef.trim() : '';
return fallback || 'custom-openai';
if (fallback && fallback !== 'default-openai') return fallback;
return raw;
}
export function resolveProviderDefaults(input: {

View file

@ -1,6 +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';
import { defaultModelForProviderType } from '@/lib/shared/tts-provider-policy';
// Runtime config (admin-controlled) is layered on top of the static defaults
// below. We resolve it lazily so this module stays importable from non-React
@ -96,7 +96,7 @@ 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 defaultProviderRef = runtimeProviderRef.trim();
const defaultProviderType = isBuiltInTtsProviderId(defaultProviderRef) ? defaultProviderRef : 'unknown';
const defaultModel = isBuiltInTtsProviderId(defaultProviderType)
? defaultModelForProviderType(defaultProviderType)

View file

@ -1,7 +1,11 @@
import { expect, test } from '@playwright/test';
import { inArray } from 'drizzle-orm';
import { db } from '../../src/db';
import { adminProviders } from '../../src/db/schema';
import {
AdminProviderError,
listAdminProviders,
toMasked,
validateProviderType,
validateSlug,
@ -48,5 +52,87 @@ test.describe('admin provider validation', () => {
expect(toMasked(record).apiKeyMask).toBe('••••abcd');
});
});
test('lists providers in deterministic updated/created/slug order', async () => {
const suffix = `${Date.now()}${Math.random().toString(36).slice(2, 8)}`;
const inserted = [
{
id: `prov-aaa-${suffix}`,
slug: `ord-aaa-${suffix}`,
displayName: 'Order AAA',
providerType: 'openai' as const,
baseUrl: null,
apiKeyCiphertext: 'cipher',
apiKeyIv: 'iv',
apiKeyLast4: '1111',
defaultModel: null,
defaultInstructions: null,
enabled: 1,
createdAt: 200,
updatedAt: 300,
},
{
id: `prov-zzz-${suffix}`,
slug: `ord-zzz-${suffix}`,
displayName: 'Order ZZZ',
providerType: 'openai' as const,
baseUrl: null,
apiKeyCiphertext: 'cipher',
apiKeyIv: 'iv',
apiKeyLast4: '2222',
defaultModel: null,
defaultInstructions: null,
enabled: 1,
createdAt: 200,
updatedAt: 300,
},
{
id: `prov-mid-${suffix}`,
slug: `ord-mid-${suffix}`,
displayName: 'Order MID',
providerType: 'openai' as const,
baseUrl: null,
apiKeyCiphertext: 'cipher',
apiKeyIv: 'iv',
apiKeyLast4: '3333',
defaultModel: null,
defaultInstructions: null,
enabled: 1,
createdAt: 150,
updatedAt: 300,
},
{
id: `prov-old-${suffix}`,
slug: `ord-old-${suffix}`,
displayName: 'Order OLD',
providerType: 'openai' as const,
baseUrl: null,
apiKeyCiphertext: 'cipher',
apiKeyIv: 'iv',
apiKeyLast4: '4444',
defaultModel: null,
defaultInstructions: null,
enabled: 1,
createdAt: 999,
updatedAt: 200,
},
];
try {
await db.insert(adminProviders).values(inserted);
const listed = await listAdminProviders();
const ordered = listed
.filter((row) => row.slug.endsWith(suffix))
.map((row) => row.slug);
expect(ordered).toEqual([
`ord-aaa-${suffix}`,
`ord-zzz-${suffix}`,
`ord-mid-${suffix}`,
`ord-old-${suffix}`,
]);
} finally {
await db.delete(adminProviders).where(inArray(adminProviders.id, inserted.map((row) => row.id)));
}
});
});

View file

@ -60,9 +60,20 @@ test.describe('tts provider catalog', () => {
expect(supportsNativeModelSpeed('replicate', 'qwen/qwen3-tts')).toBe(false);
});
test('normalizes legacy default-openai provider ref to fallback', () => {
test('preserves default-openai unless explicit non-default fallback is provided', () => {
expect(normalizeLegacyProviderRef('default-openai', 'shared-replicate')).toBe('shared-replicate');
expect(normalizeLegacyProviderRef('default-openai', '')).toBe('custom-openai');
expect(normalizeLegacyProviderRef('default-openai', '')).toBe('default-openai');
expect(normalizeLegacyProviderRef('default-openai', 'default-openai')).toBe('default-openai');
expect(normalizeLegacyProviderRef('default-openai')).toBe('default-openai');
});
test('resolveProviderDefaults keeps runtime/admin default provider ref when valid', () => {
const defaults = resolveProviderDefaults({
providerRef: 'default-openai',
providerType: 'unknown',
});
expect(defaults.providerRef).toBe('default-openai');
expect(defaults.providerType).toBe('unknown');
});
test('resolves shared-provider default model as authoritative', () => {

View file

@ -21,6 +21,32 @@ const SHARED: SharedProviderEntry[] = [
];
test.describe('resolveTtsSettingsViewModel (admin/shared modes)', () => {
test('keeps default-openai selection when that shared provider exists', () => {
const vm = resolveTtsSettingsViewModel({
providerRef: 'default-openai',
providerType: 'unknown',
modelValue: 'kokoro',
customModelInput: '',
showAllDeepInfra: false,
showAllProviderModels: true,
sharedProviders: [
{
slug: 'default-openai',
displayName: 'Default OpenAI',
providerType: 'openai',
defaultModel: 'gpt-4o-mini-tts',
defaultInstructions: null,
},
...SHARED,
],
allowBuiltInProviders: false,
});
expect(vm.selectedProviderRef).toBe('default-openai');
expect(vm.selectedSharedProvider?.slug).toBe('default-openai');
expect(vm.selectedModelId).toBe('gpt-4o-mini-tts');
});
test('restrict mode exposes only shared providers', () => {
const vm = resolveTtsSettingsViewModel({
providerRef: 'shared-openai',