Skip to content

[Bug]: Persisted subscription LLM skips runtime transport reconstruction #4091

Description

@lufen

Bug Description

Serialized ChatGPT subscription LLM profiles can reload as incomplete runtime objects. LLM._restore_is_subscription restores _is_subscription=True, but runtime-only OAuth credentials, the Codex base_url, and transport headers are not serialized. create_subscription_llm_from_config currently returns early for any llm.is_subscription, so the incomplete object is never rebuilt.

The resulting LLM sends Responses API requests to https://api.openai.com/v1/responses instead of the ChatGPT Codex endpoint and fails with Missing scopes: api.responses.write.

Related Agent Canvas reports:

Expected Behavior

Loading a persisted subscription LLM should rebuild a complete runtime subscription transport with:

  • base_url=https://chatgpt.com/backend-api/codex
  • OAuth credentials attached at runtime
  • Codex request headers restored
  • the provider-qualified model name

An already-initialized in-process runtime LLM should remain unchanged.

Actual Behavior

LLM.from_persisted() restores is_subscription=True, then create_subscription_llm_from_config() returns the object unchanged because of:

if llm.is_subscription:
    return llm

The persisted object has base_url=None and no runtime credentials. A model call then reaches the normal OpenAI Platform endpoint and fails:

litellm.BadRequestError: OpenAIException -
You have insufficient permissions for this operation.
Missing scopes: api.responses.write.

Steps to Reproduce

from openhands.sdk import LLM
from openhands.sdk.llm.auth.credentials import OAuthCredentials
from openhands.sdk.llm.auth.openai import OpenAISubscriptionAuth

credentials = OAuthCredentials(
    vendor="openai",
    access_token="access-token",
    refresh_token="refresh-token",
    expires_at=4_102_444_800_000,
)
source = OpenAISubscriptionAuth().create_llm(
    model="gpt-5.6",
    credentials=credentials,
)
persisted = source.to_persisted()
loaded = LLM.from_persisted(persisted)

assert persisted["is_subscription"] is True
assert "base_url" not in persisted
assert loaded.base_url == "https://chatgpt.com/backend-api/codex"

The final assertion fails on current main because loaded.base_url is None.

Installation Method

Editable monorepo checkout using uv sync --dev.

SDK Version

main at release 1.35.0 plus commit 39f404d ancestry; reproduced on current upstream main on 2026-07-11.

Python Version

Python 3.13.12. The defect is serialization/runtime-state logic and is not Windows-specific.

Proposed Fix

Only preserve an existing subscription LLM when runtime credentials are actually attached:

if llm.is_subscription and llm._subscription_credentials is not None:
    return llm

Otherwise rebuild through OpenAISubscriptionAuth.create_llm(...). Also exclude the computed is_subscription field from reconstruction kwargs.

A regression test should cover the complete create_llm() -> to_persisted() -> from_persisted() round trip and assert restoration of the Codex URL, OAuth credentials, and headers.

Existing Issue Search

  • I searched existing issues in OpenHands/software-agent-sdk and found no duplicate.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions