Skip to content

Commit 8ee3d30

Browse files
committed
feat(llm): add enterprise gateway and SSL verification support
1 parent d5995c3 commit 8ee3d30

File tree

4 files changed

+305
-13
lines changed

4 files changed

+305
-13
lines changed
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Enterprise Gateway Example
4+
5+
Demonstrates configuring OpenHands for environments that route LLM traffic through
6+
an API gateway requiring custom headers and optional TLS overrides.
7+
"""
8+
9+
import os
10+
import uuid
11+
from datetime import datetime
12+
13+
from pydantic import SecretStr
14+
15+
from openhands.sdk import Agent, Conversation, MessageEvent
16+
from openhands.sdk.llm import LLM, content_to_str
17+
18+
19+
def build_gateway_llm() -> LLM:
20+
"""Create an LLM instance configured for an enterprise gateway."""
21+
now = datetime.utcnow()
22+
correlation_id = uuid.uuid4().hex
23+
request_id = uuid.uuid4().hex
24+
25+
ssl_env = os.getenv("LLM_SSL_VERIFY")
26+
ssl_verify: bool | str = ssl_env if ssl_env is not None else False
27+
28+
return LLM(
29+
model=os.getenv("LLM_MODEL", "gemini-2.5-flash"),
30+
base_url=os.getenv(
31+
"LLM_BASE_URL", "https://your-corporate-proxy.company.com/api/llm"
32+
),
33+
# an api_key input is always required but is unused when api keys are passed via extra headers
34+
api_key=SecretStr(os.getenv("LLM_API_KEY", "placeholder")),
35+
custom_llm_provider=os.getenv("LLM_CUSTOM_LLM_PROVIDER", "openai"),
36+
ssl_verify=ssl_verify,
37+
extra_headers={
38+
# Typical headers forwarded by gateways
39+
"Authorization": os.getenv("LLM_GATEWAY_TOKEN", "Bearer YOUR_TOKEN"),
40+
"Content-Type": "application/json",
41+
"x-correlation-id": correlation_id,
42+
"x-request-id": request_id,
43+
"x-request-date": now.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3],
44+
"x-client-id": os.getenv("LLM_CLIENT_ID", "YOUR_CLIENT_ID"),
45+
"X-USECASE-ID": os.getenv("LLM_USECASE_ID", "YOUR_USECASE_ID"),
46+
"x-api-key": os.getenv("LLM_GATEWAY_API_KEY", "YOUR_API_KEY"),
47+
},
48+
# additional optional parameters
49+
timeout=30,
50+
num_retries=1,
51+
)
52+
53+
54+
if __name__ == "__main__":
55+
print("=== Enterprise Gateway Configuration Example ===")
56+
57+
# Build LLM with enterprise gateway configuration
58+
llm = build_gateway_llm()
59+
60+
# Create agent and conversation
61+
agent = Agent(llm=llm, cli_mode=True)
62+
conversation = Conversation(
63+
agent=agent,
64+
workspace=os.getcwd(),
65+
visualize=False,
66+
)
67+
68+
try:
69+
# Send a message to test the enterprise gateway configuration
70+
conversation.send_message(
71+
"Analyze this codebase and create 3 facts about the current project into FACTS.txt. Do not write code."
72+
)
73+
conversation.run()
74+
75+
finally:
76+
conversation.close()

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

Lines changed: 37 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,14 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin):
182182
)
183183
ollama_base_url: str | None = Field(default=None)
184184

185+
ssl_verify: bool | str | None = Field(
186+
default=None,
187+
description=(
188+
"TLS verification forwarded to LiteLLM; "
189+
"set to False when corporate proxies break certificate chains."
190+
),
191+
)
192+
185193
drop_params: bool = Field(default=True)
186194
modify_params: bool = Field(
187195
default=True,
@@ -498,7 +506,7 @@ def completion(
498506
log_ctx = {
499507
"messages": formatted_messages[:], # already simple dicts
500508
"tools": tools,
501-
"kwargs": {k: v for k, v in call_kwargs.items()},
509+
"kwargs": dict(call_kwargs),
502510
"context_window": self.max_input_tokens or 0,
503511
}
504512
if tools and not use_native_fc:
@@ -613,7 +621,7 @@ def responses(
613621
"llm_path": "responses",
614622
"input": input_items[:],
615623
"tools": tools,
616-
"kwargs": {k: v for k, v in call_kwargs.items()},
624+
"kwargs": dict(call_kwargs),
617625
"context_window": self.max_input_tokens or 0,
618626
}
619627
self._telemetry.on_request(log_ctx=log_ctx)
@@ -645,7 +653,9 @@ def _one_attempt(**retry_kwargs) -> ResponsesAPIResponse:
645653
else None,
646654
api_base=self.base_url,
647655
api_version=self.api_version,
656+
custom_llm_provider=self.custom_llm_provider,
648657
timeout=self.timeout,
658+
ssl_verify=self.ssl_verify,
649659
drop_params=self.drop_params,
650660
seed=self.seed,
651661
**final_kwargs,
@@ -716,7 +726,9 @@ def _transport_call(
716726
api_key=self.api_key.get_secret_value() if self.api_key else None,
717727
api_base=self.base_url,
718728
api_version=self.api_version,
729+
custom_llm_provider=self.custom_llm_provider,
719730
timeout=self.timeout,
731+
ssl_verify=self.ssl_verify,
720732
drop_params=self.drop_params,
721733
seed=self.seed,
722734
messages=messages,
@@ -982,6 +994,7 @@ def load_from_json(cls, json_path: str) -> LLM:
982994
@classmethod
983995
def load_from_env(cls, prefix: str = "LLM_") -> LLM:
984996
TRUTHY = {"true", "1", "yes", "on"}
997+
FALSY = {"false", "0", "no", "off"}
985998

986999
def _unwrap_type(t: Any) -> Any:
9871000
origin = get_origin(t)
@@ -990,31 +1003,44 @@ def _unwrap_type(t: Any) -> Any:
9901003
args = [a for a in get_args(t) if a is not type(None)]
9911004
return args[0] if args else t
9921005

993-
def _cast_value(raw: str, t: Any) -> Any:
994-
t = _unwrap_type(t)
1006+
def _cast_value(field_name: str, raw: str, annotation: Any) -> Any:
1007+
stripped = raw.strip()
1008+
lowered = stripped.lower()
1009+
if field_name == "ssl_verify":
1010+
if lowered in TRUTHY:
1011+
return True
1012+
if lowered in FALSY:
1013+
return False
1014+
return stripped
1015+
1016+
t = _unwrap_type(annotation)
9951017
if t is SecretStr:
996-
return SecretStr(raw)
1018+
return SecretStr(stripped)
9971019
if t is bool:
998-
return raw.lower() in TRUTHY
1020+
if lowered in TRUTHY:
1021+
return True
1022+
if lowered in FALSY:
1023+
return False
1024+
return stripped.lower() in TRUTHY
9991025
if t is int:
10001026
try:
1001-
return int(raw)
1027+
return int(stripped)
10021028
except ValueError:
10031029
return None
10041030
if t is float:
10051031
try:
1006-
return float(raw)
1032+
return float(stripped)
10071033
except ValueError:
10081034
return None
10091035
origin = get_origin(t)
10101036
if (origin in (list, dict, tuple)) or (
10111037
isinstance(t, type) and issubclass(t, BaseModel)
10121038
):
10131039
try:
1014-
return json.loads(raw)
1040+
return json.loads(stripped)
10151041
except Exception:
10161042
pass
1017-
return raw
1043+
return stripped
10181044

10191045
data: dict[str, Any] = {}
10201046
fields: dict[str, Any] = {
@@ -1029,7 +1055,7 @@ def _cast_value(raw: str, t: Any) -> Any:
10291055
field_name = key[len(prefix) :].lower()
10301056
if field_name not in fields:
10311057
continue
1032-
v = _cast_value(value, fields[field_name])
1058+
v = _cast_value(field_name, value, fields[field_name])
10331059
if v is not None:
10341060
data[field_name] = v
10351061
return cls(**data)

openhands-sdk/openhands/sdk/llm/utils/telemetry.py

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,28 @@
1616

1717
logger = get_logger(__name__)
1818

19+
_SENSITIVE_HEADER_NAMES = {
20+
"authorization",
21+
"proxy-authorization",
22+
"proxy_authorization",
23+
"cookie",
24+
"set-cookie",
25+
"set_cookie",
26+
}
27+
_SENSITIVE_HEADER_KEYWORDS = (
28+
"api-key",
29+
"api_key",
30+
"access-token",
31+
"access_token",
32+
"auth-token",
33+
"auth_token",
34+
"secret",
35+
"x-api-key",
36+
"x-api-token",
37+
"x-auth-token",
38+
)
39+
_MASK = "***"
40+
1941

2042
class Telemetry(BaseModel):
2143
"""
@@ -232,7 +254,7 @@ def log_llm_call(
232254
f"{uuid.uuid4().hex[:4]}.json"
233255
),
234256
)
235-
data = self._req_ctx.copy()
257+
data = _sanitize_log_ctx(self._req_ctx)
236258
data["response"] = (
237259
resp # ModelResponse | ResponsesAPIResponse;
238260
# serialized via _safe_json
@@ -301,6 +323,54 @@ def log_llm_call(
301323
warnings.warn(f"Telemetry logging failed: {e}")
302324

303325

326+
def _sanitize_log_ctx(ctx: dict[str, Any] | None) -> dict[str, Any]:
327+
if not isinstance(ctx, dict):
328+
return {}
329+
sanitized: dict[str, Any] = {}
330+
for key, value in ctx.items():
331+
if key == "kwargs" and isinstance(value, dict):
332+
sanitized["kwargs"] = _sanitize_kwargs(value)
333+
elif key == "extra_headers" and isinstance(value, dict):
334+
sanitized["extra_headers"] = _sanitize_headers(value)
335+
else:
336+
sanitized[key] = value
337+
return sanitized
338+
339+
340+
def _sanitize_kwargs(kwargs: dict[str, Any]) -> dict[str, Any]:
341+
sanitized = dict(kwargs)
342+
extra_headers = sanitized.get("extra_headers")
343+
if isinstance(extra_headers, dict):
344+
sanitized["extra_headers"] = _sanitize_headers(extra_headers)
345+
return sanitized
346+
347+
348+
def _sanitize_headers(headers: dict[str, Any]) -> dict[str, Any]:
349+
sanitized: dict[str, Any] = {}
350+
for key, value in headers.items():
351+
sanitized[key] = _mask_header_value(key, value)
352+
return sanitized
353+
354+
355+
def _mask_header_value(key: Any, value: Any) -> Any:
356+
if not isinstance(key, str):
357+
return value
358+
if _is_sensitive_header(key):
359+
return _mask_value(value)
360+
return value
361+
362+
363+
def _is_sensitive_header(name: str) -> bool:
364+
lowered = name.lower()
365+
if lowered in _SENSITIVE_HEADER_NAMES:
366+
return True
367+
return any(keyword in lowered for keyword in _SENSITIVE_HEADER_KEYWORDS)
368+
369+
370+
def _mask_value(_value: Any) -> str:
371+
return _MASK
372+
373+
304374
def _safe_json(obj: Any) -> Any:
305375
# Centralized serializer for telemetry logs.
306376
# Today, responses are Pydantic ModelResponse or ResponsesAPIResponse; rely on it.

0 commit comments

Comments
 (0)