mirror of
https://github.com/open-webui/open-webui.git
synced 2026-10-05 02:41:34 +00:00
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:
parent
b1d40f3409
commit
a464ee9e90
5 changed files with 1382 additions and 44 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'}`}
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
Loading…
Add table
Reference in a new issue