mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-10 22:41:41 +00:00
Merge branch 'main' into litellm_responses_structured_output
This commit is contained in:
commit
94c1b21ae7
12 changed files with 1124 additions and 203 deletions
BIN
dist/litellm-1.57.6.tar.gz
vendored
BIN
dist/litellm-1.57.6.tar.gz
vendored
Binary file not shown.
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
||||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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" {
|
||||
|
|
|
|||
|
|
@ -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 "
|
||||
{currentTeam}" 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 "
|
||||
{currentTeam}" 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}
|
||||
/>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue