Skip to content

fix: resolve provider connection credentials in GET /api/profiles/{name} - #4584

Open
all-hands-bot wants to merge 1 commit into
mainfrom
fix/provider-connection-resolve-on-get-profile
Open

fix: resolve provider connection credentials in GET /api/profiles/{name}#4584
all-hands-bot wants to merge 1 commit into
mainfrom
fix/provider-connection-resolve-on-get-profile

Conversation

@all-hands-bot

@all-hands-bot all-hands-bot commented Aug 23, 2026

Copy link
Copy Markdown
Collaborator

HUMAN:


AGENT:

Why

Source PR reviewed: #4492 ("Add read-at-use LLM provider connections")

Risk lenses applied: #1 (Boundary contracts and alternate valid inputs), #4 (Multiple construction and configuration paths), #8 (Credentials, redaction, and sensitive persistence boundaries)

Deterministic reproduction and impact:

PR #4492 introduced provider-connection-backed LLM profiles where credentials are stored in a ProviderConnectionStore and referenced by provider_connection_id from the LLM profile. However, GET /api/profiles/{name} hardcoded resolve_provider=False:

# profiles_router.py line 185 (before fix)
llm = store.load(name, cipher=cipher, resolve_provider=False)

This means the endpoint never resolves provider connection credentials, even when the caller sends X-Expose-Secrets: plaintext. The returned api_key and base_url are always None for provider-connection-backed profiles.

RemoteWorkspace.get_llm() calls this endpoint via _fetch_llm_profile_config() to build a working LLM. Because the endpoint returns api_key=None, the resulting LLM object has no API key and cannot authenticate — every LLM call fails with LLMAuthenticationError.

This affects any user who:

  1. Creates a provider connection via POST /api/llm/provider-connections
  2. Links an LLM profile to it via provider_connection_id
  3. Calls workspace.get_llm() (or any agent-server flow that resolves the active profile)

Root cause: The endpoint was designed for display/listing (where resolve_provider=False avoids injecting credentials and avoids failing on dangling references). But it is also the same endpoint used by get_llm() for credential resolution. The display-mode default was applied universally, with no opt-in for credential resolution.

Proof that the regression test fails on unmodified main:

$ uv run pytest tests/agent_server/test_profiles_router.py::test_get_profile_resolve_provider_returns_connection_credentials -x -v

Output on main (before fix):

FAILED tests/agent_server/test_profiles_router.py::test_get_profile_resolve_provider_returns_connection_credentials
  - AssertionError: assert None == 'sk-provider-resolve-test'

The profile is linked to a provider connection with api_key="sk-provider-resolve-test", but GET /api/profiles/conn-profile returns api_key=None even with X-Expose-Secrets: plaintext.

Why the original tests did not catch this:

The existing RemoteWorkspace.get_llm() tests (e.g., test_get_llm_with_profile_name) mock the HTTP response and fabricate the api_key field in the mock — they never exercise the real server endpoint that hardcodes resolve_provider=False. The router-level tests (test_settings_active_profile_resolves_provider_connection) test the GET /api/settings path (which goes through settings_router, not profiles_router) and that path does resolve the provider. No test verified that GET /api/profiles/{name} itself returns resolved credentials when asked.

Summary

  • Added resolve_provider: bool = False query parameter to GET /api/profiles/{name} in profiles_router.py; when true, the linked provider connection's api_key and base_url are resolved into the returned config
  • Updated _fetch_llm_profile_config() in openhands/sdk/workspace/remote/base.py to pass params={"resolve_provider": "true"} so get_llm() receives resolved credentials
  • Added regression test test_get_profile_resolve_provider_returns_connection_credentials verifying both display-mode (api_key=None) and resolved-mode (api_key from provider connection)
  • Updated existing test_get_llm_with_profile_name to assert resolve_provider=true is passed in the request params

REST API contract changes

Compared with base OpenAPI 9421149592da for public /api/** paths.

--- base public OpenAPI
+++ head public OpenAPI
@@ -201,0 +202 @@
+parameter GET /api/profiles/{name} query:resolve_provider required=false schema=type="boolean" default=false

Issue Number

N/A — discovered during the weekly regression hunt.

How to Test

Before/after test commands and results:

  1. Regression test on unmodified main (fails):
$ git stash && git checkout main
$ uv run pytest tests/agent_server/test_profiles_router.py::test_get_profile_resolve_provider_returns_connection_credentials -x
FAILED - AssertionError: assert None == 'sk-provider-resolve-test'
  1. Regression test with fix (passes):
$ git checkout fix/provider-connection-resolve-on-get-profile
$ uv run pytest tests/agent_server/test_profiles_router.py::test_get_profile_resolve_provider_returns_connection_credentials -x
PASSED
  1. Full profiles_router suite (101 tests pass):
$ uv run pytest tests/agent_server/test_profiles_router.py -x
101 passed
  1. Remote workspace get_llm tests (8 tests pass):
$ uv run pytest tests/sdk/workspace/remote/test_remote_workspace.py -k "get_llm" -x
8 passed
  1. Provider connection + profile store tests (77 tests pass):
$ uv run pytest tests/sdk/llm/test_provider_connection_store.py tests/sdk/llm/test_llm_profile_store.py -x
77 passed
  1. Pre-commit on all changed files:
$ uv run pre-commit run --files openhands-agent-server/openhands/agent_server/profiles_router.py openhands-sdk/openhands/sdk/workspace/remote/base.py tests/agent_server/test_profiles_router.py tests/sdk/workspace/remote/test_remote_workspace.py
All checks passed

End-to-end reproduction:

The regression test creates a provider connection with api_key="sk-provider-resolve-test" and base_url="https://api.openai.com/v1", links a profile to it, then verifies:

  • GET /api/profiles/conn-profile (default) returns api_key=None (display mode — correct)
  • GET /api/profiles/conn-profile?resolve_provider=true with X-Expose-Secrets: plaintext returns api_key="sk-provider-resolve-test" and base_url="https://api.openai.com/v1" (resolved mode — was broken, now fixed)

Video/Screenshots

N/A — text-based reproduction with captured test output above.

Design Doc

N/A — small, focused fix.

Type

  • Bug fix
  • Feature
  • Refactor
  • Breaking change
  • Docs / chore

Notes

  • Reviewers requested: @juanmichelini (author of PR Add read-at-use LLM provider connections #4492) and @hieptl (approver of PR Add read-at-use LLM provider connections #4492)
  • The fix is backward-compatible: the resolve_provider query parameter defaults to false, preserving the existing display-mode behavior for all current callers (UI, GET /api/settings via settings_router, etc.)
  • The X-Expose-Secrets: plaintext header still controls whether secrets are exposed in the response; resolve_provider=true only controls whether the provider connection's credentials are resolved into the config (they are still subject to the secret exposure mode)
  • A dangling provider_connection_id with resolve_provider=true will raise ProviderConnectionNotFound → HTTP 422, which is the expected behavior for get_llm() (it should fail loudly rather than silently return api_key=None)

🐳 Agent Server images for this PR — GHCR package, pull/run commands, and all pushed tags (click to expand)

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:b7da2cd-python

Run

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

All tags pushed for this build

ghcr.io/openhands/agent-server:b7da2cd-golang-amd64
ghcr.io/openhands/agent-server:b7da2cddb8b80f91629afb882746748690434a49-golang-amd64
ghcr.io/openhands/agent-server:fix-provider-connection-resolve-on-get-profile-golang-amd64
ghcr.io/openhands/agent-server:b7da2cd-golang_tag_1.21-bookworm-amd64
ghcr.io/openhands/agent-server:b7da2cd-golang-arm64
ghcr.io/openhands/agent-server:b7da2cddb8b80f91629afb882746748690434a49-golang-arm64
ghcr.io/openhands/agent-server:fix-provider-connection-resolve-on-get-profile-golang-arm64
ghcr.io/openhands/agent-server:b7da2cd-golang_tag_1.21-bookworm-arm64
ghcr.io/openhands/agent-server:b7da2cd-java-amd64
ghcr.io/openhands/agent-server:b7da2cddb8b80f91629afb882746748690434a49-java-amd64
ghcr.io/openhands/agent-server:fix-provider-connection-resolve-on-get-profile-java-amd64
ghcr.io/openhands/agent-server:b7da2cd-eclipse-temurin_tag_17-jdk-amd64
ghcr.io/openhands/agent-server:b7da2cd-java-arm64
ghcr.io/openhands/agent-server:b7da2cddb8b80f91629afb882746748690434a49-java-arm64
ghcr.io/openhands/agent-server:fix-provider-connection-resolve-on-get-profile-java-arm64
ghcr.io/openhands/agent-server:b7da2cd-eclipse-temurin_tag_17-jdk-arm64
ghcr.io/openhands/agent-server:b7da2cd-python-amd64
ghcr.io/openhands/agent-server:b7da2cddb8b80f91629afb882746748690434a49-python-amd64
ghcr.io/openhands/agent-server:fix-provider-connection-resolve-on-get-profile-python-amd64
ghcr.io/openhands/agent-server:b7da2cd-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim-amd64
ghcr.io/openhands/agent-server:b7da2cd-python-arm64
ghcr.io/openhands/agent-server:b7da2cddb8b80f91629afb882746748690434a49-python-arm64
ghcr.io/openhands/agent-server:fix-provider-connection-resolve-on-get-profile-python-arm64
ghcr.io/openhands/agent-server:b7da2cd-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim-arm64
ghcr.io/openhands/agent-server:b7da2cd-golang
ghcr.io/openhands/agent-server:b7da2cddb8b80f91629afb882746748690434a49-golang
ghcr.io/openhands/agent-server:fix-provider-connection-resolve-on-get-profile-golang
ghcr.io/openhands/agent-server:b7da2cd-golang_tag_1.21-bookworm
ghcr.io/openhands/agent-server:b7da2cd-java
ghcr.io/openhands/agent-server:b7da2cddb8b80f91629afb882746748690434a49-java
ghcr.io/openhands/agent-server:fix-provider-connection-resolve-on-get-profile-java
ghcr.io/openhands/agent-server:b7da2cd-eclipse-temurin_tag_17-jdk
ghcr.io/openhands/agent-server:b7da2cd-python
ghcr.io/openhands/agent-server:b7da2cddb8b80f91629afb882746748690434a49-python
ghcr.io/openhands/agent-server:fix-provider-connection-resolve-on-get-profile-python
ghcr.io/openhands/agent-server:b7da2cd-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim

About Multi-Architecture Support

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

PR #4492 introduced provider-connection-backed LLM profiles, but the
GET /api/profiles/{name} endpoint hardcoded resolve_provider=False. This
meant RemoteWorkspace.get_llm() — which calls that endpoint with
X-Expose-Secrets: plaintext — received api_key=None and base_url=None
for any profile linked to a provider connection, making the resulting
LLM unable to authenticate.

Fix: add a resolve_provider query parameter (default False, preserving
display-mode behavior) and have _fetch_llm_profile_config() pass
resolve_provider=true so credentials are resolved at read-at-use time.

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

Copy link
Copy Markdown
Contributor

Python API breakage checks — ✅ PASSED

Result:PASSED

Action log

@github-actions

Copy link
Copy Markdown
Contributor

REST API breakage checks (OpenAPI) — ✅ PASSED

Result:PASSED

Action log

@github-actions

Copy link
Copy Markdown
Contributor

Coverage

Coverage Report •
FileStmtsMissCoverMissing
openhands-agent-server/openhands/agent_server
   profiles_router.py192697%482–487
openhands-sdk/openhands/sdk/workspace/remote
   base.py2955083%81–86, 127–131, 208–210, 224–226, 267–277, 281, 365, 416, 525, 574, 576–578, 634–635, 643, 725–730, 768, 813–815, 836
TOTAL41447759782% 

@all-hands-bot

Copy link
Copy Markdown
Collaborator Author

👋 This PR needs a couple of things fixed before OpenHands can review it:

  • the PR description's HUMAN: section needs at least 20 characters describing what you tested, not just the template placeholder

Push an update once this is addressed and this check re-runs automatically.

This is an automated check - no AI was used to generate this comment.

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.

2 participants