mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-07 08:26:10 +00:00
add sortBy and sortOrder params for /v2/model/info
This commit is contained in:
parent
fe444f3ed5
commit
1581bcf985
3 changed files with 682 additions and 287 deletions
|
|
@ -7807,6 +7807,7 @@ async def _apply_search_filter_to_models(
|
|||
size: int,
|
||||
prisma_client: Optional[Any],
|
||||
proxy_config: Any,
|
||||
sort_by: Optional[str] = None,
|
||||
) -> Tuple[List[Dict[str, Any]], Optional[int]]:
|
||||
"""
|
||||
Apply search filter to models, querying database for additional matching models.
|
||||
|
|
@ -7818,6 +7819,7 @@ async def _apply_search_filter_to_models(
|
|||
size: Page size
|
||||
prisma_client: Prisma client for database queries
|
||||
proxy_config: Proxy config for decrypting models
|
||||
sort_by: Optional sort field - if provided, fetch all matching models instead of paginating at DB level
|
||||
|
||||
Returns:
|
||||
Tuple of (filtered_models, total_count). total_count is None if not searching.
|
||||
|
|
@ -7881,17 +7883,15 @@ async def _apply_search_filter_to_models(
|
|||
# Calculate total count for search results
|
||||
search_total_count = router_models_count + db_models_total_count
|
||||
|
||||
# Fetch database models if we need more for the current page
|
||||
if router_models_count < models_needed_for_page:
|
||||
models_to_fetch = min(
|
||||
models_needed_for_page - router_models_count, db_models_total_count
|
||||
)
|
||||
|
||||
if models_to_fetch > 0:
|
||||
# If sorting is requested, we need to fetch ALL matching models to sort correctly
|
||||
# Otherwise, we can optimize by only fetching what's needed for the current page
|
||||
if sort_by:
|
||||
# Fetch all matching database models for sorting
|
||||
if db_models_total_count > 0:
|
||||
db_models_raw = (
|
||||
await prisma_client.db.litellm_proxymodeltable.find_many(
|
||||
where=db_where_condition,
|
||||
take=models_to_fetch,
|
||||
take=db_models_total_count, # Fetch all matching models
|
||||
)
|
||||
)
|
||||
|
||||
|
|
@ -7902,6 +7902,28 @@ async def _apply_search_filter_to_models(
|
|||
)
|
||||
if decrypted_models:
|
||||
db_models.extend(decrypted_models)
|
||||
else:
|
||||
# Fetch database models if we need more for the current page
|
||||
if router_models_count < models_needed_for_page:
|
||||
models_to_fetch = min(
|
||||
models_needed_for_page - router_models_count, db_models_total_count
|
||||
)
|
||||
|
||||
if models_to_fetch > 0:
|
||||
db_models_raw = (
|
||||
await prisma_client.db.litellm_proxymodeltable.find_many(
|
||||
where=db_where_condition,
|
||||
take=models_to_fetch,
|
||||
)
|
||||
)
|
||||
|
||||
# Convert database models to router format
|
||||
for db_model in db_models_raw:
|
||||
decrypted_models = proxy_config.decrypt_model_list_from_db(
|
||||
[db_model]
|
||||
)
|
||||
if decrypted_models:
|
||||
db_models.extend(decrypted_models)
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.exception(
|
||||
f"Error querying database models with search: {str(e)}"
|
||||
|
|
@ -7917,6 +7939,80 @@ async def _apply_search_filter_to_models(
|
|||
return filtered_models, search_total_count
|
||||
|
||||
|
||||
def _sort_models(
|
||||
all_models: List[Dict[str, Any]],
|
||||
sort_by: Optional[str],
|
||||
sort_order: str = "asc",
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Sort models by the specified field and order.
|
||||
|
||||
Args:
|
||||
all_models: List of models to sort
|
||||
sort_by: Field to sort by (model_name, created_at, updated_at, costs, status)
|
||||
sort_order: Sort order (asc or desc)
|
||||
|
||||
Returns:
|
||||
Sorted list of models
|
||||
"""
|
||||
if not sort_by or sort_by not in ["model_name", "created_at", "updated_at", "costs", "status"]:
|
||||
return all_models
|
||||
|
||||
reverse = sort_order.lower() == "desc"
|
||||
|
||||
def get_sort_key(model: Dict[str, Any]) -> Any:
|
||||
model_info = model.get("model_info", {})
|
||||
|
||||
if sort_by == "model_name":
|
||||
return model.get("model_name", "").lower()
|
||||
|
||||
elif sort_by == "created_at":
|
||||
created_at = model_info.get("created_at")
|
||||
if created_at is None:
|
||||
# Put None values at the end for asc, at the start for desc
|
||||
return (datetime.max if not reverse else datetime.min)
|
||||
if isinstance(created_at, str):
|
||||
try:
|
||||
return datetime.fromisoformat(created_at.replace("Z", "+00:00"))
|
||||
except (ValueError, AttributeError):
|
||||
return datetime.min if not reverse else datetime.max
|
||||
return created_at
|
||||
|
||||
elif sort_by == "updated_at":
|
||||
updated_at = model_info.get("updated_at")
|
||||
if updated_at is None:
|
||||
return (datetime.max if not reverse else datetime.min)
|
||||
if isinstance(updated_at, str):
|
||||
try:
|
||||
return datetime.fromisoformat(updated_at.replace("Z", "+00:00"))
|
||||
except (ValueError, AttributeError):
|
||||
return datetime.min if not reverse else datetime.max
|
||||
return updated_at
|
||||
|
||||
elif sort_by == "costs":
|
||||
input_cost = model_info.get("input_cost_per_token", 0) or 0
|
||||
output_cost = model_info.get("output_cost_per_token", 0) or 0
|
||||
total_cost = input_cost + output_cost
|
||||
# Put 0 or None costs at the end for asc, at the start for desc
|
||||
if total_cost == 0:
|
||||
return (float("inf") if not reverse else float("-inf"))
|
||||
return total_cost
|
||||
|
||||
elif sort_by == "status":
|
||||
# False (config) comes before True (db) for asc
|
||||
db_model = model_info.get("db_model", False)
|
||||
return db_model
|
||||
|
||||
return None
|
||||
|
||||
try:
|
||||
sorted_models = sorted(all_models, key=get_sort_key, reverse=reverse)
|
||||
return sorted_models
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.exception(f"Error sorting models by {sort_by}: {str(e)}")
|
||||
return all_models
|
||||
|
||||
|
||||
def _paginate_models_response(
|
||||
all_models: List[Dict[str, Any]],
|
||||
page: int,
|
||||
|
|
@ -8109,6 +8205,14 @@ async def model_info_v2(
|
|||
None,
|
||||
description="Filter models by team ID. Returns models with direct_access=True or teamId in access_via_team_ids",
|
||||
),
|
||||
sortBy: Optional[str] = fastapi.Query(
|
||||
None,
|
||||
description="Field to sort by. Options: model_name, created_at, updated_at, costs, status",
|
||||
),
|
||||
sortOrder: Optional[str] = fastapi.Query(
|
||||
"asc",
|
||||
description="Sort order. Options: asc, desc",
|
||||
),
|
||||
):
|
||||
"""
|
||||
BETA ENDPOINT. Might change unexpectedly. Use `/v1/model/info` for now.
|
||||
|
|
@ -8193,6 +8297,7 @@ async def model_info_v2(
|
|||
size=size,
|
||||
prisma_client=prisma_client,
|
||||
proxy_config=proxy_config,
|
||||
sort_by=sortBy,
|
||||
)
|
||||
|
||||
if user_models_only:
|
||||
|
|
@ -8236,6 +8341,20 @@ async def model_info_v2(
|
|||
if modelId is not None:
|
||||
search_total_count = len(all_models)
|
||||
|
||||
# Apply sorting before pagination
|
||||
if sortBy:
|
||||
# Validate sortOrder
|
||||
if sortOrder and sortOrder.lower() not in ["asc", "desc"]:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid sortOrder: {sortOrder}. Must be 'asc' or 'desc'",
|
||||
)
|
||||
all_models = _sort_models(
|
||||
all_models=all_models,
|
||||
sort_by=sortBy,
|
||||
sort_order=sortOrder or "asc",
|
||||
)
|
||||
|
||||
verbose_proxy_logger.debug("all_models: %s", all_models)
|
||||
|
||||
return _paginate_models_response(
|
||||
|
|
|
|||
|
|
@ -4013,6 +4013,279 @@ async def test_model_info_v2_filter_by_team_id(monkeypatch):
|
|||
app.dependency_overrides = original_overrides
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"sort_by,sort_order,expected_order",
|
||||
[
|
||||
# Test model_name sorting
|
||||
("model_name", "asc", ["a-model", "b-model", "z-model"]),
|
||||
("model_name", "desc", ["z-model", "b-model", "a-model"]),
|
||||
# Test created_at sorting
|
||||
("created_at", "asc", ["old-model", "mid-model", "new-model"]),
|
||||
("created_at", "desc", ["new-model", "mid-model", "old-model"]),
|
||||
# Test updated_at sorting
|
||||
("updated_at", "asc", ["old-updated", "mid-updated", "new-updated"]),
|
||||
("updated_at", "desc", ["new-updated", "mid-updated", "old-updated"]),
|
||||
# Test costs sorting
|
||||
("costs", "asc", ["low-cost", "mid-cost", "high-cost"]),
|
||||
("costs", "desc", ["high-cost", "mid-cost", "low-cost"]),
|
||||
# Test status sorting (False/config models come before True/db models in asc)
|
||||
("status", "asc", ["config-model-1", "config-model-2", "db-model"]),
|
||||
("status", "desc", ["db-model", "config-model-1", "config-model-2"]),
|
||||
],
|
||||
)
|
||||
async def test_model_info_v2_sorting(monkeypatch, sort_by, sort_order, expected_order):
|
||||
"""
|
||||
Test sorting functionality for /v2/model/info endpoint.
|
||||
Tests all sortBy fields (model_name, created_at, updated_at, costs, status)
|
||||
with both asc and desc sort orders.
|
||||
"""
|
||||
from datetime import datetime, timedelta
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
from litellm.proxy.proxy_server import app, proxy_config, user_api_key_auth
|
||||
|
||||
# Create base time for date comparisons
|
||||
base_time = datetime(2024, 1, 1, 12, 0, 0)
|
||||
|
||||
# Create mock models with different values for each sort field
|
||||
mock_models = []
|
||||
|
||||
if sort_by == "model_name":
|
||||
# Models with different names
|
||||
mock_models = [
|
||||
{
|
||||
"model_name": "z-model",
|
||||
"litellm_params": {"model": "z-model"},
|
||||
"model_info": {"id": "z-model"},
|
||||
},
|
||||
{
|
||||
"model_name": "a-model",
|
||||
"litellm_params": {"model": "a-model"},
|
||||
"model_info": {"id": "a-model"},
|
||||
},
|
||||
{
|
||||
"model_name": "b-model",
|
||||
"litellm_params": {"model": "b-model"},
|
||||
"model_info": {"id": "b-model"},
|
||||
},
|
||||
]
|
||||
elif sort_by == "created_at":
|
||||
# Models with different created_at timestamps
|
||||
mock_models = [
|
||||
{
|
||||
"model_name": "new-model",
|
||||
"litellm_params": {"model": "new-model"},
|
||||
"model_info": {
|
||||
"id": "new-model",
|
||||
"created_at": (base_time + timedelta(days=3)).isoformat(),
|
||||
},
|
||||
},
|
||||
{
|
||||
"model_name": "old-model",
|
||||
"litellm_params": {"model": "old-model"},
|
||||
"model_info": {
|
||||
"id": "old-model",
|
||||
"created_at": (base_time - timedelta(days=3)).isoformat(),
|
||||
},
|
||||
},
|
||||
{
|
||||
"model_name": "mid-model",
|
||||
"litellm_params": {"model": "mid-model"},
|
||||
"model_info": {
|
||||
"id": "mid-model",
|
||||
"created_at": base_time.isoformat(),
|
||||
},
|
||||
},
|
||||
]
|
||||
elif sort_by == "updated_at":
|
||||
# Models with different updated_at timestamps
|
||||
mock_models = [
|
||||
{
|
||||
"model_name": "new-updated",
|
||||
"litellm_params": {"model": "new-updated"},
|
||||
"model_info": {
|
||||
"id": "new-updated",
|
||||
"updated_at": (base_time + timedelta(days=3)).isoformat(),
|
||||
},
|
||||
},
|
||||
{
|
||||
"model_name": "old-updated",
|
||||
"litellm_params": {"model": "old-updated"},
|
||||
"model_info": {
|
||||
"id": "old-updated",
|
||||
"updated_at": (base_time - timedelta(days=3)).isoformat(),
|
||||
},
|
||||
},
|
||||
{
|
||||
"model_name": "mid-updated",
|
||||
"litellm_params": {"model": "mid-updated"},
|
||||
"model_info": {
|
||||
"id": "mid-updated",
|
||||
"updated_at": base_time.isoformat(),
|
||||
},
|
||||
},
|
||||
]
|
||||
elif sort_by == "costs":
|
||||
# Models with different costs (input_cost + output_cost)
|
||||
mock_models = [
|
||||
{
|
||||
"model_name": "high-cost",
|
||||
"litellm_params": {"model": "high-cost"},
|
||||
"model_info": {
|
||||
"id": "high-cost",
|
||||
"input_cost_per_token": 0.00005,
|
||||
"output_cost_per_token": 0.00015,
|
||||
},
|
||||
},
|
||||
{
|
||||
"model_name": "low-cost",
|
||||
"litellm_params": {"model": "low-cost"},
|
||||
"model_info": {
|
||||
"id": "low-cost",
|
||||
"input_cost_per_token": 0.00001,
|
||||
"output_cost_per_token": 0.00003,
|
||||
},
|
||||
},
|
||||
{
|
||||
"model_name": "mid-cost",
|
||||
"litellm_params": {"model": "mid-cost"},
|
||||
"model_info": {
|
||||
"id": "mid-cost",
|
||||
"input_cost_per_token": 0.00003,
|
||||
"output_cost_per_token": 0.00007,
|
||||
},
|
||||
},
|
||||
]
|
||||
elif sort_by == "status":
|
||||
# Models with different db_model status (False = config, True = db)
|
||||
mock_models = [
|
||||
{
|
||||
"model_name": "db-model",
|
||||
"litellm_params": {"model": "db-model"},
|
||||
"model_info": {"id": "db-model", "db_model": True},
|
||||
},
|
||||
{
|
||||
"model_name": "config-model-1",
|
||||
"litellm_params": {"model": "config-model-1"},
|
||||
"model_info": {"id": "config-model-1", "db_model": False},
|
||||
},
|
||||
{
|
||||
"model_name": "config-model-2",
|
||||
"litellm_params": {"model": "config-model-2"},
|
||||
"model_info": {"id": "config-model-2", "db_model": False},
|
||||
},
|
||||
]
|
||||
|
||||
# Mock llm_router
|
||||
mock_router = MagicMock()
|
||||
mock_router.model_list = mock_models
|
||||
|
||||
# Mock prisma_client
|
||||
mock_prisma_client = MagicMock()
|
||||
|
||||
# Mock proxy_config.get_config
|
||||
mock_get_config = AsyncMock(return_value={})
|
||||
|
||||
# Mock user authentication
|
||||
mock_user_api_key_dict = MagicMock(spec=UserAPIKeyAuth)
|
||||
mock_user_api_key_dict.user_id = "test-user"
|
||||
mock_user_api_key_dict.api_key = "test-key"
|
||||
mock_user_api_key_dict.team_models = []
|
||||
mock_user_api_key_dict.models = []
|
||||
|
||||
# Apply monkeypatches
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", mock_router)
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.user_model", None)
|
||||
monkeypatch.setattr(proxy_config, "get_config", mock_get_config)
|
||||
|
||||
# Override auth dependency
|
||||
original_overrides = app.dependency_overrides.copy()
|
||||
app.dependency_overrides[user_api_key_auth] = lambda: mock_user_api_key_dict
|
||||
|
||||
client = TestClient(app)
|
||||
try:
|
||||
# Test sorting with specified sortBy and sortOrder
|
||||
response = client.get(
|
||||
"/v2/model/info", params={"sortBy": sort_by, "sortOrder": sort_order}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["data"]) == len(expected_order)
|
||||
|
||||
# Verify models are in expected order
|
||||
actual_order = [m["model_name"] for m in data["data"]]
|
||||
assert actual_order == expected_order, (
|
||||
f"Sorting failed for sortBy={sort_by}, sortOrder={sort_order}. "
|
||||
f"Expected: {expected_order}, Got: {actual_order}"
|
||||
)
|
||||
|
||||
finally:
|
||||
app.dependency_overrides = original_overrides
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_model_info_v2_sorting_invalid_sort_order(monkeypatch):
|
||||
"""
|
||||
Test that invalid sortOrder values return a 400 error.
|
||||
"""
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
from litellm.proxy.proxy_server import app, proxy_config, user_api_key_auth
|
||||
|
||||
# Create mock models
|
||||
mock_models = [
|
||||
{
|
||||
"model_name": "test-model",
|
||||
"litellm_params": {"model": "test-model"},
|
||||
"model_info": {"id": "test-model"},
|
||||
}
|
||||
]
|
||||
|
||||
# Mock llm_router
|
||||
mock_router = MagicMock()
|
||||
mock_router.model_list = mock_models
|
||||
|
||||
# Mock prisma_client
|
||||
mock_prisma_client = MagicMock()
|
||||
|
||||
# Mock proxy_config.get_config
|
||||
mock_get_config = AsyncMock(return_value={})
|
||||
|
||||
# Mock user authentication
|
||||
mock_user_api_key_dict = MagicMock(spec=UserAPIKeyAuth)
|
||||
mock_user_api_key_dict.user_id = "test-user"
|
||||
mock_user_api_key_dict.api_key = "test-key"
|
||||
mock_user_api_key_dict.team_models = []
|
||||
mock_user_api_key_dict.models = []
|
||||
|
||||
# Apply monkeypatches
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", mock_router)
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.user_model", None)
|
||||
monkeypatch.setattr(proxy_config, "get_config", mock_get_config)
|
||||
|
||||
# Override auth dependency
|
||||
original_overrides = app.dependency_overrides.copy()
|
||||
app.dependency_overrides[user_api_key_auth] = lambda: mock_user_api_key_dict
|
||||
|
||||
client = TestClient(app)
|
||||
try:
|
||||
# Test invalid sortOrder
|
||||
response = client.get(
|
||||
"/v2/model/info", params={"sortBy": "model_name", "sortOrder": "invalid"}
|
||||
)
|
||||
assert response.status_code == 400
|
||||
data = response.json()
|
||||
assert "Invalid sortOrder" in data["detail"]
|
||||
|
||||
finally:
|
||||
app.dependency_overrides = original_overrides
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_search_filter_to_models(monkeypatch):
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -17,297 +17,300 @@ export const columns = (
|
|||
expandedRows: Set<string>,
|
||||
setExpandedRows: (expandedRows: Set<string>) => void,
|
||||
): ColumnDef<ModelData>[] => [
|
||||
{
|
||||
header: () => <span className="text-sm font-semibold">Model ID</span>,
|
||||
accessorKey: "model_info.id",
|
||||
cell: ({ row }) => {
|
||||
const model = row.original;
|
||||
return (
|
||||
<Tooltip title={model.model_info.id}>
|
||||
<div
|
||||
className="font-mono text-blue-500 bg-blue-50 hover:bg-blue-100 text-xs font-normal px-2 py-0.5 text-left w-full truncate whitespace-nowrap cursor-pointer max-w-[15ch]"
|
||||
onClick={() => setSelectedModelId(model.model_info.id)}
|
||||
>
|
||||
{model.model_info.id}
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: () => <span className="text-sm font-semibold">Model Information</span>,
|
||||
accessorKey: "model_name",
|
||||
size: 250, // Fixed column width
|
||||
cell: ({ row }) => {
|
||||
const model = row.original;
|
||||
const displayName = getDisplayModelName(row.original) || "-";
|
||||
const tooltipContent = (
|
||||
<div>
|
||||
<div>
|
||||
<strong>Provider:</strong> {model.provider || "-"}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Public Model Name:</strong> {displayName}
|
||||
</div>
|
||||
<div>
|
||||
<strong>LiteLLM Model Name:</strong> {model.litellm_model_name || "-"}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Tooltip title={tooltipContent}>
|
||||
<div className="flex items-start space-x-2 min-w-0 w-full max-w-[250px]">
|
||||
{/* Provider Icon */}
|
||||
<div className="flex-shrink-0 mt-0.5">
|
||||
{model.provider ? (
|
||||
<ProviderLogo provider={model.provider} />
|
||||
) : (
|
||||
<div className="w-4 h-4 rounded-full bg-gray-200 flex items-center justify-center text-xs">-</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Model Names Container */}
|
||||
<div className="flex flex-col min-w-0 flex-1">
|
||||
{/* Public Model Name */}
|
||||
<div className="text-xs font-medium text-gray-900 truncate max-w-[210px]">{displayName}</div>
|
||||
{/* LiteLLM Model Name */}
|
||||
<div className="text-xs text-gray-500 truncate mt-0.5 max-w-[210px]">
|
||||
{model.litellm_model_name || "-"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: () => <span className="text-sm font-semibold">Credentials</span>,
|
||||
accessorKey: "litellm_credential_name",
|
||||
size: 180, // Fixed column width
|
||||
cell: ({ row }) => {
|
||||
const model = row.original;
|
||||
const credentialName = model.litellm_params?.litellm_credential_name;
|
||||
|
||||
return credentialName ? (
|
||||
<Tooltip title={`Credential: ${credentialName}`}>
|
||||
<div className="flex items-center space-x-2 max-w-[180px]">
|
||||
<KeyIcon className="w-4 h-4 text-blue-500 flex-shrink-0" />
|
||||
<span className="text-xs truncate" title={credentialName}>
|
||||
{credentialName}
|
||||
</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<div className="flex items-center space-x-2 max-w-[180px]">
|
||||
<KeyIcon className="w-4 h-4 text-gray-300 flex-shrink-0" />
|
||||
<span className="text-xs text-gray-400">No credentials</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: () => <span className="text-sm font-semibold">Created By</span>,
|
||||
accessorKey: "model_info.created_by",
|
||||
sortingFn: "datetime",
|
||||
size: 160, // Fixed column width
|
||||
cell: ({ row }) => {
|
||||
const model = row.original;
|
||||
const isConfigModel = !model.model_info?.db_model;
|
||||
const createdBy = model.model_info.created_by;
|
||||
const createdAt = model.model_info.created_at ? new Date(model.model_info.created_at).toLocaleDateString() : null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-w-0 max-w-[160px]">
|
||||
{/* Created By - Primary */}
|
||||
<div
|
||||
className="text-xs font-medium text-gray-900 truncate"
|
||||
title={isConfigModel ? "Defined in config" : createdBy || "Unknown"}
|
||||
>
|
||||
{isConfigModel ? "Defined in config" : createdBy || "Unknown"}
|
||||
</div>
|
||||
{/* Created At - Secondary */}
|
||||
<div
|
||||
className="text-xs text-gray-500 truncate mt-0.5"
|
||||
title={isConfigModel ? "Config file" : createdAt || "Unknown date"}
|
||||
>
|
||||
{isConfigModel ? "-" : createdAt || "Unknown date"}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: () => <span className="text-sm font-semibold">Updated At</span>,
|
||||
accessorKey: "model_info.updated_at",
|
||||
sortingFn: "datetime",
|
||||
cell: ({ row }) => {
|
||||
const model = row.original;
|
||||
return (
|
||||
<span className="text-xs">
|
||||
{model.model_info.updated_at ? new Date(model.model_info.updated_at).toLocaleDateString() : "-"}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: () => <span className="text-sm font-semibold">Costs</span>,
|
||||
accessorKey: "input_cost",
|
||||
size: 120, // Fixed column width
|
||||
cell: ({ row }) => {
|
||||
const model = row.original;
|
||||
const inputCost = model.input_cost;
|
||||
const outputCost = model.output_cost;
|
||||
|
||||
// If both costs are missing or undefined, show "-"
|
||||
if (!inputCost && !outputCost) {
|
||||
{
|
||||
header: () => <span className="text-sm font-semibold">Model ID</span>,
|
||||
accessorKey: "model_info.id",
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const model = row.original;
|
||||
return (
|
||||
<div className="max-w-[120px]">
|
||||
<span className="text-xs text-gray-400">-</span>
|
||||
<Tooltip title={model.model_info.id}>
|
||||
<div
|
||||
className="font-mono text-blue-500 bg-blue-50 hover:bg-blue-100 text-xs font-normal px-2 py-0.5 text-left w-full truncate whitespace-nowrap cursor-pointer max-w-[15ch]"
|
||||
onClick={() => setSelectedModelId(model.model_info.id)}
|
||||
>
|
||||
{model.model_info.id}
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: () => <span className="text-sm font-semibold">Model Information</span>,
|
||||
accessorKey: "model_name",
|
||||
size: 250, // Fixed column width
|
||||
cell: ({ row }) => {
|
||||
const model = row.original;
|
||||
const displayName = getDisplayModelName(row.original) || "-";
|
||||
const tooltipContent = (
|
||||
<div>
|
||||
<div>
|
||||
<strong>Provider:</strong> {model.provider || "-"}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Public Model Name:</strong> {displayName}
|
||||
</div>
|
||||
<div>
|
||||
<strong>LiteLLM Model Name:</strong> {model.litellm_model_name || "-"}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip title="Cost per 1M tokens">
|
||||
<div className="flex flex-col min-w-0 max-w-[120px]">
|
||||
{/* Input Cost - Primary */}
|
||||
{inputCost && <div className="text-xs font-medium text-gray-900 truncate">In: ${inputCost}</div>}
|
||||
{/* Output Cost - Secondary */}
|
||||
{outputCost && <div className="text-xs text-gray-500 truncate mt-0.5">Out: ${outputCost}</div>}
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: () => <span className="text-sm font-semibold">Team ID</span>,
|
||||
accessorKey: "model_info.team_id",
|
||||
cell: ({ row }) => {
|
||||
const model = row.original;
|
||||
return model.model_info.team_id ? (
|
||||
<div className="overflow-hidden">
|
||||
<Tooltip title={model.model_info.team_id}>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
className="font-mono text-blue-500 bg-blue-50 hover:bg-blue-100 text-xs font-normal px-2 py-0.5 text-left overflow-hidden truncate max-w-[200px]"
|
||||
onClick={() => setSelectedTeamId(model.model_info.team_id)}
|
||||
>
|
||||
{model.model_info.team_id.slice(0, 7)}...
|
||||
</Button>
|
||||
return (
|
||||
<Tooltip title={tooltipContent}>
|
||||
<div className="flex items-start space-x-2 min-w-0 w-full max-w-[250px]">
|
||||
{/* Provider Icon */}
|
||||
<div className="flex-shrink-0 mt-0.5">
|
||||
{model.provider ? (
|
||||
<ProviderLogo provider={model.provider} />
|
||||
) : (
|
||||
<div className="w-4 h-4 rounded-full bg-gray-200 flex items-center justify-center text-xs">-</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Model Names Container */}
|
||||
<div className="flex flex-col min-w-0 flex-1">
|
||||
{/* Public Model Name */}
|
||||
<div className="text-xs font-medium text-gray-900 truncate max-w-[210px]">{displayName}</div>
|
||||
{/* LiteLLM Model Name */}
|
||||
<div className="text-xs text-gray-500 truncate mt-0.5 max-w-[210px]">
|
||||
{model.litellm_model_name || "-"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
) : (
|
||||
"-"
|
||||
);
|
||||
);
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
header: () => <span className="text-sm font-semibold">Model Access Group</span>,
|
||||
accessorKey: "model_info.model_access_group",
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const model = row.original;
|
||||
const accessGroups = model.model_info.access_groups;
|
||||
{
|
||||
header: () => <span className="text-sm font-semibold">Credentials</span>,
|
||||
accessorKey: "litellm_credential_name",
|
||||
enableSorting: false,
|
||||
size: 180, // Fixed column width
|
||||
cell: ({ row }) => {
|
||||
const model = row.original;
|
||||
const credentialName = model.litellm_params?.litellm_credential_name;
|
||||
|
||||
if (!accessGroups || accessGroups.length === 0) {
|
||||
return "-";
|
||||
}
|
||||
return credentialName ? (
|
||||
<Tooltip title={`Credential: ${credentialName}`}>
|
||||
<div className="flex items-center space-x-2 max-w-[180px]">
|
||||
<KeyIcon className="w-4 h-4 text-blue-500 flex-shrink-0" />
|
||||
<span className="text-xs truncate" title={credentialName}>
|
||||
{credentialName}
|
||||
</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<div className="flex items-center space-x-2 max-w-[180px]">
|
||||
<KeyIcon className="w-4 h-4 text-gray-300 flex-shrink-0" />
|
||||
<span className="text-xs text-gray-400">No credentials</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: () => <span className="text-sm font-semibold">Created By</span>,
|
||||
accessorKey: "model_info.created_by",
|
||||
sortingFn: "datetime",
|
||||
size: 160, // Fixed column width
|
||||
cell: ({ row }) => {
|
||||
const model = row.original;
|
||||
const isConfigModel = !model.model_info?.db_model;
|
||||
const createdBy = model.model_info.created_by;
|
||||
const createdAt = model.model_info.created_at ? new Date(model.model_info.created_at).toLocaleDateString() : null;
|
||||
|
||||
const modelId = model.model_info.id;
|
||||
const isExpanded = expandedRows.has(modelId);
|
||||
const shouldShowExpandButton = accessGroups.length > 1;
|
||||
|
||||
const toggleExpanded = () => {
|
||||
const newExpanded = new Set(expandedRows);
|
||||
if (isExpanded) {
|
||||
newExpanded.delete(modelId);
|
||||
} else {
|
||||
newExpanded.add(modelId);
|
||||
}
|
||||
setExpandedRows(newExpanded);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 overflow-hidden">
|
||||
<Badge size="xs" color="blue" className="text-xs px-1.5 py-0.5 h-5 leading-tight flex-shrink-0">
|
||||
{accessGroups[0]}
|
||||
</Badge>
|
||||
|
||||
{(isExpanded || (!shouldShowExpandButton && accessGroups.length === 2)) &&
|
||||
accessGroups.slice(1).map((group: string, index: number) => (
|
||||
<Badge
|
||||
key={index + 1}
|
||||
size="xs"
|
||||
color="blue"
|
||||
className="text-xs px-1.5 py-0.5 h-5 leading-tight flex-shrink-0"
|
||||
>
|
||||
{group}
|
||||
</Badge>
|
||||
))}
|
||||
|
||||
{shouldShowExpandButton && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleExpanded();
|
||||
}}
|
||||
className="text-xs text-blue-600 hover:text-blue-800 px-1 py-0.5 rounded hover:bg-blue-50 h-5 leading-tight flex-shrink-0 whitespace-nowrap"
|
||||
return (
|
||||
<div className="flex flex-col min-w-0 max-w-[160px]">
|
||||
{/* Created By - Primary */}
|
||||
<div
|
||||
className="text-xs font-medium text-gray-900 truncate"
|
||||
title={isConfigModel ? "Defined in config" : createdBy || "Unknown"}
|
||||
>
|
||||
{isExpanded ? "−" : `+${accessGroups.length - 1}`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
{isConfigModel ? "Defined in config" : createdBy || "Unknown"}
|
||||
</div>
|
||||
{/* Created At - Secondary */}
|
||||
<div
|
||||
className="text-xs text-gray-500 truncate mt-0.5"
|
||||
title={isConfigModel ? "Config file" : createdAt || "Unknown date"}
|
||||
>
|
||||
{isConfigModel ? "-" : createdAt || "Unknown date"}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
header: () => <span className="text-sm font-semibold">Status</span>,
|
||||
accessorKey: "model_info.db_model",
|
||||
cell: ({ row }) => {
|
||||
const model = row.original;
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
{
|
||||
header: () => <span className="text-sm font-semibold">Updated At</span>,
|
||||
accessorKey: "model_info.updated_at",
|
||||
sortingFn: "datetime",
|
||||
cell: ({ row }) => {
|
||||
const model = row.original;
|
||||
return (
|
||||
<span className="text-xs">
|
||||
{model.model_info.updated_at ? new Date(model.model_info.updated_at).toLocaleDateString() : "-"}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: () => <span className="text-sm font-semibold">Costs</span>,
|
||||
accessorKey: "input_cost",
|
||||
size: 120, // Fixed column width
|
||||
cell: ({ row }) => {
|
||||
const model = row.original;
|
||||
const inputCost = model.input_cost;
|
||||
const outputCost = model.output_cost;
|
||||
|
||||
// If both costs are missing or undefined, show "-"
|
||||
if (!inputCost && !outputCost) {
|
||||
return (
|
||||
<div className="max-w-[120px]">
|
||||
<span className="text-xs text-gray-400">-</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip title="Cost per 1M tokens">
|
||||
<div className="flex flex-col min-w-0 max-w-[120px]">
|
||||
{/* Input Cost - Primary */}
|
||||
{inputCost && <div className="text-xs font-medium text-gray-900 truncate">In: ${inputCost}</div>}
|
||||
{/* Output Cost - Secondary */}
|
||||
{outputCost && <div className="text-xs text-gray-500 truncate mt-0.5">Out: ${outputCost}</div>}
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: () => <span className="text-sm font-semibold">Team ID</span>,
|
||||
accessorKey: "model_info.team_id",
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const model = row.original;
|
||||
return model.model_info.team_id ? (
|
||||
<div className="overflow-hidden">
|
||||
<Tooltip title={model.model_info.team_id}>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
className="font-mono text-blue-500 bg-blue-50 hover:bg-blue-100 text-xs font-normal px-2 py-0.5 text-left overflow-hidden truncate max-w-[200px]"
|
||||
onClick={() => setSelectedTeamId(model.model_info.team_id)}
|
||||
>
|
||||
{model.model_info.team_id.slice(0, 7)}...
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
) : (
|
||||
"-"
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: () => <span className="text-sm font-semibold">Model Access Group</span>,
|
||||
accessorKey: "model_info.model_access_group",
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const model = row.original;
|
||||
const accessGroups = model.model_info.access_groups;
|
||||
|
||||
if (!accessGroups || accessGroups.length === 0) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
const modelId = model.model_info.id;
|
||||
const isExpanded = expandedRows.has(modelId);
|
||||
const shouldShowExpandButton = accessGroups.length > 1;
|
||||
|
||||
const toggleExpanded = () => {
|
||||
const newExpanded = new Set(expandedRows);
|
||||
if (isExpanded) {
|
||||
newExpanded.delete(modelId);
|
||||
} else {
|
||||
newExpanded.add(modelId);
|
||||
}
|
||||
setExpandedRows(newExpanded);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 overflow-hidden">
|
||||
<Badge size="xs" color="blue" className="text-xs px-1.5 py-0.5 h-5 leading-tight flex-shrink-0">
|
||||
{accessGroups[0]}
|
||||
</Badge>
|
||||
|
||||
{(isExpanded || (!shouldShowExpandButton && accessGroups.length === 2)) &&
|
||||
accessGroups.slice(1).map((group: string, index: number) => (
|
||||
<Badge
|
||||
key={index + 1}
|
||||
size="xs"
|
||||
color="blue"
|
||||
className="text-xs px-1.5 py-0.5 h-5 leading-tight flex-shrink-0"
|
||||
>
|
||||
{group}
|
||||
</Badge>
|
||||
))}
|
||||
|
||||
{shouldShowExpandButton && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleExpanded();
|
||||
}}
|
||||
className="text-xs text-blue-600 hover:text-blue-800 px-1 py-0.5 rounded hover:bg-blue-50 h-5 leading-tight flex-shrink-0 whitespace-nowrap"
|
||||
>
|
||||
{isExpanded ? "−" : `+${accessGroups.length - 1}`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: () => <span className="text-sm font-semibold">Status</span>,
|
||||
accessorKey: "model_info.db_model",
|
||||
cell: ({ row }) => {
|
||||
const model = row.original;
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium
|
||||
${model.model_info.db_model ? "bg-blue-50 text-blue-600" : "bg-gray-100 text-gray-600"}
|
||||
`}
|
||||
>
|
||||
{model.model_info.db_model ? "DB Model" : "Config Model"}
|
||||
</div>
|
||||
);
|
||||
>
|
||||
{model.model_info.db_model ? "DB Model" : "Config Model"}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: () => <span className="text-sm font-semibold">Actions</span>,
|
||||
cell: ({ row }) => {
|
||||
const model = row.original;
|
||||
const canEditModel = userRole === "Admin" || model.model_info?.created_by === userID;
|
||||
const isConfigModel = !model.model_info?.db_model;
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-2 pr-4">
|
||||
{isConfigModel ? (
|
||||
<Tooltip title="Config model cannot be deleted on the dashboard. Please delete it from the config file.">
|
||||
<Icon icon={TrashIcon} size="sm" className="opacity-50 cursor-not-allowed" />
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip title="Delete model">
|
||||
<Icon
|
||||
icon={TrashIcon}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (canEditModel) {
|
||||
setSelectedModelId(model.model_info.id);
|
||||
}
|
||||
}}
|
||||
className={!canEditModel ? "opacity-50 cursor-not-allowed" : "cursor-pointer hover:text-red-600"}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
{
|
||||
id: "actions",
|
||||
header: () => <span className="text-sm font-semibold">Actions</span>,
|
||||
cell: ({ row }) => {
|
||||
const model = row.original;
|
||||
const canEditModel = userRole === "Admin" || model.model_info?.created_by === userID;
|
||||
const isConfigModel = !model.model_info?.db_model;
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-2 pr-4">
|
||||
{isConfigModel ? (
|
||||
<Tooltip title="Config model cannot be deleted on the dashboard. Please delete it from the config file.">
|
||||
<Icon icon={TrashIcon} size="sm" className="opacity-50 cursor-not-allowed" />
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip title="Delete model">
|
||||
<Icon
|
||||
icon={TrashIcon}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (canEditModel) {
|
||||
setSelectedModelId(model.model_info.id);
|
||||
}
|
||||
}}
|
||||
className={!canEditModel ? "opacity-50 cursor-not-allowed" : "cursor-pointer hover:text-red-600"}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
];
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue