From 34744cd29eacea9bbdec17e628a81c2ce0737d16 Mon Sep 17 00:00:00 2001 From: Simon Pinfold Date: Mon, 10 Aug 2026 14:05:21 -0700 Subject: [PATCH] Add tags_all / tags_any / tags_none tag filters to the assets list API (#15332) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Implement tags_all/tags_any/tags_none on the assets list API (BE-6600) Adds the three canonically-named tag filter params to GET /api/assets and GET /api/assets/tags/refine: - tags_all: asset carries every tag (replaces include_tags) - tags_any: asset carries at least one tag (new) - tags_none: asset carries no tag (replaces exclude_tags) Clauses intersect; tags_none always wins. include_tags/exclude_tags remain as permanent deprecated aliases and behave exactly as before when used on their own. Invalid combinations return 400 INVALID_TAG_FILTER, but only when the request uses at least one new-name parameter (non-empty after normalisation): - mixed spellings of one slot (include_tags with tags_all, exclude_tags with tags_none) - the same tag in the effective all-list and none-list (query can never match) Old-names-only requests gain no new error paths: include_tags=a&exclude_tags=a still returns an empty 200. tags_any/tags_none overlap stays valid (dead term, not a dead query). * Address review findings: positional-compat, deprecation metadata, test matrix - Move any_tags to the end of the four touched signatures: inserting it mid-signature silently misbound pre-existing positional callers (e.g. a caller passing name_contains positionally would have it consumed as any_tags). - Mark include_tags/exclude_tags Field(deprecated=True) on both list schemas so generated schema metadata matches the contract, not just a comment (schemas_out.py already uses this form for Asset.name). - Add tests: legal cross-slot old/new combinations, repeated query-key concatenation (pins Core behavior; outside the cross-platform contract), tags_any two-page cursor consistency (total/has_more/ no-overlap), refine-route mixed-spelling rejection + legacy-conflict preservation, and schema deprecation metadata. * Pin tag-value opacity: case-sensitive matching, byte-exact conflict check The prod tag survey (~/comfy/prod-model-tag-shape.md) found live case-distinct tag pairs (SEEDVR2/seedvr2) that resolve differently, so the contract now states tag values are opaque byte-strings. Pin that: case-distinct tags filter separately, and a case-distinct all/none pair is not an INVALID_TAG_FILTER conflict. * Document tags_all/tags_any/tags_none in openapi.yaml, deprecate aliases Add the three tag-filter parameters to both listAssets and getAssetTagHistogram parameter blocks and mark include_tags/exclude_tags deprecated: true, keeping the spec in step with the runtime schemas so generated clients can discover the new filters while the aliases stay present for existing consumers. * Move schemas_in import to module scope in test_list_filter Review feedback: no import cycle requires the local import. * Silence per-request DeprecationWarning in the tag-filter remap shim Reading the deprecated include_tags/exclude_tags fields by attribute fires pydantic's DeprecationWarning on every list/refine request even for callers using only the new names. The warning is aimed at API clients, not the server's own remap; read via model_dump instead. * Cap tag-filter lists at 100 entries, all spellings Review finding: unbounded tag lists fan out into one correlated EXISTS per tag on both page and count statements. Cap each list at 100 normalized entries with 400 INVALID_TAG_FILTER naming the parameter. Applies to the legacy spellings as well — a deliberate, decided exception to the old-names-behave-identically rule, since a cap only on new names would leave the same fan-out reachable through the aliases. * Strip process narration from comments Comments carried decision dates, contract cross-references, and review context. Keep only the constraints the code cannot show, one line each. --- app/assets/api/routes.py | 103 ++++- app/assets/api/schemas_in.py | 26 +- .../database/queries/asset_reference.py | 6 +- app/assets/database/queries/common.py | 13 +- app/assets/database/queries/tags.py | 4 +- app/assets/services/asset_management.py | 3 + app/assets/services/tagging.py | 3 + openapi.yaml | 66 ++- tests-unit/assets_test/test_list_filter.py | 419 ++++++++++++++++++ 9 files changed, 624 insertions(+), 19 deletions(-) diff --git a/app/assets/api/routes.py b/app/assets/api/routes.py index e25b8a57f..d43485861 100644 --- a/app/assets/api/routes.py +++ b/app/assets/api/routes.py @@ -18,7 +18,7 @@ from app.assets.api.schemas_in import ( AssetValidationError, UploadError, ) -from app.assets.helpers import validate_blake3_hash +from app.assets.helpers import normalize_tags, validate_blake3_hash from app.assets.api.upload import ( delete_temp_file_if_exists, parse_multipart_upload, @@ -117,6 +117,87 @@ def _build_validation_error_response(code: str, ve: ValidationError) -> web.Resp return _build_error_response(400, code, "Validation failed.", {"errors": errors}) +class InvalidTagFilterError(Exception): + """Invalid combination of tag-filter query parameters.""" + + def __init__(self, message: str, details: dict): + super().__init__(message) + self.details = details + + +# Caps the per-tag EXISTS fan-out; deliberately covers the legacy spellings too. +MAX_TAG_FILTER_TAGS = 100 + + +def _resolve_tag_filters( + q: schemas_in.ListAssetsQuery | schemas_in.TagsRefineQuery, +) -> tuple[list[str], list[str], list[str]]: + """Resolve legacy (include/exclude) and new (all/any/none) tag-filter + spellings into effective (all, any, none) lists. + + Combination validation applies only when the request uses at least one + new-name parameter (non-empty after normalisation); requests using only + the legacy names keep their historical behaviour, including degenerate + combinations like include_tags=a&exclude_tags=a. + """ + # model_dump, not attribute access: deprecated fields warn on every attribute read. + legacy = q.model_dump(include={"include_tags", "exclude_tags"}) + include_tags = normalize_tags(legacy["include_tags"]) + exclude_tags = normalize_tags(legacy["exclude_tags"]) + tags_all = normalize_tags(q.tags_all) + tags_any = normalize_tags(q.tags_any) + tags_none = normalize_tags(q.tags_none) + + for param_name, values in ( + ("include_tags", include_tags), + ("exclude_tags", exclude_tags), + ("tags_all", tags_all), + ("tags_any", tags_any), + ("tags_none", tags_none), + ): + if len(values) > MAX_TAG_FILTER_TAGS: + raise InvalidTagFilterError( + f"'{param_name}' lists {len(values)} tags; the maximum is " + f"{MAX_TAG_FILTER_TAGS}.", + { + "parameter": param_name, + "count": len(values), + "max": MAX_TAG_FILTER_TAGS, + }, + ) + + if not (tags_all or tags_any or tags_none): + return include_tags, [], exclude_tags + + if include_tags and tags_all: + raise InvalidTagFilterError( + "Cannot combine 'include_tags' with 'tags_all'; use 'tags_all'.", + {"parameters": ["include_tags", "tags_all"]}, + ) + if exclude_tags and tags_none: + raise InvalidTagFilterError( + "Cannot combine 'exclude_tags' with 'tags_none'; use 'tags_none'.", + {"parameters": ["exclude_tags", "tags_none"]}, + ) + + all_param, all_list = ( + ("tags_all", tags_all) if tags_all else ("include_tags", include_tags) + ) + none_param, none_list = ( + ("tags_none", tags_none) if tags_none else ("exclude_tags", exclude_tags) + ) + + conflicting = sorted(set(all_list) & set(none_list)) + if conflicting: + raise InvalidTagFilterError( + f"Query can never match: {', '.join(repr(t) for t in conflicting)} " + f"required by '{all_param}' but rejected by '{none_param}'.", + {"conflicting_tags": conflicting, "parameters": [all_param, none_param]}, + ) + + return all_list, tags_any, none_list + + def _validate_sort_field(requested: str | None) -> str: if not requested: return "created_at" @@ -217,6 +298,11 @@ async def list_assets_route(request: web.Request) -> web.Response: except ValidationError as ve: return _build_validation_error_response("INVALID_QUERY", ve) + try: + tags_all, tags_any, tags_none = _resolve_tag_filters(q) + except InvalidTagFilterError as e: + return _build_error_response(400, "INVALID_TAG_FILTER", str(e), e.details) + sort = _validate_sort_field(q.sort) order_candidate = (q.order or "desc").lower() order = order_candidate if order_candidate in {"asc", "desc"} else "desc" @@ -224,8 +310,9 @@ async def list_assets_route(request: web.Request) -> web.Response: try: result = list_assets_page( owner_id=USER_MANAGER.get_request_user_id(request), - include_tags=q.include_tags, - exclude_tags=q.exclude_tags, + include_tags=tags_all, + exclude_tags=tags_none, + any_tags=tags_any, name_contains=q.name_contains, metadata_filter=q.metadata_filter, limit=q.limit, @@ -715,10 +802,16 @@ async def get_tags_refine(request: web.Request) -> web.Response: except ValidationError as ve: return _build_validation_error_response("INVALID_QUERY", ve) + try: + tags_all, tags_any, tags_none = _resolve_tag_filters(q) + except InvalidTagFilterError as e: + return _build_error_response(400, "INVALID_TAG_FILTER", str(e), e.details) + tag_counts = list_tag_histogram( owner_id=USER_MANAGER.get_request_user_id(request), - include_tags=q.include_tags, - exclude_tags=q.exclude_tags, + include_tags=tags_all, + exclude_tags=tags_none, + any_tags=tags_any, name_contains=q.name_contains, metadata_filter=q.metadata_filter, limit=q.limit, diff --git a/app/assets/api/schemas_in.py b/app/assets/api/schemas_in.py index 38a942b7b..862700a24 100644 --- a/app/assets/api/schemas_in.py +++ b/app/assets/api/schemas_in.py @@ -50,8 +50,12 @@ class ParsedUpload: class ListAssetsQuery(BaseModel): - include_tags: list[str] = Field(default_factory=list) - exclude_tags: list[str] = Field(default_factory=list) + # Deprecated spellings: include_tags ≡ tags_all, exclude_tags ≡ tags_none. + include_tags: list[str] = Field(default_factory=list, deprecated=True) + exclude_tags: list[str] = Field(default_factory=list, deprecated=True) + tags_all: list[str] = Field(default_factory=list) + tags_any: list[str] = Field(default_factory=list) + tags_none: list[str] = Field(default_factory=list) name_contains: str | None = None # Accept either a JSON string (query param) or a dict @@ -70,7 +74,10 @@ class ListAssetsQuery(BaseModel): ) order: Literal["asc", "desc"] = "desc" - @field_validator("include_tags", "exclude_tags", mode="before") + @field_validator( + "include_tags", "exclude_tags", "tags_all", "tags_any", "tags_none", + mode="before", + ) @classmethod def _split_csv_tags(cls, v): # Accept "a,b,c" or ["a","b"] (we are liberal in what we accept) @@ -154,13 +161,20 @@ class CreateFromHashBody(BaseModel): class TagsRefineQuery(BaseModel): - include_tags: list[str] = Field(default_factory=list) - exclude_tags: list[str] = Field(default_factory=list) + # Deprecated spellings: include_tags ≡ tags_all, exclude_tags ≡ tags_none. + include_tags: list[str] = Field(default_factory=list, deprecated=True) + exclude_tags: list[str] = Field(default_factory=list, deprecated=True) + tags_all: list[str] = Field(default_factory=list) + tags_any: list[str] = Field(default_factory=list) + tags_none: list[str] = Field(default_factory=list) name_contains: str | None = None metadata_filter: dict[str, Any] | None = None limit: conint(ge=1, le=1000) = 100 - @field_validator("include_tags", "exclude_tags", mode="before") + @field_validator( + "include_tags", "exclude_tags", "tags_all", "tags_any", "tags_none", + mode="before", + ) @classmethod def _split_csv_tags(cls, v): if v is None: diff --git a/app/assets/database/queries/asset_reference.py b/app/assets/database/queries/asset_reference.py index 967b0e43a..126a0c9a4 100644 --- a/app/assets/database/queries/asset_reference.py +++ b/app/assets/database/queries/asset_reference.py @@ -268,6 +268,8 @@ def list_references_page( order: str | None = None, after_cursor_value: object | None = None, after_cursor_id: str | None = None, + # Appended last so pre-existing positional callers keep binding correctly. + any_tags: Sequence[str] | None = None, ) -> tuple[list[AssetReference], dict[str, list[str]], int]: """List references with pagination, filtering, and sorting. @@ -293,7 +295,7 @@ def list_references_page( escaped, esc = escape_sql_like_string(name_contains) base = base.where(AssetReference.name.ilike(f"%{escaped}%", escape=esc)) - base = apply_tag_filters(base, include_tags, exclude_tags) + base = apply_tag_filters(base, include_tags, exclude_tags, any_tags) base = apply_metadata_filter(base, metadata_filter) sort = (sort or "created_at").lower() @@ -345,7 +347,7 @@ def list_references_page( count_stmt = count_stmt.where( AssetReference.name.ilike(f"%{escaped}%", escape=esc) ) - count_stmt = apply_tag_filters(count_stmt, include_tags, exclude_tags) + count_stmt = apply_tag_filters(count_stmt, include_tags, exclude_tags, any_tags) count_stmt = apply_metadata_filter(count_stmt, metadata_filter) total = int(session.execute(count_stmt).scalar_one() or 0) diff --git a/app/assets/database/queries/common.py b/app/assets/database/queries/common.py index 89bb49327..7b0c211a0 100644 --- a/app/assets/database/queries/common.py +++ b/app/assets/database/queries/common.py @@ -60,10 +60,13 @@ def apply_tag_filters( stmt: sa.sql.Select, include_tags: Sequence[str] | None = None, exclude_tags: Sequence[str] | None = None, + any_tags: Sequence[str] | None = None, ) -> sa.sql.Select: - """include_tags: every tag must be present; exclude_tags: none may be present.""" + """include_tags: every tag must be present; any_tags: at least one must be + present; exclude_tags: none may be present.""" include_tags = normalize_tags(include_tags) exclude_tags = normalize_tags(exclude_tags) + any_tags = normalize_tags(any_tags) if include_tags: for tag_name in include_tags: @@ -74,6 +77,14 @@ def apply_tag_filters( ) ) + if any_tags: + stmt = stmt.where( + exists().where( + (AssetReferenceTag.asset_reference_id == AssetReference.id) + & (AssetReferenceTag.tag_name.in_(any_tags)) + ) + ) + if exclude_tags: stmt = stmt.where( ~exists().where( diff --git a/app/assets/database/queries/tags.py b/app/assets/database/queries/tags.py index 148f34801..e5f70e3df 100644 --- a/app/assets/database/queries/tags.py +++ b/app/assets/database/queries/tags.py @@ -340,6 +340,8 @@ def list_tag_counts_for_filtered_assets( name_contains: str | None = None, metadata_filter: dict | None = None, limit: int = 100, + # Appended last so pre-existing positional callers keep binding correctly. + any_tags: Sequence[str] | None = None, ) -> dict[str, int]: """Return tag counts for assets matching the given filters. @@ -359,7 +361,7 @@ def list_tag_counts_for_filtered_assets( escaped, esc = escape_sql_like_string(name_contains) ref_sq = ref_sq.where(AssetReference.name.ilike(f"%{escaped}%", escape=esc)) - ref_sq = apply_tag_filters(ref_sq, include_tags, exclude_tags) + ref_sq = apply_tag_filters(ref_sq, include_tags, exclude_tags, any_tags) ref_sq = apply_metadata_filter(ref_sq, metadata_filter) ref_sq = ref_sq.subquery() diff --git a/app/assets/services/asset_management.py b/app/assets/services/asset_management.py index a4c8b5a75..efdfd31a8 100644 --- a/app/assets/services/asset_management.py +++ b/app/assets/services/asset_management.py @@ -279,6 +279,8 @@ def list_assets_page( sort: str = "created_at", order: str = "desc", after: str | None = None, + # Appended last so pre-existing positional callers keep binding correctly. + any_tags: Sequence[str] | None = None, ) -> ListAssetsResult: """List assets with optional cursor pagination. @@ -317,6 +319,7 @@ def list_assets_page( owner_id=owner_id, include_tags=include_tags, exclude_tags=exclude_tags, + any_tags=any_tags, name_contains=name_contains, metadata_filter=metadata_filter, limit=fetch_limit, diff --git a/app/assets/services/tagging.py b/app/assets/services/tagging.py index 5fa39d26a..69c8cf39c 100644 --- a/app/assets/services/tagging.py +++ b/app/assets/services/tagging.py @@ -85,6 +85,8 @@ def list_tag_histogram( name_contains: str | None = None, metadata_filter: dict | None = None, limit: int = 100, + # Appended last so pre-existing positional callers keep binding correctly. + any_tags: Sequence[str] | None = None, ) -> dict[str, int]: with create_session() as session: return list_tag_counts_for_filtered_assets( @@ -92,6 +94,7 @@ def list_tag_histogram( owner_id=owner_id, include_tags=include_tags, exclude_tags=exclude_tags, + any_tags=any_tags, name_contains=name_contains, metadata_filter=metadata_filter, limit=limit, diff --git a/openapi.yaml b/openapi.yaml index a50312226..59c659dd3 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -1521,7 +1521,8 @@ paths: Supports filtering by tags, name, metadata, and sorting options. operationId: listAssets parameters: - - description: Filter assets that have ALL of these tags + - deprecated: true + description: 'Deprecated alias of tags_all: filter assets that have ALL of these tags' explode: false in: query name: include_tags @@ -1530,7 +1531,8 @@ paths: type: string type: array style: form - - description: Exclude assets that have ANY of these tags + - deprecated: true + description: 'Deprecated alias of tags_none: exclude assets that have ANY of these tags' explode: false in: query name: exclude_tags @@ -1539,6 +1541,33 @@ paths: type: string type: array style: form + - description: Filter assets that have ALL of these tags + explode: false + in: query + name: tags_all + schema: + items: + type: string + type: array + style: form + - description: Filter assets that have AT LEAST ONE of these tags + explode: false + in: query + name: tags_any + schema: + items: + type: string + type: array + style: form + - description: Exclude assets that have ANY of these tags + explode: false + in: query + name: tags_none + schema: + items: + type: string + type: array + style: form - description: Filter assets where name contains this substring (case-insensitive) in: query name: name_contains @@ -2312,7 +2341,8 @@ paths: Only returns tags with non-zero counts (tags that exist on matching assets). operationId: getAssetTagHistogram parameters: - - description: Filter assets that have ALL of these tags + - deprecated: true + description: 'Deprecated alias of tags_all: filter assets that have ALL of these tags' explode: false in: query name: include_tags @@ -2321,7 +2351,8 @@ paths: type: string type: array style: form - - description: Exclude assets that have ANY of these tags + - deprecated: true + description: 'Deprecated alias of tags_none: exclude assets that have ANY of these tags' explode: false in: query name: exclude_tags @@ -2330,6 +2361,33 @@ paths: type: string type: array style: form + - description: Filter assets that have ALL of these tags + explode: false + in: query + name: tags_all + schema: + items: + type: string + type: array + style: form + - description: Filter assets that have AT LEAST ONE of these tags + explode: false + in: query + name: tags_any + schema: + items: + type: string + type: array + style: form + - description: Exclude assets that have ANY of these tags + explode: false + in: query + name: tags_none + schema: + items: + type: string + type: array + style: form - description: Filter assets where name contains this substring (case-insensitive) in: query name: name_contains diff --git a/tests-unit/assets_test/test_list_filter.py b/tests-unit/assets_test/test_list_filter.py index d1cba87b3..04da4f86a 100644 --- a/tests-unit/assets_test/test_list_filter.py +++ b/tests-unit/assets_test/test_list_filter.py @@ -1,10 +1,14 @@ import time import uuid +import warnings import pytest import requests from helpers import assert_hash_fields_consistent +from app.assets.api import routes as assets_routes +from app.assets.api import schemas_in + def test_list_assets_paging_and_sort(http: requests.Session, api_base: str, asset_factory, make_asset_bytes): names = ["a1_u.safetensors", "a2_u.safetensors", "a3_u.safetensors"] @@ -337,3 +341,418 @@ def test_list_assets_name_contains_literal_underscore( assert b["name"] not in names, "Underscore must be escaped — should not match 'fooxbar'" assert c["name"] not in names, "Underscore must be escaped — should not match 'foobar'" assert body["total"] == 1 + + +def test_list_assets_tags_any_alone(http, api_base, asset_factory, make_asset_bytes): + scope = f"lf-any-{uuid.uuid4().hex[:6]}" + t = ["models", "model_type:checkpoints", "unit-tests", scope] + a = asset_factory("any_a.safetensors", [*t, f"{scope}-alpha"], {}, make_asset_bytes("any_a")) + b = asset_factory("any_b.safetensors", [*t, f"{scope}-beta"], {}, make_asset_bytes("any_b")) + c = asset_factory("any_c.safetensors", [*t, f"{scope}-gamma"], {}, make_asset_bytes("any_c")) + + r = http.get( + api_base + "/api/assets", + params={"tags_any": f"{scope}-alpha,{scope}-beta", "limit": "50"}, + timeout=120, + ) + body = r.json() + assert r.status_code == 200, body + names = [x["name"] for x in body["assets"]] + assert a["name"] in names + assert b["name"] in names + assert c["name"] not in names + + +def test_list_assets_tags_any_with_tags_all(http, api_base, asset_factory, make_asset_bytes): + scope = f"lf-anyall-{uuid.uuid4().hex[:6]}" + t = ["models", "model_type:checkpoints", "unit-tests", scope] + alpha, beta = f"{scope}-alpha", f"{scope}-beta" + x = asset_factory("aa_x.safetensors", [*t, alpha], {}, make_asset_bytes("aa_x")) + y = asset_factory("aa_y.safetensors", [*t, beta], {}, make_asset_bytes("aa_y")) + w = asset_factory("aa_w.safetensors", t, {}, make_asset_bytes("aa_w")) + d = asset_factory( + "aa_d.safetensors", + ["models", "model_type:checkpoints", "unit-tests", f"{scope}-other", alpha], + {}, + make_asset_bytes("aa_d"), + ) + + r = http.get( + api_base + "/api/assets", + params={"tags_all": f"unit-tests,{scope}", "tags_any": f"{alpha},{beta}", "limit": "50"}, + timeout=120, + ) + body = r.json() + assert r.status_code == 200, body + names = [a["name"] for a in body["assets"]] + assert x["name"] in names + assert y["name"] in names + assert w["name"] not in names, "asset matching tags_all but not tags_any must be excluded" + assert d["name"] not in names, "asset matching tags_any but not tags_all must be excluded" + + +def test_list_assets_tags_none_wins_over_tags_any(http, api_base, asset_factory, make_asset_bytes): + scope = f"lf-nonewins-{uuid.uuid4().hex[:6]}" + t = ["models", "model_type:checkpoints", "unit-tests", scope] + alpha, beta = f"{scope}-alpha", f"{scope}-beta" + x = asset_factory("nw_x.safetensors", [*t, alpha], {}, make_asset_bytes("nw_x")) + y = asset_factory("nw_y.safetensors", [*t, alpha, beta], {}, make_asset_bytes("nw_y")) + + r = http.get( + api_base + "/api/assets", + params={"tags_any": alpha, "tags_none": beta, "limit": "50"}, + timeout=120, + ) + body = r.json() + assert r.status_code == 200, body + names = [a["name"] for a in body["assets"]] + assert x["name"] in names + assert y["name"] not in names, "tags_none must exclude an asset even when it matches tags_any" + + +def test_list_assets_empty_tag_filter_lists_behave_as_absent(http, api_base, asset_factory, make_asset_bytes): + scope = f"lf-empty-{uuid.uuid4().hex[:6]}" + t = ["models", "model_type:checkpoints", "unit-tests", scope] + a = asset_factory("em_a.safetensors", t, {}, make_asset_bytes("em_a")) + b = asset_factory("em_b.safetensors", t, {}, make_asset_bytes("em_b")) + expected = {a["name"], b["name"]} + + # Empty new-name lists impose no constraint. + r1 = http.get( + api_base + "/api/assets", + params={"tags_all": f"unit-tests,{scope}", "tags_any": "", "tags_none": ""}, + timeout=120, + ) + b1 = r1.json() + assert r1.status_code == 200, b1 + assert {x["name"] for x in b1["assets"]} == expected + + # An empty new-name param alongside old names must not trigger validation. + r2 = http.get( + api_base + "/api/assets", + params={"include_tags": f"unit-tests,{scope}", "tags_any": ""}, + timeout=120, + ) + b2 = r2.json() + assert r2.status_code == 200, b2 + assert {x["name"] for x in b2["assets"]} == expected + + # An empty tags_all next to include_tags is not a mixed-spelling conflict. + r3 = http.get( + api_base + "/api/assets", + params={"include_tags": f"unit-tests,{scope}", "tags_all": ""}, + timeout=120, + ) + b3 = r3.json() + assert r3.status_code == 200, b3 + assert {x["name"] for x in b3["assets"]} == expected + + +def test_list_assets_old_names_match_new_names(http, api_base, asset_factory, make_asset_bytes): + scope = f"lf-alias-{uuid.uuid4().hex[:6]}" + t = ["models", "model_type:checkpoints", "unit-tests", scope] + alpha, beta = f"{scope}-alpha", f"{scope}-beta" + asset_factory("al_a.safetensors", [*t, alpha], {}, make_asset_bytes("al_a")) + asset_factory("al_b.safetensors", [*t, beta], {}, make_asset_bytes("al_b")) + + def names_for(params: dict) -> tuple[list, int]: + r = http.get(api_base + "/api/assets", params={**params, "sort": "name", "order": "asc"}, timeout=120) + body = r.json() + assert r.status_code == 200, body + return [x["name"] for x in body["assets"]], body["total"] + + # include_tags ≡ tags_all + old_names, old_total = names_for({"include_tags": f"unit-tests,{scope}"}) + new_names, new_total = names_for({"tags_all": f"unit-tests,{scope}"}) + assert old_names == new_names + assert old_total == new_total + + # exclude_tags ≡ tags_none (and old/new spellings mix across slots) + old_names, old_total = names_for({"include_tags": f"unit-tests,{scope}", "exclude_tags": alpha}) + new_names, new_total = names_for({"tags_all": f"unit-tests,{scope}", "tags_none": alpha}) + mixed_names, mixed_total = names_for({"include_tags": f"unit-tests,{scope}", "tags_none": alpha}) + assert old_names == new_names == mixed_names == ["al_b.safetensors"] + assert old_total == new_total == mixed_total == 1 + + +@pytest.mark.parametrize( + "params,expected_parameters", + [ + ({"include_tags": "mx-x", "tags_all": "mx-y"}, ["include_tags", "tags_all"]), + ({"exclude_tags": "mx-x", "tags_none": "mx-y"}, ["exclude_tags", "tags_none"]), + ], + ids=["include_tags_with_tags_all", "exclude_tags_with_tags_none"], +) +def test_list_assets_mixed_tag_spellings_rejected(http, api_base, params, expected_parameters): + r = http.get(api_base + "/api/assets", params=params, timeout=120) + body = r.json() + assert r.status_code == 400, body + assert body["error"]["code"] == "INVALID_TAG_FILTER" + assert body["error"]["details"]["parameters"] == expected_parameters + + +@pytest.mark.parametrize( + "params,conflicting,parameters", + [ + ( + {"tags_all": "cf-x", "tags_none": "cf-x"}, + ["cf-x"], + ["tags_all", "tags_none"], + ), + ( + {"include_tags": "cf-x", "tags_none": "cf-x"}, + ["cf-x"], + ["include_tags", "tags_none"], + ), + ( + {"tags_all": "cf-a,cf-b", "tags_none": "cf-b,cf-c"}, + ["cf-b"], + ["tags_all", "tags_none"], + ), + ], + ids=["new_names", "include_tags_remapped", "partial_overlap"], +) +def test_list_assets_all_none_conflict_rejected(http, api_base, params, conflicting, parameters): + r = http.get(api_base + "/api/assets", params=params, timeout=120) + body = r.json() + assert r.status_code == 400, body + assert body["error"]["code"] == "INVALID_TAG_FILTER" + assert body["error"]["details"]["conflicting_tags"] == conflicting + assert body["error"]["details"]["parameters"] == parameters + + +def test_list_assets_any_none_overlap_accepted(http, api_base, asset_factory, make_asset_bytes): + scope = f"lf-deadterm-{uuid.uuid4().hex[:6]}" + t = ["models", "model_type:checkpoints", "unit-tests", scope] + alpha, beta = f"{scope}-alpha", f"{scope}-beta" + x = asset_factory("dt_x.safetensors", [*t, alpha], {}, make_asset_bytes("dt_x")) + y = asset_factory("dt_y.safetensors", [*t, beta], {}, make_asset_bytes("dt_y")) + + # alpha is a dead term (in both tags_any and tags_none) but the query is valid. + r = http.get( + api_base + "/api/assets", + params={"tags_any": f"{alpha},{beta}", "tags_none": alpha, "limit": "50"}, + timeout=120, + ) + body = r.json() + assert r.status_code == 200, body + names = [a["name"] for a in body["assets"]] + assert y["name"] in names + assert x["name"] not in names + + +def test_list_assets_legacy_include_exclude_conflict_still_200(http, api_base, asset_factory, make_asset_bytes): + scope = f"lf-legacy-{uuid.uuid4().hex[:6]}" + t = ["models", "model_type:checkpoints", "unit-tests", scope] + asset_factory("lg_a.safetensors", t, {}, make_asset_bytes("lg_a")) + + # Old names only: the self-contradictory query stays an empty 200, never a 400. + r = http.get( + api_base + "/api/assets", + params={"include_tags": scope, "exclude_tags": scope}, + timeout=120, + ) + body = r.json() + assert r.status_code == 200, body + assert body["assets"] == [] + + +def test_tags_refine_new_tag_filters(http, api_base, asset_factory, make_asset_bytes): + scope = f"rf-{uuid.uuid4().hex[:6]}" + t = ["models", "model_type:checkpoints", "unit-tests", scope] + alpha, beta = f"{scope}-alpha", f"{scope}-beta" + asset_factory("rf_a.safetensors", [*t, alpha], {}, make_asset_bytes("rf_a")) + asset_factory("rf_b.safetensors", [*t, beta], {}, make_asset_bytes("rf_b")) + + r = http.get( + api_base + "/api/assets/tags/refine", + params={"tags_any": f"{alpha},{beta}", "tags_none": alpha}, + timeout=120, + ) + body = r.json() + assert r.status_code == 200, body + counts = body["tag_counts"] + assert counts.get(beta) == 1 + assert alpha not in counts + + r2 = http.get( + api_base + "/api/assets/tags/refine", + params={"tags_all": "rf-x", "tags_none": "rf-x"}, + timeout=120, + ) + body2 = r2.json() + assert r2.status_code == 400, body2 + assert body2["error"]["code"] == "INVALID_TAG_FILTER" + assert body2["error"]["details"]["conflicting_tags"] == ["rf-x"] + + +def test_list_assets_cross_slot_old_new_combinations(http, api_base, asset_factory, make_asset_bytes): + """Old and new spellings of *different* slots combine freely; only + same-slot mixing is rejected.""" + scope = f"lf-cross-{uuid.uuid4().hex[:6]}" + t = ["models", "model_type:checkpoints", "unit-tests", scope] + alpha, beta = f"{scope}-alpha", f"{scope}-beta" + a = asset_factory("cs_a.safetensors", [*t, alpha], {}, make_asset_bytes("cs_a")) + b = asset_factory("cs_b.safetensors", [*t, beta], {}, make_asset_bytes("cs_b")) + + def names_for(params: dict) -> set: + r = http.get(api_base + "/api/assets", params=params, timeout=120) + body = r.json() + assert r.status_code == 200, body + return {x["name"] for x in body["assets"]} + + assert names_for( + {"include_tags": f"unit-tests,{scope}", "tags_any": alpha} + ) == {a["name"]} + assert names_for( + {"tags_all": f"unit-tests,{scope}", "exclude_tags": alpha} + ) == {b["name"]} + assert names_for( + {"tags_any": f"{alpha},{beta}", "exclude_tags": alpha} + ) == {b["name"]} + + +def test_list_assets_repeated_query_keys_concatenate(http, api_base, asset_factory, make_asset_bytes): + """Repeated occurrences of a tag param concatenate before the CSV split + (Core-local behavior, not a cross-platform guarantee).""" + scope = f"lf-repeat-{uuid.uuid4().hex[:6]}" + t = ["models", "model_type:checkpoints", "unit-tests", scope] + alpha, beta = f"{scope}-alpha", f"{scope}-beta" + a = asset_factory("rp_a.safetensors", [*t, alpha], {}, make_asset_bytes("rp_a")) + b = asset_factory("rp_b.safetensors", [*t, beta], {}, make_asset_bytes("rp_b")) + + # requests encodes a list value as repeated keys: tags_any=&tags_any= + r = http.get( + api_base + "/api/assets", + params={"tags_any": [alpha, beta], "limit": "50"}, + timeout=120, + ) + body = r.json() + assert r.status_code == 200, body + names = {x["name"] for x in body["assets"]} + assert {a["name"], b["name"]} <= names + + +def test_list_assets_tags_any_cursor_pagination_consistent(http, api_base, asset_factory, make_asset_bytes): + scope = f"lf-anypage-{uuid.uuid4().hex[:6]}" + t = ["models", "model_type:checkpoints", "unit-tests", scope] + alpha = f"{scope}-alpha" + expected = set() + for i in range(3): + made = asset_factory(f"pg_{i}.safetensors", [*t, alpha], {}, make_asset_bytes(f"pg_{i}")) + expected.add(made["name"]) + + r1 = http.get( + api_base + "/api/assets", + params={"tags_any": alpha, "limit": "2", "sort": "name", "order": "asc"}, + timeout=120, + ) + b1 = r1.json() + assert r1.status_code == 200, b1 + assert b1["total"] == 3 + assert b1["has_more"] is True + assert b1.get("next_cursor"), "expected a keyset cursor on the first page" + + r2 = http.get( + api_base + "/api/assets", + params={ + "tags_any": alpha, + "limit": "2", + "sort": "name", + "order": "asc", + "after": b1["next_cursor"], + }, + timeout=120, + ) + b2 = r2.json() + assert r2.status_code == 200, b2 + assert b2["has_more"] is False + + page1 = {x["name"] for x in b1["assets"]} + page2 = {x["name"] for x in b2["assets"]} + assert not page1 & page2, "cursor pages must not overlap" + assert page1 | page2 == expected + + +def test_tags_refine_mixed_spellings_rejected_and_legacy_conflict_kept(http, api_base): + r = http.get( + api_base + "/api/assets/tags/refine", + params={"include_tags": "rfmx-x", "tags_all": "rfmx-y"}, + timeout=120, + ) + body = r.json() + assert r.status_code == 400, body + assert body["error"]["code"] == "INVALID_TAG_FILTER" + assert body["error"]["details"]["parameters"] == ["include_tags", "tags_all"] + + # Old names only: the refine route keeps legacy behaviour too. + r2 = http.get( + api_base + "/api/assets/tags/refine", + params={"include_tags": "rfmx-z", "exclude_tags": "rfmx-z"}, + timeout=120, + ) + body2 = r2.json() + assert r2.status_code == 200, body2 + assert body2["tag_counts"] == {} + + +def test_list_assets_tag_values_case_sensitive(http, api_base, asset_factory, make_asset_bytes): + """Case-distinct tags are distinct; the all/none conflict check is byte-exact.""" + scope = f"lf-case-{uuid.uuid4().hex[:6]}" + t = ["models", "model_type:checkpoints", "unit-tests", scope] + upper, lower = f"{scope}-ALPHA", f"{scope}-alpha" + a = asset_factory("cx_a.safetensors", [*t, upper], {}, make_asset_bytes("cx_a")) + b = asset_factory("cx_b.safetensors", [*t, lower], {}, make_asset_bytes("cx_b")) + + def names_for(params: dict) -> set: + r = http.get(api_base + "/api/assets", params=params, timeout=120) + body = r.json() + assert r.status_code == 200, body + return {x["name"] for x in body["assets"]} + + assert names_for({"tags_all": f"unit-tests,{scope},{upper}"}) == {a["name"]} + assert names_for({"tags_any": lower, "limit": "50"}) == {b["name"]} + # Case-distinct all/none pair is NOT a conflict — byte-exact comparison. + assert names_for({"tags_all": f"unit-tests,{scope},{upper}", "tags_none": lower}) == {a["name"]} + + +def test_tag_list_cap_applies_to_all_spellings(http, api_base): + """The cap covers the legacy spellings too.""" + big = ",".join(f"cap-{i}" for i in range(101)) + for param in ("tags_any", "include_tags"): + r = http.get(api_base + "/api/assets", params={param: big}, timeout=120) + body = r.json() + assert r.status_code == 400, body + assert body["error"]["code"] == "INVALID_TAG_FILTER" + assert body["error"]["details"]["parameter"] == param + assert body["error"]["details"]["max"] == 100 + + exact = ",".join(f"cap-{i}" for i in range(100)) + r = http.get(api_base + "/api/assets", params={"tags_any": exact}, timeout=120) + assert r.status_code == 200, r.json() + + # The cap counts normalized (deduped) tags, not raw CSV items. + dups = ",".join("cap-dup" for _ in range(150)) + r = http.get(api_base + "/api/assets", params={"tags_any": dups}, timeout=120) + assert r.status_code == 200, r.json() + + +def test_resolve_tag_filters_no_deprecation_warning(): + """The deprecated-field warning is for API clients; the server's own remap + shim must not fire it on every request.""" + for q in ( + schemas_in.ListAssetsQuery(tags_all="a", tags_none="b"), + schemas_in.TagsRefineQuery(tags_any="c"), + ): + with warnings.catch_warnings(): + warnings.simplefilter("error", DeprecationWarning) + assets_routes._resolve_tag_filters(q) + + +def test_tag_filter_alias_fields_marked_deprecated(): + for model in (schemas_in.ListAssetsQuery, schemas_in.TagsRefineQuery): + props = model.model_json_schema()["properties"] + for field in ("include_tags", "exclude_tags"): + assert props[field].get("deprecated") is True, (model.__name__, field) + for field in ("tags_all", "tags_any", "tags_none"): + assert "deprecated" not in props[field], (model.__name__, field)