From 00f12c0e47fb47b1318a20e97bacc753e769612b Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Wed, 14 Oct 2026 18:49:51 +0400 Subject: [PATCH] refac --- backend/open_webui/config.py | 8 + .../0bd252b206fb_add_missing_auth_rows.py | 34 + backend/open_webui/models/config.py | 1 + backend/open_webui/models/users.py | 4 + backend/open_webui/routers/audio/__init__.py | 2 + backend/open_webui/routers/audio/realtime.py | 3 +- backend/open_webui/routers/scim.py | 15 +- src/lib/apis/audio/index.ts | 1 + .../admin/Analytics/Dashboard.svelte | 594 ++++++++++-------- .../components/admin/Settings/Audio.svelte | 30 + src/lib/i18n/locales/en-US/translation.json | 2 + 11 files changed, 406 insertions(+), 288 deletions(-) create mode 100644 backend/open_webui/migrations/versions/0bd252b206fb_add_missing_auth_rows.py diff --git a/backend/open_webui/config.py b/backend/open_webui/config.py index 16b4436226..cdad7d5859 100644 --- a/backend/open_webui/config.py +++ b/backend/open_webui/config.py @@ -1657,6 +1657,13 @@ 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') AUDIO_REALTIME_VOICE = os.getenv('AUDIO_REALTIME_VOICE', 'marin') AUDIO_REALTIME_TRANSCRIPTION_MODEL = os.getenv('AUDIO_REALTIME_TRANSCRIPTION_MODEL', 'gpt-transcribe') +audio_realtime_input_params = os.getenv('AUDIO_REALTIME_INPUT_PARAMS', '') +try: + audio_realtime_input_params = JSONCodec.loads(audio_realtime_input_params) +except JSONCodec.JSONDecodeError: + audio_realtime_input_params = None + +AUDIO_REALTIME_INPUT_PARAMS = audio_realtime_input_params if isinstance(audio_realtime_input_params, dict) else None REALTIME_CALL_PROMPT_TEMPLATE = os.getenv('REALTIME_CALL_PROMPT_TEMPLATE') AUDIO_TTS_SPLIT_ON = os.getenv('AUDIO_TTS_SPLIT_ON', 'punctuation') @@ -3136,6 +3143,7 @@ DEFAULT_CONFIG = { 'audio.realtime.model': AUDIO_REALTIME_MODEL, 'audio.realtime.voice': AUDIO_REALTIME_VOICE, 'audio.realtime.transcription_model': AUDIO_REALTIME_TRANSCRIPTION_MODEL, + 'audio.realtime.input_params': AUDIO_REALTIME_INPUT_PARAMS, 'audio.realtime.prompt_template': REALTIME_CALL_PROMPT_TEMPLATE, 'audio.tts.engine': AUDIO_TTS_ENGINE, 'audio.tts.model': AUDIO_TTS_MODEL, diff --git a/backend/open_webui/migrations/versions/0bd252b206fb_add_missing_auth_rows.py b/backend/open_webui/migrations/versions/0bd252b206fb_add_missing_auth_rows.py new file mode 100644 index 0000000000..163b59cc03 --- /dev/null +++ b/backend/open_webui/migrations/versions/0bd252b206fb_add_missing_auth_rows.py @@ -0,0 +1,34 @@ +"""add missing auth rows + +Revision ID: 0bd252b206fb +Revises: c92da83b017f +""" + +import sqlalchemy as sa +from alembic import op + +revision = '0bd252b206fb' +down_revision = 'c92da83b017f' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + user = sa.table('user', sa.column('id', sa.Text), sa.column('email', sa.Text)) + auth = sa.table( + 'auth', + sa.column('id', sa.Text), + sa.column('email', sa.Text), + sa.column('password', sa.Text), + sa.column('active', sa.Boolean), + ) + # An empty password hash never verifies; existing credentials remain untouched. + missing = sa.select(user.c.id, user.c.email, sa.literal(''), sa.true()).where( + ~sa.exists().where(auth.c.id == user.c.id) + ) + op.execute(auth.insert().from_select(['id', 'email', 'password', 'active'], missing)) + + +def downgrade() -> None: + # Backfilled accounts may have acquired credentials or MFA state since the upgrade. + pass diff --git a/backend/open_webui/models/config.py b/backend/open_webui/models/config.py index fc6592f409..9d1323b0f9 100644 --- a/backend/open_webui/models/config.py +++ b/backend/open_webui/models/config.py @@ -31,6 +31,7 @@ DICT_CONFIG_KEY_ALIASES = { 'image_generation.automatic1111.api_params': ('AUTOMATIC1111_PARAMS',), 'image_generation.openai.params': ('IMAGES_OPENAI_API_PARAMS',), 'audio.tts.openai.params': ('AUDIO_TTS_OPENAI_PARAMS',), + 'audio.realtime.input_params': ('AUDIO_REALTIME_INPUT_PARAMS',), 'models.default_metadata': ('DEFAULT_MODEL_METADATA',), 'models.default_params': ('DEFAULT_MODEL_PARAMS',), 'task.model.params': ('TASK_MODEL_PARAMS',), diff --git a/backend/open_webui/models/users.py b/backend/open_webui/models/users.py index 6a46388bb0..0aa6a6f167 100644 --- a/backend/open_webui/models/users.py +++ b/backend/open_webui/models/users.py @@ -814,6 +814,10 @@ class UsersTable: return None for key, value in updated.items(): setattr(user, key, value) + if 'email' in updated: + from open_webui.models.auths import Auth + + await session.execute(update(Auth).where(Auth.id == id).values(email=updated['email'])) await session.commit() return UserModel.model_validate(user) diff --git a/backend/open_webui/routers/audio/__init__.py b/backend/open_webui/routers/audio/__init__.py index e2b9b3b3fc..fda9e11854 100644 --- a/backend/open_webui/routers/audio/__init__.py +++ b/backend/open_webui/routers/audio/__init__.py @@ -108,6 +108,7 @@ REALTIME_CONFIG_KEYS = { 'MODEL': 'audio.realtime.model', 'VOICE': 'audio.realtime.voice', 'TRANSCRIPTION_MODEL': 'audio.realtime.transcription_model', + 'INPUT_PARAMS': 'audio.realtime.input_params', 'REALTIME_CALL_PROMPT_TEMPLATE': 'audio.realtime.prompt_template', } @@ -302,6 +303,7 @@ class RealtimeConfigForm(BaseModel): MODEL: str = Field(default='gpt-realtime-2.1-mini', min_length=1, max_length=200) VOICE: str = Field(default='marin', min_length=1, max_length=200) TRANSCRIPTION_MODEL: str = Field(default='gpt-transcribe', min_length=1, max_length=200) + INPUT_PARAMS: Optional[dict] = None REALTIME_CALL_PROMPT_TEMPLATE: Optional[str] = None diff --git a/backend/open_webui/routers/audio/realtime.py b/backend/open_webui/routers/audio/realtime.py index c53e6e9fca..a6ee756679 100644 --- a/backend/open_webui/routers/audio/realtime.py +++ b/backend/open_webui/routers/audio/realtime.py @@ -384,6 +384,7 @@ async def realtime_call(ws: WebSocket): 'audio.realtime.model', 'audio.realtime.voice', 'audio.realtime.transcription_model', + 'audio.realtime.input_params', 'audio.realtime.prompt_template', 'user.permissions', ) @@ -458,7 +459,6 @@ async def realtime_call(ws: WebSocket): 'audio': { 'input': { 'format': {'type': 'audio/pcm', 'rate': 24000}, - 'noise_reduction': {'type': 'near_field'}, 'transcription': {'model': config['audio.realtime.transcription_model']}, # Wait for the finalized transcript before requesting a response. This gives # every function call an unambiguous input_item_id, even during barge-in. @@ -468,6 +468,7 @@ async def realtime_call(ws: WebSocket): 'interrupt_response': False, 'create_response': False, }, + **(config.get('audio.realtime.input_params') or {}), }, 'output': {'format': {'type': 'audio/pcm', 'rate': 24000}, 'voice': voice}, }, diff --git a/backend/open_webui/routers/scim.py b/backend/open_webui/routers/scim.py index 2f90ac1557..886ec521a9 100644 --- a/backend/open_webui/routers/scim.py +++ b/backend/open_webui/routers/scim.py @@ -20,12 +20,14 @@ from open_webui.constants import ERROR_MESSAGES from open_webui.events import EVENTS, publish_event from open_webui.env import SCIM_AUTH_PROVIDER from open_webui.internal.db import get_async_session +from open_webui.models.auths import Auths from open_webui.models.groups import GroupModel, Groups, GroupHierarchyError from open_webui.models.users import UserModel, Users from open_webui.utils.auth import ( decode_token, get_admin_user, get_current_user, + get_password_hash, get_verified_user, ) from pydantic import BaseModel, ConfigDict, Field @@ -620,9 +622,6 @@ async def create_user( detail=f'User with email {email} already exists', ) - # Create user - user_id = str(uuid.uuid4()) - # Parse name if provided name = user_data.displayName if user_data.name: @@ -636,10 +635,10 @@ async def create_user( if user_data.photos and len(user_data.photos) > 0: profile_image = user_data.photos[0].value - new_user = await Users.insert_new_user( - id=user_id, - name=name, + new_user = await Auths.insert_new_auth( email=email, + password=await get_password_hash(str(uuid.uuid4())), + name=name, profile_image_url=profile_image, role='user' if user_data.active else 'pending', db=db, @@ -651,7 +650,7 @@ async def create_user( detail='Failed to create user', ) - new_user = await Users.update_user_scim_by_id(user_id, get_scim_provider(), user_data.externalId, db=db) + new_user = await Users.update_user_scim_by_id(new_user.id, get_scim_provider(), user_data.externalId, db=db) if not new_user: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, @@ -871,7 +870,7 @@ async def delete_user( detail=f'User {user_id} not found', ) - success = await Users.delete_user_by_id(user_id, db=db) + success = await Auths.delete_auth_by_id(user_id, db=db) if not success: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, diff --git a/src/lib/apis/audio/index.ts b/src/lib/apis/audio/index.ts index 959a13b97a..d7376b9aaf 100644 --- a/src/lib/apis/audio/index.ts +++ b/src/lib/apis/audio/index.ts @@ -36,6 +36,7 @@ export type RealtimeConfig = { MODEL: string; VOICE: string; TRANSCRIPTION_MODEL: string; + INPUT_PARAMS: Record | null; REALTIME_CALL_PROMPT_TEMPLATE: string | null; }; diff --git a/src/lib/components/admin/Analytics/Dashboard.svelte b/src/lib/components/admin/Analytics/Dashboard.svelte index 22fea3afc7..818c8569a7 100644 --- a/src/lib/components/admin/Analytics/Dashboard.svelte +++ b/src/lib/components/admin/Analytics/Dashboard.svelte @@ -68,6 +68,14 @@ } }; + type TokenUsage = { input_tokens: number; output_tokens: number; total_tokens: number }; + $: tokenColumns = [ + { key: 'input_tokens', label: $i18n.t('Input') }, + { key: 'output_tokens', label: $i18n.t('Output') }, + { key: 'total_tokens', label: $i18n.t('Total') } + ] as const; + let selectedLeaderboard = 'models'; + // Data let summary = { total_messages: 0, total_chats: 0, total_models: 0, total_users: 0 }; let modelStats: Array<{ @@ -77,12 +85,11 @@ unique_chats?: number; name?: string; }> = []; - let userStats: Array<{ user_id: string; name?: string; email?: string; count: number }> = []; + let userStats: Array< + { user_id: string; name?: string; email?: string; count: number } & Partial + > = []; let dailyStats: Array<{ date: string; models: Record }> = []; - let tokenStats: Record< - string, - { input_tokens: number; output_tokens: number; total_tokens: number } - > = {}; + let tokenStats: Record = {}; let totalTokens = { input: 0, output: 0, total: 0 }; let tokenTooltip: Instance | null = null; @@ -188,9 +195,13 @@ if (modelOrderBy === 'name') { return modelDirection === 'asc' ? a.name.localeCompare(b.name) : b.name.localeCompare(a.name); } - if (modelOrderBy === 'tokens') { - const aTokens = tokenStats[a.model_id]?.total_tokens ?? 0; - const bTokens = tokenStats[b.model_id]?.total_tokens ?? 0; + if ( + modelOrderBy === 'input_tokens' || + modelOrderBy === 'output_tokens' || + modelOrderBy === 'total_tokens' + ) { + const aTokens = tokenStats[a.model_id]?.[modelOrderBy] ?? 0; + const bTokens = tokenStats[b.model_id]?.[modelOrderBy] ?? 0; return modelDirection === 'asc' ? aTokens - bTokens : bTokens - aTokens; } if (modelOrderBy === 'users') { @@ -212,9 +223,13 @@ const nameB = b.name || b.user_id; return userDirection === 'asc' ? nameA.localeCompare(nameB) : nameB.localeCompare(nameA); } - if (userOrderBy === 'tokens') { - const aTokens = a.total_tokens ?? 0; - const bTokens = b.total_tokens ?? 0; + if ( + userOrderBy === 'input_tokens' || + userOrderBy === 'output_tokens' || + userOrderBy === 'total_tokens' + ) { + const aTokens = a[userOrderBy] ?? 0; + const bTokens = b[userOrderBy] ?? 0; return userDirection === 'asc' ? aTokens - bTokens : bTokens - aTokens; } return userDirection === 'asc' ? a.count - b.count : b.count - a.count; @@ -371,278 +386,299 @@ {:else} -
- -
-
- {$i18n.t('Model Usage')} -
-
- - - - - - - - - - - - - - {#each sortedModels as model, idx (model.model_id)} - { - selectedModel = { id: model.model_id, name: model.name }; - showModelModal = true; - }} - > - - - - - - +
+ {#each [{ key: 'models', label: $i18n.t('Models') }, { key: 'users', label: $i18n.t('Users') }] as tab} + + {/each} +
+ +
+ {#if selectedLeaderboard === 'models'} + +
+
+
# toggleModelSort('name')} - > -
- {$i18n.t('Model')} - {#if modelOrderBy === 'name'} - - {#if modelDirection === 'asc'}{:else}{/if} - - {:else} - - {/if} -
-
toggleModelSort('count')} - > -
- {$i18n.t('Messages')} - {#if modelOrderBy === 'count'} - - {#if modelDirection === 'asc'}{:else}{/if} - - {:else} - - {/if} -
-
toggleModelSort('users')} - > -
- {$i18n.t('Users')} - {#if modelOrderBy === 'users'} - - {#if modelDirection === 'asc'}{:else}{/if} - - {:else} - - {/if} -
-
toggleModelSort('chats')} - > -
- {$i18n.t('Chats')} - {#if modelOrderBy === 'chats'} - - {#if modelDirection === 'asc'}{:else}{/if} - - {:else} - - {/if} -
-
toggleModelSort('tokens')} - > -
- {$i18n.t('Tokens')} - {#if modelOrderBy === 'tokens'} - - {#if modelDirection === 'asc'}{:else}{/if} - - {:else} - - {/if} -
-
toggleModelSort('percentage')} - > -
- % - {#if modelOrderBy === 'percentage'} - - {#if modelDirection === 'asc'}{:else}{/if} - - {:else} - - {/if} -
-
{idx + 1} -
- {model.name} { - // LICENSE covers this Open WebUI fallback logo. - // Do not alter, remove, obscure, or replace it except as LICENSE permits: - // https://docs.openwebui.com/license. - e.target.src = '/favicon.png'; - }} - /> - {model.name} -
-
{model.count.toLocaleString()}{(model.unique_users ?? 0).toLocaleString()}{(model.unique_chats ?? 0).toLocaleString()}{formatNumber(tokenStats[model.model_id]?.total_tokens ?? 0)}
+ + + + - - {/each} - {#if sortedModels.length === 0} - - {/if} - -
# toggleModelSort('name')} > - - {totalModelMessages > 0 - ? ((model.count / totalModelMessages) * 100).toFixed(1) - : 0}% -
{$i18n.t('No data')}
-
-
- - -
-
- {$i18n.t('User Activity')} -
-
- - - - - - - - - - - {#each sortedUsers as user, idx (user.user_id)} - - - - - + + + + + {#each tokenColumns as column} + + {/each} + - {/each} - {#if sortedUsers.length === 0} - - {/if} - -
# toggleUserSort('name')} - > -
- {$i18n.t('User')} - {#if userOrderBy === 'name'} - - {#if userDirection === 'asc'}{:else}{/if} - - {:else} - - {/if} -
-
toggleUserSort('count')} - > -
- {$i18n.t('Messages')} - {#if userOrderBy === 'count'} - - {#if userDirection === 'asc'}{:else}{/if} - - {:else} - - {/if} -
-
toggleUserSort('tokens')} - > -
- {$i18n.t('Tokens')} - {#if userOrderBy === 'tokens'} - - {#if userDirection === 'asc'}{:else}{/if} - - {:else} - - {/if} -
-
{idx + 1} -
- {user.name { - e.target.src = '/user.png'; - }} - /> - {user.name || user.email || user.user_id.substring(0, 8)} +
+ {$i18n.t('Model')} + {#if modelOrderBy === 'name'} + + {#if modelDirection === 'asc'}{:else}{/if} + + {:else} + + {/if}
-
{user.count.toLocaleString()}{formatNumber(user.total_tokens ?? 0)} toggleModelSort('count')} + > +
+ {$i18n.t('Messages')} + {#if modelOrderBy === 'count'} + + {#if modelDirection === 'asc'}{:else}{/if} + + {:else} + + {/if} +
+
toggleModelSort('users')} + > +
+ {$i18n.t('Users')} + {#if modelOrderBy === 'users'} + + {#if modelDirection === 'asc'}{:else}{/if} + + {:else} + + {/if} +
+
toggleModelSort('chats')} + > +
+ {$i18n.t('Chats')} + {#if modelOrderBy === 'chats'} + + {#if modelDirection === 'asc'}{:else}{/if} + + {:else} + + {/if} +
+
toggleModelSort(column.key)} + > +
+ {column.label} + {#if modelOrderBy === column.key} + + {#if modelDirection === 'asc'}{:else}{/if} + + {:else} + + {/if} +
+
toggleModelSort('percentage')} + > +
+ % + {#if modelOrderBy === 'percentage'} + + {#if modelDirection === 'asc'}{:else}{/if} + + {:else} + + {/if} +
+
{$i18n.t('No data')}
+ + + {#each sortedModels as model, idx (model.model_id)} + { + selectedModel = { id: model.model_id, name: model.name }; + showModelModal = true; + }} + > + {idx + 1} + +
+ {model.name} { + // LICENSE covers this Open WebUI fallback logo. + // Do not alter, remove, obscure, or replace it except as LICENSE permits: + // https://docs.openwebui.com/license. + e.target.src = '/favicon.png'; + }} + /> + {model.name} +
+ + {model.count.toLocaleString()} + {(model.unique_users ?? 0).toLocaleString()} + {(model.unique_chats ?? 0).toLocaleString()} + {#each tokenColumns as column} + {formatNumber(tokenStats[model.model_id]?.[column.key] ?? 0)} + {/each} + + {totalModelMessages > 0 + ? ((model.count / totalModelMessages) * 100).toFixed(1) + : 0}% + + + {/each} + {#if sortedModels.length === 0} + {$i18n.t('No data')} + {/if} + + +
-
+ {:else} + +
+
+ + + + + + + {#each tokenColumns as column} + + {/each} + + + + {#each sortedUsers as user, idx (user.user_id)} + + + + + {#each tokenColumns as column} + + {/each} + + {/each} + {#if sortedUsers.length === 0} + + {/if} + +
# toggleUserSort('name')} + > +
+ {$i18n.t('User')} + {#if userOrderBy === 'name'} + + {#if userDirection === 'asc'}{:else}{/if} + + {:else} + + {/if} +
+
toggleUserSort('count')} + > +
+ {$i18n.t('Messages')} + {#if userOrderBy === 'count'} + + {#if userDirection === 'asc'}{:else}{/if} + + {:else} + + {/if} +
+
toggleUserSort(column.key)} + > +
+ {column.label} + {#if userOrderBy === column.key} + + {#if userDirection === 'asc'}{:else}{/if} + + {:else} + + {/if} +
+
{idx + 1} +
+ {user.name { + e.target.src = '/user.png'; + }} + /> + {user.name || user.email || user.user_id.substring(0, 8)} +
+
{user.count.toLocaleString()}{formatNumber(user[column.key] ?? 0)}
{$i18n.t('No data')}
+
+
+ {/if}
diff --git a/src/lib/components/admin/Settings/Audio.svelte b/src/lib/components/admin/Settings/Audio.svelte index 74f2449dd2..5b92421c2b 100644 --- a/src/lib/components/admin/Settings/Audio.svelte +++ b/src/lib/components/admin/Settings/Audio.svelte @@ -46,8 +46,10 @@ MODEL: 'gpt-realtime-2.1-mini', VOICE: 'marin', TRANSCRIPTION_MODEL: 'gpt-transcribe', + INPUT_PARAMS: null, REALTIME_CALL_PROMPT_TEMPLATE: '' }; + let REALTIME_INPUT_PARAMS = ''; // Audio let TTS_OPENAI_API_BASE_URL = ''; @@ -151,9 +153,23 @@ const updateConfigHandler = async () => { let openaiParams = {}; + let realtimeInputParams: Record | null = null; try { openaiParams = TTS_OPENAI_PARAMS ? JSON.parse(TTS_OPENAI_PARAMS) : {}; TTS_OPENAI_PARAMS = JSON.stringify(openaiParams, null, 2); + if (REALTIME_INPUT_PARAMS.trim()) { + realtimeInputParams = JSON.parse(REALTIME_INPUT_PARAMS); + if ( + !realtimeInputParams || + typeof realtimeInputParams !== 'object' || + Array.isArray(realtimeInputParams) + ) { + throw new Error('Input parameters must be a JSON object'); + } + } + REALTIME_INPUT_PARAMS = realtimeInputParams + ? JSON.stringify(realtimeInputParams, null, 2) + : ''; } catch { toast.error($i18n.t('Invalid JSON format for Parameters')); return; @@ -162,6 +178,7 @@ const res = await updateAudioConfig(localStorage.token, { realtime: { ...realtime, + INPUT_PARAMS: realtimeInputParams, REALTIME_CALL_PROMPT_TEMPLATE: (realtime.REALTIME_CALL_PROMPT_TEMPLATE ?? '').trim() || null }, tts: { @@ -240,6 +257,9 @@ ...res.realtime, REALTIME_CALL_PROMPT_TEMPLATE: res.realtime?.REALTIME_CALL_PROMPT_TEMPLATE ?? '' }; + REALTIME_INPUT_PARAMS = res.realtime?.INPUT_PARAMS + ? JSON.stringify(res.realtime.INPUT_PARAMS, null, 2) + : ''; STT_OPENAI_API_BASE_URL = res.stt.OPENAI_API_BASE_URL; STT_OPENAI_API_KEY = res.stt.OPENAI_API_KEY; STT_OPENAI_API_REQUEST_FORMAT = res.stt.OPENAI_API_REQUEST_FORMAT || 'multipart'; @@ -364,6 +384,16 @@ + +