mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-14 23:21:35 +00:00
feat(proxy-ui): key resolved models API, KeyModelList, access group expansion
- Add key_resolved_models_helpers with sectioned payload, search, truncation, and wildcard expansion for access groups using router model names and auth pattern matching.
- Extend GET /key/{key_id}/models with search and compact; pass router names into payload builder.
- Dashboard: KeyModelResponse, fetchKeyModelCall, useGetKeyModels (default load + debounced search), KeyModelList with inner Cards, info icon for all-team-without-team, scroll-capped layout.
- Access group titles: access_group label plus group name without brackets; concrete models replace wildcard entries in group sections.
- Add Python and Vitest coverage for helpers and UI.
Made-with: Cursor
This commit is contained in:
parent
c9c87dd52c
commit
dcbc0d4b98
10 changed files with 1047 additions and 91 deletions
|
|
@ -56,6 +56,10 @@ from litellm.proxy.management_endpoints.common_utils import (
|
|||
_is_user_team_admin,
|
||||
_set_object_metadata_field,
|
||||
)
|
||||
from litellm.proxy.management_endpoints.key_resolved_models_helpers import (
|
||||
prepare_key_models_response_payload,
|
||||
resolve_key_models_for_display,
|
||||
)
|
||||
from litellm.proxy.management_endpoints.model_management_endpoints import (
|
||||
_add_model_to_db,
|
||||
)
|
||||
|
|
@ -2807,14 +2811,25 @@ async def info_key_fn(
|
|||
async def key_resolved_models_fn(
|
||||
key_id: str,
|
||||
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
|
||||
search: Optional[str] = Query(
|
||||
default=None,
|
||||
description="Case-insensitive substring filter on resolved model names. Omit or blank for no filter.",
|
||||
),
|
||||
compact: bool = Query(
|
||||
default=False,
|
||||
description="When true, returns section structure and counts but omits per-section model name lists.",
|
||||
),
|
||||
):
|
||||
"""
|
||||
|
||||
Return resolved models for a virtual key as `model_display_sections` for the admin UI.
|
||||
|
||||
- **source**: How models were resolved (`all-team-models`, `all-proxy-models`, `no-default-models`).
|
||||
- **model_display_sections**: Ordered sections (`all_proxy_models`, `all_team_models`, `access_group`, `ungrouped`); a model may appear under multiple access-group sections.
|
||||
- **search**: Filters the resolved list before truncation and sectioning.
|
||||
- **compact**: Metadata-only payload (empty `models` arrays in each section) for fast initial load.
|
||||
- **all_team_models_without_team**: True when the key uses `all-team-models` but has no `team_id` (assign a team in key settings).
|
||||
"""
|
||||
from litellm.proxy.proxy_server import (
|
||||
llm_router,
|
||||
prisma_client,
|
||||
)
|
||||
from litellm.proxy.proxy_server import llm_router, prisma_client
|
||||
|
||||
try:
|
||||
if prisma_client is None:
|
||||
|
|
@ -2850,33 +2865,32 @@ async def key_resolved_models_fn(
|
|||
)
|
||||
|
||||
key_models = list[str](key_info.models or [])
|
||||
all_models: List[str] = []
|
||||
|
||||
model_access_groups: Dict[str, List[str]] = {}
|
||||
if llm_router is not None:
|
||||
all_models = llm_router.get_model_names()
|
||||
model_access_groups = llm_router.get_model_access_groups()
|
||||
|
||||
source: str = SpecialModelNames.no_default_models.value
|
||||
resolved: List[str] = key_models
|
||||
resolved, source, all_team_models_without_team = (
|
||||
await resolve_key_models_for_display(
|
||||
key_models=key_models,
|
||||
team_id=key_info.team_id,
|
||||
prisma_client=prisma_client,
|
||||
llm_router=llm_router,
|
||||
)
|
||||
)
|
||||
|
||||
#Team Models
|
||||
if (SpecialModelNames.all_team_models.value in key_models):
|
||||
if key_info.team_id is not None:
|
||||
source = SpecialModelNames.all_team_models.value
|
||||
team_row = await prisma_client.db.litellm_teamtable.find_unique(
|
||||
where={"team_id": key_info.team_id},
|
||||
)
|
||||
if team_row is not None and team_row.models is not None:
|
||||
resolved = list[str](team_row.models)
|
||||
else:
|
||||
source = SpecialModelNames.all_team_models.value
|
||||
resolved = all_models
|
||||
|
||||
#Proxy Models
|
||||
if SpecialModelNames.all_proxy_models.value in key_models or SpecialModelNames.all_proxy_models.value in resolved:
|
||||
source = SpecialModelNames.all_proxy_models.value
|
||||
resolved = all_models
|
||||
all_router_model_names: List[str] = []
|
||||
if llm_router is not None:
|
||||
all_router_model_names = list(llm_router.get_model_names())
|
||||
|
||||
return {"models": resolved, "source": source}
|
||||
return prepare_key_models_response_payload(
|
||||
resolved=resolved,
|
||||
source=source,
|
||||
all_team_models_without_team=all_team_models_without_team,
|
||||
model_access_groups=model_access_groups,
|
||||
search=search,
|
||||
compact=compact,
|
||||
all_router_model_names=all_router_model_names,
|
||||
)
|
||||
except Exception as e:
|
||||
raise handle_exception_on_proxy(e)
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,271 @@
|
|||
"""
|
||||
Helpers for GET /key/{key_id}/models: resolve key model lists and build sectioned UI payloads.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional, Tuple, TypedDict
|
||||
|
||||
from litellm.proxy._types import SpecialModelNames
|
||||
from litellm.proxy.auth.auth_checks import (
|
||||
_is_wildcard_pattern,
|
||||
_model_matches_any_wildcard_pattern_in_list,
|
||||
is_model_allowed_by_pattern,
|
||||
)
|
||||
from litellm.proxy.utils import PrismaClient
|
||||
from litellm.router import Router
|
||||
|
||||
KEY_RESOLVED_MODELS_DISPLAY_LIMIT = 500
|
||||
|
||||
SECTION_ALL_PROXY = "all_proxy_models"
|
||||
SECTION_ALL_TEAM = "all_team_models"
|
||||
SECTION_ACCESS_GROUP = "access_group"
|
||||
SECTION_UNGROUPED = "ungrouped"
|
||||
|
||||
TITLE_ALL_PROXY = "All proxy models"
|
||||
TITLE_ALL_TEAM = "All team models"
|
||||
TITLE_UNGROUPED = "Other models"
|
||||
|
||||
|
||||
class ModelDisplaySection(TypedDict):
|
||||
title: str
|
||||
section_kind: str
|
||||
models: List[str]
|
||||
|
||||
|
||||
def _filter_models_by_search(models: List[str], search: Optional[str]) -> List[str]:
|
||||
if not search:
|
||||
return list(models)
|
||||
needle = search.strip().lower()
|
||||
if not needle:
|
||||
return list(models)
|
||||
return [m for m in models if needle in m.lower()]
|
||||
|
||||
|
||||
async def resolve_key_models_for_display(
|
||||
*,
|
||||
key_models: List[str],
|
||||
team_id: Optional[str],
|
||||
prisma_client: PrismaClient,
|
||||
llm_router: Optional[Router],
|
||||
) -> Tuple[List[str], str, bool]:
|
||||
"""
|
||||
Returns (resolved_model_names, source, all_team_models_without_team).
|
||||
"""
|
||||
all_models: List[str] = []
|
||||
if llm_router is not None:
|
||||
all_models = list(llm_router.get_model_names())
|
||||
|
||||
source: str = SpecialModelNames.no_default_models.value
|
||||
resolved: List[str] = list(key_models)
|
||||
all_team_models_without_team = False
|
||||
|
||||
if SpecialModelNames.all_team_models.value in key_models:
|
||||
if team_id is not None:
|
||||
source = SpecialModelNames.all_team_models.value
|
||||
team_row = await prisma_client.db.litellm_teamtable.find_unique(
|
||||
where={"team_id": team_id},
|
||||
)
|
||||
if team_row is not None and team_row.models is not None:
|
||||
resolved = list(team_row.models)
|
||||
else:
|
||||
source = SpecialModelNames.all_team_models.value
|
||||
all_team_models_without_team = True
|
||||
resolved = list(all_models)
|
||||
|
||||
if (
|
||||
SpecialModelNames.all_proxy_models.value in key_models
|
||||
or SpecialModelNames.all_proxy_models.value in resolved
|
||||
):
|
||||
source = SpecialModelNames.all_proxy_models.value
|
||||
resolved = list(all_models)
|
||||
|
||||
return resolved, source, all_team_models_without_team
|
||||
|
||||
|
||||
def _concrete_models_allowed_by_resolved(
|
||||
resolved: List[str], router_model_names: List[str]
|
||||
) -> List[str]:
|
||||
"""Concrete router model names the key may call, given resolved patterns (incl. wildcards)."""
|
||||
resolved_list = list(resolved)
|
||||
resolved_set = set(resolved)
|
||||
out: List[str] = []
|
||||
seen: set[str] = set()
|
||||
for m in router_model_names:
|
||||
if m in seen:
|
||||
continue
|
||||
if m in resolved_set:
|
||||
out.append(m)
|
||||
seen.add(m)
|
||||
elif _model_matches_any_wildcard_pattern_in_list(m, resolved_list):
|
||||
out.append(m)
|
||||
seen.add(m)
|
||||
return out
|
||||
|
||||
|
||||
def _expand_group_models_for_display(
|
||||
group_models: List[str],
|
||||
concrete_pool: List[str],
|
||||
) -> List[str]:
|
||||
"""
|
||||
Expand wildcard entries in a group's model list to concrete names from the pool.
|
||||
Non-wildcard entries are included when present in the pool.
|
||||
"""
|
||||
pool_set = set(concrete_pool)
|
||||
out: List[str] = []
|
||||
seen: set[str] = set()
|
||||
for g in group_models:
|
||||
if _is_wildcard_pattern(g):
|
||||
for m in concrete_pool:
|
||||
if m in seen:
|
||||
continue
|
||||
if is_model_allowed_by_pattern(m, g):
|
||||
out.append(m)
|
||||
seen.add(m)
|
||||
else:
|
||||
if g in seen:
|
||||
continue
|
||||
if g in pool_set:
|
||||
out.append(g)
|
||||
seen.add(g)
|
||||
return out
|
||||
|
||||
|
||||
def _models_in_any_access_group(
|
||||
model_access_groups: Dict[str, List[str]], candidate_models: List[str]
|
||||
) -> set:
|
||||
"""Set of candidate_models that appear in at least one access group list (concrete names)."""
|
||||
cset = set(candidate_models)
|
||||
in_group: set = set()
|
||||
for models_in_group in model_access_groups.values():
|
||||
for m in models_in_group:
|
||||
if m in cset:
|
||||
in_group.add(m)
|
||||
return in_group
|
||||
|
||||
|
||||
def build_model_display_sections(
|
||||
*,
|
||||
display_models: List[str],
|
||||
source: str,
|
||||
model_access_groups: Dict[str, List[str]],
|
||||
compact: bool,
|
||||
) -> List[ModelDisplaySection]:
|
||||
"""
|
||||
Build ordered sections for the admin UI.
|
||||
|
||||
When source is all-proxy or all-team, a scope section lists all display_models first,
|
||||
then every intersecting **model_access_groups** section is still included (models may
|
||||
repeat across the sentinel section and each group). Ungrouped is omitted for sentinels
|
||||
only to avoid repeating the full flat list as "Other models".
|
||||
"""
|
||||
sections: List[ModelDisplaySection] = []
|
||||
empty_models: List[str] = [] if compact else []
|
||||
|
||||
display_set = set(display_models)
|
||||
|
||||
if source == SpecialModelNames.all_proxy_models.value:
|
||||
sections.append(
|
||||
ModelDisplaySection(
|
||||
title=TITLE_ALL_PROXY,
|
||||
section_kind=SECTION_ALL_PROXY,
|
||||
models=empty_models if compact else list(display_models),
|
||||
)
|
||||
)
|
||||
elif source == SpecialModelNames.all_team_models.value:
|
||||
sections.append(
|
||||
ModelDisplaySection(
|
||||
title=TITLE_ALL_TEAM,
|
||||
section_kind=SECTION_ALL_TEAM,
|
||||
models=empty_models if compact else list(display_models),
|
||||
)
|
||||
)
|
||||
|
||||
for group_name, group_models in model_access_groups.items():
|
||||
# group_models are models in this group that appear in resolved; further restrict to display slice
|
||||
intersected = [m for m in group_models if m in display_set]
|
||||
if not intersected:
|
||||
continue
|
||||
sections.append(
|
||||
ModelDisplaySection(
|
||||
title=group_name,
|
||||
section_kind=SECTION_ACCESS_GROUP,
|
||||
models=empty_models if compact else intersected,
|
||||
)
|
||||
)
|
||||
|
||||
# Sentinel scope sections already list the full display set; skip ungrouped to avoid duplicating it.
|
||||
skip_ungrouped = source in (
|
||||
SpecialModelNames.all_proxy_models.value,
|
||||
SpecialModelNames.all_team_models.value,
|
||||
)
|
||||
if not skip_ungrouped:
|
||||
in_any = _models_in_any_access_group(model_access_groups, display_models)
|
||||
ungrouped = [m for m in display_models if m not in in_any]
|
||||
if ungrouped:
|
||||
sections.append(
|
||||
ModelDisplaySection(
|
||||
title=TITLE_UNGROUPED,
|
||||
section_kind=SECTION_UNGROUPED,
|
||||
models=empty_models if compact else ungrouped,
|
||||
)
|
||||
)
|
||||
|
||||
return sections
|
||||
|
||||
|
||||
def prepare_key_models_response_payload(
|
||||
*,
|
||||
resolved: List[str],
|
||||
source: str,
|
||||
all_team_models_without_team: bool,
|
||||
model_access_groups: Dict[str, List[str]],
|
||||
search: Optional[str],
|
||||
compact: bool,
|
||||
all_router_model_names: List[str],
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Apply search, truncation, and build the JSON-serializable response dict.
|
||||
|
||||
Access-group sections list concrete model names: wildcards in router group metadata
|
||||
are expanded against models allowed for this key (resolved ∩ router names).
|
||||
"""
|
||||
base_concrete = _concrete_models_allowed_by_resolved(resolved, all_router_model_names)
|
||||
|
||||
expanded_groups: Dict[str, List[str]] = {}
|
||||
for group_name, group_models in model_access_groups.items():
|
||||
expanded = _expand_group_models_for_display(group_models, base_concrete)
|
||||
if expanded:
|
||||
expanded_groups[group_name] = expanded
|
||||
|
||||
filtered_concrete = _filter_models_by_search(base_concrete, search)
|
||||
matched_count = len(filtered_concrete)
|
||||
models_truncated = matched_count > KEY_RESOLVED_MODELS_DISPLAY_LIMIT
|
||||
display_models = (
|
||||
filtered_concrete[:KEY_RESOLVED_MODELS_DISPLAY_LIMIT]
|
||||
if models_truncated
|
||||
else filtered_concrete
|
||||
)
|
||||
|
||||
display_set = set(display_models)
|
||||
intersected_groups: Dict[str, List[str]] = {}
|
||||
for group_name, models in expanded_groups.items():
|
||||
in_slice = [m for m in models if m in display_set]
|
||||
if in_slice:
|
||||
intersected_groups[group_name] = in_slice
|
||||
|
||||
model_display_sections = build_model_display_sections(
|
||||
display_models=display_models,
|
||||
source=source,
|
||||
model_access_groups=intersected_groups,
|
||||
compact=compact,
|
||||
)
|
||||
|
||||
return {
|
||||
"model_display_sections": model_display_sections,
|
||||
"source": source,
|
||||
"resolved_total_count": len(resolved),
|
||||
"matched_count": matched_count,
|
||||
"models_truncated": models_truncated,
|
||||
"all_team_models_without_team": all_team_models_without_team,
|
||||
}
|
||||
|
|
@ -0,0 +1,206 @@
|
|||
"""
|
||||
Unit tests for key_resolved_models_helpers.
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from litellm.proxy._types import SpecialModelNames
|
||||
from litellm.proxy.management_endpoints.key_resolved_models_helpers import (
|
||||
KEY_RESOLVED_MODELS_DISPLAY_LIMIT,
|
||||
prepare_key_models_response_payload,
|
||||
resolve_key_models_for_display,
|
||||
_filter_models_by_search,
|
||||
)
|
||||
|
||||
|
||||
def test_filter_models_by_search():
|
||||
models = ["GPT-4", "claude-3", "embed-small"]
|
||||
assert _filter_models_by_search(models, None) == models
|
||||
assert _filter_models_by_search(models, " ") == models
|
||||
assert _filter_models_by_search(models, "gpt") == ["GPT-4"]
|
||||
assert _filter_models_by_search(models, "CLAUDE") == ["claude-3"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_all_team_models_without_team():
|
||||
mock_prisma = MagicMock()
|
||||
mock_prisma.db.litellm_teamtable.find_unique = AsyncMock(return_value=None)
|
||||
mock_router = MagicMock()
|
||||
mock_router.get_model_names.return_value = ["a", "b"]
|
||||
|
||||
resolved, source, no_team = await resolve_key_models_for_display(
|
||||
key_models=["all-team-models"],
|
||||
team_id=None,
|
||||
prisma_client=mock_prisma,
|
||||
llm_router=mock_router,
|
||||
)
|
||||
assert source == SpecialModelNames.all_team_models.value
|
||||
assert resolved == ["a", "b"]
|
||||
assert no_team is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_all_team_models_with_team():
|
||||
mock_prisma = MagicMock()
|
||||
team_row = MagicMock()
|
||||
team_row.models = ["team-m1"]
|
||||
mock_prisma.db.litellm_teamtable.find_unique = AsyncMock(return_value=team_row)
|
||||
mock_router = MagicMock()
|
||||
mock_router.get_model_names.return_value = ["proxy-wide"]
|
||||
|
||||
resolved, source, no_team = await resolve_key_models_for_display(
|
||||
key_models=["all-team-models"],
|
||||
team_id="team-1",
|
||||
prisma_client=mock_prisma,
|
||||
llm_router=mock_router,
|
||||
)
|
||||
assert source == SpecialModelNames.all_team_models.value
|
||||
assert resolved == ["team-m1"]
|
||||
assert no_team is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_all_proxy_models():
|
||||
mock_prisma = MagicMock()
|
||||
mock_router = MagicMock()
|
||||
mock_router.get_model_names.return_value = ["m1", "m2"]
|
||||
|
||||
resolved, source, no_team = await resolve_key_models_for_display(
|
||||
key_models=["all-proxy-models"],
|
||||
team_id=None,
|
||||
prisma_client=mock_prisma,
|
||||
llm_router=mock_router,
|
||||
)
|
||||
assert source == SpecialModelNames.all_proxy_models.value
|
||||
assert resolved == ["m1", "m2"]
|
||||
assert no_team is False
|
||||
|
||||
|
||||
def test_prepare_payload_search_and_truncation():
|
||||
big = [f"m{i}" for i in range(KEY_RESOLVED_MODELS_DISPLAY_LIMIT + 50)]
|
||||
out = prepare_key_models_response_payload(
|
||||
resolved=big,
|
||||
source=SpecialModelNames.no_default_models.value,
|
||||
all_team_models_without_team=False,
|
||||
model_access_groups={},
|
||||
search=None,
|
||||
compact=False,
|
||||
all_router_model_names=big,
|
||||
)
|
||||
assert out["resolved_total_count"] == len(big)
|
||||
assert out["matched_count"] == len(big)
|
||||
assert out["models_truncated"] is True
|
||||
ung = out["model_display_sections"][0]
|
||||
assert ung["section_kind"] == "ungrouped"
|
||||
assert len(ung["models"]) == KEY_RESOLVED_MODELS_DISPLAY_LIMIT
|
||||
|
||||
|
||||
def test_prepare_payload_compact():
|
||||
out = prepare_key_models_response_payload(
|
||||
resolved=["x", "y"],
|
||||
source=SpecialModelNames.no_default_models.value,
|
||||
all_team_models_without_team=False,
|
||||
model_access_groups={},
|
||||
search=None,
|
||||
compact=True,
|
||||
all_router_model_names=["x", "y"],
|
||||
)
|
||||
assert out["matched_count"] == 2
|
||||
for sec in out["model_display_sections"]:
|
||||
assert sec["models"] == []
|
||||
|
||||
|
||||
def test_prepare_payload_explicit_models_with_access_groups():
|
||||
out = prepare_key_models_response_payload(
|
||||
resolved=["a", "b", "z"],
|
||||
source=SpecialModelNames.no_default_models.value,
|
||||
all_team_models_without_team=False,
|
||||
model_access_groups={"G": ["a", "b"], "H": ["b"]},
|
||||
search=None,
|
||||
compact=False,
|
||||
all_router_model_names=["a", "b", "z"],
|
||||
)
|
||||
kinds = [s["section_kind"] for s in out["model_display_sections"]]
|
||||
assert "access_group" in kinds
|
||||
assert "ungrouped" in kinds
|
||||
ung = next(s for s in out["model_display_sections"] if s["section_kind"] == "ungrouped")
|
||||
assert ung["models"] == ["z"]
|
||||
|
||||
|
||||
def test_all_proxy_models_includes_access_group_sections():
|
||||
"""Sentinel 'all proxy' section is first; access groups are not skipped."""
|
||||
out = prepare_key_models_response_payload(
|
||||
resolved=["m1", "m2", "m3"],
|
||||
source=SpecialModelNames.all_proxy_models.value,
|
||||
all_team_models_without_team=False,
|
||||
model_access_groups={
|
||||
"grp-a": ["m1", "not-in-resolved"],
|
||||
"grp-b": ["m2", "m1"],
|
||||
},
|
||||
search=None,
|
||||
compact=False,
|
||||
all_router_model_names=["m1", "m2", "m3"],
|
||||
)
|
||||
sections = out["model_display_sections"]
|
||||
kinds = [s["section_kind"] for s in sections]
|
||||
assert kinds[0] == "all_proxy_models"
|
||||
assert sections[0]["models"] == ["m1", "m2", "m3"]
|
||||
assert kinds.count("access_group") == 2
|
||||
by_title = {s["title"]: s["models"] for s in sections}
|
||||
assert by_title["grp-a"] == ["m1"]
|
||||
assert by_title["grp-b"] == ["m2", "m1"]
|
||||
assert "ungrouped" not in kinds
|
||||
|
||||
|
||||
def test_all_team_models_includes_access_group_sections():
|
||||
out = prepare_key_models_response_payload(
|
||||
resolved=["x", "y"],
|
||||
source=SpecialModelNames.all_team_models.value,
|
||||
all_team_models_without_team=False,
|
||||
model_access_groups={"T1": ["x"], "T2": ["x", "y"]},
|
||||
search=None,
|
||||
compact=False,
|
||||
all_router_model_names=["x", "y"],
|
||||
)
|
||||
sections = out["model_display_sections"]
|
||||
kinds = [s["section_kind"] for s in sections]
|
||||
assert kinds[0] == "all_team_models"
|
||||
assert sections[0]["models"] == ["x", "y"]
|
||||
assert kinds.count("access_group") == 2
|
||||
assert "ungrouped" not in kinds
|
||||
|
||||
|
||||
def test_same_model_listed_under_multiple_access_groups():
|
||||
"""Overlap across groups is allowed; model 'b' appears in G and H."""
|
||||
out = prepare_key_models_response_payload(
|
||||
resolved=["a", "b", "c"],
|
||||
source=SpecialModelNames.no_default_models.value,
|
||||
all_team_models_without_team=False,
|
||||
model_access_groups={"G": ["a", "b"], "H": ["b", "c"]},
|
||||
search=None,
|
||||
compact=False,
|
||||
all_router_model_names=["a", "b", "c"],
|
||||
)
|
||||
g = next(s for s in out["model_display_sections"] if s["title"] == "G")
|
||||
h = next(s for s in out["model_display_sections"] if s["title"] == "H")
|
||||
assert "b" in g["models"] and "b" in h["models"]
|
||||
|
||||
|
||||
def test_access_group_wildcard_expands_to_concrete_models():
|
||||
router_models = ["openai/gpt-4o", "openai/gpt-3.5-turbo", "anthropic/claude-3"]
|
||||
out = prepare_key_models_response_payload(
|
||||
resolved=["openai/*"],
|
||||
source=SpecialModelNames.no_default_models.value,
|
||||
all_team_models_without_team=False,
|
||||
model_access_groups={"openai_group": ["openai/*"]},
|
||||
search=None,
|
||||
compact=False,
|
||||
all_router_model_names=router_models,
|
||||
)
|
||||
sec = next(s for s in out["model_display_sections"] if s["title"] == "openai_group")
|
||||
assert "openai/*" not in sec["models"]
|
||||
assert "openai/gpt-4o" in sec["models"]
|
||||
assert "openai/gpt-3.5-turbo" in sec["models"]
|
||||
assert "anthropic/claude-3" not in sec["models"]
|
||||
|
|
@ -0,0 +1,208 @@
|
|||
/* @vitest-environment jsdom */
|
||||
import React from "react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import KeyModelList from "./KeyModelList";
|
||||
import * as useGetKeyModelsHook from "@/hooks/keys/useGetKeyModels";
|
||||
|
||||
vi.mock("@/hooks/keys/useGetKeyModels", () => ({
|
||||
useGetKeyModels: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockUseGetKeyModels = vi.mocked(useGetKeyModelsHook.useGetKeyModels);
|
||||
|
||||
describe("KeyModelList", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should render grouped models by default without requiring search", () => {
|
||||
mockUseGetKeyModels.mockReturnValue({
|
||||
searchInput: "",
|
||||
setSearchInput: vi.fn(),
|
||||
defaultModelsQuery: {
|
||||
data: {
|
||||
model_display_sections: [
|
||||
{ title: "All proxy models", section_kind: "all_proxy_models", models: ["m1", "m2"] },
|
||||
{ title: "grp-a", section_kind: "access_group", models: ["m1"] },
|
||||
],
|
||||
source: "all-proxy-models",
|
||||
resolved_total_count: 2,
|
||||
matched_count: 2,
|
||||
models_truncated: false,
|
||||
all_team_models_without_team: false,
|
||||
},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
isSuccess: true,
|
||||
} as any,
|
||||
searchQuery: { data: undefined, isLoading: false, isFetching: false, isFetched: false } as any,
|
||||
hasActiveSearch: false,
|
||||
isInitialLoading: false,
|
||||
searchInputLoading: false,
|
||||
});
|
||||
|
||||
render(<KeyModelList key_id="k1" />);
|
||||
expect(screen.getByText("Models")).toBeInTheDocument();
|
||||
expect(screen.getByText("All proxy models")).toBeInTheDocument();
|
||||
expect(screen.getByText("access_group")).toBeInTheDocument();
|
||||
expect(screen.getByText("grp-a")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("m1").length).toBeGreaterThanOrEqual(1);
|
||||
const innerCards = document.querySelectorAll(".ant-card-type-inner");
|
||||
expect(innerCards.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("should show explanation icon next to All team models when no team is assigned", () => {
|
||||
mockUseGetKeyModels.mockReturnValue({
|
||||
searchInput: "",
|
||||
setSearchInput: vi.fn(),
|
||||
defaultModelsQuery: {
|
||||
data: {
|
||||
model_display_sections: [
|
||||
{ title: "All team models", section_kind: "all_team_models", models: ["a"] },
|
||||
],
|
||||
source: "all-team-models",
|
||||
resolved_total_count: 1,
|
||||
matched_count: 1,
|
||||
models_truncated: false,
|
||||
all_team_models_without_team: true,
|
||||
},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
isSuccess: true,
|
||||
} as any,
|
||||
searchQuery: { data: undefined, isLoading: false, isFetching: false, isFetched: false } as any,
|
||||
hasActiveSearch: false,
|
||||
isInitialLoading: false,
|
||||
searchInputLoading: false,
|
||||
});
|
||||
|
||||
render(<KeyModelList key_id="k1" />);
|
||||
expect(screen.getByLabelText("Why this matters")).toBeInTheDocument();
|
||||
expect(screen.getByText("All team models")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should use a scroll-capped list region with overflow-y-auto", () => {
|
||||
mockUseGetKeyModels.mockReturnValue({
|
||||
searchInput: "",
|
||||
setSearchInput: vi.fn(),
|
||||
defaultModelsQuery: {
|
||||
data: {
|
||||
model_display_sections: [
|
||||
{ title: "Other models", section_kind: "ungrouped", models: ["x"] },
|
||||
],
|
||||
source: "no-default-models",
|
||||
resolved_total_count: 1,
|
||||
matched_count: 1,
|
||||
models_truncated: false,
|
||||
all_team_models_without_team: false,
|
||||
},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
isSuccess: true,
|
||||
} as any,
|
||||
searchQuery: { data: undefined, isLoading: false, isFetching: false, isFetched: false } as any,
|
||||
hasActiveSearch: false,
|
||||
isInitialLoading: false,
|
||||
searchInputLoading: false,
|
||||
});
|
||||
|
||||
render(<KeyModelList key_id="k1" />);
|
||||
const scroll = screen.getByTestId("key-model-list-scroll");
|
||||
expect(scroll.className).toMatch(/overflow-y-auto/);
|
||||
expect(scroll.className).toMatch(/min-h-0/);
|
||||
});
|
||||
|
||||
it("should show search results when hasActiveSearch", () => {
|
||||
mockUseGetKeyModels.mockReturnValue({
|
||||
searchInput: "gpt",
|
||||
setSearchInput: vi.fn(),
|
||||
defaultModelsQuery: { data: undefined, isLoading: false, isError: false, isSuccess: true } as any,
|
||||
searchQuery: {
|
||||
data: {
|
||||
model_display_sections: [
|
||||
{ title: "Other models", section_kind: "ungrouped", models: ["gpt-4"] },
|
||||
],
|
||||
source: "no-default-models",
|
||||
resolved_total_count: 1,
|
||||
matched_count: 1,
|
||||
models_truncated: false,
|
||||
all_team_models_without_team: false,
|
||||
},
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
isFetched: true,
|
||||
isSuccess: true,
|
||||
} as any,
|
||||
hasActiveSearch: true,
|
||||
isInitialLoading: false,
|
||||
searchInputLoading: false,
|
||||
});
|
||||
|
||||
render(<KeyModelList key_id="k1" />);
|
||||
expect(screen.getByText("gpt-4")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show nothing found when search succeeds with zero matches", () => {
|
||||
mockUseGetKeyModels.mockReturnValue({
|
||||
searchInput: "zzz",
|
||||
setSearchInput: vi.fn(),
|
||||
defaultModelsQuery: { data: undefined, isLoading: false, isError: false, isSuccess: true } as any,
|
||||
searchQuery: {
|
||||
data: {
|
||||
model_display_sections: [],
|
||||
source: "no-default-models",
|
||||
resolved_total_count: 5,
|
||||
matched_count: 0,
|
||||
models_truncated: false,
|
||||
all_team_models_without_team: false,
|
||||
},
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
isFetched: true,
|
||||
isSuccess: true,
|
||||
} as any,
|
||||
hasActiveSearch: true,
|
||||
isInitialLoading: false,
|
||||
searchInputLoading: false,
|
||||
});
|
||||
|
||||
render(<KeyModelList key_id="k1" />);
|
||||
expect(screen.getByText(/Nothing found for "zzz"/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render all proxy scope and access group sections together", () => {
|
||||
mockUseGetKeyModels.mockReturnValue({
|
||||
searchInput: "m",
|
||||
setSearchInput: vi.fn(),
|
||||
defaultModelsQuery: { data: undefined, isLoading: false, isError: false, isSuccess: true } as any,
|
||||
searchQuery: {
|
||||
data: {
|
||||
model_display_sections: [
|
||||
{ title: "All proxy models", section_kind: "all_proxy_models", models: ["m1", "m2"] },
|
||||
{ title: "grp-a", section_kind: "access_group", models: ["m1"] },
|
||||
{ title: "grp-b", section_kind: "access_group", models: ["m2", "m1"] },
|
||||
],
|
||||
source: "all-proxy-models",
|
||||
resolved_total_count: 2,
|
||||
matched_count: 2,
|
||||
models_truncated: false,
|
||||
all_team_models_without_team: false,
|
||||
},
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
isFetched: true,
|
||||
isSuccess: true,
|
||||
} as any,
|
||||
hasActiveSearch: true,
|
||||
isInitialLoading: false,
|
||||
searchInputLoading: false,
|
||||
});
|
||||
|
||||
render(<KeyModelList key_id="k1" />);
|
||||
expect(screen.getByText("All proxy models")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("access_group").length).toBeGreaterThanOrEqual(2);
|
||||
const m1Tags = screen.getAllByText("m1");
|
||||
expect(m1Tags.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,36 +1,192 @@
|
|||
import React from "react";
|
||||
import {UseGetKeyModels} from "@/hooks/keys/useGetKeyModels";
|
||||
import { Card, Tag } from 'antd';
|
||||
import React from 'react';
|
||||
import { QuestionCircleOutlined } from '@ant-design/icons';
|
||||
import { Alert, Card, Empty, Input, Tag, Tooltip, Typography } from 'antd';
|
||||
import { useGetKeyModels } from '@/hooks/keys/useGetKeyModels';
|
||||
import type { KeyModelDisplaySection } from '@/components/networking';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
/** Card body (search + list) max height — list area scrolls inside flex layout. */
|
||||
const CARD_BODY_MAX_HEIGHT = 'min(72vh, 580px)';
|
||||
|
||||
interface KeyModelListProps {
|
||||
key_id: string;
|
||||
key_id: string;
|
||||
}
|
||||
|
||||
const extractDefaultTags = (source: string) => {
|
||||
if (source === 'all-proxy-models') {
|
||||
return <Tag className="ml-2">All proxy models</Tag>
|
||||
} else if (source === 'all-team-models') {
|
||||
return <Tag className="ml-2">All team models</Tag>
|
||||
}
|
||||
return ''
|
||||
}
|
||||
const INNER_CARD_CLASS = 'mb-3 last:mb-0';
|
||||
|
||||
const SectionBlock: React.FC<{
|
||||
section: KeyModelDisplaySection;
|
||||
warnNoTeam: boolean;
|
||||
}> = ({ section, warnNoTeam }) => {
|
||||
const tagRow = (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{section.models.map((item, index) => (
|
||||
<Tag key={`${section.section_kind}-${section.title}-${item}-${index}`}>{item}</Tag>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (section.section_kind === 'access_group') {
|
||||
return (
|
||||
<Card
|
||||
size="small"
|
||||
type="inner"
|
||||
className={INNER_CARD_CLASS}
|
||||
data-section-kind={section.section_kind}
|
||||
title={
|
||||
<span>
|
||||
<Text code>access_group</Text>
|
||||
<Text strong className="ml-2">
|
||||
{section.title}
|
||||
</Text>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
{tagRow}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (section.section_kind === 'all_proxy_models' || section.section_kind === 'all_team_models') {
|
||||
const showNoTeamHint = section.section_kind === 'all_team_models' && warnNoTeam;
|
||||
const title = (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Text strong>{section.title}</Text>
|
||||
{showNoTeamHint ? (
|
||||
<Tooltip
|
||||
title='This key uses "All team models" but has no team assigned. Assign a team in key settings so access follows that team model list.'
|
||||
placement="topLeft"
|
||||
>
|
||||
<QuestionCircleOutlined
|
||||
className="text-black cursor-help text-base"
|
||||
aria-label="Why this matters"
|
||||
/>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
return (
|
||||
<Card
|
||||
size="small"
|
||||
type="inner"
|
||||
className={INNER_CARD_CLASS}
|
||||
data-section-kind={section.section_kind}
|
||||
title={title}
|
||||
>
|
||||
{tagRow}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
size="small"
|
||||
type="inner"
|
||||
className={INNER_CARD_CLASS}
|
||||
data-section-kind={section.section_kind}
|
||||
title={<Text strong>{section.title}</Text>}
|
||||
>
|
||||
{tagRow}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const KeyModelList: React.FC<KeyModelListProps> = ({ key_id }) => {
|
||||
const { data: keyModels, isLoading} = UseGetKeyModels(key_id)
|
||||
const {
|
||||
searchInput,
|
||||
setSearchInput,
|
||||
defaultModelsQuery,
|
||||
searchQuery,
|
||||
hasActiveSearch,
|
||||
isInitialLoading,
|
||||
searchInputLoading,
|
||||
} = useGetKeyModels(key_id);
|
||||
|
||||
|
||||
const defaultData = defaultModelsQuery.data;
|
||||
const searchData = searchQuery.data;
|
||||
const warnNoTeam =
|
||||
(searchData?.all_team_models_without_team ?? defaultData?.all_team_models_without_team) === true;
|
||||
|
||||
const title = keyModels ? <>Model {extractDefaultTags(keyModels.source)}</> : 'Model'
|
||||
return (
|
||||
<Card title={title} loading={isLoading}>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{keyModels && keyModels.models.map((item:string)=> {return <Tag>{item}</Tag>})}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
const sections = hasActiveSearch ? searchData?.model_display_sections : defaultData?.model_display_sections;
|
||||
const truncated = hasActiveSearch ? searchData?.models_truncated : defaultData?.models_truncated;
|
||||
const nothingFound =
|
||||
hasActiveSearch &&
|
||||
searchQuery.isFetched &&
|
||||
!searchQuery.isFetching &&
|
||||
searchData !== undefined &&
|
||||
searchData.matched_count === 0;
|
||||
|
||||
const renderBody = () => {
|
||||
if (defaultModelsQuery.isError) {
|
||||
return <Empty description="Could not load models for this key" />;
|
||||
}
|
||||
|
||||
if (nothingFound) {
|
||||
return <Empty description={`Nothing found for "${searchInput.trim()}"`} />;
|
||||
}
|
||||
|
||||
if (searchQuery.isError && hasActiveSearch) {
|
||||
return <Empty description="Could not load search results" />;
|
||||
}
|
||||
|
||||
if (sections && sections.length > 0) {
|
||||
return (
|
||||
<>
|
||||
{truncated ? (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
className="mb-3"
|
||||
message="Results capped — refine your search to narrow matches."
|
||||
/>
|
||||
) : null}
|
||||
{sections.map((section) => (
|
||||
<SectionBlock key={`${section.section_kind}-${section.title}`} section={section} warnNoTeam={warnNoTeam} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isInitialLoading && defaultModelsQuery.isSuccess && (!sections || sections.length === 0)) {
|
||||
return <Empty description="No models resolved for this key" />;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
title="Models"
|
||||
loading={isInitialLoading}
|
||||
styles={{
|
||||
body: {
|
||||
padding: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
maxHeight: CARD_BODY_MAX_HEIGHT,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<div className="shrink-0 border-b border-gray-100 px-4 pb-3 pt-3">
|
||||
<Input.Search
|
||||
allowClear
|
||||
placeholder="Filter models…"
|
||||
value={searchInput}
|
||||
loading={searchInputLoading}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
onSearch={(v) => setSearchInput(v)}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
data-testid="key-model-list-scroll"
|
||||
className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-4 pb-4 pt-2"
|
||||
>
|
||||
{renderBody()}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default KeyModelList;
|
||||
|
||||
|
|
|
|||
|
|
@ -406,7 +406,7 @@ describe("individualModelHealthCheckCall", () => {
|
|||
});
|
||||
|
||||
|
||||
describe("fetching models from key information", () => {
|
||||
describe("fetchKeyModelCall", () => {
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
|
|
@ -417,23 +417,32 @@ describe("fetching models from key information", () => {
|
|||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it("should call /health with model_id query param so health checks run by deployment id", async () => {
|
||||
it("should request /key/{id}/models with optional compact and search query params", async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue({
|
||||
healthy_count: 1,
|
||||
unhealthy_count: 0,
|
||||
healthy_endpoints: [],
|
||||
unhealthy_endpoints: [],
|
||||
model_display_sections: [],
|
||||
source: "no-default-models",
|
||||
resolved_total_count: 0,
|
||||
matched_count: 0,
|
||||
models_truncated: false,
|
||||
all_team_models_without_team: false,
|
||||
}),
|
||||
} as any);
|
||||
global.fetch = mockFetch as any;
|
||||
|
||||
await Networking.fetchKeyModelCall("token-123", "key-abc-456");
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledOnce();
|
||||
const [url] = mockFetch.mock.calls[0];
|
||||
const urlStr = typeof url === "string" ? url : (url as Request).url;
|
||||
await Networking.fetchKeyModelCall("token-123", "key-abc-456", { compact: true });
|
||||
let [url] = mockFetch.mock.calls[0];
|
||||
let urlStr = typeof url === "string" ? url : (url as Request).url;
|
||||
expect(urlStr).toContain("key-abc-456");
|
||||
expect(urlStr).toContain("compact=true");
|
||||
|
||||
vi.clearAllMocks();
|
||||
global.fetch = mockFetch as any;
|
||||
await Networking.fetchKeyModelCall("token-123", "key-abc-456", { search: "gpt-4" });
|
||||
[url] = mockFetch.mock.calls[0];
|
||||
urlStr = typeof url === "string" ? url : (url as Request).url;
|
||||
const parsed = new URL(urlStr, "http://example.com");
|
||||
expect(parsed.searchParams.get("search")).toBe("gpt-4");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3308,18 +3308,48 @@ export const keyAliasesCall = async (
|
|||
}
|
||||
};
|
||||
|
||||
export interface keyModelResponse {
|
||||
source: string;
|
||||
export type KeyModelDisplaySectionKind =
|
||||
| "all_proxy_models"
|
||||
| "all_team_models"
|
||||
| "access_group"
|
||||
| "ungrouped";
|
||||
|
||||
export interface KeyModelDisplaySection {
|
||||
title: string;
|
||||
section_kind: KeyModelDisplaySectionKind;
|
||||
models: string[];
|
||||
}
|
||||
|
||||
export interface KeyModelResponse {
|
||||
model_display_sections: KeyModelDisplaySection[];
|
||||
source: string;
|
||||
resolved_total_count: number;
|
||||
matched_count: number;
|
||||
models_truncated: boolean;
|
||||
all_team_models_without_team: boolean;
|
||||
}
|
||||
|
||||
export type FetchKeyModelCallOptions = {
|
||||
search?: string;
|
||||
compact?: boolean;
|
||||
};
|
||||
|
||||
export const fetchKeyModelCall = async (
|
||||
accessToken: string,
|
||||
key_id: string
|
||||
): Promise<keyModelResponse> => {
|
||||
|
||||
key_id: string,
|
||||
options?: FetchKeyModelCallOptions
|
||||
): Promise<KeyModelResponse> => {
|
||||
try {
|
||||
let url = proxyBaseUrl ? `${proxyBaseUrl}/key/${key_id}/models` : `/key/${key_id}/models`;
|
||||
const params = new URLSearchParams();
|
||||
if (options?.search !== undefined && options.search.trim() !== "") {
|
||||
params.set("search", options.search.trim());
|
||||
}
|
||||
if (options?.compact === true) {
|
||||
params.set("compact", "true");
|
||||
}
|
||||
const qs = params.toString();
|
||||
const base = proxyBaseUrl ? `${proxyBaseUrl}/key/${key_id}/models` : `/key/${key_id}/models`;
|
||||
let url = qs ? `${base}?${qs}` : base;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
|
|
|
|||
|
|
@ -369,7 +369,7 @@ export default function KeyInfoView({
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="w-full h-screen p-4">
|
||||
<div className="w-full p-4">
|
||||
<KeyInfoHeader
|
||||
data={{
|
||||
keyName: currentKeyData.key_alias || "Virtual Key",
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import React from "react";
|
|||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { UseGetKeyModels } from "./useGetKeyModels";
|
||||
import { useGetKeyModels } from "./useGetKeyModels";
|
||||
import * as networking from "@/components/networking";
|
||||
|
||||
vi.mock("@/components/networking", () => ({
|
||||
|
|
@ -16,8 +16,20 @@ vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
|
|||
})),
|
||||
}));
|
||||
|
||||
const emptyKeyModelResponse = {
|
||||
model_display_sections: [],
|
||||
source: "no-default-models",
|
||||
resolved_total_count: 0,
|
||||
matched_count: 0,
|
||||
models_truncated: false,
|
||||
all_team_models_without_team: false,
|
||||
};
|
||||
|
||||
const createQueryClient = () =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => {
|
||||
|
|
@ -25,27 +37,31 @@ const wrapper = ({ children }: { children: React.ReactNode }) => {
|
|||
return React.createElement(QueryClientProvider, { client: queryClient }, children);
|
||||
};
|
||||
|
||||
const mockAccessToken = 'test-key-id';
|
||||
const mockAccessGroups = ["group-1", "group-2", "group-3"];
|
||||
|
||||
describe("useGetKeyModels", () => {
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
const useAuthorizedModule = await import("@/app/(dashboard)/hooks/useAuthorized");
|
||||
vi.mocked(useAuthorizedModule.default).mockReturnValue({
|
||||
accessToken: mockAccessToken,
|
||||
accessToken: "test-token-456",
|
||||
} as any);
|
||||
});
|
||||
|
||||
it("should return hook result without errors", () => {
|
||||
vi.mocked(networking.fetchKeyModelCall).mockResolvedValue({source: '', models: []});
|
||||
it("should load default full model list without compact", async () => {
|
||||
vi.mocked(networking.fetchKeyModelCall).mockResolvedValue({
|
||||
...emptyKeyModelResponse,
|
||||
resolved_total_count: 3,
|
||||
model_display_sections: [
|
||||
{ title: "Other models", section_kind: "ungrouped", models: ["a", "b", "c"] },
|
||||
],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => UseGetKeyModels('test-key-id'), { wrapper });
|
||||
const { result } = renderHook(() => useGetKeyModels("test-key-id"), { wrapper });
|
||||
|
||||
expect(result.current).toBeDefined();
|
||||
expect(result.current).toHaveProperty("data");
|
||||
expect(result.current).toHaveProperty("isSuccess");
|
||||
expect(result.current).toHaveProperty("isError");
|
||||
expect(result.current).toHaveProperty("status");
|
||||
expect(result.current).toHaveProperty("defaultModelsQuery");
|
||||
expect(result.current).toHaveProperty("searchQuery");
|
||||
|
||||
await waitFor(() => expect(result.current.defaultModelsQuery.isSuccess).toBe(true));
|
||||
expect(networking.fetchKeyModelCall).toHaveBeenCalledWith("test-token-456", "test-key-id");
|
||||
expect(result.current.defaultModelsQuery.data?.resolved_total_count).toBe(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,15 +1,61 @@
|
|||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useDebouncedState } from '@tanstack/react-pacer/debouncer';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { fetchKeyModelCall } from '@/components/networking';
|
||||
import useAuthorized from '@/app/(dashboard)/hooks/useAuthorized';
|
||||
|
||||
export const UseGetKeyModels = (key_id: string) => {
|
||||
/** Wait after last keystroke before calling the search API (avoids spzzy refetches). */
|
||||
const SEARCH_DEBOUNCE_MS = 450;
|
||||
|
||||
export const useGetKeyModels = (key_id: string) => {
|
||||
const { accessToken } = useAuthorized();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['keyModels', key_id],
|
||||
queryFn: () => {
|
||||
if (!accessToken) throw new Error("Access Token required");
|
||||
return fetchKeyModelCall(accessToken, key_id);
|
||||
},
|
||||
const [searchInput, setSearchInputState] = useState('');
|
||||
const [debouncedSearch, setDebouncedSearch] = useDebouncedState('', {
|
||||
wait: SEARCH_DEBOUNCE_MS,
|
||||
});
|
||||
|
||||
const setSearchInput = useCallback(
|
||||
(value: string) => {
|
||||
setSearchInputState(value);
|
||||
setDebouncedSearch(value);
|
||||
},
|
||||
[setDebouncedSearch],
|
||||
);
|
||||
|
||||
const trimmedDebounced = debouncedSearch.trim();
|
||||
const hasActiveSearch = trimmedDebounced.length > 0;
|
||||
/** True while the user is still typing and the debounced value has not caught up yet. */
|
||||
const isSearchDebouncing = searchInput.trim() !== trimmedDebounced;
|
||||
|
||||
const defaultModelsQuery = useQuery({
|
||||
queryKey: ['keyModelsDefault', key_id],
|
||||
queryFn: () => {
|
||||
if (!accessToken) throw new Error('Access Token required');
|
||||
return fetchKeyModelCall(accessToken, key_id);
|
||||
},
|
||||
enabled: Boolean(accessToken && key_id),
|
||||
});
|
||||
|
||||
const searchQuery = useQuery({
|
||||
queryKey: ['keyModelsSearch', key_id, trimmedDebounced],
|
||||
queryFn: () => {
|
||||
if (!accessToken) throw new Error('Access Token required');
|
||||
return fetchKeyModelCall(accessToken, key_id, { search: trimmedDebounced });
|
||||
},
|
||||
enabled: Boolean(accessToken && key_id && hasActiveSearch),
|
||||
});
|
||||
|
||||
const isSearchFetching = hasActiveSearch && searchQuery.isFetching;
|
||||
const searchInputLoading = isSearchDebouncing || isSearchFetching;
|
||||
|
||||
return {
|
||||
searchInput,
|
||||
setSearchInput,
|
||||
debouncedSearch: trimmedDebounced,
|
||||
defaultModelsQuery,
|
||||
searchQuery,
|
||||
hasActiveSearch,
|
||||
isInitialLoading: defaultModelsQuery.isLoading,
|
||||
searchInputLoading,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue