From a464ee9e907187030ba816f6c41091167a0d270d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Sun, 7 Jun 2026 10:00:53 +0200 Subject: [PATCH] feat: TASK-2026-05-26-004 prepare OpenAI web auth PR branch Rewrite the feature branch onto current upstream/dev and drop the maintainer-requested test and guide files from the PR diff while preserving the core OpenAI web-auth implementation. --- backend/open_webui/routers/openai.py | 708 +++++++++++++++++- src/lib/apis/openai/index.ts | 296 +++++++- src/lib/components/AddConnectionModal.svelte | 86 ++- .../admin/Settings/Connections.svelte | 67 +- .../OpenAIWebAuthConnection.svelte | 269 +++++++ 5 files changed, 1382 insertions(+), 44 deletions(-) create mode 100644 src/lib/components/admin/Settings/Connections/OpenAIWebAuthConnection.svelte diff --git a/backend/open_webui/routers/openai.py b/backend/open_webui/routers/openai.py index 8aa8eff36e..f1bfb915ad 100644 --- a/backend/open_webui/routers/openai.py +++ b/backend/open_webui/routers/openai.py @@ -1,12 +1,14 @@ from __future__ import annotations import asyncio +import base64 import hashlib import json import logging import re +import time from typing import Optional -from urllib.parse import quote, urlparse +from urllib.parse import quote, urlencode, urlparse import aiohttp from aiocache import cached @@ -36,6 +38,7 @@ from open_webui.internal.db import get_async_session from open_webui.models.access_grants import AccessGrants from open_webui.models.groups import Groups from open_webui.models.models import Models +from open_webui.models.oauth_sessions import OAuthSessions from open_webui.models.users import UserModel from open_webui.utils.access_control import check_model_access, has_connection_access from open_webui.utils.anthropic import get_anthropic_models, is_anthropic_url @@ -110,6 +113,12 @@ async def send_get_request( return await response.json() except Exception as e: # Handle connection error here + if is_openai_codex_web_auth_config(config): + log.warning( + 'OpenAI web auth model request failed for host=%s error=%s', + urlparse(url).hostname if url else None, + e, + ) log.error(f'Connection error: {e}') return None @@ -123,7 +132,31 @@ async def get_models_request( ): if is_anthropic_url(url): return await get_anthropic_models(url, key, user=user) - return await send_get_request(request, f'{url}/models', key, user=user, config=config) + try: + return await send_get_request(request, f'{url}/models', key, user=user, config=config) + except HTTPException as e: + if is_openai_codex_web_auth_config(config): + log.warning( + 'OpenAI web auth model request failed for host=%s status=%s detail=%s', + urlparse(url).hostname, + e.status_code, + e.detail, + ) + raise + + +def is_empty_native_openai_bearer_connection( + url: str, + key: str = '', + config: Optional[dict] = None, +) -> bool: + config = config or {} + auth_type = config.get('auth_type') + return ( + urlparse(url).hostname == 'api.openai.com' + and not key + and (auth_type == 'bearer' or auth_type is None) + ) def openai_reasoning_model_handler(payload): @@ -200,6 +233,14 @@ async def get_headers_and_cookies( if oauth_token: token = f'{oauth_token.get("access_token", "")}' + elif is_openai_codex_web_auth_config(config): + token = await get_openai_web_auth_access_token() + if not token: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail='OpenAI web auth credential is not connected or requires reconnection', + ) + elif auth_type in ('azure_ad', 'microsoft_entra_id'): token = get_microsoft_entra_id_access_token() @@ -210,6 +251,15 @@ async def get_headers_and_cookies( custom_headers = get_custom_headers(config.get('headers'), user, metadata) headers.update(custom_headers) + if is_openai_codex_web_auth_config(config) and token: + headers['Authorization'] = f'Bearer {token}' + session = await get_openai_web_auth_session() + apply_openai_codex_web_auth_headers( + headers, + account_id=(session.token or {}).get('account_id') if session else None, + metadata=metadata, + ) + return headers, cookies @@ -236,6 +286,568 @@ def get_microsoft_entra_id_access_token(): router = APIRouter() +OPENAI_WEB_AUTH_DEVICE_SESSION_PROVIDER = 'openai_web_auth_device' +OPENAI_WEB_AUTH_CREDENTIAL_PROVIDER = 'openai_web_auth_credential' +OPENAI_WEB_AUTH_STORAGE_USER_ID = '__openai_web_auth__' +OPENAI_WEB_AUTH_ISSUER = 'https://auth.openai.com' +OPENAI_WEB_AUTH_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann' +OPENAI_WEB_AUTH_VERIFICATION_URL = f'{OPENAI_WEB_AUTH_ISSUER}/codex/device' +OPENAI_WEB_AUTH_REDIRECT_URI = f'{OPENAI_WEB_AUTH_ISSUER}/deviceauth/callback' +OPENAI_WEB_AUTH_DEFAULT_EXPIRES_IN = 3600 +OPENAI_WEB_AUTH_REFRESH_SKEW_SECONDS = 300 +OPENAI_CODEX_WEB_AUTH_TYPE = 'openai_codex_web_auth' +OPENAI_CODEX_WEB_AUTH_LEGACY_TYPE = 'openai_web_auth' +OPENAI_CODEX_API_ENDPOINT = 'https://chatgpt.com/backend-api/codex/responses' +OPENAI_CODEX_WEB_AUTH_MODEL_IDS = [ + 'gpt-5.5', + 'gpt-5.2', + 'gpt-5.3-codex', + 'gpt-5.3-codex-spark', + 'gpt-5.4', + 'gpt-5.4-mini', +] + + +class OpenAIWebAuthStartResponse(BaseModel): + verification_url: str + user_code: str + session_id: str + interval: int + expires_at: int + + +class OpenAIWebAuthCompleteForm(BaseModel): + session_id: str + + +class OpenAIWebAuthStatusResponse(BaseModel): + credential_type: str + connected: bool + has_credential: bool + status: str + expires_at: Optional[int] = None + + +def is_openai_codex_web_auth_config(config: Optional[dict] = None) -> bool: + return (config or {}).get('auth_type') in ( + OPENAI_CODEX_WEB_AUTH_TYPE, + OPENAI_CODEX_WEB_AUTH_LEGACY_TYPE, + ) + + +def build_openai_codex_web_auth_models(url_idx: int) -> dict: + return { + 'object': 'list', + 'data': [ + { + 'id': model_id, + 'name': model_id, + 'owned_by': 'openai', + 'openai': {'id': model_id}, + 'urlIdx': url_idx, + 'connection_type': 'external', + 'provider': 'OpenAI Account Auth', + } + for model_id in OPENAI_CODEX_WEB_AUTH_MODEL_IDS + ], + } + + +async def build_openai_codex_web_auth_models_if_connected(url_idx: int) -> Optional[dict]: + token = await get_openai_web_auth_access_token() + if not token: + return None + return build_openai_codex_web_auth_models(url_idx) + + +def apply_openai_codex_web_auth_headers(headers: dict, account_id: Optional[str] = None, metadata: Optional[dict] = None): + if account_id: + headers['ChatGPT-Account-Id'] = account_id + headers['originator'] = 'open-webui' + headers['User-Agent'] = 'Open WebUI' + session_id = metadata.get('chat_id') if metadata else None + if session_id: + headers['session_id'] = str(session_id) + + +def prepare_openai_codex_web_auth_request( + payload: dict, + is_responses: bool = False, +) -> tuple[str, dict, bool]: + """Return the Codex account-auth endpoint and Responses-compatible payload.""" + + responses_payload = payload if is_responses else convert_to_responses_payload(payload) + if not responses_payload.get('instructions'): + responses_payload['instructions'] = 'You are ChatGPT, a helpful assistant.' + responses_payload['store'] = False + responses_payload['stream'] = True + + return ( + OPENAI_CODEX_API_ENDPOINT, + responses_payload, + True, + ) + + +def iter_openai_codex_sse_payloads(text: str): + for line in text.splitlines(): + line = line.strip() + if not line.startswith('data:'): + continue + data = line.removeprefix('data:').strip() + if not data or data == '[DONE]': + continue + try: + yield json.loads(data) + except Exception: + continue + + +def parse_openai_codex_sse_response(text: str): + last_payload = None + for payload in iter_openai_codex_sse_payloads(text): + last_payload = payload + return last_payload + + +def build_openai_codex_error_payload(payload: Optional[dict]) -> dict: + payload = payload or {} + details = payload.get('error') or payload.get('status_details') or payload.get('response') or payload + + if isinstance(details, dict): + message = details.get('message') or details.get('error') or details.get('detail') or 'OpenAI account auth request failed' + code = details.get('code') or payload.get('type') or 'openai_codex_web_auth_error' + error_type = details.get('type') or payload.get('type') or 'upstream_error' + else: + message = str(details) if details else 'OpenAI account auth request failed' + code = payload.get('type') or 'openai_codex_web_auth_error' + error_type = payload.get('type') or 'upstream_error' + + return { + 'error': { + 'message': message, + 'type': error_type, + 'code': code, + } + } + + +def convert_openai_codex_sse_payload(payload: dict) -> Optional[bytes]: + event_type = payload.get('type') + if event_type == 'response.output_text.delta': + delta = payload.get('delta') or payload.get('text') or '' + chunk = { + 'id': payload.get('response_id') or payload.get('id') or '', + 'object': 'chat.completion.chunk', + 'model': payload.get('model') or '', + 'choices': [ + { + 'index': 0, + 'delta': {'content': delta}, + 'finish_reason': None, + } + ], + } + return f'data: {json.dumps(chunk)}\n\n'.encode() + + if event_type in ('response.failed', 'response.incomplete'): + return ( + f'data: {json.dumps(build_openai_codex_error_payload(payload))}\n\n' + 'data: [DONE]\n\n' + ).encode() + + if event_type == 'response.completed': + return b'data: [DONE]\n\n' + + return None + + +async def openai_codex_stream_chunks_handler(stream: aiohttp.StreamReader): + buffer = b'' + async for data, _ in stream.iter_chunks(): + if not data: + continue + lines = (buffer + data).split(b'\n') + buffer = lines[-1] + for raw_line in lines[:-1]: + line = raw_line.decode('utf-8', 'replace').strip() + if not line.startswith('data:'): + continue + event_data = line.removeprefix('data:').strip() + if not event_data: + continue + if event_data == '[DONE]': + yield b'data: [DONE]\n\n' + continue + try: + converted = convert_openai_codex_sse_payload(json.loads(event_data)) + except Exception: + converted = None + if converted: + yield converted + + if buffer: + line = buffer.decode('utf-8', 'replace').strip() + if line.startswith('data:'): + event_data = line.removeprefix('data:').strip() + try: + converted = convert_openai_codex_sse_payload(json.loads(event_data)) + except Exception: + converted = None + if converted: + yield converted + + +def _parse_openai_web_auth_positive_int(value, default: int, field_name: str) -> int: + if value is None: + return default + + try: + parsed = int(value) + except (TypeError, ValueError): + raise HTTPException( + status_code=502, + detail=f'OpenAI web auth response contained an invalid {field_name}', + ) + + if parsed < 1: + raise HTTPException( + status_code=502, + detail=f'OpenAI web auth response contained an invalid {field_name}', + ) + return parsed + + +def _decode_jwt_payload(token: str) -> dict: + try: + payload = token.split('.')[1] + payload += '=' * (-len(payload) % 4) + return json.loads(base64.urlsafe_b64decode(payload.encode()).decode()) + except Exception: + return {} + + +def _extract_openai_account_id(tokens: dict) -> Optional[str]: + claims = _decode_jwt_payload(tokens.get('id_token') or tokens.get('access_token') or '') + if not claims: + return None + + api_auth = claims.get('https://api.openai.com/auth') + if isinstance(claims.get('chatgpt_account_id'), str): + return claims['chatgpt_account_id'] + if isinstance(api_auth, dict) and isinstance(api_auth.get('chatgpt_account_id'), str): + return api_auth['chatgpt_account_id'] + + organizations = claims.get('organizations') + if isinstance(organizations, list): + for organization in organizations: + if isinstance(organization, dict) and isinstance(organization.get('id'), str): + return organization['id'] + return None + + +def _openai_web_auth_status_from_session(session) -> OpenAIWebAuthStatusResponse: + if not session: + return OpenAIWebAuthStatusResponse( + credential_type='none', + connected=False, + has_credential=False, + status='not_configured', + ) + + status_value = 'connected' if session.expires_at > int(time.time()) else 'reconnect_required' + return OpenAIWebAuthStatusResponse( + credential_type='web_auth', + connected=status_value == 'connected', + has_credential=True, + status=status_value, + expires_at=session.expires_at, + ) + + +async def _post_openai_web_auth_json(url: str, json_payload: dict) -> tuple[int, dict]: + async with aiohttp.ClientSession( + trust_env=True, + timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT), + ) as session: + async with session.post( + url, + json=json_payload, + headers={ + 'Content-Type': 'application/json', + 'User-Agent': 'Open WebUI', + }, + ssl=AIOHTTP_CLIENT_SESSION_SSL, + ) as response: + try: + payload = await response.json() + except Exception: + payload = {} + return response.status, payload + + +async def _post_openai_web_auth_form(url: str, form_payload: dict) -> tuple[int, dict]: + async with aiohttp.ClientSession( + trust_env=True, + timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT), + ) as session: + async with session.post( + url, + data=urlencode(form_payload), + headers={'Content-Type': 'application/x-www-form-urlencoded'}, + ssl=AIOHTTP_CLIENT_SESSION_SSL, + ) as response: + try: + payload = await response.json() + except Exception: + payload = {} + return response.status, payload + + +async def start_openai_web_auth_device_flow() -> dict: + status_code, data = await _post_openai_web_auth_json( + f'{OPENAI_WEB_AUTH_ISSUER}/api/accounts/deviceauth/usercode', + {'client_id': OPENAI_WEB_AUTH_CLIENT_ID}, + ) + if status_code >= 400: + raise HTTPException(status_code=502, detail='OpenAI web auth start failed') + + device_auth_id = data.get('device_auth_id') + user_code = data.get('user_code') + if not isinstance(device_auth_id, str) or not isinstance(user_code, str): + raise HTTPException(status_code=502, detail='OpenAI web auth start response was incomplete') + + interval = _parse_openai_web_auth_positive_int(data.get('interval'), 5, 'interval') + expires_in = _parse_openai_web_auth_positive_int(data.get('expires_in'), 600, 'expiration') + return { + 'device_auth_id': device_auth_id, + 'user_code': user_code, + 'interval': interval, + 'expires_at': int(time.time()) + expires_in, + } + + +async def complete_openai_web_auth_device_flow(device_auth_id: str, user_code: str) -> dict: + device_status, device_data = await _post_openai_web_auth_json( + f'{OPENAI_WEB_AUTH_ISSUER}/api/accounts/deviceauth/token', + { + 'device_auth_id': device_auth_id, + 'user_code': user_code, + }, + ) + if device_status in (403, 404): + raise HTTPException(status_code=409, detail='OpenAI authorization is not complete yet') + if device_status >= 400: + raise HTTPException(status_code=502, detail='OpenAI web auth completion failed') + + authorization_code = device_data.get('authorization_code') + code_verifier = device_data.get('code_verifier') + if not isinstance(authorization_code, str) or not isinstance(code_verifier, str): + raise HTTPException(status_code=502, detail='OpenAI web auth completion response was incomplete') + + token_status, tokens = await _post_openai_web_auth_form( + f'{OPENAI_WEB_AUTH_ISSUER}/oauth/token', + { + 'grant_type': 'authorization_code', + 'code': authorization_code, + 'redirect_uri': OPENAI_WEB_AUTH_REDIRECT_URI, + 'client_id': OPENAI_WEB_AUTH_CLIENT_ID, + 'code_verifier': code_verifier, + }, + ) + if token_status >= 400: + raise HTTPException(status_code=502, detail='OpenAI web auth token exchange failed') + if not tokens.get('access_token') or not tokens.get('refresh_token'): + raise HTTPException(status_code=502, detail='OpenAI web auth token response was incomplete') + + expires_in = _parse_openai_web_auth_positive_int( + tokens.get('expires_in'), + OPENAI_WEB_AUTH_DEFAULT_EXPIRES_IN, + 'expiration', + ) + return { + 'access_token': tokens['access_token'], + 'refresh_token': tokens['refresh_token'], + 'id_token': tokens.get('id_token'), + 'expires_at': int(time.time()) + expires_in, + # Stored server-side only. Not exposed in public status DTOs until product confirms it is safe metadata. + 'account_id': _extract_openai_account_id(tokens), + } + + +async def refresh_openai_web_auth_credential(token: dict) -> Optional[dict]: + refresh_token = token.get('refresh_token') + if not refresh_token: + return None + + token_status, tokens = await _post_openai_web_auth_form( + f'{OPENAI_WEB_AUTH_ISSUER}/oauth/token', + { + 'grant_type': 'refresh_token', + 'refresh_token': refresh_token, + 'client_id': OPENAI_WEB_AUTH_CLIENT_ID, + }, + ) + if token_status >= 400 or not tokens.get('access_token'): + return None + + try: + expires_in = _parse_openai_web_auth_positive_int( + tokens.get('expires_in'), + OPENAI_WEB_AUTH_DEFAULT_EXPIRES_IN, + 'expiration', + ) + merged = { + **token, + 'access_token': tokens['access_token'], + 'refresh_token': tokens.get('refresh_token') or refresh_token, + 'id_token': tokens.get('id_token') or token.get('id_token'), + 'expires_at': int(time.time()) + expires_in, + } + except HTTPException: + return None + merged['account_id'] = _extract_openai_account_id(merged) or token.get('account_id') + return merged + + +async def get_openai_web_auth_session(): + return await OAuthSessions.get_session_by_provider_and_user_id( + OPENAI_WEB_AUTH_CREDENTIAL_PROVIDER, + OPENAI_WEB_AUTH_STORAGE_USER_ID, + ) + + +async def get_openai_web_auth_access_token() -> Optional[str]: + session = await get_openai_web_auth_session() + if not session: + return None + + if session.expires_at <= int(time.time()) + OPENAI_WEB_AUTH_REFRESH_SKEW_SECONDS: + refreshed_token = await refresh_openai_web_auth_credential(session.token) + if not refreshed_token: + return None + session = await OAuthSessions.update_session_by_id(session.id, refreshed_token) + if not session: + return None + + return session.token.get('access_token') + + +async def has_connected_openai_web_auth_credential() -> bool: + try: + return bool(await get_openai_web_auth_access_token()) + except HTTPException: + return False + + +async def build_openai_web_auth_status() -> OpenAIWebAuthStatusResponse: + return _openai_web_auth_status_from_session(await get_openai_web_auth_session()) + + +async def invalidate_openai_models_cache(request: Optional[Request] = None, user: Optional[UserModel] = None): + """Clear OpenAI model caches after provider credentials or config change. + + The route-level model list is cached per user by ``get_all_models`` and the + merged model lookup is also held in ``request.app.state.OPENAI_MODELS`` for + routing. Both can otherwise keep an empty/stale model list after an admin + adds, removes, or switches the native OpenAI credential path. + """ + + cache_keys = ['openai_all_models'] + if user and getattr(user, 'id', None): + cache_keys.append(f'openai_all_models_{user.id}') + + for cache_key in cache_keys: + try: + await get_all_models.cache.delete(cache_key) + except Exception as e: + log.debug(f'Failed to invalidate OpenAI models cache key {cache_key}: {e}') + + if request is not None: + try: + request.app.state.OPENAI_MODELS = {} + except Exception as e: + log.debug(f'Failed to reset OpenAI app-state model cache: {e}') + + +@router.get('/web-auth/status', response_model=OpenAIWebAuthStatusResponse) +async def get_web_auth_status(user=Depends(get_admin_user)): + return await build_openai_web_auth_status() + + +@router.post('/web-auth/start', response_model=OpenAIWebAuthStartResponse) +async def start_web_auth(user=Depends(get_admin_user)): + started = await start_openai_web_auth_device_flow() + created = await OAuthSessions.create_session( + user_id=OPENAI_WEB_AUTH_STORAGE_USER_ID, + provider=OPENAI_WEB_AUTH_DEVICE_SESSION_PROVIDER, + token={ + 'device_auth_id': started['device_auth_id'], + 'user_code': started['user_code'], + 'expires_at': started['expires_at'], + }, + ) + if not created: + raise HTTPException(status_code=500, detail='Failed to store OpenAI web auth session') + + return OpenAIWebAuthStartResponse( + verification_url=OPENAI_WEB_AUTH_VERIFICATION_URL, + user_code=started['user_code'], + session_id=created.id, + interval=started['interval'], + expires_at=started['expires_at'], + ) + + +@router.post('/web-auth/complete', response_model=OpenAIWebAuthStatusResponse) +async def complete_web_auth( + form_data: OpenAIWebAuthCompleteForm, + request: Request, + user=Depends(get_admin_user), +): + device_session = await OAuthSessions.get_session_by_id(form_data.session_id) + if not device_session or device_session.provider != OPENAI_WEB_AUTH_DEVICE_SESSION_PROVIDER: + raise HTTPException(status_code=404, detail='OpenAI web auth session not found') + if device_session.expires_at <= int(time.time()): + await OAuthSessions.delete_session_by_id(device_session.id) + raise HTTPException(status_code=409, detail='OpenAI web auth session expired') + + credential = await complete_openai_web_auth_device_flow( + device_session.token.get('device_auth_id', ''), + device_session.token.get('user_code', ''), + ) + + await OAuthSessions.delete_sessions_by_user_id_and_provider( + OPENAI_WEB_AUTH_STORAGE_USER_ID, + OPENAI_WEB_AUTH_CREDENTIAL_PROVIDER, + ) + created = await OAuthSessions.create_session( + user_id=OPENAI_WEB_AUTH_STORAGE_USER_ID, + provider=OPENAI_WEB_AUTH_CREDENTIAL_PROVIDER, + token=credential, + ) + await OAuthSessions.delete_session_by_id(device_session.id) + if not created: + raise HTTPException(status_code=500, detail='Failed to store OpenAI web auth credential') + + await invalidate_openai_models_cache(request, user) + + return _openai_web_auth_status_from_session(created) + + +@router.post('/web-auth/disconnect', response_model=OpenAIWebAuthStatusResponse) +async def disconnect_web_auth(request: Request, user=Depends(get_admin_user)): + await OAuthSessions.delete_sessions_by_user_id_and_provider( + OPENAI_WEB_AUTH_STORAGE_USER_ID, + OPENAI_WEB_AUTH_CREDENTIAL_PROVIDER, + ) + await OAuthSessions.delete_sessions_by_user_id_and_provider( + OPENAI_WEB_AUTH_STORAGE_USER_ID, + OPENAI_WEB_AUTH_DEVICE_SESSION_PROVIDER, + ) + await invalidate_openai_models_cache(request, user) + return await build_openai_web_auth_status() + @router.get('/config') async def get_config(request: Request, user=Depends(get_admin_user)): @@ -279,6 +891,8 @@ async def update_config(request: Request, form_data: OpenAIConfigForm, user=Depe key: value for key, value in request.app.state.config.OPENAI_API_CONFIGS.items() if key in keys } + await invalidate_openai_models_cache(request, user) + return { 'ENABLE_OPENAI_API': request.app.state.config.ENABLE_OPENAI_API, 'OPENAI_API_BASE_URLS': request.app.state.config.OPENAI_API_BASE_URLS, @@ -387,6 +1001,10 @@ async def get_all_models_responses(request: Request, user: UserModel) -> list: request_tasks = [] for idx, url in enumerate(api_base_urls): if (str(idx) not in api_configs) and (url not in api_configs): # Legacy support + if is_empty_native_openai_bearer_connection(url, api_keys[idx]): + log.info('Skipping empty native OpenAI bearer connection at index %s during model listing', idx) + request_tasks.append(asyncio.ensure_future(asyncio.sleep(0, None))) + continue request_tasks.append(get_models_request(request, url, api_keys[idx], user=user)) else: api_config = api_configs.get( @@ -398,6 +1016,15 @@ async def get_all_models_responses(request: Request, user: UserModel) -> list: model_ids = api_config.get('model_ids', []) if enable: + if is_openai_codex_web_auth_config(api_config): + request_tasks.append( + asyncio.ensure_future(build_openai_codex_web_auth_models_if_connected(idx)) + ) + continue + if is_empty_native_openai_bearer_connection(url, api_keys[idx], api_config): + log.info('Skipping empty native OpenAI bearer connection at index %s during model listing', idx) + request_tasks.append(asyncio.ensure_future(asyncio.sleep(0, None))) + continue if len(model_ids) == 0: request_tasks.append(get_models_request(request, url, api_keys[idx], user=user, config=api_config)) else: @@ -587,6 +1214,14 @@ async def get_models(request: Request, url_idx: int | None = None, user=Depends( request.app.state.config.OPENAI_API_CONFIGS.get(url, {}), # Legacy support ) + if is_empty_native_openai_bearer_connection(url, key, api_config): + log.info('Skipping empty native OpenAI bearer connection at index %s during direct model listing', url_idx) + return models + + if is_openai_codex_web_auth_config(api_config): + models = await build_openai_codex_web_auth_models_if_connected(url_idx) + return models or {'data': []} + r = None async with aiohttp.ClientSession( trust_env=True, @@ -619,6 +1254,14 @@ async def get_models(request: Request, url_idx: int | None = None, user=Depends( error_detail = f'External Error: {res["error"]}' except Exception: pass + + if is_openai_codex_web_auth_config(api_config): + log.warning( + 'OpenAI web auth model request failed for host=%s status=%s detail=%s', + urlparse(url).hostname, + r.status, + error_detail, + ) raise Exception(error_detail) response_data = await r.json() @@ -1157,11 +1800,15 @@ async def generate_chat_completion( if logit_bias: payload['logit_bias'] = json.loads(logit_bias) + requested_stream = bool(payload.get('stream')) headers, cookies = await get_headers_and_cookies(request, url, key, api_config, metadata, user=user) is_responses = api_config.get('api_type') == 'responses' + is_codex_web_auth = is_openai_codex_web_auth_config(api_config) - if api_config.get('azure') or api_config.get('provider') == 'azure': + if is_codex_web_auth: + request_url, payload, is_responses = prepare_openai_codex_web_auth_request(payload, is_responses) + elif api_config.get('azure') or api_config.get('provider') == 'azure': # Only set api-key header if not using Azure Entra ID authentication auth_type = api_config.get('auth_type', 'bearer') if auth_type not in ('azure_ad', 'microsoft_entra_id'): @@ -1221,6 +1868,41 @@ async def generate_chat_completion( timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT), ) + if is_codex_web_auth and 'text/event-stream' in r.headers.get('Content-Type', ''): + if r.status >= 400: + error_body = await r.text() + log.error( + 'OpenAI account auth returned HTTP %d with SSE content-type: %s', + r.status, + error_body[:1000], + ) + parsed_error = parse_openai_codex_sse_response(error_body) + error_content = build_openai_codex_error_payload(parsed_error) + return JSONResponse(status_code=r.status, content=error_content) + + if requested_stream: + streaming = True + return StreamingResponse( + stream_wrapper(r, content_handler=openai_codex_stream_chunks_handler), + status_code=r.status, + media_type='text/event-stream', + headers=_clean_proxy_headers(r.headers), + ) + + response_text = await r.text() + parsed_response = parse_openai_codex_sse_response(response_text) + if parsed_response is None: + raise HTTPException(status_code=502, detail='OpenAI account auth response could not be parsed') + + if parsed_response.get('type') in ('response.failed', 'response.incomplete'): + return JSONResponse( + status_code=502, + content=build_openai_codex_error_payload(parsed_response), + ) + + response = convert_responses_result(parsed_response) + return response + # Check if response is SSE if 'text/event-stream' in r.headers.get('Content-Type', ''): # If the provider returned an error status with SSE content-type, @@ -1252,8 +1934,20 @@ async def generate_chat_completion( try: response = await r.json() except Exception as e: - log.error(e) - response = await r.text() + response_text = await r.text() + if is_codex_web_auth: + parsed_response = parse_openai_codex_sse_response(response_text) + if parsed_response is not None: + if parsed_response.get('type') in ('response.failed', 'response.incomplete'): + response = build_openai_codex_error_payload(parsed_response) + else: + response = parsed_response + else: + log.error(e) + response = response_text + else: + log.error(e) + response = response_text if r.status >= 400: if isinstance(response, (dict, list)): @@ -1439,7 +2133,9 @@ async def responses( try: headers, cookies = await get_headers_and_cookies(request, url, key, api_config, user=user) - if api_config.get('azure') or api_config.get('provider') == 'azure': + if is_openai_codex_web_auth_config(api_config): + request_url = OPENAI_CODEX_API_ENDPOINT + elif api_config.get('azure') or api_config.get('provider') == 'azure': auth_type = api_config.get('auth_type', 'bearer') if auth_type not in ('azure_ad', 'microsoft_entra_id'): headers['api-key'] = key diff --git a/src/lib/apis/openai/index.ts b/src/lib/apis/openai/index.ts index d18565fec3..59ca67d433 100644 --- a/src/lib/apis/openai/index.ts +++ b/src/lib/apis/openai/index.ts @@ -1,4 +1,4 @@ -import { OPENAI_API_BASE_URL, WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants'; +import { OPENAI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants'; export const getOpenAIConfig = async (token: string = '') => { let error = null; @@ -39,6 +39,292 @@ type OpenAIConfig = { OPENAI_API_CONFIGS: object; }; +export const OPENAI_WEB_AUTH_API_BASE_URL = 'https://api.openai.com/v1'; +export const OPENAI_CODEX_WEB_AUTH_API_BASE_URL = 'https://chatgpt.com/backend-api/codex'; +export const OPENAI_CODEX_WEB_AUTH_TYPE = 'openai_codex_web_auth'; +export const OPENAI_CODEX_WEB_AUTH_LEGACY_TYPE = 'openai_web_auth'; + +export const getSupportedOpenAIConnectionAuthTypes = ({ + direct = false, + ollama = false, + azure = false +}: { + direct?: boolean; + ollama?: boolean; + azure?: boolean; +} = {}) => { + const authTypes = [ + { value: 'none', label: 'None' }, + { value: 'bearer', label: 'Bearer' } + ]; + + if (ollama) { + return authTypes; + } + + authTypes.push({ value: 'session', label: 'Session' }); + + if (!direct) { + authTypes.push({ value: 'system_oauth', label: 'OAuth' }); + if (azure) { + authTypes.push({ value: 'microsoft_entra_id', label: 'Entra ID' }); + } + } + + return authTypes; +}; + +export const isOpenAICodexWebAuthConfig = (config: Record = {}) => + config.auth_type === OPENAI_CODEX_WEB_AUTH_TYPE || + config.auth_type === OPENAI_CODEX_WEB_AUTH_LEGACY_TYPE; + +export const isEmptyNativeOpenAIBearerConnection = ( + url: string, + key = '', + config: Record = {} +) => + url.replace(/\/$/, '') === OPENAI_WEB_AUTH_API_BASE_URL && + !key && + (!config.auth_type || config.auth_type === 'bearer'); + +export const createOpenAIWebAuthConnectionConfig = ( + existingConfig: Record = {} +) => ({ + ...existingConfig, + enable: true, + auth_type: OPENAI_CODEX_WEB_AUTH_TYPE, + connection_type: existingConfig.connection_type ?? 'external' +}); + +export const applyOpenAIWebAuthConnection = ( + urls: string[], + keys: string[], + configs: Record> +) => { + const normalizedUrls = urls.map((url) => url.replace(/\/$/, '')); + const normalizedKeys = [...keys]; + while (normalizedKeys.length < normalizedUrls.length) { + normalizedKeys.push(''); + } + + const nextUrls: string[] = []; + const nextKeys: string[] = []; + const nextConfigs: Record> = {}; + let webAuthIdx = -1; + let emptyBearerIdx = -1; + + for (const [idx, url] of normalizedUrls.entries()) { + const config = configs[idx] ?? {}; + const key = normalizedKeys[idx] ?? ''; + const webAuth = isOpenAICodexWebAuthConfig(config); + const emptyBearer = isEmptyNativeOpenAIBearerConnection(url, key, config); + + if ( + (url === OPENAI_WEB_AUTH_API_BASE_URL || url === OPENAI_CODEX_WEB_AUTH_API_BASE_URL) && + !key && + webAuth + ) { + if (webAuthIdx === -1) { + webAuthIdx = nextUrls.length; + } else { + continue; + } + } + + if (emptyBearer && emptyBearerIdx === -1) { + emptyBearerIdx = nextUrls.length; + } + + nextUrls.push(url); + nextKeys.push(key); + nextConfigs[nextUrls.length - 1] = config; + } + + const targetIdx = webAuthIdx >= 0 ? webAuthIdx : emptyBearerIdx; + if (targetIdx >= 0) { + nextUrls[targetIdx] = OPENAI_CODEX_WEB_AUTH_API_BASE_URL; + nextKeys[targetIdx] = ''; + nextConfigs[targetIdx] = createOpenAIWebAuthConnectionConfig(nextConfigs[targetIdx] ?? {}); + } else { + nextUrls.push(OPENAI_CODEX_WEB_AUTH_API_BASE_URL); + nextKeys.push(''); + nextConfigs[nextUrls.length - 1] = createOpenAIWebAuthConnectionConfig(); + } + + const finalUrls: string[] = []; + const finalKeys: string[] = []; + const finalConfigs: Record> = {}; + + for (const [idx, url] of nextUrls.entries()) { + const config = nextConfigs[idx] ?? {}; + const key = nextKeys[idx] ?? ''; + if (isEmptyNativeOpenAIBearerConnection(url, key, config)) { + continue; + } + + finalUrls.push(url); + finalKeys.push(key); + finalConfigs[finalUrls.length - 1] = config; + } + + return { + urls: finalUrls, + keys: finalKeys, + configs: finalConfigs + }; +}; + +export const createOpenAIWebAuthConfigUpdate = (config: OpenAIConfig): OpenAIConfig => { + const next = applyOpenAIWebAuthConnection( + config.OPENAI_API_BASE_URLS, + config.OPENAI_API_KEYS, + config.OPENAI_API_CONFIGS as Record> + ); + + return { + ...config, + ENABLE_OPENAI_API: true, + OPENAI_API_BASE_URLS: next.urls, + OPENAI_API_KEYS: next.keys, + OPENAI_API_CONFIGS: next.configs + }; +}; + +export type OpenAIWebAuthStatus = { + credential_type: 'none' | 'web_auth' | string; + connected: boolean; + has_credential: boolean; + status: 'not_configured' | 'connected' | 'reconnect_required' | string; + expires_at?: number | null; +}; + +export type OpenAIWebAuthStart = { + verification_url: string; + user_code: string; + session_id: string; + interval: number; + expires_at: number; +}; + +const normalizeOpenAIWebAuthNumber = (value: unknown): number | null => { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return null; + } + return value; +}; + +const normalizeOpenAIWebAuthString = (value: unknown): string => { + return typeof value === 'string' ? value : ''; +}; + +const normalizeOpenAIWebAuthStatus = (body: unknown): OpenAIWebAuthStatus => { + const source = body && typeof body === 'object' ? (body as Record) : {}; + const expiresAt = normalizeOpenAIWebAuthNumber(source.expires_at); + + return { + credential_type: normalizeOpenAIWebAuthString(source.credential_type) || 'none', + connected: source.connected === true, + has_credential: source.has_credential === true, + status: normalizeOpenAIWebAuthString(source.status) || 'not_configured', + ...(expiresAt !== null ? { expires_at: expiresAt } : {}) + }; +}; + +const normalizeOpenAIWebAuthStart = (body: unknown): OpenAIWebAuthStart => { + const source = body && typeof body === 'object' ? (body as Record) : {}; + + return { + verification_url: normalizeOpenAIWebAuthString(source.verification_url), + user_code: normalizeOpenAIWebAuthString(source.user_code), + session_id: normalizeOpenAIWebAuthString(source.session_id), + interval: normalizeOpenAIWebAuthNumber(source.interval) ?? 0, + expires_at: normalizeOpenAIWebAuthNumber(source.expires_at) ?? 0 + }; +}; + +const handleOpenAIWebAuthResponse = async ( + response: Response, + normalize: (body: unknown) => T +): Promise => { + const body = await response.json().catch(() => ({})); + if (!response.ok) { + throw body; + } + return normalize(body); +}; + +const normalizeOpenAIWebAuthError = (err: unknown) => { + console.error(err); + if (err && typeof err === 'object' && 'detail' in err) { + return (err as { detail: string }).detail; + } + return 'Server connection failed'; +}; + +export const getOpenAIWebAuthStatus = async (token: string = ''): Promise => { + try { + return await fetch(`${OPENAI_API_BASE_URL}/web-auth/status`, { + method: 'GET', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + ...(token && { authorization: `Bearer ${token}` }) + } + }).then((res) => handleOpenAIWebAuthResponse(res, normalizeOpenAIWebAuthStatus)); + } catch (err) { + throw normalizeOpenAIWebAuthError(err); + } +}; + +export const startOpenAIWebAuth = async (token: string = ''): Promise => { + try { + return await fetch(`${OPENAI_API_BASE_URL}/web-auth/start`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + ...(token && { authorization: `Bearer ${token}` }) + } + }).then((res) => handleOpenAIWebAuthResponse(res, normalizeOpenAIWebAuthStart)); + } catch (err) { + throw normalizeOpenAIWebAuthError(err); + } +}; + +export const completeOpenAIWebAuth = async ( + token: string = '', + sessionId: string +): Promise => { + try { + return await fetch(`${OPENAI_API_BASE_URL}/web-auth/complete`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + ...(token && { authorization: `Bearer ${token}` }) + }, + body: JSON.stringify({ session_id: sessionId }) + }).then((res) => handleOpenAIWebAuthResponse(res, normalizeOpenAIWebAuthStatus)); + } catch (err) { + throw normalizeOpenAIWebAuthError(err); + } +}; + +export const disconnectOpenAIWebAuth = async (token: string = ''): Promise => { + try { + return await fetch(`${OPENAI_API_BASE_URL}/web-auth/disconnect`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + ...(token && { authorization: `Bearer ${token}` }) + } + }).then((res) => handleOpenAIWebAuthResponse(res, normalizeOpenAIWebAuthStatus)); + } catch (err) { + throw normalizeOpenAIWebAuthError(err); + } +}; + export const updateOpenAIConfig = async (token: string = '', config: OpenAIConfig) => { let error = null; @@ -133,10 +419,14 @@ export const getOpenAIModels = async (token: string, urlIdx?: number) => { export const verifyOpenAIConnection = async ( token: string = '', - connection: dict = {}, + connection: Record = {}, direct: boolean = false ) => { - const { url, key, config } = connection; + const { url, key, config } = connection as { + url?: string; + key?: string; + config?: Record; + }; if (!url) { throw 'OpenAI: URL is required'; } diff --git a/src/lib/components/AddConnectionModal.svelte b/src/lib/components/AddConnectionModal.svelte index 37f4a7eb68..6e021a7cbe 100644 --- a/src/lib/components/AddConnectionModal.svelte +++ b/src/lib/components/AddConnectionModal.svelte @@ -4,13 +4,16 @@ const i18n = getContext('i18n'); import { settings } from '$lib/stores'; - import { verifyOpenAIConnection } from '$lib/apis/openai'; + import { + OPENAI_CODEX_WEB_AUTH_API_BASE_URL, + getSupportedOpenAIConnectionAuthTypes, + verifyOpenAIConnection + } from '$lib/apis/openai'; import { verifyOllamaConnection } from '$lib/apis/ollama'; import Modal from '$lib/components/common/Modal.svelte'; import Plus from '$lib/components/icons/Plus.svelte'; import Minus from '$lib/components/icons/Minus.svelte'; - import PencilSolid from '$lib/components/icons/PencilSolid.svelte'; import SensitiveInput from '$lib/components/common/SensitiveInput.svelte'; import Tooltip from '$lib/components/common/Tooltip.svelte'; import Switch from '$lib/components/common/Switch.svelte'; @@ -20,8 +23,12 @@ import XMark from '$lib/components/icons/XMark.svelte'; import Textarea from './common/Textarea.svelte'; - export let onSubmit: Function = () => {}; - export let onDelete: Function = () => {}; + export let onSubmit: (connection: { + url: string; + key: string; + config: Record; + }) => Promise | void = () => {}; + export let onDelete: () => Promise | void = () => {}; export let show = false; export let edit = false; @@ -59,6 +66,14 @@ let loading = false; let showDeleteConfirmDialog = false; + $: authOptions = (() => { + const options = getSupportedOpenAIConnectionAuthTypes({ direct, ollama, azure }); + if (edit && auth_type === 'openai_codex_web_auth') { + return [...options, { value: 'openai_codex_web_auth', label: 'OpenAI Account Auth' }]; + } + return options; + })(); + const verifyOllamaHandler = async () => { // remove trailing slash from url url = url.replace(/\/$/, ''); @@ -79,6 +94,13 @@ // remove trailing slash from url url = url.replace(/\/$/, ''); + if (auth_type === 'openai_codex_web_auth') { + toast.error( + $i18n.t('Verify OpenAI Account Auth from the dedicated OpenAI Account Auth card below the connection list') + ); + return; + } + let _headers = null; if (headers) { @@ -89,7 +111,7 @@ throw new Error('Headers must be a valid JSON object'); } headers = JSON.stringify(_headers, null, 2); - } catch (error) { + } catch { toast.error($i18n.t('Headers must be a valid JSON object')); return; } @@ -142,6 +164,17 @@ return; } + if ( + auth_type === 'openai_codex_web_auth' && + url.replace(/\/$/, '') !== OPENAI_CODEX_WEB_AUTH_API_BASE_URL + ) { + loading = false; + toast.error( + $i18n.t('OpenAI Account Auth is only available for the account-auth runtime URL') + ); + return; + } + if (azure) { if (!apiVersion) { loading = false; @@ -150,7 +183,10 @@ return; } - if (!key && !['azure_ad', 'microsoft_entra_id'].includes(auth_type)) { + if ( + !key && + !['azure_ad', 'microsoft_entra_id', 'openai_codex_web_auth'].includes(auth_type) + ) { loading = false; toast.error($i18n.t('Key is required')); @@ -171,7 +207,7 @@ throw new Error('Headers must be a valid JSON object'); } headers = JSON.stringify(_headers, null, 2); - } catch (error) { + } catch { toast.error($i18n.t('Headers must be a valid JSON object')); return; } @@ -179,6 +215,9 @@ // remove trailing slash from url url = url.replace(/\/$/, ''); + if (auth_type === 'openai_codex_web_auth') { + key = ''; + } const connection = { url, @@ -324,13 +363,13 @@ {#if !ollama} - + + + + + + {/if} @@ -386,16 +425,9 @@ class={`dark:bg-gray-900 w-full text-sm bg-transparent pr-5 ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`} bind:value={auth_type} > - - - - {#if !ollama} - - {#if !direct} - - - {/if} - {/if} + {#each authOptions as option} + + {/each} @@ -412,6 +444,12 @@ > {$i18n.t('No authentication')} + {:else if auth_type === 'openai_codex_web_auth'} +
+ {$i18n.t('Uses the connected OpenAI account auth credential')} +
{:else if auth_type === 'session'}
import { toast } from 'svelte-sonner'; - import { createEventDispatcher, onMount, getContext, tick } from 'svelte'; + import { createEventDispatcher, onMount, getContext } from 'svelte'; const dispatch = createEventDispatcher(); import { getOllamaConfig, updateOllamaConfig } from '$lib/apis/ollama'; - import { getOpenAIConfig, updateOpenAIConfig, getOpenAIModels } from '$lib/apis/openai'; + import { + createOpenAIWebAuthConfigUpdate, + isOpenAICodexWebAuthConfig, + isEmptyNativeOpenAIBearerConnection, + getOpenAIConfig, + updateOpenAIConfig, + getOpenAIModels + } from '$lib/apis/openai'; import { getModels as _getModels, getBackendConfig } from '$lib/apis'; import { getConnectionsConfig, setConnectionsConfig } from '$lib/apis/configs'; @@ -17,6 +24,7 @@ import Plus from '$lib/components/icons/Plus.svelte'; import OpenAIConnection from './Connections/OpenAIConnection.svelte'; + import OpenAIWebAuthConnection from './Connections/OpenAIWebAuthConnection.svelte'; import AddConnectionModal from '$lib/components/AddConnectionModal.svelte'; import OllamaConnection from './Connections/OllamaConnection.svelte'; @@ -49,6 +57,13 @@ let showAddOpenAIConnectionModal = false; let showAddOllamaConnectionModal = false; + $: openAIWebAuthConfigured = OPENAI_API_BASE_URLS.some((url, idx) => { + return ( + isOpenAICodexWebAuthConfig(OPENAI_API_CONFIGS[idx]) && + (OPENAI_API_CONFIGS[idx]?.enable ?? true) + ); + }); + const updateOpenAIHandler = async () => { if (ENABLE_OPENAI_API !== null) { // Remove trailing slashes @@ -136,6 +151,22 @@ await updateOllamaHandler(); }; + const useOpenAIWebAuthConnectionHandler = async () => { + const next = createOpenAIWebAuthConfigUpdate({ + ENABLE_OPENAI_API: ENABLE_OPENAI_API ?? true, + OPENAI_API_BASE_URLS, + OPENAI_API_KEYS, + OPENAI_API_CONFIGS + }); + + ENABLE_OPENAI_API = next.ENABLE_OPENAI_API; + OPENAI_API_BASE_URLS = next.OPENAI_API_BASE_URLS; + OPENAI_API_KEYS = next.OPENAI_API_KEYS; + OPENAI_API_CONFIGS = next.OPENAI_API_CONFIGS; + + await updateOpenAIHandler(); + }; + onMount(async () => { if ($user?.role === 'admin') { let ollamaConfig = {}; @@ -172,11 +203,20 @@ } } - OPENAI_API_BASE_URLS.forEach(async (url, idx) => { + OPENAI_API_BASE_URLS.forEach(async (url, idx) => { OPENAI_API_CONFIGS[idx] = OPENAI_API_CONFIGS[idx] || {}; if (!(OPENAI_API_CONFIGS[idx]?.enable ?? true)) { return; } + if ( + isEmptyNativeOpenAIBearerConnection( + url, + OPENAI_API_KEYS[idx] ?? '', + OPENAI_API_CONFIGS[idx] + ) + ) { + return; + } const res = await getOpenAIModels(localStorage.token, idx); if (res.pipelines) { pipelineUrls[url] = true; @@ -269,13 +309,13 @@ updateOpenAIHandler(); }} onDelete={() => { - OPENAI_API_BASE_URLS = OPENAI_API_BASE_URLS.filter( - (url, urlIdx) => idx !== urlIdx - ); - OPENAI_API_KEYS = OPENAI_API_KEYS.filter((key, keyIdx) => idx !== keyIdx); + OPENAI_API_BASE_URLS = OPENAI_API_BASE_URLS.filter( + (_url, urlIdx) => idx !== urlIdx + ); + OPENAI_API_KEYS = OPENAI_API_KEYS.filter((_key, keyIdx) => idx !== keyIdx); let newConfig = {}; - OPENAI_API_BASE_URLS.forEach((url, newIdx) => { + OPENAI_API_BASE_URLS.forEach((_url, newIdx) => { newConfig[newIdx] = OPENAI_API_CONFIGS[newIdx < idx ? newIdx : newIdx + 1]; }); @@ -285,6 +325,11 @@ /> {/each}
+ + {/if} @@ -324,7 +369,7 @@
- {#each OLLAMA_BASE_URLS as url, idx} + {#each OLLAMA_BASE_URLS as url, idx (url)} { - OLLAMA_BASE_URLS = OLLAMA_BASE_URLS.filter((url, urlIdx) => idx !== urlIdx); + OLLAMA_BASE_URLS = OLLAMA_BASE_URLS.filter((_url, urlIdx) => idx !== urlIdx); let newConfig = {}; - OLLAMA_BASE_URLS.forEach((url, newIdx) => { + OLLAMA_BASE_URLS.forEach((_url, newIdx) => { newConfig[newIdx] = OLLAMA_API_CONFIGS[newIdx < idx ? newIdx : newIdx + 1]; }); diff --git a/src/lib/components/admin/Settings/Connections/OpenAIWebAuthConnection.svelte b/src/lib/components/admin/Settings/Connections/OpenAIWebAuthConnection.svelte new file mode 100644 index 0000000000..c0c6830e91 --- /dev/null +++ b/src/lib/components/admin/Settings/Connections/OpenAIWebAuthConnection.svelte @@ -0,0 +1,269 @@ + + +
+
+
+
{$i18n.t('OpenAI Account Auth')}
+
+ {$i18n.t( + 'Connect OpenAI with an account authorization flow. API-key connections remain available above.' + )} +
+
+ + {#if loading === 'status'} + + {/if} +
+ + {#if status} +
+ {#if status.connected} + {$i18n.t('Status')}: {$i18n.t('Connected')} + {:else if status.status === 'reconnect_required'} + {$i18n.t('Status')}: {$i18n.t('Reconnect required')} + {:else if pendingAuthorization} + {$i18n.t('Status')}: {$i18n.t('Awaiting authorization')} + {:else} + {$i18n.t('Status')}: {$i18n.t('Not connected')} + {/if} +
+ + {#if status.expires_at} +
+ {$i18n.t('Expires')}: {formatExpiration(status.expires_at)} +
+ {/if} + + {#if status.connected} +
+ {#if configured} + {$i18n.t('This account auth is enabled for account-auth compatible OpenAI models.')} + {:else} + {$i18n.t('Enable it for account-auth compatible OpenAI models before use.')} + {/if} +
+ {/if} + {/if} + + {#if pendingAuthorization} +
+
+ {$i18n.t('User code')} + {pendingAuthorization.user_code} +
+ +
+ {$i18n.t('Authorization page')} + + {$i18n.t('Open OpenAI authorization page')} + +
+ +
+ {$i18n.t('Check interval')} + {$i18n.t('{{seconds}} seconds', { seconds: pendingAuthorization.interval })} +
+ +
+ {$i18n.t('Code expires')} + {formatExpiration(pendingAuthorization.expires_at)} +
+
+ {/if} + + {#if error} +
{error}
+ {/if} + +
+ {#if pendingAuthorization} + + {:else if status?.connected} + {#if !configured} + + {/if} + + + {:else} + + {/if} + + {#if status?.has_credential || pendingAuthorization} + + {/if} +
+