Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
f141299
in progress GET /api/assets/{uuid}/content endpoint support
Kosinkadink Jan 15, 2026
e527b72
more progress
Kosinkadink Jan 16, 2026
a5ed151
Merge branch 'master' into assets-redo-part2
Kosinkadink Jan 16, 2026
e5c1de4
Finished @ROUTES.get(f"/api/assets/{{id:{UUID_RE}}}/content")
Kosinkadink Jan 16, 2026
fab9b71
Finished @ROUTES.head("/api/assets/hash/{hash}")
Kosinkadink Jan 16, 2026
41d3640
Finished @ROUTES.post("/api/assets/from-hash")
Kosinkadink Jan 16, 2026
6db4f4e
Finished @ROUTES.post("/api/assets")
Kosinkadink Jan 16, 2026
e0c063f
Finished @ROUTES.put(f"/api/assets/{{id:{UUID_RE}}}")
Kosinkadink Jan 16, 2026
e69a5aa
Finished @ROUTES.put(f"/api/assets/{{id:{UUID_RE}}}/preview")
Kosinkadink Jan 16, 2026
63c98d0
Finished @ROUTES.delete(f"/api/assets/{{id:{UUID_RE}}}")
Kosinkadink Jan 16, 2026
9e3f559
Finished @ROUTES.post(f"/api/assets/{{id:{UUID_RE}}}/tags")
Kosinkadink Jan 16, 2026
63f9f1b
Finish @ROUTES.delete(f"/api/assets/{{id:{UUID_RE}}}/tags")
Kosinkadink Jan 16, 2026
287da64
Finished @ROUTES.post("/api/assets/scan/seed")
Kosinkadink Jan 16, 2026
65a5992
Remove unnecessary logging statement used for testing
Kosinkadink Jan 16, 2026
facda42
Remove extra whitespace at end of routes.py
Kosinkadink Jan 16, 2026
8e9c801
Add input + output roots to scans
Kosinkadink Jan 25, 2026
702cfcd
Merge branch 'master' into assets-redo-part2
Kosinkadink Jan 26, 2026
6a450a8
Revert seed_assets to only do models root, remove blake3 requirement …
Kosinkadink Jan 27, 2026
0bb6d3a
Merge branch 'master' into assets-redo-part2
Kosinkadink Jan 27, 2026
e17542b
Comment out @ROUTES.post("/api/assets/scan/seed")
Kosinkadink Jan 27, 2026
4866bbf
Comment out import for commented out code
Kosinkadink Jan 27, 2026
b16390c
Made some routes returmn 501's while functionality is worked on
Kosinkadink Jan 27, 2026
32d4888
Fix import for currently unused upload_asset_from_temp_path function
Kosinkadink Jan 28, 2026
724145f
Merge branch 'master' into assets-redo-part2
Kosinkadink Jan 28, 2026
cf950e4
Merge branch 'master' into assets-redo-part2
Kosinkadink Jan 28, 2026
32ce7a7
Removed 501 early returns on endpoints intended to be released, remov…
Kosinkadink Jan 28, 2026
e735a8f
Satisfy ruff
Kosinkadink Jan 28, 2026
d5e6e2a
Fixed inconsistent spacing in routes.py
Kosinkadink Jan 28, 2026
902e84d
Remove tags from body of @ROUTES.put(f"/api/assets/{{id:{UUID_RE}}}")…
Kosinkadink Jan 29, 2026
2aafb71
Add node for custom node authors in routes.py
Kosinkadink Jan 29, 2026
25f83d7
Fixed resolve_asset_content_for_download accessing asset outside of s…
Kosinkadink Jan 29, 2026
f484d66
Merge branch 'master' into assets-redo-part2
Kosinkadink Jan 29, 2026
69f6c37
Leave the preview_url blank, don't serialize it as `null`
DrJKL Jan 29, 2026
2f0db0e
Order the tags by when they were added (Ends up being directory depth…
DrJKL Jan 29, 2026
6840ad0
Added tests, rewritten from the ones present in the asset-management …
Kosinkadink Jan 30, 2026
eb78ea0
Added @ROUTES.post("/api/assets/seed") for now to help with tests
Kosinkadink Jan 30, 2026
1e622d3
Fixed issues in manager.py that had to do with creating a result afte…
Kosinkadink Jan 30, 2026
11da0e6
Satisfy ruff
Kosinkadink Jan 30, 2026
c0e26b9
Added test-assets.yml to github workflows, added a requirements.txt t…
Kosinkadink Jan 30, 2026
6128930
Use windows-latest runner for test-assets
Kosinkadink Jan 30, 2026
a999cbc
Merge branch 'master' into assets-redo-part2
Kosinkadink Jan 30, 2026
942b2a6
Add pruning of Assets not reachable through the current configs (#12168)
DrJKL Jan 30, 2026
e8e1c13
Moved assets test to unit tests dir
Kosinkadink Jan 31, 2026
6b20d0e
Fix test setup for assets_test after move
Kosinkadink Jan 31, 2026
96020c2
Merge branch 'master' into assets-redo-part2
Kosinkadink Jan 31, 2026
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
414 changes: 413 additions & 1 deletion app/assets/api/routes.py

Large diffs are not rendered by default.

196 changes: 183 additions & 13 deletions app/assets/api/schemas_in.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import json
import uuid
from typing import Any, Literal

from pydantic import (
Expand All @@ -8,9 +7,9 @@
Field,
conint,
field_validator,
model_validator,
)


class ListAssetsQuery(BaseModel):
include_tags: list[str] = Field(default_factory=list)
exclude_tags: list[str] = Field(default_factory=list)
Expand Down Expand Up @@ -57,6 +56,57 @@ def _parse_metadata_json(cls, v):
return None


class UpdateAssetBody(BaseModel):
name: str | None = None
user_metadata: dict[str, Any] | None = None

@model_validator(mode="after")
def _at_least_one(self):
if self.name is None and self.user_metadata is None:
raise ValueError("Provide at least one of: name, user_metadata.")
return self


class CreateFromHashBody(BaseModel):
model_config = ConfigDict(extra="ignore", str_strip_whitespace=True)

hash: str
name: str
tags: list[str] = Field(default_factory=list)
user_metadata: dict[str, Any] = Field(default_factory=dict)

@field_validator("hash")
@classmethod
def _require_blake3(cls, v):
s = (v or "").strip().lower()
if ":" not in s:
raise ValueError("hash must be 'blake3:<hex>'")
algo, digest = s.split(":", 1)
if algo != "blake3":
raise ValueError("only canonical 'blake3:<hex>' is accepted here")
if not digest or any(c for c in digest if c not in "0123456789abcdef"):
raise ValueError("hash digest must be lowercase hex")
return s

@field_validator("tags", mode="before")
@classmethod
def _tags_norm(cls, v):
if v is None:
return []
if isinstance(v, list):
out = [str(t).strip().lower() for t in v if str(t).strip()]
seen = set()
dedup = []
for t in out:
if t not in seen:
seen.add(t)
dedup.append(t)
return dedup
if isinstance(v, str):
return [t.strip().lower() for t in v.split(",") if t.strip()]
return []


class TagsListQuery(BaseModel):
model_config = ConfigDict(extra="ignore", str_strip_whitespace=True)

Expand All @@ -75,20 +125,140 @@ def normalize_prefix(cls, v: str | None) -> str | None:
return v.lower() or None


class SetPreviewBody(BaseModel):
"""Set or clear the preview for an AssetInfo. Provide an Asset.id or null."""
preview_id: str | None = None
class TagsAdd(BaseModel):
model_config = ConfigDict(extra="ignore")
tags: list[str] = Field(..., min_length=1)

@field_validator("preview_id", mode="before")
@field_validator("tags")
@classmethod
def _norm_uuid(cls, v):
def normalize_tags(cls, v: list[str]) -> list[str]:
out = []
for t in v:
if not isinstance(t, str):
raise TypeError("tags must be strings")
tnorm = t.strip().lower()
if tnorm:
out.append(tnorm)
seen = set()
deduplicated = []
for x in out:
if x not in seen:
seen.add(x)
deduplicated.append(x)
return deduplicated


class TagsRemove(TagsAdd):
pass


class UploadAssetSpec(BaseModel):
"""Upload Asset operation.
- tags: ordered; first is root ('models'|'input'|'output');
if root == 'models', second must be a valid category from folder_paths.folder_names_and_paths
- name: display name
- user_metadata: arbitrary JSON object (optional)
- hash: optional canonical 'blake3:<hex>' provided by the client for validation / fast-path

Files created via this endpoint are stored on disk using the **content hash** as the filename stem
and the original extension is preserved when available.
"""
model_config = ConfigDict(extra="ignore", str_strip_whitespace=True)

tags: list[str] = Field(..., min_length=1)
name: str | None = Field(default=None, max_length=512, description="Display Name")
user_metadata: dict[str, Any] = Field(default_factory=dict)
hash: str | None = Field(default=None)

@field_validator("hash", mode="before")
@classmethod
def _parse_hash(cls, v):
if v is None:
return None
s = str(v).strip()
s = str(v).strip().lower()
if not s:
return None
try:
uuid.UUID(s)
except Exception:
raise ValueError("preview_id must be a UUID")
return s
if ":" not in s:
raise ValueError("hash must be 'blake3:<hex>'")
algo, digest = s.split(":", 1)
if algo != "blake3":
raise ValueError("only canonical 'blake3:<hex>' is accepted here")
if not digest or any(c for c in digest if c not in "0123456789abcdef"):
raise ValueError("hash digest must be lowercase hex")
return f"{algo}:{digest}"

@field_validator("tags", mode="before")
@classmethod
def _parse_tags(cls, v):
"""
Accepts a list of strings (possibly multiple form fields),
where each string can be:
- JSON array (e.g., '["models","loras","foo"]')
- comma-separated ('models, loras, foo')
- single token ('models')
Returns a normalized, deduplicated, ordered list.
"""
items: list[str] = []
if v is None:
return []
if isinstance(v, str):
v = [v]

if isinstance(v, list):
for item in v:
if item is None:
continue
s = str(item).strip()
if not s:
continue
if s.startswith("["):
try:
arr = json.loads(s)
if isinstance(arr, list):
items.extend(str(x) for x in arr)
continue
except Exception:
pass # fallback to CSV parse below
items.extend([p for p in s.split(",") if p.strip()])
else:
return []

# normalize + dedupe
norm = []
seen = set()
for t in items:
tnorm = str(t).strip().lower()
if tnorm and tnorm not in seen:
seen.add(tnorm)
norm.append(tnorm)
return norm

@field_validator("user_metadata", mode="before")
@classmethod
def _parse_metadata_json(cls, v):
if v is None or isinstance(v, dict):
return v or {}
if isinstance(v, str):
s = v.strip()
if not s:
return {}
try:
parsed = json.loads(s)
except Exception as e:
raise ValueError(f"user_metadata must be JSON: {e}") from e
if not isinstance(parsed, dict):
raise ValueError("user_metadata must be a JSON object")
return parsed
return {}

@model_validator(mode="after")
def _validate_order(self):
if not self.tags:
raise ValueError("tags must be provided and non-empty")
root = self.tags[0]
if root not in {"models", "input", "output"}:
raise ValueError("first tag must be one of: models, input, output")
if root == "models":
if len(self.tags) < 2:
raise ValueError("models uploads require a category tag as the second tag")
return self
33 changes: 33 additions & 0 deletions app/assets/api/schemas_out.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,21 @@ class AssetsList(BaseModel):
has_more: bool


class AssetUpdated(BaseModel):
id: str
name: str
asset_hash: str | None = None
tags: list[str] = Field(default_factory=list)
user_metadata: dict[str, Any] = Field(default_factory=dict)
updated_at: datetime | None = None

model_config = ConfigDict(from_attributes=True)

@field_serializer("updated_at")
def _ser_updated(self, v: datetime | None, _info):
return v.isoformat() if v else None


class AssetDetail(BaseModel):
id: str
name: str
Expand All @@ -48,6 +63,10 @@ def _ser_dt(self, v: datetime | None, _info):
return v.isoformat() if v else None


class AssetCreated(AssetDetail):
created_new: bool


class TagUsage(BaseModel):
name: str
count: int
Expand All @@ -58,3 +77,17 @@ class TagsList(BaseModel):
tags: list[TagUsage] = Field(default_factory=list)
total: int
has_more: bool


class TagsAdd(BaseModel):
model_config = ConfigDict(str_strip_whitespace=True)
added: list[str] = Field(default_factory=list)
already_present: list[str] = Field(default_factory=list)
total_tags: list[str] = Field(default_factory=list)


class TagsRemove(BaseModel):
model_config = ConfigDict(str_strip_whitespace=True)
removed: list[str] = Field(default_factory=list)
not_present: list[str] = Field(default_factory=list)
total_tags: list[str] = Field(default_factory=list)
Loading