Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changes/11480.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Expose a `modelCards` connection on `VFolder` GraphQL nodes for reverse lookup from a vfolder to its registered model cards.
3 changes: 3 additions & 0 deletions docs/manager/graphql-reference/supergraph.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

"""
Expand Down
3 changes: 3 additions & 0 deletions docs/manager/graphql-reference/v2-schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

"""
Expand Down
41 changes: 40 additions & 1 deletion src/ai/backend/manager/api/adapters/model_card/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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(
Expand Down
63 changes: 62 additions & 1 deletion src/ai/backend/manager/api/gql/vfolder_v2/types/node.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
9 changes: 9 additions & 0 deletions src/ai/backend/manager/models/model_card/conditions.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

from collections.abc import Collection
from uuid import UUID

import sqlalchemy as sa
Expand All @@ -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,
Expand All @@ -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]:
Expand Down
25 changes: 25 additions & 0 deletions src/ai/backend/manager/repositories/model_card/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -23,6 +24,7 @@
"AvailablePresetsSearchResult",
"ModelCardSearchResult",
"ProjectModelCardSearchScope",
"VFolderModelCardSearchScope",
)


Expand Down Expand Up @@ -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 ()
Loading