diff --git a/docs/my-website/docs/proxy/admin_ui_sso.md b/docs/my-website/docs/proxy/admin_ui_sso.md index 37e45b5028..f88d348044 100644 --- a/docs/my-website/docs/proxy/admin_ui_sso.md +++ b/docs/my-website/docs/proxy/admin_ui_sso.md @@ -223,6 +223,7 @@ GENERIC_USER_FIRST_NAME_ATTRIBUTE = "first_name" GENERIC_USER_LAST_NAME_ATTRIBUTE = "last_name" GENERIC_USER_ROLE_ATTRIBUTE = "given_role" GENERIC_USER_PROVIDER_ATTRIBUTE = "provider" +GENERIC_USER_EXTRA_ATTRIBUTES = "department,employee_id,manager" # comma-separated list of additional fields to extract from SSO response GENERIC_CLIENT_STATE = "some-state" # if the provider needs a state parameter GENERIC_INCLUDE_CLIENT_ID = "false" # some providers enforce that the client_id is not in the body GENERIC_SCOPE = "openid profile email" # default scope openid is sometimes not enough to retrieve basic user info like first_name and last_name located in profile scope @@ -239,6 +240,40 @@ Use `GENERIC_USER_ROLE_ATTRIBUTE` to specify which attribute in the SSO token co Nested attribute paths are supported (e.g., `claims.role` or `attributes.litellm_role`). +**Capturing Additional SSO Fields** + +Use `GENERIC_USER_EXTRA_ATTRIBUTES` to extract additional fields from the SSO provider response beyond the standard user attributes (id, email, name, etc.). This is useful when you need to access custom organization-specific data (e.g., department, employee ID, groups) in your [custom SSO handler](./custom_sso.md). + +```shell +# Comma-separated list of field names to extract +GENERIC_USER_EXTRA_ATTRIBUTES="department,employee_id,manager,groups" +``` + +**Accessing Extra Fields in Custom SSO Handler:** + +```python +from litellm.proxy.management_endpoints.types import CustomOpenID + +async def custom_sso_handler(userIDPInfo: CustomOpenID): + # Access the extra fields + extra_fields = getattr(userIDPInfo, 'extra_fields', None) or {} + + user_department = extra_fields.get("department") + employee_id = extra_fields.get("employee_id") + user_groups = extra_fields.get("groups", []) + + # Use these fields for custom logic (e.g., team assignment, access control) + # ... +``` + +**Nested Field Paths:** + +Dot notation is supported for nested fields: + +```shell +GENERIC_USER_EXTRA_ATTRIBUTES="org_info.department,org_info.cost_center,metadata.employee_type" +``` + - Set Redirect URI, if your provider requires it - Set a redirect url = `/sso/callback` ```shell diff --git a/docs/my-website/docs/proxy/config_settings.md b/docs/my-website/docs/proxy/config_settings.md index 5cdae51f44..c78a5c9243 100644 --- a/docs/my-website/docs/proxy/config_settings.md +++ b/docs/my-website/docs/proxy/config_settings.md @@ -640,6 +640,7 @@ router_settings: | GENERIC_TOKEN_ENDPOINT | Token endpoint for generic OAuth providers | GENERIC_USER_DISPLAY_NAME_ATTRIBUTE | Attribute for user's display name in generic auth | GENERIC_USER_EMAIL_ATTRIBUTE | Attribute for user's email in generic auth +| GENERIC_USER_EXTRA_ATTRIBUTES | Comma-separated list of additional fields to extract from generic SSO provider response (e.g., "department,employee_id,groups"). Accessible via `CustomOpenID.extra_fields` in custom SSO handlers. Supports dot notation for nested fields | GENERIC_USER_FIRST_NAME_ATTRIBUTE | Attribute for user's first name in generic auth | GENERIC_USER_ID_ATTRIBUTE | Attribute for user ID in generic auth | GENERIC_USER_LAST_NAME_ATTRIBUTE | Attribute for user's last name in generic auth diff --git a/docs/my-website/docs/proxy/custom_sso.md b/docs/my-website/docs/proxy/custom_sso.md index bbd7f41bee..8b7adeb0c5 100644 --- a/docs/my-website/docs/proxy/custom_sso.md +++ b/docs/my-website/docs/proxy/custom_sso.md @@ -142,6 +142,18 @@ async def custom_sso_handler(userIDPInfo: OpenID) -> SSOUserDefinedValues: f"No ID found for user. userIDPInfo.id is None {userIDPInfo}" ) + ################################################# + # Access extra fields from SSO provider (requires GENERIC_USER_EXTRA_ATTRIBUTES env var) + # Example: Set GENERIC_USER_EXTRA_ATTRIBUTES="department,employee_id,groups" + extra_fields = getattr(userIDPInfo, 'extra_fields', None) or {} + user_department = extra_fields.get("department") + employee_id = extra_fields.get("employee_id") + user_groups = extra_fields.get("groups", []) + + print(f"User department: {user_department}") # noqa + print(f"Employee ID: {employee_id}") # noqa + print(f"User groups: {user_groups}") # noqa + ################################################# ################################################# # Run your custom code / logic here diff --git a/litellm/litellm_core_utils/prompt_templates/common_utils.py b/litellm/litellm_core_utils/prompt_templates/common_utils.py index b1c2d0a52f..cdddee4e54 100644 --- a/litellm/litellm_core_utils/prompt_templates/common_utils.py +++ b/litellm/litellm_core_utils/prompt_templates/common_utils.py @@ -1272,3 +1272,59 @@ def parse_tool_call_arguments( ) raise ValueError(error_message) from e + + +def split_concatenated_json_objects(raw: str) -> List[Dict[str, Any]]: + """ + Split a string that contains one or more concatenated JSON objects into + a list of parsed dicts. + + LLM providers (notably Bedrock Claude Sonnet 4.5) sometimes return + multiple tool-call argument objects concatenated in a single + ``arguments`` string, e.g.:: + + '{"command":["curl",...]}{"command":["curl",...]}{"command":["curl",...]}' + + ``json.loads()`` fails on this with ``JSONDecodeError: Extra data``. + This helper uses ``json.JSONDecoder.raw_decode()`` to walk the string + and extract each JSON object individually. + + Returns + ------- + list[dict] + A list of parsed dicts – one per JSON object found. If *raw* is + empty or whitespace-only, an empty list is returned. + + Raises + ------ + json.JSONDecodeError + If the string contains text that cannot be parsed as JSON at all. + """ + import json + + raw = raw.strip() + if not raw: + return [] + + decoder = json.JSONDecoder() + results: List[Dict[str, Any]] = [] + idx = 0 + length = len(raw) + + while idx < length: + # Skip whitespace between objects + while idx < length and raw[idx] in " \t\n\r": + idx += 1 + if idx >= length: + break + + obj, end_idx = decoder.raw_decode(raw, idx) + if isinstance(obj, dict): + results.append(obj) + else: + # Non-dict JSON value – wrap in empty dict (Bedrock requires + # toolUse.input to be an object). + results.append({}) + idx = end_idx + + return results diff --git a/litellm/litellm_core_utils/prompt_templates/factory.py b/litellm/litellm_core_utils/prompt_templates/factory.py index f9ecd78ff1..c907ed32b9 100644 --- a/litellm/litellm_core_utils/prompt_templates/factory.py +++ b/litellm/litellm_core_utils/prompt_templates/factory.py @@ -3287,25 +3287,68 @@ def _convert_to_bedrock_tool_call_invoke( - extract name - extract id """ + from litellm.litellm_core_utils.prompt_templates.common_utils import ( + split_concatenated_json_objects, + ) try: _parts_list: List[BedrockContentBlock] = [] for tool in tool_calls: if "function" in tool: - id = tool["id"] + tool_id = tool["id"] name = tool["function"].get("name", "") arguments = tool["function"].get("arguments", "") - arguments_dict = json.loads(arguments) if arguments else {} - # Ensure arguments_dict is always a dict (Bedrock requires toolUse.input to be an object) - # When some providers return arguments: '""' (JSON-encoded empty string), json.loads returns "" - if not isinstance(arguments_dict, dict): - arguments_dict = {} + if not arguments or not arguments.strip(): arguments_dict = {} else: - arguments_dict = json.loads(arguments) + try: + arguments_dict = json.loads(arguments) + # Ensure arguments_dict is always a dict + # (Bedrock requires toolUse.input to be an object). + # Some providers return arguments: '""' which + # json.loads decodes to a bare string. + if not isinstance(arguments_dict, dict): + arguments_dict = {} + except json.JSONDecodeError: + # The model may return multiple JSON objects + # concatenated in a single arguments string, e.g. + # '{"cmd":"a"}{"cmd":"b"}{"cmd":"c"}' + # Split them and emit one toolUse block per object. + # Fixes: https://github.com/BerriAI/litellm/issues/20543 + parsed_objects = split_concatenated_json_objects( + arguments + ) + if parsed_objects: + # First object keeps the original tool id. + for obj_idx, obj in enumerate(parsed_objects): + block_id = ( + tool_id + if obj_idx == 0 + else f"{tool_id}_{obj_idx}" + ) + bedrock_tool = BedrockToolUseBlock( + input=obj, name=name, toolUseId=block_id + ) + _parts_list.append( + BedrockContentBlock(toolUse=bedrock_tool) + ) + # cache_control applies to the whole original + # tool call; attach after the last split block. + if tool.get("cache_control", None) is not None: + _parts_list.append( + BedrockContentBlock( + cachePoint=CachePointBlock( + type="default" + ) + ) + ) + continue + # Fallback: no objects extracted — use empty dict. + arguments_dict = {} + bedrock_tool = BedrockToolUseBlock( - input=arguments_dict, name=name, toolUseId=id + input=arguments_dict, name=name, toolUseId=tool_id ) bedrock_content_block = BedrockContentBlock(toolUse=bedrock_tool) _parts_list.append(bedrock_content_block) diff --git a/litellm/llms/anthropic/experimental_pass_through/adapters/handler.py b/litellm/llms/anthropic/experimental_pass_through/adapters/handler.py index a17eba75b3..296ae97aea 100644 --- a/litellm/llms/anthropic/experimental_pass_through/adapters/handler.py +++ b/litellm/llms/anthropic/experimental_pass_through/adapters/handler.py @@ -30,6 +30,55 @@ ANTHROPIC_ADAPTER = AnthropicAdapter() class LiteLLMMessagesToCompletionTransformationHandler: + @staticmethod + def _route_openai_thinking_to_responses_api_if_needed( + completion_kwargs: Dict[str, Any], + *, + thinking: Optional[Dict[str, Any]], + ) -> None: + """ + When users call `litellm.anthropic.messages.*` with a non-Anthropic model and + `thinking={"type": "enabled", ...}`, LiteLLM converts this into OpenAI + `reasoning_effort`. + + For OpenAI models, Chat Completions typically does not return reasoning text + (only token accounting). To return a thinking-like content block in the + Anthropic response format, we route the request through OpenAI's Responses API + and request a reasoning summary. + """ + custom_llm_provider = completion_kwargs.get("custom_llm_provider") + if custom_llm_provider is None: + try: + _, inferred_provider, _, _ = litellm.utils.get_llm_provider( + model=cast(str, completion_kwargs.get("model")) + ) + custom_llm_provider = inferred_provider + except Exception: + custom_llm_provider = None + + if custom_llm_provider != "openai": + return + + if not isinstance(thinking, dict) or thinking.get("type") != "enabled": + return + + model = completion_kwargs.get("model") + if isinstance(model, str) and model and not model.startswith("responses/"): + reasoning_effort = completion_kwargs.get("reasoning_effort") + if isinstance(reasoning_effort, str) and reasoning_effort: + completion_kwargs["reasoning_effort"] = { + "effort": reasoning_effort, + "summary": "detailed", + } + elif isinstance(reasoning_effort, dict): + if ( + "summary" not in reasoning_effort + and "generate_summary" not in reasoning_effort + ): + updated_reasoning_effort = dict(reasoning_effort) + updated_reasoning_effort["summary"] = "detailed" + completion_kwargs["reasoning_effort"] = updated_reasoning_effort + @staticmethod def _prepare_completion_kwargs( *, @@ -123,6 +172,11 @@ class LiteLLMMessagesToCompletionTransformationHandler: ): completion_kwargs[key] = value + LiteLLMMessagesToCompletionTransformationHandler._route_openai_thinking_to_responses_api_if_needed( + completion_kwargs, + thinking=thinking, + ) + return completion_kwargs, tool_name_mapping @staticmethod diff --git a/litellm/llms/ollama/chat/transformation.py b/litellm/llms/ollama/chat/transformation.py index 8c98cc5405..bc5aa654aa 100644 --- a/litellm/llms/ollama/chat/transformation.py +++ b/litellm/llms/ollama/chat/transformation.py @@ -502,13 +502,12 @@ class OllamaChatCompletionResponseIterator(BaseModelResponseIterator): reasoning_content: Optional[str] = None content: Optional[str] = None if chunk["message"].get("thinking") is not None: - if self.started_reasoning_content is False: - reasoning_content = chunk["message"].get("thinking") - self.started_reasoning_content = True - elif self.finished_reasoning_content is False: - reasoning_content = chunk["message"].get("thinking") - self.finished_reasoning_content = True + reasoning_content = chunk["message"].get("thinking") + self.started_reasoning_content = True elif chunk["message"].get("content") is not None: + if self.started_reasoning_content and not self.finished_reasoning_content: + self.finished_reasoning_content = True + message_content = chunk["message"].get("content") if "" in message_content: message_content = message_content.replace("", "") diff --git a/litellm/proxy/_experimental/mcp_server/openapi_to_mcp_generator.py b/litellm/proxy/_experimental/mcp_server/openapi_to_mcp_generator.py index b635f15ed0..deb0b4f954 100644 --- a/litellm/proxy/_experimental/mcp_server/openapi_to_mcp_generator.py +++ b/litellm/proxy/_experimental/mcp_server/openapi_to_mcp_generator.py @@ -3,6 +3,8 @@ This module is used to generate MCP tools from OpenAPI specs. """ import json +import asyncio +import os from pathlib import PurePosixPath from typing import Any, Dict, Optional from urllib.parse import quote @@ -45,8 +47,36 @@ def _sanitize_path_parameter_value(param_value: Any, param_name: str) -> str: def load_openapi_spec(filepath: str) -> Dict[str, Any]: - """Load OpenAPI specification from JSON file.""" - with open(filepath, "r") as f: + """ + Sync wrapper. For URL specs, use the shared/custom MCP httpx client. + """ + try: + # If we're already inside an event loop, prefer the async function. + asyncio.get_running_loop() + raise RuntimeError( + "load_openapi_spec() was called from within a running event loop. " + "Use 'await load_openapi_spec_async(...)' instead." + ) + except RuntimeError as e: + # "no running event loop" is fine; other RuntimeErrors we re-raise + if "no running event loop" not in str(e).lower(): + raise + return asyncio.run(load_openapi_spec_async(filepath)) + +async def load_openapi_spec_async(filepath: str) -> Dict[str, Any]: + if filepath.startswith("http://") or filepath.startswith("https://"): + client = get_async_httpx_client(llm_provider=httpxSpecialProvider.MCP) + # NOTE: do not close shared client if get_async_httpx_client returns a shared singleton. + # If it returns a new client each time, consider wrapping it in an async context manager. + r = await client.get(filepath) + r.raise_for_status() + return r.json() + + # fallback: local file + # Local filesystem path + if not os.path.exists(filepath): + raise FileNotFoundError(f"OpenAPI spec not found at {filepath}") + with open(filepath, "r", encoding="utf-8") as f: return json.load(f) diff --git a/litellm/proxy/custom_sso.py b/litellm/proxy/custom_sso.py index 210e9eea3d..b2b028dfbe 100644 --- a/litellm/proxy/custom_sso.py +++ b/litellm/proxy/custom_sso.py @@ -24,9 +24,12 @@ async def custom_sso_handler(userIDPInfo: OpenID) -> SSOUserDefinedValues: print(f"userIDPInfo: {userIDPInfo}") # noqa if userIDPInfo.id is None: - raise ValueError( - f"No ID found for user. userIDPInfo.id is None {userIDPInfo}" - ) + raise ValueError(f"No ID found for user. userIDPInfo.id is None {userIDPInfo}") + + # Access extra fields from the IDP response (requires GENERIC_USER_EXTRA_ATTRIBUTES env var) + # Example: Set GENERIC_USER_EXTRA_ATTRIBUTES="group,NTID,domain" to capture these fields + # extra_fields = getattr(userIDPInfo, 'extra_fields', None) or {} + # user_groups = extra_fields.get("group", []) # check if user exists in litellm proxy DB _user_info = await user_info(user_id=userIDPInfo.id) diff --git a/litellm/proxy/management_endpoints/types.py b/litellm/proxy/management_endpoints/types.py index a35fc4a5f3..295c2ad50b 100644 --- a/litellm/proxy/management_endpoints/types.py +++ b/litellm/proxy/management_endpoints/types.py @@ -4,7 +4,7 @@ Types for the management endpoints Might include fastapi/proxy requirements.txt related imports """ -from typing import List, Optional, cast +from typing import Any, Dict, List, Optional, cast from fastapi_sso.sso.base import OpenID @@ -56,3 +56,4 @@ def get_litellm_user_role(role_str) -> Optional[LitellmUserRoles]: class CustomOpenID(OpenID): team_ids: List[str] user_role: Optional[LitellmUserRoles] = None + extra_fields: Optional[Dict[str, Any]] = None diff --git a/litellm/proxy/management_endpoints/ui_sso.py b/litellm/proxy/management_endpoints/ui_sso.py index 278f3bdaaf..7274b389a9 100644 --- a/litellm/proxy/management_endpoints/ui_sso.py +++ b/litellm/proxy/management_endpoints/ui_sso.py @@ -190,9 +190,15 @@ def process_sso_jwt_access_token( if access_token_str and result: import jwt - access_token_payload = jwt.decode( - access_token_str, options={"verify_signature": False} - ) + try: + access_token_payload = jwt.decode( + access_token_str, options={"verify_signature": False} + ) + except jwt.exceptions.DecodeError: + verbose_proxy_logger.debug( + "Access token is not a valid JWT (possibly an opaque token), skipping JWT-based extraction" + ) + return # Extract team IDs from access token if sso_jwt_handler is available if sso_jwt_handler: @@ -401,6 +407,8 @@ def generic_response_convertor( generic_user_role_attribute_name = os.getenv("GENERIC_USER_ROLE_ATTRIBUTE", "role") + generic_user_extra_attributes = os.getenv("GENERIC_USER_EXTRA_ATTRIBUTES", None) + verbose_proxy_logger.debug( f" generic_user_id_attribute_name: {generic_user_id_attribute_name}\n generic_user_email_attribute_name: {generic_user_email_attribute_name}" ) @@ -473,6 +481,14 @@ def generic_response_convertor( f"Found valid LitellmUserRoles '{role.value}' from SSO attribute '{generic_user_role_attribute_name}'" ) + # Build extra_fields dict from GENERIC_USER_EXTRA_ATTRIBUTES if specified + extra_fields: Optional[Dict[str, Any]] = None + if generic_user_extra_attributes: + extra_fields = {} + for attr_name in generic_user_extra_attributes.split(","): + attr_name = attr_name.strip() + extra_fields[attr_name] = get_nested_value(response, attr_name) + return CustomOpenID( id=get_nested_value(response, generic_user_id_attribute_name), display_name=get_nested_value( @@ -484,6 +500,7 @@ def generic_response_convertor( provider=get_nested_value(response, generic_provider_attribute_name), team_ids=all_teams, user_role=user_role, + extra_fields=extra_fields, ) diff --git a/litellm/responses/litellm_completion_transformation/transformation.py b/litellm/responses/litellm_completion_transformation/transformation.py index c3add94601..2bff4e23c7 100644 --- a/litellm/responses/litellm_completion_transformation/transformation.py +++ b/litellm/responses/litellm_completion_transformation/transformation.py @@ -602,18 +602,53 @@ class LiteLLMCompletionResponsesConfig: } return None + @staticmethod + def _get_mapping_or_attr_value(obj: Any, key: str, default: Any = None) -> Any: + """ + Safely read a field from dict-like or attribute-based objects. + """ + if obj is None: + return default + + if isinstance(obj, dict): + return obj.get(key, default) + + getter = getattr(obj, "get", None) + if callable(getter): + try: + return getter(key, default) + except (TypeError, AttributeError): + pass + + return getattr(obj, key, default) + @staticmethod def _create_tool_call_chunk( tool_use_definition: Dict[str, Any], tool_call_id: str, index: int ) -> ChatCompletionToolCallChunk: """Create a ChatCompletionToolCallChunk from tool_use_definition.""" - function_raw = tool_use_definition.get("function") - function: Dict[str, Any] = function_raw if isinstance(function_raw, dict) else {} - tool_use_id_raw = tool_use_definition.get("id") + function_raw = LiteLLMCompletionResponsesConfig._get_mapping_or_attr_value( + tool_use_definition, "function" + ) + function_name_raw = LiteLLMCompletionResponsesConfig._get_mapping_or_attr_value( + function_raw, "name" + ) + function_arguments_raw = LiteLLMCompletionResponsesConfig._get_mapping_or_attr_value( + function_raw, "arguments" + ) + function: Dict[str, Any] = { + "name": function_name_raw or "", + "arguments": function_arguments_raw or "{}", + } + tool_use_id_raw = LiteLLMCompletionResponsesConfig._get_mapping_or_attr_value( + tool_use_definition, "id" + ) tool_use_id: str = ( str(tool_use_id_raw) if tool_use_id_raw is not None else str(tool_call_id) ) - tool_use_type_raw = tool_use_definition.get("type") + tool_use_type_raw = LiteLLMCompletionResponsesConfig._get_mapping_or_attr_value( + tool_use_definition, "type" + ) tool_use_type: str = ( str(tool_use_type_raw) if tool_use_type_raw is not None else "function" ) @@ -627,6 +662,63 @@ class LiteLLMCompletionResponsesConfig: index=index, ) + @staticmethod + def _normalize_tool_use_definition( + tool_use_definition: Any, tool_call_id: str + ) -> Optional[Dict[str, Any]]: + """ + Normalize cached tool_call definitions to a dict-like shape consumed by _create_tool_call_chunk. + """ + if not tool_use_definition: + return None + + if isinstance(tool_use_definition, dict): + normalized_definition: Dict[str, Any] = dict(tool_use_definition) + else: + tool_use_id_raw = LiteLLMCompletionResponsesConfig._get_mapping_or_attr_value( + tool_use_definition, "id" + ) + tool_use_type_raw = LiteLLMCompletionResponsesConfig._get_mapping_or_attr_value( + tool_use_definition, "type" + ) + function_raw = LiteLLMCompletionResponsesConfig._get_mapping_or_attr_value( + tool_use_definition, "function" + ) + + # Object does not expose the expected tool_call fields. + if ( + tool_use_id_raw is None + and tool_use_type_raw is None + and function_raw is None + ): + return None + + normalized_definition = { + "id": tool_use_id_raw, + "type": tool_use_type_raw, + "function": function_raw, + } + + function_raw = normalized_definition.get("function") + if function_raw is not None and not isinstance(function_raw, dict): + function_name_raw = LiteLLMCompletionResponsesConfig._get_mapping_or_attr_value( + function_raw, "name" + ) + function_arguments_raw = LiteLLMCompletionResponsesConfig._get_mapping_or_attr_value( + function_raw, "arguments" + ) + if function_name_raw is not None or function_arguments_raw is not None: + normalized_definition["function"] = { + "name": function_name_raw, + "arguments": function_arguments_raw, + } + + normalized_definition["id"] = normalized_definition.get("id") or tool_call_id + normalized_definition["type"] = ( + normalized_definition.get("type") or "function" + ) + return normalized_definition + @staticmethod def _add_tool_call_to_assistant( assistant_message: Any, tool_call_chunk: ChatCompletionToolCallChunk @@ -740,13 +832,19 @@ class LiteLLMCompletionResponsesConfig: tool_call_id, tools ) ) - - if _tool_use_definition: - if not isinstance(_tool_use_definition, dict): - _tool_use_definition = {} + + normalized_tool_use_definition = ( + LiteLLMCompletionResponsesConfig._normalize_tool_use_definition( + _tool_use_definition, tool_call_id + ) + ) + + if normalized_tool_use_definition: tool_call_chunk = ( LiteLLMCompletionResponsesConfig._create_tool_call_chunk( - _tool_use_definition, tool_call_id, len(tool_calls) + normalized_tool_use_definition, + tool_call_id, + len(tool_calls), ) ) LiteLLMCompletionResponsesConfig._add_tool_call_to_assistant( diff --git a/tests/mcp_tests/test_openapi_spec_path_url.py b/tests/mcp_tests/test_openapi_spec_path_url.py new file mode 100644 index 0000000000..03e9db9496 --- /dev/null +++ b/tests/mcp_tests/test_openapi_spec_path_url.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +from typing import Any, Dict + +import httpx +import pytest + +from litellm.proxy._experimental.mcp_server import openapi_to_mcp_generator as gen + + +class _FakeAsyncHTTPHandler: + """ + Minimal stand-in for the object returned by get_async_httpx_client(). + openapi_to_mcp_generator.load_openapi_spec_async() calls: + + client = get_async_httpx_client(...) + r = await client.get(url, timeout=30.0) + + So we must implement async get(). + """ + + def __init__(self, response: httpx.Response, expected_url: str): + self._response = response + self._expected_url = expected_url + self.calls = 0 + + async def get(self, request_url: str, timeout: float = 30.0): + self.calls += 1 + assert request_url == self._expected_url + assert timeout == 30.0 + return self._response + + +def test_load_openapi_spec_supports_http_url(monkeypatch: pytest.MonkeyPatch) -> None: + url = "http://example.local/openapi.json" + expected: Dict[str, Any] = { + "openapi": "3.0.0", + "info": {"title": "Test API", "version": "1.0.0"}, + "paths": {}, + } + + # httpx.Response must include a Request for raise_for_status() to work. + req = httpx.Request("GET", url) + resp = httpx.Response(status_code=200, json=expected, request=req) + + calls = {"get_async_httpx_client": 0} + handler_holder: Dict[str, Any] = {} + + def fake_get_async_httpx_client(*args, **kwargs): + calls["get_async_httpx_client"] += 1 + h = _FakeAsyncHTTPHandler(resp, expected_url=url) + handler_holder["handler"] = h + return h + + # Ensure shared/custom client path is used + monkeypatch.setattr(gen, "get_async_httpx_client", fake_get_async_httpx_client) + + # Fail loudly if someone reintroduces direct httpx.get() + def boom(*args, **kwargs): + raise AssertionError("Direct httpx.get() must not be used for URL spec loading") + + monkeypatch.setattr(httpx, "get", boom) + + spec = gen.load_openapi_spec(url) + + assert spec == expected + assert calls["get_async_httpx_client"] == 1 + assert handler_holder["handler"].calls == 1 + + +def test_load_openapi_spec_supports_local_file_path(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None: + expected: Dict[str, Any] = { + "openapi": "3.0.0", + "info": {"title": "Local API", "version": "1.0.0"}, + "paths": {}, + } + + p = tmp_path / "openapi.json" + p.write_text( + '{"openapi":"3.0.0","info":{"title":"Local API","version":"1.0.0"},"paths":{}}', + encoding="utf-8", + ) + + # For local files, shared client must NOT be used. + def boom_client(*args, **kwargs): + raise AssertionError("get_async_httpx_client() must not be called for local file paths") + + monkeypatch.setattr(gen, "get_async_httpx_client", boom_client) + + spec = gen.load_openapi_spec(str(p)) + assert spec == expected + diff --git a/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_common_utils.py b/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_common_utils.py index 980693aa73..f566f91841 100644 --- a/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_common_utils.py +++ b/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_common_utils.py @@ -12,6 +12,7 @@ sys.path.insert( from litellm.litellm_core_utils.prompt_templates.common_utils import ( get_format_from_file_id, handle_any_messages_to_chat_completion_str_messages_conversion, + split_concatenated_json_objects, update_messages_with_model_file_ids, ) @@ -143,3 +144,58 @@ def test_convert_prefix_message_to_non_prefix_messages(): }, {"role": "assistant", "content": "value"}, ] + + +# ── split_concatenated_json_objects tests ── + + +def test_split_concatenated_json_single_object(): + """A single valid JSON object is returned as a one-element list.""" + result = split_concatenated_json_objects('{"location": "Boston"}') + assert result == [{"location": "Boston"}] + + +def test_split_concatenated_json_multiple_objects(): + """ + Multiple JSON objects concatenated without separators are split correctly. + This is the exact pattern from issue #20543 where Bedrock Claude Sonnet 4.5 + returns concatenated JSON in a single tool call arguments string. + """ + raw = ( + '{"command": ["curl", "-i", "http://localhost:9009"]}' + '{"command": ["curl", "-i", "http://localhost:9009/robots.txt"]}' + '{"command": ["curl", "-i", "http://localhost:9009/sitemap.xml"]}' + ) + result = split_concatenated_json_objects(raw) + assert len(result) == 3 + assert result[0] == {"command": ["curl", "-i", "http://localhost:9009"]} + assert result[1] == {"command": ["curl", "-i", "http://localhost:9009/robots.txt"]} + assert result[2] == {"command": ["curl", "-i", "http://localhost:9009/sitemap.xml"]} + + +def test_split_concatenated_json_with_whitespace(): + """Objects separated by whitespace are handled correctly.""" + raw = '{"a": 1} {"b": 2}\n{"c": 3}' + result = split_concatenated_json_objects(raw) + assert len(result) == 3 + assert result[0] == {"a": 1} + assert result[1] == {"b": 2} + assert result[2] == {"c": 3} + + +def test_split_concatenated_json_empty_string(): + """Empty or whitespace-only strings return an empty list.""" + assert split_concatenated_json_objects("") == [] + assert split_concatenated_json_objects(" ") == [] + + +def test_split_concatenated_json_non_dict_value(): + """Non-dict JSON values (e.g. arrays, strings) are replaced with {}.""" + result = split_concatenated_json_objects('[1, 2, 3]') + assert result == [{}] + + +def test_split_concatenated_json_invalid_raises(): + """Completely invalid JSON raises JSONDecodeError.""" + with pytest.raises(json.JSONDecodeError): + split_concatenated_json_objects("not json at all") diff --git a/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py b/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py index e87233a52a..707b5bdc77 100644 --- a/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py +++ b/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py @@ -8,6 +8,7 @@ from litellm.litellm_core_utils.prompt_templates.factory import ( BAD_MESSAGE_ERROR_STR, BedrockConverseMessagesProcessor, BedrockImageProcessor, + _convert_to_bedrock_tool_call_invoke, ollama_pt, ) @@ -1590,3 +1591,153 @@ def test_bedrock_tools_unpack_defs_no_oom_with_nested_refs(): # Verify $defs have been removed (Bedrock doesn't support them) tool_schema = result[0]["toolSpec"].get("inputSchema", {}).get("json", {}) assert "$defs" not in tool_schema, "$defs should be removed after expansion" + + +# ── _convert_to_bedrock_tool_call_invoke tests ── + + +def test_bedrock_tool_call_invoke_normal_single_tool(): + """Normal single tool call with valid JSON arguments.""" + tool_calls = [ + { + "id": "call_abc123", + "type": "function", + "function": { + "name": "get_weather", + "arguments": '{"location": "Boston, MA"}', + }, + } + ] + result = _convert_to_bedrock_tool_call_invoke(tool_calls) + assert len(result) == 1 + assert result[0]["toolUse"]["toolUseId"] == "call_abc123" + assert result[0]["toolUse"]["name"] == "get_weather" + assert result[0]["toolUse"]["input"] == {"location": "Boston, MA"} + + +def test_bedrock_tool_call_invoke_empty_arguments(): + """Tool call with empty arguments produces an empty dict input.""" + tool_calls = [ + { + "id": "call_empty", + "type": "function", + "function": {"name": "do_something", "arguments": ""}, + } + ] + result = _convert_to_bedrock_tool_call_invoke(tool_calls) + assert len(result) == 1 + assert result[0]["toolUse"]["input"] == {} + + +def test_bedrock_tool_call_invoke_concatenated_json(): + """ + Tool call whose arguments contain multiple concatenated JSON objects + (the bug from issue #20543) is split into separate Bedrock toolUse blocks. + + Bedrock Claude Sonnet 4.5 sometimes returns multiple tool call arguments + concatenated in a single string like: + '{"command":["curl",...]}{"command":["curl",...]}{"command":["curl",...]}' + """ + tool_calls = [ + { + "id": "tooluse_L7I3TewYAUhoheJZQEuwVN", + "type": "function", + "function": { + "name": "shell", + "arguments": ( + '{"command": ["curl", "-i", "http://localhost:9009", "-m", "10"]}' + '{"command": ["curl", "-i", "http://localhost:9009/robots.txt", "-m", "5"]}' + '{"command": ["curl", "-i", "http://localhost:9009/sitemap.xml", "-m", "5"]}' + ), + }, + } + ] + result = _convert_to_bedrock_tool_call_invoke(tool_calls) + + # Should produce 3 separate toolUse blocks + assert len(result) == 3 + + # First block keeps original tool id + assert result[0]["toolUse"]["toolUseId"] == "tooluse_L7I3TewYAUhoheJZQEuwVN" + assert result[0]["toolUse"]["name"] == "shell" + assert result[0]["toolUse"]["input"] == { + "command": ["curl", "-i", "http://localhost:9009", "-m", "10"] + } + + # Subsequent blocks get suffixed ids + assert result[1]["toolUse"]["toolUseId"] == "tooluse_L7I3TewYAUhoheJZQEuwVN_1" + assert result[1]["toolUse"]["name"] == "shell" + assert result[1]["toolUse"]["input"] == { + "command": ["curl", "-i", "http://localhost:9009/robots.txt", "-m", "5"] + } + + assert result[2]["toolUse"]["toolUseId"] == "tooluse_L7I3TewYAUhoheJZQEuwVN_2" + assert result[2]["toolUse"]["name"] == "shell" + assert result[2]["toolUse"]["input"] == { + "command": ["curl", "-i", "http://localhost:9009/sitemap.xml", "-m", "5"] + } + + +def test_bedrock_tool_call_invoke_concatenated_json_with_cache_control(): + """ + When a tool call has cache_control AND concatenated JSON arguments, + the cachePoint block is appended after the last split block. + """ + tool_calls = [ + { + "id": "call_cached", + "type": "function", + "cache_control": {"type": "default"}, + "function": { + "name": "shell", + "arguments": '{"a": 1}{"b": 2}', + }, + } + ] + result = _convert_to_bedrock_tool_call_invoke(tool_calls) + + # 2 toolUse blocks + 1 cachePoint block + assert len(result) == 3 + assert "toolUse" in result[0] + assert "toolUse" in result[1] + assert "cachePoint" in result[2] + + +def test_bedrock_tool_call_invoke_non_dict_arguments(): + """Arguments that parse to a non-dict (e.g. '""') produce empty dict input.""" + tool_calls = [ + { + "id": "call_non_dict", + "type": "function", + "function": {"name": "tool", "arguments": '""'}, + } + ] + result = _convert_to_bedrock_tool_call_invoke(tool_calls) + assert len(result) == 1 + assert result[0]["toolUse"]["input"] == {} + + +def test_bedrock_tool_call_invoke_multiple_normal_tools(): + """Multiple separate tool calls (normal parallel calling) work correctly.""" + tool_calls = [ + { + "id": "call_1", + "type": "function", + "function": { + "name": "get_weather", + "arguments": '{"city": "NYC"}', + }, + }, + { + "id": "call_2", + "type": "function", + "function": { + "name": "get_weather", + "arguments": '{"city": "LA"}', + }, + }, + ] + result = _convert_to_bedrock_tool_call_invoke(tool_calls) + assert len(result) == 2 + assert result[0]["toolUse"]["toolUseId"] == "call_1" + assert result[1]["toolUse"]["toolUseId"] == "call_2" diff --git a/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_anthropic_experimental_pass_through_messages_handler.py b/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_anthropic_experimental_pass_through_messages_handler.py index 66d62aae1e..80fd3ab698 100644 --- a/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_anthropic_experimental_pass_through_messages_handler.py +++ b/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_anthropic_experimental_pass_through_messages_handler.py @@ -185,7 +185,14 @@ def test_openai_model_with_thinking_converts_to_reasoning_effort(): # Verify reasoning_effort is set (converted from thinking) assert "reasoning_effort" in call_kwargs, "reasoning_effort should be passed to completion" - assert call_kwargs["reasoning_effort"] == "minimal", f"reasoning_effort should be 'minimal' for budget_tokens=1024, got {call_kwargs.get('reasoning_effort')}" + assert call_kwargs["reasoning_effort"] == { + "effort": "minimal", + "summary": "detailed", + }, f"reasoning_effort should request a reasoning summary for OpenAI responses API, got {call_kwargs.get('reasoning_effort')}" + + # Verify OpenAI thinking requests are routed to the Responses API + assert call_kwargs.get("model") == "responses/gpt-5.2" + # Verify thinking is NOT passed (non-Claude model) assert "thinking" not in call_kwargs, "thinking should NOT be passed for non-Claude models" diff --git a/tests/test_litellm/llms/ollama/test_ollama_chat_transformation.py b/tests/test_litellm/llms/ollama/test_ollama_chat_transformation.py index af6481a6cb..02495106a8 100644 --- a/tests/test_litellm/llms/ollama/test_ollama_chat_transformation.py +++ b/tests/test_litellm/llms/ollama/test_ollama_chat_transformation.py @@ -10,7 +10,8 @@ sys.path.insert( 0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../../..")) ) -from litellm.llms.ollama.chat.transformation import OllamaChatConfig +from litellm.llms.ollama.chat.transformation import OllamaChatConfig, OllamaChatCompletionResponseIterator + from litellm.types.llms.openai import AllMessageValues from litellm.utils import get_optional_params @@ -473,3 +474,130 @@ class TestOllamaToolCalling: # finish_reason should be "stop" (default behavior) assert result.choices[0].finish_reason == "stop" assert result.choices[0].message.tool_calls is None + + +class TestOllamaReasoningContentStreaming: + """Test that reasoning_content is properly extracted from all thinking chunks.""" + + def test_multiple_thinking_chunks_all_returned_as_reasoning_content(self): + """ + Test that more than 2 consecutive thinking chunks are all returned as reasoning_content. + + Previously, the code had a bug where finished_reasoning_content was set to True + after just 2 chunks with 'thinking', causing subsequent thinking content to be lost. + """ + iterator = OllamaChatCompletionResponseIterator( + streaming_response=iter([]), # Not used in chunk_parser + sync_stream=True, + ) + + # Simulate 5 consecutive chunks with 'thinking' content + thinking_chunks = [ + { + "model": "deepseek-r1", + "message": {"role": "assistant", "thinking": f"Thinking chunk {i}"}, + "done": False, + } + for i in range(1, 6) + ] + + # Process all thinking chunks + reasoning_contents = [] + for chunk in thinking_chunks: + result = iterator.chunk_parser(chunk) + rc = result.choices[0].delta.reasoning_content + reasoning_contents.append(rc) + + # ALL chunks should have reasoning_content, not just the first 2 + assert len(reasoning_contents) == 5 + assert reasoning_contents[0] == "Thinking chunk 1" + assert reasoning_contents[1] == "Thinking chunk 2" + assert reasoning_contents[2] == "Thinking chunk 3" # This was previously None + assert reasoning_contents[3] == "Thinking chunk 4" # This was previously None + assert reasoning_contents[4] == "Thinking chunk 5" # This was previously None + + # Verify none of them are None + for i, rc in enumerate(reasoning_contents): + assert rc is not None, f"Chunk {i+1} reasoning_content should not be None" + + def test_thinking_to_content_transition(self): + """ + Test that transition from thinking to regular content works correctly. + """ + iterator = OllamaChatCompletionResponseIterator( + streaming_response=iter([]), + sync_stream=True, + ) + + # First: thinking chunks + thinking_chunk = { + "model": "deepseek-r1", + "message": {"role": "assistant", "thinking": "Let me think about this..."}, + "done": False, + } + result1 = iterator.chunk_parser(thinking_chunk) + assert result1.choices[0].delta.reasoning_content == "Let me think about this..." + assert result1.choices[0].delta.content is None + + # Then: regular content chunk + content_chunk = { + "model": "deepseek-r1", + "message": {"role": "assistant", "content": "Here is my answer."}, + "done": False, + } + result2 = iterator.chunk_parser(content_chunk) + assert result2.choices[0].delta.content == "Here is my answer." + # reasoning_content is not set when there's no thinking in the chunk + assert getattr(result2.choices[0].delta, 'reasoning_content', None) is None + + def test_think_tags_in_content(self): + """ + Test that tags embedded in content are properly parsed. + """ + iterator = OllamaChatCompletionResponseIterator( + streaming_response=iter([]), + sync_stream=True, + ) + + # Content with tag + chunk1 = { + "model": "deepseek-r1", + "message": {"role": "assistant", "content": "I need to analyze this"}, + "done": False, + } + result1 = iterator.chunk_parser(chunk1) + assert result1.choices[0].delta.reasoning_content == "I need to analyze this" + assert result1.choices[0].delta.content is None + + # Content with tag (end of thinking) + chunk2 = { + "model": "deepseek-r1", + "message": {"role": "assistant", "content": "The answer is 42."}, + "done": False, + } + result2 = iterator.chunk_parser(chunk2) + assert result2.choices[0].delta.content == "The answer is 42." + # reasoning_content is not set when it's regular content + assert getattr(result2.choices[0].delta, 'reasoning_content', None) is None + + def test_done_chunk_with_thinking(self): + """ + Test that the final chunk with done=True and thinking content works. + """ + iterator = OllamaChatCompletionResponseIterator( + streaming_response=iter([]), + sync_stream=True, + ) + + # Final chunk with thinking + done_chunk = { + "model": "deepseek-r1", + "message": {"role": "assistant", "thinking": "Final thought"}, + "done": True, + "done_reason": "stop", + } + result = iterator.chunk_parser(done_chunk) + assert result.choices[0].delta.reasoning_content == "Final thought" + assert result.choices[0].finish_reason == "stop" + + diff --git a/tests/test_litellm/proxy/management_endpoints/test_ui_sso.py b/tests/test_litellm/proxy/management_endpoints/test_ui_sso.py index 16f8082679..74d36c0aca 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_ui_sso.py +++ b/tests/test_litellm/proxy/management_endpoints/test_ui_sso.py @@ -2374,47 +2374,6 @@ class TestProcessSSOJWTAccessToken: "groups": ["team1", "team2", "team3"], } - def test_process_sso_jwt_access_token_with_valid_token( - self, mock_jwt_handler, sample_jwt_token, sample_jwt_payload - ): - """Test processing a valid JWT access token with team extraction""" - from litellm.proxy.management_endpoints.ui_sso import ( - process_sso_jwt_access_token, - ) - - # Create a result object without team_ids - result = CustomOpenID( - id="test_user", - email="test@example.com", - first_name="Test", - last_name="User", - display_name="Test User", - provider="generic", - team_ids=[], - ) - - with patch("jwt.decode", return_value=sample_jwt_payload) as mock_jwt_decode: - # Act - process_sso_jwt_access_token( - access_token_str=sample_jwt_token, - sso_jwt_handler=mock_jwt_handler, - result=result, - ) - - # Assert - # Verify JWT was decoded correctly - mock_jwt_decode.assert_called_once_with( - sample_jwt_token, options={"verify_signature": False} - ) - - # Verify team IDs were extracted from JWT - mock_jwt_handler.get_team_ids_from_jwt.assert_called_once_with( - sample_jwt_payload - ) - - # Verify team IDs were set on the result object - assert result.team_ids == ["team1", "team2", "team3"] - def test_process_sso_jwt_access_token_with_existing_team_ids( self, mock_jwt_handler, sample_jwt_token ): @@ -2549,27 +2508,6 @@ class TestProcessSSOJWTAccessToken: mock_jwt_handler.get_team_ids_from_jwt.assert_not_called() assert result.team_ids == [] - def test_process_sso_jwt_access_token_no_sso_jwt_handler(self, sample_jwt_token): - """Test that JWT is decoded for role extraction even when sso_jwt_handler is None, - but team_ids are not extracted (team extraction requires sso_jwt_handler).""" - from litellm.proxy.management_endpoints.ui_sso import ( - process_sso_jwt_access_token, - ) - - result = CustomOpenID(id="test_user", email="test@example.com", team_ids=[]) - - mock_payload = {"sub": "test_user", "email": "test@example.com"} - with patch("jwt.decode", return_value=mock_payload) as mock_jwt_decode: - # Act - process_sso_jwt_access_token( - access_token_str=sample_jwt_token, sso_jwt_handler=None, result=result - ) - - # JWT is decoded (for role extraction) but team_ids are not extracted - mock_jwt_decode.assert_called_once() - assert result.team_ids == [] - assert result.user_role is None - def test_process_sso_jwt_access_token_no_result( self, mock_jwt_handler, sample_jwt_token ): @@ -2590,10 +2528,12 @@ class TestProcessSSOJWTAccessToken: mock_jwt_decode.assert_not_called() mock_jwt_handler.get_team_ids_from_jwt.assert_not_called() - def test_process_sso_jwt_access_token_jwt_decode_exception( + def test_process_sso_jwt_access_token_non_decode_exception_propagates( self, mock_jwt_handler, sample_jwt_token ): - """Test that JWT decode exceptions are not caught (should propagate up)""" + """Test that non-DecodeError JWT exceptions still propagate up.""" + import jwt as pyjwt + from litellm.proxy.management_endpoints.ui_sso import ( process_sso_jwt_access_token, ) @@ -2601,19 +2541,16 @@ class TestProcessSSOJWTAccessToken: result = CustomOpenID(id="test_user", email="test@example.com", team_ids=[]) with patch( - "jwt.decode", side_effect=Exception("JWT decode error") + "jwt.decode", side_effect=pyjwt.exceptions.InvalidKeyError("Invalid key") ) as mock_jwt_decode: - # Act & Assert - with pytest.raises(Exception, match="JWT decode error"): + with pytest.raises(pyjwt.exceptions.InvalidKeyError, match="Invalid key"): process_sso_jwt_access_token( access_token_str=sample_jwt_token, sso_jwt_handler=mock_jwt_handler, result=result, ) - # Verify JWT decode was attempted mock_jwt_decode.assert_called_once() - # But team extraction should not have been called mock_jwt_handler.get_team_ids_from_jwt.assert_not_called() def test_process_sso_jwt_access_token_empty_team_ids_from_jwt( @@ -2646,6 +2583,124 @@ class TestProcessSSOJWTAccessToken: # Even empty team IDs should be set assert result.team_ids == [] + def test_process_sso_jwt_access_token_with_opaque_token(self, mock_jwt_handler): + """Test that opaque (non-JWT) access tokens are handled gracefully without raising.""" + from litellm.proxy.management_endpoints.ui_sso import ( + process_sso_jwt_access_token, + ) + + result = CustomOpenID( + id="test_user", + email="test@example.com", + first_name="Test", + last_name="User", + display_name="Test User", + provider="generic", + team_ids=["existing_team"], + user_role=None, + ) + + # Opaque tokens like those from Logto are short random strings, not JWTs + opaque_token = "uTxyjXbS_random_opaque_token_string" + + # Should NOT raise - opaque tokens should be silently skipped + process_sso_jwt_access_token( + access_token_str=opaque_token, + sso_jwt_handler=mock_jwt_handler, + result=result, + ) + + # Result should be untouched + mock_jwt_handler.get_team_ids_from_jwt.assert_not_called() + assert result.team_ids == ["existing_team"] + assert result.user_role is None + + def test_process_sso_jwt_access_token_real_jwt_with_role_and_teams( + self, mock_jwt_handler + ): + """Test that a real JWT containing role and team fields is correctly processed.""" + import jwt as pyjwt + + from litellm.proxy.management_endpoints.ui_sso import ( + process_sso_jwt_access_token, + ) + + payload = { + "sub": "user123", + "email": "admin@example.com", + "role": "proxy_admin", + "groups": ["team_alpha", "team_beta"], + } + real_jwt_token = pyjwt.encode(payload, "test-secret", algorithm="HS256") + + mock_jwt_handler.get_team_ids_from_jwt.return_value = [ + "team_alpha", + "team_beta", + ] + + result = CustomOpenID( + id="user123", + email="admin@example.com", + first_name="Admin", + last_name="User", + display_name="Admin User", + provider="generic", + team_ids=[], + user_role=None, + ) + + process_sso_jwt_access_token( + access_token_str=real_jwt_token, + sso_jwt_handler=mock_jwt_handler, + result=result, + ) + + # Team IDs should be extracted via sso_jwt_handler + mock_jwt_handler.get_team_ids_from_jwt.assert_called_once_with(payload) + assert result.team_ids == ["team_alpha", "team_beta"] + + # Role should be extracted from the "role" field in the JWT + from litellm.proxy._types import LitellmUserRoles + + assert result.user_role == LitellmUserRoles.PROXY_ADMIN + + def test_process_sso_jwt_access_token_real_jwt_without_role_and_teams(self): + """Test that a real JWT without role/team fields leaves result unchanged.""" + import jwt as pyjwt + + from litellm.proxy.management_endpoints.ui_sso import ( + process_sso_jwt_access_token, + ) + + payload = { + "sub": "user456", + "email": "plain@example.com", + "iat": 1700000000, + } + real_jwt_token = pyjwt.encode(payload, "test-secret", algorithm="HS256") + + result = CustomOpenID( + id="user456", + email="plain@example.com", + first_name="Plain", + last_name="User", + display_name="Plain User", + provider="generic", + team_ids=[], + user_role=None, + ) + + # No sso_jwt_handler, no role/team fields in JWT + process_sso_jwt_access_token( + access_token_str=real_jwt_token, + sso_jwt_handler=None, + result=result, + ) + + # Nothing should be modified + assert result.team_ids == [] + assert result.user_role is None + @pytest.mark.asyncio async def test_get_ui_settings_includes_api_doc_base_url(): @@ -4071,3 +4126,123 @@ def test_process_sso_jwt_access_token_with_role_mappings(): # Should get highest privilege role assert result.user_role == LitellmUserRoles.PROXY_ADMIN + +def test_generic_response_convertor_with_extra_attributes(monkeypatch): + """Test that extra attributes are extracted when GENERIC_USER_EXTRA_ATTRIBUTES is set""" + from litellm.proxy.management_endpoints.ui_sso import generic_response_convertor + + monkeypatch.setenv("GENERIC_CLIENT_ID", "test_client") + monkeypatch.setenv("GENERIC_USER_EXTRA_ATTRIBUTES", "custom_field1,custom_field2,custom_field3") + + mock_response = { + "sub": "user-id-123", + "email": "user@example.com", + "given_name": "John", + "family_name": "Doe", + "name": "John Doe", + "provider": "generic", + "custom_field1": "value1", + "custom_field2": ["item1", "item2"], + "custom_field3": {"nested": "data"}, + } + + mock_jwt_handler = MagicMock(spec=JWTHandler) + mock_jwt_handler.get_team_ids_from_jwt.return_value = [] + + result = generic_response_convertor( + response=mock_response, + jwt_handler=mock_jwt_handler, + sso_jwt_handler=None, + role_mappings=None, + ) + + assert result.extra_fields is not None + assert result.extra_fields["custom_field1"] == "value1" + assert result.extra_fields["custom_field2"] == ["item1", "item2"] + assert result.extra_fields["custom_field3"] == {"nested": "data"} + +def test_generic_response_convertor_without_extra_attributes(monkeypatch): + """Test backward compatibility - extra_fields is None when env var not set""" + from litellm.proxy.management_endpoints.ui_sso import generic_response_convertor + + monkeypatch.setenv("GENERIC_CLIENT_ID", "test_client") + # Don't set GENERIC_USER_EXTRA_ATTRIBUTES + + mock_response = { + "sub": "user-id-123", + "email": "user@example.com", + "given_name": "John", + "family_name": "Doe", + "name": "John Doe", + "provider": "generic", + "custom_field1": "value1", + "custom_field2": "value2", + } + + mock_jwt_handler = MagicMock(spec=JWTHandler) + mock_jwt_handler.get_team_ids_from_jwt.return_value = [] + + result = generic_response_convertor( + response=mock_response, + jwt_handler=mock_jwt_handler, + sso_jwt_handler=None, + role_mappings=None, + ) + + assert result.extra_fields is None + +def test_generic_response_convertor_extra_attributes_with_nested_paths(monkeypatch): + """Test that nested paths work with dot notation""" + from litellm.proxy.management_endpoints.ui_sso import generic_response_convertor + + monkeypatch.setenv("GENERIC_CLIENT_ID", "test_client") + monkeypatch.setenv("GENERIC_USER_EXTRA_ATTRIBUTES", "org_info.department,org_info.manager") + + mock_response = { + "sub": "user-id-123", + "email": "user@example.com", + "org_info": { + "department": "Engineering", + "manager": "Jane Smith" + } + } + + mock_jwt_handler = MagicMock(spec=JWTHandler) + mock_jwt_handler.get_team_ids_from_jwt.return_value = [] + + result = generic_response_convertor( + response=mock_response, + jwt_handler=mock_jwt_handler, + sso_jwt_handler=None, + role_mappings=None, + ) + + assert result.extra_fields is not None + assert result.extra_fields["org_info.department"] == "Engineering" + assert result.extra_fields["org_info.manager"] == "Jane Smith" + +def test_generic_response_convertor_extra_attributes_missing_field(monkeypatch): + """Test that missing fields return None""" + from litellm.proxy.management_endpoints.ui_sso import generic_response_convertor + + monkeypatch.setenv("GENERIC_CLIENT_ID", "test_client") + monkeypatch.setenv("GENERIC_USER_EXTRA_ATTRIBUTES", "missing_field,another_missing") + + mock_response = { + "sub": "user-id-123", + "email": "user@example.com", + } + + mock_jwt_handler = MagicMock(spec=JWTHandler) + mock_jwt_handler.get_team_ids_from_jwt.return_value = [] + + result = generic_response_convertor( + response=mock_response, + jwt_handler=mock_jwt_handler, + sso_jwt_handler=None, + role_mappings=None, + ) + + assert result.extra_fields is not None + assert result.extra_fields["missing_field"] is None + assert result.extra_fields["another_missing"] is None \ No newline at end of file diff --git a/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py b/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py index b183875d05..6d6162437c 100644 --- a/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py +++ b/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py @@ -7,6 +7,7 @@ sys.path.insert( from litellm.responses.litellm_completion_transformation.transformation import ( LiteLLMCompletionResponsesConfig, + TOOL_CALLS_CACHE, ) from litellm.types.llms.openai import ( ChatCompletionResponseMessage, @@ -17,6 +18,8 @@ from litellm.types.utils import ( CompletionTokensDetailsWrapper, Message, ModelResponse, + Function, + ChatCompletionMessageToolCall, PromptTokensDetailsWrapper, Usage, ) @@ -755,6 +758,98 @@ class TestFunctionCallTransformation: tool_call = tool_calls[0] assert tool_call.get("id") == "fallback_id" + def test_ensure_tool_results_preserves_cached_openai_object_tool_call(self): + """ + Test cached ChatCompletionMessageToolCall objects are normalized correctly. + """ + tool_call_id = "call_cached_openai_object" + TOOL_CALLS_CACHE.set_cache( + key=tool_call_id, + value=ChatCompletionMessageToolCall( + id=tool_call_id, + type="function", + function=Function( + name="search_web", + arguments='{"query": "python bugs"}', + ), + ), + ) + + messages_missing_tool_calls = [ + {"role": "user", "content": "Search for python bugs"}, + {"role": "assistant", "content": None, "tool_calls": []}, + {"role": "tool", "content": "Found 5 results", "tool_call_id": tool_call_id}, + ] + + try: + fixed_messages = LiteLLMCompletionResponsesConfig._ensure_tool_results_have_corresponding_tool_calls( + messages=messages_missing_tool_calls, + tools=None, + ) + finally: + TOOL_CALLS_CACHE.delete_cache(key=tool_call_id) + + assistant_msg = fixed_messages[1] + tool_calls = assistant_msg.get("tool_calls", []) + assert len(tool_calls) == 1 + + tool_call = tool_calls[0] + function = tool_call.get("function", {}) + assert function.get("name") == "search_web" + assert function.get("arguments") == '{"query": "python bugs"}' + + def test_ensure_tool_results_preserves_cached_attr_object_tool_call(self): + """ + Test cached attribute-only tool call objects are normalized correctly. + """ + + class AttrOnlyFunction: + def __init__(self, name: str, arguments: str): + self.name = name + self.arguments = arguments + + class AttrOnlyToolCall: + def __init__(self, id: str, type: str, function: AttrOnlyFunction): + self.id = id + self.type = type + self.function = function + + tool_call_id = "call_cached_attr_object" + TOOL_CALLS_CACHE.set_cache( + key=tool_call_id, + value=AttrOnlyToolCall( + id=tool_call_id, + type="function", + function=AttrOnlyFunction( + name="search_web", + arguments='{"query": "attribute objects"}', + ), + ), + ) + + messages_missing_tool_calls = [ + {"role": "user", "content": "Search using attr object"}, + {"role": "assistant", "content": None, "tool_calls": []}, + {"role": "tool", "content": "Found 3 results", "tool_call_id": tool_call_id}, + ] + + try: + fixed_messages = LiteLLMCompletionResponsesConfig._ensure_tool_results_have_corresponding_tool_calls( + messages=messages_missing_tool_calls, + tools=None, + ) + finally: + TOOL_CALLS_CACHE.delete_cache(key=tool_call_id) + + assistant_msg = fixed_messages[1] + tool_calls = assistant_msg.get("tool_calls", []) + assert len(tool_calls) == 1 + + tool_call = tool_calls[0] + function = tool_call.get("function", {}) + assert function.get("name") == "search_web" + assert function.get("arguments") == '{"query": "attribute objects"}' + class TestToolChoiceTransformation: """Test the tool_choice transformation fix for Cursor IDE bug""" @@ -1678,4 +1773,4 @@ class TestStreamingIDConsistency: # Verify it matches the cached ID assert iterator._cached_item_id is not None - assert iterator._cached_item_id == text_done_id \ No newline at end of file + assert iterator._cached_item_id == text_done_id diff --git a/ui/litellm-dashboard/src/components/team/team_info.tsx b/ui/litellm-dashboard/src/components/team/team_info.tsx new file mode 100644 index 0000000000..014f8fb901 --- /dev/null +++ b/ui/litellm-dashboard/src/components/team/team_info.tsx @@ -0,0 +1,1296 @@ +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import UserSearchModal from "@/components/common_components/user_search_modal"; +import { + getGuardrailsList, + getPoliciesList, + getPolicyInfoWithGuardrails, + Member, + Organization, + organizationInfoCall, + teamInfoCall, + teamMemberAddCall, + teamMemberDeleteCall, + teamMemberUpdateCall, + teamUpdateCall, +} from "@/components/networking"; +import { formatNumberWithCommas } from "@/utils/dataUtils"; +import { mapEmptyStringToNull } from "@/utils/keyUpdateUtils"; +import { isProxyAdminRole } from "@/utils/roles"; +import { InfoCircleOutlined } from "@ant-design/icons"; +import { ArrowLeftIcon } from "@heroicons/react/outline"; +import { + Badge, + Card, + Grid, + Tab, + TabGroup, + TabList, + TabPanel, + TabPanels, + Text, + TextInput, + Title, + Button as TremorButton, +} from "@tremor/react"; +import { Button, Form, Input, message, Select, Switch, Tooltip } from "antd"; +import { CheckIcon, CopyIcon } from "lucide-react"; +import React, { useEffect, useMemo, useState } from "react"; +import { copyToClipboard as utilCopyToClipboard } from "../../utils/dataUtils"; +import AgentSelector from "../agent_management/AgentSelector"; +import DeleteResourceModal from "../common_components/DeleteResourceModal"; +import DurationSelect from "../common_components/DurationSelect"; +import PassThroughRoutesSelector from "../common_components/PassThroughRoutesSelector"; +import { unfurlWildcardModelsInList } from "../key_team_helpers/fetch_available_models_team_key"; +import LoggingSettingsView from "../logging_settings_view"; +import MCPServerSelector from "../mcp_server_management/MCPServerSelector"; +import MCPToolPermissions from "../mcp_server_management/MCPToolPermissions"; +import { ModelSelect } from "../ModelSelect/ModelSelect"; +import NotificationsManager from "../molecules/notifications_manager"; +import { fetchMCPAccessGroups } from "../networking"; +import ObjectPermissionsView from "../object_permissions_view"; +import NumericalInput from "../shared/numerical_input"; +import VectorStoreSelector from "../vector_store_management/VectorStoreSelector"; +import EditLoggingSettings from "./EditLoggingSettings"; +import MemberModal from "./EditMembership"; +import MemberPermissions from "./member_permissions"; +import TeamMembersComponent from "./team_member_view"; + +export interface TeamMembership { + user_id: string; + team_id: string; + budget_id: string; + spend: number; + litellm_budget_table: { + budget_id: string; + soft_budget: number | null; + max_budget: number | null; + max_parallel_requests: number | null; + tpm_limit: number | null; + rpm_limit: number | null; + model_max_budget: Record | null; + budget_duration: string | null; + }; +} + +export interface TeamData { + team_id: string; + team_info: { + team_alias: string; + team_id: string; + organization_id: string | null; + admins: string[]; + members: string[]; + members_with_roles: Member[]; + metadata: Record; + tpm_limit: number | null; + rpm_limit: number | null; + max_budget: number | null; + soft_budget?: number | null; + budget_duration: string | null; + models: string[]; + blocked: boolean; + spend: number; + max_parallel_requests: number | null; + budget_reset_at: string | null; + model_id: string | null; + litellm_model_table: { + model_aliases: Record; + } | null; + created_at: string; + guardrails?: string[]; + policies?: string[]; + object_permission?: { + object_permission_id: string; + mcp_servers: string[]; + mcp_access_groups?: string[]; + mcp_tool_permissions?: Record; + vector_stores: string[]; + agents?: string[]; + agent_access_groups?: string[]; + }; + team_member_budget_table: { + max_budget: number; + budget_duration: string; + tpm_limit: number | null; + rpm_limit: number | null; + } | null; + }; + keys: any[]; + team_memberships: TeamMembership[]; +} + +export interface TeamInfoProps { + teamId: string; + onUpdate: (data: any) => void; + onClose: () => void; + accessToken: string | null; + is_team_admin: boolean; + is_proxy_admin: boolean; + userModels: string[]; + editTeam: boolean; + premiumUser?: boolean; +} + +const getOrganizationModels = (organization: Organization | null, userModels: string[]) => { + let tempModelsToPick = []; + + if (organization) { + // Check if organization has "all-proxy-models" in its models array + if (organization.models.includes("all-proxy-models")) { + // Treat as all-proxy-models (use userModels) + tempModelsToPick = userModels; + } else if (organization.models.length > 0) { + // Organization has specific models + tempModelsToPick = organization.models; + } else { + // Empty array [] is treated as all-proxy-models + tempModelsToPick = userModels; + } + } else { + // No organization, show all available models + tempModelsToPick = userModels; + } + + return unfurlWildcardModelsInList(tempModelsToPick, userModels); +}; + +const TeamInfoView: React.FC = ({ + teamId, + onClose, + accessToken, + is_team_admin, + is_proxy_admin, + userModels, + editTeam, + premiumUser = false, + onUpdate, +}) => { + const [teamData, setTeamData] = useState(null); + const [loading, setLoading] = useState(true); + const [isAddMemberModalVisible, setIsAddMemberModalVisible] = useState(false); + const [form] = Form.useForm(); + const [isEditMemberModalVisible, setIsEditMemberModalVisible] = useState(false); + const [selectedEditMember, setSelectedEditMember] = useState(null); + const [isEditing, setIsEditing] = useState(false); + const [mcpAccessGroups, setMcpAccessGroups] = useState([]); + const [mcpAccessGroupsLoaded, setMcpAccessGroupsLoaded] = useState(false); + const [copiedStates, setCopiedStates] = useState>({}); + const [guardrailsList, setGuardrailsList] = useState([]); + const [policiesList, setPoliciesList] = useState([]); + const [policyGuardrails, setPolicyGuardrails] = useState>({}); + const [loadingPolicies, setLoadingPolicies] = useState(false); + const [memberToDelete, setMemberToDelete] = useState(null); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + const [isTeamSaving, setIsTeamSaving] = useState(false); + const [organization, setOrganization] = useState(null); + const { userRole } = useAuthorized(); + + const canEditTeam = is_team_admin || is_proxy_admin; + + const fetchTeamInfo = async () => { + try { + setLoading(true); + if (!accessToken) return; + const response = await teamInfoCall(accessToken, teamId); + setTeamData(response); + } catch (error) { + NotificationsManager.fromBackend("Failed to load team information"); + console.error("Error fetching team info:", error); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchTeamInfo(); + }, [teamId, accessToken]); + + // Fetch organization data when team has organization_id + useEffect(() => { + const fetchOrganization = async () => { + if (!accessToken || !teamData?.team_info?.organization_id) { + setOrganization(null); + return; + } + + try { + const orgData = await organizationInfoCall(accessToken, teamData.team_info.organization_id); + setOrganization(orgData); + } catch (error) { + console.error("Error fetching organization info:", error); + setOrganization(null); + } + }; + + fetchOrganization(); + }, [accessToken, teamData?.team_info?.organization_id]); + + // Compute modelsToPick based on organization and userModels + const modelsToPick = useMemo(() => { + return getOrganizationModels(organization, userModels); + }, [organization, userModels]); + + const fetchMcpAccessGroups = async () => { + if (!accessToken) return; + if (mcpAccessGroupsLoaded) return; + try { + const groups = await fetchMCPAccessGroups(accessToken); + setMcpAccessGroups(groups); + setMcpAccessGroupsLoaded(true); + } catch (error) { + console.error("Failed to fetch MCP access groups:", error); + } + }; + + useEffect(() => { + const fetchGuardrails = async () => { + try { + if (!accessToken) return; + const response = await getGuardrailsList(accessToken); + const guardrailNames = response.guardrails.map((g: { guardrail_name: string }) => g.guardrail_name); + setGuardrailsList(guardrailNames); + } catch (error) { + console.error("Failed to fetch guardrails:", error); + } + }; + + const fetchPolicies = async () => { + try { + if (!accessToken) return; + const response = await getPoliciesList(accessToken); + const policyNames = response.policies.map((p: { policy_name: string }) => p.policy_name); + setPoliciesList(policyNames); + } catch (error) { + console.error("Failed to fetch policies:", error); + } + }; + + fetchGuardrails(); + fetchPolicies(); + }, [accessToken]); + + // Fetch resolved guardrails for all policies + useEffect(() => { + const fetchPolicyGuardrails = async () => { + if (!accessToken || !teamData?.team_info?.policies || teamData.team_info.policies.length === 0) { + return; + } + + setLoadingPolicies(true); + const guardrailsMap: Record = {}; + + try { + await Promise.all( + teamData.team_info.policies.map(async (policyName: string) => { + try { + const policyInfo = await getPolicyInfoWithGuardrails(accessToken, policyName); + guardrailsMap[policyName] = policyInfo.resolved_guardrails || []; + } catch (error) { + console.error(`Failed to fetch guardrails for policy ${policyName}:`, error); + guardrailsMap[policyName] = []; + } + }) + ); + setPolicyGuardrails(guardrailsMap); + } catch (error) { + console.error("Failed to fetch policy guardrails:", error); + } finally { + setLoadingPolicies(false); + } + }; + + fetchPolicyGuardrails(); + }, [accessToken, teamData?.team_info?.policies]); + + const handleMemberCreate = async (values: any) => { + try { + if (accessToken == null) return; + + const member: Member = { + user_email: values.user_email, + user_id: values.user_id, + role: values.role, + }; + + await teamMemberAddCall(accessToken, teamId, member); + + NotificationsManager.success("Team member added successfully"); + setIsAddMemberModalVisible(false); + form.resetFields(); + + // Fetch updated team info + const updatedTeamData = await teamInfoCall(accessToken, teamId); + setTeamData(updatedTeamData); + + // Notify parent component of the update + onUpdate(updatedTeamData); + } catch (error: any) { + let errMsg = "Failed to add team member"; + + if (error?.raw?.detail?.error?.includes("Assigning team admins is a premium feature")) { + errMsg = "Assigning admins is an enterprise-only feature. Please upgrade your LiteLLM plan to enable this."; + } else if (error?.message) { + errMsg = error.message; + } + + NotificationsManager.fromBackend(errMsg); + console.error("Error adding team member:", error); + } + }; + + const handleMemberUpdate = async (values: any) => { + try { + if (accessToken == null) { + return; + } + + const member: Member = { + user_email: values.user_email, + user_id: values.user_id, + role: values.role, + max_budget_in_team: values.max_budget_in_team, + tpm_limit: values.tpm_limit, + rpm_limit: values.rpm_limit, + }; + console.log("Updating member with values:", member); + message.destroy(); // Remove all existing toasts + + await teamMemberUpdateCall(accessToken, teamId, member); + + NotificationsManager.success("Team member updated successfully"); + setIsEditMemberModalVisible(false); + + // Fetch updated team info + const updatedTeamData = await teamInfoCall(accessToken, teamId); + setTeamData(updatedTeamData); + + // Notify parent component of the update + onUpdate(updatedTeamData); + } catch (error: any) { + let errMsg = "Failed to update team member"; + if (error?.raw?.detail?.includes("Assigning team admins is a premium feature")) { + errMsg = "Assigning admins is an enterprise-only feature. Please upgrade your LiteLLM plan to enable this."; + } else if (error?.message) { + errMsg = error.message; + } + setIsEditMemberModalVisible(false); + + message.destroy(); // Remove all existing toasts + + NotificationsManager.fromBackend(errMsg); + console.error("Error updating team member:", error); + } + }; + + const handleMemberDelete = (member: Member) => { + setMemberToDelete(member); + setIsDeleteModalOpen(true); + }; + + const handleDeleteConfirm = async () => { + if (!memberToDelete || !accessToken) return; + + setIsDeleting(true); + try { + await teamMemberDeleteCall(accessToken, teamId, memberToDelete); + + NotificationsManager.success("Team member removed successfully"); + + // Fetch updated team info + const updatedTeamData = await teamInfoCall(accessToken, teamId); + setTeamData(updatedTeamData); + + // Notify parent component of the update + onUpdate(updatedTeamData); + } catch (error) { + NotificationsManager.fromBackend("Failed to remove team member"); + console.error("Error removing team member:", error); + } finally { + setIsDeleting(false); + setIsDeleteModalOpen(false); + setMemberToDelete(null); + } + }; + + const handleDeleteCancel = () => { + setIsDeleteModalOpen(false); + setMemberToDelete(null); + }; + + const handleTeamUpdate = async (values: any) => { + try { + if (!accessToken) return; + setIsTeamSaving(true); + + let parsedMetadata = {}; + 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; + parsedMetadata = rest; + } catch (e) { + NotificationsManager.fromBackend("Invalid JSON in metadata field"); + return; + } + + let secretManagerSettings: Record | undefined; + if (typeof values.secret_manager_settings === "string") { + const trimmedSecretConfig = values.secret_manager_settings.trim(); + if (trimmedSecretConfig.length > 0) { + try { + secretManagerSettings = JSON.parse(values.secret_manager_settings); + } catch (e) { + NotificationsManager.fromBackend("Invalid JSON in secret manager settings"); + return; + } + } + } + + const sanitizeNumeric = (v: any) => { + if (v === null || v === undefined) return null; + if (typeof v === "string" && v.trim() === "") return null; + if (typeof v === "number" && Number.isNaN(v)) return null; + return v; + }; + + const updateData: any = { + team_id: teamId, + team_alias: values.team_alias, + models: values.models, + tpm_limit: sanitizeNumeric(values.tpm_limit), + rpm_limit: sanitizeNumeric(values.rpm_limit), + max_budget: values.max_budget, + soft_budget: sanitizeNumeric(values.soft_budget), + budget_duration: values.budget_duration, + metadata: { + ...parsedMetadata, + ...(values.guardrails?.length > 0 ? { guardrails: values.guardrails } : {}), + ...(values.logging_settings?.length > 0 ? { logging: values.logging_settings } : {}), + disable_global_guardrails: values.disable_global_guardrails || false, + soft_budget_alerting_emails: + typeof values.soft_budget_alerting_emails === "string" + ? values.soft_budget_alerting_emails + .split(",") + .map((email: string) => email.trim()) + .filter((email: string) => email.length > 0) + : values.soft_budget_alerting_emails || [], + ...(secretManagerSettings !== undefined ? { secret_manager_settings: secretManagerSettings } : {}), + }, + ...(values.policies?.length > 0 ? { policies: values.policies } : {}), + organization_id: values.organization_id, + }; + + updateData.max_budget = mapEmptyStringToNull(updateData.max_budget); + updateData.team_member_budget_duration = values.team_member_budget_duration; + + if (values.team_member_budget !== undefined) { + updateData.team_member_budget = Number(values.team_member_budget); + } + + if (values.team_member_key_duration !== undefined) { + updateData.team_member_key_duration = values.team_member_key_duration; + } + + if (values.team_member_tpm_limit !== undefined || values.team_member_rpm_limit !== undefined) { + updateData.team_member_tpm_limit = sanitizeNumeric(values.team_member_tpm_limit); + updateData.team_member_rpm_limit = sanitizeNumeric(values.team_member_rpm_limit); + } + + // Handle object_permission updates + const { servers, accessGroups } = values.mcp_servers_and_groups || { + servers: [], + accessGroups: [], + }; + const serverIds = new Set(servers || []); + const mcpToolPermissions = Object.fromEntries( + Object.entries(values.mcp_tool_permissions || {}).filter(([serverId]) => serverIds.has(serverId)), + ); + + updateData.object_permission = {}; + if (servers) { + updateData.object_permission.mcp_servers = servers; + } + if (accessGroups) { + updateData.object_permission.mcp_access_groups = accessGroups; + } + if (mcpToolPermissions) { + updateData.object_permission.mcp_tool_permissions = mcpToolPermissions; + } + delete values.mcp_servers_and_groups; + delete values.mcp_tool_permissions; + + // Handle agent permissions + const { agents, accessGroups: agentAccessGroups } = values.agents_and_groups || { + agents: [], + accessGroups: [], + }; + if (agents && agents.length > 0) { + updateData.object_permission.agents = agents; + } + if (agentAccessGroups && agentAccessGroups.length > 0) { + updateData.object_permission.agent_access_groups = agentAccessGroups; + } + delete values.agents_and_groups; + + // Handle vector stores permissions + if (values.vector_stores && values.vector_stores.length > 0) { + updateData.object_permission.vector_stores = values.vector_stores; + } + + const response = await teamUpdateCall(accessToken, updateData); + + NotificationsManager.success("Team settings updated successfully"); + setIsEditing(false); + fetchTeamInfo(); + } catch (error) { + console.error("Error updating team:", error); + } finally { + setIsTeamSaving(false); + } + }; + + if (loading) { + return
Loading...
; + } + + if (!teamData?.team_info) { + return
Team not found
; + } + + const { team_info: info } = teamData; + + const copyToClipboard = async (text: string, key: string) => { + const success = await utilCopyToClipboard(text); + if (success) { + setCopiedStates((prev) => ({ ...prev, [key]: true })); + setTimeout(() => { + setCopiedStates((prev) => ({ ...prev, [key]: false })); + }, 2000); + } + }; + + return ( +
+
+
+ + Back to Teams + + {info.team_alias} +
+ {info.team_id} +
+
+
+ + + + {[ + Overview, + ...(canEditTeam + ? [ + Members, + Member Permissions, + Settings, + ] + : []), + ]} + + + + {/* Overview Panel */} + + + + Budget Status +
+ ${formatNumberWithCommas(info.spend, 4)} + + of {info.max_budget === null ? "Unlimited" : `$${formatNumberWithCommas(info.max_budget, 4)}`} + + {info.budget_duration && Reset: {info.budget_duration}} +
+ {info.team_member_budget_table && ( + + Team Member Budget: ${formatNumberWithCommas(info.team_member_budget_table.max_budget, 4)} + + )} +
+
+ + + Rate Limits +
+ TPM: {info.tpm_limit || "Unlimited"} + RPM: {info.rpm_limit || "Unlimited"} + {info.max_parallel_requests && Max Parallel Requests: {info.max_parallel_requests}} +
+
+ + + Models +
+ {info.models.length === 0 ? ( + All proxy models + ) : ( + info.models.map((model, index) => ( + + {model} + + )) + )} +
+
+ + + Virtual Keys +
+ User Keys: {teamData.keys.filter((key) => key.user_id).length} + Service Account Keys: {teamData.keys.filter((key) => !key.user_id).length} + Total: {teamData.keys.length} +
+
+ + + + + Guardrails + {info.guardrails && info.guardrails.length > 0 ? ( +
+ {info.guardrails.map((guardrail: string, index: number) => ( + + {guardrail} + + ))} +
+ ) : ( + No guardrails configured + )} + {info.metadata?.disable_global_guardrails && ( +
+ Global Guardrails Disabled +
+ )} +
+ + + Policies + {info.policies && info.policies.length > 0 ? ( +
+ {info.policies.map((policy: string, index: number) => ( +
+
+ {policy} + {loadingPolicies && Loading guardrails...} +
+ {!loadingPolicies && policyGuardrails[policy] && policyGuardrails[policy].length > 0 && ( +
+ Resolved Guardrails: +
+ {policyGuardrails[policy].map((guardrail: string, gIndex: number) => ( + + {guardrail} + + ))} +
+
+ )} +
+ ))} +
+ ) : ( + No policies configured + )} +
+ + +
+
+ + {/* Members Panel */} + + + + + {/* Member Permissions Panel */} + {canEditTeam && ( + + + + )} + + {/* Settings Panel */} + + +
+ Team Settings + {canEditTeam && !isEditing && ( + setIsEditing(true)}>Edit Settings + )} +
+ + {isEditing ? ( +
rest)(info.metadata), + null, + 2, + ) + : "", + logging_settings: info.metadata?.logging || [], + secret_manager_settings: info.metadata?.secret_manager_settings + ? JSON.stringify(info.metadata.secret_manager_settings, null, 2) + : "", + organization_id: info.organization_id, + vector_stores: info.object_permission?.vector_stores || [], + mcp_servers: info.object_permission?.mcp_servers || [], + mcp_access_groups: info.object_permission?.mcp_access_groups || [], + mcp_servers_and_groups: { + servers: info.object_permission?.mcp_servers || [], + accessGroups: info.object_permission?.mcp_access_groups || [], + }, + mcp_tool_permissions: info.object_permission?.mcp_tool_permissions || {}, + agents_and_groups: { + agents: info.object_permission?.agents || [], + accessGroups: info.object_permission?.agent_access_groups || [], + }, + }} + layout="vertical" + > + + + + + + form.setFieldValue("models", values)} + teamID={teamId} + organizationID={teamData?.team_info?.organization_id || undefined} + options={{ + includeSpecialOptions: true, + includeUserModels: !teamData?.team_info?.organization_id, + showAllProxyModelsOverride: isProxyAdminRole(userRole) && !teamData?.team_info?.organization_id, + }} + context="team" + dataTestId="models-select" + /> + + + + + + + + + + + + + + + + + + + + form.setFieldValue("team_member_budget_duration", value)} + value={form.getFieldValue("team_member_budget_duration")} + /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + Guardrails{" "} + + e.stopPropagation()} + > + + + + + } + name="guardrails" + help="Select existing guardrails or enter new ones" + > + ({ value: name, label: name }))} + /> + + + + form.setFieldValue("vector_stores", values)} + value={form.getFieldValue("vector_stores")} + accessToken={accessToken || ""} + placeholder="Select vector stores" + /> + + + + form.setFieldValue("allowed_passthrough_routes", values)} + value={form.getFieldValue("allowed_passthrough_routes")} + accessToken={accessToken || ""} + placeholder="Select pass through routes" + /> + + + + form.setFieldValue("mcp_servers_and_groups", val)} + value={form.getFieldValue("mcp_servers_and_groups")} + accessToken={accessToken || ""} + placeholder="Select MCP servers or access groups (optional)" + /> + + + {/* Hidden field to register mcp_tool_permissions with the form */} + + + + prevValues.mcp_servers_and_groups !== currentValues.mcp_servers_and_groups || + prevValues.mcp_tool_permissions !== currentValues.mcp_tool_permissions + } + > + {() => ( +
+ form.setFieldsValue({ mcp_tool_permissions: toolPerms })} + /> +
+ )} +
+ + + form.setFieldValue("agents_and_groups", val)} + value={form.getFieldValue("agents_and_groups")} + accessToken={accessToken || ""} + placeholder="Select agents or access groups (optional)" + /> + + + + + + + + form.setFieldValue("logging_settings", values)} + /> + + + { + if (!value) { + return Promise.resolve(); + } + try { + JSON.parse(value); + return Promise.resolve(); + } catch (error) { + return Promise.reject(new Error("Please enter valid JSON")); + } + }, + }, + ]} + > + + + + + + + +
+
+ setIsEditing(false)} disabled={isTeamSaving}> + Cancel + + + Save Changes + +
+
+
+ ) : ( +
+
+ Team Name +
{info.team_alias}
+
+
+ Team ID +
{info.team_id}
+
+
+ Created At +
{new Date(info.created_at).toLocaleString()}
+
+
+ Models +
+ {info.models.map((model, index) => ( + + {model} + + ))} +
+
+
+ Rate Limits +
TPM: {info.tpm_limit || "Unlimited"}
+
RPM: {info.rpm_limit || "Unlimited"}
+
+
+ Team Budget +
+ Max Budget:{" "} + {info.max_budget !== null ? `$${formatNumberWithCommas(info.max_budget, 4)}` : "No Limit"} +
+
+ Soft Budget:{" "} + {info.soft_budget !== null && info.soft_budget !== undefined + ? `$${formatNumberWithCommas(info.soft_budget, 4)}` + : "No Limit"} +
+
Budget Reset: {info.budget_duration || "Never"}
+ {info.metadata?.soft_budget_alerting_emails && + Array.isArray(info.metadata.soft_budget_alerting_emails) && + info.metadata.soft_budget_alerting_emails.length > 0 && ( +
+ Soft Budget Alerting Emails: {info.metadata.soft_budget_alerting_emails.join(", ")} +
+ )} +
+
+ + Team Member Settings{" "} + + + + +
Max Budget: {info.team_member_budget_table?.max_budget || "No Limit"}
+
Budget Duration: {info.team_member_budget_table?.budget_duration || "No Limit"}
+
Key Duration: {info.metadata?.team_member_key_duration || "No Limit"}
+
TPM Limit: {info.team_member_budget_table?.tpm_limit || "No Limit"}
+
RPM Limit: {info.team_member_budget_table?.rpm_limit || "No Limit"}
+
+
+ Organization ID +
{info.organization_id}
+
+
+ Status + {info.blocked ? "Blocked" : "Active"} +
+ +
+ Disable Global Guardrails +
+ {info.metadata?.disable_global_guardrails === true ? ( + Enabled - Global guardrails bypassed + ) : ( + Disabled - Global guardrails active + )} +
+
+ + + + + + {info.metadata?.secret_manager_settings && ( +
+ Secret Manager Settings +
+                        {JSON.stringify(info.metadata.secret_manager_settings, null, 2)}
+                      
+
+ )} +
+ )} +
+
+
+
+ + setIsEditMemberModalVisible(false)} + onSubmit={handleMemberUpdate} + initialData={selectedEditMember} + mode="edit" + config={{ + title: "Edit Member", + showEmail: true, + showUserId: true, + roleOptions: [ + { label: "Admin", value: "admin" }, + { label: "User", value: "user" }, + ], + additionalFields: [ + { + name: "max_budget_in_team", + label: ( + + Team Member Budget (USD){" "} + + + + + ), + type: "numerical" as const, + step: 0.01, + min: 0, + placeholder: "Budget limit for this member within this team", + }, + { + name: "tpm_limit", + label: ( + + Team Member TPM Limit{" "} + + + + + ), + type: "numerical" as const, + step: 1, + min: 0, + placeholder: "Tokens per minute limit for this member in this team", + }, + { + name: "rpm_limit", + label: ( + + Team Member RPM Limit{" "} + + + + + ), + type: "numerical" as const, + step: 1, + min: 0, + placeholder: "Requests per minute limit for this member in this team", + }, + ], + }} + /> + + setIsAddMemberModalVisible(false)} + onSubmit={handleMemberCreate} + accessToken={accessToken} + /> + + {/* Delete Member Confirmation Modal */} + +
+ ); +}; + +export default TeamInfoView;