Merge remote-tracking branch 'origin/main' into fix/1931-torchaudio-backend
# Conflicts: # CHANGELOG.md
This commit is contained in:
@@ -10,6 +10,7 @@ the frozen-backend fallback mirror it for their toolchains.
|
||||
|
||||
**Highlights**
|
||||
- Upgrading torch for an RTX 50-series card no longer trades one startup crash for another, and the upgrade is documented (#1931)
|
||||
- An engine you have not installed now says so, instead of reporting a failed check (#1866)
|
||||
- The Accessibility prompt no longer floats over first-run setup and every other app until you grant it (#1845, #1886)
|
||||
- The last onboarding step offers to install a speech-to-text model instead of failing three times when none is installed (#1856)
|
||||
- A download that fails because the folder sits behind a mount point Windows will not cross now says so, and where to move it (#1957)
|
||||
|
||||
@@ -32,6 +32,54 @@ def _public_routing_reason(status: object, diagnostic: object) -> str:
|
||||
return _ROUTING_BY_STATUS.get(status, _ROUTING_UNAVAILABLE)
|
||||
|
||||
|
||||
# Categories for WHY an engine is unavailable. The probe's own sentence cannot
|
||||
# cross the boundary — it carries exception text, local paths and sometimes
|
||||
# credentials — but "Engine unavailable. Check installation and configuration."
|
||||
# told the user nothing at all, and "Last error: A previous engine check
|
||||
# failed." reads like a crash rather than "you have not installed this yet"
|
||||
# (#1866). Classifying the private diagnostic into an owned sentence keeps the
|
||||
# boundary intact and still names the kind of problem and the place to fix it.
|
||||
_UNAVAILABLE_NOT_INSTALLED = (
|
||||
"This engine's package isn't installed yet. Install it from "
|
||||
"Model Catalogue → Engines."
|
||||
)
|
||||
_UNAVAILABLE_NEEDS_CONFIG = (
|
||||
"This engine needs to be configured before it can run. Open "
|
||||
"Model Catalogue → Engines to finish setting it up."
|
||||
)
|
||||
_UNAVAILABLE_FILE_MISSING = (
|
||||
"A file this engine needs is missing or unreadable. Reinstall it from "
|
||||
"Model Catalogue → Engines."
|
||||
)
|
||||
|
||||
# Matched against the lowered probe text. Ordered most specific first: a
|
||||
# missing file often also says "not installed", and the file case has the more
|
||||
# useful remedy of the two.
|
||||
_UNAVAILABLE_SIGNATURES = (
|
||||
(_UNAVAILABLE_FILE_MISSING, (
|
||||
"file is missing", "file is empty", "file is unreadable",
|
||||
"script missing", "binary", "not found at",
|
||||
)),
|
||||
(_UNAVAILABLE_NEEDS_CONFIG, (
|
||||
"environment variable", "configure a server endpoint", "api key",
|
||||
"unconfigured", "set the", "base url",
|
||||
)),
|
||||
(_UNAVAILABLE_NOT_INSTALLED, (
|
||||
"not installed", "package missing", "not available", "no module named",
|
||||
"import ", "unavailable:", "failed to load",
|
||||
)),
|
||||
)
|
||||
|
||||
|
||||
def _public_unavailable_reason(diagnostic: object) -> str:
|
||||
"""Map a private availability probe to an accurate stable category."""
|
||||
private = diagnostic.lower() if isinstance(diagnostic, str) else ""
|
||||
for public, markers in _UNAVAILABLE_SIGNATURES:
|
||||
if any(marker in private for marker in markers):
|
||||
return public
|
||||
return _UNAVAILABLE
|
||||
|
||||
|
||||
def public_backends(entries: list[dict]) -> list[dict]:
|
||||
"""Copy registry entries while replacing service diagnostics.
|
||||
|
||||
@@ -46,7 +94,7 @@ def public_backends(entries: list[dict]) -> list[dict]:
|
||||
for entry in entries:
|
||||
item = dict(entry)
|
||||
if item.get("reason") is not None:
|
||||
item["reason"] = _UNAVAILABLE
|
||||
item["reason"] = _public_unavailable_reason(item["reason"])
|
||||
if item.get("last_error") is not None:
|
||||
item["last_error"] = _PREVIOUS_FAILURE
|
||||
if item.get("routing_reason") is not None:
|
||||
|
||||
@@ -521,6 +521,14 @@ def test_is_available_rejects_garbage_binary(monkeypatch, tmp_path):
|
||||
assert "not a usable executable" in reason
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
os.name == "nt",
|
||||
reason=(
|
||||
"POSIX exec bits do not exist on Windows: os.access(path, os.X_OK) is "
|
||||
"true for any file that exists, so the assertion below can never fail "
|
||||
"there and the whole module errored on a Windows checkout instead."
|
||||
),
|
||||
)
|
||||
def test_is_available_does_not_chmod_placeholder(monkeypatch, tmp_path):
|
||||
"""The #437 exec-bit self-heal must never bless a placeholder: with no
|
||||
manifest present, no SHA check confirmed the file, so chmod +x on an
|
||||
|
||||
@@ -80,7 +80,12 @@ def test_docs_url_survives_the_public_metadata_scrub():
|
||||
}
|
||||
(public,) = public_backends([entry])
|
||||
|
||||
assert public["reason"] == "Engine unavailable. Check installation and configuration."
|
||||
# The reason is still replaced — what matters here is that no private text
|
||||
# survives it. Since #1866 the replacement names the KIND of problem, so
|
||||
# pinning the old generic sentence would fight that on purpose.
|
||||
assert "/home/alice" not in public["reason"]
|
||||
assert "CosyVoice" not in public["reason"]
|
||||
assert "isn't installed yet" in public["reason"]
|
||||
assert public["last_error"] == "A previous engine check failed."
|
||||
assert "/home/alice" not in public["reason"]
|
||||
assert public["docs_url"] == entry["docs_url"]
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
"""#1866 — an unavailable engine must say what KIND of problem it has.
|
||||
|
||||
Model Catalogue → Engines rendered "Engine unavailable. Check installation and
|
||||
configuration." plus "Last error: A previous engine check failed." for engines
|
||||
the user had simply never installed. Neither names a missing package, a missing
|
||||
step, or a next action, and the second reads like a crash or a poisoned cache
|
||||
rather than "you have not installed this yet".
|
||||
|
||||
The probe's own sentence still cannot cross the boundary — it carries exception
|
||||
text, local paths and sometimes credentials. What changed is that the private
|
||||
diagnostic is now CLASSIFIED into a VoiceStudio-owned category, the same shape
|
||||
`_public_routing_reason` already uses for routing.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from api.public_engine_metadata import public_backends
|
||||
|
||||
_PRIVATE_PATH = "/Users/alice/Library/Caches/secret-token-abc123"
|
||||
|
||||
|
||||
def _reason(diagnostic):
|
||||
return public_backends([{"id": "e", "reason": diagnostic}])[0]["reason"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"diagnostic",
|
||||
[
|
||||
"voxcpm package not installed.",
|
||||
"transformers not installed",
|
||||
"funasr not installed. Install with: uv pip install funasr",
|
||||
"kittentts not installed: No module named 'kittentts'",
|
||||
"omnivoice package missing: cannot import name",
|
||||
"mlx-whisper unavailable: not supported on this platform",
|
||||
],
|
||||
)
|
||||
def test_a_missing_package_says_so(diagnostic):
|
||||
assert "isn't installed yet" in _reason(diagnostic)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"diagnostic",
|
||||
[
|
||||
"Set ELEVENLABS_API_KEY environment variable.",
|
||||
"Configure a server endpoint in Model Catalogue → Engines",
|
||||
"unconfigured",
|
||||
],
|
||||
)
|
||||
def test_a_configuration_gap_says_so(diagnostic):
|
||||
assert "needs to be configured" in _reason(diagnostic)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"diagnostic",
|
||||
[
|
||||
"file is empty (0 bytes) — a placeholder, not a real binary",
|
||||
"file is missing",
|
||||
"ASR sidecar script missing at /opt/thing/run.py",
|
||||
],
|
||||
)
|
||||
def test_a_missing_file_says_so(diagnostic):
|
||||
assert "missing or unreadable" in _reason(diagnostic)
|
||||
|
||||
|
||||
def test_an_unrecognised_probe_falls_back_to_the_generic_line():
|
||||
# The categories must not guess. Anything unclassified keeps the old text
|
||||
# rather than asserting a cause the probe never gave.
|
||||
assert _reason("something entirely unexpected") == (
|
||||
"Engine unavailable. Check installation and configuration."
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"diagnostic",
|
||||
[
|
||||
f"kittentts not installed: No module named 'kittentts' at {_PRIVATE_PATH}",
|
||||
f"file is unreadable ({_PRIVATE_PATH})",
|
||||
f"Set ELEVENLABS_API_KEY; current value read from {_PRIVATE_PATH}",
|
||||
],
|
||||
)
|
||||
def test_no_private_text_crosses_the_boundary(diagnostic):
|
||||
# The whole reason the reason was replaced in the first place.
|
||||
out = _reason(diagnostic)
|
||||
assert _PRIVATE_PATH not in out
|
||||
assert "alice" not in out
|
||||
assert "secret-token-abc123" not in out
|
||||
|
||||
|
||||
def test_registry_authored_fields_still_pass_through():
|
||||
row = public_backends(
|
||||
[
|
||||
{
|
||||
"id": "e",
|
||||
"reason": "voxcpm package not installed.",
|
||||
"install_hint": "uv pip install voxcpm",
|
||||
"docs_url": "https://example.invalid/docs",
|
||||
"setup_snippet": "export FOO=1",
|
||||
}
|
||||
]
|
||||
)[0]
|
||||
assert row["install_hint"] == "uv pip install voxcpm"
|
||||
assert row["docs_url"] == "https://example.invalid/docs"
|
||||
assert row["setup_snippet"] == "export FOO=1"
|
||||
|
||||
|
||||
def test_the_input_row_is_not_mutated():
|
||||
original = {"id": "e", "reason": "voxcpm package not installed."}
|
||||
public_backends([original])
|
||||
assert original["reason"] == "voxcpm package not installed."
|
||||
Reference in New Issue
Block a user