From 0730f61127d9fe9bf5eeed0c22de662b548af7ff Mon Sep 17 00:00:00 2001 From: Krish Dholakia Date: Wed, 9 Jul 2025 22:00:57 -0700 Subject: [PATCH] OTEL - OTEL_RESOURCE_ATTRIBUTES support + Model Hub - new model hub table view, new `/public/model_hub` endpoint, fix duplicates in `/model_group/info` (#12468) * fix(opentelemetry.py): initial commit adding support for OTEL_RESOURCE_ATTRIBUTES * test: update unit tests * feat(ui/): initial commit with revamped model hub * fix(model_hub_table.tsx): generic 'supports_' rendering as capabilities * feat(model_hub_table.tsx): indicate if a model is publicly available * refactor(model_hub_table.tsx): refactor to use common model data table component * fix(model_hub_table.tsx): fix box sizes * fix(model_hub_table.tsx): enable selecting / deselecting models in columns * feat(public_endpoints.py): initial commit adding `/public/model_hub` endpoint enables sharing public models * feat(public_endpoints/): instrumentation for public model hub route * feat(proxy_server.py): support request access form for model groups allows user to request access to a model * refactor: use a dictionary of text + link instead of 1 hardcoded request access form * fix(proxy_server.py): prevent duplicates in model_group info * fix: fix linting error * fix(__init__.py): fix linting error --- litellm/__init__.py | 12 +- litellm/integrations/opentelemetry.py | 81 ++- litellm/proxy/_new_secret_config.yaml | 3 +- litellm/proxy/_types.py | 2 + litellm/proxy/proxy_server.py | 7 +- litellm/proxy/public_endpoints/__init__.py | 3 + .../public_endpoints/public_endpoints.py | 35 ++ litellm/types/router.py | 46 +- .../integrations/test_opentelemetry.py | 293 +++++++++-- .../src/app/model_hub_table/page.tsx | 25 + ui/litellm-dashboard/src/app/page.tsx | 7 + .../src/components/leftnav.tsx | 11 +- .../src/components/model_dashboard/table.tsx | 19 +- .../src/components/model_hub_table.tsx | 487 ++++++++++++++++++ .../components/model_hub_table_columns.tsx | 281 ++++++++++ 15 files changed, 1222 insertions(+), 90 deletions(-) create mode 100644 litellm/proxy/public_endpoints/__init__.py create mode 100644 litellm/proxy/public_endpoints/public_endpoints.py create mode 100644 ui/litellm-dashboard/src/app/model_hub_table/page.tsx create mode 100644 ui/litellm-dashboard/src/components/model_hub_table.tsx create mode 100644 ui/litellm-dashboard/src/components/model_hub_table_columns.tsx diff --git a/litellm/__init__.py b/litellm/__init__.py index 0e1b7d97b4..7931488e85 100644 --- a/litellm/__init__.py +++ b/litellm/__init__.py @@ -61,8 +61,14 @@ from litellm.constants import ( DEFAULT_ALLOWED_FAILS, ) from litellm.types.guardrails import GuardrailItem -from litellm.types.secret_managers.main import KeyManagementSystem, KeyManagementSettings -from litellm.types.proxy.management_endpoints.ui_sso import DefaultTeamSSOParams, LiteLLM_UpperboundKeyGenerateParams +from litellm.types.secret_managers.main import ( + KeyManagementSystem, + KeyManagementSettings, +) +from litellm.types.proxy.management_endpoints.ui_sso import ( + DefaultTeamSSOParams, + LiteLLM_UpperboundKeyGenerateParams, +) from litellm.types.utils import StandardKeyGenerationConfig, LlmProviders from litellm.integrations.custom_logger import CustomLogger from litellm.litellm_core_utils.logging_callback_manager import LoggingCallbackManager @@ -320,6 +326,8 @@ prometheus_metrics_config: Optional[List] = None disable_add_prefix_to_prompt: bool = ( False # used by anthropic, to disable adding prefix to prompt ) +public_model_groups: Optional[List[str]] = None +public_model_groups_links: Dict[str, str] = {} #### REQUEST PRIORITIZATION ##### priority_reservation: Optional[Dict[str, float]] = None diff --git a/litellm/integrations/opentelemetry.py b/litellm/integrations/opentelemetry.py index 8f92ca72ed..22ab309290 100644 --- a/litellm/integrations/opentelemetry.py +++ b/litellm/integrations/opentelemetry.py @@ -41,15 +41,43 @@ else: Context = Any LITELLM_TRACER_NAME = os.getenv("OTEL_TRACER_NAME", "litellm") -LITELLM_RESOURCE: Dict[Any, Any] = { - "service.name": os.getenv("OTEL_SERVICE_NAME", "litellm"), - "deployment.environment": os.getenv("OTEL_ENVIRONMENT_NAME", "production"), - "model_id": os.getenv("OTEL_SERVICE_NAME", "litellm"), -} +# Remove the hardcoded LITELLM_RESOURCE dictionary - we'll create it properly later RAW_REQUEST_SPAN_NAME = "raw_gen_ai_request" LITELLM_REQUEST_SPAN_NAME = "litellm_request" +def _get_litellm_resource(): + """ + Create a proper OpenTelemetry Resource that respects OTEL_RESOURCE_ATTRIBUTES + while maintaining backward compatibility with LiteLLM-specific environment variables. + """ + from opentelemetry.sdk.resources import OTELResourceDetector, Resource + + # Create base resource attributes with LiteLLM-specific defaults + # These will be overridden by OTEL_RESOURCE_ATTRIBUTES if present + base_attributes: Dict[str, Optional[str]] = { + "service.name": os.getenv("OTEL_SERVICE_NAME", "litellm"), + "deployment.environment": os.getenv("OTEL_ENVIRONMENT_NAME", "production"), + # Fix the model_id to use proper environment variable or default to service name + "model_id": os.getenv( + "OTEL_MODEL_ID", os.getenv("OTEL_SERVICE_NAME", "litellm") + ), + } + + # Create base resource with LiteLLM-specific defaults + base_resource = Resource.create(base_attributes) # type: ignore + + # Create resource from OTEL_RESOURCE_ATTRIBUTES using the detector + otel_resource_detector = OTELResourceDetector() + env_resource = otel_resource_detector.detect() + + # Merge the resources: env_resource takes precedence over base_resource + # This ensures OTEL_RESOURCE_ATTRIBUTES overrides LiteLLM defaults + merged_resource = base_resource.merge(env_resource) + + return merged_resource + + @dataclass class OpenTelemetryConfig: exporter: Union[str, SpanExporter] = "console" @@ -94,7 +122,6 @@ class OpenTelemetry(CustomLogger): **kwargs, ): from opentelemetry import trace - from opentelemetry.sdk.resources import Resource from opentelemetry.sdk.trace import TracerProvider from opentelemetry.trace import SpanKind @@ -105,7 +132,7 @@ class OpenTelemetry(CustomLogger): self.OTEL_EXPORTER = self.config.exporter self.OTEL_ENDPOINT = self.config.endpoint self.OTEL_HEADERS = self.config.headers - provider = TracerProvider(resource=Resource(attributes=LITELLM_RESOURCE)) + provider = TracerProvider(resource=_get_litellm_resource()) provider.add_span_processor(self._get_span_processor()) self.callback_name = callback_name @@ -316,7 +343,7 @@ class OpenTelemetry(CustomLogger): # End Parent OTEL Sspan parent_otel_span.end(end_time=self._to_ns(datetime.now())) - + ######################################################### # Team/Key Based Logging Control Flow ######################################################### @@ -331,43 +358,48 @@ class OpenTelemetry(CustomLogger): Tracer: The tracer to use for this request """ dynamic_headers = self._get_dynamic_otel_headers_from_kwargs(kwargs) - + if dynamic_headers is not None: # Create spans using a temporary tracer with dynamic headers tracer_to_use = self._get_tracer_with_dynamic_headers(dynamic_headers) - verbose_logger.debug("Using dynamic headers for this request: %s", dynamic_headers) + verbose_logger.debug( + "Using dynamic headers for this request: %s", dynamic_headers + ) else: tracer_to_use = self.tracer - + return tracer_to_use - + def _get_dynamic_otel_headers_from_kwargs(self, kwargs) -> Optional[dict]: """Extract dynamic headers from kwargs if available.""" - standard_callback_dynamic_params: Optional[ - StandardCallbackDynamicParams - ] = kwargs.get("standard_callback_dynamic_params") - + standard_callback_dynamic_params: Optional[StandardCallbackDynamicParams] = ( + kwargs.get("standard_callback_dynamic_params") + ) + if not standard_callback_dynamic_params: return None dynamic_headers = self.construct_dynamic_otel_headers( standard_callback_dynamic_params=standard_callback_dynamic_params ) - + return dynamic_headers if dynamic_headers else None def _get_tracer_with_dynamic_headers(self, dynamic_headers: dict): """Create a temporary tracer with dynamic headers for this request only.""" - from opentelemetry.sdk.resources import Resource from opentelemetry.sdk.trace import TracerProvider # Create a temporary tracer provider with dynamic headers - temp_provider = TracerProvider(resource=Resource(attributes=LITELLM_RESOURCE)) - temp_provider.add_span_processor(self._get_span_processor(dynamic_headers=dynamic_headers)) - + temp_provider = TracerProvider(resource=_get_litellm_resource()) + temp_provider.add_span_processor( + self._get_span_processor(dynamic_headers=dynamic_headers) + ) + return temp_provider.get_tracer(LITELLM_TRACER_NAME) - def construct_dynamic_otel_headers(self, standard_callback_dynamic_params: StandardCallbackDynamicParams) -> Optional[dict]: + def construct_dynamic_otel_headers( + self, standard_callback_dynamic_params: StandardCallbackDynamicParams + ) -> Optional[dict]: """ Construct dynamic headers from standard callback dynamic params @@ -377,7 +409,7 @@ class OpenTelemetry(CustomLogger): dict: A dictionary of dynamic headers """ return None - + ######################################################### # End of Team/Key Based Logging Control Flow ######################################################### @@ -391,7 +423,7 @@ class OpenTelemetry(CustomLogger): kwargs, self.config, ) - + _parent_context, parent_otel_span = self._get_span_context(kwargs) # Span 1: Request sent to litellm SDK otel_tracer: Tracer = self.get_tracer_to_use_for_request(kwargs) @@ -488,7 +520,6 @@ class OpenTelemetry(CustomLogger): guardrail_span.end(end_time=self._to_ns(end_time_datetime)) - def _handle_failure(self, kwargs, response_obj, start_time, end_time): from opentelemetry.trace import Status, StatusCode diff --git a/litellm/proxy/_new_secret_config.yaml b/litellm/proxy/_new_secret_config.yaml index 8a18ff49c2..f76f86301d 100644 --- a/litellm/proxy/_new_secret_config.yaml +++ b/litellm/proxy/_new_secret_config.yaml @@ -9,6 +9,5 @@ model_list: prompt_id: test-chat-prompt prompt_version: 4 - litellm_settings: - store_audit_logs: true \ No newline at end of file + public_model_groups: ["gpt-3.5-turbo"] \ No newline at end of file diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 76c66b7cc3..22631e507a 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -448,6 +448,7 @@ class LiteLLMRoutes(enum.Enum): "/metrics", "/litellm/.well-known/litellm-ui-config", "/.well-known/litellm-ui-config", + "/public/model_hub", ] ) @@ -877,6 +878,7 @@ class LiteLLM_MCPServerTable(LiteLLMPydanticObjectBase): teams: List[Dict[str, Optional[str]]] = Field(default_factory=list) mcp_info: Optional[MCPInfo] = None + class NewUserRequestTeam(LiteLLMPydanticObjectBase): team_id: str max_budget_in_team: Optional[float] = None diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 9c4a2bdee5..4af6c000f5 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -300,6 +300,7 @@ from litellm.proxy.pass_through_endpoints.pass_through_endpoints import ( from litellm.proxy.pass_through_endpoints.pass_through_endpoints import ( router as pass_through_router, ) +from litellm.proxy.public_endpoints import router as public_endpoints_router from litellm.proxy.rerank_endpoints.endpoints import router as rerank_router from litellm.proxy.response_api_endpoints.endpoints import router as response_router from litellm.proxy.route_llm_request import route_request @@ -6507,12 +6508,15 @@ def _get_model_group_info( llm_router: Router, all_models_str: List[str], model_group: Optional[str] ) -> List[ModelGroupInfo]: model_groups: List[ModelGroupInfo] = [] + # ensure all_models_str is a set + all_models_str_set = set(all_models_str) - for model in all_models_str: + for model in all_models_str_set: if model_group is not None and model_group != model: continue _model_group_info = llm_router.get_model_group_info(model_group=model) + if _model_group_info is not None: model_groups.append(_model_group_info) else: @@ -8630,6 +8634,7 @@ async def get_routes(): app.include_router(router) app.include_router(response_router) app.include_router(batches_router) +app.include_router(public_endpoints_router) app.include_router(rerank_router) app.include_router(image_router) app.include_router(fine_tuning_router) diff --git a/litellm/proxy/public_endpoints/__init__.py b/litellm/proxy/public_endpoints/__init__.py new file mode 100644 index 0000000000..50eaf69e66 --- /dev/null +++ b/litellm/proxy/public_endpoints/__init__.py @@ -0,0 +1,3 @@ +from .public_endpoints import router + +__all__ = ["router"] diff --git a/litellm/proxy/public_endpoints/public_endpoints.py b/litellm/proxy/public_endpoints/public_endpoints.py new file mode 100644 index 0000000000..af33d68905 --- /dev/null +++ b/litellm/proxy/public_endpoints/public_endpoints.py @@ -0,0 +1,35 @@ +from typing import List + +from fastapi import APIRouter, Depends, HTTPException + +from litellm.proxy._types import CommonProxyErrors +from litellm.proxy.auth.user_api_key_auth import user_api_key_auth +from litellm.types.router import ModelGroupInfo + +router = APIRouter() + + +@router.get( + "/public/model_hub", + tags=["public", "model management"], + dependencies=[Depends(user_api_key_auth)], + response_model=List[ModelGroupInfo], +) +async def public_model_hub(): + import litellm + from litellm.proxy.proxy_server import _get_model_group_info, llm_router + + if llm_router is None: + raise HTTPException( + status_code=400, detail=CommonProxyErrors.no_llm_router.value + ) + + model_groups: List[ModelGroupInfo] = [] + if litellm.public_model_groups is not None: + model_groups = _get_model_group_info( + llm_router=llm_router, + all_models_str=litellm.public_model_groups, + model_group=None, + ) + + return model_groups diff --git a/litellm/types/router.py b/litellm/types/router.py index 94e3521dbd..e9e9b731eb 100644 --- a/litellm/types/router.py +++ b/litellm/types/router.py @@ -97,16 +97,18 @@ class ModelInfo(BaseModel): id: Optional[ str ] # Allow id to be optional on input, but it will always be present as a str in the model instance - db_model: bool = False # used for proxy - to separate models which are stored in the db vs. config. + db_model: bool = ( + False # used for proxy - to separate models which are stored in the db vs. config. + ) updated_at: Optional[datetime.datetime] = None updated_by: Optional[str] = None created_at: Optional[datetime.datetime] = None created_by: Optional[str] = None - base_model: Optional[ - str - ] = None # specify if the base model is azure/gpt-3.5-turbo etc for accurate cost tracking + base_model: Optional[str] = ( + None # specify if the base model is azure/gpt-3.5-turbo etc for accurate cost tracking + ) tier: Optional[Literal["free", "paid"]] = None """ @@ -181,12 +183,12 @@ class GenericLiteLLMParams(CredentialLiteLLMParams, CustomPricingLiteLLMParams): custom_llm_provider: Optional[str] = None tpm: Optional[int] = None rpm: Optional[int] = None - timeout: Optional[ - Union[float, str, httpx.Timeout] - ] = None # if str, pass in as os.environ/ - stream_timeout: Optional[ - Union[float, str] - ] = None # timeout when making stream=True calls, if str, pass in as os.environ/ + timeout: Optional[Union[float, str, httpx.Timeout]] = ( + None # if str, pass in as os.environ/ + ) + stream_timeout: Optional[Union[float, str]] = ( + None # timeout when making stream=True calls, if str, pass in as os.environ/ + ) max_retries: Optional[int] = None organization: Optional[str] = None # for openai orgs configurable_clientside_auth_params: CONFIGURABLE_CLIENTSIDE_AUTH_PARAMS = None @@ -261,9 +263,9 @@ class GenericLiteLLMParams(CredentialLiteLLMParams, CustomPricingLiteLLMParams): if max_retries is not None and isinstance(max_retries, str): max_retries = int(max_retries) # cast to int # We need to keep max_retries in args since it's a parameter of GenericLiteLLMParams - args[ - "max_retries" - ] = max_retries # Put max_retries back in args after popping it + args["max_retries"] = ( + max_retries # Put max_retries back in args after popping it + ) super().__init__(**args, **params) def __contains__(self, key): @@ -732,21 +734,27 @@ class LiteLLM_RouterFileObject(TypedDict, total=False): litellm_params_sensitive_credential_hash: str file_object: OpenAIFileObject + @dataclass class MockRouterTestingParams: mock_testing_fallbacks: Optional[bool] = None mock_testing_context_fallbacks: Optional[bool] = None mock_testing_content_policy_fallbacks: Optional[bool] = None - + @classmethod - def from_kwargs(cls, kwargs: dict) -> 'MockRouterTestingParams': + def from_kwargs(cls, kwargs: dict) -> "MockRouterTestingParams": from litellm.secret_managers.main import str_to_bool + def extract_bool_param(name: str) -> Optional[bool]: value = kwargs.pop(name, None) return str_to_bool(value) if isinstance(value, str) else value - + return cls( mock_testing_fallbacks=extract_bool_param("mock_testing_fallbacks"), - mock_testing_context_fallbacks=extract_bool_param("mock_testing_context_fallbacks"), - mock_testing_content_policy_fallbacks=extract_bool_param("mock_testing_content_policy_fallbacks") - ) \ No newline at end of file + mock_testing_context_fallbacks=extract_bool_param( + "mock_testing_context_fallbacks" + ), + mock_testing_content_policy_fallbacks=extract_bool_param( + "mock_testing_content_policy_fallbacks" + ), + ) diff --git a/tests/test_litellm/integrations/test_opentelemetry.py b/tests/test_litellm/integrations/test_opentelemetry.py index 1628f8a782..e11895e30e 100644 --- a/tests/test_litellm/integrations/test_opentelemetry.py +++ b/tests/test_litellm/integrations/test_opentelemetry.py @@ -72,22 +72,32 @@ class TestOpenTelemetry(unittest.TestCase): # Setup otel = OpenTelemetry() otel.tracer = MagicMock() - + # Mock the dynamic header extraction and tracer creation - with patch.object(otel, '_get_dynamic_otel_headers_from_kwargs') as mock_get_headers, \ - patch.object(otel, '_get_tracer_with_dynamic_headers') as mock_get_tracer: - + with patch.object( + otel, "_get_dynamic_otel_headers_from_kwargs" + ) as mock_get_headers, patch.object( + otel, "_get_tracer_with_dynamic_headers" + ) as mock_get_tracer: + # Test case 1: With dynamic headers - mock_get_headers.return_value = {"arize-space-id": "test-space", "api_key": "test-key"} + mock_get_headers.return_value = { + "arize-space-id": "test-space", + "api_key": "test-key", + } mock_dynamic_tracer = MagicMock() mock_get_tracer.return_value = mock_dynamic_tracer - - kwargs = {"standard_callback_dynamic_params": {"arize_space_key": "test-space"}} + + kwargs = { + "standard_callback_dynamic_params": {"arize_space_key": "test-space"} + } result = otel.get_tracer_to_use_for_request(kwargs) - + # Assertions mock_get_headers.assert_called_once_with(kwargs) - mock_get_tracer.assert_called_once_with({"arize-space-id": "test-space", "api_key": "test-key"}) + mock_get_tracer.assert_called_once_with( + {"arize-space-id": "test-space", "api_key": "test-key"} + ) self.assertEqual(result, mock_dynamic_tracer) def test_get_tracer_to_use_for_request_without_dynamic_headers(self): @@ -95,14 +105,16 @@ class TestOpenTelemetry(unittest.TestCase): # Setup otel = OpenTelemetry() otel.tracer = MagicMock() - + # Mock the dynamic header extraction to return None - with patch.object(otel, '_get_dynamic_otel_headers_from_kwargs') as mock_get_headers: + with patch.object( + otel, "_get_dynamic_otel_headers_from_kwargs" + ) as mock_get_headers: mock_get_headers.return_value = None - + kwargs = {} result = otel.get_tracer_to_use_for_request(kwargs) - + # Assertions mock_get_headers.assert_called_once_with(kwargs) self.assertEqual(result, otel.tracer) @@ -111,35 +123,42 @@ class TestOpenTelemetry(unittest.TestCase): """Test that _get_dynamic_otel_headers_from_kwargs correctly extracts dynamic headers from kwargs.""" # Setup otel = OpenTelemetry() - + # Mock the construct_dynamic_otel_headers method - with patch.object(otel, 'construct_dynamic_otel_headers') as mock_construct: + with patch.object(otel, "construct_dynamic_otel_headers") as mock_construct: # Test case 1: With standard_callback_dynamic_params - mock_construct.return_value = {"arize-space-id": "test-space", "api_key": "test-key"} - + mock_construct.return_value = { + "arize-space-id": "test-space", + "api_key": "test-key", + } + standard_params = { "arize_space_key": "test-space", - "arize_api_key": "test-key" + "arize_api_key": "test-key", } kwargs = {"standard_callback_dynamic_params": standard_params} - + result = otel._get_dynamic_otel_headers_from_kwargs(kwargs) - + # Assertions - mock_construct.assert_called_once_with(standard_callback_dynamic_params=standard_params) - self.assertEqual(result, {"arize-space-id": "test-space", "api_key": "test-key"}) - + mock_construct.assert_called_once_with( + standard_callback_dynamic_params=standard_params + ) + self.assertEqual( + result, {"arize-space-id": "test-space", "api_key": "test-key"} + ) + # Test case 2: Without standard_callback_dynamic_params kwargs_empty = {} result_empty = otel._get_dynamic_otel_headers_from_kwargs(kwargs_empty) - + # Should return None when no dynamic params self.assertIsNone(result_empty) - + # Test case 3: With empty construct result mock_construct.return_value = {} result_empty_construct = otel._get_dynamic_otel_headers_from_kwargs(kwargs) - + # Should return None when construct returns empty dict self.assertIsNone(result_empty_construct) @@ -149,28 +168,234 @@ class TestOpenTelemetry(unittest.TestCase): """Test that _get_tracer_with_dynamic_headers creates a temporary tracer with dynamic headers.""" # Setup otel = OpenTelemetry() - + # Mock the span processor creation - with patch.object(otel, '_get_span_processor') as mock_get_span_processor: + with patch.object(otel, "_get_span_processor") as mock_get_span_processor: mock_span_processor = MagicMock() mock_get_span_processor.return_value = mock_span_processor - + # Mock the tracer provider and its methods mock_provider_instance = MagicMock() mock_tracer_provider.return_value = mock_provider_instance mock_tracer = MagicMock() mock_provider_instance.get_tracer.return_value = mock_tracer - + # Mock the resource mock_resource_instance = MagicMock() mock_resource.return_value = mock_resource_instance - + # Test dynamic_headers = {"arize-space-id": "test-space", "api_key": "test-key"} result = otel._get_tracer_with_dynamic_headers(dynamic_headers) - + # Assertions - mock_get_span_processor.assert_called_once_with(dynamic_headers=dynamic_headers) - mock_provider_instance.add_span_processor.assert_called_once_with(mock_span_processor) + mock_get_span_processor.assert_called_once_with( + dynamic_headers=dynamic_headers + ) + mock_provider_instance.add_span_processor.assert_called_once_with( + mock_span_processor + ) mock_provider_instance.get_tracer.assert_called_once_with("litellm") self.assertEqual(result, mock_tracer) + + @patch.dict(os.environ, {}, clear=True) + @patch("opentelemetry.sdk.resources.Resource.create") + @patch("opentelemetry.sdk.resources.OTELResourceDetector") + def test_get_litellm_resource_with_defaults( + self, mock_detector_cls, mock_resource_create + ): + """Test _get_litellm_resource with default values when no environment variables are set.""" + from litellm.integrations.opentelemetry import _get_litellm_resource + + # Mock the Resource.create method + mock_base_resource = MagicMock() + mock_resource_create.return_value = mock_base_resource + + # Mock the OTELResourceDetector + mock_detector = MagicMock() + mock_detector_cls.return_value = mock_detector + mock_env_resource = MagicMock() + mock_detector.detect.return_value = mock_env_resource + + # Mock the merged resource + mock_merged_resource = MagicMock() + mock_base_resource.merge.return_value = mock_merged_resource + + # Call the function + result = _get_litellm_resource() + + # Verify Resource.create was called with correct default attributes + expected_attributes = { + "service.name": "litellm", + "deployment.environment": "production", + "model_id": "litellm", + } + mock_resource_create.assert_called_once_with(expected_attributes) + mock_detector.detect.assert_called_once() + mock_base_resource.merge.assert_called_once_with(mock_env_resource) + self.assertEqual(result, mock_merged_resource) + + @patch.dict( + os.environ, + { + "OTEL_SERVICE_NAME": "test-service", + "OTEL_ENVIRONMENT_NAME": "staging", + "OTEL_MODEL_ID": "test-model", + }, + clear=True, + ) + @patch("opentelemetry.sdk.resources.Resource.create") + @patch("opentelemetry.sdk.resources.OTELResourceDetector") + def test_get_litellm_resource_with_litellm_env_vars( + self, mock_detector_cls, mock_resource_create + ): + """Test _get_litellm_resource with LiteLLM-specific environment variables.""" + from litellm.integrations.opentelemetry import _get_litellm_resource + + # Mock the Resource.create method + mock_base_resource = MagicMock() + mock_resource_create.return_value = mock_base_resource + + # Mock the OTELResourceDetector + mock_detector = MagicMock() + mock_detector_cls.return_value = mock_detector + mock_env_resource = MagicMock() + mock_detector.detect.return_value = mock_env_resource + + # Mock the merged resource + mock_merged_resource = MagicMock() + mock_base_resource.merge.return_value = mock_merged_resource + + # Call the function + result = _get_litellm_resource() + + # Verify Resource.create was called with environment variable values + expected_attributes = { + "service.name": "test-service", + "deployment.environment": "staging", + "model_id": "test-model", + } + mock_resource_create.assert_called_once_with(expected_attributes) + mock_detector.detect.assert_called_once() + mock_base_resource.merge.assert_called_once_with(mock_env_resource) + self.assertEqual(result, mock_merged_resource) + + @patch.dict( + os.environ, + { + "OTEL_RESOURCE_ATTRIBUTES": "service.name=otel-service,deployment.environment=production,custom.attr=value", + "OTEL_SERVICE_NAME": "should-be-overridden", + }, + clear=True, + ) + @patch("opentelemetry.sdk.resources.Resource.create") + @patch("opentelemetry.sdk.resources.OTELResourceDetector") + def test_get_litellm_resource_with_otel_resource_attributes( + self, mock_detector_cls, mock_resource_create + ): + """Test _get_litellm_resource with OTEL_RESOURCE_ATTRIBUTES environment variable.""" + from litellm.integrations.opentelemetry import _get_litellm_resource + + # Mock the Resource.create method to simulate the actual behavior + # In reality, Resource.create() would parse OTEL_RESOURCE_ATTRIBUTES and merge it + mock_base_resource = MagicMock() + mock_resource_create.return_value = mock_base_resource + + # Mock the OTELResourceDetector + mock_detector = MagicMock() + mock_detector_cls.return_value = mock_detector + mock_env_resource = MagicMock() + mock_detector.detect.return_value = mock_env_resource + + # Mock the merged resource + mock_merged_resource = MagicMock() + mock_base_resource.merge.return_value = mock_merged_resource + + # Call the function + result = _get_litellm_resource() + + # Verify Resource.create was called with the base attributes + # The actual OTEL_RESOURCE_ATTRIBUTES parsing is handled by OpenTelemetry SDK + expected_attributes = { + "service.name": "should-be-overridden", + "deployment.environment": "production", + "model_id": "should-be-overridden", + } + mock_resource_create.assert_called_once_with(expected_attributes) + mock_detector.detect.assert_called_once() + mock_base_resource.merge.assert_called_once_with(mock_env_resource) + self.assertEqual(result, mock_merged_resource) + + @patch.dict(os.environ, {}, clear=True) + def test_get_litellm_resource_integration_with_real_resource(self): + """Integration test to verify _get_litellm_resource works with actual OpenTelemetry Resource.""" + from litellm.integrations.opentelemetry import _get_litellm_resource + + # This test uses the real OpenTelemetry Resource.create() method + result = _get_litellm_resource() + + # Verify the result is a Resource instance + from opentelemetry.sdk.resources import Resource + + self.assertIsInstance(result, Resource) + + # Verify the resource has the expected default attributes + attributes = result.attributes + self.assertEqual(attributes.get("service.name"), "litellm") + self.assertEqual(attributes.get("deployment.environment"), "production") + self.assertEqual(attributes.get("model_id"), "litellm") + + @patch.dict( + os.environ, + { + "OTEL_RESOURCE_ATTRIBUTES": "service.name=from-env,custom.attribute=test-value,deployment.environment=test-env" + }, + clear=True, + ) + def test_get_litellm_resource_real_otel_resource_attributes(self): + """Integration test to verify OTEL_RESOURCE_ATTRIBUTES is properly handled.""" + from litellm.integrations.opentelemetry import _get_litellm_resource + + # This test uses the real OpenTelemetry Resource.create() method + result = _get_litellm_resource() + + print("RESULT", result) + + # Verify the result is a Resource instance + from opentelemetry.sdk.resources import Resource + + self.assertIsInstance(result, Resource) + + # Verify that OTEL_RESOURCE_ATTRIBUTES values override the defaults + attributes = result.attributes + self.assertEqual(attributes.get("service.name"), "from-env") + self.assertEqual(attributes.get("deployment.environment"), "test-env") + self.assertEqual(attributes.get("custom.attribute"), "test-value") + # model_id should still be set from the base attributes since it wasn't in OTEL_RESOURCE_ATTRIBUTES + self.assertEqual(attributes.get("model_id"), "litellm") + + @patch.dict( + os.environ, + { + "OTEL_SERVICE_NAME": "litellm-service", + "OTEL_RESOURCE_ATTRIBUTES": "service.name=otel-override,extra.attr=extra-value", + }, + clear=True, + ) + def test_get_litellm_resource_precedence(self): + """Test that OTEL_SERVICE_NAME takes precedence over OTEL_RESOURCE_ATTRIBUTES according to OpenTelemetry spec.""" + from litellm.integrations.opentelemetry import _get_litellm_resource + + # This test verifies the OpenTelemetry standard behavior + result = _get_litellm_resource() + + # Verify the result is a Resource instance + from opentelemetry.sdk.resources import Resource + + self.assertIsInstance(result, Resource) + + # According to OpenTelemetry spec, OTEL_SERVICE_NAME takes precedence over service.name in OTEL_RESOURCE_ATTRIBUTES + attributes = result.attributes + self.assertEqual(attributes.get("service.name"), "litellm-service") + # But other attributes from OTEL_RESOURCE_ATTRIBUTES should still be present + self.assertEqual(attributes.get("extra.attr"), "extra-value") diff --git a/ui/litellm-dashboard/src/app/model_hub_table/page.tsx b/ui/litellm-dashboard/src/app/model_hub_table/page.tsx new file mode 100644 index 0000000000..06c66dedff --- /dev/null +++ b/ui/litellm-dashboard/src/app/model_hub_table/page.tsx @@ -0,0 +1,25 @@ +"use client"; +import React, { Suspense, useEffect, useState } from "react"; +import { useSearchParams } from "next/navigation"; +import { modelHubCall } from "@/components/networking"; +import ModelHubTable from "@/components/model_hub_table"; + +export default function PublicModelHubTable() { + const searchParams = useSearchParams()!; + const key = searchParams.get("key"); + const [accessToken, setAccessToken] = useState(null); + + useEffect(() => { + if (!key) { + return; + } + setAccessToken(key); + }, [key]); + /** + * populate navbar + * + */ + return ( + + ); +} \ No newline at end of file diff --git a/ui/litellm-dashboard/src/app/page.tsx b/ui/litellm-dashboard/src/app/page.tsx index 9d1d49545f..59b9e0214c 100644 --- a/ui/litellm-dashboard/src/app/page.tsx +++ b/ui/litellm-dashboard/src/app/page.tsx @@ -19,6 +19,7 @@ import PassThroughSettings from "@/components/pass_through_settings"; import BudgetPanel from "@/components/budgets/budget_panel"; import SpendLogsTable from "@/components/view_logs"; import ModelHub from "@/components/model_hub"; +import ModelHubTable from "@/components/model_hub_table"; import NewUsagePage from "@/components/new_usage"; import APIRef from "@/components/api_ref"; import ChatUI from "@/components/chat_ui"; @@ -375,6 +376,12 @@ export default function CreateKeyPage() { publicPage={false} premiumUser={premiumUser} /> + ) : page == "model-hub-table" ? ( + ) : page == "caching" ? ( = ({ { key: "17", page: "organizations", label: "Organizations", icon: , roles: all_admin_roles }, { key: "5", page: "users", label: "Internal Users", icon: , roles: all_admin_roles }, { key: "14", page: "api_ref", label: "API Reference", icon: }, - { key: "16", page: "model-hub", label: "Model Hub", icon: }, + { + key: "16", + page: "model-hub", + label: "Model Hub", + icon: , + children: [ + { key: "16a", page: "model-hub", label: "Card View", icon: }, + { key: "16b", page: "model-hub-table", label: "Table View", icon: } + ] + }, { key: "15", page: "logs", label: "Logs", icon: }, { key: "11", page: "guardrails", label: "Guardrails", icon: , roles: all_admin_roles }, { key: "18", page: "mcp-servers", label: "MCP Servers", icon: }, diff --git a/ui/litellm-dashboard/src/components/model_dashboard/table.tsx b/ui/litellm-dashboard/src/components/model_dashboard/table.tsx index c3e0c03c5f..30f01d91db 100644 --- a/ui/litellm-dashboard/src/components/model_dashboard/table.tsx +++ b/ui/litellm-dashboard/src/components/model_dashboard/table.tsx @@ -20,22 +20,29 @@ import { } from "@tremor/react"; import { SwitchVerticalIcon, ChevronUpIcon, ChevronDownIcon, TableIcon } from "@heroicons/react/outline"; +// Extend the column meta type to include className +declare module "@tanstack/react-table" { + interface ColumnMeta { + className?: string; + } +} + interface ModelDataTableProps { data: TData[]; columns: ColumnDef[]; isLoading?: boolean; table: any; // Add table prop to access column visibility controls + defaultSorting?: SortingState; } export function ModelDataTable({ data = [], columns, isLoading = false, - table + table, + defaultSorting = [] }: ModelDataTableProps) { - const [sorting, setSorting] = React.useState([ - { id: "model_info.created_at", desc: true } - ]); + const [sorting, setSorting] = React.useState(defaultSorting); const [columnResizeMode] = React.useState("onChange"); const [columnSizing, setColumnSizing] = React.useState({}); const [columnVisibility, setColumnVisibility] = React.useState({}); @@ -103,7 +110,7 @@ export function ModelDataTable({ header.id === 'actions' ? 'sticky right-0 bg-white shadow-[-4px_0_8px_-6px_rgba(0,0,0,0.1)] z-20 w-[120px] ml-8' : '' - }`} + } ${header.column.columnDef.meta?.className || ''}`} style={{ width: header.id === 'actions' ? 120 : header.getSize(), position: header.id === 'actions' ? 'sticky' : 'relative', @@ -166,7 +173,7 @@ export function ModelDataTable({ cell.column.id === 'actions' ? 'sticky right-0 bg-white shadow-[-4px_0_8px_-6px_rgba(0,0,0,0.1)] z-20 w-[120px] ml-8' : '' - }`} + } ${cell.column.columnDef.meta?.className || ''}`} style={{ width: cell.column.id === 'actions' ? 120 : cell.column.getSize(), position: cell.column.id === 'actions' ? 'sticky' : 'relative', diff --git a/ui/litellm-dashboard/src/components/model_hub_table.tsx b/ui/litellm-dashboard/src/components/model_hub_table.tsx new file mode 100644 index 0000000000..4a5746b9b5 --- /dev/null +++ b/ui/litellm-dashboard/src/components/model_hub_table.tsx @@ -0,0 +1,487 @@ +import React, { useEffect, useState, useRef } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { modelHubCall } from "./networking"; +import { getConfigFieldSetting, updateConfigFieldSetting } from "./networking"; +import { ModelDataTable } from "./model_dashboard/table"; +import { modelHubColumns } from "./model_hub_table_columns"; +import { + Card, + Text, + Title, + Button, + Badge, + Flex, +} from "@tremor/react"; +import { Modal, message } from "antd"; +import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; +import { Table as TableInstance } from '@tanstack/react-table'; + +interface ModelHubTableProps { + accessToken: string | null; + publicPage: boolean; + premiumUser: boolean; +} + +interface ModelGroupInfo { + model_group: string; + providers: string[]; + max_input_tokens?: number; + max_output_tokens?: number; + input_cost_per_token?: number; + output_cost_per_token?: number; + mode?: string; + tpm?: number; + rpm?: number; + supports_parallel_function_calling: boolean; + supports_vision: boolean; + supports_function_calling: boolean; + supported_openai_params?: string[]; + public?: boolean; // Whether the model is public (defaults to false) + // Allow any additional properties for flexibility + [key: string]: any; +} + +const ModelHubTable: React.FC = ({ + accessToken, + publicPage, + premiumUser, +}) => { + const [publicPageAllowed, setPublicPageAllowed] = useState(false); + const [modelHubData, setModelHubData] = useState(null); + const [loading, setLoading] = useState(true); + const [isModalVisible, setIsModalVisible] = useState(false); + const [isPublicPageModalVisible, setIsPublicPageModalVisible] = useState(false); + const [selectedModel, setSelectedModel] = useState(null); + const [searchTerm, setSearchTerm] = useState(""); + const [selectedProvider, setSelectedProvider] = useState(""); + const [selectedMode, setSelectedMode] = useState(""); + const [selectedModels, setSelectedModels] = useState>(new Set()); + const router = useRouter(); + const tableRef = useRef>(null); + + useEffect(() => { + if (!accessToken) { + return; + } + + const fetchData = async () => { + try { + setLoading(true); + const _modelHubData = await modelHubCall(accessToken); + console.log("ModelHubData:", _modelHubData); + setModelHubData(_modelHubData.data); + + getConfigFieldSetting(accessToken, "enable_public_model_hub") + .then((data) => { + console.log(`data: ${JSON.stringify(data)}`); + if (data.field_value == true) { + setPublicPageAllowed(true); + } + }) + .catch((error) => { + // do nothing + }); + } catch (error) { + console.error("There was an error fetching the model data", error); + } finally { + setLoading(false); + } + }; + + fetchData(); + }, [accessToken, publicPage]); + + const showModal = (model: ModelGroupInfo) => { + setSelectedModel(model); + setIsModalVisible(true); + }; + + const goToPublicModelPage = () => { + router.replace(`/model_hub_table?key=${accessToken}`); + }; + + const handleMakePublicPage = async () => { + if (!accessToken) { + return; + } + updateConfigFieldSetting(accessToken, "enable_public_model_hub", true).then( + (data) => { + setIsPublicPageModalVisible(true); + } + ); + }; + + const handleOk = () => { + setIsModalVisible(false); + setIsPublicPageModalVisible(false); + setSelectedModel(null); + }; + + const handleCancel = () => { + setIsModalVisible(false); + setIsPublicPageModalVisible(false); + setSelectedModel(null); + }; + + const copyToClipboard = (text: string) => { + navigator.clipboard.writeText(text); + message.success("Copied to clipboard!"); + }; + + const formatCapabilityName = (key: string) => { + // Remove 'supports_' prefix and convert snake_case to Title Case + return key + .replace(/^supports_/, '') + .split('_') + .map(word => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); + }; + + const getModelCapabilities = (model: ModelGroupInfo) => { + // Find all properties that start with 'supports_' and are true + return Object.entries(model) + .filter(([key, value]) => key.startsWith('supports_') && value === true) + .map(([key]) => key); + }; + + const formatCost = (cost: number) => { + return `$${(cost * 1_000_000).toFixed(2)}`; + }; + + const getUniqueProviders = (data: ModelGroupInfo[]) => { + const providers = new Set(); + data.forEach(model => { + model.providers.forEach(provider => providers.add(provider)); + }); + return Array.from(providers); + }; + + const getUniqueModes = (data: ModelGroupInfo[]) => { + const modes = new Set(); + data.forEach(model => { + if (model.mode) modes.add(model.mode); + }); + return Array.from(modes); + }; + + const filteredData = modelHubData?.filter(model => { + const matchesSearch = model.model_group.toLowerCase().includes(searchTerm.toLowerCase()); + const matchesProvider = selectedProvider === "" || model.providers.includes(selectedProvider); + const matchesMode = selectedMode === "" || model.mode === selectedMode; + return matchesSearch && matchesProvider && matchesMode; + }) || []; + + const handleRowSelection = (modelGroup: string, isSelected: boolean) => { + const newSelection = new Set(selectedModels); + if (isSelected) { + newSelection.add(modelGroup); + } else { + newSelection.delete(modelGroup); + } + setSelectedModels(newSelection); + }; + + const handleSelectAll = (checked: boolean) => { + console.log("checked", checked); + if (checked) { + const allModelGroups = filteredData.map(model => model.model_group); + setSelectedModels(new Set(allModelGroups)); + } else { + setSelectedModels(new Set()); + } + }; + + // Use the same logic as health check columns + const allModelsSelected = filteredData.length > 0 && filteredData.every(model => selectedModels.has(model.model_group)); + const isIndeterminate = selectedModels.size > 0 && !allModelsSelected; + + // Clear selections when filters change to avoid confusion + useEffect(() => { + setSelectedModels(new Set()); + }, [searchTerm, selectedProvider, selectedMode]); + + return ( +
+ {(publicPage && publicPageAllowed) || publicPage == false ? ( +
+
+ Model Hub - Table View + {publicPage == false ? ( + premiumUser ? ( + + ) : ( + + ) + ) : ( +
+ Filter by key: + {`/ui/model_hub_table?key=`} +
+ )} +
+ + {/* Filters */} + +
+
+ Search Models: + setSearchTerm(e.target.value)} + className="border rounded px-3 py-2 w-64 h-10 text-sm" + /> +
+
+ Provider: + +
+
+ Mode: + +
+
+
+ + {/* Model Table */} + + +
+ + Showing {filteredData.length} of {modelHubData?.length || 0} models + + {selectedModels.size > 0 && ( +
+ + {selectedModels.size} model{selectedModels.size !== 1 ? 's' : ''} selected + + +
+ )} +
+
+ ) : ( + + + Public Model Hub not enabled. + +

+ Ask your proxy admin to enable this on their Admin UI. +

+
+ )} + + {/* Public Page Modal */} + +
+
+ Shareable Link: + + {`/ui/model_hub_table?key=`} + +
+
+ +
+
+
+ + {/* Model Details Modal */} + + {selectedModel && ( +
+ {/* Model Overview */} +
+ Model Overview +
+
+ Model Group: + {selectedModel.model_group} +
+
+ Mode: + {selectedModel.mode || "Not specified"} +
+
+ Providers: +
+ {selectedModel.providers.map(provider => ( + {provider} + ))} +
+
+
+
+ + {/* Token and Cost Information */} +
+ Token & Cost Information +
+
+ Max Input Tokens: + {selectedModel.max_input_tokens?.toLocaleString() || "Not specified"} +
+
+ Max Output Tokens: + {selectedModel.max_output_tokens?.toLocaleString() || "Not specified"} +
+
+ Input Cost per 1M Tokens: + {selectedModel.input_cost_per_token ? formatCost(selectedModel.input_cost_per_token) : "Not specified"} +
+
+ Output Cost per 1M Tokens: + {selectedModel.output_cost_per_token ? formatCost(selectedModel.output_cost_per_token) : "Not specified"} +
+
+
+ + {/* Capabilities */} +
+ Capabilities +
+ {(() => { + const capabilities = getModelCapabilities(selectedModel); + const colors = ['green', 'blue', 'purple', 'orange', 'red', 'yellow']; + + if (capabilities.length === 0) { + return No special capabilities listed; + } + + return capabilities.map((capability, index) => ( + + {formatCapabilityName(capability)} + + )); + })()} +
+
+ + {/* Rate Limits */} + {(selectedModel.tpm || selectedModel.rpm) && ( +
+ Rate Limits +
+ {selectedModel.tpm && ( +
+ Tokens per Minute: + {selectedModel.tpm.toLocaleString()} +
+ )} + {selectedModel.rpm && ( +
+ Requests per Minute: + {selectedModel.rpm.toLocaleString()} +
+ )} +
+
+ )} + + {/* Supported OpenAI Parameters */} + {selectedModel.supported_openai_params && ( +
+ Supported OpenAI Parameters +
+ {selectedModel.supported_openai_params.map(param => ( + {param} + ))} +
+
+ )} + + {/* Usage Example */} +
+ Usage Example + + {`import openai + +client = openai.OpenAI( + api_key="your_api_key", + base_url="http://0.0.0.0:4000" # Your LiteLLM Proxy URL +) + +response = client.chat.completions.create( + model="${selectedModel.model_group}", + messages=[ + { + "role": "user", + "content": "Hello, how are you?" + } + ] +) + +print(response.choices[0].message.content)`} + +
+
+ )} +
+
+ ); +}; + +export default ModelHubTable; \ No newline at end of file diff --git a/ui/litellm-dashboard/src/components/model_hub_table_columns.tsx b/ui/litellm-dashboard/src/components/model_hub_table_columns.tsx new file mode 100644 index 0000000000..b25916497b --- /dev/null +++ b/ui/litellm-dashboard/src/components/model_hub_table_columns.tsx @@ -0,0 +1,281 @@ +import { ColumnDef } from "@tanstack/react-table"; +import { Button, Badge, Text } from "@tremor/react"; +import { Tooltip, Checkbox, Tag } from "antd"; +import { + CopyOutlined, + InfoCircleOutlined +} from "@ant-design/icons"; + +interface ModelHubData { + model_group: string; + providers: string[]; + max_input_tokens?: number; + max_output_tokens?: number; + input_cost_per_token?: number; + output_cost_per_token?: number; + mode?: string; + tpm?: number; + rpm?: number; + supports_parallel_function_calling: boolean; + supports_vision: boolean; + supports_function_calling: boolean; + supported_openai_params?: string[]; + public?: boolean; + [key: string]: any; +} + +const formatCapabilityName = (key: string) => { + return key + .replace(/^supports_/, '') + .split('_') + .map(word => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); +}; + +const getModelCapabilities = (model: ModelHubData) => { + return Object.entries(model) + .filter(([key, value]) => key.startsWith('supports_') && value === true) + .map(([key]) => key); +}; + +const formatCost = (cost: number) => { + return `$${(cost * 1_000_000).toFixed(2)}`; +}; + +const formatTokens = (tokens: number) => { + if (tokens >= 1_000_000) { + return `${(tokens / 1_000_000).toFixed(1)}M`; + } else if (tokens >= 1_000) { + return `${(tokens / 1_000).toFixed(1)}K`; + } + return tokens.toString(); +}; + +export const modelHubColumns = ( + selectedModels: Set, + allModelsSelected: boolean, + isIndeterminate: boolean, + handleModelSelection: (modelGroup: string, checked: boolean) => void, + handleSelectAll: (checked: boolean) => void, + showModal: (model: ModelHubData) => void, + copyToClipboard: (text: string) => void, +): ColumnDef[] => [ + { + header: () => ( + handleSelectAll(e.target.checked)} + onClick={(e) => e.stopPropagation()} + /> + ), + id: "select", + enableSorting: false, + cell: ({ row }) => { + const model = row.original; + const isSelected = selectedModels.has(model.model_group); + + return ( + handleModelSelection(model.model_group, e.target.checked)} + onClick={(e) => e.stopPropagation()} + /> + ); + }, + }, + { + header: "Model", + accessorKey: "model_group", + enableSorting: true, + sortingFn: "alphanumeric", + cell: ({ row }) => { + const model = row.original; + + return ( +
+
+ {model.model_group} + + copyToClipboard(model.model_group)} + className="cursor-pointer text-gray-500 hover:text-blue-500 text-xs" + /> + +
+ {/* Show provider on mobile when provider column is hidden */} +
+ + {model.providers.join(", ")} + +
+
+ ); + }, + }, + { + header: "Provider", + accessorKey: "providers", + enableSorting: true, + sortingFn: (rowA, rowB) => { + const providersA = rowA.original.providers.join(", "); + const providersB = rowB.original.providers.join(", "); + return providersA.localeCompare(providersB); + }, + cell: ({ row }) => { + const model = row.original; + + return ( +
+ {model.providers.slice(0, 2).map(provider => ( + + {provider} + + ))} + {model.providers.length > 2 && ( + +{model.providers.length - 2} + )} +
+ ); + }, + meta: { + className: "hidden md:table-cell", + }, + }, + { + header: "Mode", + accessorKey: "mode", + enableSorting: true, + sortingFn: "alphanumeric", + cell: ({ row }) => { + const model = row.original; + + return model.mode ? ( + {model.mode} + ) : ( + - + ); + }, + meta: { + className: "hidden lg:table-cell", + }, + }, + { + header: "Tokens", + accessorKey: "max_input_tokens", + enableSorting: true, + sortingFn: (rowA, rowB) => { + const tokensA = (rowA.original.max_input_tokens || 0) + (rowA.original.max_output_tokens || 0); + const tokensB = (rowB.original.max_input_tokens || 0) + (rowB.original.max_output_tokens || 0); + return tokensA - tokensB; + }, + cell: ({ row }) => { + const model = row.original; + + return ( +
+ + {model.max_input_tokens ? formatTokens(model.max_input_tokens) : "-"} / {model.max_output_tokens ? formatTokens(model.max_output_tokens) : "-"} + +
+ ); + }, + meta: { + className: "hidden lg:table-cell", + }, + }, + { + header: "Cost/1M", + accessorKey: "input_cost_per_token", + enableSorting: true, + sortingFn: (rowA, rowB) => { + const costA = (rowA.original.input_cost_per_token || 0) + (rowA.original.output_cost_per_token || 0); + const costB = (rowB.original.input_cost_per_token || 0) + (rowB.original.output_cost_per_token || 0); + return costA - costB; + }, + cell: ({ row }) => { + const model = row.original; + + return ( +
+ + {model.input_cost_per_token ? formatCost(model.input_cost_per_token) : "-"} + + + {model.output_cost_per_token ? formatCost(model.output_cost_per_token) : "-"} + +
+ ); + }, + }, + { + header: "Features", + accessorKey: "capabilities", + enableSorting: false, + cell: ({ row }) => { + const model = row.original; + const capabilities = getModelCapabilities(model); + const colors = ['green', 'blue', 'purple', 'orange', 'red', 'yellow']; + + return ( +
+ {capabilities.length === 0 ? ( + - + ) : ( + capabilities.map((capability, index) => ( + + {formatCapabilityName(capability)} + + )) + )} +
+ ); + }, + }, + { + header: "Public", + accessorKey: "public", + enableSorting: true, + sortingFn: (rowA, rowB) => { + const publicA = rowA.original.public === true ? 1 : 0; + const publicB = rowB.original.public === true ? 1 : 0; + return publicA - publicB; + }, + cell: ({ row }) => { + const model = row.original; + + return model.public === true ? ( + Yes + ) : ( + No + ); + }, + meta: { + className: "hidden md:table-cell", + }, + }, + { + header: "Details", + id: "details", + enableSorting: false, + cell: ({ row }) => { + const model = row.original; + + return ( + + ); + }, + }, +]; \ No newline at end of file