Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 12 additions & 5 deletions openhands-agent-server/openhands/agent_server/profiles_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,24 +165,31 @@ 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)

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,
Expand Down
1 change: 1 addition & 0 deletions openhands-sdk/openhands/sdk/workspace/remote/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
67 changes: 46 additions & 21 deletions tests/agent_server/test_profiles_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions tests/sdk/workspace/remote/test_remote_workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading