mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-30 01:52:18 +00:00
fix(a2a): strip additionalInterfaces and let configured metadata win over A2A request
- merge_agent_card no longer carries upstream additionalInterfaces through; storing those alternate URLs would let authenticated agent callers reach the backend directly and bypass proxy auth/budget/logging. - apply_forward_metadata_to_completion_params now layers client-supplied A2A metadata UNDER any agent-owner-configured extra_body.metadata, so server-set run metadata stays authoritative on key conflicts.
This commit is contained in:
parent
a0944e1638
commit
06384db07b
5 changed files with 64 additions and 14 deletions
|
|
@ -107,9 +107,9 @@ class A2ACompletionBridgeHandler:
|
|||
if k not in ("model", "custom_llm_provider") and k not in _AGENT_ONLY_PARAMS
|
||||
}
|
||||
completion_params.update(litellm_params_to_add)
|
||||
# Apply forward metadata AFTER the litellm_params merge so an
|
||||
# agent-configured ``extra_body`` does not overwrite the forwarded
|
||||
# A2A metadata; the helper merges into any existing ``extra_body``.
|
||||
# Apply forward metadata AFTER the litellm_params merge so the helper
|
||||
# sees any agent-owner-configured ``extra_body.metadata`` and can keep
|
||||
# those keys authoritative over the client-supplied A2A metadata.
|
||||
A2ACompletionBridgeTransformation.apply_forward_metadata_to_completion_params(
|
||||
completion_params=completion_params,
|
||||
a2a_message=message,
|
||||
|
|
@ -222,9 +222,9 @@ class A2ACompletionBridgeHandler:
|
|||
if k not in ("model", "custom_llm_provider") and k not in _AGENT_ONLY_PARAMS
|
||||
}
|
||||
completion_params.update(litellm_params_to_add)
|
||||
# Apply forward metadata AFTER the litellm_params merge so an
|
||||
# agent-configured ``extra_body`` does not overwrite the forwarded
|
||||
# A2A metadata; the helper merges into any existing ``extra_body``.
|
||||
# Apply forward metadata AFTER the litellm_params merge so the helper
|
||||
# sees any agent-owner-configured ``extra_body.metadata`` and can keep
|
||||
# those keys authoritative over the client-supplied A2A metadata.
|
||||
A2ACompletionBridgeTransformation.apply_forward_metadata_to_completion_params(
|
||||
completion_params=completion_params,
|
||||
a2a_message=message,
|
||||
|
|
|
|||
|
|
@ -100,14 +100,14 @@ class A2ACompletionBridgeTransformation:
|
|||
extra_body = completion_params.get("extra_body")
|
||||
if not isinstance(extra_body, dict):
|
||||
extra_body = {}
|
||||
# Merge into any existing ``extra_body.metadata`` so an
|
||||
# agent-configured ``extra_body: {metadata: {...}}`` is preserved;
|
||||
# forwarded A2A metadata takes precedence on key conflicts.
|
||||
# Layer client-supplied A2A metadata under any agent-owner-configured
|
||||
# ``extra_body.metadata`` so the configured keys remain authoritative
|
||||
# and an A2A caller cannot overwrite server-set run metadata.
|
||||
existing_metadata = extra_body.get("metadata")
|
||||
merged_metadata: Dict[str, Any] = (
|
||||
{**existing_metadata} if isinstance(existing_metadata, dict) else {}
|
||||
existing_dict: Dict[str, Any] = (
|
||||
existing_metadata if isinstance(existing_metadata, dict) else {}
|
||||
)
|
||||
merged_metadata.update(forward_metadata)
|
||||
merged_metadata: Dict[str, Any] = {**forward_metadata, **existing_dict}
|
||||
extra_body = {**extra_body, "metadata": merged_metadata}
|
||||
completion_params["extra_body"] = extra_body
|
||||
|
||||
|
|
|
|||
|
|
@ -43,6 +43,12 @@ _ALLOWED_CAPABILITY_KEYS = {"streaming"}
|
|||
# card as a defense against upstream drift. ``supportedInterfaces`` is kept
|
||||
# verbatim per product spec even though it is not in the v1.0 schema — clients
|
||||
# that expect it will find it; clients that don't will ignore it.
|
||||
#
|
||||
# ``additionalInterfaces`` is deliberately excluded: it advertises alternate
|
||||
# upstream URLs (HTTP/JSONRPC/gRPC backends) that, if persisted and served,
|
||||
# would let authenticated agent callers reach the backend directly and bypass
|
||||
# the proxy's auth/budget/logging. The proxy publishes its own entrypoint via
|
||||
# ``supportedInterfaces`` instead.
|
||||
_ALLOWED_TOP_LEVEL_KEYS = {
|
||||
"protocolVersion",
|
||||
"name",
|
||||
|
|
@ -53,7 +59,6 @@ _ALLOWED_TOP_LEVEL_KEYS = {
|
|||
"defaultOutputModes",
|
||||
"skills",
|
||||
"preferredTransport",
|
||||
"additionalInterfaces",
|
||||
"supportedInterfaces",
|
||||
"iconUrl",
|
||||
"provider",
|
||||
|
|
|
|||
|
|
@ -32,7 +32,10 @@ class TestA2AStreamingTransformation:
|
|||
# Metadata is forwarded on the run payload only, not duplicated on messages.
|
||||
assert "metadata" not in openai_messages[0]
|
||||
|
||||
completion_params: dict = {"model": "langgraph/agent", "messages": openai_messages}
|
||||
completion_params: dict = {
|
||||
"model": "langgraph/agent",
|
||||
"messages": openai_messages,
|
||||
}
|
||||
A2ACompletionBridgeTransformation.apply_forward_metadata_to_completion_params(
|
||||
completion_params=completion_params,
|
||||
a2a_message=message,
|
||||
|
|
@ -43,6 +46,38 @@ class TestA2AStreamingTransformation:
|
|||
"skillId": "draft_reply",
|
||||
}
|
||||
|
||||
def test_configured_metadata_wins_over_forwarded_a2a_metadata(self):
|
||||
from litellm.a2a_protocol.litellm_completion_bridge.transformation import (
|
||||
A2ACompletionBridgeTransformation,
|
||||
)
|
||||
|
||||
# Agent-owner-configured run metadata in ``extra_body``.
|
||||
completion_params: dict = {
|
||||
"model": "langgraph/agent",
|
||||
"messages": [],
|
||||
"extra_body": {
|
||||
"metadata": {"owner_tag": "prod", "trace": "server-set"},
|
||||
"other": "keep",
|
||||
},
|
||||
}
|
||||
# Client tries to overwrite ``trace`` and inject a new key.
|
||||
message = {
|
||||
"role": "user",
|
||||
"parts": [{"text": "hi"}],
|
||||
"metadata": {"trace": "client-spoof", "skillId": "draft_reply"},
|
||||
}
|
||||
A2ACompletionBridgeTransformation.apply_forward_metadata_to_completion_params(
|
||||
completion_params=completion_params,
|
||||
a2a_message=message,
|
||||
params={"metadata": {"trace": "client-spoof-2"}},
|
||||
)
|
||||
assert completion_params["extra_body"]["other"] == "keep"
|
||||
assert completion_params["extra_body"]["metadata"] == {
|
||||
"owner_tag": "prod",
|
||||
"trace": "server-set",
|
||||
"skillId": "draft_reply",
|
||||
}
|
||||
|
||||
def test_langgraph_transform_preserves_message_metadata(self):
|
||||
from litellm.llms.langgraph.chat.transformation import LangGraphConfig
|
||||
|
||||
|
|
|
|||
|
|
@ -164,3 +164,13 @@ def test_does_not_mutate_input():
|
|||
snapshot = dict(upstream)
|
||||
merge_agent_card(upstream, proxy_url=PROXY_URL, proxy_base_url=PROXY_BASE)
|
||||
assert upstream == snapshot
|
||||
|
||||
|
||||
def test_strips_additional_interfaces_to_prevent_backend_url_leak():
|
||||
upstream = _full_upstream_card()
|
||||
upstream["additionalInterfaces"] = [
|
||||
{"url": "http://internal-backend:8080/", "transport": "JSONRPC"},
|
||||
{"url": "grpc://internal-backend:50051", "transport": "GRPC"},
|
||||
]
|
||||
merged = merge_agent_card(upstream, proxy_url=PROXY_URL, proxy_base_url=PROXY_BASE)
|
||||
assert "additionalInterfaces" not in merged
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue