From 3054b6ea60fe533c00652b3637e0cab9b9a2fee7 Mon Sep 17 00:00:00 2001 From: Ishaan Jaff Date: Fri, 12 Dec 2025 16:38:04 -0800 Subject: [PATCH] [Feat] A2A Gateway - allow adding Azure Foundry Agents on UI (#17909) * add CostConfigFields * add CostConfigFields * add output_cost_per_token * refactor table * add agent cost view * add azure foundry fields * add foundry logo * fix: clean error * fix utils * fix agent edi * add easter egg * fix order * test_handle_streaming_forwards_api_key * fix forward api key down * fix a2a send msg * add A2a comparison on compare playground * fix chat ui * fix bedrock agentcore stream --- .../litellm_completion_bridge/handler.py | 4 + .../bedrock/chat/agentcore/sse_iterator.py | 412 ++++++++---------- .../public_endpoints/agent_create_fields.json | 43 ++ .../llm_translation/test_bedrock_agentcore.py | 8 +- .../test_completion_bridge_streaming.py | 90 ++++ .../public/assets/logos/azure_ai_foundry.png | Bin 0 -> 26316 bytes .../src/components/agents.tsx | 14 +- .../src/components/agents/agent_info.tsx | 67 ++- .../src/components/agents/agent_table.tsx | 24 +- .../src/components/agents/agent_type_utils.ts | 67 +++ .../src/components/navbar.tsx | 11 +- .../components/playground/chat_ui/ChatUI.tsx | 19 + .../playground/compareUI/CompareUI.tsx | 172 ++++++-- .../compareUI/components/ComparisonPanel.tsx | 31 +- .../compareUI/components/UnifiedSelector.tsx | 48 ++ .../playground/compareUI/endpoint_config.ts | 138 ++++++ .../playground/llm_calls/a2a_send_message.tsx | 25 +- 17 files changed, 867 insertions(+), 306 deletions(-) create mode 100644 ui/litellm-dashboard/public/assets/logos/azure_ai_foundry.png create mode 100644 ui/litellm-dashboard/src/components/agents/agent_type_utils.ts create mode 100644 ui/litellm-dashboard/src/components/playground/compareUI/components/UnifiedSelector.tsx create mode 100644 ui/litellm-dashboard/src/components/playground/compareUI/endpoint_config.ts diff --git a/litellm/a2a_protocol/litellm_completion_bridge/handler.py b/litellm/a2a_protocol/litellm_completion_bridge/handler.py index 1f8892c91b..2eab255183 100644 --- a/litellm/a2a_protocol/litellm_completion_bridge/handler.py +++ b/litellm/a2a_protocol/litellm_completion_bridge/handler.py @@ -55,6 +55,7 @@ class A2ACompletionBridgeHandler: # Get completion params custom_llm_provider = litellm_params.get("custom_llm_provider") model = litellm_params.get("model", "agent") + api_key = litellm_params.get("api_key") # Build full model string if provider specified # Skip prepending if model already starts with the provider prefix @@ -72,6 +73,7 @@ class A2ACompletionBridgeHandler: model=full_model, messages=openai_messages, api_base=api_base, + api_key=api_key, stream=False, ) @@ -127,6 +129,7 @@ class A2ACompletionBridgeHandler: # Get completion params custom_llm_provider = litellm_params.get("custom_llm_provider") model = litellm_params.get("model", "agent") + api_key = litellm_params.get("api_key") # Build full model string if provider specified # Skip prepending if model already starts with the provider prefix @@ -157,6 +160,7 @@ class A2ACompletionBridgeHandler: model=full_model, messages=openai_messages, api_base=api_base, + api_key=api_key, stream=True, ) diff --git a/litellm/llms/bedrock/chat/agentcore/sse_iterator.py b/litellm/llms/bedrock/chat/agentcore/sse_iterator.py index e0da4fcd44..90c5ada769 100644 --- a/litellm/llms/bedrock/chat/agentcore/sse_iterator.py +++ b/litellm/llms/bedrock/chat/agentcore/sse_iterator.py @@ -5,7 +5,7 @@ Handles Server-Sent Events (SSE) streaming responses from AgentCore. """ import json -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any, Optional import httpx @@ -19,262 +19,234 @@ if TYPE_CHECKING: class AgentCoreSSEStreamIterator: - """Iterator for AgentCore SSE streaming responses. Supports both sync and async iteration.""" + """ + Iterator for AgentCore SSE streaming responses. + Supports both sync and async iteration. + + CRITICAL: The line iterators are created lazily on first access and reused. + We must NOT create new iterators in __aiter__/__iter__ because + CustomStreamWrapper calls __aiter__ on every call to its __anext__, + which would create new iterators and cause StreamConsumed errors. + """ def __init__(self, response: httpx.Response, model: str): self.response = response self.model = model self.finished = False - self.line_iterator = None - self.async_line_iterator = None + self._sync_iter: Any = None + self._async_iter: Any = None + self._sync_iter_initialized = False + self._async_iter_initialized = False def __iter__(self): - """Initialize sync iteration.""" - self.line_iterator = self.response.iter_lines() + """Initialize sync iteration - create iterator lazily on first call only.""" + if not self._sync_iter_initialized: + self._sync_iter = iter(self.response.iter_lines()) + self._sync_iter_initialized = True return self def __aiter__(self): - """Initialize async iteration.""" - self.async_line_iterator = self.response.aiter_lines() + """Initialize async iteration - create iterator lazily on first call only.""" + if not self._async_iter_initialized: + self._async_iter = self.response.aiter_lines().__aiter__() + self._async_iter_initialized = True return self - def __next__(self) -> ModelResponse: - """Sync iteration - parse SSE events and yield ModelResponse chunks.""" + def _parse_sse_line(self, line: str) -> Optional[ModelResponse]: + """ + Parse a single SSE line and return a ModelResponse chunk if applicable. + + AgentCore SSE format: + - data: {"event": {"contentBlockDelta": {"delta": {"text": "..."}}}} + - data: {"event": {"metadata": {"usage": {...}}}} + - data: {"message": {...}} + """ + line = line.strip() + if not line or not line.startswith("data:"): + return None + + json_str = line[5:].strip() + if not json_str: + return None + try: - if self.line_iterator is None: + data = json.loads(json_str) + + # Skip non-dict data (some lines contain Python repr strings) + if not isinstance(data, dict): + return None + + # Process content delta events + if "event" in data and isinstance(data["event"], dict): + event_payload = data["event"] + content_block_delta = event_payload.get("contentBlockDelta") + + if content_block_delta: + delta = content_block_delta.get("delta", {}) + text = delta.get("text", "") + + if text: + # Return chunk with text + chunk = ModelResponse( + id=f"chatcmpl-{uuid.uuid4()}", + created=0, + model=self.model, + object="chat.completion.chunk", + ) + + chunk.choices = [ + StreamingChoices( + finish_reason=None, + index=0, + delta=Delta(content=text, role="assistant"), + ) + ] + + return chunk + + # Check for metadata/usage - this signals the end + metadata = event_payload.get("metadata") + if metadata and "usage" in metadata: + chunk = ModelResponse( + id=f"chatcmpl-{uuid.uuid4()}", + created=0, + model=self.model, + object="chat.completion.chunk", + ) + + chunk.choices = [ + StreamingChoices( + finish_reason="stop", + index=0, + delta=Delta(), + ) + ] + + usage_data: AgentCoreUsage = metadata["usage"] # type: ignore + setattr( + chunk, + "usage", + Usage( + prompt_tokens=usage_data.get("inputTokens", 0), + completion_tokens=usage_data.get("outputTokens", 0), + total_tokens=usage_data.get("totalTokens", 0), + ), + ) + + self.finished = True + return chunk + + # Check for final message (alternative finish signal) + if "message" in data and isinstance(data["message"], dict): + if not self.finished: + chunk = ModelResponse( + id=f"chatcmpl-{uuid.uuid4()}", + created=0, + model=self.model, + object="chat.completion.chunk", + ) + + chunk.choices = [ + StreamingChoices( + finish_reason="stop", + index=0, + delta=Delta(), + ) + ] + + self.finished = True + return chunk + + except json.JSONDecodeError: + verbose_logger.debug(f"Skipping non-JSON SSE line: {line[:100]}") + + return None + + def _create_final_chunk(self) -> ModelResponse: + """Create a final chunk to signal stream completion.""" + chunk = ModelResponse( + id=f"chatcmpl-{uuid.uuid4()}", + created=0, + model=self.model, + object="chat.completion.chunk", + ) + + chunk.choices = [ + StreamingChoices( + finish_reason="stop", + index=0, + delta=Delta(), + ) + ] + + return chunk + + def __next__(self) -> ModelResponse: + """ + Sync iteration - parse SSE events and yield ModelResponse chunks. + + Uses next() on the stored iterator to properly resume between calls. + """ + try: + if self._sync_iter is None: raise StopIteration - for line in self.line_iterator: - line = line.strip() - - if not line or not line.startswith('data:'): - continue - - # Extract JSON from SSE line - json_str = line[5:].strip() - if not json_str: - continue - + + # Keep getting lines until we have a result to return + while True: try: - data = json.loads(json_str) - - # Skip non-dict data - if not isinstance(data, dict): - continue - - # Process content delta events - if "event" in data and isinstance(data["event"], dict): - event_payload = data["event"] - content_block_delta = event_payload.get("contentBlockDelta") - - if content_block_delta: - delta = content_block_delta.get("delta", {}) - text = delta.get("text", "") - - if text: - # Yield chunk with text - chunk = ModelResponse( - id=f"chatcmpl-{uuid.uuid4()}", - created=0, - model=self.model, - object="chat.completion.chunk", - ) - - chunk.choices = [ - StreamingChoices( - finish_reason=None, - index=0, - delta=Delta(content=text, role="assistant"), - ) - ] - - return chunk - - # Check for metadata/usage - metadata = event_payload.get("metadata") - if metadata and "usage" in metadata: - # This is the final chunk with usage - chunk = ModelResponse( - id=f"chatcmpl-{uuid.uuid4()}", - created=0, - model=self.model, - object="chat.completion.chunk", - ) - - chunk.choices = [ - StreamingChoices( - finish_reason="stop", - index=0, - delta=Delta(), - ) - ] - - usage_data: AgentCoreUsage = metadata["usage"] # type: ignore - setattr(chunk, "usage", Usage( - prompt_tokens=usage_data.get("inputTokens", 0), - completion_tokens=usage_data.get("outputTokens", 0), - total_tokens=usage_data.get("totalTokens", 0), - )) - - self.finished = True - return chunk - - # Check for final message (alternative finish signal) - if "message" in data and isinstance(data["message"], dict): - if not self.finished: - chunk = ModelResponse( - id=f"chatcmpl-{uuid.uuid4()}", - created=0, - model=self.model, - object="chat.completion.chunk", - ) - - chunk.choices = [ - StreamingChoices( - finish_reason="stop", - index=0, - delta=Delta(), - ) - ] - - self.finished = True - return chunk - - except json.JSONDecodeError: - verbose_logger.debug(f"Skipping non-JSON SSE line: {line[:100]}") - continue - - # Stream ended naturally - raise StopIteration + line = next(self._sync_iter) + except StopIteration: + # Stream ended - send final chunk if not already finished + if not self.finished: + self.finished = True + return self._create_final_chunk() + raise + + result = self._parse_sse_line(line) + if result is not None: + return result except StopIteration: raise except httpx.StreamConsumed: - # This is expected when the stream has been fully consumed raise StopIteration except httpx.StreamClosed: - # This is expected when the stream is closed raise StopIteration except Exception as e: verbose_logger.error(f"Error in AgentCore SSE stream: {str(e)}") raise StopIteration async def __anext__(self) -> ModelResponse: - """Async iteration - parse SSE events and yield ModelResponse chunks.""" + """ + Async iteration - parse SSE events and yield ModelResponse chunks. + + Uses __anext__() on the stored iterator to properly resume between calls. + """ try: - if self.async_line_iterator is None: + if self._async_iter is None: raise StopAsyncIteration - async for line in self.async_line_iterator: - line = line.strip() - - if not line or not line.startswith('data:'): - continue - - # Extract JSON from SSE line - json_str = line[5:].strip() - if not json_str: - continue - + + # Keep getting lines until we have a result to return + while True: try: - data = json.loads(json_str) - - # Skip non-dict data - if not isinstance(data, dict): - continue - - # Process content delta events - if "event" in data and isinstance(data["event"], dict): - event_payload = data["event"] - content_block_delta = event_payload.get("contentBlockDelta") - - if content_block_delta: - delta = content_block_delta.get("delta", {}) - text = delta.get("text", "") - - if text: - # Yield chunk with text - chunk = ModelResponse( - id=f"chatcmpl-{uuid.uuid4()}", - created=0, - model=self.model, - object="chat.completion.chunk", - ) - - chunk.choices = [ - StreamingChoices( - finish_reason=None, - index=0, - delta=Delta(content=text, role="assistant"), - ) - ] - - return chunk - - # Check for metadata/usage - metadata = event_payload.get("metadata") - if metadata and "usage" in metadata: - # This is the final chunk with usage - chunk = ModelResponse( - id=f"chatcmpl-{uuid.uuid4()}", - created=0, - model=self.model, - object="chat.completion.chunk", - ) - - chunk.choices = [ - StreamingChoices( - finish_reason="stop", - index=0, - delta=Delta(), - ) - ] - - usage_data: AgentCoreUsage = metadata["usage"] # type: ignore - setattr(chunk, "usage", Usage( - prompt_tokens=usage_data.get("inputTokens", 0), - completion_tokens=usage_data.get("outputTokens", 0), - total_tokens=usage_data.get("totalTokens", 0), - )) - - self.finished = True - return chunk - - # Check for final message (alternative finish signal) - if "message" in data and isinstance(data["message"], dict): - if not self.finished: - chunk = ModelResponse( - id=f"chatcmpl-{uuid.uuid4()}", - created=0, - model=self.model, - object="chat.completion.chunk", - ) - - chunk.choices = [ - StreamingChoices( - finish_reason="stop", - index=0, - delta=Delta(), - ) - ] - - self.finished = True - return chunk - - except json.JSONDecodeError: - verbose_logger.debug(f"Skipping non-JSON SSE line: {line[:100]}") - continue - - # Stream ended naturally - raise StopAsyncIteration + line = await self._async_iter.__anext__() + except StopAsyncIteration: + # Stream ended - send final chunk if not already finished + if not self.finished: + self.finished = True + return self._create_final_chunk() + raise + + result = self._parse_sse_line(line) + if result is not None: + return result except StopAsyncIteration: raise except httpx.StreamConsumed: - # This is expected when the stream has been fully consumed raise StopAsyncIteration except httpx.StreamClosed: - # This is expected when the stream is closed raise StopAsyncIteration except Exception as e: verbose_logger.error(f"Error in AgentCore SSE stream: {str(e)}") raise StopAsyncIteration - diff --git a/litellm/proxy/public_endpoints/agent_create_fields.json b/litellm/proxy/public_endpoints/agent_create_fields.json index ab2838d050..232f6a300f 100644 --- a/litellm/proxy/public_endpoints/agent_create_fields.json +++ b/litellm/proxy/public_endpoints/agent_create_fields.json @@ -71,6 +71,49 @@ "litellm_params_template": { "custom_llm_provider": "bedrock" } + }, + { + "agent_type": "azure_ai_foundry", + "agent_type_display_name": "Azure AI Foundry", + "description": "Connect to Microsoft Azure AI Foundry agents", + "logo_url": "/assets/logos/azure_ai_foundry.png", + "inherit_credentials_from_provider": "Azure AI", + "model_template": "azure_ai/agents/{agent_id}", + "credential_fields": [ + { + "key": "agent_id", + "label": "Agent ID", + "placeholder": "asst_abc123", + "tooltip": "The agent/assistant ID from your Azure AI Foundry project (e.g., asst_abc123)", + "required": true, + "field_type": "text", + "default_value": null, + "include_in_litellm_params": false + }, + { + "key": "api_base", + "label": "Azure AI API Base", + "placeholder": "https://your-project.services.ai.azure.com", + "tooltip": "The base URL for your Azure AI Foundry project endpoint", + "required": true, + "field_type": "text", + "default_value": null, + "include_in_litellm_params": true + }, + { + "key": "api_key", + "label": "Azure AI API Key", + "placeholder": null, + "tooltip": "API key for authenticating with your Azure AI Foundry project", + "required": true, + "field_type": "password", + "default_value": null, + "include_in_litellm_params": true + } + ], + "litellm_params_template": { + "custom_llm_provider": "azure_ai" + } } ] diff --git a/tests/llm_translation/test_bedrock_agentcore.py b/tests/llm_translation/test_bedrock_agentcore.py index 6dc6215a5e..029bdf4e37 100644 --- a/tests/llm_translation/test_bedrock_agentcore.py +++ b/tests/llm_translation/test_bedrock_agentcore.py @@ -41,16 +41,16 @@ def test_bedrock_agentcore_basic(model): @pytest.mark.asyncio @pytest.mark.parametrize( "model", [ - "bedrock/agentcore/arn:aws:bedrock-agentcore:us-west-2:888602223428:runtime/non_stream_agent-mdfwS2DlAu", # non-streaming invocation - "bedrock/agentcore/arn:aws:bedrock-agentcore:us-west-2:888602223428:runtime/hosted_agent_r9jvp-3ySZuRHjLC", # streaming invocation + "bedrock/agentcore/arn:aws:bedrock-agentcore:us-west-2:888602223428:runtime/hosted_agent_13sf6-cALnp38iZD", # streaming invocation ] ) async def test_bedrock_agentcore_with_streaming(model): """ Test AgentCore with streaming """ + print("running streming test for model=", model) #litellm._turn_on_debug() - response = litellm.completion( + response = await litellm.acompletion( model="bedrock/agentcore/arn:aws:bedrock-agentcore:us-west-2:888602223428:runtime/hosted_agent_r9jvp-3ySZuRHjLC", messages=[ { @@ -61,7 +61,7 @@ async def test_bedrock_agentcore_with_streaming(model): stream=True, ) - for chunk in response: + async for chunk in response: print("chunk=", chunk) diff --git a/tests/test_litellm/a2a_protocol/test_completion_bridge_streaming.py b/tests/test_litellm/a2a_protocol/test_completion_bridge_streaming.py index c088b3460a..6f21029cd1 100644 --- a/tests/test_litellm/a2a_protocol/test_completion_bridge_streaming.py +++ b/tests/test_litellm/a2a_protocol/test_completion_bridge_streaming.py @@ -157,3 +157,93 @@ async def test_handle_streaming_emits_proper_events(): assert events[3]["result"]["status"]["state"] == "completed" assert events[3]["result"]["final"] is True + +@pytest.mark.asyncio +async def test_handle_streaming_forwards_api_key(): + """Test that handle_streaming forwards api_key from litellm_params to acompletion.""" + from litellm.a2a_protocol.litellm_completion_bridge.handler import ( + A2ACompletionBridgeHandler, + ) + + mock_chunk = MagicMock() + mock_chunk.choices = [MagicMock()] + mock_chunk.choices[0].delta = MagicMock() + mock_chunk.choices[0].delta.content = "Response" + + async def mock_streaming_response(): + yield mock_chunk + + 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": "azure_ai", + "model": "agents/asst_123", + "api_key": "test-api-key-12345", + }, + api_base="https://example.azure.com/", + ): + events.append(event) + + # Verify acompletion was called with api_key + mock_acompletion.assert_called_once() + call_kwargs = mock_acompletion.call_args.kwargs + assert call_kwargs["api_key"] == "test-api-key-12345" + assert call_kwargs["api_base"] == "https://example.azure.com/" + assert call_kwargs["model"] == "azure_ai/agents/asst_123" + + +@pytest.mark.asyncio +async def test_handle_non_streaming_forwards_api_key(): + """Test that handle_non_streaming forwards api_key from litellm_params to acompletion.""" + from litellm.a2a_protocol.litellm_completion_bridge.handler import ( + A2ACompletionBridgeHandler, + ) + + mock_response = MagicMock() + mock_response.choices = [MagicMock()] + mock_response.choices[0].message = MagicMock() + mock_response.choices[0].message.content = "Hello!" + mock_response.id = "resp-123" + + with patch("litellm.acompletion", new_callable=AsyncMock) as mock_acompletion: + mock_acompletion.return_value = mock_response + + params = { + "message": { + "role": "user", + "parts": [{"kind": "text", "text": "Hi"}], + "messageId": "msg-123", + } + } + + await A2ACompletionBridgeHandler.handle_non_streaming( + request_id="req-456", + params=params, + litellm_params={ + "custom_llm_provider": "azure_ai", + "model": "agents/asst_456", + "api_key": "my-secret-api-key", + }, + api_base="https://my-azure.com/", + ) + + # Verify acompletion was called with api_key + mock_acompletion.assert_called_once() + call_kwargs = mock_acompletion.call_args.kwargs + assert call_kwargs["api_key"] == "my-secret-api-key" + assert call_kwargs["api_base"] == "https://my-azure.com/" + assert call_kwargs["model"] == "azure_ai/agents/asst_456" + diff --git a/ui/litellm-dashboard/public/assets/logos/azure_ai_foundry.png b/ui/litellm-dashboard/public/assets/logos/azure_ai_foundry.png new file mode 100644 index 0000000000000000000000000000000000000000..9f19b52e0bca454a75a12cd7fdc82d1efe5ee92b GIT binary patch literal 26316 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 vp<|9dc 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

Q8DihK4X*Wy{OMZSvDb4Y(% z)I-Wsh)s-DW^mz8@|%_6-#k9c+ox3Zy%*6&8U7-Wq=(05?G>8=dF~JZ&TtQMecmXE$f=MQ)i04ls)u9918bY1YKQxoT zb4Bo6=;eBpUP;u|4|%5O49Y|yotnTn>JasM`tIg?0OfHWq zcw}Br(rnqYjUT?JP^$gm0YIh#vpVlqnh5JsRyD>&=42A{81m>;Wd*!IzQ z%8T`)>Z1H@hIkD2)q`*EmV$LU-EInI>w)QGp?hXT?cKd9MUL(!7@r>UKq7wFAu#n6 z_u|=dj*Z}i^N=3RY+>g#@B(6Uu+VLJlRTpg)w=U3) zUi$<)Q3@8qq4RU#{$126NldQ1A zSYjBTKD2<%W3oSjDR{iZ1j&RjszM-8;SM^8WZePXiB>~wF|V2fxJd)i*EPOUr0o#; EKlrEFfdBvi literal 0 HcmV?d00001 diff --git a/ui/litellm-dashboard/src/components/agents.tsx b/ui/litellm-dashboard/src/components/agents.tsx index b8a9306990..d6d8b320ec 100644 --- a/ui/litellm-dashboard/src/components/agents.tsx +++ b/ui/litellm-dashboard/src/components/agents.tsx @@ -91,14 +91,14 @@ const AgentsPanel: React.FC = ({ accessToken, userRole }) => { return (

-
-
-

Agents

-

List of A2A-spec agents that are available to be used in your organization. Go to AI Hub, to make agents public.

+
+

Agents

+

List of A2A-spec agents that are available to be used in your organization. Go to AI Hub, to make agents public.

+
+
-
{selectedAgentId ? ( diff --git a/ui/litellm-dashboard/src/components/agents/agent_info.tsx b/ui/litellm-dashboard/src/components/agents/agent_info.tsx index 626e352bd3..8d0febd841 100644 --- a/ui/litellm-dashboard/src/components/agents/agent_info.tsx +++ b/ui/litellm-dashboard/src/components/agents/agent_info.tsx @@ -2,11 +2,13 @@ import React, { useState, useEffect } from "react"; import { Card, Title, Text, Button as TremorButton, Tab, TabGroup, TabList, TabPanel, TabPanels} from "@tremor/react"; import { Form, Input, Button as AntButton, message, Spin, Descriptions } from "antd"; import { ArrowLeftIcon } from "@heroicons/react/outline"; -import { getAgentInfo, patchAgentCall } from "../networking"; +import { getAgentInfo, patchAgentCall, getAgentCreateMetadata, AgentCreateInfo } from "../networking"; import { Agent } from "./types"; import AgentFormFields from "./agent_form_fields"; +import DynamicAgentFormFields, { buildDynamicAgentData } from "./dynamic_agent_form_fields"; import { buildAgentDataFromForm, parseAgentForForm } from "./agent_config"; import AgentCostView from "./agent_cost_view"; +import { detectAgentType, parseDynamicAgentForForm } from "./agent_type_utils"; interface AgentInfoViewProps { agentId: string; @@ -26,6 +28,20 @@ const AgentInfoView: React.FC = ({ const [isEditing, setIsEditing] = useState(false); const [isSaving, setIsSaving] = useState(false); const [form] = Form.useForm(); + const [agentTypeMetadata, setAgentTypeMetadata] = useState([]); + const [detectedAgentType, setDetectedAgentType] = useState("a2a"); + + useEffect(() => { + const fetchMetadata = async () => { + try { + const metadata = await getAgentCreateMetadata(); + setAgentTypeMetadata(metadata); + } catch (error) { + console.error("Error fetching agent metadata:", error); + } + }; + fetchMetadata(); + }, []); useEffect(() => { fetchAgentInfo(); @@ -38,7 +54,22 @@ const AgentInfoView: React.FC = ({ try { const data = await getAgentInfo(accessToken, agentId); setAgent(data); + + // Detect agent type + const agentType = detectAgentType(data); + setDetectedAgentType(agentType); + + // Parse form values based on agent type + if (agentType === "a2a") { + form.setFieldsValue(parseAgentForForm(data)); + } else { + const typeInfo = agentTypeMetadata.find(t => t.agent_type === agentType); + if (typeInfo) { + form.setFieldsValue(parseDynamicAgentForForm(data, typeInfo)); + } else { form.setFieldsValue(parseAgentForForm(data)); + } + } } catch (error) { console.error("Error fetching agent info:", error); message.error("Failed to load agent information"); @@ -47,12 +78,38 @@ const AgentInfoView: React.FC = ({ } }; + // Re-parse form when metadata is loaded + useEffect(() => { + if (agent && agentTypeMetadata.length > 0) { + const agentType = detectAgentType(agent); + if (agentType !== "a2a") { + const typeInfo = agentTypeMetadata.find(t => t.agent_type === agentType); + if (typeInfo) { + form.setFieldsValue(parseDynamicAgentForForm(agent, typeInfo)); + } + } + } + }, [agentTypeMetadata, agent]); + + const selectedAgentTypeInfo = agentTypeMetadata.find(t => t.agent_type === detectedAgentType); + const handleUpdate = async (values: any) => { if (!accessToken || !agent) return; setIsSaving(true); try { - const updateData = buildAgentDataFromForm(values, agent); + let updateData: any; + + if (detectedAgentType === "a2a") { + updateData = buildAgentDataFromForm(values, agent); + } else if (selectedAgentTypeInfo) { + updateData = buildDynamicAgentData(values, selectedAgentTypeInfo); + // Preserve the agent_name from form + updateData.agent_name = values.agent_name; + } else { + updateData = buildAgentDataFromForm(values, agent); + } + await patchAgentCall(accessToken, agentId, updateData); message.success("Agent updated successfully"); setIsEditing(false); @@ -192,7 +249,13 @@ const AgentInfoView: React.FC = ({ + {detectedAgentType === "a2a" ? ( + + ) : selectedAgentTypeInfo ? ( + + ) : ( + )}
{ diff --git a/ui/litellm-dashboard/src/components/agents/agent_table.tsx b/ui/litellm-dashboard/src/components/agents/agent_table.tsx index b141122c94..7634c4396f 100644 --- a/ui/litellm-dashboard/src/components/agents/agent_table.tsx +++ b/ui/litellm-dashboard/src/components/agents/agent_table.tsx @@ -51,18 +51,18 @@ const AgentTable: React.FC = ({ cell: ({ row }) => { const agent = row.original; const name = agent.agent_name || ""; - return ( + return (
- - + + { @@ -201,12 +201,12 @@ const AgentTable: React.FC = ({

No agents found. Create one to get started.

-
-
+
+ )} - - + +
); diff --git a/ui/litellm-dashboard/src/components/agents/agent_type_utils.ts b/ui/litellm-dashboard/src/components/agents/agent_type_utils.ts new file mode 100644 index 0000000000..fd04aa4c26 --- /dev/null +++ b/ui/litellm-dashboard/src/components/agents/agent_type_utils.ts @@ -0,0 +1,67 @@ +import { Agent } from "./types"; +import { AgentCreateInfo } from "../networking"; + +/** + * Detects the agent type from an agent's litellm_params. + * Returns the agent_type string (e.g., "langgraph", "azure_ai_foundry", "bedrock_agentcore", or "a2a") + */ +export const detectAgentType = (agent: Agent): string => { + const model = agent.litellm_params?.model || ""; + const customProvider = agent.litellm_params?.custom_llm_provider; + + // Check by custom_llm_provider first + if (customProvider === "langgraph") return "langgraph"; + if (customProvider === "azure_ai") return "azure_ai_foundry"; + if (customProvider === "bedrock") return "bedrock_agentcore"; + + // Check by model prefix + if (model.startsWith("langgraph/")) return "langgraph"; + if (model.startsWith("azure_ai/agents/")) return "azure_ai_foundry"; + if (model.startsWith("bedrock/agentcore/")) return "bedrock_agentcore"; + + // Default to a2a + return "a2a"; +}; + +/** + * Parses agent data for dynamic form fields (non-A2A agents). + * Extracts values from litellm_params based on the agent type metadata. + */ +export const parseDynamicAgentForForm = ( + agent: Agent, + agentTypeInfo: AgentCreateInfo +): Record => { + const values: Record = { + agent_name: agent.agent_name, + description: agent.agent_card_params?.description || "", + }; + + // Extract credential field values from litellm_params + for (const field of agentTypeInfo.credential_fields) { + if (field.include_in_litellm_params !== false) { + values[field.key] = agent.litellm_params?.[field.key] || field.default_value || ""; + } else { + // For fields not in litellm_params (like agent_id), try to extract from model string + if (agentTypeInfo.model_template && agent.litellm_params?.model) { + const model = agent.litellm_params.model; + const templateParts = agentTypeInfo.model_template.split("/"); + const modelParts = model.split("/"); + + // Find the placeholder position and extract the value + templateParts.forEach((part, index) => { + if (part === `{${field.key}}` && modelParts[index]) { + values[field.key] = modelParts[index]; + } + }); + } + } + } + + // Extract cost configuration + values.cost_per_query = agent.litellm_params?.cost_per_query; + values.input_cost_per_token = agent.litellm_params?.input_cost_per_token; + values.output_cost_per_token = agent.litellm_params?.output_cost_per_token; + + return values; +}; + diff --git a/ui/litellm-dashboard/src/components/navbar.tsx b/ui/litellm-dashboard/src/components/navbar.tsx index 2e0edcfcd8..e032649ae2 100644 --- a/ui/litellm-dashboard/src/components/navbar.tsx +++ b/ui/litellm-dashboard/src/components/navbar.tsx @@ -165,7 +165,16 @@ const Navbar: React.FC = ({
- LiteLLM Brand +
+ LiteLLM Brand + + 🎄 + +
{version && ( = ({
)} + {/* Suggested prompts - show when chat is empty and not loading */} + {chatHistory.length === 0 && !isLoading && ( +
+ {(endpointType === EndpointType.A2A_AGENTS + ? ["What can you help me with?", "Tell me about yourself", "What tasks can you perform?"] + : ["Write me a poem", "Explain quantum computing", "Draft a polite email requesting a meeting"] + ).map((prompt) => ( + + ))} +
+ )} +
{/* Left: attachment and code interpreter icons */} diff --git a/ui/litellm-dashboard/src/components/playground/compareUI/CompareUI.tsx b/ui/litellm-dashboard/src/components/playground/compareUI/CompareUI.tsx index 9ba0953505..85cfd160a2 100644 --- a/ui/litellm-dashboard/src/components/playground/compareUI/CompareUI.tsx +++ b/ui/litellm-dashboard/src/components/playground/compareUI/CompareUI.tsx @@ -11,11 +11,25 @@ import type { TokenUsage } from "../chat_ui/ResponseMetrics"; import type { MessageType, VectorStoreSearchResponse } from "../chat_ui/types"; import { makeOpenAIChatCompletionRequest } from "../llm_calls/chat_completion"; import { fetchAvailableModels } from "../llm_calls/fetch_models"; +import { Agent, fetchAvailableAgents } from "../llm_calls/fetch_agents"; +import { makeA2AStreamMessageRequest } from "../llm_calls/a2a_send_message"; import { ComparisonPanel } from "./components/ComparisonPanel"; import { MessageInput } from "./components/MessageInput"; +import { + EndpointId, + EndpointIdType, + getAvailableEndpoints, + getEndpointConfig, + isAgentEndpoint, + hasValidSelection, + getComparisonSelection, + modelOptionsToSelectorOptions, + agentOptionsToSelectorOptions, +} from "./endpoint_config"; export interface ComparisonInstance { id: string; model: string; + agent: string; messages: MessageType[]; isLoading: boolean; tags: string[]; @@ -38,12 +52,13 @@ const GENERIC_FOLLOW_UPS = [ "What are the next steps?", ]; const SUGGESTED_PROMPTS = ["Write me a poem", "Explain quantum computing", "Draft a polite email requesting a meeting"]; -const DEFAULT_ENDPOINT = "/v1/chat/completions"; +const DEFAULT_ENDPOINT = EndpointId.CHAT_COMPLETIONS; export default function CompareUI({ accessToken, disabledPersonalKeyCreation }: CompareUIProps) { const [comparisons, setComparisons] = useState([ { id: "1", model: "", + agent: "", messages: [], isLoading: false, tags: [], @@ -58,6 +73,7 @@ export default function CompareUI({ accessToken, disabledPersonalKeyCreation }: { id: "2", model: "", + agent: "", messages: [], isLoading: false, tags: [], @@ -71,7 +87,18 @@ export default function CompareUI({ accessToken, disabledPersonalKeyCreation }: }, ]); const [modelOptions, setModelOptions] = useState([]); + const [agentOptions, setAgentOptions] = useState([]); const [isLoadingModels, setIsLoadingModels] = useState(false); + const [isLoadingAgents, setIsLoadingAgents] = useState(false); + const [selectedEndpoint, setSelectedEndpoint] = useState(DEFAULT_ENDPOINT); + + // Derived state from endpoint config + const endpointConfig = getEndpointConfig(selectedEndpoint); + const isA2AMode = isAgentEndpoint(selectedEndpoint); + const selectorOptions = isA2AMode + ? agentOptionsToSelectorOptions(agentOptions) + : modelOptionsToSelectorOptions(modelOptions); + const isLoadingOptions = isA2AMode ? isLoadingAgents : isLoadingModels; const [inputValue, setInputValue] = useState(""); const [uploadedFile, setUploadedFile] = useState(null); const [uploadedFilePreviewUrl, setUploadedFilePreviewUrl] = useState(null); @@ -134,6 +161,37 @@ export default function CompareUI({ accessToken, disabledPersonalKeyCreation }: active = false; }; }, [effectiveApiKey]); + + // Fetch agents when A2A mode is selected + useEffect(() => { + let active = true; + const loadAgents = async () => { + if (!effectiveApiKey || !isA2AMode) { + setAgentOptions([]); + return; + } + setIsLoadingAgents(true); + try { + const agents = await fetchAvailableAgents(effectiveApiKey); + if (!active) return; + setAgentOptions(agents); + } catch (error) { + console.error("CompareUI: failed to fetch agents", error); + if (active) { + setAgentOptions([]); + } + } finally { + if (active) { + setIsLoadingAgents(false); + } + } + }; + loadAgents(); + return () => { + active = false; + }; + }, [effectiveApiKey, isA2AMode]); + useEffect(() => { if (modelOptions.length === 0) { return; @@ -160,10 +218,12 @@ export default function CompareUI({ accessToken, disabledPersonalKeyCreation }: if (comparisons.length >= maxComparisons) { return; } - const fallback = modelOptions[comparisons.length % (modelOptions.length || 1)] ?? ""; + const fallbackModel = modelOptions[comparisons.length % (modelOptions.length || 1)] ?? ""; + const fallbackAgent = agentOptions[comparisons.length % (agentOptions.length || 1)]?.agent_name ?? ""; const newComparison: ComparisonInstance = { id: Date.now().toString(), - model: fallback, + model: fallbackModel, + agent: fallbackAgent, messages: [], isLoading: false, tags: [], @@ -430,8 +490,9 @@ export default function CompareUI({ accessToken, disabledPersonalKeyCreation }: if (targetComparisons.length === 0) { return; } - if (targetComparisons.some((comparison) => !comparison.model)) { - NotificationsManager.fromBackend("Select a model before sending a message."); + // Validate selection based on endpoint type + if (targetComparisons.some((comparison) => !hasValidSelection(comparison, selectedEndpoint))) { + NotificationsManager.fromBackend(endpointConfig.validationMessage); return; } @@ -450,6 +511,8 @@ export default function CompareUI({ accessToken, disabledPersonalKeyCreation }: { id: string; model: string; + agent: string; + inputMessage: string; traceId: string; tags: string[]; vectorStores: string[]; @@ -472,6 +535,8 @@ export default function CompareUI({ accessToken, disabledPersonalKeyCreation }: preparedTargets.set(comparison.id, { id: comparison.id, model: comparison.model, + agent: comparison.agent, + inputMessage: trimmed, traceId, tags: comparison.tags, vectorStores: comparison.vectorStores, @@ -508,26 +573,55 @@ export default function CompareUI({ accessToken, disabledPersonalKeyCreation }: const guardrails = prepared.guardrails.length > 0 ? prepared.guardrails : undefined; const comparison = comparisons.find((c) => c.id === prepared.id); const useAdvancedParams = comparison?.useAdvancedParams ?? false; - makeOpenAIChatCompletionRequest( - prepared.apiChatHistory, - (chunk, model) => appendAssistantChunk(prepared.id, chunk, model), - prepared.model, - effectiveApiKey, - tags, - undefined, - (content) => appendReasoningContent(prepared.id, content), - (time) => updateTimingDataForComparison(prepared.id, time), - (usage) => updateUsageDataForComparison(prepared.id, usage), - prepared.traceId, - vectorStoreIds, - guardrails, - undefined, - undefined, - (searchResults) => updateSearchResultsForComparison(prepared.id, searchResults), - useAdvancedParams ? prepared.temperature : undefined, - useAdvancedParams ? prepared.maxTokens : undefined, - (latency) => updateTotalLatencyForComparison(prepared.id, latency), - ) + + // Use A2A or chat completion based on endpoint + const requestPromise = isA2AMode + ? makeA2AStreamMessageRequest( + prepared.agent, + prepared.inputMessage, + (text, model) => { + // A2A sends full accumulated text, so replace instead of append + setComparisons((prev) => + prev.map((c) => { + if (c.id !== prepared.id) return c; + const messages = [...c.messages]; + const last = messages[messages.length - 1]; + if (last && last.role === "assistant") { + messages[messages.length - 1] = { ...last, content: text, model: last.model ?? model }; + } else { + messages.push({ role: "assistant", content: text, model }); + } + return { ...c, messages }; + }), + ); + }, + effectiveApiKey, + undefined, + (time) => updateTimingDataForComparison(prepared.id, time), + (latency) => updateTotalLatencyForComparison(prepared.id, latency), + ) + : makeOpenAIChatCompletionRequest( + prepared.apiChatHistory, + (chunk, model) => appendAssistantChunk(prepared.id, chunk, model), + prepared.model, + effectiveApiKey, + tags, + undefined, + (content) => appendReasoningContent(prepared.id, content), + (time) => updateTimingDataForComparison(prepared.id, time), + (usage) => updateUsageDataForComparison(prepared.id, usage), + prepared.traceId, + vectorStoreIds, + guardrails, + undefined, + undefined, + (searchResults) => updateSearchResultsForComparison(prepared.id, searchResults), + useAdvancedParams ? prepared.temperature : undefined, + useAdvancedParams ? prepared.maxTokens : undefined, + (latency) => updateTotalLatencyForComparison(prepared.id, latency), + ); + + requestPromise .catch((error) => { const errorMessage = error instanceof Error ? error.message : String(error); console.error("CompareUI: failed to fetch response", error); @@ -618,11 +712,20 @@ export default function CompareUI({ accessToken, disabledPersonalKeyCreation }:
Endpoint - - - +
{uploadedFile && ( diff --git a/ui/litellm-dashboard/src/components/playground/compareUI/components/ComparisonPanel.tsx b/ui/litellm-dashboard/src/components/playground/compareUI/components/ComparisonPanel.tsx index c9a99b3d5c..5073d4549c 100644 --- a/ui/litellm-dashboard/src/components/playground/compareUI/components/ComparisonPanel.tsx +++ b/ui/litellm-dashboard/src/components/playground/compareUI/components/ComparisonPanel.tsx @@ -2,11 +2,13 @@ import { Settings, X } from "lucide-react"; import { useState } from "react"; import { ComparisonInstance } from "../CompareUI"; import { MessageDisplay } from "./MessageDisplay"; -import { ModelSelector } from "./ModelSelector"; +import { UnifiedSelector } from "./UnifiedSelector"; import TagSelector from "../../../tag_management/TagSelector"; import VectorStoreSelector from "../../../vector_store_management/VectorStoreSelector"; import GuardrailSelector from "../../../guardrails/GuardrailSelector"; import { Checkbox, Divider, Popover, Slider } from "antd"; +import { SelectorOption, EndpointConfig, isAgentEndpoint, getComparisonSelection } from "../endpoint_config"; + interface ComparisonPanelProps { comparison: ComparisonInstance; onUpdate: ( @@ -15,8 +17,9 @@ interface ComparisonPanelProps { ) => void; onRemove: () => void; canRemove: boolean; - modelOptions: string[]; - isLoadingModels: boolean; + selectorOptions: SelectorOption[]; + isLoadingOptions: boolean; + endpointConfig: EndpointConfig; apiKey: string; } export function ComparisonPanel({ @@ -24,10 +27,13 @@ export function ComparisonPanel({ onUpdate, onRemove, canRemove, - modelOptions, - isLoadingModels, + selectorOptions, + isLoadingOptions, + endpointConfig, apiKey, }: ComparisonPanelProps) { + const isA2AMode = isAgentEndpoint(endpointConfig.id); + const currentSelection = getComparisonSelection(comparison, endpointConfig.id); const [popoverVisible, setPopoverVisible] = useState(false); const handleSyncChange = (checked: boolean) => { @@ -194,14 +200,13 @@ export function ComparisonPanel({
- - onUpdate({ - model, - }) + + onUpdate(isA2AMode ? { agent: value } : { model: value }) } />
diff --git a/ui/litellm-dashboard/src/components/playground/compareUI/components/UnifiedSelector.tsx b/ui/litellm-dashboard/src/components/playground/compareUI/components/UnifiedSelector.tsx new file mode 100644 index 0000000000..c531eed373 --- /dev/null +++ b/ui/litellm-dashboard/src/components/playground/compareUI/components/UnifiedSelector.tsx @@ -0,0 +1,48 @@ +/** + * Unified selector component that handles both model and agent selection + * based on the current endpoint configuration. + */ + +import { Select, Spin } from "antd"; +import { SelectorOption, EndpointConfig } from "../endpoint_config"; + +interface UnifiedSelectorProps { + value: string; + options: SelectorOption[]; + loading: boolean; + config: EndpointConfig; + onChange: (value: string) => void; +} + +export function UnifiedSelector({ + value, + options, + loading, + config, + onChange, +}: UnifiedSelectorProps) { + return ( +