mirror of
https://github.com/open-webui/open-webui.git
synced 2026-10-15 04:18:18 +00:00
refac
This commit is contained in:
parent
2bf3fb01ba
commit
3ec0774503
6 changed files with 168 additions and 48 deletions
|
|
@ -1650,6 +1650,8 @@ AUDIO_TTS_VOICE = os.getenv('AUDIO_TTS_VOICE', 'alloy')
|
|||
REALTIME_TTS_PROMPT_TEMPLATE = os.getenv('REALTIME_TTS_PROMPT_TEMPLATE')
|
||||
|
||||
AUDIO_REALTIME_ENABLED = os.getenv('AUDIO_REALTIME_ENABLED', 'False').lower() == 'true'
|
||||
AUDIO_REALTIME_PROVIDER = os.getenv('AUDIO_REALTIME_PROVIDER', 'openai')
|
||||
AUDIO_REALTIME_AUTH_TYPE = os.getenv('AUDIO_REALTIME_AUTH_TYPE', 'api_key')
|
||||
AUDIO_REALTIME_OPENAI_API_BASE_URL = os.getenv('AUDIO_REALTIME_OPENAI_API_BASE_URL', 'https://api.openai.com/v1')
|
||||
AUDIO_REALTIME_OPENAI_API_KEY = os.getenv('AUDIO_REALTIME_OPENAI_API_KEY', '')
|
||||
AUDIO_REALTIME_MODEL = os.getenv('AUDIO_REALTIME_MODEL', 'gpt-realtime-2.1-mini')
|
||||
|
|
@ -3127,6 +3129,8 @@ DEFAULT_CONFIG = {
|
|||
'audio.tts.openai.params': AUDIO_TTS_OPENAI_PARAMS,
|
||||
'audio.tts.api_key': AUDIO_TTS_API_KEY,
|
||||
'audio.realtime.enabled': AUDIO_REALTIME_ENABLED,
|
||||
'audio.realtime.provider': AUDIO_REALTIME_PROVIDER,
|
||||
'audio.realtime.auth_type': AUDIO_REALTIME_AUTH_TYPE,
|
||||
'audio.realtime.openai.api_base_url': AUDIO_REALTIME_OPENAI_API_BASE_URL,
|
||||
'audio.realtime.openai.api_key': AUDIO_REALTIME_OPENAI_API_KEY,
|
||||
'audio.realtime.model': AUDIO_REALTIME_MODEL,
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import uuid
|
|||
import wave
|
||||
from fnmatch import fnmatch
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from typing import Literal, Optional
|
||||
from urllib.parse import urlencode, urlsplit, urlunsplit
|
||||
|
||||
import aiofiles
|
||||
|
|
@ -101,6 +101,8 @@ TTS_CONFIG_KEYS = {
|
|||
|
||||
REALTIME_CONFIG_KEYS = {
|
||||
'ENABLED': 'audio.realtime.enabled',
|
||||
'PROVIDER': 'audio.realtime.provider',
|
||||
'AUTH_TYPE': 'audio.realtime.auth_type',
|
||||
'OPENAI_API_BASE_URL': 'audio.realtime.openai.api_base_url',
|
||||
'OPENAI_API_KEY': 'audio.realtime.openai.api_key',
|
||||
'MODEL': 'audio.realtime.model',
|
||||
|
|
@ -293,6 +295,8 @@ class STTConfigForm(BaseModel):
|
|||
|
||||
class RealtimeConfigForm(BaseModel):
|
||||
ENABLED: bool = False
|
||||
PROVIDER: Literal['openai', 'azure'] = 'openai'
|
||||
AUTH_TYPE: Literal['api_key', 'microsoft_entra_id'] = 'api_key'
|
||||
OPENAI_API_BASE_URL: str = 'https://api.openai.com/v1'
|
||||
OPENAI_API_KEY: str = ''
|
||||
MODEL: str = Field(default='gpt-realtime-2.1-mini', min_length=1, max_length=200)
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ from open_webui.models.config import Config
|
|||
from open_webui.models.models import Models, ModelVoice
|
||||
from open_webui.utils.access_control import has_permission
|
||||
from open_webui.utils.auth import get_verified_user_by_token
|
||||
from open_webui.utils.headers import get_microsoft_entra_id_access_token
|
||||
from open_webui.utils.json_codec import JSONCodec
|
||||
from open_webui.utils.models import check_model_access, get_all_models
|
||||
from open_webui.utils.session_pool import get_session
|
||||
|
|
@ -300,6 +301,55 @@ class CallProtocol:
|
|||
raise ValueError('Unsupported call command')
|
||||
|
||||
|
||||
async def _get_realtime_connection(config: dict) -> tuple[str, dict[str, str]]:
|
||||
provider = config.get('audio.realtime.provider', 'openai')
|
||||
auth_type = config.get('audio.realtime.auth_type', 'api_key')
|
||||
if provider not in {'openai', 'azure'} or auth_type not in {'api_key', 'microsoft_entra_id'}:
|
||||
raise ValueError('Unsupported Realtime provider or authentication method')
|
||||
if provider != 'azure' and auth_type == 'microsoft_entra_id':
|
||||
raise ValueError('Microsoft Entra ID requires the Azure OpenAI Realtime provider')
|
||||
|
||||
try:
|
||||
url = urlsplit(config.get('audio.realtime.openai.api_base_url') or '')
|
||||
valid_url = (
|
||||
url.scheme in {'http', 'https'}
|
||||
and url.hostname
|
||||
and not (url.username or url.password or url.query or url.fragment)
|
||||
)
|
||||
url.port # Validate a supplied port before opening the connection.
|
||||
except ValueError:
|
||||
valid_url = False
|
||||
if not valid_url:
|
||||
raise ValueError('Invalid Realtime provider URL')
|
||||
model = config.get('audio.realtime.model')
|
||||
if not model:
|
||||
raise ValueError('Configure the Realtime model')
|
||||
ws_url = urlunsplit(
|
||||
(
|
||||
'wss' if url.scheme == 'https' else 'ws',
|
||||
url.netloc,
|
||||
url.path.rstrip('/') + '/realtime',
|
||||
urlencode({'model': model}),
|
||||
'',
|
||||
)
|
||||
)
|
||||
|
||||
if auth_type == 'microsoft_entra_id':
|
||||
try:
|
||||
token = await asyncio.to_thread(get_microsoft_entra_id_access_token)
|
||||
except Exception:
|
||||
raise ValueError('Unable to obtain a Microsoft Entra ID token for Realtime calls') from None
|
||||
if not token:
|
||||
raise ValueError('Unable to obtain a Microsoft Entra ID token for Realtime calls')
|
||||
headers = {'Authorization': f'Bearer {token}'}
|
||||
else:
|
||||
key = config.get('audio.realtime.openai.api_key')
|
||||
if not key:
|
||||
raise ValueError('Configure the Realtime API key')
|
||||
headers = {'api-key': key} if provider == 'azure' else {'Authorization': f'Bearer {key}'}
|
||||
return ws_url, headers
|
||||
|
||||
|
||||
@router.websocket('/realtime')
|
||||
async def realtime_call(ws: WebSocket):
|
||||
await ws.accept()
|
||||
|
|
@ -321,6 +371,8 @@ async def realtime_call(ws: WebSocket):
|
|||
raise ValueError('Authentication expired or invalid')
|
||||
config = await Config.get_many(
|
||||
'audio.realtime.enabled',
|
||||
'audio.realtime.provider',
|
||||
'audio.realtime.auth_type',
|
||||
'audio.realtime.openai.api_base_url',
|
||||
'audio.realtime.openai.api_key',
|
||||
'audio.realtime.model',
|
||||
|
|
@ -360,38 +412,30 @@ async def realtime_call(ws: WebSocket):
|
|||
override = ModelVoice.model_validate((model_info.meta.model_dump().get('voice') if model_info else None) or {})
|
||||
voice_model = config.get('audio.realtime.model')
|
||||
voice = override.voice or config.get('audio.realtime.voice')
|
||||
key = config.get('audio.realtime.openai.api_key')
|
||||
url = urlsplit(config.get('audio.realtime.openai.api_base_url') or '')
|
||||
if (
|
||||
url.scheme not in {'http', 'https'}
|
||||
or not url.netloc
|
||||
or url.username
|
||||
or url.password
|
||||
or url.query
|
||||
or url.fragment
|
||||
):
|
||||
raise ValueError('Invalid Realtime provider URL')
|
||||
if not key or not voice_model or not voice or not config.get('audio.realtime.transcription_model'):
|
||||
raise ValueError('Configure the Realtime API key, model, voice, and transcription model')
|
||||
ws_url = urlunsplit(
|
||||
(
|
||||
'wss' if url.scheme == 'https' else 'ws',
|
||||
url.netloc,
|
||||
url.path.rstrip('/') + '/realtime',
|
||||
urlencode({'model': voice_model}),
|
||||
'',
|
||||
)
|
||||
)
|
||||
session = await get_session()
|
||||
if not voice_model or not voice or not config.get('audio.realtime.transcription_model'):
|
||||
raise ValueError('Configure the Realtime model, voice, and transcription model')
|
||||
async with asyncio.timeout(30):
|
||||
async with asyncio.timeout(15):
|
||||
upstream = await session.ws_connect(
|
||||
ws_url,
|
||||
headers={'Authorization': f'Bearer {key}'},
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
heartbeat=20,
|
||||
max_msg_size=MAX_EVENT_BYTES,
|
||||
)
|
||||
ws_url, headers = await _get_realtime_connection(config)
|
||||
session = await get_session()
|
||||
try:
|
||||
upstream = await session.ws_connect(
|
||||
ws_url,
|
||||
headers=headers,
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
heartbeat=20,
|
||||
max_msg_size=MAX_EVENT_BYTES,
|
||||
)
|
||||
except aiohttp.WSServerHandshakeError as exc:
|
||||
if exc.status in {401, 403}:
|
||||
raise ValueError(
|
||||
'Realtime provider authentication rejected. Check the credentials and access.'
|
||||
) from None
|
||||
if exc.status == 404:
|
||||
raise ValueError(
|
||||
'Realtime endpoint not found. Check the API base URL and deployment name.'
|
||||
) from None
|
||||
raise
|
||||
event = await upstream.receive_json()
|
||||
if event.get('type') != 'session.created':
|
||||
raise ValueError('Provider did not create a voice session')
|
||||
|
|
|
|||
|
|
@ -27,10 +27,22 @@ export const getAudioConfig = async (token: string) => {
|
|||
return res;
|
||||
};
|
||||
|
||||
export type RealtimeConfig = {
|
||||
ENABLED: boolean;
|
||||
PROVIDER: 'openai' | 'azure';
|
||||
AUTH_TYPE: 'api_key' | 'microsoft_entra_id';
|
||||
OPENAI_API_BASE_URL: string;
|
||||
OPENAI_API_KEY: string;
|
||||
MODEL: string;
|
||||
VOICE: string;
|
||||
TRANSCRIPTION_MODEL: string;
|
||||
REALTIME_CALL_PROMPT_TEMPLATE: string | null;
|
||||
};
|
||||
|
||||
type AudioConfigForm = {
|
||||
tts: Record<string, unknown>;
|
||||
stt: Record<string, unknown>;
|
||||
realtime?: Record<string, unknown>;
|
||||
realtime?: Partial<RealtimeConfig>;
|
||||
};
|
||||
|
||||
export const updateAudioConfig = async (token: string, payload: AudioConfigForm) => {
|
||||
|
|
|
|||
|
|
@ -8,9 +8,10 @@
|
|||
getAudioConfig,
|
||||
updateAudioConfig,
|
||||
getModels as _getModels,
|
||||
getVoices as _getVoices
|
||||
getVoices as _getVoices,
|
||||
type RealtimeConfig
|
||||
} from '$lib/apis/audio';
|
||||
import { config, settings } from '$lib/stores';
|
||||
import { config } from '$lib/stores';
|
||||
|
||||
import Spinner from '$lib/components/common/Spinner.svelte';
|
||||
import SensitiveInput from '$lib/components/common/SensitiveInput.svelte';
|
||||
|
|
@ -31,8 +32,10 @@
|
|||
|
||||
export let saveHandler: () => void;
|
||||
|
||||
let realtime = {
|
||||
let realtime: RealtimeConfig & { REALTIME_CALL_PROMPT_TEMPLATE: string } = {
|
||||
ENABLED: false,
|
||||
PROVIDER: 'openai',
|
||||
AUTH_TYPE: 'api_key',
|
||||
OPENAI_API_BASE_URL: 'https://api.openai.com/v1',
|
||||
OPENAI_API_KEY: '',
|
||||
MODEL: 'gpt-realtime-2.1-mini',
|
||||
|
|
@ -146,7 +149,7 @@
|
|||
try {
|
||||
openaiParams = TTS_OPENAI_PARAMS ? JSON.parse(TTS_OPENAI_PARAMS) : {};
|
||||
TTS_OPENAI_PARAMS = JSON.stringify(openaiParams, null, 2);
|
||||
} catch (e) {
|
||||
} catch {
|
||||
toast.error($i18n.t('Invalid JSON format for Parameters'));
|
||||
return;
|
||||
}
|
||||
|
|
@ -154,7 +157,7 @@
|
|||
const res = await updateAudioConfig(localStorage.token, {
|
||||
realtime: {
|
||||
...realtime,
|
||||
REALTIME_CALL_PROMPT_TEMPLATE: realtime.REALTIME_CALL_PROMPT_TEMPLATE.trim() || null
|
||||
REALTIME_CALL_PROMPT_TEMPLATE: (realtime.REALTIME_CALL_PROMPT_TEMPLATE ?? '').trim() || null
|
||||
},
|
||||
tts: {
|
||||
OPENAI_API_BASE_URL: TTS_OPENAI_API_BASE_URL,
|
||||
|
|
@ -283,21 +286,67 @@
|
|||
</SettingsSelect>
|
||||
</AdminSettingRow>
|
||||
{#if realtime.ENABLED}
|
||||
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
<AdminSettingField label={$i18n.t('OpenAI API Base URL')}>
|
||||
<input class={inputClass} bind:value={realtime.OPENAI_API_BASE_URL} />
|
||||
</AdminSettingField>
|
||||
<AdminSettingField label={$i18n.t('API Key')}>
|
||||
<SensitiveInput
|
||||
variant="settings"
|
||||
placeholder={$i18n.t('API Key')}
|
||||
bind:value={realtime.OPENAI_API_KEY}
|
||||
<AdminSettingRow label={$i18n.t('Provider')}>
|
||||
<SettingsSelect
|
||||
bind:value={realtime.PROVIDER}
|
||||
ariaLabel={$i18n.t('Provider')}
|
||||
on:change={() => (realtime.AUTH_TYPE = 'api_key')}
|
||||
>
|
||||
<option value="openai">{$i18n.t('settings.audio.realtime.openai')}</option>
|
||||
<option value="azure">{$i18n.t('Azure OpenAI')}</option>
|
||||
</SettingsSelect>
|
||||
</AdminSettingRow>
|
||||
{#if realtime.PROVIDER === 'azure'}
|
||||
<AdminSettingRow
|
||||
label={$i18n.t('Authentication')}
|
||||
description={realtime.AUTH_TYPE === 'microsoft_entra_id'
|
||||
? $i18n.t('settings.audio.realtime.entraHelp')
|
||||
: ''}
|
||||
>
|
||||
<SettingsSelect bind:value={realtime.AUTH_TYPE} ariaLabel={$i18n.t('Authentication')}>
|
||||
<option value="api_key">{$i18n.t('API Key')}</option>
|
||||
<option value="microsoft_entra_id">{$i18n.t('settings.audio.realtime.entra')}</option>
|
||||
</SettingsSelect>
|
||||
</AdminSettingRow>
|
||||
{/if}
|
||||
<div class="grid grid-cols-1 gap-2" class:sm:grid-cols-2={realtime.AUTH_TYPE === 'api_key'}>
|
||||
<AdminSettingField
|
||||
label={$i18n.t(
|
||||
realtime.PROVIDER === 'azure' ? 'settings.audio.realtime.azureUrl' : 'API Base URL'
|
||||
)}
|
||||
description={realtime.PROVIDER === 'azure'
|
||||
? $i18n.t('settings.audio.realtime.azureUrlHelp')
|
||||
: ''}
|
||||
forId="realtime-api-base-url"
|
||||
>
|
||||
<input
|
||||
id="realtime-api-base-url"
|
||||
class={inputClass}
|
||||
bind:value={realtime.OPENAI_API_BASE_URL}
|
||||
/>
|
||||
</AdminSettingField>
|
||||
{#if realtime.AUTH_TYPE === 'api_key'}
|
||||
<AdminSettingField label={$i18n.t('API Key')}>
|
||||
<SensitiveInput
|
||||
variant="settings"
|
||||
placeholder={$i18n.t('API Key')}
|
||||
bind:value={realtime.OPENAI_API_KEY}
|
||||
/>
|
||||
</AdminSettingField>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
<AdminSettingField label={$i18n.t('Voice Model')}>
|
||||
<AdminSettingField
|
||||
label={$i18n.t(
|
||||
realtime.PROVIDER === 'azure' ? 'settings.audio.realtime.deployment' : 'Voice Model'
|
||||
)}
|
||||
description={realtime.PROVIDER === 'azure'
|
||||
? $i18n.t('settings.audio.realtime.deploymentHelp')
|
||||
: ''}
|
||||
forId="realtime-model"
|
||||
>
|
||||
<input
|
||||
id="realtime-model"
|
||||
class={inputClass}
|
||||
bind:value={realtime.MODEL}
|
||||
placeholder="gpt-realtime-2.1-mini"
|
||||
|
|
|
|||
|
|
@ -3908,5 +3908,12 @@
|
|||
"Public key": "",
|
||||
"Enter the Ed25519 public key provided by the app.": "",
|
||||
"Edit details manually": "",
|
||||
"Enter details manually": ""
|
||||
"Enter details manually": "",
|
||||
"settings.audio.realtime.openai": "OpenAI / compatible",
|
||||
"settings.audio.realtime.entra": "Microsoft Entra ID",
|
||||
"settings.audio.realtime.azureUrl": "Azure OpenAI API Base URL",
|
||||
"settings.audio.realtime.azureUrlHelp": "Use the full API base URL, for example https://<resource>.openai.azure.com/openai/v1.",
|
||||
"settings.audio.realtime.entraHelp": "Uses DefaultAzureCredential on the backend server, not the signed-in browser user. Configure a managed identity or Azure credentials on the server.",
|
||||
"settings.audio.realtime.deployment": "Realtime Deployment Name",
|
||||
"settings.audio.realtime.deploymentHelp": "Enter your Azure deployment name, which can differ from the model name. Choose a voice and transcription model supported by that deployment."
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue