From 4e74ee5b632b6081162b0f9a4dfd190d20d3a31b Mon Sep 17 00:00:00 2001 From: openhands Date: Wed, 6 May 2026 14:36:20 +0000 Subject: [PATCH 1/6] Add get_llm(), get_secrets(), and get_mcp_config() methods to RemoteWorkspace Add default implementations of settings methods to RemoteWorkspace base class that fetch configuration from the agent-server's persisted settings endpoints. This enables DockerWorkspace, APIRemoteWorkspace, and other RemoteWorkspace subclasses to retrieve LLM config, secrets, and MCP config from the agent-server. - get_llm(): Fetches LLM settings from /api/settings with X-Expose-Secrets: plaintext - get_secrets(): Returns LookupSecret references for lazy secret resolution - get_mcp_config(): Returns MCP configuration in MCPConfig-compatible format OpenHandsCloudWorkspace already overrides these methods to use Cloud API endpoints. Closes #3076 Co-authored-by: openhands --- .../openhands/sdk/workspace/remote/base.py | 201 ++++++++++++- .../workspace/remote/test_remote_workspace.py | 269 ++++++++++++++++++ 2 files changed, 469 insertions(+), 1 deletion(-) diff --git a/openhands-sdk/openhands/sdk/workspace/remote/base.py b/openhands-sdk/openhands/sdk/workspace/remote/base.py index 7e7059c3a1..07dc4cd3af 100644 --- a/openhands-sdk/openhands/sdk/workspace/remote/base.py +++ b/openhands-sdk/openhands/sdk/workspace/remote/base.py @@ -1,17 +1,37 @@ from collections.abc import Generator from pathlib import Path -from typing import Any +from typing import TYPE_CHECKING, Any from urllib.request import urlopen import httpx +import tenacity from pydantic import PrivateAttr from openhands.sdk.git.models import GitChange, GitDiff +from openhands.sdk.logger import get_logger from openhands.sdk.workspace.base import BaseWorkspace from openhands.sdk.workspace.models import CommandResult, FileOperationResult from openhands.sdk.workspace.remote.remote_workspace_mixin import RemoteWorkspaceMixin +if TYPE_CHECKING: + from openhands.sdk.llm.llm import LLM + from openhands.sdk.secret import LookupSecret + + +logger = get_logger(__name__) + +# Number of retry attempts for transient API failures +_MAX_RETRIES = 3 + + +def _is_retryable_error(error: BaseException) -> bool: + """Return True for transient errors that are worth retrying.""" + if isinstance(error, httpx.HTTPStatusError): + return error.response.status_code >= 500 + return isinstance(error, (httpx.ConnectError, httpx.TimeoutException)) + + class RemoteWorkspace(RemoteWorkspaceMixin, BaseWorkspace): """Remote workspace implementation that connects to an OpenHands agent server. @@ -232,3 +252,182 @@ def conversation_id(self) -> str | None: The conversation ID if one has been registered, None otherwise. """ return None + + # ── Settings Methods ────────────────────────────────────────────────── + # These methods fetch configuration from the agent-server's persisted + # settings endpoints. Subclasses like OpenHandsCloudWorkspace may override + # to use alternative endpoints (e.g., Cloud API). + + @tenacity.retry( + stop=tenacity.stop_after_attempt(_MAX_RETRIES), + wait=tenacity.wait_exponential(multiplier=1, min=1, max=5), + retry=tenacity.retry_if_exception(_is_retryable_error), + reraise=True, + ) + def get_llm(self, **llm_kwargs: Any) -> "LLM": + """Fetch LLM settings from the agent-server's persisted settings. + + Calls ``GET /api/settings`` with ``X-Expose-Secrets: plaintext`` header + to retrieve the LLM configuration (model, api_key, base_url) and returns + a fully usable ``LLM`` instance. Retries up to 3 times on transient + errors (network issues, server 5xx). + + Args: + **llm_kwargs: Additional keyword arguments passed to the LLM + constructor, allowing overrides of any LLM parameter + (e.g., ``model``, ``temperature``). + + Returns: + An LLM instance configured with the persisted settings. + + Raises: + httpx.HTTPStatusError: If the API request fails. + RuntimeError: If the workspace host is not set. + + Example: + >>> with DockerWorkspace(...) as workspace: + ... llm = workspace.get_llm() + ... agent = Agent(llm=llm, tools=get_default_tools()) + """ + from openhands.sdk.llm.llm import LLM + + if not self.host or self.host == "undefined": + raise RuntimeError("Workspace host is not set") + + headers = dict(self._headers) + headers["X-Expose-Secrets"] = "plaintext" + + response = self.client.get("/api/settings", headers=headers) + response.raise_for_status() + data = response.json() + + agent_settings = data.get("agent_settings", {}) + llm_config = agent_settings.get("llm", {}) + + kwargs: dict[str, Any] = {} + if llm_config.get("model"): + kwargs["model"] = llm_config["model"] + if llm_config.get("api_key"): + kwargs["api_key"] = llm_config["api_key"] + if llm_config.get("base_url"): + kwargs["base_url"] = llm_config["base_url"] + + # User-provided kwargs take precedence + kwargs.update(llm_kwargs) + + return LLM(**kwargs) + + @tenacity.retry( + stop=tenacity.stop_after_attempt(_MAX_RETRIES), + wait=tenacity.wait_exponential(multiplier=1, min=1, max=5), + retry=tenacity.retry_if_exception(_is_retryable_error), + reraise=True, + ) + def get_secrets(self, names: list[str] | None = None) -> dict[str, "LookupSecret"]: + """Build ``LookupSecret`` references for the agent-server's secrets. + + Fetches the list of available secret **names** from the agent-server + (no raw values) and returns a dict of ``LookupSecret`` objects whose + URLs point to per-secret endpoints. The agent-server resolves each + ``LookupSecret`` lazily, so raw values **never** transit through + the SDK client. + + The returned dict is compatible with ``conversation.update_secrets()``. + + Args: + names: Optional list of secret names to include. If ``None``, + all available secrets are returned. + + Returns: + A dictionary mapping secret names to ``LookupSecret`` instances. + + Raises: + httpx.HTTPStatusError: If the API request fails. + RuntimeError: If the workspace host is not set. + + Example: + >>> with DockerWorkspace(...) as workspace: + ... secrets = workspace.get_secrets() + ... conversation.update_secrets(secrets) + ... + ... # Or a subset + ... gh = workspace.get_secrets(names=["GITHUB_TOKEN"]) + ... conversation.update_secrets(gh) + """ + from openhands.sdk.secret import LookupSecret + + if not self.host or self.host == "undefined": + raise RuntimeError("Workspace host is not set") + + response = self.client.get("/api/settings/secrets", headers=self._headers) + response.raise_for_status() + data = response.json() + + result: dict[str, LookupSecret] = {} + for item in data.get("secrets", []): + name = item["name"] + if names is not None and name not in names: + continue + result[name] = LookupSecret( + url=f"{self.host}/api/settings/secrets/{name}", + headers=dict(self._headers), + description=item.get("description"), + ) + + return result + + @tenacity.retry( + stop=tenacity.stop_after_attempt(_MAX_RETRIES), + wait=tenacity.wait_exponential(multiplier=1, min=1, max=5), + retry=tenacity.retry_if_exception(_is_retryable_error), + reraise=True, + ) + def get_mcp_config(self) -> dict[str, Any]: + """Fetch MCP configuration from the agent-server's persisted settings. + + Calls ``GET /api/settings`` with ``X-Expose-Secrets: plaintext`` header + to retrieve the MCP configuration and transforms it into the format + expected by the SDK Agent and ``fastmcp.mcp_config.MCPConfig``. + + Returns: + A dictionary with ``mcpServers`` key containing server configurations + (compatible with ``MCPConfig.model_validate()``), or an empty dict + if no MCP config is set. + + Raises: + httpx.HTTPStatusError: If the API request fails. + RuntimeError: If the workspace host is not set. + + Example: + >>> with DockerWorkspace(...) as workspace: + ... llm = workspace.get_llm() + ... mcp_config = workspace.get_mcp_config() + ... agent = Agent(llm=llm, mcp_config=mcp_config, tools=...) + ... + ... # Or validate as MCPConfig: + ... from fastmcp.mcp_config import MCPConfig + ... config = MCPConfig.model_validate(mcp_config) + """ + if not self.host or self.host == "undefined": + raise RuntimeError("Workspace host is not set") + + headers = dict(self._headers) + headers["X-Expose-Secrets"] = "plaintext" + + response = self.client.get("/api/settings", headers=headers) + response.raise_for_status() + data = response.json() + + agent_settings = data.get("agent_settings", {}) + mcp_config_data = agent_settings.get("mcp_config") + + if not mcp_config_data: + return {} + + # The agent-server stores MCP config in MCPConfig format already + # (with mcpServers key), so we can return it directly if present + if "mcpServers" in mcp_config_data: + return mcp_config_data + + # Handle legacy format or empty config + return {} diff --git a/tests/sdk/workspace/remote/test_remote_workspace.py b/tests/sdk/workspace/remote/test_remote_workspace.py index 4cb3dfbf42..bfc14f4cac 100644 --- a/tests/sdk/workspace/remote/test_remote_workspace.py +++ b/tests/sdk/workspace/remote/test_remote_workspace.py @@ -402,3 +402,272 @@ def test_alive_with_normalized_host(mock_urlopen): def test_alive_is_property(): """Test that alive is a property, not a method.""" assert isinstance(RemoteWorkspace.alive, property) + + +# ── Settings Methods Tests ──────────────────────────────────────────────── + + +def test_get_llm_returns_configured_llm(monkeypatch): + """Test get_llm returns an LLM with persisted settings.""" + from pydantic import SecretStr + + # Allow short context windows for testing + monkeypatch.setenv("ALLOW_SHORT_CONTEXT_WINDOWS", "true") + + workspace = RemoteWorkspace( + host="http://localhost:8000", working_dir="/tmp", api_key="test-key" + ) + + mock_client = MagicMock() + mock_response = Mock() + mock_response.json.return_value = { + "agent_settings": { + "llm": { + "model": "gpt-4", + "api_key": "sk-test-key", + "base_url": "https://api.openai.com/v1", + } + }, + "conversation_settings": {}, + "llm_api_key_is_set": True, + } + mock_response.raise_for_status = Mock() + mock_client.get.return_value = mock_response + workspace._client = mock_client + + llm = workspace.get_llm() + + # Verify the LLM was created with correct settings + assert llm.model == "gpt-4" + # api_key can be str | SecretStr | None + assert llm.api_key is not None + if isinstance(llm.api_key, SecretStr): + assert llm.api_key.get_secret_value() == "sk-test-key" + else: + assert llm.api_key == "sk-test-key" + assert llm.base_url == "https://api.openai.com/v1" + + # Verify API was called with correct headers + mock_client.get.assert_called_once() + call_args = mock_client.get.call_args + assert call_args[0][0] == "/api/settings" + assert call_args[1]["headers"]["X-Expose-Secrets"] == "plaintext" + assert call_args[1]["headers"]["X-Session-API-Key"] == "test-key" + + +def test_get_llm_with_kwargs_override(monkeypatch): + """Test get_llm allows kwargs to override persisted settings.""" + from pydantic import SecretStr + + # Allow short context windows for testing + monkeypatch.setenv("ALLOW_SHORT_CONTEXT_WINDOWS", "true") + + workspace = RemoteWorkspace( + host="http://localhost:8000", working_dir="/tmp", api_key="test-key" + ) + + mock_client = MagicMock() + mock_response = Mock() + mock_response.json.return_value = { + "agent_settings": { + "llm": { + "model": "gpt-3.5-turbo", + "api_key": "sk-persisted-key", + } + }, + "conversation_settings": {}, + "llm_api_key_is_set": True, + } + mock_response.raise_for_status = Mock() + mock_client.get.return_value = mock_response + workspace._client = mock_client + + # Override model but use persisted API key + llm = workspace.get_llm(model="gpt-4o") + + assert llm.model == "gpt-4o" # Overridden + # api_key can be str | SecretStr | None + assert llm.api_key is not None + if isinstance(llm.api_key, SecretStr): + assert llm.api_key.get_secret_value() == "sk-persisted-key" + else: + assert llm.api_key == "sk-persisted-key" + + +def test_get_llm_raises_on_undefined_host(): + """Test get_llm raises RuntimeError when host is undefined.""" + workspace = RemoteWorkspace(host="undefined", working_dir="/tmp") + + with pytest.raises(RuntimeError, match="Workspace host is not set"): + workspace.get_llm() + + +def test_get_secrets_returns_lookup_secrets(): + """Test get_secrets returns LookupSecret references.""" + workspace = RemoteWorkspace( + host="http://localhost:8000", working_dir="/tmp", api_key="test-key" + ) + + mock_client = MagicMock() + mock_response = Mock() + mock_response.json.return_value = { + "secrets": [ + {"name": "GITHUB_TOKEN", "description": "GitHub personal access token"}, + {"name": "OPENAI_API_KEY", "description": None}, + ] + } + mock_response.raise_for_status = Mock() + mock_client.get.return_value = mock_response + workspace._client = mock_client + + secrets = workspace.get_secrets() + + assert len(secrets) == 2 + assert "GITHUB_TOKEN" in secrets + assert "OPENAI_API_KEY" in secrets + + # Check LookupSecret structure + gh_secret = secrets["GITHUB_TOKEN"] + assert gh_secret.url == "http://localhost:8000/api/settings/secrets/GITHUB_TOKEN" + assert gh_secret.headers == {"X-Session-API-Key": "test-key"} + assert gh_secret.description == "GitHub personal access token" + + openai_secret = secrets["OPENAI_API_KEY"] + assert openai_secret.description is None + + +def test_get_secrets_filters_by_names(): + """Test get_secrets filters secrets by names when provided.""" + workspace = RemoteWorkspace( + host="http://localhost:8000", working_dir="/tmp", api_key="test-key" + ) + + mock_client = MagicMock() + mock_response = Mock() + mock_response.json.return_value = { + "secrets": [ + {"name": "GITHUB_TOKEN", "description": "GitHub token"}, + {"name": "OPENAI_API_KEY", "description": "OpenAI key"}, + {"name": "AWS_ACCESS_KEY", "description": "AWS key"}, + ] + } + mock_response.raise_for_status = Mock() + mock_client.get.return_value = mock_response + workspace._client = mock_client + + # Request only specific secrets + secrets = workspace.get_secrets(names=["GITHUB_TOKEN", "AWS_ACCESS_KEY"]) + + assert len(secrets) == 2 + assert "GITHUB_TOKEN" in secrets + assert "AWS_ACCESS_KEY" in secrets + assert "OPENAI_API_KEY" not in secrets + + +def test_get_secrets_returns_empty_dict_when_no_secrets(): + """Test get_secrets returns empty dict when no secrets exist.""" + workspace = RemoteWorkspace(host="http://localhost:8000", working_dir="/tmp") + + mock_client = MagicMock() + mock_response = Mock() + mock_response.json.return_value = {"secrets": []} + mock_response.raise_for_status = Mock() + mock_client.get.return_value = mock_response + workspace._client = mock_client + + secrets = workspace.get_secrets() + + assert secrets == {} + + +def test_get_secrets_raises_on_undefined_host(): + """Test get_secrets raises RuntimeError when host is undefined.""" + workspace = RemoteWorkspace(host="undefined", working_dir="/tmp") + + with pytest.raises(RuntimeError, match="Workspace host is not set"): + workspace.get_secrets() + + +def test_get_mcp_config_returns_config(): + """Test get_mcp_config returns MCP configuration.""" + workspace = RemoteWorkspace( + host="http://localhost:8000", working_dir="/tmp", api_key="test-key" + ) + + mock_client = MagicMock() + mock_response = Mock() + mock_response.json.return_value = { + "agent_settings": { + "mcp_config": { + "mcpServers": { + "shttp_0": { + "url": "https://mcp.example.com/api", + "transport": "streamable-http", + } + } + } + }, + "conversation_settings": {}, + "llm_api_key_is_set": True, + } + mock_response.raise_for_status = Mock() + mock_client.get.return_value = mock_response + workspace._client = mock_client + + config = workspace.get_mcp_config() + + assert "mcpServers" in config + assert "shttp_0" in config["mcpServers"] + assert config["mcpServers"]["shttp_0"]["url"] == "https://mcp.example.com/api" + + # Verify API was called with correct headers + call_args = mock_client.get.call_args + assert call_args[1]["headers"]["X-Expose-Secrets"] == "plaintext" + + +def test_get_mcp_config_returns_empty_dict_when_no_config(): + """Test get_mcp_config returns empty dict when no MCP config exists.""" + workspace = RemoteWorkspace(host="http://localhost:8000", working_dir="/tmp") + + mock_client = MagicMock() + mock_response = Mock() + mock_response.json.return_value = { + "agent_settings": {"llm": {"model": "gpt-4"}}, + "conversation_settings": {}, + "llm_api_key_is_set": True, + } + mock_response.raise_for_status = Mock() + mock_client.get.return_value = mock_response + workspace._client = mock_client + + config = workspace.get_mcp_config() + + assert config == {} + + +def test_get_mcp_config_returns_empty_dict_when_mcp_config_is_none(): + """Test get_mcp_config returns empty dict when mcp_config is None.""" + workspace = RemoteWorkspace(host="http://localhost:8000", working_dir="/tmp") + + mock_client = MagicMock() + mock_response = Mock() + mock_response.json.return_value = { + "agent_settings": {"llm": {"model": "gpt-4"}, "mcp_config": None}, + "conversation_settings": {}, + "llm_api_key_is_set": True, + } + mock_response.raise_for_status = Mock() + mock_client.get.return_value = mock_response + workspace._client = mock_client + + config = workspace.get_mcp_config() + + assert config == {} + + +def test_get_mcp_config_raises_on_undefined_host(): + """Test get_mcp_config raises RuntimeError when host is undefined.""" + workspace = RemoteWorkspace(host="undefined", working_dir="/tmp") + + with pytest.raises(RuntimeError, match="Workspace host is not set"): + workspace.get_mcp_config() From 0c7da06607ac43f96d2635ec7c0b593c8c94db69 Mon Sep 17 00:00:00 2001 From: openhands Date: Wed, 6 May 2026 14:42:11 +0000 Subject: [PATCH 2/6] Add example demonstrating workspace.get_llm() usage Add example 13 showing how to: 1. Spin up an agent-server 2. Configure LLM settings via the Settings API 3. Use workspace.get_llm() to retrieve a configured LLM 4. Start a conversation using the retrieved LLM 5. Demonstrate get_secrets() and get_mcp_config() methods Co-authored-by: openhands --- .../13_workspace_get_llm.py | 285 ++++++++++++++++++ 1 file changed, 285 insertions(+) create mode 100644 examples/02_remote_agent_server/13_workspace_get_llm.py diff --git a/examples/02_remote_agent_server/13_workspace_get_llm.py b/examples/02_remote_agent_server/13_workspace_get_llm.py new file mode 100644 index 0000000000..f74b3b3f46 --- /dev/null +++ b/examples/02_remote_agent_server/13_workspace_get_llm.py @@ -0,0 +1,285 @@ +"""Example demonstrating workspace.get_llm() for settings-driven conversations. + +This example shows how to use the new RemoteWorkspace settings methods: +1. Spin up an agent-server (via subprocess or Docker) +2. Configure LLM settings via the Settings API +3. Use workspace.get_llm() to retrieve a configured LLM +4. Start a conversation using the retrieved LLM + +This pattern enables: +- Centralized LLM configuration on the agent-server +- Consistent LLM settings across multiple conversations +- Easy override of specific LLM parameters via kwargs +""" + +import os +import subprocess +import sys +import threading +import time + +import httpx + +from openhands.sdk import Conversation, get_logger +from openhands.sdk.workspace.remote.base import RemoteWorkspace +from openhands.tools.preset.default import get_default_agent + + +logger = get_logger(__name__) + + +def _stream_output(stream, prefix, target_stream): + """Stream output from subprocess to target stream with prefix.""" + try: + for line in iter(stream.readline, ""): + if line: + target_stream.write(f"[{prefix}] {line}") + target_stream.flush() + except Exception as e: + print(f"Error streaming {prefix}: {e}", file=sys.stderr) + finally: + stream.close() + + +class ManagedAPIServer: + """Context manager for subprocess-managed OpenHands API server.""" + + def __init__(self, port: int = 8000, host: str = "127.0.0.1"): + self.port: int = port + self.host: str = host + self.process: subprocess.Popen[str] | None = None + self.base_url: str = f"http://{host}:{port}" + self.stdout_thread: threading.Thread | None = None + self.stderr_thread: threading.Thread | None = None + + def __enter__(self): + """Start the API server subprocess.""" + print(f"Starting OpenHands API server on {self.base_url}...") + + # Set OH_SECRET_KEY to enable encrypted secrets feature + # Set TMUX_TMPDIR to a short path to avoid socket path length issues + env = { + "LOG_JSON": "true", + "OH_SECRET_KEY": "example-secret-key-for-demo-only-32b", + "TMUX_TMPDIR": "/tmp/oh-tmux", + **os.environ, + } + + self.process = subprocess.Popen( + [ + "python", + "-m", + "openhands.agent_server", + "--port", + str(self.port), + "--host", + self.host, + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + env=env, + ) + + assert self.process is not None + assert self.process.stdout is not None + assert self.process.stderr is not None + self.stdout_thread = threading.Thread( + target=_stream_output, + args=(self.process.stdout, "SERVER", sys.stdout), + daemon=True, + ) + self.stderr_thread = threading.Thread( + target=_stream_output, + args=(self.process.stderr, "SERVER", sys.stderr), + daemon=True, + ) + self.stdout_thread.start() + self.stderr_thread.start() + + # Wait for server to be ready + max_retries = 30 + for i in range(max_retries): + try: + response = httpx.get(f"{self.base_url}/health", timeout=2.0) + if response.status_code == 200: + print(f"✅ Server ready after {i + 1} attempts") + return self + except httpx.RequestError: + pass + time.sleep(1) + + raise RuntimeError(f"Server failed to start after {max_retries} seconds") + + def __exit__(self, exc_type, exc_val, exc_tb): + """Stop the API server subprocess.""" + if self.process: + print("Stopping API server...") + self.process.terminate() + try: + self.process.wait(timeout=5) + except subprocess.TimeoutExpired: + self.process.kill() + self.process.wait() + print("✅ Server stopped") + + +# Get LLM configuration from environment +api_key = os.getenv("LLM_API_KEY") +assert api_key is not None, "LLM_API_KEY environment variable is not set." +llm_model = os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929") +llm_base_url = os.getenv("LLM_BASE_URL") # Optional custom base URL + +with ManagedAPIServer(port=8766) as server: + # Create HTTP client for settings API + client = httpx.Client(base_url=server.base_url, timeout=120.0) + + try: + # ══════════════════════════════════════════════════════════════ + # Part 1: Configure LLM Settings on Agent-Server + # ══════════════════════════════════════════════════════════════ + logger.info("\n" + "=" * 60) + logger.info("🔧 Configuring LLM settings on agent-server") + logger.info("=" * 60) + + # Store LLM configuration via the Settings API + llm_config: dict[str, str] = { + "model": llm_model, + "api_key": api_key, + } + if llm_base_url: + llm_config["base_url"] = llm_base_url + + response = client.patch( + "/api/settings", + json={"agent_settings_diff": {"llm": llm_config}}, + ) + assert response.status_code == 200, f"PATCH settings failed: {response.text}" + settings = response.json() + + logger.info("✅ LLM settings stored successfully") + logger.info(f" - Model: {settings['agent_settings']['llm']['model']}") + logger.info(f" - API key set: {settings['llm_api_key_is_set']}") + + # ══════════════════════════════════════════════════════════════ + # Part 2: Create Workspace and Retrieve LLM via get_llm() + # ══════════════════════════════════════════════════════════════ + logger.info("\n" + "=" * 60) + logger.info("🔗 Creating workspace and retrieving LLM configuration") + logger.info("=" * 60) + + # Create a RemoteWorkspace pointing to our agent-server + # Note: In production, you might use DockerWorkspace or APIRemoteWorkspace + # which handle container lifecycle. Here we use RemoteWorkspace directly + # since we're managing the server ourselves. + workspace = RemoteWorkspace( + host=server.base_url, + working_dir="/tmp/workspace_get_llm_demo", + ) + + # Use get_llm() to retrieve LLM configured on the agent-server! + # This fetches settings from /api/settings with X-Expose-Secrets: plaintext + llm = workspace.get_llm() + + logger.info("✅ Retrieved LLM from workspace.get_llm()") + logger.info(f" - Model: {llm.model}") + logger.info(f" - Base URL: {llm.base_url or '(default)'}") + + # You can also override specific settings: + # llm_custom = workspace.get_llm(model="gpt-4o", temperature=0.5) + + # ══════════════════════════════════════════════════════════════ + # Part 3: Create Agent and Start Conversation + # ══════════════════════════════════════════════════════════════ + logger.info("\n" + "=" * 60) + logger.info("🤖 Creating agent with retrieved LLM") + logger.info("=" * 60) + + # Create agent using the LLM from workspace settings + agent = get_default_agent(llm=llm, cli_mode=True) + + logger.info("✅ Agent created with workspace LLM settings") + + # ══════════════════════════════════════════════════════════════ + # Part 4: Start Conversation and Run Task + # ══════════════════════════════════════════════════════════════ + logger.info("\n" + "=" * 60) + logger.info("💬 Starting conversation") + logger.info("=" * 60) + + # Create conversation using the workspace and agent + conversation = Conversation( + agent=agent, + workspace=workspace, + ) + + try: + logger.info(f" Conversation ID: {conversation.state.id}") + + # Send a simple task + conversation.send_message("What is 2 + 2? Just respond with the number.") + logger.info("📝 Sent message, running conversation...") + conversation.run() + + logger.info("✅ Conversation completed!") + logger.info(f" Status: {conversation.state.execution_status}") + + # Get cost metrics + cost = ( + conversation.conversation_stats.get_combined_metrics().accumulated_cost + ) + logger.info(f" Cost: ${cost:.6f}") + + print(f"EXAMPLE_COST: {cost}") + + finally: + conversation.close() + logger.info("🧹 Conversation closed") + + # ══════════════════════════════════════════════════════════════ + # Part 5: Demonstrate get_secrets() and get_mcp_config() + # ══════════════════════════════════════════════════════════════ + logger.info("\n" + "=" * 60) + logger.info("🔐 Demonstrating get_secrets() and get_mcp_config()") + logger.info("=" * 60) + + # Store a test secret + response = client.put( + "/api/settings/secrets", + json={ + "name": "TEST_SECRET", + "value": "secret-value-123", + "description": "Test secret for demo", + }, + ) + assert response.status_code == 200 + + # Retrieve secrets via workspace.get_secrets() + secrets = workspace.get_secrets() + logger.info( + f"✅ Retrieved {len(secrets)} secret(s) via workspace.get_secrets()" + ) + for name, lookup_secret in secrets.items(): + logger.info(f" - {name}: LookupSecret(url={lookup_secret.url})") + + # Clean up test secret + client.delete("/api/settings/secrets/TEST_SECRET") + logger.info(" Test secret deleted") + + # get_mcp_config() returns empty dict if no MCP config is set + mcp_config = workspace.get_mcp_config() + logger.info(f"✅ MCP config: {mcp_config or '(none configured)'}") + + logger.info("\n" + "=" * 60) + logger.info("🎉 Example completed successfully!") + logger.info("=" * 60) + logger.info(""" +Key takeaways: +1. Configure LLM settings once via /api/settings +2. Use workspace.get_llm() to retrieve configured LLM +3. Use workspace.get_secrets() for lazy secret resolution +4. Use workspace.get_mcp_config() for MCP server configuration +""") + + finally: + client.close() From e0bcd4ed40dfaa92bd65bf925be69c5050926966 Mon Sep 17 00:00:00 2001 From: openhands Date: Wed, 6 May 2026 14:49:23 +0000 Subject: [PATCH 3/6] Update example to demonstrate secure API key authentication - Generate random session API key for agent-server - Demonstrate 401 rejection without API key - Show RemoteWorkspace.api_key usage for authenticated requests - Verify LookupSecrets include auth headers for resolution Co-authored-by: openhands --- .../13_workspace_get_llm.py | 111 +++++++++++++----- 1 file changed, 84 insertions(+), 27 deletions(-) diff --git a/examples/02_remote_agent_server/13_workspace_get_llm.py b/examples/02_remote_agent_server/13_workspace_get_llm.py index f74b3b3f46..765ba4fc92 100644 --- a/examples/02_remote_agent_server/13_workspace_get_llm.py +++ b/examples/02_remote_agent_server/13_workspace_get_llm.py @@ -1,18 +1,27 @@ """Example demonstrating workspace.get_llm() for settings-driven conversations. -This example shows how to use the new RemoteWorkspace settings methods: -1. Spin up an agent-server (via subprocess or Docker) -2. Configure LLM settings via the Settings API -3. Use workspace.get_llm() to retrieve a configured LLM +This example shows how to use the new RemoteWorkspace settings methods with +API key authentication for secure access: + +1. Spin up an agent-server with a session API key configured +2. Configure LLM settings via the Settings API (requires API key auth) +3. Use workspace.get_llm() to retrieve a configured LLM (also authenticated) 4. Start a conversation using the retrieved LLM +Security Model: +- The agent-server is configured with SESSION_API_KEY env var +- All requests must include the X-Session-API-Key header +- RemoteWorkspace.api_key parameter sets this header automatically +- LookupSecrets include the API key in their headers for resolution + This pattern enables: -- Centralized LLM configuration on the agent-server -- Consistent LLM settings across multiple conversations -- Easy override of specific LLM parameters via kwargs +- Secure centralized LLM configuration on the agent-server +- Authenticated access to settings and secrets +- Consistent security across all workspace operations """ import os +import secrets import subprocess import sys import threading @@ -42,25 +51,34 @@ def _stream_output(stream, prefix, target_stream): class ManagedAPIServer: - """Context manager for subprocess-managed OpenHands API server.""" + """Context manager for subprocess-managed OpenHands API server. + + Launches an agent-server with a randomly generated session API key + for secure access. All API requests must include this key. + """ def __init__(self, port: int = 8000, host: str = "127.0.0.1"): self.port: int = port self.host: str = host self.process: subprocess.Popen[str] | None = None self.base_url: str = f"http://{host}:{port}" + # Generate a random session API key for this server instance + self.session_api_key: str = secrets.token_urlsafe(32) self.stdout_thread: threading.Thread | None = None self.stderr_thread: threading.Thread | None = None def __enter__(self): - """Start the API server subprocess.""" + """Start the API server subprocess with session API key auth.""" print(f"Starting OpenHands API server on {self.base_url}...") + print("🔐 Session API key configured (required for all requests)") - # Set OH_SECRET_KEY to enable encrypted secrets feature - # Set TMUX_TMPDIR to a short path to avoid socket path length issues + # Configure server with security: + # - OH_SECRET_KEY: enables encrypted storage of secrets + # - SESSION_API_KEY: requires all requests to be authenticated env = { "LOG_JSON": "true", "OH_SECRET_KEY": "example-secret-key-for-demo-only-32b", + "SESSION_API_KEY": self.session_api_key, # Enable auth! "TMUX_TMPDIR": "/tmp/oh-tmux", **os.environ, } @@ -131,10 +149,36 @@ def __exit__(self, exc_type, exc_val, exc_tb): llm_base_url = os.getenv("LLM_BASE_URL") # Optional custom base URL with ManagedAPIServer(port=8766) as server: - # Create HTTP client for settings API - client = httpx.Client(base_url=server.base_url, timeout=120.0) + # Create HTTP client for settings API - MUST include session API key! + # The X-Session-API-Key header authenticates all requests + client = httpx.Client( + base_url=server.base_url, + timeout=120.0, + headers={"X-Session-API-Key": server.session_api_key}, + ) try: + # ══════════════════════════════════════════════════════════════ + # Part 0: Demonstrate Authentication Requirement + # ══════════════════════════════════════════════════════════════ + logger.info("\n" + "=" * 60) + logger.info("🔐 Demonstrating API key authentication") + logger.info("=" * 60) + + # Request WITHOUT api key should fail (401 Unauthorized) + unauthenticated = httpx.Client(base_url=server.base_url, timeout=10.0) + response = unauthenticated.get("/api/settings") + assert response.status_code == 401, ( + f"Expected 401 without API key, got {response.status_code}" + ) + logger.info("✅ Request without API key rejected (401 Unauthorized)") + unauthenticated.close() + + # Request WITH api key should succeed + response = client.get("/api/settings") + assert response.status_code == 200, f"Authenticated request failed: {response}" + logger.info("✅ Request with API key accepted (200 OK)") + # ══════════════════════════════════════════════════════════════ # Part 1: Configure LLM Settings on Agent-Server # ══════════════════════════════════════════════════════════════ @@ -168,17 +212,21 @@ def __exit__(self, exc_type, exc_val, exc_tb): logger.info("🔗 Creating workspace and retrieving LLM configuration") logger.info("=" * 60) - # Create a RemoteWorkspace pointing to our agent-server - # Note: In production, you might use DockerWorkspace or APIRemoteWorkspace - # which handle container lifecycle. Here we use RemoteWorkspace directly - # since we're managing the server ourselves. + # Create a RemoteWorkspace with API key authentication! + # The api_key is used for X-Session-API-Key header on all requests, + # including get_llm(), get_secrets(), and get_mcp_config(). workspace = RemoteWorkspace( host=server.base_url, working_dir="/tmp/workspace_get_llm_demo", + api_key=server.session_api_key, # Authenticate workspace requests ) + logger.info("✅ Workspace created with session API key") + # Use get_llm() to retrieve LLM configured on the agent-server! - # This fetches settings from /api/settings with X-Expose-Secrets: plaintext + # This calls GET /api/settings with both: + # - X-Session-API-Key (authentication) + # - X-Expose-Secrets: plaintext (to get the actual API key value) llm = workspace.get_llm() logger.info("✅ Retrieved LLM from workspace.get_llm()") @@ -237,7 +285,7 @@ def __exit__(self, exc_type, exc_val, exc_tb): logger.info("🧹 Conversation closed") # ══════════════════════════════════════════════════════════════ - # Part 5: Demonstrate get_secrets() and get_mcp_config() + # Part 5: Demonstrate get_secrets() with API Key Auth # ══════════════════════════════════════════════════════════════ logger.info("\n" + "=" * 60) logger.info("🔐 Demonstrating get_secrets() and get_mcp_config()") @@ -255,12 +303,20 @@ def __exit__(self, exc_type, exc_val, exc_tb): assert response.status_code == 200 # Retrieve secrets via workspace.get_secrets() - secrets = workspace.get_secrets() + # The returned LookupSecrets include the API key in their headers + # so they can authenticate when resolved by the agent-server + workspace_secrets = workspace.get_secrets() logger.info( - f"✅ Retrieved {len(secrets)} secret(s) via workspace.get_secrets()" + f"✅ Retrieved {len(workspace_secrets)} secret(s) via " + "workspace.get_secrets()" ) - for name, lookup_secret in secrets.items(): - logger.info(f" - {name}: LookupSecret(url={lookup_secret.url})") + for name, lookup_secret in workspace_secrets.items(): + logger.info(f" - {name}: LookupSecret") + logger.info(f" URL: {lookup_secret.url}") + # The LookupSecret includes the X-Session-API-Key header + # so it can authenticate when resolved + has_auth = "X-Session-API-Key" in (lookup_secret.headers or {}) + logger.info(f" Has API key header: {has_auth}") # Clean up test secret client.delete("/api/settings/secrets/TEST_SECRET") @@ -275,10 +331,11 @@ def __exit__(self, exc_type, exc_val, exc_tb): logger.info("=" * 60) logger.info(""" Key takeaways: -1. Configure LLM settings once via /api/settings -2. Use workspace.get_llm() to retrieve configured LLM -3. Use workspace.get_secrets() for lazy secret resolution -4. Use workspace.get_mcp_config() for MCP server configuration +1. Agent-server can be secured with SESSION_API_KEY env var +2. RemoteWorkspace.api_key passes X-Session-API-Key header +3. workspace.get_llm() retrieves LLM with authentication +4. workspace.get_secrets() returns LookupSecrets with auth headers +5. workspace.get_mcp_config() retrieves MCP config with auth """) finally: From b7d1d9882cf6fb9c57b9e78a7a0ce6bbca85fbcb Mon Sep 17 00:00:00 2001 From: openhands Date: Wed, 6 May 2026 20:02:31 +0000 Subject: [PATCH 4/6] refactor: Move API models to SDK for shared validation Address review feedback: 1. Pass all llm_config fields to LLM constructor, not just model/api_key/base_url 2. Move response models from agent-server to SDK for sharing: - SettingsResponse, SettingsUpdateRequest - SecretsListResponse, SecretItemResponse, SecretCreateRequest 3. Use Pydantic model_validate() for proper response validation 4. Agent-server now imports these models from SDK This enables SDK clients (RemoteWorkspace) to validate responses using the same models used by agent-server endpoints, eliminating duplication and ensuring contract consistency. Co-authored-by: openhands --- .../agent_server/persistence/__init__.py | 14 ++-- .../agent_server/persistence/models.py | 28 +------ .../openhands/agent_server/settings_router.py | 43 ++++------- .../openhands/sdk/settings/__init__.py | 13 ++++ .../openhands/sdk/settings/api_models.py | 77 +++++++++++++++++++ .../openhands/sdk/workspace/remote/base.py | 42 +++++----- 6 files changed, 137 insertions(+), 80 deletions(-) create mode 100644 openhands-sdk/openhands/sdk/settings/api_models.py diff --git a/openhands-agent-server/openhands/agent_server/persistence/__init__.py b/openhands-agent-server/openhands/agent_server/persistence/__init__.py index def3b52e10..bba7969fc4 100644 --- a/openhands-agent-server/openhands/agent_server/persistence/__init__.py +++ b/openhands-agent-server/openhands/agent_server/persistence/__init__.py @@ -1,13 +1,16 @@ -"""Persistence module for settings and secrets storage.""" +"""Persistence module for settings and secrets storage. + +Note: API request/response models (SecretCreateRequest, SecretItemResponse, +SecretsListResponse, SettingsResponse, SettingsUpdateRequest) are defined +in the SDK to enable sharing between SDK clients and agent-server. +See: openhands.sdk.settings.api_models +""" from openhands.agent_server.persistence.models import ( SECRET_NAME_PATTERN, CustomSecret, - CustomSecretCreate, - CustomSecretResponse, PersistedSettings, Secrets, - SecretsResponse, SettingsUpdatePayload, ) from openhands.agent_server.persistence.store import ( @@ -26,11 +29,8 @@ "SECRET_NAME_PATTERN", # Models "CustomSecret", - "CustomSecretCreate", - "CustomSecretResponse", "PersistedSettings", "Secrets", - "SecretsResponse", "SettingsUpdatePayload", # Stores "FileSecretsStore", diff --git a/openhands-agent-server/openhands/agent_server/persistence/models.py b/openhands-agent-server/openhands/agent_server/persistence/models.py index 38e1f7fe9d..842ad334c1 100644 --- a/openhands-agent-server/openhands/agent_server/persistence/models.py +++ b/openhands-agent-server/openhands/agent_server/persistence/models.py @@ -344,31 +344,11 @@ def _normalize_inputs(cls, data: dict | object) -> dict | object: return data -# ── Response Models for API ────────────────────────────────────────────── - - -class CustomSecretCreate(BaseModel): - """Request model for creating a custom secret.""" - - name: str - value: SecretStr - description: str | None = None - - -class CustomSecretResponse(BaseModel): - """Response model for a custom secret (without value).""" - - name: str - description: str | None = None - - -class SecretsResponse(BaseModel): - """Response model listing available secrets.""" - - secrets: list[CustomSecretResponse] - - # ── Helper Functions ───────────────────────────────────────────────────── +# +# Note: API request/response models have been moved to the SDK to enable +# sharing between SDK clients and the agent-server. See: +# openhands.sdk.settings.api_models (SecretCreateRequest, SecretItemResponse, etc.) def _coerce_dict_secrets(d: dict[str, Any]) -> dict[str, Any]: diff --git a/openhands-agent-server/openhands/agent_server/settings_router.py b/openhands-agent-server/openhands/agent_server/settings_router.py index e2e5aee9ce..713add93dc 100644 --- a/openhands-agent-server/openhands/agent_server/settings_router.py +++ b/openhands-agent-server/openhands/agent_server/settings_router.py @@ -2,14 +2,11 @@ from typing import Any, Literal, cast from fastapi import APIRouter, HTTPException, Request, Response, status -from pydantic import BaseModel, ValidationError +from pydantic import ValidationError from openhands.agent_server.persistence import ( SECRET_NAME_PATTERN, - CustomSecretCreate, - CustomSecretResponse, PersistedSettings, - SecretsResponse, get_secrets_store, get_settings_store, ) @@ -17,7 +14,12 @@ from openhands.sdk.logger import get_logger from openhands.sdk.settings import ( ConversationSettings, + SecretCreateRequest, + SecretItemResponse, + SecretsListResponse, + SettingsResponse, SettingsSchema, + SettingsUpdateRequest, export_agent_settings_schema, ) @@ -137,21 +139,6 @@ def _parse_expose_secrets_header(request: Request) -> ExposeSecretsMode | None: ) -class SettingsResponse(BaseModel): - """Response model for settings.""" - - agent_settings: dict[str, Any] - conversation_settings: dict[str, Any] - llm_api_key_is_set: bool - - -class SettingsUpdateRequest(BaseModel): - """Request model for updating settings.""" - - agent_settings_diff: dict[str, Any] | None = None - conversation_settings_diff: dict[str, Any] | None = None - - @settings_router.get(SETTINGS_PATH, response_model=SettingsResponse) async def get_settings(request: Request) -> SettingsResponse: """Get current settings. @@ -315,8 +302,8 @@ def apply_update(settings: PersistedSettings) -> PersistedSettings: # ── Secrets CRUD Endpoints ─────────────────────────────────────────────── -@settings_router.get(SECRETS_PATH, response_model=SecretsResponse) -async def list_secrets(request: Request) -> SecretsResponse: +@settings_router.get(SECRETS_PATH, response_model=SecretsListResponse) +async def list_secrets(request: Request) -> SecretsListResponse: """List all available secrets (names and descriptions only, no values).""" config = _get_config(request) store = get_secrets_store(config) @@ -330,11 +317,11 @@ async def list_secrets(request: Request) -> SecretsResponse: ) if secrets is None: - return SecretsResponse(secrets=[]) + return SecretsListResponse(secrets=[]) - return SecretsResponse( + return SecretsListResponse( secrets=[ - CustomSecretResponse(name=name, description=secret.description) + SecretItemResponse(name=name, description=secret.description) for name, secret in secrets.custom_secrets.items() ] ) @@ -373,10 +360,10 @@ async def get_secret_value(request: Request, name: str) -> Response: return Response(content=value, media_type="text/plain") -@settings_router.put(SECRETS_PATH, response_model=CustomSecretResponse) +@settings_router.put(SECRETS_PATH, response_model=SecretItemResponse) async def create_secret( - request: Request, secret: CustomSecretCreate -) -> CustomSecretResponse: + request: Request, secret: SecretCreateRequest +) -> SecretItemResponse: """Create or update a custom secret (upsert). Raises: @@ -412,7 +399,7 @@ async def create_secret( "client_host": request.client.host if request.client else "unknown", }, ) - return CustomSecretResponse(name=secret.name, description=secret.description) + return SecretItemResponse(name=secret.name, description=secret.description) @settings_router.delete(SECRET_VALUE_PATH) diff --git a/openhands-sdk/openhands/sdk/settings/__init__.py b/openhands-sdk/openhands/sdk/settings/__init__.py index bcc1e5dce7..9c8f723956 100644 --- a/openhands-sdk/openhands/sdk/settings/__init__.py +++ b/openhands-sdk/openhands/sdk/settings/__init__.py @@ -2,6 +2,13 @@ from typing import TYPE_CHECKING, Any +from .api_models import ( + SecretCreateRequest, + SecretItemResponse, + SecretsListResponse, + SettingsResponse, + SettingsUpdateRequest, +) from .metadata import ( SETTINGS_METADATA_KEY, SETTINGS_SECTION_METADATA_KEY, @@ -71,13 +78,19 @@ "OpenHandsAgentSettings", "SETTINGS_METADATA_KEY", "SETTINGS_SECTION_METADATA_KEY", + # API models for settings endpoints + "SecretCreateRequest", + "SecretItemResponse", + "SecretsListResponse", "SettingProminence", "SettingsChoice", "SettingsFieldMetadata", "SettingsFieldSchema", + "SettingsResponse", "SettingsSchema", "SettingsSectionMetadata", "SettingsSectionSchema", + "SettingsUpdateRequest", "VerificationSettings", "create_agent_from_settings", "default_agent_settings", diff --git a/openhands-sdk/openhands/sdk/settings/api_models.py b/openhands-sdk/openhands/sdk/settings/api_models.py new file mode 100644 index 0000000000..b09a46fdc8 --- /dev/null +++ b/openhands-sdk/openhands/sdk/settings/api_models.py @@ -0,0 +1,77 @@ +"""API request and response models for settings endpoints. + +These models define the contract between SDK clients and agent-server settings +endpoints. They are defined in the SDK so both packages can share them without +circular dependencies (SDK cannot import from agent-server, but agent-server +can import from SDK). + +Server-side usage: + The agent-server imports these models and uses them as FastAPI response_model. + +Client-side usage: + RemoteWorkspace uses these models to validate responses from settings APIs. +""" + +from typing import Any + +from pydantic import BaseModel, SecretStr + + +# ── Settings API Models ─────────────────────────────────────────────────── + + +class SettingsResponse(BaseModel): + """Response model for GET /api/settings. + + Contains the full settings payload including agent configuration, + conversation settings, and a flag indicating if an LLM API key is set. + """ + + agent_settings: dict[str, Any] + conversation_settings: dict[str, Any] + llm_api_key_is_set: bool + + +class SettingsUpdateRequest(BaseModel): + """Request model for PATCH /api/settings. + + Supports partial updates via diff objects that are deep-merged with + existing settings. + """ + + agent_settings_diff: dict[str, Any] | None = None + conversation_settings_diff: dict[str, Any] | None = None + + +# ── Secrets API Models ──────────────────────────────────────────────────── + + +class SecretItemResponse(BaseModel): + """Response model for a secret item (without value). + + Used in list responses and as the response for create/update operations. + """ + + name: str + description: str | None = None + + +class SecretsListResponse(BaseModel): + """Response model for GET /api/settings/secrets. + + Lists all available secrets with their names and descriptions. + Values are never included in list responses. + """ + + secrets: list[SecretItemResponse] + + +class SecretCreateRequest(BaseModel): + """Request model for PUT /api/settings/secrets. + + Creates or updates a secret with the given name and value. + """ + + name: str + value: SecretStr + description: str | None = None diff --git a/openhands-sdk/openhands/sdk/workspace/remote/base.py b/openhands-sdk/openhands/sdk/workspace/remote/base.py index 07dc4cd3af..c0ea8b20cf 100644 --- a/openhands-sdk/openhands/sdk/workspace/remote/base.py +++ b/openhands-sdk/openhands/sdk/workspace/remote/base.py @@ -9,6 +9,7 @@ from openhands.sdk.git.models import GitChange, GitDiff from openhands.sdk.logger import get_logger +from openhands.sdk.settings import SecretsListResponse, SettingsResponse from openhands.sdk.workspace.base import BaseWorkspace from openhands.sdk.workspace.models import CommandResult, FileOperationResult from openhands.sdk.workspace.remote.remote_workspace_mixin import RemoteWorkspaceMixin @@ -299,20 +300,17 @@ def get_llm(self, **llm_kwargs: Any) -> "LLM": response = self.client.get("/api/settings", headers=headers) response.raise_for_status() - data = response.json() - agent_settings = data.get("agent_settings", {}) - llm_config = agent_settings.get("llm", {}) + # Validate response using shared SDK model + data = SettingsResponse.model_validate(response.json()) + + llm_config = data.agent_settings.get("llm", {}) - kwargs: dict[str, Any] = {} - if llm_config.get("model"): - kwargs["model"] = llm_config["model"] - if llm_config.get("api_key"): - kwargs["api_key"] = llm_config["api_key"] - if llm_config.get("base_url"): - kwargs["base_url"] = llm_config["base_url"] + # Start with all persisted LLM config fields + # The server returns agent_settings.llm as a serialized LLM model_dump() + kwargs: dict[str, Any] = dict(llm_config) - # User-provided kwargs take precedence + # User-provided kwargs take precedence over persisted settings kwargs.update(llm_kwargs) return LLM(**kwargs) @@ -361,17 +359,18 @@ def get_secrets(self, names: list[str] | None = None) -> dict[str, "LookupSecret response = self.client.get("/api/settings/secrets", headers=self._headers) response.raise_for_status() - data = response.json() + + # Validate response using shared SDK model + data = SecretsListResponse.model_validate(response.json()) result: dict[str, LookupSecret] = {} - for item in data.get("secrets", []): - name = item["name"] - if names is not None and name not in names: + for item in data.secrets: + if names is not None and item.name not in names: continue - result[name] = LookupSecret( - url=f"{self.host}/api/settings/secrets/{name}", + result[item.name] = LookupSecret( + url=f"{self.host}/api/settings/secrets/{item.name}", headers=dict(self._headers), - description=item.get("description"), + description=item.description, ) return result @@ -416,10 +415,11 @@ def get_mcp_config(self) -> dict[str, Any]: response = self.client.get("/api/settings", headers=headers) response.raise_for_status() - data = response.json() - agent_settings = data.get("agent_settings", {}) - mcp_config_data = agent_settings.get("mcp_config") + # Validate response using shared SDK model + data = SettingsResponse.model_validate(response.json()) + + mcp_config_data = data.agent_settings.get("mcp_config") if not mcp_config_data: return {} From 9435240bcdf23941984a06d43e7e5291fc404414 Mon Sep 17 00:00:00 2001 From: openhands Date: Wed, 6 May 2026 20:06:39 +0000 Subject: [PATCH 5/6] Use validate_agent_settings() for deep validation of settings response Build on the previous commit's SettingsResponse validation by also validating the agent_settings dict through validate_agent_settings(), which produces a fully typed AgentSettingsConfig (OpenHandsAgentSettings or ACPAgentSettings). This gives: - get_llm(): returns settings.llm directly (a real LLM instance with all persisted fields, not just model/api_key/base_url) - get_mcp_config(): accesses settings.mcp_config (typed MCPConfig), uses isinstance check to correctly handle ACPAgentSettings which has no mcp_config field - _fetch_agent_settings(): shared helper that calls GET /api/settings, validates the outer SettingsResponse, then validates the inner agent_settings dict through the SDK's discriminated union Co-authored-by: openhands --- .../openhands/sdk/workspace/remote/base.py | 90 ++++++++++--------- .../workspace/remote/test_remote_workspace.py | 6 +- 2 files changed, 52 insertions(+), 44 deletions(-) diff --git a/openhands-sdk/openhands/sdk/workspace/remote/base.py b/openhands-sdk/openhands/sdk/workspace/remote/base.py index c0ea8b20cf..018072d3d8 100644 --- a/openhands-sdk/openhands/sdk/workspace/remote/base.py +++ b/openhands-sdk/openhands/sdk/workspace/remote/base.py @@ -18,6 +18,8 @@ if TYPE_CHECKING: from openhands.sdk.llm.llm import LLM from openhands.sdk.secret import LookupSecret + from openhands.sdk.settings import OpenHandsAgentSettings + from openhands.sdk.settings.model import ACPAgentSettings, LLMAgentSettings logger = get_logger(__name__) @@ -259,6 +261,29 @@ def conversation_id(self) -> str | None: # settings endpoints. Subclasses like OpenHandsCloudWorkspace may override # to use alternative endpoints (e.g., Cloud API). + def _fetch_agent_settings( + self, + ) -> "OpenHandsAgentSettings | LLMAgentSettings | ACPAgentSettings": + """Call ``GET /api/settings`` and return a validated settings model. + + Uses ``X-Expose-Secrets: plaintext`` so secret fields (e.g. LLM + api_key) are returned as plain strings. The outer response is + validated via :class:`SettingsResponse`, then the ``agent_settings`` + dict is validated through :func:`validate_agent_settings` which + picks the correct discriminated-union variant + (``OpenHandsAgentSettings`` or ``ACPAgentSettings``). + """ + from openhands.sdk.settings import validate_agent_settings + + headers = dict(self._headers) + headers["X-Expose-Secrets"] = "plaintext" + + response = self.client.get("/api/settings", headers=headers) + response.raise_for_status() + + data = SettingsResponse.model_validate(response.json()) + return validate_agent_settings(data.agent_settings) + @tenacity.retry( stop=tenacity.stop_after_attempt(_MAX_RETRIES), wait=tenacity.wait_exponential(multiplier=1, min=1, max=5), @@ -269,14 +294,13 @@ def get_llm(self, **llm_kwargs: Any) -> "LLM": """Fetch LLM settings from the agent-server's persisted settings. Calls ``GET /api/settings`` with ``X-Expose-Secrets: plaintext`` header - to retrieve the LLM configuration (model, api_key, base_url) and returns - a fully usable ``LLM`` instance. Retries up to 3 times on transient - errors (network issues, server 5xx). + to retrieve the full LLM configuration and returns a fully usable + ``LLM`` instance. All persisted LLM fields (model, api_key, + base_url, temperature, max_output_tokens, …) are preserved. Args: - **llm_kwargs: Additional keyword arguments passed to the LLM - constructor, allowing overrides of any LLM parameter - (e.g., ``model``, ``temperature``). + **llm_kwargs: Additional keyword arguments that override + persisted values (e.g., ``model``, ``temperature``). Returns: An LLM instance configured with the persisted settings. @@ -295,25 +319,16 @@ def get_llm(self, **llm_kwargs: Any) -> "LLM": if not self.host or self.host == "undefined": raise RuntimeError("Workspace host is not set") - headers = dict(self._headers) - headers["X-Expose-Secrets"] = "plaintext" - - response = self.client.get("/api/settings", headers=headers) - response.raise_for_status() - - # Validate response using shared SDK model - data = SettingsResponse.model_validate(response.json()) - - llm_config = data.agent_settings.get("llm", {}) + settings = self._fetch_agent_settings() - # Start with all persisted LLM config fields - # The server returns agent_settings.llm as a serialized LLM model_dump() - kwargs: dict[str, Any] = dict(llm_config) + if not llm_kwargs: + return settings.llm - # User-provided kwargs take precedence over persisted settings - kwargs.update(llm_kwargs) - - return LLM(**kwargs) + # Dump persisted LLM config and merge overrides, then + # reconstruct so Pydantic validators run on the merged values + llm_data = settings.llm.model_dump(context={"expose_secrets": "plaintext"}) + llm_data.update(llm_kwargs) + return LLM(**llm_data) @tenacity.retry( stop=tenacity.stop_after_attempt(_MAX_RETRIES), @@ -385,8 +400,8 @@ def get_mcp_config(self) -> dict[str, Any]: """Fetch MCP configuration from the agent-server's persisted settings. Calls ``GET /api/settings`` with ``X-Expose-Secrets: plaintext`` header - to retrieve the MCP configuration and transforms it into the format - expected by the SDK Agent and ``fastmcp.mcp_config.MCPConfig``. + to retrieve the MCP configuration and returns a dict compatible with + ``MCPConfig.model_validate()`` and the ``Agent(mcp_config=...)`` kwarg. Returns: A dictionary with ``mcpServers`` key containing server configurations @@ -407,27 +422,18 @@ def get_mcp_config(self) -> dict[str, Any]: ... from fastmcp.mcp_config import MCPConfig ... config = MCPConfig.model_validate(mcp_config) """ + from openhands.sdk.settings import OpenHandsAgentSettings + if not self.host or self.host == "undefined": raise RuntimeError("Workspace host is not set") - headers = dict(self._headers) - headers["X-Expose-Secrets"] = "plaintext" - - response = self.client.get("/api/settings", headers=headers) - response.raise_for_status() - - # Validate response using shared SDK model - data = SettingsResponse.model_validate(response.json()) + settings = self._fetch_agent_settings() - mcp_config_data = data.agent_settings.get("mcp_config") - - if not mcp_config_data: + # mcp_config only exists on OpenHandsAgentSettings, not ACPAgentSettings + if not isinstance(settings, OpenHandsAgentSettings): return {} - # The agent-server stores MCP config in MCPConfig format already - # (with mcpServers key), so we can return it directly if present - if "mcpServers" in mcp_config_data: - return mcp_config_data + if settings.mcp_config is None: + return {} - # Handle legacy format or empty config - return {} + return settings.mcp_config.model_dump(exclude_none=True, exclude_defaults=True) diff --git a/tests/sdk/workspace/remote/test_remote_workspace.py b/tests/sdk/workspace/remote/test_remote_workspace.py index bfc14f4cac..2ed7452119 100644 --- a/tests/sdk/workspace/remote/test_remote_workspace.py +++ b/tests/sdk/workspace/remote/test_remote_workspace.py @@ -625,8 +625,9 @@ def test_get_mcp_config_returns_config(): assert call_args[1]["headers"]["X-Expose-Secrets"] == "plaintext" -def test_get_mcp_config_returns_empty_dict_when_no_config(): +def test_get_mcp_config_returns_empty_dict_when_no_config(monkeypatch): """Test get_mcp_config returns empty dict when no MCP config exists.""" + monkeypatch.setenv("ALLOW_SHORT_CONTEXT_WINDOWS", "true") workspace = RemoteWorkspace(host="http://localhost:8000", working_dir="/tmp") mock_client = MagicMock() @@ -645,8 +646,9 @@ def test_get_mcp_config_returns_empty_dict_when_no_config(): assert config == {} -def test_get_mcp_config_returns_empty_dict_when_mcp_config_is_none(): +def test_get_mcp_config_returns_empty_dict_when_mcp_config_is_none(monkeypatch): """Test get_mcp_config returns empty dict when mcp_config is None.""" + monkeypatch.setenv("ALLOW_SHORT_CONTEXT_WINDOWS", "true") workspace = RemoteWorkspace(host="http://localhost:8000", working_dir="/tmp") mock_client = MagicMock() From 270c233829017513f1e9238cd215ae10c42392a7 Mon Sep 17 00:00:00 2001 From: openhands Date: Wed, 6 May 2026 20:18:33 +0000 Subject: [PATCH 6/6] Add typed accessor methods to SettingsResponse for client-side type safety - Add get_agent_settings() method that validates and returns AgentSettingsConfig - Add get_conversation_settings() method that validates and returns ConversationSettings - Keep dict[str, Any] fields since server needs to control secret serialization via context - Document why typed fields aren't used (FastAPI serialization loses context) - Provide examples in docstrings for client usage Co-authored-by: openhands --- .../openhands/sdk/settings/api_models.py | 51 ++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/openhands-sdk/openhands/sdk/settings/api_models.py b/openhands-sdk/openhands/sdk/settings/api_models.py index b09a46fdc8..6ac08ee8bb 100644 --- a/openhands-sdk/openhands/sdk/settings/api_models.py +++ b/openhands-sdk/openhands/sdk/settings/api_models.py @@ -10,13 +10,31 @@ Client-side usage: RemoteWorkspace uses these models to validate responses from settings APIs. + Use the typed accessor methods (``get_agent_settings()``, + ``get_conversation_settings()``) to parse the raw dicts into typed models. + +Note on dict fields: + ``SettingsResponse`` uses ``dict[str, Any]`` for ``agent_settings`` and + ``conversation_settings`` rather than typed models because the server needs + to control how secrets are serialized (plaintext/encrypted/redacted) via + serialization context. Typed Pydantic fields would lose this context during + FastAPI's automatic JSON serialization. + + Clients that need type safety should use the accessor methods which validate + the dicts into ``AgentSettingsConfig`` and ``ConversationSettings``. """ -from typing import Any +from __future__ import annotations + +from typing import TYPE_CHECKING, Any from pydantic import BaseModel, SecretStr +if TYPE_CHECKING: + from .model import AgentSettingsConfig, ConversationSettings + + # ── Settings API Models ─────────────────────────────────────────────────── @@ -25,12 +43,43 @@ class SettingsResponse(BaseModel): Contains the full settings payload including agent configuration, conversation settings, and a flag indicating if an LLM API key is set. + + The ``agent_settings`` and ``conversation_settings`` fields are raw dicts + because the server controls secret serialization via context. Use the + typed accessor methods for validation: + + Example:: + + response = SettingsResponse.model_validate(api_response.json()) + agent = response.get_agent_settings() # Returns AgentSettingsConfig + conv = response.get_conversation_settings() # Returns ConversationSettings """ agent_settings: dict[str, Any] conversation_settings: dict[str, Any] llm_api_key_is_set: bool + def get_agent_settings(self) -> AgentSettingsConfig: + """Parse and validate ``agent_settings`` into a typed model. + + Returns: + The validated agent settings as either ``OpenHandsAgentSettings`` + or ``ACPAgentSettings`` depending on the ``agent_kind`` discriminator. + """ + from .model import validate_agent_settings + + return validate_agent_settings(self.agent_settings) + + def get_conversation_settings(self) -> ConversationSettings: + """Parse and validate ``conversation_settings`` into a typed model. + + Returns: + The validated conversation settings. + """ + from .model import ConversationSettings + + return ConversationSettings.model_validate(self.conversation_settings) + class SettingsUpdateRequest(BaseModel): """Request model for PATCH /api/settings.