From 913c6d5d764052a8cadc725eb6d3413a76d37bbd Mon Sep 17 00:00:00 2001 From: Richard R Date: Wed, 13 May 2026 12:08:39 -0600 Subject: [PATCH] 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. --- src/components/SettingsModal.tsx | 1 - src/contexts/ConfigContext.tsx | 1 - src/lib/server/admin/providers.ts | 11 ++- src/lib/shared/tts-provider-policy.ts | 3 +- src/types/config.ts | 4 +- tests/unit/admin-providers-validation.spec.ts | 88 ++++++++++++++++++- tests/unit/tts-provider-catalog.spec.ts | 15 +++- .../tts-settings-admin-view-model.spec.ts | 26 ++++++ 8 files changed, 139 insertions(+), 10 deletions(-) diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index 7ea3e59..82a3292 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -820,7 +820,6 @@ export function SettingsModal({ className = '' }: { className?: string }) { const defaults = resolveProviderDefaults({ providerRef: runtimeConfig.defaultTtsProvider, sharedProviders, - fallbackProviderRef: 'custom-openai', }); setLocalApiKey(''); setLocalBaseUrl(''); diff --git a/src/contexts/ConfigContext.tsx b/src/contexts/ConfigContext.tsx index 0266732..fc7bee6 100644 --- a/src/contexts/ConfigContext.tsx +++ b/src/contexts/ConfigContext.tsx @@ -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]); diff --git a/src/lib/server/admin/providers.ts b/src/lib/server/admin/providers.ts index 29ebfd6..8faa99a 100644 --- a/src/lib/server/admin/providers.ts +++ b/src/lib/server/admin/providers.ts @@ -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 { - 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>).map(rowToRecord); } diff --git a/src/lib/shared/tts-provider-policy.ts b/src/lib/shared/tts-provider-policy.ts index 10e04b0..1301c0b 100644 --- a/src/lib/shared/tts-provider-policy.ts +++ b/src/lib/shared/tts-provider-policy.ts @@ -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: { diff --git a/src/types/config.ts b/src/types/config.ts index 7e6d6cd..29d55e0 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -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) diff --git a/tests/unit/admin-providers-validation.spec.ts b/tests/unit/admin-providers-validation.spec.ts index 27fb6ed..ca3b68b 100644 --- a/tests/unit/admin-providers-validation.spec.ts +++ b/tests/unit/admin-providers-validation.spec.ts @@ -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))); + } + }); +}); diff --git a/tests/unit/tts-provider-catalog.spec.ts b/tests/unit/tts-provider-catalog.spec.ts index 75570a2..0fe08b8 100644 --- a/tests/unit/tts-provider-catalog.spec.ts +++ b/tests/unit/tts-provider-catalog.spec.ts @@ -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', () => { diff --git a/tests/unit/tts-settings-admin-view-model.spec.ts b/tests/unit/tts-settings-admin-view-model.spec.ts index cdaf671..13b2055 100644 --- a/tests/unit/tts-settings-admin-view-model.spec.ts +++ b/tests/unit/tts-settings-admin-view-model.spec.ts @@ -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',