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.
This commit is contained in:
Pascal André 2026-06-07 10:00:53 +02:00
parent b1d40f3409
commit a464ee9e90
No known key found for this signature in database
5 changed files with 1382 additions and 44 deletions

View file

@ -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

View file

@ -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<string, unknown> = {}) =>
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<string, unknown> = {}
) =>
url.replace(/\/$/, '') === OPENAI_WEB_AUTH_API_BASE_URL &&
!key &&
(!config.auth_type || config.auth_type === 'bearer');
export const createOpenAIWebAuthConnectionConfig = (
existingConfig: Record<string, unknown> = {}
) => ({
...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<string, Record<string, unknown>>
) => {
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<string, Record<string, unknown>> = {};
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<string, Record<string, unknown>> = {};
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<string, Record<string, unknown>>
);
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<string, unknown>) : {};
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<string, unknown>) : {};
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 <T>(
response: Response,
normalize: (body: unknown) => T
): Promise<T> => {
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<OpenAIWebAuthStatus> => {
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<OpenAIWebAuthStart> => {
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<OpenAIWebAuthStatus> => {
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<OpenAIWebAuthStatus> => {
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<string, unknown> = {},
direct: boolean = false
) => {
const { url, key, config } = connection;
const { url, key, config } = connection as {
url?: string;
key?: string;
config?: Record<string, unknown>;
};
if (!url) {
throw 'OpenAI: URL is required';
}

View file

@ -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<string, unknown>;
}) => Promise<void> | void = () => {};
export let onDelete: () => Promise<void> | 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}
<datalist id="suggestions">
<option value="https://api.openai.com/v1" />
<option value="https://api.anthropic.com/v1" />
<option value="https://generativelanguage.googleapis.com/v1beta/openai" />
<option value="https://api.mistral.ai/v1" />
<option value="https://api.groq.com/openai/v1" />
<option value="https://openrouter.ai/api/v1" />
<option value="https://api.x.ai/v1" />
<option value="https://api.openai.com/v1"></option>
<option value="https://api.anthropic.com/v1"></option>
<option value="https://generativelanguage.googleapis.com/v1beta/openai"></option>
<option value="https://api.mistral.ai/v1"></option>
<option value="https://api.groq.com/openai/v1"></option>
<option value="https://openrouter.ai/api/v1"></option>
<option value="https://api.x.ai/v1"></option>
</datalist>
{/if}
</div>
@ -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}
>
<option value="none">{$i18n.t('None')}</option>
<option value="bearer">{$i18n.t('Bearer')}</option>
{#if !ollama}
<option value="session">{$i18n.t('Session')}</option>
{#if !direct}
<option value="system_oauth">{$i18n.t('OAuth')}</option>
<option value="microsoft_entra_id">{$i18n.t('Entra ID')}</option>
{/if}
{/if}
{#each authOptions as option}
<option value={option.value}>{$i18n.t(option.label)}</option>
{/each}
</select>
</div>
@ -412,6 +444,12 @@
>
{$i18n.t('No authentication')}
</div>
{:else if auth_type === 'openai_codex_web_auth'}
<div
class={`text-xs self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
{$i18n.t('Uses the connected OpenAI account auth credential')}
</div>
{:else if auth_type === 'session'}
<div
class={`text-xs self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}

View file

@ -1,11 +1,18 @@
<script lang="ts">
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}
</div>
<OpenAIWebAuthConnection
configured={openAIWebAuthConfigured}
onUseCredential={useOpenAIWebAuthConnectionHandler}
/>
</div>
{/if}
</div>
@ -324,7 +369,7 @@
<div class="flex w-full gap-1.5">
<div class="flex-1 flex flex-col gap-1.5 mt-1.5">
{#each OLLAMA_BASE_URLS as url, idx}
{#each OLLAMA_BASE_URLS as url, idx (url)}
<OllamaConnection
bind:url={OLLAMA_BASE_URLS[idx]}
bind:config={OLLAMA_API_CONFIGS[idx]}
@ -333,10 +378,10 @@
updateOllamaHandler();
}}
onDelete={() => {
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];
});

View file

@ -0,0 +1,269 @@
<script lang="ts">
import { getContext, onMount } from 'svelte';
import { toast } from 'svelte-sonner';
import Spinner from '$lib/components/common/Spinner.svelte';
import {
completeOpenAIWebAuth,
disconnectOpenAIWebAuth,
getOpenAIWebAuthStatus,
startOpenAIWebAuth,
type OpenAIWebAuthStart,
type OpenAIWebAuthStatus
} from '$lib/apis/openai';
const i18n = getContext('i18n');
export let configured = false;
export let onUseCredential: () => Promise<void> | void = () => {};
let status: OpenAIWebAuthStatus | null = null;
let pendingAuthorization: OpenAIWebAuthStart | null = null;
let loading: 'status' | 'start' | 'complete' | 'disconnect' | 'configure' | null = null;
let error = '';
const formatExpiration = (expiresAt?: number | null) => {
if (!expiresAt) {
return '';
}
return new Date(expiresAt * 1000).toLocaleString();
};
const refreshStatus = async () => {
loading = 'status';
error = '';
const res = await getOpenAIWebAuthStatus(localStorage.token).catch((err) => {
error = `${err}`;
});
if (res) {
status = res;
}
loading = null;
};
const startHandler = async () => {
loading = 'start';
error = '';
const res = await startOpenAIWebAuth(localStorage.token).catch((err) => {
error = `${err}`;
toast.error(`${err}`);
});
if (res) {
pendingAuthorization = res;
status = {
credential_type: status?.credential_type ?? 'none',
connected: false,
has_credential: status?.has_credential ?? false,
status: 'awaiting_authorization',
expires_at: res.expires_at
};
toast.success($i18n.t('OpenAI authorization started'));
}
loading = null;
};
const completeHandler = async () => {
if (!pendingAuthorization?.session_id) {
return;
}
loading = 'complete';
error = '';
const res = await completeOpenAIWebAuth(
localStorage.token,
pendingAuthorization.session_id
).catch((err) => {
error = `${err}`;
toast.error(`${err}`);
});
if (res) {
status = res;
pendingAuthorization = null;
if (res.connected) {
await Promise.resolve(onUseCredential()).catch((err) => {
error = `${err}`;
toast.error(`${err}`);
});
toast.success($i18n.t('OpenAI account auth connected'));
} else {
toast.error($i18n.t('OpenAI account auth requires reconnect'));
}
}
loading = null;
};
const disconnectHandler = async () => {
loading = 'disconnect';
error = '';
const res = await disconnectOpenAIWebAuth(localStorage.token).catch((err) => {
error = `${err}`;
toast.error(`${err}`);
});
if (res) {
status = res;
pendingAuthorization = null;
toast.success($i18n.t('OpenAI account auth disconnected'));
}
loading = null;
};
const useCredentialHandler = async () => {
loading = 'configure';
error = '';
await Promise.resolve(onUseCredential()).catch((err) => {
error = `${err}`;
toast.error(`${err}`);
});
loading = null;
};
onMount(() => {
refreshStatus();
});
</script>
<div class="mt-3 rounded-xl border border-gray-100 dark:border-gray-850 p-3 space-y-2">
<div class="flex items-start justify-between gap-3">
<div>
<div class="font-medium text-xs">{$i18n.t('OpenAI Account Auth')}</div>
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{$i18n.t(
'Connect OpenAI with an account authorization flow. API-key connections remain available above.'
)}
</div>
</div>
{#if loading === 'status'}
<Spinner />
{/if}
</div>
{#if status}
<div class="text-xs text-gray-600 dark:text-gray-300">
{#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}
</div>
{#if status.expires_at}
<div class="text-xs text-gray-500 dark:text-gray-400">
{$i18n.t('Expires')}: {formatExpiration(status.expires_at)}
</div>
{/if}
{#if status.connected}
<div class="text-xs text-gray-500 dark:text-gray-400">
{#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}
</div>
{/if}
{/if}
{#if pendingAuthorization}
<div class="rounded-lg bg-gray-50 dark:bg-gray-850 p-2 space-y-1.5 text-xs">
<div class="flex justify-between gap-2">
<span class="text-gray-500 dark:text-gray-400">{$i18n.t('User code')}</span>
<span class="font-mono tracking-wide">{pendingAuthorization.user_code}</span>
</div>
<div class="flex justify-between gap-2">
<span class="text-gray-500 dark:text-gray-400">{$i18n.t('Authorization page')}</span>
<a
class="underline font-medium"
href={pendingAuthorization.verification_url}
target="_blank"
rel="noreferrer"
>
{$i18n.t('Open OpenAI authorization page')}
</a>
</div>
<div class="flex justify-between gap-2">
<span class="text-gray-500 dark:text-gray-400">{$i18n.t('Check interval')}</span>
<span>{$i18n.t('{{seconds}} seconds', { seconds: pendingAuthorization.interval })}</span>
</div>
<div class="flex justify-between gap-2">
<span class="text-gray-500 dark:text-gray-400">{$i18n.t('Code expires')}</span>
<span>{formatExpiration(pendingAuthorization.expires_at)}</span>
</div>
</div>
{/if}
{#if error}
<div class="text-xs text-red-500">{error}</div>
{/if}
<div class="flex flex-wrap gap-2 pt-1">
{#if pendingAuthorization}
<button
class="px-3 py-1 text-xs font-medium rounded-full bg-black text-white dark:bg-white dark:text-black disabled:opacity-50"
type="button"
disabled={loading !== null}
on:click={completeHandler}
>
{$i18n.t('I’ve authorized')}
{#if loading === 'complete'}<Spinner />{/if}
</button>
{:else if status?.connected}
{#if !configured}
<button
class="px-3 py-1 text-xs font-medium rounded-full bg-black text-white dark:bg-white dark:text-black disabled:opacity-50"
type="button"
disabled={loading !== null}
on:click={useCredentialHandler}
>
{$i18n.t('Use for account-auth models')}
{#if loading === 'configure'}<Spinner />{/if}
</button>
{/if}
<button
class="px-3 py-1 text-xs font-medium rounded-full border border-gray-200 dark:border-gray-800 disabled:opacity-50"
type="button"
disabled={loading !== null}
on:click={startHandler}
>
{$i18n.t('Reconnect')}
{#if loading === 'start'}<Spinner />{/if}
</button>
{:else}
<button
class="px-3 py-1 text-xs font-medium rounded-full bg-black text-white dark:bg-white dark:text-black disabled:opacity-50"
type="button"
disabled={loading !== null}
on:click={startHandler}
>
{$i18n.t('Connect with OpenAI')}
{#if loading === 'start'}<Spinner />{/if}
</button>
{/if}
{#if status?.has_credential || pendingAuthorization}
<button
class="px-3 py-1 text-xs font-medium rounded-full text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 disabled:opacity-50"
type="button"
disabled={loading !== null}
on:click={disconnectHandler}
>
{$i18n.t('Disconnect')}
{#if loading === 'disconnect'}<Spinner />{/if}
</button>
{/if}
</div>
</div>