Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 48 additions & 44 deletions litellm/a2a_protocol/card_resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@

from collections.abc import Mapping
from types import MappingProxyType
from typing import TYPE_CHECKING, Any, Final
from typing import TYPE_CHECKING, Any, Final, Protocol, runtime_checkable

from litellm._logging import verbose_logger
from litellm.a2a_protocol.exceptions import A2AAgentCardDiscoveryError
from litellm.constants import LOCALHOST_URL_PATTERNS

if TYPE_CHECKING:
Expand All @@ -18,6 +19,8 @@
_A2ACardResolver: Any = None
AGENT_CARD_WELL_KNOWN_PATH: str = "/.well-known/agent-card.json"
PREV_AGENT_CARD_WELL_KNOWN_PATH: str = "/.well-known/agent.json"
FOUNDRY_AGENT_CARD_PATH: Final = "/agentCard/v1.0"
Comment thread
greptile-apps[bot] marked this conversation as resolved.
AGENT_CARD_PATH_PARAM: Final = "agent_card_path"

try:
from a2a.client import A2ACardResolver as _A2ACardResolver
Expand All @@ -29,6 +32,20 @@
pass


@runtime_checkable
class _HasStatusCode(Protocol):
status_code: int | None


def _discovery_status_code(failures: tuple[tuple[str, Exception], ...]) -> int:
statuses: Final = tuple(
error.status_code
for _, error in failures
if isinstance(error, _HasStatusCode) and error.status_code is not None and error.status_code != 404
)
return statuses[0] if statuses else 404


def is_localhost_or_internal_url(url: str | None) -> bool:
"""
Check if a URL is a localhost or internal URL.
Expand Down Expand Up @@ -145,61 +162,48 @@ class LiteLLMA2ACardResolver(_A2ACardResolver):
"""
Custom A2A card resolver that supports multiple well-known paths.

Extends the base A2ACardResolver to try both:
Extends the base A2ACardResolver to try, in order:
- /.well-known/agent-card.json (standard)
- /.well-known/agent.json (previous/alternative)
- /agentCard/v1.0
"""

async def get_agent_card(
self,
relative_card_path: str | None = None,
http_kwargs: Mapping[str, object] | None = None,
) -> "AgentCard":
"""
Fetch the agent card, trying multiple well-known paths.

First tries the standard path, then falls back to the previous path.

Args:
relative_card_path: Optional path to the agent card endpoint.
If None, tries both well-known paths.
http_kwargs: Optional dictionary of keyword arguments to pass to httpx.get

Returns:
AgentCard from the A2A agent

Raises:
A2AClientHTTPError or A2AClientJSONError if both paths fail
"""
# If a specific path is provided, use the parent implementation
"""Fetch the agent card, probing every known path when none is given."""
if relative_card_path is not None:
return await super().get_agent_card(
relative_card_path=relative_card_path,
http_kwargs=http_kwargs,
)

# Try both well-known paths
paths: Final = [
AGENT_CARD_WELL_KNOWN_PATH,
PREV_AGENT_CARD_WELL_KNOWN_PATH,
]

last_error = None
for path in paths:
try:
verbose_logger.debug("Attempting to fetch agent card from %s%s", self.base_url, path)
return await super().get_agent_card(
relative_card_path=path,
http_kwargs=http_kwargs,
)
except Exception as e:
verbose_logger.debug("Failed to fetch agent card from %s%s: %s", self.base_url, path, e)
last_error = e
continue

# If we get here, all paths failed - re-raise the last error
if last_error is not None:
raise last_error

# This shouldn't happen, but just in case
raise Exception(f"Failed to fetch agent card from {self.base_url}. Tried paths: {', '.join(paths)}")
return await self._get_agent_card_from_first_reachable_path(
paths=(AGENT_CARD_WELL_KNOWN_PATH, PREV_AGENT_CARD_WELL_KNOWN_PATH, FOUNDRY_AGENT_CARD_PATH),
http_kwargs=http_kwargs,
failures=(),
)

async def _get_agent_card_from_first_reachable_path(
self,
paths: tuple[str, ...],
http_kwargs: Mapping[str, object] | None,
failures: tuple[tuple[str, Exception], ...],
) -> "AgentCard":
if not paths:
raise A2AAgentCardDiscoveryError(
base_url=self.base_url,
failures=failures,
status_code=_discovery_status_code(failures),
)
path: Final = paths[0]
try:
verbose_logger.debug("Attempting to fetch agent card from %s%s", self.base_url, path)
return await super().get_agent_card(relative_card_path=path, http_kwargs=http_kwargs)
except Exception as e:
verbose_logger.debug("Failed to fetch agent card from %s%s: %s", self.base_url, path, e)
return await self._get_agent_card_from_first_reachable_path(
paths=paths[1:], http_kwargs=http_kwargs, failures=(*failures, (path, e))
)
16 changes: 15 additions & 1 deletion litellm/a2a_protocol/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
Custom exception types for A2A protocol operations, following LiteLLM's exception pattern.
"""

from typing import Final

import httpx


Expand Down Expand Up @@ -100,18 +102,30 @@ def __init__(
model: str | None = None,
response: httpx.Response | None = None,
litellm_debug_info: str | None = None,
status_code: int = 404,
):
self.url = url
super().__init__(
message=message,
status_code=404,
status_code=status_code,
llm_provider="a2a_agent",
model=model,
response=response,
litellm_debug_info=litellm_debug_info,
)


class A2AAgentCardDiscoveryError(A2AAgentCardError):
def __init__(self, base_url: str, failures: tuple[tuple[str, Exception], ...], status_code: int) -> None:
self.failures = failures
attempts: Final = ", ".join(f"{path} ({error})" for path, error in failures)
super().__init__(
message=f"Failed to fetch agent card from {base_url}. Tried {attempts}",
url=base_url,
status_code=status_code,
)


class A2ALocalhostURLError(A2AConnectionError):
"""
Raised when an agent card contains a localhost/internal URL.
Expand Down
2 changes: 2 additions & 0 deletions litellm/a2a_protocol/litellm_completion_bridge/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

import litellm
from litellm._logging import verbose_logger
from litellm.a2a_protocol.card_resolver import AGENT_CARD_PATH_PARAM
from litellm.a2a_protocol.litellm_completion_bridge.transformation import (
A2ACompletionBridgeTransformation,
A2AStreamingContext,
Expand All @@ -36,6 +37,7 @@
"agent_name",
"agent_id",
"agent_card_params",
AGENT_CARD_PATH_PARAM,
A2A_USER_API_KEY_HASH_PARAM,
}
)
Expand Down
58 changes: 46 additions & 12 deletions litellm/a2a_protocol/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
import datetime
import uuid
from collections.abc import AsyncIterator, Coroutine, Mapping
from types import ModuleType
from types import MappingProxyType, ModuleType
from typing import TYPE_CHECKING, Any, Final, Optional, cast

import litellm
Expand Down Expand Up @@ -72,6 +72,7 @@

# Import our custom card resolver that supports multiple well-known paths
from litellm.a2a_protocol.card_resolver import (
AGENT_CARD_PATH_PARAM,
LiteLLMA2ACardResolver,
get_agent_card_url,
normalize_agent_card_interfaces,
Expand Down Expand Up @@ -132,6 +133,26 @@ def _set_agent_id_on_logging_obj(
_A2A_COST_PARAM_KEYS: Final = ("cost_per_query", "input_cost_per_token", "output_cost_per_token")


def _a2a_cost_params(litellm_params: Mapping[str, object] | None) -> Mapping[str, object]:
"""Only the agent's pricing keys reach the logging object; its credentials never do."""
return MappingProxyType(
{
key: litellm_params[key]
for key in _A2A_COST_PARAM_KEYS
if litellm_params is not None and litellm_params.get(key) is not None
}
)


def _card_http_kwargs(extra_headers: dict[str, str] | None) -> dict[str, object] | None:
return {"headers": extra_headers} if extra_headers else None # mutable-ok: a2a-sdk's get_agent_card takes a dict


def _agent_card_path(litellm_params: Mapping[str, object]) -> str | None:
configured_path: Final = litellm_params.get(AGENT_CARD_PATH_PARAM)
return configured_path if isinstance(configured_path, str) and configured_path else None


def _set_litellm_params_on_logging_obj(
kwargs: Mapping[str, object],
litellm_params: Mapping[str, object],
Expand All @@ -148,9 +169,7 @@ def _set_litellm_params_on_logging_obj(
if not isinstance(logging_obj, Logging):
return

cost_params: Final = {
key: litellm_params[key] for key in _A2A_COST_PARAM_KEYS if litellm_params.get(key) is not None
}
cost_params: Final = _a2a_cost_params(litellm_params)
if not cost_params:
return

Expand Down Expand Up @@ -475,7 +494,11 @@ async def asend_message(
# Overlay agent-level headers (agent headers take precedence over LiteLLM internal ones)
if agent_extra_headers:
extra_headers.update(agent_extra_headers)
a2a_client = await create_a2a_client(base_url=api_base, extra_headers=extra_headers)
a2a_client = await create_a2a_client(
base_url=api_base,
extra_headers=extra_headers,
relative_card_path=_agent_card_path(litellm_params),
)

# Type assertion: a2a_client is guaranteed to be non-None here
assert a2a_client is not None
Expand Down Expand Up @@ -588,11 +611,10 @@ def _build_streaming_logging_obj(
if agent_id:
logging_obj.model_call_details["agent_id"] = agent_id

_litellm_params: Final = litellm_params.copy() if litellm_params else {}
if metadata:
_litellm_params["metadata"] = metadata
if proxy_server_request:
_litellm_params["proxy_server_request"] = proxy_server_request
_request_context: Final = (("metadata", metadata), ("proxy_server_request", proxy_server_request))
_litellm_params: Final = dict( # mutable-ok: Logging.litellm_params is declared as a dict
(*_a2a_cost_params(litellm_params).items(), *((key, value) for key, value in _request_context if value))
)

logging_obj.litellm_params = _litellm_params
logging_obj.optional_params = _litellm_params
Expand Down Expand Up @@ -700,6 +722,7 @@ async def asend_message_streaming(
base_url=api_base,
extra_headers=extra_headers,
streaming=True,
relative_card_path=_agent_card_path(litellm_params),
)

assert a2a_client is not None
Expand Down Expand Up @@ -746,6 +769,7 @@ async def create_a2a_client(
timeout: float = DEFAULT_A2A_AGENT_TIMEOUT,
extra_headers: dict[str, str] | None = None,
streaming: bool = False,
relative_card_path: str | None = None,
) -> "A2AClientType":
"""
Create an A2A client for the given agent URL.
Expand All @@ -757,6 +781,8 @@ async def create_a2a_client(
base_url: The base URL of the A2A agent (e.g., "http://localhost:10001")
timeout: Request timeout in seconds (default: ``DEFAULT_A2A_AGENT_TIMEOUT`` / env ``DEFAULT_A2A_AGENT_TIMEOUT``)
extra_headers: Optional additional headers to include in requests
relative_card_path: Optional card path relative to ``base_url`` (e.g. ``agentCard/v1.0`` for a
Microsoft Foundry agent); when None the well-known paths are probed in order

Returns:
An initialized a2a.client.A2AClient instance
Expand Down Expand Up @@ -790,7 +816,10 @@ async def create_a2a_client(

resolver: Final = A2ACardResolver(httpx_client=httpx_client, base_url=base_url)
agent_card: Final = normalize_agent_card_interfaces(
await resolver.get_agent_card(http_kwargs={"headers": extra_headers} if extra_headers else None)
await resolver.get_agent_card(
relative_card_path=relative_card_path,
http_kwargs=_card_http_kwargs(extra_headers),
)
)

a2a_client: Final = await create_client( # pyright: ignore[reportOptionalCall]
Expand Down Expand Up @@ -820,6 +849,7 @@ async def aget_agent_card(
base_url: str,
timeout: float = DEFAULT_A2A_AGENT_TIMEOUT,
extra_headers: dict[str, str] | None = None,
relative_card_path: str | None = None,
) -> "AgentCard":
"""
Fetch the agent card from an A2A agent.
Expand All @@ -828,6 +858,7 @@ async def aget_agent_card(
base_url: The base URL of the A2A agent (e.g., "http://localhost:10001")
timeout: Request timeout in seconds (default: ``DEFAULT_A2A_AGENT_TIMEOUT`` / env ``DEFAULT_A2A_AGENT_TIMEOUT``)
extra_headers: Optional additional headers to include in requests
relative_card_path: Optional card path relative to ``base_url``; when None the well-known paths are probed

Returns:
AgentCard from the A2A agent
Expand All @@ -850,7 +881,10 @@ async def aget_agent_card(
httpx_client=httpx_client,
base_url=base_url,
)
agent_card: Final = await resolver.get_agent_card()
agent_card: Final = await resolver.get_agent_card(
relative_card_path=relative_card_path,
http_kwargs=_card_http_kwargs(extra_headers),
)

verbose_logger.info("Fetched agent card: %s", agent_card.name if hasattr(agent_card, "name") else "unknown")
return agent_card
6 changes: 5 additions & 1 deletion litellm/llms/a2a/chat/streaming_iterator.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from litellm.llms.base_llm.base_model_iterator import BaseModelResponseIterator
from litellm.types.utils import GenericStreamingChunk, ModelResponseStream

from ..common_utils import extract_text_from_a2a_response
from ..common_utils import A2AError, extract_text_from_a2a_response


class A2AModelResponseIterator(BaseModelResponseIterator):
Expand Down Expand Up @@ -56,6 +56,10 @@ def chunk_parser(self, chunk: dict) -> GenericStreamingChunk | ModelResponseStre
}
}
"""
error: Final = chunk.get("error")
if isinstance(error, dict):
raise A2AError(status_code=500, message=f"A2A error: {error.get('message', 'Unknown error')}")

try:
# Extract text from A2A response
text: Final = extract_text_from_a2a_response(chunk)
Expand Down
Loading
Loading