From 5a09e563527a8136da100375a573a42d0e594d9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miralles=20Sol=C3=A9?= <46744682B@gcb.intranet.gencat.cat> Date: Sat, 4 Apr 2026 00:06:14 +0200 Subject: [PATCH 1/8] Add model placeholder support for OpenAI base URLs --- components/settings/add-provider-dialog.tsx | 6 +++- components/settings/provider-config-panel.tsx | 9 ++++-- lib/ai/providers.ts | 19 +++++++++++- tests/ai/minimax-provider.test.ts | 30 ++++++++++++++++++- 4 files changed, 59 insertions(+), 5 deletions(-) diff --git a/components/settings/add-provider-dialog.tsx b/components/settings/add-provider-dialog.tsx index fb4c12e23..26a3e0c92 100644 --- a/components/settings/add-provider-dialog.tsx +++ b/components/settings/add-provider-dialog.tsx @@ -26,6 +26,10 @@ interface AddProviderDialogProps { export function AddProviderDialog({ open, onOpenChange, onAdd }: AddProviderDialogProps) { const { t } = useI18n(); + const baseUrlPlaceholder = + type === 'openai' + ? 'https://example-resource.openai.azure.com/openai/deployments/{{model}}' + : 'https://api.example.com/v1'; // Internal state const [name, setName] = useState(''); @@ -128,7 +132,7 @@ export function AddProviderDialog({ open, onOpenChange, onAdd }: AddProviderDial setBaseUrl(e.target.value)} /> diff --git a/components/settings/provider-config-panel.tsx b/components/settings/provider-config-panel.tsx index 7c765c9ba..c0b075aeb 100644 --- a/components/settings/provider-config-panel.tsx +++ b/components/settings/provider-config-panel.tsx @@ -32,7 +32,7 @@ import { Send, } from 'lucide-react'; import { useI18n } from '@/lib/hooks/use-i18n'; -import type { ProviderConfig } from '@/lib/ai/providers'; +import { 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 +258,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 diff --git a/lib/ai/providers.ts b/lib/ai/providers.ts index 7a1a03116..f0603f92a 100644 --- a/lib/ai/providers.ts +++ b/lib/ai/providers.ts @@ -45,6 +45,8 @@ 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; + /** * Provider registry */ @@ -1054,6 +1056,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 +1099,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, ); diff --git a/tests/ai/minimax-provider.test.ts b/tests/ai/minimax-provider.test.ts index 89ebc8541..fd54ca338 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 { getProvider, resolveProviderBaseUrl } from '@/lib/ai/providers'; describe('MiniMax provider defaults', () => { it('uses the Anthropic-compatible v1 endpoint by default', () => { @@ -19,4 +19,32 @@ 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'); + }); }); From 5ee232ba9144d3b0f0b2609dbed2c97647c76b0c Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Fri, 3 Apr 2026 22:32:02 +0000 Subject: [PATCH 2/8] Fix Azure OpenAI provider URL handling --- components/settings/add-provider-dialog.tsx | 37 ++++++++------- components/settings/provider-config-panel.tsx | 8 +++- lib/ai/providers.ts | 47 ++++++++++++++----- tests/ai/minimax-provider.test.ts | 22 ++++++++- 4 files changed, 83 insertions(+), 31 deletions(-) diff --git a/components/settings/add-provider-dialog.tsx b/components/settings/add-provider-dialog.tsx index 26a3e0c92..f83e2fde1 100644 --- a/components/settings/add-provider-dialog.tsx +++ b/components/settings/add-provider-dialog.tsx @@ -26,10 +26,6 @@ interface AddProviderDialogProps { export function AddProviderDialog({ open, onOpenChange, onAdd }: AddProviderDialogProps) { const { t } = useI18n(); - const baseUrlPlaceholder = - type === 'openai' - ? 'https://example-resource.openai.azure.com/openai/deployments/{{model}}' - : 'https://api.example.com/v1'; // Internal state const [name, setName] = useState(''); @@ -37,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'; + + const resetForm = () => { + setName(''); + setType('openai'); + setBaseUrl(''); + setIcon(''); + setRequiresApiKey(true); + }; - // 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 handleOpenChange = (nextOpen: boolean) => { + if (!nextOpen) { + resetForm(); } - } + + onOpenChange(nextOpen); + }; const handleClose = () => { - onOpenChange(false); + handleOpenChange(false); }; const handleAdd = () => { @@ -66,7 +69,7 @@ export function AddProviderDialog({ open, onOpenChange, onAdd }: AddProviderDial }; return ( - + {t('settings.addProviderDialog')} diff --git a/components/settings/provider-config-panel.tsx b/components/settings/provider-config-panel.tsx index c0b075aeb..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 { resolveProviderBaseUrl, 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'; @@ -282,7 +286,7 @@ export function ProviderConfigPanel({ endpointPath = ''; } - const fullUrl = effectiveBaseUrl + endpointPath; + const fullUrl = finalizeProviderRequestUrl(effectiveBaseUrl + endpointPath); return (

diff --git a/lib/ai/providers.ts b/lib/ai/providers.ts index f0603f92a..9d77eac8b 100644 --- a/lib/ai/providers.ts +++ b/lib/ai/providers.ts @@ -46,6 +46,30 @@ const log = createLogger('AIProviders'); 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 @@ -1109,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; @@ -1138,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/tests/ai/minimax-provider.test.ts b/tests/ai/minimax-provider.test.ts index fd54ca338..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, resolveProviderBaseUrl } 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', () => { @@ -47,4 +47,24 @@ describe('OpenAI-compatible base URL templates', () => { ), ).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', + ); + }); }); From 00bd41c1df0f94f70a71afa5d5d697e76f552dac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miralles=20Sol=C3=A9?= <46744682B@gcb.intranet.gencat.cat> Date: Sat, 4 Apr 2026 11:51:17 +0200 Subject: [PATCH 3/8] feat(tts): add Azure AI Foundry TTS provider with DragonHD voice support - Add 'azure-foundry-tts' to TTSProviderId union type - Register azure-foundry-tts provider in constants.ts with standard Neural and premium DragonHD voices (en-US, zh-CN, ca-ES, es-ES) - Add getVoiceLanguage() helper to extract BCP-47 locale from Azure voice ID - Fix xml:lang hardcoded 'zh-CN' bug in generateAzureTTS (now dynamic) - Add generateAzureFoundryTTS() using multi-service Cognitive Services URL - Wire new provider into generateTTS() switch, settings store, env map and i18n Co-Authored-By: Claude Sonnet 4.6 --- lib/audio/constants.ts | 34 ++++++++++++++++++++ lib/audio/tts-providers.ts | 60 +++++++++++++++++++++++++++++++++-- lib/audio/types.ts | 1 + lib/i18n/settings.ts | 2 ++ lib/server/provider-config.ts | 1 + lib/store/settings.ts | 1 + 6 files changed, 97 insertions(+), 2 deletions(-) diff --git a/lib/audio/constants.ts b/lib/audio/constants.ts index 423f5b82c..e79d329cf 100644 --- a/lib/audio/constants.ts +++ b/lib/audio/constants.ts @@ -209,6 +209,38 @@ 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 }, + { 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-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 +1152,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 +1164,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..25bbc88b8 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,48 @@ 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 || TTS_PROVIDERS['azure-foundry-tts'].defaultBaseUrl; + const lang = getVoiceLanguage(config.voice); + + const rate = config.speed ? `${((config.speed - 1) * 100).toFixed(0)}%` : '0%'; + const ssml = ` + + + ${escapeXml(text)} + + + `.trim(); + + const response = await fetch(`${baseUrl}/cognitiveservices/v1`, { + 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) { + throw new Error(`Azure AI Foundry TTS error: ${response.statusText}`); + } + + 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 }, From d00bb96cf4513f76b2ef5e6368149a4ff8726b9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miralles=20Sol=C3=A9?= <46744682B@gcb.intranet.gencat.cat> Date: Sat, 4 Apr 2026 12:01:30 +0200 Subject: [PATCH 4/8] fix(tts): add azure-foundry-tts label to getTTSProviderName in all components The provider name mapping in audio-settings.tsx, settings/index.tsx and media-popover.tsx was missing the 'azure-foundry-tts' entry, causing the sidebar label to appear blank. Co-Authored-By: Claude Sonnet 4.6 --- components/generation/media-popover.tsx | 1 + components/settings/audio-settings.tsx | 1 + components/settings/index.tsx | 1 + 3 files changed, 3 insertions(+) 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/audio-settings.tsx b/components/settings/audio-settings.tsx index d88590ac0..e4490c4a7 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'), 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'), From e6989cac8be5565de5875035b227cb19d456270e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miralles=20Sol=C3=A9?= <46744682B@gcb.intranet.gencat.cat> Date: Sat, 4 Apr 2026 12:09:35 +0200 Subject: [PATCH 5/8] feat(tts): add voice selector to settings panel + expand azure-foundry-tts voices - Add voice Select dropdown to audio-settings.tsx for all providers with voices defined in constants (excludes azure-tts which uses its own locale-filter + big JSON approach) - Add Multilingual Neural voices: AvaMultilingual, AndrewMultilingual, AdamMultilingual - Add Adam:DragonHDLatestNeural HD voice - Voices list now totals 20 entries covering en-US, zh-CN, ca-ES, es-ES Co-Authored-By: Claude Sonnet 4.6 --- components/settings/audio-settings.tsx | 26 ++++++++++++++++++++++++++ lib/audio/constants.ts | 5 +++++ 2 files changed, 31 insertions(+) diff --git a/components/settings/audio-settings.tsx b/components/settings/audio-settings.tsx index e4490c4a7..b91a8e2e9 100644 --- a/components/settings/audio-settings.tsx +++ b/components/settings/audio-settings.tsx @@ -500,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/lib/audio/constants.ts b/lib/audio/constants.ts index e79d329cf..956e54ddc 100644 --- a/lib/audio/constants.ts +++ b/lib/audio/constants.ts @@ -223,6 +223,10 @@ export const TTS_PROVIDERS: Record = { { 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 }, @@ -232,6 +236,7 @@ export const TTS_PROVIDERS: Record = { // 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' }, From dd3acbb1d6e2904a2a8cac7cad64a5ba24fda4ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miralles=20Sol=C3=A9?= <46744682B@gcb.intranet.gencat.cat> Date: Sat, 4 Apr 2026 12:14:17 +0200 Subject: [PATCH 6/8] feat(tts): add voice selector to TTSSettings panel + azure-foundry-tts URL preview - Add voice Select dropdown to tts-settings.tsx for all providers with voices in constants (syncs to global store when provider is active, uses local state when testing a non-active provider) - Add 'azure-foundry-tts' to URL preview switch (/cognitiveservices/v1) Co-Authored-By: Claude Sonnet 4.6 --- components/settings/tts-settings.tsx | 51 ++++++++++++++++++++++++++-- 1 file changed, 49 insertions(+), 2 deletions(-) diff --git a/components/settings/tts-settings.tsx b/components/settings/tts-settings.tsx index 4a35a4f36..b8f16a6ba 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 () => { @@ -240,6 +267,7 @@ export function TTSSettings({ selectedProviderId }: TTSSettingsProps) { endpointPath = '/audio/speech'; break; case 'azure-tts': + case 'azure-foundry-tts': endpointPath = '/cognitiveservices/v1'; break; case 'qwen-tts': @@ -262,6 +290,25 @@ export function TTSSettings({ selectedProviderId }: TTSSettingsProps) { )} + {/* Voice selector — shown for providers with voices defined in constants */} + {getTTSVoices(selectedProviderId).length > 0 && ( +
+ + +
+ )} + {/* Test TTS */}
From 3226b2ea5d74cbd0fc9d4771e9dd263f04d716a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miralles=20Sol=C3=A9?= <46744682B@gcb.intranet.gencat.cat> Date: Sat, 4 Apr 2026 12:16:57 +0200 Subject: [PATCH 7/8] fix(tts): improve azure-foundry-tts error messages and URL validation - Detect unconfigured {resource} placeholder and throw clear message - Validate API key presence before making the request - Include HTTP status code, response body and actual endpoint URL in errors - Strip trailing slash from base URL before appending path Co-Authored-By: Claude Sonnet 4.6 --- lib/audio/tts-providers.ts | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/lib/audio/tts-providers.ts b/lib/audio/tts-providers.ts index 25bbc88b8..9b688e57c 100644 --- a/lib/audio/tts-providers.ts +++ b/lib/audio/tts-providers.ts @@ -266,8 +266,23 @@ async function generateAzureFoundryTTS( config: TTSModelConfig, text: string, ): Promise { - const baseUrl = config.baseUrl || TTS_PROVIDERS['azure-foundry-tts'].defaultBaseUrl; + 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); + const endpoint = `${baseUrl.replace(/\/$/, '')}/cognitiveservices/v1`; const rate = config.speed ? `${((config.speed - 1) * 100).toFixed(0)}%` : '0%'; const ssml = ` @@ -278,10 +293,10 @@ async function generateAzureFoundryTTS( `.trim(); - const response = await fetch(`${baseUrl}/cognitiveservices/v1`, { + const response = await fetch(endpoint, { method: 'POST', headers: { - 'Ocp-Apim-Subscription-Key': config.apiKey!, + 'Ocp-Apim-Subscription-Key': config.apiKey, 'Content-Type': 'application/ssml+xml; charset=utf-8', 'X-Microsoft-OutputFormat': 'audio-16khz-128kbitrate-mono-mp3', }, @@ -289,7 +304,12 @@ async function generateAzureFoundryTTS( }); if (!response.ok) { - throw new Error(`Azure AI Foundry TTS error: ${response.statusText}`); + 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(); From 5a2d5bf6c2e7f785965fdbc56203fe207f4be40e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miralles=20Sol=C3=A9?= <46744682B@gcb.intranet.gencat.cat> Date: Sat, 4 Apr 2026 12:27:35 +0200 Subject: [PATCH 8/8] fix(tts): correct azure-foundry-tts endpoint path to /tts/cognitiveservices/v1 Custom domain endpoints (*.cognitiveservices.azure.com) require the /tts/ prefix before /cognitiveservices/v1, unlike regional endpoints which use just /cognitiveservices/v1 directly on the TTS subdomain. Ref: https://learn.microsoft.com/azure/ai-services/speech-service/speech-services-private-link Standard: {region}.tts.speech.microsoft.com/cognitiveservices/v1 Custom: {custom}.cognitiveservices.azure.com/tts/cognitiveservices/v1 Also update URL preview in tts-settings.tsx to show the correct path. Co-Authored-By: Claude Sonnet 4.6 --- components/settings/tts-settings.tsx | 4 +++- lib/audio/tts-providers.ts | 6 +++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/components/settings/tts-settings.tsx b/components/settings/tts-settings.tsx index b8f16a6ba..dc36e5a80 100644 --- a/components/settings/tts-settings.tsx +++ b/components/settings/tts-settings.tsx @@ -267,9 +267,11 @@ export function TTSSettings({ selectedProviderId }: TTSSettingsProps) { endpointPath = '/audio/speech'; break; case 'azure-tts': - case 'azure-foundry-tts': endpointPath = '/cognitiveservices/v1'; break; + case 'azure-foundry-tts': + endpointPath = '/tts/cognitiveservices/v1'; + break; case 'qwen-tts': endpointPath = '/services/aigc/multimodal-generation/generation'; break; diff --git a/lib/audio/tts-providers.ts b/lib/audio/tts-providers.ts index 9b688e57c..066c8180f 100644 --- a/lib/audio/tts-providers.ts +++ b/lib/audio/tts-providers.ts @@ -282,7 +282,11 @@ async function generateAzureFoundryTTS( } const lang = getVoiceLanguage(config.voice); - const endpoint = `${baseUrl.replace(/\/$/, '')}/cognitiveservices/v1`; + // 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 = `