Skip to content

Commit dec1e40

Browse files
committed
Add GGUF file handling and resolution logic in AudioModelInstaller
- Introduced methods for stripping package prefixes, discovering GGUF files, and selecting the primary GGUF weight from a package. - Enhanced the `_resolve_installed_model_path` method to support nested GGUF structures and ensure proper linking to `model.gguf`. - Added comprehensive tests to validate the new GGUF handling functionalities, ensuring robust package resolution and error handling for ambiguous cases. - Updated existing tests to reflect changes in the audio model installer logic, improving overall test coverage and reliability.
1 parent 748679f commit dec1e40

2 files changed

Lines changed: 293 additions & 10 deletions

File tree

backend/services/audio_model_installer.py

Lines changed: 226 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -786,6 +786,208 @@ def _check_required_files(package: dict, model_path: str) -> None:
786786
if missing:
787787
raise RuntimeError(f"Installed package is missing required files: {missing}")
788788

789+
@staticmethod
790+
def _strip_package_prefix(remote_path: str, strip_prefix: str) -> str:
791+
remote = str(remote_path or "").replace("\\", "/").lstrip("/")
792+
prefix = str(strip_prefix or "").replace("\\", "/").strip("/")
793+
if not prefix:
794+
return remote
795+
if remote == prefix:
796+
return ""
797+
head = prefix + "/"
798+
if remote.startswith(head):
799+
return remote[len(head) :]
800+
return remote
801+
802+
@staticmethod
803+
def _is_under_root(root: str, path: str) -> bool:
804+
try:
805+
return os.path.commonpath([root, path]) == root
806+
except ValueError:
807+
return False
808+
809+
@classmethod
810+
def _declared_gguf_paths(cls, package: dict, package_root: str) -> List[str]:
811+
"""GGUF paths declared by package.files (after strip_prefix), if present on disk."""
812+
root = os.path.realpath(package_root)
813+
strip_prefix = str(package.get("strip_prefix") or "")
814+
found: List[str] = []
815+
for remote in package.get("files") or []:
816+
remote_s = str(remote or "")
817+
if not remote_s.lower().endswith(".gguf"):
818+
continue
819+
relative = cls._strip_package_prefix(remote_s, strip_prefix)
820+
if not relative:
821+
continue
822+
candidate = os.path.realpath(os.path.join(root, relative))
823+
if cls._is_under_root(root, candidate) and os.path.isfile(candidate):
824+
found.append(candidate)
825+
return list(dict.fromkeys(found))
826+
827+
@classmethod
828+
def _discover_gguf_files(
829+
cls,
830+
package_root: str,
831+
*,
832+
max_depth: int = 4,
833+
) -> List[str]:
834+
"""Find ``*.gguf`` files under a package root (bounded recursive walk)."""
835+
root = os.path.realpath(package_root)
836+
if not os.path.isdir(root):
837+
return []
838+
839+
found: List[str] = []
840+
root_depth = root.rstrip(os.sep).count(os.sep)
841+
for dirpath, dirnames, filenames in os.walk(root):
842+
# Keep the walk shallow and skip cache / VCS noise.
843+
depth = dirpath.rstrip(os.sep).count(os.sep) - root_depth
844+
if depth >= max_depth:
845+
dirnames[:] = []
846+
dirnames[:] = [
847+
name
848+
for name in dirnames
849+
if name not in {".git", ".cache", "__pycache__", ".staging"}
850+
and not name.startswith(".")
851+
]
852+
for name in filenames:
853+
if not name.lower().endswith(".gguf"):
854+
continue
855+
path = os.path.realpath(os.path.join(dirpath, name))
856+
if cls._is_under_root(root, path) and os.path.isfile(path):
857+
found.append(path)
858+
found.sort()
859+
return found
860+
861+
@classmethod
862+
def _select_package_gguf(cls, package: dict, package_root: str) -> Optional[str]:
863+
"""Choose the package's primary GGUF weight, or None for non-GGUF layouts.
864+
865+
Selection mirrors audio.cpp directory discovery where possible, then falls
866+
back to package declarations and a unique nested GGUF. Ambiguous GGUF
867+
packages raise instead of guessing.
868+
"""
869+
root = os.path.realpath(package_root)
870+
if not os.path.isdir(root):
871+
return None
872+
873+
named = os.path.join(root, "model.gguf")
874+
if os.path.isfile(named):
875+
return os.path.realpath(named)
876+
877+
top_level = [
878+
os.path.realpath(os.path.join(root, name))
879+
for name in sorted(os.listdir(root))
880+
if name.lower().endswith(".gguf")
881+
and os.path.isfile(os.path.join(root, name))
882+
]
883+
if len(top_level) == 1:
884+
return top_level[0]
885+
886+
declared = cls._declared_gguf_paths(package, root)
887+
if len(declared) == 1:
888+
return declared[0]
889+
890+
discovered = cls._discover_gguf_files(root)
891+
# Ignore a root model.gguf symlink target double-count by uniquing.
892+
discovered = list(dict.fromkeys(discovered))
893+
if len(discovered) == 1:
894+
return discovered[0]
895+
896+
if len(declared) > 1:
897+
raise RuntimeError(
898+
f"Ambiguous GGUF package under {root}: package.files lists "
899+
f"{len(declared)} GGUF weights. Install a single-weight package "
900+
"or point audiocpp at one file explicitly."
901+
)
902+
903+
expects_gguf = (
904+
str(package.get("format") or "").lower() == "gguf"
905+
or any(
906+
str(item or "").lower().endswith(".gguf")
907+
for item in (package.get("files") or [])
908+
)
909+
)
910+
if expects_gguf and len(discovered) > 1:
911+
preview = ", ".join(
912+
os.path.relpath(path, root) for path in discovered[:6]
913+
)
914+
raise RuntimeError(
915+
f"Ambiguous GGUF package under {root}: found {len(discovered)} "
916+
f".gguf files ({preview}). audio.cpp only auto-detects a single "
917+
"root GGUF; fix the package layout or declare exactly one "
918+
"package.files GGUF entry."
919+
)
920+
return None
921+
922+
@classmethod
923+
def _ensure_root_model_gguf_link(cls, package_root: str, gguf_path: str) -> bool:
924+
"""Expose a nested GGUF as ``model.gguf`` so audio.cpp directory discovery works.
925+
926+
Returns True when the package root already has, or successfully gains, a
927+
root-level ``model.gguf`` usable by ``find_directory_gguf``.
928+
"""
929+
root = os.path.realpath(package_root)
930+
target = os.path.realpath(gguf_path)
931+
if not cls._is_under_root(root, target) or not os.path.isfile(target):
932+
return False
933+
934+
link_path = os.path.join(root, "model.gguf")
935+
if os.path.isfile(link_path):
936+
return os.path.realpath(link_path) == target
937+
938+
if os.path.lexists(link_path):
939+
# Broken / wrong symlink left behind — replace it.
940+
try:
941+
os.unlink(link_path)
942+
except OSError:
943+
return False
944+
945+
if os.path.dirname(target) == root:
946+
# Unique top-level GGUF already discoverable; optional convenience link.
947+
rel = os.path.basename(target)
948+
else:
949+
rel = os.path.relpath(target, root)
950+
951+
try:
952+
os.symlink(rel, link_path)
953+
return os.path.isfile(link_path)
954+
except OSError:
955+
# Symlinks unavailable (some Windows/container FS setups): hardlink, then copy.
956+
try:
957+
os.link(target, link_path)
958+
return os.path.isfile(link_path)
959+
except OSError:
960+
try:
961+
shutil.copy2(target, link_path)
962+
return os.path.isfile(link_path)
963+
except OSError:
964+
return False
965+
966+
@classmethod
967+
def _resolve_installed_model_path(cls, package: dict, package_root: str) -> str:
968+
"""Return the path Studio should inspect / persist for an installed package.
969+
970+
For GGUF packages this normalizes nested layouts (e.g. ``turbo/*.gguf``) to
971+
something audio.cpp can load:
972+
1. select the primary GGUF (root / declared / unique nested)
973+
2. publish ``model.gguf`` at the package root when needed
974+
3. prefer the package directory once discovery works; otherwise the GGUF file
975+
"""
976+
root = os.path.realpath(package_root)
977+
if not os.path.isdir(root):
978+
return package_root
979+
980+
try:
981+
gguf = cls._select_package_gguf(package, root)
982+
except RuntimeError:
983+
raise
984+
if not gguf:
985+
return root
986+
987+
if cls._ensure_root_model_gguf_link(root, gguf):
988+
return root
989+
return gguf
990+
789991
def _model_record(
790992
self,
791993
package: dict,
@@ -971,17 +1173,31 @@ async def install_package(
9711173
task_id, package, staging_root, active, options
9721174
)
9731175
self._check_required_files(package, staged_model_path)
974-
inspection = await self._inspect(
975-
task_id,
976-
active,
977-
staged_model_path,
978-
self._resolve_inspect_family(
979-
package=package,
980-
family_hint=str(options.get("family") or "") or None,
981-
model_path=staged_model_path,
982-
),
1176+
runtime_model_path = self._resolve_installed_model_path(
1177+
package, staged_model_path
9831178
)
984-
relative_model_path = os.path.relpath(staged_model_path, staging_root)
1179+
try:
1180+
inspection = await self._inspect(
1181+
task_id,
1182+
active,
1183+
runtime_model_path,
1184+
self._resolve_inspect_family(
1185+
package=package,
1186+
family_hint=str(options.get("family") or "") or None,
1187+
model_path=runtime_model_path,
1188+
),
1189+
)
1190+
except RuntimeError as exc:
1191+
detail = str(exc)
1192+
if "missing model package file" in detail and "safetensors" in detail:
1193+
raise RuntimeError(
1194+
f"{detail} Hint: audio.cpp fell back to the safetensors source, "
1195+
"usually because no discoverable package-root GGUF was present. "
1196+
"Studio tries to publish nested *.gguf weights as model.gguf; "
1197+
"if this persists, the upstream package layout is incomplete."
1198+
) from exc
1199+
raise
1200+
relative_model_path = os.path.relpath(runtime_model_path, staging_root)
9851201
self.pm.update_task(
9861202
task_id,
9871203
progress=95,

backend/tests/test_audio_model_installer.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,73 @@ def _installer(tmp_path, monkeypatch):
7171
return installer, store
7272

7373

74+
def test_resolve_installed_model_path_publishes_nested_gguf_as_model_gguf(tmp_path):
75+
root = tmp_path / "ACE-Step1.5-GGUF"
76+
nested = root / "turbo"
77+
nested.mkdir(parents=True)
78+
gguf = nested / "ace-step-1.5-turbo-bf16.gguf"
79+
gguf.write_bytes(b"GGUF")
80+
# No package.files hint — discovery must find the unique nested weight.
81+
package = {"id": "ace_step_turbo_bf16", "format": "gguf", "files": []}
82+
resolved = AudioModelInstaller._resolve_installed_model_path(package, str(root))
83+
assert resolved == str(root.resolve())
84+
link = root / "model.gguf"
85+
assert link.is_file()
86+
assert link.resolve() == gguf.resolve()
87+
88+
89+
def test_resolve_installed_model_path_uses_declared_gguf_when_ambiguous(tmp_path):
90+
root = tmp_path / "ACE-Step1.5-GGUF"
91+
(root / "turbo").mkdir(parents=True)
92+
(root / "base").mkdir(parents=True)
93+
turbo = root / "turbo" / "ace-step-1.5-turbo-bf16.gguf"
94+
base = root / "base" / "ace-step-1.5-base-bf16.gguf"
95+
turbo.write_bytes(b"GGUF")
96+
base.write_bytes(b"GGUF")
97+
package = {
98+
"id": "ace_step_turbo_bf16",
99+
"format": "gguf",
100+
"strip_prefix": "ACE-Step1.5-GGUF",
101+
"files": ["ACE-Step1.5-GGUF/turbo/ace-step-1.5-turbo-bf16.gguf"],
102+
}
103+
resolved = AudioModelInstaller._resolve_installed_model_path(package, str(root))
104+
assert resolved == str(root.resolve())
105+
assert (root / "model.gguf").resolve() == turbo.resolve()
106+
107+
108+
def test_select_package_gguf_errors_when_ambiguous_without_declaration(tmp_path):
109+
root = tmp_path / "multi"
110+
(root / "a").mkdir(parents=True)
111+
(root / "b").mkdir(parents=True)
112+
(root / "a" / "one.gguf").write_bytes(b"GGUF")
113+
(root / "b" / "two.gguf").write_bytes(b"GGUF")
114+
package = {"id": "multi_gguf", "format": "gguf", "files": []}
115+
with pytest.raises(RuntimeError, match="Ambiguous GGUF package"):
116+
AudioModelInstaller._select_package_gguf(package, str(root))
117+
118+
119+
def test_resolve_installed_model_path_keeps_directory_without_gguf(tmp_path):
120+
root = tmp_path / "Ace-Step1.5"
121+
root.mkdir()
122+
(root / "config.json").write_text("{}", encoding="utf-8")
123+
package = {"id": "ace_step", "files": []}
124+
assert AudioModelInstaller._resolve_installed_model_path(package, str(root)) == str(
125+
root.resolve()
126+
)
127+
128+
129+
def test_resolve_installed_model_path_keeps_top_level_gguf_directory(tmp_path):
130+
root = tmp_path / "Qwen3-ASR-0.6B-GGUF"
131+
root.mkdir()
132+
gguf = root / "qwen3-asr-0.6b-q8_0.gguf"
133+
gguf.write_bytes(b"GGUF")
134+
package = {"id": "qwen3_asr_q8", "format": "gguf", "files": []}
135+
resolved = AudioModelInstaller._resolve_installed_model_path(package, str(root))
136+
assert resolved == str(root.resolve())
137+
# Convenience model.gguf link is fine; directory remains the runtime path.
138+
assert (root / "model.gguf").is_file() or gguf.is_file()
139+
140+
74141
def test_family_from_bundle_reads_model_type(tmp_path):
75142
bundle = tmp_path / "Qwen3-ASR-0.6B"
76143
bundle.mkdir()

0 commit comments

Comments
 (0)