Skip to content

feat(settings): configurable models directory (#64) - #149

Merged
debpalash merged 3 commits into
mainfrom
feat/configurable-models-dir
May 29, 2026
Merged

feat(settings): configurable models directory (#64)#149
debpalash merged 3 commits into
mainfrom
feat/configurable-models-dir

Conversation

@debpalash

@debpalash debpalash commented May 29, 2026

Copy link
Copy Markdown
Owner

Closes #64.

What

Lets users choose where model weights download (the HuggingFace / Torch cache) from Settings → Models, instead of being pinned to ~/.cache/huggingface. Useful when the system drive is small/slow and the user wants weights on a bigger or faster volume.

How

Backend

  • core/user_env.py — durable per-user env file (~/.config/omnivoice/env) helper with get/set/unset. Upsert preserves other keys (e.g. a persisted HF_TOKEN) and writes the file 0600. main.py already loads this at startup before importing torch/HF, so a written value takes effect on the next launch. Path resolves at call time (honoring an OMNIVOICE_ENV_FILE override) so it's robust to module re-import.
  • api/routers/settings.pyGET/PUT /api/settings/storage/models-dir:
    • PUT validates the directory is writable (mkdir + write-probe → 400 if not), persists the choice in the settings store, and writes OMNIVOICE_CACHE_DIR to the durable env. Empty path clears → reverts to default.
    • Returns restart_required: true (an in-use cache can't be safely relocated mid-process). Loopback-gated like the other settings endpoints.

Frontend

  • StoragePanel in the Models tab — view / set / reset the directory; shows effective-vs-configured-vs-default and a restart note.

Constraints

  • Cross-platform default parity: default cache path is the HF default on every OS; the feature is the same on mac/Win/Linux. ✅
  • Local-first: no network, no telemetry. ✅
  • Backward-compatible: absent setting → existing behavior, no migration. ✅
  • No version bump.

Tests

  • tests/test_user_env.py (5) — upsert/unset/preserve/0600.
  • tests/test_models_dir_setting.py (4) — persist+durable-env write, unwritable→400, clear→default, GET shape.

All 9 green. (The unrelated test_supertonic3::test_license_gate fails locally only because supertonic isn't in my local venv; it's a runtime dep present in CI.)

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Storage settings panel and API to view/save the models download directory, showing configured, effective, and default paths; saving/clearing updates persisted per-user env and may require restart.
    • Input validation for saved paths, ensures directories are created and writable, and rejects invalid characters.
  • Tests

    • Added tests covering storage settings behavior, input validation, default resolution, and per-user env persistence.

Review Change Stack

Let users pick where model weights download (the HuggingFace / Torch
cache) instead of being pinned to ~/.cache/huggingface — useful when the
system drive is small or slow.

Backend:
- core/user_env.py: durable per-user env file (~/.config/omnivoice/env)
  helper with upsert/unset that preserves other keys and writes 0600.
  main.py already loads this at startup before importing torch/HF, so the
  value takes effect on the next launch. Path resolves at call time via an
  OMNIVOICE_ENV_FILE override so it's robust to module re-import in tests.
- settings.py: GET/PUT /api/settings/storage/models-dir — validates the
  dir is writable (mkdir + write-probe → 400 if not), persists the choice,
  and writes OMNIVOICE_CACHE_DIR to the durable env. Empty path clears →
  reverts to default. Returns restart_required since an in-use cache can't
  be safely moved mid-process. Loopback-gated like the other settings.

Frontend:
- StoragePanel: Models tab panel to view/set/reset the directory, shows
  effective vs configured vs default + a restart note.

Cross-platform default parity preserved (default cache path is the HF
default on every OS); local-first (no network); backward-compatible
(absent setting → existing behavior). No version bump.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented May 29, 2026

Copy link
Copy Markdown
Contributor

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 5903e939-63f0-400f-a7d7-ce3ac000dba7

📥 Commits

Reviewing files that changed from the base of the PR and between 10a85e4 and 7ef9b11.

📒 Files selected for processing (5)
  • backend/api/routers/settings.py
  • backend/core/user_env.py
  • frontend/src/components/settings/StoragePanel.jsx
  • tests/test_models_dir_setting.py
  • tests/test_user_env.py
🚧 Files skipped from review as they are similar to previous changes (4)
  • tests/test_user_env.py
  • frontend/src/components/settings/StoragePanel.jsx
  • backend/api/routers/settings.py
  • backend/core/user_env.py

📝 Walkthrough

Walkthrough

Adds a durable per-user env helper, backend GET/PUT settings endpoints to configure the HuggingFace/Torch models directory (validation, creation, writability probe, durable OMNIVOICE_CACHE_DIR), a React StoragePanel UI, and tests covering behavior and edge cases.

Changes

Configurable Models Directory Storage

Layer / File(s) Summary
Per-user environment file persistence
backend/core/user_env.py, tests/test_user_env.py
Dotenv-style per-user env file at USER_ENV_PATH with get_user_env, set_user_env (upsert), and unset_user_env. Ensures parent dir, newline normalization, and best-effort 0600 permissions. Tests validate upsert, preservation, unset, missing-key, bare-filename behavior, and POSIX perms.
Models directory settings endpoints
backend/api/routers/settings.py, tests/test_models_dir_setting.py
Adds constants and helpers for default/effective cache dirs; GET /api/settings/storage/models-dir returns configured/effective/default; PUT /api/settings/storage/models-dir rejects control characters, expands/creates/probes directory, persists/clears durable OMNIVOICE_CACHE_DIR, and returns restart_required: true for changes. Tests cover persistence, creation failures, unwritable path, embedded NUL rejection, clearing, response shape, and XDG-aware default.
Frontend storage configuration panel
frontend/src/components/settings/StoragePanel.jsx, frontend/src/components/settings/StoragePanel.css
Adds StoragePanel React component that loads current storage config, provides input and Save/Reset controls, handles errors and success toasts, refreshes state after save, and shows restart notice. CSS defines panel layout, inputs, buttons, and meta/error styling.
Settings page models tab integration
frontend/src/pages/Settings.jsx
Imports and renders StoragePanel alongside ModelStoreTab in the models tab.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 22.22% 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
Title check ✅ Passed The title 'feat(settings): configurable models directory' directly summarizes the main change—adding a user-configurable models directory setting.
Description check ✅ Passed The PR description covers the What/How structure, explains the backend and frontend implementation details, notes constraints, and includes test results.
Linked Issues check ✅ Passed The PR fully addresses issue #64 by enabling users to configure where models/weights are downloaded, supporting alternative volumes, and surfacing the setting in the UI.
Out of Scope Changes check ✅ Passed All changes are directly scoped to the configurable models directory feature: backend env/settings endpoints, frontend UI panel, and comprehensive tests.

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

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/configurable-models-dir

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint skipped: no ESLint configuration detected in root package.json. To enable, add eslint to devDependencies.


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.

Comment thread backend/api/routers/settings.py Dismissed
Comment thread backend/api/routers/settings.py Fixed
Comment thread backend/api/routers/settings.py Fixed
Comment thread tests/test_user_env.py Fixed
Comment thread tests/test_user_env.py Fixed
Comment thread backend/core/user_env.py Fixed
@greptile-apps

greptile-apps Bot commented May 29, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

Adds a configurable models-download directory (HuggingFace / Torch cache) accessible from Settings → Models. Users can point weights at a different drive; the choice is persisted to ~/.config/omnivoice/env as OMNIVOICE_CACHE_DIR and applied by main.py on the next restart.

  • Backend: new core/user_env.py helper (upsert-preserving, 0600-safe) and GET/PUT /api/settings/storage/models-dir endpoints with write-probe validation and control-character rejection.
  • Frontend: StoragePanel component in the Models tab showing configured/effective/default paths with a restart prompt on change.
  • Tests: 9 new tests covering persistence, unwritable paths, NUL bytes, clear-to-default, and file permissions.

Confidence Score: 4/5

Safe to merge with one fix: the effective/default path display is wrong for users who have HF_HOME in their environment or rely on the platform default — models land in the hub subdirectory but the UI points one level higher.

The path-reporting functions report one directory level above where HuggingFace actually stores model files when HF_HOME is set or in the default no-config case. The configured path (when set via this feature) is correct because main.py sets HF_HUB_CACHE directly. All other mechanics — persistence, validation, permission handling, frontend — look correct.

backend/api/routers/settings.py — the _effective_models_dir() and _default_models_dir() functions need the hub subdirectory appended for the HF_HOME and default-path cases.

Important Files Changed

Filename Overview
backend/api/routers/settings.py Adds GET/PUT /api/settings/storage/models-dir endpoints; _effective_models_dir() and _default_models_dir() both report paths one directory level above where HuggingFace actually stores model files in the HF_HOME/default cases.
backend/core/user_env.py New durable per-user env file helper; addresses the previous permission race with _opener_0600, but _write_lines still does a non-atomic truncate-then-write that could corrupt the file (losing HF_TOKEN) on a crash mid-write.
frontend/src/components/settings/StoragePanel.jsx New React component for the Models directory panel; correctly handles loading, saving, error display, and restart prompt with clean separation of concerns.
frontend/src/components/settings/StoragePanel.css New CSS for StoragePanel; uses CSS variables for theming and is self-contained.
frontend/src/pages/Settings.jsx Wires StoragePanel into the existing models tab; minimal change wrapped in a fragment.
tests/test_models_dir_setting.py Good coverage of the PUT/GET endpoints including unwritable dir, NUL byte, clear-to-default, and XDG_CACHE_HOME awareness.
tests/test_user_env.py Covers upsert, key preservation, unset, bare-filename edge case, and 0600 permission; comprehensive for the helper.

Sequence Diagram

sequenceDiagram
    participant UI as StoragePanel (React)
    participant API as PUT /api/settings/storage/models-dir
    participant FS as Filesystem
    participant Env as user_env.py (~/.config/omnivoice/env)
    participant Main as main.py (next launch)

    UI->>API: "PUT {path: "/data/models"}"
    API->>API: validate control chars
    API->>FS: makedirs + write-probe + cleanup
    FS-->>API: ok / OSError 400
    API->>Env: set_user_env(OMNIVOICE_CACHE_DIR, path)
    API-->>UI: "{configured, effective, restart_required: true}"
    UI->>UI: show restart notice

    Note over Main: On next backend start
    Main->>Env: dotenv.load_dotenv(~/.config/omnivoice/env)
    Main->>Main: "os.environ[HF_HOME] = OMNIVOICE_CACHE_DIR"
    Main->>Main: "os.environ[HF_HUB_CACHE] = OMNIVOICE_CACHE_DIR"
    Main->>Main: "os.environ[TORCH_HOME] = OMNIVOICE_CACHE_DIR"
Loading

Fix All in Claude Code

Reviews (3): Last reviewed commit: "refactor(#64): single source of truth fo..." | Re-trigger Greptile

Comment thread backend/api/routers/settings.py
Comment thread backend/core/user_env.py Outdated
Comment thread backend/api/routers/settings.py Outdated

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 6

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@backend/api/routers/settings.py`:
- Around line 252-275: The code currently swallows exceptions from
settings_store.set_text(...) (and continues after
user_env.set_user_env()/unset_user_env), which can mislead the UI into believing
the change persisted; update the handlers around settings_store.set_text and
user_env.set_user_env/unset_user_env (references: _MODELS_DIR_KEY,
settings_store.set_text, user_env.set_user_env, user_env.unset_user_env,
_MODELS_DIR_ENV) so that if either persisting to settings_store OR updating the
durable user_env fails you raise a 500 (e.g., throw
HTTPException(status_code=500)) instead of logging and returning success; ensure
both operations succeed before returning {"configured":..., "restart_required":
True} and on failure, include the original error context in the raised
exception/log.
- Around line 209-219: _default_models_dir currently hard-codes
"~/.cache/huggingface" which disagrees with huggingface_hub's XDG-aware default;
replace its logic to use huggingface_hub's canonical default (e.g., call
huggingface_hub.utils.default_cache_path or equivalent XDG-aware helper) so GET
/storage/models-dir reports the true default, update _effective_models_dir to
prefer that canonical default when HF env vars are unset, and in set_models_dir
ensure failures from settings_store.set_text (both clear and set branches) are
not swallowed — surface the error and return a failure response; only update
OMNIVOICE_CACHE_DIR/env vars after successfully persisting via
settings_store.set_text so persisted `configured` cannot diverge from
`effective`.

In `@backend/core/user_env.py`:
- Around line 20-25: The helper _read_lines currently swallows all OSError
subclasses which can hide genuine read failures and cause
set_user_env/unset_user_env to rebuild from an empty baseline; change the
exception handling so only FileNotFoundError is treated as "file missing" and
returns [], while any other OSError is re-raised (i.e., replace the broad except
OSError with an except FileNotFoundError: return [] and let other errors
propagate) so callers like set_user_env and unset_user_env will abort on real
read errors.
- Around line 28-29: In _write_lines, guard the os.makedirs call so it does not
run when os.path.dirname(path) is empty (e.g., path="env"); compute parent =
os.path.dirname(path) and only call os.makedirs(parent, exist_ok=True) if parent
is truthy, then proceed to open and write the file as before; this prevents
os.makedirs("") from raising while keeping behavior for nested paths.

In `@frontend/src/components/settings/StoragePanel.jsx`:
- Around line 81-97: The models-directory text input (className
"storagepanel__input", data-testid "models-dir-input", controlled by state
setInput/input) lacks an accessible label; add programmatic labeling by either
adding a visible <label> tied to that input via htmlFor/id or by referencing
existing heading/help text with aria-labelledby or aria-describedby attributes
on the input (ensure you add a matching id to the heading/paragraph). Update the
input element to include the chosen aria attribute or id reference so screen
readers receive a descriptive label while preserving the existing disabled,
value, placeholder and event handlers.

In `@tests/test_models_dir_setting.py`:
- Around line 39-42: The test test_rejects_unwritable_dir currently uses a
Unix-specific path ("/dev/null/...") which fails on Windows; change the test to
simulate an unwritable directory instead of relying on OS paths by mocking the
filesystem operations used by s.set_models_dir (e.g., mock os.makedirs and
builtins.open or the specific helper that creates/validates the directory) to
raise OSError, then call
s.set_models_dir(s._ModelsDirBody(path="some/any/path")) and assert the raised
fastapi.HTTPException has status_code 400; this keeps the test OS-neutral while
still exercising the rejection logic in s.set_models_dir.
🪄 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: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: aa84980c-07b9-4b49-a396-1ff17d6f1687

📥 Commits

Reviewing files that changed from the base of the PR and between 15958d3 and 61c57b7.

📒 Files selected for processing (7)
  • backend/api/routers/settings.py
  • backend/core/user_env.py
  • frontend/src/components/settings/StoragePanel.css
  • frontend/src/components/settings/StoragePanel.jsx
  • frontend/src/pages/Settings.jsx
  • tests/test_models_dir_setting.py
  • tests/test_user_env.py

Comment thread backend/api/routers/settings.py
Comment thread backend/api/routers/settings.py
Comment thread backend/core/user_env.py
Comment thread backend/core/user_env.py Outdated
Comment thread frontend/src/components/settings/StoragePanel.jsx Outdated
Comment thread tests/test_models_dir_setting.py Outdated
- settings.py: reject control/NUL chars in the path with a 400 before any
  filesystem call (an embedded NUL otherwise raised ValueError → 500). Also
  serves as the explicit input-validation barrier for the user-chosen path
  (loopback-gated same-user local file picker — no cross-privilege boundary).
- test_user_env.py: use `with open(...)` so the file is closed and the assert
  has no side effects.
- user_env.py: comment the best-effort chmod except clause.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

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.

🧹 Nitpick comments (1)
tests/test_models_dir_setting.py (1)

45-50: 💤 Low value

Use a non-/tmp path to silence Ruff S108 and clarify intent.

The path here is rejected by the control-char check before any filesystem access, so the /tmp/ prefix is meaningless and only trips Ruff S108 (hardcoded temp path). A relative path makes the intent clearer and keeps the linter quiet.

🧹 Proposed tweak
-        s.set_models_dir(s._ModelsDirBody(path="/tmp/mo\x00dels"))
+        s.set_models_dir(s._ModelsDirBody(path="models\x00dir"))
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_models_dir_setting.py` around lines 45 - 50, Change the hardcoded
/tmp prefix in the failing test to a non-absolute/relative path so Ruff S108 is
not triggered: in test_rejects_path_with_null_byte update the ModelsDirBody path
argument (used in s.set_models_dir and the test function name) to a relative
path (e.g., "mo\x00dels" or "./mo\x00dels") instead of "/tmp/mo\x00dels" since
the control-char check rejects the path before any filesystem access and the
prefix is unnecessary.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@tests/test_models_dir_setting.py`:
- Around line 45-50: Change the hardcoded /tmp prefix in the failing test to a
non-absolute/relative path so Ruff S108 is not triggered: in
test_rejects_path_with_null_byte update the ModelsDirBody path argument (used in
s.set_models_dir and the test function name) to a relative path (e.g.,
"mo\x00dels" or "./mo\x00dels") instead of "/tmp/mo\x00dels" since the
control-char check rejects the path before any filesystem access and the prefix
is unnecessary.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 39d2ea95-6f10-4ed3-bab1-dad714bc4649

📥 Commits

Reviewing files that changed from the base of the PR and between 61c57b7 and 10a85e4.

📒 Files selected for processing (4)
  • backend/api/routers/settings.py
  • backend/core/user_env.py
  • tests/test_models_dir_setting.py
  • tests/test_user_env.py
🚧 Files skipped from review as they are similar to previous changes (2)
  • backend/core/user_env.py
  • tests/test_user_env.py

Address CodeRabbit + Greptile review on PR #149:

- P1 (both bots): the settings_store copy of the models dir was only ever
  read by this GET endpoint, so it was a redundant cache that could diverge
  from the durable env file (the value main.py actually reads). Drop it —
  the per-user env file (OMNIVOICE_CACHE_DIR) is now the single source of
  truth: PUT writes it, GET reads it back. No divergence possible.
- XDG-aware default (CodeRabbit): _default_models_dir now honors
  XDG_CACHE_HOME, matching huggingface_hub's real default on Linux.
- Atomic 0600 write (Greptile, security): user_env writes via an os.open
  opener that creates the file 0600 from the start — no world-readable
  window before chmod for a file that can hold HF_TOKEN.
- _read_lines only swallows FileNotFoundError; other OSErrors propagate so
  an upsert can't silently drop existing keys on a transient read failure.
- Guard makedirs("") when the env path is a bare filename (no parent).
- Best-effort write-probe cleanup in a finally; raise ... from e.
- a11y: label the models-dir input via aria-labelledby/aria-describedby.
- OS-neutral unwritable-dir test (mock makedirs) instead of Unix-only
  /dev/null path semantics.

12 tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@debpalash

Copy link
Copy Markdown
Owner Author

Addressed the review in 7ef9b11:

Store/env divergence (CodeRabbit :283/:219, Greptile :259, both P1/Major) — the settings_store copy of the models dir was only ever read back by this GET endpoint, so it was a redundant cache that could diverge from the durable env file (the value main.py actually reads at startup). Removed it entirely. OMNIVOICE_CACHE_DIR in the per-user env file is now the single source of truth: PUT writes it, GET reads it back — divergence is structurally impossible, so there's nothing to half-persist.

XDG-aware default (CodeRabbit :219)_default_models_dir() now honors XDG_CACHE_HOME, matching huggingface_hub's real default on Linux so GET reports the true default.

Non-atomic secret exposure (Greptile P2, security)user_env now writes via an os.open opener that creates the file 0600 from the start; no world-readable window before chmod for a file that can hold HF_TOKEN.

_read_lines swallowing all OSError (CodeRabbit :25) — now only FileNotFoundError → empty; other OSErrors propagate so an upsert can't silently drop existing keys on a transient read failure.

makedirs("") (CodeRabbit :29), write-probe cleanup (Greptile P2), raise ... from (Ruff B904), a11y input label (CodeRabbit :97), OS-neutral unwritable-dir test (CodeRabbit :42) — all fixed.

CodeQL "user-provided value in path" (3× high, :271/:273/:275) — not fixing these as a vuln, dismissing as false-positive for the threat model. This is a loopback-gated, same-user desktop app where the user is intentionally choosing their own local cache directory; the backend runs as that same user, so there's no privilege boundary for traversal to cross — picking an arbitrary directory is the feature. Added a control-character/NUL guard up front as defense-in-depth (turns a would-be ValueError/500 into a clean 400). All 12 tests green.

try:
os.makedirs(path, exist_ok=True)
probe = os.path.join(path, ".omnivoice_write_test")
with open(probe, "w", encoding="utf-8") as f:
# Best-effort cleanup; a failed remove (concurrent process, perm change)
# must not leave the request hanging or mask the real error.
try:
os.remove(os.path.join(path, ".omnivoice_write_test"))
# must not leave the request hanging or mask the real error.
try:
os.remove(os.path.join(path, ".omnivoice_write_test"))
except OSError:
@debpalash
debpalash merged commit 1cfda2f into main May 29, 2026
14 of 15 checks passed
@debpalash
debpalash deleted the feat/configurable-models-dir branch May 29, 2026 11:22
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.

[Feature] (Windows 1x) Saving downloaded models and weights to desired directory

2 participants