fix: AI feature install failures — missing rembg session and Fastify 415 (#102, #103)

Register custom BiRefNet-matting ONNX session in install_feature.py so
rembg.new_session("birefnet-matting") no longer raises ValueError during
on-demand installs. The session was already registered in remove_bg.py
(runtime) and download_models.py (build-time) but was missed in the
install path, causing background-removal bundle installs to always fail.

Send JSON body on install/uninstall POST requests to avoid Fastify 5's
strict content-type parser rejecting body-less POSTs with 415.

Fix error message extraction to preserve structured {"error": ...} JSON
from the Python script and filter out pthread_setaffinity_np noise.
This commit is contained in:
SnapOtter
2026-04-27 02:38:17 +08:00
parent 02a84b8741
commit 4f81b29fbc
4 changed files with 75 additions and 9 deletions
+26 -3
View File
@@ -202,9 +202,32 @@ export async function registerFeatureRoutes(app: FastifyInstance): Promise<void>
duration_ms: Date.now() - installStartTime,
});
} else {
const errorDetail =
lastStderrLines.filter((l) => !l.startsWith("{")).join("\n") || stdoutBuffer.trim();
const errorMsg = errorDetail || `Install failed with exit code ${code}`;
// Extract the structured error from Python's fail() function first.
// fail() writes {"error": "..."} to stderr — prefer this over raw lines.
let errorMsg: string | undefined;
for (let i = lastStderrLines.length - 1; i >= 0; i--) {
const line = lastStderrLines[i];
if (line.startsWith("{")) {
try {
const parsed = JSON.parse(line) as Record<string, unknown>;
if (typeof parsed.error === "string") {
errorMsg = parsed.error;
break;
}
} catch {
// Not valid JSON
}
}
}
if (!errorMsg) {
const meaningful = lastStderrLines.filter(
(l) => !l.startsWith("{") && !l.includes("pthread_setaffinity_np"),
);
errorMsg =
meaningful.join("\n") ||
stdoutBuffer.trim() ||
`Install failed with exit code ${code}`;
}
setInstallProgress(bundleId, null, errorMsg);
updateSingleFileProgress({ jobId, phase: "failed", percent: 0, error: errorMsg });
}
+5 -2
View File
@@ -191,7 +191,10 @@ export const useFeaturesStore = create<FeaturesState>((set, get) => {
});
try {
const result = await apiPost<{ jobId: string }>(`/v1/admin/features/${bundleId}/install`);
const result = await apiPost<{ jobId: string }>(
`/v1/admin/features/${bundleId}/install`,
{},
);
listenToProgress(bundleId, result.jobId);
} catch (err) {
const installing = { ...get().installing };
@@ -209,7 +212,7 @@ export const useFeaturesStore = create<FeaturesState>((set, get) => {
uninstallBundle: async (bundleId: string) => {
try {
await apiPost(`/v1/admin/features/${bundleId}/uninstall`);
await apiPost(`/v1/admin/features/${bundleId}/uninstall`, {});
await refreshBundles();
} catch (err) {
set({
+40
View File
@@ -277,6 +277,45 @@ def download_url_model(model: dict, models_dir: str) -> None:
os.rename(tmp_path, final_path)
_matting_registered = False
def _register_birefnet_matting() -> None:
"""Register the custom BiRefNet-matting ONNX session.
This model is not built into rembg — it must be registered before
calling new_session("birefnet-matting"). The same registration is
done in remove_bg.py (runtime) and download_models.py (build-time).
"""
global _matting_registered
if _matting_registered:
return
_matting_registered = True
import pooch
from rembg.sessions import sessions_class
from rembg.sessions.birefnet_general import BiRefNetSessionGeneral
class BiRefNetMattingSession(BiRefNetSessionGeneral):
@classmethod
def download_models(cls, *args, **kwargs):
fname = f"{cls.name(*args, **kwargs)}.onnx"
pooch.retrieve(
"https://github.com/ZhengPeng7/BiRefNet/releases/download/v1/BiRefNet-matting-epoch_100.onnx",
None,
fname=fname,
path=cls.u2net_home(*args, **kwargs),
progressbar=True,
)
return os.path.join(cls.u2net_home(*args, **kwargs), fname)
@classmethod
def name(cls, *args, **kwargs):
return "birefnet-matting"
sessions_class.append(BiRefNetMattingSession)
def download_rembg_session(model: dict, models_dir: str) -> None:
"""Download a rembg model by initializing a session."""
args = model.get("args", [])
@@ -291,6 +330,7 @@ def download_rembg_session(model: dict, models_dir: str) -> None:
os.environ["U2NET_HOME"] = u2net_dir
from rembg import new_session
_register_birefnet_matting()
new_session(model_name)
+4 -4
View File
@@ -1966,7 +1966,7 @@ describe("useFeaturesStore", () => {
await useFeaturesStore.getState().uninstallBundle("ai-rembg");
expect(mockApiPost).toHaveBeenCalledWith("/v1/admin/features/ai-rembg/uninstall");
expect(mockApiPost).toHaveBeenCalledWith("/v1/admin/features/ai-rembg/uninstall", {});
});
it("uninstallBundle sets error on failure", async () => {
@@ -2207,8 +2207,8 @@ describe("useFeaturesStore", () => {
await useFeaturesStore.getState().reinstallBundle("ai-rembg");
expect(mockApiPost).toHaveBeenCalledWith("/v1/admin/features/ai-rembg/uninstall");
expect(mockApiPost).toHaveBeenCalledWith("/v1/admin/features/ai-rembg/install");
expect(mockApiPost).toHaveBeenCalledWith("/v1/admin/features/ai-rembg/uninstall", {});
expect(mockApiPost).toHaveBeenCalledWith("/v1/admin/features/ai-rembg/install", {});
});
// -- EventSource progress handling ----------------------------------------
@@ -2452,7 +2452,7 @@ describe("useFeaturesStore", () => {
expect(useFeaturesStore.getState().installAllActive).toBe(false);
expect(useFeaturesStore.getState().queued).toEqual([]);
// Only not-installed bundle should have been installed
expect(mockApiPost).toHaveBeenCalledWith("/v1/admin/features/ai-rembg/install");
expect(mockApiPost).toHaveBeenCalledWith("/v1/admin/features/ai-rembg/install", {});
});
it("installAll skips bundles that are already installed", async () => {