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.
- openspeech/__init__.py +75 -0
- openspeech/__main__.py +5 -0
- openspeech/cli.py +413 -0
- openspeech/client/__init__.py +4 -0
- openspeech/client/client.py +145 -0
- openspeech/config.py +212 -0
- openspeech/core/__init__.py +0 -0
- openspeech/core/base.py +75 -0
- openspeech/core/enums.py +39 -0
- openspeech/core/models.py +61 -0
- openspeech/core/registry.py +37 -0
- openspeech/core/settings.py +8 -0
- openspeech/demo.py +675 -0
- openspeech/dispatch/__init__.py +0 -0
- openspeech/dispatch/context.py +34 -0
- openspeech/dispatch/dispatcher.py +661 -0
- openspeech/dispatch/executors/__init__.py +0 -0
- openspeech/dispatch/executors/base.py +34 -0
- openspeech/dispatch/executors/in_process.py +66 -0
- openspeech/dispatch/executors/remote.py +64 -0
- openspeech/dispatch/executors/subprocess_exec.py +446 -0
- openspeech/dispatch/fanout.py +95 -0
- openspeech/dispatch/filters.py +73 -0
- openspeech/dispatch/lifecycle.py +178 -0
- openspeech/dispatch/watcher.py +82 -0
- openspeech/engine_catalog.py +236 -0
- openspeech/engine_registry.yaml +347 -0
- openspeech/exceptions.py +51 -0
- openspeech/factory.py +325 -0
- openspeech/local_engines/__init__.py +12 -0
- openspeech/local_engines/aim_resolver.py +91 -0
- openspeech/local_engines/backends/__init__.py +1 -0
- openspeech/local_engines/backends/docker_backend.py +490 -0
- openspeech/local_engines/backends/native_backend.py +902 -0
- openspeech/local_engines/base.py +30 -0
- openspeech/local_engines/engines/__init__.py +1 -0
- openspeech/local_engines/engines/faster_whisper.py +36 -0
- openspeech/local_engines/engines/fish_speech.py +33 -0
- openspeech/local_engines/engines/sherpa_onnx.py +56 -0
- openspeech/local_engines/engines/whisper.py +41 -0
- openspeech/local_engines/engines/whisperlivekit.py +60 -0
- openspeech/local_engines/manager.py +208 -0
- openspeech/local_engines/models.py +50 -0
- openspeech/local_engines/progress.py +69 -0
- openspeech/local_engines/registry.py +19 -0
- openspeech/local_engines/task_store.py +52 -0
- openspeech/local_engines/tasks.py +71 -0
- openspeech/logging_config.py +607 -0
- openspeech/observe/__init__.py +0 -0
- openspeech/observe/base.py +79 -0
- openspeech/observe/debug.py +44 -0
- openspeech/observe/latency.py +19 -0
- openspeech/observe/metrics.py +47 -0
- openspeech/observe/tracing.py +44 -0
- openspeech/observe/usage.py +27 -0
- openspeech/providers/__init__.py +0 -0
- openspeech/providers/_template.py +101 -0
- openspeech/providers/stt/__init__.py +0 -0
- openspeech/providers/stt/alibaba.py +86 -0
- openspeech/providers/stt/assemblyai.py +135 -0
- openspeech/providers/stt/azure_speech.py +99 -0
- openspeech/providers/stt/baidu.py +135 -0
- openspeech/providers/stt/deepgram.py +311 -0
- openspeech/providers/stt/elevenlabs.py +385 -0
- openspeech/providers/stt/faster_whisper.py +211 -0
- openspeech/providers/stt/google_cloud.py +106 -0
- openspeech/providers/stt/iflytek.py +427 -0
- openspeech/providers/stt/macos_speech.py +226 -0
- openspeech/providers/stt/openai.py +84 -0
- openspeech/providers/stt/sherpa_onnx.py +353 -0
- openspeech/providers/stt/tencent.py +212 -0
- openspeech/providers/stt/volcengine.py +107 -0
- openspeech/providers/stt/whisper.py +153 -0
- openspeech/providers/stt/whisperlivekit.py +530 -0
- openspeech/providers/stt/windows_speech.py +249 -0
- openspeech/providers/tts/__init__.py +0 -0
- openspeech/providers/tts/alibaba.py +95 -0
- openspeech/providers/tts/azure_speech.py +123 -0
- openspeech/providers/tts/baidu.py +143 -0
- openspeech/providers/tts/coqui.py +64 -0
- openspeech/providers/tts/cosyvoice.py +90 -0
- openspeech/providers/tts/deepgram.py +174 -0
- openspeech/providers/tts/elevenlabs.py +311 -0
- openspeech/providers/tts/fish_speech.py +158 -0
- openspeech/providers/tts/google_cloud.py +107 -0
- openspeech/providers/tts/iflytek.py +209 -0
- openspeech/providers/tts/macos_say.py +251 -0
- openspeech/providers/tts/minimax.py +122 -0
- openspeech/providers/tts/openai.py +104 -0
- openspeech/providers/tts/piper.py +104 -0
- openspeech/providers/tts/tencent.py +189 -0
- openspeech/providers/tts/volcengine.py +117 -0
- openspeech/providers/tts/windows_sapi.py +234 -0
- openspeech/server/__init__.py +1 -0
- openspeech/server/app.py +72 -0
- openspeech/server/auth.py +42 -0
- openspeech/server/middleware.py +75 -0
- openspeech/server/routes/__init__.py +1 -0
- openspeech/server/routes/management.py +848 -0
- openspeech/server/routes/stt.py +121 -0
- openspeech/server/routes/tts.py +159 -0
- openspeech/server/routes/webui.py +29 -0
- openspeech/server/webui/app.js +2649 -0
- openspeech/server/webui/index.html +216 -0
- openspeech/server/webui/styles.css +617 -0
- openspeech/server/ws/__init__.py +1 -0
- openspeech/server/ws/stt_stream.py +263 -0
- openspeech/server/ws/tts_stream.py +207 -0
- openspeech/telemetry/__init__.py +21 -0
- openspeech/telemetry/perf.py +307 -0
- openspeech/utils/__init__.py +5 -0
- openspeech/utils/audio_converter.py +406 -0
- openspeech/utils/audio_playback.py +156 -0
- openspeech/vendor_registry.yaml +74 -0
- openspeechapi-0.1.0.dist-info/METADATA +101 -0
- openspeechapi-0.1.0.dist-info/RECORD +118 -0
- openspeechapi-0.1.0.dist-info/WHEEL +4 -0
- 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)}
|