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..765ba4fc92 --- /dev/null +++ b/examples/02_remote_agent_server/13_workspace_get_llm.py @@ -0,0 +1,342 @@ +"""Example demonstrating workspace.get_llm() for settings-driven conversations. + +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: +- 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 +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. + + 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 with session API key auth.""" + print(f"Starting OpenHands API server on {self.base_url}...") + print("๐Ÿ” Session API key configured (required for all requests)") + + # 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, + } + + 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 - 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 + # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + 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 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 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()") + 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() with API Key Auth + # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + 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() + # 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(workspace_secrets)} secret(s) via " + "workspace.get_secrets()" + ) + 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") + 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. 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: + client.close() 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..6ac08ee8bb --- /dev/null +++ b/openhands-sdk/openhands/sdk/settings/api_models.py @@ -0,0 +1,126 @@ +"""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. + 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 __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 โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + +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. + + 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. + + 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 7e7059c3a1..018072d3d8 100644 --- a/openhands-sdk/openhands/sdk/workspace/remote/base.py +++ b/openhands-sdk/openhands/sdk/workspace/remote/base.py @@ -1,17 +1,40 @@ 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.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 +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__) + +# 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 +255,185 @@ 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). + + 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), + 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 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 that override + persisted values (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") + + settings = self._fetch_agent_settings() + + if not llm_kwargs: + return settings.llm + + # 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), + 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() + + # Validate response using shared SDK model + data = SecretsListResponse.model_validate(response.json()) + + result: dict[str, LookupSecret] = {} + for item in data.secrets: + if names is not None and item.name not in names: + continue + result[item.name] = LookupSecret( + url=f"{self.host}/api/settings/secrets/{item.name}", + headers=dict(self._headers), + description=item.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 returns a dict compatible with + ``MCPConfig.model_validate()`` and the ``Agent(mcp_config=...)`` kwarg. + + 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) + """ + from openhands.sdk.settings import OpenHandsAgentSettings + + if not self.host or self.host == "undefined": + raise RuntimeError("Workspace host is not set") + + settings = self._fetch_agent_settings() + + # mcp_config only exists on OpenHandsAgentSettings, not ACPAgentSettings + if not isinstance(settings, OpenHandsAgentSettings): + return {} + + if settings.mcp_config is None: + 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 4cb3dfbf42..2ed7452119 100644 --- a/tests/sdk/workspace/remote/test_remote_workspace.py +++ b/tests/sdk/workspace/remote/test_remote_workspace.py @@ -402,3 +402,274 @@ 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(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() + 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(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() + 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()