openspeechapi 0.2.10__tar.gz → 0.2.11__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (161) hide show
  1. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/PKG-INFO +5 -1
  2. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/config.py +15 -0
  3. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/core/base.py +5 -0
  4. openspeechapi-0.2.11/openspeechapi/core/model_hub.py +488 -0
  5. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/dispatch/dispatcher.py +9 -6
  6. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/local_engines/aim_resolver.py +14 -2
  7. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/providers/stt/alibaba.py +87 -86
  8. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/providers/stt/baidu.py +136 -135
  9. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/providers/stt/dolphin_stt.py +11 -11
  10. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/providers/stt/elevenlabs.py +1 -0
  11. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/providers/stt/faster_whisper.py +212 -211
  12. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/providers/stt/fireredasr_stt.py +1 -0
  13. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/providers/stt/funasr_stt.py +14 -9
  14. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/providers/stt/gemma4.py +97 -18
  15. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/providers/stt/kimi_audio_stt.py +9 -12
  16. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/providers/stt/mlx_whisper_stt.py +10 -11
  17. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/providers/stt/openai.py +94 -93
  18. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/providers/stt/paraformer.py +1 -0
  19. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/providers/stt/parakeet_mlx_stt.py +11 -11
  20. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/providers/stt/phi4_multimodal_stt.py +1 -0
  21. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/providers/stt/qwen3_asr.py +12 -11
  22. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/providers/stt/qwen3_omni_stt.py +1 -0
  23. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/providers/stt/sensevoice.py +17 -16
  24. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/providers/stt/tencent.py +213 -212
  25. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/providers/stt/volcengine.py +108 -107
  26. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/providers/stt/vosk_stt.py +1 -0
  27. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/providers/stt/voxtral_stt.py +10 -11
  28. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/providers/stt/wenet_stt.py +1 -0
  29. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/providers/stt/whisper.py +154 -153
  30. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/server/app.py +32 -0
  31. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/server/extras_installer.py +22 -2
  32. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/server/routes/management.py +8 -4
  33. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/server/webui/app.js +3 -1
  34. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/pyproject.toml +10 -2
  35. openspeechapi-0.2.10/openspeechapi/core/model_hub.py +0 -257
  36. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/.gitignore +0 -0
  37. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/README.md +0 -0
  38. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/__init__.py +0 -0
  39. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/__main__.py +0 -0
  40. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/cli.py +0 -0
  41. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/client/__init__.py +0 -0
  42. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/client/client.py +0 -0
  43. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/core/__init__.py +0 -0
  44. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/core/enums.py +0 -0
  45. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/core/models.py +0 -0
  46. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/core/registry.py +0 -0
  47. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/core/settings.py +0 -0
  48. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/demo.py +0 -0
  49. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/dispatch/__init__.py +0 -0
  50. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/dispatch/aim_provision.py +0 -0
  51. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/dispatch/context.py +0 -0
  52. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/dispatch/executors/__init__.py +0 -0
  53. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/dispatch/executors/base.py +0 -0
  54. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/dispatch/executors/in_process.py +0 -0
  55. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/dispatch/executors/remote.py +0 -0
  56. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/dispatch/executors/subprocess_exec.py +0 -0
  57. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/dispatch/fanout.py +0 -0
  58. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/dispatch/filters.py +0 -0
  59. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/dispatch/lifecycle.py +0 -0
  60. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/dispatch/watcher.py +0 -0
  61. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/engine_catalog.py +0 -0
  62. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/engine_registry.yaml +0 -0
  63. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/exceptions.py +0 -0
  64. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/factory.py +0 -0
  65. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/local_engines/__init__.py +0 -0
  66. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/local_engines/backends/__init__.py +0 -0
  67. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/local_engines/backends/docker_backend.py +0 -0
  68. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/local_engines/backends/native_backend.py +0 -0
  69. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/local_engines/base.py +0 -0
  70. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/local_engines/engines/__init__.py +0 -0
  71. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/local_engines/engines/faster_whisper.py +0 -0
  72. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/local_engines/engines/fish_speech.py +0 -0
  73. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/local_engines/engines/sherpa_onnx.py +0 -0
  74. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/local_engines/engines/whisper.py +0 -0
  75. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/local_engines/engines/whisperlivekit.py +0 -0
  76. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/local_engines/isolated_venv.py +0 -0
  77. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/local_engines/manager.py +0 -0
  78. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/local_engines/models.py +0 -0
  79. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/local_engines/progress.py +0 -0
  80. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/local_engines/registry.py +0 -0
  81. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/local_engines/task_store.py +0 -0
  82. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/local_engines/tasks.py +0 -0
  83. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/logging_config.py +0 -0
  84. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/observe/__init__.py +0 -0
  85. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/observe/base.py +0 -0
  86. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/observe/debug.py +0 -0
  87. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/observe/latency.py +0 -0
  88. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/observe/metrics.py +0 -0
  89. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/observe/tracing.py +0 -0
  90. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/observe/usage.py +0 -0
  91. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/providers/__init__.py +0 -0
  92. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/providers/_template.py +0 -0
  93. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/providers/stt/__init__.py +0 -0
  94. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/providers/stt/_local_audio.py +0 -0
  95. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/providers/stt/assemblyai.py +0 -0
  96. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/providers/stt/azure_speech.py +0 -0
  97. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/providers/stt/canary_qwen_stt.py +0 -0
  98. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/providers/stt/deepgram.py +0 -0
  99. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/providers/stt/google_cloud.py +0 -0
  100. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/providers/stt/iflytek.py +0 -0
  101. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/providers/stt/macos_speech.py +0 -0
  102. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/providers/stt/mms_languages.json +0 -0
  103. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/providers/stt/mms_stt.py +0 -0
  104. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/providers/stt/moonshine_stt.py +0 -0
  105. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/providers/stt/sherpa_onnx.py +0 -0
  106. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/providers/stt/whisperlivekit.py +0 -0
  107. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/providers/stt/windows_speech.py +0 -0
  108. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/providers/tts/__init__.py +0 -0
  109. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/providers/tts/alibaba.py +0 -0
  110. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/providers/tts/azure_speech.py +0 -0
  111. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/providers/tts/baidu.py +0 -0
  112. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/providers/tts/coqui.py +0 -0
  113. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/providers/tts/cosyvoice.py +0 -0
  114. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/providers/tts/deepgram.py +0 -0
  115. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/providers/tts/elevenlabs.py +0 -0
  116. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/providers/tts/fish_speech.py +0 -0
  117. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/providers/tts/google_cloud.py +0 -0
  118. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/providers/tts/iflytek.py +0 -0
  119. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/providers/tts/macos_say.py +0 -0
  120. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/providers/tts/minimax.py +0 -0
  121. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/providers/tts/openai.py +0 -0
  122. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/providers/tts/piper.py +0 -0
  123. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/providers/tts/tencent.py +0 -0
  124. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/providers/tts/volcengine.py +0 -0
  125. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/providers/tts/windows_sapi.py +0 -0
  126. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/server/__init__.py +0 -0
  127. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/server/auth.py +0 -0
  128. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/server/middleware.py +0 -0
  129. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/server/native_installer.py +0 -0
  130. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/server/routes/__init__.py +0 -0
  131. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/server/routes/stt.py +0 -0
  132. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/server/routes/tts.py +0 -0
  133. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/server/routes/webui.py +0 -0
  134. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/server/webui/index.html +0 -0
  135. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/server/webui/styles.css +0 -0
  136. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/server/ws/__init__.py +0 -0
  137. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/server/ws/stt_stream.py +0 -0
  138. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/server/ws/tts_stream.py +0 -0
  139. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/telemetry/__init__.py +0 -0
  140. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/telemetry/perf.py +0 -0
  141. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/utils/__init__.py +0 -0
  142. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/utils/audio_converter.py +0 -0
  143. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/utils/audio_playback.py +0 -0
  144. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/openspeechapi/vendor_registry.yaml +0 -0
  145. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/providers.example.yaml +0 -0
  146. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/scripts/aim_adopt.py +0 -0
  147. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/scripts/aim_consumers.py +0 -0
  148. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/scripts/engines/cloud/install.sh +0 -0
  149. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/scripts/engines/faster-whisper/native/install.sh +0 -0
  150. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/scripts/engines/fish-speech/native/install.sh +0 -0
  151. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/scripts/engines/macos-stt/_bundle.sh +0 -0
  152. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/scripts/engines/macos-stt/install.sh +0 -0
  153. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/scripts/engines/macos-stt/macos_stt.swift +0 -0
  154. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/scripts/engines/macos-stt/request_auth.swift +0 -0
  155. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/scripts/engines/sherpa-onnx/native/install.sh +0 -0
  156. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/scripts/engines/sherpa-onnx/native/run_streaming_server.py +0 -0
  157. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/scripts/engines/whisper/native/install.sh +0 -0
  158. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/scripts/engines/whisperlivekit/native/install.sh +0 -0
  159. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/scripts/gen_mms_languages.py +0 -0
  160. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/scripts/preload_stt_model.py +0 -0
  161. {openspeechapi-0.2.10 → openspeechapi-0.2.11}/scripts/release.sh +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: openspeechapi
3
- Version: 0.2.10
3
+ Version: 0.2.11
4
4
  Summary: Unified speech interface for STT/TTS providers
5
5
  Requires-Python: >=3.11
6
6
  Requires-Dist: httpx>=0.27
@@ -70,7 +70,11 @@ Requires-Dist: funasr>=1.1.0; extra == 'funasr-stt'
70
70
  Requires-Dist: torch; extra == 'funasr-stt'
71
71
  Requires-Dist: torchaudio; extra == 'funasr-stt'
72
72
  Provides-Extra: gemma4-stt
73
+ Requires-Dist: accelerate; (sys_platform != 'darwin') and extra == 'gemma4-stt'
74
+ Requires-Dist: librosa; (sys_platform != 'darwin') and extra == 'gemma4-stt'
73
75
  Requires-Dist: mlx-vlm<0.6.2,>=0.6.1; (sys_platform == 'darwin') and extra == 'gemma4-stt'
76
+ Requires-Dist: torch; (sys_platform != 'darwin') and extra == 'gemma4-stt'
77
+ Requires-Dist: transformers; (sys_platform != 'darwin') and extra == 'gemma4-stt'
74
78
  Provides-Extra: google
75
79
  Provides-Extra: google-stt
76
80
  Provides-Extra: google-tts
@@ -99,6 +99,13 @@ class ServerConfig:
99
99
  # Root dir for non-aim model downloads. "" → ~/.openspeechapi/models. When aim
100
100
  # is installed it manages its own ~/AI store and this is unused.
101
101
  model_storage_root: str = ""
102
+ # Robust download: HF mirror endpoints tried (in order) when huggingface.co is
103
+ # unreachable — model_hub auto-routes HF downloads here so HF-blocked boxes work
104
+ # without per-box hf_endpoint config. Exports OSA_HF_MIRRORS for workers.
105
+ hf_mirrors: list[str] = field(default_factory=lambda: ["https://hf-mirror.com"])
106
+ # Per-source download attempts (huggingface_hub resumes between attempts) before
107
+ # switching source. Exports OSA_MODEL_DOWNLOAD_RETRIES for workers.
108
+ model_download_retries: int = 3
102
109
 
103
110
 
104
111
  @dataclass
@@ -220,6 +227,14 @@ def load_config(path: Path) -> OpenSpeechConfig:
220
227
  server_cfg.hf_endpoint = srv.get("hf_endpoint", server_cfg.hf_endpoint)
221
228
  server_cfg.prefer_modelscope = bool(srv.get("prefer_modelscope", server_cfg.prefer_modelscope))
222
229
  server_cfg.model_storage_root = srv.get("model_storage_root", server_cfg.model_storage_root)
230
+ mirrors = srv.get("hf_mirrors")
231
+ if isinstance(mirrors, list) and mirrors:
232
+ server_cfg.hf_mirrors = [str(m).strip() for m in mirrors if str(m).strip()]
233
+ try:
234
+ server_cfg.model_download_retries = max(
235
+ 1, int(srv.get("model_download_retries", server_cfg.model_download_retries)))
236
+ except (TypeError, ValueError):
237
+ pass
223
238
  # explicit default_model_source wins; else legacy prefer_modelscope → ms
224
239
  src = str(srv.get("default_model_source", "")).strip().lower()
225
240
  if src in ("hf", "ms"):
@@ -68,6 +68,11 @@ class SpeechProvider(ABC):
68
68
  settings_cls: type[BaseSettings] = BaseSettings # Override in subclass
69
69
  capabilities: set[Capability]
70
70
  field_options: dict[str, list] = {} # Dropdown choices per setting key (override in subclass)
71
+ # Languages this engine supports, for the Engines/Catalog language-filter tags ONLY
72
+ # (canonical ISO codes, e.g. ["zh","en","ja"]). Use this for multilingual engines that
73
+ # don't enumerate languages in field_options.language (gemma4/phi4/qwen3-omni/whisper…).
74
+ # The backend merges it with field_options.language when deriving the `languages` shown.
75
+ supported_languages: list[str] = []
71
76
  # UI category: "cloud" | "local" | "native". Leave None to auto-derive from
72
77
  # ``execution_mode`` (remote→cloud, subprocess/local/in_process→local) and
73
78
  # the OS-native provider set. Only set explicitly to override that default.
@@ -0,0 +1,488 @@
1
+ """Resolve a model reference (local path or repo id) for transformers/mlx loaders,
2
+ with optional ModelScope acceleration (hub=ms) for HF-blocked / slow networks.
3
+
4
+ On the China deploy box ModelScope is ~9× faster than hf-mirror and sidesteps HF's
5
+ Xet CAS (unreachable there). See docs/architecture/modelscope-hub.md.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import time
11
+ from collections.abc import Mapping
12
+ from typing import Any
13
+
14
+ from openspeechapi.core.base import AimModelSpec, aim_local_path_or, aim_model_id
15
+ from openspeechapi.logging_config import logger
16
+
17
+
18
+ def _prefer_ms_env() -> bool:
19
+ """True when the server-level prefer_modelscope switch is on. ``create_app``
20
+ exports ``OSA_PREFER_MODELSCOPE=1`` from ``server.prefer_modelscope``; subprocess
21
+ workers inherit it (same mechanism as ``HF_ENDPOINT``)."""
22
+ return os.environ.get("OSA_PREFER_MODELSCOPE", "").strip().lower() in ("1", "true", "yes", "on")
23
+
24
+
25
+ # ── network reachability + retry policy (robust model download) ──────────────────
26
+ # Downloads fail on HF-blocked / flaky nets in two ways diagnosed in the field:
27
+ # (1) HF unreachable → loaders that hit huggingface.co hang then "model not found";
28
+ # (2) a reachable source's large file (e.g. SenseVoice's 893MB model.pt) breaks
29
+ # mid-transfer and the SDK doesn't resume → total failure.
30
+ # The helpers below add: a cached reachability probe, an auto HF-mirror, and retrying
31
+ # snapshot downloads with cross-source fallback. Used by resolve_model_source /
32
+ # resolve_hub_model so every provider that downloads through model_hub benefits.
33
+
34
+ _DEFAULT_HF_MIRRORS = ("https://hf-mirror.com",)
35
+ _REACH_TTL_S = 300.0 # cache reachability for 5 min (avoid re-probing per load)
36
+ _REACH_PROBE_TIMEOUT_S = 4.0
37
+ _reach_cache: dict[str, tuple[bool, float]] = {}
38
+
39
+
40
+ def _hf_mirrors() -> list[str]:
41
+ """HF mirror endpoints to try when huggingface.co is unreachable. Override via
42
+ OSA_HF_MIRRORS (comma-separated); exported from server.hf_mirrors."""
43
+ raw = os.environ.get("OSA_HF_MIRRORS", "").strip()
44
+ if raw:
45
+ return [m.strip() for m in raw.split(",") if m.strip()]
46
+ return list(_DEFAULT_HF_MIRRORS)
47
+
48
+
49
+ def _download_retries() -> int:
50
+ """Per-source download attempts (resume between attempts). Override via
51
+ OSA_MODEL_DOWNLOAD_RETRIES; exported from server.model_download_retries."""
52
+ try:
53
+ return max(1, int(os.environ.get("OSA_MODEL_DOWNLOAD_RETRIES", "3")))
54
+ except ValueError:
55
+ return 3
56
+
57
+
58
+ def _probe(url: str, timeout: float) -> bool:
59
+ """True if `url` answers (any non-5xx) within `timeout`s. httpx is a core dep;
60
+ import lazily so local-path-only resolves pay nothing."""
61
+ try:
62
+ import httpx
63
+ return httpx.get(url, timeout=timeout, follow_redirects=True).status_code < 500
64
+ except Exception:
65
+ return False
66
+
67
+
68
+ def _reachable(url: str, *, ttl: float = _REACH_TTL_S,
69
+ timeout: float = _REACH_PROBE_TIMEOUT_S) -> bool:
70
+ """Cached reachability for `url` (TTL-bounded so flapping nets re-probe)."""
71
+ now = time.monotonic()
72
+ hit = _reach_cache.get(url)
73
+ if hit is not None and (now - hit[1]) < ttl:
74
+ return hit[0]
75
+ ok = _probe(url, timeout)
76
+ _reach_cache[url] = (ok, now)
77
+ return ok
78
+
79
+
80
+ def reachable_sources() -> dict[str, bool]:
81
+ """Which model sources are reachable right now (cached): real HF, any HF mirror,
82
+ ModelScope. Drives reachability-aware source ordering."""
83
+ return {
84
+ "hf": _reachable("https://huggingface.co"),
85
+ "hf_mirror": any(_reachable(m) for m in _hf_mirrors()),
86
+ "ms": _reachable("https://www.modelscope.cn"),
87
+ }
88
+
89
+
90
+ def ensure_reachable_download_env() -> None:
91
+ """Point HF downloads at a reachable endpoint so a loader's OWN sub-downloads
92
+ (e.g. funasr's VAD model) also succeed on HF-blocked nets — not just the main
93
+ weights we materialise ourselves.
94
+
95
+ Respects an existing HF_ENDPOINT (operator/config override). Otherwise, when
96
+ huggingface.co is unreachable but a known mirror is up, exports the mirror and
97
+ disables Xet (mirrors don't proxy HF's Xet CAS → classic LFS). Idempotent."""
98
+ if os.environ.get("HF_ENDPOINT", "").strip():
99
+ return
100
+ if _reachable("https://huggingface.co"):
101
+ return
102
+ for m in _hf_mirrors():
103
+ if _reachable(m):
104
+ os.environ["HF_ENDPOINT"] = m
105
+ os.environ.setdefault("HF_HUB_DISABLE_XET", "1")
106
+ logger.info("huggingface.co unreachable → auto-routing HF downloads via "
107
+ "mirror {} (Xet disabled)", m)
108
+ return
109
+
110
+
111
+ def effective_source(hub: str) -> str:
112
+ """Effective download source for a model: 'hf' or 'ms'.
113
+
114
+ - explicit engine hub 'ms'/'hf' wins
115
+ - ``'auto'`` → reachability-aware: the operator default (OSA_DEFAULT_MODEL_SOURCE) when
116
+ its source is reachable; else ModelScope if reachable (funasr-native, works on
117
+ HF-blocked nets); else HuggingFace (direct or via auto-mirror). This only decides a
118
+ *fresh* download's source — _materialise reuses any already-downloaded copy first.
119
+ - hub '' → global OSA_DEFAULT_MODEL_SOURCE (default 'hf')
120
+ - back-compat: OSA_PREFER_MODELSCOPE=1 with no explicit default → 'ms'
121
+ """
122
+ h = (hub or "").strip().lower()
123
+ if h in ("hf", "ms"):
124
+ return h
125
+ glob = os.environ.get("OSA_DEFAULT_MODEL_SOURCE", "").strip().lower()
126
+ if h == "auto":
127
+ reach = reachable_sources()
128
+ if glob == "hf" and (reach["hf"] or reach["hf_mirror"]):
129
+ return "hf"
130
+ if glob == "ms" and reach["ms"]:
131
+ return "ms"
132
+ if reach["ms"]:
133
+ return "ms" # ModelScope reachable → prefer it (HF-block-proof)
134
+ return "hf" # HF direct, or auto-mirror via ensure_reachable_download_env
135
+ if glob in ("hf", "ms"):
136
+ return glob
137
+ if _prefer_ms_env():
138
+ return "ms"
139
+ return "hf"
140
+
141
+
142
+ def default_storage_root() -> str:
143
+ """Root dir for non-aim downloads. OSA_MODEL_ROOT if set, else
144
+ ~/.openspeechapi/models. (When aim manages models this is unused.)"""
145
+ root = os.environ.get("OSA_MODEL_ROOT", "").strip()
146
+ if root:
147
+ return os.path.expanduser(root)
148
+ return os.path.expanduser("~/.openspeechapi/models")
149
+
150
+
151
+ def _ensure_symlink(link: str, target: str) -> str:
152
+ """Make `link` point at `target` (for loaders that require a fixed path).
153
+ Idempotent: repoints a stale symlink; NEVER clobbers a real file/dir
154
+ (logs and returns it as-is — see the safety rule). Returns the path to use."""
155
+ link = os.path.expanduser(link)
156
+ target = os.path.expanduser(target)
157
+ if os.path.islink(link):
158
+ if os.path.realpath(link) == os.path.realpath(target):
159
+ return link
160
+ os.unlink(link) # stale symlink → repoint
161
+ elif os.path.exists(link):
162
+ logger.warning("symlink: {} is a real path (not a symlink); using as-is, "
163
+ "not linking to {}", link, target)
164
+ return link
165
+ parent = os.path.dirname(link)
166
+ if parent:
167
+ os.makedirs(parent, exist_ok=True)
168
+ os.symlink(target, link)
169
+ return link
170
+
171
+
172
+ def _maybe_symlink(fixed_path: str, resolved: str) -> str:
173
+ """If a loader requires a fixed path, symlink it to the resolved dir; else
174
+ return the resolved dir unchanged."""
175
+ return _ensure_symlink(fixed_path, resolved) if fixed_path else resolved
176
+
177
+
178
+ def _aim_ensure(aim_spec: Any) -> str | None:
179
+ """Thin seam over aim_ensure_model (kept separate so tests can monkeypatch)."""
180
+ if aim_spec is None:
181
+ return None
182
+ try:
183
+ from openspeechapi.local_engines.aim_resolver import aim_ensure_model
184
+ return aim_ensure_model(aim_spec)
185
+ except Exception as exc: # noqa: BLE001 - aim missing/broken → fall through
186
+ logger.warning("aim resolve failed ({}); falling back to source download", exc)
187
+ return None
188
+
189
+
190
+ def _ms_snapshot(repo: str, *, retries: int = 1) -> str | None:
191
+ """modelscope.snapshot_download(repo) into the storage root, retried; None on
192
+ failure. ModelScope's SDK doesn't resume reliably, so a broken large file restarts
193
+ — the cross-source fallback in _materialise (→ hf-mirror) is the real safety net."""
194
+ try:
195
+ from modelscope import snapshot_download
196
+ except ImportError:
197
+ return None
198
+ cache = os.path.join(default_storage_root(), "modelscope")
199
+ os.makedirs(cache, exist_ok=True)
200
+ last: Exception | None = None
201
+ for attempt in range(1, retries + 1):
202
+ try:
203
+ return snapshot_download(repo, cache_dir=cache)
204
+ except Exception as exc: # noqa: BLE001 - no ms mirror / network
205
+ last = exc
206
+ logger.warning("ModelScope snapshot {} attempt {}/{} failed ({})",
207
+ repo, attempt, retries, exc)
208
+ logger.warning("ModelScope snapshot failed for {} after {} attempt(s) ({})",
209
+ repo, retries, last)
210
+ return None
211
+
212
+
213
+ def _hf_snapshot(repo: str, *, endpoint: str | None = None, retries: int = 1) -> str | None:
214
+ """huggingface_hub.snapshot_download(repo) into the storage root, retried; None on
215
+ failure. huggingface_hub resumes partially-downloaded files between attempts, so
216
+ retrying recovers from broken large transfers (the SenseVoice model.pt case).
217
+
218
+ ``endpoint`` overrides HF_ENDPOINT for this call (e.g. an auto-selected mirror);
219
+ a mirror endpoint also disables Xet (mirrors don't proxy HF's Xet CAS). Runs in the
220
+ provider's worker (one model at a time) so the env save/restore can't race."""
221
+ try:
222
+ from huggingface_hub import snapshot_download
223
+ except ImportError:
224
+ return None
225
+ cache = os.path.join(default_storage_root(), "huggingface")
226
+ os.makedirs(cache, exist_ok=True)
227
+ saved = {k: os.environ.get(k) for k in ("HF_ENDPOINT", "HF_HUB_DISABLE_XET")}
228
+ if endpoint:
229
+ os.environ["HF_ENDPOINT"] = endpoint
230
+ if "huggingface.co" not in endpoint:
231
+ os.environ["HF_HUB_DISABLE_XET"] = "1"
232
+ last: Exception | None = None
233
+ try:
234
+ for attempt in range(1, retries + 1):
235
+ try:
236
+ return snapshot_download(repo, cache_dir=cache)
237
+ except Exception as exc: # noqa: BLE001 - network/not found → caller falls back
238
+ last = exc
239
+ logger.warning("HF snapshot {} attempt {}/{} failed ({})",
240
+ repo, attempt, retries, exc)
241
+ logger.warning("HF snapshot failed for {} after {} attempt(s) ({})",
242
+ repo, retries, last)
243
+ return None
244
+ finally:
245
+ for k, v in saved.items():
246
+ if v is None:
247
+ os.environ.pop(k, None)
248
+ else:
249
+ os.environ[k] = v
250
+
251
+
252
+ def _download_archive(url: str, model_id: str) -> str | None:
253
+ """Download an archive (zip/tar) from a custom URL into <root>/url/<id> and
254
+ unpack it; return the dir. None on failure. (Engines without an HF/ms source.)"""
255
+ import shutil
256
+ import tempfile
257
+ import urllib.request
258
+ dest = os.path.join(default_storage_root(), "url", model_id)
259
+ marker = os.path.join(dest, ".download_complete")
260
+ if os.path.isfile(marker):
261
+ return dest
262
+ try:
263
+ os.makedirs(dest, exist_ok=True)
264
+ with tempfile.NamedTemporaryFile(delete=False) as tf:
265
+ with urllib.request.urlopen(url) as resp: # noqa: S310 - operator-configured URL
266
+ shutil.copyfileobj(resp, tf)
267
+ tmp = tf.name
268
+ try:
269
+ shutil.unpack_archive(tmp, dest)
270
+ finally:
271
+ os.unlink(tmp)
272
+ open(marker, "w").close() # noqa: WPS515 - sentinel written only on success
273
+ return dest
274
+ except Exception as exc: # noqa: BLE001
275
+ logger.warning("custom-url download failed for {} ({})", url, exc)
276
+ return None
277
+
278
+
279
+ def _download_candidates(
280
+ repo: str, hub: str, ms_repo_map: Mapping[str, str] | None,
281
+ ) -> list[tuple[str, str, str | None]]:
282
+ """Ordered ``(kind, repo_id, endpoint)`` download attempts for ``repo``, by hub
283
+ preference and current reachability.
284
+
285
+ - ms candidate only when an ms repo id is known (explicit map, or hub=ms so the
286
+ configured id *is* the ms id) AND ModelScope is reachable — never guess an HF org
287
+ onto ModelScope (the "FunAudioLLM/… not exists on ms" failure mode).
288
+ - hf candidate endpoint: operator HF_ENDPOINT > real HF (if up) > reachable mirror.
289
+ """
290
+ reach = reachable_sources()
291
+ ms_map = ms_repo_map or {}
292
+ pref = effective_source(hub)
293
+ explicit_endpoint = os.environ.get("HF_ENDPOINT", "").strip() or None
294
+
295
+ def hf_cand() -> tuple[str, str, str | None] | None:
296
+ if explicit_endpoint:
297
+ return ("hf", repo, explicit_endpoint)
298
+ if reach["hf"]:
299
+ return ("hf", repo, None)
300
+ for m in _hf_mirrors():
301
+ if _reachable(m):
302
+ return ("hf", repo, m)
303
+ return None
304
+
305
+ def ms_cand() -> tuple[str, str, str | None] | None:
306
+ rid = ms_map.get(repo) or (repo if (hub or "").strip().lower() == "ms" else None)
307
+ return ("ms", rid, None) if (rid and reach["ms"]) else None
308
+
309
+ ordered = [ms_cand(), hf_cand()] if pref == "ms" else [hf_cand(), ms_cand()]
310
+ return [c for c in ordered if c is not None]
311
+
312
+
313
+ def _hf_local(repo: str) -> str | None:
314
+ """Path to an already-downloaded HF snapshot of ``repo`` (no network), or None.
315
+ Checks both the default HF cache (where funasr/transformers download) and our storage
316
+ root, so an existing copy is reused no matter which path originally fetched it."""
317
+ try:
318
+ from huggingface_hub import snapshot_download
319
+ except ImportError:
320
+ return None
321
+ for cache_dir in (None, os.path.join(default_storage_root(), "huggingface")):
322
+ try:
323
+ return snapshot_download(repo, cache_dir=cache_dir, local_files_only=True)
324
+ except Exception: # noqa: BLE001 - not in this cache → try the next / give up
325
+ continue
326
+ return None
327
+
328
+
329
+ def _ms_local(repo: str) -> str | None:
330
+ """Path to an already-downloaded ModelScope snapshot of ``repo`` (no network), or None.
331
+ Checks our storage root and the default modelscope cache."""
332
+ for base in (os.path.join(default_storage_root(), "modelscope"),
333
+ os.path.expanduser("~/.cache/modelscope/hub")):
334
+ d = os.path.join(base, *repo.split("/"))
335
+ if os.path.isdir(d) and os.listdir(d):
336
+ return d
337
+ return None
338
+
339
+
340
+ def _local_hit(kind: str, rid: str) -> str | None:
341
+ """An already-downloaded local copy for this candidate (no network), or None."""
342
+ return _ms_local(rid) if kind == "ms" else _hf_local(rid)
343
+
344
+
345
+ def _materialise(repo: str, hub: str, ms_repo_map: Mapping[str, str] | None) -> str | None:
346
+ """Resolve ``repo`` to a local dir. **Existing copy first**: reuse an already-downloaded
347
+ copy from ANY candidate platform (local, no network, no re-download) before fetching —
348
+ so a model already present via one hub is NOT re-downloaded just because the other hub
349
+ is preferred (e.g. ms-preferred but the hf copy already exists). Only when nothing is
350
+ local does it download from the preferred reachable source, switching sources on
351
+ failure. None if all candidates fail."""
352
+ cands = _download_candidates(repo, hub, ms_repo_map)
353
+ # Pass 1: reuse any already-downloaded copy (any candidate platform).
354
+ for kind, rid, _endpoint in cands:
355
+ hit = _local_hit(kind, rid)
356
+ if hit:
357
+ logger.info("model download: reusing local {} copy of {} ({})", kind, rid, hit)
358
+ return hit
359
+ # Pass 2: nothing local → download from the preferred reachable source.
360
+ retries = _download_retries()
361
+ for kind, rid, endpoint in cands:
362
+ logger.info("model download: {} ← {} source {} (endpoint={})",
363
+ repo, kind, rid, endpoint or "default")
364
+ got = (_ms_snapshot(rid, retries=retries) if kind == "ms"
365
+ else _hf_snapshot(rid, endpoint=endpoint, retries=retries))
366
+ if got:
367
+ logger.info("model download: {} ready at {} (via {})", repo, got, kind)
368
+ return got
369
+ logger.warning("model download: {} source {} failed; switching source", kind, rid)
370
+ return None
371
+
372
+
373
+ def local_model_path(repo: str, ms_repo_map: Mapping[str, str] | None = None) -> str | None:
374
+ """An already-downloaded local copy of ``repo`` from EITHER platform (HF cache or
375
+ ModelScope cache), or None. Lets a caller skip a re-download when a copy already exists
376
+ under the other hub's id/cache — e.g. so `hub=auto` preferring ModelScope won't refetch
377
+ a model that's already present from a prior HuggingFace download."""
378
+ ms_repo = (ms_repo_map or {}).get(repo, repo)
379
+ return _hf_local(repo) or _ms_local(ms_repo)
380
+
381
+
382
+ def build_aim_spec(model: str, hub: str, ms_repo_map: Mapping[str, str] | None = None,
383
+ category: str = "asr/model") -> AimModelSpec | None:
384
+ """Shared ``aim_model_spec`` body for HF⇄ModelScope-mirrored models.
385
+
386
+ Returns None when a copy is already downloaded on EITHER platform — so the worker's
387
+ existing-copy-first resolve reuses it instead of aim re-downloading from the (possibly
388
+ different) preferred hub. Otherwise resolves ``hub`` ('auto' → a reachable platform) to a
389
+ concrete source and builds the spec with the matching repo id (``<scheme>-<org>-<repo>``,
390
+ so `aim scan`-adopted weights resolve). aim-store copies are found via `aim resolve`, so
391
+ those are still reused even though local_model_path doesn't see the store."""
392
+ if local_model_path(model, ms_repo_map):
393
+ return None
394
+ scheme = "ms" if effective_source(hub) == "ms" else "hf"
395
+ repo = (ms_repo_map or {}).get(model, model) if scheme == "ms" else model
396
+ return AimModelSpec(source=f"{scheme}:{repo}",
397
+ model_id=aim_model_id(repo, scheme), category=category)
398
+
399
+
400
+ def resolve_model_source(
401
+ repo: str,
402
+ *,
403
+ hub: str = "",
404
+ model_path: str = "",
405
+ aim_spec: Any = None,
406
+ ms_repo_map: Mapping[str, str] | None = None,
407
+ custom_url: str = "",
408
+ fixed_path: str = "",
409
+ category: str = "asr/model", # noqa: ARG001 - reserved; aim_spec already carries it
410
+ snapshot: bool = False,
411
+ ) -> str:
412
+ """Return a loadable local path (or a repo id for HF loaders). See
413
+ docs/architecture/unified-model-download.md §2 for the priority chain.
414
+
415
+ When ``snapshot=True`` and the source falls through to HF, the function
416
+ calls ``_hf_snapshot`` to materialise a real local directory (honoring
417
+ ``HF_ENDPOINT``). Loaders that require a local dir (dolphin, etc.) use this
418
+ instead of receiving a bare repo id. On failure it falls back to the repo id
419
+ so callers are never broken."""
420
+ # 1. explicit local path wins.
421
+ local = aim_local_path_or(model_path, "")
422
+ if local:
423
+ return _maybe_symlink(fixed_path, local)
424
+ # 2. aim (when available + opted in). None → native-CAS already in aim cache OR
425
+ # aim absent → fall through (the hf branch returns the repo id).
426
+ aim_path = _aim_ensure(aim_spec)
427
+ if aim_path:
428
+ return _maybe_symlink(fixed_path, aim_path)
429
+ # 3. engine custom url (engines with no HF/ms repo-id load path).
430
+ if custom_url:
431
+ url_dir = _download_archive(custom_url, repo.replace("/", "-"))
432
+ if url_dir:
433
+ return _maybe_symlink(fixed_path, url_dir)
434
+ # 4. make a loader's OWN sub-downloads (e.g. funasr's VAD) reach HF on blocked nets.
435
+ ensure_reachable_download_env()
436
+ # 5. robust download → local dir, when one is needed: the loader wants a local dir
437
+ # (snapshot), ModelScope is the source (loaders take a dir there), or HF is
438
+ # unreachable (handing back a bare repo id would dead-end). Reachability-aware,
439
+ # retrying, cross-source. Otherwise (HF reachable, hf loader) return the repo id
440
+ # so the loader fetches directly — the long-standing fast path.
441
+ if (snapshot or (hub or "").strip().lower() == "auto"
442
+ or effective_source(hub) == "ms" or not reachable_sources()["hf"]):
443
+ got = _materialise(repo, hub, ms_repo_map)
444
+ if got:
445
+ return _maybe_symlink(fixed_path, got)
446
+ logger.warning("model download: all sources failed for {}; returning repo id "
447
+ "(loader will try directly)", repo)
448
+ return repo
449
+
450
+
451
+ def effective_hub(hub: str) -> str:
452
+ """Effective download hub for a model.
453
+
454
+ - explicit ``ms`` → ``ms``
455
+ - ``""`` / ``hf`` while the global ``OSA_PREFER_MODELSCOPE`` switch is on → ``ms``
456
+ - else → ``hf``
457
+ """
458
+ h = (hub or "").strip().lower()
459
+ if h == "ms":
460
+ return "ms"
461
+ if h in ("", "hf") and _prefer_ms_env():
462
+ return "ms"
463
+ return "hf"
464
+
465
+
466
+ def resolve_hub_model(
467
+ model: str,
468
+ hub: str = "hf",
469
+ *,
470
+ model_path: str = "",
471
+ ms_repo_map: Mapping[str, str] | None = None,
472
+ ) -> str:
473
+ """Resolve a ``from_pretrained``-style model ref (funasr / transformers loaders) to
474
+ a loadable local dir or a repo id, with reachability-aware, retrying, cross-source
475
+ download (auto HF mirror on blocked nets, ModelScope ↔ HF fallback). Returns a bare
476
+ repo id only when HF is reachable and the loader can fetch it itself (fast path).
477
+
478
+ No aim trigger here — callers aim-provision ``model_path`` out of band (main
479
+ process); an aim-provisioned local path wins and survives aim uninstall (no
480
+ re-download). Missing ``modelscope`` no longer raises: the cross-source fallback
481
+ routes to HF / a mirror instead of breaking the load."""
482
+ local = aim_local_path_or(model_path, "")
483
+ if local:
484
+ return local
485
+ return resolve_model_source(
486
+ model, hub=hub, model_path=model_path, ms_repo_map=ms_repo_map,
487
+ aim_spec=None, snapshot=False,
488
+ )
@@ -397,13 +397,16 @@ class ServiceDispatcher:
397
397
  c.value if hasattr(c, "value") else str(c) for c in caps
398
398
  )
399
399
 
400
- # Supported languages from field_options (for the Engines tag filter).
401
- # Drop the auto-detect / non-speech sentinels — they aren't languages.
400
+ # Supported languages for the Engines tag filter: field_options.language plus the
401
+ # provider's `supported_languages` (multilingual engines that don't enumerate a
402
+ # language setting). Drop the auto-detect / non-speech sentinels — not languages.
402
403
  field_opts = getattr(provider_cls, "field_options", {}) or {}
403
- languages = [
404
- str(x) for x in (field_opts.get("language") or [])
405
- if str(x).lower() not in ("auto", "nospeech")
406
- ]
404
+ _langs = list(field_opts.get("language") or []) + list(
405
+ getattr(provider_cls, "supported_languages", []) or []
406
+ )
407
+ languages = list(dict.fromkeys(
408
+ str(x) for x in _langs if str(x).lower() not in ("auto", "nospeech")
409
+ ))
407
410
 
408
411
  # Pick display-friendly settings
409
412
  display_info = {}
@@ -8,6 +8,8 @@ import shutil
8
8
  import subprocess
9
9
  from typing import Any
10
10
 
11
+ from openspeechapi.logging_config import logger
12
+
11
13
  # Resolve is a quick registry lookup; downloads can be large. Both are bounded so
12
14
  # a stalled aim CLI (e.g. a blocked network) can't hang forever — on timeout the
13
15
  # caller falls back to the provider's own download.
@@ -147,13 +149,23 @@ def _aim_download(source: str, model_id: str, category: str) -> bool:
147
149
 
148
150
  Bounded by ``OSA_AIM_DOWNLOAD_TIMEOUT_S`` (default 1800s); on timeout/failure
149
151
  returns False so the caller falls back to the provider's own download."""
150
- args = ["download", source, "--name", model_id]
152
+ # ``-y`` auto-confirms installing aim's missing backend tools (modelscope /
153
+ # huggingface_hub) so a server-driven (no-stdin) ``aim download`` never blocks on the
154
+ # interactive "Install backend? [y/N]" prompt and aborts — aim >= 0.2.0. (Equivalent:
155
+ # AIM_ASSUME_YES=1 env or defaults.auto_install_backend in aim's config.)
156
+ args = ["download", source, "--name", model_id, "-y"]
151
157
  if category:
152
158
  args += ["--category", category]
153
159
  try:
154
160
  _run_aim(args, timeout=_download_timeout_s())
155
161
  return True
156
- except Exception:
162
+ except Exception as exc: # noqa: BLE001 - any failure → provider falls back, but log why
163
+ # Don't fail silently — surface the reason (network down, backend install failed, …)
164
+ # so it's diagnosable; the provider then falls back to its own robust download.
165
+ logger.warning(
166
+ "aim download failed for {} (name={}): {} — falling back to the provider's "
167
+ "own download.", source, model_id, str(exc)[:300],
168
+ )
157
169
  return False
158
170
 
159
171