From fe10076cc8cbc819438f49833a4d8ad56f2db194 Mon Sep 17 00:00:00 2001 From: Alona King Date: Tue, 4 Nov 2025 14:46:24 -0500 Subject: [PATCH 1/2] feat(llm): add enterprise gateway and SSL verification support --- .../26_enterprise_gateway.py | 76 +++++++++++ openhands-sdk/openhands/sdk/llm/llm.py | 48 +++++-- .../openhands/sdk/llm/utils/telemetry.py | 72 ++++++++++- tests/sdk/llm/test_llm.py | 122 +++++++++++++++++- 4 files changed, 305 insertions(+), 13 deletions(-) create mode 100644 examples/01_standalone_sdk/26_enterprise_gateway.py diff --git a/examples/01_standalone_sdk/26_enterprise_gateway.py b/examples/01_standalone_sdk/26_enterprise_gateway.py new file mode 100644 index 0000000000..e9cb3f0291 --- /dev/null +++ b/examples/01_standalone_sdk/26_enterprise_gateway.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +""" +Enterprise Gateway Example + +Demonstrates configuring OpenHands for environments that route LLM traffic through +an API gateway requiring custom headers and optional TLS overrides. +""" + +import os +import uuid +from datetime import datetime + +from pydantic import SecretStr + +from openhands.sdk import Agent, Conversation, MessageEvent +from openhands.sdk.llm import LLM, content_to_str + + +def build_gateway_llm() -> LLM: + """Create an LLM instance configured for an enterprise gateway.""" + now = datetime.utcnow() + correlation_id = uuid.uuid4().hex + request_id = uuid.uuid4().hex + + ssl_env = os.getenv("LLM_SSL_VERIFY") + ssl_verify: bool | str = ssl_env if ssl_env is not None else False + + return LLM( + model=os.getenv("LLM_MODEL", "gemini-2.5-flash"), + base_url=os.getenv( + "LLM_BASE_URL", "https://your-corporate-proxy.company.com/api/llm" + ), + # an api_key input is always required but is unused when api keys are passed via extra headers + api_key=SecretStr(os.getenv("LLM_API_KEY", "placeholder")), + custom_llm_provider=os.getenv("LLM_CUSTOM_LLM_PROVIDER", "openai"), + ssl_verify=ssl_verify, + extra_headers={ + # Typical headers forwarded by gateways + "Authorization": os.getenv("LLM_GATEWAY_TOKEN", "Bearer YOUR_TOKEN"), + "Content-Type": "application/json", + "x-correlation-id": correlation_id, + "x-request-id": request_id, + "x-request-date": now.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3], + "x-client-id": os.getenv("LLM_CLIENT_ID", "YOUR_CLIENT_ID"), + "X-USECASE-ID": os.getenv("LLM_USECASE_ID", "YOUR_USECASE_ID"), + "x-api-key": os.getenv("LLM_GATEWAY_API_KEY", "YOUR_API_KEY"), + }, + # additional optional parameters + timeout=30, + num_retries=1, + ) + + +if __name__ == "__main__": + print("=== Enterprise Gateway Configuration Example ===") + + # Build LLM with enterprise gateway configuration + llm = build_gateway_llm() + + # Create agent and conversation + agent = Agent(llm=llm, cli_mode=True) + conversation = Conversation( + agent=agent, + workspace=os.getcwd(), + visualize=False, + ) + + try: + # Send a message to test the enterprise gateway configuration + conversation.send_message( + "Analyze this codebase and create 3 facts about the current project into FACTS.txt. Do not write code." + ) + conversation.run() + + finally: + conversation.close() diff --git a/openhands-sdk/openhands/sdk/llm/llm.py b/openhands-sdk/openhands/sdk/llm/llm.py index c30e7a54e4..877bbe064b 100644 --- a/openhands-sdk/openhands/sdk/llm/llm.py +++ b/openhands-sdk/openhands/sdk/llm/llm.py @@ -189,6 +189,14 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin): ) ollama_base_url: str | None = Field(default=None) + ssl_verify: bool | str | None = Field( + default=None, + description=( + "TLS verification forwarded to LiteLLM; " + "set to False when corporate proxies break certificate chains." + ), + ) + drop_params: bool = Field(default=True) modify_params: bool = Field( default=True, @@ -512,7 +520,7 @@ def completion( log_ctx = { "messages": formatted_messages[:], # already simple dicts "tools": tools, - "kwargs": {k: v for k, v in call_kwargs.items()}, + "kwargs": dict(call_kwargs), "context_window": self.max_input_tokens or 0, } if tools and not use_native_fc: @@ -629,7 +637,7 @@ def responses( "llm_path": "responses", "input": input_items[:], "tools": tools, - "kwargs": {k: v for k, v in call_kwargs.items()}, + "kwargs": dict(call_kwargs), "context_window": self.max_input_tokens or 0, } self._telemetry.on_request(log_ctx=log_ctx) @@ -665,7 +673,9 @@ def _one_attempt(**retry_kwargs) -> ResponsesAPIResponse: api_key=api_key_value, api_base=self.base_url, api_version=self.api_version, + custom_llm_provider=self.custom_llm_provider, timeout=self.timeout, + ssl_verify=self.ssl_verify, drop_params=self.drop_params, seed=self.seed, **final_kwargs, @@ -742,7 +752,9 @@ def _transport_call( api_key=api_key_value, api_base=self.base_url, api_version=self.api_version, + custom_llm_provider=self.custom_llm_provider, timeout=self.timeout, + ssl_verify=self.ssl_verify, drop_params=self.drop_params, seed=self.seed, messages=messages, @@ -1027,6 +1039,7 @@ def load_from_json(cls, json_path: str) -> LLM: @classmethod def load_from_env(cls, prefix: str = "LLM_") -> LLM: TRUTHY = {"true", "1", "yes", "on"} + FALSY = {"false", "0", "no", "off"} def _unwrap_type(t: Any) -> Any: origin = get_origin(t) @@ -1035,20 +1048,33 @@ def _unwrap_type(t: Any) -> Any: args = [a for a in get_args(t) if a is not type(None)] return args[0] if args else t - def _cast_value(raw: str, t: Any) -> Any: - t = _unwrap_type(t) + def _cast_value(field_name: str, raw: str, annotation: Any) -> Any: + stripped = raw.strip() + lowered = stripped.lower() + if field_name == "ssl_verify": + if lowered in TRUTHY: + return True + if lowered in FALSY: + return False + return stripped + + t = _unwrap_type(annotation) if t is SecretStr: - return SecretStr(raw) + return SecretStr(stripped) if t is bool: - return raw.lower() in TRUTHY + if lowered in TRUTHY: + return True + if lowered in FALSY: + return False + return stripped.lower() in TRUTHY if t is int: try: - return int(raw) + return int(stripped) except ValueError: return None if t is float: try: - return float(raw) + return float(stripped) except ValueError: return None origin = get_origin(t) @@ -1056,10 +1082,10 @@ def _cast_value(raw: str, t: Any) -> Any: isinstance(t, type) and issubclass(t, BaseModel) ): try: - return json.loads(raw) + return json.loads(stripped) except Exception: pass - return raw + return stripped data: dict[str, Any] = {} fields: dict[str, Any] = { @@ -1074,7 +1100,7 @@ def _cast_value(raw: str, t: Any) -> Any: field_name = key[len(prefix) :].lower() if field_name not in fields: continue - v = _cast_value(value, fields[field_name]) + v = _cast_value(field_name, value, fields[field_name]) if v is not None: data[field_name] = v return cls(**data) diff --git a/openhands-sdk/openhands/sdk/llm/utils/telemetry.py b/openhands-sdk/openhands/sdk/llm/utils/telemetry.py index 2e6b1ac785..0f9a6a7d02 100644 --- a/openhands-sdk/openhands/sdk/llm/utils/telemetry.py +++ b/openhands-sdk/openhands/sdk/llm/utils/telemetry.py @@ -16,6 +16,28 @@ logger = get_logger(__name__) +_SENSITIVE_HEADER_NAMES = { + "authorization", + "proxy-authorization", + "proxy_authorization", + "cookie", + "set-cookie", + "set_cookie", +} +_SENSITIVE_HEADER_KEYWORDS = ( + "api-key", + "api_key", + "access-token", + "access_token", + "auth-token", + "auth_token", + "secret", + "x-api-key", + "x-api-token", + "x-auth-token", +) +_MASK = "***" + class Telemetry(BaseModel): """ @@ -234,7 +256,7 @@ def log_llm_call( f"{uuid.uuid4().hex[:4]}.json" ), ) - data = self._req_ctx.copy() + data = _sanitize_log_ctx(self._req_ctx) data["response"] = ( resp # ModelResponse | ResponsesAPIResponse; # serialized via _safe_json @@ -303,6 +325,54 @@ def log_llm_call( warnings.warn(f"Telemetry logging failed: {e}") +def _sanitize_log_ctx(ctx: dict[str, Any] | None) -> dict[str, Any]: + if not isinstance(ctx, dict): + return {} + sanitized: dict[str, Any] = {} + for key, value in ctx.items(): + if key == "kwargs" and isinstance(value, dict): + sanitized["kwargs"] = _sanitize_kwargs(value) + elif key == "extra_headers" and isinstance(value, dict): + sanitized["extra_headers"] = _sanitize_headers(value) + else: + sanitized[key] = value + return sanitized + + +def _sanitize_kwargs(kwargs: dict[str, Any]) -> dict[str, Any]: + sanitized = dict(kwargs) + extra_headers = sanitized.get("extra_headers") + if isinstance(extra_headers, dict): + sanitized["extra_headers"] = _sanitize_headers(extra_headers) + return sanitized + + +def _sanitize_headers(headers: dict[str, Any]) -> dict[str, Any]: + sanitized: dict[str, Any] = {} + for key, value in headers.items(): + sanitized[key] = _mask_header_value(key, value) + return sanitized + + +def _mask_header_value(key: Any, value: Any) -> Any: + if not isinstance(key, str): + return value + if _is_sensitive_header(key): + return _mask_value(value) + return value + + +def _is_sensitive_header(name: str) -> bool: + lowered = name.lower() + if lowered in _SENSITIVE_HEADER_NAMES: + return True + return any(keyword in lowered for keyword in _SENSITIVE_HEADER_KEYWORDS) + + +def _mask_value(_value: Any) -> str: + return _MASK + + def _safe_json(obj: Any) -> Any: # Centralized serializer for telemetry logs. # Prefer robust serialization for Pydantic models first to avoid cycles. diff --git a/tests/sdk/llm/test_llm.py b/tests/sdk/llm/test_llm.py index e5a2eef089..9e60aaec5e 100644 --- a/tests/sdk/llm/test_llm.py +++ b/tests/sdk/llm/test_llm.py @@ -1,3 +1,4 @@ +import os from typing import Any from unittest.mock import Mock, patch @@ -6,6 +7,7 @@ RateLimitError, ) from litellm.types.llms.openai import ResponseAPIUsage, ResponsesAPIResponse +from litellm.types.utils import ModelResponse from openai.types.responses.response_output_message import ResponseOutputMessage from openai.types.responses.response_output_text import ResponseOutputText from pydantic import SecretStr @@ -447,6 +449,124 @@ def test_responses_call_time_extra_headers_override_config(mock_responses): assert "X-Trace" not in headers +@patch("openhands.sdk.llm.llm.litellm_completion") +def test_llm_ssl_verify_and_custom_provider(mock_completion): + """Test that ssl_verify and custom_llm_provider are forwarded to LiteLLM.""" + mock_response = create_mock_litellm_response("Test response") + mock_completion.return_value = mock_response + + # Create LLM with ssl_verify and custom_llm_provider + llm = LLM( + usage_id="test-llm", + model="gpt-4o", + api_key=SecretStr("test_key"), + ssl_verify=False, + custom_llm_provider="openai", + base_url="https://corporate-proxy.example.com/api", + extra_headers={"X-Corporate-Token": "secret"}, + num_retries=0, + ) + + messages = [Message(role="user", content=[TextContent(text="Hi")])] + _ = llm.completion(messages=messages) + + assert mock_completion.call_count == 1 + _, kwargs = mock_completion.call_args + + # Verify ssl_verify is passed + assert kwargs.get("ssl_verify") is False + + # Verify custom_llm_provider is passed + assert kwargs.get("custom_llm_provider") == "openai" + + # Verify api_base is passed + assert kwargs.get("api_base") == "https://corporate-proxy.example.com/api" + + # Verify extra_headers are passed + headers = kwargs.get("extra_headers") or {} + assert headers.get("X-Corporate-Token") == "secret" + + +@patch("openhands.sdk.llm.llm.litellm_responses") +def test_llm_responses_ssl_verify_and_custom_provider(mock_responses): + """Test that ssl_verify and custom_llm_provider are forwarded in responses API.""" + msg = ResponseOutputMessage.model_construct( + id="msg-test", + type="message", + role="assistant", + status="completed", + content=[ + ResponseOutputText(type="output_text", text="Test response", annotations=[]) + ], + ) + usage = ResponseAPIUsage(input_tokens=0, output_tokens=0, total_tokens=0) + resp = ResponsesAPIResponse( + id="test-resp", + created_at=0, + output=[msg], + usage=usage, + parallel_tool_calls=False, + tool_choice="auto", + top_p=None, + tools=[], + instructions="", + status="completed", + ) + mock_responses.return_value = resp + + llm = LLM( + usage_id="test-llm", + model="gpt-4o", + api_key=SecretStr("test_key"), + ssl_verify=False, + custom_llm_provider="openai", + base_url="https://corporate-proxy.example.com/api", + num_retries=0, + ) + + messages = [Message(role="user", content=[TextContent(text="Hi")])] + _ = llm.responses(messages=messages) + + assert mock_responses.call_count == 1 + _, kwargs = mock_responses.call_args + + # Verify ssl_verify is passed + assert kwargs.get("ssl_verify") is False + + # Verify custom_llm_provider is passed + assert kwargs.get("custom_llm_provider") == "openai" + + # Verify api_base is passed + assert kwargs.get("api_base") == "https://corporate-proxy.example.com/api" + + +def test_llm_ssl_verify_env_parsing(): + """Test that ssl_verify is correctly parsed from environment variables.""" + # Test various false values + for value in ["false", "False", "FALSE", "0", "no", "off"]: + os.environ["LLM_SSL_VERIFY"] = value + os.environ["LLM_MODEL"] = "gpt-4" + llm = LLM.load_from_env() + assert llm.ssl_verify is False, f"Failed for value: {value}" + + # Test various true values + for value in ["true", "True", "TRUE", "1", "yes", "on"]: + os.environ["LLM_SSL_VERIFY"] = value + llm = LLM.load_from_env() + assert llm.ssl_verify is True, f"Failed for value: {value}" + + # Test certificate path (string value) + os.environ["LLM_SSL_VERIFY"] = "/path/to/cert.pem" + llm = LLM.load_from_env() + assert llm.ssl_verify == "/path/to/cert.pem" + + # Clean up + if "LLM_SSL_VERIFY" in os.environ: + del os.environ["LLM_SSL_VERIFY"] + if "LLM_MODEL" in os.environ: + del os.environ["LLM_MODEL"] + + def test_llm_vision_support(default_llm): """Test LLM vision support detection.""" llm = default_llm @@ -651,7 +771,7 @@ def test_llm_config_validation(): @patch("openhands.sdk.llm.llm.litellm_completion") def test_llm_no_response_error(mock_completion): """Test handling of LLMNoResponseError.""" - from litellm.types.utils import ModelResponse, Usage + from litellm.types.utils import Usage # Mock empty response using proper ModelResponse mock_response = ModelResponse( From 45aafa7f318be53325ef04194b5f50128591b398 Mon Sep 17 00:00:00 2001 From: Alona King Date: Tue, 11 Nov 2025 14:17:26 -0500 Subject: [PATCH 2/2] fix: address pre-commit linting issues - Fix line length issues in enterprise gateway example - Update imports to use get_default_agent instead of Agent directly - Remove try/finally block and conversation.close() call to match SDK patterns --- .../26_enterprise_gateway.py | 26 +++++++++---------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/examples/01_standalone_sdk/26_enterprise_gateway.py b/examples/01_standalone_sdk/26_enterprise_gateway.py index e9cb3f0291..d322fa5a29 100644 --- a/examples/01_standalone_sdk/26_enterprise_gateway.py +++ b/examples/01_standalone_sdk/26_enterprise_gateway.py @@ -12,8 +12,9 @@ from pydantic import SecretStr -from openhands.sdk import Agent, Conversation, MessageEvent -from openhands.sdk.llm import LLM, content_to_str +from openhands.sdk import Conversation +from openhands.sdk.llm import LLM +from openhands.tools.preset.default import get_default_agent def build_gateway_llm() -> LLM: @@ -30,7 +31,8 @@ def build_gateway_llm() -> LLM: base_url=os.getenv( "LLM_BASE_URL", "https://your-corporate-proxy.company.com/api/llm" ), - # an api_key input is always required but is unused when api keys are passed via extra headers + # an api_key input is always required but is unused when api keys + # are passed via extra headers api_key=SecretStr(os.getenv("LLM_API_KEY", "placeholder")), custom_llm_provider=os.getenv("LLM_CUSTOM_LLM_PROVIDER", "openai"), ssl_verify=ssl_verify, @@ -58,19 +60,15 @@ def build_gateway_llm() -> LLM: llm = build_gateway_llm() # Create agent and conversation - agent = Agent(llm=llm, cli_mode=True) + agent = get_default_agent(llm=llm, cli_mode=True) conversation = Conversation( agent=agent, workspace=os.getcwd(), - visualize=False, ) - try: - # Send a message to test the enterprise gateway configuration - conversation.send_message( - "Analyze this codebase and create 3 facts about the current project into FACTS.txt. Do not write code." - ) - conversation.run() - - finally: - conversation.close() + # Send a message to test the enterprise gateway configuration + conversation.send_message( + "Analyze this codebase and create 3 facts about the current " + "project into FACTS.txt. Do not write code." + ) + conversation.run()