Skip to content

Commit 7a1983d

Browse files
committed
feat: add enterprise gateway support with Wells Fargo configuration
- Add ssl_verify field to LLM class for certificate handling - Forward ssl_verify and custom_llm_provider to LiteLLM calls - Exclude extra_headers from telemetry logging for security - Improve environment variable parsing for ssl_verify (supports false/true/cert paths) - Add comprehensive tests for ssl_verify and custom_llm_provider - Add enterprise_gateway_example.py demonstrating Wells Fargo configuration This supersedes PR #963 by merging Wells Fargo requirements with the extra_headers support from PR #733.
1 parent ccdac20 commit 7a1983d

File tree

3 files changed

+291
-11
lines changed

3 files changed

+291
-11
lines changed
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Example demonstrating how to configure the LLM class for enterprise API gateways
4+
that require custom headers and SSL certificate handling.
5+
6+
This example shows configuration patterns used at Wells Fargo and other enterprises
7+
with corporate proxies or API management systems like Tachyon/Apigee.
8+
"""
9+
10+
import os
11+
import uuid
12+
from datetime import datetime
13+
from openhands.sdk.llm import LLM
14+
15+
16+
def create_enterprise_llm():
17+
"""
18+
Create an LLM instance configured for enterprise gateway access.
19+
20+
This example shows how to:
21+
1. Add custom headers required by the gateway (auth tokens, correlation IDs)
22+
2. Set a custom base URL for the enterprise proxy
23+
3. Disable SSL verification when corporate proxies break cert chains
24+
4. Specify the underlying provider explicitly
25+
"""
26+
27+
# Generate dynamic headers that may be required by the gateway
28+
now = datetime.now()
29+
correlation_id = str(uuid.uuid4())
30+
request_id = str(uuid.uuid4())
31+
32+
# Configure the LLM with enterprise gateway settings
33+
llm = LLM(
34+
model="openai/gemini-2.5-flash", # Model name as exposed by gateway
35+
api_key="placeholder", # Often required even if not used
36+
37+
# Enterprise proxy endpoint
38+
base_url="https://your-corporate-proxy.company.com/api/llm",
39+
40+
# Custom headers required by the gateway
41+
extra_headers={
42+
"Authorization": "Bearer YOUR_ENTERPRISE_TOKEN",
43+
"Content-Type": "application/json",
44+
"x-correlation-id": correlation_id,
45+
"x-request-id": request_id,
46+
"x-wf-request-date": now.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3],
47+
"X-WF-USECASE-ID": "YOUR_USECASE_ID",
48+
"x-wf-client-id": "YOUR_CLIENT_ID",
49+
"x-wf-api-key": "YOUR_API_KEY",
50+
},
51+
52+
# Disable SSL verification if corporate proxy breaks certificate chain
53+
ssl_verify=False, # Set to True in production if certs are properly configured
54+
55+
# Explicitly specify the provider for LiteLLM routing
56+
custom_llm_provider="openai",
57+
58+
# Other configurations
59+
num_retries=1,
60+
timeout=30,
61+
)
62+
63+
return llm
64+
65+
66+
def create_llm_from_env():
67+
"""
68+
Create an LLM instance using environment variables.
69+
70+
Set these environment variables:
71+
- LLM_MODEL=openai/gemini-2.5-flash
72+
- LLM_API_KEY=placeholder
73+
- LLM_BASE_URL=https://your-corporate-proxy.company.com/api/llm
74+
- LLM_SSL_VERIFY=false
75+
- LLM_CUSTOM_LLM_PROVIDER=openai
76+
- LLM_EXTRA_HEADERS='{"Authorization": "Bearer TOKEN", "x-correlation-id": "123"}'
77+
"""
78+
79+
# The load_from_env method automatically handles:
80+
# - Boolean parsing for ssl_verify (accepts: false, False, 0, no, off)
81+
# - JSON parsing for complex fields like extra_headers
82+
llm = LLM.load_from_env()
83+
84+
return llm
85+
86+
87+
def example_usage():
88+
"""Demonstrate using the enterprise-configured LLM."""
89+
90+
# Create the LLM instance
91+
llm = create_enterprise_llm()
92+
93+
# Use the LLM for chat completion
94+
response = llm.chat(
95+
messages=[
96+
{"role": "system", "content": "You are a helpful assistant."},
97+
{"role": "user", "content": "What is the capital of France?"}
98+
]
99+
)
100+
101+
print(f"Response: {response.choices[0].message.content}")
102+
103+
# The extra_headers are automatically included in the request to the gateway
104+
# The ssl_verify setting is applied to the HTTPS connection
105+
# The custom_llm_provider ensures proper routing through LiteLLM
106+
107+
108+
if __name__ == "__main__":
109+
# Example 1: Direct configuration
110+
print("Example 1: Direct configuration")
111+
llm = create_enterprise_llm()
112+
print(f"Created LLM with model: {llm.model}")
113+
print(f"Base URL: {llm.base_url}")
114+
print(f"SSL Verify: {llm.ssl_verify}")
115+
print(f"Extra headers configured: {bool(llm.extra_headers)}")
116+
117+
# Example 2: Environment variable configuration
118+
print("\nExample 2: Environment variable configuration")
119+
# Set example environment variables (normally these would be set externally)
120+
os.environ["LLM_MODEL"] = "openai/gpt-4"
121+
os.environ["LLM_BASE_URL"] = "https://api-gateway.example.com/v1"
122+
os.environ["LLM_SSL_VERIFY"] = "false"
123+
os.environ["LLM_CUSTOM_LLM_PROVIDER"] = "openai"
124+
os.environ["LLM_EXTRA_HEADERS"] = '{"x-api-key": "secret123"}'
125+
126+
llm_env = LLM.load_from_env()
127+
print(f"Created LLM from env with model: {llm_env.model}")
128+
print(f"Base URL: {llm_env.base_url}")
129+
print(f"SSL Verify: {llm_env.ssl_verify}")
130+
print(f"Extra headers: {llm_env.extra_headers}")

openhands-sdk/openhands/sdk/llm/llm.py

Lines changed: 43 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,14 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin):
166166
)
167167
ollama_base_url: str | None = Field(default=None)
168168

169+
ssl_verify: bool | str | None = Field(
170+
default=None,
171+
description=(
172+
"TLS verification forwarded to LiteLLM; "
173+
"set to False when corporate proxies break certificate chains."
174+
),
175+
)
176+
169177
drop_params: bool = Field(default=True)
170178
modify_params: bool = Field(
171179
default=True,
@@ -455,10 +463,13 @@ def completion(
455463
assert self._telemetry is not None
456464
log_ctx = None
457465
if self._telemetry.log_enabled:
466+
sanitized_kwargs = {
467+
k: v for k, v in call_kwargs.items() if k != "extra_headers"
468+
}
458469
log_ctx = {
459470
"messages": formatted_messages[:], # already simple dicts
460471
"tools": tools,
461-
"kwargs": {k: v for k, v in call_kwargs.items()},
472+
"kwargs": sanitized_kwargs,
462473
"context_window": self.max_input_tokens or 0,
463474
}
464475
if tools and not use_native_fc:
@@ -566,11 +577,14 @@ def responses(
566577
assert self._telemetry is not None
567578
log_ctx = None
568579
if self._telemetry.log_enabled:
580+
sanitized_kwargs = {
581+
k: v for k, v in call_kwargs.items() if k != "extra_headers"
582+
}
569583
log_ctx = {
570584
"llm_path": "responses",
571585
"input": input_items[:],
572586
"tools": tools,
573-
"kwargs": {k: v for k, v in call_kwargs.items()},
587+
"kwargs": sanitized_kwargs,
574588
"context_window": self.max_input_tokens or 0,
575589
}
576590
self._telemetry.on_request(log_ctx=log_ctx)
@@ -602,7 +616,9 @@ def _one_attempt(**retry_kwargs) -> ResponsesAPIResponse:
602616
else None,
603617
api_base=self.base_url,
604618
api_version=self.api_version,
619+
custom_llm_provider=self.custom_llm_provider,
605620
timeout=self.timeout,
621+
ssl_verify=self.ssl_verify,
606622
drop_params=self.drop_params,
607623
seed=self.seed,
608624
**final_kwargs,
@@ -670,7 +686,9 @@ def _transport_call(
670686
api_key=self.api_key.get_secret_value() if self.api_key else None,
671687
api_base=self.base_url,
672688
api_version=self.api_version,
689+
custom_llm_provider=self.custom_llm_provider,
673690
timeout=self.timeout,
691+
ssl_verify=self.ssl_verify,
674692
drop_params=self.drop_params,
675693
seed=self.seed,
676694
messages=messages,
@@ -932,6 +950,7 @@ def load_from_json(cls, json_path: str) -> LLM:
932950
@classmethod
933951
def load_from_env(cls, prefix: str = "LLM_") -> LLM:
934952
TRUTHY = {"true", "1", "yes", "on"}
953+
FALSY = {"false", "0", "no", "off"}
935954

936955
def _unwrap_type(t: Any) -> Any:
937956
origin = get_origin(t)
@@ -940,31 +959,44 @@ def _unwrap_type(t: Any) -> Any:
940959
args = [a for a in get_args(t) if a is not type(None)]
941960
return args[0] if args else t
942961

943-
def _cast_value(raw: str, t: Any) -> Any:
944-
t = _unwrap_type(t)
962+
def _cast_value(field_name: str, raw: str, annotation: Any) -> Any:
963+
stripped = raw.strip()
964+
lowered = stripped.lower()
965+
if field_name == "ssl_verify":
966+
if lowered in TRUTHY:
967+
return True
968+
if lowered in FALSY:
969+
return False
970+
return stripped
971+
972+
t = _unwrap_type(annotation)
945973
if t is SecretStr:
946-
return SecretStr(raw)
974+
return SecretStr(stripped)
947975
if t is bool:
948-
return raw.lower() in TRUTHY
976+
if lowered in TRUTHY:
977+
return True
978+
if lowered in FALSY:
979+
return False
980+
return None
949981
if t is int:
950982
try:
951-
return int(raw)
983+
return int(stripped)
952984
except ValueError:
953985
return None
954986
if t is float:
955987
try:
956-
return float(raw)
988+
return float(stripped)
957989
except ValueError:
958990
return None
959991
origin = get_origin(t)
960992
if (origin in (list, dict, tuple)) or (
961993
isinstance(t, type) and issubclass(t, BaseModel)
962994
):
963995
try:
964-
return json.loads(raw)
996+
return json.loads(stripped)
965997
except Exception:
966998
pass
967-
return raw
999+
return stripped
9681000

9691001
data: dict[str, Any] = {}
9701002
fields: dict[str, Any] = {
@@ -979,7 +1011,7 @@ def _cast_value(raw: str, t: Any) -> Any:
9791011
field_name = key[len(prefix) :].lower()
9801012
if field_name not in fields:
9811013
continue
982-
v = _cast_value(value, fields[field_name])
1014+
v = _cast_value(field_name, value, fields[field_name])
9831015
if v is not None:
9841016
data[field_name] = v
9851017
return cls(**data)

0 commit comments

Comments
 (0)