fix(net): SOCKS-proxy users can synthesize again — ship socksio, cache-first model resolution (#959) (#966)

* fix(net): SOCKS-proxy users can synthesize again — ship socksio, cache-first model resolution, degrade LLM clients (#959)

Under ALL_PROXY/HTTPS_PROXY=socks5:// without socksio installed, httpx
raises ImportError AT CLIENT CONSTRUCTION ("Using SOCKS proxy, but the
'socksio' package is not installed"). huggingface_hub's get_session()
builds exactly that client inside snapshot_download, so POST /generate
500'd with the bare message even for a fully installed model, and
preload_model's model_info probe hit the same error and silently
skipped warm-up. Latent since v0.3.5 — #947's fresh-process engine
spawning unmasked it in v0.3.10 by handing the user's proxy env
directly to a clean backend process.

Three layers, so the class (any session-construction failure) is dead,
not just the reported instance:

* Ship SOCKS support: socksio>=1.0 in [project] dependencies (pure
  Python, MIT, zero transitive deps) AND in backend.spec hiddenimports
  — httpx imports it lazily in try/except, so PyInstaller's tracer
  misses it and the frozen installers would stay broken without the
  explicit entry. uv.lock regenerated; `uv lock --check` and
  `uv sync --frozen` (the Docker/release bootstrap semantics) verified.

* Cache-first model resolution: from_pretrained's snapshot resolution
  extracted into _resolve_snapshot_dir() — local dir, else
  snapshot_download(local_files_only=True) (a complete cache resolves
  with NO HTTP session constructed), else the original network path.
  preload_model's failed network probe now falls back to a cache-only
  check and warms up anyway instead of silently skipping (honest log
  either way).

* Class guards: resolve_skill_client wraps OpenAI() construction —
  env-shaped construction failures degrade to the existing "LLM
  unavailable" contract instead of 500ing the calling feature; and
  core.failure learns SOCKS_PROXY_SUPPORT_MISSING with an actionable
  hint, appended on the raw-string surfaces (global 500 handler,
  model-install SSE) via the new append_hint().

Fail-before/pass-after verified by reverting the fix: 11 of the 12 new
tests fail pre-fix (the remaining one is the unchanged network-fallback
contract). 165 tests green across the touched suites.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(changelog): add SOCKS-proxy resilience under [Unreleased] (#966)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Palash Debnath
2026-07-05 20:00:08 +05:30
committed by GitHub
co-authored by Claude Fable 5 mergetest
parent c47633a409
commit de99bc3bd5
13 changed files with 439 additions and 21 deletions
+1
View File
@@ -18,6 +18,7 @@ The bundled TTS model package (`pyproject.toml`) is versioned independently.
- **Completed dub tracks always show their video tabs.** Opening a project with a finished dubbed track hid the Original/track switcher until you re-selected the language — visibility was keyed to the language dropdown instead of the project's tracks, and restored projects couldn't set the language because the history database froze it at empty forever. Tabs now render from the tracks themselves, history keeps its language (existing projects heal without migration), restoring a project can no longer 404 the video preview, and track pills gained duration/timing tooltips plus an accurate now-playing indicator. (#956)
- **Running from source works again, and the install docs stop lying.** `bun run desktop-prod` broke when the frontend became a workspace (`bunx` could fetch the wrong "tauri" package from npm — fixed everywhere including CI); the Linux white-screen guidance now leads with the variable that actually fixes modern Ubuntu (`WEBKIT_DISABLE_DMABUF_RENDERER=1`, with the exact `EGL_BAD_PARAMETER` error quoted); Windows docs now state plainly that GPU acceleration is NVIDIA-only there; the Linux docs document the ROCm support that already shipped (the "planned follow-up" note was stale); and prerequisites are split installer-vs-source with git and curl included. (#964)
- **Your LLM provider now survives a restart.** Setting up Ollama (or any provider), testing it, and saving looked like it worked — then a restart forgot the selection: only the separate "Save & use for translation" button ever persisted it, and a leftover setting from the retired (≤0.3.7) translation panel could silently steal the choice back to "Custom" on every launch. An explicit save now activates the provider when none was chosen yet, the leftover legacy settings are migrated into the Custom provider once and removed, and the panel says "Saved — not yet used for translation" instead of staying silent when your edit isn't the active provider. (#965)
- **SOCKS-proxy users can synthesize again — and an installed model can never again be blocked by a broken network stack.** With a system-wide SOCKS proxy set, clicking Synthesize 500'd with a raw "socksio not installed" error: loading an already-downloaded model still constructed a network session first, which failed at creation. The app now ships SOCKS support (including in the packaged installers), resolves installed models **cache-first** (no network session when the files are already on disk — the local-first guarantee at the loader level), warms up at startup even when the online check fails, degrades LLM extras instead of crashing on proxy errors, and classifies the error with an actionable hint if it ever does surface. (#966)
## [0.3.10] — 2026-07-05
+8
View File
@@ -33,6 +33,14 @@ hiddenimports = [
'uvicorn.lifespan', 'uvicorn.lifespan.on',
'fastapi', 'fastapi.responses', 'starlette',
'multipart',
# SOCKS proxy support (#959). httpx imports socksio lazily inside a
# try/except (only when a socks5:// proxy env var is set), so
# PyInstaller's static tracer never sees it — without this entry the
# frozen installers keep raising "Using SOCKS proxy, but the 'socksio'
# package is not installed" on every model load under a SOCKS proxy,
# even though pyproject.toml ships the package. Guarded by
# tests/test_socks_proxy.py.
'socksio',
# Core
'uuid', 'asyncio',
+5 -3
View File
@@ -530,14 +530,16 @@ async def install_model(req: InstallModelRequest):
_install_cooldowns[req.repo_id] = _time_fail.time()
# #874: when the install failed because the configured HF mirror is
# unreachable, name the mirror + the setting instead of leaking the
# raw connectivity error. No-op for every other failure.
from core.failure import append_hf_mirror_hint
# raw connectivity error. #959: likewise for the SOCKS-proxy class
# (missing socksio fails the download's session construction).
# No-op for every other failure.
from core.failure import append_hint
hf_progress.emit({
"repo_id": req.repo_id,
"filename": req.repo_id,
"downloaded": 0, "total": 0, "pct": 0.0,
"phase": "install_error",
"error": append_hf_mirror_hint(str(e)),
"error": append_hint(str(e)),
})
finally:
_cancelled.discard(req.repo_id)
+37
View File
@@ -41,6 +41,7 @@ _HINTS: dict[str, str] = {
"COMPUTE_TYPE_UNSUPPORTED": "Your GPU doesn't support float16 — OmniVoice retried on int8. If transcription still fails, set OMNIVOICE/ASR_COMPUTE_TYPE=int8 or use CPU.",
"TRANSFORMERS_IMPORT": "Your transformers install is incomplete. Reinstall it (`uv pip install --reinstall transformers`) or switch ASR to faster-whisper (Settings → Models).",
"OS_INVALID_ARGUMENT": "The OS rejected a file operation (Errno 22 / invalid argument) — in the transcribe path this is the temporary WAV write before ASR. It's almost always the temp directory: missing, read-only, on a full or removed drive, or blocked by antivirus. Check that your system TEMP/TMP folder exists and is writable and the drive has free space (add an OmniVoice antivirus exclusion if you use one), then retry.",
"SOCKS_PROXY_SUPPORT_MISSING": "A SOCKS proxy is configured in your environment (ALL_PROXY/HTTPS_PROXY=socks5://…) and the backend's HTTP client is missing SOCKS support. Newer OmniVoice builds ship SOCKS support (the socksio package) — update the app. If you still see this, unset ALL_PROXY/HTTPS_PROXY for OmniVoice, or run `uv pip install 'httpx[socks]'` in the backend venv, then restart.",
"UNSUPPORTED_VIDEO_URL": "This link isn't a directly downloadable video. Paste a direct video page (e.g. a youtube.com/watch?v=… or douyin.com/video/<id> link), not a share/profile/feed link — or download the file and drop it in directly.",
"VIDEO_DOWNLOAD_NETWORK": "The connection to the video server dropped mid-download (often a transient CDN/network blip or a regional rate-limit). Just retry — OmniVoice already cleaned up the partial download. If it keeps failing, check your network/VPN.",
"BROKEN_VENV": "The Python backend environment was moved or damaged. OmniVoice rebuilds it automatically on the next launch; if it keeps failing, use Clean & Retry on the setup screen.",
@@ -168,6 +169,33 @@ def append_hf_mirror_hint(text: str) -> str:
return f"{text}{hint}" if hint else text
# Classes whose hint is safe to attach on the CONTEXT-FREE surfaces (the
# global 500 handler in main.py, the model-install SSE in setup/download.py),
# where all we have is a raw error string with no stage. Only classes whose
# classify() trigger is unmistakable belong here — e.g. VIDEO_DOWNLOAD_NETWORK
# must NOT be added: its bare "timed out" trigger would stamp a "video server"
# hint on a model-load timeout that leaks through the 500 handler.
_CONTEXT_FREE_HINT_CLASSES = frozenset({
"SOCKS_PROXY_SUPPORT_MISSING",
})
def append_hint(text: str) -> str:
"""``"{text}{hint}"`` for raw-string surfaces (the global 500 handler,
the model-install SSE): the dynamic mirror hint (#874) when that class
applies, else a context-free static class hint (#959). ``text`` unchanged
otherwise — a no-op for every other error. Never raises."""
try:
hint = hf_mirror_hint(text)
if not hint:
topic = classify(text)
if topic in _CONTEXT_FREE_HINT_CLASSES:
hint = _HINTS.get(topic, "")
except Exception:
return text
return f"{text}{hint}" if hint else text
def classify(reason: str) -> str:
"""Map a failure reason to a docs-taxonomy key, or "" when unknown.
@@ -220,6 +248,15 @@ def classify(reason: str) -> str:
)
):
return "TRANSFORMERS_IMPORT"
# #959: httpx raises ImportError AT CLIENT CONSTRUCTION ("Using SOCKS
# proxy, but the 'socksio' package is not installed. Make sure to install
# httpx using `pip install httpx[socks]`.") when ALL_PROXY/HTTPS_PROXY is
# socks5:// and socksio isn't importable. It surfaced from
# huggingface_hub's get_session() inside model load — a bare 500 on
# /generate with no next step. Checked BEFORE the HF-auth/mirror rules so
# a message that also carries HF wording still names this class.
if "socks proxy" in low or "socksio" in low:
return "SOCKS_PROXY_SUPPORT_MISSING"
if ("huggingface" in low or "hf_token" in low or "401" in low or "unauthorized" in low) and (
"token" in low or "auth" in low or "401" in low or "unauthorized" in low
):
+7 -6
View File
@@ -728,13 +728,14 @@ async def global_exception_handler(request: Request, exc: Exception):
# #874: a model download that failed because the CONFIGURED Hugging Face
# mirror (HF_ENDPOINT) is unreachable used to leak the raw transformers
# message ("We couldn't connect to 'https://hf-mirror.com' …") as the 500
# detail with no next step. Appending the shared mirror hint HERE covers
# every route that can leak a model-load/download error (generate, dub,
# archetypes, …), not just TTS generate. append_hf_mirror_hint is a no-op
# for every other error and never raises.
from core.failure import append_hf_mirror_hint
# detail with no next step. #959: same story for the SOCKS-proxy class
# ("Using SOCKS proxy, but the 'socksio' package is not installed").
# Appending the shared hints HERE covers every route that can leak a
# model-load/download error (generate, dub, archetypes, …), not just TTS
# generate. append_hint is a no-op for every other error and never raises.
from core.failure import append_hint
return JSONResponse(
{"detail": append_hf_mirror_hint(str(exc)), "error_class": _entry.get("error_class")},
{"detail": append_hint(str(exc)), "error_class": _entry.get("error_class")},
status_code=500,
headers=headers,
)
+17 -1
View File
@@ -242,8 +242,24 @@ def resolve_skill_client(skill_id: str) -> Optional[SkillClient]:
# skill's wall-clock budget (the cinematic pass budget, the glossary call
# timeout) from inside one request. Fail fast — the per-call timeout and the
# pass-level budget are the only bounds we want. Mirrors OpenAICompatBackend.
#
# #959 class guard: OpenAI() eagerly builds its httpx client, which can
# raise AT CONSTRUCTION for environment-shaped reasons — the reported one
# is httpx's ImportError under ALL_PROXY/HTTPS_PROXY=socks5:// without
# socksio; a malformed proxy URL or broken cert bundle fails the same way.
# The contract here is already "None == LLM unavailable, degrade" — a bad
# proxy env must degrade the skill, never 500 the calling feature.
try:
client = OpenAI(max_retries=0, **kw)
except Exception as exc:
logger.warning(
"LLM client construction failed for skill %s (provider %s): %s"
"treating the skill as unavailable.",
skill_id, res.provider.id, exc,
)
return None
return SkillClient(
client=OpenAI(max_retries=0, **kw),
client=client,
model=llm_providers.resolve_model(res.provider),
provider_id=res.provider.id,
timeout=_default_timeout(),
+37 -4
View File
@@ -1022,6 +1022,22 @@ async def get_model():
return model
def _checkpoint_in_local_cache(checkpoint: str) -> bool:
"""True when ``checkpoint`` is loadable with NO network: an existing local
directory, or a COMPLETE HF cache snapshot. ``snapshot_download(...,
local_files_only=True)`` never constructs an HTTP session, so a broken
proxy env (#959: ``ALL_PROXY``/``HTTPS_PROXY=socks5://`` without socksio)
can't false-negative this probe. Never raises."""
if os.path.isdir(checkpoint):
return True
try:
from huggingface_hub import snapshot_download
snapshot_download(checkpoint, local_files_only=True)
return True
except Exception:
return False
async def preload_model():
"""Background model warm-up — call from lifespan startup.
@@ -1042,10 +1058,27 @@ async def preload_model():
try:
from huggingface_hub import model_info
model_info(checkpoint, timeout=5)
except Exception:
# Model not downloaded yet — skip preload
logger.info("Preload skipped: %s not available locally.", checkpoint)
return
except Exception as probe_err:
# The probe failing does NOT mean the model isn't installed — it
# means the Hub API wasn't reachable from this process. The #959
# class: under ALL_PROXY/HTTPS_PROXY=socks5:// without socksio,
# hf_hub's get_session() raises ImportError AT CLIENT CONSTRUCTION;
# same story for offline mode, DNS, or firewall failures. Fall back
# to a cache-only probe (no HTTP session involved) and warm up
# anyway when the model is locally present, instead of silently
# skipping and letting the first /generate eat the full load.
if not _checkpoint_in_local_cache(checkpoint):
logger.info(
"Preload skipped: %s not available locally (network probe "
"failed: %s: %s).",
checkpoint, type(probe_err).__name__, probe_err,
)
return
logger.warning(
"Network probe for %s failed (%s: %s) — model found in the "
"local cache; warming up from cache.",
checkpoint, type(probe_err).__name__, probe_err,
)
logger.info("Preloading TTS model in background…")
_last_used = time.time()
+27 -7
View File
@@ -182,6 +182,29 @@ class OmniVoiceConfig(PretrainedConfig):
self.audio_codebook_weights = audio_codebook_weights
def _resolve_snapshot_dir(checkpoint) -> str:
"""Local snapshot directory for ``checkpoint`` (a local dir or a HF repo id).
Cache-first (#959): a COMPLETE local cache is resolved with
``snapshot_download(..., local_files_only=True)``, which never constructs
an HTTP session — so no session-construction failure (e.g. httpx's
ImportError under ``ALL_PROXY``/``HTTPS_PROXY=socks5://`` without socksio,
a malformed proxy URL, a broken cert bundle) can break synthesis of an
already-installed model. Only a cache miss / incomplete cache falls
through to the original network ``snapshot_download``, whose errors
(auth, connectivity, proxy) surface exactly as before.
"""
if os.path.isdir(checkpoint):
return checkpoint
from huggingface_hub import snapshot_download
try:
return snapshot_download(checkpoint, local_files_only=True)
except Exception:
# Miss/incomplete (LocalEntryNotFoundError et al.) → network path.
return snapshot_download(checkpoint)
class OmniVoice(PreTrainedModel):
_supports_flex_attn = True
_supports_flash_attn_2 = True
@@ -264,13 +287,10 @@ class OmniVoice(PreTrainedModel):
)
if not train_mode:
# Resolve local path for audio tokenizer subdirectory
if os.path.isdir(pretrained_model_name_or_path):
resolved_path = pretrained_model_name_or_path
else:
from huggingface_hub import snapshot_download
resolved_path = snapshot_download(pretrained_model_name_or_path)
# Resolve local path for audio tokenizer subdirectory
# cache-first so a proxy-broken HTTP session can't fail an
# installed model (#959; see _resolve_snapshot_dir).
resolved_path = _resolve_snapshot_dir(pretrained_model_name_or_path)
model.text_tokenizer = AutoTokenizer.from_pretrained(
pretrained_model_name_or_path
+10
View File
@@ -144,6 +144,16 @@ dependencies = [
# frozen `uv sync` installs a working engine on every platform.
"sherpa-onnx>=1.13.3",
"sherpa-onnx-core>=1.13.3",
# SOCKS proxy support for httpx (#959). huggingface_hub's get_session()
# builds an httpx.Client, which raises ImportError AT CONSTRUCTION when
# ALL_PROXY/HTTPS_PROXY is socks5:// and socksio isn't importable — every
# model load/download 500'd for SOCKS-proxy users ("Using SOCKS proxy, but
# the 'socksio' package is not installed"). Same failure shape for the
# OpenAI SDK's client. Pure-Python, MIT, zero transitive deps, ~13 KB —
# identical on macOS/Windows/Linux. Also in backend.spec hiddenimports:
# httpx imports it lazily inside try/except, so PyInstaller's tracer
# misses it and frozen installers would stay broken without the entry.
"socksio>=1.0",
]
[project.optional-dependencies]
+25
View File
@@ -118,6 +118,31 @@ def test_classify_broken_venv_missing_own_package():
assert failure.classify("No module named 'omnivoice_helper'") == ""
def test_classify_socks_proxy_support_missing():
# #959: the exact httpx message at client CONSTRUCTION under a socks5://
# proxy env without socksio — it surfaced as a bare 500 from /generate
# (huggingface_hub's get_session() builds the client inside model load).
reason = (
"Using SOCKS proxy, but the 'socksio' package is not installed. "
"Make sure to install httpx using `pip install httpx[socks]`."
)
assert failure.classify(reason) == "SOCKS_PROXY_SUPPORT_MISSING"
evt = failure.build_failure(
ImportError(reason), stage="model-load", include_diagnostic=False
)
assert evt["docs_topic"] == "SOCKS_PROXY_SUPPORT_MISSING"
assert evt["hint"], "the SOCKS-proxy class must carry an actionable hint"
assert "ALL_PROXY" in evt["hint"]
# append_hint is the raw-string surface (main.py's global 500 handler,
# the model-install SSE) — the detail keeps the real error AND gains the
# hint, and stays a pass-through for unknown reasons.
out = failure.append_hint(reason)
assert out.startswith(reason) and "ALL_PROXY" in out
assert failure.append_hint("some unrelated failure") == "some unrelated failure"
# A generic proxy connectivity error must NOT be mislabelled.
assert failure.classify("ProxyError: connection refused by 10.0.0.1:8080") == ""
def test_classify_generic_still_empty():
# A genuinely unknown reason must still classify to "" (no false hint).
assert failure.classify("some totally unrelated failure") == ""
+20
View File
@@ -166,6 +166,26 @@ def test_client_uses_active_when_no_override(skills, store):
assert handle is not None and handle.provider_id == "groq"
@pytest.mark.skipif(not _HAS_OPENAI, reason="openai package not installed")
def test_client_none_when_construction_fails(skills, store, monkeypatch):
# #959: OpenAI() eagerly builds its httpx client — under
# ALL_PROXY/HTTPS_PROXY=socks5:// without socksio it raises ImportError
# AT CONSTRUCTION. Contract: None == "LLM unavailable, degrade" — an
# environment-shaped construction failure must degrade the skill, never
# 500 the calling feature.
_activate_groq(store)
import openai
def boom(*args, **kwargs):
raise ImportError(
"Using SOCKS proxy, but the 'socksio' package is not installed. "
"Make sure to install httpx using `pip install httpx[socks]`."
)
monkeypatch.setattr(openai, "OpenAI", boom)
assert skills.resolve_skill_client("cinematic_translation") is None
def test_backend_off_when_disabled(skills, store):
from services.llm_backend import OffBackend
_activate_groq(store)
+234
View File
@@ -0,0 +1,234 @@
"""#959: a SOCKS proxy env (ALL_PROXY/HTTPS_PROXY=socks5://) broke synthesis.
httpx raises ImportError AT CLIENT CONSTRUCTION ("Using SOCKS proxy, but the
'socksio' package is not installed") when a socks5:// proxy env var is set and
socksio isn't importable. huggingface_hub's ``get_session()`` builds exactly
that client inside ``snapshot_download``, so ``POST /generate`` 500'd with the
bare message even for a FULLY INSTALLED model and ``preload_model``'s
``model_info`` probe hit the same error and silently skipped warm-up. Latent
since v0.3.5; unmasked by #947's fresh-process spawning (the parent process no
longer masked the proxy env).
Three layers, each covered here:
(a) ship socksio pyproject dependency + backend.spec hiddenimports
(httpx imports it lazily in try/except, so PyInstaller's tracer misses
it: nothing imports it statically, hence the recurrence guard);
(b) cache-first model resolution a complete local cache resolves with
``local_files_only=True`` (no HTTP session constructed), so no
session-construction failure can break synthesis of an installed model;
(c) the 500 detail carries the actionable class hint instead of the bare
httpx message.
"""
from __future__ import annotations
import asyncio
import os
import re
from pathlib import Path
import pytest
os.environ.setdefault("OMNIVOICE_MODEL", "test")
os.environ.setdefault("OMNIVOICE_DISABLE_FILE_LOG", "1")
PROJECT_ROOT = Path(__file__).resolve().parents[1]
# The exact httpx message from issue #959.
_SOCKS_MSG = (
"Using SOCKS proxy, but the 'socksio' package is not installed. "
"Make sure to install httpx using `pip install httpx[socks]`."
)
# ── (a) socksio ships — source install AND frozen installers ────────────────
def test_socksio_declared_in_pyproject_dependencies():
import tomllib
data = tomllib.loads((PROJECT_ROOT / "pyproject.toml").read_text())
deps = data["project"]["dependencies"]
assert any(re.match(r"socksio\b", d) for d in deps), (
"#959 regression: socksio must be a [project] dependency — without it "
"any socks5:// ALL_PROXY/HTTPS_PROXY env breaks every httpx client "
"construction (model downloads, hub probes, OpenAI-compat LLM clients)."
)
def test_socksio_in_backend_spec_hiddenimports():
# httpx imports socksio lazily inside try/except — PyInstaller's static
# tracer never sees it, so a pyproject dep alone leaves the FROZEN
# installers broken. Comments are stripped so a mention in a comment
# can't satisfy the check.
code_lines = [
line.split("#", 1)[0]
for line in (PROJECT_ROOT / "backend.spec").read_text().splitlines()
]
assert any("'socksio'" in line or '"socksio"' in line for line in code_lines), (
"#959 regression: 'socksio' must be listed in backend.spec "
"hiddenimports or the frozen installers ship without SOCKS support."
)
def test_httpx_client_constructs_under_socks_proxy_env(monkeypatch):
# Construction only — no network. FAILS (ImportError) in an env without
# socksio; passes once (a) ships it.
monkeypatch.setenv("ALL_PROXY", "socks5://127.0.0.1:9")
import httpx
httpx.Client().close()
asyncio.run(httpx.AsyncClient().aclose())
# ── (b) cache-first model resolution ─────────────────────────────────────────
def test_resolve_snapshot_dir_uses_local_dir(tmp_path):
from omnivoice.models.omnivoice import _resolve_snapshot_dir
assert _resolve_snapshot_dir(str(tmp_path)) == str(tmp_path)
def test_resolve_snapshot_dir_prefers_complete_cache(monkeypatch, tmp_path):
# A complete local cache must resolve WITHOUT ever constructing an HTTP
# session — the network path raising the #959 ImportError proves the
# cached branch won. FAILS pre-fix (the old code always hit the network
# snapshot_download for repo ids).
import huggingface_hub
from omnivoice.models import omnivoice as ov
snap = tmp_path / "snap"
snap.mkdir()
calls: list[dict] = []
def fake_snapshot_download(repo_id, *args, **kwargs):
calls.append(kwargs)
if kwargs.get("local_files_only"):
return str(snap)
raise ImportError(_SOCKS_MSG)
monkeypatch.setattr(huggingface_hub, "snapshot_download", fake_snapshot_download)
assert ov._resolve_snapshot_dir("k2-fsa/OmniVoice") == str(snap)
assert calls == [{"local_files_only": True}]
def test_resolve_snapshot_dir_falls_back_to_network_on_cache_miss(monkeypatch):
# Miss/incomplete cache → the original network snapshot_download, so
# first-install behavior (and its error surface) is unchanged.
import huggingface_hub
from omnivoice.models import omnivoice as ov
def fake_snapshot_download(repo_id, *args, **kwargs):
if kwargs.get("local_files_only"):
raise FileNotFoundError(f"{repo_id} not in cache")
return "/net/snapshot"
monkeypatch.setattr(huggingface_hub, "snapshot_download", fake_snapshot_download)
assert ov._resolve_snapshot_dir("k2-fsa/OmniVoice") == "/net/snapshot"
# ── (b) preload probe: network failure ≠ "model not installed" ──────────────
def _fail_model_info(monkeypatch):
import huggingface_hub
def boom(*args, **kwargs):
raise ImportError(_SOCKS_MSG)
monkeypatch.setattr(huggingface_hub, "model_info", boom)
def test_preload_warms_up_from_cache_when_network_probe_fails(monkeypatch):
# Pre-fix, ANY model_info failure silently skipped warm-up — under a SOCKS
# proxy env that meant a fully cached model never preloaded and the first
# /generate ate the whole load. Now a failed network probe falls back to a
# cache-only check and warms up anyway.
import services.model_manager as mm
_fail_model_info(monkeypatch)
monkeypatch.setattr(mm, "model", None)
monkeypatch.setattr(mm, "resolve_omnivoice_checkpoint", lambda: "k2-fsa/OmniVoice")
monkeypatch.setattr(mm, "_checkpoint_in_local_cache", lambda cp: True)
sentinel = object()
async def fake_load():
return sentinel
monkeypatch.setattr(mm, "_load_model_with_timeout", fake_load)
asyncio.run(mm.preload_model())
assert mm.model is sentinel
def test_preload_still_skips_when_not_cached(monkeypatch):
# Probe failed AND nothing in the cache → the historical skip (no heavy
# load attempt that would fail and pollute startup logs).
import services.model_manager as mm
_fail_model_info(monkeypatch)
monkeypatch.setattr(mm, "model", None)
monkeypatch.setattr(mm, "resolve_omnivoice_checkpoint", lambda: "k2-fsa/OmniVoice")
monkeypatch.setattr(mm, "_checkpoint_in_local_cache", lambda cp: False)
async def must_not_load(): # pragma: no cover - failure path
raise AssertionError("warm-up must not run for an uncached model")
monkeypatch.setattr(mm, "_load_model_with_timeout", must_not_load)
asyncio.run(mm.preload_model())
assert mm.model is None
def test_checkpoint_in_local_cache_probe(monkeypatch, tmp_path):
import huggingface_hub
import services.model_manager as mm
# A local dir needs no hub at all.
assert mm._checkpoint_in_local_cache(str(tmp_path)) is True
def fake_snapshot_download(repo_id, *args, **kwargs):
assert kwargs.get("local_files_only") is True # never a network probe
if repo_id == "org/cached":
return "/cache/snap"
raise FileNotFoundError(repo_id)
monkeypatch.setattr(huggingface_hub, "snapshot_download", fake_snapshot_download)
assert mm._checkpoint_in_local_cache("org/cached") is True
assert mm._checkpoint_in_local_cache("org/missing") is False
# ── (c) /generate 500 detail carries the actionable hint ────────────────────
@pytest.fixture(scope="module")
def client():
from fastapi.testclient import TestClient
from main import app
import core.db
core.db.init_db()
# raise_server_exceptions=False: the unhandled ImportError must flow
# through the global 500 handler (main.py) — the surface under test —
# instead of re-raising into the test.
return TestClient(
app, client=("127.0.0.1", 50000), raise_server_exceptions=False
)
def test_generate_500_detail_carries_socks_hint(client, monkeypatch):
# get_model() is called OUTSIDE /generate's try block, so the ImportError
# reaches the global handler bare. Pre-fix the 500 detail was the raw
# httpx message with no next step; it must now carry the class hint.
# generation.py binds get_model at import (`from services.model_manager
# import get_model`), so the module-local binding is the effective seam.
import api.routers.generation as gen
async def boom():
raise ImportError(_SOCKS_MSG)
monkeypatch.setattr(gen, "get_model", boom)
r = client.post("/generate", data={"text": "hello", "engine": "omnivoice"})
assert r.status_code == 500
detail = r.json()["detail"]
assert detail.startswith(_SOCKS_MSG) # the real error stays visible
assert detail != _SOCKS_MSG, "500 detail must not be the bare httpx message"
assert "unset ALL_PROXY/HTTPS_PROXY" in detail # ...and actionable
Generated
+11
View File
@@ -3239,6 +3239,7 @@ dependencies = [
{ name = "setuptools" },
{ name = "sherpa-onnx" },
{ name = "sherpa-onnx-core" },
{ name = "socksio" },
{ name = "soundfile" },
{ name = "tensorboardx" },
{ name = "torch", version = "2.8.0", source = { registry = "https://pypi.org/simple" }, marker = "sys_platform != 'linux' and sys_platform != 'win32'" },
@@ -3318,6 +3319,7 @@ requires-dist = [
{ name = "setuptools", specifier = ">=75,<80" },
{ name = "sherpa-onnx", specifier = ">=1.13.3" },
{ name = "sherpa-onnx-core", specifier = ">=1.13.3" },
{ name = "socksio", specifier = ">=1.0" },
{ name = "soundfile" },
{ name = "supertonic", marker = "extra == 'supertonic'", specifier = "==1.3.1" },
{ name = "tensorboardx" },
@@ -5389,6 +5391,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
]
[[package]]
name = "socksio"
version = "1.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f8/5c/48a7d9495be3d1c651198fd99dbb6ce190e2274d0f28b9051307bdec6b85/socksio-1.0.0.tar.gz", hash = "sha256:f88beb3da5b5c38b9890469de67d0cb0f9d494b78b106ca1845f96c10b91c4ac", size = 19055, upload-time = "2020-04-17T15:50:34.664Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/37/c3/6eeb6034408dac0fa653d126c9204ade96b819c936e136c5e8a6897eee9c/socksio-1.0.0-py3-none-any.whl", hash = "sha256:95dc1f15f9b34e8d7b16f06d74b8ccf48f609af32ab33c608d08761c5dcbb1f3", size = 12763, upload-time = "2020-04-17T15:50:31.878Z" },
]
[[package]]
name = "sortedcontainers"
version = "2.4.0"