feat(cli): add enterprise gateway support via configuration files

Adds support for enterprise LLM gateways through TOML configuration files,
enabling OpenHands CLI usage in corporate environments with custom API
management solutions.

Features:
- Load gateway configuration from TOML file via --gateway-config flag
- Support for environment variable OPENHANDS_GATEWAY_CONFIG
- Environment variable expansion in config values (${ENV:VAR_NAME})
- Comprehensive example configuration file with documentation
- Clean separation from interactive setup flow

Implementation:
- Added gateway_config.py module for loading and parsing TOML configs
- Thread gateway config through CLI entry to agent initialization
- Apply gateway settings when creating LLM instance
- Update tests to handle new gateway_config_path parameter
- Remove interactive gateway setup to keep UI simple

This enables enterprise customers to configure:
- OAuth2/token authentication with identity providers
- Custom headers for routing and authorization
- Request body parameters for compliance/monitoring
- All without impacting the standard user experience

Note: Requires openhands-sdk>=1.0.0a6 once the SDK PR is merged.
Currently set to >=1.0.0a4 for compatibility.
This commit is contained in:
Alona King 2025-10-29 16:01:25 -04:00
parent b2071944fb
commit ad024b3e3e
12 changed files with 312 additions and 230 deletions

View File

@ -33,4 +33,29 @@ uv run openhands
# The binary will be in dist/
./dist/openhands # macOS/Linux
# dist/openhands.exe # Windows
```
```
## Enterprise Gateway Support
For enterprise users with custom LLM gateways, you can provide a gateway configuration file to handle authentication and custom headers/parameters.
### Using Gateway Configuration
```bash
# Using command line flag
uv run openhands --gateway-config ~/mycompany-gateway.toml
# Or using environment variable
export OPENHANDS_GATEWAY_CONFIG=~/mycompany-gateway.toml
uv run openhands
```
See `examples/gateway-config-example.toml` for a complete configuration example with comments.
### Key Features
- **OAuth2/Token Exchange**: Automatically handles token acquisition and refresh
- **Custom Headers**: Add headers required by your gateway
- **Environment Variables**: Use `${ENV:VAR_NAME}` syntax for sensitive values
- **Extra Body Parameters**: Include additional fields in LLM request bodies
- **TOML Format**: Clean, readable configuration with comments

View File

@ -0,0 +1,53 @@
# Enterprise Gateway Configuration Example for OpenHands CLI
# Configure OpenHands to work with enterprise LLM gateways
#
# Usage:
# 1. Copy this file and customize it.
# 2. Reference secrets with ${ENV:VAR_NAME} syntax.
# 3. Run: openhands --gateway-config /path/to/your-config.toml
# or export OPENHANDS_GATEWAY_CONFIG=/path/to/your-config.toml and run openhands
# Optional provider name for logging/debugging.
gateway_provider = "custom"
# === Identity Provider Configuration ===
# Remove this section entirely if the gateway does not require token exchange.
gateway_auth_url = "https://identity.example.com/oauth2/token"
gateway_auth_method = "POST"
gateway_auth_token_path = "access_token" # Required if using identity provider (dot notation supported)
gateway_auth_expires_in_path = "expires_in" # Optional; remove if response lacks expiry
gateway_auth_token_ttl = 3600 # Optional fallback (seconds). Remove for default (300s).
gateway_auth_verify_ssl = true # Set to false only for local/self-signed testing
[gateway_auth_headers]
# Headers sent to the identity provider. Remove if not needed.
Content-Type = "application/json"
X-Client-Id = "${ENV:GATEWAY_CLIENT_ID}"
X-Client-Secret = "${ENV:GATEWAY_CLIENT_SECRET}"
[gateway_auth_body]
# Request body for the identity provider. Remove if not needed.
grant_type = "client_credentials"
audience = "llm-gateway"
scope = "llm:access"
# === Gateway Request Configuration ===
gateway_token_header = "Authorization" # Optional. Remove to use default ("Authorization")
gateway_token_prefix = "Bearer " # Optional. Remove for no prefix
[custom_headers]
# Headers added to every LLM request. Remove entries you do not need.
X-Gateway-Context = "openhands-cli"
X-Request-Priority = "normal"
X-Tenant-Id = "${ENV:TENANT_ID}"
[extra_body_params] # Optional JSON body additions merged into the request payload.
[extra_body_params.metadata]
source = "cli"
version = "1.0"
user = "${ENV:USER}"
[extra_body_params.gateway_options]
retry_on_failure = true
timeout_seconds = 30

View File

@ -55,9 +55,15 @@ def _print_exit_hint(conversation_id: str) -> None:
def run_cli_entry(resume_conversation_id: str | None = None) -> None:
def run_cli_entry(
resume_conversation_id: str | None = None,
gateway_config_path: str | None = None
) -> None:
"""Run the agent chat session using the agent SDK.
Args:
resume_conversation_id: Optional conversation ID to resume
gateway_config_path: Optional path to gateway configuration file
Raises:
AgentSetupError: If agent setup fails
@ -66,7 +72,10 @@ def run_cli_entry(resume_conversation_id: str | None = None) -> None:
"""
try:
conversation = start_fresh_conversation(resume_conversation_id)
conversation = start_fresh_conversation(
resume_conversation_id,
gateway_config_path=gateway_config_path
)
except MissingAgentSpec:
print_formatted_text(HTML('\n<yellow>Setup is required to use OpenHands CLI.</yellow>'))
print_formatted_text(HTML('\n<yellow>Goodbye! 👋</yellow>'))

View File

@ -1,6 +1,7 @@
"""Main argument parser for OpenHands CLI."""
import argparse
import os
def create_main_parser() -> argparse.ArgumentParser:
@ -30,6 +31,14 @@ Examples:
type=str,
help='Conversation ID to resume'
)
parser.add_argument(
'--gateway-config',
type=str,
default=os.environ.get('OPENHANDS_GATEWAY_CONFIG'),
help='Path to enterprise gateway configuration (JSON file). Can also be set via OPENHANDS_GATEWAY_CONFIG environment variable.',
metavar='PATH'
)
# Only serve as subcommand
subparsers = parser.add_subparsers(

View File

@ -0,0 +1,106 @@
"""
Gateway configuration loader for OpenHands CLI.
Handles loading and parsing of enterprise gateway configuration files.
"""
from __future__ import annotations
import re
from pathlib import Path
from typing import Any
try: # Python 3.11+
import tomllib # type: ignore[attr-defined]
except ModuleNotFoundError: # pragma: no cover - fallback for older interpreters
import tomli as tomllib # type: ignore[no-redef]
_VALID_FIELDS: set[str] = {
'gateway_provider',
'gateway_auth_url',
'gateway_auth_method',
'gateway_auth_headers',
'gateway_auth_body',
'gateway_auth_token_path',
'gateway_auth_expires_in_path',
'gateway_auth_token_ttl',
'gateway_token_header',
'gateway_token_prefix',
'gateway_auth_verify_ssl',
'custom_headers',
'extra_body_params',
}
def load_gateway_config(config_path: Path | str) -> dict[str, Any]:
"""Load gateway configuration from a TOML file.
Args:
config_path: Path to the gateway configuration file
Returns:
Dictionary containing gateway configuration
Raises:
FileNotFoundError: If the config file doesn't exist
ValueError: If the file cannot be parsed or is not a mapping
"""
if isinstance(config_path, str):
config_path = Path(config_path)
config_path = config_path.expanduser()
if not config_path.exists():
raise FileNotFoundError(f"Gateway config file not found: {config_path}")
if config_path.suffix.lower() not in {'.toml', '.tml'}:
raise ValueError(f'Gateway configuration must be TOML: {config_path}')
try:
with open(config_path, 'rb') as f:
config = tomllib.load(f)
except tomllib.TOMLDecodeError as exc:
raise ValueError(f'Invalid TOML in gateway config file: {exc}') from exc
if not isinstance(config, dict):
raise ValueError('Gateway config must be a mapping/object')
unknown_fields = set(config.keys()) - _VALID_FIELDS
if unknown_fields:
print(f'Warning: Unknown gateway config fields will be ignored: {unknown_fields}')
return config
def expand_env_vars(config: dict[str, Any]) -> dict[str, Any]:
"""Expand environment variables in config values.
Supports ${ENV:VAR_NAME} syntax in string values.
Args:
config: Gateway configuration dictionary
Returns:
Configuration with environment variables expanded
"""
import os
def expand_value(value: Any) -> Any:
if isinstance(value, str):
# Look for ${ENV:VAR_NAME} pattern
pattern = r'\$\{ENV:([^}]+)\}'
def replacer(match):
var_name = match.group(1)
env_value = os.environ.get(var_name)
if env_value is None:
raise ValueError(f"Environment variable {var_name} is not set")
return env_value
return re.sub(pattern, replacer, value)
elif isinstance(value, dict):
return {k: expand_value(v) for k, v in value.items()}
elif isinstance(value, list):
return [expand_value(v) for v in value]
return value
return expand_value(config)

View File

@ -28,13 +28,16 @@ class MissingAgentSpec(Exception):
def setup_conversation(
conversation_id: str | None = None,
include_security_analyzer: bool = True
include_security_analyzer: bool = True,
gateway_config_path: str | None = None
) -> BaseConversation:
"""
Setup the conversation with agent.
Args:
conversation_id: conversation ID to use. If not provided, a random UUID will be generated.
include_security_analyzer: Whether to include the security analyzer
gateway_config_path: Optional path to gateway configuration file
Raises:
MissingAgentSpec: If agent specification is not found or invalid.
@ -62,6 +65,54 @@ def setup_conversation(
'Agent specification not found. Please configure your agent settings.'
)
# Apply gateway configuration if provided
if gateway_config_path:
from openhands_cli.gateway_config import load_gateway_config, expand_env_vars
try:
gateway_config = load_gateway_config(gateway_config_path)
except FileNotFoundError as e:
raise ValueError(
f"Gateway configuration file not found: {gateway_config_path}\n"
f"Please check the file path or set OPENHANDS_GATEWAY_CONFIG correctly."
) from e
except ValueError as e:
raise ValueError(
f"Invalid gateway configuration file: {e}\n"
f"Please ensure {gateway_config_path} is a valid TOML file."
) from e
try:
gateway_config = expand_env_vars(gateway_config)
except ValueError as e:
raise ValueError(
f"Failed to expand environment variables in gateway config: {e}\n"
f"Make sure all referenced environment variables are set."
) from e
# Update the agent's LLM with gateway configuration
llm = agent.llm
# Create a new LLM instance with the gateway config
from openhands.sdk import LLM
from openhands_cli.llm_utils import get_llm_metadata
llm_kwargs = {
'model': llm.model,
'api_key': llm.api_key,
'base_url': llm.base_url,
'service_id': llm.service_id,
'metadata': get_llm_metadata(model_name=llm.model, llm_type='agent'),
**gateway_config # Add all gateway config fields
}
try:
new_llm = LLM(**llm_kwargs)
agent = agent.model_copy(update={'llm': new_llm})
print(f"✓ Gateway configuration loaded from: {gateway_config_path}")
except Exception as e:
raise ValueError(
f"Failed to apply gateway configuration: {e}\n"
f"Please check that all gateway settings in {gateway_config_path} are valid."
) from e
if not include_security_analyzer:
# Remove security analyzer from agent spec
@ -89,7 +140,8 @@ def setup_conversation(
def start_fresh_conversation(
resume_conversation_id: str | None = None
resume_conversation_id: str | None = None,
gateway_config_path: str | None = None
) -> BaseConversation:
"""Start a fresh conversation by creating a new conversation instance.
@ -98,6 +150,7 @@ def start_fresh_conversation(
Args:
resume_conversation_id: Optional conversation ID to resume
gateway_config_path: Optional path to gateway configuration file
Returns:
BaseConversation: A new conversation instance
@ -105,7 +158,10 @@ def start_fresh_conversation(
conversation = None
settings_screen = SettingsScreen()
try:
conversation = setup_conversation(resume_conversation_id)
conversation = setup_conversation(
resume_conversation_id,
gateway_config_path=gateway_config_path
)
return conversation
except MissingAgentSpec:
# For first-time users, show the full settings flow with choice between basic/advanced
@ -113,4 +169,7 @@ def start_fresh_conversation(
# Try once again after settings setup attempt
return setup_conversation(resume_conversation_id)
return setup_conversation(
resume_conversation_id,
gateway_config_path=gateway_config_path
)

View File

@ -42,7 +42,10 @@ def main() -> None:
from openhands_cli.agent_chat import run_cli_entry
# Start agent chat
run_cli_entry(resume_conversation_id=args.resume)
run_cli_entry(
resume_conversation_id=args.resume,
gateway_config_path=args.gateway_config
)
except KeyboardInterrupt:
print_formatted_text(HTML('\n<yellow>Goodbye! 👋</yellow>'))
except EOFError:

View File

@ -1,6 +1,4 @@
import json
import os
from typing import Any
from openhands.sdk import LLM, BaseConversation, LocalFileStore
from openhands.sdk.security.confirmation_policy import NeverConfirm
@ -25,7 +23,6 @@ from openhands_cli.user_actions.settings_action import (
save_settings_confirmation,
settings_type_confirmation,
)
from openhands_cli.user_actions.utils import cli_confirm, cli_text_input
class SettingsScreen:
@ -151,18 +148,17 @@ class SettingsScreen:
def handle_advanced_settings(self, escapable=True):
"""Handle advanced settings configuration with clean step-by-step flow."""
step_counter = StepCounter(18)
step_counter = StepCounter(4)
try:
custom_model = prompt_custom_model(step_counter)
base_url = prompt_base_url(step_counter)
api_key = prompt_api_key(
step_counter,
custom_model.split('/')[0] if len(custom_model.split('/')) > 1 else '',
self.conversation.agent.llm.api_key if self.conversation else None,
self.conversation.state.agent.llm.api_key if self.conversation else None,
escapable=escapable,
)
memory_condensation = choose_memory_condensation(step_counter)
gateway_settings = self._prompt_gateway_settings(step_counter)
# Confirm save
save_settings_confirmation()
@ -172,32 +168,16 @@ class SettingsScreen:
# Store the collected settings for persistence
self._save_advanced_settings(
custom_model,
base_url,
api_key,
memory_condensation,
gateway_settings,
custom_model, base_url, api_key, memory_condensation
)
def _save_llm_settings(
self,
model,
api_key,
base_url: str | None = None,
gateway_options: dict[str, Any] | None = None,
) -> None:
gateway_kwargs = {
key: value
for key, value in (gateway_options or {}).items()
if value is not None and value != {}
}
def _save_llm_settings(self, model, api_key, base_url: str | None = None) -> None:
llm = LLM(
model=model,
api_key=api_key,
base_url=base_url,
service_id='agent',
metadata=get_llm_metadata(model_name=model, llm_type='agent'),
**gateway_kwargs,
)
agent = self.agent_store.load()
@ -208,19 +188,9 @@ class SettingsScreen:
self.agent_store.save(agent)
def _save_advanced_settings(
self,
custom_model: str,
base_url: str,
api_key: str,
memory_condensation: bool,
gateway_settings: dict[str, Any] | None,
self, custom_model: str, base_url: str, api_key: str, memory_condensation: bool
):
self._save_llm_settings(
custom_model,
api_key,
base_url=base_url,
gateway_options=gateway_settings,
)
self._save_llm_settings(custom_model, api_key, base_url=base_url)
agent_spec = self.agent_store.load()
if not agent_spec:
@ -230,181 +200,3 @@ class SettingsScreen:
agent_spec.model_copy(update={'condenser': None})
self.agent_store.save(agent_spec)
def _prompt_gateway_settings(self, step_counter: StepCounter) -> dict[str, Any] | None:
"""Collect enterprise gateway configuration from the user."""
options = ['Yes, configure gateway settings', 'No, skip']
try:
choice = cli_confirm(
step_counter.next_step(
'Configure enterprise gateway settings (e.g., Tachyon)? '
),
options,
escapable=True,
)
except KeyboardInterrupt:
raise
if choice == 1:
return {}
gateway_provider = cli_text_input(
step_counter.next_step(
'Gateway provider name (ENTER for "tachyon"): '
),
escapable=True,
)
gateway_provider = gateway_provider or 'tachyon'
gateway_auth_url = cli_text_input(
step_counter.next_step(
'Identity provider URL (ENTER to skip if not required): '
),
escapable=True,
)
method_input = cli_text_input(
step_counter.next_step(
'Identity provider HTTP method (ENTER for POST): '
),
escapable=True,
)
gateway_auth_method = method_input.upper() if method_input else 'POST'
gateway_auth_headers = self._prompt_json_mapping(
step_counter.next_step(
'Identity provider headers as JSON (ENTER to skip): '
)
)
gateway_auth_body = self._prompt_json_mapping(
step_counter.next_step(
'Identity provider JSON body (ENTER to skip): '
)
)
token_path = cli_text_input(
step_counter.next_step(
'Token path in identity provider response (ENTER for access_token): '
),
escapable=True,
)
gateway_auth_token_path = token_path or 'access_token'
expires_in_path = cli_text_input(
step_counter.next_step(
'expires_in path in response (ENTER to skip): '
),
escapable=True,
)
gateway_auth_expires_in_path = expires_in_path or None
ttl_seconds = self._prompt_optional_int(
step_counter.next_step(
'Token TTL fallback in seconds (ENTER to skip): '
)
)
token_header_input = cli_text_input(
step_counter.next_step(
'Header name for gateway token (ENTER for Authorization): '
),
escapable=True,
)
gateway_token_header = token_header_input or 'Authorization'
token_prefix_input = cli_text_input(
step_counter.next_step(
'Token prefix (ENTER for "Bearer "): '
),
escapable=True,
)
gateway_token_prefix = token_prefix_input if token_prefix_input != '' else 'Bearer '
verify_choice = cli_confirm(
step_counter.next_step(
'Verify TLS certificates for identity provider? '
'(recommended): '
),
['Yes', 'No'],
escapable=True,
)
gateway_auth_verify_ssl = verify_choice == 0
custom_headers = self._prompt_json_mapping(
step_counter.next_step(
'Additional headers for gateway requests (ENTER to skip): '
)
)
extra_body = self._prompt_json_mapping(
step_counter.next_step(
'Additional JSON body params for gateway requests (ENTER to skip): '
)
)
settings: dict[str, Any] = {
'gateway_provider': gateway_provider,
'gateway_auth_url': gateway_auth_url or None,
'gateway_auth_method': gateway_auth_method,
'gateway_auth_headers': gateway_auth_headers,
'gateway_auth_body': gateway_auth_body,
'gateway_auth_token_path': gateway_auth_token_path,
'gateway_auth_expires_in_path': gateway_auth_expires_in_path,
'gateway_auth_token_ttl': ttl_seconds,
'gateway_token_header': gateway_token_header,
'gateway_token_prefix': gateway_token_prefix,
'gateway_auth_verify_ssl': gateway_auth_verify_ssl,
'custom_headers': custom_headers,
'extra_body_params': extra_body,
}
return settings
def _prompt_json_mapping(self, question: str) -> dict[str, Any] | None:
while True:
try:
raw_value = cli_text_input(question, escapable=True)
except KeyboardInterrupt:
raise
if not raw_value:
return None
try:
parsed = json.loads(raw_value)
except json.JSONDecodeError as err:
print_formatted_text(
HTML(
f"\n<red>Invalid JSON: {err}. Please enter a JSON object or press ENTER to skip.</red>"
)
)
continue
if not isinstance(parsed, dict):
print_formatted_text(
HTML(
'\n<red>Please enter a JSON object (e.g., {"X-Header": "value"}).</red>'
)
)
continue
return parsed
def _prompt_optional_int(self, question: str) -> int | None:
while True:
try:
raw_value = cli_text_input(question, escapable=True)
except KeyboardInterrupt:
raise
if not raw_value:
return None
try:
return int(raw_value)
except ValueError:
print_formatted_text(
HTML('\n<red>Please enter a valid integer value.</red>')
)

View File

@ -18,10 +18,12 @@ classifiers = [
# Using Git URLs for dependencies so installs from PyPI pull from GitHub
# TODO: pin package versions once agent-sdk has published PyPI packages
dependencies = [
"openhands-sdk==1.0.0a3",
"openhands-tools==1.0.0a3",
"fastapi>=0.120.2",
"openhands-sdk>=1.0.0a4",
"openhands-tools>=1.0.0a3",
"prompt-toolkit>=3",
"typer>=0.17.4",
"uvicorn>=0.38.0",
]
scripts = { openhands = "openhands_cli.simple_main:main" }

View File

@ -20,7 +20,7 @@ def test_start_fresh_conversation_success(mock_setup_conversation):
# Verify the result
assert result == mock_conversation
mock_setup_conversation.assert_called_once_with(None)
mock_setup_conversation.assert_called_once_with(None, gateway_config_path=None)
@patch('openhands_cli.setup.SettingsScreen')
@ -49,6 +49,9 @@ def test_start_fresh_conversation_missing_agent_spec(
assert result == mock_conversation
# Should be called twice: first fails, second succeeds
assert mock_setup_conversation.call_count == 2
for call in mock_setup_conversation.call_args_list:
assert call.args[0] is None
assert call.kwargs.get('gateway_config_path') is None
# Settings screen should be called once with first_time=True (new behavior)
mock_settings_screen.configure_settings.assert_called_once_with(first_time=True)

View File

@ -68,6 +68,7 @@ class TestConversationRunner:
convo = Conversation(agent)
convo.max_iteration_per_run = 1
convo.max_iteration_per_run = 1
convo.state.agent_status = agent_status
cr = ConversationRunner(convo)
cr.set_confirmation_policy(NeverConfirm())
@ -108,10 +109,16 @@ class TestConversationRunner:
agent.security_analyzer = MagicMock()
convo = Conversation(agent)
convo.max_iteration_per_run = 1
convo.state.agent_status = AgentExecutionStatus.WAITING_FOR_CONFIRMATION
def fake_run() -> None:
agent.step(convo.state, lambda *_args, **_kwargs: None)
cr = ConversationRunner(convo)
cr.set_confirmation_policy(AlwaysConfirm())
with patch.object(
convo, 'run', side_effect=fake_run
), patch.object(
cr, '_handle_confirmation_request', return_value=confirmation
) as mock_confirmation_request:
cr.process_message(message=None)
@ -131,10 +138,15 @@ class TestConversationRunner:
convo = Conversation(agent)
convo.state.agent_status = AgentExecutionStatus.PAUSED
def fake_run() -> None:
agent.step(convo.state, lambda *_args, **_kwargs: None)
cr = ConversationRunner(convo)
cr.set_confirmation_policy(AlwaysConfirm())
with patch.object(cr, '_handle_confirmation_request') as _mock_h:
with patch.object(convo, 'run', side_effect=fake_run), patch.object(
cr, '_handle_confirmation_request'
) as _mock_h:
cr.process_message(message=None)
# No confirmation was needed up front; we still expect exactly one run.

View File

@ -26,7 +26,9 @@ class TestMainEntryPoint:
simple_main.main()
# Should call run_cli_entry with no resume conversation ID
mock_run_agent_chat.assert_called_once_with(resume_conversation_id=None)
mock_run_agent_chat.assert_called_once_with(
resume_conversation_id=None, gateway_config_path=None
)
@patch('openhands_cli.agent_chat.run_cli_entry')
@patch('sys.argv', ['openhands'])
@ -88,7 +90,8 @@ class TestMainEntryPoint:
# Should call run_cli_entry with the provided resume conversation ID
mock_run_agent_chat.assert_called_once_with(
resume_conversation_id='test-conversation-id'
resume_conversation_id='test-conversation-id',
gateway_config_path=None,
)
@ -97,8 +100,14 @@ class TestMainEntryPoint:
@pytest.mark.parametrize(
"argv,expected_kwargs",
[
(['openhands'], {"resume_conversation_id": None}),
(['openhands', '--resume', 'test-id'], {"resume_conversation_id": 'test-id'}),
(
['openhands'],
{"resume_conversation_id": None, "gateway_config_path": None},
),
(
['openhands', '--resume', 'test-id'],
{"resume_conversation_id": 'test-id', "gateway_config_path": None},
),
],
)
def test_main_cli_calls_run_cli_entry(monkeypatch, argv, expected_kwargs):