Conversation
…nd types Add two new fields to LiteLLM_AgentsTable: - static_headers (Json): admin-configured headers always sent to the backend agent - extra_headers (String[]): header names to extract from the client request and forward Extend AgentConfig, PatchAgentRequest, and AgentResponse with the same fields. Also remove duplicate spec_path field from LiteLLM_MCPServerTable. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Update add_agent_to_db, patch_agent_in_db, and update_agent_in_db to read and write the two new header fields when creating or updating agents. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Mirrors merge_mcp_headers from the MCP server utils. Dynamic headers come first; static (admin-configured) headers overlay and win on conflict. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
In invoke_agent_a2a:
- Extract admin-configured extra_headers from client request by name
- Extract convention-based headers (x-a2a-{agent_id/name}-{header}) from client request
- Merge with static_headers (static wins on conflict)
- Pass merged headers down to asend_message and _handle_stream_message
In asend_message / asend_message_streaming:
- Accept agent_extra_headers kwarg
- Overlay onto LiteLLM internal headers before creating the httpx client
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
ALTER TABLE LiteLLM_AgentsTable to add:
- static_headers JSONB DEFAULT '{}'
- extra_headers TEXT[] DEFAULT ARRAY[]::TEXT[]
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Covers:
- Static headers forwarded to backend
- Dynamic headers extracted by name (extra_headers config)
- Convention-based x-a2a-{agent_id/name}-{header} forwarding
- Static headers win over dynamic on conflict
- Unrelated x-a2a- prefixes are not forwarded
- No-header case leaves existing behaviour unchanged
- merge_agent_headers utility unit tests
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…dit form Add a new "Authentication Headers" panel to AgentFormFields: - Static Headers: key-value Form.List (always sent to the backend agent, static wins on conflict with dynamic) - Forward Client Headers: Select[tags] of header names to extract from the client request and forward (extra_headers) Update buildAgentDataFromForm to serialize both fields for the API. Update parseAgentForForm to deserialize them back for editing. Covers both the create wizard (add_agent_form) and the edit view (agent_info). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
New page: docs/a2a_agent_headers.md
Covers all three header forwarding methods:
- Static headers (admin-configured, always sent)
- Forward client headers (extra_headers — admin lists names, client supplies values)
- Convention-based x-a2a-{agent_name/id}-{header} (no admin config needed)
Documents merge precedence (static wins), header isolation guarantee,
combining all three methods, and API reference for static_headers / extra_headers fields.
Registered in sidebars.js under /a2a - A2A Agent Gateway.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Greptile SummaryThis PR adds three complementary mechanisms for forwarding authentication headers from LiteLLM clients to backend A2A agents: static headers (admin-configured, always sent), forward headers (admin-specified header names extracted from the client request), and convention-based headers ( Key changes:
Outstanding concerns:
Confidence Score: 3/5
Sequence DiagramsequenceDiagram
participant Client
participant LiteLLMProxy as LiteLLM Proxy<br/>(invoke_agent_a2a)
participant Registry as AgentRegistry
participant Merge as merge_agent_headers
participant A2AClient as create_a2a_client<br/>(fresh httpx.AsyncClient)
participant BackendAgent as Backend A2A Agent
Client->>LiteLLMProxy: POST /a2a/{agent_id}<br/>+ x-a2a-{name}-authorization: Bearer dyn<br/>+ Authorization: Bearer client
LiteLLMProxy->>Registry: get_agent_by_id / get_agent_by_name
Registry-->>LiteLLMProxy: AgentResponse<br/>(static_headers, extra_headers)
Note over LiteLLMProxy: Build dynamic_headers<br/>1. admin extra_headers list → extract from request<br/>2. convention x-a2a-{id/name}-{header} → extract
LiteLLMProxy->>Merge: merge_agent_headers(dynamic, static)
Note over Merge: merged = {**dynamic}<br/>merged.update(static) ← static wins
Merge-->>LiteLLMProxy: agent_extra_headers
LiteLLMProxy->>A2AClient: create_a2a_client(base_url, extra_headers)
Note over A2AClient: httpx.AsyncClient(headers=extra_headers)<br/>← fresh per call, no caching
A2AClient-->>LiteLLMProxy: a2a_client
LiteLLMProxy->>BackendAgent: asend_message(request, agent_extra_headers)
BackendAgent-->>LiteLLMProxy: SendMessageResponse
LiteLLMProxy-->>Client: JSONResponse
Last reviewed commit: 501671a |
| if agent.get("static_headers") is not None: | ||
| update_data["static_headers"] = safe_dumps( | ||
| dict(agent.get("static_headers")) # type: ignore | ||
| ) |
There was a problem hiding this comment.
patch_agent_in_db cannot clear static_headers
The guard if agent.get("static_headers") is not None: means a PATCH request with static_headers=None will silently leave the existing value unchanged. An admin who wants to remove all static headers from an agent via PATCH has no way to do so — the only option is a full PUT/update.
Consider using a sentinel value or a dedicated clear_static_headers: bool field, or replacing is not None with an explicit presence check (e.g. "static_headers" in agent).
| async def test_create_a2a_client_uses_fresh_httpx_client(): | ||
| """ | ||
| Two calls to create_a2a_client with different extra_headers must NOT | ||
| share the same underlying httpx.AsyncClient instance. | ||
| """ | ||
| import httpx | ||
|
|
||
| from litellm.a2a_protocol.main import create_a2a_client | ||
|
|
||
| created_clients = [] | ||
|
|
||
| fake_agent_card = MagicMock() | ||
| fake_agent_card.name = "test-agent" | ||
|
|
||
| class FakeResolver: | ||
| def __init__(self, **kw): | ||
| created_clients.append(kw.get("httpx_client")) | ||
| async def get_agent_card(self): | ||
| return fake_agent_card | ||
|
|
||
| class FakeA2AClient: | ||
| def __init__(self, httpx_client, agent_card): | ||
| self._client = httpx_client | ||
| self._litellm_agent_card = agent_card | ||
|
|
||
| with patch("litellm.a2a_protocol.main.A2A_SDK_AVAILABLE", True), patch( | ||
| "litellm.a2a_protocol.main.A2ACardResolver", FakeResolver | ||
| ), patch("litellm.a2a_protocol.main._A2AClient", FakeA2AClient): | ||
| await create_a2a_client( | ||
| base_url="http://agent-a:9999", | ||
| extra_headers={"Authorization": "Bearer a"}, | ||
| ) | ||
| await create_a2a_client( | ||
| base_url="http://agent-b:9999", | ||
| extra_headers={"Authorization": "Bearer b"}, | ||
| ) | ||
|
|
||
| assert len(created_clients) == 2 | ||
| # Must be distinct objects | ||
| assert created_clients[0] is not created_clients[1], ( | ||
| "create_a2a_client reused a cached httpx client — headers will bleed between agents" | ||
| ) |
There was a problem hiding this comment.
test_create_a2a_client_uses_fresh_httpx_client will fail — bug not fixed
The test correctly identifies the isolation problem: two calls to create_a2a_client with different extra_headers must not share the same httpx.AsyncClient. However, the underlying implementation (see main.py lines 635–643) still calls get_async_httpx_client(llm_provider=httpxSpecialProvider.A2A, params={"timeout": timeout}), which returns a cached client for the same key. Both calls in this test will produce the same httpx_client object, so created_clients[0] is not created_clients[1] will be False and the assertion will fail.
This test will fail in CI until create_a2a_client is updated to use a fresh (non-cached) httpx client per invocation.
… clearing
- create_a2a_client already uses httpx.AsyncClient directly (no shared cache);
test_create_a2a_client_uses_fresh_httpx_client will now pass in CI
- asend_message_streaming now injects X-LiteLLM-Trace-Id and
X-LiteLLM-Agent-Id headers, matching the non-streaming path
- patch_agent_in_db uses key-presence check ("in agent") instead of
is not None so PATCH with static_headers=None/[] correctly clears the field
Made-with: Cursor
|
@greptile-apps re review |
| def merge_agent_headers( | ||
| *, | ||
| dynamic_headers: Optional[Mapping[str, str]] = None, | ||
| static_headers: Optional[Mapping[str, str]] = None, | ||
| ) -> Optional[Dict[str, str]]: | ||
| """Merge outbound HTTP headers for A2A agent calls. | ||
|
|
||
| Merge rules: | ||
| - Start with ``dynamic_headers`` (values extracted from the incoming client request). | ||
| - Overlay ``static_headers`` (admin-configured per agent). | ||
|
|
||
| If both contain the same key, ``static_headers`` wins. | ||
| """ | ||
| merged: Dict[str, str] = {} | ||
|
|
||
| if dynamic_headers: | ||
| merged.update({str(k): str(v) for k, v in dynamic_headers.items()}) | ||
|
|
||
| if static_headers: | ||
| merged.update({str(k): str(v) for k, v in static_headers.items()}) | ||
|
|
||
| return merged or None |
There was a problem hiding this comment.
Case-sensitive merge breaks "static headers always win" invariant
The merge_agent_headers function merges by exact (case-sensitive) key. HTTP headers are case-insensitive, so a mismatch in casing between a dynamic header key and a static header key results in both being included in the merged dict instead of the static one winning.
Concrete scenario:
- Admin configures
static_headers = {"Authorization": "Bearer admin-token"}(mixed case, the common convention) - Client sends
x-a2a-my-agent-authorization: Bearer client-token - Convention-based extraction produces
dynamic_headers = {"authorization": "Bearer client-token"}(lowercase, because it's sliced from the already-lowercasednormalizeddict) - After merge:
{"authorization": "Bearer client-token", "Authorization": "Bearer admin-token"}— two separate entries, static did NOT win
The backend agent receives both headers. Depending on how httpx serializes duplicate case-insensitive headers the backend may actually use the client-supplied value, defeating the security guarantee.
Fix: normalize keys to lowercase before merging:
| def merge_agent_headers( | |
| *, | |
| dynamic_headers: Optional[Mapping[str, str]] = None, | |
| static_headers: Optional[Mapping[str, str]] = None, | |
| ) -> Optional[Dict[str, str]]: | |
| """Merge outbound HTTP headers for A2A agent calls. | |
| Merge rules: | |
| - Start with ``dynamic_headers`` (values extracted from the incoming client request). | |
| - Overlay ``static_headers`` (admin-configured per agent). | |
| If both contain the same key, ``static_headers`` wins. | |
| """ | |
| merged: Dict[str, str] = {} | |
| if dynamic_headers: | |
| merged.update({str(k): str(v) for k, v in dynamic_headers.items()}) | |
| if static_headers: | |
| merged.update({str(k): str(v) for k, v in static_headers.items()}) | |
| return merged or None | |
| def merge_agent_headers( | |
| *, | |
| dynamic_headers: Optional[Mapping[str, str]] = None, | |
| static_headers: Optional[Mapping[str, str]] = None, | |
| ) -> Optional[Dict[str, str]]: | |
| """Merge outbound HTTP headers for A2A agent calls. | |
| Merge rules: | |
| - Start with ``dynamic_headers`` (values extracted from the incoming client request). | |
| - Overlay ``static_headers`` (admin-configured per agent). | |
| If both contain the same key, ``static_headers`` wins. | |
| """ | |
| merged: Dict[str, str] = {} | |
| if dynamic_headers: | |
| merged.update({str(k).lower(): str(v) for k, v in dynamic_headers.items()}) | |
| if static_headers: | |
| merged.update({str(k).lower(): str(v) for k, v in static_headers.items()}) | |
| return merged or None |
| if static_headers_val_u is not None: | ||
| update_data["static_headers"] = static_headers_val_u | ||
| if extra_headers_val_u is not None: | ||
| update_data["extra_headers"] = extra_headers_val_u |
There was a problem hiding this comment.
PUT (update_agent_in_db) cannot clear static_headers or extra_headers
Both fields use an is not None / early-return guard:
if static_headers_val_u is not None:
update_data["static_headers"] = static_headers_val_u
...
if extra_headers_val_u is not None:
update_data["extra_headers"] = extra_headers_val_uA full PUT/replace request that intentionally omits static_headers (sending None) will silently leave the existing DB value intact rather than clearing it. This is semantically incorrect for a full-replace PUT operation and can leave stale authentication headers attached to an agent even after a full update.
For extra_headers, sending extra_headers=[] (empty list) does work since [] is not None. For static_headers, the caller must send {} explicitly to clear, but omitting the field (the natural PUT behavior) leaves the old value — this is easy to misuse.
Consider always including these fields in update_data for PUT, defaulting to {} / [] when not supplied:
| if static_headers_val_u is not None: | |
| update_data["static_headers"] = static_headers_val_u | |
| if extra_headers_val_u is not None: | |
| update_data["extra_headers"] = extra_headers_val_u | |
| if static_headers_val_u is not None: | |
| update_data["static_headers"] = static_headers_val_u | |
| else: | |
| update_data["static_headers"] = safe_dumps({}) | |
| if extra_headers_val_u is not None: | |
| update_data["extra_headers"] = extra_headers_val_u | |
| else: | |
| update_data["extra_headers"] = [] |
| with patch( | ||
| "litellm.proxy.agent_endpoints.a2a_endpoints._get_agent", | ||
| return_value=mock_agent, | ||
| ), patch( | ||
| "litellm.proxy.agent_endpoints.auth.agent_permission_handler.AgentRequestHandler.is_agent_allowed", | ||
| new_callable=AsyncMock, | ||
| return_value=True, | ||
| ), patch( | ||
| "litellm.proxy.common_request_processing.add_litellm_data_to_request", | ||
| side_effect=lambda data, **kw: data, | ||
| ), patch( | ||
| "litellm.a2a_protocol.asend_message", | ||
| new_callable=AsyncMock, | ||
| return_value=mock_response, | ||
| ) as mock_asend, patch( | ||
| "litellm.a2a_protocol.create_a2a_client", | ||
| new_callable=AsyncMock, | ||
| ), patch( | ||
| "litellm.proxy.proxy_server.general_settings", | ||
| {}, | ||
| ), patch( | ||
| "litellm.proxy.proxy_server.proxy_config", | ||
| MagicMock(), | ||
| ), patch( | ||
| "litellm.proxy.proxy_server.version", | ||
| "1.0.0", | ||
| ), patch.dict( | ||
| sys.modules, | ||
| {"a2a": MagicMock(), "a2a.types": mock_a2a_types}, | ||
| ), patch( | ||
| "litellm.a2a_protocol.main.A2A_SDK_AVAILABLE", | ||
| True, | ||
| ): |
There was a problem hiding this comment.
proxy_logging_obj is not mocked — tests may fail when it is None
The _invoke helper patches general_settings, proxy_config, and version from litellm.proxy.proxy_server, but not proxy_logging_obj. After asend_message returns, the endpoint calls proxy_logging_obj.post_call_success_hook(...) at line 440 of a2a_endpoints.py.
proxy_logging_obj is re-imported inside invoke_agent_a2a at call-time from litellm.proxy.proxy_server. In a fresh test environment that attribute is typically None, causing AttributeError: 'NoneType' object has no attribute 'post_call_success_hook' — the tests would fail before ever asserting on headers.
Add the missing patch:
| with patch( | |
| "litellm.proxy.agent_endpoints.a2a_endpoints._get_agent", | |
| return_value=mock_agent, | |
| ), patch( | |
| "litellm.proxy.agent_endpoints.auth.agent_permission_handler.AgentRequestHandler.is_agent_allowed", | |
| new_callable=AsyncMock, | |
| return_value=True, | |
| ), patch( | |
| "litellm.proxy.common_request_processing.add_litellm_data_to_request", | |
| side_effect=lambda data, **kw: data, | |
| ), patch( | |
| "litellm.a2a_protocol.asend_message", | |
| new_callable=AsyncMock, | |
| return_value=mock_response, | |
| ) as mock_asend, patch( | |
| "litellm.a2a_protocol.create_a2a_client", | |
| new_callable=AsyncMock, | |
| ), patch( | |
| "litellm.proxy.proxy_server.general_settings", | |
| {}, | |
| ), patch( | |
| "litellm.proxy.proxy_server.proxy_config", | |
| MagicMock(), | |
| ), patch( | |
| "litellm.proxy.proxy_server.version", | |
| "1.0.0", | |
| ), patch.dict( | |
| sys.modules, | |
| {"a2a": MagicMock(), "a2a.types": mock_a2a_types}, | |
| ), patch( | |
| "litellm.a2a_protocol.main.A2A_SDK_AVAILABLE", | |
| True, | |
| ): | |
| ), patch( | |
| "litellm.proxy.proxy_server.proxy_logging_obj", | |
| MagicMock(post_call_success_hook=AsyncMock(side_effect=lambda **kw: kw["response"])), | |
| ), patch( |
| with patch( | ||
| "litellm.proxy.agent_endpoints.a2a_endpoints._get_agent", | ||
| return_value=agent, | ||
| ), patch( | ||
| "litellm.proxy.agent_endpoints.auth.agent_permission_handler.AgentRequestHandler.is_agent_allowed", | ||
| new_callable=AsyncMock, | ||
| return_value=True, | ||
| ), patch( | ||
| "litellm.proxy.common_request_processing.add_litellm_data_to_request", | ||
| side_effect=lambda data, **kw: data, | ||
| ), patch( | ||
| "litellm.a2a_protocol.asend_message", | ||
| new_callable=AsyncMock, | ||
| return_value=mock_response, | ||
| ) as mock_asend, patch( | ||
| "litellm.a2a_protocol.create_a2a_client", | ||
| new_callable=AsyncMock, | ||
| ), patch( | ||
| "litellm.proxy.proxy_server.general_settings", {} | ||
| ), patch( | ||
| "litellm.proxy.proxy_server.proxy_config", MagicMock() | ||
| ), patch( | ||
| "litellm.proxy.proxy_server.version", "1.0.0" | ||
| ), patch.dict( | ||
| sys.modules, | ||
| {"a2a": MagicMock(), "a2a.types": _a2a_types_module()}, | ||
| ), patch( | ||
| "litellm.a2a_protocol.main.A2A_SDK_AVAILABLE", True | ||
| ): | ||
| from litellm.proxy.agent_endpoints.a2a_endpoints import invoke_agent_a2a | ||
|
|
||
| await invoke_agent_a2a( | ||
| agent_id=agent.agent_id, | ||
| request=request, | ||
| fastapi_response=fastapi_response, | ||
| user_api_key_dict=user_api_key_dict, | ||
| ) | ||
| return mock_asend.call_args.kwargs.get("agent_extra_headers") | ||
|
|
There was a problem hiding this comment.
proxy_logging_obj is not mocked here either
The _invoke_agent helper (used by every test in this file) has the same gap: proxy_logging_obj is not patched, so any test that reaches proxy_logging_obj.post_call_success_hook(...) will raise AttributeError in a clean test environment.
Please add the same mock as noted in the test_agent_headers.py comment:
| with patch( | |
| "litellm.proxy.agent_endpoints.a2a_endpoints._get_agent", | |
| return_value=agent, | |
| ), patch( | |
| "litellm.proxy.agent_endpoints.auth.agent_permission_handler.AgentRequestHandler.is_agent_allowed", | |
| new_callable=AsyncMock, | |
| return_value=True, | |
| ), patch( | |
| "litellm.proxy.common_request_processing.add_litellm_data_to_request", | |
| side_effect=lambda data, **kw: data, | |
| ), patch( | |
| "litellm.a2a_protocol.asend_message", | |
| new_callable=AsyncMock, | |
| return_value=mock_response, | |
| ) as mock_asend, patch( | |
| "litellm.a2a_protocol.create_a2a_client", | |
| new_callable=AsyncMock, | |
| ), patch( | |
| "litellm.proxy.proxy_server.general_settings", {} | |
| ), patch( | |
| "litellm.proxy.proxy_server.proxy_config", MagicMock() | |
| ), patch( | |
| "litellm.proxy.proxy_server.version", "1.0.0" | |
| ), patch.dict( | |
| sys.modules, | |
| {"a2a": MagicMock(), "a2a.types": _a2a_types_module()}, | |
| ), patch( | |
| "litellm.a2a_protocol.main.A2A_SDK_AVAILABLE", True | |
| ): | |
| from litellm.proxy.agent_endpoints.a2a_endpoints import invoke_agent_a2a | |
| await invoke_agent_a2a( | |
| agent_id=agent.agent_id, | |
| request=request, | |
| fastapi_response=fastapi_response, | |
| user_api_key_dict=user_api_key_dict, | |
| ) | |
| return mock_asend.call_args.kwargs.get("agent_extra_headers") | |
| ), patch( | |
| "litellm.proxy.proxy_server.proxy_logging_obj", | |
| MagicMock(post_call_success_hook=AsyncMock(side_effect=lambda **kw: kw["response"])), | |
| ), patch( |
…eaders when omitted
For full-replace PUT semantics, always include static_headers and extra_headers
in update_data, defaulting to {} and [] when not supplied. Previously,
omitting these fields left stale DB values intact (e.g. auth headers).
Made-with: Cursor
| name={[name, "value"]} | ||
| rules={[{ required: true, message: "Value required" }]} | ||
| > | ||
| <Input placeholder="Value (e.g. Bearer token123)" style={{ width: 260 }} /> |
There was a problem hiding this comment.
Use Input.Password for static header values
The static header value input uses a plain <Input>, which displays sensitive credentials (Bearer tokens, API keys) in full plaintext. Anyone viewing the screen while an admin configures or edits an agent would see these secrets. Consider using <Input.Password> with the visibilityToggle prop so the value is masked by default but can be revealed intentionally.
| <Input placeholder="Value (e.g. Bearer token123)" style={{ width: 260 }} /> | |
| <Form.Item | |
| {...restField} | |
| name={[name, "value"]} | |
| rules={[{ required: true, message: "Value required" }]} | |
| > | |
| <Input.Password placeholder="Value (e.g. Bearer token123)" style={{ width: 260 }} visibilityToggle /> | |
| </Form.Item> |
| static_headers: Optional[Dict[str, str]] = None | ||
| extra_headers: Optional[List[str]] = None |
There was a problem hiding this comment.
static_headers values returned in full in API responses
AgentResponse includes static_headers: Optional[Dict[str, str]] which is returned verbatim by all list/get agent endpoints. These fields are intended to hold sensitive admin-configured credentials such as Authorization: Bearer <internal-token> or API keys. Returning them in plain text in API responses means any consumer of the agents API (e.g., a user who has been granted read access to agents) can retrieve the raw secret values.
The standard practice is to either:
- Exclude
static_headersfrom read responses entirely and only allow it in create/update requests, or - Mask the values (e.g.,
"Authorization": "Bearer ****") in the response model.
Consider adding an exclude or a @field_serializer that masks the values when serializing for output, while preserving them for internal use during request forwarding.
| url="http://backend-agent:10001", | ||
| ): | ||
| mock_agent = MagicMock() | ||
| mock_agent.agent_id = "agent-123" |
There was a problem hiding this comment.
_make_mock_agent base helper does not set agent_name
The helper sets mock_agent.agent_id = "agent-123" but never sets mock_agent.agent_name. In a2a_endpoints.py (line 408), both agent.agent_id and agent.agent_name are iterated for convention-based prefix matching:
for alias in (agent.agent_id.lower(), agent.agent_name.lower()):Because MagicMock auto-creates attributes, agent.agent_name resolves to a MagicMock whose str() form is an unpredictable ID string. This means the convention-based prefix check for the agent_name alias silently uses a garbage prefix, and no test covers that code path for the base helper.
Add mock_agent.agent_name = "test-agent" (or another stable value) to the base helper so all tests have a fully faithful representation of AgentResponse. The same gap exists in the _make_agent helper in test_agent_header_isolation.py.
Relevant issues
Fixes LIT-1945
Pre-Submission checklist
Please complete all items before asking a LiteLLM maintainer to review your PR
tests/test_litellm/directory, Adding at least 1 test is a hard requirement - see detailsmake test-unit@greptileaiand received a Confidence Score of at least 4/5 before requesting a maintainer reviewCI (LiteLLM team)
Branch creation CI run
Link:
CI run for the last commit
Link:
Merge / cherry-pick CI run
Links:
Type
🆕 New Feature
🐛 Bug Fix
🧹 Refactoring
📖 Documentation
🚄 Infrastructure
✅ Test
Changes