openreader/tests/unit/tts-provider-catalog.spec.ts
Richard R d5eebd3b11 chore(config): remove Deepinfra model gating and related admin setting
Eliminate the showAllDeepInfraModels runtime/admin config and all code paths
that allowed restricting Deepinfra's model catalog. Deepinfra now always
shows the full model list regardless of API key or environment variable.
Update documentation, environment examples, admin panels, runtime config,
provider catalog logic, and tests to reflect this change.
2026-05-13 18:42:40 -06:00

436 lines
13 KiB
TypeScript

import { expect, test } from '@playwright/test';
import {
REPLICATE_KOKORO_82M_VERSIONED_MODEL,
getDefaultVoices,
providerSupportsCustomModel,
resolveProviderModels,
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';
test.describe('tts provider catalog', () => {
test('resolves provider models with fixed Deepinfra catalog', () => {
expect(resolveProviderModels('openai').map((model) => model.id)).toEqual([
'tts-1',
'tts-1-hd',
'gpt-4o-mini-tts',
]);
expect(resolveProviderModels('deepinfra', {
apiKey: '',
}).map((model) => model.id)).toEqual([
'hexgrad/Kokoro-82M',
'canopylabs/orpheus-3b-0.1-ft',
'sesame/csm-1b',
'ResembleAI/chatterbox',
'Zyphra/Zonos-v0.1-hybrid',
'Zyphra/Zonos-v0.1-transformer',
'custom',
]);
});
test('resolves default voices and instruction support unchanged', () => {
expect(getDefaultVoices('openai', 'gpt-4o-mini-tts')).toContain('sage');
expect(getDefaultVoices('custom-openai', 'kokoro')).toContain('af_sarah');
expect(getDefaultVoices('deepinfra', 'ResembleAI/chatterbox')).toEqual(['None']);
expect(getDefaultVoices('deepinfra', 'Zyphra/Zonos-v0.1-transformer')).toEqual(['random']);
expect(supportsTtsInstructions('gpt-4o-mini-tts')).toBe(true);
expect(supportsTtsInstructions('tts-1')).toBe(false);
expect(providerSupportsCustomModel('openai')).toBe(false);
expect(providerSupportsCustomModel('deepinfra')).toBe(true);
});
test('uses blocklist semantics for Replicate native speed support', () => {
expect(supportsNativeModelSpeed('replicate', REPLICATE_KOKORO_82M_VERSIONED_MODEL)).toBe(true);
expect(supportsNativeModelSpeed('replicate', 'acme/runtime-speed-model')).toBe(true);
expect(supportsNativeModelSpeed('replicate', 'google/gemini-3.1-flash-tts')).toBe(false);
expect(supportsNativeModelSpeed('replicate', 'qwen/qwen3-tts')).toBe(false);
});
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('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', () => {
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 () => ({
ok: true,
json: async () => ({ voices: [] }),
}) as Response;
try {
await expect(resolveVoices({
provider: 'custom-openai',
model: 'kokoro',
apiKey: 'token',
baseUrl: 'https://example.com',
})).resolves.toEqual([]);
} finally {
globalThis.fetch = originalFetch;
}
});
test('fetches custom Replicate model voices from model schema', async () => {
const originalFetch = globalThis.fetch;
let calls = 0;
let url = '';
globalThis.fetch = async (input) => {
calls += 1;
url = String(input);
return {
ok: true,
json: async () => ({
latest_version: {
openapi_schema: {
components: {
schemas: {
Input: {
type: 'object',
properties: {
voice: {
type: 'string',
enum: ['Narrator A', 'Narrator B'],
},
},
},
},
},
},
},
}),
} as Response;
};
try {
await expect(resolveVoices({
provider: 'replicate',
model: 'acme/my-tts-model-voices',
apiKey: 'r8_token',
})).resolves.toEqual(['Narrator A', 'Narrator B']);
expect(calls).toBe(1);
expect(url).toContain('/v1/models/acme/my-tts-model-voices');
} finally {
globalThis.fetch = originalFetch;
}
});
test('falls back to default voice for custom Replicate models when schema lookup fails', async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = async () => ({
ok: false,
status: 404,
json: async () => ({}),
}) as Response;
try {
await expect(resolveVoices({
provider: 'replicate',
model: 'acme/my-tts-model-fallback',
apiKey: 'r8_token',
})).resolves.toEqual(['default']);
} finally {
globalThis.fetch = originalFetch;
}
});
test('uses schema voices first for built-in Replicate models', async () => {
const originalFetch = globalThis.fetch;
let calls = 0;
globalThis.fetch = async () => {
calls += 1;
return {
ok: true,
json: async () => ({
latest_version: {
openapi_schema: {
components: {
schemas: {
Input: {
type: 'object',
properties: {
speaker: {
type: 'string',
enum: ['Schema Aiden', 'Schema Dylan'],
},
},
},
},
},
},
},
}),
} as Response;
};
try {
await expect(resolveVoices({
provider: 'replicate',
model: 'qwen/qwen3-tts',
apiKey: 'r8_token',
})).resolves.toEqual(['Schema Aiden', 'Schema Dylan']);
expect(calls).toBe(1);
} finally {
globalThis.fetch = originalFetch;
}
});
test('falls back to built-in Replicate static voices when schema has no voice enum', async () => {
const originalFetch = globalThis.fetch;
let calls = 0;
globalThis.fetch = async () => {
calls += 1;
return {
ok: true,
json: async () => ({
latest_version: {
openapi_schema: {
components: {
schemas: {
Input: {
type: 'object',
properties: {
speaker: { type: 'string' },
},
},
},
},
},
},
}),
} as Response;
};
try {
await expect(resolveVoices({
provider: 'replicate',
model: 'inworld/tts-1.5-mini',
apiKey: 'r8_token',
})).resolves.toEqual(['Ashley', 'Dennis', 'Alex', 'Darlene']);
expect(calls).toBe(1);
} finally {
globalThis.fetch = originalFetch;
}
});
test('resolves Replicate custom-model voice input key and caches it', async () => {
const originalFetch = globalThis.fetch;
let calls = 0;
globalThis.fetch = async () => {
calls += 1;
return {
ok: true,
json: async () => ({
latest_version: {
openapi_schema: {
components: {
schemas: {
Input: {
type: 'object',
properties: {
voice_id: { type: 'string' },
},
},
},
},
},
},
}),
} as Response;
};
try {
await expect(resolveReplicateVoiceInputKey({
provider: 'replicate',
model: 'acme/custom-voice-model',
apiKey: 'r8_token',
})).resolves.toBe('voice_id');
await expect(resolveReplicateVoiceInputKey({
provider: 'replicate',
model: 'acme/custom-voice-model',
apiKey: 'r8_token',
})).resolves.toBe('voice_id');
expect(calls).toBe(1);
} finally {
globalThis.fetch = originalFetch;
}
});
test('shares Replicate schema fetch between voices and voice-key resolution', async () => {
const originalFetch = globalThis.fetch;
let calls = 0;
globalThis.fetch = async () => {
calls += 1;
return {
ok: true,
json: async () => ({
latest_version: {
openapi_schema: {
components: {
schemas: {
Input: {
type: 'object',
properties: {
voice: {
type: 'string',
enum: ['V1', 'V2'],
},
},
},
},
},
},
},
}),
} as Response;
};
try {
await expect(resolveVoices({
provider: 'replicate',
model: 'acme/shared-schema-model',
apiKey: 'r8_token',
})).resolves.toEqual(['V1', 'V2']);
await expect(resolveReplicateVoiceInputKey({
provider: 'replicate',
model: 'acme/shared-schema-model',
apiKey: 'r8_token',
})).resolves.toBe('voice');
expect(calls).toBe(1);
} finally {
globalThis.fetch = originalFetch;
}
});
});
test.describe('config helpers', () => {
test('persists voice preferences by provider/model pair', () => {
expect(getVoicePreferenceKey('openai', 'tts-1')).toBe('openai:tts-1');
const voiceUpdate = applyConfigUpdate({
providerRef: 'openai',
providerType: 'openai',
ttsModel: 'tts-1',
savedVoices: {},
}, 'voice', 'alloy');
expect(voiceUpdate.storagePatch).toEqual({
savedVoices: { 'openai:tts-1': 'alloy' },
voice: 'alloy',
});
const providerUpdate = applyConfigUpdate({
providerRef: 'openai',
providerType: 'openai',
ttsModel: 'tts-1',
savedVoices: {
'deepinfra:hexgrad/Kokoro-82M': 'af_sarah',
},
}, 'providerRef', 'deepinfra');
expect(providerUpdate.storagePatch).toEqual({
providerRef: 'deepinfra',
voice: '',
});
});
test('builds synced preference patches and honors non-default filtering', () => {
expect(buildSyncedPreferencePatch({
voiceSpeed: 1.2,
baseUrl: 'http://localhost',
ttsModel: 'kokoro',
})).toEqual({
voiceSpeed: 1.2,
ttsModel: 'kokoro',
});
expect(buildSyncedPreferencePatch({
voiceSpeed: 1,
ttsModel: 'kokoro',
}, { nonDefaultOnly: true })).toEqual({});
});
});