Skip to content

Commit 878c17d

Browse files
committed
feat(llm): add enterprise gateway and SSL verification support
1 parent a482ab1 commit 878c17d

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
@@ -189,6 +189,14 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin):
189189
)
190190
ollama_base_url: str | None = Field(default=None)
191191

192+
ssl_verify: bool | str | None = Field(
193+
default=None,
194+
description=(
195+
"TLS verification forwarded to LiteLLM; "
196+
"set to False when corporate proxies break certificate chains."
197+
),
198+
)
199+
192200
drop_params: bool = Field(default=True)
193201
modify_params: bool = Field(
194202
default=True,
@@ -512,7 +520,7 @@ def completion(
512520
log_ctx = {
513521
"messages": formatted_messages[:], # already simple dicts
514522
"tools": tools,
515-
"kwargs": {k: v for k, v in call_kwargs.items()},
523+
"kwargs": dict(call_kwargs),
516524
"context_window": self.max_input_tokens or 0,
517525
}
518526
if tools and not use_native_fc:
@@ -629,7 +637,7 @@ def responses(
629637
"llm_path": "responses",
630638
"input": input_items[:],
631639
"tools": tools,
632-
"kwargs": {k: v for k, v in call_kwargs.items()},
640+
"kwargs": dict(call_kwargs),
633641
"context_window": self.max_input_tokens or 0,
634642
}
635643
self._telemetry.on_request(log_ctx=log_ctx)
@@ -665,7 +673,9 @@ def _one_attempt(**retry_kwargs) -> ResponsesAPIResponse:
665673
api_key=api_key_value,
666674
api_base=self.base_url,
667675
api_version=self.api_version,
676+
custom_llm_provider=self.custom_llm_provider,
668677
timeout=self.timeout,
678+
ssl_verify=self.ssl_verify,
669679
drop_params=self.drop_params,
670680
seed=self.seed,
671681
**final_kwargs,
@@ -742,7 +752,9 @@ def _transport_call(
742752
api_key=api_key_value,
743753
api_base=self.base_url,
744754
api_version=self.api_version,
755+
custom_llm_provider=self.custom_llm_provider,
745756
timeout=self.timeout,
757+
ssl_verify=self.ssl_verify,
746758
drop_params=self.drop_params,
747759
seed=self.seed,
748760
messages=messages,
@@ -1027,6 +1039,7 @@ def load_from_json(cls, json_path: str) -> LLM:
10271039
@classmethod
10281040
def load_from_env(cls, prefix: str = "LLM_") -> LLM:
10291041
TRUTHY = {"true", "1", "yes", "on"}
1042+
FALSY = {"false", "0", "no", "off"}
10301043

10311044
def _unwrap_type(t: Any) -> Any:
10321045
origin = get_origin(t)
@@ -1035,31 +1048,44 @@ def _unwrap_type(t: Any) -> Any:
10351048
args = [a for a in get_args(t) if a is not type(None)]
10361049
return args[0] if args else t
10371050

1038-
def _cast_value(raw: str, t: Any) -> Any:
1039-
t = _unwrap_type(t)
1051+
def _cast_value(field_name: str, raw: str, annotation: Any) -> Any:
1052+
stripped = raw.strip()
1053+
lowered = stripped.lower()
1054+
if field_name == "ssl_verify":
1055+
if lowered in TRUTHY:
1056+
return True
1057+
if lowered in FALSY:
1058+
return False
1059+
return stripped
1060+
1061+
t = _unwrap_type(annotation)
10401062
if t is SecretStr:
1041-
return SecretStr(raw)
1063+
return SecretStr(stripped)
10421064
if t is bool:
1043-
return raw.lower() in TRUTHY
1065+
if lowered in TRUTHY:
1066+
return True
1067+
if lowered in FALSY:
1068+
return False
1069+
return stripped.lower() in TRUTHY
10441070
if t is int:
10451071
try:
1046-
return int(raw)
1072+
return int(stripped)
10471073
except ValueError:
10481074
return None
10491075
if t is float:
10501076
try:
1051-
return float(raw)
1077+
return float(stripped)
10521078
except ValueError:
10531079
return None
10541080
origin = get_origin(t)
10551081
if (origin in (list, dict, tuple)) or (
10561082
isinstance(t, type) and issubclass(t, BaseModel)
10571083
):
10581084
try:
1059-
return json.loads(raw)
1085+
return json.loads(stripped)
10601086
except Exception:
10611087
pass
1062-
return raw
1088+
return stripped
10631089

10641090
data: dict[str, Any] = {}
10651091
fields: dict[str, Any] = {
@@ -1074,7 +1100,7 @@ def _cast_value(raw: str, t: Any) -> Any:
10741100
field_name = key[len(prefix) :].lower()
10751101
if field_name not in fields:
10761102
continue
1077-
v = _cast_value(value, fields[field_name])
1103+
v = _cast_value(field_name, value, fields[field_name])
10781104
if v is not None:
10791105
data[field_name] = v
10801106
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
"""
@@ -234,7 +256,7 @@ def log_llm_call(
234256
f"{uuid.uuid4().hex[:4]}.json"
235257
),
236258
)
237-
data = self._req_ctx.copy()
259+
data = _sanitize_log_ctx(self._req_ctx)
238260
data["response"] = (
239261
resp # ModelResponse | ResponsesAPIResponse;
240262
# serialized via _safe_json
@@ -303,6 +325,54 @@ def log_llm_call(
303325
warnings.warn(f"Telemetry logging failed: {e}")
304326

305327

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

0 commit comments

Comments
 (0)