openspeechapi 0.1.0__py3-none-any.whl

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 (118) hide show
  1. openspeech/__init__.py +75 -0
  2. openspeech/__main__.py +5 -0
  3. openspeech/cli.py +413 -0
  4. openspeech/client/__init__.py +4 -0
  5. openspeech/client/client.py +145 -0
  6. openspeech/config.py +212 -0
  7. openspeech/core/__init__.py +0 -0
  8. openspeech/core/base.py +75 -0
  9. openspeech/core/enums.py +39 -0
  10. openspeech/core/models.py +61 -0
  11. openspeech/core/registry.py +37 -0
  12. openspeech/core/settings.py +8 -0
  13. openspeech/demo.py +675 -0
  14. openspeech/dispatch/__init__.py +0 -0
  15. openspeech/dispatch/context.py +34 -0
  16. openspeech/dispatch/dispatcher.py +661 -0
  17. openspeech/dispatch/executors/__init__.py +0 -0
  18. openspeech/dispatch/executors/base.py +34 -0
  19. openspeech/dispatch/executors/in_process.py +66 -0
  20. openspeech/dispatch/executors/remote.py +64 -0
  21. openspeech/dispatch/executors/subprocess_exec.py +446 -0
  22. openspeech/dispatch/fanout.py +95 -0
  23. openspeech/dispatch/filters.py +73 -0
  24. openspeech/dispatch/lifecycle.py +178 -0
  25. openspeech/dispatch/watcher.py +82 -0
  26. openspeech/engine_catalog.py +236 -0
  27. openspeech/engine_registry.yaml +347 -0
  28. openspeech/exceptions.py +51 -0
  29. openspeech/factory.py +325 -0
  30. openspeech/local_engines/__init__.py +12 -0
  31. openspeech/local_engines/aim_resolver.py +91 -0
  32. openspeech/local_engines/backends/__init__.py +1 -0
  33. openspeech/local_engines/backends/docker_backend.py +490 -0
  34. openspeech/local_engines/backends/native_backend.py +902 -0
  35. openspeech/local_engines/base.py +30 -0
  36. openspeech/local_engines/engines/__init__.py +1 -0
  37. openspeech/local_engines/engines/faster_whisper.py +36 -0
  38. openspeech/local_engines/engines/fish_speech.py +33 -0
  39. openspeech/local_engines/engines/sherpa_onnx.py +56 -0
  40. openspeech/local_engines/engines/whisper.py +41 -0
  41. openspeech/local_engines/engines/whisperlivekit.py +60 -0
  42. openspeech/local_engines/manager.py +208 -0
  43. openspeech/local_engines/models.py +50 -0
  44. openspeech/local_engines/progress.py +69 -0
  45. openspeech/local_engines/registry.py +19 -0
  46. openspeech/local_engines/task_store.py +52 -0
  47. openspeech/local_engines/tasks.py +71 -0
  48. openspeech/logging_config.py +607 -0
  49. openspeech/observe/__init__.py +0 -0
  50. openspeech/observe/base.py +79 -0
  51. openspeech/observe/debug.py +44 -0
  52. openspeech/observe/latency.py +19 -0
  53. openspeech/observe/metrics.py +47 -0
  54. openspeech/observe/tracing.py +44 -0
  55. openspeech/observe/usage.py +27 -0
  56. openspeech/providers/__init__.py +0 -0
  57. openspeech/providers/_template.py +101 -0
  58. openspeech/providers/stt/__init__.py +0 -0
  59. openspeech/providers/stt/alibaba.py +86 -0
  60. openspeech/providers/stt/assemblyai.py +135 -0
  61. openspeech/providers/stt/azure_speech.py +99 -0
  62. openspeech/providers/stt/baidu.py +135 -0
  63. openspeech/providers/stt/deepgram.py +311 -0
  64. openspeech/providers/stt/elevenlabs.py +385 -0
  65. openspeech/providers/stt/faster_whisper.py +211 -0
  66. openspeech/providers/stt/google_cloud.py +106 -0
  67. openspeech/providers/stt/iflytek.py +427 -0
  68. openspeech/providers/stt/macos_speech.py +226 -0
  69. openspeech/providers/stt/openai.py +84 -0
  70. openspeech/providers/stt/sherpa_onnx.py +353 -0
  71. openspeech/providers/stt/tencent.py +212 -0
  72. openspeech/providers/stt/volcengine.py +107 -0
  73. openspeech/providers/stt/whisper.py +153 -0
  74. openspeech/providers/stt/whisperlivekit.py +530 -0
  75. openspeech/providers/stt/windows_speech.py +249 -0
  76. openspeech/providers/tts/__init__.py +0 -0
  77. openspeech/providers/tts/alibaba.py +95 -0
  78. openspeech/providers/tts/azure_speech.py +123 -0
  79. openspeech/providers/tts/baidu.py +143 -0
  80. openspeech/providers/tts/coqui.py +64 -0
  81. openspeech/providers/tts/cosyvoice.py +90 -0
  82. openspeech/providers/tts/deepgram.py +174 -0
  83. openspeech/providers/tts/elevenlabs.py +311 -0
  84. openspeech/providers/tts/fish_speech.py +158 -0
  85. openspeech/providers/tts/google_cloud.py +107 -0
  86. openspeech/providers/tts/iflytek.py +209 -0
  87. openspeech/providers/tts/macos_say.py +251 -0
  88. openspeech/providers/tts/minimax.py +122 -0
  89. openspeech/providers/tts/openai.py +104 -0
  90. openspeech/providers/tts/piper.py +104 -0
  91. openspeech/providers/tts/tencent.py +189 -0
  92. openspeech/providers/tts/volcengine.py +117 -0
  93. openspeech/providers/tts/windows_sapi.py +234 -0
  94. openspeech/server/__init__.py +1 -0
  95. openspeech/server/app.py +72 -0
  96. openspeech/server/auth.py +42 -0
  97. openspeech/server/middleware.py +75 -0
  98. openspeech/server/routes/__init__.py +1 -0
  99. openspeech/server/routes/management.py +848 -0
  100. openspeech/server/routes/stt.py +121 -0
  101. openspeech/server/routes/tts.py +159 -0
  102. openspeech/server/routes/webui.py +29 -0
  103. openspeech/server/webui/app.js +2649 -0
  104. openspeech/server/webui/index.html +216 -0
  105. openspeech/server/webui/styles.css +617 -0
  106. openspeech/server/ws/__init__.py +1 -0
  107. openspeech/server/ws/stt_stream.py +263 -0
  108. openspeech/server/ws/tts_stream.py +207 -0
  109. openspeech/telemetry/__init__.py +21 -0
  110. openspeech/telemetry/perf.py +307 -0
  111. openspeech/utils/__init__.py +5 -0
  112. openspeech/utils/audio_converter.py +406 -0
  113. openspeech/utils/audio_playback.py +156 -0
  114. openspeech/vendor_registry.yaml +74 -0
  115. openspeechapi-0.1.0.dist-info/METADATA +101 -0
  116. openspeechapi-0.1.0.dist-info/RECORD +118 -0
  117. openspeechapi-0.1.0.dist-info/WHEEL +4 -0
  118. openspeechapi-0.1.0.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,848 @@
1
+ """Management endpoints."""
2
+ from __future__ import annotations
3
+ import asyncio
4
+ import json
5
+ import queue
6
+ import subprocess
7
+ import sys
8
+ from pathlib import Path
9
+ from pydantic import BaseModel, Field
10
+ from fastapi import APIRouter, HTTPException, Query, Request
11
+ from fastapi.responses import StreamingResponse
12
+
13
+ import yaml
14
+
15
+ from openspeech.engine_catalog import get_catalog, get_catalog_entry, get_installed_engines
16
+
17
+ router = APIRouter()
18
+
19
+
20
+ @router.get("/engines")
21
+ async def list_engines(request: Request):
22
+ dispatcher = request.app.state.dispatcher
23
+ engines = dispatcher.list_engines_info()
24
+ # Augment with async health check (not available in sync list_engines_info)
25
+ health = await dispatcher.health()
26
+ for eng in engines:
27
+ eng["healthy"] = health.get(eng["name"], False)
28
+ return {"engines": engines}
29
+
30
+
31
+ # Backward compatibility: old endpoint
32
+ @router.get("/providers")
33
+ async def list_providers(request: Request):
34
+ """Deprecated: use /engines instead."""
35
+ result = await list_engines(request)
36
+ return {"providers": result["engines"]}
37
+
38
+
39
+ @router.get("/health")
40
+ async def health_check(request: Request):
41
+ dispatcher = request.app.state.dispatcher
42
+ health = await dispatcher.health()
43
+ all_healthy = all(health.values()) if health else True
44
+ return {"status": "ok" if all_healthy else "degraded", "engines": health}
45
+
46
+
47
+ @router.get("/metrics")
48
+ async def metrics(request: Request):
49
+ dispatcher = request.app.state.dispatcher
50
+ from openspeech.observe.metrics import MetricsObserver
51
+
52
+ for obs in dispatcher._observer_mgr.list():
53
+ if isinstance(obs, MetricsObserver):
54
+ result = {}
55
+ for name in dispatcher.list_engines():
56
+ provider_metrics = {}
57
+ for method in ("transcribe", "synthesize"):
58
+ m = obs.get(name, method)
59
+ if m is not None:
60
+ provider_metrics[method] = {
61
+ "total_calls": m.total_calls,
62
+ "error_count": m.error_count,
63
+ "durations_ms": m.durations_ms,
64
+ "ttfb_ms": m.ttfb_ms,
65
+ }
66
+ result[name] = provider_metrics
67
+ return {"metrics": result}
68
+
69
+ return {"metrics": {}, "message": "No MetricsObserver registered"}
70
+
71
+
72
+ @router.post("/admin/reload")
73
+ async def reload_config(request: Request):
74
+ """Hot-reload: re-read providers.yaml and apply config changes without restart."""
75
+ dispatcher = request.app.state.dispatcher
76
+ config_path = request.app.state.config_path
77
+ registry = request.app.state.registry
78
+ result = await dispatcher.reload_config(config_path, registry)
79
+ return result
80
+
81
+
82
+ @router.get("/admin/states")
83
+ async def engine_states(request: Request):
84
+ """Return current lifecycle state for each registered engine."""
85
+ dispatcher = request.app.state.dispatcher
86
+ return {"states": dispatcher.provider_states()}
87
+
88
+
89
+ def _mask_secret(value: str) -> str:
90
+ """Mask sensitive values, showing first 4 chars + ****."""
91
+ if not value or len(value) <= 4:
92
+ return "****" if value else ""
93
+ return value[:4] + "****"
94
+
95
+
96
+ _SECRET_KEYS = {"api_key", "secret_key", "api_secret", "access_token", "subscription_key", "secret_id"}
97
+
98
+
99
+ def _sanitize_settings(settings: dict) -> dict:
100
+ """Mask secret fields in provider settings for safe API responses."""
101
+ out = {}
102
+ for k, v in settings.items():
103
+ if k in _SECRET_KEYS and isinstance(v, str) and v:
104
+ out[k] = _mask_secret(v)
105
+ else:
106
+ out[k] = v
107
+ return out
108
+
109
+
110
+ def _is_masked_secret_value(value: object) -> bool:
111
+ """Detect masked placeholder values like ``sk_xxxx****`` or ``****``."""
112
+ if not isinstance(value, str):
113
+ return False
114
+ if "****" not in value:
115
+ return False
116
+ # Treat any placeholder containing mask stars as masked, not real secret.
117
+ return True
118
+
119
+
120
+ def _restore_masked_provider_secrets(new_providers: dict, old_providers: dict) -> dict:
121
+ """Replace masked incoming provider secrets with existing raw values."""
122
+ if not isinstance(new_providers, dict):
123
+ return new_providers
124
+ restored = {}
125
+ for pname, spec in new_providers.items():
126
+ if not isinstance(spec, dict):
127
+ restored[pname] = spec
128
+ continue
129
+ prev = old_providers.get(pname, {}) if isinstance(old_providers, dict) else {}
130
+ merged = dict(spec)
131
+ for k, v in list(merged.items()):
132
+ if k in _SECRET_KEYS and _is_masked_secret_value(v):
133
+ if isinstance(prev, dict) and prev.get(k):
134
+ merged[k] = prev[k]
135
+ restored[pname] = merged
136
+ return restored
137
+
138
+
139
+ @router.get("/admin/config")
140
+ async def get_config(request: Request):
141
+ """Return current config as JSON with secrets masked."""
142
+ config_path: Path = request.app.state.config_path
143
+ if not config_path.exists():
144
+ raise HTTPException(status_code=404, detail="Config file not found")
145
+ with open(config_path, encoding="utf-8") as f:
146
+ raw = yaml.safe_load(f)
147
+
148
+ # Parse providers (shared credentials)
149
+ providers_out = {}
150
+ for name, spec in (raw.get("providers") or {}).items():
151
+ # In new format, providers are flat credential dicts
152
+ if isinstance(spec, dict) and "provider" not in spec and "exec_mode" not in spec:
153
+ providers_out[name] = _sanitize_settings(spec)
154
+
155
+ # Parse engines (or old-format providers)
156
+ engines_raw = raw.get("engines") or {}
157
+ if not engines_raw and "providers" in raw:
158
+ # Old format: providers contains engine configs
159
+ engines_raw = raw.get("providers") or {}
160
+ providers_out = {} # No credential providers in old format
161
+
162
+ engines_out = {}
163
+ for name, spec in engines_raw.items():
164
+ if not isinstance(spec, dict):
165
+ continue
166
+ # Skip if this is a credential provider (no exec_mode/provider field)
167
+ if "exec_mode" not in spec and "provider" not in spec:
168
+ continue
169
+ entry = {
170
+ "provider": spec.get("provider", ""),
171
+ "exec_mode": spec.get("exec_mode", "remote"),
172
+ "settings": _sanitize_settings(spec.get("settings", {})),
173
+ "preload": spec.get("preload", False),
174
+ }
175
+ # Resolve type/category/display_name/provider_key from engine catalog
176
+ cat_entry = get_catalog_entry(name)
177
+ if cat_entry:
178
+ entry["type"] = cat_entry.type
179
+ entry["category"] = cat_entry.category
180
+ entry["display_name"] = cat_entry.display_name
181
+ entry["provider_key"] = cat_entry.provider
182
+ engines_out[name] = entry
183
+
184
+ return {"providers": providers_out, "engines": engines_out}
185
+
186
+
187
+ @router.get("/admin/config/raw")
188
+ async def get_config_raw(request: Request):
189
+ """Return raw config content with secrets unmasked (for save round-trip)."""
190
+ config_path: Path = request.app.state.config_path
191
+ if not config_path.exists():
192
+ raise HTTPException(status_code=404, detail="Config file not found")
193
+ with open(config_path, encoding="utf-8") as f:
194
+ raw = yaml.safe_load(f)
195
+ return raw
196
+
197
+
198
+ class EngineConfigUpdate(BaseModel):
199
+ engines: dict
200
+ providers: dict = Field(default_factory=dict)
201
+
202
+
203
+ # Backward compatibility alias
204
+ ProviderConfigUpdate = EngineConfigUpdate
205
+
206
+
207
+ @router.put("/admin/config")
208
+ async def update_config(request: Request, body: EngineConfigUpdate):
209
+ """Write new config to providers.yaml and hot-reload."""
210
+ config_path: Path = request.app.state.config_path
211
+
212
+ # Read existing config to preserve non-engine sections (server, etc.)
213
+ existing = {}
214
+ if config_path.exists():
215
+ with open(config_path, encoding="utf-8") as f:
216
+ existing = yaml.safe_load(f) or {}
217
+
218
+ # Update engines and providers sections
219
+ if body.providers:
220
+ existing["providers"] = _restore_masked_provider_secrets(
221
+ body.providers,
222
+ existing.get("providers", {}),
223
+ )
224
+ existing["engines"] = body.engines
225
+
226
+ # Remove old-format 'providers' if it contained engine configs (migration)
227
+ if "providers" in existing and "engines" in existing:
228
+ prov = existing.get("providers", {})
229
+ # Check if providers section has engine-style entries
230
+ has_engine_entries = any(
231
+ isinstance(v, dict) and ("exec_mode" in v or "provider" in v)
232
+ for v in prov.values()
233
+ )
234
+ if has_engine_entries and not body.providers:
235
+ # Old format detected, remove engine entries from providers
236
+ existing.pop("providers", None)
237
+
238
+ # Write YAML
239
+ with open(config_path, "w", encoding="utf-8") as f:
240
+ yaml.dump(existing, f, default_flow_style=False, allow_unicode=True, sort_keys=False)
241
+
242
+ # Hot-reload
243
+ dispatcher = request.app.state.dispatcher
244
+ registry = request.app.state.registry
245
+ result = await dispatcher.reload_config(config_path, registry)
246
+ return {"status": "ok", "reload": result}
247
+
248
+
249
+ @router.get("/admin/provider-templates")
250
+ async def provider_templates(request: Request):
251
+ """Return all registered provider names and their default settings schema."""
252
+ from openspeech.factory import _PROVIDER_MAP, _resolve
253
+
254
+ templates = []
255
+ for name in sorted(_PROVIDER_MAP):
256
+ try:
257
+ provider_cls, settings_cls = _resolve(name)
258
+ # Get default settings by instantiating with no args
259
+ defaults = {}
260
+ if settings_cls:
261
+ s = settings_cls()
262
+ for k, v in s.__dict__.items():
263
+ if not k.startswith("_"):
264
+ defaults[k] = v
265
+ ptype = getattr(provider_cls, "provider_type", None)
266
+ # Look up field_options from catalog
267
+ cat_entry = get_catalog_entry(name)
268
+ if not cat_entry:
269
+ # Fallback: find by factory key (provider field in registry)
270
+ for _e in get_catalog():
271
+ if _e.provider == name:
272
+ cat_entry = _e
273
+ break
274
+ fopts = cat_entry.field_options if cat_entry else {}
275
+ display = cat_entry.display_name if cat_entry and cat_entry.display_name else name
276
+ templates.append({
277
+ "provider": name,
278
+ "display_name": display,
279
+ "type": ptype.value if ptype else "unknown",
280
+ "defaults": defaults,
281
+ "field_options": fopts,
282
+ })
283
+ except Exception:
284
+ templates.append({"provider": name, "display_name": name, "type": "unknown", "defaults": {}, "field_options": {}})
285
+ return {"templates": templates}
286
+
287
+
288
+ # ---- Vendor/Provider Templates ----
289
+
290
+ @router.get("/admin/vendor-templates")
291
+ async def vendor_templates(request: Request):
292
+ """Return vendor registry templates (shared credential field definitions)."""
293
+ from openspeech.engine_catalog import get_vendor_registry
294
+ return {"vendors": get_vendor_registry()}
295
+
296
+
297
+ @router.get("/admin/providers")
298
+ async def get_providers(request: Request):
299
+ """Return configured providers (shared credentials) with secrets masked."""
300
+ config_path: Path = request.app.state.config_path
301
+ if not config_path.exists():
302
+ return {"providers": {}}
303
+ with open(config_path, encoding="utf-8") as f:
304
+ raw = yaml.safe_load(f) or {}
305
+
306
+ # Only return providers in new format (flat credential dicts)
307
+ providers_out = {}
308
+ for name, spec in (raw.get("providers") or {}).items():
309
+ if isinstance(spec, dict) and "exec_mode" not in spec and "provider" not in spec:
310
+ providers_out[name] = _sanitize_settings(spec)
311
+ return {"providers": providers_out}
312
+
313
+
314
+ class ProvidersUpdate(BaseModel):
315
+ providers: dict
316
+
317
+
318
+ @router.put("/admin/providers")
319
+ async def update_providers(request: Request, body: ProvidersUpdate):
320
+ """Update providers (shared credentials) section and hot-reload."""
321
+ config_path: Path = request.app.state.config_path
322
+
323
+ existing = {}
324
+ if config_path.exists():
325
+ with open(config_path, encoding="utf-8") as f:
326
+ existing = yaml.safe_load(f) or {}
327
+
328
+ existing["providers"] = _restore_masked_provider_secrets(
329
+ body.providers,
330
+ existing.get("providers", {}),
331
+ )
332
+
333
+ with open(config_path, "w", encoding="utf-8") as f:
334
+ yaml.dump(existing, f, default_flow_style=False, allow_unicode=True, sort_keys=False)
335
+
336
+ # Hot-reload
337
+ dispatcher = request.app.state.dispatcher
338
+ registry = request.app.state.registry
339
+ result = await dispatcher.reload_config(config_path, registry)
340
+ return {"status": "ok", "reload": result}
341
+
342
+
343
+ # ---- Engine Catalog ----
344
+
345
+ @router.get("/engine-catalog")
346
+ async def engine_catalog(request: Request):
347
+ """Return full engine catalog with installation status.
348
+
349
+ Native meta-aliases (native-stt / native-tts) are excluded — only
350
+ platform-specific entries (windows-stt, macos-stt, etc.) are shown.
351
+ """
352
+ config_path: Path = request.app.state.config_path
353
+ installed = get_installed_engines(config_path)
354
+ catalog = get_catalog()
355
+
356
+ # Filter out native meta-alias entries
357
+ _NATIVE_META = {"native-stt", "native-tts"}
358
+
359
+ return {
360
+ "engines": [
361
+ {
362
+ "name": e.name,
363
+ "vendor": e.vendor,
364
+ "provider": e.provider,
365
+ "type": e.type,
366
+ "category": e.category,
367
+ "description": e.description,
368
+ "display_name": e.display_name or e.name,
369
+ "installed": e.name in installed,
370
+ "compatible": e.compatible,
371
+ "platforms": e.platforms,
372
+ "default_alias": e.default_alias,
373
+ "pip_deps": e.pip_deps,
374
+ "pip_extras": e.pip_extras,
375
+ "field_options": e.field_options,
376
+ }
377
+ for e in catalog
378
+ if e.name not in _NATIVE_META
379
+ ]
380
+ }
381
+
382
+
383
+ @router.post("/engine-catalog/{name}/install")
384
+ async def install_engine(request: Request, name: str):
385
+ """Install an engine: write config + install pip deps + hot-reload."""
386
+ entry = get_catalog_entry(name)
387
+ if entry is None:
388
+ raise HTTPException(status_code=404, detail=f"Engine '{name}' not found in catalog")
389
+
390
+ if not entry.compatible:
391
+ raise HTTPException(
392
+ status_code=400,
393
+ detail=f"Engine '{name}' is not compatible with this platform ({sys.platform})"
394
+ )
395
+
396
+ config_path: Path = request.app.state.config_path
397
+
398
+ # Read existing config
399
+ existing = {}
400
+ if config_path.exists():
401
+ with open(config_path, encoding="utf-8") as f:
402
+ existing = yaml.safe_load(f) or {}
403
+ if "engines" not in existing:
404
+ existing["engines"] = {}
405
+
406
+ # Check if already installed
407
+ installed = get_installed_engines(config_path)
408
+ if name in installed:
409
+ return {"status": "already_installed", "alias": entry.default_alias}
410
+
411
+ # Build engine entry
412
+ engine_entry = {
413
+ "exec_mode": entry.default_exec_mode,
414
+ "settings": dict(entry.default_settings),
415
+ }
416
+
417
+ # If engine has a vendor, reference it as provider
418
+ if entry.vendor:
419
+ engine_entry["provider"] = entry.vendor
420
+ # Remove settings keys that duplicate vendor shared fields (e.g. api_key)
421
+ # — these are inherited from vendor credentials at merge time
422
+ from openspeech.engine_catalog import get_vendor_registry
423
+ vendors = get_vendor_registry()
424
+ vendor_tpl = vendors.get(entry.vendor, {})
425
+ shared_keys = set(vendor_tpl.get("shared_fields", {}).keys())
426
+ engine_entry["settings"] = {
427
+ k: v for k, v in engine_entry["settings"].items()
428
+ if k not in shared_keys
429
+ }
430
+ # Auto-create provider entry if not exists
431
+ if "providers" not in existing:
432
+ existing["providers"] = {}
433
+ if entry.vendor not in existing.get("providers", {}):
434
+ # Create empty provider with required fields from vendor registry
435
+ from openspeech.engine_catalog import get_vendor_registry
436
+ vendors = get_vendor_registry()
437
+ vendor_tpl = vendors.get(entry.vendor, {})
438
+ provider_defaults = {}
439
+ for field_name, field_def in vendor_tpl.get("shared_fields", {}).items():
440
+ if isinstance(field_def, dict) and "default" in field_def:
441
+ provider_defaults[field_name] = field_def["default"]
442
+ else:
443
+ provider_defaults[field_name] = ""
444
+ existing["providers"][entry.vendor] = provider_defaults
445
+
446
+ existing["engines"][entry.default_alias] = engine_entry
447
+
448
+ with open(config_path, "w", encoding="utf-8") as f:
449
+ yaml.dump(existing, f, default_flow_style=False, allow_unicode=True, sort_keys=False)
450
+
451
+ # Run install script for cloud engines (pip deps + verification)
452
+ install_log = ""
453
+ if entry.category == "cloud" and entry.pip_deps:
454
+ script = Path(__file__).resolve().parents[3] / "scripts" / "engines" / "cloud" / "install.sh"
455
+ if script.exists():
456
+ env = {
457
+ **dict(__import__("os").environ),
458
+ "OPENSPEECH_ENGINE": entry.name,
459
+ "OPENSPEECH_PIP_DEPS": " ".join(entry.pip_deps),
460
+ "OPENSPEECH_PYTHON_BIN": sys.executable,
461
+ }
462
+ try:
463
+ proc = subprocess.run(
464
+ ["bash", str(script)],
465
+ capture_output=True, text=True, timeout=180, env=env,
466
+ )
467
+ install_log = proc.stdout
468
+ except Exception as e:
469
+ install_log = f"Install script error: {e}"
470
+
471
+ # Fallback: direct pip install for any remaining deps
472
+ pip_results = []
473
+ for dep in entry.pip_deps:
474
+ try:
475
+ # Check if already importable
476
+ subprocess.run(
477
+ [sys.executable, "-c", f"import {dep}"],
478
+ capture_output=True, check=True, timeout=10,
479
+ )
480
+ pip_results.append({"dep": dep, "status": "ok"})
481
+ except subprocess.CalledProcessError:
482
+ try:
483
+ subprocess.run(
484
+ [sys.executable, "-m", "pip", "install", dep, "--quiet"],
485
+ capture_output=True, text=True, check=True, timeout=120,
486
+ )
487
+ pip_results.append({"dep": dep, "status": "installed"})
488
+ except Exception as e:
489
+ pip_results.append({"dep": dep, "status": "failed", "error": str(e)})
490
+
491
+ # Hot-reload
492
+ dispatcher = request.app.state.dispatcher
493
+ registry = request.app.state.registry
494
+ reload_result = await dispatcher.reload_config(config_path, registry)
495
+
496
+ return {
497
+ "status": "installed",
498
+ "alias": entry.default_alias,
499
+ "pip": pip_results,
500
+ "install_log": install_log,
501
+ "reload": reload_result,
502
+ }
503
+
504
+
505
+ @router.post("/engine-catalog/{name}/uninstall")
506
+ async def uninstall_engine(request: Request, name: str):
507
+ """Uninstall an engine: remove from config + hot-reload."""
508
+ entry = get_catalog_entry(name)
509
+ if entry is None:
510
+ raise HTTPException(status_code=404, detail=f"Engine '{name}' not found in catalog")
511
+
512
+ config_path: Path = request.app.state.config_path
513
+
514
+ existing = {}
515
+ if config_path.exists():
516
+ with open(config_path, encoding="utf-8") as f:
517
+ existing = yaml.safe_load(f) or {}
518
+
519
+ engines = existing.get("engines", {})
520
+
521
+ # Find and remove aliases using this provider
522
+ removed = []
523
+ for alias in list(engines.keys()):
524
+ # Check if this engine matches by provider factory key
525
+ eng_spec = engines[alias]
526
+ provider_val = eng_spec.get("provider", "")
527
+ # Look up factory key from catalog
528
+ from openspeech.config import _resolve_factory_key
529
+ factory_key = _resolve_factory_key(alias, provider_val)
530
+ if factory_key == entry.provider:
531
+ del engines[alias]
532
+ removed.append(alias)
533
+
534
+ if not removed:
535
+ return {"status": "not_installed"}
536
+
537
+ existing["engines"] = engines
538
+ with open(config_path, "w", encoding="utf-8") as f:
539
+ yaml.dump(existing, f, default_flow_style=False, allow_unicode=True, sort_keys=False)
540
+
541
+ # Hot-reload
542
+ dispatcher = request.app.state.dispatcher
543
+ registry = request.app.state.registry
544
+ reload_result = await dispatcher.reload_config(config_path, registry)
545
+
546
+ return {"status": "uninstalled", "removed": removed, "reload": reload_result}
547
+
548
+
549
+ class BatchInstallRequest(BaseModel):
550
+ engines: list[str] = Field(..., description='Engine names to install, or ["all"] for all compatible')
551
+
552
+
553
+ @router.post("/engine-catalog/batch-install")
554
+ async def batch_install_engines(request: Request, body: BatchInstallRequest):
555
+ """Install multiple engines at once. Use ["all"] to install all compatible engines."""
556
+ config_path: Path = request.app.state.config_path
557
+ catalog = get_catalog()
558
+ installed = get_installed_engines(config_path)
559
+
560
+ # Resolve engine list
561
+ if body.engines == ["all"]:
562
+ to_install = [e for e in catalog if e.compatible and e.name not in installed]
563
+ else:
564
+ to_install = []
565
+ for name in body.engines:
566
+ entry = get_catalog_entry(name)
567
+ if entry is None:
568
+ continue
569
+ if not entry.compatible:
570
+ continue
571
+ if entry.name in installed:
572
+ continue
573
+ to_install.append(entry)
574
+
575
+ if not to_install:
576
+ return {"status": "nothing_to_install", "results": []}
577
+
578
+ # Read existing config
579
+ existing = {}
580
+ if config_path.exists():
581
+ with open(config_path, encoding="utf-8") as f:
582
+ existing = yaml.safe_load(f) or {}
583
+ if "engines" not in existing:
584
+ existing["engines"] = {}
585
+ if "providers" not in existing:
586
+ existing["providers"] = {}
587
+
588
+ # Load vendor registry for auto-creating provider entries
589
+ from openspeech.engine_catalog import get_vendor_registry
590
+ vendors = get_vendor_registry()
591
+
592
+ results = []
593
+ for entry in to_install:
594
+ # Build engine entry
595
+ engine_entry = {
596
+ "exec_mode": entry.default_exec_mode,
597
+ "settings": dict(entry.default_settings),
598
+ }
599
+
600
+ # If engine has a vendor, reference it as provider
601
+ if entry.vendor:
602
+ engine_entry["provider"] = entry.vendor
603
+ if entry.vendor not in existing["providers"]:
604
+ vendor_tpl = vendors.get(entry.vendor, {})
605
+ provider_defaults = {}
606
+ for field_name, field_def in vendor_tpl.get("shared_fields", {}).items():
607
+ if isinstance(field_def, dict) and "default" in field_def:
608
+ provider_defaults[field_name] = field_def["default"]
609
+ else:
610
+ provider_defaults[field_name] = ""
611
+ existing["providers"][entry.vendor] = provider_defaults
612
+
613
+ existing["engines"][entry.default_alias] = engine_entry
614
+
615
+ # Install pip deps
616
+ pip_status = "ok"
617
+ for dep in entry.pip_deps:
618
+ try:
619
+ subprocess.run(
620
+ [sys.executable, "-c", f"import {dep}"],
621
+ capture_output=True, check=True, timeout=10,
622
+ )
623
+ except subprocess.CalledProcessError:
624
+ try:
625
+ subprocess.run(
626
+ [sys.executable, "-m", "pip", "install", dep, "--quiet"],
627
+ capture_output=True, text=True, check=True, timeout=120,
628
+ )
629
+ except Exception:
630
+ pip_status = "pip_failed"
631
+
632
+ results.append({"name": entry.name, "alias": entry.default_alias, "pip": pip_status})
633
+
634
+ # Write config
635
+ with open(config_path, "w", encoding="utf-8") as f:
636
+ yaml.dump(existing, f, default_flow_style=False, allow_unicode=True, sort_keys=False)
637
+
638
+ # Hot-reload
639
+ dispatcher = request.app.state.dispatcher
640
+ registry = request.app.state.registry
641
+ reload_result = await dispatcher.reload_config(config_path, registry)
642
+
643
+ return {"status": "installed", "results": results, "reload": reload_result}
644
+
645
+
646
+ class EngineActionRequest(BaseModel):
647
+ engine: str = "fish-speech"
648
+ action: str
649
+ runtime: str = "docker"
650
+ api_url: str = "http://127.0.0.1:8080"
651
+ install_dir: str = "~/AI/services"
652
+ work_dir: str = ".openspeech/engines"
653
+ timeout_s: float = 120.0
654
+ retries: int = 0
655
+ options: dict = Field(default_factory=dict)
656
+
657
+
658
+ from openspeech.local_engines import EngineAction, RuntimeConfig
659
+
660
+
661
+ def _runtime_cfg_from_query(
662
+ runtime: str,
663
+ api_url: str,
664
+ install_dir: str,
665
+ work_dir: str,
666
+ timeout_s: float,
667
+ retries: int,
668
+ ) -> RuntimeConfig:
669
+ return RuntimeConfig(
670
+ runtime=runtime,
671
+ api_url=api_url,
672
+ install_dir=install_dir,
673
+ work_dir=work_dir,
674
+ timeout_s=timeout_s,
675
+ retries=retries,
676
+ options={},
677
+ )
678
+
679
+
680
+ def _task_snapshot(task) -> dict:
681
+ if hasattr(task, "snapshot"):
682
+ return task.snapshot()
683
+ raise TypeError("Task object must expose snapshot()")
684
+
685
+
686
+ @router.post("/engines/actions")
687
+ async def start_engine_action(request: Request, body: EngineActionRequest):
688
+ manager = request.app.state.engine_manager
689
+ try:
690
+ action = EngineAction(body.action)
691
+ cfg = RuntimeConfig(
692
+ runtime=body.runtime,
693
+ api_url=body.api_url,
694
+ install_dir=body.install_dir,
695
+ work_dir=body.work_dir,
696
+ timeout_s=body.timeout_s,
697
+ retries=body.retries,
698
+ options=body.options,
699
+ )
700
+ task = manager.run_action_async(body.engine, action, cfg)
701
+ return {"task": _task_snapshot(task)}
702
+ except Exception as exc:
703
+ raise HTTPException(status_code=400, detail=str(exc))
704
+
705
+
706
+ @router.get("/engines/status")
707
+ async def engine_status(
708
+ request: Request,
709
+ engine: str = Query(default="fish-speech"),
710
+ runtime: str = Query(default="docker"),
711
+ api_url: str = Query(default="http://127.0.0.1:8080"),
712
+ install_dir: str = Query(default="~/AI/services"),
713
+ work_dir: str = Query(default=".openspeech/engines"),
714
+ timeout_s: float = Query(default=120.0, ge=1.0, le=3600.0),
715
+ retries: int = Query(default=0, ge=0, le=10),
716
+ ):
717
+ manager = request.app.state.engine_manager
718
+ try:
719
+ cfg = _runtime_cfg_from_query(runtime, api_url, install_dir, work_dir, timeout_s, retries)
720
+ status = manager.status(engine, cfg)
721
+ return {
722
+ "engine": status.engine,
723
+ "runtime": status.runtime,
724
+ "running": status.running,
725
+ "healthy": status.healthy,
726
+ "detail": status.detail,
727
+ "metadata": status.metadata,
728
+ }
729
+ except Exception as exc:
730
+ raise HTTPException(status_code=400, detail=str(exc))
731
+
732
+
733
+ @router.get("/engines/logs")
734
+ async def engine_logs(
735
+ request: Request,
736
+ engine: str = Query(default="fish-speech"),
737
+ runtime: str = Query(default="docker"),
738
+ api_url: str = Query(default="http://127.0.0.1:8080"),
739
+ install_dir: str = Query(default="~/AI/services"),
740
+ work_dir: str = Query(default=".openspeech/engines"),
741
+ timeout_s: float = Query(default=120.0, ge=1.0, le=3600.0),
742
+ retries: int = Query(default=0, ge=0, le=10),
743
+ lines: int = Query(default=100, ge=1, le=2000),
744
+ ):
745
+ manager = request.app.state.engine_manager
746
+ try:
747
+ cfg = _runtime_cfg_from_query(runtime, api_url, install_dir, work_dir, timeout_s, retries)
748
+ logs = manager.logs(engine, cfg, lines=lines)
749
+ return {"logs": logs}
750
+ except Exception as exc:
751
+ raise HTTPException(status_code=400, detail=str(exc))
752
+
753
+
754
+ @router.get("/engines/tasks/{task_id}")
755
+ async def get_engine_task(request: Request, task_id: str):
756
+ manager = request.app.state.engine_manager
757
+ task = manager.get_task(task_id)
758
+ if task is None:
759
+ raise HTTPException(status_code=404, detail=f"Task not found: {task_id}")
760
+ return {"task": _task_snapshot(task)}
761
+
762
+
763
+ @router.get("/engines/tasks")
764
+ async def list_engine_tasks(
765
+ request: Request,
766
+ engine: str | None = Query(default=None),
767
+ limit: int = Query(default=20, ge=1, le=200),
768
+ ):
769
+ manager = request.app.state.engine_manager
770
+ tasks = manager.list_tasks(engine=engine, limit=limit)
771
+ return {"tasks": [_task_snapshot(t) for t in tasks]}
772
+
773
+
774
+ @router.get("/engines/tasks/{task_id}/events")
775
+ async def stream_engine_task_events(
776
+ request: Request,
777
+ task_id: str,
778
+ poll_interval: float = Query(default=1.0, ge=0.2, le=10.0),
779
+ ):
780
+ manager = request.app.state.engine_manager
781
+ task = manager.get_task(task_id)
782
+ if task is None:
783
+ raise HTTPException(status_code=404, detail=f"Task not found: {task_id}")
784
+
785
+ q = manager.emitter.subscribe(task_id)
786
+
787
+ async def _iter_events():
788
+ last_updated = ""
789
+ try:
790
+ initial = _task_snapshot(task)
791
+ yield f"event: snapshot\ndata: {json.dumps(initial, ensure_ascii=True)}\n\n"
792
+ if initial.get("status") in {"succeeded", "failed", "cancelled"}:
793
+ yield f"event: done\ndata: {json.dumps(initial, ensure_ascii=True)}\n\n"
794
+ return
795
+
796
+ while True:
797
+ if await request.is_disconnected():
798
+ return
799
+ try:
800
+ evt = await asyncio.to_thread(q.get, True, poll_interval)
801
+ payload = {
802
+ "task_id": evt.task_id,
803
+ "engine": evt.engine,
804
+ "action": evt.action,
805
+ "runtime": evt.runtime,
806
+ "phase": evt.phase,
807
+ "message": evt.message,
808
+ "progress": evt.progress,
809
+ "eta_seconds": evt.eta_seconds,
810
+ "status": evt.status.value,
811
+ "timestamp": evt.timestamp.isoformat(),
812
+ }
813
+ yield f"event: progress\ndata: {json.dumps(payload, ensure_ascii=True)}\n\n"
814
+ except queue.Empty:
815
+ pass
816
+
817
+ latest_task = manager.get_task(task_id)
818
+ if latest_task is None:
819
+ return
820
+ latest = _task_snapshot(latest_task)
821
+ stamp = str(latest.get("updated_at", ""))
822
+ if stamp != last_updated:
823
+ yield f"event: snapshot\ndata: {json.dumps(latest, ensure_ascii=True)}\n\n"
824
+ last_updated = stamp
825
+ if latest.get("status") in {"succeeded", "failed", "cancelled"}:
826
+ yield f"event: done\ndata: {json.dumps(latest, ensure_ascii=True)}\n\n"
827
+ return
828
+ finally:
829
+ manager.emitter.unsubscribe(task_id, q)
830
+
831
+ return StreamingResponse(
832
+ _iter_events(),
833
+ media_type="text/event-stream",
834
+ headers={
835
+ "Cache-Control": "no-cache",
836
+ "Connection": "keep-alive",
837
+ "X-Accel-Buffering": "no",
838
+ },
839
+ )
840
+
841
+
842
+ @router.post("/engines/tasks/{task_id}/cancel")
843
+ async def cancel_engine_task(request: Request, task_id: str):
844
+ manager = request.app.state.engine_manager
845
+ task = manager.cancel_task(task_id)
846
+ if task is None:
847
+ raise HTTPException(status_code=404, detail=f"Task not found: {task_id}")
848
+ return {"task": _task_snapshot(task)}