mirror of
https://github.com/OpenHands/OpenHands.git
synced 2025-12-26 05:48:36 +08:00
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:
parent
b2071944fb
commit
ad024b3e3e
@ -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
|
||||
53
openhands-cli/examples/gateway-config-example.toml
Normal file
53
openhands-cli/examples/gateway-config-example.toml
Normal 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
|
||||
@ -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>'))
|
||||
|
||||
@ -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(
|
||||
|
||||
106
openhands-cli/openhands_cli/gateway_config.py
Normal file
106
openhands-cli/openhands_cli/gateway_config.py
Normal 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)
|
||||
@ -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
|
||||
)
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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>')
|
||||
)
|
||||
|
||||
@ -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" }
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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):
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user