add sortBy and sortOrder params for /v2/model/info

This commit is contained in:
yuneng-jiang 2026-01-27 16:54:52 -08:00
parent fe444f3ed5
commit 1581bcf985
3 changed files with 682 additions and 287 deletions

View file

@ -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(

View file

@ -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):
"""

View file

@ -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>
);
},
},
},
];
];