Skip to content

Commit 000d74d

Browse files
Faraaz1994GWeale
authored andcommitted
feat: Support passing dynamic custom headers to LiteLLM via RunConfig
Merge #4299 **Please ensure you have read the [contribution guide](https://github.com/google/adk-python/blob/main/CONTRIBUTING.md) before creating a pull request.** ### Link to Issue or Description of Change **1. Link to an existing issue (if applicable):** - Closes: #4297 **Problem:** When using ADK with LiteLLM, there's no way to pass dynamic per-request HTTP configuration like custom headers, timeouts, or retry options via RunConfig. **Solution:** Add an http_options field to RunConfig that propagates through the request pipeline to the model layer. This allows runtime configuration of: - headers: Custom HTTP headers (auth tokens, tracing IDs, correlation headers) - timeout: Per-request timeout overrides - retry_options: Per-request retry configuration - extra_body: Additional request body parameters ### Testing Plan **Unit Tests:** - [X] I have added or updated unit tests for my change. - [x] All unit tests pass locally. ### Checklist - [X] I have read the [CONTRIBUTING.md](https://github.com/google/adk-python/blob/main/CONTRIBUTING.md) document. - [X] I have performed a self-review of my own code. - [X] I have commented my code, particularly in hard-to-understand areas. - [X] I have added tests that prove my fix is effective or that my feature works. - [X] New and existing unit tests pass locally with my changes. - [X] I have manually tested my changes end-to-end. - [ ] Any dependent changes have been merged and published in downstream modules. ### Additional context Co-authored-by: George Weale <gweale@google.com> COPYBARA_INTEGRATE_REVIEW=#4299 from Faraaz1994:feature/custom_http_headers c7e5f11 PiperOrigin-RevId: 940577536
1 parent 4e44632 commit 000d74d

6 files changed

Lines changed: 328 additions & 0 deletions

File tree

src/google/adk/agents/run_config.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,9 @@ class RunConfig(BaseModel):
196196
speech_config: Optional[types.SpeechConfig] = None
197197
"""Speech configuration for the live agent."""
198198

199+
http_options: Optional[types.HttpOptions] = None
200+
"""HTTP options for the agent execution (e.g. custom headers)."""
201+
199202
response_modalities: Optional[list[types.Modality]] = None
200203
"""The output modalities. If not set, it's default to AUDIO."""
201204

src/google/adk/flows/llm_flows/base_llm_flow.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1023,6 +1023,15 @@ async def _preprocess_async(
10231023
f' but got {type(agent)}'
10241024
)
10251025

1026+
# Request defaults; _BasicLlmRequestProcessor merges them onto agent config.
1027+
if (
1028+
invocation_context.run_config
1029+
and invocation_context.run_config.http_options
1030+
):
1031+
llm_request.config.http_options = (
1032+
invocation_context.run_config.http_options.model_copy(deep=True)
1033+
)
1034+
10261035
# Runs processors.
10271036
for processor in self.request_processors:
10281037
async with Aclosing(

src/google/adk/flows/llm_flows/basic.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,30 @@
2929
from ._base_llm_processor import BaseLlmRequestProcessor
3030

3131

32+
def _merge_run_config_http_options(
33+
config: types.GenerateContentConfig,
34+
run_config_http_options: types.HttpOptions,
35+
) -> None:
36+
"""Merges RunConfig http_options into the request config, RunConfig wins.
37+
38+
base_url and api_version are configuration-time settings, not request-time,
39+
so they are intentionally not merged here.
40+
"""
41+
if config.http_options is None:
42+
config.http_options = run_config_http_options
43+
return
44+
45+
if run_config_http_options.headers:
46+
if config.http_options.headers is None:
47+
config.http_options.headers = {}
48+
config.http_options.headers.update(run_config_http_options.headers)
49+
50+
for field in ('timeout', 'retry_options', 'extra_body'):
51+
value = getattr(run_config_http_options, field, None)
52+
if value is not None:
53+
setattr(config.http_options, field, value)
54+
55+
3256
def _build_basic_request(
3357
invocation_context: InvocationContext,
3458
llm_request: LlmRequest,
@@ -45,11 +69,18 @@ def _build_basic_request(
4569
agent = invocation_context.agent
4670
model = agent.canonical_model
4771
llm_request.model = model if isinstance(model, str) else model.model
72+
73+
# Preserved across the agent-config overwrite below, then merged back.
74+
run_config_http_options = llm_request.config.http_options
75+
4876
llm_request.config = (
4977
agent.generate_content_config.model_copy(deep=True)
5078
if agent.generate_content_config
5179
else types.GenerateContentConfig()
5280
)
81+
82+
if run_config_http_options:
83+
_merge_run_config_http_options(llm_request.config, run_config_http_options)
5384
# Only set output_schema if no tools are specified. as of now, model don't
5485
# support output_schema and tools together. we have a workaround to support
5586
# both output_schema and tools at the same time. see

src/google/adk/models/lite_llm.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2475,6 +2475,30 @@ async def generate_content_async(
24752475
if generation_params:
24762476
completion_args.update(generation_params)
24772477

2478+
if llm_request.config.http_options:
2479+
http_opts = llm_request.config.http_options
2480+
if http_opts.headers:
2481+
extra_headers = completion_args.get("extra_headers", {})
2482+
if isinstance(extra_headers, dict):
2483+
extra_headers = extra_headers.copy()
2484+
else:
2485+
extra_headers = {}
2486+
extra_headers.update(http_opts.headers)
2487+
completion_args["extra_headers"] = extra_headers
2488+
2489+
if http_opts.timeout is not None:
2490+
completion_args["timeout"] = http_opts.timeout
2491+
2492+
if (
2493+
http_opts.retry_options is not None
2494+
and http_opts.retry_options.attempts is not None
2495+
):
2496+
# LiteLLM accepts num_retries as a top-level parameter.
2497+
completion_args["num_retries"] = http_opts.retry_options.attempts
2498+
2499+
if http_opts.extra_body is not None:
2500+
completion_args["extra_body"] = http_opts.extra_body
2501+
24782502
if stream:
24792503
text = ""
24802504
reasoning_parts: List[types.Part] = []

tests/unittests/flows/llm_flows/test_basic_processor.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,3 +294,82 @@ async def test_translation_config_defaults_to_none(self):
294294
pass
295295

296296
assert llm_request.live_connect_config.translation_config is None
297+
298+
@pytest.mark.asyncio
299+
async def test_preserves_merged_http_options(self):
300+
"""Test that processor preserves and merges existing http_options."""
301+
agent = LlmAgent(
302+
name='test_agent',
303+
model='gemini-1.5-flash',
304+
generate_content_config=types.GenerateContentConfig(
305+
http_options=types.HttpOptions(
306+
timeout=1000,
307+
headers={'Agent-Header': 'agent-val'},
308+
)
309+
),
310+
)
311+
312+
invocation_context = await _create_invocation_context(agent)
313+
llm_request = LlmRequest()
314+
315+
# Simulate http_options propagated from RunConfig.
316+
llm_request.config.http_options = types.HttpOptions(
317+
timeout=500, # Should override agent.
318+
headers={
319+
'RunConfig-Header': 'run-val',
320+
'Agent-Header': 'run-val-override',
321+
},
322+
)
323+
324+
processor = _BasicLlmRequestProcessor()
325+
326+
async for _ in processor.run_async(invocation_context, llm_request):
327+
pass
328+
329+
# RunConfig timeout wins.
330+
assert llm_request.config.http_options.timeout == 500
331+
332+
# Headers merged, RunConfig wins on conflict.
333+
assert (
334+
llm_request.config.http_options.headers['RunConfig-Header'] == 'run-val'
335+
)
336+
assert (
337+
llm_request.config.http_options.headers['Agent-Header']
338+
== 'run-val-override'
339+
)
340+
341+
@pytest.mark.asyncio
342+
async def test_merges_http_options_without_headers(self):
343+
"""RunConfig timeout/extra_body merge even when no headers are set."""
344+
agent = LlmAgent(
345+
name='test_agent',
346+
model='gemini-1.5-flash',
347+
generate_content_config=types.GenerateContentConfig(
348+
http_options=types.HttpOptions(
349+
timeout=1000,
350+
headers={'Agent-Header': 'agent-val'},
351+
)
352+
),
353+
)
354+
355+
invocation_context = await _create_invocation_context(agent)
356+
llm_request = LlmRequest()
357+
358+
# Propagated RunConfig http_options with no headers.
359+
llm_request.config.http_options = types.HttpOptions(
360+
timeout=500,
361+
extra_body={'priority': 'high'},
362+
)
363+
364+
processor = _BasicLlmRequestProcessor()
365+
366+
async for _ in processor.run_async(invocation_context, llm_request):
367+
pass
368+
369+
# timeout and extra_body still merge despite empty headers.
370+
assert llm_request.config.http_options.timeout == 500
371+
assert llm_request.config.http_options.extra_body == {'priority': 'high'}
372+
# Agent headers are untouched.
373+
assert (
374+
llm_request.config.http_options.headers['Agent-Header'] == 'agent-val'
375+
)

tests/unittests/models/test_litellm.py

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5607,3 +5607,185 @@ def test_redact_file_uri_for_log_http_url_keeps_scheme_and_tail():
56075607
_redact_file_uri_for_log("https://example.com/path/file.pdf")
56085608
== "https://<redacted>/file.pdf"
56095609
)
5610+
5611+
5612+
@pytest.mark.asyncio
5613+
async def test_generate_content_async_passes_http_options_headers_as_extra_headers(
5614+
mock_acompletion, lite_llm_instance
5615+
):
5616+
"""Test that http_options.headers from LlmRequest are forwarded to litellm."""
5617+
llm_request = LlmRequest(
5618+
contents=[
5619+
types.Content(
5620+
role="user", parts=[types.Part.from_text(text="Test prompt")]
5621+
)
5622+
],
5623+
config=types.GenerateContentConfig(
5624+
http_options=types.HttpOptions(
5625+
headers={"X-User-Id": "user-123", "X-Trace-Id": "trace-abc"}
5626+
)
5627+
),
5628+
)
5629+
5630+
async for _ in lite_llm_instance.generate_content_async(llm_request):
5631+
pass
5632+
5633+
mock_acompletion.assert_called_once()
5634+
_, kwargs = mock_acompletion.call_args
5635+
assert "extra_headers" in kwargs
5636+
assert kwargs["extra_headers"]["X-User-Id"] == "user-123"
5637+
assert kwargs["extra_headers"]["X-Trace-Id"] == "trace-abc"
5638+
5639+
5640+
@pytest.mark.asyncio
5641+
async def test_generate_content_async_merges_http_options_with_existing_extra_headers(
5642+
mock_response,
5643+
):
5644+
"""Test that http_options.headers merge with pre-existing extra_headers."""
5645+
mock_acompletion = AsyncMock(return_value=mock_response)
5646+
mock_client = MockLLMClient(mock_acompletion, Mock())
5647+
# Create instance with pre-existing extra_headers via kwargs
5648+
lite_llm_with_extra = LiteLlm(
5649+
model="test_model",
5650+
llm_client=mock_client,
5651+
extra_headers={"X-Api-Key": "secret-key"},
5652+
)
5653+
5654+
llm_request = LlmRequest(
5655+
contents=[
5656+
types.Content(
5657+
role="user", parts=[types.Part.from_text(text="Test prompt")]
5658+
)
5659+
],
5660+
config=types.GenerateContentConfig(
5661+
http_options=types.HttpOptions(headers={"X-User-Id": "user-456"})
5662+
),
5663+
)
5664+
5665+
async for _ in lite_llm_with_extra.generate_content_async(llm_request):
5666+
pass
5667+
5668+
mock_acompletion.assert_called_once()
5669+
_, kwargs = mock_acompletion.call_args
5670+
assert "extra_headers" in kwargs
5671+
# Both existing and new headers should be present
5672+
assert kwargs["extra_headers"]["X-Api-Key"] == "secret-key"
5673+
assert kwargs["extra_headers"]["X-User-Id"] == "user-456"
5674+
5675+
5676+
@pytest.mark.asyncio
5677+
async def test_generate_content_async_http_options_headers_override_existing(
5678+
mock_response,
5679+
):
5680+
"""Test that http_options.headers override same-key extra_headers from init."""
5681+
mock_acompletion = AsyncMock(return_value=mock_response)
5682+
mock_client = MockLLMClient(mock_acompletion, Mock())
5683+
lite_llm_with_extra = LiteLlm(
5684+
model="test_model",
5685+
llm_client=mock_client,
5686+
extra_headers={"X-Override-Me": "old-value"},
5687+
)
5688+
5689+
llm_request = LlmRequest(
5690+
contents=[
5691+
types.Content(
5692+
role="user", parts=[types.Part.from_text(text="Test prompt")]
5693+
)
5694+
],
5695+
config=types.GenerateContentConfig(
5696+
http_options=types.HttpOptions(headers={"X-Override-Me": "new-value"})
5697+
),
5698+
)
5699+
5700+
async for _ in lite_llm_with_extra.generate_content_async(llm_request):
5701+
pass
5702+
5703+
mock_acompletion.assert_called_once()
5704+
_, kwargs = mock_acompletion.call_args
5705+
# Request-level headers should override init-level headers
5706+
assert kwargs["extra_headers"]["X-Override-Me"] == "new-value"
5707+
5708+
5709+
@pytest.mark.asyncio
5710+
async def test_generate_content_async_passes_http_options_timeout(
5711+
mock_acompletion, lite_llm_instance
5712+
):
5713+
"""Test that http_options.timeout is forwarded to litellm."""
5714+
5715+
llm_request = LlmRequest(
5716+
contents=[
5717+
types.Content(
5718+
role="user", parts=[types.Part.from_text(text="Test prompt")]
5719+
)
5720+
],
5721+
config=types.GenerateContentConfig(
5722+
http_options=types.HttpOptions(timeout=30000)
5723+
),
5724+
)
5725+
5726+
async for _ in lite_llm_instance.generate_content_async(llm_request):
5727+
pass
5728+
5729+
mock_acompletion.assert_called_once()
5730+
_, kwargs = mock_acompletion.call_args
5731+
assert "timeout" in kwargs
5732+
assert kwargs["timeout"] == 30000
5733+
5734+
5735+
@pytest.mark.asyncio
5736+
async def test_generate_content_async_passes_http_options_retry_options(
5737+
mock_acompletion, lite_llm_instance
5738+
):
5739+
"""Test that http_options.retry_options is forwarded to litellm."""
5740+
5741+
llm_request = LlmRequest(
5742+
contents=[
5743+
types.Content(
5744+
role="user", parts=[types.Part.from_text(text="Test prompt")]
5745+
)
5746+
],
5747+
config=types.GenerateContentConfig(
5748+
http_options=types.HttpOptions(
5749+
retry_options=types.HttpRetryOptions(
5750+
attempts=3,
5751+
)
5752+
)
5753+
),
5754+
)
5755+
5756+
async for _ in lite_llm_instance.generate_content_async(llm_request):
5757+
pass
5758+
5759+
mock_acompletion.assert_called_once()
5760+
_, kwargs = mock_acompletion.call_args
5761+
assert "num_retries" in kwargs
5762+
assert kwargs["num_retries"] == 3
5763+
5764+
5765+
@pytest.mark.asyncio
5766+
async def test_generate_content_async_passes_http_options_extra_body(
5767+
mock_acompletion, lite_llm_instance
5768+
):
5769+
"""Test that http_options.extra_body is forwarded to litellm."""
5770+
5771+
llm_request = LlmRequest(
5772+
contents=[
5773+
types.Content(
5774+
role="user", parts=[types.Part.from_text(text="Test prompt")]
5775+
)
5776+
],
5777+
config=types.GenerateContentConfig(
5778+
http_options=types.HttpOptions(
5779+
extra_body={"custom_field": "custom_value", "priority": "high"}
5780+
)
5781+
),
5782+
)
5783+
5784+
async for _ in lite_llm_instance.generate_content_async(llm_request):
5785+
pass
5786+
5787+
mock_acompletion.assert_called_once()
5788+
_, kwargs = mock_acompletion.call_args
5789+
assert "extra_body" in kwargs
5790+
assert kwargs["extra_body"]["custom_field"] == "custom_value"
5791+
assert kwargs["extra_body"]["priority"] == "high"

0 commit comments

Comments
 (0)