Skip to content

fix: harden input validation, file safety, and error handling#227

Open
ankit1999 wants to merge 11 commits intojamiepine:mainfrom
ankit1999:fix/code-review-hardening
Open

fix: harden input validation, file safety, and error handling#227
ankit1999 wants to merge 11 commits intojamiepine:mainfrom
ankit1999:fix/code-review-hardening

Conversation

@ankit1999
Copy link
Copy Markdown

@ankit1999 ankit1999 commented Mar 2, 2026

Summary

Addresses code review findings across frontend and backend — input validation, file integrity, error handling, and code deduplication.

Changes

Frontend

  • ModelManagement.tsx — Validate model_name format before removal; disable Remove button during pending mutation
  • client.ts — URL-encode modelId in removeCustomModel path
  • useGenerationForm.ts — Constrain modelSize with regex (built-in sizes + custom slugs)
  • useModelStatus.ts (new) — Shared hook extracted from GenerationForm / FloatingGenerateBox

Backend

  • custom_models.py — Atomic writes (temp file → fsync → os.replace), threading lock, corrupt config backup, strict owner/repo regex for hf_repo_id
  • models.py — Regex pattern on model_size Field
  • config.py — Platform-appropriate default data dir for PyInstaller --onefile bundles (via platformdirs)
  • main.py — Re-raise HTTPException before broad except Exception so 202 download-in-progress responses aren't swallowed as 500s
  • custom_models.json — Removed pre-seeded entry for clean installs
  • requirements.txt — Added platformdirs>=4.0.0
  • backend/README.md — Documented --data-dir CLI flag

Skipped

  • main.py probe_model_cache_status refactoring (pure refactoring, deferred to reduce regression risk)

Verification

  • ✅ TypeScript tsc --noEmit — zero errors
  • ✅ Python py_compile — all modified files pass

Summary by CodeRabbit

Release Notes

  • New Features

    • Added support for registering and managing custom HuggingFace TTS models through a user-friendly dialog
    • New dedicated custom models section with add and unregister functionality
  • Improvements

    • Enhanced platform-specific data directory management for better persistence across installations
    • Improved build system stability with more robust error handling

- New custom_models.py module for CRUD management of user-defined HF TTS models
- New /custom-models API endpoints (list, add, get, delete)
- Updated MLX and PyTorch backends to resolve custom model paths (custom:slug format)
- Added Custom Models section to ModelManagement UI with add/remove dialogs
- Updated GenerationForm and FloatingGenerateBox with grouped model selectors
- Added CustomModelCreate/Response types and API client methods
- Added instruct field to GenerationRequest type
- Graceful actool fallback in build.rs for non-Xcode environments
- Added custom_models hidden import for PyInstaller bundling

Author: AJ - Kamyab (Ankit Jain)
- ModelManagement.tsx: validate model_name format before removal, guard duplicate clicks
- client.ts: URL-encode modelId in removeCustomModel path
- useGenerationForm.ts: constrain modelSize with regex pattern
- useModelStatus.ts: extract shared hook from GenerationForm/FloatingGenerateBox
- custom_models.py: atomic writes, threading lock, corrupt config backup, strict hf_repo_id regex
- models.py: add regex pattern to model_size Field
- config.py: platform-appropriate default data dir for PyInstaller bundles
- main.py: re-raise HTTPException before broad except to preserve 202 responses
- custom_models.json: remove pre-seeded entry for clean installs
- requirements.txt: add platformdirs dependency
- backend/README.md: document --data-dir CLI flag
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Mar 2, 2026

📝 Walkthrough

Walkthrough

This PR adds end-to-end support for registering and managing user-defined custom HuggingFace TTS models. It introduces a persistent custom_models.py backend module with CRUD operations, new API endpoints, frontend UI for model management, and updates model resolution in both MLX and PyTorch backends to recognize custom: prefixed identifiers.

Changes

Cohort / File(s) Summary
Frontend UI - Custom Model Management
app/src/components/ServerSettings/ModelManagement.tsx, app/src/components/Generation/FloatingGenerateBox.tsx
Added "Custom Models" section with add/remove dialogs, dialog state management, mutations for custom model CRUD, and unregister button in model details. Minor ternary expression refactoring in FloatingGenerateBox.
Frontend API Client & Types
app/src/lib/api/client.ts, app/src/lib/api/types.ts
Added three new API methods (listCustomModels, addCustomModel, removeCustomModel), new TypeScript interfaces for custom model requests/responses, updated GenerationRequest.model_size to accept generic strings, and added is_custom flag to ModelStatus.
Frontend Hooks & Logic
app/src/lib/hooks/useGenerationForm.ts, app/src/lib/hooks/useModelStatus.ts
Updated modelSize validation to allow custom:<slug> identifiers with regex pattern, added custom branch in handleSubmit for slug extraction, and created new useModelStatus hook that fetches status and derives filtered builtInModels and customModels lists.
Backend Custom Models Core
backend/custom_models.py, backend/models.py, backend/routes/models.py
New persistent custom model storage module with JSON-based config, atomic file operations, and slug generation. Added Pydantic models (CustomModelCreate, CustomModelResponse, CustomModelListResponse). Added /custom-models CRUD endpoints with error handling and cache directory management for DELETE /models/{custom:model_name}.
Backend Model Resolution
backend/backends/mlx_backend.py, backend/backends/pytorch_backend.py
Extended _get_model_path() to recognize custom: prefix, resolve to HuggingFace repo ID via get_hf_repo_id_for_custom_model(), and bypass built-in model map validation for custom identifiers.
Backend Configuration & Build
backend/config.py, backend/requirements.txt, backend/build_binary.py, backend/voicebox-server.spec, backend/main.py
Added platform-specific data directory resolution using platformdirs with PyInstaller fallback, included backend.custom_models in PyInstaller hidden imports, added platformdirs>=4.0.0 dependency, and minor formatting adjustment.
Build & CI/CD
tauri/src-tauri/build.rs, .github/workflows/release.yml
Replaced panic flows in Tauri build with graceful logging warnings for actool failures. Removed entire GitHub Actions release workflow (251 lines).
Data Files
data/custom_models.json
Added new JSON file with empty models array for custom model storage.

Sequence Diagram(s)

sequenceDiagram
    participant User as User
    participant UI as ModelManagement UI
    participant API as API Client
    participant Backend as Backend Server
    participant Storage as custom_models.json
    participant HF as HuggingFace

    User->>UI: Click "Add Custom Model"
    UI->>UI: Open dialog, enter repo ID & display name
    User->>UI: Submit form
    UI->>API: POST /custom-models {hf_repo_id, display_name}
    API->>Backend: Custom model request
    Backend->>Backend: Validate repo ID format
    Backend->>Backend: Generate slug from repo ID
    Backend->>Backend: Check for duplicates
    Backend->>Storage: Append new custom model (atomic write)
    Storage-->>Backend: Persisted
    Backend-->>API: CustomModelResponse {id, hf_repo_id, display_name, added_at}
    API-->>UI: Success
    UI->>UI: Invalidate modelStatus query, close dialog
    UI->>Backend: GET /models/status (refetch)
    Backend->>Storage: list_custom_models()
    Storage-->>Backend: Custom models list
    Backend->>HF: Check HF cache for each custom model
    HF-->>Backend: Cache metadata
    Backend-->>UI: ModelStatus with custom entries
    UI->>User: Display updated custom models section
Loading
sequenceDiagram
    participant User as User
    participant UI as Generation UI
    participant Hook as useGenerationForm
    participant API as API Client
    participant Backend as Backend Server
    participant MLX as MLX Backend
    participant Cache as HF Cache

    User->>UI: Select custom model from dropdown
    UI->>Hook: modelSize = "custom:my-model"
    Hook->>Hook: Validate against regex<br/>(1.7B|0.6B|1B|3B|custom:[a-z0-9]...)
    Hook->>Hook: Extract slug "my-model"
    Hook->>API: getModelStatus() [refetch]
    API->>Backend: GET /models/status
    Backend->>Backend: Fetch custom model by id
    Backend-->>API: ModelStatus including custom entry
    API-->>Hook: modelStatus updated
    Hook->>Hook: Set displayName from model metadata
    User->>UI: Generate audio
    UI->>Hook: handleSubmit with modelSize="custom:my-model"
    Hook->>Hook: Build GenerationRequest<br/>model_size="custom:my-model"
    API->>Backend: POST /generation {model_size="custom:my-model", ...}
    Backend->>MLX: _get_model_path("custom:my-model")
    MLX->>MLX: Detect custom: prefix
    MLX->>Backend: get_hf_repo_id_for_custom_model("my-model")
    Backend-->>MLX: "owner/repo"
    MLX->>Cache: Load model from HF cache<br/>models--owner--repo
    Cache-->>MLX: Model files
    MLX-->>Backend: model_path
    Backend->>Backend: Generate audio with custom model
    Backend-->>API: Audio response
    API-->>UI: Display result
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • PR #285 — Directly related to custom HuggingFace model support; modifies same backend modules (custom_models, model resolution, API endpoints).
  • PR #268 — Overlaps in model-management frontend and backend refactoring across ServerSettings/ModelManagement.tsx, API client/types, and routes.
  • PR #319 — Shares updates to useGenerationForm.ts and model-selection logic for new model identifier support.

Suggested reviewers

  • rhmod09-dev

🐰 Hoppy hop, the models grow,
Custom ones from HuggingFace flow,
A dialog here, a schema there,
Persistent JSON with utmost care,
Slugs resolve through backends swift,
Custom voices—such a gift! 🎤✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 75.61% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'fix: harden input validation, file safety, and error handling' accurately reflects the primary objectives of the changeset, which includes input validation hardening, file safety improvements (atomic writes, path encoding), and error handling enhancements across frontend and backend.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
⚔️ Resolve merge conflicts
  • Resolve merge conflict in branch fix/code-review-hardening

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
app/src/lib/hooks/useGenerationForm.ts (1)

120-132: ⚠️ Potential issue | 🟠 Major

Handle download-pending responses before using result as GenerationResponse.

This block assumes result.duration exists. When backend returns 202 download-pending payload, this can crash the success path.

✅ Safer flow: early-return on undownloaded model
       try {
         const modelStatus = await apiClient.getModelStatus();
         const model = modelStatus.models.find((m) => m.model_name === modelName);

         if (model) {
           displayName = model.display_name;
           if (!model.downloaded) {
             // Not yet downloaded — enable progress tracking UI
             setDownloadingModelName(modelName);
             setDownloadingDisplayName(displayName);
+            if (!model.downloading) {
+              await apiClient.triggerModelDownload(modelName);
+            }
+            toast({
+              title: 'Model download in progress',
+              description: `${displayName} is downloading. Try generating again when ready.`,
+            });
+            return;
           }
         }
       } catch (error) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/lib/hooks/useGenerationForm.ts` around lines 120 - 132, The success
handler assumes result is a fully available GenerationResponse and accesses
result.duration, which will crash if the backend returned a 202 download-pending
payload; update the flow in useGenerationForm where generation.mutateAsync is
awaited (the block that constructs toast with result.duration) to first detect
the download-pending response (e.g., check a status field or absence of
duration/download URL on the returned object) and early-return or show a
"download pending" toast instead of accessing result.duration; ensure subsequent
logic only runs when result.duration exists (guard on result.duration or
result.status === 'ready') so you never dereference duration on a pending
response.
🧹 Nitpick comments (5)
backend/config.py (1)

50-57: Consider ensuring the data directory exists in get_data_dir().

Unlike set_data_dir() and the subdirectory getters (e.g., get_profiles_dir()), get_data_dir() does not call mkdir(). If called before set_data_dir() and the directory doesn't exist, callers may encounter errors when writing files.

🔧 Proposed fix
 def get_data_dir() -> Path:
     """
     Get the data directory path.
 
     Returns:
         Path to the data directory
     """
+    _data_dir.mkdir(parents=True, exist_ok=True)
     return _data_dir
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/config.py` around lines 50 - 57, get_data_dir() should ensure the
underlying directory exists like set_data_dir() and get_profiles_dir() do:
before returning the module-level _data_dir Path, call
_data_dir.mkdir(parents=True, exist_ok=True) (or equivalent) to create the
directory if missing so callers won’t fail when writing files; update the
get_data_dir() function to perform this mkdir step and then return _data_dir.
backend/backends/mlx_backend.py (1)

40-65: Consider extracting shared custom model resolution logic.

The custom model resolution code (lines 40-49) is nearly identical to pytorch_backend.py (lines 65-73). While this duplication is minimal and acceptable, you could extract it to a shared utility if this pattern expands to more backends.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/backends/mlx_backend.py` around lines 40 - 65, The duplicate
custom-model resolution block (checking model_size.startswith("custom:"),
extracting custom_id, calling get_hf_repo_id_for_custom_model and raising
ValueError) should be extracted into a shared helper so both mlx_backend and
pytorch_backend reuse it; create a utility function (e.g.,
resolve_custom_model_hf_repo or get_hf_repo_id_from_model_size) that accepts
model_size, imports/calls get_hf_repo_id_for_custom_model, returns the
hf_repo_id or raises the ValueError, and replace the inline code in mlx_backend
(the model_size.startswith("custom:") branch) and the corresponding code in
pytorch_backend with calls to this new helper; keep existing behavior/logging
(print) and preserve mlx_model_map/other logic.
backend/custom_models.py (1)

47-62: Use logger.exception to include traceback in error logs.

Within exception handlers, logger.exception automatically includes the traceback, which is more useful for debugging than logger.error.

♻️ Proposed fix
     except json.JSONDecodeError as exc:
         # Back up the corrupt file so we don't lose data
         backup = path.with_suffix(
             f".json.corrupt.{datetime.utcnow().strftime('%Y%m%dT%H%M%S')}"
         )
         try:
             path.rename(backup)
-            logger.error(
+            logger.exception(
                 "Corrupt custom_models.json backed up to %s: %s", backup, exc
             )
         except OSError as rename_err:
-            logger.error(
+            logger.exception(
                 "Failed to back up corrupt config %s: %s (original error: %s)",
                 path, rename_err, exc,
             )
         raise
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/custom_models.py` around lines 47 - 62, Replace the logger.error
calls inside the JSON decode and nested OSError handlers with logger.exception
so the traceback is recorded: in the except json.JSONDecodeError as exc block,
change the logger.error("Corrupt custom_models.json backed up to %s: %s",
backup, exc) to logger.exception(...) and in the inner except OSError as
rename_err block change logger.error("Failed to back up corrupt config %s: %s
(original error: %s)", path, rename_err, exc) to logger.exception(...)
(preserving the descriptive messages and context) so the full tracebacks for the
JSONDecodeError and the rename error are included.
backend/main.py (1)

1546-1552: Don’t silently swallow cache-probe exceptions in model status paths.

Current except Exception: pass behavior hides actionable failures. Log at debug level so support can diagnose cache-state inconsistencies.

🧭 Minimal observability patch
+import logging
+logger = logging.getLogger(__name__)
@@
-                        except Exception:
-                            pass
+                        except Exception:
+                            logger.debug("Custom model cache probe failed (incomplete check): %s", hf_repo_id, exc_info=True)
@@
-                            except Exception:
-                                pass
+                            except Exception:
+                                logger.debug("Custom model size computation failed: %s", hf_repo_id, exc_info=True)
@@
-                                except Exception:
-                                    pass
-                except Exception:
-                    pass
+                                except Exception:
+                                    logger.debug("Custom model fallback size computation failed: %s", hf_repo_id, exc_info=True)
+                except Exception:
+                    logger.debug("Custom model fallback cache probe failed: %s", hf_repo_id, exc_info=True)
@@
-            except Exception:
-                pass
+            except Exception:
+                logger.debug("Custom model loaded-state check failed: %s", model_name, exc_info=True)
@@
-        except Exception:
+        except Exception:
+            logger.debug("Custom model status assembly failed: %s", model_name, exc_info=True)
             is_downloading = model_name in active_download_names

Also applies to: 1557-1560, 1584-1593, 1598-1601, 1618-1628

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/main.py` around lines 1546 - 1552, Replace silent except blocks that
currently do "except Exception: pass" in the model status cache probes (the
blocks that compute cache_dir_path, blobs_dir and has_incomplete using
hf_constants.HF_HUB_CACHE and hf_repo_id) with exception handlers that call the
module logger at debug level including a descriptive message and the exception
details (e.g., "failed probing HF cache for repo {hf_repo_id},
cache_dir={cache_dir_path}") so failures aren't swallowed; make the same change
for the other similar blocks flagged (the ones around lines computing
blobs_dir/has_incomplete in the same function) to ensure consistent debug-level
observability while preserving existing control flow.
app/src/components/Generation/GenerationForm.tsx (1)

146-174: Extract shared model-option rendering/mapping to avoid drift.

The built-in/custom select rendering and model_namesizeValue mapping is duplicated in FloatingGenerateBox.tsx; fallback text has already started diverging. Consider a shared helper/component for this block.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/components/Generation/GenerationForm.tsx` around lines 146 - 174, The
built-in/custom model option rendering and the model_name→sizeValue mapping in
GenerationForm (see builtInModels, customModels, SelectGroup/SelectItem usage
and the sizeValue = model.model_name.replace('qwen-tts-', '')) are duplicated in
FloatingGenerateBox; extract a shared helper or small presentational component
(e.g., renderModelOptions or ModelSelectGroup component and a mapModelNameToSize
utility) that returns the same SelectGroup/SelectItem structure and performs the
model_name→sizeValue transformation, then replace the duplicated loops in both
GenerationForm.tsx and FloatingGenerateBox.tsx to call the new shared
function/component so fallback text and mapping remain consistent.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/src/components/Generation/GenerationForm.tsx`:
- Line 37: The hook useModelStatus currently classifies builtInModels using
model_name.startsWith('qwen-tts'), which incorrectly flags repos like
"qwen-tts/my-voice" as built-in; update the predicate in useModelStatus (the
filter that builds builtInModels/customModels) to only treat true built-ins such
as the core qwen-tts identifier (e.g., model_name === 'qwen-tts' or match a
pattern that disallows a following '/'), for example replace
startsWith('qwen-tts') with a stricter check (exact equality or a regex like
/^qwen-tts($|[:@])/) so repo-qualified names with a slash go to customModels.

In `@app/src/components/ServerSettings/ModelManagement.tsx`:
- Around line 617-623: The Remove button currently disables only when
model.loaded or isUnregistering is true; update the disable condition for the
Button (the component with onClick={onRemove}) to also check the showDownloading
flag so removal is prevented while a download is active. Locate the Button that
uses onRemove and the disabled prop, and add showDownloading (or its local/state
variable) into the combined disabled expression alongside model.loaded and
isUnregistering.

In `@backend/custom_models.py`:
- Line 10: Remove the unused fcntl import from the top of custom_models.py
(delete the `import fcntl` statement), verify there are no remaining references
to fcntl in the file (the module already uses threading.Lock for
synchronization), and run tests/linting to confirm no regressions.

In `@backend/voicebox-server.spec`:
- Around line 16-19: The spec unconditionally calls collect_all('mlx') and
collect_all('mlx_audio') which fails when MLX isn't installed; wrap those
collect_all calls in a runtime presence check (e.g., try/except ImportError or
importlib.util.find_spec) before invoking collect_all so that if 'mlx' or
'mlx_audio' is missing you skip updating tmp_ret/datas/binaries/hiddenimports.
Specifically, guard the collect_all('mlx') and collect_all('mlx_audio') blocks
(the places that assign tmp_ret and then do datas += tmp_ret[0]; binaries +=
tmp_ret[1]; hiddenimports += tmp_ret[2]) so they only run when the package is
importable.

---

Outside diff comments:
In `@app/src/lib/hooks/useGenerationForm.ts`:
- Around line 120-132: The success handler assumes result is a fully available
GenerationResponse and accesses result.duration, which will crash if the backend
returned a 202 download-pending payload; update the flow in useGenerationForm
where generation.mutateAsync is awaited (the block that constructs toast with
result.duration) to first detect the download-pending response (e.g., check a
status field or absence of duration/download URL on the returned object) and
early-return or show a "download pending" toast instead of accessing
result.duration; ensure subsequent logic only runs when result.duration exists
(guard on result.duration or result.status === 'ready') so you never dereference
duration on a pending response.

---

Nitpick comments:
In `@app/src/components/Generation/GenerationForm.tsx`:
- Around line 146-174: The built-in/custom model option rendering and the
model_name→sizeValue mapping in GenerationForm (see builtInModels, customModels,
SelectGroup/SelectItem usage and the sizeValue =
model.model_name.replace('qwen-tts-', '')) are duplicated in
FloatingGenerateBox; extract a shared helper or small presentational component
(e.g., renderModelOptions or ModelSelectGroup component and a mapModelNameToSize
utility) that returns the same SelectGroup/SelectItem structure and performs the
model_name→sizeValue transformation, then replace the duplicated loops in both
GenerationForm.tsx and FloatingGenerateBox.tsx to call the new shared
function/component so fallback text and mapping remain consistent.

In `@backend/backends/mlx_backend.py`:
- Around line 40-65: The duplicate custom-model resolution block (checking
model_size.startswith("custom:"), extracting custom_id, calling
get_hf_repo_id_for_custom_model and raising ValueError) should be extracted into
a shared helper so both mlx_backend and pytorch_backend reuse it; create a
utility function (e.g., resolve_custom_model_hf_repo or
get_hf_repo_id_from_model_size) that accepts model_size, imports/calls
get_hf_repo_id_for_custom_model, returns the hf_repo_id or raises the
ValueError, and replace the inline code in mlx_backend (the
model_size.startswith("custom:") branch) and the corresponding code in
pytorch_backend with calls to this new helper; keep existing behavior/logging
(print) and preserve mlx_model_map/other logic.

In `@backend/config.py`:
- Around line 50-57: get_data_dir() should ensure the underlying directory
exists like set_data_dir() and get_profiles_dir() do: before returning the
module-level _data_dir Path, call _data_dir.mkdir(parents=True, exist_ok=True)
(or equivalent) to create the directory if missing so callers won’t fail when
writing files; update the get_data_dir() function to perform this mkdir step and
then return _data_dir.

In `@backend/custom_models.py`:
- Around line 47-62: Replace the logger.error calls inside the JSON decode and
nested OSError handlers with logger.exception so the traceback is recorded: in
the except json.JSONDecodeError as exc block, change the logger.error("Corrupt
custom_models.json backed up to %s: %s", backup, exc) to logger.exception(...)
and in the inner except OSError as rename_err block change logger.error("Failed
to back up corrupt config %s: %s (original error: %s)", path, rename_err, exc)
to logger.exception(...) (preserving the descriptive messages and context) so
the full tracebacks for the JSONDecodeError and the rename error are included.

In `@backend/main.py`:
- Around line 1546-1552: Replace silent except blocks that currently do "except
Exception: pass" in the model status cache probes (the blocks that compute
cache_dir_path, blobs_dir and has_incomplete using hf_constants.HF_HUB_CACHE and
hf_repo_id) with exception handlers that call the module logger at debug level
including a descriptive message and the exception details (e.g., "failed probing
HF cache for repo {hf_repo_id}, cache_dir={cache_dir_path}") so failures aren't
swallowed; make the same change for the other similar blocks flagged (the ones
around lines computing blobs_dir/has_incomplete in the same function) to ensure
consistent debug-level observability while preserving existing control flow.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 38bf96f and a7e698a.

⛔ Files ignored due to path filters (2)
  • bun.lock is excluded by !**/*.lock
  • tauri/src-tauri/Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (19)
  • app/src/components/Generation/FloatingGenerateBox.tsx
  • app/src/components/Generation/GenerationForm.tsx
  • app/src/components/ServerSettings/ModelManagement.tsx
  • app/src/lib/api/client.ts
  • app/src/lib/api/types.ts
  • app/src/lib/hooks/useGenerationForm.ts
  • app/src/lib/hooks/useModelStatus.ts
  • backend/README.md
  • backend/backends/mlx_backend.py
  • backend/backends/pytorch_backend.py
  • backend/build_binary.py
  • backend/config.py
  • backend/custom_models.py
  • backend/main.py
  • backend/models.py
  • backend/requirements.txt
  • backend/voicebox-server.spec
  • data/custom_models.json
  • tauri/src-tauri/build.rs

const { form, handleSubmit, isPending } = useGenerationForm();

// Use shared hook for model status fetching and grouping
const { builtInModels, customModels } = useModelStatus();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Prevent built-in/custom option overlap from hook classification.

builtInModels from useModelStatus is based on model_name.startsWith('qwen-tts'). A custom repo like qwen-tts/my-voice can appear in both groups and be misinterpreted in built-in mapping.

🔧 Proposed fix (in app/src/lib/hooks/useModelStatus.ts)
-const builtInModels =
-  modelStatus?.models.filter((m) => m.model_name.startsWith('qwen-tts')) || [];
+const builtInModels =
+  modelStatus?.models.filter((m) => !m.is_custom && m.model_name.startsWith('qwen-tts-')) || [];
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/components/Generation/GenerationForm.tsx` at line 37, The hook
useModelStatus currently classifies builtInModels using
model_name.startsWith('qwen-tts'), which incorrectly flags repos like
"qwen-tts/my-voice" as built-in; update the predicate in useModelStatus (the
filter that builds builtInModels/customModels) to only treat true built-ins such
as the core qwen-tts identifier (e.g., model_name === 'qwen-tts' or match a
pattern that disallows a following '/'), for example replace
startsWith('qwen-tts') with a stricter check (exact equality or a regex like
/^qwen-tts($|[:@])/) so repo-qualified names with a slash go to customModels.

Comment on lines +617 to +623
<Button
size="sm"
onClick={onRemove}
variant="ghost"
title="Remove custom model from list"
disabled={model.loaded || isUnregistering}
>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Disable “remove custom model” while download is active.

Unregistering during showDownloading can create inconsistent UX/state around in-flight downloads.

🛠️ Small UI guard improvement
         <Button
           size="sm"
           onClick={onRemove}
           variant="ghost"
           title="Remove custom model from list"
-          disabled={model.loaded || isUnregistering}
+          disabled={model.loaded || isUnregistering || showDownloading}
         >
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/components/ServerSettings/ModelManagement.tsx` around lines 617 -
623, The Remove button currently disables only when model.loaded or
isUnregistering is true; update the disable condition for the Button (the
component with onClick={onRemove}) to also check the showDownloading flag so
removal is prevented while a download is active. Locate the Button that uses
onRemove and the disabled prop, and add showDownloading (or its local/state
variable) into the combined disabled expression alongside model.loaded and
isUnregistering.

Comment thread backend/custom_models.py Outdated
Comment on lines +16 to +19
tmp_ret = collect_all('mlx')
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
tmp_ret = collect_all('mlx_audio')
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

git ls-files | head -20

Repository: jamiepine/voicebox

Length of output: 446


🏁 Script executed:

find . -name "*.spec" -o -name "*requirements*.txt" -o -name "pyproject.toml" -o -name "setup.cfg" | grep -v ".git" | head -20

Repository: jamiepine/voicebox

Length of output: 169


🏁 Script executed:

ls -la backend/ 2>/dev/null || echo "backend dir not found"

Repository: jamiepine/voicebox

Length of output: 1843


🏁 Script executed:

# Check if the spec file exists and examine it
if [ -f "backend/voicebox-server.spec" ]; then
  echo "=== backend/voicebox-server.spec ===" 
  head -30 backend/voicebox-server.spec
fi

Repository: jamiepine/voicebox

Length of output: 1802


🏁 Script executed:

echo "=== backend/requirements.txt ===" && cat backend/requirements.txt && echo && \
echo "=== backend/requirements-mlx.txt ===" && cat backend/requirements-mlx.txt

Repository: jamiepine/voicebox

Length of output: 672


🏁 Script executed:

echo "=== Build workflows ===" && \
for f in .github/workflows/*.yml; do
  echo "--- $f ---"
  cat "$f" | head -50
done

Repository: jamiepine/voicebox

Length of output: 2778


🏁 Script executed:

echo "=== Check Makefile for build commands ===" && \
cat Makefile 2>/dev/null | head -80

Repository: jamiepine/voicebox

Length of output: 3156


🏁 Script executed:

# Verify PyInstaller collect_all behavior and check if error handling exists in build_binary.py
echo "=== backend/build_binary.py ===" && \
head -50 backend/build_binary.py && \
echo "..." && \
tail -30 backend/build_binary.py

Repository: jamiepine/voicebox

Length of output: 2784


🏁 Script executed:

# Check if there's any error handling around collect_all in the repo
rg -n "collect_all|try.*collect" backend/

Repository: jamiepine/voicebox

Length of output: 268


Guard MLX collect_all calls for environments where MLX is absent.

The spec file unconditionally calls collect_all('mlx') and collect_all('mlx_audio') (lines 16–19), but MLX is declared as Apple Silicon-only in requirements-mlx.txt and conditionally installed only on aarch64-apple-darwin. Windows and Intel Mac CI builds skip MLX dependencies, causing PyInstaller to fail when the spec file is executed on these platforms. Add package-presence guards before collecting.

Suggested fix
+from importlib.util import find_spec
+
 tmp_ret = collect_all('mlx')
 datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
 tmp_ret = collect_all('mlx_audio')
 datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
+if find_spec("mlx") is not None:
+    _d, _b, _h = collect_all("mlx")
+    datas += _d
+    binaries += _b
+    hiddenimports += _h
+
+if find_spec("mlx_audio") is not None:
+    _d, _b, _h = collect_all("mlx_audio")
+    datas += _d
+    binaries += _b
+    hiddenimports += _h
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
tmp_ret = collect_all('mlx')
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
tmp_ret = collect_all('mlx_audio')
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
from importlib.util import find_spec
if find_spec("mlx") is not None:
_d, _b, _h = collect_all("mlx")
datas += _d
binaries += _b
hiddenimports += _h
if find_spec("mlx_audio") is not None:
_d, _b, _h = collect_all("mlx_audio")
datas += _d
binaries += _b
hiddenimports += _h
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/voicebox-server.spec` around lines 16 - 19, The spec unconditionally
calls collect_all('mlx') and collect_all('mlx_audio') which fails when MLX isn't
installed; wrap those collect_all calls in a runtime presence check (e.g.,
try/except ImportError or importlib.util.find_spec) before invoking collect_all
so that if 'mlx' or 'mlx_audio' is missing you skip updating
tmp_ret/datas/binaries/hiddenimports. Specifically, guard the collect_all('mlx')
and collect_all('mlx_audio') blocks (the places that assign tmp_ret and then do
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]) so
they only run when the package is importable.

Resolved 16 merge conflicts to integrate custom model management
into the new refactored architecture:

Frontend:
- Removed inline modelSize selectors in FloatingGenerateBox and
  GenerationForm in favor of the unified EngineModelSelector
- Merged model_size typing (string for custom: slugs) with
  origin/main's GenerationRequest properties (engine, effects_chain, etc.)
- Preserved custom model CRUD in ModelManagement with new modal UI
- Updated useGenerationForm to handle custom model identifiers
  within the new multi-engine architecture

Backend:
- Accepted origin/main's modular app.py + routes architecture
- Injected custom model status into routes/models.py get_model_status
- Added custom model CRUD endpoints (/custom-models) to the models router
- Merged platformdirs fallback into config.py path resolution
- Extended model_size regex to accept 1B, 3B sizes alongside custom: slugs
- Combined hidden imports in build_binary.py and voicebox-server.spec
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
app/src/lib/api/client.ts (1)

3-22: ⚠️ Potential issue | 🟠 Major

Duplicate ActiveTasksResponse import.

ActiveTasksResponse is imported on both line 4 and line 19 (Biome flags this as a parse error). Drop the second occurrence; the other added types (CustomModelCreate, CustomModelResponse, CustomModelListResponse) should stay, ideally merged alphabetically into the existing sorted import list.

🛠️ Proposed fix
 import type {
-  ActiveTasksResponse,
   ApplyEffectsRequest,
   AvailableEffectsResponse,
+  ActiveTasksResponse,
+  CustomModelCreate,
+  CustomModelListResponse,
+  CustomModelResponse,
   CudaStatus,
   EffectConfig,
   EffectPresetCreate,
   EffectPresetResponse,
   GenerationRequest,
   GenerationResponse,
   GenerationVersionResponse,
   HealthResponse,
   HistoryListResponse,
   HistoryQuery,
   HistoryResponse,
   ModelDownloadRequest,
-  ActiveTasksResponse,
-  CustomModelCreate,
-  CustomModelResponse,
-  CustomModelListResponse,
   ModelStatusListResponse,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/lib/api/client.ts` around lines 3 - 22, The import list contains a
duplicate ActiveTasksResponse symbol; remove the second occurrence and merge the
new types (CustomModelCreate, CustomModelResponse, CustomModelListResponse) into
the existing sorted import block so the list remains alphabetized and
deduplicated (update the top-level import that currently declares
ActiveTasksResponse, ApplyEffectsRequest, ..., GenerationResponse, etc., to
include the three CustomModel* types in the correct alphabetical position and
omit the duplicate ActiveTasksResponse).
app/src/components/ServerSettings/ModelManagement.tsx (1)

1-1186: ⚠️ Potential issue | 🔴 Critical

This file is structurally broken and will not compile.

Biome is already flagging five parse errors, but there are far more blocking issues — this file as posted cannot type-check or run. Key problems:

  1. Imports are corrupted (lines 3-64).

    • Line 3 concatenates two imports on one line (...} from 'react';import {) and the second import { never gets a matching source before intervening imports appear.
    • Lines 19-25 (DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; plus Input/Label/Progress) have no opening import { — the dialog named imports (Dialog, DialogContent, DialogDescription) are missing entirely, yet they're used at lines 714-715, 1147-1148, etc.
    • Line 64 (import { usePlatform } from '@/platform/PlatformContext';) and line 65 (useServerStore) are placed inside the ModelManagement function body opened at line 39. Imports cannot appear inside a function.
  2. ModelManagement never closes cleanly. Top-level declarations like fetchHuggingFaceModelInfo, MODEL_DESCRIPTIONS, formatDownloads, formatLicense, formatPipelineTag, formatBytes (lines 67-123) are nested inside ModelManagement() because the function opened at line 39 has no matching close before them. Corresponding closing } then never balances.

  3. Multiple statements on one line where newlines were lost.

    • Line 318: two await queryClient.* calls on a single line.
    • Line 343: title: 'Unload failed', description: error.message, — missing newline between object properties.
    • Line 410: .replace('license:', ''); return ( — return statement merged with the previous assignment.
    • Line 1030: {pendingMigrateDir} </div> — closing tag merged with text.
  4. Undefined references.

    • modelStatus, isLoading (used at lines 231, 377-378, 387, 390, 496, 500, 502) — useModelStatus() is never imported or called, and no useQuery(['modelStatus']) exists in this file either.
    • addCustomModelMutation (lines 357, 1177, 1178) and removeCustomModelMutation (lines 683, 694, 697, 938, 940, 948) are used but never declared.
    • CustomModelItem (line 669) is used but neither imported nor defined.
    • DialogContent, DialogDescription, Dialog (lines 714-715, 720, 1147-1148, 1151) are not imported.
  5. JSX mismatched. Lines 710-711 close with </div> ) : null} </CardContent> but no <Card>/<CardContent> ever opens in this file. The <Dialog open={addDialogOpen}> block at lines 1147-1183 is emitted after ModelManagement closes at line 1145, making it orphaned top-level JSX followed by another stray </div>, ), and } at 1184-1186.

  6. Duplicated "Custom Models" UI. The sections loop at lines 502-586 already renders a "Custom Models" subsection (with section.isCustom Add button at 508-512), and then lines 651-707 render another full "Custom Models" section, seemingly copy-pasted from an earlier version. One of the two needs to go.

This looks like the result of a messy merge/patch — tsc --noEmit would not pass on this file despite what the PR description claims. Please re-apply the intended hunks cleanly (reconstruct imports, ensure the function body/JSX balances, wire up useModelStatus, declare the missing mutations, and either define or import CustomModelItem) and re-run tsc --noEmit before merging.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/components/ServerSettings/ModelManagement.tsx` around lines 1 - 1186,
The file is syntactically broken due to mangled imports, misplaced top-level
declarations inside the ModelManagement function, missing hook/mutation
declarations, duplicate UI blocks, and several merged lines — fix by: 1) move
and reconstruct all import statements to the top (include Dialog, DialogContent,
DialogDescription, DialogFooter/DialogHeader/DialogTitle, CustomModelItem,
useModelStatus/usePlatform/useServerStore), removing the stray concatenated
import fragments; 2) ensure ModelManagement() opens and closes correctly so
helper functions (fetchHuggingFaceModelInfo, MODEL_DESCRIPTIONS,
formatDownloads/formatLicense/formatPipelineTag/formatBytes) are declared
outside the component or moved above the component and not nested inside it; 3)
declare or create the missing React Query mutations/variables
(addCustomModelMutation, removeCustomModelMutation, modelStatus/isLoading via
useModelStatus or a useQuery for ['modelStatus']), and wire up CustomModelItem
prop types; 4) remove the duplicated "Custom Models" UI block (keep only the
sections-based rendering) and fix merged statements/newlines (split combined
await/queryClient calls and object properties like onError descriptions) so JSX
tags (CardContent/Dialog/DialogContent/etc.) are balanced; 5) run tsc --noEmit
and eslint/formatter to catch any remaining parse/type errors before committing.
🧹 Nitpick comments (4)
backend/backends/pytorch_backend.py (1)

54-63: Custom model resolution looks correct.

Logic correctly handles the custom: prefix before falling through to the built-in map, and raises ValueError consistent with the unknown-size error path below, so _load_model_sync / _is_model_cached callers see a uniform failure mode. The lazy import of ..custom_models avoids potential circular-import risk at module load.

One minor nit: the # @Modified AJ - Kamyab (Ankit Jain) attribution comment on line 55 is better tracked via VCS (git blame) than inline; consider dropping it to keep the source clean.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/backends/pytorch_backend.py` around lines 54 - 63, Remove the inline
attribution comment "# `@modified` AJ - Kamyab (Ankit Jain) — Added custom model
path resolution" to keep the source clean and rely on VCS history; leave the
surrounding logic intact (the model_size.startswith("custom:") branch that
computes custom_id, calls get_hf_repo_id_for_custom_model(custom_id), and raises
ValueError when not found) so callers like _load_model_sync and _is_model_cached
continue to see the same failure behavior.
app/src/lib/api/client.ts (1)

459-463: Minor: indentation inside removeCustomModel body.

Line 460 is indented at 6 spaces (new code) but lines 461-463 retain the surrounding 4-space indentation, making the closing brace misalign with the return. Not a correctness issue, but worth normalizing before merge.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/lib/api/client.ts` around lines 459 - 463, The indentation inside the
removeCustomModel method is inconsistent: adjust the indentation so the return
statement and the closing brace align with the surrounding 4-space indentation
used in this file; locate the async removeCustomModel(modelId: string) method
and change the line(s) currently indented with 6 spaces to use 4 spaces so the
return and the closing brace line up with other methods.
app/src/lib/hooks/useModelStatus.ts (1)

22-29: Consider a defensive fallback for customModels.

Filtering customModels strictly on m.is_custom couples this hook tightly to the backend always populating the flag. As noted on the backend change, the current /models/status implementation forgets to set is_custom=True on injected custom entries, so this hook will currently return an empty customModels list. Once that's fixed server-side this works, but a combined predicate (e.g. m.is_custom || m.model_name.startsWith('custom:')) would make the hook resilient to future drift and match the convention used elsewhere (ModelManagement.tsx).

🛠️ Proposed fix
     const customModels = useMemo(
-        () => modelStatus?.models.filter((m) => m.is_custom) ?? [],
+        () => modelStatus?.models.filter((m) => m.is_custom || m.model_name.startsWith('custom:')) ?? [],
         [modelStatus],
     );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/lib/hooks/useModelStatus.ts` around lines 22 - 29, The customModels
filter in useModelStatus currently only checks m.is_custom which can be missing;
update the useMemo for customModels in useModelStatus to defensively include
models whose model_name follows the custom convention (e.g.,
m.model_name.startsWith('custom:')) in addition to m.is_custom (i.e., filter by
m.is_custom || m.model_name?.startsWith('custom:')), preserving the null-safety
fallback (?? []) so the hook remains resilient to backend omissions and matches
the convention used in ModelManagement.tsx.
backend/routes/models.py (1)

389-428: Duplicate custom-model scanning logic.

This block re-implements cache directory presence / weights detection / size computation that already exists in the built-in loop at lines 312-345. Since custom entries differ only in where their config comes from, consider extracting the cache-probe into a helper (e.g. _probe_repo_cache(repo_id) -> (downloaded, size_mb)) and reusing it for both paths. Not a blocker, but given the PR explicitly defers probe_model_cache_status refactoring, this is the natural spot to consolidate.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/routes/models.py` around lines 389 - 428, The custom-model block
duplicates the repo cache probing/size logic already used in the built-in model
loop (the code around the existing probe_model_cache_status logic at lines
~312-345); extract that logic into a small helper (e.g.
_probe_repo_cache(repo_id) -> (downloaded: bool, size_mb: Optional[float])) and
call it from both the custom entries loop and the built-in loop so the code in
the custom_models.list_custom_models() handling only maps config fields and uses
_probe_repo_cache(hf_repo) to set downloaded and size_mb; preserve use of
models.ModelStatus, hf_repo in active_download_repos for downloading flag, and
keep exception handling around the per-entry probe.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/src/components/Generation/FloatingGenerateBox.tsx`:
- Around line 11-13: The import list in FloatingGenerateBox.tsx includes unused
symbols SelectGroup and SelectLabel; remove these two from the named imports
(the import that currently lists SelectGroup, SelectItem, SelectLabel) so only
used symbols like SelectItem remain, then run a quick type-check/ESLint to
ensure no other unused imports remain.

In `@app/src/components/Generation/GenerationForm.tsx`:
- Around line 18-20: The imports SelectGroup and SelectLabel in the
GenerationForm component are unused; remove them from the import list that
currently includes SelectGroup, SelectItem, SelectLabel so only the needed
symbols (e.g., SelectItem) remain, updating the import statement at the top of
GenerationForm.tsx (the component/function named GenerationForm) to eliminate
SelectGroup and SelectLabel.

In `@backend/routes/models.py`:
- Around line 495-509: Add a short docstring or inline comment at the start of
the custom model cache-delete branch (the code that checks
model_name.startswith("custom:"), extracts custom_id and deletes the HF repo
cache_dir) stating that this endpoint only removes the HF cache directory and
does not unregister or unload the model (it intentionally does not call
remove_custom_model(custom_id) or perform any unload), and that callers must
also call the DELETE /custom-models/{model_id} endpoint to remove the registry
entry and fully delete the model; reference the variables model_name, custom_id
and the helper remove_custom_model in the comment to make the divergence
explicit for future maintainers.
- Around line 415-425: The appended ModelStatus entries for injected custom
models are missing the is_custom flag; update the models.ModelStatus
construction inside the statuses.append call that builds entries for
cm_name/display/hf_repo so it includes is_custom=True for these injected custom
entries (leave other ModelStatus constructions unchanged); this ensures the
useModelStatus hook (and consumers like GenerationForm) can detect custom models
via the is_custom field.

---

Outside diff comments:
In `@app/src/components/ServerSettings/ModelManagement.tsx`:
- Around line 1-1186: The file is syntactically broken due to mangled imports,
misplaced top-level declarations inside the ModelManagement function, missing
hook/mutation declarations, duplicate UI blocks, and several merged lines — fix
by: 1) move and reconstruct all import statements to the top (include Dialog,
DialogContent, DialogDescription, DialogFooter/DialogHeader/DialogTitle,
CustomModelItem, useModelStatus/usePlatform/useServerStore), removing the stray
concatenated import fragments; 2) ensure ModelManagement() opens and closes
correctly so helper functions (fetchHuggingFaceModelInfo, MODEL_DESCRIPTIONS,
formatDownloads/formatLicense/formatPipelineTag/formatBytes) are declared
outside the component or moved above the component and not nested inside it; 3)
declare or create the missing React Query mutations/variables
(addCustomModelMutation, removeCustomModelMutation, modelStatus/isLoading via
useModelStatus or a useQuery for ['modelStatus']), and wire up CustomModelItem
prop types; 4) remove the duplicated "Custom Models" UI block (keep only the
sections-based rendering) and fix merged statements/newlines (split combined
await/queryClient calls and object properties like onError descriptions) so JSX
tags (CardContent/Dialog/DialogContent/etc.) are balanced; 5) run tsc --noEmit
and eslint/formatter to catch any remaining parse/type errors before committing.

In `@app/src/lib/api/client.ts`:
- Around line 3-22: The import list contains a duplicate ActiveTasksResponse
symbol; remove the second occurrence and merge the new types (CustomModelCreate,
CustomModelResponse, CustomModelListResponse) into the existing sorted import
block so the list remains alphabetized and deduplicated (update the top-level
import that currently declares ActiveTasksResponse, ApplyEffectsRequest, ...,
GenerationResponse, etc., to include the three CustomModel* types in the correct
alphabetical position and omit the duplicate ActiveTasksResponse).

---

Nitpick comments:
In `@app/src/lib/api/client.ts`:
- Around line 459-463: The indentation inside the removeCustomModel method is
inconsistent: adjust the indentation so the return statement and the closing
brace align with the surrounding 4-space indentation used in this file; locate
the async removeCustomModel(modelId: string) method and change the line(s)
currently indented with 6 spaces to use 4 spaces so the return and the closing
brace line up with other methods.

In `@app/src/lib/hooks/useModelStatus.ts`:
- Around line 22-29: The customModels filter in useModelStatus currently only
checks m.is_custom which can be missing; update the useMemo for customModels in
useModelStatus to defensively include models whose model_name follows the custom
convention (e.g., m.model_name.startsWith('custom:')) in addition to m.is_custom
(i.e., filter by m.is_custom || m.model_name?.startsWith('custom:')), preserving
the null-safety fallback (?? []) so the hook remains resilient to backend
omissions and matches the convention used in ModelManagement.tsx.

In `@backend/backends/pytorch_backend.py`:
- Around line 54-63: Remove the inline attribution comment "# `@modified` AJ -
Kamyab (Ankit Jain) — Added custom model path resolution" to keep the source
clean and rely on VCS history; leave the surrounding logic intact (the
model_size.startswith("custom:") branch that computes custom_id, calls
get_hf_repo_id_for_custom_model(custom_id), and raises ValueError when not
found) so callers like _load_model_sync and _is_model_cached continue to see the
same failure behavior.

In `@backend/routes/models.py`:
- Around line 389-428: The custom-model block duplicates the repo cache
probing/size logic already used in the built-in model loop (the code around the
existing probe_model_cache_status logic at lines ~312-345); extract that logic
into a small helper (e.g. _probe_repo_cache(repo_id) -> (downloaded: bool,
size_mb: Optional[float])) and call it from both the custom entries loop and the
built-in loop so the code in the custom_models.list_custom_models() handling
only maps config fields and uses _probe_repo_cache(hf_repo) to set downloaded
and size_mb; preserve use of models.ModelStatus, hf_repo in
active_download_repos for downloading flag, and keep exception handling around
the per-entry probe.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 7947d167-e950-4785-8b1b-10f1289f738a

📥 Commits

Reviewing files that changed from the base of the PR and between a7e698a and ae135ae.

📒 Files selected for processing (17)
  • .github/workflows/release.yml
  • app/src/components/Generation/FloatingGenerateBox.tsx
  • app/src/components/Generation/GenerationForm.tsx
  • app/src/components/ServerSettings/ModelManagement.tsx
  • app/src/lib/api/client.ts
  • app/src/lib/api/types.ts
  • app/src/lib/hooks/useGenerationForm.ts
  • app/src/lib/hooks/useModelStatus.ts
  • backend/backends/mlx_backend.py
  • backend/backends/pytorch_backend.py
  • backend/build_binary.py
  • backend/config.py
  • backend/main.py
  • backend/models.py
  • backend/requirements.txt
  • backend/routes/models.py
  • backend/voicebox-server.spec
💤 Files with no reviewable changes (1)
  • .github/workflows/release.yml
✅ Files skipped from review due to trivial changes (3)
  • backend/requirements.txt
  • backend/voicebox-server.spec
  • backend/main.py
🚧 Files skipped from review as they are similar to previous changes (4)
  • backend/build_binary.py
  • backend/backends/mlx_backend.py
  • app/src/lib/hooks/useGenerationForm.ts
  • backend/models.py

Comment thread app/src/components/Generation/FloatingGenerateBox.tsx Outdated
Comment thread app/src/components/Generation/GenerationForm.tsx Outdated
Comment thread backend/routes/models.py
Comment thread backend/routes/models.py
Comment on lines +495 to +509
# Handle custom models
if model_name.startswith("custom:"):
custom_id = model_name[len("custom:"):]
cm = custom_models.get_custom_model(custom_id)
if not cm:
raise HTTPException(status_code=404, detail=f"Custom model '{custom_id}' not found")
hf_repo_id = cm.get("hf_repo_id", "")
try:
cache_dir = hf_constants.HF_HUB_CACHE
repo_cache_dir = Path(cache_dir) / ("models--" + hf_repo_id.replace("/", "--"))
if repo_cache_dir.exists():
shutil.rmtree(repo_cache_dir)
return {"message": f"Custom model {model_name} cache deleted successfully"}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to delete custom model cache: {str(e)}") from e
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Custom-model cache delete leaves registry intact and skips unload — intentional, but document the divergence.

This branch deletes the HF cache directory but does not call remove_custom_model(custom_id) or attempt to unload the model. The separate DELETE /custom-models/{model_id} endpoint handles unregistration. That's a reasonable split, but the frontend has to call both endpoints to fully remove a custom model; otherwise the entry reappears as "not downloaded" after cache clear. Consider a short docstring note so callers don't assume this endpoint removes the registration too.

🧰 Tools
🪛 Ruff (0.15.10)

[warning] 509-509: Use explicit conversion flag

Replace with conversion flag

(RUF010)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/routes/models.py` around lines 495 - 509, Add a short docstring or
inline comment at the start of the custom model cache-delete branch (the code
that checks model_name.startswith("custom:"), extracts custom_id and deletes the
HF repo cache_dir) stating that this endpoint only removes the HF cache
directory and does not unregister or unload the model (it intentionally does not
call remove_custom_model(custom_id) or perform any unload), and that callers
must also call the DELETE /custom-models/{model_id} endpoint to remove the
registry entry and fully delete the model; reference the variables model_name,
custom_id and the helper remove_custom_model in the comment to make the
divergence explicit for future maintainers.

- Add exception chaining (raise ... from e) on all unchained raises in delete_model
- Rename loop variable 'config' to 'model_cfg' in get_model_status to avoid shadowing
- Rebuild ModelManagement.tsx from upstream base with clean custom model additions
  (fixes broken imports, missing query/mutations, duplicate JSX, code outside function)
- Add Plus icon, DialogFooter, Input, Label imports for custom model dialog
- Add addCustomModelMutation and removeCustomModelMutation definitions
- Add Custom Models section to sections array with Add button
- Add Unregister button in detail modal for custom models
- Add Add Custom Model dialog (repo ID + display name)
- Remove unused SelectGroup/SelectLabel imports in FloatingGenerateBox and GenerationForm
- Remove duplicate ActiveTasksResponse import in client.ts
- Remove unused fcntl import in backend/custom_models.py (cross-platform safety)
- Set is_custom=True when injecting custom model status entries in routes/models.py
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
app/src/components/ServerSettings/ModelManagement.tsx (1)

898-970: ⚠️ Potential issue | 🟠 Major

Allow unregistering custom models before download.

The Unregister action is nested under freshSelectedModel.downloaded, so a newly added custom model that has not been downloaded can only be downloaded, not removed. Render the custom unregister action in the non-downloaded branch too, or lift it outside the downloaded/not-downloaded split.

🛠️ One way to keep unregister available
-                  ) : (
-                    <Button
-                      size="sm"
-                      onClick={() => handleDownload(freshSelectedModel.model_name)}
-                      className="flex-1"
-                    >
-                      <Download className="h-4 w-4 mr-2" />
-                      Download
-                    </Button>
+                  ) : (
+                    <div className="flex gap-2 flex-1">
+                      <Button
+                        size="sm"
+                        onClick={() => handleDownload(freshSelectedModel.model_name)}
+                        className="flex-1"
+                      >
+                        <Download className="h-4 w-4 mr-2" />
+                        Download
+                      </Button>
+                      {freshSelectedModel.model_name.startsWith('custom:') && (
+                        <Button
+                          size="sm"
+                          onClick={() => {
+                            if (removeCustomModelMutation.isPending) return;
+                            const customId = freshSelectedModel.model_name.slice('custom:'.length);
+                            removeCustomModelMutation.mutate(customId, {
+                              onSuccess: () => {
+                                setDetailOpen(false);
+                                setSelectedModel(null);
+                              },
+                            });
+                          }}
+                          variant="destructive"
+                          disabled={removeCustomModelMutation.isPending}
+                          className="flex-1"
+                          title="Unregister model from custom list"
+                        >
+                          <X className="h-4 w-4 mr-2" />
+                          Unregister
+                        </Button>
+                      )}
+                    </div>
                   )}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/components/ServerSettings/ModelManagement.tsx` around lines 898 -
970, The Unregister button for custom models is only rendered inside the
downloaded branch so newly added custom models that aren't downloaded can't be
unregistered; move or duplicate the custom unregister UI so it appears
regardless of freshSelectedModel.downloaded (i.e., lift the conditional
freshSelectedModel.model_name.startsWith('custom:') block out of the
downloaded/not-downloaded ternary or render an equivalent button in the
non-downloaded branch), keeping the same click handler that checks
removeCustomModelMutation.isPending, slices the custom id, calls
removeCustomModelMutation.mutate(customId, { onSuccess: () => {
setDetailOpen(false); setSelectedModel(null); } }), and preserves disabling
logic (use removeCustomModelMutation.isPending and/or freshSelectedModel.loaded
as appropriate).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@backend/custom_models.py`:
- Around line 40-63: The loader currently treats any IOError as a missing config
and returns {"models": []}, which can mask permission/transient errors and cause
overwrites; change the final except to only treat a missing file as empty:
import errno and either catch FileNotFoundError or use "except OSError as e:"
then if e.errno == errno.ENOENT return {"models": []} else re-raise the
exception so permission and other I/O failures surface; ensure this loader (the
function around the shown snippet used by add_custom_model) uses that behavior.
- Around line 98-166: Replace the loose regex validation in add_custom_model
with Hugging Face’s official validator: import validate_repo_id (from
huggingface_hub.utils or huggingface_hub) and call validate_repo_id(hf_repo_id,
repo_type="model") after trimming hf_repo_id; catch the ValueError it raises and
re-raise or convert to your ValueError with a clear message so invalid IDs are
rejected early. Update or remove the _HF_REPO_RE usage to avoid allowing repo
IDs that the Hub would reject (refer to add_custom_model and _HF_REPO_RE to
locate places to change).

In `@backend/routes/models.py`:
- Around line 389-425: The custom model status detection misses active custom
downloads and some file types; update the loop in
custom_models.list_custom_models handling (symbols:
custom_models.list_custom_models, active_download_repos, models.ModelStatus,
hf_constants.HF_HUB_CACHE, repo_cache) to mirror built-in logic by: (1) marking
downloading=true if either hf_repo OR the custom model name (cm_name like
"custom:<id>") is present in active_download_repos; (2) expand on-disk detection
to include .npz, .pt, .pth (in addition to .safetensors and .bin) and treat
partial/incomplete download artifacts (e.g., *.part, *.partial, temp/lock files,
or known incomplete blob markers) as "downloading" rather than fully downloaded;
and (3) compute size_mb only when files are fully present. Ensure these checks
replace the existing repo_cache/snapshots checks and propagate the corrected
downloaded/downloading flags into the models.ModelStatus(...) call.
- Around line 415-425: The custom-model entries are being forced to
loaded=False; instead detect loaded state by resolving the backend and comparing
its _current_model_size to the custom model name (cm_name). Locate the code
creating models.ModelStatus (where cm_name, hf_repo, cm_size_mb are available)
and replace the naive loaded flag with a check that: resolve the backend
instance for this model (same logic used for load_model_async), then set loaded
= (backend is not None and getattr(backend, "_current_model_size", None) ==
cm_name). Keep other fields (downloaded, downloading, is_custom) unchanged and
do not call get_model_config or check_model_loaded for custom models.

---

Outside diff comments:
In `@app/src/components/ServerSettings/ModelManagement.tsx`:
- Around line 898-970: The Unregister button for custom models is only rendered
inside the downloaded branch so newly added custom models that aren't downloaded
can't be unregistered; move or duplicate the custom unregister UI so it appears
regardless of freshSelectedModel.downloaded (i.e., lift the conditional
freshSelectedModel.model_name.startsWith('custom:') block out of the
downloaded/not-downloaded ternary or render an equivalent button in the
non-downloaded branch), keeping the same click handler that checks
removeCustomModelMutation.isPending, slices the custom id, calls
removeCustomModelMutation.mutate(customId, { onSuccess: () => {
setDetailOpen(false); setSelectedModel(null); } }), and preserves disabling
logic (use removeCustomModelMutation.isPending and/or freshSelectedModel.loaded
as appropriate).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 6de2f34a-90f6-49f4-ae54-dfb90794a3e0

📥 Commits

Reviewing files that changed from the base of the PR and between ae135ae and c49aec4.

📒 Files selected for processing (7)
  • app/src/components/Generation/FloatingGenerateBox.tsx
  • app/src/components/ServerSettings/ModelManagement.tsx
  • app/src/lib/api/client.ts
  • backend/backends/mlx_backend.py
  • backend/backends/pytorch_backend.py
  • backend/custom_models.py
  • backend/routes/models.py
✅ Files skipped from review due to trivial changes (1)
  • app/src/components/Generation/FloatingGenerateBox.tsx

Comment thread backend/custom_models.py
Comment on lines +40 to +63
path = _get_config_path()
if not path.exists():
return {"models": []}
try:
with open(path, "r") as f:
return json.load(f)
except json.JSONDecodeError as exc:
# Back up the corrupt file so we don't lose data
backup = path.with_suffix(
f".json.corrupt.{datetime.utcnow().strftime('%Y%m%dT%H%M%S')}"
)
try:
path.rename(backup)
logger.error(
"Corrupt custom_models.json backed up to %s: %s", backup, exc
)
except OSError as rename_err:
logger.error(
"Failed to back up corrupt config %s: %s (original error: %s)",
path, rename_err, exc,
)
raise
except IOError:
return {"models": []}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don’t treat every read failure as a missing config.

Line 62 also catches permission errors and transient I/O failures after the file exists. In add_custom_model, that can turn a failed read into {"models": []} and then overwrite the existing registry on save. Only missing files should return an empty config; other OSErrors should surface.

🛠️ Proposed safer load path
-    if not path.exists():
-        return {"models": []}
     try:
-        with open(path, "r") as f:
+        with open(path) as f:
             return json.load(f)
+    except FileNotFoundError:
+        return {"models": []}
@@
-    except IOError:
-        return {"models": []}
+    except OSError:
+        logger.exception("Failed to read custom models config: %s", path)
+        raise
🧰 Tools
🪛 Ruff (0.15.10)

[warning] 44-44: Unnecessary mode argument

Remove mode argument

(UP015)


[warning] 62-62: Replace aliased errors with OSError

Replace IOError with builtin OSError

(UP024)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/custom_models.py` around lines 40 - 63, The loader currently treats
any IOError as a missing config and returns {"models": []}, which can mask
permission/transient errors and cause overwrites; change the final except to
only treat a missing file as empty: import errno and either catch
FileNotFoundError or use "except OSError as e:" then if e.errno == errno.ENOENT
return {"models": []} else re-raise the exception so permission and other I/O
failures surface; ensure this loader (the function around the shown snippet used
by add_custom_model) uses that behavior.

Comment thread backend/custom_models.py
Comment on lines +98 to +166
# Regex for valid HuggingFace repo IDs: owner/repo where each segment is
# non-empty and contains only alphanumeric characters, dots, underscores,
# and hyphens.
_HF_REPO_RE = re.compile(r"^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$")


def _generate_id(hf_repo_id: str) -> str:
"""Generate a slug ID from a HuggingFace repo ID.

Example: 'AryanNsc/IND-QWENTTS-V1' -> 'aryansc-ind-qwentts-v1'
"""
slug = hf_repo_id.lower().replace("/", "-")
slug = re.sub(r"[^a-z0-9-]", "-", slug)
slug = re.sub(r"-+", "-", slug).strip("-")
return slug


def list_custom_models() -> List[dict]:
"""List all custom models.

Returns:
List of custom model dicts
"""
with _config_lock:
data = _load_config()
return data.get("models", [])


def get_custom_model(model_id: str) -> Optional[dict]:
"""Get a single custom model by ID.

Args:
model_id: Custom model ID (slug)

Returns:
Model dict or None if not found
"""
models = list_custom_models()
for model in models:
if model["id"] == model_id:
return model
return None


def add_custom_model(hf_repo_id: str, display_name: str) -> dict:
"""Add a new custom model.

Args:
hf_repo_id: HuggingFace repo ID (e.g. 'AryanNsc/IND-QWENTTS-V1')
display_name: User-friendly display name

Returns:
Created model dict

Raises:
ValueError: If model already exists or inputs are invalid
"""
hf_repo_id = hf_repo_id.strip()
display_name = display_name.strip()

if not hf_repo_id:
raise ValueError("HuggingFace repo ID is required")
if not display_name:
raise ValueError("Display name is required")
if not _HF_REPO_RE.match(hf_repo_id):
raise ValueError(
"HuggingFace repo ID must be in format 'owner/model-name' "
"(alphanumeric, dots, underscores, and hyphens only, no leading/trailing slashes)"
)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Use HuggingFace’s repo validator instead of the loose regex.

Line 101 accepts values like owner/foo..bar, owner/-repo, or owner/repo--x; HuggingFace Hub rejects those, so registration can succeed but downloads fail later. The HuggingFace docs for validate_repo_id list the stricter constraints, including --/.. rejection and length/name rules: https://huggingface.co/docs/huggingface_hub/package_reference/utilities

🛠️ Proposed validation alignment
+from huggingface_hub.utils import validate_repo_id
+
@@
-_HF_REPO_RE = re.compile(r"^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$")
+_HF_REPO_RE = re.compile(r"^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$")
@@
-    if not _HF_REPO_RE.match(hf_repo_id):
+    if hf_repo_id.count("/") != 1:
         raise ValueError(
             "HuggingFace repo ID must be in format 'owner/model-name' "
             "(alphanumeric, dots, underscores, and hyphens only, no leading/trailing slashes)"
         )
+    try:
+        validate_repo_id(hf_repo_id)
+    except ValueError as exc:
+        raise ValueError(f"Invalid HuggingFace repo ID: {hf_repo_id}") from exc
Hugging Face huggingface_hub validate_repo_id rules owner repo -- .. leading trailing hyphen dot max length
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/custom_models.py` around lines 98 - 166, Replace the loose regex
validation in add_custom_model with Hugging Face’s official validator: import
validate_repo_id (from huggingface_hub.utils or huggingface_hub) and call
validate_repo_id(hf_repo_id, repo_type="model") after trimming hf_repo_id; catch
the ValueError it raises and re-raise or convert to your ValueError with a clear
message so invalid IDs are rejected early. Update or remove the _HF_REPO_RE
usage to avoid allowing repo IDs that the Hub would reject (refer to
add_custom_model and _HF_REPO_RE to locate places to change).

Comment thread backend/routes/models.py
Comment on lines +389 to +425
# ── Inject custom model entries ──────────────────────────────────────
try:
from huggingface_hub import constants as hf_constants

custom_entries = custom_models.list_custom_models()
for entry in custom_entries:
cm_name = f"custom:{entry['id']}"
hf_repo = entry.get("hf_repo_id", "")
display = entry.get("display_name", cm_name)
downloaded = False
cm_size_mb = None
try:
cache_dir = hf_constants.HF_HUB_CACHE
repo_cache = Path(cache_dir) / ("models--" + hf_repo.replace("/", "--"))
if repo_cache.exists():
snapshots = repo_cache / "snapshots"
if snapshots.exists() and (
any(snapshots.rglob("*.safetensors"))
or any(snapshots.rglob("*.bin"))
):
downloaded = True
total = sum(f.stat().st_size for f in repo_cache.rglob("*") if f.is_file())
cm_size_mb = total / (1024 * 1024)
except Exception:
pass

statuses.append(
models.ModelStatus(
model_name=cm_name,
display_name=display,
hf_repo_id=hf_repo,
downloaded=downloaded,
downloading=hf_repo in active_download_repos,
size_mb=cm_size_mb,
loaded=False,
is_custom=True,
)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Mirror built-in download/cache detection for custom entries.

Custom downloads are tracked by model_name (custom:<slug>), but Line 421 checks only hf_repo in active_download_repos, which may miss active custom downloads. This block also ignores .npz/.pt/.pth and incomplete blobs, so MLX custom models or partial downloads can show the wrong status.

🛠️ Proposed status fixes
             try:
                 cache_dir = hf_constants.HF_HUB_CACHE
                 repo_cache = Path(cache_dir) / ("models--" + hf_repo.replace("/", "--"))
                 if repo_cache.exists():
+                    blobs = repo_cache / "blobs"
+                    has_incomplete = blobs.exists() and any(blobs.glob("*.incomplete"))
                     snapshots = repo_cache / "snapshots"
-                    if snapshots.exists() and (
-                        any(snapshots.rglob("*.safetensors"))
-                        or any(snapshots.rglob("*.bin"))
-                    ):
+                    if not has_incomplete and snapshots.exists() and any(
+                        snapshots.rglob(pattern)
+                        for pattern in ("*.safetensors", "*.bin", "*.pt", "*.pth", "*.npz")
+                    ):
                         downloaded = True
-                        total = sum(f.stat().st_size for f in repo_cache.rglob("*") if f.is_file())
+                        total = sum(
+                            f.stat().st_size
+                            for f in repo_cache.rglob("*")
+                            if f.is_file() and not f.name.endswith(".incomplete")
+                        )
                         cm_size_mb = total / (1024 * 1024)
             except Exception:
                 pass
 
+            is_downloading = cm_name in active_download_names or hf_repo in active_download_repos
+            if is_downloading:
+                downloaded = False
+                cm_size_mb = None
+
             statuses.append(
                 models.ModelStatus(
@@
-                    downloading=hf_repo in active_download_repos,
+                    downloading=is_downloading,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/routes/models.py` around lines 389 - 425, The custom model status
detection misses active custom downloads and some file types; update the loop in
custom_models.list_custom_models handling (symbols:
custom_models.list_custom_models, active_download_repos, models.ModelStatus,
hf_constants.HF_HUB_CACHE, repo_cache) to mirror built-in logic by: (1) marking
downloading=true if either hf_repo OR the custom model name (cm_name like
"custom:<id>") is present in active_download_repos; (2) expand on-disk detection
to include .npz, .pt, .pth (in addition to .safetensors and .bin) and treat
partial/incomplete download artifacts (e.g., *.part, *.partial, temp/lock files,
or known incomplete blob markers) as "downloading" rather than fully downloaded;
and (3) compute size_mb only when files are fully present. Ensure these checks
replace the existing repo_cache/snapshots checks and propagate the corrected
downloaded/downloading flags into the models.ModelStatus(...) call.

Comment thread backend/routes/models.py
Comment on lines +415 to +425
statuses.append(
models.ModelStatus(
model_name=cm_name,
display_name=display,
hf_repo_id=hf_repo,
downloaded=downloaded,
downloading=hf_repo in active_download_repos,
size_mb=cm_size_mb,
loaded=False,
is_custom=True,
)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Inspect backend model-config resolution for custom model IDs.
rg -nP -C4 '\bdef\s+get_model_config\b|custom:' backend

Repository: jamiepine/voicebox

Length of output: 6486


🏁 Script executed:

# Find get_all_model_configs definition
rg -n "def get_all_model_configs" backend

Repository: jamiepine/voicebox

Length of output: 144


🏁 Script executed:

# Find check_model_loaded function
rg -n "def check_model_loaded" backend

Repository: jamiepine/voicebox

Length of output: 147


🏁 Script executed:

# Examine custom_models.py to see how custom model configs are handled
head -50 backend/custom_models.py

Repository: jamiepine/voicebox

Length of output: 1435


🏁 Script executed:

# Check the current custom model injection code at lines 391-425 in models.py
sed -n '385,430p' backend/routes/models.py

Repository: jamiepine/voicebox

Length of output: 1886


🏁 Script executed:

# Get the full implementation of get_all_model_configs
sed -n '361,380p' backend/backends/__init__.py

Repository: jamiepine/voicebox

Length of output: 794


🏁 Script executed:

# Get the full implementation of check_model_loaded
sed -n '469,485p' backend/backends/__init__.py

Repository: jamiepine/voicebox

Length of output: 860


🏁 Script executed:

# Get the full implementation of get_model_config
sed -n '374,390p' backend/backends/__init__.py

Repository: jamiepine/voicebox

Length of output: 592


🏁 Script executed:

# Check how custom models are structured - do they have ModelConfig representation?
rg -n "class ModelConfig|def get_custom_model" backend

Repository: jamiepine/voicebox

Length of output: 196


🏁 Script executed:

# Search for how custom models are used/loaded in the request handling
rg -n "cm_name|custom_model|model_name.*custom:" backend/routes/models.py -A 3 -B 1 | head -80

Repository: jamiepine/voicebox

Length of output: 2280


🏁 Script executed:

# Check if custom models go through the same TTS backend engine loading
rg -n "get_tts_model\|is_loaded\|engine.*custom" backend -i

Repository: jamiepine/voicebox

Length of output: 44


🏁 Script executed:

# Look at the delete/unregister flow to understand the real impact
rg -n "def.*delete.*model|def.*unregister" backend/routes/models.py -A 10

Repository: jamiepine/voicebox

Length of output: 624


🏁 Script executed:

# Check how custom models are used in TTS inference - are they loaded like built-in models?
rg -n "model_name.*custom|custom.*model_name|custom:" backend/services -A 3 -B 1

Repository: jamiepine/voicebox

Length of output: 44


🏁 Script executed:

# Check the TTS model loading/inference logic to see if it handles custom model names
rg -n "def.*inference|def.*synthesize|_current_model_size|model_size" backend/services/tts.py -A 2 | head -100

Repository: jamiepine/voicebox

Length of output: 44


🏁 Script executed:

# Look at the frontend side to see what `loaded` status actually controls
fd -e tsx -e ts frontend | xargs rg -l "loaded|delete.*model" | head -3

Repository: jamiepine/voicebox

Length of output: 143


🏁 Script executed:

# Check the delete/unregister endpoint's actual guards
sed -n '491,540p' backend/routes/models.py

Repository: jamiepine/voicebox

Length of output: 2141


🏁 Script executed:

# Search for how TTS model loads and uses model names/sizes
rg -n "model_size|_current_model_size|engine" backend/services/tts.py | head -40

Repository: jamiepine/voicebox

Length of output: 44


🏁 Script executed:

# Check if there's any custom model tracking in the service layer
rg -n "custom:|cm_name" backend/services

Repository: jamiepine/voicebox

Length of output: 44


🏁 Script executed:

# Check if custom models have an engine or model size in their stored config
rg -n "add_custom_model|custom_model.*schema|CustomModel" backend/custom_models.py -A 5 | head -60

Repository: jamiepine/voicebox

Length of output: 319


🏁 Script executed:

# Look for where custom models are actually used in inference
rg -n "custom:" backend/main.py backend/services -B 2 -A 2

Repository: jamiepine/voicebox

Length of output: 44


🏁 Script executed:

# Check if the TTS model can accept custom model names
rg -n "def.*synthesize|def.*__init__|model_size.*custom|custom.*engine" backend/services/tts.py -B 2 -A 5

Repository: jamiepine/voicebox

Length of output: 44


🏁 Script executed:

# Check main.py to see how custom models are handled in the TTS request
rg -n "model_size|custom" backend/main.py -B 3 -A 3

Repository: jamiepine/voicebox

Length of output: 44


🏁 Script executed:

# Look at TTS service to understand what "loaded" means
head -100 backend/services/tts.py

Repository: jamiepine/voicebox

Length of output: 828


🏁 Script executed:

# Check if custom models have any special handling in backend initialization
rg -n "custom|ModelConfig" backend/backends/__init__.py | head -40

Repository: jamiepine/voicebox

Length of output: 1865


🏁 Script executed:

# Check the frontend to see how it uses the 'loaded' status for custom models
find frontend -name "*.tsx" -o -name "*.ts" | xargs grep -l "loaded\|delete\|custom" 2>/dev/null | head -5

Repository: jamiepine/voicebox

Length of output: 105


🏁 Script executed:

# Look at how custom models are actually used when making TTS requests
rg -n "model_size\|SynthesisRequest\|synthesize" backend/routes -B 2 -A 5 | head -80

Repository: jamiepine/voicebox

Length of output: 44


🏁 Script executed:

# Check if custom models actually support different "sizes" or engines
sed -n '142,180p' backend/custom_models.py

Repository: jamiepine/voicebox

Length of output: 1385


🏁 Script executed:

# Understand the relationship: when a custom model is used, what engine handles it?
rg -n "hf_repo_id\|model_size\|engine" backend/routes/models.py -B 2 -A 2 | grep -A 10 "custom_entries"

Repository: jamiepine/voicebox

Length of output: 44


🏁 Script executed:

# Check how synthesis requests work - do they specify custom model IDs or do they use engine+repo?
rg -n "SynthesisRequest\|model_size.*=\|class.*Request" backend/models.py -A 10 | head -60

Repository: jamiepine/voicebox

Length of output: 44


🏁 Script executed:

# Verify: are custom models used with a specific engine or as full model replacements?
sed -n '76,90p' backend/models.py

Repository: jamiepine/voicebox

Length of output: 1015


🏁 Script executed:

# Check if custom models can be loaded/tracked like built-in models
rg -n "is_loaded|_loaded|_current_model" backend/backends -A 2 | head -50

Repository: jamiepine/voicebox

Length of output: 3198


🏁 Script executed:

# Verify the actual inference path: how does the backend use a custom model spec?
rg -n "synthesize|load.*model|custom:" backend/backends/pytorch_backend.py -B 2 -A 5 | head -80

Repository: jamiepine/voicebox

Length of output: 3103


🏁 Script executed:

# Check if there's tracking of which custom model is currently loaded
rg -n "_current.*model\|loaded.*custom\|custom.*loaded" backend -r

Repository: jamiepine/voicebox

Length of output: 475


🏁 Script executed:

# Verify: can custom models actually be loaded and tracked like built-in models?
sed -n '78,100p' backend/backends/pytorch_backend.py

Repository: jamiepine/voicebox

Length of output: 882


🏁 Script executed:

# Check the full flow of model loading - does it handle custom model IDs correctly?
sed -n '102,140p' backend/backends/pytorch_backend.py

Repository: jamiepine/voicebox

Length of output: 1784


🏁 Script executed:

# Understand what "loaded=False" prevents in the frontend - is it just UI or backend validation?
rg -n "loaded.*False\|disable.*delete\|unregister" backend/routes -B 2 -A 3

Repository: jamiepine/voicebox

Length of output: 44


🏁 Script executed:

# Verify: after loading a custom model, what is _current_model_size set to?
# Also check if custom models use a different backend or the same pytorch/qwen backend
rg -n "qwen_custom_voice\|pytorch.*custom\|custom.*engine" backend/backends -B 2 -A 5 | head -100

Repository: jamiepine/voicebox

Length of output: 44


🏁 Script executed:

# Check the synthesis endpoint to see what happens when custom model is requested
rg -n "def.*synthesize\|model_size.*custom" backend/routes -B 3 -A 8

Repository: jamiepine/voicebox

Length of output: 44


🏁 Script executed:

# Search for any synthesis endpoint or handler that uses model_size
rg -n "def.*synth\|async def\|model_size" backend/routes/synthesis.py -B 2 -A 8 2>/dev/null || rg -n "def.*synth\|async def" backend/routes -B 2 -A 8

Repository: jamiepine/voicebox

Length of output: 44


🏁 Script executed:

# Check if there's any test or example showing custom model usage
fd -e py backend | xargs grep -l "custom:" | head -3

Repository: jamiepine/voicebox

Length of output: 129


🏁 Script executed:

# Look for where load_model is actually called with custom model IDs
rg -n "load_model\|synthesize" backend/routes -B 3 -A 10 | head -80

Repository: jamiepine/voicebox

Length of output: 44


🏁 Script executed:

# Check what the backend's _current_model_size tracking looks like
sed -n '34,42p' backend/backends/pytorch_backend.py

Repository: jamiepine/voicebox

Length of output: 362


🏁 Script executed:

# Understand: when a custom model is loaded, is _current_model_size set to "custom:xyz"?
sed -n '56,62p' backend/backends/pytorch_backend.py

Repository: jamiepine/voicebox

Length of output: 433


🏁 Script executed:

# Look at how the Qwen backend handles model_size in load path
sed -n '45,80p' backend/backends/pytorch_backend.py

Repository: jamiepine/voicebox

Length of output: 1359


🏁 Script executed:

# Key question: when a custom model is loaded, what does _current_model_size actually contain?
# Check the complete _load_model_sync to see what's stored
sed -n '102,146p' backend/backends/pytorch_backend.py

Repository: jamiepine/voicebox

Length of output: 1968


🏁 Script executed:

# Now check: does check_model_loaded compare against _current_model_size?
sed -n '469,510p' backend/backends/__init__.py

Repository: jamiepine/voicebox

Length of output: 1826


🏁 Script executed:

# Critical verification: when a custom model is loaded, what gets stored in _current_model_size?
# If custom model "custom:my-repo" is loaded, is _current_model_size set to "custom:my-repo"?
sed -n '136,138p' backend/backends/pytorch_backend.py

Repository: jamiepine/voicebox

Length of output: 212


🏁 Script executed:

# Confirmation: are custom models able to actually be loaded via the standard load_model path?
sed -n '78,98p' backend/backends/pytorch_backend.py

Repository: jamiepine/voicebox

Length of output: 818


🏁 Script executed:

# Final check: is there a way to get the currently loaded HF repo ID for comparison?
rg -n "hf_repo_id\|get_hf_repo_id_for_custom_model" backend/backends

Repository: jamiepine/voicebox

Length of output: 44


Custom models need actual loaded state checking, but the suggested approach won't work.

The concern is valid: custom models are hard-coded with loaded=False even though they can be loaded and tracked like built-in models. However, the suggested fix is incorrect.

get_model_config("custom:<slug>") will always return None because get_all_model_configs() only includes built-in models, and check_model_loaded() requires a ModelConfig object. Custom models are stored separately and don't have ModelConfig representations.

Instead, check if the custom model is currently loaded by comparing against the backend's _current_model_size. When a custom model is loaded (e.g., load_model_async("custom:xyz")), _current_model_size is set to that exact string. Resolve the custom entry, get the backend instance, and check if backend._current_model_size == cm_name:

+            custom_loaded = False
+            try:
+                backend = get_tts_backend()
+                custom_loaded = backend._current_model_size == cm_name if backend.is_loaded() else False
+            except Exception:
+                custom_loaded = False
+
             statuses.append(
                 models.ModelStatus(
                     model_name=cm_name,
                     display_name=display,
                     hf_repo_id=hf_repo,
                     downloaded=downloaded,
                     downloading=hf_repo in active_download_repos,
                     size_mb=cm_size_mb,
-                    loaded=False,
+                    loaded=custom_loaded,
                     is_custom=True,
                 )
             )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/routes/models.py` around lines 415 - 425, The custom-model entries
are being forced to loaded=False; instead detect loaded state by resolving the
backend and comparing its _current_model_size to the custom model name
(cm_name). Locate the code creating models.ModelStatus (where cm_name, hf_repo,
cm_size_mb are available) and replace the naive loaded flag with a check that:
resolve the backend instance for this model (same logic used for
load_model_async), then set loaded = (backend is not None and getattr(backend,
"_current_model_size", None) == cm_name). Keep other fields (downloaded,
downloading, is_custom) unchanged and do not call get_model_config or
check_model_loaded for custom models.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant