diff --git a/docs/my-website/docs/proxy/ai_hub.md b/docs/my-website/docs/proxy/ai_hub.md new file mode 100644 index 00000000000..a7865db6cdb --- /dev/null +++ b/docs/my-website/docs/proxy/ai_hub.md @@ -0,0 +1,240 @@ +import Image from '@theme/IdealImage'; +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# AI Hub + +Share models and agents with your organization. Show developers what's available without needing to rebuild them. + +This feature is **available in v1.74.3-stable and above**. + +## Overview + +Admin can select models/agents to expose on public AI hub → Users go to the public url and see what's available. + + + +## Models + +### How to use + +#### 1. Go to the Admin UI + +Navigate to the Model Hub page in the Admin UI (`PROXY_BASE_URL/ui/?login=success&page=model-hub-table`) + + + +#### 2. Select the models you want to expose + +Click on `Select Models to Make Public` and select the models you want to expose. + + + +#### 3. Confirm the changes + + + +#### 4. Success! + +Go to the public url (`PROXY_BASE_URL/ui/model_hub_table`) and see available models. + + + +### API Endpoints + +- `GET /public/model_hub` – returns the list of public model groups. Requires a valid user API key. +- `GET /public/model_hub/info` – returns metadata (docs title, version, useful links) for the public model hub. + +## Agents + +:::info +Agents are only available in v1.79.4-stable and above. +::: + +Share pre-built agents (A2A spec) across your organization. Users can discover and use agents without rebuilding them. + +[**Demo Video**](https://drive.google.com/file/d/1r-_Rtiu04RW5Fwwu3_eshtA1oZtC3_DH/view?usp=sharing) + +### 1. Create an agent + +Create an agent that follows the [A2A spec](https://a2a.dev/). + + + + + + + + +```bash +curl -X POST 'http://0.0.0.0:4000/v1/agents' \ +--header 'Authorization: Bearer ' \ +--header 'Content-Type: application/json' \ +--data '{ + "agent_name": "hello-world-agent", + "agent_card_params": { + "protocolVersion": "1.0", + "name": "Hello World Agent", + "description": "Just a hello world agent", + "url": "http://localhost:9999/", + "version": "1.0.0", + "defaultInputModes": ["text"], + "defaultOutputModes": ["text"], + "capabilities": { + "streaming": true + }, + "skills": [ + { + "id": "hello_world", + "name": "Returns hello world", + "description": "just returns hello world", + "tags": ["hello world"], + "examples": ["hi", "hello world"] + } + ] + } +}' +``` + +**Expected Response** + +```json +{ + "agent_id": "123e4567-e89b-12d3-a456-426614174000", + "agent_name": "hello-world-agent", + "agent_card_params": { + "protocolVersion": "1.0", + "name": "Hello World Agent", + "description": "Just a hello world agent", + "url": "http://localhost:9999/", + "version": "1.0.0", + "defaultInputModes": ["text"], + "defaultOutputModes": ["text"], + "capabilities": { + "streaming": true + }, + "skills": [ + { + "id": "hello_world", + "name": "Returns hello world", + "description": "just returns hello world", + "tags": ["hello world"], + "examples": ["hi", "hello world"] + } + ] + }, + "created_at": "2025-11-15T10:30:00Z", + "created_by": "user123" +} +``` + + + + +### 2. Make agent public + +Make the agent discoverable on the AI Hub. + + + + +Navigate to the Agents Tab on the AI Hub page + + + +Select the agents you want to make public and click on `Make Public` button. + + + + + + +**Option 1: Make single agent public** + +```bash +curl -X POST 'http://0.0.0.0:4000/v1/agents/123e4567-e89b-12d3-a456-426614174000/make_public' \ +--header 'Authorization: Bearer ' \ +--header 'Content-Type: application/json' +``` + +**Option 2: Make multiple agents public** + + +```bash +curl -X POST 'http://0.0.0.0:4000/v1/agents/make_public' \ +--header 'Authorization: Bearer ' \ +--header 'Content-Type: application/json' \ +--data '{ + "agent_ids": [ + "123e4567-e89b-12d3-a456-426614174000", + "123e4567-e89b-12d3-a456-426614174001" + ] +}' +``` + +**Expected Response** + +```json +{ + "message": "Successfully updated public agent groups", + "public_agent_groups": [ + "123e4567-e89b-12d3-a456-426614174000" + ], + "updated_by": "user123" +} +``` + + + + + + + +### 3. View public agents + +Users can now discover the agent via the public endpoint. + + + + + + + + + +```bash +curl -X GET 'http://0.0.0.0:4000/public/agent_hub' \ +--header 'Authorization: Bearer ' +``` + +**Expected Response** + +```json +[ + { + "protocolVersion": "1.0", + "name": "Hello World Agent", + "description": "Just a hello world agent", + "url": "http://localhost:9999/", + "version": "1.0.0", + "defaultInputModes": ["text"], + "defaultOutputModes": ["text"], + "capabilities": { + "streaming": true + }, + "skills": [ + { + "id": "hello_world", + "name": "Returns hello world", + "description": "just returns hello world", + "tags": ["hello world"], + "examples": ["hi", "hello world"] + } + ] + } +] +``` + + + + diff --git a/docs/my-website/docs/proxy/enterprise.md b/docs/my-website/docs/proxy/enterprise.md index b5d8ab59077..cfd6ab31015 100644 --- a/docs/my-website/docs/proxy/enterprise.md +++ b/docs/my-website/docs/proxy/enterprise.md @@ -901,9 +901,11 @@ curl --location 'http://0.0.0.0:4000/chat/completions' \ ' ``` -## Public Model Hub +## Public AI Hub -Share a public page of available models for users +Share a public page of available models and agents for users + +[Learn more](./ai_hub.md) diff --git a/docs/my-website/docs/proxy/model_hub.md b/docs/my-website/docs/proxy/model_hub.md deleted file mode 100644 index 6c12194d751..00000000000 --- a/docs/my-website/docs/proxy/model_hub.md +++ /dev/null @@ -1,53 +0,0 @@ -import Image from '@theme/IdealImage'; -import Tabs from '@theme/Tabs'; -import TabItem from '@theme/TabItem'; - -# Model Hub - -Tell developers what models are available on the proxy. - -This feature is **available in v1.74.3-stable and above**. - -## Overview - -Admin can select models to expose on public model hub -> Users can go to the public url (`/ui/model_hub_table`) and see available models. - - - -## How to use - -### 1. Go to the Admin UI - -Navigate to the Model Hub page in the Admin UI (`PROXY_BASE_URL/ui/?login=success&page=model-hub-table`) - - - -### 2. Select the models you want to expose - -Click on `Make Public` and select the models you want to expose. - - - -### 3. Confirm the changes - - - -### 4. Success! - -Go to the public url (`PROXY_BASE_URL/ui/model_hub_table`) and see available models. - - - -## API Endpoints - -LiteLLM also exposes REST endpoints: - -- `GET /public/model_hub` – returns the list of public model groups. Requires a valid user API key. -- `GET /public/model_hub/info` – returns metadata (docs title, version, useful links) for the public model hub. -- `GET /public/providers` – returns a sorted list of all providers supported by LiteLLM. No authentication required. - -Example: - -```bash -curl -s PROXY_BASE_URL/public/providers | jq -``` diff --git a/docs/my-website/docs/proxy/ui.md b/docs/my-website/docs/proxy/ui.md index f7419d20740..f6fa02fb69b 100644 --- a/docs/my-website/docs/proxy/ui.md +++ b/docs/my-website/docs/proxy/ui.md @@ -59,11 +59,13 @@ Allow others to create/delete their own keys. The Admin UI provides comprehensive model management capabilities: - **Add Models**: Add new models through the UI without restarting the proxy -- **Model Hub**: Make models public for developers to discover available models +- **AI Hub**: Make models and agents public for developers to discover what's available - **Price Data Sync**: Keep model pricing data up to date by syncing from GitHub For detailed information on model management, see [Model Management](./model_management.md). +For information on sharing models and agents, see [AI Hub](./ai_hub.md). + :::tip Sync Model Pricing Data [Sync model pricing data from GitHub](./sync_models_github.md) to keep your model cost information current. ::: diff --git a/docs/my-website/img/add_agent.png b/docs/my-website/img/add_agent.png new file mode 100644 index 00000000000..f9a96b95e30 Binary files /dev/null and b/docs/my-website/img/add_agent.png differ diff --git a/docs/my-website/img/ai_hub_with_agents.png b/docs/my-website/img/ai_hub_with_agents.png new file mode 100644 index 00000000000..f61214636c1 Binary files /dev/null and b/docs/my-website/img/ai_hub_with_agents.png differ diff --git a/docs/my-website/img/make_agents_public.png b/docs/my-website/img/make_agents_public.png new file mode 100644 index 00000000000..25cf57ae751 Binary files /dev/null and b/docs/my-website/img/make_agents_public.png differ diff --git a/docs/my-website/img/public_agent_hub.png b/docs/my-website/img/public_agent_hub.png new file mode 100644 index 00000000000..24f47da12b0 Binary files /dev/null and b/docs/my-website/img/public_agent_hub.png differ diff --git a/docs/my-website/sidebars.js b/docs/my-website/sidebars.js index 92592d3a473..dbd33e05371 100644 --- a/docs/my-website/sidebars.js +++ b/docs/my-website/sidebars.js @@ -149,7 +149,7 @@ const sidebars = { "proxy/admin_ui_sso", "proxy/custom_root_ui", "proxy/custom_sso", - "proxy/model_hub", + "proxy/ai_hub", "proxy/public_teams", "proxy/self_serve", "proxy/ui", diff --git a/litellm-proxy-extras/litellm_proxy_extras/migrations/20251115120021_baseline_diff/migration.sql b/litellm-proxy-extras/litellm_proxy_extras/migrations/20251115120021_baseline_diff/migration.sql new file mode 100644 index 00000000000..2f725d83806 --- /dev/null +++ b/litellm-proxy-extras/litellm_proxy_extras/migrations/20251115120021_baseline_diff/migration.sql @@ -0,0 +1,2 @@ +-- This is an empty migration. + diff --git a/litellm-proxy-extras/litellm_proxy_extras/migrations/20251115120539_baseline_diff/migration.sql b/litellm-proxy-extras/litellm_proxy_extras/migrations/20251115120539_baseline_diff/migration.sql new file mode 100644 index 00000000000..2f725d83806 --- /dev/null +++ b/litellm-proxy-extras/litellm_proxy_extras/migrations/20251115120539_baseline_diff/migration.sql @@ -0,0 +1,2 @@ +-- This is an empty migration. + diff --git a/litellm/__init__.py b/litellm/__init__.py index 170566a0164..b47c7b74d33 100644 --- a/litellm/__init__.py +++ b/litellm/__init__.py @@ -181,22 +181,22 @@ prometheus_initialize_budget_metrics: Optional[bool] = False require_auth_for_metrics_endpoint: Optional[bool] = False argilla_batch_size: Optional[int] = None datadog_use_v1: Optional[bool] = False # if you want to use v1 datadog logged payload. -gcs_pub_sub_use_v1: Optional[ - bool -] = False # if you want to use v1 gcs pubsub logged payload -generic_api_use_v1: Optional[ - bool -] = False # if you want to use v1 generic api logged payload +gcs_pub_sub_use_v1: Optional[bool] = ( + False # if you want to use v1 gcs pubsub logged payload +) +generic_api_use_v1: Optional[bool] = ( + False # if you want to use v1 generic api logged payload +) argilla_transformation_object: Optional[Dict[str, Any]] = None -_async_input_callback: List[ - Union[str, Callable, CustomLogger] -] = [] # internal variable - async custom callbacks are routed here. -_async_success_callback: List[ - Union[str, Callable, CustomLogger] -] = [] # internal variable - async custom callbacks are routed here. -_async_failure_callback: List[ - Union[str, Callable, CustomLogger] -] = [] # internal variable - async custom callbacks are routed here. +_async_input_callback: List[Union[str, Callable, CustomLogger]] = ( + [] +) # internal variable - async custom callbacks are routed here. +_async_success_callback: List[Union[str, Callable, CustomLogger]] = ( + [] +) # internal variable - async custom callbacks are routed here. +_async_failure_callback: List[Union[str, Callable, CustomLogger]] = ( + [] +) # internal variable - async custom callbacks are routed here. pre_call_rules: List[Callable] = [] post_call_rules: List[Callable] = [] turn_off_message_logging: Optional[bool] = False @@ -204,18 +204,18 @@ log_raw_request_response: bool = False redact_messages_in_exceptions: Optional[bool] = False redact_user_api_key_info: Optional[bool] = False filter_invalid_headers: Optional[bool] = False -add_user_information_to_llm_headers: Optional[ - bool -] = None # adds user_id, team_id, token hash (params from StandardLoggingMetadata) to request headers +add_user_information_to_llm_headers: Optional[bool] = ( + None # adds user_id, team_id, token hash (params from StandardLoggingMetadata) to request headers +) store_audit_logs = False # Enterprise feature, allow users to see audit logs ### end of callbacks ############# -email: Optional[ - str -] = None # Not used anymore, will be removed in next MAJOR release - https://github.com/BerriAI/litellm/discussions/648 -token: Optional[ - str -] = None # Not used anymore, will be removed in next MAJOR release - https://github.com/BerriAI/litellm/discussions/648 +email: Optional[str] = ( + None # Not used anymore, will be removed in next MAJOR release - https://github.com/BerriAI/litellm/discussions/648 +) +token: Optional[str] = ( + None # Not used anymore, will be removed in next MAJOR release - https://github.com/BerriAI/litellm/discussions/648 +) telemetry = True max_tokens: int = DEFAULT_MAX_TOKENS # OpenAI Defaults drop_params = bool(os.getenv("LITELLM_DROP_PARAMS", False)) @@ -271,9 +271,9 @@ use_client: bool = False ssl_verify: Union[str, bool] = True ssl_security_level: Optional[str] = None ssl_certificate: Optional[str] = None -ssl_ecdh_curve: Optional[ - str -] = None # Set to 'X25519' to disable PQC and improve performance +ssl_ecdh_curve: Optional[str] = ( + None # Set to 'X25519' to disable PQC and improve performance +) disable_streaming_logging: bool = False disable_token_counter: bool = False disable_add_transform_inline_image_block: bool = False @@ -319,20 +319,24 @@ enable_loadbalancing_on_batch_endpoints: Optional[bool] = None enable_caching_on_provider_specific_optional_params: bool = ( False # feature-flag for caching on optional params - e.g. 'top_k' ) -caching: bool = False # Not used anymore, will be removed in next MAJOR release - https://github.com/BerriAI/litellm/discussions/648 -caching_with_models: bool = False # # Not used anymore, will be removed in next MAJOR release - https://github.com/BerriAI/litellm/discussions/648 -cache: Optional[ - Cache -] = None # cache object <- use this - https://docs.litellm.ai/docs/caching +caching: bool = ( + False # Not used anymore, will be removed in next MAJOR release - https://github.com/BerriAI/litellm/discussions/648 +) +caching_with_models: bool = ( + False # # Not used anymore, will be removed in next MAJOR release - https://github.com/BerriAI/litellm/discussions/648 +) +cache: Optional[Cache] = ( + None # cache object <- use this - https://docs.litellm.ai/docs/caching +) default_in_memory_ttl: Optional[float] = None default_redis_ttl: Optional[float] = None default_redis_batch_cache_expiry: Optional[float] = None model_alias_map: Dict[str, str] = {} model_group_settings: Optional["ModelGroupSettings"] = None max_budget: float = 0.0 # set the max budget across all providers -budget_duration: Optional[ - str -] = None # proxy only - resets budget after fixed duration. You can set duration as seconds ("30s"), minutes ("30m"), hours ("30h"), days ("30d"). +budget_duration: Optional[str] = ( + None # proxy only - resets budget after fixed duration. You can set duration as seconds ("30s"), minutes ("30m"), hours ("30h"), days ("30d"). +) default_soft_budget: float = ( DEFAULT_SOFT_BUDGET # by default all litellm proxy keys have a soft budget of 50.0 ) @@ -341,7 +345,9 @@ forward_traceparent_to_llm_provider: bool = False _current_cost = 0.0 # private variable, used if max budget is set error_logs: Dict = {} -add_function_to_prompt: bool = False # if function calling not supported by api, append function call details to system prompt +add_function_to_prompt: bool = ( + False # if function calling not supported by api, append function call details to system prompt +) client_session: Optional[httpx.Client] = None aclient_session: Optional[httpx.AsyncClient] = None model_fallbacks: Optional[List] = None # Deprecated for 'litellm.fallbacks' @@ -379,8 +385,11 @@ prometheus_metrics_config: Optional[List] = None disable_add_prefix_to_prompt: bool = ( False # used by anthropic, to disable adding prefix to prompt ) -disable_copilot_system_to_assistant: bool = False # If false (default), converts all 'system' role messages to 'assistant' for GitHub Copilot compatibility. Set to true to disable this behavior. +disable_copilot_system_to_assistant: bool = ( + False # If false (default), converts all 'system' role messages to 'assistant' for GitHub Copilot compatibility. Set to true to disable this behavior. +) public_model_groups: Optional[List[str]] = None +public_agent_groups: Optional[List[str]] = None public_model_groups_links: Dict[str, str] = {} #### REQUEST PRIORITIZATION ####### priority_reservation: Optional[Dict[str, Union[float, PriorityReservationDict]]] = None @@ -390,13 +399,17 @@ priority_reservation_settings: "PriorityReservationSettings" = ( ######## Networking Settings ######## -use_aiohttp_transport: bool = True # Older variable, aiohttp is now the default. use disable_aiohttp_transport instead. +use_aiohttp_transport: bool = ( + True # Older variable, aiohttp is now the default. use disable_aiohttp_transport instead. +) aiohttp_trust_env: bool = False # set to true to use HTTP_ Proxy settings disable_aiohttp_transport: bool = False # Set this to true to use httpx instead disable_aiohttp_trust_env: bool = ( False # When False, aiohttp will respect HTTP(S)_PROXY env vars ) -force_ipv4: bool = False # when True, litellm will force ipv4 for all LLM requests. Some users have seen httpx ConnectionError when using ipv6. +force_ipv4: bool = ( + False # when True, litellm will force ipv4 for all LLM requests. Some users have seen httpx ConnectionError when using ipv6. +) module_level_aclient = AsyncHTTPHandler( timeout=request_timeout, client_alias="module level aclient" ) @@ -410,13 +423,13 @@ fallbacks: Optional[List] = None context_window_fallbacks: Optional[List] = None content_policy_fallbacks: Optional[List] = None allowed_fails: int = 3 -num_retries_per_request: Optional[ - int -] = None # for the request overall (incl. fallbacks + model retries) +num_retries_per_request: Optional[int] = ( + None # for the request overall (incl. fallbacks + model retries) +) ####### SECRET MANAGERS ##################### -secret_manager_client: Optional[ - Any -] = None # list of instantiated key management clients - e.g. azure kv, infisical, etc. +secret_manager_client: Optional[Any] = ( + None # list of instantiated key management clients - e.g. azure kv, infisical, etc. +) _google_kms_resource_name: Optional[str] = None _key_management_system: Optional[KeyManagementSystem] = None _key_management_settings: KeyManagementSettings = KeyManagementSettings() @@ -426,9 +439,9 @@ output_parse_pii: bool = False from litellm.litellm_core_utils.get_model_cost_map import get_model_cost_map model_cost = get_model_cost_map(url=model_cost_map_url) -cost_discount_config: Dict[ - str, float -] = {} # Provider-specific cost discounts {"vertex_ai": 0.05} = 5% discount +cost_discount_config: Dict[str, float] = ( + {} +) # Provider-specific cost discounts {"vertex_ai": 0.05} = 5% discount custom_prompt_dict: Dict[str, dict] = {} check_provider_endpoint = False @@ -1423,12 +1436,12 @@ from .types.llms.custom_llm import CustomLLMItem from .types.utils import GenericStreamingChunk custom_provider_map: List[CustomLLMItem] = [] -_custom_providers: List[ - str -] = [] # internal helper util, used to track names of custom providers -disable_hf_tokenizer_download: Optional[ - bool -] = None # disable huggingface tokenizer download. Defaults to openai clk100 +_custom_providers: List[str] = ( + [] +) # internal helper util, used to track names of custom providers +disable_hf_tokenizer_download: Optional[bool] = ( + None # disable huggingface tokenizer download. Defaults to openai clk100 +) global_disable_no_log_param: bool = False ### CLI UTILITIES ### diff --git a/litellm/constants.py b/litellm/constants.py index 3f763cad926..5c4198ef1e5 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -1,7 +1,9 @@ import os from typing import List, Literal -DEFAULT_HEALTH_CHECK_PROMPT = str(os.getenv("DEFAULT_HEALTH_CHECK_PROMPT", "test from litellm")) +DEFAULT_HEALTH_CHECK_PROMPT = str( + os.getenv("DEFAULT_HEALTH_CHECK_PROMPT", "test from litellm") +) AZURE_DEFAULT_RESPONSES_API_VERSION = str( os.getenv("AZURE_DEFAULT_RESPONSES_API_VERSION", "preview") ) @@ -18,7 +20,9 @@ DEFAULT_SQS_FLUSH_INTERVAL_SECONDS = int( DEFAULT_NUM_WORKERS_LITELLM_PROXY = int( os.getenv("DEFAULT_NUM_WORKERS_LITELLM_PROXY", 1) ) -DYNAMIC_RATE_LIMIT_ERROR_THRESHOLD_PER_MINUTE = int(os.getenv("DYNAMIC_RATE_LIMIT_ERROR_THRESHOLD_PER_MINUTE", 1)) +DYNAMIC_RATE_LIMIT_ERROR_THRESHOLD_PER_MINUTE = int( + os.getenv("DYNAMIC_RATE_LIMIT_ERROR_THRESHOLD_PER_MINUTE", 1) +) DEFAULT_SQS_BATCH_SIZE = int(os.getenv("DEFAULT_SQS_BATCH_SIZE", 512)) SQS_SEND_MESSAGE_ACTION = "SendMessage" SQS_API_VERSION = "2012-11-05" @@ -85,8 +89,12 @@ MAX_TOKEN_TRIMMING_ATTEMPTS = int( os.getenv("MAX_TOKEN_TRIMMING_ATTEMPTS", 10) ) # Maximum number of attempts to trim the message -RUNWAYML_DEFAULT_API_VERSION = str(os.getenv("RUNWAYML_DEFAULT_API_VERSION", "2024-11-06")) -RUNWAYML_POLLING_TIMEOUT = int(os.getenv("RUNWAYML_POLLING_TIMEOUT", 600)) # 10 minutes default for image generation +RUNWAYML_DEFAULT_API_VERSION = str( + os.getenv("RUNWAYML_DEFAULT_API_VERSION", "2024-11-06") +) +RUNWAYML_POLLING_TIMEOUT = int( + os.getenv("RUNWAYML_POLLING_TIMEOUT", 600) +) # 10 minutes default for image generation ########## Networking constants ############################################################## _DEFAULT_TTL_FOR_HTTPX_CLIENTS = 3600 # 1 hour, re-use the same httpx client for 1 hour @@ -110,22 +118,21 @@ REALTIME_WEBSOCKET_MAX_MESSAGE_SIZE_BYTES = ( DEFAULT_SSL_CIPHERS = os.getenv( "LITELLM_SSL_CIPHERS", # Priority 1: TLS 1.3 ciphers (fastest, ~50ms handshake) - "TLS_AES_256_GCM_SHA384:" # Fastest observed in testing - "TLS_AES_128_GCM_SHA256:" # Slightly faster than 256-bit - "TLS_CHACHA20_POLY1305_SHA256:" # Fast on ARM/mobile + "TLS_AES_256_GCM_SHA384:" # Fastest observed in testing + "TLS_AES_128_GCM_SHA256:" # Slightly faster than 256-bit + "TLS_CHACHA20_POLY1305_SHA256:" # Fast on ARM/mobile # Priority 2: TLS 1.2 ECDHE+GCM (fast, ~100ms handshake, widely supported) "ECDHE-RSA-AES256-GCM-SHA384:" "ECDHE-RSA-AES128-GCM-SHA256:" "ECDHE-ECDSA-AES256-GCM-SHA384:" "ECDHE-ECDSA-AES128-GCM-SHA256:" # Priority 3: Additional modern ciphers (good balance) - "ECDHE-RSA-CHACHA20-POLY1305:" - "ECDHE-ECDSA-CHACHA20-POLY1305:" + "ECDHE-RSA-CHACHA20-POLY1305:" "ECDHE-ECDSA-CHACHA20-POLY1305:" # Priority 4: Widely compatible fallbacks (slower but universally supported) - "ECDHE-RSA-AES256-SHA384:" # Common fallback - "ECDHE-RSA-AES128-SHA256:" # Very widely supported - "AES256-GCM-SHA384:" # Non-PFS fallback (compatibility) - "AES128-GCM-SHA256", # Last resort (maximum compatibility) + "ECDHE-RSA-AES256-SHA384:" # Common fallback + "ECDHE-RSA-AES128-SHA256:" # Very widely supported + "AES256-GCM-SHA384:" # Non-PFS fallback (compatibility) + "AES128-GCM-SHA256", # Last resort (maximum compatibility) ) ########### v2 Architecture constants for managing writing updates to the database ########### @@ -282,7 +289,9 @@ ANTHROPIC_WEB_SEARCH_TOOL_MAX_USES = { DEFAULT_IMAGE_ENDPOINT_MODEL = "dall-e-2" DEFAULT_VIDEO_ENDPOINT_MODEL = "sora-2" -DEFAULT_GOOGLE_VIDEO_DURATION_SECONDS = int(os.getenv("DEFAULT_GOOGLE_VIDEO_DURATION_SECONDS", 8)) +DEFAULT_GOOGLE_VIDEO_DURATION_SECONDS = int( + os.getenv("DEFAULT_GOOGLE_VIDEO_DURATION_SECONDS", 8) +) ### DATAFORSEO CONSTANTS ### DEFAULT_DATAFORSEO_LOCATION_CODE = int( @@ -371,7 +380,7 @@ LITELLM_CHAT_PROVIDERS = [ "vercel_ai_gateway", "wandb", "ovhcloud", - "lemonade" + "lemonade", ] LITELLM_EMBEDDING_PROVIDERS_SUPPORTING_INPUT_ARRAY_OF_TOKENS = [ @@ -631,7 +640,7 @@ clarifai_models: set = set( "clarifai/qwen.qwenLM.Qwen3-14B", "clarifai/qwen.qwenLM.QwQ-32B-AWQ", "clarifai/anthropic.completion.claude-3_5-haiku", - "clarifai/anthropic.completion.claude-3_7-sonnet", + "clarifai/anthropic.completion.claude-3_7-sonnet", ] ) @@ -797,28 +806,22 @@ WANDB_MODELS: set = set( # openai models "openai/gpt-oss-120b", "openai/gpt-oss-20b", - # zai-org models "zai-org/GLM-4.5", - # Qwen models "Qwen/Qwen3-235B-A22B-Instruct-2507", "Qwen/Qwen3-Coder-480B-A35B-Instruct", "Qwen/Qwen3-235B-A22B-Thinking-2507", - # moonshotai "moonshotai/Kimi-K2-Instruct", - # meta models "meta-llama/Llama-3.1-8B-Instruct", "meta-llama/Llama-3.3-70B-Instruct", "meta-llama/Llama-4-Scout-17B-16E-Instruct", - # deepseek-ai "deepseek-ai/DeepSeek-V3.1", "deepseek-ai/DeepSeek-R1-0528", "deepseek-ai/DeepSeek-V3-0324", - # microsoft "microsoft/Phi-4-mini-instruct", ] @@ -1032,7 +1035,9 @@ LITELLM_INTERNAL_JOBS_SERVICE_ACCOUNT_NAME = "litellm_internal_jobs" # Key Rotation Constants LITELLM_KEY_ROTATION_ENABLED = os.getenv("LITELLM_KEY_ROTATION_ENABLED", "false") -LITELLM_KEY_ROTATION_CHECK_INTERVAL_SECONDS = int(os.getenv("LITELLM_KEY_ROTATION_CHECK_INTERVAL_SECONDS", 86400)) # 24 hours default +LITELLM_KEY_ROTATION_CHECK_INTERVAL_SECONDS = int( + os.getenv("LITELLM_KEY_ROTATION_CHECK_INTERVAL_SECONDS", 86400) +) # 24 hours default UI_SESSION_TOKEN_TEAM_ID = "litellm-dashboard" LITELLM_PROXY_ADMIN_NAME = "default_user_id" @@ -1060,14 +1065,28 @@ PROXY_BATCH_POLLING_INTERVAL = int(os.getenv("PROXY_BATCH_POLLING_INTERVAL", 360 PROXY_BUDGET_RESCHEDULER_MAX_TIME = int( os.getenv("PROXY_BUDGET_RESCHEDULER_MAX_TIME", 605) ) -PROXY_BATCH_WRITE_AT = int(os.getenv("PROXY_BATCH_WRITE_AT", 10)) # in seconds, increased from 10 +PROXY_BATCH_WRITE_AT = int( + os.getenv("PROXY_BATCH_WRITE_AT", 10) +) # in seconds, increased from 10 # APScheduler Configuration - MEMORY LEAK FIX # These settings prevent memory leaks in APScheduler's normalize() and _apply_jitter() functions -APSCHEDULER_COALESCE = os.getenv("APSCHEDULER_COALESCE", "True").lower() in ["true", "1"] # collapse many missed runs into one -APSCHEDULER_MISFIRE_GRACE_TIME = int(os.getenv("APSCHEDULER_MISFIRE_GRACE_TIME", 3600)) # ignore runs older than 1 hour (was 120) -APSCHEDULER_MAX_INSTANCES = int(os.getenv("APSCHEDULER_MAX_INSTANCES", 1)) # prevent concurrent job instances -APSCHEDULER_REPLACE_EXISTING = os.getenv("APSCHEDULER_REPLACE_EXISTING", "True").lower() in ["true", "1"] # always replace existing jobs +APSCHEDULER_COALESCE = os.getenv("APSCHEDULER_COALESCE", "True").lower() in [ + "true", + "1", +] # collapse many missed runs into one +APSCHEDULER_MISFIRE_GRACE_TIME = int( + os.getenv("APSCHEDULER_MISFIRE_GRACE_TIME", 3600) +) # ignore runs older than 1 hour (was 120) +APSCHEDULER_MAX_INSTANCES = int( + os.getenv("APSCHEDULER_MAX_INSTANCES", 1) +) # prevent concurrent job instances +APSCHEDULER_REPLACE_EXISTING = os.getenv( + "APSCHEDULER_REPLACE_EXISTING", "True" +).lower() in [ + "true", + "1", +] # always replace existing jobs DEFAULT_HEALTH_CHECK_INTERVAL = int( os.getenv("DEFAULT_HEALTH_CHECK_INTERVAL", 300) @@ -1097,6 +1116,7 @@ SECRET_MANAGER_REFRESH_INTERVAL = int( ) LITELLM_SETTINGS_SAFE_DB_OVERRIDES = [ "default_internal_user_params", + "public_agent_groups", "public_model_groups", "public_model_groups_links", ] diff --git a/litellm/proxy/_new_secret_config.yaml b/litellm/proxy/_new_secret_config.yaml index 20d5cc53c50..901e2bf0c57 100644 --- a/litellm/proxy/_new_secret_config.yaml +++ b/litellm/proxy/_new_secret_config.yaml @@ -34,4 +34,4 @@ agent_list: make_public: true litellm_settings: - callbacks: ["prometheus"] \ No newline at end of file + callbacks: ["prometheus"] diff --git a/litellm/proxy/agent_endpoints/agent_registry.py b/litellm/proxy/agent_endpoints/agent_registry.py index f67bb9c617d..0d2df3856a1 100644 --- a/litellm/proxy/agent_endpoints/agent_registry.py +++ b/litellm/proxy/agent_endpoints/agent_registry.py @@ -1,42 +1,50 @@ +import hashlib +import json from datetime import datetime, timezone from typing import Any, Dict, List, Optional +import litellm from litellm.litellm_core_utils.safe_json_dumps import safe_dumps from litellm.proxy.utils import PrismaClient -from litellm.types.agents import AgentConfig +from litellm.types.agents import AgentConfig, AgentResponse, PatchAgentRequest class AgentRegistry: def __init__(self): - self.agent_list: List[AgentConfig] = [] + self.agent_list: List[AgentResponse] = [] def reset_agent_list(self): self.agent_list = [] - def register_agent(self, agent_config: AgentConfig): + def register_agent(self, agent_config: AgentResponse): self.agent_list.append(agent_config) def deregister_agent(self, agent_name: str): self.agent_list = [ - agent for agent in self.agent_list if agent.get("agent_name") != agent_name + agent for agent in self.agent_list if agent.agent_name != agent_name ] def get_agent_list(self, agent_names: Optional[List[str]] = None): if agent_names is not None: return [ - agent - for agent in self.agent_list - if agent.get("agent_name") in agent_names + agent for agent in self.agent_list if agent.agent_name in agent_names ] return self.agent_list - def get_public_agent_list(self): - public_agent_list = [] + def get_public_agent_list(self) -> List[AgentResponse]: + public_agent_list: List[AgentResponse] = [] + if litellm.public_agent_groups is None: + return public_agent_list for agent in self.agent_list: - if agent.get("litellm_params", {}).get("make_public", False) is True: + if agent.agent_id in litellm.public_agent_groups: public_agent_list.append(agent) return public_agent_list + def _create_agent_id(self, agent_config: AgentConfig) -> str: + return hashlib.sha256( + json.dumps(agent_config, sort_keys=True).encode() + ).hexdigest() + def load_agents_from_config(self, agent_config: Optional[List[AgentConfig]] = None): if agent_config is None: return None @@ -50,7 +58,10 @@ class AgentRegistry: if not all([agent_name, agent_card_params]): continue - self.register_agent(agent_config=agent_config_item) + # create a stable hash id for config item + config_hash = self._create_agent_id(agent_config_item) + + self.register_agent(agent_config=AgentResponse(agent_id=config_hash, **agent_config_item)) # type: ignore def load_agents_from_db_and_config( self, @@ -64,14 +75,14 @@ class AgentRegistry: if not isinstance(agent_config_item, dict): raise ValueError("agent_config must be a list of dictionaries") - self.register_agent(agent_config=agent_config_item) + self.register_agent(agent_config=AgentResponse(agent_id=self._create_agent_id(agent_config_item), **agent_config_item)) # type: ignore if db_agents: for db_agent in db_agents: if not isinstance(db_agent, dict): raise ValueError("db_agents must be a list of dictionaries") - self.register_agent(agent_config=AgentConfig(**db_agent)) # type: ignore + self.register_agent(agent_config=AgentResponse(**db_agent)) # type: ignore return self.agent_list ########################################################### @@ -79,7 +90,7 @@ class AgentRegistry: ############################################################ async def add_agent_to_db( self, agent: AgentConfig, prisma_client: PrismaClient, created_by: str - ) -> Dict[str, Any]: + ) -> AgentResponse: """ Add an agent to the database """ @@ -119,7 +130,7 @@ class AgentRegistry: } ) - return dict(created_agent) + return AgentResponse(**created_agent.model_dump()) # type: ignore except Exception as e: raise Exception(f"Error adding agent to DB: {str(e)}") @@ -137,13 +148,70 @@ class AgentRegistry: except Exception as e: raise Exception(f"Error deleting agent from DB: {str(e)}") + async def patch_agent_in_db( + self, + agent_id: str, + agent: PatchAgentRequest, + prisma_client: PrismaClient, + updated_by: str, + ) -> AgentResponse: + """ + Patch an agent in the database. + + Get the existing agent from the database and patch it with the new values. + + Args: + agent_id: The ID of the agent to patch + agent: The new agent values to patch + prisma_client: The Prisma client to use + updated_by: The user ID of the user who is patching the agent + + Returns: + The patched agent + """ + try: + + existing_agent = await prisma_client.db.litellm_agentstable.find_unique( + where={"agent_id": agent_id} + ) + if existing_agent is not None: + existing_agent = dict(existing_agent) + + if existing_agent is None: + raise Exception(f"Agent with ID {agent_id} not found") + + augment_agent = {**existing_agent, **agent} + update_data = {} + if augment_agent.get("agent_name"): + update_data["agent_name"] = augment_agent.get("agent_name") + if augment_agent.get("litellm_params"): + update_data["litellm_params"] = safe_dumps( + augment_agent.get("litellm_params") + ) + if augment_agent.get("agent_card_params"): + update_data["agent_card_params"] = safe_dumps( + augment_agent.get("agent_card_params") + ) + # Patch agent in DB + patched_agent = await prisma_client.db.litellm_agentstable.update( + where={"agent_id": agent_id}, + data={ + **update_data, + "updated_by": updated_by, + "updated_at": datetime.now(timezone.utc), + }, + ) + return AgentResponse(**patched_agent.model_dump()) # type: ignore + except Exception as e: + raise Exception(f"Error patching agent in DB: {str(e)}") + async def update_agent_in_db( self, agent_id: str, agent: AgentConfig, prisma_client: PrismaClient, updated_by: str, - ) -> Dict[str, Any]: + ) -> AgentResponse: """ Update an agent in the database """ @@ -182,7 +250,7 @@ class AgentRegistry: }, ) - return dict(updated_agent) + return AgentResponse(**updated_agent.model_dump()) # type: ignore except Exception as e: raise Exception(f"Error updating agent in DB: {str(e)}") @@ -209,27 +277,27 @@ class AgentRegistry: def get_agent_by_id( self, agent_id: str, - ) -> Optional[Dict[str, Any]]: + ) -> Optional[AgentResponse]: """ Get an agent by its ID from the database """ try: for agent in self.agent_list: - if agent.get("agent_id") == agent_id: - return dict(agent) + if agent.agent_id == agent_id: + return agent return None except Exception as e: raise Exception(f"Error getting agent from DB: {str(e)}") - def get_agent_by_name(self, agent_name: str) -> Optional[Dict[str, Any]]: + def get_agent_by_name(self, agent_name: str) -> Optional[AgentResponse]: """ Get an agent by its name from the database """ try: for agent in self.agent_list: - if agent.get("agent_name") == agent_name: - return dict(agent) + if agent.agent_name == agent_name: + return agent return None except Exception as e: diff --git a/litellm/proxy/agent_endpoints/endpoints.py b/litellm/proxy/agent_endpoints/endpoints.py index 87c3cb44c45..489c8e82302 100644 --- a/litellm/proxy/agent_endpoints/endpoints.py +++ b/litellm/proxy/agent_endpoints/endpoints.py @@ -12,10 +12,17 @@ from typing import Any, List from fastapi import APIRouter, Depends, HTTPException, Request +import litellm from litellm._logging import verbose_proxy_logger from litellm.proxy._types import CommonProxyErrors, LitellmUserRoles, UserAPIKeyAuth from litellm.proxy.auth.user_api_key_auth import user_api_key_auth -from litellm.types.agents import AgentConfig, AgentResponse +from litellm.types.agents import ( + AgentConfig, + AgentMakePublicResponse, + AgentResponse, + MakeAgentsPublicRequest, + PatchAgentRequest, +) router = APIRouter() @@ -24,7 +31,7 @@ router = APIRouter() "/v1/agents", tags=["[beta] Agents"], dependencies=[Depends(user_api_key_auth)], - response_model=List[AgentConfig], + response_model=List[AgentResponse], ) async def get_agents( request: Request, @@ -38,25 +45,40 @@ async def get_agents( -H "Authorization: Bearer your-key" \ ``` - Returns: List[AgentConfig] + Returns: List[AgentResponse] """ from litellm.proxy.agent_endpoints.agent_registry import global_agent_registry try: + returned_agents: List[AgentResponse] = [] if ( user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN or user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN.value ): - return global_agent_registry.get_agent_list() + returned_agents = global_agent_registry.get_agent_list() key_agents = user_api_key_dict.metadata.get("agents") _team_metadata = user_api_key_dict.team_metadata or {} team_agents = _team_metadata.get("agents") if key_agents is not None: - return global_agent_registry.get_agent_list(agent_names=key_agents) + returned_agents = global_agent_registry.get_agent_list( + agent_names=key_agents + ) if team_agents is not None: - return global_agent_registry.get_agent_list(agent_names=team_agents) - return [] + returned_agents = global_agent_registry.get_agent_list( + agent_names=team_agents + ) + + # add is_public field to each agent - we do it this way, to allow setting config agents as public + for agent in returned_agents: + if agent.litellm_params is None: + agent.litellm_params = {} + agent.litellm_params["is_public"] = ( + litellm.public_agent_groups is not None + and (agent.agent_id in litellm.public_agent_groups) + ) + + return returned_agents except HTTPException: raise except Exception as e: @@ -149,12 +171,12 @@ async def create_agent( agent=request, prisma_client=prisma_client, created_by=created_by ) - agent_name = result.get("agent_name", "Unknown") - agent_id = result.get("agent_id", "Unknown") + agent_name = result.agent_name + agent_id = result.agent_id # Also register in memory try: - AGENT_REGISTRY.register_agent(agent_config=request) + AGENT_REGISTRY.register_agent(agent_config=result) verbose_proxy_logger.info( f"Successfully registered agent '{agent_name}' (ID: {agent_id}) in memory" ) @@ -163,7 +185,7 @@ async def create_agent( f"Failed to register agent '{agent_name}' (ID: {agent_id}) in memory: {reg_error}" ) - return AgentResponse(**result) + return result except HTTPException: raise @@ -200,14 +222,14 @@ async def get_agent_by_id(agent_id: str): where={"agent_id": agent_id} ) if agent is not None: - agent = dict(agent) + agent = AgentResponse(**agent.model_dump()) # type: ignore if agent is None: raise HTTPException( status_code=404, detail=f"Agent with ID {agent_id} not found" ) - return AgentResponse(**agent) + return agent except HTTPException: raise except Exception as e: @@ -290,13 +312,102 @@ async def update_agent( # deregister in memory AGENT_REGISTRY.deregister_agent(agent_name=existing_agent.get("agent_name")) # type: ignore # register in memory - AGENT_REGISTRY.register_agent(agent_config=request) + AGENT_REGISTRY.register_agent(agent_config=result) verbose_proxy_logger.info( f"Successfully updated agent '{existing_agent.get('agent_name')}' (ID: {agent_id}) in memory" ) - return AgentResponse(**result) + return result + except HTTPException: + raise + except Exception as e: + verbose_proxy_logger.exception(f"Error updating agent: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.patch( + "/v1/agents/{agent_id}", + tags=["[beta] Agents"], + dependencies=[Depends(user_api_key_auth)], + response_model=AgentResponse, +) +async def patch_agent( + agent_id: str, + request: PatchAgentRequest, + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), +): + """ + Update an existing agent + + Example Request: + ```bash + curl -X PUT "http://localhost:4000/agents/123e4567-e89b-12d3-a456-426614174000" \\ + -H "Authorization: Bearer " \\ + -H "Content-Type: application/json" \\ + -d '{ + "agent": { + "agent_name": "updated-agent", + "agent_card_params": { + "protocolVersion": "1.0", + "name": "Updated Agent", + "description": "Updated description", + "url": "http://localhost:9999/", + "version": "1.1.0", + "defaultInputModes": ["text"], + "defaultOutputModes": ["text"], + "capabilities": { + "streaming": true + }, + "skills": [] + }, + "litellm_params": { + "make_public": false + } + } + }' + ``` + """ + from litellm.proxy.proxy_server import prisma_client + + if prisma_client is None: + raise HTTPException( + status_code=500, detail=CommonProxyErrors.db_not_connected_error.value + ) + + try: + # Check if agent exists + existing_agent = await prisma_client.db.litellm_agentstable.find_unique( + where={"agent_id": agent_id} + ) + if existing_agent is not None: + existing_agent = dict(existing_agent) + + if existing_agent is None: + raise HTTPException( + status_code=404, detail=f"Agent with ID {agent_id} not found" + ) + + # Get the user ID from the API key auth + updated_by = user_api_key_dict.user_id or "unknown" + + result = await AGENT_REGISTRY.patch_agent_in_db( + agent_id=agent_id, + agent=request, + prisma_client=prisma_client, + updated_by=updated_by, + ) + + # deregister in memory + AGENT_REGISTRY.deregister_agent(agent_name=existing_agent.get("agent_name")) # type: ignore + # register in memory + AGENT_REGISTRY.register_agent(agent_config=result) + + verbose_proxy_logger.info( + f"Successfully updated agent '{existing_agent.get('agent_name')}' (ID: {agent_id}) in memory" + ) + + return result except HTTPException: raise except Exception as e: @@ -356,3 +467,229 @@ async def delete_agent(agent_id: str): except Exception as e: verbose_proxy_logger.exception(f"Error deleting agent: {e}") raise HTTPException(status_code=500, detail=str(e)) + + +@router.post( + "/v1/agents/{agent_id}/make_public", + tags=["[beta] Agents"], + dependencies=[Depends(user_api_key_auth)], + response_model=AgentMakePublicResponse, +) +async def make_agent_public( + agent_id: str, + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), +): + """ + Make an agent publicly discoverable + + Example Request: + ```bash + curl -X POST "http://localhost:4000/v1/agents/123e4567-e89b-12d3-a456-426614174000/make_public" \\ + -H "Authorization: Bearer " \\ + -H "Content-Type: application/json" + ``` + + Example Response: + ```json + { + "agent_id": "123e4567-e89b-12d3-a456-426614174000", + "agent_name": "my-custom-agent", + "litellm_params": { + "make_public": true + }, + "agent_card_params": {...}, + "created_at": "2025-11-15T10:30:00Z", + "updated_at": "2025-11-15T10:35:00Z", + "created_by": "user123", + "updated_by": "user123" + } + ``` + """ + from litellm.proxy.proxy_server import prisma_client + + if prisma_client is None: + raise HTTPException( + status_code=500, detail=CommonProxyErrors.db_not_connected_error.value + ) + + try: + # Update the public model groups + import litellm + from litellm.proxy.agent_endpoints.agent_registry import ( + global_agent_registry as AGENT_REGISTRY, + ) + from litellm.proxy.proxy_server import proxy_config + + # Check if user has admin permissions + if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN: + raise HTTPException( + status_code=403, + detail={ + "error": "Only proxy admins can update public model groups. Your role={}".format( + user_api_key_dict.user_role + ) + }, + ) + + agent = AGENT_REGISTRY.get_agent_by_id(agent_id=agent_id) + if agent is None: + # check if agent exists in DB + agent = await prisma_client.db.litellm_agentstable.find_unique( + where={"agent_id": agent_id} + ) + if agent is not None: + agent = AgentResponse(**agent.model_dump()) # type: ignore + + if agent is None: + raise HTTPException( + status_code=404, detail=f"Agent with ID {agent_id} not found" + ) + + if litellm.public_agent_groups is None: + litellm.public_agent_groups = [] + # handle duplicates + if agent.agent_id in litellm.public_agent_groups: + raise HTTPException( + status_code=400, + detail=f"Agent with name {agent.agent_name} already in public agent groups", + ) + litellm.public_agent_groups.append(agent.agent_id) + + # Load existing config + config = await proxy_config.get_config() + + # Update config with new settings + if "litellm_settings" not in config or config["litellm_settings"] is None: + config["litellm_settings"] = {} + + config["litellm_settings"]["public_agent_groups"] = litellm.public_agent_groups + + # Save the updated config + await proxy_config.save_config(new_config=config) + + verbose_proxy_logger.debug( + f"Updated public agent groups to: {litellm.public_agent_groups} by user: {user_api_key_dict.user_id}" + ) + + return { + "message": "Successfully updated public agent groups", + "public_agent_groups": litellm.public_agent_groups, + "updated_by": user_api_key_dict.user_id, + } + except HTTPException: + raise + except Exception as e: + verbose_proxy_logger.exception(f"Error making agent public: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post( + "/v1/agents/make_public", + tags=["[beta] Agents"], + dependencies=[Depends(user_api_key_auth)], + response_model=AgentMakePublicResponse, +) +async def make_agents_public( + request: MakeAgentsPublicRequest, + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), +): + """ + Make multiple agents publicly discoverable + + Example Request: + ```bash + curl -X POST "http://localhost:4000/v1/agents/make_public" \\ + -H "Authorization: Bearer " \\ + -H "Content-Type: application/json" \\ + -d '{ + "agent_ids": ["123e4567-e89b-12d3-a456-426614174000", "123e4567-e89b-12d3-a456-426614174001"] + }' + ``` + + Example Response: + ```json + { + "agent_id": "123e4567-e89b-12d3-a456-426614174000", + "agent_name": "my-custom-agent", + "litellm_params": { + "make_public": true + }, + "agent_card_params": {...}, + "created_at": "2025-11-15T10:30:00Z", + "updated_at": "2025-11-15T10:35:00Z", + "created_by": "user123", + "updated_by": "user123" + } + ``` + """ + from litellm.proxy.proxy_server import prisma_client + + if prisma_client is None: + raise HTTPException( + status_code=500, detail=CommonProxyErrors.db_not_connected_error.value + ) + + try: + # Update the public model groups + import litellm + from litellm.proxy.agent_endpoints.agent_registry import ( + global_agent_registry as AGENT_REGISTRY, + ) + from litellm.proxy.proxy_server import proxy_config + + # Load existing config + config = await proxy_config.get_config() + # Check if user has admin permissions + if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN: + raise HTTPException( + status_code=403, + detail={ + "error": "Only proxy admins can update public model groups. Your role={}".format( + user_api_key_dict.user_role + ) + }, + ) + + if litellm.public_agent_groups is None: + litellm.public_agent_groups = [] + + for agent_id in request.agent_ids: + agent = AGENT_REGISTRY.get_agent_by_id(agent_id=agent_id) + if agent is None: + # check if agent exists in DB + agent = await prisma_client.db.litellm_agentstable.find_unique( + where={"agent_id": agent_id} + ) + if agent is not None: + agent = AgentResponse(**agent.model_dump()) # type: ignore + + if agent is None: + raise HTTPException( + status_code=404, detail=f"Agent with ID {agent_id} not found" + ) + + litellm.public_agent_groups = request.agent_ids + + # Update config with new settings + if "litellm_settings" not in config or config["litellm_settings"] is None: + config["litellm_settings"] = {} + + config["litellm_settings"]["public_agent_groups"] = litellm.public_agent_groups + + # Save the updated config + await proxy_config.save_config(new_config=config) + + verbose_proxy_logger.debug( + f"Updated public agent groups to: {litellm.public_agent_groups} by user: {user_api_key_dict.user_id}" + ) + + return { + "message": "Successfully updated public agent groups", + "public_agent_groups": litellm.public_agent_groups, + "updated_by": user_api_key_dict.user_id, + } + except HTTPException: + raise + except Exception as e: + verbose_proxy_logger.exception(f"Error making agent public: {e}") + raise HTTPException(status_code=500, detail=str(e)) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index a6e73199f0e..1bc96556136 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -1911,7 +1911,9 @@ class ProxyConfig: raise Exception("Unable to load config from given source.") else: # default to file + config = await self._get_config_from_file(config_file_path=config_file_path) + ## UPDATE CONFIG WITH DB if prisma_client is not None and store_model_in_db is True: config = await self._update_config_from_db( @@ -1927,6 +1929,7 @@ class ProxyConfig: config = self._check_for_os_environ_vars(config=config) self.update_config_state(config=config) + return config def update_config_state(self, config: dict): diff --git a/litellm/proxy/public_endpoints/public_endpoints.py b/litellm/proxy/public_endpoints/public_endpoints.py index 01600218460..159d357c2a6 100644 --- a/litellm/proxy/public_endpoints/public_endpoints.py +++ b/litellm/proxy/public_endpoints/public_endpoints.py @@ -3,17 +3,17 @@ from typing import List from fastapi import APIRouter, Depends, HTTPException from litellm.proxy._types import CommonProxyErrors +from litellm.proxy.auth.user_api_key_auth import user_api_key_auth from litellm.proxy.public_endpoints.provider_create_metadata import ( get_provider_create_metadata, ) -from litellm.proxy.auth.user_api_key_auth import user_api_key_auth from litellm.types.agents import AgentCard from litellm.types.proxy.management_endpoints.model_management_endpoints import ( ModelGroupInfoProxy, ) from litellm.types.proxy.public_endpoints.public_endpoints import ( - PublicModelHubInfo, ProviderCreateInfo, + PublicModelHubInfo, ) from litellm.types.utils import LlmProviders @@ -53,10 +53,19 @@ async def public_model_hub(): response_model=List[AgentCard], ) async def get_agents(): + import litellm from litellm.proxy.agent_endpoints.agent_registry import global_agent_registry agents = global_agent_registry.get_public_agent_list() - return [agent.get("agent_card_params") for agent in agents] + + if litellm.public_agent_groups is None: + return [] + agent_card_list = [ + agent.agent_card_params + for agent in agents + if agent.agent_id in litellm.public_agent_groups + ] + return agent_card_list @router.get( diff --git a/litellm/types/agents.py b/litellm/types/agents.py index b9be640fd70..850dab0ea7f 100644 --- a/litellm/types/agents.py +++ b/litellm/types/agents.py @@ -158,14 +158,20 @@ class AgentCard(TypedDict, total=False): signatures: Optional[List[AgentCardSignature]] -class AgentLitellmParams(TypedDict): - make_public: bool +class AugmentedAgentCard(AgentCard): + is_public: bool class AgentConfig(TypedDict, total=False): agent_name: Required[str] agent_card_params: Required[AgentCard] - litellm_params: AgentLitellmParams + litellm_params: Dict[str, Any] # allow for any future litellm params + + +class PatchAgentRequest(TypedDict, total=False): + agent_name: str + agent_card_params: AgentCard + litellm_params: Dict[str, Any] # Request/Response models for CRUD endpoints @@ -184,3 +190,13 @@ class AgentResponse(BaseModel): class ListAgentsResponse(BaseModel): agents: List[AgentResponse] + + +class AgentMakePublicResponse(BaseModel): + message: str + public_agent_groups: List[str] + updated_by: str + + +class MakeAgentsPublicRequest(BaseModel): + agent_ids: List[str] diff --git a/ui/litellm-dashboard/src/app/page.tsx b/ui/litellm-dashboard/src/app/page.tsx index f09220772eb..08052bf75ca 100644 --- a/ui/litellm-dashboard/src/app/page.tsx +++ b/ui/litellm-dashboard/src/app/page.tsx @@ -27,6 +27,7 @@ import CacheDashboard from "@/components/cache_dashboard"; import { getUiConfig, proxyBaseUrl, setGlobalLitellmHeaderName } from "@/components/networking"; import { Organization } from "@/components/networking"; import GuardrailsPanel from "@/components/guardrails"; +import AgentsPanel from "@/components/agents"; import PromptsPanel from "@/components/prompts"; import TransformRequestPanel from "@/components/transform_request"; import { fetchUserModels } from "@/components/organisms/create_key_button"; @@ -415,6 +416,8 @@ export default function CreateKeyPage() { ) : page == "guardrails" ? ( + ) : page == "agents" ? ( + ) : page == "prompts" ? ( ) : page == "transform-request" ? ( diff --git a/ui/litellm-dashboard/src/components/agent_hub_table_columns.tsx b/ui/litellm-dashboard/src/components/agent_hub_table_columns.tsx new file mode 100644 index 00000000000..026165c0eb9 --- /dev/null +++ b/ui/litellm-dashboard/src/components/agent_hub_table_columns.tsx @@ -0,0 +1,243 @@ +import { ColumnDef } from "@tanstack/react-table"; +import { Button, Badge, Text } from "@tremor/react"; +import { Tooltip, Tag } from "antd"; +import { CopyOutlined, InfoCircleOutlined } from "@ant-design/icons"; + +export interface AgentHubData { + agent_id?: string; + protocolVersion: string; + name: string; + description: string; + url: string; + version: string; + capabilities?: { + streaming?: boolean; + [key: string]: any; + }; + defaultInputModes?: string[]; + defaultOutputModes?: string[]; + skills?: Array<{ + id: string; + name: string; + description: string; + tags?: string[]; + examples?: string[]; + }>; + supportsAuthenticatedExtendedCard?: boolean; + is_public?: boolean; + [key: string]: any; +} + +export const agentHubColumns = ( + showModal: (agent: AgentHubData) => void, + copyToClipboard: (text: string) => void, + publicPage: boolean = false, +): ColumnDef[] => { + const allColumns: ColumnDef[] = [ + { + header: "Agent Name", + accessorKey: "name", + enableSorting: true, + sortingFn: "alphanumeric", + cell: ({ row }) => { + const agent = row.original; + + return ( +
+
+ {agent.name} + + copyToClipboard(agent.name)} + className="cursor-pointer text-gray-500 hover:text-blue-500 text-xs" + /> + +
+ {/* Show description on mobile */} +
+ {agent.description} +
+
+ ); + }, + }, + { + header: "Description", + accessorKey: "description", + enableSorting: true, + sortingFn: "alphanumeric", + cell: ({ row }) => { + const agent = row.original; + + return ( + + {agent.description || "-"} + + ); + }, + meta: { + className: "hidden md:table-cell", + }, + }, + { + header: "Version", + accessorKey: "version", + enableSorting: true, + sortingFn: "alphanumeric", + cell: ({ row }) => { + const agent = row.original; + + return ( + + v{agent.version} + + ); + }, + meta: { + className: "hidden lg:table-cell", + }, + }, + { + header: "Protocol", + accessorKey: "protocolVersion", + enableSorting: true, + sortingFn: "alphanumeric", + cell: ({ row }) => { + const agent = row.original; + + return ( + + {agent.protocolVersion || "-"} + + ); + }, + meta: { + className: "hidden lg:table-cell", + }, + }, + { + header: "Skills", + accessorKey: "skills", + enableSorting: false, + cell: ({ row }) => { + const agent = row.original; + const skills = agent.skills || []; + + return ( +
+ + {skills.length} skill{skills.length !== 1 ? "s" : ""} + + {skills.length > 0 && ( +
+ {skills.slice(0, 2).map((skill) => ( + + {skill.name} + + ))} + {skills.length > 2 && ( + +{skills.length - 2} + )} +
+ )} +
+ ); + }, + }, + { + header: "Capabilities", + accessorKey: "capabilities", + enableSorting: false, + cell: ({ row }) => { + const agent = row.original; + const capabilities = agent.capabilities || {}; + const capabilityList = Object.entries(capabilities) + .filter(([_, value]) => value === true) + .map(([key]) => key); + + return ( +
+ {capabilityList.length === 0 ? ( + - + ) : ( + capabilityList.map((capability) => ( + + {capability} + + )) + )} +
+ ); + }, + }, + { + header: "I/O Modes", + accessorKey: "defaultInputModes", + enableSorting: false, + cell: ({ row }) => { + const agent = row.original; + const inputModes = agent.defaultInputModes || []; + const outputModes = agent.defaultOutputModes || []; + + return ( +
+ + In: {inputModes.join(", ") || "-"} + + + Out: {outputModes.join(", ") || "-"} + +
+ ); + }, + meta: { + className: "hidden xl:table-cell", + }, + }, + { + header: "Public", + accessorKey: "is_public", + enableSorting: true, + sortingFn: (rowA, rowB) => { + const publicA = rowA.original.is_public === true ? 1 : 0; + const publicB = rowB.original.is_public === true ? 1 : 0; + return publicA - publicB; + }, + cell: ({ row }) => { + console.log(`CHECKPOINT 1: ${JSON.stringify(row.original)}`); + const agent = row.original; + + return agent.is_public === true ? ( + + Yes + + ) : ( + + No + + ); + }, + meta: { + className: "hidden md:table-cell", + }, + }, + { + header: "Details", + id: "details", + enableSorting: false, + cell: ({ row }) => { + const agent = row.original; + + return ( + + ); + }, + }, + ]; + + return allColumns; +}; + diff --git a/ui/litellm-dashboard/src/components/agents.tsx b/ui/litellm-dashboard/src/components/agents.tsx new file mode 100644 index 00000000000..b8a93069908 --- /dev/null +++ b/ui/litellm-dashboard/src/components/agents.tsx @@ -0,0 +1,149 @@ +import React, { useState, useEffect } from "react"; +import { Button } from "@tremor/react"; +import { Modal } from "antd"; +import { getAgentsList, deleteAgentCall } from "./networking"; +import AddAgentForm from "./agents/add_agent_form"; +import AgentTable from "./agents/agent_table"; +import { isAdminRole } from "@/utils/roles"; +import AgentInfoView from "./agents/agent_info"; +import NotificationsManager from "./molecules/notifications_manager"; +import { Agent } from "./agents/types"; + +interface AgentsPanelProps { + accessToken: string | null; + userRole?: string; +} + +interface AgentsResponse { + agents: Agent[]; +} + +const AgentsPanel: React.FC = ({ accessToken, userRole }) => { + const [agentsList, setAgentsList] = useState([]); + const [isAddModalVisible, setIsAddModalVisible] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + const [agentToDelete, setAgentToDelete] = useState<{ id: string; name: string } | null>(null); + const [selectedAgentId, setSelectedAgentId] = useState(null); + + const isAdmin = userRole ? isAdminRole(userRole) : false; + + const fetchAgents = async () => { + if (!accessToken) { + return; + } + + setIsLoading(true); + try { + const response: AgentsResponse = await getAgentsList(accessToken); + console.log(`agents: ${JSON.stringify(response)}`); + setAgentsList(response.agents); + } catch (error) { + console.error("Error fetching agents:", error); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + fetchAgents(); + }, [accessToken]); + + const handleAddAgent = () => { + if (selectedAgentId) { + setSelectedAgentId(null); + } + setIsAddModalVisible(true); + }; + + const handleCloseModal = () => { + setIsAddModalVisible(false); + }; + + const handleSuccess = () => { + fetchAgents(); + }; + + const handleDeleteClick = (agentId: string, agentName: string) => { + setAgentToDelete({ id: agentId, name: agentName }); + }; + + const handleDeleteConfirm = async () => { + if (!agentToDelete || !accessToken) return; + + setIsDeleting(true); + try { + await deleteAgentCall(accessToken, agentToDelete.id); + NotificationsManager.success(`Agent "${agentToDelete.name}" deleted successfully`); + fetchAgents(); + } catch (error) { + console.error("Error deleting agent:", error); + NotificationsManager.fromBackend("Failed to delete agent"); + } finally { + setIsDeleting(false); + setAgentToDelete(null); + } + }; + + const handleDeleteCancel = () => { + setAgentToDelete(null); + }; + + return ( +
+
+
+

Agents

+

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

+
+ +
+ + {selectedAgentId ? ( + setSelectedAgentId(null)} + accessToken={accessToken} + isAdmin={isAdmin} + /> + ) : ( + setSelectedAgentId(id)} + /> + )} + + + + {agentToDelete && ( + +

Are you sure you want to delete agent: {agentToDelete.name}?

+

This action cannot be undone.

+
+ )} +
+ ); +}; + +export default AgentsPanel; + diff --git a/ui/litellm-dashboard/src/components/agents/add_agent_form.tsx b/ui/litellm-dashboard/src/components/agents/add_agent_form.tsx new file mode 100644 index 00000000000..ffdfe0c8e49 --- /dev/null +++ b/ui/litellm-dashboard/src/components/agents/add_agent_form.tsx @@ -0,0 +1,85 @@ +import React, { useState } from "react"; +import { Modal, Form, Button as AntButton, message } from "antd"; +import { createAgentCall } from "../networking"; +import AgentFormFields from "./agent_form_fields"; +import { getDefaultFormValues, buildAgentDataFromForm } from "./agent_config"; + +interface AddAgentFormProps { + visible: boolean; + onClose: () => void; + accessToken: string | null; + onSuccess: () => void; +} + +const AddAgentForm: React.FC = ({ + visible, + onClose, + accessToken, + onSuccess, +}) => { + const [form] = Form.useForm(); + const [isSubmitting, setIsSubmitting] = useState(false); + + const handleSubmit = async (values: any) => { + if (!accessToken) { + message.error("No access token available"); + return; + } + + setIsSubmitting(true); + try { + const agentData = buildAgentDataFromForm(values); + await createAgentCall(accessToken, agentData); + message.success("Agent created successfully"); + form.resetFields(); + onSuccess(); + onClose(); + } catch (error) { + console.error("Error creating agent:", error); + message.error("Failed to create agent"); + } finally { + setIsSubmitting(false); + } + }; + + const handleCancel = () => { + form.resetFields(); + onClose(); + }; + + return ( + +
+ + + +
+ + Cancel + + + Create Agent + +
+
+ +
+ ); +}; + +export default AddAgentForm; + diff --git a/ui/litellm-dashboard/src/components/agents/agent_config.ts b/ui/litellm-dashboard/src/components/agents/agent_config.ts new file mode 100644 index 00000000000..e930c2e07d7 --- /dev/null +++ b/ui/litellm-dashboard/src/components/agents/agent_config.ts @@ -0,0 +1,271 @@ +/** + * Shared configuration for agent form fields + * Used across create, view, and update operations + */ + +export interface FieldConfig { + name: string; + label: string; + type: "text" | "textarea" | "url" | "switch" | "list"; + required?: boolean; + tooltip?: string; + placeholder?: string; + defaultValue?: any; + rows?: number; + validation?: any[]; +} + +export interface SectionConfig { + key: string; + title: string; + fields: FieldConfig[]; + defaultExpanded?: boolean; +} + +export const AGENT_FORM_CONFIG: { + basic: SectionConfig; + skills: SectionConfig; + capabilities: SectionConfig; + optional: SectionConfig; + litellm: SectionConfig; +} = { + basic: { + key: "basic", + title: "Basic Information", + defaultExpanded: true, + fields: [ + { + name: "name", + label: "Display Name", + type: "text", + required: true, + placeholder: "e.g., Customer Support Agent", + }, + { + name: "description", + label: "Description", + type: "textarea", + required: true, + placeholder: "Describe what this agent does...", + rows: 3, + }, + { + name: "url", + label: "URL", + type: "url", + required: true, + placeholder: "http://localhost:9999/", + tooltip: "Base URL where the agent is hosted", + }, + { + name: "version", + label: "Version", + type: "text", + placeholder: "1.0.0", + defaultValue: "1.0.0", + }, + { + name: "protocolVersion", + label: "Protocol Version", + type: "text", + placeholder: "1.0", + defaultValue: "1.0", + }, + ], + }, + skills: { + key: "skills", + title: "Skills", + fields: [ + { + name: "skills", + label: "Skills", + type: "list", + defaultValue: [], + }, + ], + }, + capabilities: { + key: "capabilities", + title: "Capabilities", + fields: [ + { + name: "streaming", + label: "Streaming", + type: "switch", + defaultValue: false, + }, + { + name: "pushNotifications", + label: "Push Notifications", + type: "switch", + }, + { + name: "stateTransitionHistory", + label: "State Transition History", + type: "switch", + }, + ], + }, + optional: { + key: "optional", + title: "Optional Settings", + fields: [ + { + name: "iconUrl", + label: "Icon URL", + type: "url", + placeholder: "https://example.com/icon.png", + }, + { + name: "documentationUrl", + label: "Documentation URL", + type: "url", + placeholder: "https://docs.example.com", + }, + { + name: "supportsAuthenticatedExtendedCard", + label: "Supports Authenticated Extended Card", + type: "switch", + }, + ], + }, + litellm: { + key: "litellm", + title: "LiteLLM Parameters", + fields: [ + { + name: "model", + label: "Model (Optional)", + type: "text", + }, + { + name: "make_public", + label: "Make Public", + type: "switch", + }, + ], + }, +}; + +export const SKILL_FIELD_CONFIG = { + id: { + name: "id", + label: "Skill ID", + required: true, + placeholder: "e.g., hello_world", + }, + name: { + name: "name", + label: "Skill Name", + required: true, + placeholder: "e.g., Returns hello world", + }, + description: { + name: "description", + label: "Description", + required: true, + placeholder: "What this skill does", + rows: 2, + }, + tags: { + name: "tags", + label: "Tags (comma-separated)", + required: true, + placeholder: "e.g., hello world, greeting", + }, + examples: { + name: "examples", + label: "Examples (comma-separated)", + placeholder: "e.g., hi, hello world", + }, +}; + +/** + * Get default form values from configuration + */ +export const getDefaultFormValues = () => { + const defaults: any = { + defaultInputModes: ["text"], + defaultOutputModes: ["text"], + }; + + Object.values(AGENT_FORM_CONFIG).forEach((section) => { + section.fields.forEach((field) => { + if (field.defaultValue !== undefined) { + defaults[field.name] = field.defaultValue; + } + }); + }); + + return defaults; +}; + +/** + * Build agent data from form values according to AgentConfig spec + */ +export const buildAgentDataFromForm = (values: any, existingAgent?: any) => { + const agentData: any = { + agent_name: values.agent_name, + agent_card_params: { + protocolVersion: values.protocolVersion || "1.0", + name: values.name, + description: values.description, + url: values.url, + version: values.version || "1.0.0", + defaultInputModes: existingAgent?.agent_card_params?.defaultInputModes || ["text"], + defaultOutputModes: existingAgent?.agent_card_params?.defaultOutputModes || ["text"], + capabilities: { + streaming: values.streaming === true, + ...(values.pushNotifications !== undefined && { pushNotifications: values.pushNotifications }), + ...(values.stateTransitionHistory !== undefined && { stateTransitionHistory: values.stateTransitionHistory }), + }, + skills: values.skills || [], + ...(values.iconUrl && { iconUrl: values.iconUrl }), + ...(values.documentationUrl && { documentationUrl: values.documentationUrl }), + ...(values.supportsAuthenticatedExtendedCard !== undefined && { + supportsAuthenticatedExtendedCard: values.supportsAuthenticatedExtendedCard, + }), + }, + }; + + // Only add litellm_params if there are values + if (values.model || values.make_public !== undefined) { + agentData.litellm_params = { + ...(values.model && { model: values.model }), + ...(values.make_public !== undefined && { make_public: values.make_public }), + }; + } + + return agentData; +}; + +/** + * Parse agent data for form fields + */ +export const parseAgentForForm = (agent: any) => { + const skills = + agent.agent_card_params?.skills?.map((skill: any) => ({ + ...skill, + tags: skill.tags, + examples: skill.examples || [], + })) || []; + + return { + agent_name: agent.agent_name, + name: agent.agent_card_params?.name, + description: agent.agent_card_params?.description, + url: agent.agent_card_params?.url, + version: agent.agent_card_params?.version, + protocolVersion: agent.agent_card_params?.protocolVersion, + streaming: agent.agent_card_params?.capabilities?.streaming, + pushNotifications: agent.agent_card_params?.capabilities?.pushNotifications, + stateTransitionHistory: agent.agent_card_params?.capabilities?.stateTransitionHistory, + skills: skills, + iconUrl: agent.agent_card_params?.iconUrl, + documentationUrl: agent.agent_card_params?.documentationUrl, + supportsAuthenticatedExtendedCard: agent.agent_card_params?.supportsAuthenticatedExtendedCard, + model: agent.litellm_params?.model, + make_public: agent.litellm_params?.make_public, + }; +}; diff --git a/ui/litellm-dashboard/src/components/agents/agent_form_fields.tsx b/ui/litellm-dashboard/src/components/agents/agent_form_fields.tsx new file mode 100644 index 00000000000..6d0c0820a4e --- /dev/null +++ b/ui/litellm-dashboard/src/components/agents/agent_form_fields.tsx @@ -0,0 +1,176 @@ +import React from "react"; +import { Form, Input, Switch, Collapse } from "antd"; +import { Button as AntButton } from "antd"; +import { PlusOutlined, MinusCircleOutlined } from "@ant-design/icons"; +import { AGENT_FORM_CONFIG, SKILL_FIELD_CONFIG } from "./agent_config"; + +const { Panel } = Collapse; + +interface AgentFormFieldsProps { + showAgentName?: boolean; +} + +/** + * Reusable form fields component for agent forms + * Uses shared configuration from agent_config.ts + */ +const AgentFormFields: React.FC = ({ showAgentName = true }) => { + return ( + <> + {showAgentName && ( + + + + )} + + + {/* Basic Information */} + + {AGENT_FORM_CONFIG.basic.fields.map((field) => ( + + {field.type === 'textarea' ? ( + + ) : ( + + )} + + ))} + + + {/* Skills */} + + + {(fields, { add, remove }) => ( + <> + {fields.map((field) => ( +
+ + + + + + + + + + + + + e.target.value.split(',').map((s: string) => s.trim())} + getValueProps={(value) => ({ value: Array.isArray(value) ? value.join(', ') : value })} + > + + + + e.target.value.split(',').map((s: string) => s.trim()).filter((s: string) => s)} + getValueProps={(value) => ({ value: Array.isArray(value) ? value.join(', ') : '' })} + > + + + + remove(field.name)} + icon={} + > + Remove Skill + +
+ ))} + add()} + icon={} + style={{ width: '100%' }} + > + Add Skill + + + )} +
+
+ + {/* Capabilities */} + + {AGENT_FORM_CONFIG.capabilities.fields.map((field) => ( + + + + ))} + + + {/* Optional Settings */} + + {AGENT_FORM_CONFIG.optional.fields.map((field) => ( + + {field.type === 'switch' ? : } + + ))} + + + {/* LiteLLM Parameters */} + + {AGENT_FORM_CONFIG.litellm.fields.map((field) => ( + + {field.type === 'switch' ? : } + + ))} + +
+ + ); +}; + +export default AgentFormFields; + diff --git a/ui/litellm-dashboard/src/components/agents/agent_info.tsx b/ui/litellm-dashboard/src/components/agents/agent_info.tsx new file mode 100644 index 00000000000..c997ebc1e5b --- /dev/null +++ b/ui/litellm-dashboard/src/components/agents/agent_info.tsx @@ -0,0 +1,219 @@ +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 { Agent } from "./types"; +import AgentFormFields from "./agent_form_fields"; +import { buildAgentDataFromForm, parseAgentForForm } from "./agent_config"; + +interface AgentInfoViewProps { + agentId: string; + onClose: () => void; + accessToken: string | null; + isAdmin: boolean; +} + +const AgentInfoView: React.FC = ({ + agentId, + onClose, + accessToken, + isAdmin, +}) => { + const [agent, setAgent] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [isEditing, setIsEditing] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const [form] = Form.useForm(); + + useEffect(() => { + fetchAgentInfo(); + }, [agentId, accessToken]); + + const fetchAgentInfo = async () => { + if (!accessToken) return; + + setIsLoading(true); + try { + const data = await getAgentInfo(accessToken, agentId); + setAgent(data); + form.setFieldsValue(parseAgentForForm(data)); + } catch (error) { + console.error("Error fetching agent info:", error); + message.error("Failed to load agent information"); + } finally { + setIsLoading(false); + } + }; + + const handleUpdate = async (values: any) => { + if (!accessToken || !agent) return; + + setIsSaving(true); + try { + const updateData = buildAgentDataFromForm(values, agent); + await patchAgentCall(accessToken, agentId, updateData); + message.success("Agent updated successfully"); + setIsEditing(false); + fetchAgentInfo(); + } catch (error) { + console.error("Error updating agent:", error); + message.error("Failed to update agent"); + } finally { + setIsSaving(false); + } + }; + + if (isLoading) { + return ( +
+
+ +
+
+ ); + } + + if (!agent) { + return ( +
+
Agent not found
+ + Back to Agents List + +
+ ); + } + + // Format date helper function + const formatDate = (dateString?: string) => { + if (!dateString) return "-"; + const date = new Date(dateString); + return date.toLocaleString(); + }; + + return ( +
+
+ + Back to Agents + + {agent.agent_name || "Unnamed Agent"} + {agent.agent_id} +
+ + + + Overview + {isAdmin ? Settings : <>} + + + + {/* Overview Panel */} + + + {agent.agent_id} + {agent.agent_name} + {agent.agent_card_params?.name || "-"} + {agent.agent_card_params?.description || "-"} + {agent.agent_card_params?.url || "-"} + {agent.agent_card_params?.version || "-"} + {agent.agent_card_params?.protocolVersion || "-"} + + {agent.agent_card_params?.capabilities?.streaming ? "Yes" : "No"} + + {agent.agent_card_params?.capabilities?.pushNotifications && ( + Yes + )} + {agent.agent_card_params?.capabilities?.stateTransitionHistory && ( + Yes + )} + + {agent.agent_card_params?.skills?.length || 0} configured + + {agent.litellm_params?.model && ( + {agent.litellm_params.model} + )} + {agent.litellm_params?.make_public !== undefined && ( + {agent.litellm_params.make_public ? "Yes" : "No"} + )} + {agent.agent_card_params?.iconUrl && ( + {agent.agent_card_params.iconUrl} + )} + {agent.agent_card_params?.documentationUrl && ( + {agent.agent_card_params.documentationUrl} + )} + {formatDate(agent.created_at)} + {formatDate(agent.updated_at)} + + + {agent.agent_card_params?.skills && agent.agent_card_params.skills.length > 0 && ( +
+ Skills + + {agent.agent_card_params.skills.map((skill: any, index: number) => ( + +
+
ID: {skill.id}
+
Description: {skill.description}
+
Tags: {Array.isArray(skill.tags) ? skill.tags.join(", ") : skill.tags}
+ {skill.examples && skill.examples.length > 0 && ( +
Examples: {Array.isArray(skill.examples) ? skill.examples.join(", ") : skill.examples}
+ )} +
+
+ ))} +
+
+ )} +
+ + {/* Settings Panel (only for admins) */} + {isAdmin && ( + + +
+ Agent Settings + {!isEditing && ( + setIsEditing(true)}>Edit Settings + )} +
+ + {isEditing ? ( +
+ + + + + + +
+ { + setIsEditing(false); + fetchAgentInfo(); + }}> + Cancel + + + Save Changes + +
+ + ) : ( + Click "Edit Settings" to modify agent configuration. + )} +
+
+ )} +
+
+
+ ); +}; + +export default AgentInfoView; + diff --git a/ui/litellm-dashboard/src/components/agents/agent_table.tsx b/ui/litellm-dashboard/src/components/agents/agent_table.tsx new file mode 100644 index 00000000000..fed79d5abb0 --- /dev/null +++ b/ui/litellm-dashboard/src/components/agents/agent_table.tsx @@ -0,0 +1,93 @@ +import React from "react"; +import { Table, TableHead, TableRow, TableHeaderCell, TableBody, TableCell, Button, Icon } from "@tremor/react"; +import { TrashIcon } from "@heroicons/react/outline"; +import { Tooltip } from "antd"; +import { Agent } from "./types"; + +interface AgentTableProps { + agentsList: Agent[]; + isLoading: boolean; + onDeleteClick: (agentId: string, agentName: string) => void; + accessToken: string | null; + onAgentUpdated: () => void; + isAdmin: boolean; + onAgentClick: (agentId: string) => void; +} + +const AgentTable: React.FC = ({ + agentsList, + isLoading, + onDeleteClick, + accessToken, + onAgentUpdated, + isAdmin, + onAgentClick, +}) => { + if (isLoading) { + return
Loading agents...
; + } + + if (!agentsList || agentsList.length === 0) { + return
No agents found. Create one to get started.
; + } + + return ( + + + + Agent Name + Description + Created At + {isAdmin && Actions} + + + + {agentsList.map((agent) => ( + + + + + + + + {agent.agent_card_params?.description || "No description"} + + + {agent.created_at + ? new Date(agent.created_at).toLocaleDateString() + : "N/A"} + + {isAdmin && ( + +
+ + { + e.stopPropagation(); + onDeleteClick(agent.agent_id, agent.agent_name); + }} + aria-label="Delete agent" + /> + +
+
+ )} +
+ ))} +
+
+ ); +}; + +export default AgentTable; + diff --git a/ui/litellm-dashboard/src/components/agents/types.ts b/ui/litellm-dashboard/src/components/agents/types.ts new file mode 100644 index 00000000000..5c63d334129 --- /dev/null +++ b/ui/litellm-dashboard/src/components/agents/types.ts @@ -0,0 +1,20 @@ +export interface Agent { + agent_id: string; + agent_name: string; + litellm_params: { + model: string; + [key: string]: any; + }; + agent_card_params?: { + description?: string; + [key: string]: any; + }; + created_at?: string; + updated_at?: string; + created_by?: string; + updated_by?: string; +} + +export interface AgentsResponse { + agents: Agent[]; +} diff --git a/ui/litellm-dashboard/src/components/leftnav.test.tsx b/ui/litellm-dashboard/src/components/leftnav.test.tsx index 43f20880add..fe858d6cb1c 100644 --- a/ui/litellm-dashboard/src/components/leftnav.test.tsx +++ b/ui/litellm-dashboard/src/components/leftnav.test.tsx @@ -41,9 +41,10 @@ describe("Sidebar (leftnav)", () => { "Internal Users", "Budgets", "API Reference", - "Model Hub", + "AI Hub", "Logs", "Guardrails", + "MCP Servers", "Tools", "Experimental", "Settings", @@ -54,15 +55,15 @@ describe("Sidebar (leftnav)", () => { }); }); - it("expands a nested tab to reveal its children (Tools > MCP Servers)", async () => { + it("expands a nested tab to reveal its children (Tools > Search Tools)", async () => { const { getByText, queryByText } = render(); - expect(queryByText("MCP Servers")).not.toBeInTheDocument(); + expect(queryByText("Search Tools")).not.toBeInTheDocument(); act(() => { fireEvent.click(getByText("Tools")); }); await waitFor(() => { - expect(getByText("MCP Servers")).toBeInTheDocument(); + expect(getByText("Search Tools")).toBeInTheDocument(); }); }); }); diff --git a/ui/litellm-dashboard/src/components/leftnav.tsx b/ui/litellm-dashboard/src/components/leftnav.tsx index 87a6279347d..853b4be2c1a 100644 --- a/ui/litellm-dashboard/src/components/leftnav.tsx +++ b/ui/litellm-dashboard/src/components/leftnav.tsx @@ -12,6 +12,7 @@ import { KeyOutlined, LineChartOutlined, PlayCircleOutlined, + RobotOutlined, SafetyOutlined, SearchOutlined, SettingOutlined, @@ -100,7 +101,7 @@ const Sidebar: React.FC = ({ accessToken, setPage, userRole, defau { key: "16", page: "model-hub-table", - label: "Model Hub", + label: "AI Hub", icon: , }, { key: "15", page: "logs", label: "Logs", icon: }, @@ -111,13 +112,13 @@ const Sidebar: React.FC = ({ accessToken, setPage, userRole, defau icon: , roles: all_admin_roles, }, + { key: "18", page: "mcp-servers", label: "MCP Servers", icon: }, { key: "26", page: "tools", label: "Tools", icon: , children: [ - { key: "18", page: "mcp-servers", label: "MCP Servers", icon: }, { key: "28", page: "search-tools", @@ -146,6 +147,13 @@ const Sidebar: React.FC = ({ accessToken, setPage, userRole, defau icon: , roles: all_admin_roles, }, + { + "key": "29", + "page": "agents", + "label": "Agents", + "icon": , + "roles": rolesWithWriteAccess, + }, { key: "25", page: "prompts", diff --git a/ui/litellm-dashboard/src/components/make_agent_public_form.tsx b/ui/litellm-dashboard/src/components/make_agent_public_form.tsx new file mode 100644 index 00000000000..1790d7f5ae5 --- /dev/null +++ b/ui/litellm-dashboard/src/components/make_agent_public_form.tsx @@ -0,0 +1,299 @@ +import React, { useState, useEffect } from "react"; +import { Modal, Form, Steps, Button, Checkbox } from "antd"; +import { Text, Title, Badge } from "@tremor/react"; +import { makeAgentsPublicCall } from "./networking"; +import NotificationsManager from "./molecules/notifications_manager"; +import { AgentHubData } from "./agent_hub_table_columns"; + +const { Step } = Steps; + +interface MakeAgentPublicFormProps { + visible: boolean; + onClose: () => void; + accessToken: string; + agentHubData: AgentHubData[]; + onSuccess: () => void; +} + +const MakeAgentPublicForm: React.FC = ({ + visible, + onClose, + accessToken, + agentHubData, + onSuccess, +}) => { + const [currentStep, setCurrentStep] = useState(0); + const [selectedAgents, setSelectedAgents] = useState>(new Set()); + const [loading, setLoading] = useState(false); + const [form] = Form.useForm(); + + const handleClose = () => { + setCurrentStep(0); + setSelectedAgents(new Set()); + form.resetFields(); + onClose(); + }; + + const handleNext = () => { + if (currentStep === 0) { + if (selectedAgents.size === 0) { + NotificationsManager.fromBackend("Please select at least one agent to make public"); + return; + } + setCurrentStep(1); + } + }; + + const handlePrevious = () => { + if (currentStep === 1) { + setCurrentStep(0); + } + }; + + const handleAgentSelection = (agentId: string, checked: boolean) => { + const newSelection = new Set(selectedAgents); + if (checked) { + newSelection.add(agentId); + } else { + newSelection.delete(agentId); + } + setSelectedAgents(newSelection); + }; + + const handleSelectAll = (checked: boolean) => { + if (checked) { + const allAgentIds = agentHubData.map((agent) => agent.agent_id || agent.name); + setSelectedAgents(new Set(allAgentIds)); + } else { + setSelectedAgents(new Set()); + } + }; + + // Initialize and preselect already public agents when modal opens + useEffect(() => { + if (visible && agentHubData.length > 0) { + // Preselect agents that are already public + const alreadyPublicAgents = agentHubData + .filter((agent) => agent.is_public === true) + .map((agent) => agent.agent_id || agent.name); + + setSelectedAgents(new Set(alreadyPublicAgents)); + } + }, [visible, agentHubData]); + + const handleSubmit = async () => { + if (selectedAgents.size === 0) { + NotificationsManager.fromBackend("Please select at least one agent to make public"); + return; + } + + setLoading(true); + try { + const agentIdsToMakePublic = Array.from(selectedAgents); + + // Make batch API call for all agents + await makeAgentsPublicCall(accessToken, agentIdsToMakePublic); + + NotificationsManager.success(`Successfully made ${agentIdsToMakePublic.length} agent(s) public!`); + handleClose(); + onSuccess(); + } catch (error) { + console.error("Error making agents public:", error); + NotificationsManager.fromBackend("Failed to make agents public. Please try again."); + } finally { + setLoading(false); + } + }; + + const renderStep1Content = () => { + const allAgentsSelected = + agentHubData.length > 0 && agentHubData.every((agent) => selectedAgents.has(agent.agent_id || agent.name)); + const isIndeterminate = selectedAgents.size > 0 && !allAgentsSelected; + + return ( +
+
+ Select Agents to Make Public +
+ handleSelectAll(e.target.checked)} + disabled={agentHubData.length === 0} + > + Select All {agentHubData.length > 0 && `(${agentHubData.length})`} + +
+
+ + + Select the agents you want to be visible on the public model hub. Users will still require a valid API key to + use these agents. + + +
+
+ {agentHubData.length === 0 ? ( +
+ No agents available. +
+ ) : ( + agentHubData.map((agent) => { + const agentId = agent.agent_id || agent.name; + return ( +
+ handleAgentSelection(agentId, e.target.checked)} + /> +
+
+ {agent.name} + + v{agent.version} + +
+ {agent.description} + {agent.skills && agent.skills.length > 0 && ( +
+ {agent.skills.slice(0, 3).map((skill) => ( + + {skill.name} + + ))} + {agent.skills.length > 3 && ( + +{agent.skills.length - 3} more + )} +
+ )} +
+
+ ); + }) + )} +
+
+ + {selectedAgents.size > 0 && ( +
+ + {selectedAgents.size} agent{selectedAgents.size !== 1 ? "s" : ""} selected + +
+ )} +
+ ); + }; + + const renderStep2Content = () => { + return ( +
+ Confirm Making Agents Public + +
+ + Warning: Once you make these agents public, anyone who can go to the{" "} + /ui/model_hub_table will be able to know they exist on the proxy. + +
+ +
+ Agents to be made public: +
+
+ {Array.from(selectedAgents).map((agentId) => { + const agent = agentHubData.find((a) => (a.agent_id || a.name) === agentId); + return ( +
+
+
+ {agent?.name || agentId} + {agent && ( + + v{agent.version} + + )} +
+ {agent?.description && ( + {agent.description} + )} +
+
+ ); + })} +
+
+
+ +
+ + Total: {selectedAgents.size} agent{selectedAgents.size !== 1 ? "s" : ""} will be made + public + +
+
+ ); + }; + + const renderStepContent = () => { + switch (currentStep) { + case 0: + return renderStep1Content(); + case 1: + return renderStep2Content(); + default: + return null; + } + }; + + const renderStepButtons = () => { + return ( +
+ + +
+ {currentStep === 0 && ( + + )} + + {currentStep === 1 && ( + + )} +
+
+ ); + }; + + return ( + +
+ + + + + + {renderStepContent()} + {renderStepButtons()} +
+
+ ); +}; + +export default MakeAgentPublicForm; + diff --git a/ui/litellm-dashboard/src/components/model_hub_table.tsx b/ui/litellm-dashboard/src/components/model_hub_table.tsx index 60937f09311..73ab7c85adb 100644 --- a/ui/litellm-dashboard/src/components/model_hub_table.tsx +++ b/ui/litellm-dashboard/src/components/model_hub_table.tsx @@ -1,15 +1,18 @@ import React, { useEffect, useState, useRef, useCallback } from "react"; import { useRouter } from "next/navigation"; -import { modelHubCall, modelHubPublicModelsCall, getProxyBaseUrl } from "./networking"; +import { modelHubCall, modelHubPublicModelsCall, getAgentsList, getProxyBaseUrl } from "./networking"; import { getConfigFieldSetting } from "./networking"; import { ModelDataTable } from "./model_dashboard/table"; import { modelHubColumns } from "./model_hub_table_columns"; +import { agentHubColumns, AgentHubData } from "./agent_hub_table_columns"; import PublicModelHub from "./public_model_hub"; import MakeModelPublicForm from "./make_model_public_form"; +import MakeAgentPublicForm from "./make_agent_public_form"; import ModelFilters from "./model_filters"; import UsefulLinksManagement from "./useful_links_management"; -import { Card, Text, Title, Button, Badge } from "@tremor/react"; +import { Card, Text, Title, Button, Badge, TabGroup, TabList, Tab, TabPanels, TabPanel } from "@tremor/react"; import { Modal } from "antd"; +import { CopyOutlined } from "@ant-design/icons"; import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; import { Table as TableInstance } from "@tanstack/react-table"; import { Copy } from "lucide-react"; @@ -51,8 +54,15 @@ const ModelHubTable: React.FC = ({ accessToken, publicPage, const [selectedModel, setSelectedModel] = useState(null); const [filteredData, setFilteredData] = useState([]); const [isMakePublicModalVisible, setIsMakePublicModalVisible] = useState(false); + // Agent Hub state + const [agentHubData, setAgentHubData] = useState(null); + const [isMakeAgentPublicModalVisible, setIsMakeAgentPublicModalVisible] = useState(false); + const [agentLoading, setAgentLoading] = useState(true); + const [selectedAgent, setSelectedAgent] = useState(null); + const [isAgentModalVisible, setIsAgentModalVisible] = useState(false); const router = useRouter(); const tableRef = useRef>(null); + const agentTableRef = useRef>(null); useEffect(() => { const fetchData = async (accessToken: string) => { @@ -103,11 +113,46 @@ const ModelHubTable: React.FC = ({ accessToken, publicPage, } }, [accessToken, publicPage]); + // Fetch Agent Hub data + useEffect(() => { + const fetchAgentData = async () => { + if (!accessToken) { + return; + } + + try { + setAgentLoading(true); + const response = await getAgentsList(accessToken); + console.log("AgentHubData:", response); + let agents = response.agents; + let agent_card_list = agents.map((agent: any) => ({ + agent_id: agent.agent_id, + ...agent.agent_card_params, + is_public: agent.litellm_params.is_public, + })); + setAgentHubData(agent_card_list); + } catch (error) { + console.error("There was an error fetching the agent data", error); + } finally { + setAgentLoading(false); + } + }; + + if (!publicPage) { + fetchAgentData(); + } + }, [publicPage, accessToken]); + const showModal = (model: ModelGroupInfo) => { setSelectedModel(model); setIsModalVisible(true); }; + const showAgentModal = (agent: AgentHubData) => { + setSelectedAgent(agent); + setIsAgentModalVisible(true); + }; + const goToPublicModelPage = () => { router.replace(`/model_hub_table?key=${accessToken}`); }; @@ -121,16 +166,29 @@ const ModelHubTable: React.FC = ({ accessToken, publicPage, setIsMakePublicModalVisible(true); }; + const handleMakeAgentPublicPage = () => { + if (!accessToken) { + return; + } + + // Show the modal for selecting agents to make public + setIsMakeAgentPublicModalVisible(true); + }; + const handleOk = () => { setIsModalVisible(false); setIsPublicPageModalVisible(false); setSelectedModel(null); + setIsAgentModalVisible(false); + setSelectedAgent(null); }; const handleCancel = () => { setIsModalVisible(false); setIsPublicPageModalVisible(false); setSelectedModel(null); + setIsAgentModalVisible(false); + setSelectedAgent(null); }; const copyToClipboard = (text: string) => { @@ -173,6 +231,27 @@ const ModelHubTable: React.FC = ({ accessToken, publicPage, } }; + const handleMakeAgentPublicSuccess = () => { + // Refresh the agent hub data after successful public operation + if (accessToken) { + const fetchAgentData = async () => { + try { + const response = await getAgentsList(accessToken); + let agents = response.agents; + let agent_card_list = agents.map((agent: any) => ({ + agent_id: agent.agent_id, + ...agent.agent_card_params, + is_public: agent.is_public, + })); + setAgentHubData(agent_card_list); + } catch (error) { + console.error("Error refreshing agent data:", error); + } + }; + fetchAgentData(); + } + }; + const handleFilteredDataChange = useCallback((newFilteredData: ModelGroupInfo[]) => { setFilteredData(newFilteredData); }, []); @@ -189,12 +268,13 @@ const ModelHubTable: React.FC = ({ accessToken, publicPage,
{publicPage == false ? (
+ {/* Header with Title, Description and URL */}
- Model Hub + AI Hub {isAdminRole(userRole || "") ? (

- Make models public for developers to know what models are available on the proxy. + Make models and agents public for developers to know what's available.

) : (

A list of all public model names personally available to you.

@@ -212,12 +292,6 @@ const ModelHubTable: React.FC = ({ accessToken, publicPage,
- - {publicPage == false && isAdminRole(userRole || "") && ( - - )}
@@ -228,26 +302,77 @@ const ModelHubTable: React.FC = ({ accessToken, publicPage,
)} - {/* Model Filters and Table */} - - {/* Filters */} - + {/* Tab System for Model Hub and Agent Hub */} + + + Model Hub + Agent Hub + - {/* Model Table */} - - + + {/* Model Hub Tab */} + + {/* Model Filters and Table */} + + {/* Header with Make Public Button */} + {publicPage == false && isAdminRole(userRole || "") && ( +
+ +
+ )} + + {/* Filters */} + -
- - Showing {filteredData.length} of {modelHubData?.length || 0} models - -
+ {/* Model Table */} + +
+ +
+ + Showing {filteredData.length} of {modelHubData?.length || 0} models + +
+
+ + {/* Agent Hub Tab */} + + + {/* Header with Make Public Button */} + {publicPage == false && isAdminRole(userRole || "") && ( +
+ +
+ )} + + {/* Agent Table */} + +
+ +
+ + Showing {agentHubData?.length || 0} agent{agentHubData?.length !== 1 ? "s" : ""} + +
+
+
+ ) : ( @@ -429,6 +554,145 @@ print(response.choices[0].message.content)`} )} + {/* Agent Details Modal */} + + {selectedAgent && ( +
+ {/* Agent Overview */} +
+ Agent Overview +
+
+ Name: + {selectedAgent.name} +
+
+ Version: + v{selectedAgent.version} +
+
+ Protocol Version: + {selectedAgent.protocolVersion} +
+
+ URL: +
+ {selectedAgent.url} + copyToClipboard(selectedAgent.url)} + className="cursor-pointer text-gray-500 hover:text-blue-500" + /> +
+
+
+
+ Description: + {selectedAgent.description} +
+
+ + {/* Capabilities */} + {selectedAgent.capabilities && Object.keys(selectedAgent.capabilities).length > 0 && ( +
+ Capabilities +
+ {Object.entries(selectedAgent.capabilities) + .filter(([_, value]) => value === true) + .map(([key]) => ( + + {key} + + ))} +
+
+ )} + + {/* Input/Output Modes */} +
+ Input/Output Modes +
+
+ Input Modes: +
+ {selectedAgent.defaultInputModes?.map((mode) => ( + + {mode} + + )) || Not specified} +
+
+
+ Output Modes: +
+ {selectedAgent.defaultOutputModes?.map((mode) => ( + + {mode} + + )) || Not specified} +
+
+
+
+ + {/* Skills */} + {selectedAgent.skills && selectedAgent.skills.length > 0 && ( +
+ Skills +
+ {selectedAgent.skills.map((skill) => ( +
+
+
+ {skill.name} + ID: {skill.id} +
+ {skill.tags && skill.tags.length > 0 && ( +
+ {skill.tags.map((tag) => ( + + {tag} + + ))} +
+ )} +
+ {skill.description} + {skill.examples && skill.examples.length > 0 && ( +
+ Examples: +
+ {skill.examples.map((example, idx) => ( + + {example} + + ))} +
+
+ )} +
+ ))} +
+
+ )} + + {/* Additional Properties */} + {selectedAgent.supportsAuthenticatedExtendedCard && ( +
+ Additional Features + Supports Authenticated Extended Card +
+ )} +
+ )} +
+ {/* Make Model Public Form */} + + {/* Make Agent Public Form */} + setIsMakeAgentPublicModalVisible(false)} + accessToken={accessToken || ""} + agentHubData={agentHubData || []} + onSuccess={handleMakeAgentPublicSuccess} + /> ); }; diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 28670344837..51d74050009 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -1903,6 +1903,17 @@ export const modelHubPublicModelsCall = async () => { return response.json(); }; +export const agentHubPublicModelsCall = async () => { + const url = proxyBaseUrl ? `${proxyBaseUrl}/public/agent_hub` : `/public/agent_hub`; + const response = await fetch(url, { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }); + return response.json(); +}; + export const modelHubCall = async (accessToken: string) => { /** * Get all models on proxy @@ -5299,6 +5310,36 @@ export const patchPromptCall = async (accessToken: string, promptId: string, pro } }; +export const createAgentCall = async (accessToken: string, agentData: any) => { + try { + const url = proxyBaseUrl ? `${proxyBaseUrl}/v1/agents` : `/v1/agents`; + + const response = await fetch(url, { + method: "POST", + headers: { + [globalLitellmHeaderName]: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + ...agentData, + }), + }); + + if (!response.ok) { + const errorData = await response.text(); + handleError(errorData); + throw new Error(errorData); + } + + const data = await response.json(); + console.log("Create agent response:", data); + return data; + } catch (error) { + console.error("Failed to create agent:", error); + throw error; + } +}; + export const createGuardrailCall = async (accessToken: string, guardrailData: any) => { try { const url = proxyBaseUrl ? `${proxyBaseUrl}/guardrails` : `/guardrails`; @@ -6408,6 +6449,90 @@ export const resetEmailEventSettings = async (accessToken: string) => { export { type UserInfo } from "./view_users/types"; // Re-export UserInfo export { type Team } from "./key_team_helpers/key_list"; // Re-export Team +export const deleteAgentCall = async (accessToken: string, agentId: string) => { + try { + const url = proxyBaseUrl ? `${proxyBaseUrl}/v1/agents/${agentId}` : `/v1/agents/${agentId}`; + + const response = await fetch(url, { + method: "DELETE", + headers: { + [globalLitellmHeaderName]: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + const errorData = await response.text(); + handleError(errorData); + throw new Error(errorData); + } + + const data = await response.json(); + console.log("Delete agent response:", data); + return data; + } catch (error) { + console.error("Failed to delete agent:", error); + throw error; + } +}; + +export const makeAgentPublicCall = async (accessToken: string, agentId: string) => { + try { + const url = proxyBaseUrl ? `${proxyBaseUrl}/v1/agents/${agentId}/make_public` : `/v1/agents/${agentId}/make_public`; + + const response = await fetch(url, { + method: "POST", + headers: { + [globalLitellmHeaderName]: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + const errorData = await response.text(); + handleError(errorData); + throw new Error(errorData); + } + + const data = await response.json(); + console.log("Make agent public response:", data); + return data; + } catch (error) { + console.error("Failed to make agent public:", error); + throw error; + } +}; + +export const makeAgentsPublicCall = async (accessToken: string, agentIds: string[]) => { + try { + const url = proxyBaseUrl ? `${proxyBaseUrl}/v1/agents/make_public` : `/v1/agents/make_public`; + + const response = await fetch(url, { + method: "POST", + headers: { + [globalLitellmHeaderName]: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + agent_ids: agentIds, + }), + }); + + if (!response.ok) { + const errorData = await response.text(); + handleError(errorData); + throw new Error(errorData); + } + + const data = await response.json(); + console.log("Make agents public response:", data); + return data; + } catch (error) { + console.error("Failed to make agents public:", error); + throw error; + } +}; + export const deleteGuardrailCall = async (accessToken: string, guardrailId: string) => { try { const url = proxyBaseUrl ? `${proxyBaseUrl}/guardrails/${guardrailId}` : `/guardrails/${guardrailId}`; @@ -6493,6 +6618,61 @@ export const getGuardrailProviderSpecificParams = async (accessToken: string) => } }; + +export const getAgentsList = async (accessToken: string) => { + try { + const url = proxyBaseUrl ? `${proxyBaseUrl}/v1/agents` : `/v1/agents`; + + const response = await fetch(url, { + method: "GET", + headers: { + [globalLitellmHeaderName]: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + const errorData = await response.text(); + handleError(errorData); + throw new Error("Failed to get agents list"); + } + + const data = await response.json(); + console.log("Agents list response:", data); + return { agents: data }; + } catch (error) { + console.error("Failed to get agents list:", error); + throw error; + } +}; + +export const getAgentInfo = async (accessToken: string, agentId: string) => { + try { + const url = proxyBaseUrl ? `${proxyBaseUrl}/v1/agents/${agentId}` : `/v1/agents/${agentId}`; + + const response = await fetch(url, { + method: "GET", + headers: { + [globalLitellmHeaderName]: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + const errorData = await response.text(); + handleError(errorData); + throw new Error("Failed to get agent info"); + } + + const data = await response.json(); + console.log("Agent info response:", data); + return data; + } catch (error) { + console.error("Failed to get agent info:", error); + throw error; + } +}; + export const getGuardrailInfo = async (accessToken: string, guardrailId: string) => { try { const url = proxyBaseUrl ? `${proxyBaseUrl}/guardrails/${guardrailId}/info` : `/guardrails/${guardrailId}/info`; @@ -6520,6 +6700,43 @@ export const getGuardrailInfo = async (accessToken: string, guardrailId: string) } }; +export const patchAgentCall = async ( + accessToken: string, + agentId: string, + updateData: { + agent_name?: string; + litellm_params?: Record; + agent_card_params?: Record; + }, +) => { + try { + const url = proxyBaseUrl ? `${proxyBaseUrl}/v1/agents/${agentId}` : `/v1/agents/${agentId}`; + + const response = await fetch(url, { + method: "PATCH", + headers: { + [globalLitellmHeaderName]: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(updateData), + }); + + if (!response.ok) { + const errorData = await response.text(); + handleError(errorData); + throw new Error("Failed to patch agent"); + } + + const data = await response.json(); + console.log("Patch agent response:", data); + return data; + } catch (error) { + console.error("Failed to update guardrail:", error); + throw error; + } +}; + + export const updateGuardrailCall = async ( accessToken: string, guardrailId: string, diff --git a/ui/litellm-dashboard/src/components/public_model_hub.tsx b/ui/litellm-dashboard/src/components/public_model_hub.tsx index c32c7462a54..a08030a2d3e 100644 --- a/ui/litellm-dashboard/src/components/public_model_hub.tsx +++ b/ui/litellm-dashboard/src/components/public_model_hub.tsx @@ -1,9 +1,9 @@ import React, { useEffect, useState, useRef, useMemo } from "react"; -import { modelHubPublicModelsCall, getPublicModelHubInfo } from "./networking"; +import { modelHubPublicModelsCall, getPublicModelHubInfo, agentHubPublicModelsCall } from "./networking"; import { ModelDataTable } from "./model_dashboard/table"; import { ColumnDef } from "@tanstack/react-table"; import { Card, Text, Title, Button } from "@tremor/react"; -import { Tag, Tooltip, Modal, Select } from "antd"; +import { Tag, Tooltip, Modal, Select, Tabs } from "antd"; import { ExternalLinkIcon, SearchIcon } from "@heroicons/react/outline"; import { Copy, Info } from "lucide-react"; import { Table as TableInstance } from "@tanstack/react-table"; @@ -15,6 +15,8 @@ import Navbar from "./navbar"; import { ThemeProvider } from "@/contexts/ThemeContext"; import NotificationsManager from "./molecules/notifications_manager"; +const { TabPane } = Tabs; + interface ModelGroupInfo { model_group: string; providers: string[]; @@ -32,26 +34,62 @@ interface ModelGroupInfo { [key: string]: any; } +interface AgentCard { + protocolVersion: string; + name: string; + description: string; + url: string; + version: string; + capabilities?: { + streaming?: boolean; + pushNotifications?: boolean; + stateTransitionHistory?: boolean; + }; + defaultInputModes: string[]; + defaultOutputModes: string[]; + skills: Array<{ + id: string; + name: string; + description: string; + tags: string[]; + }>; + iconUrl?: string; + provider?: { + organization: string; + url: string; + }; + documentationUrl?: string; + [key: string]: any; +} + interface PublicModelHubProps { accessToken?: string | null; } const PublicModelHub: React.FC = ({ accessToken }) => { const [modelHubData, setModelHubData] = useState(null); + const [agentHubData, setAgentHubData] = useState(null); const [pageTitle, setPageTitle] = useState("LiteLLM Gateway"); const [customDocsDescription, setCustomDocsDescription] = useState(null); const [litellmVersion, setLitellmVersion] = useState(""); const [usefulLinks, setUsefulLinks] = useState>({}); const [loading, setLoading] = useState(true); + const [agentLoading, setAgentLoading] = useState(true); const [searchTerm, setSearchTerm] = useState(""); + const [agentSearchTerm, setAgentSearchTerm] = useState(""); const [selectedProviders, setSelectedProviders] = useState([]); const [selectedModes, setSelectedModes] = useState([]); const [selectedFeatures, setSelectedFeatures] = useState([]); + const [selectedAgentSkills, setSelectedAgentSkills] = useState([]); const [serviceStatus, setServiceStatus] = useState("I'm alive! ✓"); const [isModalVisible, setIsModalVisible] = useState(false); + const [isAgentModalVisible, setIsAgentModalVisible] = useState(false); const [selectedModel, setSelectedModel] = useState(null); + const [selectedAgent, setSelectedAgent] = useState(null); const [proxySettings, setProxySettings] = useState({}); + const [activeTab, setActiveTab] = useState("models"); const tableRef = useRef>(null); + const agentTableRef = useRef>(null); useEffect(() => { const fetchPublicData = async () => { @@ -68,6 +106,19 @@ const PublicModelHub: React.FC = ({ accessToken }) => { } }; + const fetchAgentData = async () => { + try { + setAgentLoading(true); + const _agentHubData = await agentHubPublicModelsCall(); + console.log("AgentHubData:", _agentHubData); + setAgentHubData(_agentHubData); + } catch (error) { + console.error("There was an error fetching the public agent data", error); + } finally { + setAgentLoading(false); + } + }; + const fetchPublicModelHubInfo = async () => { const publicModelHubInfo = await getPublicModelHubInfo(); console.log("Public Model Hub Info:", publicModelHubInfo); @@ -80,6 +131,7 @@ const PublicModelHub: React.FC = ({ accessToken }) => { fetchPublicModelHubInfo(); fetchPublicData(); + fetchAgentData(); }, []); // Clear filters when filter values change to avoid confusion @@ -123,6 +175,16 @@ const PublicModelHub: React.FC = ({ accessToken }) => { return Array.from(features).sort(); }; + const getUniqueAgentSkills = (data: AgentCard[]) => { + const skills = new Set(); + data.forEach((agent) => { + agent.skills?.forEach((skill) => { + skill.tags?.forEach((tag) => skills.add(tag)); + }); + }); + return Array.from(skills).sort(); + }; + const filteredData = useMemo(() => { if (!modelHubData) return []; @@ -197,6 +259,57 @@ const PublicModelHub: React.FC = ({ accessToken }) => { }); }, [modelHubData, searchTerm, selectedProviders, selectedModes, selectedFeatures]); + const filteredAgentData = useMemo(() => { + if (!agentHubData) return []; + + let searchResults = agentHubData; + + // Apply search if there's a search term + if (agentSearchTerm.trim()) { + const lowercaseSearch = agentSearchTerm.toLowerCase(); + const searchWords = lowercaseSearch.split(/\s+/); + + searchResults = agentHubData.filter((agent) => { + const agentName = agent.name.toLowerCase(); + const agentDescription = agent.description.toLowerCase(); + + // Check if it contains the exact search term + if (agentName.includes(lowercaseSearch) || agentDescription.includes(lowercaseSearch)) { + return true; + } + + // Check if it contains all search words + return searchWords.every((word) => agentName.includes(word) || agentDescription.includes(word)); + }); + + // Sort by relevance + searchResults = searchResults.sort((a, b) => { + const aName = a.name.toLowerCase(); + const bName = b.name.toLowerCase(); + + const aExactMatch = aName === lowercaseSearch ? 1000 : 0; + const bExactMatch = bName === lowercaseSearch ? 1000 : 0; + + const aStartsWith = aName.startsWith(lowercaseSearch) ? 100 : 0; + const bStartsWith = bName.startsWith(lowercaseSearch) ? 100 : 0; + + const aScore = aExactMatch + aStartsWith + (1000 - aName.length); + const bScore = bExactMatch + bStartsWith + (1000 - bName.length); + + return bScore - aScore; + }); + } + + // Apply skill filters + return searchResults.filter((agent) => { + const matchesSkill = + selectedAgentSkills.length === 0 || + agent.skills?.some((skill) => skill.tags?.some((tag) => selectedAgentSkills.includes(tag))); + + return matchesSkill; + }); + }, [agentHubData, agentSearchTerm, selectedAgentSkills]); + const showModal = (model: ModelGroupInfo) => { setSelectedModel(model); setIsModalVisible(true); @@ -212,6 +325,21 @@ const PublicModelHub: React.FC = ({ accessToken }) => { setSelectedModel(null); }; + const showAgentModal = (agent: AgentCard) => { + setSelectedAgent(agent); + setIsAgentModalVisible(true); + }; + + const handleAgentModalOk = () => { + setIsAgentModalVisible(false); + setSelectedAgent(null); + }; + + const handleAgentModalCancel = () => { + setIsAgentModalVisible(false); + setSelectedAgent(null); + }; + const copyToClipboard = (text: string) => { navigator.clipboard.writeText(text); NotificationsManager.success("Copied to clipboard!"); @@ -446,6 +574,143 @@ const PublicModelHub: React.FC = ({ accessToken }) => { }, ]; + const publicAgentHubColumns = (): ColumnDef[] => [ + { + header: "Agent Name", + accessorKey: "name", + enableSorting: true, + cell: ({ row }) => ( +
+ + + +
+ ), + size: 150, + }, + { + header: "Description", + accessorKey: "description", + enableSorting: false, + cell: ({ row }) => { + const description = row.original.description; + const truncated = description.length > 80 ? description.substring(0, 80) + "..." : description; + return ( + + {truncated} + + ); + }, + size: 250, + }, + { + header: "Version", + accessorKey: "version", + enableSorting: true, + cell: ({ row }) => {row.original.version}, + size: 80, + }, + { + header: "Provider", + accessorKey: "provider", + enableSorting: false, + cell: ({ row }) => { + const provider = row.original.provider; + if (!provider) return -; + return ( +
+ {provider.organization} +
+ ); + }, + size: 120, + }, + { + header: "Skills", + accessorKey: "skills", + enableSorting: false, + cell: ({ row }) => { + const skills = row.original.skills || []; + if (skills.length === 0) { + return -; + } + + if (skills.length === 1) { + return ( +
+ + {skills[0].name} + +
+ ); + } + + return ( +
+ + {skills[0].name} + + +
All Skills:
+ {skills.map((skill, index) => ( +
+ • {skill.name} +
+ ))} +
+ } + trigger="click" + placement="topLeft" + > + e.stopPropagation()} + > + +{skills.length - 1} + + + + ); + }, + size: 150, + }, + { + header: "Capabilities", + accessorKey: "capabilities", + enableSorting: false, + cell: ({ row }) => { + const capabilities = row.original.capabilities || {}; + const capList = Object.entries(capabilities) + .filter(([_, value]) => value === true) + .map(([key]) => key); + + if (capList.length === 0) { + return -; + } + + return ( +
+ {capList.map((cap) => ( + + {cap} + + ))} +
+ ); + }, + size: 150, + }, + ]; + return (
@@ -503,125 +768,202 @@ const PublicModelHub: React.FC = ({ accessToken }) => {
- {/* Models Table */} + {/* Tabs for Models and Agents */} -
- Available Models -
+ + {/* Models Tab */} + +
+ Available Models +
- {/* Filters */} -
-
-
- Search Models: - - - + {/* Filters */} +
+
+
+ Search Models: + + + +
+
+ + setSearchTerm(e.target.value)} + className="border border-gray-300 rounded-lg pl-10 pr-4 py-2 w-full text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white" + /> +
+
+
+ Provider: + +
+
+ Mode: + +
+
+ Features: + +
-
- - setSearchTerm(e.target.value)} - className="border border-gray-300 rounded-lg pl-10 pr-4 py-2 w-full text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white" - /> + + + +
+ + Showing {filteredData.length} of {modelHubData?.length || 0} models +
-
-
- Provider: - -
-
- Mode: - -
-
- Features: - -
-
+
+ + setAgentSearchTerm(e.target.value)} + className="border border-gray-300 rounded-lg pl-10 pr-4 py-2 w-full text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white" + /> +
+
+
+ Skills: + +
+
- + -
- - Showing {filteredData.length} of {modelHubData?.length || 0} models - -
+
+ + Showing {filteredAgentData.length} of {agentHubData?.length || 0} agents + +
+
+ )} +
@@ -852,6 +1194,308 @@ const PublicModelHub: React.FC = ({ accessToken }) => { )} + + {/* Agent Details Modal */} + + {selectedAgent?.name || "Agent Details"} + {selectedAgent && ( + + copyToClipboard(selectedAgent.name)} + className="cursor-pointer text-gray-500 hover:text-blue-500 w-4 h-4" + /> + + )} + + } + width={1000} + open={isAgentModalVisible} + footer={null} + onOk={handleAgentModalOk} + onCancel={handleAgentModalCancel} + > + {selectedAgent && ( +
+ {/* Agent Overview */} +
+ Agent Overview +
+
+ Name: + {selectedAgent.name} +
+
+ Version: + {selectedAgent.version} +
+
+ Description: + {selectedAgent.description} +
+ {selectedAgent.url && ( + + )} +
+
+ + {/* Capabilities */} + {selectedAgent.capabilities && ( +
+ Capabilities +
+ {Object.entries(selectedAgent.capabilities) + .filter(([_, value]) => value === true) + .map(([key]) => ( + + {key} + + ))} +
+
+ )} + + {/* Skills */} + {selectedAgent.skills && selectedAgent.skills.length > 0 && ( +
+ Skills +
+ {selectedAgent.skills.map((skill, index) => ( +
+
+
+ {skill.name} + {skill.description} +
+
+ {skill.tags && skill.tags.length > 0 && ( +
+ {skill.tags.map((tag) => ( + + {tag} + + ))} +
+ )} +
+ ))} +
+
+ )} + + {/* Input/Output Modes */} +
+ Input/Output Modes +
+
+ Input Modes: +
+ {selectedAgent.defaultInputModes?.map((mode) => ( + + {mode} + + ))} +
+
+
+ Output Modes: +
+ {selectedAgent.defaultOutputModes?.map((mode) => ( + + {mode} + + ))} +
+
+
+
+ + {/* Documentation */} + {selectedAgent.documentationUrl && ( +
+ Documentation + + + View Documentation + +
+ )} + + {/* A2A Usage Example */} +
+ Usage Example (A2A Protocol) + + {/* Step 1: Retrieve Agent Card */} +
+ Step 1: Retrieve Agent Card +
+
+{`base_url = '${selectedAgent.url}'
+
+resolver = A2ACardResolver(
+    httpx_client=httpx_client,
+    base_url=base_url,
+    # agent_card_path uses default, extended_agent_card_path also uses default
+)
+
+# Fetch Public Agent Card and Initialize Client
+final_agent_card_to_use: AgentCard | None = None
+_public_card = (
+    await resolver.get_agent_card()
+)  # Fetches from default public path - \`/agents/{agent_id}/\`
+final_agent_card_to_use = _public_card
+
+if _public_card.supports_authenticated_extended_card:
+    try:
+        auth_headers_dict = {
+            'Authorization': 'Bearer dummy-token-for-extended-card'
+        }
+        _extended_card = await resolver.get_agent_card(
+            relative_card_path=EXTENDED_AGENT_CARD_PATH,
+            http_kwargs={'headers': auth_headers_dict},
+        )
+        final_agent_card_to_use = (
+            _extended_card  # Update to use the extended card
+        )
+    except Exception as e_extended:
+        logger.warning(
+            f'Failed to fetch extended agent card: {e_extended}. Will proceed with public card.',
+            exc_info=True,
+        )`}
+                    
+
+
+ +
+
+ + {/* Step 2: Call the Agent */} +
+ Step 2: Call the Agent +
+
+{`client = A2AClient(
+    httpx_client=httpx_client, agent_card=final_agent_card_to_use
+)
+
+send_message_payload: dict[str, Any] = {
+    'message': {
+        'role': 'user',
+        'parts': [
+            {'kind': 'text', 'text': 'how much is 10 USD in INR?'}
+        ],
+        'messageId': uuid4().hex,
+    },
+}
+request = SendMessageRequest(
+    id=str(uuid4()), params=MessageSendParams(**send_message_payload)
+)
+
+response = await client.send_message(request)
+print(response.model_dump(mode='json', exclude_none=True))`}
+                    
+
+
+ +
+
+
+
+ )} +
);