Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions components/generation/media-popover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ function getTTSProviderName(providerId: TTSProviderId, t: (key: string) => strin
const names: Record<TTSProviderId, string> = {
'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'),
Expand Down
35 changes: 21 additions & 14 deletions components/settings/add-provider-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => {
Expand All @@ -62,7 +69,7 @@ export function AddProviderDialog({ open, onOpenChange, onAdd }: AddProviderDial
};

return (
<Dialog open={open} onOpenChange={onOpenChange}>
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-[450px]">
<DialogTitle className="sr-only">{t('settings.addProviderDialog')}</DialogTitle>
<DialogDescription className="sr-only">
Expand Down Expand Up @@ -128,7 +135,7 @@ export function AddProviderDialog({ open, onOpenChange, onAdd }: AddProviderDial
<Label>{t('settings.defaultBaseUrl')}</Label>
<Input
type="url"
placeholder="https://api.example.com/v1"
placeholder={baseUrlPlaceholder}
value={baseUrl}
onChange={(e) => setBaseUrl(e.target.value)}
/>
Expand Down
27 changes: 27 additions & 0 deletions components/settings/audio-settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ function getTTSProviderName(providerId: TTSProviderId, t: (key: string) => strin
const names: Record<TTSProviderId, string> = {
'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'),
Expand Down Expand Up @@ -499,6 +500,32 @@ export function AudioSettings({ onSave }: AudioSettingsProps = {}) {
</div>
</>
)}

{/* 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 && (
<div className="space-y-2">
<Label className="text-sm">{t('settings.ttsVoice')}</Label>
<Select
value={ttsVoice}
onValueChange={(v) => {
setTTSVoice(v);
onSave?.();
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{getTTSVoices(ttsProviderId).map((voice) => (
<SelectItem key={voice.id} value={voice.id}>
{voice.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
</div>

Expand Down
1 change: 1 addition & 0 deletions components/settings/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ function getTTSProviderName(providerId: TTSProviderId, t: (key: string) => strin
const names: Record<TTSProviderId, string> = {
'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'),
Expand Down
15 changes: 12 additions & 3 deletions components/settings/provider-config-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand All @@ -277,7 +286,7 @@ export function ProviderConfigPanel({
endpointPath = '';
}

const fullUrl = effectiveBaseUrl + endpointPath;
const fullUrl = finalizeProviderRequestUrl(effectiveBaseUrl + endpointPath);

return (
<p className="text-xs text-muted-foreground break-all">
Expand Down
53 changes: 51 additions & 2 deletions components/settings/tts-settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;

Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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;
Expand All @@ -262,6 +292,25 @@ export function TTSSettings({ selectedProviderId }: TTSSettingsProps) {
</>
)}

{/* Voice selector — shown for providers with voices defined in constants */}
{getTTSVoices(selectedProviderId).length > 0 && (
<div className="space-y-2">
<Label className="text-sm">{t('settings.ttsVoice')}</Label>
<Select value={selectedVoice} onValueChange={handleVoiceChange}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{getTTSVoices(selectedProviderId).map((voice) => (
<SelectItem key={voice.id} value={voice.id}>
{voice.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}

{/* Test TTS */}
<div className="space-y-2">
<Label className="text-sm">{t('settings.testTTS')}</Label>
Expand Down
66 changes: 54 additions & 12 deletions lib/ai/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1083,28 +1123,29 @@ 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,
);

let model: LanguageModel;

switch (providerType) {
case 'openai': {
const providerId = config.providerId;
const openaiOptions: Parameters<typeof createOpenAI>[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<string, unknown>).__thinkingContext as
| { getStore?: () => unknown }
| undefined;
Expand All @@ -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);
Expand Down
Loading