feat: workspace.get_llm() and get_secrets() for OpenHandsCloudWorkspace credential inheritance#2409
feat: workspace.get_llm() and get_secrets() for OpenHandsCloudWorkspace credential inheritance#2409
Conversation
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>
Python API breakage checks — ✅ PASSEDResult: ✅ PASSED |
REST API breakage checks (OpenAPI) — ✅ PASSEDResult: ✅ PASSED |
…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>
Coverage Report •
|
||||||||||||||||||||||||||||||||||||||||
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>
…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>
|
✅ PR Artifacts Cleaned Up The |
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>
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>
|
@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. |
|
I'm on it! xingyaoww can track my progress at all-hands.dev |
all-hands-bot
left a comment
There was a problem hiding this comment.
🟢 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>
- 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>
|
Based on my review of the work done, here's the summary: Summary of ChangesSDK PR Fixes (pushed to
|
Co-authored-by: openhands <openhands@all-hands.dev>
✅ E2E Testing Complete (2026-03-17)Ran full e2e against the staging feature deployment ( Results — all passing
Test artifacts pushed to
The server companion PR (#13383) was deployed via deploy#3436 and the |
Co-authored-by: openhands <openhands@all-hands.dev>
📋 Full Example 10 stdout uploadedRan Complete stdout log pushed in commit Highlights from the run:
|
…e_credentials Co-authored-by: openhands <openhands@all-hands.dev>
Matches rename in OpenHands/software-agent-sdk#2409. Co-authored-by: openhands <openhands@all-hands.dev>
|
Besides agent confirmation, i've also confirmed that this is working by running the script manually.
|
malhotra5
left a comment
There was a problem hiding this comment.
LGTM! Nice work, just a nit regarding error handling on the API calls
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>

Summary
Adds
get_llm()andget_secrets()methods toOpenHandsCloudWorkspace, enabling SDK-created conversations to inherit the user's SaaS credentials.Design
get_llm(**kwargs): CallsGET /api/v1/users/me?expose_secrets=truewith both Bearer token andX-Session-API-Keyheaders (dual auth). Extractsllm_model,llm_api_key,llm_base_urland returns a fully usableLLMinstance. User-provided kwargs override SaaS settings.get_secrets(names=None): CallsGET /sandboxes/{id}/settings/secrets(X-Session-API-Key auth) for names only, then buildsLookupSecretreferences withenv_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
Companion PR
expose_secretsparam on/users/me+ secrets endpoints)Tests
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
eclipse-temurin:17-jdknikolaik/python-nodejs:python3.13-nodejs22golang:1.21-bookwormPull (multi-arch manifest)
# Each variant is a multi-arch manifest supporting both amd64 and arm64 docker pull ghcr.io/openhands/agent-server:8fe0b03-pythonRun
All tags pushed for this build
About Multi-Architecture Support
8fe0b03-python) is a multi-arch manifest supporting both amd64 and arm648fe0b03-python-amd64) are also available if needed