Merge branch 'main' into litellm_responses_structured_output

This commit is contained in:
Krish Dholakia 2025-09-04 12:23:26 -07:00 committed by GitHub
commit 94c1b21ae7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 1124 additions and 203 deletions

Binary file not shown.

View file

@ -31,30 +31,26 @@ class BasePassthroughConfig(BaseLLMModelInfo):
Args:
endpoint: str - the endpoint to add to the url
base_target_url: str - the base url to add the endpoint to
request_query_params: dict - the query params to add to the url
request_query_params: Optional[dict] - the query params to add to the url
Returns:
str - the formatted url
httpx.URL - the formatted url
"""
from urllib.parse import urlencode
import httpx
encoded_endpoint = httpx.URL(endpoint).path
base = base_target_url.rstrip('/')
endpoint = endpoint.lstrip('/')
full_url = f"{base}/{endpoint}"
# Ensure endpoint starts with '/' for proper URL construction
if not encoded_endpoint.startswith("/"):
encoded_endpoint = "/" + encoded_endpoint
# Construct the full target URL using httpx
base_url = httpx.URL(base_target_url)
updated_url = base_url.copy_with(path=encoded_endpoint)
url = httpx.URL(full_url)
if request_query_params:
# Create a new URL with the merged query params
updated_url = updated_url.copy_with(
url = url.copy_with(
query=urlencode(request_query_params).encode("ascii")
)
return updated_url
return url
@abstractmethod
def get_complete_url(

View file

@ -10,6 +10,7 @@ from typing import List, Literal, Optional, Tuple, Union, cast, overload
import httpx
import litellm
from litellm.constants import RESPONSE_FORMAT_TOOL_NAME
from litellm.litellm_core_utils.core_helpers import map_finish_reason
from litellm.litellm_core_utils.litellm_logging import Logging
from litellm.litellm_core_utils.llm_response_utils.convert_dict_to_response import (
@ -322,7 +323,6 @@ class AmazonConverseConfig(BaseConfig):
def _create_json_tool_call_for_response_format(
self,
json_schema: Optional[dict] = None,
schema_name: str = "json_tool_call",
description: Optional[str] = None,
) -> ChatCompletionToolParam:
"""
@ -347,7 +347,7 @@ class AmazonConverseConfig(BaseConfig):
_input_schema = json_schema
tool_param_function_chunk = ChatCompletionToolParamFunctionChunk(
name=schema_name, parameters=_input_schema
name=RESPONSE_FORMAT_TOOL_NAME, parameters=_input_schema
)
if description:
tool_param_function_chunk["description"] = description
@ -391,14 +391,11 @@ class AmazonConverseConfig(BaseConfig):
continue
json_schema: Optional[dict] = None
schema_name: str = ""
description: Optional[str] = None
if "response_schema" in value:
json_schema = value["response_schema"]
schema_name = "json_tool_call"
elif "json_schema" in value:
json_schema = value["json_schema"]["schema"]
schema_name = value["json_schema"]["name"]
description = value["json_schema"].get("description")
if "type" in value and value["type"] == "text":
@ -414,7 +411,6 @@ class AmazonConverseConfig(BaseConfig):
"""
_tool = self._create_json_tool_call_for_response_format(
json_schema=json_schema,
schema_name=schema_name if schema_name != "" else "json_tool_call",
description=description,
)
optional_params = self._add_tools_to_optional_params(
@ -430,7 +426,7 @@ class AmazonConverseConfig(BaseConfig):
optional_params["tool_choice"] = ToolChoiceValuesBlock(
tool=SpecificToolChoiceBlock(
name=schema_name if schema_name != "" else "json_tool_call"
name=RESPONSE_FORMAT_TOOL_NAME
)
)
optional_params["json_mode"] = True
@ -1119,8 +1115,7 @@ class AmazonConverseConfig(BaseConfig):
self._transform_thinking_blocks(reasoningContentBlocks)
)
chat_completion_message["content"] = content_str
if json_mode is True and tools is not None and len(tools) == 1:
# to support 'json_schema' logic on bedrock models
if json_mode is True and tools is not None and len(tools) == 1 and tools[0]["function"]["name"] == RESPONSE_FORMAT_TOOL_NAME:
json_mode_content_str: Optional[str] = tools[0]["function"].get("arguments")
if json_mode_content_str is not None:
chat_completion_message["content"] = json_mode_content_str

View file

@ -41,9 +41,15 @@ class BedrockPassthroughConfig(
model_id=None,
)
api_base = f"https://bedrock-runtime.{aws_region_name}.amazonaws.com"
aws_bedrock_runtime_endpoint = optional_params.get("aws_bedrock_runtime_endpoint")
endpoint_url, _ = self.get_runtime_endpoint(
api_base=api_base,
aws_bedrock_runtime_endpoint=aws_bedrock_runtime_endpoint,
aws_region_name=aws_region_name,
endpoint_type="runtime",
)
return self.format_url(endpoint, api_base, request_query_params or {}), api_base
return self.format_url(endpoint, endpoint_url, request_query_params or {}), endpoint_url
def sign_request(
self,

View file

@ -1,6 +1,15 @@
from typing import TYPE_CHECKING, Any, Dict, Optional, Union, cast, get_type_hints
from typing import (
TYPE_CHECKING,
Any,
Dict,
Optional,
Union,
cast,
get_type_hints,
)
import httpx
from openai.types.responses import ResponseReasoningItem
from pydantic import BaseModel
import litellm
@ -92,12 +101,67 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig):
# if it's pydantic, convert to dict
if isinstance(item, BaseModel):
validated_input.append(item.model_dump(exclude_none=True))
elif isinstance(item, dict):
# Handle reasoning items specifically to filter out status=None
verbose_logger.debug(f"Handling reasoning item: {item}")
if item.get("type") == "reasoning":
# Type assertion since we know it's a dict at this point
dict_item = cast(Dict[str, Any], item)
filtered_item = self._handle_reasoning_item(dict_item)
else:
# For other dict items, just pass through
filtered_item = cast(Dict[str, Any], item)
validated_input.append(filtered_item)
else:
validated_input.append(item)
return validated_input
return validated_input # type: ignore
# Input is expected to be either str or List, no single BaseModel expected
return input
def _handle_reasoning_item(self, item: Dict[str, Any]) -> Dict[str, Any]:
"""
Handle reasoning items specifically to filter out status=None using OpenAI's model.
Issue: https://github.com/BerriAI/litellm/issues/13484
OpenAI API does not accept ReasoningItem(status=None), so we need to:
1. Check if the item is a reasoning type
2. Create a ResponseReasoningItem object with the item data
3. Convert it back to dict with exclude_none=True to filter None values
"""
verbose_logger.debug(f"Handling reasoning item: {item}")
if item.get("type") == "reasoning":
try:
# Ensure required fields are present for ResponseReasoningItem
item_data = dict(item)
if "id" not in item_data:
item_data["id"] = f"reasoning_{hash(str(item_data))}"
if "summary" not in item_data:
item_data["summary"] = (
item_data.get("reasoning_content", "")[:100] + "..."
if len(item_data.get("reasoning_content", "")) > 100
else item_data.get("reasoning_content", "")
)
# Create ResponseReasoningItem object from the item data
reasoning_item = ResponseReasoningItem(**item_data)
# Convert back to dict with exclude_none=True to exclude None fields
dict_reasoning_item = reasoning_item.model_dump(exclude_none=True)
return dict_reasoning_item
except Exception as e:
verbose_logger.debug(
f"Failed to create ResponseReasoningItem, falling back to manual filtering: {e}"
)
# Fallback: manually filter out known None fields
filtered_item = {
k: v
for k, v in item.items()
if v is not None
or k not in {"status", "content", "encrypted_content"}
}
return filtered_item
return item
def transform_response_api_response(
self,
model: str,

View file

@ -662,3 +662,58 @@ class BaseResponsesAPITest(ABC):
# Validate the response
print("Response:", json.dumps(response, indent=4, default=str))
def test_openai_responses_api_dict_input_filtering(self):
"""
Test that regular dict inputs with status fields are properly filtered
to replicate exclude_unset=True behavior for non-Pydantic objects.
"""
from litellm.llms.openai.responses.transformation import OpenAIResponsesAPIConfig
# Test input with regular dict objects (like from JSON)
test_input = [
{
"role": "user",
"content": "test"
},
{
"id": "rs_123",
"summary": [{"text": "test", "type": "summary_text"}],
"type": "reasoning",
"content": None, # Should be filtered out
"encrypted_content": None, # Should be filtered out
"status": None # Should be filtered out
},
{
"arguments": "{}",
"call_id": "call_123",
"name": "get_today",
"type": "function_call",
"id": "fc_123",
"status": "completed" # Should be preserved (not a default field)
}
]
config = OpenAIResponsesAPIConfig()
validated_input = config._validate_input_param(test_input)
# Verify the results
assert len(validated_input) == 3
# Check reasoning item (index 1)
reasoning_item = validated_input[1]
assert reasoning_item["type"] == "reasoning"
assert "status" not in reasoning_item, "status field should be filtered out from reasoning item"
assert "content" not in reasoning_item, "content field should be filtered out from reasoning item"
assert "encrypted_content" not in reasoning_item, "encrypted_content field should be filtered out from reasoning item"
assert "id" in reasoning_item, "id field should be preserved"
assert "summary" in reasoning_item, "summary field should be preserved"
# Check function call item (index 2)
function_call_item = validated_input[2]
assert function_call_item["type"] == "function_call"
assert "status" in function_call_item, "status field should be preserved in function call item"
assert function_call_item["status"] == "completed", "status value should be preserved"
print("✅ OpenAI Responses API dict input filtering test passed")

View file

@ -665,7 +665,6 @@ async def test_openai_gpt5_reasoning():
print("response: ", response)
assert response.choices[0].message.content is not None
@pytest.mark.asyncio
async def test_openai_safety_identifier_parameter():
"""Test that safety_identifier parameter is correctly passed to the OpenAI API."""
@ -723,3 +722,4 @@ def test_openai_safety_identifier_parameter_sync():
assert "safety_identifier" in request_body
# Verify safety_identifier is correctly sent to the API
assert request_body["safety_identifier"] == "user_code_123456"

View file

@ -475,6 +475,239 @@ def test_transform_response_with_bash_tool():
assert args["command"] == "ls -la *.py"
def test_transform_response_with_structured_response_being_called():
"""Test response transformation with structured response."""
from litellm.llms.bedrock.chat.converse_transformation import AmazonConverseConfig
from litellm.types.utils import ModelResponse
# Simulate a Bedrock Converse response with a bash tool call
response_json = {
"additionalModelResponseFields": {},
"metrics": {"latencyMs": 100.0},
"output": {
"message": {
"role": "assistant",
"content": [
{
"toolUse": {
"toolUseId": "tooluse_456",
"name": "json_tool_call",
"input": {
"Current_Temperature": 62,
"Weather_Explanation": "San Francisco typically has mild, cool weather year-round due to its coastal location and marine influence. The city is known for its fog, moderate temperatures, and relatively stable climate with little seasonal variation."},
}
}
]
}
},
"stopReason": "tool_use",
"usage": {
"inputTokens": 8,
"outputTokens": 3,
"totalTokens": 11,
"cacheReadInputTokenCount": 0,
"cacheReadInputTokens": 0,
"cacheWriteInputTokenCount": 0,
"cacheWriteInputTokens": 0,
},
}
# Mock httpx.Response
class MockResponse:
def json(self):
return response_json
@property
def text(self):
return json.dumps(response_json)
config = AmazonConverseConfig()
model_response = ModelResponse()
optional_params = {
"json_mode": True,
"tools": [
{
'type': 'function',
'function': {
'name': 'get_weather',
'description': 'Get the current weather in a given location',
'parameters': {
'type': 'object',
'properties': {
'location': {
'type': 'string',
'description': 'The city and state, e.g. San Francisco, CA'
},
'unit': {
'type': 'string',
'enum': ['celsius', 'fahrenheit']
}
},
'required': ['location']
}
}
},
{
'type': 'function',
'function': {
'name': 'json_tool_call',
'parameters': {
'$schema': 'http://json-schema.org/draft-07/schema#',
'type': 'object',
'required': ['Weather_Explanation', 'Current_Temperature'],
'properties': {
'Weather_Explanation': {
'type': ['string', 'null'],
'description': '1-2 sentences explaining the weather in the location'
},
'Current_Temperature': {
'type': ['number', 'null'],
'description': 'Current temperature in the location'
}
},
'additionalProperties': False
}
}
}
]
}
# Call the transformation logic
result = config._transform_response(
model="bedrock/anthropic.claude-3-5-sonnet-20240620-v1:0",
response=MockResponse(),
model_response=model_response,
stream=False,
logging_obj=None,
optional_params=optional_params,
api_key=None,
data=None,
messages=[],
encoding=None,
)
# Check that the tool call is present in the returned message
assert result.choices[0].message.tool_calls is None
assert result.choices[0].message.content is not None
assert result.choices[0].message.content == '{"Current_Temperature": 62, "Weather_Explanation": "San Francisco typically has mild, cool weather year-round due to its coastal location and marine influence. The city is known for its fog, moderate temperatures, and relatively stable climate with little seasonal variation."}'
def test_transform_response_with_structured_response_calling_tool():
"""Test response transformation with structured response."""
from litellm.llms.bedrock.chat.converse_transformation import AmazonConverseConfig
from litellm.types.utils import ModelResponse
# Simulate a Bedrock Converse response with a bash tool call
response_json = {
"metrics": {
"latencyMs": 1148
},
"output": {
"message":
{
"content": [
{
"text": "I\'ll check the current weather in San Francisco for you."
},
{
"toolUse": {
"input": {
"location": "San Francisco, CA",
"unit": "celsius"
},
"name": "get_weather",
"toolUseId": "tooluse_oKk__QrqSUmufMw3Q7vGaQ"
}
}
],
"role": "assistant"
}
},
"stopReason": "tool_use",
"usage": {
"cacheReadInputTokenCount": 0,
"cacheReadInputTokens": 0,
"cacheWriteInputTokenCount": 0,
"cacheWriteInputTokens": 0,
"inputTokens": 534,
"outputTokens": 69,
"totalTokens": 603
}
}
# Mock httpx.Response
class MockResponse:
def json(self):
return response_json
@property
def text(self):
return json.dumps(response_json)
config = AmazonConverseConfig()
model_response = ModelResponse()
optional_params = {
"json_mode": True,
"tools": [
{
'type': 'function',
'function': {
'name': 'get_weather',
'description': 'Get the current weather in a given location',
'parameters': {
'type': 'object',
'properties': {
'location': {
'type': 'string',
'description': 'The city and state, e.g. San Francisco, CA'
},
'unit': {
'type': 'string',
'enum': ['celsius', 'fahrenheit']
}
},
'required': ['location']
}
}
},
{
'type': 'function',
'function': {
'name': 'json_tool_call',
'parameters': {
'$schema': 'http://json-schema.org/draft-07/schema#',
'type': 'object',
'required': ['Weather_Explanation', 'Current_Temperature'],
'properties': {
'Weather_Explanation': {
'type': ['string', 'null'],
'description': '1-2 sentences explaining the weather in the location'
},
'Current_Temperature': {
'type': ['number', 'null'],
'description': 'Current temperature in the location'
}
},
'additionalProperties': False
}
}
}
]
}
# Call the transformation logic
result = config._transform_response(
model="bedrock/eu.anthropic.claude-sonnet-4-20250514-v1:0",
response=MockResponse(),
model_response=model_response,
stream=False,
logging_obj=None,
optional_params=optional_params,
api_key=None,
data=None,
messages=[],
encoding=None,
)
# Check that the tool call is present in the returned message
assert result.choices[0].message.tool_calls is not None
assert len(result.choices[0].message.tool_calls) == 1
assert result.choices[0].message.tool_calls[0].function.name == "get_weather"
assert result.choices[0].message.tool_calls[0].function.arguments == '{"location": "San Francisco, CA", "unit": "celsius"}'
@pytest.mark.asyncio
async def test_bedrock_bash_tool_acompletion():
"""Test Bedrock with bash tool for ls command using acompletion."""
@ -938,6 +1171,68 @@ def test_transform_request_with_function_tool():
assert request_data["toolConfig"]["tools"][0]["toolSpec"]["name"] == "get_weather"
def test_map_openai_params_with_response_format():
"""Test map_openai_params with response_format."""
config = AmazonConverseConfig()
tools = [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get the current weather in a given location",
"parameters": {
"type": "object",
"properties": {
"location": {
"type": "string",
"description": "The city and state, e.g. San Francisco, CA",
},
"unit": {"type": "string", "enum": ["celsius", "fahrenheit"]},
},
"required": ["location"],
},
}
}
]
json_schema = {
"type": "json_schema",
"json_schema": {
"name": "WeatherResult",
"schema": {
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"required": ["Weather_Explanation", "Current_Temperature"],
"properties": {
"Weather_Explanation": {
"type": ["string", "null"],
"description": "1-2 sentences explaining the weather in the location",
},
"Current_Temperature": {
"type": ["number", "null"],
"description": "Current temperature in the location",
},
},
"additionalProperties": False,
},
"strict": False,
},
}
optional_params = config.map_openai_params(
non_default_params={"response_format": json_schema},
optional_params={"tools": tools},
model="eu.anthropic.claude-sonnet-4-20250514-v1:0",
drop_params=False
)
assert "tools" in optional_params
assert len(optional_params["tools"]) == 2
assert optional_params["tools"][1]["type"] == "function"
assert optional_params["tools"][1]["function"]["name"] == "json_tool_call"
@pytest.mark.asyncio
async def test_assistant_message_cache_control():
"""Test that assistant messages with cache_control generate cachePoint blocks."""

View file

@ -0,0 +1,177 @@
import os
import sys
from unittest.mock import patch
sys.path.insert(
0, os.path.abspath("../../../../..")
) # Adds the parent directory to the system path
from litellm.llms.bedrock.passthrough.transformation import BedrockPassthroughConfig
def test_bedrock_passthrough_get_complete_url_default_endpoint():
"""Test get_complete_url with default AWS endpoint (no override)"""
config = BedrockPassthroughConfig()
# Mock the methods following the pattern from test_base_aws_llm.py
with patch.object(config, '_get_aws_region_name', return_value="us-east-1"), \
patch.object(config, 'get_runtime_endpoint', return_value=(
"https://bedrock-runtime.us-east-1.amazonaws.com",
"https://bedrock-runtime.us-east-1.amazonaws.com"
)) as mock_get_runtime:
url, api_base = config.get_complete_url(
api_base=None,
api_key=None,
model="anthropic.claude-3-sonnet",
endpoint="/model/anthropic.claude-3-sonnet/invoke",
request_query_params=None,
litellm_params={}
)
# Verify get_runtime_endpoint was called with correct parameters
mock_get_runtime.assert_called_once_with(
api_base=None,
aws_bedrock_runtime_endpoint=None,
aws_region_name="us-east-1",
endpoint_type="runtime"
)
# Verify URL construction
assert str(url) == "https://bedrock-runtime.us-east-1.amazonaws.com/model/anthropic.claude-3-sonnet/invoke"
assert api_base == "https://bedrock-runtime.us-east-1.amazonaws.com"
def test_bedrock_passthrough_get_complete_url_custom_endpoint_no_path():
"""Test get_complete_url with custom endpoint (no base path)"""
config = BedrockPassthroughConfig()
with patch.object(config, '_get_aws_region_name', return_value="us-west-2"), \
patch.object(config, 'get_runtime_endpoint', return_value=(
"http://proxy.com",
"http://proxy.com"
)) as mock_get_runtime:
url, api_base = config.get_complete_url(
api_base="http://proxy.com",
api_key=None,
model="anthropic.claude-3-sonnet",
endpoint="/model/anthropic.claude-3-sonnet/invoke",
request_query_params=None,
litellm_params={}
)
# Verify get_runtime_endpoint was called with the api_base
mock_get_runtime.assert_called_once_with(
api_base="http://proxy.com",
aws_bedrock_runtime_endpoint=None,
aws_region_name="us-west-2",
endpoint_type="runtime"
)
# Verify URL construction
assert str(url) == "http://proxy.com/model/anthropic.claude-3-sonnet/invoke"
assert api_base == "http://proxy.com"
def test_bedrock_passthrough_get_complete_url_custom_endpoint_with_path():
"""Test get_complete_url with custom endpoint that has a base path"""
config = BedrockPassthroughConfig()
with patch.object(config, '_get_aws_region_name', return_value="us-west-2"), \
patch.object(config, 'get_runtime_endpoint', return_value=(
"http://proxy.com/bedrockproxy",
"http://proxy.com/bedrockproxy"
)) as mock_get_runtime:
url, api_base = config.get_complete_url(
api_base="http://proxy.com/bedrockproxy",
api_key=None,
model="anthropic.claude-3-sonnet",
endpoint="/model/anthropic.claude-3-sonnet/invoke",
request_query_params=None,
litellm_params={
"aws_bedrock_runtime_endpoint": "http://proxy.com/bedrockproxy"
}
)
# Verify get_runtime_endpoint was called with correct parameters
mock_get_runtime.assert_called_once_with(
api_base="http://proxy.com/bedrockproxy",
aws_bedrock_runtime_endpoint="http://proxy.com/bedrockproxy",
aws_region_name="us-west-2",
endpoint_type="runtime"
)
# Verify URL construction preserves the proxy path
assert str(url) == "http://proxy.com/bedrockproxy/model/anthropic.claude-3-sonnet/invoke"
assert api_base == "http://proxy.com/bedrockproxy"
def test_format_url_simple_joining():
"""Test format_url with simple URL joining"""
config = BedrockPassthroughConfig()
result = config.format_url(
endpoint="model/test/invoke",
base_target_url="https://api.example.com",
request_query_params={}
)
assert str(result) == "https://api.example.com/model/test/invoke"
def test_format_url_preserves_proxy_paths():
"""Test format_url preserves proxy paths in base URL"""
config = BedrockPassthroughConfig()
result = config.format_url(
endpoint="model/test/invoke",
base_target_url="http://proxy.com/bedrockproxy",
request_query_params={}
)
# This is the key test - proxy path should be preserved
assert str(result) == "http://proxy.com/bedrockproxy/model/test/invoke"
def test_format_url_with_query_parameters():
"""Test format_url properly handles query parameters"""
config = BedrockPassthroughConfig()
result = config.format_url(
endpoint="model/test/invoke",
base_target_url="http://proxy.com/bedrockproxy",
request_query_params={"param1": "value1", "param2": "value2"}
)
# Should preserve proxy path and add query params
result_str = str(result)
assert "http://proxy.com/bedrockproxy/model/test/invoke" in result_str
assert "param1=value1" in result_str
assert "param2=value2" in result_str
def test_format_url_handles_trailing_slash_normalization():
"""Test format_url properly handles base URLs with and without trailing slashes"""
config = BedrockPassthroughConfig()
# Test with trailing slash
result_with_slash = config.format_url(
endpoint="model/test/invoke",
base_target_url="http://proxy.com/bedrockproxy/",
request_query_params={}
)
# Test without trailing slash
result_without_slash = config.format_url(
endpoint="model/test/invoke",
base_target_url="http://proxy.com/bedrockproxy",
request_query_params={}
)
# Both should produce the same result
assert str(result_with_slash) == str(result_without_slash)
assert str(result_with_slash) == "http://proxy.com/bedrockproxy/model/test/invoke"

View file

@ -668,3 +668,248 @@ def test_get_supported_openai_params():
assert "stream" in params
assert "background" in params
assert "stream" in params
class TestOpenAIFieldExclusionRegistry:
"""Test suite for the OpenAI Field Exclusion Registry system"""
def setup_method(self):
"""Setup test fixtures"""
from litellm.llms.openai.responses.transformation import (
OpenAIFieldExclusionRegistry,
OpenAIResponsesAPIConfig
)
self.registry = OpenAIFieldExclusionRegistry
self.config = OpenAIResponsesAPIConfig()
def test_registry_initialization(self):
"""Test that the registry is properly initialized with ResponseReasoningItem"""
# Test that we can get excluded fields (should not be empty if ResponseReasoningItem is registered)
all_excluded_fields = self.registry.get_all_excluded_fields()
# The registry should have at least some fields if ResponseReasoningItem was successfully registered
# If OpenAI SDK is not available, this might be empty, which is also valid
assert isinstance(all_excluded_fields, set), "get_all_excluded_fields should return a set"
# If we have the OpenAI SDK available, we should have the expected fields
try:
from openai.types.responses import ResponseReasoningItem
reasoning_fields = self.registry.get_excluded_fields_for_model(ResponseReasoningItem)
expected_fields = {'status', 'content', 'encrypted_content'}
assert expected_fields.issubset(reasoning_fields), f"Expected fields {expected_fields} to be subset of {reasoning_fields}"
except ImportError:
# If OpenAI SDK is not available, that's fine - the registry should handle this gracefully
pytest.skip("OpenAI SDK not available, skipping ResponseReasoningItem specific tests")
def test_register_model_functionality(self):
"""Test that we can register new models to the registry"""
from pydantic import BaseModel
from typing import Optional
# Create a test model with default None fields
class TestResponseModel(BaseModel):
id: str
type: str = "test"
status: Optional[str] = None
content: Optional[str] = None
required_field: str
# Register the test model
self.registry.register_model(TestResponseModel)
# Verify it was registered and fields are detected
excluded_fields = self.registry.get_excluded_fields_for_model(TestResponseModel)
expected_excluded = {'status', 'content'} # Fields with default None
assert expected_excluded.issubset(excluded_fields), f"Expected {expected_excluded} to be in {excluded_fields}"
assert 'id' not in excluded_fields, "Required field 'id' should not be excluded"
assert 'required_field' not in excluded_fields, "Required field 'required_field' should not be excluded"
def test_get_all_excluded_fields(self):
"""Test that get_all_excluded_fields aggregates fields from all registered models"""
all_fields_before = self.registry.get_all_excluded_fields()
# Create and register a test model
from pydantic import BaseModel
from typing import Optional
class AnotherTestModel(BaseModel):
id: str
unique_field: Optional[str] = None
self.registry.register_model(AnotherTestModel)
all_fields_after = self.registry.get_all_excluded_fields()
# The new fields should be included
assert 'unique_field' in all_fields_after, "New model's excluded field should be included"
assert len(all_fields_after) >= len(all_fields_before), "Should have at least as many fields as before"
def test_convenience_registration_method(self):
"""Test the convenience method for registering models"""
from pydantic import BaseModel
from typing import Optional
class ConvenienceTestModel(BaseModel):
id: str
convenience_field: Optional[str] = None
# Use the convenience method
self.config.register_model_for_field_exclusion(ConvenienceTestModel)
# Verify it was registered
excluded_fields = self.registry.get_excluded_fields_for_model(ConvenienceTestModel)
assert 'convenience_field' in excluded_fields, "Field should be excluded after registration"
def test_field_filtering_with_registry(self):
"""Test that the field filtering works correctly with the registry"""
# Test data that matches the structure of ResponseReasoningItem
test_input = [
{
"role": "user",
"content": "test message"
},
{
"id": "reasoning-123",
"type": "reasoning",
"status": None, # Should be filtered out
"content": None, # Should be filtered out
"encrypted_content": None, # Should be filtered out
"summary": [{"text": "This reasoning shows...", "type": "summary_text"}],
"role": "assistant"
},
{
"id": "message-456",
"type": "message",
"status": "completed", # Should be preserved (not None)
"content": "Hello! How can I help?", # Should be preserved (not None)
"role": "assistant"
}
]
# Process the input through the validation
result = self.config._validate_input_param(test_input)
# Verify the structure
assert len(result) == 3, "Should have 3 items"
# Check the reasoning item (index 1)
reasoning_item = result[1]
assert reasoning_item["type"] == "reasoning"
assert reasoning_item["id"] == "reasoning-123"
assert "summary" in reasoning_item, "summary field should be preserved"
assert "role" in reasoning_item, "role field should be preserved"
# These fields should be filtered out if they are in the registry
all_excluded_fields = self.registry.get_all_excluded_fields()
if 'status' in all_excluded_fields:
assert "status" not in reasoning_item, "status field should be filtered out"
if 'content' in all_excluded_fields:
assert "content" not in reasoning_item, "content field should be filtered out"
if 'encrypted_content' in all_excluded_fields:
assert "encrypted_content" not in reasoning_item, "encrypted_content field should be filtered out"
# Check the message item (index 2) - non-None values should be preserved
message_item = result[2]
assert message_item["type"] == "message"
assert message_item["status"] == "completed", "Non-None status should be preserved"
assert message_item["content"] == "Hello! How can I help?", "Non-None content should be preserved"
def test_field_filtering_with_empty_registry(self):
"""Test that filtering works gracefully when no models are registered"""
# Create a fresh registry for this test
from litellm.llms.openai.responses.transformation import OpenAIFieldExclusionRegistry
# Save the current state
original_models = OpenAIFieldExclusionRegistry._MODELS_REQUIRING_EXCLUSION.copy()
try:
# Clear the registry
OpenAIFieldExclusionRegistry._MODELS_REQUIRING_EXCLUSION.clear()
# Test data
test_input = [{
"id": "test-123",
"status": None,
"content": None,
"other_field": "should be preserved"
}]
# Process the input
result = self.config._validate_input_param(test_input)
# With empty registry, nothing should be filtered (all fields preserved)
assert len(result) == 1
item = result[0]
assert "status" in item, "With empty registry, status should be preserved"
assert "content" in item, "With empty registry, content should be preserved"
assert item["other_field"] == "should be preserved"
finally:
# Restore the original state
OpenAIFieldExclusionRegistry._MODELS_REQUIRING_EXCLUSION = original_models
def test_pydantic_v1_v2_compatibility(self):
"""Test that the registry works with both Pydantic v1 and v2"""
from pydantic import BaseModel
from typing import Optional
class CompatibilityTestModel(BaseModel):
id: str
optional_field: Optional[str] = None
required_field: str = "default"
# Register the model
self.registry.register_model(CompatibilityTestModel)
# Get excluded fields
excluded_fields = self.registry.get_excluded_fields_for_model(CompatibilityTestModel)
# Should work regardless of Pydantic version
assert isinstance(excluded_fields, set), "Should return a set"
assert 'optional_field' in excluded_fields, "Field with default None should be excluded"
# Test that the model fields are accessible (works in both v1 and v2)
model_fields = getattr(CompatibilityTestModel, "model_fields", None)
if model_fields is None:
model_fields = getattr(CompatibilityTestModel, "__fields__", {})
assert len(model_fields) > 0, "Should be able to access model fields"
def test_non_registered_model_returns_empty_set(self):
"""Test that non-registered models return empty excluded fields"""
from pydantic import BaseModel
class UnregisteredModel(BaseModel):
id: str
some_field: str = None
# Don't register this model
excluded_fields = self.registry.get_excluded_fields_for_model(UnregisteredModel)
assert excluded_fields == set(), "Non-registered model should return empty set"
@pytest.mark.parametrize("field_value", [None, "", 0, False, []])
def test_only_none_values_are_filtered(self, field_value):
"""Test that only None values are filtered, not other falsy values"""
test_input = [{
"id": "test-123",
"status": field_value,
"content": "actual content",
"other_field": "preserved"
}]
result = self.config._validate_input_param(test_input)
item = result[0]
if field_value is None:
# Only None should be filtered (if status is in the registry)
all_excluded_fields = self.registry.get_all_excluded_fields()
if 'status' in all_excluded_fields:
assert "status" not in item, f"None value should be filtered out"
else:
assert item["status"] is None, f"If not in registry, None should be preserved"
else:
# Other falsy values should be preserved
assert "status" in item, f"Non-None value {field_value} should be preserved"
assert item["status"] == field_value, f"Value should be exactly {field_value}"

View file

@ -8,6 +8,7 @@ import {
useReactTable,
ColumnResizeMode,
VisibilityState,
PaginationState,
} from "@tanstack/react-table";
import React from "react";
import {
@ -18,7 +19,7 @@ import {
TableRow,
TableCell,
} from "@tremor/react";
import { SwitchVerticalIcon, ChevronUpIcon, ChevronDownIcon, TableIcon } from "@heroicons/react/outline";
import { SwitchVerticalIcon, ChevronUpIcon, ChevronDownIcon } from "@heroicons/react/outline";
// Extend the column meta type to include className
declare module "@tanstack/react-table" {

View file

@ -1,4 +1,4 @@
import React, { useState, useEffect, useRef } from "react"
import React, { useState, useEffect, useRef, useMemo } from "react"
import {
Card,
Title,
@ -61,7 +61,7 @@ import HealthCheckComponent from "../model_dashboard/HealthCheckComponent";
import PassThroughSettings from "../pass_through_settings";
import ModelGroupAliasSettings from "../model_group_alias_settings";
import { all_admin_roles } from "@/utils/roles";
import { Table as TableInstance } from "@tanstack/react-table";
import { Table as TableInstance, PaginationState } from "@tanstack/react-table";
import NotificationsManager from "../molecules/notifications_manager";
interface ModelDashboardProps {
@ -191,12 +191,21 @@ const ModelDashboard: React.FC<ModelDashboardProps> = ({
const [currentTeam, setCurrentTeam] = useState<string>("personal") // 'personal' or team_id
const [modelViewMode, setModelViewMode] = useState<"current_team" | "all">("current_team")
// Add state for showing/hiding filters
const [showFilters, setShowFilters] = useState<boolean>(false)
const [showColumnDropdown, setShowColumnDropdown] = useState(false)
const [isDropdownOpen, setIsDropdownOpen] = useState(false)
const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set())
const dropdownRef = useRef<HTMLDivElement>(null)
const tableRef = useRef<TableInstance<any>>(null)
// Pagination state
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 50,
})
const [selectedTabIndex, setSelectedTabIndex] = useState(0)
const handleCreateNewModelClick = () => {
@ -206,6 +215,63 @@ const ModelDashboard: React.FC<ModelDashboardProps> = ({
setSelectedTabIndex(1)
}
const resetFilters = () => {
setModelNameSearch("")
setSelectedModelGroup("all")
setSelectedModelAccessGroupFilter(null)
setCurrentTeam("personal")
setModelViewMode("current_team")
setPagination({ pageIndex: 0, pageSize: 50 })
}
// Memoize filtered data to prevent unnecessary re-calculations
const filteredData = useMemo(() => {
if (!modelData || !modelData.data || modelData.data.length === 0) {
return [];
}
return modelData.data.filter((model: any) => {
const searchMatch =
modelNameSearch === "" ||
model.model_name.toLowerCase().includes(modelNameSearch.toLowerCase())
const modelNameMatch =
selectedModelGroup === "all" ||
model.model_name === selectedModelGroup ||
!selectedModelGroup ||
(selectedModelGroup === "wildcard" && model.model_name?.includes("*"))
const accessGroupMatch =
selectedModelAccessGroupFilter === "all" ||
model.model_info["access_groups"]?.includes(selectedModelAccessGroupFilter) ||
!selectedModelAccessGroupFilter
let teamAccessMatch = true
if (modelViewMode === "current_team") {
if (currentTeam === "personal") {
teamAccessMatch = model.model_info?.direct_access === true
} else {
teamAccessMatch =
model.model_info?.access_via_team_ids?.includes(currentTeam) === true
}
}
return searchMatch && modelNameMatch && accessGroupMatch && teamAccessMatch
});
}, [modelData, modelNameSearch, selectedModelGroup, selectedModelAccessGroupFilter, currentTeam, modelViewMode]);
// Memoize paginated data
const paginatedData = useMemo(() => {
const startIndex = pagination.pageIndex * pagination.pageSize;
const endIndex = startIndex + pagination.pageSize;
return filteredData.slice(startIndex, endIndex);
}, [filteredData, pagination.pageIndex, pagination.pageSize]);
// Reset pagination when filters change
useEffect(() => {
setPagination(prev => ({ ...prev, pageIndex: 0 }))
}, [modelNameSearch, selectedModelGroup, selectedModelAccessGroupFilter, currentTeam, modelViewMode])
const setProviderModelsFn = (provider: Providers) => {
const _providerModels = getProviderModels(provider, modelMap)
setProviderModels(_providerModels)
@ -1006,119 +1072,166 @@ const ModelDashboard: React.FC<ModelDashboardProps> = ({
<div className="flex flex-col space-y-4">
<div className="bg-white rounded-lg shadow">
<div className="border-b px-6 py-4">
<div className="flex flex-col space-y-4">
{/* Current Team Selector - Prominent */}
<div className="flex items-center justify-between bg-gray-50 rounded-lg p-4 border-2 border-gray-200">
<div>
<div className="flex items-center gap-4">
<Text className="text-lg font-semibold text-gray-900">Current Team:</Text>
<Select
className="w-80"
defaultValue="personal"
value={currentTeam}
onValueChange={(value) => setCurrentTeam(value)}
>
<SelectItem value="personal">
{/* Current Team and View Mode Selector - Prominent Section */}
<div className="border-b px-6 py-4 bg-gray-50">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Text className="text-lg font-semibold text-gray-900">Current Team:</Text>
<Select
className="w-80"
defaultValue="personal"
value={currentTeam}
onValueChange={(value) => setCurrentTeam(value)}
>
<SelectItem value="personal">
<div className="flex items-center gap-2">
<div className="w-2 h-2 bg-blue-500 rounded-full"></div>
<span className="font-medium">Personal</span>
</div>
</SelectItem>
{teams
?.filter((team) => team.team_id)
.map((team) => (
<SelectItem key={team.team_id} value={team.team_id}>
<div className="flex items-center gap-2">
<div className="w-2 h-2 bg-blue-500 rounded-full"></div>
<span className="font-medium">Personal</span>
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span className="font-medium">
{team.team_alias
? `${team.team_alias.slice(0, 30)}...`
: `Team ${team.team_id.slice(0, 30)}...`}
</span>
</div>
</SelectItem>
{teams
?.filter((team) => team.team_id)
.map((team) => (
<SelectItem key={team.team_id} value={team.team_id}>
<div className="flex items-center gap-2">
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span className="font-medium">
{team.team_alias
? `${team.team_alias.slice(0, 30)}...`
: `Team ${team.team_id.slice(0, 30)}...`}
</span>
</div>
</SelectItem>
))}
</Select>
</div>
{modelViewMode === "current_team" && (
<div className="flex items-start gap-2 mt-2 bg-gray-50 rounded">
<InfoCircleOutlined className="text-gray-400 mt-0.5 flex-shrink-0 text-xs" />
<div className="text-xs text-gray-500">
{currentTeam === "personal" ? (
<span>
To access these models: Create a Virtual Key without selecting a team on the{" "}
<a
href="/?login=success&page=api-keys"
className="text-gray-600 hover:text-gray-800 underline"
>
Virtual Keys page
</a>
</span>
) : (
<span>
To access these models: Create a Virtual Key and select Team as &quot;
{currentTeam}&quot; on the{" "}
<a
href="/?login=success&page=api-keys"
className="text-gray-600 hover:text-gray-800 underline"
>
Virtual Keys page
</a>
</span>
)}
</div>
</div>
)}
</div>
{/* Model View Mode Toggle - Also prominent */}
<div className="flex items-center gap-4">
<Text className="text-lg font-semibold text-gray-900">View:</Text>
<Select
className="w-64"
defaultValue="current_team"
value={modelViewMode}
onValueChange={(value) => setModelViewMode(value as "current_team" | "all")}
>
<SelectItem value="current_team">
<div className="flex items-center gap-2">
<div className="w-2 h-2 bg-purple-500 rounded-full"></div>
<span className="font-medium">Current Team Models</span>
</div>
</SelectItem>
<SelectItem value="all">
<div className="flex items-center gap-2">
<div className="w-2 h-2 bg-gray-500 rounded-full"></div>
<span className="font-medium">All Available Models</span>
</div>
</SelectItem>
</Select>
</div>
))}
</Select>
</div>
{/* Other Filters */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{/* Model Name Search */}
<div className="flex items-center gap-2">
<Text>Search Public Model Name:</Text>
<TextInput
className="w-64"
placeholder="Search model names..."
value={modelNameSearch}
onValueChange={setModelNameSearch}
/>
</div>
<div className="flex items-center gap-4">
<Text className="text-lg font-semibold text-gray-900">View:</Text>
<Select
className="w-64"
defaultValue="current_team"
value={modelViewMode}
onValueChange={(value) => setModelViewMode(value as "current_team" | "all")}
>
<SelectItem value="current_team">
<div className="flex items-center gap-2">
<div className="w-2 h-2 bg-purple-500 rounded-full"></div>
<span className="font-medium">Current Team Models</span>
</div>
</SelectItem>
<SelectItem value="all">
<div className="flex items-center gap-2">
<div className="w-2 h-2 bg-gray-500 rounded-full"></div>
<span className="font-medium">All Available Models</span>
</div>
</SelectItem>
</Select>
</div>
</div>
{modelViewMode === "current_team" && (
<div className="flex items-start gap-2 mt-3">
<InfoCircleOutlined className="text-gray-400 mt-0.5 flex-shrink-0 text-xs" />
<div className="text-xs text-gray-500">
{currentTeam === "personal" ? (
<span>
To access these models: Create a Virtual Key without selecting a team on the{" "}
<a
href="/?login=success&page=api-keys"
className="text-gray-600 hover:text-gray-800 underline"
>
Virtual Keys page
</a>
</span>
) : (
<span>
To access these models: Create a Virtual Key and select Team as &quot;
{currentTeam}&quot; on the{" "}
<a
href="/?login=success&page=api-keys"
className="text-gray-600 hover:text-gray-800 underline"
>
Virtual Keys page
</a>
</span>
)}
</div>
</div>
)}
</div>
{/* Search and Filter Controls */}
<div className="border-b px-6 py-4">
<div className="flex flex-col space-y-4">
{/* Search and Filter Controls */}
<div className="flex flex-wrap items-center gap-3">
{/* Model Name Search */}
<div className="relative w-64">
<input
type="text"
placeholder="Search model names..."
className="w-full px-3 py-2 pl-8 border rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
value={modelNameSearch}
onChange={(e) => setModelNameSearch(e.target.value)}
/>
<svg
className="absolute left-2.5 top-2.5 h-4 w-4 text-gray-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
</div>
{/* Filter Button */}
<button
className={`px-3 py-2 text-sm border rounded-md hover:bg-gray-50 flex items-center gap-2 ${showFilters ? "bg-gray-100" : ""}`}
onClick={() => setShowFilters(!showFilters)}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z"
/>
</svg>
Filters
</button>
{/* Reset Filters Button */}
<button
className="px-3 py-2 text-sm border rounded-md hover:bg-gray-50 flex items-center gap-2"
onClick={resetFilters}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
Reset Filters
</button>
</div>
{/* Additional Filters */}
{showFilters && (
<div className="flex flex-wrap items-center gap-3 mt-3">
{/* Model Name Filter */}
<div className="flex items-center gap-2">
<Text>Filter by Public Model Name:</Text>
<div className="w-64">
<Select
className="w-64"
defaultValue={selectedModelGroup ?? "all"}
onValueChange={(value) => setSelectedModelGroup(value === "all" ? "all" : value)}
value={selectedModelGroup ?? "all"}
onValueChange={(value) => setSelectedModelGroup(value === "all" ? "all" : value)}
placeholder="Filter by Public Model Name"
>
<SelectItem value="all">All Models</SelectItem>
<SelectItem value="wildcard">Wildcard Models (*)</SelectItem>
@ -1130,15 +1243,14 @@ const ModelDashboard: React.FC<ModelDashboardProps> = ({
</Select>
</div>
<div className="flex items-center gap-2">
<Text>Filter by Model Access Group:</Text>
{/* Model Access Group Filter */}
<div className="w-64">
<Select
className="w-64"
defaultValue="all"
value={selectedModelAccessGroupFilter ?? "all"}
onValueChange={(value) =>
setSelectedModelAccessGroupFilter(value === "all" ? null : value)
}
placeholder="Filter by Model Access Group"
>
<SelectItem value="all">All Model Access Groups</SelectItem>
{availableModelAccessGroups.map((accessGroup, idx) => (
@ -1149,41 +1261,47 @@ const ModelDashboard: React.FC<ModelDashboardProps> = ({
</Select>
</div>
</div>
</div>
)}
{/* Results Count */}
{/* Results Count and Pagination Controls */}
<div className="flex justify-between items-center">
<Text className="text-sm text-gray-700">
Showing{" "}
{modelData && modelData.data.length > 0
? modelData.data.filter((model: any) => {
const searchMatch =
modelNameSearch === "" ||
model.model_name.toLowerCase().includes(modelNameSearch.toLowerCase())
const modelNameMatch =
selectedModelGroup === "all" ||
model.model_name === selectedModelGroup ||
!selectedModelGroup
const accessGroupMatch =
selectedModelAccessGroupFilter === "all" ||
model.model_info["access_groups"]?.includes(selectedModelAccessGroupFilter) ||
!selectedModelAccessGroupFilter
let teamAccessMatch = true
if (modelViewMode === "current_team") {
if (currentTeam === "personal") {
teamAccessMatch = model.model_info?.direct_access === true
} else {
teamAccessMatch =
model.model_info?.access_via_team_ids?.includes(currentTeam) === true
}
}
return searchMatch && modelNameMatch && accessGroupMatch && teamAccessMatch
}).length
: 0}{" "}
results
</Text>
<span className="text-sm text-gray-700">
{filteredData.length > 0 ? (
`Showing ${pagination.pageIndex * pagination.pageSize + 1} - ${Math.min(
(pagination.pageIndex + 1) * pagination.pageSize,
filteredData.length
)} of ${filteredData.length} results`
) : (
"Showing 0 results"
)}
</span>
{/* Pagination Controls */}
{filteredData.length > pagination.pageSize && (
<div className="flex items-center space-x-2">
<button
onClick={() => setPagination(prev => ({ ...prev, pageIndex: prev.pageIndex - 1 }))}
disabled={pagination.pageIndex === 0}
className={`px-3 py-1 text-sm border rounded-md ${
pagination.pageIndex === 0 ? "bg-gray-100 text-gray-400 cursor-not-allowed" : "hover:bg-gray-50"
}`}
>
Previous
</button>
<button
onClick={() => setPagination(prev => ({ ...prev, pageIndex: prev.pageIndex + 1 }))}
disabled={pagination.pageIndex >= Math.ceil(filteredData.length / pagination.pageSize) - 1}
className={`px-3 py-1 text-sm border rounded-md ${
pagination.pageIndex >= Math.ceil(filteredData.length / pagination.pageSize) - 1
? "bg-gray-100 text-gray-400 cursor-not-allowed"
: "hover:bg-gray-50"
}`}
>
Next
</button>
</div>
)}
</div>
</div>
</div>
@ -1202,38 +1320,7 @@ const ModelDashboard: React.FC<ModelDashboardProps> = ({
expandedRows,
setExpandedRows,
)}
data={modelData.data.filter((model: any) => {
// Model name search filter
const searchMatch =
modelNameSearch === "" ||
model.model_name.toLowerCase().includes(modelNameSearch.toLowerCase())
// Model name filter
const modelNameMatch =
selectedModelGroup === "all" ||
model.model_name === selectedModelGroup ||
!selectedModelGroup ||
(selectedModelGroup === "wildcard" && model.model_name?.includes("*"))
// Model access group filter
const accessGroupMatch =
selectedModelAccessGroupFilter === "all" ||
model.model_info["access_groups"]?.includes(selectedModelAccessGroupFilter) ||
!selectedModelAccessGroupFilter
// Team access filter based on current team and view mode
let teamAccessMatch = true
if (modelViewMode === "current_team") {
if (currentTeam === "personal") {
// Show only models with direct access
teamAccessMatch = model.model_info?.direct_access === true
} else {
// Show only models accessible by the current team
teamAccessMatch = model.model_info?.access_via_team_ids?.includes(currentTeam) === true
}
}
// For 'all' mode, show all models (teamAccessMatch remains true)
return searchMatch && modelNameMatch && accessGroupMatch && teamAccessMatch
})}
data={paginatedData}
isLoading={false}
table={tableRef}
/>