Skip to content

Add LLM subscription auth endpoints#3367

Open
neubig wants to merge 9 commits into
mainfrom
add-llm-subscription-endpoints
Open

Add LLM subscription auth endpoints#3367
neubig wants to merge 9 commits into
mainfrom
add-llm-subscription-endpoints

Conversation

@neubig
Copy link
Copy Markdown
Member

@neubig neubig commented May 23, 2026

Summary

  • add safe OpenAI subscription status, device-code start/poll, logout, and model endpoints to agent-server
  • add serializable LLM auth mode fields and resolve subscription-backed LLM configs at runtime without exposing OAuth tokens
  • cover endpoint token-redaction behavior and subscription settings round-tripping in tests

Tests

  • uv run ruff check openhands-sdk/openhands/sdk/llm/llm.py openhands-sdk/openhands/sdk/llm/auth/openai.py openhands-sdk/openhands/sdk/llm/auth/init.py openhands-sdk/openhands/sdk/settings/model.py openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py openhands-agent-server/openhands/agent_server/llm_router.py tests/agent_server/test_llm_router.py tests/sdk/test_settings.py
  • uv run pytest tests/agent_server/test_llm_router.py tests/sdk/test_settings.py -q

This PR was created by an AI agent (OpenHands) on behalf of the user.


Agent Server images for this PR

GHCR package: https://github.com/OpenHands/agent-sdk/pkgs/container/agent-server

Variants & Base Images

Variant Architectures Base Image Docs / Tags
java amd64, arm64 eclipse-temurin:17-jdk Link
python amd64, arm64 nikolaik/python-nodejs:python3.13-nodejs22-slim Link
golang amd64, arm64 golang:1.21-bookworm Link

Pull (multi-arch manifest)

# Each variant is a multi-arch manifest supporting both amd64 and arm64
docker pull ghcr.io/openhands/agent-server:9fd55ba-python

Run

docker run -it --rm \
  -p 8000:8000 \
  --name agent-server-9fd55ba-python \
  ghcr.io/openhands/agent-server:9fd55ba-python

All tags pushed for this build

ghcr.io/openhands/agent-server:9fd55ba-golang-amd64
ghcr.io/openhands/agent-server:9fd55ba8be5506338d3e8fc3f0cc545b641577a2-golang-amd64
ghcr.io/openhands/agent-server:add-llm-subscription-endpoints-golang-amd64
ghcr.io/openhands/agent-server:9fd55ba-golang_tag_1.21-bookworm-amd64
ghcr.io/openhands/agent-server:9fd55ba-golang-arm64
ghcr.io/openhands/agent-server:9fd55ba8be5506338d3e8fc3f0cc545b641577a2-golang-arm64
ghcr.io/openhands/agent-server:add-llm-subscription-endpoints-golang-arm64
ghcr.io/openhands/agent-server:9fd55ba-golang_tag_1.21-bookworm-arm64
ghcr.io/openhands/agent-server:9fd55ba-java-amd64
ghcr.io/openhands/agent-server:9fd55ba8be5506338d3e8fc3f0cc545b641577a2-java-amd64
ghcr.io/openhands/agent-server:add-llm-subscription-endpoints-java-amd64
ghcr.io/openhands/agent-server:9fd55ba-eclipse-temurin_tag_17-jdk-amd64
ghcr.io/openhands/agent-server:9fd55ba-java-arm64
ghcr.io/openhands/agent-server:9fd55ba8be5506338d3e8fc3f0cc545b641577a2-java-arm64
ghcr.io/openhands/agent-server:add-llm-subscription-endpoints-java-arm64
ghcr.io/openhands/agent-server:9fd55ba-eclipse-temurin_tag_17-jdk-arm64
ghcr.io/openhands/agent-server:9fd55ba-python-amd64
ghcr.io/openhands/agent-server:9fd55ba8be5506338d3e8fc3f0cc545b641577a2-python-amd64
ghcr.io/openhands/agent-server:add-llm-subscription-endpoints-python-amd64
ghcr.io/openhands/agent-server:9fd55ba-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim-amd64
ghcr.io/openhands/agent-server:9fd55ba-python-arm64
ghcr.io/openhands/agent-server:9fd55ba8be5506338d3e8fc3f0cc545b641577a2-python-arm64
ghcr.io/openhands/agent-server:add-llm-subscription-endpoints-python-arm64
ghcr.io/openhands/agent-server:9fd55ba-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim-arm64
ghcr.io/openhands/agent-server:9fd55ba-golang
ghcr.io/openhands/agent-server:9fd55ba8be5506338d3e8fc3f0cc545b641577a2-golang
ghcr.io/openhands/agent-server:add-llm-subscription-endpoints-golang
ghcr.io/openhands/agent-server:9fd55ba-golang_tag_1.21-bookworm
ghcr.io/openhands/agent-server:9fd55ba-java
ghcr.io/openhands/agent-server:9fd55ba8be5506338d3e8fc3f0cc545b641577a2-java
ghcr.io/openhands/agent-server:add-llm-subscription-endpoints-java
ghcr.io/openhands/agent-server:9fd55ba-eclipse-temurin_tag_17-jdk
ghcr.io/openhands/agent-server:9fd55ba-python
ghcr.io/openhands/agent-server:9fd55ba8be5506338d3e8fc3f0cc545b641577a2-python
ghcr.io/openhands/agent-server:add-llm-subscription-endpoints-python
ghcr.io/openhands/agent-server:9fd55ba-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim

About Multi-Architecture Support

  • Each variant tag (e.g., 9fd55ba-python) is a multi-arch manifest supporting both amd64 and arm64
  • Docker automatically pulls the correct architecture for your platform
  • Individual architecture tags (e.g., 9fd55ba-python-amd64) are also available if needed

Co-authored-by: openhands <openhands@all-hands.dev>
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 23, 2026

Python API breakage checks — ✅ PASSED

Result:PASSED

Action log

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 23, 2026

REST API breakage checks (OpenAPI) — ✅ PASSED

Result:PASSED

Action log

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 23, 2026

Coverage

Coverage Report •
FileStmtsMissCoverMissing
openhands-agent-server/openhands/agent_server
   llm_router.py1331687%97, 103, 111, 165, 176–177, 218–220, 229–234, 244
openhands-sdk/openhands/sdk/conversation/impl
   local_conversation.py5424491%312, 317, 461, 507, 544, 560, 625, 856–857, 860, 973, 984–987, 994–995, 998, 1004–1005, 1008, 1014, 1029, 1032, 1036–1037, 1041–1043, 1050, 1136, 1141, 1251, 1253, 1257–1258, 1269–1270, 1295, 1490, 1494, 1564, 1571–1572
openhands-sdk/openhands/sdk/llm
   llm.py73620572%510, 534, 567, 754, 756–760, 768–773, 874, 968–970, 988, 1004, 1011–1012, 1032–1035, 1041–1043, 1047–1051, 1057, 1060, 1063–1064, 1109, 1111–1112, 1142, 1188, 1199–1201, 1205, 1211–1214, 1216–1223, 1231–1233, 1243–1245, 1248–1249, 1253, 1256–1257, 1259–1260, 1262, 1318–1322, 1324, 1326, 1337, 1341–1342, 1345–1346, 1356–1357, 1365–1372, 1375, 1377, 1390–1392, 1397–1401, 1406–1412, 1416–1421, 1429–1431, 1442–1444, 1448–1449, 1454–1456, 1458–1459, 1461, 1465–1469, 1475, 1478–1480, 1523, 1525, 1645, 1658–1664, 1666, 1669, 1780–1781, 2013–2014, 2023, 2029, 2034, 2085, 2087–2092, 2094–2111, 2114–2118, 2120–2121, 2127–2136, 2193, 2195
openhands-sdk/openhands/sdk/llm/auth
   openai.py37912467%181–182, 186–188, 241–243, 267–268, 279–281, 303–304, 308, 318–319, 322, 353, 369, 374–375, 384–386, 391–392, 401–403, 516, 521–523, 555–556, 558–561, 564, 567, 569–570, 572–576, 581–586, 592–596, 602–603, 606–612, 618, 620–622, 624–629, 635, 637, 639–641, 643, 647–648, 651–652, 656, 659, 665–667, 670, 674, 683–686, 717–718, 799, 801, 810, 874–875, 879, 882–886, 889–891, 894–895, 905, 909, 914, 953, 992
openhands-sdk/openhands/sdk/settings
   model.py5665091%83, 108, 113, 352, 362–365, 368, 381, 385, 391, 401, 407, 412, 602, 615, 626, 636, 640, 642, 644, 646, 648, 650, 652, 931, 933, 1046, 1230, 1298, 1337, 1364, 1400–1403, 1429, 1553, 1598, 1630, 1640, 1642, 1647, 1665, 1678, 1680, 1682, 1684, 1691
TOTAL28569664576% 

Copy link
Copy Markdown
Collaborator

@all-hands-bot all-hands-bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 QA Report: PARTIAL

The new subscription API endpoints and SDK settings path worked up to the point that does not require a human OpenAI subscription login; completed OAuth/login-backed LLM use could not be verified without credentials.

Does this PR achieve its stated goal?

Partially. I verified with a real running agent-server that the PR adds the previously-missing OpenAI subscription endpoints: models/status/device-start/device-poll/logout all returned the expected HTTP statuses and safe response shapes, and the device-start call reached OpenAI and produced a real browser sign-in challenge. I also verified the SDK now preserves auth_type="subscription" / subscription_vendor="openai" in serialized settings and no longer silently creates a normal API-key LLM from that config. I could not verify the final connected status or an actual subscription-backed LLM call because that requires completing the OpenAI device-code flow with a ChatGPT subscription account.

Phase Result
Environment Setup make build completed and the agent-server launched locally
CI Status ⏳ Most checks are green; several build/QA/review checks were still in progress when checked
Functional Verification 🟡 New endpoints/settings behavior verified; completed OAuth + real subscription LLM use not verified
Functional Verification

Test 1: Subscription endpoints on the running agent-server

Step 1 — Reproduce / establish baseline without the PR:

Checked out main at 3d9fc105856acd1d8786b8ba76ea2f3dc8be2fc8, launched the real server:

OPENHANDS_SUPPRESS_BANNER=1 uv run python -m openhands.agent_server --host 127.0.0.1 --port 8010

Then called existing and new API routes:

GET /api/llm/providers -> HTTP 200
GET /api/llm/subscription/openai/status -> {"detail":"Not Found"} HTTP 404
GET /api/llm/subscription/openai/models -> {"detail":"Not Found"} HTTP 404

This confirms the base branch served the existing LLM API but did not expose the subscription endpoints.

Step 2 — Apply the PR's changes:

Checked out add-llm-subscription-endpoints at c47c75573cf1d1b614458207ae34cc9387517867 and launched the real server on port 8011.

Step 3 — Re-run with the PR in place:

GET /api/llm/providers -> HTTP 200
GET /api/llm/subscription/openai/models -> HTTP 200
{"vendor":"openai","models":["gpt-5.1-codex-max","gpt-5.1-codex-mini","gpt-5.2","gpt-5.2-codex","gpt-5.3-codex"]}

GET /api/llm/subscription/openai/status -> HTTP 200
{"vendor":"openai","connected":false,"account_email":null,"expires_at":null}

POST /api/llm/subscription/openai/device/start -> HTTP 200
{"device_code":"<43-char opaque server token>","user_code":"<redacted live OpenAI user code>","verification_uri":"https://auth.openai.com/codex/device","verification_uri_complete":null,"expires_at":1779551456489,"interval_seconds":5}

POST /api/llm/subscription/openai/device/poll with the returned opaque token -> HTTP 200
{"vendor":"openai","connected":false,"account_email":null,"expires_at":null}

POST /api/llm/subscription/openai/device/poll with an invalid token -> HTTP 404
{"detail":"Subscription device login not found or expired"}

POST /api/llm/subscription/openai/logout -> HTTP 200
{"vendor":"openai","connected":false,"account_email":null,"expires_at":null}

This shows the PR adds functional HTTP endpoints, preserves the existing providers endpoint, returns an OpenAI device-code challenge through the real API path, keeps polling server-side via an opaque token, handles pending/invalid poll states, and returns safe status/logout payloads without OAuth access or refresh tokens.

Test 2: SDK subscription settings round-trip and runtime behavior

Step 1 — Reproduce / establish baseline without the PR:

On main, ran a small user-style SDK script that constructs an LLM with subscription fields, serializes OpenHandsAgentSettings, and calls create_agent():

LLM_CREATED {'model': 'gpt-5.2-codex', ...}
SETTINGS_DUMP {'model': 'gpt-5.2-codex', ...}
CREATE_AGENT_OK

The subscription fields were absent from the dump and the agent was created as a normal LLM configuration, confirming the base branch did not round-trip subscription auth settings.

Step 2 — Apply the PR's changes:

Checked out c47c75573cf1d1b614458207ae34cc9387517867 and ran the same script.

Step 3 — Re-run with the PR in place:

LLM_CREATED {'model': 'gpt-5.2-codex', 'auth_type': 'subscription', 'subscription_vendor': 'openai', ...}
SETTINGS_DUMP {'model': 'gpt-5.2-codex', 'auth_type': 'subscription', 'subscription_vendor': 'openai', ...}
CREATE_AGENT_ERROR ValueError OpenAI subscription login is required

This shows the PR preserves the subscription auth fields in serialized settings and changes runtime behavior from silently creating a regular API-key LLM to requiring an OpenAI subscription login before building the agent.

Unable to Verify

I could not complete the OpenAI browser device-code flow, verify connected: true, verify token refresh behavior, or make an actual subscription-backed LLM completion because this environment does not have a ChatGPT subscription account to authorize the live device challenge. Future QA would benefit from AGENTS.md guidance documenting whether a safe non-production OpenAI subscription test account or approved OAuth stub is available for end-to-end subscription login verification.

Issues Found

None from functional QA. The only gap is credential-bound verification of the completed OpenAI login and actual subscription LLM use.

This review was created by an AI agent (OpenHands) on behalf of the user.

Copy link
Copy Markdown
Collaborator

@all-hands-bot all-hands-bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Taste Rating: Acceptable, but I found a few correctness issues around subscription runtime resolution and device-login lifecycle. Inline comments have the actionable details.

Risk: 🟡 Medium — this touches auth/session state and LLM runtime wiring for long-running agents.
Verdict: COMMENT pending fixes.

This review was generated by an AI agent (OpenHands) on behalf of the user.


Was this automated review useful? React with 👍 or 👎 to this review to help us measure review quality.
Workflow run: https://github.com/OpenHands/software-agent-sdk/actions/runs/26336675207

Comment thread openhands-agent-server/openhands/agent_server/llm_router.py Outdated
Comment thread openhands-agent-server/openhands/agent_server/llm_router.py Outdated
Comment thread openhands-sdk/openhands/sdk/llm/auth/openai.py Outdated
Comment thread openhands-sdk/openhands/sdk/llm/auth/openai.py Outdated
Comment thread openhands-sdk/openhands/sdk/llm/auth/openai.py Outdated
Comment thread openhands-sdk/openhands/sdk/settings/model.py Outdated
Co-authored-by: openhands <openhands@all-hands.dev>
@neubig neubig added the review-this This label triggers a PR review by OpenHands label May 23, 2026 — with OpenHands AI
Copy link
Copy Markdown
Collaborator

@all-hands-bot all-hands-bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Taste Rating: Acceptable, but I found a few correctness issues around OAuth token handling and device-login lifecycle.

Risk: 🟡 Medium — this touches auth/session state and LLM runtime wiring for long-running agents.
Verdict: COMMENT pending fixes.

This review was generated by an AI agent (OpenHands) on behalf of the user.


Was this automated review useful? React with 👍 or 👎 to this review to help us measure review quality.
Workflow run: https://github.com/OpenHands/software-agent-sdk/actions/runs/26337336844

Comment thread openhands-sdk/openhands/sdk/llm/auth/openai.py
Comment thread openhands-sdk/openhands/sdk/llm/auth/openai.py
Comment thread openhands-agent-server/openhands/agent_server/llm_router.py Outdated
Comment thread openhands-agent-server/openhands/agent_server/llm_router.py Outdated
Comment thread openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: openhands <openhands@all-hands.dev>
@neubig neubig removed the review-this This label triggers a PR review by OpenHands label May 23, 2026
@neubig neubig added the review-this This label triggers a PR review by OpenHands label May 23, 2026 — with OpenHands AI
Copy link
Copy Markdown
Collaborator

@all-hands-bot all-hands-bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Taste Rating: Acceptable, but I found a few correctness issues around subscription runtime state, device-login lifecycle, and profile switching.

Risk: 🟡 Medium — this touches auth/session state and long-running LLM/condenser behavior.
Verdict: COMMENT pending fixes.

This review was generated by an AI agent (OpenHands) on behalf of the user.


Was this automated review useful? React with 👍 or 👎 to this review to help us measure review quality.
Workflow run: https://github.com/OpenHands/software-agent-sdk/actions/runs/26338423527

Comment thread openhands-agent-server/openhands/agent_server/llm_router.py Outdated
Comment thread openhands-sdk/openhands/sdk/llm/llm.py Outdated
Comment thread openhands-sdk/openhands/sdk/llm/llm.py
Comment thread openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: openhands <openhands@all-hands.dev>
@neubig neubig removed the review-this This label triggers a PR review by OpenHands label May 23, 2026
@neubig neubig added the review-this This label triggers a PR review by OpenHands label May 23, 2026 — with OpenHands AI
@neubig neubig removed the review-this This label triggers a PR review by OpenHands label May 23, 2026
@neubig neubig added the review-this This label triggers a PR review by OpenHands label May 23, 2026 — with OpenHands AI
@neubig neubig removed the review-this This label triggers a PR review by OpenHands label May 23, 2026
@neubig neubig added the review-this This label triggers a PR review by OpenHands label May 25, 2026
Copy link
Copy Markdown
Collaborator

@all-hands-bot all-hands-bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Taste Rating: Acceptable, but I found a few correctness issues around subscription runtime resolution and credential refresh.

Risk: 🟡 Medium — this touches auth/session state and async LLM runtime behavior.
Verdict: COMMENT pending fixes.

This review was generated by an AI agent (OpenHands) on behalf of the user.


Was this automated review useful? React with 👍 or 👎 to this review to help us measure review quality.
Workflow run: https://github.com/OpenHands/software-agent-sdk/actions/runs/26411115492

if model.startswith("openai/"):
model = model.removeprefix("openai/")

auth = OpenAISubscriptionAuth()
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Important: This always creates a fresh OpenAISubscriptionAuth() with the default credential store, even when llm is already a runtime subscription LLM carrying a custom store or in-memory credentials from OpenAISubscriptionAuth(credential_store=...).create_llm(...). Since create_agent() and switch_llm() now call this helper, those valid LLMs can fail with OpenAI subscription login is required or silently switch accounts. Please make runtime subscription LLMs a no-op here, or carry their existing credential store/credentials into the new auth.

label="API Key",
),
)
auth_type: Literal["api_key", "subscription"] = Field(
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Important: Adding this public field makes LLM.load_from_env() accept LLM_AUTH_TYPE=subscription, but that loader still returns cls(**data) rather than rehydrating via the subscription runtime resolver. The result has auth_type='subscription' but _is_subscription=False, no Codex base URL, and no token refresh. Please route env-loaded subscription configs through the same create_subscription_llm_from_config() path used by JSON/settings.

auth = OpenAISubscriptionAuth(
credential_store=self._subscription_credential_store
)
credentials = auth.refresh_if_needed_sync()
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Important: _atransport_call() also reaches this helper, so an expired subscription token performs a synchronous HTTP refresh on the event loop while the global LiteLLM modify_params lock is held. Long-running async conversations can stall unrelated requests during refresh. Please add an async subscription credential path for acompletion()/aresponses() or refresh before entering the synchronous transport section.

"""Return safe ChatGPT subscription connection state without tokens."""
auth = _get_openai_subscription_auth()
try:
await auth.refresh_if_needed()
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Important: refresh_if_needed() can write refreshed credentials, but this endpoint does it outside the device-login/logout lock. If a status request starts refreshing an expired old token while a device poll saves a newly completed login, the later refresh write can overwrite the new credentials. Please serialize all credential writes (refresh, save, logout) or add a compare-and-swap check in the credential store.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

review-this This label triggers a PR review by OpenHands

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants