fix(chat): inherit signed-in model permissions and select eligible teams (#35187)

This commit is contained in:
Ishaan 2026-10-23 17:06:15 -07:00
parent f6b2086502
commit bb6fa5bbb7
15 changed files with 1008 additions and 35 deletions

View file

@ -3923,6 +3923,8 @@ class ProxyErrorTypes(str, enum.Enum):
Team does not have access to the model
"""
team_selection_required = "team_selection_required"
user_model_access_denied = "user_model_access_denied"
"""
User does not have access to the model

View file

@ -0,0 +1,211 @@
"""Resolve dashboard model requests through the signed-in user's existing grants."""
from __future__ import annotations
from collections.abc import Sequence
from dataclasses import dataclass
from typing import TYPE_CHECKING, Final
from litellm.constants import UI_SESSION_TOKEN_TEAM_ID
from litellm.proxy._experimental.mcp_server.ui_session_utils import (
is_ui_session_credential,
)
from litellm.proxy._types import (
LiteLLM_TeamMembership,
LiteLLM_TeamTableCachedObj,
LiteLLM_UserTable,
LitellmUserRoles,
ProxyErrorTypes,
ProxyException,
UserAPIKeyAuth,
)
from litellm.proxy.auth.auth_checks import (
can_key_call_model,
can_team_access_model,
can_user_call_model,
check_team_member_model_access,
)
from litellm.proxy.auth.model_access_denied import model_access_denied_client_message
from litellm.proxy.auth.resolvers.grants import (
GrantResolver,
ResolvedGrants,
UserLookup,
user_models,
)
from litellm.proxy.auth.team_grants import team_grants
from litellm.types.proxy.management_endpoints.model_management_endpoints import (
ModelGroupTeam,
)
if TYPE_CHECKING:
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
from litellm.proxy.utils import PrismaClient, ProxyLogging
from litellm.router import Router
def is_model_ui_session(auth: UserAPIKeyAuth) -> bool:
return (
is_ui_session_credential(auth)
and auth.agent_id is None
and auth.user_role in (None, LitellmUserRoles.INTERNAL_USER, LitellmUserRoles.INTERNAL_USER_VIEW_ONLY)
)
@dataclass(frozen=True, slots=True)
class TeamModelSource:
team: LiteLLM_TeamTableCachedObj
membership: LiteLLM_TeamMembership | None
auth: UserAPIKeyAuth
@dataclass(frozen=True, slots=True)
class UISessionModelSources:
auth: UserAPIKeyAuth
user: LiteLLM_UserTable
teams: tuple[TeamModelSource, ...]
router: Router | None
prisma: PrismaClient
cache: UserApiKeyCache
logging: ProxyLogging
async def directly_allows(self, models: Sequence[str]) -> bool:
try:
for model in models:
await can_key_call_model(model, None, self.auth, self.router, self.prisma)
await can_user_call_model(model=model, user_object=self.user, llm_router=self.router)
except ProxyException:
return False
return True
async def team_allows(self, source: TeamModelSource, models: Sequence[str]) -> bool:
if source.team.blocked:
return False
try:
for model in models:
await can_team_access_model(
model=model,
team_object=source.team,
llm_router=self.router,
team_model_aliases=source.auth.team_model_aliases,
prisma_client=self.prisma,
)
await check_team_member_model_access(
model=model,
team_object=source.team,
valid_token=source.auth,
llm_router=self.router,
prisma_client=self.prisma,
user_api_key_cache=self.cache,
proxy_logging_obj=self.logging,
team_membership=source.membership,
team_membership_loaded=True,
)
except ProxyException:
return False
return True
async def available_teams(self, model: str) -> tuple[ModelGroupTeam, ...] | None:
if await self.directly_allows((model,)):
return ()
allowed: Final = tuple(
[
ModelGroupTeam(team_id=source.team.team_id, team_alias=source.team.team_alias)
for source in self.teams
if await self.team_allows(source, (model,))
]
)
return allowed or None
async def for_request(self, models: Sequence[str], selected_team_id: str | None) -> UserAPIKeyAuth:
if selected_team_id is None and await self.directly_allows(models):
return self.auth
allowed: Final = tuple([source for source in self.teams if await self.team_allows(source, models)])
if selected_team_id is not None:
selected: Final = next(
(source for source in allowed if source.team.team_id == selected_team_id),
None,
)
if selected is None:
raise ProxyException(
message="The selected team does not grant access to this model.",
type=ProxyErrorTypes.team_model_access_denied,
param="x-litellm-team-id",
code=403,
)
return selected.auth
if len(allowed) == 1:
return allowed[0].auth
if len(allowed) > 1:
raise ProxyException(
message="Choose a team for this model before sending the request.",
type=ProxyErrorTypes.team_selection_required,
param="x-litellm-team-id",
code=409,
)
raise ProxyException(
message=model_access_denied_client_message(model=list(models)),
type=ProxyErrorTypes.key_model_access_denied,
param="model",
code=403,
)
async def load_ui_session_model_sources(
auth: UserAPIKeyAuth,
router: Router | None,
prisma: PrismaClient | None,
cache: UserApiKeyCache,
logging: ProxyLogging,
) -> UISessionModelSources | None:
if not is_model_ui_session(auth):
return None
if prisma is None:
raise ProxyException(
message="Unable to resolve the signed-in user's model access.",
type=ProxyErrorTypes.no_db_connection,
param=None,
code=503,
)
resolver: Final = GrantResolver(prisma, cache, parent_otel_span=auth.parent_otel_span, proxy_logging_obj=logging)
identity: Final = await resolver.resolve(UserLookup(auth.user_id), team_id=None)
if not isinstance(identity, ResolvedGrants) or identity.user_object is None:
raise ProxyException(
message="Unable to resolve the signed-in user's model access.",
type=ProxyErrorTypes.auth_error,
param=None,
code=403,
)
user: Final = identity.user_object
if user.user_role not in (LitellmUserRoles.INTERNAL_USER, LitellmUserRoles.INTERNAL_USER_VIEW_ONLY):
return None
# Login copies the user model list into the UI key. Refresh that snapshot
# without changing the session's identity, restrictions, or budget.
live_auth: Final = auth.model_copy(update={"models": list(user_models(user))})
sources: Final = tuple(
[
await _team_model_source(resolver, auth, user, team_id)
for team_id in dict.fromkeys(user.teams)
if team_id != UI_SESSION_TOKEN_TEAM_ID
]
)
return UISessionModelSources(
live_auth, user, tuple(source for source in sources if source is not None), router, prisma, cache, logging
)
async def _team_model_source(
resolver: GrantResolver, auth: UserAPIKeyAuth, user: LiteLLM_UserTable, team_id: str
) -> TeamModelSource | None:
grant: Final = await resolver.resolve(UserLookup(user.user_id), team_id=team_id)
if not isinstance(grant, ResolvedGrants) or grant.team_object is None:
return None
team: Final = grant.team_object
scoped: Final = auth.model_copy(
update={
**team_grants(team, grant.team_membership, user.user_id),
"team_id": team.team_id,
"org_id": auth.org_id or team.organization_id,
"models": [],
}
)
return TeamModelSource(team, grant.team_membership, scoped)

View file

@ -2879,6 +2879,31 @@ async def validate_resolved_virtual_key( # noqa: C901 # Preserve ordering of e
## base case ## key is disabled
if valid_token.blocked is True:
raise Exception("Key is blocked. Update via `/key/unblock` if you're an admin.")
if RouteChecks.is_llm_api_route(route=route):
from litellm.proxy.auth.ui_session_models import load_ui_session_model_sources
session_sources: Final = await load_ui_session_model_sources(
valid_token, llm_router, prisma_client, user_api_key_cache, proxy_logging_obj
)
if session_sources is not None:
requested_model: Final = _get_model_from_request_context(
request_data, route, request, llm_router, valid_token.team_id
)
requested_models: Final = (
(requested_model,) if isinstance(requested_model, str) else tuple(requested_model or ())
)
if requested_models:
valid_token = await session_sources.for_request( # rebind-ok: replace the local token with a scoped copy for all existing checks below
(
*requested_models,
*(
name
for target in iter_request_fallback_targets(request_data)
if (name := fallback_target_model_name(target)) is not None
),
),
request.headers.get("x-litellm-team-id"),
)
await enforce_key_and_fallback_model_access(
valid_token=valid_token,
request_data=request_data,

View file

@ -999,6 +999,7 @@ from litellm.types.llms.openai import (
from litellm.types.proxy.control_plane_endpoints import WorkerRegistryEntry
from litellm.types.proxy.management_endpoints.model_management_endpoints import (
ModelGroupInfoProxy,
ModelGroupInfoResponse,
)
from litellm.types.proxy.management_endpoints.ui_sso import (
DefaultTeamSSOParams,
@ -17801,6 +17802,7 @@ _get_model_group_info: Final = get_model_group_info
"/model_group/info",
tags=["model management"],
dependencies=[Depends(user_api_key_auth)],
response_model=ModelGroupInfoResponse,
)
async def model_group_info(
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
@ -18004,13 +18006,24 @@ async def model_group_info(
append_agents_to_model_group,
)
visible_groups: Final = get_model_group_info(
llm_router=llm_router,
all_models_str=listed_group_names,
model_group=model_group,
team_id=user_api_key_dict.team_id,
)
from litellm.proxy.auth.ui_session_models import load_ui_session_model_sources
session_sources: Final = await load_ui_session_model_sources(
user_api_key_dict, llm_router, prisma_client, user_api_key_cache, proxy_logging_obj
)
if session_sources is not None:
for group in visible_groups:
group.available_teams = await session_sources.available_teams(group.model_group)
model_groups: Final = await append_agents_to_model_group(
model_groups=get_model_group_info(
llm_router=llm_router,
all_models_str=listed_group_names,
model_group=model_group,
team_id=user_api_key_dict.team_id,
),
model_groups=[
group for group in visible_groups if session_sources is None or group.available_teams is not None
],
user_api_key_dict=user_api_key_dict,
)
internal_to_public: Final = TeamModelNameTranslator.build_internal_to_public_map(llm_router, general_settings)

View file

@ -9430,6 +9430,16 @@ async def get_available_models_for_user(
get_key_models,
get_team_models,
)
from litellm.proxy.auth.ui_session_models import load_ui_session_model_sources
session_sources: Final = (
await load_ui_session_model_sources(
user_api_key_dict, llm_router, prisma_client, user_api_key_cache, proxy_logging_obj
)
if user_api_key_cache is not None and proxy_logging_obj is not None and team_id is None
else None
)
model_auth: Final = session_sources.auth if session_sources is not None else user_api_key_dict
# Get proxy model list and access groups
if llm_router is None:
@ -9441,7 +9451,7 @@ async def get_available_models_for_user(
requested_team_object: Final = (
await _get_validated_team_object(
user_api_key_dict=user_api_key_dict,
user_api_key_dict=model_auth,
team_id=team_id,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
@ -9453,9 +9463,9 @@ async def get_available_models_for_user(
key_models: Final[Sequence[str]] = (
()
if requested_team_object is not None and requested_team_object.team_id != user_api_key_dict.team_id
if requested_team_object is not None and requested_team_object.team_id != model_auth.team_id
else get_key_models(
user_api_key_dict=user_api_key_dict,
user_api_key_dict=model_auth,
proxy_model_list=proxy_model_list,
model_access_groups=model_access_groups,
include_model_access_groups=include_model_access_groups,
@ -9463,19 +9473,17 @@ async def get_available_models_for_user(
)
team_models: Final = get_team_models(
team_models=(
requested_team_object.models if requested_team_object is not None else user_api_key_dict.team_models
),
team_models=(requested_team_object.models if requested_team_object is not None else model_auth.team_models),
proxy_model_list=proxy_model_list,
model_access_groups=model_access_groups,
include_model_access_groups=include_model_access_groups,
)
effective_team_id: Final = team_id or user_api_key_dict.team_id
effective_team_id: Final = team_id or model_auth.team_id
access_group_models: Final = (
await _get_access_group_models(
user_api_key_dict=user_api_key_dict,
user_api_key_dict=model_auth,
team_object=requested_team_object
or await _get_team_object_for_access_groups(
team_id=effective_team_id,
@ -9491,9 +9499,9 @@ async def get_available_models_for_user(
else _AccessGroupModels()
)
key_model_names: Final = cast("list[str]", user_api_key_dict.models) # cast-ok: key models are a string array
key_model_names: Final = cast("list[str]", model_auth.models) # cast-ok: key models are a string array
key_model_check_skipped: Final = (
bool(user_api_key_dict.config) or SpecialModelNames.all_team_models.value in key_model_names
bool(model_auth.config) or SpecialModelNames.all_team_models.value in key_model_names
)
granted_key_models: Final = (
(
@ -9526,19 +9534,38 @@ async def get_available_models_for_user(
list(
await personal_key_team_visible_models(
models=complete_models,
user_api_key_dict=user_api_key_dict,
user_api_key_dict=model_auth,
llm_router=llm_router,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
proxy_logging_obj=proxy_logging_obj,
)
)
if personal_key_team_model_access_applies(user_api_key_dict)
if personal_key_team_model_access_applies(model_auth)
else complete_models
)
if session_sources is not None:
candidates: Final = list(all_models)
for source in session_sources.teams:
candidates.extend(
await get_available_models_for_user(
user_api_key_dict=source.auth,
llm_router=llm_router,
general_settings=general_settings,
user_model=user_model,
prisma_client=prisma_client,
proxy_logging_obj=proxy_logging_obj,
include_model_access_groups=include_model_access_groups,
only_model_access_groups=only_model_access_groups,
return_wildcard_routes=return_wildcard_routes,
user_api_key_cache=user_api_key_cache,
)
)
return [name for name in dict.fromkeys(candidates) if await session_sources.available_teams(name) is not None]
agent_visible: Final = await _agent_access_group_visible_models(
user_api_key_dict=user_api_key_dict,
user_api_key_dict=model_auth,
llm_router=llm_router,
include_model_access_groups=include_model_access_groups,
return_wildcard_routes=return_wildcard_routes,

View file

@ -8,6 +8,13 @@ from litellm.types.llms.base import LiteLLMBaseModel
from ...router import ModelGroupInfo
class ModelGroupTeam(LiteLLMBaseModel):
model_config = ConfigDict(frozen=True)
team_id: str
team_alias: str | None = None
class ModelGroupInfoProxy(ModelGroupInfo):
is_public_model_group: bool = Field(default=False)
health_status: str | None = Field(default=None)
@ -15,6 +22,13 @@ class ModelGroupInfoProxy(ModelGroupInfo):
health_checked_at: str | None = Field(default=None)
custom_tags: tuple[str, ...] | None = None
custom_metadata: Mapping[str, str | int | float | bool] | None = None
available_teams: tuple[ModelGroupTeam, ...] | None = None
class ModelGroupInfoResponse(LiteLLMBaseModel):
model_config = ConfigDict(frozen=True)
data: tuple[ModelGroupInfoProxy, ...]
class UpdateUsefulLinksRequest(LiteLLMBaseModel):

View file

@ -0,0 +1,341 @@
from typing import cast
import pytest
from fastapi.encoders import jsonable_encoder
import litellm
from litellm.constants import UI_SESSION_TOKEN_TEAM_ID
from litellm.proxy import proxy_server
from litellm.proxy._types import (
LiteLLM_AccessGroupTable,
LiteLLM_BudgetTable,
LiteLLM_TeamMembership,
LiteLLM_TeamTableCachedObj,
LiteLLM_UserTable,
LitellmUserRoles,
ProxyException,
UserAPIKeyAuth,
)
from litellm.proxy.auth.auth_checks import can_key_call_model, team_max_budget_check
from litellm.proxy.auth.ui_session_models import load_ui_session_model_sources
from litellm.proxy.common_utils.user_api_key_cache import (
UserApiKeyCache,
team_membership_reservation_cache_key,
)
from litellm.proxy.utils import (
PrismaClient,
ProxyLogging,
get_available_models_for_user,
)
from litellm.types.proxy.management_endpoints.model_management_endpoints import (
ModelGroupTeam,
ModelGroupInfoProxy,
ModelGroupInfoResponse,
)
@pytest.fixture
def router():
return litellm.Router(
model_list=[
{
"model_name": name,
"litellm_params": {
"model": "openai/gpt-4.1-mini",
"api_key": "test-key",
},
}
for name in ("personal", "research", "shared", "forbidden")
]
)
@pytest.fixture
async def account(monkeypatch, router):
cache = UserApiKeyCache()
logging = ProxyLogging(cache)
# Every row is in the real auth cache; the database must never be touched.
prisma = cast(PrismaClient, object())
user = LiteLLM_UserTable(
user_id="researcher",
user_role=LitellmUserRoles.INTERNAL_USER_VIEW_ONLY,
models=["personal"],
teams=["research-team"],
max_budget=10,
)
team = LiteLLM_TeamTableCachedObj(
team_id="research-team",
team_alias="Research",
models=["research", "shared"],
max_budget=20,
)
member = LiteLLM_TeamMembership(user_id=user.user_id, team_id=team.team_id, spend=0)
auth = UserAPIKeyAuth(
token="session-hash",
user_id=user.user_id,
team_id=UI_SESSION_TOKEN_TEAM_ID,
user_role=LitellmUserRoles.INTERNAL_USER_VIEW_ONLY,
models=["personal"],
max_budget=3,
)
await cache.async_set_cache(key=user.user_id, value=user, model_type=LiteLLM_UserTable)
await cache.async_set_cache(key=f"team_id:{team.team_id}", value=team, model_type=LiteLLM_TeamTableCachedObj)
await cache.async_set_cache(
key=team_membership_reservation_cache_key(user.user_id, team.team_id),
value=member,
)
monkeypatch.setattr(proxy_server, "prisma_client", prisma)
monkeypatch.setattr(proxy_server, "user_api_key_cache", cache)
monkeypatch.setattr(proxy_server, "proxy_logging_obj", logging)
return auth, user, team, member, prisma, cache, logging
async def sources(account, router):
auth, _, _, _, prisma, cache, logging = account
result = await load_ui_session_model_sources(auth, router, prisma, cache, logging)
assert result is not None
return result
async def test_discovery_includes_team_models_without_exposing_unrelated_models(account, router):
auth, _, _, _, prisma, cache, logging = account
models = await get_available_models_for_user(auth, router, {}, None, prisma, logging, user_api_key_cache=cache)
access = await sources(account, router)
assert {model: await access.available_teams(model) for model in models} == {
"personal": (),
"research": (ModelGroupTeam(team_id="research-team", team_alias="Research"),),
"shared": (ModelGroupTeam(team_id="research-team", team_alias="Research"),),
}
async def test_sole_team_preserves_identity_role_and_budgets_without_changing_cached_session(account, router):
access = await sources(account, router)
scoped = await access.for_request(("research",), None)
assert (
scoped.team_id,
scoped.user_id,
scoped.user_role,
scoped.max_budget,
scoped.team_max_budget,
) == (
"research-team",
"researcher",
LitellmUserRoles.INTERNAL_USER_VIEW_ONLY,
3,
20,
)
assert account[0].team_id == UI_SESSION_TOKEN_TEAM_ID
assert account[0].models == ["personal"]
async def test_session_grants_resolve_before_the_auth_builder_attaches_the_user_role(account, router):
auth, _, _, _, prisma, cache, logging = account
access = await load_ui_session_model_sources(
auth.model_copy(update={"user_role": None}), router, prisma, cache, logging
)
assert access is not None
assert (await access.for_request(("research",), None)).team_id == "research-team"
async def test_direct_user_access_is_the_default_without_a_team_selection(account, router):
access = await sources(account, router)
assert await access.for_request(("personal",), None) == account[0]
@pytest.mark.parametrize("live_models", [["no-default-models"], ["shared"]])
async def test_removed_user_and_team_grants_deny_even_when_the_session_snapshot_allows(account, router, live_models):
auth, user, _, _, prisma, cache, logging = account
await cache.async_set_cache(
key=user.user_id,
value=user.model_copy(update={"models": live_models, "teams": []}),
model_type=LiteLLM_UserTable,
)
stale_auth = auth.model_copy(update={"models": ["personal", "research"]})
access = await load_ui_session_model_sources(stale_auth, router, prisma, cache, logging)
assert access is not None
for model in ("personal", "research"):
assert await access.available_teams(model) is None
with pytest.raises(ProxyException) as error:
await access.for_request((model,), None)
assert (error.value.code, error.value.type, error.value.param) == ("403", "key_model_access_denied", "model")
async def test_new_direct_grants_are_listed_and_usable_without_logging_in_again(account, router):
auth, user, _, _, prisma, cache, logging = account
await cache.async_set_cache(
key=user.user_id,
value=user.model_copy(update={"models": ["personal", "forbidden"]}),
model_type=LiteLLM_UserTable,
)
models = await get_available_models_for_user(auth, router, {}, None, prisma, logging, user_api_key_cache=cache)
access = await sources(account, router)
scoped = await access.for_request(("forbidden",), None)
assert set(models) == {"personal", "research", "shared", "forbidden"}
assert await access.available_teams("forbidden") == ()
assert await can_key_call_model("forbidden", None, scoped, router)
assert (scoped.team_id, scoped.user_id, scoped.max_budget) == (UI_SESSION_TOKEN_TEAM_ID, "researcher", 3)
assert auth.models == ["personal"]
async def test_explicit_team_is_not_silently_replaced_by_a_direct_user_grant(account, router):
_, _, team, _, _, cache, _ = account
await cache.async_set_cache(
key=f"team_id:{team.team_id}",
value=team.model_copy(update={"models": ["personal", "research"]}),
model_type=LiteLLM_TeamTableCachedObj,
)
access = await sources(account, router)
assert (await access.for_request(("personal",), "research-team")).team_id == "research-team"
with pytest.raises(ProxyException) as error:
await access.for_request(("personal",), "removed-team")
assert error.value.code == "403"
async def test_team_access_group_grants_are_discoverable_and_usable(account, router):
_, _, team, _, _, cache, _ = account
group = LiteLLM_AccessGroupTable(
access_group_id="research-group", access_group_name="Research models", access_model_names=["research"]
)
await cache.async_set_cache(key="access_group_id:research-group", value=group, model_type=LiteLLM_AccessGroupTable)
await cache.async_set_cache(
key=f"team_id:{team.team_id}",
value=team.model_copy(update={"models": ["shared"], "access_group_ids": ["research-group"]}),
model_type=LiteLLM_TeamTableCachedObj,
)
access = await sources(account, router)
assert await access.available_teams("research") == (ModelGroupTeam(team_id="research-team", team_alias="Research"),)
assert (await access.for_request(("research",), None)).team_id == "research-team"
async def test_multiple_granting_teams_require_explicit_choice(account, router):
auth, user, _, _, _, cache, _ = account
other = LiteLLM_TeamTableCachedObj(team_id="other-team", models=["research"])
await cache.async_set_cache(
key=user.user_id,
value=user.model_copy(update={"teams": ["research-team", "other-team"]}),
model_type=LiteLLM_UserTable,
)
await cache.async_set_cache(key="team_id:other-team", value=other, model_type=LiteLLM_TeamTableCachedObj)
await cache.async_set_cache(
key=team_membership_reservation_cache_key(user.user_id, other.team_id),
value=LiteLLM_TeamMembership(user_id=user.user_id, team_id=other.team_id),
)
access = await sources(account, router)
with pytest.raises(ProxyException) as error:
await access.for_request(("research",), None)
assert (error.value.code, error.value.type, error.value.param) == (
"409",
"team_selection_required",
"x-litellm-team-id",
)
assert (await access.for_request(("research",), "other-team")).team_id == "other-team"
assert auth.team_id == UI_SESSION_TOKEN_TEAM_ID
@pytest.mark.parametrize("reason", ["blocked", "removed", "member_scope"])
async def test_unavailable_team_grants_are_not_listed_or_selectable(account, router, reason):
_, user, team, member, _, cache, _ = account
if reason == "blocked":
await cache.async_set_cache(
key=f"team_id:{team.team_id}",
value=team.model_copy(update={"blocked": True}),
model_type=LiteLLM_TeamTableCachedObj,
)
elif reason == "removed":
await cache.async_set_cache(
key=user.user_id,
value=user.model_copy(update={"teams": []}),
model_type=LiteLLM_UserTable,
)
else:
await cache.async_set_cache(
key=team_membership_reservation_cache_key(user.user_id, team.team_id),
value=member.model_copy(update={"litellm_budget_table": LiteLLM_BudgetTable(allowed_models=["shared"])}),
)
access = await sources(account, router)
assert await access.available_teams("research") is None
with pytest.raises(ProxyException) as error:
await access.for_request(("research",), "research-team")
assert error.value.code == "403"
async def test_forbidden_model_and_fallback_keep_normal_key_denial(account, router):
access = await sources(account, router)
with pytest.raises(ProxyException) as error:
await access.for_request(("research", "forbidden"), None)
assert (error.value.code, error.value.type) == ("403", "key_model_access_denied")
async def test_selected_team_still_enforces_its_budget(account, router):
access = await sources(account, router)
scoped = await access.for_request(("research",), None)
team = account[2].model_copy(update={"max_budget": 1, "spend": 2})
with pytest.raises(litellm.BudgetExceededError):
await team_max_budget_check(team, scoped, account[6])
@pytest.mark.parametrize(
"auth",
[
UserAPIKeyAuth(
user_id="researcher",
team_id="research-team",
user_role=LitellmUserRoles.INTERNAL_USER,
),
UserAPIKeyAuth(user_id="researcher", user_role=LitellmUserRoles.INTERNAL_USER),
UserAPIKeyAuth(
user_id="admin",
team_id=UI_SESSION_TOKEN_TEAM_ID,
user_role=LitellmUserRoles.PROXY_ADMIN,
),
],
)
async def test_other_credentials_are_never_widened(auth, router, account):
assert await load_ui_session_model_sources(auth, router, account[4], account[5], account[6]) is None
def test_typed_response_preserves_existing_alias_and_arbitrary_metadata():
group = ModelGroupInfoProxy(
model_group="team-visible-alias",
providers=["openai"],
custom_tags=("research",),
custom_metadata={"n": 5, "flag": True, "label": "Research"},
supported_endpoints=("/v1/responses",),
)
assert ModelGroupInfoResponse(data=(group,)).model_dump(mode="json") == jsonable_encoder({"data": [group]})
async def test_team_public_model_preserves_capabilities_in_session_discovery(account, monkeypatch):
auth, user, team, _, _, cache, _ = account
router = litellm.Router(
model_list=[
{
"model_name": "model_name_research-team_private",
"litellm_params": {"model": "openai/gpt-4.1-mini", "api_key": "test-key"},
"model_info": {"team_id": "research-team", "team_public_model_name": "team-chat", "mode": "chat"},
}
]
)
await cache.async_set_cache(
key=f"team_id:{team.team_id}",
value=team.model_copy(update={"models": ["model_name_research-team_private"]}),
model_type=LiteLLM_TeamTableCachedObj,
)
await cache.async_set_cache(
key=user.user_id,
value=user.model_copy(update={"models": ["no-default-models"]}),
model_type=LiteLLM_UserTable,
)
monkeypatch.setattr(proxy_server, "llm_router", router)
monkeypatch.setattr(proxy_server, "llm_model_list", router.model_list)
monkeypatch.setattr(proxy_server, "general_settings", {})
monkeypatch.setattr(proxy_server, "user_model", None)
response = await proxy_server.model_group_info(user_api_key_dict=auth)
assert [(group.model_group, group.providers, group.mode, group.available_teams) for group in response["data"]] == [
(
"model_name_research-team_private",
["openai"],
"chat",
(ModelGroupTeam(team_id="research-team", team_alias="Research"),),
),
]

View file

@ -1,6 +1,8 @@
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
import { act, fireEvent, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useChatHistory } from "@/components/chat/useChatHistory";
import { fetchAvailableModels } from "@/components/llm_calls/fetch_models";
import ChatConversationPage from "./page";
import { renderWithProviders } from "@/../tests/test-utils";
import type { OnUrlUpdateFunction } from "nuqs/adapters/testing";
@ -64,10 +66,13 @@ vi.mock("@/contexts/ChatShellContext", () => ({
},
}));
const ONE_TURN_ARG_COUNT = 25;
const ONE_TURN_ARG_COUNT = 26;
const ON_TIMING_DATA_INDEX = 7;
const ON_USAGE_DATA_INDEX = 8;
const ON_TOTAL_LATENCY_INDEX = 24;
const CUSTOM_HEADERS_INDEX = 25;
const ON_RESPONSE_ID_INDEX = 15;
const PREVIOUS_RESPONSE_ID_INDEX = 14;
async function sendOneMessage(onUrlUpdate?: OnUrlUpdateFunction): Promise<void> {
renderWithProviders(<ChatConversationPage />, { onUrlUpdate });
@ -83,6 +88,7 @@ describe("/ui/chat request metrics", () => {
beforeEach(() => {
localStorage.clear();
mockMakeOpenAIResponsesRequest.mockReset();
vi.mocked(fetchAvailableModels).mockResolvedValue([{ model_group: "gpt-5.4-mini" }]);
shellState.storageUnavailable = false;
});
@ -109,7 +115,7 @@ describe("/ui/chat request metrics", () => {
expect(screen.getByLabelText("Cost: $0.000063")).toBeInTheDocument();
});
it("supplies the timing, usage and latency callbacks at the positional slots the Responses helper reads", async () => {
it("should preserve metric callback positions and omit a team header for a direct model grant", async () => {
mockMakeOpenAIResponsesRequest.mockResolvedValue(undefined);
await sendOneMessage();
@ -119,6 +125,57 @@ describe("/ui/chat request metrics", () => {
expect(typeof call[ON_TIMING_DATA_INDEX]).toBe("function");
expect(typeof call[ON_USAGE_DATA_INDEX]).toBe("function");
expect(typeof call[ON_TOTAL_LATENCY_INDEX]).toBe("function");
expect(call[CUSTOM_HEADERS_INDEX]).toBeUndefined();
});
it("should ignore a late old-team response ID after selecting a different billing team", async () => {
const user = userEvent.setup();
vi.mocked(fetchAvailableModels).mockResolvedValue([
{
model_group: "gpt-5.4-mini",
available_teams: [
{ team_id: "research", team_alias: "Research" },
{ team_id: "engineering", team_alias: "Engineering" },
],
},
]);
mockMakeOpenAIResponsesRequest.mockImplementation(async (...args: unknown[]) => {
const turn = mockMakeOpenAIResponsesRequest.mock.calls.length;
const updateText = args[1] as (role: string, delta: string) => void;
const onResponseId = args[ON_RESPONSE_ID_INDEX] as (id: string) => void;
updateText("assistant", `Reply ${turn}`);
onResponseId(`response-${turn}`);
});
renderWithProviders(<ChatConversationPage />);
await screen.findByRole("button", { name: "gpt-5.4-mini" });
await user.click(screen.getByRole("combobox", { name: "Team paying for this request" }));
await user.click(await screen.findByRole("option", { name: "Research" }));
async function send(text: string) {
act(() =>
fireEvent.change(screen.getByRole("textbox", { name: "Message", exact: true }), {
target: { value: text },
}),
);
await user.click(screen.getByRole("button", { name: "Send", exact: true }));
}
await send("First question");
await screen.findByText("Reply 1");
await waitFor(() => expect(screen.getByRole("combobox", { name: "Team paying for this request" })).toBeEnabled());
const oldResponseId = mockMakeOpenAIResponsesRequest.mock.calls[0][ON_RESPONSE_ID_INDEX] as (id: string) => void;
await user.click(screen.getByRole("combobox", { name: "Team paying for this request" }));
await user.click(await screen.findByRole("option", { name: "Engineering" }));
act(() => oldResponseId("late-research-response"));
await send("Continue with Engineering");
await screen.findByText("Reply 2");
const engineeringRequest = mockMakeOpenAIResponsesRequest.mock.calls[1];
expect(engineeringRequest[PREVIOUS_RESPONSE_ID_INDEX]).toBeNull();
expect(engineeringRequest[CUSTOM_HEADERS_INDEX]).toEqual({ "x-litellm-team-id": "engineering" });
await send("Continue again");
await screen.findByText("Reply 3");
expect(mockMakeOpenAIResponsesRequest.mock.calls[2][PREVIOUS_RESPONSE_ID_INDEX]).toBe("response-2");
});
it("puts the new conversation ID in the URL after the first message is sent", async () => {

View file

@ -0,0 +1,148 @@
import { act, fireEvent, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders, testQueryClient } from "@/../tests/test-utils";
import { ChatShellProvider } from "@/contexts/ChatShellContext";
import type { ModelGroup } from "@/components/llm_calls/fetch_models";
import { recordChatQuery } from "@/lib/telemetry/chat";
import ChatConversationPage from "./page";
vi.mock("@/lib/telemetry/chat", () => ({ recordChatQuery: vi.fn(async () => {}) }));
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn(), replace: vi.fn() }) }));
const teams = [
{ team_id: "research", team_alias: "Research" },
{ team_id: "engineering", team_alias: "Engineering" },
];
const requests: { path: string; teamId: string | null; body: Record<string, unknown> }[] = [];
let models: ModelGroup[];
function renderChat() {
return renderWithProviders(
<ChatShellProvider
accessToken="session-token"
userId="team-user"
userEmail="member@example.com"
userRole="Internal User"
premiumUser={false}
>
<ChatConversationPage />
</ChatShellProvider>,
);
}
function enterPrompt(text = "Hello") {
act(() =>
fireEvent.change(screen.getByRole("textbox", { name: "Message", exact: true }), { target: { value: text } }),
);
}
beforeEach(() => {
localStorage.clear();
testQueryClient.clear();
vi.mocked(recordChatQuery).mockClear();
requests.length = 0;
models = [{ model_group: "shared-chat", mode: "chat", providers: ["openai"], available_teams: teams }];
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const request =
input instanceof Request ? input : new Request(new URL(String(input), window.location.origin), init);
const path = new URL(request.url).pathname;
if (path.endsWith("/model_group/info")) return Response.json({ data: models });
if (path.endsWith("/responses") || path.endsWith("/images/generations")) {
requests.push({
path,
teamId: request.headers.get("x-litellm-team-id"),
body: JSON.parse(await request.text()),
});
if (path.endsWith("/images/generations"))
return Response.json({ created: 1, data: [{ b64_json: "aGVsbG8=" }] });
const events = [
{ type: "response.output_text.delta", delta: `Reply ${requests.length}` },
{ type: "response.completed", response: { id: `response-${requests.length}`, output: [] } },
];
return new Response(
events.map((event) => `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`).join(""),
{ headers: { "Content-Type": "text/event-stream" } },
);
}
return Response.json({}, { status: 404 });
}),
);
});
afterEach(() => {
vi.unstubAllGlobals();
localStorage.clear();
});
describe("Chat session team selection", () => {
it("should render the model and require a billing choice only when multiple teams grant access", async () => {
renderChat();
await screen.findByRole("button", { name: "shared-chat" });
expect(screen.getByRole("combobox", { name: "Team paying for this request" })).toHaveTextContent("Choose team");
enterPrompt();
expect(screen.getByRole("button", { name: "Send", exact: true })).toBeDisabled();
expect(requests).toHaveLength(0);
expect(recordChatQuery).not.toHaveBeenCalled();
});
it.each([
{ allowed: [], teamId: null },
{ allowed: [teams[0]], teamId: "research" },
])("should submit without a team selector when access is unambiguous ($teamId)", async ({ allowed, teamId }) => {
models[0].available_teams = allowed;
const user = userEvent.setup();
renderChat();
await screen.findByRole("button", { name: "shared-chat" });
expect(screen.queryByRole("combobox", { name: "Team paying for this request" })).not.toBeInTheDocument();
enterPrompt();
await user.click(screen.getByRole("button", { name: "Send", exact: true }));
await screen.findByText("Reply 1");
expect(requests[0].teamId).toBe(teamId);
});
it("should charge the selected team and replay history when switching teams", async () => {
const user = userEvent.setup();
renderChat();
await screen.findByRole("button", { name: "shared-chat" });
await user.click(screen.getByRole("combobox", { name: "Team paying for this request" }));
await user.click(await screen.findByRole("option", { name: "Research" }));
enterPrompt("First question");
await user.click(screen.getByRole("button", { name: "Send", exact: true }));
await screen.findByText("Reply 1");
await user.click(screen.getByRole("combobox", { name: "Team paying for this request" }));
await user.click(await screen.findByRole("option", { name: "Engineering" }));
enterPrompt("Follow up");
await user.click(screen.getByRole("button", { name: "Send", exact: true }));
await screen.findByText("Reply 2");
expect(requests.map((request) => request.teamId)).toEqual(["research", "engineering"]);
expect(requests[1].body).not.toHaveProperty("previous_response_id");
expect(requests[1].body.input).toEqual(
expect.arrayContaining([expect.objectContaining({ role: "user", content: "First question" })]),
);
expect(recordChatQuery).toHaveBeenCalledTimes(2);
});
it("should send image generation to the chosen team and clear the choice when changing models", async () => {
models.push({ model_group: "studio-image", mode: "image_generation", available_teams: teams });
const user = userEvent.setup();
renderChat();
await screen.findByRole("button", { name: "shared-chat" });
await user.click(screen.getByRole("combobox", { name: "Team paying for this request" }));
await user.click(await screen.findByRole("option", { name: "Research" }));
await user.click(screen.getByRole("button", { name: "shared-chat", exact: true }));
await user.click(await screen.findByRole("button", { name: /^studio-image/ }));
enterPrompt("Draw a circle");
expect(screen.getByRole("button", { name: "Generate image", exact: true })).toBeDisabled();
await user.click(screen.getByRole("combobox", { name: "Team paying for this request" }));
await user.click(await screen.findByRole("option", { name: "Engineering" }));
await user.click(screen.getByRole("button", { name: "Generate image", exact: true }));
await waitFor(() => expect(requests).toHaveLength(1));
expect(requests[0]).toMatchObject({
teamId: "engineering",
body: { model: "studio-image", prompt: "Draw a circle" },
});
});
});

View file

@ -12,7 +12,13 @@ import ChatMessages from "@/components/chat/ChatMessages";
import MCPConnectPicker from "@/components/chat/MCPConnectPicker";
import { fetchAvailableModels, type ModelGroup } from "@/components/llm_calls/fetch_models";
import type { AssistantMessageUpdate, ChatImageAttachment, ChatMessage } from "@/components/chat/types";
import ChatModelPicker, { isChatModel, modelChatMode, type ChatMode } from "@/components/chat/ChatModelPicker";
import ChatModelPicker, {
ChatTeamPicker,
chatTeamSelection,
isChatModel,
modelChatMode,
type ChatMode,
} from "@/components/chat/ChatModelPicker";
import MoyaiChatComposer from "@/components/chat/moyai/MoyaiChatComposer";
import MoyaiChatHome from "@/components/chat/moyai/MoyaiChatHome";
import { runChatRequest } from "@/components/chat/runChatRequest";
@ -48,6 +54,7 @@ export default function ChatConversationPage() {
} = useChatShell();
const [selectedModel, setSelectedModel] = useState<string | null>(null);
const [selectedTeamId, setSelectedTeamId] = useState<string | null>(null);
const [models, setModels] = useState<ModelGroup[]>([]);
const [mode, setMode] = useState<ChatMode>("chat");
const [attachments, setAttachments] = useState<ChatImageAttachment[]>([]);
@ -139,6 +146,7 @@ export default function ChatConversationPage() {
}
responsesSessionGenerationRef.current += 1;
setSelectedModel(model.model_group);
setSelectedTeamId(null);
setMode(modelChatMode(model));
setResponsesSessionId(null);
setAttachmentError(null);
@ -201,6 +209,14 @@ export default function ChatConversationPage() {
const model = generationModel ?? selectedModel;
if (!hasMessageContent(trimmed, images, requestMode) || !model) return;
if (isStreaming || abortControllerRef.current || readImagesRef.current) return;
const teamSelection = chatTeamSelection(
models.find((item) => item.model_group === model),
selectedTeamId,
);
if (teamSelection.needsSelection) {
setAttachmentError("Choose which team should pay for this request.");
return;
}
const sessionGeneration = responsesSessionGenerationRef.current;
setInputText("");
@ -254,6 +270,7 @@ export default function ChatConversationPage() {
mode: requestMode,
prompt: trimmed,
accessToken,
teamId: teamSelection.teamId,
signal: requestController.signal,
messages: history,
previousResponseId,
@ -278,6 +295,8 @@ export default function ChatConversationPage() {
activeConversationId,
activeConversation,
selectedModel,
selectedTeamId,
models,
selectedMCPServers,
mode,
attachments,
@ -311,11 +330,31 @@ export default function ChatConversationPage() {
return;
}
if (!newContent.trim() && !original.attachments?.length) return;
const requestModel = original.imageGeneration?.model ?? selectedModel;
if (
chatTeamSelection(
models.find((item) => item.model_group === requestModel),
selectedTeamId,
).needsSelection
) {
setAttachmentError("Choose a team for this model before resending the message.");
return;
}
const priorMessages = msgs.slice(0, idx);
truncateFromMessage(activeConversationId, messageId);
handleSend(newContent, priorMessages, original.attachments ?? [], original.imageGeneration?.model);
},
[activeConversationId, isStreaming, activeConversation, truncateFromMessage, handleSend, mode, selectedModel],
[
activeConversationId,
isStreaming,
activeConversation,
truncateFromMessage,
handleSend,
mode,
selectedModel,
models,
selectedTeamId,
],
);
// Track scroll position to show/hide scroll-to-bottom button
@ -365,9 +404,21 @@ export default function ChatConversationPage() {
const showBlankState = !activeConversation || activeConversation.messages.length === 0;
const selectTeam = (teamId: string | null) => {
responsesSessionGenerationRef.current += 1;
setSelectedTeamId(teamId);
setResponsesSessionId(null);
setAttachmentError(null);
};
const isModelSelectionDisabled = isStreaming || isReadingImages;
const inputBar = (inConversation: boolean) => {
const preparing = isLoadingModels || isReadingImages;
const submitDisabled = !hasMessageContent(inputText, attachments, mode) || !selectedModel || preparing;
const model = models.find((item) => item.model_group === selectedModel);
const teamSelection = chatTeamSelection(model, selectedTeamId);
const canSubmit = hasMessageContent(inputText, attachments, mode) && selectedModel;
const submitDisabled = !canSubmit || preparing || teamSelection.needsSelection;
return (
<div
onDragOver={(event) => {
@ -427,13 +478,21 @@ export default function ChatConversationPage() {
) : undefined
}
modelPicker={
<ChatModelPicker
models={models}
selectedModel={selectedModel}
loading={isLoadingModels}
disabled={isStreaming || isReadingImages}
onSelect={selectModel}
/>
<div className="flex min-w-0 flex-wrap items-center justify-end gap-1">
<ChatTeamPicker
model={model}
selectedTeamId={selectedTeamId}
disabled={isModelSelectionDisabled}
onSelect={selectTeam}
/>
<ChatModelPicker
models={models}
selectedModel={selectedModel}
loading={isLoadingModels}
disabled={isModelSelectionDisabled}
onSelect={selectModel}
/>
</div>
}
tools={
mode === "chat" ? (

View file

@ -4,6 +4,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import type { ModelGroup } from "@/components/llm_calls/fetch_models";
@ -17,6 +18,51 @@ export function isChatModel(model: ModelGroup): boolean {
return !model.mode || ["chat", "responses", "completion", "image_generation"].includes(model.mode);
}
export function chatTeamSelection(
model: ModelGroup | undefined,
selectedTeamId: string | null,
): { teamId: string | undefined; needsSelection: boolean } {
const teams = model?.available_teams ?? [];
const team = teams.length === 1 ? teams[0] : teams.find((item) => item.team_id === selectedTeamId);
return { teamId: team?.team_id, needsSelection: teams.length > 1 && !team };
}
export function ChatTeamPicker({
model,
selectedTeamId,
disabled,
onSelect,
}: {
model: ModelGroup | undefined;
selectedTeamId: string | null;
disabled: boolean;
onSelect: (teamId: string | null) => void;
}) {
const teams = model?.available_teams ?? [];
if (teams.length < 2) return null;
const selection = chatTeamSelection(model, selectedTeamId);
return (
<Select
value={selection.teamId ?? null}
items={teams.map((team) => ({ value: team.team_id, label: team.team_alias || team.team_id }))}
disabled={disabled}
onValueChange={onSelect}
>
<SelectTrigger aria-label="Team paying for this request" size="sm" className="max-w-40">
<SelectValue placeholder="Choose team" />
</SelectTrigger>
<SelectContent className="chat-theme w-max min-w-64 max-w-[calc(100vw-2rem)]">
{teams.map((team) => (
<SelectItem key={team.team_id} value={team.team_id}>
{team.team_alias || team.team_id}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
export default function ChatModelPicker({
models,
selectedModel,

View file

@ -76,13 +76,20 @@ export async function generateChatImages({
model,
accessToken,
signal,
teamId,
}: {
prompt: string;
model: string;
accessToken: string;
signal?: AbortSignal;
teamId?: string;
}): Promise<ChatImageAttachment[]> {
const client = new OpenAI({ apiKey: accessToken, baseURL: getGatewayBaseUrl(), dangerouslyAllowBrowser: true });
const client = new OpenAI({
apiKey: accessToken,
baseURL: getGatewayBaseUrl(),
dangerouslyAllowBrowser: true,
defaultHeaders: teamId ? { "x-litellm-team-id": teamId } : undefined,
});
const response = await client.images.generate({ model, prompt }, { signal });
const mimeType = `image/${response.output_format ?? "png"}`;
const images = (response.data ?? []).flatMap((image, index): ChatImageAttachment[] => {

View file

@ -7,6 +7,7 @@ interface ChatRequestOptions {
mode: "chat" | "image";
prompt: string;
accessToken: string;
teamId?: string;
signal: AbortSignal;
messages: Pick<
ChatMessage,
@ -23,6 +24,7 @@ export async function runChatRequest({
mode,
prompt,
accessToken,
teamId,
signal,
messages,
previousResponseId,
@ -37,7 +39,7 @@ export async function runChatRequest({
try {
signal.throwIfAborted();
if (mode === "image") {
const imageRequest = { prompt, model, accessToken, signal };
const imageRequest = { prompt, model, accessToken, signal, teamId };
const generatedImages = await generateChatImages(imageRequest);
signal.throwIfAborted();
onUpdate({ generatedImages });
@ -77,6 +79,7 @@ export async function runChatRequest({
undefined,
true,
(totalLatency) => onUpdate({ totalLatency }),
teamId ? { "x-litellm-team-id": teamId } : undefined,
);
signal.throwIfAborted();
} catch (error: unknown) {

View file

@ -2,9 +2,13 @@
import { excludeProxyWideSentinel } from "@/components/key_team_helpers/fetch_available_models_team_key";
import { modelAvailableCall, modelHubCall } from "@/components/networking";
import type { components } from "@/lib/http/schema";
export type ModelTeam = components["schemas"]["ModelGroupTeam"];
export interface ModelGroup {
model_group: string;
available_teams?: ModelTeam[];
providers?: string[];
mode?: string;
supports_reasoning?: boolean;
@ -14,6 +18,7 @@ export interface ModelGroup {
interface AvailableModel {
model_group?: string | null;
available_teams?: ModelTeam[] | null;
model_name?: string | null;
id?: string | null;
providers?: string[] | null;
@ -27,6 +32,7 @@ const toModelGroup = (item: AvailableModel): ModelGroup => {
const groupName = (item.model_group || item.id || item.model_name) ?? "";
return {
model_group: groupName,
...(item.available_teams && { available_teams: item.available_teams }),
...(item.providers && { providers: item.providers }),
...(item.mode && { mode: item.mode }),
...(item.supports_reasoning === true && { supports_reasoning: true }),

View file

@ -38354,6 +38354,8 @@ export interface components {
};
/** ModelGroupInfoProxy */
ModelGroupInfoProxy: {
/** Available Teams */
available_teams?: components["schemas"]["ModelGroupTeam"][] | null;
/** Configurable Clientside Auth Params */
configurable_clientside_auth_params?: (string | components["schemas"]["ConfigurableClientsideParamsCustomAuth-Output"])[] | null;
/** Custom Metadata */
@ -38447,6 +38449,18 @@ export interface components {
/** Tpm */
tpm?: number | null;
};
/** ModelGroupInfoResponse */
ModelGroupInfoResponse: {
/** Data */
data: components["schemas"]["ModelGroupInfoProxy"][];
};
/** ModelGroupTeam */
ModelGroupTeam: {
/** Team Alias */
team_alias?: string | null;
/** Team Id */
team_id: string;
};
/** ModelInfoDelete */
ModelInfoDelete: {
/** Id */
@ -65518,7 +65532,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/json": unknown;
"application/json": components["schemas"]["ModelGroupInfoResponse"];
};
};
/** @description Validation Error */