Skip to content

feat: workspace.get_llm() and get_secrets() for OpenHandsCloudWorkspace credential inheritance#2409

Open
xingyaoww wants to merge 27 commits intomainfrom
feat/cloud-workspace-get-llm-secrets
Open

feat: workspace.get_llm() and get_secrets() for OpenHandsCloudWorkspace credential inheritance#2409
xingyaoww wants to merge 27 commits intomainfrom
feat/cloud-workspace-get-llm-secrets

Conversation

@xingyaoww
Copy link
Collaborator

@xingyaoww xingyaoww commented Mar 13, 2026

Summary

Adds get_llm() and get_secrets() methods to OpenHandsCloudWorkspace, enabling SDK-created conversations to inherit the user's SaaS credentials.

Design

  • get_llm(**kwargs): Calls GET /api/v1/users/me?expose_secrets=true with both Bearer token and X-Session-API-Key headers (dual auth). Extracts llm_model, llm_api_key, llm_base_url and returns a fully usable LLM instance. User-provided kwargs override SaaS settings.
  • get_secrets(names=None): Calls GET /sandboxes/{id}/settings/secrets (X-Session-API-Key auth) for names only, then builds LookupSecret references with env_headers. Raw secret values never transit through the SDK client — they are resolved lazily by the agent-server inside the sandbox.

Security

get_llm() sends both the Bearer token (user identity) and the sandbox session key (active sandbox proof). The server verifies the session key belongs to a sandbox owned by the same user. This prevents a leaked API key from being used to extract raw LLM credentials.

Usage

with OpenHandsCloudWorkspace(...) as workspace:
    llm = workspace.get_llm()  # calls /users/me?expose_secrets=true
    agent = Agent(llm=llm, tools=get_default_tools())

    conversation = Conversation(agent=agent, workspace=workspace)
    conversation.update_secrets(workspace.get_secrets())  # LookupSecret refs
    conversation.send_message("Analyze this repo")

Companion PR

Tests

  • 8 workspace tests (get_llm, get_secrets: happy path, overrides, no-key, filtering, errors)

Resolves OpenHands/OpenHands#13268


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 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:8fe0b03-python

Run

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

All tags pushed for this build

ghcr.io/openhands/agent-server:8fe0b03-golang-amd64
ghcr.io/openhands/agent-server:8fe0b03-golang_tag_1.21-bookworm-amd64
ghcr.io/openhands/agent-server:8fe0b03-golang-arm64
ghcr.io/openhands/agent-server:8fe0b03-golang_tag_1.21-bookworm-arm64
ghcr.io/openhands/agent-server:8fe0b03-java-amd64
ghcr.io/openhands/agent-server:8fe0b03-eclipse-temurin_tag_17-jdk-amd64
ghcr.io/openhands/agent-server:8fe0b03-java-arm64
ghcr.io/openhands/agent-server:8fe0b03-eclipse-temurin_tag_17-jdk-arm64
ghcr.io/openhands/agent-server:8fe0b03-python-amd64
ghcr.io/openhands/agent-server:8fe0b03-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-amd64
ghcr.io/openhands/agent-server:8fe0b03-python-arm64
ghcr.io/openhands/agent-server:8fe0b03-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-arm64
ghcr.io/openhands/agent-server:8fe0b03-golang
ghcr.io/openhands/agent-server:8fe0b03-java
ghcr.io/openhands/agent-server:8fe0b03-python

About Multi-Architecture Support

  • Each variant tag (e.g., 8fe0b03-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., 8fe0b03-python-amd64) are also available if needed

Add methods to OpenHandsCloudWorkspace that call the new SaaS API
endpoints to retrieve the user's LLM configuration and custom secrets:

- get_llm(**llm_kwargs): Fetches LLM settings from the user's SaaS
  account and returns a configured LLM instance. User kwargs override
  SaaS defaults.
- get_secrets(names=None): Fetches custom secrets and returns a
  dict[str, str] compatible with conversation.update_secrets().

These methods enable SDK users to inherit their SaaS credentials while
retaining full control over agent customization.

Depends on OpenHands/OpenHands#13306 for the server-side API endpoints.

Related: OpenHands/OpenHands#13268

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

github-actions bot commented Mar 13, 2026

Python API breakage checks — ✅ PASSED

Result:PASSED

Action log

@github-actions
Copy link
Contributor

github-actions bot commented Mar 13, 2026

REST API breakage checks (OpenAPI) — ✅ PASSED

Result:PASSED

Action log

…t-backed configs

SDK LLM changes:
- LLM.api_key now accepts str | SecretStr | SecretSource | None
- Validator passes through SecretSource instances; deserialises dicts
- Serializer delegates to SecretSource.model_dump() for round-tripping
- _get_litellm_api_key_value() resolves SecretSource.get_value() lazily
- _init_model_info_and_caps() skips network for SecretSource api_key

OpenHandsCloudWorkspace changes:
- get_llm() calls sandbox-scoped /settings/llm (SESSION_API_KEY auth)
  and returns LLM with api_key=LookupSecret — raw key never reaches client
- get_secrets() calls /settings/secrets for names, returns dict of
  LookupSecret instances pointing to per-secret endpoints
- Added _send_settings_request() for SESSION_API_KEY-authenticated calls

Co-authored-by: openhands <openhands@all-hands.dev>
@xingyaoww xingyaoww changed the title DRAFT: feat: add get_llm() and get_secrets() to OpenHandsCloudWorkspace feat: LLM api_key accepts SecretSource; workspace returns LookupSecret-backed configs Mar 13, 2026
@github-actions
Copy link
Contributor

github-actions bot commented Mar 13, 2026

Coverage

Coverage Report •
FileStmtsMissCoverMissing
openhands-sdk/openhands/sdk/conversation/impl
   remote_conversation.py61610582%77, 79, 150, 177, 190, 192–195, 205, 227–228, 233–236, 319, 329–331, 337, 378, 520–523, 525, 545–549, 554–557, 560, 572–576, 732–733, 737–738, 752, 776–777, 796, 807–808, 828–831, 833–834, 858–860, 863–867, 869–870, 874, 876–884, 886, 923, 1053, 1121–1122, 1126, 1131–1135, 1141–1147, 1160–1161, 1196, 1252, 1259, 1265–1266, 1344–1345
openhands-sdk/openhands/sdk/secret
   secrets.py64296%85, 88
openhands-workspace/openhands/workspace/cloud
   workspace.py23217823%32–34, 108–110, 116, 119–120, 128, 132, 134–139, 147–148, 150, 153, 156–158, 162, 165–166, 169, 172–173, 177, 180–182, 185, 191, 193–195, 204–207, 217, 220, 225, 227–228, 230–232, 234, 236–237, 240–243, 245–246, 248–249, 251, 253–255, 258–259, 263–274, 278–279, 281–282, 290–291, 293–295, 297, 308, 321–322, 324–327, 331, 334–335, 338–340, 342–350, 352, 356–357, 359–362, 364–365, 371–373, 375–382, 391, 396, 429, 431–432, 434, 440, 442–448, 451, 453, 486, 488–489, 491–492, 494–499, 505, 520–521, 523–525, 527–535, 537, 540, 543, 546
TOTAL20300593370% 

LookupSecret now supports env_headers — a mapping of header name to
environment variable name.  Headers are resolved from os.environ at
get_value() call time.  This ensures the SESSION_API_KEY is never
embedded in the serialized LookupSecret; only the env var *name*
travels over the wire.  Resolution only succeeds inside the sandbox
where the env var is set.

- LookupSecret: add env_headers field, merge into headers in get_value()
- LLM: add assert to narrow dict type for pyright
- Workspace: use env_headers instead of raw headers in get_llm/get_secrets
- Tests: 3 new env_headers enforcement tests (28 total SDK tests)
- Fix pyright errors in examples and tests

Co-authored-by: openhands <openhands@all-hands.dev>
Per feedback, LookupSecret is only needed for secrets — not LLM config.
Reverted all LLM.api_key SecretSource changes (api_key stays str|SecretStr|None).
workspace.get_llm() now returns a real LLM with the raw api_key.
Deleted test_llm_secret_source_api_key.py (no longer applicable).

- LLM class: reverted to main (no SecretSource support)
- workspace.get_llm(): builds LLM from plain JSON response
- workspace.get_secrets(): still uses LookupSecret+env_headers
- 8 workspace tests pass

Co-authored-by: openhands <openhands@all-hands.dev>
@xingyaoww xingyaoww changed the title feat: LLM api_key accepts SecretSource; workspace returns LookupSecret-backed configs feat: workspace.get_llm() and get_secrets() for SaaS credential inheritance Mar 13, 2026
…ings/llm

The OpenHands app server already has GET /api/v1/users/me which returns
full user settings including llm_model, llm_api_key, llm_base_url.
A new expose_secrets query param returns the raw api_key.

get_llm() now calls /users/me?expose_secrets=true via _send_api_request
(Bearer token auth) instead of /settings/llm via _send_settings_request
(X-Session-API-Key auth), eliminating the need for a dedicated LLM
settings endpoint.

Co-authored-by: openhands <openhands@all-hands.dev>
The server now requires a valid session key when expose_secrets=true.
get_llm() includes the sandbox session key header alongside the
Bearer token, proving there is an active sandbox owned by the caller.

Co-authored-by: openhands <openhands@all-hands.dev>
Demonstrates workspace.get_llm() and workspace.get_secrets() APIs
that allow SDK users to inherit LLM config and secrets from their
OpenHands Cloud account without providing LLM_API_KEY separately.

Co-authored-by: openhands <openhands@all-hands.dev>
…tifacts

The cleanup() method was sending DELETE to the collection route
(/api/v1/sandboxes?sandbox_id=X) which returned 405. The server
expects DELETE /api/v1/sandboxes/{id} with sandbox_id as a required
query parameter (FastAPI routing quirk). Fixed to use both.

Also adds .pr/ folder with integration test logs and report from
running 09_cloud_workspace_saas_credentials.py against the staging
deployment of OpenHands PR #13383.

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

github-actions bot commented Mar 16, 2026

PR Artifacts Cleaned Up

The .pr/ directory has been automatically removed.

Two bugs fixed:

1. update_secrets() crashed with 'LookupSecret is not JSON serializable'
   when passing SecretSource objects from get_secrets(). Now uses
   model_dump(mode='json') for Pydantic SecretSource instances.

2. cleanup() was calling DELETE /api/v1/sandboxes?sandbox_id=X (405).
   Fixed to DELETE /api/v1/sandboxes/{id}?sandbox_id={id} matching
   the server's FastAPI route definition.

Updated .pr/ logs from clean end-to-end run with 2 secrets (DUMMY_1,
DUMMY_2) successfully discovered, injected, and used.

Co-authored-by: openhands <openhands@all-hands.dev>
The old prompt only listed env var names — it didn't prove values
actually resolved. New prompt asks the agent to print the last 50%
of each secret's value, which revealed that LookupSecret refs are
recognized by the agent-server (appear in system prompt) but values
are not yet injected as real environment variables.

Co-authored-by: openhands <openhands@all-hands.dev>
- LookupSecret HTTP resolution works from both SDK client and inside sandbox
- Identified deployment dependency: env_headers requires updated agent-server image
- Identified SecretStr serialization redaction issue with update_secrets()
- Proved _export_envs pipeline works end-to-end with plain string secrets

Co-authored-by: openhands <openhands@all-hands.dev>
Remove the env_headers field from LookupSecret. Instead, pass the
session key directly in the existing headers field and use
expose_secrets=True context in model_dump() so SecretStr header
values survive JSON serialization through update_secrets().

This eliminates a deployment dependency — the existing agent-server
image (1.13.0-python) already supports LookupSecret.headers, so no
image update is needed.

Changes:
- secrets.py: Remove env_headers field and os.environ resolution
- workspace.py: Use headers={} with actual session key, remove _env_headers()
- remote_conversation.py: Add context={'expose_secrets': True} to model_dump()
- test: Update assertion from env_headers to headers

Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: openhands <openhands@all-hands.dev>
@xingyaoww xingyaoww changed the title feat: workspace.get_llm() and get_secrets() for SaaS credential inheritance feat: workspace.get_llm() and get_secrets() for OpenHandsCloudWorkspace credential inheritance Mar 16, 2026
Agent output from inside sandbox:
  DUMMY_1: len=14, last_half=ecret 1
  DUMMY_2: len=14, last_half=ecret 2

Co-authored-by: openhands <openhands@all-hands.dev>
Fresh run_combined.log from end-to-end test showing:
  DUMMY_1: len=14, last_half=ecret 1
  DUMMY_2: len=14, last_half=ecret 2

Co-authored-by: openhands <openhands@all-hands.dev>
The staging server requires PR #13383 for the /sandboxes/{id}/settings/secrets
endpoints. Only the sandbox agent-server is stock 1.13.0-python.

Co-authored-by: openhands <openhands@all-hands.dev>
@xingyaoww
Copy link
Collaborator Author

@OpenHands please fix the failing CIs (pre-commits, duplicated example number) and open a new PR on OpenHands/docs repo for the docs example. make sure you also modify the docs related to OpenHandsCloudWorkspace there to add the ability of get llm & secrets.

@xingyaoww xingyaoww marked this pull request as ready for review March 16, 2026 20:56
@openhands-ai
Copy link

openhands-ai bot commented Mar 16, 2026

I'm on it! xingyaoww can track my progress at all-hands.dev

Copy link
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.

🟢 Good taste - Elegant credential inheritance design. The lazy-resolved LookupSecret pattern keeps security boundaries clean (raw values never transit through SDK). Code is simple, well-tested, and solves a real problem.

The method always returns response.text (which is always str) or raises
via raise_for_status(), so str | None was imprecise.

Co-authored-by: openhands <openhands@all-hands.dev>
…long

- Rename 09_cloud_workspace_saas_credentials.py to 10_ to avoid
  duplicate number conflict with 09_acp_agent_with_remote_runtime.py
- Fix E501 in test docstring (shorten description)
- Fix E501 in example comments and string literals (wrap lines)

Co-authored-by: openhands <openhands@all-hands.dev>
xingyaoww pushed a commit to OpenHands/docs that referenced this pull request Mar 16, 2026
- Add 'Inheriting SaaS Credentials' section documenting get_llm() and
  get_secrets() methods for credential inheritance
- Add SaaS Credentials example section with the full expandable example
- Update page description to mention SaaS credential inheritance

Companion SDK PR: OpenHands/software-agent-sdk#2409

Co-authored-by: openhands <openhands@all-hands.dev>
@openhands-ai
Copy link

openhands-ai bot commented Mar 16, 2026

Based on my review of the work done, here's the summary:

Summary of Changes

SDK PR Fixes (pushed to feat/cloud-workspace-get-llm-secrets on OpenHands/software-agent-sdk)

All changes have been pushed. The commit 10cbd942 addressed the three failing CI checks:

  1. ✅ Pre-commit (E501 line too long) — Fixed 3 lines:

    • Shortened a test docstring in tests/workspace/test_cloud_workspace_sdk_settings.py
    • Wrapped a long comment and a long string literal in the example file
  2. ✅ Duplicate example number — Renamed 09_cloud_workspace_saas_credentials.py10_cloud_workspace_saas_credentials.py (09 was already taken by 09_acp_agent_with_remote_runtime.py on main)

  3. ✅ Undocumented example — Resolved by creating the docs PR below

All three previously-failing CI checks (pre-commit, check-duplicates, check-examples) are now passing.

Docs PR (OpenHands/docs#401)

Created and pushed: OpenHands/docs#401

Updated sdk/guides/agent-server/cloud-workspace.mdx with:

  • "Inheriting SaaS Credentials" section documenting get_llm() and get_secrets() with code snippets
  • "SaaS Credentials Example" section with the full expandable example code block (referencing examples/02_remote_agent_server/10_cloud_workspace_saas_credentials.py)
  • Updated page description metadata

Also pushed a matching branch name (feat/cloud-workspace-get-llm-secrets) to the docs repo so the SDK's check-examples CI can discover the documentation.

Checklist

  • Fix pre-commit failures (E501 line too long)
  • Fix duplicated example number
  • Open new PR on OpenHands/docs repo for the example documentation
  • Modify cloud-workspace docs to add get_llm() & get_secrets() capability documentation
  • All changes pushed to remote branches

Co-authored-by: openhands <openhands@all-hands.dev>
Copy link
Collaborator Author

✅ E2E Testing Complete (2026-03-17)

Ran full e2e against the staging feature deployment (ohpr-13383-240.staging.all-hands.dev) with the latest server commit c0ed13f from OpenHands/OpenHands#13383.

Results — all passing

Phase Status Details
workspace.get_llm() model=litellm_proxy/minimax-m2.5, api_key + base_url inherited from SaaS
workspace.get_secrets() Returns 3 secrets: ['secret_1', 'secret_2', 'github_token']
Provider token discovery github_token included — provider tokens now flow to SDK
LookupSecret resolution All 3 secrets resolved: secret_1 (len=5), secret_2 (len=5), github_token (len=40)
Sandbox cleanup Sandbox created + deleted cleanly

Test artifacts pushed to .pr/ folder in commit d5367f5:

The server companion PR (#13383) was deployed via deploy#3436 and the get_provider_tokens(as_env_vars=True) refactor works end-to-end.

Co-authored-by: openhands <openhands@all-hands.dev>
Copy link
Collaborator Author

📋 Full Example 10 stdout uploaded

Ran examples/02_remote_agent_server/10_cloud_workspace_saas_credentials.py end-to-end against ohpr-13383-240.staging.all-hands.dev.

Complete stdout log pushed in commit d8b4ac4: .pr/logs/example_10_full_stdout.log

Highlights from the run:

  • Sandbox 5ePS0rOBeWHQci1yUZEqMv created → RUNNING in ~60s
  • LLM: litellm_proxy/minimax-m2.5 inherited from SaaS
  • Secrets discovered: ['secret_1', 'secret_2', 'github_token']
  • 3 secrets injected into conversation via update_secrets()
  • Agent resolved all 3 env vars inside sandbox and wrote SECRETS_CHECK.txt
  • Run completed in 34.2s, 17 events, sandbox cleaned up

…e_credentials

Co-authored-by: openhands <openhands@all-hands.dev>
@xingyaoww
Copy link
Collaborator Author

Besides agent confirmation, i've also confirmed that this is working by running the script manually.
Good to merge after one final confirmation! (cc @malhotra5)

image

Copy link
Collaborator

@malhotra5 malhotra5 left a comment

Choose a reason for hiding this comment

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

LGTM! Nice work, just a nit regarding error handling on the API calls

allhands-bot and others added 2 commits March 17, 2026 14:48
Add tenacity retry decorators to get_llm() and _send_settings_request()
with up to 3 attempts and exponential backoff. Only transient errors
(network issues, server 5xx) are retried; client errors (4xx) fail
immediately.

Co-authored-by: openhands <openhands@all-hands.dev>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: SDK-created conversations should inherit SaaS settings (credentials, repo context)

4 participants