Agents - AI Hub, make agents discoverable on model hub page for internal discovery (#16678)

* feat: initial commit adding agent hub to ui

* feat: add viewable agent hub

* feat: working support for making both config + db agents public via new 'public_agent_groups' list

* fix: agents.py

fix types

* feat: working PATCH endpoint for UI changes

* feat: add new agents panel with working crud

* refactor: refactor to show created_at on be/fe

* style: align new page with the agents table

* style: more style alignment logic

* feat: return if agent is public or not in /v1/agents

* feat: initial commit adding ui flow for making agents discoverable

* feat: new batch make public endpoint

* feat(public_model_hub.tsx): show public agents on public model hub table page

* fix(public_model_hub.tsx): add code examples for using the agent in a2a

* fix: fix indicating if agent has already been made public

* docs: document expected spec for agents is A2A

* docs: add agent hub docs

* docs: document making agents discoverable

* docs: add demo video to docs

* fix: fix ui linting errors

* fix: update tests
This commit is contained in:
Krish Dholakia 2025-11-15 18:25:16 -08:00 committed by Krrish Dholakia
parent e45655a2cf
commit f36d9e5fd9
34 changed files with 3694 additions and 332 deletions

View file

@ -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.
<Image img={require('../../img/final_public_model_hub_view.png')} />
## 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`)
<Image img={require('../../img/model_hub_admin_view.png')} />
#### 2. Select the models you want to expose
Click on `Select Models to Make Public` and select the models you want to expose.
<Image img={require('../../img/make_public_modal.png')} />
#### 3. Confirm the changes
<Image img={require('../../img/make_public_modal_confirmation.png')} />
#### 4. Success!
Go to the public url (`PROXY_BASE_URL/ui/model_hub_table`) and see available models.
<Image img={require('../../img/final_public_model_hub_view.png')} />
### 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/).
<Tabs>
<TabItem value="ui" label="UI">
<Image img={require('../../img/add_agent.png')} />
</TabItem>
<TabItem value="api" label="API">
```bash
curl -X POST 'http://0.0.0.0:4000/v1/agents' \
--header 'Authorization: Bearer <your-master-key>' \
--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"
}
```
</TabItem>
</Tabs>
### 2. Make agent public
Make the agent discoverable on the AI Hub.
<Tabs>
<TabItem value="ui" label="UI">
Navigate to the Agents Tab on the AI Hub page
<Image img={require('../../img/ai_hub_with_agents.png')} />
Select the agents you want to make public and click on `Make Public` button.
<Image img={require('../../img/make_agents_public.png')} />
</TabItem>
<TabItem value="api" label="API">
**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 <your-master-key>' \
--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 <your-master-key>' \
--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"
}
```
</TabItem>
</Tabs>
### 3. View public agents
Users can now discover the agent via the public endpoint.
<Tabs>
<TabItem value="ui" label="UI">
<Image img={require('../../img/public_agent_hub.png')} />
</TabItem>
<TabItem value="api" label="API">
```bash
curl -X GET 'http://0.0.0.0:4000/public/agent_hub' \
--header 'Authorization: Bearer <user-api-key>'
```
**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"]
}
]
}
]
```
</TabItem>
</Tabs>

View file

@ -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)
<Image img={require('../../img/model_hub.png')} style={{ width: '900px', height: 'auto' }}/>

View file

@ -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.
<Image img={require('../../img/final_public_model_hub_view.png')} />
## 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`)
<Image img={require('../../img/model_hub_admin_view.png')} />
### 2. Select the models you want to expose
Click on `Make Public` and select the models you want to expose.
<Image img={require('../../img/make_public_modal.png')} />
### 3. Confirm the changes
<Image img={require('../../img/make_public_modal_confirmation.png')} />
### 4. Success!
Go to the public url (`PROXY_BASE_URL/ui/model_hub_table`) and see available models.
<Image img={require('../../img/final_public_model_hub_view.png')} />
## 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
```

View file

@ -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.
:::

Binary file not shown.

After

Width:  |  Height:  |  Size: 616 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 626 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 647 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 445 KiB

View file

@ -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",

View file

@ -0,0 +1,2 @@
-- This is an empty migration.

View file

@ -0,0 +1,2 @@
-- This is an empty migration.

View file

@ -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 ###

View file

@ -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",
]

View file

@ -34,4 +34,4 @@ agent_list:
make_public: true
litellm_settings:
callbacks: ["prometheus"]
callbacks: ["prometheus"]

View file

@ -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:

View file

@ -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 <your_api_key>" \\
-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 <your_api_key>" \\
-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 <your_api_key>" \\
-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))

View file

@ -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):

View file

@ -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(

View file

@ -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]

View file

@ -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() {
<BudgetPanel accessToken={accessToken} />
) : page == "guardrails" ? (
<GuardrailsPanel accessToken={accessToken} userRole={userRole} />
) : page == "agents" ? (
<AgentsPanel accessToken={accessToken} userRole={userRole} />
) : page == "prompts" ? (
<PromptsPanel accessToken={accessToken} userRole={userRole} />
) : page == "transform-request" ? (

View file

@ -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<AgentHubData>[] => {
const allColumns: ColumnDef<AgentHubData>[] = [
{
header: "Agent Name",
accessorKey: "name",
enableSorting: true,
sortingFn: "alphanumeric",
cell: ({ row }) => {
const agent = row.original;
return (
<div className="space-y-1">
<div className="flex items-center space-x-2">
<Text className="font-medium text-sm">{agent.name}</Text>
<Tooltip title="Copy agent name">
<CopyOutlined
onClick={() => copyToClipboard(agent.name)}
className="cursor-pointer text-gray-500 hover:text-blue-500 text-xs"
/>
</Tooltip>
</div>
{/* Show description on mobile */}
<div className="md:hidden">
<Text className="text-xs text-gray-600">{agent.description}</Text>
</div>
</div>
);
},
},
{
header: "Description",
accessorKey: "description",
enableSorting: true,
sortingFn: "alphanumeric",
cell: ({ row }) => {
const agent = row.original;
return (
<Text className="text-xs line-clamp-2">
{agent.description || "-"}
</Text>
);
},
meta: {
className: "hidden md:table-cell",
},
},
{
header: "Version",
accessorKey: "version",
enableSorting: true,
sortingFn: "alphanumeric",
cell: ({ row }) => {
const agent = row.original;
return (
<Badge color="blue" size="sm">
v{agent.version}
</Badge>
);
},
meta: {
className: "hidden lg:table-cell",
},
},
{
header: "Protocol",
accessorKey: "protocolVersion",
enableSorting: true,
sortingFn: "alphanumeric",
cell: ({ row }) => {
const agent = row.original;
return (
<Text className="text-xs">
{agent.protocolVersion || "-"}
</Text>
);
},
meta: {
className: "hidden lg:table-cell",
},
},
{
header: "Skills",
accessorKey: "skills",
enableSorting: false,
cell: ({ row }) => {
const agent = row.original;
const skills = agent.skills || [];
return (
<div className="space-y-1">
<Text className="text-xs font-medium">
{skills.length} skill{skills.length !== 1 ? "s" : ""}
</Text>
{skills.length > 0 && (
<div className="flex flex-wrap gap-1">
{skills.slice(0, 2).map((skill) => (
<Tag key={skill.id} color="purple" className="text-xs">
{skill.name}
</Tag>
))}
{skills.length > 2 && (
<Text className="text-xs text-gray-500">+{skills.length - 2}</Text>
)}
</div>
)}
</div>
);
},
},
{
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 (
<div className="flex flex-wrap gap-1">
{capabilityList.length === 0 ? (
<Text className="text-gray-500 text-xs">-</Text>
) : (
capabilityList.map((capability) => (
<Badge key={capability} color="green" size="xs">
{capability}
</Badge>
))
)}
</div>
);
},
},
{
header: "I/O Modes",
accessorKey: "defaultInputModes",
enableSorting: false,
cell: ({ row }) => {
const agent = row.original;
const inputModes = agent.defaultInputModes || [];
const outputModes = agent.defaultOutputModes || [];
return (
<div className="space-y-1">
<Text className="text-xs">
<span className="font-medium">In:</span> {inputModes.join(", ") || "-"}
</Text>
<Text className="text-xs">
<span className="font-medium">Out:</span> {outputModes.join(", ") || "-"}
</Text>
</div>
);
},
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 ? (
<Badge color="green" size="xs">
Yes
</Badge>
) : (
<Badge color="gray" size="xs">
No
</Badge>
);
},
meta: {
className: "hidden md:table-cell",
},
},
{
header: "Details",
id: "details",
enableSorting: false,
cell: ({ row }) => {
const agent = row.original;
return (
<Button size="xs" variant="secondary" onClick={() => showModal(agent)} icon={InfoCircleOutlined}>
<span className="hidden lg:inline">Details</span>
<span className="lg:hidden">Info</span>
</Button>
);
},
},
];
return allColumns;
};

View file

@ -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<AgentsPanelProps> = ({ accessToken, userRole }) => {
const [agentsList, setAgentsList] = useState<Agent[]>([]);
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<string | null>(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 (
<div className="w-full mx-auto flex-auto overflow-y-auto m-8 p-2">
<div className="flex justify-between items-center mb-4">
<div className="flex-col gap-2">
<h1 className="text-2xl font-bold">Agents</h1>
<p className="text-sm text-gray-600">List of A2A-spec agents that are available to be used in your organization. Go to AI Hub, to make agents public.</p>
</div>
<Button onClick={handleAddAgent} disabled={!accessToken}>
+ Add New Agent
</Button>
</div>
{selectedAgentId ? (
<AgentInfoView
agentId={selectedAgentId}
onClose={() => setSelectedAgentId(null)}
accessToken={accessToken}
isAdmin={isAdmin}
/>
) : (
<AgentTable
agentsList={agentsList}
isLoading={isLoading}
onDeleteClick={handleDeleteClick}
accessToken={accessToken}
onAgentUpdated={fetchAgents}
isAdmin={isAdmin}
onAgentClick={(id) => setSelectedAgentId(id)}
/>
)}
<AddAgentForm
visible={isAddModalVisible}
onClose={handleCloseModal}
accessToken={accessToken}
onSuccess={handleSuccess}
/>
{agentToDelete && (
<Modal
title="Delete Agent"
open={agentToDelete !== null}
onOk={handleDeleteConfirm}
onCancel={handleDeleteCancel}
confirmLoading={isDeleting}
okText="Delete"
okButtonProps={{ danger: true }}
>
<p>Are you sure you want to delete agent: {agentToDelete.name}?</p>
<p>This action cannot be undone.</p>
</Modal>
)}
</div>
);
};
export default AgentsPanel;

View file

@ -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<AddAgentFormProps> = ({
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 (
<Modal
title="Add New Agent"
open={visible}
onCancel={handleCancel}
footer={null}
width={800}
>
<Form
form={form}
layout="vertical"
onFinish={handleSubmit}
initialValues={getDefaultFormValues()}
>
<AgentFormFields showAgentName={true} />
<Form.Item>
<div style={{ display: "flex", justifyContent: "flex-end", gap: "8px" }}>
<AntButton onClick={handleCancel}>
Cancel
</AntButton>
<AntButton
htmlType="submit"
loading={isSubmitting}
>
Create Agent
</AntButton>
</div>
</Form.Item>
</Form>
</Modal>
);
};
export default AddAgentForm;

View file

@ -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,
};
};

View file

@ -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<AgentFormFieldsProps> = ({ showAgentName = true }) => {
return (
<>
{showAgentName && (
<Form.Item
label="Agent Name"
name="agent_name"
rules={[{ required: true, message: "Please enter a unique agent name" }]}
tooltip="Unique identifier for the agent"
>
<Input placeholder="e.g., customer-support-agent" />
</Form.Item>
)}
<Collapse defaultActiveKey={['basic']} style={{ marginBottom: 16 }}>
{/* Basic Information */}
<Panel header={`${AGENT_FORM_CONFIG.basic.title} (Required)`} key={AGENT_FORM_CONFIG.basic.key}>
{AGENT_FORM_CONFIG.basic.fields.map((field) => (
<Form.Item
key={field.name}
label={field.label}
name={field.name}
rules={field.required ? [{ required: true, message: `Please enter ${field.label.toLowerCase()}` }] : undefined}
tooltip={field.tooltip}
>
{field.type === 'textarea' ? (
<Input.TextArea rows={field.rows} placeholder={field.placeholder} />
) : (
<Input placeholder={field.placeholder} />
)}
</Form.Item>
))}
</Panel>
{/* Skills */}
<Panel header={`${AGENT_FORM_CONFIG.skills.title} (Required)`} key={AGENT_FORM_CONFIG.skills.key}>
<Form.List name="skills">
{(fields, { add, remove }) => (
<>
{fields.map((field) => (
<div key={field.key} style={{ marginBottom: 16, padding: 16, border: '1px solid #d9d9d9', borderRadius: 4 }}>
<Form.Item
{...field}
label={SKILL_FIELD_CONFIG.id.label}
name={[field.name, 'id']}
rules={[{ required: SKILL_FIELD_CONFIG.id.required, message: 'Required' }]}
>
<Input placeholder={SKILL_FIELD_CONFIG.id.placeholder} />
</Form.Item>
<Form.Item
{...field}
label={SKILL_FIELD_CONFIG.name.label}
name={[field.name, 'name']}
rules={[{ required: SKILL_FIELD_CONFIG.name.required, message: 'Required' }]}
>
<Input placeholder={SKILL_FIELD_CONFIG.name.placeholder} />
</Form.Item>
<Form.Item
{...field}
label={SKILL_FIELD_CONFIG.description.label}
name={[field.name, 'description']}
rules={[{ required: SKILL_FIELD_CONFIG.description.required, message: 'Required' }]}
>
<Input.TextArea rows={SKILL_FIELD_CONFIG.description.rows} placeholder={SKILL_FIELD_CONFIG.description.placeholder} />
</Form.Item>
<Form.Item
{...field}
label={SKILL_FIELD_CONFIG.tags.label}
name={[field.name, 'tags']}
rules={[{ required: SKILL_FIELD_CONFIG.tags.required, message: 'Required' }]}
getValueFromEvent={(e) => e.target.value.split(',').map((s: string) => s.trim())}
getValueProps={(value) => ({ value: Array.isArray(value) ? value.join(', ') : value })}
>
<Input placeholder={SKILL_FIELD_CONFIG.tags.placeholder} />
</Form.Item>
<Form.Item
{...field}
label={SKILL_FIELD_CONFIG.examples.label}
name={[field.name, 'examples']}
getValueFromEvent={(e) => e.target.value.split(',').map((s: string) => s.trim()).filter((s: string) => s)}
getValueProps={(value) => ({ value: Array.isArray(value) ? value.join(', ') : '' })}
>
<Input placeholder={SKILL_FIELD_CONFIG.examples.placeholder} />
</Form.Item>
<AntButton
type="link"
danger
onClick={() => remove(field.name)}
icon={<MinusCircleOutlined />}
>
Remove Skill
</AntButton>
</div>
))}
<AntButton
type="dashed"
onClick={() => add()}
icon={<PlusOutlined />}
style={{ width: '100%' }}
>
Add Skill
</AntButton>
</>
)}
</Form.List>
</Panel>
{/* Capabilities */}
<Panel header={AGENT_FORM_CONFIG.capabilities.title} key={AGENT_FORM_CONFIG.capabilities.key}>
{AGENT_FORM_CONFIG.capabilities.fields.map((field) => (
<Form.Item
key={field.name}
label={field.label}
name={field.name}
valuePropName="checked"
>
<Switch />
</Form.Item>
))}
</Panel>
{/* Optional Settings */}
<Panel header={AGENT_FORM_CONFIG.optional.title} key={AGENT_FORM_CONFIG.optional.key}>
{AGENT_FORM_CONFIG.optional.fields.map((field) => (
<Form.Item
key={field.name}
label={field.label}
name={field.name}
valuePropName={field.type === 'switch' ? 'checked' : undefined}
>
{field.type === 'switch' ? <Switch /> : <Input placeholder={field.placeholder} />}
</Form.Item>
))}
</Panel>
{/* LiteLLM Parameters */}
<Panel header={AGENT_FORM_CONFIG.litellm.title} key={AGENT_FORM_CONFIG.litellm.key}>
{AGENT_FORM_CONFIG.litellm.fields.map((field) => (
<Form.Item
key={field.name}
label={field.label}
name={field.name}
valuePropName={field.type === 'switch' ? 'checked' : undefined}
>
{field.type === 'switch' ? <Switch /> : <Input placeholder={field.placeholder} />}
</Form.Item>
))}
</Panel>
</Collapse>
</>
);
};
export default AgentFormFields;

View file

@ -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<AgentInfoViewProps> = ({
agentId,
onClose,
accessToken,
isAdmin,
}) => {
const [agent, setAgent] = useState<Agent | null>(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 (
<div className="p-4">
<div className="flex justify-center items-center h-64">
<Spin size="large" />
</div>
</div>
);
}
if (!agent) {
return (
<div className="p-4">
<div className="text-center">Agent not found</div>
<TremorButton onClick={onClose} className="mt-4">
Back to Agents List
</TremorButton>
</div>
);
}
// Format date helper function
const formatDate = (dateString?: string) => {
if (!dateString) return "-";
const date = new Date(dateString);
return date.toLocaleString();
};
return (
<div className="p-4">
<div>
<TremorButton icon={ArrowLeftIcon} variant="light" onClick={onClose} className="mb-4">
Back to Agents
</TremorButton>
<Title>{agent.agent_name || "Unnamed Agent"}</Title>
<Text className="text-gray-500 font-mono">{agent.agent_id}</Text>
</div>
<TabGroup>
<TabList className="mb-4">
<Tab key="overview">Overview</Tab>
{isAdmin ? <Tab key="settings">Settings</Tab> : <></>}
</TabList>
<TabPanels>
{/* Overview Panel */}
<TabPanel>
<Descriptions bordered column={1}>
<Descriptions.Item label="Agent ID">{agent.agent_id}</Descriptions.Item>
<Descriptions.Item label="Agent Name">{agent.agent_name}</Descriptions.Item>
<Descriptions.Item label="Display Name">{agent.agent_card_params?.name || "-"}</Descriptions.Item>
<Descriptions.Item label="Description">{agent.agent_card_params?.description || "-"}</Descriptions.Item>
<Descriptions.Item label="URL">{agent.agent_card_params?.url || "-"}</Descriptions.Item>
<Descriptions.Item label="Version">{agent.agent_card_params?.version || "-"}</Descriptions.Item>
<Descriptions.Item label="Protocol Version">{agent.agent_card_params?.protocolVersion || "-"}</Descriptions.Item>
<Descriptions.Item label="Streaming">
{agent.agent_card_params?.capabilities?.streaming ? "Yes" : "No"}
</Descriptions.Item>
{agent.agent_card_params?.capabilities?.pushNotifications && (
<Descriptions.Item label="Push Notifications">Yes</Descriptions.Item>
)}
{agent.agent_card_params?.capabilities?.stateTransitionHistory && (
<Descriptions.Item label="State Transition History">Yes</Descriptions.Item>
)}
<Descriptions.Item label="Skills">
{agent.agent_card_params?.skills?.length || 0} configured
</Descriptions.Item>
{agent.litellm_params?.model && (
<Descriptions.Item label="Model">{agent.litellm_params.model}</Descriptions.Item>
)}
{agent.litellm_params?.make_public !== undefined && (
<Descriptions.Item label="Make Public">{agent.litellm_params.make_public ? "Yes" : "No"}</Descriptions.Item>
)}
{agent.agent_card_params?.iconUrl && (
<Descriptions.Item label="Icon URL">{agent.agent_card_params.iconUrl}</Descriptions.Item>
)}
{agent.agent_card_params?.documentationUrl && (
<Descriptions.Item label="Documentation URL">{agent.agent_card_params.documentationUrl}</Descriptions.Item>
)}
<Descriptions.Item label="Created At">{formatDate(agent.created_at)}</Descriptions.Item>
<Descriptions.Item label="Updated At">{formatDate(agent.updated_at)}</Descriptions.Item>
</Descriptions>
{agent.agent_card_params?.skills && agent.agent_card_params.skills.length > 0 && (
<div style={{ marginTop: 24 }}>
<Title>Skills</Title>
<Descriptions bordered column={1} style={{ marginTop: 16 }}>
{agent.agent_card_params.skills.map((skill: any, index: number) => (
<Descriptions.Item label={skill.name || `Skill ${index + 1}`} key={index}>
<div>
<div><strong>ID:</strong> {skill.id}</div>
<div><strong>Description:</strong> {skill.description}</div>
<div><strong>Tags:</strong> {Array.isArray(skill.tags) ? skill.tags.join(", ") : skill.tags}</div>
{skill.examples && skill.examples.length > 0 && (
<div><strong>Examples:</strong> {Array.isArray(skill.examples) ? skill.examples.join(", ") : skill.examples}</div>
)}
</div>
</Descriptions.Item>
))}
</Descriptions>
</div>
)}
</TabPanel>
{/* Settings Panel (only for admins) */}
{isAdmin && (
<TabPanel>
<Card>
<div className="flex justify-between items-center mb-4">
<Title>Agent Settings</Title>
{!isEditing && (
<TremorButton onClick={() => setIsEditing(true)}>Edit Settings</TremorButton>
)}
</div>
{isEditing ? (
<Form
form={form}
layout="vertical"
onFinish={handleUpdate}
>
<Form.Item label="Agent ID">
<Input value={agent.agent_id} disabled />
</Form.Item>
<AgentFormFields showAgentName={true} />
<div className="flex justify-end gap-2 mt-6">
<AntButton onClick={() => {
setIsEditing(false);
fetchAgentInfo();
}}>
Cancel
</AntButton>
<TremorButton loading={isSaving}>
Save Changes
</TremorButton>
</div>
</Form>
) : (
<Text>Click &quot;Edit Settings&quot; to modify agent configuration.</Text>
)}
</Card>
</TabPanel>
)}
</TabPanels>
</TabGroup>
</div>
);
};
export default AgentInfoView;

View file

@ -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<AgentTableProps> = ({
agentsList,
isLoading,
onDeleteClick,
accessToken,
onAgentUpdated,
isAdmin,
onAgentClick,
}) => {
if (isLoading) {
return <div>Loading agents...</div>;
}
if (!agentsList || agentsList.length === 0) {
return <div>No agents found. Create one to get started.</div>;
}
return (
<Table>
<TableHead>
<TableRow>
<TableHeaderCell>Agent Name</TableHeaderCell>
<TableHeaderCell>Description</TableHeaderCell>
<TableHeaderCell>Created At</TableHeaderCell>
{isAdmin && <TableHeaderCell>Actions</TableHeaderCell>}
</TableRow>
</TableHead>
<TableBody>
{agentsList.map((agent) => (
<TableRow key={agent.agent_id}>
<TableCell>
<Tooltip title={agent.agent_name || ""}>
<Button
size="xs"
variant="light"
className="font-mono text-blue-500 bg-blue-50 hover:bg-blue-100 text-xs font-normal px-2 py-0.5 text-left overflow-hidden truncate max-w-[200px]"
onClick={() => onAgentClick(agent.agent_id)}
>
{agent.agent_name || ""}
</Button>
</Tooltip>
</TableCell>
<TableCell>
{agent.agent_card_params?.description || "No description"}
</TableCell>
<TableCell>
{agent.created_at
? new Date(agent.created_at).toLocaleDateString()
: "N/A"}
</TableCell>
{isAdmin && (
<TableCell>
<div className="flex space-x-2">
<Tooltip title="Delete agent">
<Icon
icon={TrashIcon}
size="sm"
className="cursor-pointer text-red-500 hover:text-red-700"
onClick={(e) => {
e.stopPropagation();
onDeleteClick(agent.agent_id, agent.agent_name);
}}
aria-label="Delete agent"
/>
</Tooltip>
</div>
</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
);
};
export default AgentTable;

View file

@ -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[];
}

View file

@ -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(<Sidebar {...defaultProps} />);
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();
});
});
});

View file

@ -12,6 +12,7 @@ import {
KeyOutlined,
LineChartOutlined,
PlayCircleOutlined,
RobotOutlined,
SafetyOutlined,
SearchOutlined,
SettingOutlined,
@ -100,7 +101,7 @@ const Sidebar: React.FC<SidebarProps> = ({ accessToken, setPage, userRole, defau
{
key: "16",
page: "model-hub-table",
label: "Model Hub",
label: "AI Hub",
icon: <AppstoreOutlined style={{ fontSize: "18px" }} />,
},
{ key: "15", page: "logs", label: "Logs", icon: <LineChartOutlined style={{ fontSize: "18px" }} /> },
@ -111,13 +112,13 @@ const Sidebar: React.FC<SidebarProps> = ({ accessToken, setPage, userRole, defau
icon: <SafetyOutlined style={{ fontSize: "18px" }} />,
roles: all_admin_roles,
},
{ key: "18", page: "mcp-servers", label: "MCP Servers", icon: <ToolOutlined style={{ fontSize: "18px" }} /> },
{
key: "26",
page: "tools",
label: "Tools",
icon: <ToolOutlined style={{ fontSize: "18px" }} />,
children: [
{ key: "18", page: "mcp-servers", label: "MCP Servers", icon: <ToolOutlined style={{ fontSize: "18px" }} /> },
{
key: "28",
page: "search-tools",
@ -146,6 +147,13 @@ const Sidebar: React.FC<SidebarProps> = ({ accessToken, setPage, userRole, defau
icon: <DatabaseOutlined style={{ fontSize: "18px" }} />,
roles: all_admin_roles,
},
{
"key": "29",
"page": "agents",
"label": "Agents",
"icon": <RobotOutlined style={{ fontSize: "18px" }} />,
"roles": rolesWithWriteAccess,
},
{
key: "25",
page: "prompts",

View file

@ -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<MakeAgentPublicFormProps> = ({
visible,
onClose,
accessToken,
agentHubData,
onSuccess,
}) => {
const [currentStep, setCurrentStep] = useState(0);
const [selectedAgents, setSelectedAgents] = useState<Set<string>>(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 (
<div className="space-y-4">
<div className="flex items-center justify-between">
<Title>Select Agents to Make Public</Title>
<div className="flex items-center space-x-2">
<Checkbox
checked={allAgentsSelected}
indeterminate={isIndeterminate}
onChange={(e) => handleSelectAll(e.target.checked)}
disabled={agentHubData.length === 0}
>
Select All {agentHubData.length > 0 && `(${agentHubData.length})`}
</Checkbox>
</div>
</div>
<Text className="text-sm text-gray-600">
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.
</Text>
<div className="max-h-96 overflow-y-auto border rounded-lg p-4">
<div className="space-y-3">
{agentHubData.length === 0 ? (
<div className="text-center py-8 text-gray-500">
<Text>No agents available.</Text>
</div>
) : (
agentHubData.map((agent) => {
const agentId = agent.agent_id || agent.name;
return (
<div
key={agentId}
className="flex items-center space-x-3 p-3 border rounded-lg hover:bg-gray-50"
>
<Checkbox
checked={selectedAgents.has(agentId)}
onChange={(e) => handleAgentSelection(agentId, e.target.checked)}
/>
<div className="flex-1">
<div className="flex items-center space-x-2">
<Text className="font-medium">{agent.name}</Text>
<Badge color="blue" size="sm">
v{agent.version}
</Badge>
</div>
<Text className="text-xs text-gray-600 mt-1">{agent.description}</Text>
{agent.skills && agent.skills.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
{agent.skills.slice(0, 3).map((skill) => (
<Badge key={skill.id} color="purple" size="xs">
{skill.name}
</Badge>
))}
{agent.skills.length > 3 && (
<Text className="text-xs text-gray-500">+{agent.skills.length - 3} more</Text>
)}
</div>
)}
</div>
</div>
);
})
)}
</div>
</div>
{selectedAgents.size > 0 && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
<Text className="text-sm text-blue-800">
<strong>{selectedAgents.size}</strong> agent{selectedAgents.size !== 1 ? "s" : ""} selected
</Text>
</div>
)}
</div>
);
};
const renderStep2Content = () => {
return (
<div className="space-y-4">
<Title>Confirm Making Agents Public</Title>
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
<Text className="text-sm text-yellow-800">
<strong>Warning:</strong> Once you make these agents public, anyone who can go to the{" "}
<code>/ui/model_hub_table</code> will be able to know they exist on the proxy.
</Text>
</div>
<div className="space-y-3">
<Text className="font-medium">Agents to be made public:</Text>
<div className="max-h-48 overflow-y-auto border rounded-lg p-3">
<div className="space-y-2">
{Array.from(selectedAgents).map((agentId) => {
const agent = agentHubData.find((a) => (a.agent_id || a.name) === agentId);
return (
<div key={agentId} className="flex items-center justify-between p-2 bg-gray-50 rounded">
<div className="flex-1">
<div className="flex items-center space-x-2">
<Text className="font-medium">{agent?.name || agentId}</Text>
{agent && (
<Badge color="blue" size="xs">
v{agent.version}
</Badge>
)}
</div>
{agent?.description && (
<Text className="text-xs text-gray-600 mt-1">{agent.description}</Text>
)}
</div>
</div>
);
})}
</div>
</div>
</div>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
<Text className="text-sm text-blue-800">
Total: <strong>{selectedAgents.size}</strong> agent{selectedAgents.size !== 1 ? "s" : ""} will be made
public
</Text>
</div>
</div>
);
};
const renderStepContent = () => {
switch (currentStep) {
case 0:
return renderStep1Content();
case 1:
return renderStep2Content();
default:
return null;
}
};
const renderStepButtons = () => {
return (
<div className="flex justify-between mt-6">
<Button onClick={currentStep === 0 ? handleClose : handlePrevious}>
{currentStep === 0 ? "Cancel" : "Previous"}
</Button>
<div className="flex space-x-2">
{currentStep === 0 && (
<Button onClick={handleNext} disabled={selectedAgents.size === 0}>
Next
</Button>
)}
{currentStep === 1 && (
<Button onClick={handleSubmit} loading={loading}>
Make Public
</Button>
)}
</div>
</div>
);
};
return (
<Modal
title="Make Agents Public"
open={visible}
onCancel={handleClose}
footer={null}
width={1200}
maskClosable={false}
>
<Form form={form} layout="vertical">
<Steps current={currentStep} className="mb-6">
<Step title="Select Agents" />
<Step title="Confirm" />
</Steps>
{renderStepContent()}
{renderStepButtons()}
</Form>
</Modal>
);
};
export default MakeAgentPublicForm;

View file

@ -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<ModelHubTableProps> = ({ accessToken, publicPage,
const [selectedModel, setSelectedModel] = useState<null | ModelGroupInfo>(null);
const [filteredData, setFilteredData] = useState<ModelGroupInfo[]>([]);
const [isMakePublicModalVisible, setIsMakePublicModalVisible] = useState(false);
// Agent Hub state
const [agentHubData, setAgentHubData] = useState<AgentHubData[] | null>(null);
const [isMakeAgentPublicModalVisible, setIsMakeAgentPublicModalVisible] = useState(false);
const [agentLoading, setAgentLoading] = useState<boolean>(true);
const [selectedAgent, setSelectedAgent] = useState<null | AgentHubData>(null);
const [isAgentModalVisible, setIsAgentModalVisible] = useState(false);
const router = useRouter();
const tableRef = useRef<TableInstance<any>>(null);
const agentTableRef = useRef<TableInstance<any>>(null);
useEffect(() => {
const fetchData = async (accessToken: string) => {
@ -103,11 +113,46 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({ 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<ModelHubTableProps> = ({ 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<ModelHubTableProps> = ({ 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<ModelHubTableProps> = ({ accessToken, publicPage,
<div className="w-full mx-4 h-[75vh]">
{publicPage == false ? (
<div className="w-full m-2 mt-2 p-8">
{/* Header with Title, Description and URL */}
<div className="flex justify-between items-center mb-6">
<div className="flex flex-col items-start">
<Title className="text-center">Model Hub</Title>
<Title className="text-center">AI Hub</Title>
{isAdminRole(userRole || "") ? (
<p className="text-sm text-gray-600">
Make models public for developers to know what models are available on the proxy.
Make models and agents public for developers to know what&apos;s available.
</p>
) : (
<p className="text-sm text-gray-600">A list of all public model names personally available to you.</p>
@ -212,12 +292,6 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({ accessToken, publicPage,
<Copy size={16} className="text-gray-600" />
</button>
</div>
{publicPage == false && isAdminRole(userRole || "") && (
<Button className="ml-4" onClick={() => handleMakePublicPage()}>
Make Public
</Button>
)}
</div>
</div>
@ -228,26 +302,77 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({ accessToken, publicPage,
</div>
)}
{/* Model Filters and Table */}
<Card>
{/* Filters */}
<ModelFilters modelHubData={modelHubData || []} onFilteredDataChange={handleFilteredDataChange} />
{/* Tab System for Model Hub and Agent Hub */}
<TabGroup>
<TabList className="mb-4">
<Tab>Model Hub</Tab>
<Tab>Agent Hub</Tab>
</TabList>
{/* Model Table */}
<ModelDataTable
columns={modelHubColumns(showModal, copyToClipboard, publicPage)}
data={filteredData}
isLoading={loading}
table={tableRef}
defaultSorting={[{ id: "model_group", desc: false }]}
/>
</Card>
<TabPanels>
{/* Model Hub Tab */}
<TabPanel>
{/* Model Filters and Table */}
<Card>
{/* Header with Make Public Button */}
{publicPage == false && isAdminRole(userRole || "") && (
<div className="flex justify-end mb-4">
<Button onClick={() => handleMakePublicPage()}>
Select Models to Make Public
</Button>
</div>
)}
{/* Filters */}
<ModelFilters modelHubData={modelHubData || []} onFilteredDataChange={handleFilteredDataChange} />
<div className="mt-4 text-center space-y-2">
<Text className="text-sm text-gray-600">
Showing {filteredData.length} of {modelHubData?.length || 0} models
</Text>
</div>
{/* Model Table */}
<ModelDataTable
columns={modelHubColumns(showModal, copyToClipboard, publicPage)}
data={filteredData}
isLoading={loading}
table={tableRef}
defaultSorting={[{ id: "model_group", desc: false }]}
/>
</Card>
<div className="mt-4 text-center space-y-2">
<Text className="text-sm text-gray-600">
Showing {filteredData.length} of {modelHubData?.length || 0} models
</Text>
</div>
</TabPanel>
{/* Agent Hub Tab */}
<TabPanel>
<Card>
{/* Header with Make Public Button */}
{publicPage == false && isAdminRole(userRole || "") && (
<div className="flex justify-end mb-4">
<Button onClick={() => handleMakeAgentPublicPage()}>
Select Agents to Make Public
</Button>
</div>
)}
{/* Agent Table */}
<ModelDataTable
columns={agentHubColumns(showAgentModal, copyToClipboard, publicPage)}
data={agentHubData || []}
isLoading={agentLoading}
table={agentTableRef}
defaultSorting={[{ id: "name", desc: false }]}
/>
</Card>
<div className="mt-4 text-center space-y-2">
<Text className="text-sm text-gray-600">
Showing {agentHubData?.length || 0} agent{agentHubData?.length !== 1 ? "s" : ""}
</Text>
</div>
</TabPanel>
</TabPanels>
</TabGroup>
</div>
) : (
<Card className="mx-auto max-w-xl mt-10">
@ -429,6 +554,145 @@ print(response.choices[0].message.content)`}
)}
</Modal>
{/* Agent Details Modal */}
<Modal
title={selectedAgent?.name || "Agent Details"}
width={1000}
visible={isAgentModalVisible}
footer={null}
onOk={handleOk}
onCancel={handleCancel}
>
{selectedAgent && (
<div className="space-y-6">
{/* Agent Overview */}
<div>
<Text className="text-lg font-semibold mb-4">Agent Overview</Text>
<div className="grid grid-cols-2 gap-4 mb-4">
<div>
<Text className="font-medium">Name:</Text>
<Text>{selectedAgent.name}</Text>
</div>
<div>
<Text className="font-medium">Version:</Text>
<Badge color="blue">v{selectedAgent.version}</Badge>
</div>
<div>
<Text className="font-medium">Protocol Version:</Text>
<Text>{selectedAgent.protocolVersion}</Text>
</div>
<div>
<Text className="font-medium">URL:</Text>
<div className="flex items-center space-x-2">
<Text className="truncate">{selectedAgent.url}</Text>
<CopyOutlined
onClick={() => copyToClipboard(selectedAgent.url)}
className="cursor-pointer text-gray-500 hover:text-blue-500"
/>
</div>
</div>
</div>
<div>
<Text className="font-medium">Description:</Text>
<Text className="mt-1">{selectedAgent.description}</Text>
</div>
</div>
{/* Capabilities */}
{selectedAgent.capabilities && Object.keys(selectedAgent.capabilities).length > 0 && (
<div>
<Text className="text-lg font-semibold mb-4">Capabilities</Text>
<div className="flex flex-wrap gap-2">
{Object.entries(selectedAgent.capabilities)
.filter(([_, value]) => value === true)
.map(([key]) => (
<Badge key={key} color="green">
{key}
</Badge>
))}
</div>
</div>
)}
{/* Input/Output Modes */}
<div>
<Text className="text-lg font-semibold mb-4">Input/Output Modes</Text>
<div className="grid grid-cols-2 gap-4">
<div>
<Text className="font-medium">Input Modes:</Text>
<div className="flex flex-wrap gap-1 mt-1">
{selectedAgent.defaultInputModes?.map((mode) => (
<Badge key={mode} color="blue">
{mode}
</Badge>
)) || <Text>Not specified</Text>}
</div>
</div>
<div>
<Text className="font-medium">Output Modes:</Text>
<div className="flex flex-wrap gap-1 mt-1">
{selectedAgent.defaultOutputModes?.map((mode) => (
<Badge key={mode} color="purple">
{mode}
</Badge>
)) || <Text>Not specified</Text>}
</div>
</div>
</div>
</div>
{/* Skills */}
{selectedAgent.skills && selectedAgent.skills.length > 0 && (
<div>
<Text className="text-lg font-semibold mb-4">Skills</Text>
<div className="space-y-4">
{selectedAgent.skills.map((skill) => (
<div key={skill.id} className="border border-gray-200 rounded p-4">
<div className="flex justify-between items-start mb-2">
<div>
<Text className="font-medium text-base">{skill.name}</Text>
<Text className="text-xs text-gray-500">ID: {skill.id}</Text>
</div>
{skill.tags && skill.tags.length > 0 && (
<div className="flex flex-wrap gap-1">
{skill.tags.map((tag) => (
<Badge key={tag} color="purple" size="xs">
{tag}
</Badge>
))}
</div>
)}
</div>
<Text className="text-sm mb-2">{skill.description}</Text>
{skill.examples && skill.examples.length > 0 && (
<div>
<Text className="text-xs font-medium text-gray-700">Examples:</Text>
<div className="flex flex-wrap gap-1 mt-1">
{skill.examples.map((example, idx) => (
<Badge key={idx} color="gray" size="xs">
{example}
</Badge>
))}
</div>
</div>
)}
</div>
))}
</div>
</div>
)}
{/* Additional Properties */}
{selectedAgent.supportsAuthenticatedExtendedCard && (
<div>
<Text className="text-lg font-semibold mb-4">Additional Features</Text>
<Badge color="green">Supports Authenticated Extended Card</Badge>
</div>
)}
</div>
)}
</Modal>
{/* Make Model Public Form */}
<MakeModelPublicForm
visible={isMakePublicModalVisible}
@ -437,6 +701,15 @@ print(response.choices[0].message.content)`}
modelHubData={modelHubData || []}
onSuccess={handleMakePublicSuccess}
/>
{/* Make Agent Public Form */}
<MakeAgentPublicForm
visible={isMakeAgentPublicModalVisible}
onClose={() => setIsMakeAgentPublicModalVisible(false)}
accessToken={accessToken || ""}
agentHubData={agentHubData || []}
onSuccess={handleMakeAgentPublicSuccess}
/>
</div>
);
};

View file

@ -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<string, any>;
agent_card_params?: Record<string, any>;
},
) => {
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,

View file

@ -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<PublicModelHubProps> = ({ accessToken }) => {
const [modelHubData, setModelHubData] = useState<ModelGroupInfo[] | null>(null);
const [agentHubData, setAgentHubData] = useState<AgentCard[] | null>(null);
const [pageTitle, setPageTitle] = useState<string>("LiteLLM Gateway");
const [customDocsDescription, setCustomDocsDescription] = useState<string | null>(null);
const [litellmVersion, setLitellmVersion] = useState<string>("");
const [usefulLinks, setUsefulLinks] = useState<Record<string, string>>({});
const [loading, setLoading] = useState<boolean>(true);
const [agentLoading, setAgentLoading] = useState<boolean>(true);
const [searchTerm, setSearchTerm] = useState<string>("");
const [agentSearchTerm, setAgentSearchTerm] = useState<string>("");
const [selectedProviders, setSelectedProviders] = useState<string[]>([]);
const [selectedModes, setSelectedModes] = useState<string[]>([]);
const [selectedFeatures, setSelectedFeatures] = useState<string[]>([]);
const [selectedAgentSkills, setSelectedAgentSkills] = useState<string[]>([]);
const [serviceStatus, setServiceStatus] = useState<string>("I'm alive! ✓");
const [isModalVisible, setIsModalVisible] = useState(false);
const [isAgentModalVisible, setIsAgentModalVisible] = useState(false);
const [selectedModel, setSelectedModel] = useState<null | ModelGroupInfo>(null);
const [selectedAgent, setSelectedAgent] = useState<null | AgentCard>(null);
const [proxySettings, setProxySettings] = useState<any>({});
const [activeTab, setActiveTab] = useState<string>("models");
const tableRef = useRef<TableInstance<any>>(null);
const agentTableRef = useRef<TableInstance<any>>(null);
useEffect(() => {
const fetchPublicData = async () => {
@ -68,6 +106,19 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ 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<PublicModelHubProps> = ({ accessToken }) => {
fetchPublicModelHubInfo();
fetchPublicData();
fetchAgentData();
}, []);
// Clear filters when filter values change to avoid confusion
@ -123,6 +175,16 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken }) => {
return Array.from(features).sort();
};
const getUniqueAgentSkills = (data: AgentCard[]) => {
const skills = new Set<string>();
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<PublicModelHubProps> = ({ 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<PublicModelHubProps> = ({ 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<PublicModelHubProps> = ({ accessToken }) => {
},
];
const publicAgentHubColumns = (): ColumnDef<AgentCard>[] => [
{
header: "Agent Name",
accessorKey: "name",
enableSorting: true,
cell: ({ row }) => (
<div className="overflow-hidden">
<Tooltip title={row.original.name}>
<Button
size="xs"
variant="light"
className="font-mono text-blue-500 bg-blue-50 hover:bg-blue-100 text-xs font-normal px-2 py-0.5 text-left"
onClick={() => showAgentModal(row.original)}
>
{row.original.name}
</Button>
</Tooltip>
</div>
),
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 (
<Tooltip title={description}>
<Text className="text-sm text-gray-700">{truncated}</Text>
</Tooltip>
);
},
size: 250,
},
{
header: "Version",
accessorKey: "version",
enableSorting: true,
cell: ({ row }) => <Text className="text-sm">{row.original.version}</Text>,
size: 80,
},
{
header: "Provider",
accessorKey: "provider",
enableSorting: false,
cell: ({ row }) => {
const provider = row.original.provider;
if (!provider) return <Text className="text-gray-400">-</Text>;
return (
<div className="text-sm">
<Text className="font-medium">{provider.organization}</Text>
</div>
);
},
size: 120,
},
{
header: "Skills",
accessorKey: "skills",
enableSorting: false,
cell: ({ row }) => {
const skills = row.original.skills || [];
if (skills.length === 0) {
return <Text className="text-gray-400">-</Text>;
}
if (skills.length === 1) {
return (
<div className="h-6 flex items-center">
<Tag color="purple" className="text-xs">
{skills[0].name}
</Tag>
</div>
);
}
return (
<div className="h-6 flex items-center space-x-1">
<Tag color="purple" className="text-xs">
{skills[0].name}
</Tag>
<Tooltip
title={
<div className="space-y-1">
<div className="font-medium">All Skills:</div>
{skills.map((skill, index) => (
<div key={index} className="text-xs">
{skill.name}
</div>
))}
</div>
}
trigger="click"
placement="topLeft"
>
<span
className="text-xs text-purple-600 cursor-pointer hover:text-purple-800 hover:underline"
onClick={(e) => e.stopPropagation()}
>
+{skills.length - 1}
</span>
</Tooltip>
</div>
);
},
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 <Text className="text-gray-400">-</Text>;
}
return (
<div className="flex flex-wrap gap-1">
{capList.map((cap) => (
<Tag key={cap} color="green" className="text-xs capitalize">
{cap}
</Tag>
))}
</div>
);
},
size: 150,
},
];
return (
<ThemeProvider accessToken={accessToken}>
<div className="min-h-screen bg-white">
@ -503,125 +768,202 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken }) => {
</div>
</Card>
{/* Models Table */}
{/* Tabs for Models and Agents */}
<Card className="p-8 bg-white border border-gray-200 rounded-lg shadow-sm">
<div className="flex justify-between items-center mb-8">
<Title className="text-2xl font-semibold text-gray-900">Available Models</Title>
</div>
<Tabs
activeKey={activeTab}
onChange={setActiveTab}
size="large"
className="public-hub-tabs"
>
{/* Models Tab */}
<TabPane tab="Model Hub" key="models">
<div className="flex justify-between items-center mb-8">
<Title className="text-2xl font-semibold text-gray-900">Available Models</Title>
</div>
{/* Filters */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8 p-6 bg-gray-50 rounded-lg border border-gray-200">
<div>
<div className="flex items-center space-x-2 mb-3">
<Text className="text-sm font-medium text-gray-700">Search Models:</Text>
<Tooltip
title="Smart search with relevance ranking - finds models containing your search terms, ranked by relevance. Try searching 'xai grok-4', 'claude-4', 'gpt-4', or 'sonnet'"
placement="top"
>
<Info className="w-4 h-4 text-gray-400 cursor-help" />
</Tooltip>
{/* Filters */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8 p-6 bg-gray-50 rounded-lg border border-gray-200">
<div>
<div className="flex items-center space-x-2 mb-3">
<Text className="text-sm font-medium text-gray-700">Search Models:</Text>
<Tooltip
title="Smart search with relevance ranking - finds models containing your search terms, ranked by relevance. Try searching 'xai grok-4', 'claude-4', 'gpt-4', or 'sonnet'"
placement="top"
>
<Info className="w-4 h-4 text-gray-400 cursor-help" />
</Tooltip>
</div>
<div className="relative">
<SearchIcon className="w-4 h-4 text-gray-400 absolute left-3 top-1/2 transform -translate-y-1/2" />
<input
type="text"
placeholder="Search model names... (smart search enabled)"
value={searchTerm}
onChange={(e) => 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"
/>
</div>
</div>
<div>
<Text className="text-sm font-medium mb-3 text-gray-700">Provider:</Text>
<Select
mode="multiple"
value={selectedProviders}
onChange={(values) => setSelectedProviders(values)}
placeholder="Select providers"
className="w-full"
size="large"
allowClear
optionRender={(option) => {
const { logo } = getProviderLogoAndName(option.value as string);
return (
<div className="flex items-center space-x-2">
{logo && (
<img
src={logo}
alt={option.label as string}
className="w-5 h-5 flex-shrink-0 object-contain"
onError={(e) => {
(e.target as HTMLImageElement).style.display = "none";
}}
/>
)}
<span className="capitalize">{option.label}</span>
</div>
);
}}
>
{modelHubData &&
getUniqueProviders(modelHubData).map((provider) => (
<Select.Option key={provider} value={provider}>
{provider}
</Select.Option>
))}
</Select>
</div>
<div>
<Text className="text-sm font-medium mb-3 text-gray-700">Mode:</Text>
<Select
mode="multiple"
value={selectedModes}
onChange={(values) => setSelectedModes(values)}
placeholder="Select modes"
className="w-full"
size="large"
allowClear
>
{modelHubData &&
getUniqueModes(modelHubData).map((mode) => (
<Select.Option key={mode} value={mode}>
{mode}
</Select.Option>
))}
</Select>
</div>
<div>
<Text className="text-sm font-medium mb-3 text-gray-700">Features:</Text>
<Select
mode="multiple"
value={selectedFeatures}
onChange={(values) => setSelectedFeatures(values)}
placeholder="Select features"
className="w-full"
size="large"
allowClear
>
{modelHubData &&
getUniqueFeatures(modelHubData).map((feature) => (
<Select.Option key={feature} value={feature}>
{feature}
</Select.Option>
))}
</Select>
</div>
</div>
<div className="relative">
<SearchIcon className="w-4 h-4 text-gray-400 absolute left-3 top-1/2 transform -translate-y-1/2" />
<input
type="text"
placeholder="Search model names... (smart search enabled)"
value={searchTerm}
onChange={(e) => 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"
/>
<ModelDataTable
columns={publicModelHubColumns()}
data={filteredData}
isLoading={loading}
table={tableRef}
defaultSorting={[{ id: "model_group", desc: false }]}
/>
<div className="mt-8 text-center">
<Text className="text-sm text-gray-600">
Showing {filteredData.length} of {modelHubData?.length || 0} models
</Text>
</div>
</div>
<div>
<Text className="text-sm font-medium mb-3 text-gray-700">Provider:</Text>
<Select
mode="multiple"
value={selectedProviders}
onChange={(values) => setSelectedProviders(values)}
placeholder="Select providers"
className="w-full"
size="large"
allowClear
optionRender={(option) => {
const { logo } = getProviderLogoAndName(option.value as string);
return (
<div className="flex items-center space-x-2">
{logo && (
<img
src={logo}
alt={option.label as string}
className="w-5 h-5 flex-shrink-0 object-contain"
onError={(e) => {
(e.target as HTMLImageElement).style.display = "none";
}}
/>
)}
<span className="capitalize">{option.label}</span>
</TabPane>
{/* Agents Tab */}
{agentHubData && agentHubData.length > 0 && (
<TabPane tab="Agent Hub" key="agents">
<div className="flex justify-between items-center mb-8">
<Title className="text-2xl font-semibold text-gray-900">Available Agents</Title>
</div>
{/* Filters */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8 p-6 bg-gray-50 rounded-lg border border-gray-200">
<div>
<div className="flex items-center space-x-2 mb-3">
<Text className="text-sm font-medium text-gray-700">Search Agents:</Text>
<Tooltip
title="Search agents by name or description"
placement="top"
>
<Info className="w-4 h-4 text-gray-400 cursor-help" />
</Tooltip>
</div>
);
}}
>
{modelHubData &&
getUniqueProviders(modelHubData).map((provider) => (
<Select.Option key={provider} value={provider}>
{provider}
</Select.Option>
))}
</Select>
</div>
<div>
<Text className="text-sm font-medium mb-3 text-gray-700">Mode:</Text>
<Select
mode="multiple"
value={selectedModes}
onChange={(values) => setSelectedModes(values)}
placeholder="Select modes"
className="w-full"
size="large"
allowClear
>
{modelHubData &&
getUniqueModes(modelHubData).map((mode) => (
<Select.Option key={mode} value={mode}>
{mode}
</Select.Option>
))}
</Select>
</div>
<div>
<Text className="text-sm font-medium mb-3 text-gray-700">Features:</Text>
<Select
mode="multiple"
value={selectedFeatures}
onChange={(values) => setSelectedFeatures(values)}
placeholder="Select features"
className="w-full"
size="large"
allowClear
>
{modelHubData &&
getUniqueFeatures(modelHubData).map((feature) => (
<Select.Option key={feature} value={feature}>
{feature}
</Select.Option>
))}
</Select>
</div>
</div>
<div className="relative">
<SearchIcon className="w-4 h-4 text-gray-400 absolute left-3 top-1/2 transform -translate-y-1/2" />
<input
type="text"
placeholder="Search agent names or descriptions..."
value={agentSearchTerm}
onChange={(e) => 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"
/>
</div>
</div>
<div>
<Text className="text-sm font-medium mb-3 text-gray-700">Skills:</Text>
<Select
mode="multiple"
value={selectedAgentSkills}
onChange={(values) => setSelectedAgentSkills(values)}
placeholder="Select skills"
className="w-full"
size="large"
allowClear
>
{agentHubData &&
getUniqueAgentSkills(agentHubData).map((skill) => (
<Select.Option key={skill} value={skill}>
{skill}
</Select.Option>
))}
</Select>
</div>
</div>
<ModelDataTable
columns={publicModelHubColumns()}
data={filteredData}
isLoading={loading}
table={tableRef}
defaultSorting={[{ id: "model_group", desc: false }]}
/>
<ModelDataTable
columns={publicAgentHubColumns()}
data={filteredAgentData}
isLoading={agentLoading}
table={agentTableRef}
defaultSorting={[{ id: "name", desc: false }]}
/>
<div className="mt-8 text-center">
<Text className="text-sm text-gray-600">
Showing {filteredData.length} of {modelHubData?.length || 0} models
</Text>
</div>
<div className="mt-8 text-center">
<Text className="text-sm text-gray-600">
Showing {filteredAgentData.length} of {agentHubData?.length || 0} agents
</Text>
</div>
</TabPane>
)}
</Tabs>
</Card>
</div>
@ -852,6 +1194,308 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken }) => {
</div>
)}
</Modal>
{/* Agent Details Modal */}
<Modal
title={
<div className="flex items-center space-x-2">
<span>{selectedAgent?.name || "Agent Details"}</span>
{selectedAgent && (
<Tooltip title="Copy agent name">
<Copy
onClick={() => copyToClipboard(selectedAgent.name)}
className="cursor-pointer text-gray-500 hover:text-blue-500 w-4 h-4"
/>
</Tooltip>
)}
</div>
}
width={1000}
open={isAgentModalVisible}
footer={null}
onOk={handleAgentModalOk}
onCancel={handleAgentModalCancel}
>
{selectedAgent && (
<div className="space-y-6">
{/* Agent Overview */}
<div>
<Text className="text-lg font-semibold mb-4">Agent Overview</Text>
<div className="grid grid-cols-2 gap-4 mb-4">
<div>
<Text className="font-medium">Name:</Text>
<Text>{selectedAgent.name}</Text>
</div>
<div>
<Text className="font-medium">Version:</Text>
<Text>{selectedAgent.version}</Text>
</div>
<div className="col-span-2">
<Text className="font-medium">Description:</Text>
<Text>{selectedAgent.description}</Text>
</div>
{selectedAgent.url && (
<div>
<Text className="font-medium">URL:</Text>
<a
href={selectedAgent.url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-800 text-sm break-all"
>
{selectedAgent.url}
</a>
</div>
)}
</div>
</div>
{/* Capabilities */}
{selectedAgent.capabilities && (
<div>
<Text className="text-lg font-semibold mb-4">Capabilities</Text>
<div className="flex flex-wrap gap-2">
{Object.entries(selectedAgent.capabilities)
.filter(([_, value]) => value === true)
.map(([key]) => (
<Tag key={key} color="green" className="capitalize">
{key}
</Tag>
))}
</div>
</div>
)}
{/* Skills */}
{selectedAgent.skills && selectedAgent.skills.length > 0 && (
<div>
<Text className="text-lg font-semibold mb-4">Skills</Text>
<div className="space-y-4">
{selectedAgent.skills.map((skill, index) => (
<div key={index} className="border border-gray-200 rounded-lg p-4">
<div className="flex items-start justify-between mb-2">
<div>
<Text className="font-medium text-base">{skill.name}</Text>
<Text className="text-sm text-gray-600">{skill.description}</Text>
</div>
</div>
{skill.tags && skill.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{skill.tags.map((tag) => (
<Tag key={tag} color="purple" className="text-xs">
{tag}
</Tag>
))}
</div>
)}
</div>
))}
</div>
</div>
)}
{/* Input/Output Modes */}
<div>
<Text className="text-lg font-semibold mb-4">Input/Output Modes</Text>
<div className="grid grid-cols-2 gap-4">
<div>
<Text className="font-medium">Input Modes:</Text>
<div className="flex flex-wrap gap-1 mt-1">
{selectedAgent.defaultInputModes?.map((mode) => (
<Tag key={mode} color="blue">
{mode}
</Tag>
))}
</div>
</div>
<div>
<Text className="font-medium">Output Modes:</Text>
<div className="flex flex-wrap gap-1 mt-1">
{selectedAgent.defaultOutputModes?.map((mode) => (
<Tag key={mode} color="blue">
{mode}
</Tag>
))}
</div>
</div>
</div>
</div>
{/* Documentation */}
{selectedAgent.documentationUrl && (
<div>
<Text className="text-lg font-semibold mb-4">Documentation</Text>
<a
href={selectedAgent.documentationUrl}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-800 flex items-center space-x-2"
>
<ExternalLinkIcon className="w-4 h-4" />
<span>View Documentation</span>
</a>
</div>
)}
{/* A2A Usage Example */}
<div>
<Text className="text-lg font-semibold mb-4">Usage Example (A2A Protocol)</Text>
{/* Step 1: Retrieve Agent Card */}
<div className="mb-4">
<Text className="text-sm font-medium mb-2 text-gray-700">Step 1: Retrieve Agent Card</Text>
<div className="bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto">
<pre className="text-xs">
{`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,
)`}
</pre>
</div>
<div className="mt-2 text-right">
<button
onClick={() => {
const codeSnippet = `from a2a.client import A2ACardResolver, A2AClient
from a2a.types import (
AgentCard,
MessageSendParams,
SendMessageRequest,
SendStreamingMessageRequest,
)
from a2a.utils.constants import (
AGENT_CARD_WELL_KNOWN_PATH,
EXTENDED_AGENT_CARD_PATH,
)
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,
)`;
copyToClipboard(codeSnippet);
}}
className="text-sm text-blue-600 hover:text-blue-800 cursor-pointer"
>
Copy to clipboard
</button>
</div>
</div>
{/* Step 2: Call the Agent */}
<div>
<Text className="text-sm font-medium mb-2 text-gray-700">Step 2: Call the Agent</Text>
<div className="bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto">
<pre className="text-xs">
{`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))`}
</pre>
</div>
<div className="mt-2 text-right">
<button
onClick={() => {
const codeSnippet = `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))`;
copyToClipboard(codeSnippet);
}}
className="text-sm text-blue-600 hover:text-blue-800 cursor-pointer"
>
Copy to clipboard
</button>
</div>
</div>
</div>
</div>
)}
</Modal>
</div>
</ThemeProvider>
);