diff --git a/components/generation/media-popover.tsx b/components/generation/media-popover.tsx index a09a32432..9f4c98cb2 100644 --- a/components/generation/media-popover.tsx +++ b/components/generation/media-popover.tsx @@ -86,6 +86,7 @@ function getTTSProviderName(providerId: TTSProviderId, t: (key: string) => strin const names: Record = { 'openai-tts': t('settings.providerOpenAITTS'), 'azure-tts': t('settings.providerAzureTTS'), + 'azure-foundry-tts': t('settings.providerAzureFoundryTTS'), 'glm-tts': t('settings.providerGLMTTS'), 'qwen-tts': t('settings.providerQwenTTS'), 'doubao-tts': t('settings.providerDoubaoTTS'), diff --git a/components/settings/add-provider-dialog.tsx b/components/settings/add-provider-dialog.tsx index fb4c12e23..f83e2fde1 100644 --- a/components/settings/add-provider-dialog.tsx +++ b/components/settings/add-provider-dialog.tsx @@ -33,22 +33,29 @@ export function AddProviderDialog({ open, onOpenChange, onAdd }: AddProviderDial const [baseUrl, setBaseUrl] = useState(''); const [icon, setIcon] = useState(''); const [requiresApiKey, setRequiresApiKey] = useState(true); + const baseUrlPlaceholder = + type === 'openai' + ? 'https://example-resource.openai.azure.com/openai/deployments/{{model}}' + : 'https://api.example.com/v1'; - // Reset form when dialog closes (derived state pattern) - const [prevOpen, setPrevOpen] = useState(open); - if (open !== prevOpen) { - setPrevOpen(open); - if (!open) { - setName(''); - setType('openai'); - setBaseUrl(''); - setIcon(''); - setRequiresApiKey(true); + const resetForm = () => { + setName(''); + setType('openai'); + setBaseUrl(''); + setIcon(''); + setRequiresApiKey(true); + }; + + const handleOpenChange = (nextOpen: boolean) => { + if (!nextOpen) { + resetForm(); } - } + + onOpenChange(nextOpen); + }; const handleClose = () => { - onOpenChange(false); + handleOpenChange(false); }; const handleAdd = () => { @@ -62,7 +69,7 @@ export function AddProviderDialog({ open, onOpenChange, onAdd }: AddProviderDial }; return ( - + {t('settings.addProviderDialog')} @@ -128,7 +135,7 @@ export function AddProviderDialog({ open, onOpenChange, onAdd }: AddProviderDial setBaseUrl(e.target.value)} /> diff --git a/components/settings/audio-settings.tsx b/components/settings/audio-settings.tsx index d88590ac0..b91a8e2e9 100644 --- a/components/settings/audio-settings.tsx +++ b/components/settings/audio-settings.tsx @@ -35,6 +35,7 @@ function getTTSProviderName(providerId: TTSProviderId, t: (key: string) => strin const names: Record = { 'openai-tts': t('settings.providerOpenAITTS'), 'azure-tts': t('settings.providerAzureTTS'), + 'azure-foundry-tts': t('settings.providerAzureFoundryTTS'), 'glm-tts': t('settings.providerGLMTTS'), 'qwen-tts': t('settings.providerQwenTTS'), 'doubao-tts': t('settings.providerDoubaoTTS'), @@ -499,6 +500,32 @@ export function AudioSettings({ onSave }: AudioSettingsProps = {}) { )} + + {/* Voice selector — shown for providers with voices defined in constants + (azure-tts uses the separate locale-filter + big JSON, so excluded) */} + {ttsProviderId !== 'azure-tts' && getTTSVoices(ttsProviderId).length > 0 && ( +
+ + +
+ )} diff --git a/components/settings/index.tsx b/components/settings/index.tsx index 5f122e2e5..03db9a128 100644 --- a/components/settings/index.tsx +++ b/components/settings/index.tsx @@ -119,6 +119,7 @@ function getTTSProviderName(providerId: TTSProviderId, t: (key: string) => strin const names: Record = { 'openai-tts': t('settings.providerOpenAITTS'), 'azure-tts': t('settings.providerAzureTTS'), + 'azure-foundry-tts': t('settings.providerAzureFoundryTTS'), 'glm-tts': t('settings.providerGLMTTS'), 'qwen-tts': t('settings.providerQwenTTS'), 'doubao-tts': t('settings.providerDoubaoTTS'), diff --git a/components/settings/provider-config-panel.tsx b/components/settings/provider-config-panel.tsx index 7c765c9ba..351203d22 100644 --- a/components/settings/provider-config-panel.tsx +++ b/components/settings/provider-config-panel.tsx @@ -32,7 +32,11 @@ import { Send, } from 'lucide-react'; import { useI18n } from '@/lib/hooks/use-i18n'; -import type { ProviderConfig } from '@/lib/ai/providers'; +import { + finalizeProviderRequestUrl, + resolveProviderBaseUrl, + type ProviderConfig, +} from '@/lib/ai/providers'; import type { ProvidersConfig } from '@/lib/types/settings'; import { formatContextWindow } from './utils'; import { cn } from '@/lib/utils'; @@ -258,7 +262,12 @@ export function ProviderConfigPanel({ className="h-8" /> {(() => { - const effectiveBaseUrl = baseUrl || provider.defaultBaseUrl || ''; + const previewModelId = models[0]?.id || 'model'; + const effectiveBaseUrl = resolveProviderBaseUrl( + provider.id, + previewModelId, + baseUrl || provider.defaultBaseUrl || '', + ); if (!effectiveBaseUrl) return null; // Generate endpoint path based on provider type @@ -277,7 +286,7 @@ export function ProviderConfigPanel({ endpointPath = ''; } - const fullUrl = effectiveBaseUrl + endpointPath; + const fullUrl = finalizeProviderRequestUrl(effectiveBaseUrl + endpointPath); return (

diff --git a/components/settings/tts-settings.tsx b/components/settings/tts-settings.tsx index 4a35a4f36..dc36e5a80 100644 --- a/components/settings/tts-settings.tsx +++ b/components/settings/tts-settings.tsx @@ -4,9 +4,16 @@ import { useState, useEffect } from 'react'; import { Label } from '@/components/ui/label'; import { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; import { useI18n } from '@/lib/hooks/use-i18n'; import { useSettingsStore } from '@/lib/store/settings'; -import { TTS_PROVIDERS, DEFAULT_TTS_VOICES } from '@/lib/audio/constants'; +import { TTS_PROVIDERS, DEFAULT_TTS_VOICES, getTTSVoices } from '@/lib/audio/constants'; import type { TTSProviderId } from '@/lib/audio/types'; import { Volume2, Loader2, CheckCircle2, XCircle, Eye, EyeOff } from 'lucide-react'; import { cn } from '@/lib/utils'; @@ -26,15 +33,29 @@ export function TTSSettings({ selectedProviderId }: TTSSettingsProps) { const ttsSpeed = useSettingsStore((state) => state.ttsSpeed); const ttsProvidersConfig = useSettingsStore((state) => state.ttsProvidersConfig); const setTTSProviderConfig = useSettingsStore((state) => state.setTTSProviderConfig); + const setTTSVoice = useSettingsStore((state) => state.setTTSVoice); const activeProviderId = useSettingsStore((state) => state.ttsProviderId); // When testing a non-active provider, use that provider's default voice // instead of the active provider's voice (which may be incompatible). - const effectiveVoice = + const defaultEffectiveVoice = selectedProviderId === activeProviderId ? ttsVoice : DEFAULT_TTS_VOICES[selectedProviderId] || 'default'; + // Local voice state for providers with voices defined in constants. + // Syncs back to the global store when this is the active provider. + const [selectedVoice, setSelectedVoice] = useState(defaultEffectiveVoice); + + const effectiveVoice = getTTSVoices(selectedProviderId).length > 0 ? selectedVoice : defaultEffectiveVoice; + + const handleVoiceChange = (voice: string) => { + setSelectedVoice(voice); + if (selectedProviderId === activeProviderId) { + setTTSVoice(voice); + } + }; + const ttsProvider = TTS_PROVIDERS[selectedProviderId] ?? TTS_PROVIDERS['openai-tts']; const isServerConfigured = !!ttsProvidersConfig[selectedProviderId]?.isServerConfigured; @@ -72,6 +93,12 @@ export function TTSSettings({ selectedProviderId }: TTSSettingsProps) { setShowApiKey(false); setTestStatus('idle'); setTestMessage(''); + setSelectedVoice( + selectedProviderId === activeProviderId + ? ttsVoice + : DEFAULT_TTS_VOICES[selectedProviderId] || 'default', + ); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedProviderId, stopPreview]); const handleTestTTS = async () => { @@ -242,6 +269,9 @@ export function TTSSettings({ selectedProviderId }: TTSSettingsProps) { case 'azure-tts': endpointPath = '/cognitiveservices/v1'; break; + case 'azure-foundry-tts': + endpointPath = '/tts/cognitiveservices/v1'; + break; case 'qwen-tts': endpointPath = '/services/aigc/multimodal-generation/generation'; break; @@ -262,6 +292,25 @@ export function TTSSettings({ selectedProviderId }: TTSSettingsProps) { )} + {/* Voice selector — shown for providers with voices defined in constants */} + {getTTSVoices(selectedProviderId).length > 0 && ( +

+ + +
+ )} + {/* Test TTS */}
diff --git a/lib/ai/providers.ts b/lib/ai/providers.ts index 7a1a03116..9d77eac8b 100644 --- a/lib/ai/providers.ts +++ b/lib/ai/providers.ts @@ -45,6 +45,32 @@ const log = createLogger('AIProviders'); // Re-export types for backward compatibility export type { ProviderId, ProviderConfig, ModelInfo, ModelConfig }; +const MODEL_BASE_URL_PLACEHOLDER = /\{\{\s*model\s*\}\}/gi; +const AZURE_OPENAI_API_VERSION = '2024-05-01-preview'; + +function shouldAppendAzureOpenAIApiVersion(url: URL): boolean { + const isAzureHost = + url.hostname.endsWith('.openai.azure.com') || url.hostname.endsWith('.cognitiveservices.azure.com'); + + return ( + isAzureHost && + url.pathname.includes('/openai/deployments/') && + !url.searchParams.has('api-version') + ); +} + +export function finalizeProviderRequestUrl(url: string): string { + try { + const parsedUrl = new URL(url); + if (shouldAppendAzureOpenAIApiVersion(parsedUrl)) { + parsedUrl.searchParams.set('api-version', AZURE_OPENAI_API_VERSION); + } + return parsedUrl.toString(); + } catch { + return url; + } +} + /** * Provider registry */ @@ -1054,6 +1080,20 @@ function normalizeMiniMaxAnthropicBaseUrl( return `${trimmed}/anthropic/v1`; } +export function resolveProviderBaseUrl( + providerId: ProviderId, + modelId: string, + baseUrl?: string, +): string | undefined { + if (!baseUrl) { + return baseUrl; + } + + const resolvedBaseUrl = baseUrl.replace(MODEL_BASE_URL_PLACEHOLDER, encodeURIComponent(modelId)); + + return normalizeMiniMaxAnthropicBaseUrl(providerId, resolvedBaseUrl); +} + /** * Get a configured language model instance with its info * Accepts individual parameters for flexibility and security @@ -1083,8 +1123,9 @@ export function getModel(config: ModelConfig): ModelWithInfo { // Resolve base URL: explicit > provider default > SDK default const provider = getProviderConfig(config.providerId); - const effectiveBaseUrl = normalizeMiniMaxAnthropicBaseUrl( + const effectiveBaseUrl = resolveProviderBaseUrl( config.providerId, + config.modelId, config.baseUrl || provider?.defaultBaseUrl || undefined, ); @@ -1092,19 +1133,19 @@ export function getModel(config: ModelConfig): ModelWithInfo { switch (providerType) { case 'openai': { + const providerId = config.providerId; const openaiOptions: Parameters[0] = { apiKey: effectiveApiKey, baseURL: effectiveBaseUrl, }; - // For OpenAI-compatible providers (not native OpenAI), add a fetch - // wrapper that injects vendor-specific thinking params into the HTTP - // body. The thinking config is read from AsyncLocalStorage, set by - // callLLM / streamLLM at call time. - if (config.providerId !== 'openai') { - const providerId = config.providerId; - openaiOptions.fetch = async (url: RequestInfo | URL, init?: RequestInit) => { - // Read thinking config from globalThis (set by thinking-context.ts) + openaiOptions.fetch = async (url: RequestInfo | URL, init?: RequestInit) => { + const rawUrl = typeof url === 'string' ? url : url.toString(); + const requestUrl = finalizeProviderRequestUrl(rawUrl); + + // For OpenAI-compatible providers (not native OpenAI), inject + // vendor-specific thinking params into the HTTP body. + if (providerId !== 'openai') { const thinkingCtx = (globalThis as Record).__thinkingContext as | { getStore?: () => unknown } | undefined; @@ -1121,9 +1162,10 @@ export function getModel(config: ModelConfig): ModelWithInfo { } } } - return globalThis.fetch(url, init); - }; - } + } + + return globalThis.fetch(requestUrl, init); + }; const openai = createOpenAI(openaiOptions); model = openai.chat(config.modelId); diff --git a/lib/audio/constants.ts b/lib/audio/constants.ts index 423f5b82c..956e54ddc 100644 --- a/lib/audio/constants.ts +++ b/lib/audio/constants.ts @@ -209,6 +209,43 @@ export const TTS_PROVIDERS: Record = { speedRange: { min: 0.5, max: 2.0, default: 1.0 }, }, + 'azure-foundry-tts': { + id: 'azure-foundry-tts', + name: 'Azure AI Foundry TTS', + requiresApiKey: true, + defaultBaseUrl: 'https://{resource}.cognitiveservices.azure.com', + icon: '/logos/azure.svg', + models: [], + defaultModelId: '', + voices: [ + // Standard Neural voices + { id: 'en-US-AvaNeural', name: 'Ava', language: 'en-US', gender: 'female' as const }, + { id: 'en-US-AndrewNeural', name: 'Andrew', language: 'en-US', gender: 'male' as const }, + { id: 'en-US-EmmaNeural', name: 'Emma', language: 'en-US', gender: 'female' as const }, + { id: 'en-US-BrianNeural', name: 'Brian', language: 'en-US', gender: 'male' as const }, + // Multilingual Neural voices (support multiple languages dynamically) + { id: 'en-US-AvaMultilingualNeural', name: 'Ava Multilingual', language: 'en-US', gender: 'female' as const }, + { id: 'en-US-AndrewMultilingualNeural', name: 'Andrew Multilingual', language: 'en-US', gender: 'male' as const }, + { id: 'en-US-AdamMultilingualNeural', name: 'Adam Multilingual', language: 'en-US', gender: 'male' as const }, + { id: 'zh-CN-XiaoxiaoNeural', name: '晓晓 (女)', language: 'zh-CN', gender: 'female' as const }, + { id: 'zh-CN-YunxiNeural', name: '云希 (男)', language: 'zh-CN', gender: 'male' as const }, + { id: 'ca-ES-AlbaNeural', name: 'Alba (Català)', language: 'ca-ES', gender: 'female' as const }, + { id: 'ca-ES-EnricNeural', name: 'Enric (Català)', language: 'ca-ES', gender: 'male' as const }, + { id: 'es-ES-ElviraNeural', name: 'Elvira (Español)', language: 'es-ES', gender: 'female' as const }, + { id: 'es-ES-AlvaroNeural', name: 'Álvaro (Español)', language: 'es-ES', gender: 'male' as const }, + // HD Neural voices (Azure AI Foundry — premium DragonHD quality) + { id: 'en-US-Ava:DragonHDLatestNeural', name: 'Ava HD', language: 'en-US', gender: 'female' as const, description: 'DragonHD' }, + { id: 'en-US-Andrew:DragonHDLatestNeural', name: 'Andrew HD', language: 'en-US', gender: 'male' as const, description: 'DragonHD' }, + { id: 'en-US-Adam:DragonHDLatestNeural', name: 'Adam HD', language: 'en-US', gender: 'male' as const, description: 'DragonHD' }, + { id: 'en-US-Emma:DragonHDLatestNeural', name: 'Emma HD', language: 'en-US', gender: 'female' as const, description: 'DragonHD' }, + { id: 'en-US-Brian:DragonHDLatestNeural', name: 'Brian HD', language: 'en-US', gender: 'male' as const, description: 'DragonHD' }, + { id: 'ca-ES-Alba:DragonHDLatestNeural', name: 'Alba HD (Català)', language: 'ca-ES', gender: 'female' as const, description: 'DragonHD' }, + { id: 'es-ES-Elvira:DragonHDLatestNeural', name: 'Elvira HD (Español)', language: 'es-ES', gender: 'female' as const, description: 'DragonHD' }, + ], + supportedFormats: ['mp3', 'wav', 'ogg'], + speedRange: { min: 0.5, max: 2.0, default: 1.0 }, + }, + 'glm-tts': { id: 'glm-tts', name: 'GLM TTS', @@ -1120,6 +1157,7 @@ export function getTTSProvider(providerId: TTSProviderId): TTSProviderConfig | u export const DEFAULT_TTS_VOICES: Record = { 'openai-tts': 'alloy', 'azure-tts': 'zh-CN-XiaoxiaoNeural', + 'azure-foundry-tts': 'en-US-AvaNeural', 'glm-tts': 'tongtong', 'qwen-tts': 'Cherry', 'doubao-tts': 'zh_female_vv_uranus_bigtts', @@ -1131,6 +1169,7 @@ export const DEFAULT_TTS_VOICES: Record = { export const DEFAULT_TTS_MODELS: Record = { 'openai-tts': 'gpt-4o-mini-tts', 'azure-tts': '', + 'azure-foundry-tts': '', 'glm-tts': 'glm-tts', 'qwen-tts': 'qwen3-tts-flash', 'doubao-tts': '', diff --git a/lib/audio/tts-providers.ts b/lib/audio/tts-providers.ts index 67f0e7cc0..066c8180f 100644 --- a/lib/audio/tts-providers.ts +++ b/lib/audio/tts-providers.ts @@ -144,6 +144,9 @@ export async function generateTTS( case 'azure-tts': return await generateAzureTTS(config, text); + case 'azure-foundry-tts': + return await generateAzureFoundryTTS(config, text); + case 'glm-tts': return await generateGLMTTS(config, text); @@ -203,6 +206,16 @@ async function generateOpenAITTS( }; } +/** + * Extracts the BCP-47 language code from an Azure voice name. + * Supports both standard ("en-US-AvaNeural") and HD ("en-US-Ava:DragonHDLatestNeural") formats. + * Falls back to 'en-US' if the format is unrecognised. + */ +function getVoiceLanguage(voice: string): string { + const match = voice.match(/^([a-z]{2,3}-[A-Z]{2,3})-/); + return match ? match[1] : 'en-US'; +} + /** * Azure TTS implementation (direct API call with SSML) */ @@ -211,12 +224,13 @@ async function generateAzureTTS( text: string, ): Promise { const baseUrl = config.baseUrl || TTS_PROVIDERS['azure-tts'].defaultBaseUrl; + const lang = getVoiceLanguage(config.voice); // Build SSML const rate = config.speed ? `${((config.speed - 1) * 100).toFixed(0)}%` : '0%'; const ssml = ` - - + + ${escapeXml(text)} @@ -243,6 +257,72 @@ async function generateAzureTTS( }; } +/** + * Azure AI Foundry TTS — multi-service Cognitive Services endpoint. + * URL format: https://{resource}.cognitiveservices.azure.com/cognitiveservices/v1 + * Supports standard Neural and premium DragonHD voices (e.g. "en-US-Ava:DragonHDLatestNeural"). + */ +async function generateAzureFoundryTTS( + config: TTSModelConfig, + text: string, +): Promise { + const baseUrl = (config.baseUrl || '').trim() || TTS_PROVIDERS['azure-foundry-tts'].defaultBaseUrl!; + + // Guard: detect unconfigured placeholder URL + if (baseUrl.includes('{resource}')) { + throw new Error( + 'Azure AI Foundry TTS: Base URL not configured. ' + + 'Replace {resource} with your actual Azure resource name, e.g. ' + + 'https://my-resource.cognitiveservices.azure.com', + ); + } + + if (!config.apiKey?.trim()) { + throw new Error('Azure AI Foundry TTS: API Key is required.'); + } + + const lang = getVoiceLanguage(config.voice); + // Custom domain endpoints require the /tts/ prefix before /cognitiveservices/v1. + // Regional endpoints (azure-tts) use: {region}.tts.speech.microsoft.com/cognitiveservices/v1 + // Custom domain endpoints (azure-foundry-tts) use: {custom}.cognitiveservices.azure.com/tts/cognitiveservices/v1 + // Reference: https://learn.microsoft.com/azure/ai-services/speech-service/speech-services-private-link + const endpoint = `${baseUrl.replace(/\/$/, '')}/tts/cognitiveservices/v1`; + + const rate = config.speed ? `${((config.speed - 1) * 100).toFixed(0)}%` : '0%'; + const ssml = ` + + + ${escapeXml(text)} + + + `.trim(); + + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'Ocp-Apim-Subscription-Key': config.apiKey, + 'Content-Type': 'application/ssml+xml; charset=utf-8', + 'X-Microsoft-OutputFormat': 'audio-16khz-128kbitrate-mono-mp3', + }, + body: ssml, + }); + + if (!response.ok) { + const body = await response.text().catch(() => ''); + throw new Error( + `Azure AI Foundry TTS error ${response.status} (${response.statusText})` + + (body ? `: ${body.slice(0, 200)}` : '') + + ` — endpoint: ${endpoint}`, + ); + } + + const arrayBuffer = await response.arrayBuffer(); + return { + audio: new Uint8Array(arrayBuffer), + format: 'mp3', + }; +} + /** * GLM TTS implementation (GLM API) */ diff --git a/lib/audio/types.ts b/lib/audio/types.ts index 0c3c91792..032d0fded 100644 --- a/lib/audio/types.ts +++ b/lib/audio/types.ts @@ -81,6 +81,7 @@ export type TTSProviderId = | 'openai-tts' | 'azure-tts' + | 'azure-foundry-tts' | 'glm-tts' | 'qwen-tts' | 'doubao-tts' diff --git a/lib/i18n/settings.ts b/lib/i18n/settings.ts index 356fea554..0ce8efe5c 100644 --- a/lib/i18n/settings.ts +++ b/lib/i18n/settings.ts @@ -224,6 +224,7 @@ export const settingsZhCN = { // Audio provider names providerOpenAITTS: 'OpenAI TTS (gpt-4o-mini-tts)', providerAzureTTS: 'Azure TTS', + providerAzureFoundryTTS: 'Azure AI Foundry TTS', providerGLMTTS: 'GLM TTS', providerQwenTTS: 'Qwen TTS(阿里云百炼)', providerDoubaoTTS: '豆包 TTS 2.0(火山引擎)', @@ -822,6 +823,7 @@ export const settingsEnUS = { // Audio provider names providerOpenAITTS: 'OpenAI TTS (gpt-4o-mini-tts)', providerAzureTTS: 'Azure TTS', + providerAzureFoundryTTS: 'Azure AI Foundry TTS', providerGLMTTS: 'GLM TTS', providerQwenTTS: 'Qwen TTS (Alibaba Cloud Bailian)', providerDoubaoTTS: 'Doubao TTS 2.0 (Volcengine)', diff --git a/lib/server/provider-config.ts b/lib/server/provider-config.ts index 27afa1411..8fc8ba2ad 100644 --- a/lib/server/provider-config.ts +++ b/lib/server/provider-config.ts @@ -54,6 +54,7 @@ const LLM_ENV_MAP: Record = { const TTS_ENV_MAP: Record = { TTS_OPENAI: 'openai-tts', TTS_AZURE: 'azure-tts', + TTS_AZURE_FOUNDRY: 'azure-foundry-tts', TTS_GLM: 'glm-tts', TTS_QWEN: 'qwen-tts', TTS_DOUBAO: 'doubao-tts', diff --git a/lib/store/settings.ts b/lib/store/settings.ts index 4b088bbc6..dcc918341 100644 --- a/lib/store/settings.ts +++ b/lib/store/settings.ts @@ -285,6 +285,7 @@ const getDefaultAudioConfig = () => ({ ttsProvidersConfig: { 'openai-tts': { apiKey: '', baseUrl: '', enabled: true }, 'azure-tts': { apiKey: '', baseUrl: '', enabled: false }, + 'azure-foundry-tts': { apiKey: '', baseUrl: '', enabled: false }, 'glm-tts': { apiKey: '', baseUrl: '', enabled: false }, 'qwen-tts': { apiKey: '', baseUrl: '', enabled: false }, 'doubao-tts': { apiKey: '', baseUrl: '', enabled: false }, diff --git a/tests/ai/minimax-provider.test.ts b/tests/ai/minimax-provider.test.ts index 89ebc8541..ff938d74a 100644 --- a/tests/ai/minimax-provider.test.ts +++ b/tests/ai/minimax-provider.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { getProvider } from '@/lib/ai/providers'; +import { finalizeProviderRequestUrl, getProvider, resolveProviderBaseUrl } from '@/lib/ai/providers'; describe('MiniMax provider defaults', () => { it('uses the Anthropic-compatible v1 endpoint by default', () => { @@ -19,4 +19,52 @@ describe('MiniMax provider defaults', () => { 'MiniMax-M2.7-highspeed', ]); }); + + it('normalizes custom MiniMax endpoints to the Anthropic-compatible v1 path', () => { + expect(resolveProviderBaseUrl('minimax', 'MiniMax-M2.5', 'https://proxy.example.com')).toBe( + 'https://proxy.example.com/anthropic/v1', + ); + }); +}); + +describe('OpenAI-compatible base URL templates', () => { + it('replaces {{model}} in the configured base URL', () => { + expect( + resolveProviderBaseUrl( + 'custom-azure-foundry', + 'gpt-5.4', + 'https://resource.example.com/openai/deployments/{{model}}', + ), + ).toBe('https://resource.example.com/openai/deployments/gpt-5.4'); + }); + + it('URL-encodes model ids inserted into path templates', () => { + expect( + resolveProviderBaseUrl( + 'custom-openai-compatible', + 'publisher/model-name', + 'https://gateway.example.com/deployments/{{model}}', + ), + ).toBe('https://gateway.example.com/deployments/publisher%2Fmodel-name'); + }); + + it('appends the Azure OpenAI api-version to deployment endpoints', () => { + expect( + finalizeProviderRequestUrl( + 'https://resource.example.cognitiveservices.azure.com/openai/deployments/gpt-5.4/chat/completions', + ), + ).toBe( + 'https://resource.example.cognitiveservices.azure.com/openai/deployments/gpt-5.4/chat/completions?api-version=2024-05-01-preview', + ); + }); + + it('preserves an explicit Azure OpenAI api-version if already present', () => { + expect( + finalizeProviderRequestUrl( + 'https://resource.example.openai.azure.com/openai/deployments/gpt-5.4/chat/completions?api-version=2024-10-21', + ), + ).toBe( + 'https://resource.example.openai.azure.com/openai/deployments/gpt-5.4/chat/completions?api-version=2024-10-21', + ); + }); });