Add tags_all / tags_any / tags_none tag filters to the assets list API (#15332)

* 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.
This commit is contained in:
Simon Pinfold 2026-08-10 14:05:21 -07:00 committed by GitHub
parent 7d11ec31cb
commit 34744cd29e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 624 additions and 19 deletions

View File

@ -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,

View File

@ -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:

View File

@ -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)

View File

@ -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(

View File

@ -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()

View File

@ -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,

View File

@ -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,

View File

@ -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

View File

@ -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=<alpha>&tags_any=<beta>
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)