From 4a7437ba5f301330fb2b8b3853ea0dc01c22c88a Mon Sep 17 00:00:00 2001 From: Ishaan Jaff Date: Wed, 10 Dec 2025 19:13:50 -0800 Subject: [PATCH] [Feat] Agent Gateway - allow adding langgraph, bedrock agent core agents (#17802) * fix: langgraph bridge streaming * add public/agents/fields * test_a2a_completion_bridge_non_streaming * TestA2AStreamingTransformation * AgentCredentialFieldMetadata * add new logo * refactor add agent * fix add dynamic fields * feat allow adding langgraph agent * add langgraph provider * stash * add AgentCreateInfo * agent_create_fields * fix fields * test_a2a_completion_bridge_bedrock_agentcore * test_a2a_completion_bridge_bedrock_agentcore * add public endpoints * fix a2a endpoints * fix dynamic fields --- .../litellm_completion_bridge/handler.py | 82 ++++++--- .../transformation.py | 136 ++++++++++++++- litellm/a2a_protocol/main.py | 6 +- .../proxy/agent_endpoints/a2a_endpoints.py | 16 +- litellm/proxy/proxy_config.yaml | 4 + .../public_endpoints/agent_create_fields.json | 76 +++++++++ .../public_endpoints/public_endpoints.py | 54 +++++- .../public_endpoints/public_endpoints.py | 22 +++ .../agent_tests/test_a2a_completion_bridge.py | 105 +++++++++++- .../test_completion_bridge_streaming.py | 159 ++++++++++++++++++ .../public/assets/logos/langgraph.png | Bin 0 -> 5495 bytes .../src/components/agents/add_agent_form.tsx | 159 +++++++++++++++--- .../agents/dynamic_agent_form_fields.tsx | 125 ++++++++++++++ .../src/components/networking.tsx | 42 +++++ 14 files changed, 918 insertions(+), 68 deletions(-) create mode 100644 litellm/proxy/public_endpoints/agent_create_fields.json create mode 100644 tests/test_litellm/a2a_protocol/test_completion_bridge_streaming.py create mode 100644 ui/litellm-dashboard/public/assets/logos/langgraph.png create mode 100644 ui/litellm-dashboard/src/components/agents/dynamic_agent_form_fields.tsx diff --git a/litellm/a2a_protocol/litellm_completion_bridge/handler.py b/litellm/a2a_protocol/litellm_completion_bridge/handler.py index e46d3580e60..1f8892c91bf 100644 --- a/litellm/a2a_protocol/litellm_completion_bridge/handler.py +++ b/litellm/a2a_protocol/litellm_completion_bridge/handler.py @@ -2,6 +2,12 @@ Handler for A2A to LiteLLM completion bridge. Routes A2A requests through litellm.acompletion based on custom_llm_provider. + +A2A Streaming Events (in order): +1. Task event (kind: "task") - Initial task creation with status "submitted" +2. Status update (kind: "status-update") - Status change to "working" +3. Artifact update (kind: "artifact-update") - Content/artifact delivery +4. Status update (kind: "status-update") - Final status "completed" with final=true """ from typing import Any, AsyncIterator, Dict, Optional @@ -10,6 +16,7 @@ import litellm from litellm._logging import verbose_logger from litellm.a2a_protocol.litellm_completion_bridge.transformation import ( A2ACompletionBridgeTransformation, + A2AStreamingContext, ) @@ -50,7 +57,8 @@ class A2ACompletionBridgeHandler: model = litellm_params.get("model", "agent") # Build full model string if provider specified - if custom_llm_provider: + # Skip prepending if model already starts with the provider prefix + if custom_llm_provider and not model.startswith(f"{custom_llm_provider}/"): full_model = f"{custom_llm_provider}/{model}" else: full_model = model @@ -87,6 +95,12 @@ class A2ACompletionBridgeHandler: """ Handle streaming A2A request via litellm.acompletion with stream=True. + Emits proper A2A streaming events: + 1. Task event (kind: "task") - Initial task with status "submitted" + 2. Status update (kind: "status-update") - Status "working" + 3. Artifact update (kind: "artifact-update") - Content delivery + 4. Status update (kind: "status-update") - Final "completed" status + Args: request_id: A2A JSON-RPC request ID params: A2A MessageSendParams containing the message @@ -94,11 +108,17 @@ class A2ACompletionBridgeHandler: api_base: API base URL from agent_card_params Yields: - A2A streaming response chunks + A2A streaming response events """ # Extract message from params message = params.get("message", {}) + # Create streaming context + ctx = A2AStreamingContext( + request_id=request_id, + input_message=message, + ) + # Transform A2A message to OpenAI format openai_messages = A2ACompletionBridgeTransformation.a2a_message_to_openai_messages( message @@ -109,7 +129,8 @@ class A2ACompletionBridgeHandler: model = litellm_params.get("model", "agent") # Build full model string if provider specified - if custom_llm_provider: + # Skip prepending if model already starts with the provider prefix + if custom_llm_provider and not model.startswith(f"{custom_llm_provider}/"): full_model = f"{custom_llm_provider}/{model}" else: full_model = model @@ -118,6 +139,19 @@ class A2ACompletionBridgeHandler: f"A2A completion bridge streaming: model={full_model}, api_base={api_base}" ) + # 1. Emit initial task event (kind: "task", status: "submitted") + task_event = A2ACompletionBridgeTransformation.create_task_event(ctx) + yield task_event + + # 2. Emit status update (kind: "status-update", status: "working") + working_event = A2ACompletionBridgeTransformation.create_status_update_event( + ctx=ctx, + state="working", + final=False, + message_text="Processing request...", + ) + yield working_event + # Call litellm.acompletion with streaming response = await litellm.acompletion( model=full_model, @@ -126,27 +160,37 @@ class A2ACompletionBridgeHandler: stream=True, ) + # 3. Accumulate content and emit artifact update + accumulated_text = "" chunk_count = 0 async for chunk in response: # type: ignore[union-attr] chunk_count += 1 - a2a_chunk = A2ACompletionBridgeTransformation.openai_chunk_to_a2a_chunk( - chunk=chunk, - request_id=request_id, - is_final=False, - ) - if a2a_chunk: - yield a2a_chunk - # Send final chunk - final_chunk = A2ACompletionBridgeTransformation.openai_chunk_to_a2a_chunk( - chunk=None, - request_id=request_id, - is_final=True, + # Extract delta content + content = "" + if chunk is not None and hasattr(chunk, "choices") and chunk.choices: + choice = chunk.choices[0] + if hasattr(choice, "delta") and choice.delta: + content = choice.delta.content or "" + + if content: + accumulated_text += content + + # Emit artifact update with accumulated content + if accumulated_text: + artifact_event = A2ACompletionBridgeTransformation.create_artifact_update_event( + ctx=ctx, + text=accumulated_text, + ) + yield artifact_event + + # 4. Emit final status update (kind: "status-update", status: "completed", final: true) + completed_event = A2ACompletionBridgeTransformation.create_status_update_event( + ctx=ctx, + state="completed", + final=True, ) - if final_chunk: - # Clear content for final chunk - final_chunk["result"]["message"]["parts"][0]["text"] = "" - yield final_chunk + yield completed_event verbose_logger.info( f"A2A completion bridge streaming completed: request_id={request_id}, chunks={chunk_count}" diff --git a/litellm/a2a_protocol/litellm_completion_bridge/transformation.py b/litellm/a2a_protocol/litellm_completion_bridge/transformation.py index 0d37063d102..bbe7daa9fc4 100644 --- a/litellm/a2a_protocol/litellm_completion_bridge/transformation.py +++ b/litellm/a2a_protocol/litellm_completion_bridge/transformation.py @@ -10,14 +10,36 @@ A2A Message Format: OpenAI Message Format: {"role": "user", "content": "Hello!"} + +A2A Streaming Events: +- Task event (kind: "task") - Initial task creation with status "submitted" +- Status update (kind: "status-update") - Status changes (working, completed) +- Artifact update (kind: "artifact-update") - Content/artifact delivery """ +from datetime import datetime, timezone from typing import Any, Dict, List, Optional from uuid import uuid4 from litellm._logging import verbose_logger +class A2AStreamingContext: + """ + Context holder for A2A streaming state. + Tracks task_id, context_id, and message accumulation. + """ + + def __init__(self, request_id: str, input_message: Dict[str, Any]): + self.request_id = request_id + self.task_id = str(uuid4()) + self.context_id = str(uuid4()) + self.input_message = input_message + self.accumulated_text = "" + self.has_emitted_task = False + self.has_emitted_working = False + + class A2ACompletionBridgeTransformation: """ Static methods for transforming between A2A and OpenAI message formats. @@ -108,6 +130,114 @@ class A2ACompletionBridgeTransformation: return a2a_response + @staticmethod + def _get_timestamp() -> str: + """Get current timestamp in ISO format with timezone.""" + return datetime.now(timezone.utc).isoformat() + + @staticmethod + def create_task_event( + ctx: A2AStreamingContext, + ) -> Dict[str, Any]: + """ + Create the initial task event with status 'submitted'. + + This is the first event emitted in an A2A streaming response. + """ + return { + "id": ctx.request_id, + "jsonrpc": "2.0", + "result": { + "contextId": ctx.context_id, + "history": [ + { + "contextId": ctx.context_id, + "kind": "message", + "messageId": ctx.input_message.get("messageId", uuid4().hex), + "parts": ctx.input_message.get("parts", []), + "role": ctx.input_message.get("role", "user"), + "taskId": ctx.task_id, + } + ], + "id": ctx.task_id, + "kind": "task", + "status": { + "state": "submitted", + }, + }, + } + + @staticmethod + def create_status_update_event( + ctx: A2AStreamingContext, + state: str, + final: bool = False, + message_text: Optional[str] = None, + ) -> Dict[str, Any]: + """ + Create a status update event. + + Args: + ctx: Streaming context + state: Status state ('working', 'completed') + final: Whether this is the final event + message_text: Optional message text for 'working' status + """ + status: Dict[str, Any] = { + "state": state, + "timestamp": A2ACompletionBridgeTransformation._get_timestamp(), + } + + # Add message for 'working' status + if state == "working" and message_text: + status["message"] = { + "contextId": ctx.context_id, + "kind": "message", + "messageId": str(uuid4()), + "parts": [{"kind": "text", "text": message_text}], + "role": "agent", + "taskId": ctx.task_id, + } + + return { + "id": ctx.request_id, + "jsonrpc": "2.0", + "result": { + "contextId": ctx.context_id, + "final": final, + "kind": "status-update", + "status": status, + "taskId": ctx.task_id, + }, + } + + @staticmethod + def create_artifact_update_event( + ctx: A2AStreamingContext, + text: str, + ) -> Dict[str, Any]: + """ + Create an artifact update event with content. + + Args: + ctx: Streaming context + text: The text content for the artifact + """ + return { + "id": ctx.request_id, + "jsonrpc": "2.0", + "result": { + "artifact": { + "artifactId": str(uuid4()), + "name": "response", + "parts": [{"kind": "text", "text": text}], + }, + "contextId": ctx.context_id, + "kind": "artifact-update", + "taskId": ctx.task_id, + }, + } + @staticmethod def openai_chunk_to_a2a_chunk( chunk: Any, @@ -117,6 +247,10 @@ class A2ACompletionBridgeTransformation: """ Transform a LiteLLM streaming chunk to A2A streaming format. + NOTE: This method is deprecated for streaming. Use the event-based + methods (create_task_event, create_status_update_event, + create_artifact_update_event) instead for proper A2A streaming. + Args: chunk: LiteLLM ModelResponse chunk request_id: Original A2A request ID @@ -135,7 +269,7 @@ class A2ACompletionBridgeTransformation: if not content and not is_final: return None - # Build A2A streaming chunk + # Build A2A streaming chunk (legacy format) a2a_chunk = { "jsonrpc": "2.0", "id": request_id, diff --git a/litellm/a2a_protocol/main.py b/litellm/a2a_protocol/main.py index 30e13acc1ff..b7766bbcc74 100644 --- a/litellm/a2a_protocol/main.py +++ b/litellm/a2a_protocol/main.py @@ -186,8 +186,7 @@ async def asend_message( if custom_llm_provider: if request is None: raise ValueError("request is required for completion bridge") - if api_base is None: - raise ValueError("api_base is required for completion bridge") + # api_base is optional for providers that derive endpoint from model (e.g., bedrock/agentcore) verbose_logger.info( f"A2A using completion bridge: provider={custom_llm_provider}, api_base={api_base}" @@ -334,8 +333,7 @@ async def asend_message_streaming( if custom_llm_provider: if request is None: raise ValueError("request is required for completion bridge") - if api_base is None: - raise ValueError("api_base is required for completion bridge") + # api_base is optional for providers that derive endpoint from model (e.g., bedrock/agentcore) verbose_logger.info( f"A2A streaming using completion bridge: provider={custom_llm_provider}" diff --git a/litellm/proxy/agent_endpoints/a2a_endpoints.py b/litellm/proxy/agent_endpoints/a2a_endpoints.py index 90b1507b386..e439761cbf9 100644 --- a/litellm/proxy/agent_endpoints/a2a_endpoints.py +++ b/litellm/proxy/agent_endpoints/a2a_endpoints.py @@ -46,7 +46,7 @@ def _get_agent(agent_id: str): async def _handle_stream_message( - api_base: str, + api_base: Optional[str], request_id: str, params: dict, litellm_params: Optional[dict] = None, @@ -213,13 +213,17 @@ async def invoke_agent_a2a( # Get backend URL and agent name agent_url = agent.agent_card_params.get("url") agent_name = agent.agent_card_params.get("name", agent_id) - if not agent_url: - return _jsonrpc_error(request_id, -32000, f"Agent '{agent_id}' has no URL configured", 500) - - verbose_proxy_logger.info(f"Proxying A2A request to agent '{agent_id}' at {agent_url}") - + # Get litellm_params (may include custom_llm_provider for completion bridge) litellm_params = agent.litellm_params or {} + custom_llm_provider = litellm_params.get("custom_llm_provider") + + # URL is required unless using completion bridge with a provider that derives endpoint from model + # (e.g., bedrock/agentcore derives endpoint from ARN in model string) + if not agent_url and not custom_llm_provider: + return _jsonrpc_error(request_id, -32000, f"Agent '{agent_id}' has no URL configured", 500) + + verbose_proxy_logger.info(f"Proxying A2A request to agent '{agent_id}' at {agent_url or 'completion-bridge'}") # Set up data dict for litellm processing body.update({ diff --git a/litellm/proxy/proxy_config.yaml b/litellm/proxy/proxy_config.yaml index a33f56b0327..47b8f2e9457 100644 --- a/litellm/proxy/proxy_config.yaml +++ b/litellm/proxy/proxy_config.yaml @@ -4,6 +4,10 @@ model_list: model: openai/gpt-4o-mini tpm: 1000 + # LangGraph models + - model_name: langgraph/* + litellm_params: + model: langgraph/* litellm_settings: callbacks: ["dynamic_rate_limiter_v3"] diff --git a/litellm/proxy/public_endpoints/agent_create_fields.json b/litellm/proxy/public_endpoints/agent_create_fields.json new file mode 100644 index 00000000000..ab2838d050c --- /dev/null +++ b/litellm/proxy/public_endpoints/agent_create_fields.json @@ -0,0 +1,76 @@ +[ + { + "agent_type": "a2a", + "agent_type_display_name": "A2A Standard", + "description": "Standard A2A protocol", + "logo_url": "/assets/logos/a2a_agent.png", + "credential_fields": [], + "litellm_params_template": {} + }, + { + "agent_type": "langgraph", + "agent_type_display_name": "LangGraph", + "description": "Connect to LangGraph agents via the LangGraph Platform API", + "logo_url": "/assets/logos/langgraph.png", + "model_template": "langgraph/{assistant_id}", + "credential_fields": [ + { + "key": "assistant_id", + "label": "Assistant ID", + "placeholder": "agent", + "tooltip": "The assistant/agent ID from your LangGraph deployment", + "required": true, + "field_type": "text", + "default_value": "agent", + "include_in_litellm_params": false + }, + { + "key": "api_base", + "label": "LangGraph API Base", + "placeholder": "http://localhost:2024", + "tooltip": "The base URL for your LangGraph server (e.g., http://localhost:2024 or your deployed LangGraph Cloud URL)", + "required": true, + "field_type": "text", + "default_value": "http://localhost:2024", + "include_in_litellm_params": true + }, + { + "key": "api_key", + "label": "LangGraph API Key", + "placeholder": null, + "tooltip": "API key for authenticating with your LangGraph server (optional for local development)", + "required": false, + "field_type": "password", + "default_value": null, + "include_in_litellm_params": true + } + ], + "litellm_params_template": { + "custom_llm_provider": "langgraph" + } + }, + { + "agent_type": "bedrock_agentcore", + "agent_type_display_name": "Bedrock AgentCore", + "description": "Connect to Amazon Bedrock AgentCore hosted agent runtimes", + "logo_url": "/assets/logos/bedrock.svg", + "inherit_credentials_from_provider": "Bedrock", + "model_template": "bedrock/agentcore/{agent_runtime_arn}", + "credential_fields": [ + { + "key": "agent_runtime_arn", + "label": "Agent Runtime ARN", + "placeholder": "arn:aws:bedrock-agentcore:us-west-2:123456789012:runtime/my-agent-runtime", + "tooltip": "The ARN of your Bedrock AgentCore runtime. Find this in your AWS Bedrock console under AgentCore.", + "required": true, + "field_type": "text", + "default_value": null, + "include_in_litellm_params": false + } + ], + "litellm_params_template": { + "custom_llm_provider": "bedrock" + } + } +] + diff --git a/litellm/proxy/public_endpoints/public_endpoints.py b/litellm/proxy/public_endpoints/public_endpoints.py index 378027d8d1b..abb69050464 100644 --- a/litellm/proxy/public_endpoints/public_endpoints.py +++ b/litellm/proxy/public_endpoints/public_endpoints.py @@ -1,6 +1,6 @@ -from typing import List -import os import json +import os +from typing import List from fastapi import APIRouter, Depends, HTTPException @@ -12,6 +12,7 @@ from litellm.types.proxy.management_endpoints.model_management_endpoints import ModelGroupInfoProxy, ) from litellm.types.proxy.public_endpoints.public_endpoints import ( + AgentCreateInfo, ProviderCreateInfo, PublicModelHubInfo, ) @@ -167,3 +168,52 @@ async def get_litellm_model_cost_map(): status_code=500, detail=f"Internal Server Error ({str(e)})", ) + + +@router.get( + "/public/agents/fields", + tags=["public", "[beta] Agents"], + response_model=List[AgentCreateInfo], +) +async def get_agent_fields() -> List[AgentCreateInfo]: + """ + Return agent type metadata required by the dashboard create-agent flow. + + If an agent has `inherit_credentials_from_provider`, the provider's credential + fields are automatically appended to the agent's credential_fields. + """ + base_path = os.path.join( + os.path.dirname(os.path.dirname(os.path.dirname(__file__))), + "proxy", + "public_endpoints", + ) + + agent_create_fields_path = os.path.join(base_path, "agent_create_fields.json") + provider_create_fields_path = os.path.join(base_path, "provider_create_fields.json") + + with open(agent_create_fields_path, "r") as f: + agent_create_fields = json.load(f) + + with open(provider_create_fields_path, "r") as f: + provider_create_fields = json.load(f) + + # Build a lookup map for providers by name + provider_map = {p["provider"]: p for p in provider_create_fields} + + # Merge inherited credential fields + for agent in agent_create_fields: + inherit_from = agent.get("inherit_credentials_from_provider") + if inherit_from and inherit_from in provider_map: + provider = provider_map[inherit_from] + # Copy provider fields and mark them for inclusion in litellm_params + inherited_fields = [] + for field in provider.get("credential_fields", []): + field_copy = field.copy() + field_copy["include_in_litellm_params"] = True + inherited_fields.append(field_copy) + # Append provider credential fields after agent's own fields + agent["credential_fields"] = agent.get("credential_fields", []) + inherited_fields + # Remove the inherit field from response (not needed by frontend) + agent.pop("inherit_credentials_from_provider", None) + + return agent_create_fields diff --git a/litellm/types/proxy/public_endpoints/public_endpoints.py b/litellm/types/proxy/public_endpoints/public_endpoints.py index aca58e36921..eeb1b10fe61 100644 --- a/litellm/types/proxy/public_endpoints/public_endpoints.py +++ b/litellm/types/proxy/public_endpoints/public_endpoints.py @@ -27,3 +27,25 @@ class ProviderCreateInfo(BaseModel): litellm_provider: str credential_fields: List[ProviderCredentialField] default_model_placeholder: Optional[str] = None + + +class AgentCredentialField(BaseModel): + key: str + label: str + placeholder: Optional[str] = None + tooltip: Optional[str] = None + required: bool = False + field_type: Literal["text", "password", "select", "upload", "textarea"] = "text" + options: Optional[List[str]] = None + default_value: Optional[str] = None + include_in_litellm_params: Optional[bool] = None + + +class AgentCreateInfo(BaseModel): + agent_type: str + agent_type_display_name: str + description: Optional[str] = None + logo_url: Optional[str] = None + credential_fields: List[AgentCredentialField] + litellm_params_template: Optional[Dict[str, str]] = None + model_template: Optional[str] = None diff --git a/tests/agent_tests/test_a2a_completion_bridge.py b/tests/agent_tests/test_a2a_completion_bridge.py index 4f4959a7fb2..4191821f3de 100644 --- a/tests/agent_tests/test_a2a_completion_bridge.py +++ b/tests/agent_tests/test_a2a_completion_bridge.py @@ -71,6 +71,12 @@ async def test_a2a_completion_bridge_non_streaming(): async def test_a2a_completion_bridge_streaming(): """ Test streaming A2A request via the completion bridge with LangGraph provider. + + Validates proper A2A streaming format with events: + 1. Task event (kind: "task") - Initial task with status "submitted" + 2. Status update (kind: "status-update") - Status "working" + 3. Artifact update (kind: "artifact-update") - Content delivery + 4. Status update (kind: "status-update") - Final "completed" status """ from litellm.a2a_protocol import asend_message_streaming @@ -98,19 +104,100 @@ async def test_a2a_completion_bridge_streaming(): chunks.append(chunk) print(f"Chunk: {chunk}") - # Validate we received chunks - assert len(chunks) > 0 + # Validate we received proper A2A streaming events + assert len(chunks) >= 4, f"Expected at least 4 chunks (task, working, artifact, completed), got {len(chunks)}" - # Validate chunk structure (chunks are dicts from bridge) + # Validate chunk structure follows A2A spec for chunk in chunks: assert "jsonrpc" in chunk assert chunk["jsonrpc"] == "2.0" + assert "id" in chunk assert "result" in chunk - assert "message" in chunk["result"] - message = chunk["result"]["message"] - assert "role" in message - assert message["role"] == "agent" - assert "parts" in message - print(f"Received {len(chunks)} chunks") + # Validate first chunk is task event + task_chunk = chunks[0] + assert task_chunk["result"]["kind"] == "task", "First chunk should be task event" + assert task_chunk["result"]["status"]["state"] == "submitted" + assert "contextId" in task_chunk["result"] + assert "id" in task_chunk["result"] # task id + assert "history" in task_chunk["result"] + + # Validate second chunk is working status update + working_chunk = chunks[1] + assert working_chunk["result"]["kind"] == "status-update", "Second chunk should be status-update" + assert working_chunk["result"]["status"]["state"] == "working" + assert "taskId" in working_chunk["result"] + assert "contextId" in working_chunk["result"] + assert working_chunk["result"]["final"] is False + + # Validate artifact update chunk + artifact_chunk = chunks[2] + assert artifact_chunk["result"]["kind"] == "artifact-update", "Third chunk should be artifact-update" + assert "artifact" in artifact_chunk["result"] + assert "artifactId" in artifact_chunk["result"]["artifact"] + assert "parts" in artifact_chunk["result"]["artifact"] + assert len(artifact_chunk["result"]["artifact"]["parts"]) > 0 + assert artifact_chunk["result"]["artifact"]["parts"][0]["kind"] == "text" + + # Validate final chunk is completed status update + final_chunk = chunks[-1] + assert final_chunk["result"]["kind"] == "status-update", "Last chunk should be status-update" + assert final_chunk["result"]["status"]["state"] == "completed" + assert final_chunk["result"]["final"] is True + + print(f"Received {len(chunks)} chunks with proper A2A streaming format") + + +@pytest.mark.asyncio +async def test_a2a_completion_bridge_bedrock_agentcore(): + """ + Test A2A request via the completion bridge with Bedrock AgentCore provider. + + Uses the AgentCore runtime ARN to call a hosted agent. + """ + from litellm.a2a_protocol import asend_message_streaming + + litellm._turn_on_debug() + + # Bedrock AgentCore ARN (streaming-capable runtime) + agentcore_arn = "arn:aws:bedrock-agentcore:us-west-2:888602223428:runtime/hosted_agent_r9jvp-3ySZuRHjLC" + + send_message_payload = { + "message": { + "role": "user", + "parts": [{"kind": "text", "text": "Explain machine learning in simple terms"}], + "messageId": uuid4().hex, + } + } + + request = SendStreamingMessageRequest( + id=str(uuid4()), + params=MessageSendParams(**send_message_payload), # type: ignore + ) + + chunks = [] + async for chunk in asend_message_streaming( + request=request, + api_base=None, # Not needed for Bedrock AgentCore + litellm_params={ + "custom_llm_provider": "bedrock", + "model": f"bedrock/agentcore/{agentcore_arn}", + }, + ): + chunks.append(chunk) + print(f"Chunk: {chunk}") + + # Validate we received proper A2A streaming events + assert len(chunks) >= 4, f"Expected at least 4 chunks, got {len(chunks)}" + + # Validate first chunk is task event + assert chunks[0]["result"]["kind"] == "task" + assert chunks[0]["result"]["status"]["state"] == "submitted" + + # Validate final chunk is completed status + assert chunks[-1]["result"]["kind"] == "status-update" + assert chunks[-1]["result"]["status"]["state"] == "completed" + assert chunks[-1]["result"]["final"] is True + + print(f"Received {len(chunks)} chunks from Bedrock AgentCore") diff --git a/tests/test_litellm/a2a_protocol/test_completion_bridge_streaming.py b/tests/test_litellm/a2a_protocol/test_completion_bridge_streaming.py new file mode 100644 index 00000000000..c088b3460a2 --- /dev/null +++ b/tests/test_litellm/a2a_protocol/test_completion_bridge_streaming.py @@ -0,0 +1,159 @@ +""" +Test A2A completion bridge streaming transformation to proper A2A format. + +Tests that the completion bridge emits proper A2A streaming events: +1. Task event (kind: "task") - Initial task with status "submitted" +2. Status update (kind: "status-update") - Status "working" +3. Artifact update (kind: "artifact-update") - Content delivery +4. Status update (kind: "status-update") - Final "completed" status +""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + + +class TestA2AStreamingTransformation: + """Test the A2A streaming transformation creates proper events.""" + + def test_create_task_event(self): + """Test that create_task_event produces proper A2A task event structure.""" + from litellm.a2a_protocol.litellm_completion_bridge.transformation import ( + A2ACompletionBridgeTransformation, + A2AStreamingContext, + ) + + input_message = { + "role": "user", + "parts": [{"kind": "text", "text": "Hello"}], + "messageId": "msg-123", + } + ctx = A2AStreamingContext(request_id="req-456", input_message=input_message) + + event = A2ACompletionBridgeTransformation.create_task_event(ctx) + + # Validate structure + assert event["jsonrpc"] == "2.0" + assert event["id"] == "req-456" + assert event["result"]["kind"] == "task" + assert event["result"]["status"]["state"] == "submitted" + assert "contextId" in event["result"] + assert "id" in event["result"] # task id + assert "history" in event["result"] + assert len(event["result"]["history"]) == 1 + assert event["result"]["history"][0]["role"] == "user" + + def test_create_status_update_working(self): + """Test that create_status_update_event produces proper working status.""" + from litellm.a2a_protocol.litellm_completion_bridge.transformation import ( + A2ACompletionBridgeTransformation, + A2AStreamingContext, + ) + + ctx = A2AStreamingContext( + request_id="req-456", + input_message={"role": "user", "parts": []}, + ) + + event = A2ACompletionBridgeTransformation.create_status_update_event( + ctx=ctx, + state="working", + final=False, + message_text="Processing...", + ) + + assert event["result"]["kind"] == "status-update" + assert event["result"]["status"]["state"] == "working" + assert event["result"]["final"] is False + assert "taskId" in event["result"] + assert "contextId" in event["result"] + assert "timestamp" in event["result"]["status"] + + def test_create_artifact_update(self): + """Test that create_artifact_update_event produces proper artifact event.""" + from litellm.a2a_protocol.litellm_completion_bridge.transformation import ( + A2ACompletionBridgeTransformation, + A2AStreamingContext, + ) + + ctx = A2AStreamingContext( + request_id="req-456", + input_message={"role": "user", "parts": []}, + ) + + event = A2ACompletionBridgeTransformation.create_artifact_update_event( + ctx=ctx, + text="Hello, I am an AI assistant.", + ) + + assert event["result"]["kind"] == "artifact-update" + assert "artifact" in event["result"] + assert "artifactId" in event["result"]["artifact"] + assert event["result"]["artifact"]["name"] == "response" + assert event["result"]["artifact"]["parts"][0]["kind"] == "text" + assert event["result"]["artifact"]["parts"][0]["text"] == "Hello, I am an AI assistant." + + +@pytest.mark.asyncio +async def test_handle_streaming_emits_proper_events(): + """Test that handle_streaming emits events in correct order with proper structure.""" + from litellm.a2a_protocol.litellm_completion_bridge.handler import ( + A2ACompletionBridgeHandler, + ) + + # Mock litellm.acompletion to return a streaming response + mock_chunk1 = MagicMock() + mock_chunk1.choices = [MagicMock()] + mock_chunk1.choices[0].delta = MagicMock() + mock_chunk1.choices[0].delta.content = "Hello" + + mock_chunk2 = MagicMock() + mock_chunk2.choices = [MagicMock()] + mock_chunk2.choices[0].delta = MagicMock() + mock_chunk2.choices[0].delta.content = " world" + + async def mock_streaming_response(): + yield mock_chunk1 + yield mock_chunk2 + + with patch("litellm.acompletion", new_callable=AsyncMock) as mock_acompletion: + mock_acompletion.return_value = mock_streaming_response() + + params = { + "message": { + "role": "user", + "parts": [{"kind": "text", "text": "Hi"}], + "messageId": "msg-123", + } + } + + events = [] + async for event in A2ACompletionBridgeHandler.handle_streaming( + request_id="req-456", + params=params, + litellm_params={"custom_llm_provider": "langgraph", "model": "agent"}, + api_base="http://localhost:2024", + ): + events.append(event) + + # Should have 4 events: task, working, artifact, completed + assert len(events) == 4 + + # Event 1: task submitted + assert events[0]["result"]["kind"] == "task" + assert events[0]["result"]["status"]["state"] == "submitted" + + # Event 2: status working + assert events[1]["result"]["kind"] == "status-update" + assert events[1]["result"]["status"]["state"] == "working" + assert events[1]["result"]["final"] is False + + # Event 3: artifact update with accumulated content + assert events[2]["result"]["kind"] == "artifact-update" + assert events[2]["result"]["artifact"]["parts"][0]["text"] == "Hello world" + + # Event 4: status completed + assert events[3]["result"]["kind"] == "status-update" + assert events[3]["result"]["status"]["state"] == "completed" + assert events[3]["result"]["final"] is True + diff --git a/ui/litellm-dashboard/public/assets/logos/langgraph.png b/ui/litellm-dashboard/public/assets/logos/langgraph.png new file mode 100644 index 0000000000000000000000000000000000000000..3df93e5205b33aaa4dfec6f7d73d729b9d2bed08 GIT binary patch literal 5495 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 literal 0 HcmV?d00001 diff --git a/ui/litellm-dashboard/src/components/agents/add_agent_form.tsx b/ui/litellm-dashboard/src/components/agents/add_agent_form.tsx index ffdfe0c8e49..f44d71cbada 100644 --- a/ui/litellm-dashboard/src/components/agents/add_agent_form.tsx +++ b/ui/litellm-dashboard/src/components/agents/add_agent_form.tsx @@ -1,7 +1,9 @@ -import React, { useState } from "react"; -import { Modal, Form, Button as AntButton, message } from "antd"; -import { createAgentCall } from "../networking"; +import React, { useState, useEffect } from "react"; +import { Modal, Form, message, Select } from "antd"; +import { Button } from "@tremor/react"; +import { createAgentCall, getAgentCreateMetadata, AgentCreateInfo } from "../networking"; import AgentFormFields from "./agent_form_fields"; +import DynamicAgentFormFields, { buildDynamicAgentData } from "./dynamic_agent_form_fields"; import { getDefaultFormValues, buildAgentDataFromForm } from "./agent_config"; interface AddAgentFormProps { @@ -19,6 +21,29 @@ const AddAgentForm: React.FC = ({ }) => { const [form] = Form.useForm(); const [isSubmitting, setIsSubmitting] = useState(false); + const [agentType, setAgentType] = useState("a2a"); + const [agentTypeMetadata, setAgentTypeMetadata] = useState([]); + const [loadingMetadata, setLoadingMetadata] = useState(false); + + // Fetch agent type metadata on mount + useEffect(() => { + const fetchMetadata = async () => { + setLoadingMetadata(true); + try { + const metadata = await getAgentCreateMetadata(); + setAgentTypeMetadata(metadata); + } catch (error) { + console.error("Error fetching agent metadata:", error); + } finally { + setLoadingMetadata(false); + } + }; + fetchMetadata(); + }, []); + + const selectedAgentTypeInfo = agentTypeMetadata.find( + (info) => info.agent_type === agentType + ); const handleSubmit = async (values: any) => { if (!accessToken) { @@ -28,10 +53,18 @@ const AddAgentForm: React.FC = ({ setIsSubmitting(true); try { - const agentData = buildAgentDataFromForm(values); + let agentData: any; + + if (agentType === "a2a") { + agentData = buildAgentDataFromForm(values); + } else if (selectedAgentTypeInfo) { + agentData = buildDynamicAgentData(values, selectedAgentTypeInfo); + } + await createAgentCall(accessToken, agentData); message.success("Agent created successfully"); form.resetFields(); + setAgentType("a2a"); onSuccess(); onClose(); } catch (error) { @@ -44,42 +77,114 @@ const AddAgentForm: React.FC = ({ const handleCancel = () => { form.resetFields(); + setAgentType("a2a"); onClose(); }; + const handleAgentTypeChange = (value: string) => { + setAgentType(value); + form.resetFields(); + }; + + // Get the logo for the selected agent type for the header + const selectedLogo = selectedAgentTypeInfo?.logo_url || agentTypeMetadata.find(a => a.agent_type === "a2a")?.logo_url; + return ( + {selectedLogo && ( + Agent + )} +

Add New Agent

+ + } open={visible} onCancel={handleCancel} footer={null} - width={800} + width={900} + className="top-8" + styles={{ + body: { padding: "24px" }, + header: { padding: "24px 24px 0 24px", border: "none" }, + }} > -
- - - -
- - Cancel - - + + {/* Agent Type Selection */} + Agent Type} + required + tooltip="Select the type of agent you want to create" + > + + + + {/* Conditional Form Fields */} +
+ {agentType === "a2a" ? ( + + ) : selectedAgentTypeInfo ? ( + + ) : null}
- - + + {/* Footer Buttons */} +
+ + +
+ +
); }; export default AddAgentForm; - diff --git a/ui/litellm-dashboard/src/components/agents/dynamic_agent_form_fields.tsx b/ui/litellm-dashboard/src/components/agents/dynamic_agent_form_fields.tsx new file mode 100644 index 00000000000..67f0f470ab2 --- /dev/null +++ b/ui/litellm-dashboard/src/components/agents/dynamic_agent_form_fields.tsx @@ -0,0 +1,125 @@ +import React from "react"; +import { Form, Input, Select } from "antd"; +import { AgentCreateInfo, AgentCredentialFieldMetadata } from "../networking"; + +interface DynamicAgentFormFieldsProps { + agentTypeInfo: AgentCreateInfo; +} + +/** + * Form fields for dynamic agent types (e.g., LangGraph). + * Renders common fields (agent name, display name, description) plus + * credential fields defined by the agent type metadata. + */ +const DynamicAgentFormFields: React.FC = ({ + agentTypeInfo, +}) => { + return ( + <> + + + + + + + + + {agentTypeInfo.credential_fields.map((field: AgentCredentialFieldMetadata) => ( + + {field.field_type === "password" ? ( + + ) : field.field_type === "textarea" ? ( + + ) : field.field_type === "select" && field.options ? ( + + ) : ( + + )} + + ))} + + ); +}; + +/** + * Builds agent data from form values for dynamic agent types. + * Uses configuration from agentTypeInfo to determine which fields to include. + */ +export const buildDynamicAgentData = ( + values: any, + agentTypeInfo: AgentCreateInfo +) => { + // Build litellm_params from template + const litellmParams: Record = { + ...(agentTypeInfo.litellm_params_template || {}), + }; + + // Add credential fields marked with include_in_litellm_params + for (const field of agentTypeInfo.credential_fields) { + const value = values[field.key]; + if (value && field.include_in_litellm_params !== false) { + litellmParams[field.key] = value; + } + } + + // Apply model_template if defined (e.g., "bedrock/agentcore/{agent_runtime_arn}") + if (agentTypeInfo.model_template) { + let model = agentTypeInfo.model_template; + // Replace {field_key} placeholders with actual values + for (const field of agentTypeInfo.credential_fields) { + const placeholder = `{${field.key}}`; + if (model.includes(placeholder) && values[field.key]) { + model = model.replace(placeholder, values[field.key]); + } + } + litellmParams.model = model; + } + + return { + agent_name: values.agent_name, + agent_card_params: { + protocolVersion: "1.0", + name: values.display_name || values.agent_name, + description: values.description || `${agentTypeInfo.agent_type_display_name} agent`, + url: values.api_base || "", + version: "1.0.0", + defaultInputModes: ["text"], + defaultOutputModes: ["text"], + capabilities: { + streaming: true, + }, + skills: [{ + id: "chat", + name: "Chat", + description: "General chat capability", + tags: ["chat", "conversation"], + }], + }, + litellm_params: litellmParams, + }; +}; + +export default DynamicAgentFormFields; + diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index cd1289f0a88..17cc77a856d 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -195,6 +195,28 @@ export interface ProviderCreateInfo { credential_fields: ProviderCredentialFieldMetadata[]; } +export interface AgentCredentialFieldMetadata { + key: string; + label: string; + placeholder?: string | null; + tooltip?: string | null; + required?: boolean; + field_type?: "text" | "password" | "select" | "upload" | "textarea"; + options?: string[] | null; + default_value?: string | null; + include_in_litellm_params?: boolean; +} + +export interface AgentCreateInfo { + agent_type: string; + agent_type_display_name: string; + description?: string | null; + logo_url?: string | null; + credential_fields: AgentCredentialFieldMetadata[]; + litellm_params_template?: Record | null; + model_template?: string | null; +} + export interface PublicModelHubInfo { docs_title: string; custom_docs_description: string | null; @@ -255,6 +277,26 @@ export const getProviderCreateMetadata = async (): Promise return jsonData; }; +export const getAgentCreateMetadata = async (): Promise => { + /** + * Fetch agent type metadata from the proxy's public endpoint. + * This is used by the UI to dynamically render agent-specific credential fields. + */ + const url = proxyBaseUrl ? `${proxyBaseUrl}/public/agents/fields` : `/public/agents/fields`; + const response = await fetch(url, { + method: "GET", + }); + + if (!response.ok) { + const errorText = await response.text(); + console.error("Failed to fetch agent create metadata:", response.status, errorText); + throw new Error("Failed to load agent configuration"); + } + + const jsonData: AgentCreateInfo[] = await response.json(); + return jsonData; +}; + // Global variable for the header name let globalLitellmHeaderName: string = "Authorization"; const MCP_AUTH_HEADER: string = "x-mcp-auth";