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:
co-authored by
Claude Fable 5
mergetest
parent
c47633a409
commit
de99bc3bd5
@@ -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
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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") == ""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user