77from app import schemas
88from app .build_info import current_commit
99from app .catalog import catalog_source_url , language_names , recommended_ids
10+ from app .config import Settings
1011from app .context import BOOTSTRAP_TOKEN_ID , TOKEN_FILE_HINT , VERSION , GatewayContext
1112from app .engine_state import active_model_path , available_engines , engine_id
1213from app .runtime_config import DEFAULT_IDLE_OFFLOAD_MINUTES
1314from app .serializers import metrics_status , model_covers
14- from app .system import detect_system
15+ from app .system import SystemInfo , detect_system
1516
1617PYTHON_PACKAGE_PATH = "Python package"
18+ PYTHON_ENGINE_INSTALL_HINT = "Install vocagateway[engines] or use the Docker image"
19+ # One engine paired with the single runtime it needs.
20+ _EngineRuntime = tuple [str , schemas .DependencyStatus ]
21+
1722
1823SIZE_FILTER_CAPS : MappingProxyType [str , int ] = MappingProxyType (
1924 {
2530)
2631
2732
33+ class _EngineRuntimes :
34+ """Per-engine runtime availability: one entry per engine the catalog ships.
35+
36+ Both the Overview "Libraries & tools" tiles and the per-model warning read
37+ this, so a card can never claim a runtime the panel says is installed.
38+ Model weights are deliberately not part of the check: this answers "can this
39+ host run the engine at all", which is what a download decision turns on.
40+ """
41+
42+ def __init__ (self , system : SystemInfo , settings : Settings ) -> None :
43+ self .system = system
44+ self .settings = settings
45+ self ._cached : dict [str , schemas .DependencyStatus ] | None = None
46+
47+ def tiles (self ) -> list [schemas .DependencyStatus ]:
48+ return list (self ._tiles .values ())
49+
50+ def missing (self , engine : str ) -> schemas .DependencyStatus | None :
51+ """The unmet runtime behind an engine, or None when it can run here."""
52+ status = self ._tiles .get (engine )
53+ return None if status is None or status .available else status
54+
55+ def entry_fields (self , engine : str ) -> dict [str , str | None ]:
56+ """The AdminModelEntry warning fields for an engine, empty when it runs."""
57+ unmet = self .missing (engine )
58+ if unmet is None :
59+ return {}
60+ return {"runtime_requirement" : unmet .name , "runtime_hint" : unmet .install_hint }
61+
62+ @property
63+ def _tiles (self ) -> dict [str , schemas .DependencyStatus ]:
64+ """Probe on first use, once per request.
65+
66+ Not cached beyond that on purpose: an operator who pip-installs an
67+ engine and reloads the page has to see it turn from Missing to
68+ Installed, which a process-lifetime cache would prevent.
69+ """
70+ if self ._cached is None :
71+ self ._cached = dict (self ._build ())
72+ return self ._cached
73+
74+ def _build (self ) -> list [_EngineRuntime ]:
75+ is_mac = self .system .os_name == "Darwin"
76+ silicon = self .system .is_apple_silicon
77+ return [
78+ * self ._binary_tiles (is_mac ),
79+ * self ._package_tiles (silicon ),
80+ * self ._app_tiles (is_mac , silicon ),
81+ ]
82+
83+ def _binary_tiles (self , is_mac : bool ) -> list [_EngineRuntime ]:
84+ whisper_hint = (
85+ "brew install whisper-cpp"
86+ if is_mac
87+ else "Included in Docker or build whisper.cpp from source"
88+ )
89+ return [
90+ (
91+ "whisper.cpp" ,
92+ schemas .DependencyStatus (
93+ name = "whisper.cpp CLI" ,
94+ available = self .system .whisper_cpp_path is not None ,
95+ path = self .system .whisper_cpp_path ,
96+ install_hint = whisper_hint ,
97+ ),
98+ ),
99+ ]
100+
101+ def _package_tiles (self , silicon : bool ) -> list [_EngineRuntime ]:
102+ mlx_ready = silicon and importlib_util .find_spec ("mlx_audio" ) is not None
103+ mlx_hint = (
104+ "Install vocagateway[apple]" if silicon else "Available only on Apple-silicon Macs"
105+ )
106+ tiles = [
107+ (engine , self ._package_status (label , module ))
108+ for engine , label , module in (
109+ ("faster-whisper" , "faster-whisper" , "faster_whisper" ),
110+ ("moonshine" , "Moonshine Voice" , "moonshine_voice" ),
111+ ("sherpa-onnx" , "sherpa-onnx" , "sherpa_onnx" ),
112+ )
113+ ]
114+ tiles .append (
115+ (
116+ "mlx-audio" ,
117+ schemas .DependencyStatus (
118+ name = "MLX Audio" ,
119+ available = mlx_ready ,
120+ path = PYTHON_PACKAGE_PATH if mlx_ready else None ,
121+ install_hint = mlx_hint ,
122+ ),
123+ )
124+ )
125+ return tiles
126+
127+ def _package_status (self , label : str , module : str ) -> schemas .DependencyStatus :
128+ installed = importlib_util .find_spec (module ) is not None
129+ return schemas .DependencyStatus (
130+ name = label ,
131+ available = installed ,
132+ path = PYTHON_PACKAGE_PATH if installed else None ,
133+ install_hint = PYTHON_ENGINE_INSTALL_HINT ,
134+ )
135+
136+ def _app_tiles (self , is_mac : bool , silicon : bool ) -> list [_EngineRuntime ]:
137+ wk_hint = "brew install whisperkit-cli" if is_mac else "Available only on Apple platforms"
138+ vocamac_hint = (
139+ "https://github.com/jatinkrmalik/vocamac"
140+ if silicon
141+ else "Available only on Apple silicon Macs"
142+ )
143+ return [
144+ (
145+ "whisperkit" ,
146+ schemas .DependencyStatus (
147+ name = "WhisperKit CLI" ,
148+ available = self .system .whisperkit_cli_path is not None ,
149+ path = self .system .whisperkit_cli_path ,
150+ install_hint = wk_hint ,
151+ ),
152+ ),
153+ (
154+ "handy" ,
155+ schemas .DependencyStatus (
156+ name = "Handy app" ,
157+ available = self .system .handy_installed ,
158+ path = str (self .settings .handy_binary ) if self .system .handy_installed else None ,
159+ install_hint = "https://handy.computer" if is_mac else "Available only on macOS" ,
160+ ),
161+ ),
162+ (
163+ "vocamac" ,
164+ schemas .DependencyStatus (
165+ name = "VocaMac app" ,
166+ available = self .system .vocamac_installed ,
167+ path = str (self .settings .vocamac_app ) if self .system .vocamac_installed else None ,
168+ install_hint = vocamac_hint ,
169+ ),
170+ ),
171+ ]
172+
173+
28174class _SystemDependencyHelper :
29175 def __init__ (self , ctx : GatewayContext ) -> None :
30176 self .ctx = ctx
@@ -35,42 +181,26 @@ def __init__(self, ctx: GatewayContext) -> None:
35181 handy_binary = self .settings .handy_binary ,
36182 vocamac_app = self .settings .vocamac_app ,
37183 )
184+ self .runtimes = _EngineRuntimes (self .system , self .settings )
38185
39186 def build_dependencies (self ) -> list [schemas .DependencyStatus ]:
40187 is_mac = self .system .os_name == "Darwin"
41188 ffmpeg_hint = (
42189 "brew install ffmpeg" if is_mac else "Install FFmpeg with your Linux package manager"
43190 )
44- whisper_hint = (
45- "brew install whisper-cpp"
46- if is_mac
47- else "Included in Docker or build whisper.cpp from source"
191+ ffmpeg = schemas .DependencyStatus (
192+ name = "FFmpeg" ,
193+ available = self .system .ffmpeg_path is not None ,
194+ path = self .system .ffmpeg_path ,
195+ install_hint = ffmpeg_hint ,
48196 )
49- deps = [
50- schemas .DependencyStatus (
51- name = "FFmpeg" ,
52- available = self .system .ffmpeg_path is not None ,
53- path = self .system .ffmpeg_path ,
54- install_hint = ffmpeg_hint ,
55- ),
56- schemas .DependencyStatus (
57- name = "whisper.cpp CLI" ,
58- available = self .system .whisper_cpp_path is not None ,
59- path = self .system .whisper_cpp_path ,
60- install_hint = whisper_hint ,
61- ),
62- ]
63- deps .extend (self ._python_dependencies ())
64- deps .extend (self ._app_dependencies (is_mac ))
65- return deps
197+ return [ffmpeg , * self .runtimes .tiles ()]
66198
67- def build_checklist (
68- self , dependencies : list [schemas .DependencyStatus ], ready : bool
69- ) -> schemas .SetupChecklist :
199+ def build_checklist (self , ready : bool ) -> schemas .SetupChecklist :
70200 return schemas .SetupChecklist (
71201 token_configured = True ,
72202 ffmpeg_available = self .system .ffmpeg_path is not None ,
73- engine_binary_available = any (dep .available for dep in dependencies [ 1 :] ),
203+ engine_binary_available = any (tile .available for tile in self . runtimes . tiles () ),
74204 model_installed = bool (self .ctx .manager .installed ())
75205 or (self .ctx .engine_manager is not None and ready ),
76206 engine_ready = ready ,
@@ -93,68 +223,6 @@ def os_summary(self) -> str:
93223 version = self .system .os_version
94224 return f"{ name } { version } "
95225
96- def _python_dependencies (self ) -> list [schemas .DependencyStatus ]:
97- silicon = self .system .is_apple_silicon
98- mlx_ready = silicon and importlib_util .find_spec ("mlx_audio" ) is not None
99- mlx_hint = (
100- "Install vocagateway[apple]" if silicon else "Available only on Apple-silicon Macs"
101- )
102- return [
103- schemas .DependencyStatus (
104- name = "faster-whisper" ,
105- available = importlib_util .find_spec ("faster_whisper" ) is not None ,
106- path = PYTHON_PACKAGE_PATH if importlib_util .find_spec ("faster_whisper" ) else None ,
107- install_hint = "Install vocagateway[engines] or use the Docker image" ,
108- ),
109- schemas .DependencyStatus (
110- name = "Moonshine Voice" ,
111- available = importlib_util .find_spec ("moonshine_voice" ) is not None ,
112- path = PYTHON_PACKAGE_PATH if importlib_util .find_spec ("moonshine_voice" ) else None ,
113- install_hint = "Install vocagateway[engines] or use the Docker image" ,
114- ),
115- schemas .DependencyStatus (
116- name = "sherpa-onnx" ,
117- available = importlib_util .find_spec ("sherpa_onnx" ) is not None ,
118- path = PYTHON_PACKAGE_PATH if importlib_util .find_spec ("sherpa_onnx" ) else None ,
119- install_hint = "Install vocagateway[engines] or use the Docker image" ,
120- ),
121- schemas .DependencyStatus (
122- name = "MLX Audio" ,
123- available = mlx_ready ,
124- path = PYTHON_PACKAGE_PATH if mlx_ready else None ,
125- install_hint = mlx_hint ,
126- ),
127- ]
128-
129- def _app_dependencies (self , is_mac : bool ) -> list [schemas .DependencyStatus ]:
130- silicon = self .system .is_apple_silicon
131- wk_hint = "brew install whisperkit-cli" if is_mac else "Available only on Apple platforms"
132- vocamac_hint = (
133- "https://github.com/jatinkrmalik/vocamac"
134- if silicon
135- else "Available only on Apple silicon Macs"
136- )
137- return [
138- schemas .DependencyStatus (
139- name = "WhisperKit CLI" ,
140- available = self .system .whisperkit_cli_path is not None ,
141- path = self .system .whisperkit_cli_path ,
142- install_hint = wk_hint ,
143- ),
144- schemas .DependencyStatus (
145- name = "Handy app" ,
146- available = self .system .handy_installed ,
147- path = str (self .settings .handy_binary ) if self .system .handy_installed else None ,
148- install_hint = "https://handy.computer" if is_mac else "Available only on macOS" ,
149- ),
150- schemas .DependencyStatus (
151- name = "VocaMac app" ,
152- available = self .system .vocamac_installed ,
153- path = str (self .settings .vocamac_app ) if self .system .vocamac_installed else None ,
154- install_hint = vocamac_hint ,
155- ),
156- ]
157-
158226
159227_ModelState = tuple [str , float | None , str | None ]
160228
@@ -168,6 +236,7 @@ def __init__(self, ctx: GatewayContext) -> None:
168236 handy_binary = ctx .settings .handy_binary ,
169237 vocamac_app = ctx .settings .vocamac_app ,
170238 )
239+ self .runtimes = _EngineRuntimes (self .system , ctx .settings )
171240 self .recommended = recommended_ids (self .system )
172241 self .installed = {model .id : model for model in ctx .manager .installed ()}
173242 self .active_path = active_model_path (ctx )
@@ -234,6 +303,7 @@ def build_entry(self, model: Any) -> schemas.AdminModelEntry:
234303 retired = model .retired ,
235304 replacement_id = model .replacement_id ,
236305 retirement_reason = model .retirement_reason ,
306+ ** self .runtimes .entry_fields (model .engine ),
237307 )
238308
239309 def build_custom_entry (self , custom : Any ) -> schemas .AdminModelEntry :
@@ -256,6 +326,7 @@ def build_custom_entry(self, custom: Any) -> schemas.AdminModelEntry:
256326 and self .ctx .engine_manager .model_is_offloaded
257327 ),
258328 recommended = False ,
329+ ** self .runtimes .entry_fields (custom .engine ),
259330 )
260331
261332 def filter_by_criteria (
@@ -327,7 +398,7 @@ async def status_payload(ctx: GatewayContext) -> schemas.AdminStatusResponse:
327398 ),
328399 bind_host = ctx .settings .bind_host ,
329400 port = ctx .settings .port ,
330- setup = helper .build_checklist (dependencies , state .ready ),
401+ setup = helper .build_checklist (state .ready ),
331402 metrics = metrics_status (metrics ),
332403 readiness = schemas .ReadinessStatus (
333404 probe_age_seconds = round (readiness_details .checked_age_seconds , 3 ),
0 commit comments