diff --git a/backend/adapter_processor_v2/adapter_processor.py b/backend/adapter_processor_v2/adapter_processor.py index a9c7f40e32..3db703096c 100644 --- a/backend/adapter_processor_v2/adapter_processor.py +++ b/backend/adapter_processor_v2/adapter_processor.py @@ -8,6 +8,13 @@ from django.core.exceptions import ObjectDoesNotExist from platform_settings_v2.platform_auth_service import PlatformAuthenticationService from tenant_account_v2.organization_member_service import OrganizationMemberService +from unstract.sdk1.adapters.adapterkit import Adapterkit +from unstract.sdk1.adapters.base import Adapter +from unstract.sdk1.adapters.x2text.constants import X2TextConstants +from unstract.sdk1.constants import AdapterTypes +from unstract.sdk1.embedding import EmbeddingCompat +from unstract.sdk1.exceptions import SdkError +from unstract.sdk1.llm import LLM from adapter_processor_v2.constants import AdapterKeys, AllowedDomains from adapter_processor_v2.exceptions import ( @@ -16,13 +23,6 @@ InValidAdapterId, TestAdapterError, ) -from unstract.sdk1.adapters.adapterkit import Adapterkit -from unstract.sdk1.adapters.base import Adapter -from unstract.sdk1.adapters.x2text.constants import X2TextConstants -from unstract.sdk1.constants import AdapterTypes -from unstract.sdk1.embedding import EmbeddingCompat -from unstract.sdk1.exceptions import SdkError -from unstract.sdk1.llm import LLM from .models import AdapterInstance, UserDefaultAdapter @@ -46,6 +46,9 @@ def get_json_schema(adapter_id: str) -> dict[str, Any]: schema_details[AdapterKeys.JSON_SCHEMA] = json.loads( updated_adapters[0].get(AdapterKeys.JSON_SCHEMA) ) + for key in ("oauth", "oauth_provider", "python_social_auth_backend"): + if key in updated_adapters[0]: + schema_details[key] = updated_adapters[0][key] else: logger.error(f"Invalid adapter Id : {adapter_id} while fetching JSON Schema") raise InValidAdapterId() @@ -68,8 +71,7 @@ def get_all_supported_adapters(user_email: str, type: str) -> list[dict[Any, Any if not is_special_user and adapter_id.startswith("noOp"): continue - supported_adapters.append( - { + adapter_details = { AdapterKeys.ID: adapter_id, AdapterKeys.NAME: each_adapter.get(AdapterKeys.NAME), AdapterKeys.DESCRIPTION: each_adapter.get(AdapterKeys.DESCRIPTION), @@ -77,7 +79,10 @@ def get_all_supported_adapters(user_email: str, type: str) -> list[dict[Any, Any AdapterKeys.ADAPTER_TYPE: each_adapter.get(AdapterKeys.ADAPTER_TYPE), AdapterKeys.DOC_URL: each_adapter.get(AdapterKeys.DOC_URL, ""), } - ) + for key in ("oauth", "oauth_provider", "python_social_auth_backend"): + if key in each_adapter: + adapter_details[key] = each_adapter[key] + supported_adapters.append(adapter_details) return supported_adapters @staticmethod diff --git a/backend/adapter_processor_v2/serializers.py b/backend/adapter_processor_v2/serializers.py index 8209dd75c7..123be31f45 100644 --- a/backend/adapter_processor_v2/serializers.py +++ b/backend/adapter_processor_v2/serializers.py @@ -2,6 +2,9 @@ from typing import Any from account_v2.serializer import UserSerializer +from backend.constants import FieldLengthConstants as FLC +from backend.serializers import AuditSerializer +from connector_auth_v2.openai_oauth import redact_openai_oauth_metadata from cryptography.fernet import Fernet from django.conf import settings from rest_framework import serializers @@ -10,14 +13,13 @@ serialize_group_refs, serialize_owner_refs, ) +from unstract.sdk1.auth.openai_oauth import is_openai_oauth_adapter +from unstract.sdk1.constants import AdapterTypes +from unstract.sdk1.constants import Common as common from utils.input_sanitizer import validate_name_field, validate_no_html_tags from adapter_processor_v2.adapter_processor import AdapterProcessor from adapter_processor_v2.constants import AdapterKeys -from backend.constants import FieldLengthConstants as FLC -from backend.serializers import AuditSerializer -from unstract.sdk1.constants import AdapterTypes -from unstract.sdk1.constants import Common as common from .models import AdapterInstance, UserDefaultAdapter @@ -87,6 +89,11 @@ def to_representation(self, instance: AdapterInstance) -> dict[str, str]: rep.pop(AdapterKeys.ADAPTER_METADATA_B) adapter_metadata = instance.metadata + if is_openai_oauth_adapter(instance.adapter_id): + # OAuth tokens and account identity stay encrypted in the database + # and are never returned to the browser after the initial login. + adapter_metadata = redact_openai_oauth_metadata(adapter_metadata) + # Hide unstract_key when use_platform_provided_unstract_key is True if ( adapter_metadata.get("use_platform_provided_unstract_key") is True diff --git a/backend/adapter_processor_v2/views.py b/backend/adapter_processor_v2/views.py index f2aef82b0c..409f9e55f0 100644 --- a/backend/adapter_processor_v2/views.py +++ b/backend/adapter_processor_v2/views.py @@ -3,6 +3,7 @@ from typing import Any from account_v2.models import User +from connector_auth_v2.openai_oauth import OpenAIOAuthService from django.db import IntegrityError from django.db.models import ProtectedError, QuerySet from django.http import HttpRequest @@ -19,7 +20,7 @@ from plugins import get_plugin from rest_framework import status from rest_framework.decorators import action -from rest_framework.exceptions import PermissionDenied +from rest_framework.exceptions import PermissionDenied, ValidationError from rest_framework.request import Request from rest_framework.response import Response from rest_framework.serializers import ModelSerializer @@ -27,6 +28,11 @@ from rest_framework.viewsets import GenericViewSet, ModelViewSet from tenant_account_v2.organization_member_service import OrganizationMemberService from tool_instance_v2.models import ToolInstance +from unstract.sdk1.auth.openai_oauth import ( + OPENAI_OAUTH_PRIVATE_FIELDS, + OpenAIOAuthError, + is_openai_oauth_adapter, +) from utils.filtering import FilterHelper from utils.pagination import OptionalPagination from utils.user_context import UserContext @@ -60,6 +66,88 @@ logger = logging.getLogger(__name__) +def _prepare_openai_oauth_payload( + request: Request, + payload: Any, + existing_metadata: dict[str, Any] | None = None, +) -> tuple[Any, str | None]: + """Inject credentials from one owned OAuth login into an adapter payload.""" + adapter_id = payload.get(AdapterKeys.ADAPTER_ID) + if not is_openai_oauth_adapter(adapter_id): + return payload, None + + submitted_metadata = payload.get(AdapterKeys.ADAPTER_METADATA) + if submitted_metadata is None and existing_metadata is not None: + adapter_metadata = dict(existing_metadata) + elif isinstance(submitted_metadata, dict): + adapter_metadata = dict(submitted_metadata) + else: + adapter_metadata = {} + + # Tokens/account identity are always sourced from the server-side login + # session or the already-encrypted row. Never trust values sent in JSON. + for key in OPENAI_OAUTH_PRIVATE_FIELDS: + adapter_metadata.pop(key, None) + adapter_metadata.pop("oauth_authenticated", None) + adapter_metadata.pop("oauth_account_label", None) + + oauth_key = request.query_params.get("oauth-key") + if oauth_key: + try: + credentials = OpenAIOAuthService.credentials_for_request(oauth_key, request) + except OpenAIOAuthError as exc: + raise ValidationError({"oauth-key": str(exc)}) from exc + adapter_metadata.update(credentials) + elif existing_metadata is not None: + for key in OPENAI_OAUTH_PRIVATE_FIELDS: + if key in existing_metadata: + adapter_metadata[key] = existing_metadata[key] + else: + raise ValidationError( + {"oauth-key": "OpenAI OAuth authentication is required for this adapter."} + ) + + payload[AdapterKeys.ADAPTER_METADATA] = adapter_metadata + return payload, oauth_key + + +def _saved_openai_oauth_metadata(request: Request) -> dict[str, Any] | None: + """Load one owned adapter's encrypted OAuth metadata for a test request.""" + adapter_instance_id = request.query_params.get("adapter-instance-id") + if not adapter_instance_id: + return None + try: + adapter_uuid = uuid.UUID(adapter_instance_id) + except (AttributeError, TypeError, ValueError) as exc: + raise ValidationError( + {"adapter-instance-id": "OpenAI OAuth adapter was not found"} + ) from exc + + adapter = ( + AdapterInstance.objects.for_user(request.user) + .filter(pk=adapter_uuid) + .first() + ) + if adapter is None or not is_openai_oauth_adapter(adapter.adapter_id): + raise ValidationError( + {"adapter-instance-id": "OpenAI OAuth adapter was not found"} + ) + metadata = adapter.metadata + return metadata if isinstance(metadata, dict) else None + + +def _consume_openai_oauth_key(cache_key: str | None, request: Request) -> None: + """Best-effort cleanup after credentials are durably stored.""" + if not cache_key: + return + try: + OpenAIOAuthService.consume(cache_key, request) + except OpenAIOAuthError: + # The adapter row is already the durable credential store. A cache + # expiry/race must not turn a successful create/update into a 500. + logger.warning("Could not consume OpenAI OAuth hand-off session") + + class DefaultAdapterViewSet(ModelViewSet): versioning_class = URLPathVersioning serializer_class = DefaultAdapterSerializer @@ -127,10 +215,18 @@ def get_adapter_schema( def test(self, request: Request) -> Response: """Tests the connector against the credentials passed.""" - serializer: AdapterInstanceSerializer = self.get_serializer(data=request.data) + payload = request.data.copy() + existing_metadata = None + if is_openai_oauth_adapter(payload.get(AdapterKeys.ADAPTER_ID)): + existing_metadata = _saved_openai_oauth_metadata(request) + payload, _ = _prepare_openai_oauth_payload( + request, payload, existing_metadata=existing_metadata + ) + serializer: AdapterInstanceSerializer = self.get_serializer(data=payload) serializer.is_valid(raise_exception=True) adapter_id = serializer.validated_data.get(AdapterKeys.ADAPTER_ID) adapter_metadata = serializer.validated_data.get(AdapterKeys.ADAPTER_METADATA) + adapter_metadata = dict(adapter_metadata or {}) adapter_metadata[AdapterKeys.ADAPTER_TYPE] = serializer.validated_data.get( AdapterKeys.ADAPTER_TYPE ) @@ -237,10 +333,12 @@ def _enforce_llm_creation_restriction(request: Any, adapter_type: str) -> None: ) def create(self, request: Any) -> Response: - serializer = self.get_serializer(data=request.data) + payload = request.data.copy() + payload, oauth_key = _prepare_openai_oauth_payload(request, payload) + serializer = self.get_serializer(data=payload) use_platform_unstract_key = False - adapter_metadata = request.data.get(AdapterKeys.ADAPTER_METADATA) + adapter_metadata = payload.get(AdapterKeys.ADAPTER_METADATA) if adapter_metadata and adapter_metadata.get( AdapterKeys.PLATFORM_PROVIDED_UNSTRACT_KEY, False ): @@ -312,6 +410,9 @@ def create(self, request: Any) -> Response: user_default_adapter.save() + # The encrypted adapter row is now the durable credential store. + _consume_openai_oauth_key(oauth_key, request) + except IntegrityError: raise DuplicateAdapterNameError( name=serializer.validated_data.get(AdapterKeys.ADAPTER_NAME) @@ -410,7 +511,10 @@ def partial_update( adapter = self.get_object() before = self.snapshot_share_axes(adapter) - response = super().partial_update(request, *args, **kwargs) + if is_openai_oauth_adapter(adapter.adapter_id): + response = self._update_openai_oauth(request, adapter, partial=True) + else: + response = super().partial_update(request, *args, **kwargs) if response.status_code == 200 and notification_plugin: self._notify_shared_users(adapter, before, request.data, request.user) return response @@ -555,6 +659,13 @@ def list_of_shared_users(self, request: HttpRequest, pk: Any = None) -> Response def update( self, request: Request, *args: tuple[Any], **kwargs: dict[str, Any] ) -> Response: + # OAuth adapters carry their credentials in the encrypted metadata row; + # inject a newly completed login or preserve the existing account when + # a metadata-only edit does not include a new login session. + adapter = self.get_object() + if is_openai_oauth_adapter(adapter.adapter_id): + return self._update_openai_oauth(request, adapter, partial=False) + # Check if adapter metadata is being updated and contains the platform key flag use_platform_unstract_key = False adapter_metadata = request.data.get(AdapterKeys.ADAPTER_METADATA) @@ -565,9 +676,6 @@ def update( use_platform_unstract_key = True logger.error(f"Platform key flag detected: {use_platform_unstract_key}") - # Get the adapter instance for update - adapter = self.get_object() - if use_platform_unstract_key: logger.error("Processing adapter with platform key") serializer = self.get_serializer(adapter, data=request.data, partial=True) @@ -597,6 +705,19 @@ def update( # For non-platform-key cases, use the default update behavior return super().update(request, *args, **kwargs) + def _update_openai_oauth( + self, request: Request, adapter: AdapterInstance, *, partial: bool + ) -> Response: + payload = request.data.copy() + payload, oauth_key = _prepare_openai_oauth_payload( + request, payload, existing_metadata=adapter.metadata + ) + serializer = self.get_serializer(adapter, data=payload, partial=partial) + serializer.is_valid(raise_exception=True) + serializer.save() + _consume_openai_oauth_key(oauth_key, request) + return Response(serializer.data) + @action(detail=True, methods=["get"]) def adapter_info(self, request: HttpRequest, pk: uuid) -> Response: adapter = self.get_object() diff --git a/backend/connector_auth_v2/migrations/0002_openai_oauth_credential.py b/backend/connector_auth_v2/migrations/0002_openai_oauth_credential.py new file mode 100644 index 0000000000..1c1c0526c3 --- /dev/null +++ b/backend/connector_auth_v2/migrations/0002_openai_oauth_credential.py @@ -0,0 +1,68 @@ +import uuid + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("connector_auth_v2", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name="OpenAIOAuthCredential", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ( + "organization_id", + models.CharField(max_length=64), + ), + ("account_id", models.CharField(max_length=255)), + ( + "account_label", + models.CharField(blank=True, default="", max_length=255), + ), + ("encrypted_credentials", models.TextField()), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("modified_at", models.DateTimeField(auto_now=True)), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="openai_oauth_credentials", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "verbose_name": "OpenAI OAuth credential", + "verbose_name_plural": "OpenAI OAuth credentials", + "db_table": "openai_oauth_credential", + }, + ), + migrations.AddConstraint( + model_name="openaioauthcredential", + constraint=models.UniqueConstraint( + fields=("user", "organization_id", "account_id"), + name="unique_openai_oauth_user_org_account", + ), + ), + migrations.AddIndex( + model_name="openaioauthcredential", + index=models.Index( + fields=("user", "organization_id", "-modified_at"), + name="openai_oauth_user_org_mod_idx", + ), + ), + ] diff --git a/backend/connector_auth_v2/models.py b/backend/connector_auth_v2/models.py index a92630c41a..af7aa00d02 100644 --- a/backend/connector_auth_v2/models.py +++ b/backend/connector_auth_v2/models.py @@ -3,6 +3,7 @@ from typing import Any from account_v2.models import User +from backend.constants import FieldLengthConstants as FieldLength from django.db import models from django.db.models.query import QuerySet from rest_framework.request import Request @@ -157,3 +158,43 @@ class Meta: class ConnectorDjangoStorage(DjangoStorage): user = ConnectorAuth + + +class OpenAIOAuthCredential(models.Model): + """Encrypted OpenAI OAuth credentials retained for the signed-in user. + + The browser hand-off in Redis is intentionally short-lived. This model is + the durable server-side account record that lets a user reopen the OAuth + form without repeating device login. Secrets are encrypted with the same + Fernet key used for adapter metadata and are never serialized to clients. + """ + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + user = models.ForeignKey( + User, + related_name="openai_oauth_credentials", + on_delete=models.CASCADE, + ) + organization_id = models.CharField(max_length=FieldLength.ORG_NAME_SIZE) + account_id = models.CharField(max_length=255) + account_label = models.CharField(max_length=255, blank=True, default="") + encrypted_credentials = models.TextField() + created_at = models.DateTimeField(auto_now_add=True) + modified_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "openai_oauth_credential" + verbose_name = "OpenAI OAuth credential" + verbose_name_plural = "OpenAI OAuth credentials" + constraints = [ + models.UniqueConstraint( + fields=["user", "organization_id", "account_id"], + name="unique_openai_oauth_user_org_account", + ), + ] + indexes = [ + models.Index( + fields=["user", "organization_id", "-modified_at"], + name="openai_oauth_user_org_mod_idx", + ), + ] diff --git a/backend/connector_auth_v2/openai_oauth.py b/backend/connector_auth_v2/openai_oauth.py new file mode 100644 index 0000000000..1e112214e1 --- /dev/null +++ b/backend/connector_auth_v2/openai_oauth.py @@ -0,0 +1,465 @@ +"""Server-side device login and credential hand-off for OpenAI OAuth.""" + +from __future__ import annotations + +import json +import os +import time +import uuid +from datetime import UTC, datetime +from typing import Any + +import httpx +from cryptography.fernet import Fernet, InvalidToken +from django.conf import settings +from django.core.cache import cache +from rest_framework.request import Request +from unstract.sdk1.auth.openai_oauth import ( + OPENAI_OAUTH_CLIENT_ID, + OPENAI_OAUTH_DEVICE_REDIRECT_URI, + OPENAI_OAUTH_DEVICE_TOKEN_URL, + OPENAI_OAUTH_DEVICE_USERCODE_URL, + OPENAI_OAUTH_DEVICE_VERIFICATION_URL, + OPENAI_OAUTH_PRIVATE_FIELDS, + OPENAI_OAUTH_TOKEN_URL, + OpenAIOAuthError, + build_openai_oauth_json_schema, + extract_account_id, + extract_email, + fetch_openai_oauth_model_catalog, + refresh_openai_oauth_metadata, +) +from utils.user_session import UserSessionUtils + +from connector_auth_v2.models import OpenAIOAuthCredential + +_CACHE_PREFIX = "openai-oauth:" +_DEFAULT_STATE_TTL_SECONDS = 900 +_STATE_TTL_SECONDS = int( + os.environ.get("OPENAI_OAUTH_STATE_TTL_SECONDS", str(_DEFAULT_STATE_TTL_SECONDS)) +) + + +class OpenAIOAuthSessionError(OpenAIOAuthError): + """Raised for an invalid, expired, or unauthorized browser login session.""" + + +def _safe_response_json(response: httpx.Response, operation: str) -> dict[str, Any]: + if not 200 <= response.status_code < 300: + raise OpenAIOAuthError( + f"OpenAI OAuth {operation} failed with status {response.status_code}" + ) + try: + payload = response.json() + except (ValueError, json.JSONDecodeError) as exc: + raise OpenAIOAuthError( + f"OpenAI OAuth {operation} returned an invalid response" + ) from exc + if not isinstance(payload, dict): + raise OpenAIOAuthError(f"OpenAI OAuth {operation} returned an invalid response") + return payload + + +def _post_json(url: str, *, json_body: dict[str, Any], operation: str) -> dict[str, Any]: + try: + response = httpx.post(url, json=json_body, timeout=15.0) + except httpx.HTTPError as exc: + raise OpenAIOAuthError( + f"OpenAI OAuth {operation} could not reach the authorization server" + ) from exc + return _safe_response_json(response, operation) + + +def _post_form(url: str, *, form_data: dict[str, str], operation: str) -> dict[str, Any]: + try: + response = httpx.post( + url, + data=form_data, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + timeout=15.0, + ) + except httpx.HTTPError as exc: + raise OpenAIOAuthError( + f"OpenAI OAuth {operation} could not reach the authorization server" + ) from exc + return _safe_response_json(response, operation) + + +def _as_expiry_seconds(value: object) -> float | None: + if value is None: + return None + if isinstance(value, (int, float)): + return float(value) + if isinstance(value, str): + try: + return float(value) + except ValueError: + try: + return datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp() + except ValueError: + return None + return None + + +def _account_label(account_id: str, email: str | None) -> str: + if email: + return email + suffix = account_id[-8:] if len(account_id) > 8 else account_id + return f"OpenAI account ({suffix})" + + +class OpenAIOAuthService: + """Own one short-lived OAuth login session per browser/account.""" + + @staticmethod + def _identity(request: Request) -> tuple[str, str]: + user_id = getattr(request.user, "pk", None) or getattr(request.user, "id", None) + organization_id = UserSessionUtils.get_organization_id(request) + if user_id is None or not organization_id: + raise OpenAIOAuthSessionError( + "An authenticated organization session is required for OpenAI OAuth" + ) + return str(user_id), str(organization_id) + + @staticmethod + def _encrypt(credentials: dict[str, Any]) -> str: + fernet = Fernet(str(settings.ENCRYPTION_KEY).encode("utf-8")) + return fernet.encrypt(json.dumps(credentials).encode("utf-8")).decode("utf-8") + + @staticmethod + def _decrypt(value: str) -> dict[str, Any]: + try: + fernet = Fernet(str(settings.ENCRYPTION_KEY).encode("utf-8")) + credentials = json.loads(fernet.decrypt(value.encode("utf-8"))) + except (InvalidToken, ValueError, json.JSONDecodeError) as exc: + raise OpenAIOAuthSessionError( + "OpenAI OAuth login session is no longer valid" + ) from exc + if not isinstance(credentials, dict): + raise OpenAIOAuthSessionError("OpenAI OAuth login session is no longer valid") + return credentials + + @classmethod + def _persist_credentials( + cls, request: Request, credentials: dict[str, Any] + ) -> OpenAIOAuthCredential: + """Upsert encrypted credentials for the current user and organization.""" + _, organization_id = cls._identity(request) + account_id = credentials.get("oauth_account_id") + if not isinstance(account_id, str) or not account_id: + raise OpenAIOAuthSessionError( + "OpenAI OAuth credentials returned no ChatGPT account" + ) + + return OpenAIOAuthCredential.objects.update_or_create( + user_id=request.user.pk, + organization_id=organization_id, + account_id=account_id, + defaults={ + "account_label": _account_label( + account_id, credentials.get("oauth_account_email") + ), + "encrypted_credentials": cls._encrypt(credentials), + }, + )[0] + + @classmethod + def _load_persisted_credentials( + cls, request: Request, credential: OpenAIOAuthCredential + ) -> dict[str, Any]: + """Decrypt and refresh one durable credential record when necessary.""" + credentials = cls._decrypt(credential.encrypted_credentials) + refreshed = refresh_openai_oauth_metadata(credentials) + account_id = refreshed.get("oauth_account_id") + if not isinstance(account_id, str) or not account_id: + raise OpenAIOAuthSessionError( + "OpenAI OAuth credentials returned no ChatGPT account" + ) + + if refreshed != credentials or credential.account_id != account_id: + credential.account_id = account_id + credential.account_label = _account_label( + account_id, refreshed.get("oauth_account_email") + ) + credential.encrypted_credentials = cls._encrypt(refreshed) + credential.save( + update_fields=[ + "account_id", + "account_label", + "encrypted_credentials", + "modified_at", + ] + ) + return refreshed + + @classmethod + def _save_state(cls, cache_key: str, state: dict[str, Any]) -> None: + expiry = _as_expiry_seconds(state.get("expires_at")) + ttl = _STATE_TTL_SECONDS + if expiry is not None: + ttl = min(ttl, max(30, int(expiry - time.time()))) + cache.set(cache_key, state, max(ttl, 30)) + + @classmethod + def _get_owned_state(cls, cache_key: str, request: Request) -> dict[str, Any]: + if not cache_key or not cache_key.startswith(_CACHE_PREFIX): + raise OpenAIOAuthSessionError("OpenAI OAuth login session is invalid") + user_id, organization_id = cls._identity(request) + state = cache.get(cache_key) + if not isinstance(state, dict): + raise OpenAIOAuthSessionError( + "OpenAI OAuth login session was not found or has expired" + ) + if ( + state.get("owner_id") != user_id + or state.get("organization_id") != organization_id + ): + raise OpenAIOAuthSessionError( + "OpenAI OAuth login session was not found or is not owned by this user" + ) + expiry = _as_expiry_seconds(state.get("expires_at")) + if expiry is not None and expiry <= time.time(): + cache.delete(cache_key) + raise OpenAIOAuthSessionError("OpenAI OAuth login session has expired") + return state + + @classmethod + def start(cls, request: Request) -> dict[str, Any]: + user_id, organization_id = cls._identity(request) + payload = _post_json( + OPENAI_OAUTH_DEVICE_USERCODE_URL, + json_body={"client_id": OPENAI_OAUTH_CLIENT_ID}, + operation="device login start", + ) + device_auth_id = payload.get("device_auth_id") + user_code = payload.get("user_code") or payload.get("usercode") + if not isinstance(device_auth_id, str) or not device_auth_id: + raise OpenAIOAuthError("OpenAI OAuth device login returned no device id") + if not isinstance(user_code, str) or not user_code: + raise OpenAIOAuthError("OpenAI OAuth device login returned no user code") + + try: + interval = max(1, int(payload.get("interval", 5))) + except (TypeError, ValueError): + interval = 5 + expires_at = payload.get("expires_at") + if not isinstance(expires_at, str): + expires_at = datetime.fromtimestamp( + time.time() + _STATE_TTL_SECONDS, tz=UTC + ).isoformat() + + cache_key = f"{_CACHE_PREFIX}{uuid.uuid4().hex}" + cls._save_state( + cache_key, + { + "status": "pending", + "owner_id": user_id, + "organization_id": organization_id, + "device_auth_id": device_auth_id, + "user_code": user_code, + "poll_interval": interval, + "expires_at": expires_at, + }, + ) + return { + "cache_key": cache_key, + "verification_url": OPENAI_OAUTH_DEVICE_VERIFICATION_URL, + "user_code": user_code, + "expires_at": expires_at, + "poll_interval": interval, + } + + @classmethod + def _success_response(cls, state: dict[str, Any]) -> dict[str, Any]: + response = { + "status": "success", + "account_label": state.get("account_label", "OpenAI account"), + } + if state.get("restored"): + response["restored"] = True + return response + + @classmethod + def _create_success_handoff( + cls, request: Request, credentials: dict[str, Any], *, restored: bool = False + ) -> dict[str, Any]: + """Create a fresh short-lived hand-off for a durable account record.""" + user_id, organization_id = cls._identity(request) + account_id = credentials.get("oauth_account_id") + if not isinstance(account_id, str) or not account_id: + raise OpenAIOAuthSessionError( + "OpenAI OAuth credentials returned no ChatGPT account" + ) + cache_key = f"{_CACHE_PREFIX}{uuid.uuid4().hex}" + state = { + "status": "success", + "owner_id": user_id, + "organization_id": organization_id, + "credentials": cls._encrypt(credentials), + "account_label": _account_label( + account_id, credentials.get("oauth_account_email") + ), + "restored": restored, + } + cls._save_state(cache_key, state) + return {"cache_key": cache_key, **cls._success_response(state)} + + @classmethod + def poll(cls, request: Request, cache_key: str) -> dict[str, Any]: + state = cls._get_owned_state(cache_key, request) + if state.get("status") == "success": + # A hand-off created by an older backend may have completed before + # durable persistence was introduced. Backfill it on first use. + if state.get("credentials"): + credentials = cls._decrypt(state["credentials"]) + cls._persist_credentials(request, credentials) + return cls._success_response(state) + + try: + response = httpx.post( + OPENAI_OAUTH_DEVICE_TOKEN_URL, + json={ + "device_auth_id": state["device_auth_id"], + "user_code": state["user_code"], + }, + timeout=15.0, + ) + except httpx.HTTPError as exc: + raise OpenAIOAuthError( + "OpenAI OAuth device login could not reach the authorization server" + ) from exc + + # The official device endpoint uses 403/404 while the user has not + # finished entering the code. Keep the browser poll alive for those + # expected responses (and for rate limiting). + if response.status_code in {403, 404, 429}: + return { + "status": "pending", + "poll_interval": state.get("poll_interval", 5), + } + device_result = _safe_response_json(response, "device login poll") + authorization_code = device_result.get("authorization_code") + code_verifier = device_result.get("code_verifier") + if not isinstance(authorization_code, str) or not isinstance(code_verifier, str): + raise OpenAIOAuthError( + "OpenAI OAuth device login returned incomplete authorization data" + ) + + token_data = _post_form( + OPENAI_OAUTH_TOKEN_URL, + form_data={ + "grant_type": "authorization_code", + "code": authorization_code, + "redirect_uri": OPENAI_OAUTH_DEVICE_REDIRECT_URI, + "client_id": OPENAI_OAUTH_CLIENT_ID, + "code_verifier": code_verifier, + }, + operation="token exchange", + ) + access_token = token_data.get("access_token") + refresh_token = token_data.get("refresh_token") + id_token = token_data.get("id_token") + if not all( + isinstance(value, str) and value for value in (access_token, refresh_token) + ): + raise OpenAIOAuthError( + "OpenAI OAuth token exchange returned incomplete credentials" + ) + + account_id = extract_account_id(id_token, access_token) + if not account_id: + raise OpenAIOAuthError( + "OpenAI OAuth token exchange returned no ChatGPT account" + ) + email = extract_email(id_token, access_token) + try: + expires_in = float(token_data.get("expires_in", 3600)) + except (TypeError, ValueError): + expires_in = 3600 + credentials = { + "oauth_access_token": access_token, + "oauth_refresh_token": refresh_token, + "oauth_id_token": id_token, + "oauth_account_id": account_id, + "oauth_account_email": email, + "oauth_expires_at": time.time() + expires_in, + "oauth_authenticated": True, + } + # Persist immediately after authorization succeeds. The Redis state + # below remains only a short-lived, browser-scoped hand-off. + cls._persist_credentials(request, credentials) + state.update( + { + "status": "success", + "credentials": cls._encrypt(credentials), + "account_label": _account_label(account_id, email), + } + ) + cls._save_state(cache_key, state) + return cls._success_response(state) + + @classmethod + def credentials_for_request(cls, cache_key: str, request: Request) -> dict[str, Any]: + state = cls._get_owned_state(cache_key, request) + if state.get("status") != "success" or not state.get("credentials"): + raise OpenAIOAuthSessionError("Complete OpenAI OAuth authentication first") + credentials = cls._decrypt(state["credentials"]) + refreshed = refresh_openai_oauth_metadata(credentials) + if refreshed != credentials: + state["credentials"] = cls._encrypt(refreshed) + cls._save_state(cache_key, state) + cls._persist_credentials(request, refreshed) + return refreshed + + @classmethod + def restore(cls, request: Request) -> dict[str, Any] | None: + """Restore the most recently used durable OpenAI account, if any.""" + user_id, organization_id = cls._identity(request) + credential = ( + OpenAIOAuthCredential.objects.filter( + user_id=user_id, + organization_id=organization_id, + ) + .order_by("-modified_at") + .first() + ) + if credential is None: + return None + + credentials = cls._load_persisted_credentials(request, credential) + return cls._create_success_handoff(request, credentials, restored=True) + + @staticmethod + def dynamic_model_schema( + credentials: dict[str, Any], *, current_model: str | None = None + ) -> tuple[dict[str, Any], dict[str, Any]]: + """Return a live account-specific form schema and refreshed credentials.""" + refreshed = refresh_openai_oauth_metadata(credentials) + catalog = fetch_openai_oauth_model_catalog(refreshed) + schema = build_openai_oauth_json_schema( + catalog, + current_model=current_model, + ) + return schema, refreshed + + @classmethod + def consume(cls, cache_key: str, request: Request) -> None: + # Validate ownership before deleting so a leaked cache key cannot be + # used to consume another user's pending login. + cls._get_owned_state(cache_key, request) + cache.delete(cache_key) + + +def redact_openai_oauth_metadata(metadata: dict[str, Any]) -> dict[str, Any]: + """Return the non-secret account state safe for API responses.""" + redacted = { + key: value + for key, value in metadata.items() + if key not in OPENAI_OAUTH_PRIVATE_FIELDS + } + redacted["oauth_authenticated"] = bool(metadata.get("oauth_access_token")) + account_id = metadata.get("oauth_account_id") + email = metadata.get("oauth_account_email") + if isinstance(account_id, str): + redacted["oauth_account_label"] = _account_label(account_id, email) + return redacted diff --git a/backend/connector_auth_v2/test_openai_oauth.py b/backend/connector_auth_v2/test_openai_oauth.py new file mode 100644 index 0000000000..f2e8fd2f28 --- /dev/null +++ b/backend/connector_auth_v2/test_openai_oauth.py @@ -0,0 +1,82 @@ +from types import SimpleNamespace +from unittest.mock import patch + +from account_v2.models import User +from cryptography.fernet import Fernet +from django.core.cache import cache +from django.test import TestCase, override_settings + +from connector_auth_v2.models import OpenAIOAuthCredential +from connector_auth_v2.openai_oauth import OpenAIOAuthService + +TEST_ENCRYPTION_KEY = Fernet.generate_key().decode("utf-8") +TEST_ORGANIZATION_ID = "oauth-test-org" +TEST_CACHES = { + "default": { + "BACKEND": "django.core.cache.backends.locmem.LocMemCache", + "LOCATION": "openai-oauth-tests", + } +} + + +@override_settings(ENCRYPTION_KEY=TEST_ENCRYPTION_KEY, CACHES=TEST_CACHES) +class OpenAIOAuthCredentialTests(TestCase): + def setUp(self) -> None: + self.user = User.objects.create_user( + username="oauth@example.com", + email="oauth@example.com", + password="not-used-in-this-test", + ) + self.request = SimpleNamespace(user=self.user) + cache.clear() + + def tearDown(self) -> None: + cache.clear() + + def test_authenticated_account_is_encrypted_and_restorable(self) -> None: + credentials = { + "oauth_access_token": "access-token", + "oauth_refresh_token": "refresh-token", + "oauth_id_token": "id-token", + "oauth_account_id": "account-123", + "oauth_account_email": "oauth@example.com", + "oauth_expires_at": 4_000_000_000, + "oauth_authenticated": True, + } + + with ( + patch.object( + OpenAIOAuthService, + "_identity", + return_value=(str(self.user.pk), TEST_ORGANIZATION_ID), + ), + patch( + "connector_auth_v2.openai_oauth.refresh_openai_oauth_metadata", + side_effect=lambda metadata: dict(metadata), + ), + ): + OpenAIOAuthService._persist_credentials(self.request, credentials) + + stored = OpenAIOAuthCredential.objects.get(user=self.user) + assert "access-token" not in stored.encrypted_credentials + assert stored.account_label == "oauth@example.com" + + restored = OpenAIOAuthService.restore(self.request) + + assert restored is not None + assert restored["status"] == "success" + assert restored["restored"] is True + assert restored["cache_key"].startswith("openai-oauth:") + + state = cache.get(restored["cache_key"]) + assert state["status"] == "success" + assert state["owner_id"] == str(self.user.pk) + assert OpenAIOAuthService._decrypt(state["credentials"]) == credentials + + def test_restore_returns_no_account_before_first_login(self) -> None: + with patch.object( + OpenAIOAuthService, + "_identity", + return_value=(str(self.user.pk), TEST_ORGANIZATION_ID), + ): + assert OpenAIOAuthService.restore(self.request) is None diff --git a/backend/connector_auth_v2/urls.py b/backend/connector_auth_v2/urls.py index 55337ad20f..6f6ccbf074 100644 --- a/backend/connector_auth_v2/urls.py +++ b/backend/connector_auth_v2/urls.py @@ -8,6 +8,10 @@ "get": "cache_key", } ) +openai_oauth_start = ConnectorAuthViewSet.as_view({"post": "openai_start"}) +openai_oauth_poll = ConnectorAuthViewSet.as_view({"get": "openai_poll"}) +openai_oauth_restore = ConnectorAuthViewSet.as_view({"get": "openai_restore"}) +openai_oauth_models = ConnectorAuthViewSet.as_view({"get": "openai_models"}) urlpatterns = format_suffix_patterns( [ @@ -17,5 +21,13 @@ connector_auth_cache, name="connector-cache", ), + path("oauth/openai/start/", openai_oauth_start, name="openai-oauth-start"), + path("oauth/openai/poll/", openai_oauth_poll, name="openai-oauth-poll"), + path( + "oauth/openai/restore/", + openai_oauth_restore, + name="openai-oauth-restore", + ), + path("oauth/openai/models/", openai_oauth_models, name="openai-oauth-models"), ] ) diff --git a/backend/connector_auth_v2/views.py b/backend/connector_auth_v2/views.py index 4d515563b6..9291916ec3 100644 --- a/backend/connector_auth_v2/views.py +++ b/backend/connector_auth_v2/views.py @@ -1,15 +1,24 @@ import logging import uuid +from adapter_processor_v2.models import AdapterInstance from django.conf import settings from rest_framework import status, viewsets from rest_framework.request import Request from rest_framework.response import Response from rest_framework.versioning import URLPathVersioning +from unstract.sdk1.auth.openai_oauth import ( + OpenAIOAuthError, + is_openai_oauth_adapter, +) from utils.user_session import UserSessionUtils from connector_auth_v2.constants import SocialAuthConstants from connector_auth_v2.exceptions import KeyNotConfigured +from connector_auth_v2.openai_oauth import ( + OpenAIOAuthService, + OpenAIOAuthSessionError, +) logger = logging.getLogger(__name__) @@ -47,3 +56,104 @@ def cache_key( status=status.HTTP_200_OK, data={"cache_key": f"{cache_key}"}, ) + + @staticmethod + def _require_authenticated(request: Request) -> Response | None: + if not getattr(request.user, "is_authenticated", False): + return Response( + {"message": "Authentication is required for OpenAI OAuth."}, + status=status.HTTP_401_UNAUTHORIZED, + ) + return None + + def openai_start(self, request: Request) -> Response: + """Start a server-side OpenAI device-code login.""" + if unauthorized := self._require_authenticated(request): + return unauthorized + try: + return Response(OpenAIOAuthService.start(request), status=status.HTTP_200_OK) + except OpenAIOAuthSessionError as exc: + return Response({"message": str(exc)}, status=status.HTTP_400_BAD_REQUEST) + except OpenAIOAuthError as exc: + logger.warning("OpenAI OAuth device login start failed: %s", exc) + return Response({"message": str(exc)}, status=status.HTTP_502_BAD_GATEWAY) + + def openai_poll(self, request: Request) -> Response: + """Poll one OpenAI device-code login and exchange it when complete.""" + if unauthorized := self._require_authenticated(request): + return unauthorized + cache_key = request.query_params.get("oauth-key") + try: + result = OpenAIOAuthService.poll(request, cache_key or "") + return Response(result, status=status.HTTP_200_OK) + except OpenAIOAuthSessionError as exc: + return Response({"message": str(exc)}, status=status.HTTP_400_BAD_REQUEST) + except OpenAIOAuthError as exc: + logger.warning("OpenAI OAuth device login poll failed: %s", exc) + return Response({"message": str(exc)}, status=status.HTTP_502_BAD_GATEWAY) + + def openai_restore(self, request: Request) -> Response: + """Restore the user's most recently authenticated OpenAI account.""" + if unauthorized := self._require_authenticated(request): + return unauthorized + try: + result = OpenAIOAuthService.restore(request) + return Response(result or {"status": "none"}, status=status.HTTP_200_OK) + except OpenAIOAuthSessionError as exc: + return Response({"message": str(exc)}, status=status.HTTP_400_BAD_REQUEST) + except OpenAIOAuthError as exc: + logger.warning("OpenAI OAuth account restore failed: %s", exc) + return Response({"message": str(exc)}, status=status.HTTP_502_BAD_GATEWAY) + + def openai_models(self, request: Request) -> Response: + """Return the live model/reasoning schema for one OAuth account.""" + if unauthorized := self._require_authenticated(request): + return unauthorized + + oauth_key = request.query_params.get("oauth-key") + adapter_instance_id = request.query_params.get("adapter-instance-id") + current_model = request.query_params.get("model") + + try: + if oauth_key: + credentials = OpenAIOAuthService.credentials_for_request( + oauth_key, request + ) + elif adapter_instance_id: + try: + adapter_uuid = uuid.UUID(adapter_instance_id) + except (AttributeError, TypeError, ValueError) as exc: + raise OpenAIOAuthSessionError( + "OpenAI OAuth adapter was not found or is not accessible" + ) from exc + adapter = ( + AdapterInstance.objects.for_user(request.user) + .filter(pk=adapter_uuid) + .first() + ) + if adapter is None or not is_openai_oauth_adapter(adapter.adapter_id): + raise OpenAIOAuthSessionError( + "OpenAI OAuth adapter was not found or is not accessible" + ) + credentials = adapter.metadata + if not isinstance(credentials, dict): + raise OpenAIOAuthSessionError( + "OpenAI OAuth adapter credentials are invalid" + ) + if not current_model and isinstance(credentials.get("model"), str): + current_model = credentials["model"] + else: + raise OpenAIOAuthSessionError( + "An OpenAI OAuth login session or adapter is required" + ) + + schema, _ = OpenAIOAuthService.dynamic_model_schema( + credentials, + current_model=current_model, + ) + return Response({"json_schema": schema}, status=status.HTTP_200_OK) + except OpenAIOAuthSessionError as exc: + return Response({"message": str(exc)}, status=status.HTTP_400_BAD_REQUEST) + except OpenAIOAuthError as exc: + logger.warning("OpenAI OAuth model discovery failed: %s", exc) + return Response({"message": str(exc)}, status=status.HTTP_502_BAD_GATEWAY) diff --git a/frontend/src/components/input-output/add-source-modal/AddSourceModal.jsx b/frontend/src/components/input-output/add-source-modal/AddSourceModal.jsx index 398026fa9d..cf7d9faaf9 100644 --- a/frontend/src/components/input-output/add-source-modal/AddSourceModal.jsx +++ b/frontend/src/components/input-output/add-source-modal/AddSourceModal.jsx @@ -1,6 +1,6 @@ import { ArrowLeft } from "lucide-react"; import PropTypes from "prop-types"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { Button } from "@/components/ui/shims/antd-button"; import { Modal } from "@/components/ui/shims/antd-overlays"; @@ -28,6 +28,7 @@ function AddSourceModal({ const [titles, setTitles] = useState({}); const [selectedSourceName, setSelectedSourceName] = useState(""); const [selectedDocUrl, setSelectedDocUrl] = useState(""); + const [draftsBySourceId, setDraftsBySourceId] = useState({}); const { setAlertDetails } = useAlertStore(); const axiosPrivate = useAxiosPrivate(); const handleException = useExceptionHandler(); @@ -35,6 +36,21 @@ function AddSourceModal({ const [isLoading, setIsLoading] = useState(false); const [sourcesList, setSourcesList] = useState([]); + const updateSourceDraft = useCallback((sourceId, draft) => { + setDraftsBySourceId((currentDrafts) => ({ + ...currentDrafts, + [sourceId]: draft, + })); + }, []); + + const clearSourceDraft = useCallback((sourceId) => { + setDraftsBySourceId((currentDrafts) => { + const nextDrafts = { ...currentDrafts }; + delete nextDrafts[sourceId]; + return nextDrafts; + }); + }, []); + const disabledIdsByType = { EMBEDDING: ["huggingface|90ec9ec2-1768-4d69-8fb1-c88b95de5e5a"], LLM: ["replicate|2715ce84-05af-4ab4-b8e9-67ac3211b81e"], @@ -58,13 +74,16 @@ function AddSourceModal({ useEffect(() => { if (!open) { - setTimeout(() => { + const resetTimeout = setTimeout(() => { // A delay added in order to avoid glitch in the UI when the modal is closed. setSelectedSourceId(null); setEditItemId(null); // Clear metadata to prevent stale data when adding a new connector setMetadata({}); + setDraftsBySourceId({}); }, 500); + + return () => clearTimeout(resetTimeout); } getListOfSources(); @@ -185,6 +204,7 @@ function AddSourceModal({ onCancel={() => { setOpen(false); setMetadata(null); + setDraftsBySourceId({}); }} maskClosable={false} title={modalTitle} @@ -197,6 +217,7 @@ function AddSourceModal({ > {selectedSourceId ? ( ) : isLoading ? ( diff --git a/frontend/src/components/input-output/add-source/AddSource.jsx b/frontend/src/components/input-output/add-source/AddSource.jsx index 60b1585f81..3d287f7dc0 100644 --- a/frontend/src/components/input-output/add-source/AddSource.jsx +++ b/frontend/src/components/input-output/add-source/AddSource.jsx @@ -1,7 +1,7 @@ import { getDefaultFormState } from "@rjsf/utils"; import validator from "@rjsf/validator-ajv8"; import PropTypes from "prop-types"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { Typography } from "@/components/ui/shims/antd-typography"; import { useAxiosPrivate } from "../../../hooks/useAxiosPrivate"; @@ -36,6 +36,24 @@ try { // Ignore if not available } +const OPENAI_OAUTH_SOURCE_PREFIX = "openai-oauth|"; +const OPENAI_OAUTH_DRAFT_FIELDS = new Set([ + "adapter_name", + "model", + "max_tokens", + "max_retries", + "timeout", + "enable_reasoning", + "reasoning_effort", +]); + +const getOpenAIOAuthDraft = (formData) => + Object.fromEntries( + Object.entries(formData || {}).filter(([fieldName]) => + OPENAI_OAUTH_DRAFT_FIELDS.has(fieldName), + ), + ); + function AddSource({ selectedSourceId, selectedSourceName, @@ -46,9 +64,17 @@ function AddSource({ editItemId, metadata, selectedDocUrl, + draftFormData, + onDraftFormDataChange, + onClearDraft, }) { const [spec, setSpec] = useState({}); const [formData, setFormData] = useState({}); + const initialDraftFormData = useRef( + selectedSourceId.startsWith(OPENAI_OAUTH_SOURCE_PREFIX) + ? getOpenAIOAuthDraft(draftFormData) + : undefined, + ); const [isLoading, setIsLoading] = useState(false); const [oAuthProvider, setOAuthProvider] = useState(""); const { setAlertDetails } = useAlertStore(); @@ -131,6 +157,11 @@ function AddSource({ if (metadata && Object.keys(metadata).length > 0) { setFormData(metadata); + } else if ( + initialDraftFormData.current && + Object.keys(initialDraftFormData.current).length > 0 + ) { + setFormData(initialDraftFormData.current); } else { const defaults = getDefaultFormState( validator, @@ -162,6 +193,25 @@ function AddSource({ } }, [metadata]); + useEffect(() => { + if ( + !selectedSourceId.startsWith(OPENAI_OAUTH_SOURCE_PREFIX) || + editItemId?.length || + (metadata && Object.keys(metadata).length > 0) + ) { + return; + } + + onDraftFormDataChange?.(selectedSourceId, getOpenAIOAuthDraft(formData)); + }, [editItemId, formData, metadata, onDraftFormDataChange, selectedSourceId]); + + const handleAddNewItem = (row, isEdit) => { + if (!isEdit) { + onClearDraft?.(selectedSourceId); + } + addNewItem?.(row, isEdit); + }; + if (selectedSourceId.includes("pcs|")) { return ( @@ -183,7 +233,7 @@ function AddSource({ oAuthProvider={oAuthProvider} selectedSourceId={selectedSourceId} isLoading={isLoading} - addNewItem={addNewItem} + addNewItem={handleAddNewItem} type={type} editItemId={editItemId} isConnector={isConnector} @@ -204,6 +254,9 @@ AddSource.propTypes = { editItemId: PropTypes.string, metadata: PropTypes.object, selectedDocUrl: PropTypes.string, + draftFormData: PropTypes.object, + onDraftFormDataChange: PropTypes.func, + onClearDraft: PropTypes.func, }; export { AddSource }; diff --git a/frontend/src/components/input-output/configure-ds/ConfigureDs.css b/frontend/src/components/input-output/configure-ds/ConfigureDs.css index 5d44bc3fef..24498cec5a 100644 --- a/frontend/src/components/input-output/configure-ds/ConfigureDs.css +++ b/frontend/src/components/input-output/configure-ds/ConfigureDs.css @@ -36,3 +36,10 @@ color: var(--primary); cursor: pointer; } + +.config-submit-hint { + color: var(--success); + font-size: 12px; + margin-top: 10px; + text-align: center; +} diff --git a/frontend/src/components/input-output/configure-ds/ConfigureDs.jsx b/frontend/src/components/input-output/configure-ds/ConfigureDs.jsx index 5bb0d6f632..526636b92f 100644 --- a/frontend/src/components/input-output/configure-ds/ConfigureDs.jsx +++ b/frontend/src/components/input-output/configure-ds/ConfigureDs.jsx @@ -1,6 +1,6 @@ import { Info } from "lucide-react"; import PropTypes from "prop-types"; -import { createRef, useEffect, useState } from "react"; +import { createRef, useCallback, useEffect, useMemo, useState } from "react"; import { Col, Row } from "@/components/ui/shims/antd-layout"; import { Popover } from "@/components/ui/shims/antd-overlays"; @@ -13,6 +13,10 @@ import { useAlertStore } from "../../../store/alert-store"; import { useSessionStore } from "../../../store/session-store"; import { OAuthDs } from "../../oauth-ds/oauth-ds/OAuthDs.jsx"; import { CustomButton } from "../../widgets/custom-button/CustomButton.jsx"; +import { + getReasoningSchemaForModel, + materializeReasoningProperty, +} from "./openai-oauth-form-schema.js"; import "./ConfigureDs.css"; function ConfigureDs({ @@ -36,6 +40,7 @@ function ConfigureDs({ const [isTcSuccessful, setIsTcSuccessful] = useState(false); const [isTcLoading, setIsTcLoading] = useState(false); const [isSubmitApiLoading, setIsSubmitApiLoading] = useState(false); + const [oauthSpec, setOAuthSpec] = useState(null); const [cacheKey, setCacheKey] = useState(""); const [status, setStatus] = useState(""); @@ -51,13 +56,91 @@ function ConfigureDs({ } = usePostHogEvents(); const { getUrl } = useRequestUrl(); - const oauthCacheKey = `oauth-cachekey-${selectedSourceId}`; - const oauthStatusKey = `oauth-status-${selectedSourceId}`; + const oauthStateScope = editItemId || selectedSourceId; + const oauthCacheKey = `oauth-cachekey-${oauthStateScope}`; + const oauthStatusKey = `oauth-status-${oauthStateScope}`; + const oauthDeviceKey = `oauth-device-${oauthStateScope}`; + + const handleOpenAIModelSchema = useCallback( + (dynamicSchema) => { + const modelSchema = dynamicSchema?.properties?.model; + if (!Array.isArray(modelSchema?.enum) || modelSchema.enum.length === 0) { + return; + } + + setOAuthSpec(dynamicSchema); + setFormData((currentFormData) => { + const current = currentFormData || {}; + const next = { ...current }; + if (!modelSchema.enum.includes(next.model)) { + next.model = modelSchema.default || modelSchema.enum[0]; + } + + if (next.enable_reasoning) { + const reasoningSchema = getReasoningSchemaForModel( + dynamicSchema, + next.model, + ); + if ( + Array.isArray(reasoningSchema?.enum) && + reasoningSchema.enum.length > 0 && + !reasoningSchema.enum.includes(next.reasoning_effort) + ) { + next.reasoning_effort = + reasoningSchema.default || reasoningSchema.enum[0]; + } + } + return next; + }); + }, + [setFormData], + ); + + const renderSchema = useMemo( + () => + materializeReasoningProperty( + oauthSpec || spec, + formData?.model, + formData?.enable_reasoning, + ), + [formData?.enable_reasoning, formData?.model, oauthSpec, spec], + ); + + useEffect(() => { + setOAuthSpec(null); + }, [spec]); + + useEffect(() => { + if (!oauthSpec || !formData?.enable_reasoning || !formData?.model) { + return; + } + const reasoningSchema = getReasoningSchemaForModel( + oauthSpec, + formData.model, + ); + if ( + !Array.isArray(reasoningSchema?.enum) || + reasoningSchema.enum.length === 0 || + reasoningSchema.enum.includes(formData.reasoning_effort) + ) { + return; + } + setFormData((currentFormData) => ({ + ...(currentFormData || {}), + reasoning_effort: reasoningSchema.default || reasoningSchema.enum[0], + })); + }, [formData, oauthSpec, setFormData]); // Determine if this is a new or existing connector const hasOAuthCredentials = - metadata && (metadata.access_token || (metadata.provider && metadata.uid)); + metadata && + (metadata.access_token || + (metadata.provider && metadata.uid) || + metadata.oauth_authenticated); const isExistingConnector = Boolean(editItemId || hasOAuthCredentials); + const hasPersistedOAuthCredentials = Boolean( + !isConnector && isExistingConnector && hasOAuthCredentials, + ); // Determine if OAuth authentication method is selected const isOAuthMethodSelected = () => { @@ -119,11 +202,9 @@ function ConfigureDs({ }, [formData]); useEffect(() => { - if (!metadata) { - setFormData({}); - return; + if (metadata && Object.keys(metadata).length > 0) { + setFormData(metadata); } - setFormData(metadata); }, [selectedSourceId, metadata, setFormData]); // Clear OAuth state when switching to a different connector @@ -157,14 +238,6 @@ function ConfigureDs({ } }, [selectedSourceId, oAuthProvider, oauthStatusKey, oauthCacheKey]); - // Cleanup OAuth localStorage when component unmounts (modal close) - useEffect(() => { - return () => { - localStorage.removeItem(oauthCacheKey); - localStorage.removeItem(oauthStatusKey); - }; - }, [oauthCacheKey, oauthStatusKey]); - const handleTestConnection = (updatedFormData) => { // Check if there any error in form proceed to test connection only there is no error. if (formRef && !formRef.current?.validateForm()) { @@ -173,10 +246,17 @@ function ConfigureDs({ if ( oAuthProvider?.length && isOAuthMethodSelected() && - (status !== "success" || !cacheKey?.length) + !( + (status === "success" && cacheKey?.length) || + hasPersistedOAuthCredentials + ) ) { const providerName = - oAuthProvider === "google-oauth2" ? "Google" : "OAuth provider"; + oAuthProvider === "google-oauth2" + ? "Google" + : oAuthProvider === "openai-oauth" + ? "OpenAI" + : "OAuth provider"; setAlertDetails({ type: "error", content: `OAuth authentication required. Please sign in with ${providerName} first.`, @@ -211,10 +291,16 @@ function ConfigureDs({ } if (oAuthProvider?.length > 0 && isOAuthMethodSelected()) { - body["connector_metadata"] = { - ...body["connector_metadata"], - ...{ "oauth-key": cacheKey }, - }; + if (isConnector) { + body["connector_metadata"] = { + ...body["connector_metadata"], + ...{ "oauth-key": cacheKey }, + }; + } else if (cacheKey?.length) { + url = `${url}?oauth-key=${encodeURIComponent(cacheKey)}`; + } else if (editItemId && hasPersistedOAuthCredentials) { + url = `${url}?adapter-instance-id=${encodeURIComponent(editItemId)}`; + } } const requestOptions = { @@ -311,7 +397,11 @@ function ConfigureDs({ url = `${url}${editItemId}/`; } - if (oAuthProvider?.length > 0 && isOAuthMethodSelected()) { + if ( + oAuthProvider?.length > 0 && + isOAuthMethodSelected() && + cacheKey?.length + ) { const encodedCacheKey = encodeURIComponent(cacheKey); url = url + `?oauth-key=${encodedCacheKey}`; } @@ -342,6 +432,7 @@ function ConfigureDs({ if (oAuthProvider?.length > 0 && isOAuthMethodSelected()) { localStorage.removeItem(oauthCacheKey); localStorage.removeItem(oauthStatusKey); + localStorage.removeItem(oauthDeviceKey); } setOpen(false); @@ -356,11 +447,13 @@ function ConfigureDs({ const updateSession = (type) => { const adapterType = type.toLowerCase(); - const adaptersList = sessionDetails?.adapters; - if (adaptersList && !adaptersList.includes(adapterType)) { - adaptersList.push(adapterType); - const adaptersListInSession = { adapters: adaptersList }; - updateSessionDetails(adaptersListInSession); + const adaptersList = Array.isArray(sessionDetails?.adapters) + ? sessionDetails.adapters + : []; + if (!adaptersList.includes(adapterType)) { + updateSessionDetails({ + adapters: [...adaptersList, adapterType], + }); } }; @@ -389,7 +482,7 @@ function ConfigureDs({ )} )} + {isTcSuccessful && ( +
+ Connection tested successfully. Click Submit to save this adapter. +
+ )} = 0 ? modelValues[labelIndex] : model; +} + +function getReasoningSchemaForModel(schema, model) { + if (!schema || !model || !Array.isArray(schema.allOf)) { + return undefined; + } + + const selectedModel = canonicalModelValue(schema, model); + const condition = schema.allOf.find( + (candidate) => + candidate?.if?.properties?.enable_reasoning?.const === true && + candidate?.if?.properties?.model?.const === selectedModel, + ); + return condition?.then?.properties?.reasoning_effort; +} + +/** + * RJSF can retain a resolved allOf branch when an account schema replaces the + * pre-auth schema while the checkbox is already enabled. Put the selected + * account's live field in the visible properties as well; the allOf branch + * remains in place for validation and model-specific requiredness. + */ +function materializeReasoningProperty(schema, model, enabled) { + if ( + !schema || + schema["x-openai-oauth-model-source"] !== OPENAI_OAUTH_MODEL_SOURCE || + !enabled + ) { + return schema; + } + + const reasoningSchema = getReasoningSchemaForModel(schema, model); + if ( + !reasoningSchema || + !Array.isArray(reasoningSchema.enum) || + reasoningSchema.enum.length === 0 + ) { + return schema; + } + + const sourceProperties = schema.properties || {}; + const properties = {}; + let inserted = false; + Object.entries(sourceProperties).forEach(([name, property]) => { + properties[name] = property; + if (name === "enable_reasoning") { + properties.reasoning_effort = reasoningSchema; + inserted = true; + } + }); + if (!inserted) { + properties.reasoning_effort = reasoningSchema; + } + + return { ...schema, properties }; +} + +export { + getReasoningSchemaForModel, + materializeReasoningProperty, + OPENAI_OAUTH_MODEL_SOURCE, +}; diff --git a/frontend/src/components/input-output/configure-ds/openai-oauth-form-schema.test.js b/frontend/src/components/input-output/configure-ds/openai-oauth-form-schema.test.js new file mode 100644 index 0000000000..0683aca979 --- /dev/null +++ b/frontend/src/components/input-output/configure-ds/openai-oauth-form-schema.test.js @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; + +import { + getReasoningSchemaForModel, + materializeReasoningProperty, +} from "./openai-oauth-form-schema.js"; + +const schema = { + "x-openai-oauth-model-source": "chatgpt-account", + properties: { + model: { + enum: ["gpt-5.6-luna", "gpt-5.6-sol"], + enumNames: ["GPT-5.6-Luna", "GPT-5.6-Sol"], + }, + enable_reasoning: { type: "boolean" }, + }, + allOf: [ + { + if: { + properties: { + enable_reasoning: { const: true }, + model: { const: "gpt-5.6-luna" }, + }, + }, + then: { + properties: { + reasoning_effort: { + type: "string", + enum: ["low", "medium", "high", "xhigh", "max"], + }, + }, + }, + }, + ], +}; + +describe("OpenAI OAuth form schema", () => { + it("resolves reasoning by live model slug or display label", () => { + expect(getReasoningSchemaForModel(schema, "gpt-5.6-luna").enum).toEqual([ + "low", + "medium", + "high", + "xhigh", + "max", + ]); + expect(getReasoningSchemaForModel(schema, "GPT-5.6-Luna").enum).toEqual([ + "low", + "medium", + "high", + "xhigh", + "max", + ]); + }); + + it("materializes the selected account options after OAuth schema load", () => { + const rendered = materializeReasoningProperty(schema, "gpt-5.6-luna", true); + + expect(Object.keys(rendered.properties)).toEqual([ + "model", + "enable_reasoning", + "reasoning_effort", + ]); + expect(rendered.properties.reasoning_effort.enum).toEqual([ + "low", + "medium", + "high", + "xhigh", + "max", + ]); + }); + + it("does not add the field while reasoning is disabled", () => { + expect(materializeReasoningProperty(schema, "gpt-5.6-luna", false)).toBe( + schema, + ); + }); +}); diff --git a/frontend/src/components/oauth-ds/oauth-ds/OAuthDs.jsx b/frontend/src/components/oauth-ds/oauth-ds/OAuthDs.jsx index cc40c50bbf..22a750a86c 100644 --- a/frontend/src/components/oauth-ds/oauth-ds/OAuthDs.jsx +++ b/frontend/src/components/oauth-ds/oauth-ds/OAuthDs.jsx @@ -1,5 +1,6 @@ import PropTypes from "prop-types"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import Cookies from "js-cookie"; import { Typography } from "@/components/ui/shims/antd-typography"; import { getBaseUrl, O_AUTH_PROVIDERS } from "../../../helpers/GetStaticData"; @@ -8,6 +9,7 @@ import { useExceptionHandler } from "../../../hooks/useExceptionHandler.jsx"; import { useAlertStore } from "../../../store/alert-store"; import GoogleOAuthButton from "../google/GoogleOAuthButton.jsx"; import MicrosoftOAuthButton from "../microsoft/MicrosoftOAuthButton.jsx"; +import OpenAIOAuthButton from "../openai/OpenAIOAuthButton.jsx"; function OAuthDs({ oAuthProvider, @@ -15,18 +17,32 @@ function OAuthDs({ setStatus, selectedSourceId, isExistingConnector, + hasOAuthCredentials = false, + oauthAccountLabel, + adapterInstanceId, + onModelsLoaded, disabled = false, }) { const axiosPrivate = useAxiosPrivate(); const { setAlertDetails } = useAlertStore(); const handleException = useExceptionHandler(); - // Simple OAuth storage keys per connector - const oauthCacheKey = `oauth-cachekey-${selectedSourceId}`; - const oauthStatusKey = `oauth-status-${selectedSourceId}`; + // Keep transient OAuth hand-off state isolated per saved adapter. New + // adapters use the provider id until they are saved; saved adapters use + // their instance id so multiple ChatGPT accounts never share browser state. + const oauthStateScope = adapterInstanceId || selectedSourceId; + const oauthCacheKey = `oauth-cachekey-${oauthStateScope}`; + const oauthStatusKey = `oauth-status-${oauthStateScope}`; + const oauthDeviceKey = `oauth-device-${oauthStateScope}`; // Determine button text based on connector state and provider const getButtonText = () => { + if ( + oAuthProvider === O_AUTH_PROVIDERS.OPENAI && + hasOAuthCredentials + ) { + return "Authenticated"; + } if (isExistingConnector) { return "Reauthenticate"; } @@ -36,15 +52,83 @@ function OAuthDs({ if (oAuthProvider === O_AUTH_PROVIDERS.GOOGLE) { return "Authenticate with Google"; } + if (oAuthProvider === O_AUTH_PROVIDERS.OPENAI) { + return "Sign in with OpenAI"; + } return "Authenticate"; }; const buttonText = getButtonText(); const [oauthStatus, setOAuthStatus] = useState(() => { - // Initialize from connector-specific status - return localStorage.getItem(oauthStatusKey); + // A durable OAuth adapter is authenticated even when there is no + // browser-side hand-off session left to restore. + return hasOAuthCredentials + ? "success" + : localStorage.getItem(oauthStatusKey); + }); + const [loginCacheKey, setLoginCacheKey] = useState(() => + localStorage.getItem(oauthCacheKey), + ); + const [deviceLogin, setDeviceLogin] = useState(() => { + try { + return JSON.parse(localStorage.getItem(oauthDeviceKey)); + } catch { + return null; + } }); + const [activeLoginCacheKey, setActiveLoginCacheKey] = useState(null); + const [isStarting, setIsStarting] = useState(false); + const [isRestoring, setIsRestoring] = useState(false); + const restoreScopeRef = useRef(null); + + const updateOAuthStatus = useCallback((newStatus) => { + setOAuthStatus(newStatus); + setStatus(newStatus); + localStorage.setItem(oauthStatusKey, newStatus); + }, [oauthStatusKey, setStatus]); + + const clearOAuthHandoff = useCallback(() => { + setLoginCacheKey(null); + setActiveLoginCacheKey(null); + setDeviceLogin(null); + setCacheKey(""); + setStatus(""); + setOAuthStatus(""); + localStorage.removeItem(oauthCacheKey); + localStorage.removeItem(oauthStatusKey); + localStorage.removeItem(oauthDeviceKey); + }, [ + oauthCacheKey, + oauthDeviceKey, + oauthStatusKey, + setCacheKey, + setStatus, + ]); + + const loadOpenAIModelSchema = useCallback( + async (oauthKey = "") => { + const params = new URLSearchParams(); + if (oauthKey) { + params.set("oauth-key", oauthKey); + } else if (adapterInstanceId) { + params.set("adapter-instance-id", adapterInstanceId); + } else { + return; + } + + const response = await axiosPrivate({ + method: "GET", + url: `/api/v1/oauth/openai/models/?${params.toString()}`, + }); + const dynamicSchema = response?.data?.json_schema; + if (!dynamicSchema) { + throw new Error("OpenAI OAuth returned no model schema"); + } + onModelsLoaded?.(dynamicSchema); + }, + [adapterInstanceId, axiosPrivate, onModelsLoaded], + ); useEffect(() => { const handleStorageChange = () => { @@ -60,25 +144,342 @@ function OAuthDs({ // Load persisted cache key if available const persistedCacheKey = localStorage.getItem(oauthCacheKey); + setLoginCacheKey(persistedCacheKey || null); if (persistedCacheKey) { setCacheKey(persistedCacheKey); + } else { + setCacheKey(""); } // Set initial status from connector-specific status const connectorStatus = localStorage.getItem(oauthStatusKey); - if (connectorStatus) { - setStatus(connectorStatus); - setOAuthStatus(connectorStatus); + setStatus(connectorStatus || ""); + setOAuthStatus(connectorStatus || ""); + + const persistedDeviceLogin = localStorage.getItem(oauthDeviceKey); + if (persistedDeviceLogin) { + try { + setDeviceLogin(JSON.parse(persistedDeviceLogin)); + } catch { + localStorage.removeItem(oauthDeviceKey); + } + } else { + setDeviceLogin(null); } return () => { window.removeEventListener("storage", handleStorageChange); // Don't clear localStorage on unmount to persist across tab switches }; - }, [selectedSourceId, oauthCacheKey, oauthStatusKey, setCacheKey, setStatus]); + }, [ + oauthCacheKey, + oauthDeviceKey, + oauthStatusKey, + selectedSourceId, + setCacheKey, + setStatus, + ]); + + useEffect(() => { + if ( + oAuthProvider !== O_AUTH_PROVIDERS.OPENAI || + adapterInstanceId || + hasOAuthCredentials || + oauthStatus === "pending" || + activeLoginCacheKey || + restoreScopeRef.current === oauthStateScope + ) { + return undefined; + } + + restoreScopeRef.current = oauthStateScope; + let isActive = true; + setIsRestoring(true); + + axiosPrivate({ + method: "GET", + url: "/api/v1/oauth/openai/restore/", + }) + .then((response) => { + if (!isActive) { + return; + } + const result = response?.data || {}; + if (result.status !== "success" || !result.cache_key) { + clearOAuthHandoff(); + return; + } + + const restoredCacheKey = result.cache_key; + const restoredLogin = { + ...result, + status: "success", + }; + setLoginCacheKey(restoredCacheKey); + setActiveLoginCacheKey(restoredCacheKey); + setCacheKey(restoredCacheKey); + setDeviceLogin(restoredLogin); + localStorage.setItem(oauthCacheKey, restoredCacheKey); + localStorage.setItem(oauthDeviceKey, JSON.stringify(restoredLogin)); + updateOAuthStatus("success"); + }) + .catch((err) => { + if (!isActive) { + return; + } + clearOAuthHandoff(); + setAlertDetails( + handleException( + err, + "Could not restore your saved OpenAI authentication", + ), + ); + }) + .finally(() => { + if (isActive) { + setIsRestoring(false); + } + }); + + return () => { + isActive = false; + }; + }, [ + activeLoginCacheKey, + adapterInstanceId, + axiosPrivate, + clearOAuthHandoff, + handleException, + hasOAuthCredentials, + oauthCacheKey, + oauthDeviceKey, + oauthStatus, + oAuthProvider, + oauthStateScope, + setAlertDetails, + setCacheKey, + updateOAuthStatus, + ]); + + useEffect(() => { + // Do not let an old pending browser state mask credentials already saved + // on an existing adapter. A live reauthentication session is allowed to + // remain pending and can still replace those credentials after testing. + if ( + oAuthProvider !== O_AUTH_PROVIDERS.OPENAI || + !hasOAuthCredentials || + loginCacheKey || + activeLoginCacheKey || + oauthStatus === "success" + ) { + return; + } + setOAuthStatus("success"); + setStatus("success"); + }, [ + activeLoginCacheKey, + hasOAuthCredentials, + loginCacheKey, + oAuthProvider, + oauthStatus, + setStatus, + ]); + + useEffect(() => { + if (oAuthProvider !== O_AUTH_PROVIDERS.OPENAI || !onModelsLoaded) { + return undefined; + } + + // A new adapter may have a stale success/cache key in localStorage. Wait + // for the server-side restore request before trying that disposable key; + // otherwise an expired model request can cancel the restore race. + if ( + !adapterInstanceId && + !activeLoginCacheKey && + (isRestoring || restoreScopeRef.current === oauthStateScope) + ) { + return undefined; + } + + // Existing adapters use their server-side encrypted credentials. A new + // OAuth login takes precedence only after that login has completed, so a + // stale localStorage cache key cannot select another account by accident. + const sessionKey = + oauthStatus === "success" + ? activeLoginCacheKey || (!adapterInstanceId ? loginCacheKey : "") + : ""; + const source = sessionKey || (adapterInstanceId ? adapterInstanceId : ""); + if (!source) { + return undefined; + } + + let isActive = true; + loadOpenAIModelSchema(sessionKey) + .catch((err) => { + if (!isActive) { + return; + } + const message = + err?.response?.data?.message || + "Could not load models available to this OpenAI account"; + + // The hand-off key is intentionally short-lived and is also removed + // after credentials are saved. Do not keep treating an expired key + // from localStorage as an authenticated new login. Saved adapters can + // fall back to their durable credentials on the next effect pass; + // unsaved adapters are returned to the sign-in state. + const isExpiredLoginSession = + sessionKey && + typeof message === "string" && + message.toLowerCase().includes("openai oauth login session"); + if (isExpiredLoginSession) { + clearOAuthHandoff(); + return; + } + setAlertDetails(handleException(err, message)); + }); + + return () => { + isActive = false; + }; + }, [ + activeLoginCacheKey, + adapterInstanceId, + handleException, + clearOAuthHandoff, + isRestoring, + loadOpenAIModelSchema, + loginCacheKey, + oauthStatus, + oAuthProvider, + onModelsLoaded, + oauthStateScope, + setAlertDetails, + setCacheKey, + setStatus, + ]); + + useEffect(() => { + if ( + oAuthProvider !== O_AUTH_PROVIDERS.OPENAI || + oauthStatus !== "pending" || + !loginCacheKey + ) { + return undefined; + } + + let isActive = true; + const pollLogin = async () => { + try { + const response = await axiosPrivate({ + method: "GET", + url: `/api/v1/oauth/openai/poll/?oauth-key=${encodeURIComponent(loginCacheKey)}`, + }); + if (!isActive) { + return; + } + const result = response?.data || {}; + if (result.status === "success") { + setDeviceLogin((current) => ({ + ...(current || {}), + account_label: result.account_label, + })); + updateOAuthStatus("success"); + } + } catch (err) { + if (!isActive) { + return; + } + const message = + err?.response?.data?.message || "OpenAI authentication failed"; + const isExpiredLoginSession = + typeof message === "string" && + message.toLowerCase().includes("openai oauth login session"); + if (isExpiredLoginSession) { + // A Redis hand-off is disposable. Clear the stale browser key so a + // durable server-side account can be restored on the next render. + clearOAuthHandoff(); + return; + } + updateOAuthStatus("error"); + setAlertDetails(handleException(err, message)); + } + }; + + const initialPoll = setTimeout(pollLogin, 1000); + const pollInterval = setInterval(pollLogin, 5000); + return () => { + isActive = false; + clearTimeout(initialPoll); + clearInterval(pollInterval); + }; + }, [ + axiosPrivate, + clearOAuthHandoff, + handleException, + loginCacheKey, + oauthStatus, + oAuthProvider, + setAlertDetails, + updateOAuthStatus, + ]); const handleOAuth = async () => { + let loginWindow; try { + if (oAuthProvider === O_AUTH_PROVIDERS.OPENAI) { + if (oauthStatus === "pending" && deviceLogin?.verification_url) { + window.open( + deviceLogin.verification_url, + "_blank", + "toolbar=yes,scrollbars=yes,resizable=yes,top=200,left=500,width=500,height=600", + ); + return; + } + + setIsStarting(true); + // Open a user-initiated window before awaiting the API call. Browsers + // may block a window opened only after the device-code request returns; + // the visible link below remains the fallback when that happens. + loginWindow = window.open( + "about:blank", + "_blank", + "toolbar=yes,scrollbars=yes,resizable=yes,top=200,left=500,width=500,height=600", + ); + const response = await axiosPrivate({ + method: "POST", + url: "/api/v1/oauth/openai/start/", + headers: { + "X-CSRFToken": Cookies.get("csrftoken"), + }, + }); + const loginDetails = response?.data || {}; + const newCacheKey = loginDetails.cache_key; + if (!newCacheKey) { + throw new Error("OpenAI OAuth did not return a login session"); + } + setLoginCacheKey(newCacheKey); + setActiveLoginCacheKey(newCacheKey); + setCacheKey(newCacheKey); + localStorage.setItem(oauthCacheKey, newCacheKey); + setDeviceLogin(loginDetails); + localStorage.setItem( + oauthDeviceKey, + JSON.stringify(loginDetails), + ); + updateOAuthStatus("pending"); + if (loginWindow && loginDetails.verification_url) { + loginWindow.location.href = loginDetails.verification_url; + } else if (loginDetails.verification_url) { + window.open( + loginDetails.verification_url, + "_blank", + "toolbar=yes,scrollbars=yes,resizable=yes,top=200,left=500,width=500,height=600", + ); + } + return; + } + // Store connector context in sessionStorage for OAuth callback (survives window.open) sessionStorage.setItem("oauth-current-connector", selectedSourceId); @@ -106,7 +507,18 @@ function OAuthDs({ "toolbar=yes,scrollbars=yes,resizable=yes,top=200,left=500,width=500,height=600", ); } catch (err) { + if (loginWindow && !loginWindow.closed) { + loginWindow.close(); + } + if (oAuthProvider === O_AUTH_PROVIDERS.OPENAI) { + setIsStarting(false); + updateOAuthStatus("error"); + } setAlertDetails(handleException(err)); + } finally { + if (oAuthProvider === O_AUTH_PROVIDERS.OPENAI) { + setIsStarting(false); + } } }; @@ -136,6 +548,22 @@ function OAuthDs({ ); } + if (O_AUTH_PROVIDERS.OPENAI === oAuthProvider) { + return ( + + ); + } + return Provider not available.; } @@ -145,6 +573,10 @@ OAuthDs.propTypes = { setStatus: PropTypes.func, selectedSourceId: PropTypes.string.isRequired, isExistingConnector: PropTypes.bool, + hasOAuthCredentials: PropTypes.bool, + oauthAccountLabel: PropTypes.string, + adapterInstanceId: PropTypes.string, + onModelsLoaded: PropTypes.func, disabled: PropTypes.bool, }; diff --git a/frontend/src/components/oauth-ds/openai/OpenAIOAuthButton.css b/frontend/src/components/oauth-ds/openai/OpenAIOAuthButton.css new file mode 100644 index 0000000000..2aa3289628 --- /dev/null +++ b/frontend/src/components/oauth-ds/openai/OpenAIOAuthButton.css @@ -0,0 +1,17 @@ +.openai-oauth-layout { + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 20px; +} + +.openai-oauth-instructions { + margin: 0; + font-size: 12px; + line-height: 1.5; +} + +.openai-oauth-account { + display: block; + font-size: 12px; +} diff --git a/frontend/src/components/oauth-ds/openai/OpenAIOAuthButton.jsx b/frontend/src/components/oauth-ds/openai/OpenAIOAuthButton.jsx new file mode 100644 index 0000000000..8b731bfefe --- /dev/null +++ b/frontend/src/components/oauth-ds/openai/OpenAIOAuthButton.jsx @@ -0,0 +1,72 @@ +import PropTypes from "prop-types"; +import { Button } from "@/components/ui/shims/antd-button"; +import { Typography } from "@/components/ui/shims/antd-typography"; + +import "./OpenAIOAuthButton.css"; + +const OpenAIOAuthButton = ({ + handleOAuth, + status, + buttonText = "Sign in with OpenAI", + disabled = false, + verificationUrl, + userCode, + accountLabel, + isStarting = false, + isRestoring = false, +}) => { + const isPending = status === "pending"; + const buttonLabel = isRestoring + ? "Restoring OpenAI authentication" + : isStarting + ? "Starting OpenAI sign-in" + : status === "success" + ? "Authenticated" + : isPending && verificationUrl + ? "Open OpenAI device login" + : isPending + ? "Retry OpenAI sign-in" + : buttonText; + + return ( +
+ + {status === "pending" && verificationUrl && userCode && ( + + Waiting for authorization. {" "} + Open{" "} + + OpenAI device login + {" "} + and enter code {userCode}. + + )} + {accountLabel && ( + + {accountLabel} + + )} +
+ ); +}; + +OpenAIOAuthButton.propTypes = { + handleOAuth: PropTypes.func.isRequired, + status: PropTypes.string, + buttonText: PropTypes.string, + disabled: PropTypes.bool, + verificationUrl: PropTypes.string, + userCode: PropTypes.string, + accountLabel: PropTypes.string, + isStarting: PropTypes.bool, + isRestoring: PropTypes.bool, +}; + +export default OpenAIOAuthButton; diff --git a/frontend/src/components/onboard/OnBoard.jsx b/frontend/src/components/onboard/OnBoard.jsx index 5f89757db7..00b81b236c 100644 --- a/frontend/src/components/onboard/OnBoard.jsx +++ b/frontend/src/components/onboard/OnBoard.jsx @@ -18,6 +18,11 @@ import "./onBoard.css"; const { Content } = Layout; +const normalizeAdapterTypes = (adapterTypes) => + (Array.isArray(adapterTypes) ? adapterTypes : []) + .filter((adapterType) => typeof adapterType === "string") + .map((adapterType) => adapterType.toLowerCase()); + function OnBoard() { const navigate = useNavigate(); const { sessionDetails } = useSessionStore(); @@ -26,12 +31,19 @@ function OnBoard() { const [editItemId, setEditItemId] = useState(null); const [type, setType] = useState(null); const homePageUrl = `/${orgName}/${homePagePath}`; - const [adaptersList, setAdaptersList] = useState(adapters || []); + const [adaptersList, setAdaptersList] = useState(() => + normalizeAdapterTypes(adapters), + ); + + useEffect(() => { + setAdaptersList(normalizeAdapterTypes(adapters)); + }, [adapters]); + useEffect(() => { if (onboardCompleted(adaptersList)) { navigate(homePageUrl); } - }, [adaptersList]); + }, [adaptersList, homePageUrl, navigate]); const steps = [ { @@ -73,9 +85,17 @@ function OnBoard() { setOpenAddSourcesModal(true); }; - const addNewItem = (row, isEdit) => { - const newAdapter = row?.adapter_type.toLowerCase(); - setAdaptersList([...adaptersList, newAdapter]); + const addNewItem = (row) => { + const newAdapter = row?.adapter_type?.toLowerCase(); + if (!newAdapter) { + return; + } + + setAdaptersList((currentAdapters) => + currentAdapters.includes(newAdapter) + ? currentAdapters + : [...currentAdapters, newAdapter], + ); }; return ( @@ -114,17 +134,22 @@ function OnBoard() { {adaptersList?.includes(step.type) ? ( -
+
Configured
) : ( - +
+ + Not configured + + +
)} diff --git a/frontend/src/components/onboard/onBoard.css b/frontend/src/components/onboard/onBoard.css index 57cd3b5651..79407cb47b 100644 --- a/frontend/src/components/onboard/onBoard.css +++ b/frontend/src/components/onboard/onBoard.css @@ -132,6 +132,29 @@ vertical-align: middle; } +.configured-status, +.unconfigured-status { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; +} + +.configured-status { + color: var(--success); + font-weight: 600; +} + +.configured-status .configured-text { + margin-left: 0; +} + +.not-configured-text { + color: #6b7280; + font-size: 0.85rem; + font-weight: 600; +} + @media screen and (max-height: 900px) { .ant-space .ant-space-item .ant-card { padding: 6px; diff --git a/frontend/src/helpers/GetStaticData.js b/frontend/src/helpers/GetStaticData.js index 9e067896c0..35abc833e3 100644 --- a/frontend/src/helpers/GetStaticData.js +++ b/frontend/src/helpers/GetStaticData.js @@ -85,6 +85,7 @@ const formatBytes = (bytes, decimals = 1) => { const O_AUTH_PROVIDERS = { GOOGLE: "google-oauth2", MICROSOFT: "azuread-tenant-oauth2", + OPENAI: "openai-oauth", }; const CONNECTOR_TYPE_MAP = { diff --git a/frontend/src/hooks/useExceptionHandler.jsx b/frontend/src/hooks/useExceptionHandler.jsx index eef7dbfc7b..227573317d 100644 --- a/frontend/src/hooks/useExceptionHandler.jsx +++ b/frontend/src/hooks/useExceptionHandler.jsx @@ -1,3 +1,4 @@ +import { useCallback } from "react"; import { useNavigate } from "react-router-dom"; import { getRequestIdFromError } from "../helpers/requestId"; @@ -5,7 +6,7 @@ import { getRequestIdFromError } from "../helpers/requestId"; const useExceptionHandler = () => { const navigate = useNavigate(); - const handleException = ( + const handleException = useCallback(( err, errMessage = "Something went wrong", setBackendErrors = undefined, @@ -99,7 +100,7 @@ const useExceptionHandler = () => { } else { return alert(errMessage); } - }; + }, [navigate]); return handleException; }; diff --git a/frontend/src/layouts/rjsf-form-layout/RjsfFormLayout.jsx b/frontend/src/layouts/rjsf-form-layout/RjsfFormLayout.jsx index 3855513500..548cf5a08b 100644 --- a/frontend/src/layouts/rjsf-form-layout/RjsfFormLayout.jsx +++ b/frontend/src/layouts/rjsf-form-layout/RjsfFormLayout.jsx @@ -276,6 +276,14 @@ function RjsfFormLayout({ }; }, [formSchema]); + // An OAuth schema arrives after the form has already mounted. Include the + // materialized reasoning field in the key so RJSF does not retain the + // pre-auth resolved allOf branch when reasoning was already enabled. + const formInstanceKey = + schema?.["x-openai-oauth-model-source"] === "chatgpt-account" + ? `openai-oauth-${Boolean(schema?.properties?.reasoning_effort)}` + : undefined; + return ( <> {isLoading ? ( @@ -289,6 +297,7 @@ function RjsfFormLayout({ /> )}
Any: f: Fernet = Fernet(Env.ENCRYPTION_KEY.encode("utf-8")) - data_dict["adapter_metadata"] = json.loads( + adapter_metadata = json.loads( f.decrypt(bytes(data_dict.pop("adapter_metadata_b")).decode("utf-8")) ) + if is_openai_oauth_adapter(data_dict.get("adapter_id")): + try: + refreshed_metadata = refresh_openai_oauth_metadata(adapter_metadata) + except OpenAIOAuthRefreshError as exc: + app.logger.warning( + "OpenAI OAuth credentials could not be refreshed for adapter " + "%s: %s", + adapter_instance_id, + exc, + ) + raise APIError( + message=( + "OpenAI OAuth credentials have expired. Reauthenticate " + "this adapter in the platform settings." + ), + code=401, + ) from exc + + if refreshed_metadata != adapter_metadata: + refreshed_ciphertext = f.encrypt( + json.dumps(refreshed_metadata).encode("utf-8") + ) + try: + update_query = ( + f'UPDATE "{Env.DB_SCHEMA}".{DBTable.ADAPTER_INSTANCE} ' + "SET adapter_metadata_b=%s " + "WHERE id=%s AND organization_id=%s" + ) + with safe_cursor( + update_query, + (refreshed_ciphertext, adapter_instance_id, organization_uid), + ): + pass + except Exception: + # The current workflow can use the refreshed copy. A later + # request may refresh again if the durable write lost a race. + app.logger.warning( + "Could not persist refreshed OpenAI OAuth credentials for " + "adapter %s", + adapter_instance_id, + exc_info=True, + ) + adapter_metadata = refreshed_metadata + + data_dict["adapter_metadata"] = adapter_metadata + return jsonify(data_dict) except InvalidToken: msg = ( diff --git a/unstract/sdk1/src/unstract/sdk1/adapters/adapterkit.py b/unstract/sdk1/src/unstract/sdk1/adapters/adapterkit.py index 330ad2db92..daf88f6681 100644 --- a/unstract/sdk1/src/unstract/sdk1/adapters/adapterkit.py +++ b/unstract/sdk1/src/unstract/sdk1/adapters/adapterkit.py @@ -5,6 +5,7 @@ from typing import Any from singleton_decorator import singleton + from unstract.sdk1.adapters import AdapterDict from unstract.sdk1.adapters.base import Adapter from unstract.sdk1.adapters.constants import Common @@ -71,6 +72,8 @@ def get_adapters_list(self) -> list[dict[str, "Any"]]: json_schema = m.get_json_schema() desc = m.get_description() icon = m.get_icon() + get_auth_metadata = getattr(m, "get_auth_metadata", None) + auth_metadata = get_auth_metadata() if get_auth_metadata else {} adapters.append( { "id": _id, @@ -81,6 +84,7 @@ def get_adapters_list(self) -> list[dict[str, "Any"]]: "adapter_type": adapter_type, "json_schema": json_schema, "doc_url": m.get_doc_url(), + **auth_metadata, } ) return adapters diff --git a/unstract/sdk1/src/unstract/sdk1/adapters/base1.py b/unstract/sdk1/src/unstract/sdk1/adapters/base1.py index 716587a23e..d2174ef16b 100644 --- a/unstract/sdk1/src/unstract/sdk1/adapters/base1.py +++ b/unstract/sdk1/src/unstract/sdk1/adapters/base1.py @@ -12,6 +12,7 @@ from typing import Any from pydantic import BaseModel, Field, model_validator + from unstract.sdk1.adapters.constants import AdapterDocs, Common from unstract.sdk1.adapters.enums import AdapterTypes @@ -255,6 +256,11 @@ def get_icon() -> str: def get_doc_url(cls) -> str: return AdapterDocs.type_index_url(cls.get_adapter_type()) + @classmethod + def get_auth_metadata(cls) -> dict[str, "Any"]: + """Return optional authentication metadata for the UI/API catalog.""" + return {} + @classmethod def get_json_schema(cls) -> str: schema_path = ( diff --git a/unstract/sdk1/src/unstract/sdk1/adapters/llm1/__init__.py b/unstract/sdk1/src/unstract/sdk1/adapters/llm1/__init__.py index a3a03c7da3..f3424265f1 100644 --- a/unstract/sdk1/src/unstract/sdk1/adapters/llm1/__init__.py +++ b/unstract/sdk1/src/unstract/sdk1/adapters/llm1/__init__.py @@ -11,6 +11,7 @@ from unstract.sdk1.adapters.llm1.ollama import OllamaLLMAdapter from unstract.sdk1.adapters.llm1.openai import OpenAILLMAdapter from unstract.sdk1.adapters.llm1.openai_compatible import OpenAICompatibleLLMAdapter +from unstract.sdk1.adapters.llm1.openai_oauth import OpenAIOAuthLLMAdapter from unstract.sdk1.adapters.llm1.openrouter import OpenRouterLLMAdapter from unstract.sdk1.adapters.llm1.vertexai import VertexAILLMAdapter @@ -29,6 +30,7 @@ "OllamaLLMAdapter", "OpenAILLMAdapter", "OpenAICompatibleLLMAdapter", + "OpenAIOAuthLLMAdapter", "OpenRouterLLMAdapter", "VertexAILLMAdapter", ] diff --git a/unstract/sdk1/src/unstract/sdk1/adapters/llm1/openai_oauth.py b/unstract/sdk1/src/unstract/sdk1/adapters/llm1/openai_oauth.py new file mode 100644 index 0000000000..c9aea521f6 --- /dev/null +++ b/unstract/sdk1/src/unstract/sdk1/adapters/llm1/openai_oauth.py @@ -0,0 +1,106 @@ +"""OpenAI ChatGPT OAuth LLM adapter. + +This adapter deliberately has a separate id and provider from the API-key +OpenAI adapter. OAuth credentials are injected by the authenticated web/API +flow and are never part of the configuration form. +""" + +from typing import Any + +from unstract.sdk1.adapters.base1 import BaseAdapter, BaseChatCompletionParameters +from unstract.sdk1.adapters.enums import AdapterTypes +from unstract.sdk1.auth.openai_oauth import ( + OPENAI_OAUTH_CHATGPT_API_BASE, + OPENAI_OAUTH_PROVIDER, +) + + +class OpenAIOAuthLLMParameters(BaseChatCompletionParameters): + """Validated per-adapter credentials for the ChatGPT Responses API.""" + + oauth_access_token: str + oauth_refresh_token: str + oauth_id_token: str | None = None + oauth_account_id: str + oauth_account_email: str | None = None + oauth_expires_at: float | int | None = None + # This is fixed by the adapter. It is not exposed in the JSON schema. + api_base: str = OPENAI_OAUTH_CHATGPT_API_BASE + reasoning_effort: str | None = None + + @staticmethod + def validate(adapter_metadata: dict[str, Any]) -> dict[str, Any]: + metadata = dict(adapter_metadata) + model = str(metadata.get("model", "")).strip() + for prefix in ("openai/", "chatgpt/"): + if model.startswith(prefix): + model = model[len(prefix) :] + break + for field in ("oauth_access_token", "oauth_refresh_token", "oauth_account_id"): + if not isinstance(metadata.get(field), str) or not metadata[field].strip(): + raise ValueError(f"Missing required OpenAI OAuth field: {field}") + if not model: + raise ValueError("Missing required OpenAI OAuth field: model") + metadata["model"] = model + metadata["api_base"] = OPENAI_OAUTH_CHATGPT_API_BASE + + validated = OpenAIOAuthLLMParameters(**metadata).model_dump() + # The LLM wrapper uses this for usage/cost bookkeeping; it is not sent + # as a provider parameter. + validated["cost_model"] = model + return validated + + @staticmethod + def validate_model(adapter_metadata: dict[str, Any]) -> str: + model = str(adapter_metadata.get("model", "")).strip() + return model.removeprefix("openai/").removeprefix("chatgpt/") + + +class OpenAIOAuthLLMAdapter(OpenAIOAuthLLMParameters, BaseAdapter): + """OpenAI LLM backed by a user-authorized ChatGPT account.""" + + @staticmethod + def get_id() -> str: + return "openai-oauth|a5ce9b7d-5f8a-4c6d-8a65-6e1f626b2b6e" + + @staticmethod + def get_metadata() -> dict[str, Any]: + return { + "name": "OpenAI (OAuth)", + "version": "1.0.0", + "adapter": OpenAIOAuthLLMAdapter, + "description": "OpenAI LLM adapter authenticated with ChatGPT OAuth", + "is_active": True, + } + + @classmethod + def get_auth_metadata(cls) -> dict[str, Any]: + return { + "oauth": True, + "oauth_provider": "openai", + "python_social_auth_backend": OPENAI_OAUTH_PROVIDER, + } + + @staticmethod + def get_name() -> str: + return "OpenAI (OAuth)" + + @staticmethod + def get_description() -> str: + return "OpenAI LLM adapter authenticated with ChatGPT OAuth" + + @staticmethod + def get_provider() -> str: + return OPENAI_OAUTH_PROVIDER + + @staticmethod + def get_icon() -> str: + return "/icons/adapter-icons/OpenAI.png" + + @staticmethod + def get_doc_url() -> str: + return "https://developers.openai.com/codex/auth/" + + @staticmethod + def get_adapter_type() -> AdapterTypes: + return AdapterTypes.LLM diff --git a/unstract/sdk1/src/unstract/sdk1/adapters/llm1/static/openai-oauth.json b/unstract/sdk1/src/unstract/sdk1/adapters/llm1/static/openai-oauth.json new file mode 100644 index 0000000000..c8938bccf6 --- /dev/null +++ b/unstract/sdk1/src/unstract/sdk1/adapters/llm1/static/openai-oauth.json @@ -0,0 +1,90 @@ +{ + "title": "OpenAI OAuth LLM", + "type": "object", + "required": [ + "adapter_name", + "model" + ], + "properties": { + "adapter_name": { + "type": "string", + "title": "Name", + "default": "", + "description": "Provide a unique name for this adapter instance. Example: openai-chatgpt-personal" + }, + "model": { + "type": "string", + "title": "Model", + "enum": [], + "enumNames": [], + "description": "Sign in with OpenAI to load the models available to this ChatGPT/Codex account." + }, + "max_tokens": { + "type": "number", + "minimum": 1, + "multipleOf": 1, + "title": "Maximum Output Tokens", + "default": 4096, + "description": "Compatibility setting retained by Unstract; the ChatGPT/Codex subscription endpoint uses its provider-managed output budget." + }, + "max_retries": { + "type": "number", + "minimum": 0, + "multipleOf": 1, + "title": "Max Retries", + "default": 5, + "description": "The maximum number of times to retry a transient request." + }, + "timeout": { + "type": "number", + "minimum": 0, + "multipleOf": 1, + "title": "Timeout", + "default": 900, + "description": "Timeout in seconds." + }, + "enable_reasoning": { + "type": "boolean", + "title": "Enable Reasoning", + "default": false, + "description": "Allow the model to apply extra reasoning for complex tasks." + } + }, + "allOf": [ + { + "if": { + "properties": { + "enable_reasoning": { + "const": true + } + } + }, + "then": { + "properties": { + "reasoning_effort": { + "type": "string", + "title": "Reasoning Effort", + "enum": [], + "enumNames": [], + "description": "Sign in with OpenAI to load reasoning levels supported by the selected model." + } + }, + "required": [ + "reasoning_effort" + ] + } + }, + { + "if": { + "properties": { + "enable_reasoning": { + "const": false + } + } + }, + "then": { + "properties": {} + } + } + ] +} diff --git a/unstract/sdk1/src/unstract/sdk1/auth/__init__.py b/unstract/sdk1/src/unstract/sdk1/auth/__init__.py new file mode 100644 index 0000000000..5f8177fa52 --- /dev/null +++ b/unstract/sdk1/src/unstract/sdk1/auth/__init__.py @@ -0,0 +1,35 @@ +"""Authentication helpers shared by SDK adapters and runtime services.""" + +from unstract.sdk1.auth.openai_oauth import ( + OPENAI_OAUTH_ADAPTER_PREFIX, + OPENAI_OAUTH_CHATGPT_API_BASE, + OPENAI_OAUTH_CODEX_CLIENT_VERSION, + OPENAI_OAUTH_MODELS_URL, + OPENAI_OAUTH_PROVIDER, + OpenAIOAuthError, + OpenAIOAuthModelCatalogError, + OpenAIOAuthRefreshError, + build_openai_oauth_json_schema, + extract_account_id, + extract_email, + fetch_openai_oauth_model_catalog, + is_openai_oauth_adapter, + refresh_openai_oauth_metadata, +) + +__all__ = [ + "OPENAI_OAUTH_ADAPTER_PREFIX", + "OPENAI_OAUTH_CHATGPT_API_BASE", + "OPENAI_OAUTH_CODEX_CLIENT_VERSION", + "OPENAI_OAUTH_MODELS_URL", + "OPENAI_OAUTH_PROVIDER", + "OpenAIOAuthError", + "OpenAIOAuthModelCatalogError", + "OpenAIOAuthRefreshError", + "build_openai_oauth_json_schema", + "extract_account_id", + "extract_email", + "fetch_openai_oauth_model_catalog", + "is_openai_oauth_adapter", + "refresh_openai_oauth_metadata", +] diff --git a/unstract/sdk1/src/unstract/sdk1/auth/openai_oauth.py b/unstract/sdk1/src/unstract/sdk1/auth/openai_oauth.py new file mode 100644 index 0000000000..76e07e08f5 --- /dev/null +++ b/unstract/sdk1/src/unstract/sdk1/auth/openai_oauth.py @@ -0,0 +1,557 @@ +"""Small, provider-specific helpers for OpenAI ChatGPT OAuth credentials. + +The browser/device login belongs to the web application. This module stays +free of Django and Flask so the platform service can refresh credentials for an +individual adapter instance without keeping a process-global account. +""" + +from __future__ import annotations + +import base64 +import json +import os +import time +from collections.abc import Mapping, Sequence +from typing import Any + +import httpx + +OPENAI_OAUTH_PROVIDER = "openai-oauth" +OPENAI_OAUTH_ADAPTER_PREFIX = f"{OPENAI_OAUTH_PROVIDER}|" +OPENAI_OAUTH_AUTH_BASE = os.environ.get( + "OPENAI_OAUTH_AUTH_BASE", "https://auth.openai.com" +).rstrip("/") +OPENAI_OAUTH_CLIENT_ID = os.environ.get( + "OPENAI_OAUTH_CLIENT_ID", "app_EMoamEEZ73f0CkXaXp7hrann" +) +OPENAI_OAUTH_CHATGPT_API_BASE = "https://chatgpt.com/backend-api/codex" +OPENAI_OAUTH_MODELS_URL = f"{OPENAI_OAUTH_CHATGPT_API_BASE}/models" +OPENAI_OAUTH_CODEX_CLIENT_VERSION = os.environ.get( + "OPENAI_OAUTH_CODEX_CLIENT_VERSION", "0.149.0" +) +OPENAI_OAUTH_DEVICE_USERCODE_URL = ( + f"{OPENAI_OAUTH_AUTH_BASE}/api/accounts/deviceauth/usercode" +) +OPENAI_OAUTH_DEVICE_TOKEN_URL = ( + f"{OPENAI_OAUTH_AUTH_BASE}/api/accounts/deviceauth/token" +) +OPENAI_OAUTH_TOKEN_URL = f"{OPENAI_OAUTH_AUTH_BASE}/oauth/token" +OPENAI_OAUTH_DEVICE_VERIFICATION_URL = f"{OPENAI_OAUTH_AUTH_BASE}/codex/device" +OPENAI_OAUTH_DEVICE_REDIRECT_URI = f"{OPENAI_OAUTH_AUTH_BASE}/deviceauth/callback" + +# OAuth metadata names are intentionally namespaced. It prevents a normal +# OpenAI API-key adapter from accidentally being treated as a ChatGPT OAuth +# adapter, and makes redaction easy at the API boundary. +OPENAI_OAUTH_SECRET_FIELDS = frozenset( + { + "oauth_access_token", + "oauth_refresh_token", + "oauth_id_token", + } +) +OPENAI_OAUTH_PRIVATE_FIELDS = frozenset( + { + *OPENAI_OAUTH_SECRET_FIELDS, + "oauth_account_id", + "oauth_account_email", + "oauth_expires_at", + } +) + + +class OpenAIOAuthError(RuntimeError): + """Base error for OpenAI OAuth credential handling.""" + + +class OpenAIOAuthRefreshError(OpenAIOAuthError): + """Raised when an expired access token cannot be refreshed.""" + + +class OpenAIOAuthModelCatalogError(OpenAIOAuthError): + """Raised when the account-specific Codex model catalog cannot be read.""" + + +def is_openai_oauth_adapter(adapter_id: str | None) -> bool: + """Return whether ``adapter_id`` identifies the OAuth-backed adapter.""" + return bool(adapter_id and adapter_id.startswith(OPENAI_OAUTH_ADAPTER_PREFIX)) + + +def _decode_jwt_claims(token: str | None) -> dict[str, Any]: + """Decode the unverified JWT payload for routing metadata only. + + The authorization server has already issued the token and the provider + validates it on every model request. We only use claims to find the + account label and expiry; this function is not an authentication check. + """ + if not token or not isinstance(token, str): + return {} + try: + parts = token.split(".") + if len(parts) != 3: + return {} + payload = parts[1] + "=" * (-len(parts[1]) % 4) + decoded = base64.urlsafe_b64decode(payload.encode("ascii")) + claims = json.loads(decoded.decode("utf-8")) + return claims if isinstance(claims, dict) else {} + except (ValueError, UnicodeDecodeError, json.JSONDecodeError): + return {} + + +def _nested_claim(claims: Mapping[str, Any], key: str) -> object | None: + """Read a claim from the common OpenAI auth namespace as a fallback.""" + for namespace in ( + "https://api.openai.com/auth", + "https://auth.openai.com/auth", + "auth", + ): + auth_claims = claims.get(namespace) + if isinstance(auth_claims, Mapping) and auth_claims.get(key): + return auth_claims[key] + return None + + +def extract_account_id(*tokens: str | None) -> str | None: + """Extract the ChatGPT account/workspace id from issued JWT claims.""" + for token in tokens: + claims = _decode_jwt_claims(token) + account_id = claims.get("chatgpt_account_id") or claims.get("account_id") + if not account_id: + account_id = _nested_claim(claims, "chatgpt_account_id") + if isinstance(account_id, str) and account_id.strip(): + return account_id.strip() + return None + + +def extract_email(*tokens: str | None) -> str | None: + """Extract an optional account email used only as a friendly label.""" + for token in tokens: + claims = _decode_jwt_claims(token) + email = claims.get("email") + if not email and isinstance(claims.get("profile"), Mapping): + email = claims["profile"].get("email") + if not email: + email = _nested_claim(claims, "email") + if isinstance(email, str) and email.strip(): + return email.strip() + return None + + +def _extract_expiry(*tokens: str | None) -> float | None: + for token in tokens: + expiry = _decode_jwt_claims(token).get("exp") + try: + if expiry is not None: + return float(expiry) + except (TypeError, ValueError): + continue + return None + + +def _response_error(response: httpx.Response, operation: str) -> OpenAIOAuthRefreshError: + """Build a safe refresh error without including response/token contents.""" + return OpenAIOAuthRefreshError( + f"OpenAI OAuth {operation} failed with status {response.status_code}" + ) + + +def _reasoning_levels(value: object) -> list[dict[str, str]]: + """Normalize the reasoning options returned by the Codex catalog.""" + if not isinstance(value, list): + return [] + + levels: list[dict[str, str]] = [] + seen: set[str] = set() + for item in value: + if isinstance(item, str): + effort = item + description = item + elif isinstance(item, Mapping): + effort = item.get("effort") or item.get("reasoning_effort") + description = item.get("description") + else: + continue + if not isinstance(effort, str) or not effort.strip(): + continue + effort = effort.strip() + if effort in seen: + continue + if not isinstance(description, str) or not description.strip(): + description = effort.replace("_", " ").replace("-", " ").title() + levels.append({"effort": effort, "description": description.strip()}) + seen.add(effort) + return levels + + +def _normalize_catalog_model( + item: Mapping[str, Any], index: int +) -> dict[str, Any] | None: + slug = item.get("slug") + if not isinstance(slug, str) or not slug.strip(): + return None + slug = slug.strip() + + visibility = item.get("visibility") + if isinstance(visibility, str) and visibility.lower() in { + "hidden", + "none", + "unlisted", + }: + return None + if item.get("supported_in_api") is False: + return None + + display_name = item.get("display_name") + if not isinstance(display_name, str) or not display_name.strip(): + display_name = slug + description = item.get("description") + if not isinstance(description, str): + description = "" + priority = item.get("priority") + try: + normalized_priority = int(priority) + except (TypeError, ValueError): + normalized_priority = 2**31 - 1 + + return { + "slug": slug, + "display_name": display_name.strip(), + "description": description.strip(), + "default_reasoning_level": item.get("default_reasoning_level"), + "supported_reasoning_levels": _reasoning_levels( + item.get("supported_reasoning_levels") + ), + "priority": normalized_priority, + "_catalog_index": index, + "is_deprecated": bool(item.get("upgrade")), + } + + +def _normalize_catalog_models(raw_models: list[object]) -> list[dict[str, Any]]: + models: list[dict[str, Any]] = [] + seen_slugs: set[str] = set() + for index, item in enumerate(raw_models): + if not isinstance(item, Mapping): + continue + model = _normalize_catalog_model(item, index) + if model is None or model["slug"] in seen_slugs: + continue + models.append(model) + seen_slugs.add(model["slug"]) + + models.sort(key=lambda item: (item["priority"], item["_catalog_index"])) + for item in models: + item.pop("_catalog_index", None) + return models + + +def _catalog_models_from_response(response: httpx.Response) -> list[object]: + if not 200 <= response.status_code < 300: + raise OpenAIOAuthModelCatalogError( + f"OpenAI OAuth model discovery failed with status {response.status_code}" + ) + + try: + payload = response.json() + except (ValueError, json.JSONDecodeError) as exc: + raise OpenAIOAuthModelCatalogError( + "OpenAI OAuth model discovery returned an invalid response" + ) from exc + if not isinstance(payload, Mapping) or not isinstance(payload.get("models"), list): + raise OpenAIOAuthModelCatalogError( + "OpenAI OAuth model discovery returned no model catalog" + ) + return payload["models"] + + +def fetch_openai_oauth_model_catalog( + metadata: Mapping[str, Any], + *, + client_version: str | None = None, +) -> list[dict[str, Any]]: + """Fetch the visible model catalog for one authenticated ChatGPT account. + + The Codex endpoint is account- and plan-aware. No model ids or reasoning + levels are defined here; the response is normalized only enough for the + configuration UI to consume it safely. + """ + access_token = metadata.get("oauth_access_token") + account_id = metadata.get("oauth_account_id") + if not isinstance(access_token, str) or not access_token.strip(): + raise OpenAIOAuthModelCatalogError( + "OpenAI OAuth model discovery requires an access token" + ) + if not isinstance(account_id, str) or not account_id.strip(): + raise OpenAIOAuthModelCatalogError( + "OpenAI OAuth model discovery requires a ChatGPT account" + ) + + try: + response = httpx.get( + OPENAI_OAUTH_MODELS_URL, + params={ + "client_version": client_version or OPENAI_OAUTH_CODEX_CLIENT_VERSION + }, + headers={ + "Authorization": f"Bearer {access_token}", + "ChatGPT-Account-ID": account_id, + "Accept": "application/json", + # Keep the catalog aligned with the originator used for model + # requests by the Unstract adapter. + "originator": "unstract", + }, + timeout=15.0, + ) + except httpx.HTTPError as exc: + raise OpenAIOAuthModelCatalogError( + "OpenAI OAuth model discovery could not reach ChatGPT" + ) from exc + + models = _normalize_catalog_models(_catalog_models_from_response(response)) + + if not models: + raise OpenAIOAuthModelCatalogError( + "OpenAI OAuth model discovery returned no available models" + ) + return models + + +def _valid_catalog_models( + model_catalog: Sequence[Mapping[str, Any]], +) -> list[dict[str, Any]]: + return [ + dict(item) + for item in model_catalog + if isinstance(item, Mapping) + and isinstance(item.get("slug"), str) + and item["slug"].strip() + ] + + +def _model_labels(models: Sequence[Mapping[str, Any]]) -> list[str]: + labels: list[str] = [] + for model in models: + slug = str(model["slug"]).strip() + label = str(model.get("display_name") or slug).strip() + if model.get("is_deprecated") and "deprecated" not in label.lower(): + label = f"{label} (deprecated)" + labels.append(label) + return labels + + +def _selected_catalog_model(slugs: Sequence[str], current_model: str | None) -> str: + selected_model = current_model.strip() if isinstance(current_model, str) else "" + for prefix in ("openai/", "chatgpt/"): + if selected_model.startswith(prefix): + selected_model = selected_model[len(prefix) :] + break + return selected_model if selected_model in slugs else slugs[0] + + +def _reasoning_schema_for_model( + model: Mapping[str, Any], +) -> dict[str, Any] | None: + levels = _reasoning_levels(model.get("supported_reasoning_levels")) + if not levels: + return None + + efforts = [level["effort"] for level in levels] + default_effort = model.get("default_reasoning_level") + if default_effort not in efforts: + default_effort = efforts[0] + return { + "type": "string", + "enum": efforts, + # RJSF renders enumNames in the select. Keep the stable effort names + # visible; the catalog descriptions are explanatory metadata, not the + # value users need to choose or persist. + "enumNames": efforts, + "default": default_effort, + "title": "Reasoning Effort", + "description": "Reasoning levels reported for this model by ChatGPT.", + } + + +def _add_reasoning_conditions( + all_of: list[dict[str, Any]], models: Sequence[Mapping[str, Any]] +) -> None: + for model in models: + slug = str(model["slug"]).strip() + reasoning_schema = _reasoning_schema_for_model(model) + if reasoning_schema is None: + continue + all_of.append( + { + "if": { + "required": ["enable_reasoning", "model"], + "properties": { + "enable_reasoning": {"const": True}, + "model": {"const": slug}, + }, + }, + "then": { + "properties": {"reasoning_effort": reasoning_schema}, + "required": ["reasoning_effort"], + }, + } + ) + + +def _remove_empty_reasoning_placeholder(all_of: list[dict[str, Any]]) -> None: + """Remove the pre-auth reasoning rule before adding live model rules.""" + all_of[:] = [ + condition + for condition in all_of + if not ( + isinstance(condition, Mapping) + and isinstance(condition.get("then"), Mapping) + and isinstance(condition["then"].get("properties"), Mapping) + and isinstance( + condition["then"]["properties"].get("reasoning_effort"), + Mapping, + ) + and condition["then"]["properties"]["reasoning_effort"].get( + "enum" + ) + == [] + ) + ] + + +def build_openai_oauth_json_schema( + model_catalog: Sequence[Mapping[str, Any]], + *, + current_model: str | None = None, + base_schema: Mapping[str, Any] | None = None, +) -> dict[str, Any]: + """Build a form schema from one account's live Codex model catalog. + + ``base_schema`` is injectable for tests. In production the adapter's + schema is loaded lazily to avoid an import cycle with this auth module. + """ + if base_schema is None: + from unstract.sdk1.adapters.llm1.openai_oauth import OpenAIOAuthLLMAdapter + + base_schema = json.loads(OpenAIOAuthLLMAdapter.get_json_schema()) + + schema = json.loads(json.dumps(base_schema)) + models = _valid_catalog_models(model_catalog) + if not models: + raise OpenAIOAuthModelCatalogError( + "Cannot build an OpenAI OAuth form without available models" + ) + + model_property = schema.setdefault("properties", {}).setdefault("model", {}) + slugs = [str(item["slug"]).strip() for item in models] + labels = _model_labels(models) + selected_model = _selected_catalog_model(slugs, current_model) + + model_property["enum"] = slugs + model_property["enumNames"] = labels + model_property["default"] = selected_model + model_property["description"] = ( + "Models reported as available by this ChatGPT/Codex account." + ) + + all_of = schema.setdefault("allOf", []) + if not isinstance(all_of, list): + all_of = [] + schema["allOf"] = all_of + + # The static adapter schema keeps an empty reasoning field so RJSF can + # render the form before login. Once the account catalog is available, + # that placeholder must not remain as an additional empty constraint. + _remove_empty_reasoning_placeholder(all_of) + _add_reasoning_conditions(all_of, models) + + schema["x-openai-oauth-model-source"] = "chatgpt-account" + return schema + + +def refresh_openai_oauth_metadata( + metadata: Mapping[str, Any], + *, + force: bool = False, + now: float | None = None, +) -> dict[str, Any]: + """Refresh one adapter's OAuth metadata when its access token is expiring. + + The returned dictionary is a copy. The old refresh token is retained when + the token endpoint does not rotate it, which is permitted by OAuth 2.0. + Callers decide where the refreshed copy is persisted; no account is stored + in module-level state. + """ + refreshed = dict(metadata) + access_token = refreshed.get("oauth_access_token") + refresh_token = refreshed.get("oauth_refresh_token") + account_id = refreshed.get("oauth_account_id") or extract_account_id( + refreshed.get("oauth_id_token"), access_token + ) + if not access_token or not refresh_token or not account_id: + raise OpenAIOAuthRefreshError( + "OpenAI OAuth metadata is missing the credentials required for refresh" + ) + + current_time = time.time() if now is None else now + try: + expires_at = float(refreshed.get("oauth_expires_at")) + except (TypeError, ValueError): + expires_at = _extract_expiry(access_token, refreshed.get("oauth_id_token")) + + # Refresh slightly before expiry so a long-running request does not start + # with a token that expires while it is in flight. + if not force and expires_at is not None and expires_at > current_time + 60: + return refreshed + + try: + response = httpx.post( + OPENAI_OAUTH_TOKEN_URL, + json={ + "client_id": OPENAI_OAUTH_CLIENT_ID, + "grant_type": "refresh_token", + "refresh_token": refresh_token, + }, + timeout=15.0, + ) + except httpx.HTTPError as exc: + raise OpenAIOAuthRefreshError( + "OpenAI OAuth token refresh could not reach the authorization server" + ) from exc + + if not 200 <= response.status_code < 300: + raise _response_error(response, "token refresh") + + try: + token_data = response.json() + except (ValueError, json.JSONDecodeError) as exc: + raise OpenAIOAuthRefreshError( + "OpenAI OAuth token refresh returned an invalid response" + ) from exc + + new_access_token = token_data.get("access_token") + if not isinstance(new_access_token, str) or not new_access_token: + raise OpenAIOAuthRefreshError( + "OpenAI OAuth token refresh returned no access token" + ) + + new_id_token = token_data.get("id_token") or refreshed.get("oauth_id_token") + new_refresh_token = token_data.get("refresh_token") or refresh_token + expires_in = token_data.get("expires_in") + try: + new_expires_at = current_time + float(expires_in) + except (TypeError, ValueError): + new_expires_at = _extract_expiry(new_access_token, new_id_token) + + refreshed.update( + { + "oauth_access_token": new_access_token, + "oauth_refresh_token": new_refresh_token, + "oauth_id_token": new_id_token, + "oauth_account_id": extract_account_id(new_id_token, new_access_token) + or account_id, + "oauth_account_email": extract_email(new_id_token, new_access_token) + or refreshed.get("oauth_account_email"), + "oauth_expires_at": new_expires_at, + "oauth_authenticated": True, + } + ) + return refreshed diff --git a/unstract/sdk1/src/unstract/sdk1/llm.py b/unstract/sdk1/src/unstract/sdk1/llm.py index e780685180..a21561b072 100644 --- a/unstract/sdk1/src/unstract/sdk1/llm.py +++ b/unstract/sdk1/src/unstract/sdk1/llm.py @@ -1,6 +1,8 @@ +import json import logging import os import re +import uuid from collections.abc import Callable, Generator, Mapping, Sequence from dataclasses import dataclass, field from enum import Enum @@ -11,8 +13,13 @@ # from litellm import get_supported_openai_params from litellm import get_max_tokens + from unstract.sdk1.adapters.constants import Common from unstract.sdk1.adapters.llm1 import adapters +from unstract.sdk1.auth.openai_oauth import ( + OPENAI_OAUTH_CHATGPT_API_BASE, + is_openai_oauth_adapter, +) from unstract.sdk1.constants import Common as SdkCommon from unstract.sdk1.constants import ToolEnv from unstract.sdk1.exceptions import LLMError, SdkError, strip_litellm_prefix @@ -496,6 +503,372 @@ def _build_messages( {"role": "user", "content": user_content}, ] + def _uses_openai_oauth(self) -> bool: + """Whether this LLM uses the per-account ChatGPT OAuth adapter.""" + return is_openai_oauth_adapter(self._adapter_id) + + @staticmethod + def _response_value( + response: object, key: str, default: object | None = None + ) -> object | None: + """Read a field from a LiteLLM dict, model, or response event.""" + if isinstance(response, Mapping): + return response.get(key, default) + try: + value = response[key] # type: ignore[index] + except (KeyError, TypeError, AttributeError, IndexError): + value = getattr(response, key, default) + return value if value is not None else default + + @staticmethod + def _responses_block(block: object) -> dict[str, object]: + """Convert one chat content block to a Responses input block.""" + if not isinstance(block, Mapping): + return {"type": "input_text", "text": str(block)} + block_type = block.get("type") + if block_type in ("text", "input_text"): + return {"type": "input_text", "text": block.get("text", "")} + if block_type == "image_url": + image_url = block.get("image_url") + detail = None + if isinstance(image_url, Mapping): + detail = image_url.get("detail") + image_url = image_url.get("url") + image_block: dict[str, object] = { + "type": "input_image", + "image_url": image_url, + } + if detail: + image_block["detail"] = detail + return image_block + if isinstance(block_type, str) and block_type.startswith("input_"): + # Preserve already-converted Responses blocks for callers that use + # complete_vision() directly. + return dict(block) + return {"type": "input_text", "text": json.dumps(dict(block))} + + @classmethod + def _responses_content(cls, content: object) -> list[dict[str, object]]: + """Convert OpenAI chat content blocks to Responses input blocks.""" + if isinstance(content, str): + return [{"type": "input_text", "text": content}] + if not isinstance(content, list): + if content is None: + return [] + return [{"type": "input_text", "text": str(content)}] + return [cls._responses_block(block) for block in content] + + @classmethod + def _responses_input( + cls, messages: list[dict[str, object]] + ) -> tuple[str, list[dict[str, object]]]: + instructions: list[str] = [] + response_input: list[dict[str, object]] = [] + for message in messages: + role = str(message.get("role", "user")) + content = message.get("content") + if role == "system": + text_blocks = cls._responses_content(content) + instructions.extend( + str(block.get("text", "")) + for block in text_blocks + if block.get("type") == "input_text" + ) + continue + if role not in {"user", "assistant"}: + role = "user" + response_input.append( + { + "type": "message", + "role": role, + "content": cls._responses_content(content), + } + ) + return "\n".join(text for text in instructions if text), response_input + + @staticmethod + def _responses_tools(tools: object) -> object: + """Translate chat function tools to the Responses tool shape.""" + if not isinstance(tools, list): + return tools + converted: list[object] = [] + for tool in tools: + if not isinstance(tool, Mapping) or tool.get("type") != "function": + converted.append(tool) + continue + function = tool.get("function") + if not isinstance(function, Mapping): + converted.append(tool) + continue + converted.append( + { + "type": "function", + "name": function.get("name"), + "description": function.get("description"), + "parameters": function.get("parameters"), + } + ) + return converted + + def _build_openai_oauth_responses_kwargs( + self, + messages: list[dict[str, object]], + completion_kwargs: dict[str, object], + *, + stream: bool, + ) -> dict[str, object]: + """Build a Responses API call with credentials for one adapter only.""" + values = dict(completion_kwargs) + access_token = str(values.pop("oauth_access_token")) + account_id = str(values.pop("oauth_account_id")) + values.pop("oauth_refresh_token", None) + values.pop("oauth_id_token", None) + values.pop("oauth_account_email", None) + values.pop("oauth_expires_at", None) + values.pop("oauth_authenticated", None) + + model = str(values.pop("model")) + api_base = str(values.pop("api_base", OPENAI_OAUTH_CHATGPT_API_BASE)) + # The ChatGPT/Codex subscription endpoint rejects both the legacy + # max_tokens option and the Responses API max_output_tokens option. + # Keep the schema value for adapter compatibility, but do not put it + # on the provider request. + values.pop("max_tokens", None) + values.pop("temperature", None) + values.pop("n", None) + values.pop("api_version", None) + values.pop("enable_reasoning", None) + values.pop("max_retries", None) + values.pop("num_retries", None) + values.pop("cost_model", None) + values.pop("context_window", None) + + reasoning_effort = values.pop("reasoning_effort", None) + # The ChatGPT Codex endpoint expects this field on Responses requests + # so encrypted reasoning can be returned and reused by the account. + values["include"] = ["reasoning.encrypted_content"] + if reasoning_effort: + values["reasoning"] = {"effort": reasoning_effort} + if "tools" in values: + values["tools"] = self._responses_tools(values["tools"]) + + instructions, response_input = self._responses_input(messages) + values.update( + { + "model": model, + "input": response_input, + "custom_llm_provider": "openai", + # LiteLLM's OpenAI handler uses this key to select its client; + # the explicit Authorization header below is account-specific. + "api_key": access_token, + "api_base": api_base, + "stream": stream, + "store": False, + } + ) + if instructions: + values["instructions"] = instructions + + headers = dict(values.pop("extra_headers", {}) or {}) + headers.update( + { + "Authorization": f"Bearer {access_token}", + "ChatGPT-Account-Id": account_id, + "Content-Type": "application/json", + "Accept": "text/event-stream" if stream else "application/json", + "originator": "unstract", + "session-id": str(uuid.uuid4()), + } + ) + values["extra_headers"] = headers + return values + + @classmethod + def _responses_output_text(cls, response: object) -> str | None: + output_text = cls._response_value(response, "output_text") + if isinstance(output_text, str): + return output_text + output = cls._response_value(response, "output") + if not isinstance(output, list): + return None + text_parts: list[str] = [] + for item in output: + if cls._response_value(item, "type") != "message": + continue + content = cls._response_value(item, "content") + if not isinstance(content, list): + continue + for block in content: + if cls._response_value(block, "type") in { + "output_text", + "text", + }: + text = cls._response_value(block, "text") + if isinstance(text, str): + text_parts.append(text) + return "".join(text_parts) or None + + @classmethod + def _responses_usage(cls, usage: object) -> dict[str, int]: + if usage is None: + return {} + + def integer(key: str, fallback: str) -> int: + value = cls._response_value(usage, key) + if value is None: + value = cls._response_value(usage, fallback, 0) + try: + return int(value or 0) + except (TypeError, ValueError): + return 0 + + return { + "prompt_tokens": integer("input_tokens", "prompt_tokens"), + "completion_tokens": integer("output_tokens", "completion_tokens"), + "total_tokens": integer("total_tokens", "total_tokens"), + } + + def _collect_openai_oauth_response( + self, + messages: list[dict[str, object]], + completion_kwargs: dict[str, object], + max_retries: int, + ) -> tuple[str | None, object | None, dict[str, int]]: + """Consume a streaming-only Responses API call as one completion. + + The ChatGPT/Codex endpoint rejects ``stream=False``. Keep the public + ``complete()`` contract by consuming the stream internally and + returning the assembled text, completed response, and usage. + """ + response_kwargs = self._build_openai_oauth_responses_kwargs( + messages, completion_kwargs, stream=True + ) + text_parts: list[str] = [] + completed_response: object | None = None + last_event: object | None = None + + for event in iter_with_retry( + lambda: litellm.responses(**response_kwargs), + max_retries=max_retries, + retry_predicate=is_retryable_litellm_error, + description=self._get_adapter_info(), + ): + last_event = event + event_type = self._response_value(event, "type") + if event_type == "response.output_text.delta": + text = self._response_value(event, "delta", "") + if isinstance(text, str): + text_parts.append(text) + elif event_type == "response.completed": + response = self._response_value(event, "response") + if response is not None: + completed_response = response + + response = ( + completed_response if completed_response is not None else last_event + ) + response_text = "".join(text_parts) or self._responses_output_text(response) + usage_source = ( + completed_response if completed_response is not None else response + ) + usage = self._responses_usage(self._response_value(usage_source, "usage")) + return response_text, response, usage + + async def _acollect_openai_oauth_response( + self, + messages: list[dict[str, object]], + completion_kwargs: dict[str, object], + max_retries: int, + ) -> tuple[str | None, object | None, dict[str, int]]: + """Async counterpart to :meth:`_collect_openai_oauth_response`.""" + response_kwargs = self._build_openai_oauth_responses_kwargs( + messages, completion_kwargs, stream=True + ) + + async def consume_stream() -> tuple[ + str | None, object | None, dict[str, int] + ]: + text_parts: list[str] = [] + completed_response: object | None = None + last_event: object | None = None + stream = await litellm.aresponses(**response_kwargs) + + async for event in stream: + last_event = event + event_type = self._response_value(event, "type") + if event_type == "response.output_text.delta": + text = self._response_value(event, "delta", "") + if isinstance(text, str): + text_parts.append(text) + elif event_type == "response.completed": + response = self._response_value(event, "response") + if response is not None: + completed_response = response + + response = ( + completed_response if completed_response is not None else last_event + ) + response_text = "".join(text_parts) or self._responses_output_text( + response + ) + usage_source = ( + completed_response if completed_response is not None else response + ) + usage = self._responses_usage( + self._response_value(usage_source, "usage") + ) + return response_text, response, usage + + return await acall_with_retry( + consume_stream, + max_retries=max_retries, + retry_predicate=is_retryable_litellm_error, + description=self._get_adapter_info(), + ) + + def _stream_openai_oauth( + self, + messages: list[dict[str, object]], + completion_kwargs: dict[str, object], + callback_manager: object | None, + max_retries: int, + ) -> Generator[LLMResponseCompat, None, None]: + """Yield text events from one account's Responses API stream.""" + response_kwargs = self._build_openai_oauth_responses_kwargs( + messages, completion_kwargs, stream=True + ) + for event in iter_with_retry( + lambda: litellm.responses(**response_kwargs), + max_retries=max_retries, + retry_predicate=is_retryable_litellm_error, + description=self._get_adapter_info(), + ): + event_type = self._response_value(event, "type") + if event_type == "response.completed": + completed_response = self._response_value(event, "response") + usage = self._responses_usage( + self._response_value(completed_response, "usage") + ) + if usage: + self._record_usage( + self._cost_model or self.kwargs["model"], + messages, + usage, + "stream_complete", + response=completed_response, + ) + continue + if event_type != "response.output_text.delta": + continue + text = self._response_value(event, "delta", "") + if not isinstance(text, str) or not text: + continue + if callback_manager and hasattr(callback_manager, "on_stream"): + callback_manager.on_stream(text) + stream_response = LLMResponseCompat(text) + stream_response.delta = text + yield stream_response + @capture_metrics def complete( self, @@ -541,20 +914,28 @@ def complete( max_retries = pop_litellm_retry_kwargs( completion_kwargs, self._get_adapter_info() ) - response: dict[str, object] = call_with_retry( - lambda: litellm.completion(messages=messages, **completion_kwargs), - max_retries=max_retries, - retry_predicate=is_retryable_litellm_error, - description=self._get_adapter_info(), - ) - - response_text = response["choices"][0]["message"]["content"] - finish_reason = response["choices"][0].get("finish_reason") + if self._uses_openai_oauth(): + response_text, response, usage = self._collect_openai_oauth_response( + messages, + completion_kwargs, + max_retries, + ) + finish_reason = None + else: + response = call_with_retry( + lambda: litellm.completion(messages=messages, **completion_kwargs), + max_retries=max_retries, + retry_predicate=is_retryable_litellm_error, + description=self._get_adapter_info(), + ) + response_text = response["choices"][0]["message"]["content"] + finish_reason = response["choices"][0].get("finish_reason") + usage = response.get("usage") self._record_usage( self._cost_model or self.kwargs["model"], messages, - response.get("usage"), + usage, "complete", response=response, ) @@ -658,18 +1039,29 @@ def complete_vision( completion_kwargs.pop("enable_prompt_caching", None) completion_kwargs.pop("context_window", None) - response: dict[str, object] = litellm.completion( - messages=messages, - **completion_kwargs, - ) - - response_text = response["choices"][0]["message"]["content"] - finish_reason = response["choices"][0].get("finish_reason") + if self._uses_openai_oauth(): + max_retries = pop_litellm_retry_kwargs( + completion_kwargs, self._get_adapter_info() + ) + response_text, response, usage = self._collect_openai_oauth_response( + messages, + completion_kwargs, + max_retries, + ) + finish_reason = None + else: + response = litellm.completion( + messages=messages, + **completion_kwargs, + ) + response_text = response["choices"][0]["message"]["content"] + finish_reason = response["choices"][0].get("finish_reason") + usage = response.get("usage") self._record_usage( self._cost_model or self.kwargs["model"], messages, - response.get("usage"), + usage, "complete_vision", response=response, ) @@ -732,32 +1124,37 @@ def stream_complete( completion_kwargs, self._get_adapter_info() ) has_yielded_content = False - for chunk in iter_with_retry( - lambda: litellm.completion( - messages=messages, - stream=True, - stream_options={"include_usage": True}, - **completion_kwargs, - ), - max_retries=max_retries, - retry_predicate=is_retryable_litellm_error, - description=self._get_adapter_info(), - ): - if chunk.get("usage"): - self._record_usage( - self._cost_model or self.kwargs["model"], - messages, - chunk.get("usage"), - "stream_complete", - response=chunk, - ) - - response = self._process_stream_chunk( - chunk, callback_manager, has_yielded_content + if self._uses_openai_oauth(): + yield from self._stream_openai_oauth( + messages, completion_kwargs, callback_manager, max_retries ) - if response is not None: - has_yielded_content = True - yield response + else: + for chunk in iter_with_retry( + lambda: litellm.completion( + messages=messages, + stream=True, + stream_options={"include_usage": True}, + **completion_kwargs, + ), + max_retries=max_retries, + retry_predicate=is_retryable_litellm_error, + description=self._get_adapter_info(), + ): + if chunk.get("usage"): + self._record_usage( + self._cost_model or self.kwargs["model"], + messages, + chunk.get("usage"), + "stream_complete", + response=chunk, + ) + + response = self._process_stream_chunk( + chunk, callback_manager, has_yielded_content + ) + if response is not None: + has_yielded_content = True + yield response except LLMError: # Already wrapped LLMError, re-raise as is @@ -812,19 +1209,30 @@ async def acomplete( max_retries = pop_litellm_retry_kwargs( completion_kwargs, self._get_adapter_info() ) - response = await acall_with_retry( - lambda: litellm.acompletion(messages=messages, **completion_kwargs), - max_retries=max_retries, - retry_predicate=is_retryable_litellm_error, - description=self._get_adapter_info(), - ) - response_text = response["choices"][0]["message"]["content"] - finish_reason = response["choices"][0].get("finish_reason") + if self._uses_openai_oauth(): + response_text, response, usage = await ( + self._acollect_openai_oauth_response( + messages, + completion_kwargs, + max_retries, + ) + ) + finish_reason = None + else: + response = await acall_with_retry( + lambda: litellm.acompletion(messages=messages, **completion_kwargs), + max_retries=max_retries, + retry_predicate=is_retryable_litellm_error, + description=self._get_adapter_info(), + ) + response_text = response["choices"][0]["message"]["content"] + finish_reason = response["choices"][0].get("finish_reason") + usage = response.get("usage") self._record_usage( self._cost_model or self.kwargs["model"], messages, - response.get("usage"), + usage, "acomplete", response=response, ) diff --git a/unstract/sdk1/tests/test_openai_oauth.py b/unstract/sdk1/tests/test_openai_oauth.py new file mode 100644 index 0000000000..bfe1bc10f2 --- /dev/null +++ b/unstract/sdk1/tests/test_openai_oauth.py @@ -0,0 +1,382 @@ +"""Tests for the per-account OpenAI ChatGPT OAuth adapter.""" + +from __future__ import annotations + +import asyncio +import base64 +import json +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from unstract.sdk1.adapters.constants import Common +from unstract.sdk1.adapters.llm1 import adapters +from unstract.sdk1.adapters.llm1.openai_oauth import ( + OpenAIOAuthLLMAdapter, + OpenAIOAuthLLMParameters, +) +from unstract.sdk1.auth.openai_oauth import ( + OPENAI_OAUTH_CHATGPT_API_BASE, + OPENAI_OAUTH_CODEX_CLIENT_VERSION, + OPENAI_OAUTH_MODELS_URL, + OPENAI_OAUTH_TOKEN_URL, + build_openai_oauth_json_schema, + extract_account_id, + extract_email, + fetch_openai_oauth_model_catalog, + refresh_openai_oauth_metadata, +) +from unstract.sdk1.llm import LLM + + +def _jwt(payload: dict[str, object]) -> str: + encoded = base64.urlsafe_b64encode(json.dumps(payload).encode()).decode().rstrip("=") + return f"header.{encoded}.signature" + + +def _metadata(access_token: str, account_id: str) -> dict[str, object]: + return { + "model": "gpt-5-codex", + "oauth_access_token": access_token, + "oauth_refresh_token": f"refresh-{account_id}", + "oauth_id_token": "id-token", + "oauth_account_id": account_id, + "oauth_account_email": f"{account_id}@example.test", + "oauth_expires_at": 4_000_000_000, + } + + +def _response_events(text: str) -> object: + return iter( + [ + {"type": "response.output_text.delta", "delta": text}, + { + "type": "response.completed", + "response": { + "id": f"response-{text}", + "output_text": text, + "usage": {}, + }, + }, + ] + ) + + +def test_openai_oauth_adapter_is_registered_with_auth_metadata() -> None: + adapter_id = OpenAIOAuthLLMAdapter.get_id() + assert adapters[adapter_id][Common.MODULE] is OpenAIOAuthLLMAdapter + + registry_metadata = adapters[adapter_id][Common.METADATA] + assert registry_metadata[Common.ADAPTER] is OpenAIOAuthLLMAdapter + assert OpenAIOAuthLLMAdapter.get_auth_metadata() == { + "oauth": True, + "oauth_provider": "openai", + "python_social_auth_backend": "openai-oauth", + } + schema = json.loads(OpenAIOAuthLLMAdapter.get_json_schema()) + assert schema["properties"]["model"]["enum"] == [] + assert ( + schema["allOf"][0]["then"]["properties"]["reasoning_effort"]["enum"] + == [] + ) + + +def test_openai_oauth_model_catalog_is_normalized_per_account() -> None: + response = MagicMock(status_code=200) + response.json.return_value = { + "models": [ + { + "slug": "hidden-model", + "display_name": "Hidden", + "visibility": "hidden", + }, + { + "slug": "gpt-account-fast", + "display_name": "Account Fast", + "description": "Fast account model", + "priority": 2, + "default_reasoning_level": "low", + "supported_reasoning_levels": [ + {"effort": "low", "description": "Light"}, + {"effort": "high", "description": "Deep"}, + ], + }, + { + "slug": "gpt-account-best", + "display_name": "Account Best", + "priority": 1, + "supported_in_api": True, + "supported_reasoning_levels": [{"effort": "max"}], + }, + {"slug": "not-supported", "supported_in_api": False}, + ] + } + metadata = _metadata("account-token", "workspace-123") + + with patch("unstract.sdk1.auth.openai_oauth.httpx.get", return_value=response) as get: + catalog = fetch_openai_oauth_model_catalog(metadata) + + assert [model["slug"] for model in catalog] == [ + "gpt-account-best", + "gpt-account-fast", + ] + assert catalog[1]["supported_reasoning_levels"] == [ + {"effort": "low", "description": "Light"}, + {"effort": "high", "description": "Deep"}, + ] + get.assert_called_once_with( + OPENAI_OAUTH_MODELS_URL, + params={"client_version": OPENAI_OAUTH_CODEX_CLIENT_VERSION}, + headers={ + "Authorization": "Bearer account-token", + "ChatGPT-Account-ID": "workspace-123", + "Accept": "application/json", + "originator": "unstract", + }, + timeout=15.0, + ) + + +def test_openai_oauth_dynamic_schema_uses_model_specific_reasoning_levels() -> None: + base_schema = json.loads(OpenAIOAuthLLMAdapter.get_json_schema()) + schema = build_openai_oauth_json_schema( + [ + { + "slug": "account-model-a", + "display_name": "Model A", + "default_reasoning_level": "high", + "supported_reasoning_levels": [ + {"effort": "low", "description": "Low"}, + {"effort": "high", "description": "High"}, + ], + }, + { + "slug": "account-model-b", + "display_name": "Model B", + "supported_reasoning_levels": [ + {"effort": "max", "description": "Maximum"}, + ], + }, + ], + current_model="account-model-b", + base_schema=base_schema, + ) + + model = schema["properties"]["model"] + assert model["enum"] == ["account-model-a", "account-model-b"] + assert model["enumNames"] == ["Model A", "Model B"] + assert model["default"] == "account-model-b" + model_conditions = schema["allOf"][1:] + assert model_conditions[0]["if"]["properties"]["model"] == { + "const": "account-model-a" + } + assert model_conditions[0]["then"]["properties"]["reasoning_effort"]["enum"] == [ + "low", + "high", + ] + assert model_conditions[0]["then"]["properties"]["reasoning_effort"][ + "enumNames" + ] == ["low", "high"] + assert model_conditions[1]["then"]["properties"]["reasoning_effort"]["enum"] == [ + "max" + ] + + +def test_openai_oauth_dynamic_schema_accepts_string_reasoning_levels() -> None: + schema = build_openai_oauth_json_schema( + [ + { + "slug": "account-model", + "supported_reasoning_levels": ["low", "medium", "high"], + } + ], + base_schema=json.loads(OpenAIOAuthLLMAdapter.get_json_schema()), + ) + + reasoning = schema["allOf"][1]["then"]["properties"]["reasoning_effort"] + assert reasoning["enum"] == ["low", "medium", "high"] + assert reasoning["enumNames"] == ["low", "medium", "high"] + + +def test_openai_oauth_parameters_normalize_model_and_fix_endpoint() -> None: + metadata = _metadata("access", "account") + metadata["model"] = "openai/gpt-5-codex" + + validated = OpenAIOAuthLLMParameters.validate(metadata) + + assert validated["model"] == "gpt-5-codex" + assert validated["api_base"] == OPENAI_OAUTH_CHATGPT_API_BASE + assert validated["cost_model"] == "gpt-5-codex" + assert metadata["model"] == "openai/gpt-5-codex" + + +def test_openai_oauth_does_not_forward_unsupported_output_token_parameters() -> None: + llm = LLM( + adapter_id=OpenAIOAuthLLMAdapter.get_id(), + adapter_metadata=_metadata("access", "account"), + ) + + request = llm._build_openai_oauth_responses_kwargs( + [], + {**_metadata("access", "account"), "max_tokens": 4096}, + stream=True, + ) + + assert "max_tokens" not in request + assert "max_output_tokens" not in request + + +@pytest.mark.parametrize( + "field", + ["oauth_access_token", "oauth_refresh_token", "oauth_account_id"], +) +def test_openai_oauth_parameters_require_account_credentials(field: str) -> None: + metadata = _metadata("access", "account") + metadata[field] = " " + + with pytest.raises(ValueError, match=field): + OpenAIOAuthLLMParameters.validate(metadata) + + +def test_openai_oauth_claim_helpers_read_account_and_email() -> None: + token = _jwt( + { + "chatgpt_account_id": "workspace-123", + "email": "person@example.test", + } + ) + + assert extract_account_id(token) == "workspace-123" + assert extract_email(token) == "person@example.test" + + +def test_refresh_openai_oauth_metadata_is_scoped_to_one_account() -> None: + response = MagicMock(status_code=200) + response.json.return_value = {"access_token": "new-access", "expires_in": 3600} + metadata = _metadata("old-access", "workspace-123") + metadata["oauth_expires_at"] = 0 + + with patch( + "unstract.sdk1.auth.openai_oauth.httpx.post", return_value=response + ) as post: + refreshed = refresh_openai_oauth_metadata(metadata, now=100, force=True) + + assert refreshed["oauth_access_token"] == "new-access" + assert refreshed["oauth_refresh_token"] == "refresh-workspace-123" + assert refreshed["oauth_account_id"] == "workspace-123" + post.assert_called_once_with( + OPENAI_OAUTH_TOKEN_URL, + json={ + "client_id": "app_EMoamEEZ73f0CkXaXp7hrann", + "grant_type": "refresh_token", + "refresh_token": "refresh-workspace-123", + }, + timeout=15.0, + ) + + +def test_llm_sends_each_account_token_and_workspace_header() -> None: + first = LLM( + adapter_id=OpenAIOAuthLLMAdapter.get_id(), + adapter_metadata=_metadata("token-a", "account-a"), + ) + second = LLM( + adapter_id=OpenAIOAuthLLMAdapter.get_id(), + adapter_metadata=_metadata("token-b", "account-b"), + ) + + with ( + patch( + "unstract.sdk1.llm.litellm.responses", + side_effect=[ + _response_events("first"), + _response_events("second"), + ], + ) as responses, + patch.object(first, "_record_usage"), + patch.object(second, "_record_usage"), + ): + assert first.complete("hello")["response"].text == "first" + assert second.complete("hello")["response"].text == "second" + + first_request = responses.call_args_list[0].kwargs + second_request = responses.call_args_list[1].kwargs + assert first_request["api_key"] == "token-a" + assert second_request["api_key"] == "token-b" + assert first_request["extra_headers"]["ChatGPT-Account-Id"] == "account-a" + assert second_request["extra_headers"]["ChatGPT-Account-Id"] == "account-b" + assert first_request["extra_headers"]["Authorization"] == "Bearer token-a" + assert second_request["extra_headers"]["Authorization"] == "Bearer token-b" + assert first_request["extra_headers"]["session-id"] + assert first_request["include"] == ["reasoning.encrypted_content"] + assert "oauth_refresh_token" not in first_request + assert first_request["api_base"] == OPENAI_OAUTH_CHATGPT_API_BASE + assert first_request["stream"] is True + assert second_request["stream"] is True + assert "max_tokens" not in first_request + assert "max_output_tokens" not in first_request + + +def test_llm_streams_responses_text_and_records_completion_usage() -> None: + llm = LLM( + adapter_id=OpenAIOAuthLLMAdapter.get_id(), + adapter_metadata=_metadata("token", "account"), + ) + events = iter( + [ + {"type": "response.output_text.delta", "delta": "hello"}, + { + "type": "response.completed", + "response": { + "id": "response-1", + "usage": {"input_tokens": 4, "output_tokens": 2, "total_tokens": 6}, + }, + }, + ] + ) + + with ( + patch("unstract.sdk1.llm.litellm.responses", return_value=events), + patch.object(llm, "_record_usage") as record_usage, + ): + chunks = list(llm.stream_complete("hello")) + + assert [chunk.text for chunk in chunks] == ["hello"] + record_usage.assert_called_once() + assert record_usage.call_args.args[2] == { + "prompt_tokens": 4, + "completion_tokens": 2, + "total_tokens": 6, + } + + +def test_llm_async_completion_uses_responses_api() -> None: + llm = LLM( + adapter_id=OpenAIOAuthLLMAdapter.get_id(), + adapter_metadata=_metadata("token", "account"), + ) + async def response_events(): + yield { + "type": "response.output_text.delta", + "delta": "async response", + } + yield { + "type": "response.completed", + "response": { + "output_text": "async response", + "usage": {}, + }, + } + + with ( + patch( + "unstract.sdk1.llm.litellm.aresponses", + new=AsyncMock(return_value=response_events()), + ) as responses, + patch.object(llm, "_record_usage"), + ): + result = asyncio.run(llm.acomplete("hello")) + + assert result["response"].text == "async response" + assert responses.await_args.kwargs["extra_headers"]["ChatGPT-Account-Id"] == "account" + assert responses.await_args.kwargs["stream"] is True