From c47c75573cf1d1b614458207ae34cc9387517867 Mon Sep 17 00:00:00 2001 From: openhands Date: Sat, 23 May 2026 15:30:25 +0000 Subject: [PATCH 1/8] Add LLM subscription auth endpoints Co-authored-by: openhands --- .../openhands/agent_server/llm_router.py | 161 +++++++++++++++++- .../conversation/impl/local_conversation.py | 3 +- .../openhands/sdk/llm/auth/__init__.py | 2 + .../openhands/sdk/llm/auth/openai.py | 101 ++++++++--- openhands-sdk/openhands/sdk/llm/llm.py | 17 ++ openhands-sdk/openhands/sdk/settings/model.py | 6 +- tests/agent_server/test_llm_router.py | 143 ++++++++++++++++ tests/sdk/test_settings.py | 47 +++++ 8 files changed, 453 insertions(+), 27 deletions(-) diff --git a/openhands-agent-server/openhands/agent_server/llm_router.py b/openhands-agent-server/openhands/agent_server/llm_router.py index c28700d313..9fa17b8d97 100644 --- a/openhands-agent-server/openhands/agent_server/llm_router.py +++ b/openhands-agent-server/openhands/agent_server/llm_router.py @@ -1,8 +1,20 @@ -"""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 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 +26,17 @@ 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 + + +_PENDING_OPENAI_DEVICE_LOGINS: dict[str, PendingDeviceLogin] = {} + + class ProvidersResponse(BaseModel): """Response containing the list of available LLM providers.""" @@ -27,11 +50,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 +150,82 @@ 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) + _drop_expired_device_logins() + _PENDING_OPENAI_DEVICE_LOGINS[token] = PendingDeviceLogin( + device_code=challenge, + expires_at=expires_at, + ) + 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.""" + _drop_expired_device_logins() + pending = _PENDING_OPENAI_DEVICE_LOGINS.get(request.device_code) + if pending is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Subscription device login not found or expired", + ) + + auth = _get_openai_subscription_auth() + credentials = await auth.poll_device_login(pending.device_code) + if credentials is None: + return SubscriptionStatusResponse(connected=False) + + _PENDING_OPENAI_DEVICE_LOGINS.pop(request.device_code, None) + 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.""" + auth = _get_openai_subscription_auth() + 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..6bd0a85161 100644 --- a/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py +++ b/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py @@ -44,6 +44,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 @@ -670,7 +671,7 @@ 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}) 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..3e74dd4aab 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") @@ -623,6 +634,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 + ) -> 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) + async def _login_with_device_code(self) -> OAuthCredentials: """Perform device-code OAuth login flow.""" device_code = await _request_device_code() @@ -640,6 +667,12 @@ 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] + ) -> OAuthCredentials: + """Exchange a completed device auth response and persist credentials.""" try: authorization_code = code_response["authorization_code"] code_verifier = code_response["code_verifier"] @@ -809,6 +842,34 @@ 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.get_credentials() + if credentials is None or credentials.is_expired(): + raise ValueError("OpenAI subscription login is required") + + runtime_llm = auth.create_llm( + model=model, + credentials=credentials, + usage_id=llm.usage_id, + ) + runtime_llm.auth_type = "subscription" + runtime_llm.subscription_vendor = "openai" + return runtime_llm + + 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..b5ff5f94e6 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.", diff --git a/openhands-sdk/openhands/sdk/settings/model.py b/openhands-sdk/openhands/sdk/settings/model.py index 4e395b55a0..e7c1bb7345 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,14 @@ 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) 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=self.build_condenser(llm), critic=self.build_critic(), ) diff --git a/tests/agent_server/test_llm_router.py b/tests/agent_server/test_llm_router.py index e6414090dd..7c286d7619 100644 --- a/tests/agent_server/test_llm_router.py +++ b/tests/agent_server/test_llm_router.py @@ -110,3 +110,146 @@ 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, + ) + ) + + class FakeAuth: + calls = 0 + + async def poll_device_login(self, device_code): + 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, + ) + + 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 "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 + + 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 diff --git a/tests/sdk/test_settings.py b/tests/sdk/test_settings.py index 8085138d1d..a1c7d1ef9f 100644 --- a/tests/sdk/test_settings.py +++ b/tests/sdk/test_settings.py @@ -1119,3 +1119,50 @@ 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 From a97e864df8ac394b39b46484556b07aba1cb4733 Mon Sep 17 00:00:00 2001 From: openhands Date: Sat, 23 May 2026 15:53:52 +0000 Subject: [PATCH 2/8] Address subscription auth lifecycle review Co-authored-by: openhands --- .../openhands/agent_server/llm_router.py | 58 +++++++++++---- .../openhands/sdk/llm/auth/openai.py | 67 +++++++++++++++-- openhands-sdk/openhands/sdk/settings/model.py | 3 +- tests/agent_server/test_llm_router.py | 15 ++++ tests/sdk/test_settings.py | 72 +++++++++++++++++++ 5 files changed, 192 insertions(+), 23 deletions(-) diff --git a/openhands-agent-server/openhands/agent_server/llm_router.py b/openhands-agent-server/openhands/agent_server/llm_router.py index 9fa17b8d97..3e2e8a868a 100644 --- a/openhands-agent-server/openhands/agent_server/llm_router.py +++ b/openhands-agent-server/openhands/agent_server/llm_router.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio import secrets import time from dataclasses import dataclass @@ -32,9 +33,13 @@ class PendingDeviceLogin: 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): @@ -183,11 +188,13 @@ async def start_openai_subscription_device_login() -> SubscriptionDeviceStartRes challenge = await auth.start_device_login() token = secrets.token_urlsafe(32) expires_at = int(time.time() * 1000) + (DEVICE_CODE_TIMEOUT_SECONDS * 1000) - _drop_expired_device_logins() - _PENDING_OPENAI_DEVICE_LOGINS[token] = PendingDeviceLogin( - device_code=challenge, - expires_at=expires_at, - ) + 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, @@ -204,20 +211,36 @@ async def poll_openai_subscription_device_login( request: SubscriptionDevicePollRequest, ) -> SubscriptionStatusResponse: """Poll a ChatGPT device-code sign-in without returning tokens.""" - _drop_expired_device_logins() - pending = _PENDING_OPENAI_DEVICE_LOGINS.get(request.device_code) - if pending is None: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Subscription device login not found or expired", - ) + 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() - credentials = await auth.poll_device_login(pending.device_code) - if credentials is None: + try: + credentials = await auth.poll_device_login(pending.device_code) + finally: + async with _OPENAI_DEVICE_LOGIN_LOCK: + _IN_FLIGHT_OPENAI_DEVICE_LOGINS.discard(request.device_code) + + async with _OPENAI_DEVICE_LOGIN_LOCK: + 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: + auth.logout() return SubscriptionStatusResponse(connected=False) - _PENDING_OPENAI_DEVICE_LOGINS.pop(request.device_code, None) return SubscriptionStatusResponse(connected=True, expires_at=credentials.expires_at) @@ -226,6 +249,11 @@ async def poll_openai_subscription_device_login( ) async def logout_openai_subscription() -> SubscriptionStatusResponse: """Remove stored ChatGPT subscription credentials.""" + global _OPENAI_DEVICE_LOGIN_EPOCH + auth = _get_openai_subscription_auth() auth.logout() + async with _OPENAI_DEVICE_LOGIN_LOCK: + _OPENAI_DEVICE_LOGIN_EPOCH += 1 + _PENDING_OPENAI_DEVICE_LOGINS.clear() return SubscriptionStatusResponse(connected=False) diff --git a/openhands-sdk/openhands/sdk/llm/auth/openai.py b/openhands-sdk/openhands/sdk/llm/auth/openai.py index 3e74dd4aab..6a45d210f8 100644 --- a/openhands-sdk/openhands/sdk/llm/auth/openai.py +++ b/openhands-sdk/openhands/sdk/llm/auth/openai.py @@ -386,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 = """ @@ -492,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, @@ -772,6 +807,8 @@ def create_llm( temperature=None, max_output_tokens=None, stream=True, + auth_type="subscription", + subscription_vendor="openai", **llm_kwargs, ) llm._is_subscription = True @@ -779,6 +816,8 @@ 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" return llm @@ -856,18 +895,32 @@ def create_subscription_llm_from_config(llm: LLM) -> LLM: model = model.removeprefix("openai/") auth = OpenAISubscriptionAuth() - credentials = auth.get_credentials() - if credentials is None or credentials.is_expired(): + credentials = auth.refresh_if_needed_sync() + if credentials is None: raise ValueError("OpenAI subscription login is required") - runtime_llm = auth.create_llm( + 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, - usage_id=llm.usage_id, + **llm_kwargs, ) - runtime_llm.auth_type = "subscription" - runtime_llm.subscription_vendor = "openai" - return runtime_llm def subscription_login( diff --git a/openhands-sdk/openhands/sdk/settings/model.py b/openhands-sdk/openhands/sdk/settings/model.py index e7c1bb7345..db61d9417b 100644 --- a/openhands-sdk/openhands/sdk/settings/model.py +++ b/openhands-sdk/openhands/sdk/settings/model.py @@ -874,13 +874,14 @@ def create_agent(self) -> Agent: 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=llm, tools=self.tools, mcp_config=mcp_config, include_default_tools=include_default_tools, agent_context=self.agent_context, - condenser=self.build_condenser(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 7c286d7619..fe4ddf0007 100644 --- a/tests/agent_server/test_llm_router.py +++ b/tests/agent_server/test_llm_router.py @@ -198,6 +198,7 @@ def test_openai_subscription_device_poll_pending_and_success(client, monkeypatch interval=1, ), expires_at=int(llm_router.time.time() * 1000) + 60_000, + epoch=llm_router._OPENAI_DEVICE_LOGIN_EPOCH, ) ) @@ -239,6 +240,19 @@ 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 @@ -253,3 +267,4 @@ def logout(self): 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/test_settings.py b/tests/sdk/test_settings.py index a1c7d1ef9f..9706a78786 100644 --- a/tests/sdk/test_settings.py +++ b/tests/sdk/test_settings.py @@ -1166,3 +1166,75 @@ def fake_create_subscription_llm_from_config(llm: LLM) -> LLM: assert agent.llm is runtime_llm assert agent.llm.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.to_persisted()["auth_type"] == "subscription" + assert llm.to_persisted()["subscription_vendor"] == "openai" + + +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 From bb71494030b35a1591a34cf79796f0be2dffc1f5 Mon Sep 17 00:00:00 2001 From: openhands Date: Sat, 23 May 2026 16:21:35 +0000 Subject: [PATCH 3/8] Harden subscription credential refresh lifecycle Co-authored-by: openhands --- .../openhands/agent_server/llm_router.py | 15 +++--- .../conversation/impl/local_conversation.py | 5 +- .../openhands/sdk/llm/auth/openai.py | 24 +++++++--- openhands-sdk/openhands/sdk/llm/llm.py | 19 +++++++- tests/agent_server/test_llm_router.py | 12 ++++- tests/sdk/conversation/test_switch_model.py | 46 +++++++++++++++++++ tests/sdk/test_settings.py | 3 ++ 7 files changed, 109 insertions(+), 15 deletions(-) diff --git a/openhands-agent-server/openhands/agent_server/llm_router.py b/openhands-agent-server/openhands/agent_server/llm_router.py index 3e2e8a868a..6a23d4f80d 100644 --- a/openhands-agent-server/openhands/agent_server/llm_router.py +++ b/openhands-agent-server/openhands/agent_server/llm_router.py @@ -225,22 +225,25 @@ async def poll_openai_subscription_device_login( auth = _get_openai_subscription_auth() try: - credentials = await auth.poll_device_login(pending.device_code) - finally: + 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) - if pending.epoch != current_epoch: - auth.logout() - return SubscriptionStatusResponse(connected=False) - + auth.save_credentials(credentials) return SubscriptionStatusResponse(connected=True, expires_at=credentials.expires_at) diff --git a/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py b/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py index 6bd0a85161..bade8a87b2 100644 --- a/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py +++ b/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py @@ -674,7 +674,10 @@ def switch_llm(self, llm: LLM) -> None: 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 = {"llm": new_llm} + if new_llm.is_subscription: + update["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/openai.py b/openhands-sdk/openhands/sdk/llm/auth/openai.py index 6a45d210f8..cf7a84ff20 100644 --- a/openhands-sdk/openhands/sdk/llm/auth/openai.py +++ b/openhands-sdk/openhands/sdk/llm/auth/openai.py @@ -674,7 +674,7 @@ async def start_device_login(self) -> DeviceCode: return await _request_device_code() async def poll_device_login( - self, device_code: DeviceCode + self, device_code: DeviceCode, *, persist: bool = True ) -> OAuthCredentials | None: """Poll once for a device-code OAuth login result. @@ -683,7 +683,7 @@ async def poll_device_login( code_response = await _poll_device_code_once(device_code) if code_response is None: return None - return await self._complete_device_login(code_response) + return await self._complete_device_login(code_response, persist=persist) async def _login_with_device_code(self) -> OAuthCredentials: """Perform device-code OAuth login flow.""" @@ -705,9 +705,12 @@ async def _login_with_device_code(self) -> OAuthCredentials: return await self._complete_device_login(code_response) async def _complete_device_login( - self, code_response: dict[str, Any] + self, code_response: dict[str, Any], *, persist: bool = True ) -> OAuthCredentials: - """Exchange a completed device auth response and persist credentials.""" + """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"] @@ -727,10 +730,19 @@ async def _complete_device_login( 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. @@ -801,7 +813,7 @@ 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, diff --git a/openhands-sdk/openhands/sdk/llm/llm.py b/openhands-sdk/openhands/sdk/llm/llm.py index b5ff5f94e6..d53bbedf2b 100644 --- a/openhands-sdk/openhands/sdk/llm/llm.py +++ b/openhands-sdk/openhands/sdk/llm/llm.py @@ -1510,7 +1510,20 @@ 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() + credentials = auth.refresh_if_needed_sync() + 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() @@ -2026,6 +2039,10 @@ def from_persisted(cls, data: Any, *, context: dict[str, Any] | None = None) -> 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/tests/agent_server/test_llm_router.py b/tests/agent_server/test_llm_router.py index fe4ddf0007..e985da6675 100644 --- a/tests/agent_server/test_llm_router.py +++ b/tests/agent_server/test_llm_router.py @@ -136,6 +136,9 @@ def get_credentials(self): 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) response = client.get("/api/llm/subscription/openai/status") @@ -205,7 +208,10 @@ def test_openai_subscription_device_poll_pending_and_success(client, monkeypatch class FakeAuth: calls = 0 - async def poll_device_login(self, device_code): + 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 @@ -216,6 +222,9 @@ async def poll_device_login(self, device_code): 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( @@ -232,6 +241,7 @@ async def poll_device_login(self, device_code): 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 diff --git a/tests/sdk/conversation/test_switch_model.py b/tests/sdk/conversation/test_switch_model.py index cb40b54cec..fa91d22c0b 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,48 @@ 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() + 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 diff --git a/tests/sdk/test_settings.py b/tests/sdk/test_settings.py index 9706a78786..929d8d50a1 100644 --- a/tests/sdk/test_settings.py +++ b/tests/sdk/test_settings.py @@ -1190,8 +1190,11 @@ def test_openai_subscription_create_llm_serializes_subscription_auth( 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( From eb74e094a9df441c3bb9d9f131ab7e015ccf66d5 Mon Sep 17 00:00:00 2001 From: openhands Date: Sat, 23 May 2026 16:34:32 +0000 Subject: [PATCH 4/8] Avoid persisting subscription runtime tokens Co-authored-by: openhands --- .../openhands/sdk/conversation/impl/local_conversation.py | 3 ++- tests/sdk/llm/auth/test_openai.py | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py b/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py index bade8a87b2..41de9c5724 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 @@ -674,7 +675,7 @@ def switch_llm(self, llm: LLM) -> None: new_llm = create_subscription_llm_from_config(llm) self.llm_registry.add(new_llm) with self._state: - update = {"llm": new_llm} + update: dict[str, Any] = {"llm": new_llm} if new_llm.is_subscription: update["condenser"] = None self.agent = self.agent.model_copy(update=update) diff --git a/tests/sdk/llm/auth/test_openai.py b/tests/sdk/llm/auth/test_openai.py index 35ed3af68a..a5a43239af 100644 --- a/tests/sdk/llm/auth/test_openai.py +++ b/tests/sdk/llm/auth/test_openai.py @@ -204,7 +204,9 @@ 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.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" From 935bd658d4893a6baf3da4d85583f65b70a67172 Mon Sep 17 00:00:00 2001 From: openhands Date: Sat, 23 May 2026 16:45:41 +0000 Subject: [PATCH 5/8] Fix subscription router test typing Co-authored-by: openhands --- tests/agent_server/test_llm_router.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/agent_server/test_llm_router.py b/tests/agent_server/test_llm_router.py index e985da6675..8c480173a8 100644 --- a/tests/agent_server/test_llm_router.py +++ b/tests/agent_server/test_llm_router.py @@ -136,9 +136,6 @@ def get_credentials(self): 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) response = client.get("/api/llm/subscription/openai/status") From 16051707571900191050d90d3eb2265ed5da2f07 Mon Sep 17 00:00:00 2001 From: openhands Date: Sat, 23 May 2026 17:17:03 +0000 Subject: [PATCH 6/8] Bind subscription runtime auth without serializing tokens Co-authored-by: openhands --- .../openhands/agent_server/llm_router.py | 9 +++--- .../conversation/impl/local_conversation.py | 10 +++++++ openhands-sdk/openhands/sdk/llm/llm.py | 17 +++++++++-- tests/sdk/conversation/test_switch_model.py | 9 +++++- tests/sdk/test_settings.py | 30 +++++++++++++++++++ 5 files changed, 68 insertions(+), 7 deletions(-) diff --git a/openhands-agent-server/openhands/agent_server/llm_router.py b/openhands-agent-server/openhands/agent_server/llm_router.py index 6a23d4f80d..82cb3479a8 100644 --- a/openhands-agent-server/openhands/agent_server/llm_router.py +++ b/openhands-agent-server/openhands/agent_server/llm_router.py @@ -242,9 +242,10 @@ async def poll_openai_subscription_device_login( 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) + auth.save_credentials(credentials) + return SubscriptionStatusResponse( + connected=True, expires_at=credentials.expires_at + ) @llm_router.post( @@ -255,8 +256,8 @@ async def logout_openai_subscription() -> SubscriptionStatusResponse: global _OPENAI_DEVICE_LOGIN_EPOCH auth = _get_openai_subscription_auth() - auth.logout() 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 41de9c5724..e802c09348 100644 --- a/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py +++ b/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py @@ -94,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, @@ -174,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)): @@ -677,7 +679,15 @@ def switch_llm(self, llm: LLM) -> None: with self._state: 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/llm.py b/openhands-sdk/openhands/sdk/llm/llm.py index d53bbedf2b..27ca649dfc 100644 --- a/openhands-sdk/openhands/sdk/llm/llm.py +++ b/openhands-sdk/openhands/sdk/llm/llm.py @@ -481,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) @@ -1513,8 +1515,12 @@ def _get_litellm_api_key_value(self) -> str | None: if self.is_subscription: from openhands.sdk.llm.auth.openai import OpenAISubscriptionAuth - auth = 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 @@ -2034,7 +2040,14 @@ 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.""" diff --git a/tests/sdk/conversation/test_switch_model.py b/tests/sdk/conversation/test_switch_model.py index fa91d22c0b..20975ef2a3 100644 --- a/tests/sdk/conversation/test_switch_model.py +++ b/tests/sdk/conversation/test_switch_model.py @@ -277,7 +277,8 @@ def test_switch_llm_to_subscription_profile_disables_condenser( def fake_create_subscription_llm_from_config(llm: LLM) -> LLM: runtime = llm.model_copy() - runtime._is_subscription = True + if llm.auth_type == "subscription": + runtime._is_subscription = True return runtime monkeypatch.setattr( @@ -298,3 +299,9 @@ def fake_create_subscription_llm_from_config(llm: LLM) -> LLM: 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/test_settings.py b/tests/sdk/test_settings.py index 929d8d50a1..48bcd716f4 100644 --- a/tests/sdk/test_settings.py +++ b/tests/sdk/test_settings.py @@ -1168,6 +1168,36 @@ def fake_create_subscription_llm_from_config(llm: LLM) -> 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: From 92dff09aa58f150c11a5757d23e766339af8a7d4 Mon Sep 17 00:00:00 2001 From: openhands Date: Sat, 23 May 2026 17:18:06 +0000 Subject: [PATCH 7/8] Bind OpenAI subscription LLM to credential store Co-authored-by: openhands --- openhands-sdk/openhands/sdk/llm/auth/openai.py | 2 ++ tests/sdk/llm/auth/test_openai.py | 1 + 2 files changed, 3 insertions(+) diff --git a/openhands-sdk/openhands/sdk/llm/auth/openai.py b/openhands-sdk/openhands/sdk/llm/auth/openai.py index cf7a84ff20..218225daef 100644 --- a/openhands-sdk/openhands/sdk/llm/auth/openai.py +++ b/openhands-sdk/openhands/sdk/llm/auth/openai.py @@ -830,6 +830,8 @@ def create_llm( llm.temperature = None llm.auth_type = "subscription" llm.subscription_vendor = "openai" + llm._subscription_credential_store = self._credential_store + llm._subscription_credentials = creds return llm diff --git a/tests/sdk/llm/auth/test_openai.py b/tests/sdk/llm/auth/test_openai.py index a5a43239af..7e9c8bd4f5 100644 --- a/tests/sdk/llm/auth/test_openai.py +++ b/tests/sdk/llm/auth/test_openai.py @@ -205,6 +205,7 @@ def test_openai_subscription_auth_create_llm_success(tmp_path): assert llm.model == "openai/gpt-5.2-codex" 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 From 0acf5e3df0c609b052e354784ea53db28d35ce5c Mon Sep 17 00:00:00 2001 From: openhands Date: Sat, 23 May 2026 17:52:10 +0000 Subject: [PATCH 8/8] Refresh CI after review rerun Co-authored-by: openhands