diff --git a/openhands-agent-server/openhands/agent_server/llm_router.py b/openhands-agent-server/openhands/agent_server/llm_router.py index c28700d313..82cb3479a8 100644 --- a/openhands-agent-server/openhands/agent_server/llm_router.py +++ b/openhands-agent-server/openhands/agent_server/llm_router.py @@ -1,8 +1,21 @@ -"""Router for LLM model and provider information endpoints.""" +"""Router for LLM model, provider, and subscription information endpoints.""" -from fastapi import APIRouter, Query -from pydantic import BaseModel +from __future__ import annotations +import asyncio +import secrets +import time +from dataclasses import dataclass + +from fastapi import APIRouter, HTTPException, Query, status +from pydantic import BaseModel, Field + +from openhands.sdk.llm.auth.openai import ( + DEVICE_CODE_TIMEOUT_SECONDS, + OPENAI_CODEX_MODELS, + DeviceCode, + OpenAISubscriptionAuth, +) from openhands.sdk.llm.utils.unverified_models import ( _extract_model_and_provider, _get_litellm_provider_names, @@ -14,6 +27,21 @@ llm_router = APIRouter(prefix="/llm", tags=["LLM"]) +@dataclass(frozen=True) +class PendingDeviceLogin: + """Server-side state for an in-progress device-code login.""" + + device_code: DeviceCode + expires_at: int + epoch: int + + +_PENDING_OPENAI_DEVICE_LOGINS: dict[str, PendingDeviceLogin] = {} +_IN_FLIGHT_OPENAI_DEVICE_LOGINS: set[str] = set() +_OPENAI_DEVICE_LOGIN_LOCK = asyncio.Lock() +_OPENAI_DEVICE_LOGIN_EPOCH = 0 + + class ProvidersResponse(BaseModel): """Response containing the list of available LLM providers.""" @@ -27,11 +55,62 @@ class ModelsResponse(BaseModel): class VerifiedModelsResponse(BaseModel): - """Response containing verified models organized by provider.""" + """Response containing verified LLM models organized by provider.""" models: dict[str, list[str]] +class SubscriptionStatusResponse(BaseModel): + """Safe subscription authentication status.""" + + vendor: str = "openai" + connected: bool + account_email: str | None = None + expires_at: int | None = None + + +class SubscriptionDeviceStartResponse(BaseModel): + """Device-code challenge details for browser sign-in.""" + + device_code: str = Field(description="Opaque server-side polling token.") + user_code: str + verification_uri: str + verification_uri_complete: str | None = None + expires_at: int + interval_seconds: int + + +class SubscriptionDevicePollRequest(BaseModel): + """Poll request for a previously-started subscription device login.""" + + device_code: str + + +class SubscriptionModelsResponse(BaseModel): + """Models available through a subscription provider.""" + + vendor: str = "openai" + models: list[str] + + +def _get_openai_subscription_auth() -> OpenAISubscriptionAuth: + return OpenAISubscriptionAuth() + + +def _status_from_auth(auth: OpenAISubscriptionAuth) -> SubscriptionStatusResponse: + creds = auth.get_credentials() + if creds is None or creds.is_expired(): + return SubscriptionStatusResponse(connected=False) + return SubscriptionStatusResponse(connected=True, expires_at=creds.expires_at) + + +def _drop_expired_device_logins() -> None: + now = int(time.time() * 1000) + for key, pending in list(_PENDING_OPENAI_DEVICE_LOGINS.items()): + if pending.expires_at <= now: + _PENDING_OPENAI_DEVICE_LOGINS.pop(key, None) + + @llm_router.get("/providers", response_model=ProvidersResponse) async def list_providers() -> ProvidersResponse: """List all available LLM providers supported by LiteLLM.""" @@ -76,3 +155,109 @@ async def list_verified_models() -> VerifiedModelsResponse: with OpenHands. """ return VerifiedModelsResponse(models=VERIFIED_MODELS) + + +@llm_router.get( + "/subscription/openai/models", response_model=SubscriptionModelsResponse +) +async def list_openai_subscription_models() -> SubscriptionModelsResponse: + """List models available through ChatGPT subscription authentication.""" + return SubscriptionModelsResponse(models=sorted(OPENAI_CODEX_MODELS)) + + +@llm_router.get( + "/subscription/openai/status", response_model=SubscriptionStatusResponse +) +async def get_openai_subscription_status() -> SubscriptionStatusResponse: + """Return safe ChatGPT subscription connection state without tokens.""" + auth = _get_openai_subscription_auth() + try: + await auth.refresh_if_needed() + except RuntimeError: + return SubscriptionStatusResponse(connected=False) + return _status_from_auth(auth) + + +@llm_router.post( + "/subscription/openai/device/start", + response_model=SubscriptionDeviceStartResponse, +) +async def start_openai_subscription_device_login() -> SubscriptionDeviceStartResponse: + """Start ChatGPT device-code sign-in without returning tokens.""" + auth = _get_openai_subscription_auth() + challenge = await auth.start_device_login() + token = secrets.token_urlsafe(32) + expires_at = int(time.time() * 1000) + (DEVICE_CODE_TIMEOUT_SECONDS * 1000) + async with _OPENAI_DEVICE_LOGIN_LOCK: + _drop_expired_device_logins() + _PENDING_OPENAI_DEVICE_LOGINS[token] = PendingDeviceLogin( + device_code=challenge, + expires_at=expires_at, + epoch=_OPENAI_DEVICE_LOGIN_EPOCH, + ) + return SubscriptionDeviceStartResponse( + device_code=token, + user_code=challenge.user_code, + verification_uri=challenge.verification_url, + expires_at=expires_at, + interval_seconds=challenge.interval, + ) + + +@llm_router.post( + "/subscription/openai/device/poll", response_model=SubscriptionStatusResponse +) +async def poll_openai_subscription_device_login( + request: SubscriptionDevicePollRequest, +) -> SubscriptionStatusResponse: + """Poll a ChatGPT device-code sign-in without returning tokens.""" + async with _OPENAI_DEVICE_LOGIN_LOCK: + _drop_expired_device_logins() + pending = _PENDING_OPENAI_DEVICE_LOGINS.pop(request.device_code, None) + if pending is None: + if request.device_code in _IN_FLIGHT_OPENAI_DEVICE_LOGINS: + return SubscriptionStatusResponse(connected=False) + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Subscription device login not found or expired", + ) + _IN_FLIGHT_OPENAI_DEVICE_LOGINS.add(request.device_code) + + auth = _get_openai_subscription_auth() + try: + credentials = await auth.poll_device_login(pending.device_code, persist=False) + except BaseException: + async with _OPENAI_DEVICE_LOGIN_LOCK: + _IN_FLIGHT_OPENAI_DEVICE_LOGINS.discard(request.device_code) + if pending.epoch == _OPENAI_DEVICE_LOGIN_EPOCH: + _PENDING_OPENAI_DEVICE_LOGINS[request.device_code] = pending + raise + + async with _OPENAI_DEVICE_LOGIN_LOCK: + _IN_FLIGHT_OPENAI_DEVICE_LOGINS.discard(request.device_code) + current_epoch = _OPENAI_DEVICE_LOGIN_EPOCH + if credentials is None: + if pending.epoch == current_epoch: + _PENDING_OPENAI_DEVICE_LOGINS[request.device_code] = pending + return SubscriptionStatusResponse(connected=False) + if pending.epoch != current_epoch: + return SubscriptionStatusResponse(connected=False) + auth.save_credentials(credentials) + return SubscriptionStatusResponse( + connected=True, expires_at=credentials.expires_at + ) + + +@llm_router.post( + "/subscription/openai/logout", response_model=SubscriptionStatusResponse +) +async def logout_openai_subscription() -> SubscriptionStatusResponse: + """Remove stored ChatGPT subscription credentials.""" + global _OPENAI_DEVICE_LOGIN_EPOCH + + auth = _get_openai_subscription_auth() + async with _OPENAI_DEVICE_LOGIN_LOCK: + _OPENAI_DEVICE_LOGIN_EPOCH += 1 + _PENDING_OPENAI_DEVICE_LOGINS.clear() + auth.logout() + return SubscriptionStatusResponse(connected=False) diff --git a/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py b/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py index a6bb1ec60e..e802c09348 100644 --- a/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py +++ b/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py @@ -5,6 +5,7 @@ import uuid from collections.abc import Mapping from pathlib import Path +from typing import Any from openhands.sdk.agent.acp_agent import ACPAgent from openhands.sdk.agent.base import AgentBase @@ -44,6 +45,7 @@ from openhands.sdk.hooks import HookConfig, HookEventProcessor, create_hook_callback from openhands.sdk.io import LocalFileStore from openhands.sdk.llm import LLM, Message, TextContent +from openhands.sdk.llm.auth.openai import create_subscription_llm_from_config from openhands.sdk.llm.llm_profile_store import LLMProfileStore from openhands.sdk.llm.llm_registry import LLMRegistry from openhands.sdk.logger import get_logger @@ -92,6 +94,7 @@ class LocalConversation(BaseConversation): _resolved_plugins: list[ResolvedPluginSource] | None _plugins_loaded: bool _pending_hook_config: HookConfig | None # Hook config to combine with plugin hooks + _subscription_disabled_condenser: Any | None def __init__( self, @@ -172,6 +175,7 @@ def __init__( self._plugins_loaded = False self._pending_hook_config = hook_config # Will be combined with plugin hooks self._agent_ready = False # Agent initialized lazily after plugins loaded + self._subscription_disabled_condenser = None self.agent = agent if isinstance(workspace, (str, Path)): @@ -670,10 +674,21 @@ def switch_llm(self, llm: LLM) -> None: try: new_llm = self.llm_registry.get(llm.usage_id) except KeyError: - new_llm = llm + new_llm = create_subscription_llm_from_config(llm) self.llm_registry.add(new_llm) with self._state: - self.agent = self.agent.model_copy(update={"llm": new_llm}) + update: dict[str, Any] = {"llm": new_llm} + if new_llm.is_subscription: + if self.agent.condenser is not None: + self._subscription_disabled_condenser = self.agent.condenser + update["condenser"] = None + elif ( + self.agent.condenser is None + and self._subscription_disabled_condenser is not None + ): + update["condenser"] = self._subscription_disabled_condenser + self._subscription_disabled_condenser = None + self.agent = self.agent.model_copy(update=update) self._state.agent = self.agent self._pin_prompt_cache_key() diff --git a/openhands-sdk/openhands/sdk/llm/auth/__init__.py b/openhands-sdk/openhands/sdk/llm/auth/__init__.py index c67564c5bc..a2a8cffd59 100644 --- a/openhands-sdk/openhands/sdk/llm/auth/__init__.py +++ b/openhands-sdk/openhands/sdk/llm/auth/__init__.py @@ -12,6 +12,7 @@ OPENAI_CODEX_MODELS, OpenAISubscriptionAuth, SupportedVendor, + create_subscription_llm_from_config, inject_system_prefix, transform_for_subscription, ) @@ -23,6 +24,7 @@ "OpenAISubscriptionAuth", "OPENAI_CODEX_MODELS", "SupportedVendor", + "create_subscription_llm_from_config", "inject_system_prefix", "transform_for_subscription", ] diff --git a/openhands-sdk/openhands/sdk/llm/auth/openai.py b/openhands-sdk/openhands/sdk/llm/auth/openai.py index ece1e281fe..218225daef 100644 --- a/openhands-sdk/openhands/sdk/llm/auth/openai.py +++ b/openhands-sdk/openhands/sdk/llm/auth/openai.py @@ -329,31 +329,42 @@ async def _request_device_code() -> DeviceCode: ) -async def _poll_device_code(device_code: DeviceCode) -> dict[str, Any]: - """Poll until OpenAI issues an authorization code for a device login.""" - deadline = time.monotonic() + DEVICE_CODE_TIMEOUT_SECONDS +async def _poll_device_code_once(device_code: DeviceCode) -> dict[str, Any] | None: + """Poll once for an OpenAI device login result. + Returns ``None`` while authorization is still pending. + """ async with AsyncClient() as client: - while time.monotonic() < deadline: - response = await client.post( - f"{ISSUER}/api/accounts/deviceauth/token", - json={ - "device_auth_id": device_code.device_auth_id, - "user_code": device_code.user_code, - }, - headers={"Content-Type": "application/json"}, - ) + response = await client.post( + f"{ISSUER}/api/accounts/deviceauth/token", + json={ + "device_auth_id": device_code.device_auth_id, + "user_code": device_code.user_code, + }, + headers={"Content-Type": "application/json"}, + ) - if response.is_success: - return response.json() + if response.is_success: + return response.json() - if response.status_code in (403, 404): - await asyncio.sleep( - min(device_code.interval, max(0, deadline - time.monotonic())) - ) - continue + if response.status_code in (403, 404): + return None + + raise RuntimeError(f"Device auth failed with status {response.status_code}") + + +async def _poll_device_code(device_code: DeviceCode) -> dict[str, Any]: + """Poll until OpenAI issues an authorization code for a device login.""" + deadline = time.monotonic() + DEVICE_CODE_TIMEOUT_SECONDS - raise RuntimeError(f"Device auth failed with status {response.status_code}") + while time.monotonic() < deadline: + token_response = await _poll_device_code_once(device_code) + if token_response is not None: + return token_response + + await asyncio.sleep( + min(device_code.interval, max(0, deadline - time.monotonic())) + ) raise RuntimeError("Device auth timed out after 15 minutes") @@ -375,6 +386,23 @@ async def _refresh_access_token(refresh_token: str) -> dict[str, Any]: return response.json() +def _refresh_access_token_sync(refresh_token: str) -> dict[str, Any]: + """Synchronously refresh the access token using a refresh token.""" + with Client() as client: + response = client.post( + f"{ISSUER}/oauth/token", + data={ + "grant_type": "refresh_token", + "refresh_token": refresh_token, + "client_id": CLIENT_ID, + }, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + if not response.is_success: + raise RuntimeError(f"Token refresh failed: {response.status_code}") + return response.json() + + # HTML templates for OAuth callback _HTML_SUCCESS = """ @@ -481,6 +509,24 @@ async def refresh_if_needed(self) -> OAuthCredentials | None: ) return updated + def refresh_if_needed_sync(self) -> OAuthCredentials | None: + """Synchronously refresh credentials if they are expired.""" + creds = self.get_credentials() + if creds is None: + return None + + if not creds.is_expired(): + return creds + + logger.info("Refreshing OpenAI access token") + tokens = _refresh_access_token_sync(creds.refresh_token) + return self._credential_store.update_tokens( + vendor=self.vendor, + access_token=tokens["access_token"], + refresh_token=tokens.get("refresh_token"), + expires_in=tokens.get("expires_in", 3600), + ) + async def login( self, open_browser: bool = True, @@ -623,6 +669,22 @@ async def handle_callback(request: web.Request) -> web.Response: finally: await runner.cleanup() + async def start_device_login(self) -> DeviceCode: + """Start a device-code OAuth login flow without polling.""" + return await _request_device_code() + + async def poll_device_login( + self, device_code: DeviceCode, *, persist: bool = True + ) -> OAuthCredentials | None: + """Poll once for a device-code OAuth login result. + + Returns ``None`` while authorization is still pending. + """ + code_response = await _poll_device_code_once(device_code) + if code_response is None: + return None + return await self._complete_device_login(code_response, persist=persist) + async def _login_with_device_code(self) -> OAuthCredentials: """Perform device-code OAuth login flow.""" device_code = await _request_device_code() @@ -640,6 +702,15 @@ async def _login_with_device_code(self) -> OAuthCredentials: ) code_response = await _poll_device_code(device_code) + return await self._complete_device_login(code_response) + + async def _complete_device_login( + self, code_response: dict[str, Any], *, persist: bool = True + ) -> OAuthCredentials: + """Exchange a completed device auth response. + + Optionally persists credentials after the exchange succeeds. + """ try: authorization_code = code_response["authorization_code"] code_verifier = code_response["code_verifier"] @@ -659,10 +730,19 @@ async def _login_with_device_code(self) -> OAuthCredentials: refresh_token=tokens["refresh_token"], expires_at=expires_at, ) - self._credential_store.save(credentials) + if persist: + self.save_credentials(credentials) logger.info("OpenAI device-code login successful") return credentials + def save_credentials(self, credentials: OAuthCredentials) -> None: + """Persist OpenAI subscription credentials.""" + self._credential_store.save(credentials) + + def extract_chatgpt_account_id(self, credentials: OAuthCredentials) -> str | None: + """Return the ChatGPT account id for request headers, if present.""" + return _extract_chatgpt_account_id(credentials.access_token) + def logout(self) -> bool: """Remove stored credentials. @@ -733,12 +813,14 @@ def create_llm( llm = LLM( model=f"openai/{model}", base_url=CODEX_API_ENDPOINT.rsplit("/", 1)[0], - api_key=creds.access_token, + api_key=None, extra_headers=extra_headers, litellm_extra_body=extra_body, temperature=None, max_output_tokens=None, stream=True, + auth_type="subscription", + subscription_vendor="openai", **llm_kwargs, ) llm._is_subscription = True @@ -746,6 +828,10 @@ def create_llm( llm.max_output_tokens = None llm._effective_max_output_tokens = None llm.temperature = None + llm.auth_type = "subscription" + llm.subscription_vendor = "openai" + llm._subscription_credential_store = self._credential_store + llm._subscription_credentials = creds return llm @@ -809,6 +895,48 @@ async def subscription_login_async( return auth.create_llm(model=model, credentials=creds, **llm_kwargs) +def create_subscription_llm_from_config(llm: LLM) -> LLM: + """Create a runtime subscription LLM from a serialized LLM config.""" + if getattr(llm, "auth_type", "api_key") != "subscription": + return llm + + vendor = llm.subscription_vendor or "openai" + if vendor != "openai": + raise ValueError(f"Unsupported subscription vendor: {vendor}") + + model = llm.model + if model.startswith("openai/"): + model = model.removeprefix("openai/") + + auth = OpenAISubscriptionAuth() + credentials = auth.refresh_if_needed_sync() + if credentials is None: + raise ValueError("OpenAI subscription login is required") + + llm_kwargs = llm.model_dump( + exclude_none=True, + exclude_defaults=True, + exclude={ + "model", + "api_key", + "base_url", + "auth_type", + "subscription_vendor", + "extra_headers", + "max_output_tokens", + "stream", + "temperature", + }, + ) + llm_kwargs["usage_id"] = llm.usage_id + + return auth.create_llm( + model=model, + credentials=credentials, + **llm_kwargs, + ) + + def subscription_login( vendor: SupportedVendor = "openai", model: str = "gpt-5.2-codex", diff --git a/openhands-sdk/openhands/sdk/llm/llm.py b/openhands-sdk/openhands/sdk/llm/llm.py index 3bee201e0b..27ca649dfc 100644 --- a/openhands-sdk/openhands/sdk/llm/llm.py +++ b/openhands-sdk/openhands/sdk/llm/llm.py @@ -199,6 +199,23 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin): label="API Key", ), ) + auth_type: Literal["api_key", "subscription"] = Field( + default="api_key", + description="Authentication mode for the LLM.", + json_schema_extra=field_meta( + SettingProminence.CRITICAL, + label="Authentication", + ), + ) + subscription_vendor: Literal["openai"] | None = Field( + default=None, + description="Subscription provider for subscription-backed LLM access.", + json_schema_extra=field_meta( + SettingProminence.CRITICAL, + label="Subscription provider", + depends_on=("auth_type",), + ), + ) base_url: str | None = Field( default=None, description="Custom base URL.", @@ -464,6 +481,8 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin): _tokenizer: Any = PrivateAttr(default=None) _telemetry: Telemetry | None = PrivateAttr(default=None) _is_subscription: bool = PrivateAttr(default=False) + _subscription_credential_store: Any = PrivateAttr(default=None) + _subscription_credentials: Any = PrivateAttr(default=None) _litellm_provider: str | None = PrivateAttr(default=None) _prompt_cache_key: str | None = PrivateAttr(default=None) _effective_max_input_tokens: int | None = PrivateAttr(default=None) @@ -1493,7 +1512,24 @@ def _infer_model_info_provider(self) -> str | None: def _get_litellm_api_key_value(self) -> str | None: api_key_value: str | None = None - if self.api_key: + if self.is_subscription: + from openhands.sdk.llm.auth.openai import OpenAISubscriptionAuth + + auth = OpenAISubscriptionAuth( + credential_store=self._subscription_credential_store + ) + credentials = auth.refresh_if_needed_sync() + if credentials is None: + credentials = self._subscription_credentials + if credentials is None: + raise ValueError("OpenAI subscription login is required") + api_key_value = credentials.access_token + account_id = auth.extract_chatgpt_account_id(credentials) + self.extra_headers = { + **(self.extra_headers or {}), + **({"chatgpt-account-id": account_id} if account_id else {}), + } + elif self.api_key: assert isinstance(self.api_key, SecretStr) api_key_value = self.api_key.get_secret_value() @@ -2004,11 +2040,22 @@ def from_persisted(cls, data: Any, *, context: dict[str, Any] | None = None) -> ) payload.pop("schema_version", None) - return cls.model_validate(payload, context=context) + llm = cls.model_validate(payload, context=context) + if llm.auth_type == "subscription": + from openhands.sdk.llm.auth.openai import ( + create_subscription_llm_from_config, + ) + + return create_subscription_llm_from_config(llm) + return llm def to_persisted(self, *, context: dict[str, Any] | None = None) -> dict[str, Any]: """Serialize this LLM for profile persistence.""" data = self.model_dump(mode="json", exclude_none=True, context=context) + if data.get("auth_type") == "subscription": + data.pop("api_key", None) + data.pop("base_url", None) + data.pop("extra_headers", None) data["schema_version"] = LLM_PROFILE_SCHEMA_VERSION return data diff --git a/openhands-sdk/openhands/sdk/settings/model.py b/openhands-sdk/openhands/sdk/settings/model.py index 1a1fcb589b..a57c66c1b0 100644 --- a/openhands-sdk/openhands/sdk/settings/model.py +++ b/openhands-sdk/openhands/sdk/settings/model.py @@ -859,6 +859,7 @@ def create_agent(self) -> Agent: agent = settings.create_agent() """ from openhands.sdk.agent import Agent + from openhands.sdk.llm.auth.openai import create_subscription_llm_from_config from openhands.sdk.tool.builtins import BUILT_IN_TOOLS, SwitchLLMTool from openhands.sdk.tool.builtins.switch_llm import has_llm_profiles @@ -872,13 +873,15 @@ def create_agent(self) -> Agent: if self.enable_switch_llm_tool and has_llm_profiles(): include_default_tools.append(SwitchLLMTool.__name__) + llm = create_subscription_llm_from_config(self.llm) + condenser = None if llm.is_subscription else self.build_condenser(llm) return Agent( - llm=self.llm, + llm=llm, tools=self.tools, mcp_config=mcp_config, include_default_tools=include_default_tools, agent_context=self.agent_context, - condenser=self.build_condenser(self.llm), + condenser=condenser, critic=self.build_critic(), ) diff --git a/tests/agent_server/test_llm_router.py b/tests/agent_server/test_llm_router.py index e6414090dd..8c480173a8 100644 --- a/tests/agent_server/test_llm_router.py +++ b/tests/agent_server/test_llm_router.py @@ -110,3 +110,168 @@ def test_verified_models_endpoint_integration(client): assert "models" in data assert "openai" in data["models"] assert "anthropic" in data["models"] + + +def test_openai_subscription_status_endpoint_does_not_return_tokens( + client, monkeypatch +): + """Status reports safe metadata without exposing OAuth tokens.""" + from openhands.agent_server import llm_router + from openhands.sdk.llm.auth.credentials import OAuthCredentials + + class FakeAuth: + async def refresh_if_needed(self): + return OAuthCredentials( + vendor="openai", + access_token="access-token", + refresh_token="refresh-token", + expires_at=4_102_444_800_000, + ) + + def get_credentials(self): + return OAuthCredentials( + vendor="openai", + access_token="access-token", + refresh_token="refresh-token", + expires_at=4_102_444_800_000, + ) + + monkeypatch.setattr(llm_router, "_get_openai_subscription_auth", FakeAuth) + + response = client.get("/api/llm/subscription/openai/status") + + assert response.status_code == 200 + data = response.json() + assert data == { + "vendor": "openai", + "connected": True, + "account_email": None, + "expires_at": 4_102_444_800_000, + } + assert "access_token" not in response.text + assert "refresh_token" not in response.text + + +def test_openai_subscription_device_start_returns_opaque_poll_token( + client, monkeypatch +): + """Device start stores OpenAI internals server-side.""" + from openhands.agent_server import llm_router + from openhands.sdk.llm.auth.openai import DeviceCode + + class FakeAuth: + async def start_device_login(self): + return DeviceCode( + verification_url="https://auth.example/device", + user_code="ABCD-EFGH", + device_auth_id="openai-device-auth-id", + interval=7, + ) + + monkeypatch.setattr(llm_router, "_get_openai_subscription_auth", FakeAuth) + monkeypatch.setattr(llm_router.secrets, "token_urlsafe", lambda _: "opaque-token") + + response = client.post("/api/llm/subscription/openai/device/start") + + assert response.status_code == 200 + data = response.json() + assert data["device_code"] == "opaque-token" + assert data["user_code"] == "ABCD-EFGH" + assert data["verification_uri"] == "https://auth.example/device" + assert data["interval_seconds"] == 7 + assert "openai-device-auth-id" not in response.text + + +def test_openai_subscription_device_poll_pending_and_success(client, monkeypatch): + """Polling returns disconnected while pending and connected after success.""" + from openhands.agent_server import llm_router + from openhands.sdk.llm.auth.credentials import OAuthCredentials + from openhands.sdk.llm.auth.openai import DeviceCode + + llm_router._PENDING_OPENAI_DEVICE_LOGINS.clear() + llm_router._PENDING_OPENAI_DEVICE_LOGINS["opaque-token"] = ( + llm_router.PendingDeviceLogin( + device_code=DeviceCode( + verification_url="https://auth.example/device", + user_code="ABCD-EFGH", + device_auth_id="openai-device-auth-id", + interval=1, + ), + expires_at=int(llm_router.time.time() * 1000) + 60_000, + epoch=llm_router._OPENAI_DEVICE_LOGIN_EPOCH, + ) + ) + + class FakeAuth: + calls = 0 + + saved_credentials = None + + async def poll_device_login(self, device_code, *, persist=True): + assert persist is False + self.__class__.calls += 1 + if self.__class__.calls == 1: + return None + return OAuthCredentials( + vendor="openai", + access_token="access-token", + refresh_token="refresh-token", + expires_at=4_102_444_800_000, + ) + + def save_credentials(self, credentials): + self.__class__.saved_credentials = credentials + + monkeypatch.setattr(llm_router, "_get_openai_subscription_auth", FakeAuth) + + pending = client.post( + "/api/llm/subscription/openai/device/poll", + json={"device_code": "opaque-token"}, + ) + success = client.post( + "/api/llm/subscription/openai/device/poll", + json={"device_code": "opaque-token"}, + ) + + assert pending.status_code == 200 + assert pending.json()["connected"] is False + assert success.status_code == 200 + assert success.json()["connected"] is True + assert success.json()["expires_at"] == 4_102_444_800_000 + assert FakeAuth.saved_credentials is not None + assert "access-token" not in success.text + assert "opaque-token" not in llm_router._PENDING_OPENAI_DEVICE_LOGINS + + +def test_openai_subscription_logout_endpoint(client, monkeypatch): + """Logout removes credentials and returns disconnected status.""" + from openhands.agent_server import llm_router + + llm_router._PENDING_OPENAI_DEVICE_LOGINS["opaque-token"] = ( + llm_router.PendingDeviceLogin( + device_code=llm_router.DeviceCode( + verification_url="https://auth.example/device", + user_code="ABCD-EFGH", + device_auth_id="openai-device-auth-id", + interval=1, + ), + expires_at=int(llm_router.time.time() * 1000) + 60_000, + epoch=llm_router._OPENAI_DEVICE_LOGIN_EPOCH, + ) + ) + + class FakeAuth: + logged_out = False + + def logout(self): + self.__class__.logged_out = True + return True + + monkeypatch.setattr(llm_router, "_get_openai_subscription_auth", FakeAuth) + + response = client.post("/api/llm/subscription/openai/logout") + + assert response.status_code == 200 + assert response.json()["connected"] is False + assert FakeAuth.logged_out is True + assert llm_router._PENDING_OPENAI_DEVICE_LOGINS == {} diff --git a/tests/sdk/conversation/test_switch_model.py b/tests/sdk/conversation/test_switch_model.py index cb40b54cec..20975ef2a3 100644 --- a/tests/sdk/conversation/test_switch_model.py +++ b/tests/sdk/conversation/test_switch_model.py @@ -5,6 +5,7 @@ from openhands.sdk import LLM, LocalConversation from openhands.sdk.agent import Agent +from openhands.sdk.context.condenser import LLMSummarizingCondenser from openhands.sdk.llm import llm_profile_store from openhands.sdk.llm.llm_profile_store import LLMProfileStore from openhands.sdk.testing import TestLLM @@ -252,3 +253,55 @@ def _spy(llm): assert len(seen) == 1 assert seen[0].usage_id == "profile:fast" assert seen[0].model == "fast-model" + + +def test_switch_llm_to_subscription_profile_disables_condenser( + monkeypatch, empty_profile_store +): + import openhands.sdk.conversation.impl.local_conversation as local_conversation + + condenser = LLMSummarizingCondenser( + llm=_make_llm("condenser-model", "condenser"), + max_size=100, + keep_first=5, + ) + conv = LocalConversation( + agent=Agent( + llm=_make_llm("default-model", "test-llm"), + tools=[], + condenser=condenser, + ), + workspace=Path.cwd(), + ) + assert conv.agent.condenser is condenser + + def fake_create_subscription_llm_from_config(llm: LLM) -> LLM: + runtime = llm.model_copy() + if llm.auth_type == "subscription": + runtime._is_subscription = True + return runtime + + monkeypatch.setattr( + local_conversation, + "create_subscription_llm_from_config", + fake_create_subscription_llm_from_config, + ) + + conv.switch_llm( + LLM( + model="gpt-5.2-codex", + usage_id="profile:codex", + auth_type="subscription", + subscription_vendor="openai", + ) + ) + + assert conv.agent.llm.is_subscription + assert conv.agent.condenser is None + assert conv.state.agent.condenser is None + + conv.switch_llm(_make_llm("regular-model", "regular")) + + assert conv.agent.llm.model == "regular-model" + assert conv.agent.condenser is condenser + assert conv.state.agent.condenser is condenser diff --git a/tests/sdk/llm/auth/test_openai.py b/tests/sdk/llm/auth/test_openai.py index 35ed3af68a..7e9c8bd4f5 100644 --- a/tests/sdk/llm/auth/test_openai.py +++ b/tests/sdk/llm/auth/test_openai.py @@ -204,7 +204,10 @@ def test_openai_subscription_auth_create_llm_success(tmp_path): llm = auth.create_llm(model="gpt-5.2-codex") assert llm.model == "openai/gpt-5.2-codex" - assert llm.api_key is not None + assert llm.api_key is None + assert llm._get_litellm_api_key_value() == "test_access_token" + assert llm.auth_type == "subscription" + assert llm.subscription_vendor == "openai" assert llm.extra_headers is not None # Uses codex_cli_rs to match official Codex CLI for compatibility assert llm.extra_headers.get("originator") == "codex_cli_rs" diff --git a/tests/sdk/test_settings.py b/tests/sdk/test_settings.py index ff27ca5a1b..2a24b94149 100644 --- a/tests/sdk/test_settings.py +++ b/tests/sdk/test_settings.py @@ -1128,3 +1128,155 @@ def test_acp_agent_reports_no_openhands_capabilities() -> None: assert agent.supports_openhands_mcp is False assert agent.supports_condenser is False assert agent.agent_kind == "acp" + + +def test_llm_subscription_fields_roundtrip() -> None: + settings = validate_agent_settings( + { + "llm": { + "model": "gpt-5.2-codex", + "auth_type": "subscription", + "subscription_vendor": "openai", + } + } + ) + + assert isinstance(settings, OpenHandsAgentSettings) + assert settings.llm.auth_type == "subscription" + assert settings.llm.subscription_vendor == "openai" + dumped = settings.model_dump(mode="json", exclude_none=True) + assert dumped["llm"]["auth_type"] == "subscription" + assert dumped["llm"]["subscription_vendor"] == "openai" + assert "api_key" not in dumped["llm"] + + +def test_llm_create_agent_resolves_subscription_llm(monkeypatch) -> None: + from openhands.sdk.llm.auth import openai + + original_llm = LLM( + model="gpt-5.2-codex", + auth_type="subscription", + subscription_vendor="openai", + ) + runtime_llm = LLM(model="openai/gpt-5.2-codex") + runtime_llm._is_subscription = True + + def fake_create_subscription_llm_from_config(llm: LLM) -> LLM: + assert llm is original_llm + return runtime_llm + + monkeypatch.setattr( + openai, + "create_subscription_llm_from_config", + fake_create_subscription_llm_from_config, + ) + + agent = OpenHandsAgentSettings(llm=original_llm).create_agent() + + assert agent.llm is runtime_llm + assert agent.llm.is_subscription is True + + +def test_llm_from_persisted_rehydrates_subscription_runtime(monkeypatch) -> None: + from openhands.sdk.llm.auth import openai + + runtime_llm = LLM(model="openai/gpt-5.2-codex", auth_type="subscription") + runtime_llm._is_subscription = True + + def fake_create_subscription_llm_from_config(llm: LLM) -> LLM: + assert llm.auth_type == "subscription" + assert llm.subscription_vendor == "openai" + return runtime_llm + + monkeypatch.setattr( + openai, + "create_subscription_llm_from_config", + fake_create_subscription_llm_from_config, + ) + + loaded = LLM.from_persisted( + { + "model": "gpt-5.2-codex", + "auth_type": "subscription", + "subscription_vendor": "openai", + "schema_version": 1, + } + ) + + assert loaded is runtime_llm + assert loaded.is_subscription is True + + +def test_openai_subscription_create_llm_serializes_subscription_auth( + monkeypatch, +) -> None: + import openhands.sdk.llm.auth.openai as openai_auth + from openhands.sdk.llm.auth.credentials import OAuthCredentials + from openhands.sdk.llm.auth.openai import OpenAISubscriptionAuth + + monkeypatch.setattr(openai_auth, "_extract_chatgpt_account_id", lambda _: None) + + llm = OpenAISubscriptionAuth().create_llm( + model="gpt-5.2-codex", + credentials=OAuthCredentials( + vendor="openai", + access_token="access-token", + refresh_token="refresh-token", + expires_at=4_102_444_800_000, + ), + usage_id="profile:test", + ) + + assert llm.auth_type == "subscription" + assert llm.subscription_vendor == "openai" + assert llm.api_key is None + assert llm.to_persisted()["auth_type"] == "subscription" + assert llm.to_persisted()["subscription_vendor"] == "openai" + assert "api_key" not in llm.to_persisted(context={"expose_secrets": "plaintext"}) + assert "base_url" not in llm.to_persisted() + + +def test_create_subscription_llm_from_config_preserves_non_auth_options( + monkeypatch, +) -> None: + import openhands.sdk.llm.auth.openai as openai_auth + from openhands.sdk.llm.auth.credentials import OAuthCredentials + + captured: dict[str, object] = {} + + class FakeAuth: + def refresh_if_needed_sync(self): + return OAuthCredentials( + vendor="openai", + access_token="access-token", + refresh_token="refresh-token", + expires_at=4_102_444_800_000, + ) + + def create_llm(self, **kwargs): + captured.update(kwargs) + return LLM( + model=f"openai/{kwargs['model']}", + auth_type="subscription", + subscription_vendor="openai", + usage_id=kwargs["usage_id"], + timeout=kwargs["timeout"], + ) + + monkeypatch.setattr(openai_auth, "OpenAISubscriptionAuth", FakeAuth) + source = LLM( + model="gpt-5.2-codex", + auth_type="subscription", + subscription_vendor="openai", + usage_id="profile:test", + timeout=123, + ) + + runtime = openai_auth.create_subscription_llm_from_config(source) + + assert runtime.usage_id == "profile:test" + assert runtime.timeout == 123 + assert captured["usage_id"] == "profile:test" + assert captured["timeout"] == 123 + assert "api_key" not in captured + assert "base_url" not in captured