From 2d2f540480d34d8a2fd16a2877bd4e9ff5015b44 Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Tue, 28 Apr 2026 16:58:03 +0530 Subject: [PATCH] feat(proxy): add team-level search provider credential resolution Allow search requests to resolve provider credentials from request metadata, team metadata, and default team settings with clear precedence, and expose this flow in proxy docs/UI with regression tests. Made-with: Cursor --- docs/my-website/docs/proxy/search.md | 92 ++++++++++++ docs/my-website/docs/proxy/team_budgets.md | 58 ++++++++ litellm/proxy/_types.py | 31 +++- .../management_endpoints/team_endpoints.py | 110 ++++++++++++++- litellm/proxy/search_endpoints/endpoints.py | 10 ++ litellm/router_utils/search_api_router.py | 127 ++++++++++++++++- .../test_team_search_credentials.py | 133 ++++++++++++++++++ .../src/components/networking.tsx | 34 +++++ .../src/components/team/TeamInfo.tsx | 53 ++++++- 9 files changed, 638 insertions(+), 10 deletions(-) create mode 100644 docs/my-website/docs/proxy/search.md create mode 100644 docs/my-website/docs/proxy/team_budgets.md create mode 100644 tests/test_litellm/proxy/search_endpoints/test_team_search_credentials.py diff --git a/docs/my-website/docs/proxy/search.md b/docs/my-website/docs/proxy/search.md new file mode 100644 index 00000000000..65c431eb53e --- /dev/null +++ b/docs/my-website/docs/proxy/search.md @@ -0,0 +1,92 @@ +# Search API + +LiteLLM supports team-aware search provider credentials for providers like Tavily, Perplexity, Brave, Exa, and Serper. + +## Per-team search provider configuration + +Set per-team credentials in team metadata: + +```json +{ + "search_provider_config": { + "tavily": { + "api_key": "tvly-team-a-key", + "api_base": "https://api.tavily.com" + }, + "perplexity": { + "api_key": "pplx-team-a-key" + } + } +} +``` + +Update via API: + +```bash +curl -X POST "http://localhost:4000/team/search_provider_config/update" \ + -H "Authorization: Bearer sk-admin-key" \ + -H "Content-Type: application/json" \ + -d '{ + "team_id": "team-a", + "provider": "tavily", + "api_key": "tvly-team-a-key", + "api_base": "https://api.tavily.com" + }' +``` + +## Request flow and precedence + +Search credentials resolve in this order: + +1. Request metadata: `metadata.search_provider_config.` +2. Team DB metadata: `user_api_key_team_metadata.search_provider_config.` +3. YAML team settings: `default_team_settings[].search_provider_config.` +4. Search tool config: `search_tools[].litellm_params` +5. Provider env fallback (`TAVILY_API_KEY`, etc.) + +## Calling search as an end-user + +The caller only uses their team-bound virtual key. + +```bash +curl -X POST "http://localhost:4000/v1/search" \ + -H "Authorization: Bearer sk-team-a-user-key" \ + -H "Content-Type: application/json" \ + -d '{ + "search_tool_name": "company-search", + "query": "latest AI news", + "max_results": 5 + }' +``` + +or with URL tool name: + +```bash +curl -X POST "http://localhost:4000/v1/search/company-search" \ + -H "Authorization: Bearer sk-team-a-user-key" \ + -H "Content-Type: application/json" \ + -d '{ + "query": "latest AI news", + "max_results": 5 + }' +``` + +## YAML examples + +```yaml +search_tools: + - search_tool_name: company-search + litellm_params: + search_provider: tavily + api_key: os.environ/TAVILY_DEFAULT_API_KEY + +default_team_settings: + - team_id: team-a + search_provider_config: + tavily: + api_key: os.environ/TAVILY_TEAM_A_API_KEY + - team_id: team-b + search_provider_config: + tavily: + api_key: os.environ/TAVILY_TEAM_B_API_KEY +``` diff --git a/docs/my-website/docs/proxy/team_budgets.md b/docs/my-website/docs/proxy/team_budgets.md new file mode 100644 index 00000000000..47f3a832b07 --- /dev/null +++ b/docs/my-website/docs/proxy/team_budgets.md @@ -0,0 +1,58 @@ +# Team Budgets and Search Cost Attribution + +When search requests are made through LiteLLM with a team-bound key, spend is attributed to that team. + +## Cost attribution for search + +Search calls (`search` / `asearch`) are logged with: + +- `metadata.user_api_key_team_id` +- spend rows in `LiteLLM_SpendLogs.team_id` + +This means each team's search usage can be queried independently even when using the same model/provider family. + +## Why per-team search keys matter + +Using one shared Tavily key makes upstream provider billing opaque by team. +With team-specific provider keys: + +- provider-side billing is isolated per team +- LiteLLM spend logs still aggregate by team id +- finance can reconcile provider invoices + LiteLLM spend logs + +## Recommended setup + +1. Issue per-team virtual keys in LiteLLM. +2. Configure `metadata.search_provider_config` per team. +3. Keep a fallback tool-level key only for teams without explicit config. + +## Example team update + +```bash +curl -X POST "http://localhost:4000/team/update" \ + -H "Authorization: Bearer sk-admin-key" \ + -H "Content-Type: application/json" \ + -d '{ + "team_id": "team-research", + "metadata": { + "search_provider_config": { + "tavily": { + "api_key": "tvly-research-key" + }, + "perplexity": { + "api_key": "pplx-research-key" + } + } + } + }' +``` + +## Example spend query + +```sql +SELECT team_id, call_type, SUM(spend) AS total_spend, COUNT(*) AS requests +FROM "LiteLLM_SpendLogs" +WHERE call_type IN ('search', 'asearch') +GROUP BY team_id, call_type +ORDER BY total_spend DESC; +``` diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 92c920ca594..7b92af92f22 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -1695,6 +1695,28 @@ class OrgMember(MemberBase): ] +class SearchProviderCredentials(LiteLLMPydanticObjectBase): + """ + Per-team credentials for a search provider. + """ + + api_key: Optional[str] = None + api_base: Optional[str] = None + + +class TeamSearchProviderConfig(LiteLLMPydanticObjectBase): + """ + Structured team-level search provider credentials. + Stored in team metadata under `search_provider_config`. + """ + + tavily: Optional[SearchProviderCredentials] = None + perplexity: Optional[SearchProviderCredentials] = None + brave: Optional[SearchProviderCredentials] = None + exa: Optional[SearchProviderCredentials] = None + serper: Optional[SearchProviderCredentials] = None + + class TeamBase(LiteLLMPydanticObjectBase): team_alias: Optional[str] = None team_id: Optional[str] = None @@ -1703,7 +1725,7 @@ class TeamBase(LiteLLMPydanticObjectBase): members: list = [] members_with_roles: List[Member] = [] team_member_permissions: Optional[List[str]] = None - metadata: Optional[dict] = None + metadata: Optional[dict] = None # may include search_provider_config tpm_limit: Optional[int] = None rpm_limit: Optional[int] = None @@ -1823,6 +1845,13 @@ class UpdateTeamRequest(LiteLLMPydanticObjectBase): ) +class TeamSearchProviderConfigUpdateRequest(LiteLLMPydanticObjectBase): + team_id: str + provider: str + api_key: Optional[str] = None + api_base: Optional[str] = None + + class ResetTeamBudgetRequest(LiteLLMPydanticObjectBase): """ internal type used to reset the budget on a team diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py index f254fea3e7f..abd5239c9a0 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -59,6 +59,7 @@ from litellm.proxy._types import ( TeamMemberUpdateResponse, TeamModelAddRequest, TeamModelDeleteRequest, + TeamSearchProviderConfigUpdateRequest, UpdateTeamRequest, UserAPIKeyAuth, ) @@ -1856,6 +1857,108 @@ async def update_team( # noqa: PLR0915 raise handle_exception_on_proxy(e) +@router.post( + "/team/search_provider_config/update", + tags=["team management"], + dependencies=[Depends(user_api_key_auth)], +) +@management_endpoint_wrapper +async def update_team_search_provider_config( + data: TeamSearchProviderConfigUpdateRequest, + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), +): + """ + Update per-team search provider credentials in team metadata. + + Stored under: + metadata.search_provider_config..{api_key, api_base} + """ + from litellm.proxy.auth.auth_checks import _cache_team_object + from litellm.proxy.proxy_server import ( + prisma_client, + proxy_logging_obj, + user_api_key_cache, + ) + + if prisma_client is None: + raise HTTPException( + status_code=500, + detail={"error": CommonProxyErrors.db_not_connected_error.value}, + ) + + provider = data.provider.strip().lower() + if provider == "": + raise HTTPException( + status_code=400, detail={"error": "provider cannot be empty"} + ) + + existing_team_row = await prisma_client.db.litellm_teamtable.find_unique( + where={"team_id": data.team_id} + ) + if existing_team_row is None: + raise HTTPException( + status_code=404, + detail={"error": f"Team not found, passed team_id={data.team_id}"}, + ) + + await _verify_team_access( + team_obj=LiteLLM_TeamTable(**existing_team_row.model_dump()), + user_api_key_dict=user_api_key_dict, + ) + + metadata: Dict[str, Any] = {} + if isinstance(existing_team_row.metadata, dict): + metadata = dict(existing_team_row.metadata) + + search_provider_config = metadata.get("search_provider_config") + if not isinstance(search_provider_config, dict): + search_provider_config = {} + + provider_config = search_provider_config.get(provider) + if not isinstance(provider_config, dict): + provider_config = {} + + if data.api_key is not None: + provider_config["api_key"] = data.api_key + if data.api_base is not None: + provider_config["api_base"] = data.api_base + + if provider_config.get("api_key") in (None, "") and provider_config.get( + "api_base" + ) in ( + None, + "", + ): + search_provider_config.pop(provider, None) + else: + search_provider_config[provider] = provider_config + + metadata["search_provider_config"] = search_provider_config + + team_row: Optional[LiteLLM_TeamTable] = ( + await prisma_client.db.litellm_teamtable.update( + where={"team_id": data.team_id}, + data={"metadata": metadata}, + include={"litellm_model_table": True}, # type: ignore + ) + ) + + if team_row is not None and team_row.team_id is not None: + await _cache_team_object( + team_id=team_row.team_id, + team_table=LiteLLM_TeamTableCachedObj(**team_row.model_dump()), + user_api_key_cache=user_api_key_cache, + proxy_logging_obj=proxy_logging_obj, + ) + + return { + "message": "Team search provider configuration updated", + "team_id": data.team_id, + "provider": provider, + "search_provider_config": search_provider_config, + } + + def _set_budget_reset_at(data: UpdateTeamRequest, updated_kv: dict) -> None: """Set budget_reset_at in updated_kv if budget_duration is provided.""" if data.budget_duration is not None: @@ -2049,8 +2152,11 @@ async def _process_team_members( # Resolve allowed_models: explicit request value, or fall back to team's default_team_member_models member_allowed_models = data.allowed_models - if member_allowed_models is None and complete_team_data.default_team_member_models: - member_allowed_models = complete_team_data.default_team_member_models + team_default_member_models = getattr( + complete_team_data, "default_team_member_models", None + ) + if member_allowed_models is None and team_default_member_models: + member_allowed_models = team_default_member_models if isinstance(data.member, Member): try: diff --git a/litellm/proxy/search_endpoints/endpoints.py b/litellm/proxy/search_endpoints/endpoints.py index 8bed5b54075..ce2949f707a 100644 --- a/litellm/proxy/search_endpoints/endpoints.py +++ b/litellm/proxy/search_endpoints/endpoints.py @@ -163,6 +163,16 @@ async def search( data["metadata"] = {} data["metadata"]["model_group"] = search_tool_name_value + # Ensure team context is available to search router credential resolution. + # add_litellm_data_to_request() also injects these values, but this keeps + # search endpoint behavior explicit and resilient for direct router paths. + if "metadata" not in data or not isinstance(data.get("metadata"), dict): + data["metadata"] = {} + if getattr(user_api_key_dict, "team_metadata", None) is not None: + data["metadata"]["user_api_key_team_metadata"] = user_api_key_dict.team_metadata + if getattr(user_api_key_dict, "team_id", None) is not None: + data["metadata"]["user_api_key_team_id"] = user_api_key_dict.team_id + # Process request using ProxyBaseLLMRequestProcessing processor = ProxyBaseLLMRequestProcessing(data=data) try: diff --git a/litellm/router_utils/search_api_router.py b/litellm/router_utils/search_api_router.py index a26aa7e71ee..e2e98a65734 100644 --- a/litellm/router_utils/search_api_router.py +++ b/litellm/router_utils/search_api_router.py @@ -8,8 +8,9 @@ import asyncio import random import traceback from functools import partial -from typing import Any, Callable +from typing import Any, Callable, Dict, Optional, Tuple +import litellm from litellm._logging import verbose_router_logger @@ -20,6 +21,96 @@ class SearchAPIRouter: Provides methods for search tool selection, load balancing, and fallback handling. """ + @staticmethod + def _get_team_config_from_default_settings( + team_id: Optional[str], + ) -> Optional[Dict[str, Any]]: + """ + Resolve team config from litellm.default_team_settings. + + This allows search requests to read per-team settings from proxy config + (YAML) similar to completion paths that use ProxyConfig.load_team_config(). + """ + if not team_id: + return None + + default_team_settings = getattr(litellm, "default_team_settings", None) + if not isinstance(default_team_settings, list): + return None + + for team_setting in default_team_settings: + if ( + isinstance(team_setting, dict) + and team_setting.get("team_id") == team_id + ): + return team_setting + return None + + @staticmethod + def _resolve_search_provider_credentials( + *, + search_provider: str, + tool_litellm_params: Dict[str, Any], + request_metadata: Optional[Dict[str, Any]] = None, + team_metadata: Optional[Dict[str, Any]] = None, + team_config: Optional[Dict[str, Any]] = None, + ) -> Tuple[Optional[str], Optional[str]]: + """ + Resolve search provider credentials with precedence: + 1. request metadata.search_provider_config.{provider} + 2. team metadata.search_provider_config.{provider} + 3. default_team_settings.search_provider_config.{provider} + 4. search_tool.litellm_params + 5. env fallback in provider validate_environment() + """ + resolved_api_key: Optional[str] = None + resolved_api_base: Optional[str] = None + + request_provider_config = {} + if isinstance(request_metadata, dict): + search_provider_config = request_metadata.get("search_provider_config") + if isinstance(search_provider_config, dict): + request_provider_config = search_provider_config.get( + search_provider, {} + ) + + team_provider_config = {} + if isinstance(team_metadata, dict): + search_provider_config = team_metadata.get("search_provider_config") + if isinstance(search_provider_config, dict): + team_provider_config = search_provider_config.get(search_provider, {}) + + team_settings_provider_config = {} + if isinstance(team_config, dict): + search_provider_config = team_config.get("search_provider_config") + if isinstance(search_provider_config, dict): + team_settings_provider_config = search_provider_config.get( + search_provider, {} + ) + + if isinstance(request_provider_config, dict): + resolved_api_key = request_provider_config.get("api_key") + resolved_api_base = request_provider_config.get("api_base") + + if resolved_api_key is None and isinstance(team_provider_config, dict): + resolved_api_key = team_provider_config.get("api_key") + if resolved_api_base is None and isinstance(team_provider_config, dict): + resolved_api_base = team_provider_config.get("api_base") + + if resolved_api_key is None and isinstance(team_settings_provider_config, dict): + resolved_api_key = team_settings_provider_config.get("api_key") + if resolved_api_base is None and isinstance( + team_settings_provider_config, dict + ): + resolved_api_base = team_settings_provider_config.get("api_base") + + if resolved_api_key is None: + resolved_api_key = tool_litellm_params.get("api_key") + if resolved_api_base is None: + resolved_api_base = tool_litellm_params.get("api_base") + + return resolved_api_key, resolved_api_base + @staticmethod async def update_router_search_tools(router_instance: Any, search_tools: list): """ @@ -198,16 +289,42 @@ class SearchAPIRouter: # Extract search provider and other params from litellm_params litellm_params = selected_tool.get("litellm_params", {}) search_provider = litellm_params.get("search_provider") - api_key = litellm_params.get("api_key") - api_base = litellm_params.get("api_base") - if not search_provider: raise ValueError( f"search_provider not found in litellm_params for search tool '{search_tool_name}'" ) + request_metadata = kwargs.get("metadata") + litellm_metadata = kwargs.get("litellm_metadata") + if not isinstance(request_metadata, dict) and isinstance( + litellm_metadata, dict + ): + request_metadata = litellm_metadata + + team_metadata = {} + team_id: Optional[str] = None + if isinstance(request_metadata, dict): + _team_metadata = request_metadata.get("user_api_key_team_metadata") + if isinstance(_team_metadata, dict): + team_metadata = _team_metadata + _team_id = request_metadata.get("user_api_key_team_id") + if isinstance(_team_id, str): + team_id = _team_id + + team_config = SearchAPIRouter._get_team_config_from_default_settings( + team_id=team_id + ) + + api_key, api_base = SearchAPIRouter._resolve_search_provider_credentials( + search_provider=search_provider, + tool_litellm_params=litellm_params, + request_metadata=request_metadata, + team_metadata=team_metadata, + team_config=team_config, + ) + verbose_router_logger.debug( - f"Selected search tool with provider: {search_provider}" + f"Selected search tool with provider: {search_provider}, team_id={team_id}" ) # Call the original search function with the provider config diff --git a/tests/test_litellm/proxy/search_endpoints/test_team_search_credentials.py b/tests/test_litellm/proxy/search_endpoints/test_team_search_credentials.py new file mode 100644 index 00000000000..eab167fb75a --- /dev/null +++ b/tests/test_litellm/proxy/search_endpoints/test_team_search_credentials.py @@ -0,0 +1,133 @@ +import os +import sys +from unittest.mock import patch + +import pytest +from fastapi.testclient import TestClient + +sys.path.insert(0, os.path.abspath("../../../../..")) + +from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth +from litellm.proxy.auth.user_api_key_auth import user_api_key_auth +from litellm.proxy.proxy_server import app +from litellm.router_utils.search_api_router import SearchAPIRouter + + +def test_resolve_credentials_team_metadata_overrides_tool_params(): + api_key, api_base = SearchAPIRouter._resolve_search_provider_credentials( + search_provider="tavily", + tool_litellm_params={ + "api_key": "tool-key", + "api_base": "https://tool.example.com", + }, + team_metadata={ + "search_provider_config": { + "tavily": { + "api_key": "team-key", + "api_base": "https://team.example.com", + } + } + }, + ) + assert api_key == "team-key" + assert api_base == "https://team.example.com" + + +def test_resolve_credentials_request_metadata_has_highest_precedence(): + api_key, api_base = SearchAPIRouter._resolve_search_provider_credentials( + search_provider="tavily", + tool_litellm_params={ + "api_key": "tool-key", + "api_base": "https://tool.example.com", + }, + request_metadata={ + "search_provider_config": { + "tavily": { + "api_key": "request-key", + "api_base": "https://request.example.com", + } + } + }, + team_metadata={ + "search_provider_config": { + "tavily": { + "api_key": "team-key", + "api_base": "https://team.example.com", + } + } + }, + ) + assert api_key == "request-key" + assert api_base == "https://request.example.com" + + +def test_resolve_credentials_from_default_team_settings(): + with patch( + "litellm.default_team_settings", + [ + { + "team_id": "team-a", + "search_provider_config": { + "tavily": { + "api_key": "team-settings-key", + "api_base": "https://team-settings.example.com", + } + }, + } + ], + ): + team_config = SearchAPIRouter._get_team_config_from_default_settings("team-a") + api_key, api_base = SearchAPIRouter._resolve_search_provider_credentials( + search_provider="tavily", + tool_litellm_params={}, + team_config=team_config, + ) + assert api_key == "team-settings-key" + assert api_base == "https://team-settings.example.com" + + +@pytest.mark.asyncio +async def test_search_endpoint_injects_team_metadata(): + captured_metadata = {} + + async def _mock_process(self, **kwargs): + nonlocal captured_metadata + captured_metadata = self.data.get("metadata", {}) + return {"object": "search", "results": []} + + app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth( + user_role=LitellmUserRoles.PROXY_ADMIN, + user_id="admin-user", + team_id="team-test", + team_metadata={ + "search_provider_config": { + "tavily": {"api_key": "team-test-key"}, + } + }, + ) + + try: + with patch( + "litellm.proxy.common_request_processing.ProxyBaseLLMRequestProcessing.base_process_llm_request", + new=_mock_process, + ): + client = TestClient(app) + response = client.post( + "/v1/search", + json={ + "search_tool_name": "tool-a", + "search_provider": "tavily", + "query": "latest ai news", + }, + ) + assert response.status_code == 200 + assert captured_metadata.get("user_api_key_team_id") == "team-test" + assert ( + captured_metadata.get("user_api_key_team_metadata", {}) + .get("search_provider_config", {}) + .get("tavily", {}) + .get("api_key") + == "team-test-key" + ) + finally: + app.dependency_overrides.pop(user_api_key_auth, None) diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 44208904a70..6cd9423c37f 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -3725,6 +3725,40 @@ export const teamUpdateCall = async ( } }; +export const updateTeamSearchProviderConfigCall = async ( + accessToken: string, + formValues: { + team_id: string; + provider: string; + api_key?: string | null; + api_base?: string | null; + }, +) => { + try { + const url = proxyBaseUrl + ? `${proxyBaseUrl}/team/search_provider_config/update` + : `/team/search_provider_config/update`; + const response = await fetch(url, { + method: "POST", + headers: { + [globalLitellmHeaderName]: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(formValues), + }); + + if (!response.ok) { + const errorData = await response.text(); + handleError(errorData); + throw new Error(errorData); + } + return await response.json(); + } catch (error) { + console.error("Failed to update team search provider config:", error); + throw error; + } +}; + /** * Patch update a model * diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx index ef98054c080..1a2e97faaaa 100644 --- a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx @@ -457,13 +457,26 @@ const TeamInfoView: React.FC = ({ try { const rawMetadata = values.metadata ? JSON.parse(values.metadata) : {}; // Exclude soft_budget_alerting_emails from parsed metadata since it's handled separately - const { soft_budget_alerting_emails, ...rest } = rawMetadata; + const { soft_budget_alerting_emails, search_provider_config, ...rest } = rawMetadata; parsedMetadata = rest; } catch (e) { NotificationsManager.fromBackend("Invalid JSON in metadata field"); return; } + let searchProviderConfig: Record | undefined; + if (typeof values.search_provider_config === "string") { + const trimmedSearchProviderConfig = values.search_provider_config.trim(); + if (trimmedSearchProviderConfig.length > 0) { + try { + searchProviderConfig = JSON.parse(trimmedSearchProviderConfig); + } catch (e) { + NotificationsManager.fromBackend("Invalid JSON in search provider configuration"); + return; + } + } + } + let secretManagerSettings: Record | undefined; if (typeof values.secret_manager_settings === "string") { const trimmedSecretConfig = values.secret_manager_settings.trim(); @@ -513,6 +526,7 @@ const TeamInfoView: React.FC = ({ budget_duration: values.budget_duration, metadata: { ...parsedMetadata, + ...(searchProviderConfig !== undefined ? { search_provider_config: searchProviderConfig } : {}), guardrails: (values.guardrails || []).filter((n: string) => !globalGuardrailNames.has(n)), opted_out_global_guardrails: optedOutGlobalGuardrails, ...(values.logging_settings?.length > 0 ? { logging: values.logging_settings } : {}), @@ -952,11 +966,14 @@ const TeamInfoView: React.FC = ({ : "", metadata: info.metadata ? JSON.stringify( - (({ logging, secret_manager_settings, soft_budget_alerting_emails, model_tpm_limit, model_rpm_limit, ...rest }) => rest)(info.metadata), + (({ logging, secret_manager_settings, soft_budget_alerting_emails, search_provider_config, model_tpm_limit, model_rpm_limit, ...rest }) => rest)(info.metadata), null, 2, ) : "", + search_provider_config: info.metadata?.search_provider_config + ? JSON.stringify(info.metadata.search_provider_config, null, 2) + : "", logging_settings: info.metadata?.logging || [], secret_manager_settings: info.metadata?.secret_manager_settings ? JSON.stringify(info.metadata.secret_manager_settings, null, 2) @@ -1399,6 +1416,29 @@ const TeamInfoView: React.FC = ({ /> + { + if (!value || (typeof value === "string" && value.trim() === "")) { + return Promise.resolve(); + } + try { + JSON.parse(value); + return Promise.resolve(); + } catch (error) { + return Promise.reject(new Error("Please enter valid JSON")); + } + }, + }, + ]} + > + + + = ({ )} + + {info.metadata?.search_provider_config && ( +
+ Search Provider Configuration +
+                          {JSON.stringify(info.metadata.search_provider_config, null, 2)}
+                        
+
+ )} )}