diff --git a/openhands-agent-server/openhands/agent_server/profiles_router.py b/openhands-agent-server/openhands/agent_server/profiles_router.py index eb08fa7c3a..ce81a7e572 100644 --- a/openhands-agent-server/openhands/agent_server/profiles_router.py +++ b/openhands-agent-server/openhands/agent_server/profiles_router.py @@ -165,13 +165,23 @@ async def list_profiles(request: Request) -> ProfileListResponse: @profiles_router.get("/{name}", response_model=ProfileDetailResponse) -async def get_profile(request: Request, name: ProfileName) -> ProfileDetailResponse: +async def get_profile( + request: Request, + name: ProfileName, + resolve_provider: bool = False, +) -> ProfileDetailResponse: """Get a profile's configuration. Use the ``X-Expose-Secrets`` header to control secret exposure: - ``encrypted``: Returns cipher-encrypted values (safe for frontend clients) - ``plaintext``: Returns raw secret values (backend clients only!) - (absent): Returns nulled ``api_key`` with ``api_key_set`` indicator + + When ``resolve_provider=true`` is passed as a query parameter, the linked + provider connection's ``api_key`` and ``base_url`` are resolved into the + returned config. This is required by ``RemoteWorkspace.get_llm()`` so the + resulting LLM can authenticate. Without it (the default), the profile is + displayed exactly as stored. """ expose_mode = parse_expose_secrets_header(request) cipher = get_cipher(request) @@ -179,10 +189,7 @@ async def get_profile(request: Request, name: ProfileName) -> ProfileDetailRespo store = get_llm_profile_store() try: with store_errors(): - # Display the profile exactly as stored: don't inject the linked - # provider's credentials, and don't fail a read when the reference - # dangles. Effective key presence is reported via ``api_key_set``. - llm = store.load(name, cipher=cipher, resolve_provider=False) + llm = store.load(name, cipher=cipher, resolve_provider=resolve_provider) except FileNotFoundError: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, diff --git a/openhands-sdk/openhands/sdk/workspace/remote/base.py b/openhands-sdk/openhands/sdk/workspace/remote/base.py index b935e13456..f41465b55b 100644 --- a/openhands-sdk/openhands/sdk/workspace/remote/base.py +++ b/openhands-sdk/openhands/sdk/workspace/remote/base.py @@ -354,6 +354,7 @@ def _fetch_llm_profile_config(self, profile_name: str) -> dict[str, Any]: response = self.client.get( f"/api/profiles/{quote(profile_name, safe='')}", headers=headers, + params={"resolve_provider": "true"}, ) if response.status_code == 404: raise FileNotFoundError(f"LLM profile '{profile_name}' not found") diff --git a/tests/agent_server/test_profiles_router.py b/tests/agent_server/test_profiles_router.py index 64a8948054..92a6a3ac57 100644 --- a/tests/agent_server/test_profiles_router.py +++ b/tests/agent_server/test_profiles_router.py @@ -1962,25 +1962,50 @@ def test_validate_profile_redacts_api_key_in_error(client): ) -def test_validate_profile_redacts_api_key_in_unknown_error(client): - """Unknown exceptions must also have API keys redacted from the response.""" - leaked_key = "sk-proj-abc123defGHIjklMNOpqrsTUVwxyz1234567890" - - with ( - patch("openhands.sdk.llm.llm.LLM.uses_responses_api", return_value=False), - patch( - "openhands.sdk.llm.llm.LLM.acompletion", - side_effect=RuntimeError(f"Request failed with key {leaked_key}"), - ), - ): - response = client.post( - "/api/profiles/leaky-unknown/validate", - json={"llm": {"model": "gpt-4o", "api_key": leaked_key}}, - ) - - assert response.status_code == 200 - body = response.json() - assert body["valid"] is False - assert leaked_key not in body["error"]["message"], ( - "API key must not appear in the validate error response" +def test_get_profile_resolve_provider_returns_connection_credentials(client): + """GET /api/profiles/{name}?resolve_provider=true must return the linked + provider connection's api_key and base_url, not None. + + Regression test for PR #4492: the endpoint hardcoded + ``resolve_provider=False``, so profiles linked to a provider connection + returned ``api_key=None`` even with ``X-Expose-Secrets: plaintext``. + This broke ``RemoteWorkspace.get_llm()`` for provider-connection-backed + profiles — the resulting LLM could not authenticate. + """ + connection_id = client.post( + "/api/llm/provider-connections", + json={ + "display_name": "OpenAI Prod", + "provider": "openai", + "api_key": "sk-provider-resolve-test", + "base_url": "https://api.openai.com/v1", + }, + ).json()["id"] + client.post( + "/api/profiles/conn-profile", + json={ + "llm": { + "model": "openai/gpt-4o", + "provider_connection_id": connection_id, + }, + "include_secrets": False, + }, ) + + # Without resolve_provider: api_key is None (display mode) + display = client.get( + "/api/profiles/conn-profile", + headers={"X-Expose-Secrets": "plaintext"}, + ).json() + assert display["config"]["api_key"] is None + assert display["config"]["provider_connection_id"] == connection_id + + # With resolve_provider=true: api_key and base_url are resolved from the + # linked provider connection so get_llm() can build a working LLM. + resolved = client.get( + "/api/profiles/conn-profile?resolve_provider=true", + headers={"X-Expose-Secrets": "plaintext"}, + ).json() + assert resolved["config"]["api_key"] == "sk-provider-resolve-test" + assert resolved["config"]["base_url"] == "https://api.openai.com/v1" + assert resolved["config"]["provider_connection_id"] == connection_id diff --git a/tests/sdk/workspace/remote/test_remote_workspace.py b/tests/sdk/workspace/remote/test_remote_workspace.py index dec14c9dfa..ddd228f6f4 100644 --- a/tests/sdk/workspace/remote/test_remote_workspace.py +++ b/tests/sdk/workspace/remote/test_remote_workspace.py @@ -653,6 +653,7 @@ def test_get_llm_with_profile_name(monkeypatch): assert call_args[0][0] == "/api/profiles/fast-model" assert call_args[1]["headers"]["X-Expose-Secrets"] == "plaintext" assert call_args[1]["headers"]["X-Session-API-Key"] == "test-key" + assert call_args[1]["params"] == {"resolve_provider": "true"} llm_with_override = workspace.get_llm( profile_name="fast-model", usage_id="custom-usage"