feat(model hub): show admin-defined custom tags and metadata from model_info (#46064)

* feat(model hub): show admin-defined custom tags and metadata from model_info

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* refactor(router): move model hub custom field merging into router_utils

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* refactor(model hub): share the custom tag badge list between the column and details

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(model hub): cover public hub, patch, null, hostile, forwarding, and killed-worker paths for custom fields

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(model hub): drop custom_metadata entries with non-string keys or non-finite numbers

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* refactor(model hub): use isinstance narrowing instead of TypeGuard for custom_metadata values

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* refactor(model hub): build custom tags and metadata in the proxy instead of the router

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(model hub): serve custom tags and metadata on model_group_alias rows

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* remove docstring from model_group_custom_fields.py

* style(model hub): fix lint and format in custom metadata tests

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* style(model hub): drop leading blank line in custom fields helper

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
jesus-berri 2026-10-20 17:45:06 -07:00 • committed by GitHub
parent 5e1e66d1ee
commit c8f7e0de7d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 604 additions and 8 deletions

View file

@ -0,0 +1,68 @@
import math
from collections.abc import Iterator, Mapping, Sequence
from types import MappingProxyType
from typing import Final, TypeAlias
from pydantic import TypeAdapter
CustomMetadataValue: TypeAlias = str | int | float | bool
_OBJECT_MAPPING_ADAPTER: Final = TypeAdapter(Mapping[object, object])
_OBJECT_LIST_ADAPTER: Final = TypeAdapter(list[object])
def _string_tags(tags: object) -> Iterator[str]:
if isinstance(tags, list):
yield from (tag for tag in _OBJECT_LIST_ADAPTER.validate_python(tags) if isinstance(tag, str))
def _is_non_finite(value: object) -> bool:
return isinstance(value, float) and not math.isfinite(value)
def _scalar_metadata(metadata: object) -> Iterator[tuple[str, CustomMetadataValue]]:
if isinstance(metadata, dict):
yield from (
(key, value)
for key, value in _OBJECT_MAPPING_ADAPTER.validate_python(metadata).items()
if isinstance(key, str) and isinstance(value, (str, int, float, bool)) and not _is_non_finite(value)
)
def _custom_tag_values(model_infos: Sequence[Mapping[object, object]]) -> Iterator[str]:
for model_info in model_infos:
yield from _string_tags(model_info.get("custom_tags"))
def _custom_metadata_entries(
model_infos: Sequence[Mapping[object, object]],
) -> Iterator[tuple[str, CustomMetadataValue]]:
for model_info in model_infos:
yield from _scalar_metadata(model_info.get("custom_metadata"))
def _merged_custom_tags(model_infos: Sequence[Mapping[object, object]]) -> tuple[str, ...] | None:
return tuple(dict.fromkeys(_custom_tag_values(model_infos))) or None
def _merged_custom_metadata(
model_infos: Sequence[Mapping[object, object]],
) -> Mapping[str, CustomMetadataValue] | None:
entries: Final = tuple(_custom_metadata_entries(model_infos))
first_seen: Final = MappingProxyType(dict(reversed(entries)))
return MappingProxyType({key: first_seen[key] for key in dict.fromkeys(key for key, _ in entries)}) or None
def _model_info(deployment: Mapping[str, object]) -> Mapping[object, object]:
model_info: Final = deployment.get("model_info")
return _OBJECT_MAPPING_ADAPTER.validate_python(model_info) if isinstance(model_info, dict) else MappingProxyType({})
def model_group_custom_fields(deployments: Sequence[Mapping[str, object]]) -> Mapping[str, object]:
model_infos: Final = tuple(_model_info(deployment) for deployment in deployments)
return MappingProxyType(
{
"custom_tags": _merged_custom_tags(model_infos),
"custom_metadata": _merged_custom_metadata(model_infos),
}
)

View file

@ -166,6 +166,7 @@ from litellm.proxy.common_utils.token_count_settings import (
resolve_token_count_settings,
)
from litellm.proxy.management_helpers.auto_router_availability import AutoRouterCatalogEntry, build_auto_router_catalog
from litellm.proxy.management_helpers.model_group_custom_fields import model_group_custom_fields
from litellm.router_utils.access_windows import access_windows_config_error
from litellm.router_utils.add_retry_fallback_headers import (
get_fallback_errors_from_headers,
@ -17747,10 +17748,7 @@ def get_model_group_info(
) -> list[ModelGroupInfoProxy]:
model_groups: Final[list[ModelGroupInfoProxy]] = []
unique_models: Final = []
for model in all_models_str:
if model not in unique_models:
unique_models.append(model)
unique_models: Final = tuple(dict.fromkeys(all_models_str))
for model in unique_models:
if model_group is not None and model_group != model:
@ -17759,7 +17757,18 @@ def get_model_group_info(
_model_group_info = llm_router.get_model_group_info(model_group=model, team_id=team_id)
if _model_group_info is not None:
model_groups.append(ModelGroupInfoProxy.model_validate(_model_group_info.model_dump()))
model_groups.append(
ModelGroupInfoProxy.model_validate(
{
**_model_group_info.model_dump(),
**model_group_custom_fields(
llm_router.get_model_list_of_routed_group(
resolve_model_group_alias(llm_router.model_group_alias, model) or model
)
),
}
)
)
else:
model_group_info = ModelGroupInfoProxy(
model_group=model,

View file

@ -1,3 +1,4 @@
from collections.abc import Mapping
from datetime import datetime
from typing import Any
@ -13,6 +14,8 @@ class ModelGroupInfoProxy(ModelGroupInfo):
health_status: str | None = Field(default=None)
health_response_time: float | None = Field(default=None)
health_checked_at: str | None = Field(default=None)
custom_tags: tuple[str, ...] | None = None
custom_metadata: Mapping[str, str | int | float | bool] | None = None
class UpdateUsefulLinksRequest(LiteLLMBaseModel):

View file

@ -0,0 +1,276 @@
import math
import uuid
from collections.abc import Mapping
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from types import MappingProxyType
from typing import Final
import httpx
import pytest
import yaml
from pydantic import JsonValue
from litellm.constants import PROXY_CONFIG_RELOAD_INTERVAL_SECONDS
from tests.integration._support.client import Gateway, Scenario, eventually, object_value, string_value
from tests.integration._support.process import graceful_stop_seconds, owned_proxy_process
LIFECYCLE_METADATA: Final = {"retirement_date": "2027-03-01", "ptu_max_tpm": 300000, "provisioned_ptu": 100}
def _model_group(gateway: Gateway, model_group: str) -> dict[str, JsonValue]:
data: Final = gateway.get("/model_group/info", params={"model_group": model_group})["data"]
assert isinstance(data, list) and len(data) == 1, data
return object_value(data[0])
def _deployment(gateway: Gateway, scenario: Scenario, model_name: str, model_info: dict[str, JsonValue]) -> str:
created: Final = gateway.post(
"/model/new",
{
"model_name": model_name,
"litellm_params": {
"model": "openai/gpt-4o-mini",
"api_key": "integration-provider-key",
"api_base": f"{gateway.upstream_url}/v1",
},
"model_info": model_info,
},
)
identity: Final = string_value(object_value(created["model_info"])["id"])
scenario.cleanups.callback(scenario.delete_model, identity)
return identity
def _custom_fields(row: dict[str, JsonValue]) -> tuple[JsonValue, JsonValue]:
assert "custom_tags" in row and "custom_metadata" in row, row
return row["custom_tags"], row["custom_metadata"]
def _public_row(gateway: Gateway, path: str, model_group: str) -> dict[str, JsonValue] | None:
response: Final = gateway.client.get(path)
assert response.status_code == 200, response.text
body: Final = response.json()
rows: Final = body if isinstance(body, list) else object_value(body)["data"]
assert isinstance(rows, list), response.text
return next((object_value(row) for row in rows if object_value(row).get("model_group") == model_group), None)
def test_model_group_info_serves_the_custom_tags_and_metadata_set_on_the_model(gateway: Gateway) -> None:
with gateway.scenario() as scenario:
model: Final = scenario.model(
model_info={"custom_tags": ["deprecated", "ptu"], "custom_metadata": LIFECYCLE_METADATA}
)
group: Final = _model_group(gateway, model)
assert group.get("custom_tags") == ["deprecated", "ptu"], group
assert group.get("custom_metadata") == LIFECYCLE_METADATA, group
def test_model_group_info_merges_custom_tags_and_metadata_across_the_groups_deployments(gateway: Gateway) -> None:
with gateway.scenario() as scenario:
model_name: Final = f"integration-{uuid.uuid4().hex}"
_deployment(
gateway,
scenario,
model_name,
{"custom_tags": ["ptu", "eu"], "custom_metadata": {"provisioned_ptu": 100, "region": "westeurope"}},
)
_deployment(
gateway,
scenario,
model_name,
{
"custom_tags": ["eu", "deprecated"],
"custom_metadata": {"provisioned_ptu": 50, "retirement_date": "2027-03-01"},
},
)
group: Final = _model_group(gateway, model_name)
assert sorted(string_value(tag) for tag in group.get("custom_tags") or []) == ["deprecated", "eu", "ptu"], group
metadata: Final = object_value(group.get("custom_metadata"))
assert metadata.get("region") == "westeurope", metadata
assert metadata.get("retirement_date") == "2027-03-01", metadata
assert metadata.get("provisioned_ptu") in (100, 50), metadata
def test_model_group_info_ignores_custom_metadata_values_that_are_not_scalars(gateway: Gateway) -> None:
with gateway.scenario() as scenario:
model: Final = scenario.model(
model_info={
"custom_tags": ["ok", 7, {"nested": True}],
"custom_metadata": {"owner": "platform", "nested": {"a": 1}, "list": [1, 2]},
}
)
group: Final = _model_group(gateway, model)
assert group.get("custom_tags") == ["ok"], group
assert group.get("custom_metadata") == {"owner": "platform"}, group
@pytest.mark.parametrize("path", ["/public/model_hub", "/public/v1/model_hub"])
def test_public_model_hub_serves_the_custom_tags_and_metadata_of_a_public_model_group(
gateway: Gateway, path: str
) -> None:
with gateway.scenario() as scenario:
model: Final = scenario.model(
model_info={"custom_tags": ["deprecated", "ptu"], "custom_metadata": LIFECYCLE_METADATA}
)
gateway.post("/model_group/make_public", {"model_groups": [model]})
row: Final = eventually(
lambda: _public_row(gateway, path, model),
lambda found: found is not None,
seconds=2 * PROXY_CONFIG_RELOAD_INTERVAL_SECONDS,
)
assert row is not None
assert _custom_fields(row) == (["deprecated", "ptu"], LIFECYCLE_METADATA), row
def test_patching_a_model_replaces_the_custom_tags_and_metadata_served_for_its_group(gateway: Gateway) -> None:
with gateway.scenario() as scenario:
model_name: Final = f"integration-{uuid.uuid4().hex}"
identity: Final = _deployment(
gateway, scenario, model_name, {"custom_tags": ["ptu"], "custom_metadata": LIFECYCLE_METADATA}
)
patched: Final = gateway.request(
"PATCH",
f"/model/{identity}/update",
{"model_info": {"custom_tags": ["retiring"], "custom_metadata": {"retirement_date": "2027-06-01"}}},
)
assert patched.status_code == 200, patched.text
group: Final = eventually(
lambda: _model_group(gateway, model_name),
lambda found: found.get("custom_tags") == ["retiring"],
seconds=2 * PROXY_CONFIG_RELOAD_INTERVAL_SECONDS,
)
assert _custom_fields(group) == (["retiring"], {"retirement_date": "2027-06-01"}), group
@pytest.mark.parametrize(
"model_info",
[
pytest.param({"description": "plain model"}, id="missing"),
pytest.param({"custom_tags": [], "custom_metadata": {}}, id="empty"),
pytest.param({"custom_tags": None, "custom_metadata": None}, id="null"),
pytest.param({"custom_tags": "ptu", "custom_metadata": "retiring"}, id="wrong-shape"),
pytest.param(
{"custom_tags": [1, None, {"a": 1}], "custom_metadata": {"nested": {"a": 1}}}, id="no-valid-entry"
),
],
)
def test_model_group_info_serves_null_custom_fields_when_the_model_sets_none_usable(
gateway: Gateway, model_info: dict[str, JsonValue]
) -> None:
with gateway.scenario() as scenario:
model: Final = scenario.model(model_info=model_info)
group: Final = _model_group(gateway, model)
assert _custom_fields(group) == (None, None), group
def test_model_group_info_keeps_long_values_whole_and_serves_a_repeated_tag_once(gateway: Gateway) -> None:
long_value: Final = "x" * 5000
with gateway.scenario() as scenario:
model: Final = scenario.model(
model_info={"custom_tags": ["ptu", "ptu", "eu", "ptu"], "custom_metadata": {"notes": long_value}}
)
group: Final = _model_group(gateway, model)
assert _custom_fields(group) == (["ptu", "eu"], {"notes": long_value}), group
def test_custom_tags_and_metadata_are_never_forwarded_to_the_provider(gateway: Gateway) -> None:
marker: Final = f"custom-fields-{uuid.uuid4().hex}"
with (
gateway.scenario() as scenario,
httpx.Client(base_url=gateway.upstream_url, timeout=5, trust_env=False) as upstream,
):
model: Final = scenario.model(
model_info={"custom_tags": ["deprecated"], "custom_metadata": {"retirement_date": "2027-03-01"}}
)
reply: Final = gateway.request(
"POST", "/v1/chat/completions", {"model": model, "messages": [{"role": "user", "content": marker}]}
)
assert reply.status_code == 200, reply.text
observed: Final = upstream.get("/__observations")
assert observed.status_code == 200, observed.text
bodies: Final = [
str(object_value(value)["body"])
for value in object_value(observed.json())["requests"] or []
if marker in str(object_value(value)["body"])
]
assert len(bodies) == 1, bodies
assert "custom_tags" not in bodies[0] and "2027-03-01" not in bodies[0], bodies[0]
def _yaml_config(
directory: Path,
upstream_url: str,
model_name: str,
model_info: Mapping[str, object] = MappingProxyType(
{"custom_tags": ["deprecated", "ptu"], "custom_metadata": LIFECYCLE_METADATA}
),
model_group_alias: Mapping[str, str] = MappingProxyType({}),
) -> Path:
config: Final = yaml.safe_load(Path("tests/integration/proxy_config.yaml").read_text())
deployment: Final = {
"model_name": model_name,
"litellm_params": {"model": "openai/gpt-4o-mini", "api_base": f"{upstream_url}/v1", "api_key": "yaml-key"},
"model_info": dict(model_info),
}
path: Final = directory / "custom_fields.yaml"
router_settings: Final = {**config.get("router_settings", {}), "model_group_alias": dict(model_group_alias)}
path.write_text(
yaml.safe_dump(
{**config, "model_list": [*config["model_list"], deployment], "router_settings": router_settings}
)
)
return path
@pytest.mark.timeout(2 * graceful_stop_seconds() + 120)
def test_yaml_custom_fields_survive_a_killed_worker_on_every_worker(gateway: Gateway, tmp_path: Path) -> None:
model_name: Final = f"integration-yaml-{uuid.uuid4().hex}"
config: Final = _yaml_config(tmp_path, gateway.upstream_url, model_name)
with owned_proxy_process(gateway, tmp_path, {}, config=config, workers=2) as owned:
victim: Final = min(owned.worker_pids())
owned.kill_worker(victim)
with ThreadPoolExecutor(max_workers=8) as pool:
groups: Final = tuple(pool.map(lambda _: _model_group(owned.gateway, model_name), range(24)))
assert all(_custom_fields(group) == (["deprecated", "ptu"], LIFECYCLE_METADATA) for group in groups), groups
@pytest.mark.timeout(2 * graceful_stop_seconds() + 120)
@pytest.mark.parametrize(
"unservable_metadata",
[
pytest.param({2027: "retirement"}, id="non-string-key"),
pytest.param({"nan": math.nan, "inf": math.inf, "neg_inf": -math.inf}, id="non-finite-number"),
],
)
def test_yaml_custom_metadata_that_json_cannot_carry_is_dropped_without_breaking_completions_or_the_hub(
gateway: Gateway, tmp_path: Path, unservable_metadata: Mapping[object, object]
) -> None:
model_name: Final = f"integration-yaml-{uuid.uuid4().hex}"
config: Final = _yaml_config(
tmp_path,
gateway.upstream_url,
model_name,
{"custom_tags": ["ptu"], "custom_metadata": {**unservable_metadata, "retirement_date": "2027-03-01"}},
)
with owned_proxy_process(gateway, tmp_path, {}, config=config) as owned:
reply: Final = owned.gateway.request(
"POST", "/v1/chat/completions", {"model": model_name, "messages": [{"role": "user", "content": "hi"}]}
)
assert reply.status_code == 200, reply.text
every_group: Final = owned.gateway.request("GET", "/model_group/info")
assert every_group.status_code == 200, every_group.text
group: Final = _model_group(owned.gateway, model_name)
assert _custom_fields(group) == (["ptu"], {"retirement_date": "2027-03-01"}), group
@pytest.mark.timeout(2 * graceful_stop_seconds() + 120)
def test_an_alias_group_serves_the_custom_tags_and_metadata_of_the_group_it_points_to(
gateway: Gateway, tmp_path: Path
) -> None:
model_name: Final = f"integration-yaml-{uuid.uuid4().hex}"
alias: Final = f"{model_name}-alias"
config: Final = _yaml_config(tmp_path, gateway.upstream_url, model_name, model_group_alias={alias: model_name})
with owned_proxy_process(gateway, tmp_path, {}, config=config) as owned:
group: Final = _model_group(owned.gateway, alias)
assert _custom_fields(group) == (["deprecated", "ptu"], LIFECYCLE_METADATA), group

View file

@ -10,7 +10,7 @@ from fastapi.testclient import TestClient
import litellm
from litellm.proxy._types import LiteLLMRoutes
from litellm.proxy.proxy_server import app
from litellm.types.router import ModelGroupInfo
from litellm.types.router import DeploymentTypedDict, ModelGroupInfo
from tests._master_key import MASTER_KEY
client = TestClient(app)
@ -21,13 +21,17 @@ LEGACY_MODEL_HUB_PATH = "/public/model_hub"
@dataclass(frozen=True, slots=True)
class _FakeRouter:
"""Stands in for the running Router: `_get_model_group_info` only ever asks it this."""
"""Stands in for the running Router: `_get_model_group_info` only ever asks it these."""
infos: Mapping[str, ModelGroupInfo]
model_group_alias: Mapping[str, str] = MappingProxyType({})
def get_model_group_info(self, model_group: str, team_id: str | None = None) -> ModelGroupInfo | None:
return self.infos.get(model_group)
def get_model_list_of_routed_group(self, model_group: str) -> list[DeploymentTypedDict]:
return []
def _info(
name: str,

View file

@ -1,7 +1,7 @@
import asyncio
import json
from datetime import datetime
from typing import Any, Dict, Final, List, Optional, Union
from typing import Any, Dict, Final, List, Optional, TypeAlias, Union
from unittest.mock import Mock
import pytest
@ -24,6 +24,7 @@ from litellm.proxy.litellm_pre_call_utils import (
get_dynamic_logging_metadata,
add_litellm_data_to_request,
)
from litellm.router import Router
from pydantic import ValidationError
pytestmark = pytest.mark.xdist_group("proxy_heavy")
@ -1602,6 +1603,109 @@ def test_get_model_group_info():
assert len(model_list) == 1
def _custom_metadata_router(*model_infos: dict[object, object]) -> Router:
return Router(
model_list=[
{
"model_name": "gpt-4",
"litellm_params": {"model": "gpt-4", "api_key": f"fake-{index}"},
"model_info": model_info,
}
for index, model_info in enumerate(model_infos)
]
)
_CustomFieldsCase: TypeAlias = tuple[
tuple[dict[object, object], ...], tuple[str, ...] | None, dict[str, str | int | float | bool] | None
]
_CUSTOM_FIELDS_CASES: Final[tuple[_CustomFieldsCase, ...]] = (
((), None, None),
(({},), None, None),
(
({"custom_tags": ["deprecated", "ptu"], "custom_metadata": {"retirement_date": "2027-03-01"}},),
("deprecated", "ptu"),
{"retirement_date": "2027-03-01"},
),
(
({}, {"custom_tags": ["ptu"], "custom_metadata": {"provisioned_ptu": 100, "gsu": False}}),
("ptu",),
{"provisioned_ptu": 100, "gsu": False},
),
(
(
{"custom_tags": ["ptu", "eu"], "custom_metadata": {"provisioned_ptu": 100, "region": "eu"}},
{"custom_tags": ["eu", "deprecated"], "custom_metadata": {"provisioned_ptu": 50, "max_tpm": 1.5}},
),
("ptu", "eu", "deprecated"),
{"provisioned_ptu": 100, "region": "eu", "max_tpm": 1.5},
),
(
({"custom_tags": ["ok", 7, None, {"x": 1}], "custom_metadata": {"owner": "ml", "nested": {"a": 1}}},),
("ok",),
{"owner": "ml"},
),
(({"custom_tags": "ptu", "custom_metadata": ["not", "a", "map"]},), None, None),
(
(
{
"custom_metadata": {
2027: "retirement",
"nan": float("nan"),
"inf": float("inf"),
"neg_inf": float("-inf"),
"max_tpm": 1.5,
}
},
),
None,
{"max_tpm": 1.5},
),
)
@pytest.mark.parametrize(("model_infos", "expected_tags", "expected_metadata"), _CUSTOM_FIELDS_CASES)
def test_get_model_group_info_merges_custom_tags_and_metadata_across_deployments(
model_infos: tuple[dict[object, object], ...],
expected_tags: tuple[str, ...] | None,
expected_metadata: dict[str, str | int | float | bool] | None,
) -> None:
from litellm.proxy.proxy_server import get_model_group_info
router: Final = _custom_metadata_router({"description": "first"}, *model_infos)
(group,) = get_model_group_info(llm_router=router, all_models_str=["gpt-4"], model_group="gpt-4")
assert (group.custom_tags, group.custom_metadata) == (expected_tags, expected_metadata)
@pytest.mark.parametrize(
"alias_target",
[pytest.param("gpt-4", id="string"), pytest.param({"model": "gpt-4", "hidden": False}, id="item")],
)
def test_get_model_group_info_serves_the_target_groups_custom_fields_on_an_alias_row(
alias_target: str | dict[str, object],
) -> None:
from litellm.proxy.proxy_server import get_model_group_info
router: Final = Router(
model_list=[
{
"model_name": "gpt-4",
"litellm_params": {"model": "gpt-4", "api_key": "fake"},
"model_info": {"custom_tags": ["deprecated"], "custom_metadata": {"retirement_date": "2027-03-01"}},
}
],
model_group_alias={"gpt-alias": alias_target},
)
groups: Final = get_model_group_info(llm_router=router, all_models_str=["gpt-4", "gpt-alias"], model_group=None)
assert {group.model_group: (group.custom_tags, group.custom_metadata) for group in groups} == {
model_group: (("deprecated",), {"retirement_date": "2027-03-01"}) for model_group in ("gpt-4", "gpt-alias")
}
@pytest.fixture
def mock_team_data():
return [

View file

@ -0,0 +1,29 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { ModelCustomMetadata } from "./ModelCustomMetadata";
describe("ModelCustomMetadata", () => {
it("renders each tag and every metadata entry with its value", () => {
render(
<ModelCustomMetadata
customTags={["deprecated", "ptu"]}
customMetadata={{ retirement_date: "2027-03-01", provisioned_ptu: 100, gsu: false }}
/>,
);
expect(screen.getByText("Tags:")).toBeInTheDocument();
expect(screen.getByText("deprecated")).toBeInTheDocument();
expect(screen.getByText("ptu")).toBeInTheDocument();
const terms = screen.getAllByRole("term").map((term) => term.textContent);
const definitions = screen.getAllByRole("definition").map((definition) => definition.textContent);
expect(terms.map((term, index) => [term, definitions[index]])).toEqual([
["retirement_date", "2027-03-01"],
["provisioned_ptu", "100"],
["gsu", "false"],
]);
});
it("renders nothing when the model has no tags or metadata", () => {
const { container } = render(<ModelCustomMetadata customTags={null} customMetadata={{}} />);
expect(container).toBeEmptyDOMElement();
});
});

View file

@ -0,0 +1,48 @@
import { Badge } from "@/components/ui/badge";
export type ModelCustomMetadataValue = string | number | boolean;
interface ModelCustomMetadataProps {
customTags?: string[] | null;
customMetadata?: Record<string, ModelCustomMetadataValue> | null;
}
export function ModelCustomTags({ tags, className = "" }: { tags: string[]; className?: string }) {
return (
<div className={`flex flex-wrap gap-1 ${className}`}>
{tags.map((tag) => (
<Badge key={tag} variant="outline">
{tag}
</Badge>
))}
</div>
);
}
export function ModelCustomMetadata({ customTags, customMetadata }: ModelCustomMetadataProps) {
const tags = customTags ?? [];
const entries = Object.entries(customMetadata ?? {});
return (
<>
{tags.length > 0 && (
<div className="col-span-2">
<p className="font-medium">Tags:</p>
<ModelCustomTags tags={tags} className="mt-1" />
</div>
)}
{entries.length > 0 && (
<div className="col-span-2">
<p className="font-medium">Custom Metadata:</p>
<dl className="mt-1 grid grid-cols-[max-content_1fr] gap-x-4 gap-y-1">
{entries.map(([key, value]) => (
<div key={key} className="contents">
<dt className="text-muted-foreground">{key}</dt>
<dd>{String(value)}</dd>
</div>
))}
</dl>
</div>
)}
</>
);
}

View file

@ -54,6 +54,7 @@ import {
isDecisionMode,
} from "@/lib/decisionModels";
import DecisionModelGarden from "@/components/AIHub/DecisionModelGarden";
import { ModelCustomMetadata } from "./ModelCustomMetadata";
interface ModelHubTableProps {
accessToken: string | null;
@ -719,6 +720,10 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({
<p className="mt-1">{selectedModel.description}</p>
</div>
)}
<ModelCustomMetadata
customTags={selectedModel.custom_tags}
customMetadata={selectedModel.custom_metadata}
/>
</div>
</div>

View file

@ -61,6 +61,17 @@ describe("getModelHubTableColumns", () => {
expect(screen.getByRole("cell", { name: "gpt-4o" })).not.toHaveClass("text-right");
});
it("shows each custom tag as a badge and a dash when the model has none", () => {
renderTable([
{ ...mockModel, custom_tags: ["deprecated", "ptu"] },
{ ...mockModel, model_group: "untagged", custom_tags: null },
]);
expect(screen.getByRole("columnheader", { name: "Tags" })).toBeInTheDocument();
const [taggedRow, untaggedRow] = screen.getAllByRole("row").slice(1);
expect(taggedRow).toHaveTextContent("deprecatedptu");
expect(untaggedRow).not.toHaveTextContent("deprecated");
});
it("shows capability badges only for supported features", () => {
renderTable([mockModel]);
expect(screen.getByText("Vision")).toBeInTheDocument();

View file

@ -17,6 +17,8 @@ import { cn } from "@/lib/cva.config";
import { displayMode } from "@/lib/decisionModels";
import { copyToClipboard } from "@/utils/dataUtils";
import { ModelCustomTags, type ModelCustomMetadataValue } from "./ModelCustomMetadata";
export interface ModelHubData {
model_group: string;
providers: string[];
@ -33,6 +35,8 @@ export interface ModelHubData {
supported_openai_params?: string[];
is_public_model_group: boolean;
description?: string;
custom_tags?: string[] | null;
custom_metadata?: Record<string, ModelCustomMetadataValue> | null;
[key: string]: any;
}
@ -210,6 +214,20 @@ export const getModelHubTableColumns = ({ onModelClick }: ModelHubTableColumnsDe
);
},
},
{
id: "custom_tags",
meta: { title: "Tags", skeleton: "chips" },
header: "Tags",
size: 160,
enableSorting: false,
cell: ({ row }) => {
const tags = row.original.custom_tags ?? [];
if (tags.length === 0) {
return <span className="text-xs text-muted-foreground">-</span>;
}
return <ModelCustomTags tags={tags} />;
},
},
{
id: "is_public_model_group",
accessorKey: "is_public_model_group",

View file

@ -8,6 +8,7 @@ import { Badge } from "@/components/ui/badge";
import { getProviderLogoAndName } from "@/components/provider_info_helpers";
import { PUBLIC_MODEL_HUB_SORTABLE_FIELDS } from "@/components/publicModelHub/publicModelHubFilters";
import { displayMode } from "@/lib/decisionModels";
import type { ModelCustomMetadataValue } from "@/components/AIHub/ModelCustomMetadata";
export interface ModelGroupInfo {
model_group: string;
@ -27,6 +28,8 @@ export interface ModelGroupInfo {
health_response_time?: number;
health_checked_at?: string;
description?: string;
custom_tags?: string[] | null;
custom_metadata?: Record<string, ModelCustomMetadataValue> | null;
[key: string]: any;
}
@ -260,6 +263,13 @@ export const getPublicModelHubColumns = ({ onModelClick }: PublicModelHubColumns
return <OverflowChips items={features} />;
},
},
{
id: "custom_tags",
meta: { title: "Tags", skeleton: "chips" },
header: "Tags",
size: 140,
cell: ({ row }) => <OverflowChips items={row.original.custom_tags ?? []} />,
},
{
id: "health_status",
accessorKey: "health_status",

View file

@ -53,6 +53,7 @@ import { getProviderLogoAndName } from "./provider_info_helpers";
import { displayMode, isDecisionMode } from "@/lib/decisionModels";
import { modelUsageExample } from "@/components/AIHub/modelUsageExample";
import { filterBySearchTerm, rankBySearchRelevance } from "@/utils/searchUtils";
import { ModelCustomMetadata } from "@/components/AIHub/ModelCustomMetadata";
interface PublicModelHubProps {
accessToken?: string | null;
@ -749,6 +750,10 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
<p className="mt-1">{selectedModel.description}</p>
</div>
)}
<ModelCustomMetadata
customTags={selectedModel.custom_tags}
customMetadata={selectedModel.custom_metadata}
/>
</div>
{/* Wildcard Routing Note */}

View file

@ -38374,6 +38374,12 @@ export interface components {
ModelGroupInfoProxy: {
/** Configurable Clientside Auth Params */
configurable_clientside_auth_params?: (string | components["schemas"]["ConfigurableClientsideParamsCustomAuth-Output"])[] | null;
/** Custom Metadata */
custom_metadata?: {
[key: string]: string | number | boolean;
} | null;
/** Custom Tags */
custom_tags?: string[] | null;
/** Description */
description?: string | null;
/** Health Checked At */