diff --git a/changes/11480.feature.md b/changes/11480.feature.md new file mode 100644 index 00000000000..cdfbde8dc09 --- /dev/null +++ b/changes/11480.feature.md @@ -0,0 +1 @@ +Expose a `modelCards` connection on `VFolder` GraphQL nodes for reverse lookup from a vfolder to its registered model cards. diff --git a/docs/manager/graphql-reference/supergraph.graphql b/docs/manager/graphql-reference/supergraph.graphql index 6e75b72e73b..5209b3aab24 100644 --- a/docs/manager/graphql-reference/supergraph.graphql +++ b/docs/manager/graphql-reference/supergraph.graphql @@ -20145,6 +20145,9 @@ type VFolder implements Node """Path for unmanaged virtual folders.""" unmanagedPath: String + + """Added in UNRELEASED. Model cards backed by this vfolder.""" + modelCards(filter: ModelCardV2Filter = null, orderBy: [ModelCardV2OrderBy!] = null, before: String = null, after: String = null, first: Int = null, last: Int = null, limit: Int = null, offset: Int = null): ModelCardV2Connection } """ diff --git a/docs/manager/graphql-reference/v2-schema.graphql b/docs/manager/graphql-reference/v2-schema.graphql index fc690ad83fb..ddc3e32c20b 100644 --- a/docs/manager/graphql-reference/v2-schema.graphql +++ b/docs/manager/graphql-reference/v2-schema.graphql @@ -13940,6 +13940,9 @@ type VFolder implements Node { """Path for unmanaged virtual folders.""" unmanagedPath: String + + """Added in UNRELEASED. Model cards backed by this vfolder.""" + modelCards(filter: ModelCardV2Filter = null, orderBy: [ModelCardV2OrderBy!] = null, before: String = null, after: String = null, first: Int = null, last: Int = null, limit: Int = null, offset: Int = null): ModelCardV2Connection } """ diff --git a/src/ai/backend/manager/api/adapters/model_card/adapter.py b/src/ai/backend/manager/api/adapters/model_card/adapter.py index 670d6586093..e596ad74ea2 100644 --- a/src/ai/backend/manager/api/adapters/model_card/adapter.py +++ b/src/ai/backend/manager/api/adapters/model_card/adapter.py @@ -76,7 +76,10 @@ from ai.backend.manager.repositories.base.rbac.entity_creator import RBACEntityCreator from ai.backend.manager.repositories.base.updater import Updater from ai.backend.manager.repositories.model_card.creators import ModelCardCreatorSpec -from ai.backend.manager.repositories.model_card.types import ProjectModelCardSearchScope +from ai.backend.manager.repositories.model_card.types import ( + ProjectModelCardSearchScope, + VFolderModelCardSearchScope, +) from ai.backend.manager.repositories.model_card.updaters import ModelCardUpdaterSpec from ai.backend.manager.services.deployment.actions.create_deployment import CreateDeploymentAction from ai.backend.manager.services.model_card.actions.available_presets import ( @@ -218,6 +221,42 @@ async def project_search( has_previous_page=result.has_previous_page, ) + async def search_by_vfolder( + self, + scope: VFolderModelCardSearchScope, + input: SearchModelCardsInput, + ) -> SearchModelCardsPayload: + """Search model cards backed by a specific VFolder. + + Used by the ``VFolderGQL.model_cards`` nested resolver. Access is + delegated to the parent VFolder resolver — the caller must already + have permission to resolve the VFolder. + """ + conditions = [scope.to_condition()] + if input.filter: + conditions.extend(self._convert_filter(input.filter)) + orders = self._convert_orders(input.order) if input.order else [] + querier = self._build_querier( + conditions=conditions, + orders=orders, + pagination_spec=_model_card_pagination_spec(), + first=input.first, + after=input.after, + last=input.last, + before=input.before, + limit=input.limit, + offset=input.offset, + ) + result = await self._processors.model_card.search.wait_for_complete( + SearchModelCardsAction(querier=querier) + ) + return SearchModelCardsPayload( + items=[self._data_to_node(d) for d in result.items], + total_count=result.total_count, + has_next_page=result.has_next_page, + has_previous_page=result.has_previous_page, + ) + async def get(self, card_id: UUID) -> ModelCardNode: conditions: list[QueryCondition] = [lambda: ModelCardRow.id == card_id] querier = self._build_querier( diff --git a/src/ai/backend/manager/api/gql/vfolder_v2/types/node.py b/src/ai/backend/manager/api/gql/vfolder_v2/types/node.py index ea014bdb641..21876d76079 100644 --- a/src/ai/backend/manager/api/gql/vfolder_v2/types/node.py +++ b/src/ai/backend/manager/api/gql/vfolder_v2/types/node.py @@ -4,18 +4,33 @@ from collections.abc import Iterable from typing import Any +from uuid import UUID -from strawberry.relay import Connection, Edge, NodeID +from strawberry import Info +from strawberry.relay import Connection, Edge, NodeID, PageInfo +from ai.backend.common.dto.manager.v2.model_card.request import SearchModelCardsInput from ai.backend.common.dto.manager.v2.vfolder.response import VFolderNode +from ai.backend.common.identifier.vfolder import VFolderUUID +from ai.backend.common.meta import NEXT_RELEASE_VERSION from ai.backend.manager.api.gql.decorators import ( BackendAIGQLMeta, + gql_added_field, gql_connection_type, gql_field, gql_node_type, ) +from ai.backend.manager.api.gql.model_card.types import ( + ModelCardFilterGQL, + ModelCardGQL, + ModelCardOrderByGQL, + ModelCardV2Connection, + ModelCardV2Edge, +) from ai.backend.manager.api.gql.pydantic_compat import PydanticNodeMixin +from ai.backend.manager.api.gql.types import StrawberryGQLContext from ai.backend.manager.api.gql.vfolder_v2.types.enum import VFolderOperationStatusGQL +from ai.backend.manager.repositories.model_card.types import VFolderModelCardSearchScope from .nested import ( VFolderAccessControlInfoGQL, @@ -64,6 +79,52 @@ class VFolderGQL(PydanticNodeMixin[VFolderNode]): ) unmanaged_path: str | None = gql_field(description="Path for unmanaged virtual folders.") + @gql_added_field( + BackendAIGQLMeta( + added_version=NEXT_RELEASE_VERSION, + description="Model cards backed by this vfolder.", + ) + ) # type: ignore[misc] + async def model_cards( + self, + info: Info[StrawberryGQLContext], + filter: ModelCardFilterGQL | None = None, + order_by: list[ModelCardOrderByGQL] | None = None, + before: str | None = None, + after: str | None = None, + first: int | None = None, + last: int | None = None, + limit: int | None = None, + offset: int | None = None, + ) -> ModelCardV2Connection | None: + result = await info.context.adapters.model_card.search_by_vfolder( + scope=VFolderModelCardSearchScope(vfolder_id=VFolderUUID(UUID(self.id))), + input=SearchModelCardsInput( + filter=filter.to_pydantic() if filter is not None else None, + order=[o.to_pydantic() for o in order_by] if order_by else None, + first=first, + after=after, + last=last, + before=before, + limit=limit, + offset=offset, + ), + ) + edges = [ + ModelCardV2Edge(node=ModelCardGQL.from_pydantic(item), cursor=str(item.id)) + for item in result.items + ] + return ModelCardV2Connection( + edges=edges, + page_info=PageInfo( + has_next_page=result.has_next_page, + has_previous_page=result.has_previous_page, + start_cursor=edges[0].cursor if edges else None, + end_cursor=edges[-1].cursor if edges else None, + ), + count=result.total_count, + ) + @classmethod async def resolve_nodes( # type: ignore[override] # Strawberry Node uses AwaitableOrValue overloads incompatible with async def cls, diff --git a/src/ai/backend/manager/models/model_card/conditions.py b/src/ai/backend/manager/models/model_card/conditions.py index 10d8f8a9052..5a2d8af93b0 100644 --- a/src/ai/backend/manager/models/model_card/conditions.py +++ b/src/ai/backend/manager/models/model_card/conditions.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Collection from uuid import UUID import sqlalchemy as sa @@ -11,6 +12,7 @@ UUIDEqualMatchSpec, UUIDInMatchSpec, ) +from ai.backend.common.identifier.vfolder import VFolderUUID from ai.backend.manager.models.condition_utils import ( make_nested_string_in_factory, make_string_in_factory, @@ -23,6 +25,13 @@ class ModelCardConditions: + @staticmethod + def by_vfolder_ids(vfolder_ids: Collection[VFolderUUID]) -> QueryCondition: + def inner() -> sa.sql.expression.ColumnElement[bool]: + return ModelCardRow.vfolder.in_(vfolder_ids) + + return inner + @staticmethod def by_domain(domain_name: str) -> QueryCondition: def inner() -> sa.sql.expression.ColumnElement[bool]: diff --git a/src/ai/backend/manager/repositories/model_card/types.py b/src/ai/backend/manager/repositories/model_card/types.py index 4e0b7affa70..7b733946212 100644 --- a/src/ai/backend/manager/repositories/model_card/types.py +++ b/src/ai/backend/manager/repositories/model_card/types.py @@ -8,6 +8,7 @@ import sqlalchemy as sa +from ai.backend.common.identifier.vfolder import VFolderUUID from ai.backend.manager.data.deployment_revision_preset.types import DeploymentRevisionPresetData from ai.backend.manager.data.model_card.types import ModelCardData from ai.backend.manager.data.permission.types import EntityType, ScopeType @@ -23,6 +24,7 @@ "AvailablePresetsSearchResult", "ModelCardSearchResult", "ProjectModelCardSearchScope", + "VFolderModelCardSearchScope", ) @@ -86,3 +88,26 @@ def membership_check_query(self) -> sa.Select[tuple[bool]]: AssociationScopesEntitiesRow.entity_id == str(self.user_id), ) ) + + +@dataclass(frozen=True) +class VFolderModelCardSearchScope(SearchScope): + """Scope for searching model cards backed by a specific VFolder. + + Access is delegated to the parent VFolder resolver — if the caller + can resolve the VFolder, they may see model cards backed by it. + """ + + vfolder_id: VFolderUUID + + def to_condition(self) -> QueryCondition: + vfolder_id = self.vfolder_id + + def inner() -> sa.sql.expression.ColumnElement[bool]: + return ModelCardRow.vfolder == vfolder_id + + return inner + + @property + def existence_checks(self) -> Sequence[ExistenceCheck[UUID]]: + return ()