@oneciel-ai/ciel-runtime 0.2.3 → 0.2.5

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.
package/ciel_runtime.py CHANGED
@@ -500,6 +500,7 @@ from ciel_runtime_support.runtime_paths import (CHANNEL_COMPACT_REQUEST_PATH, #
500
500
  from ciel_runtime_support.runtime_restart import forced_upgrade_environment
501
501
  from ciel_runtime_support.runtime_restart import running_from_npm_package as detect_running_from_npm_package
502
502
  from ciel_runtime_support.secure_json_repository import SecureJsonEffects, SecureJsonRepository
503
+ from ciel_runtime_support.speech_http_controller import SpeechHttpController, SpeechHttpPorts
503
504
  from ciel_runtime_support.session_import import ImportSessionHttpController, ImportSessionHttpPorts, ImportSessionLimits, ImportSessionRepository, ImportSessionService, import_record_line, import_tool_text, normalize_import_source
504
505
  from ciel_runtime_support.slash_command_assets import ADVISOR_NATIVE_DISABLED_SLASH_COMMAND # noqa: F401 - compatibility export
505
506
  from ciel_runtime_support.slash_command_assets import LEGACY_ADVISOR_CALL_MARKER # noqa: F401 - compatibility export
@@ -1700,6 +1701,7 @@ def render_router_home_html(cfg: dict[str, Any], provider: str, pcfg: dict[str,
1700
1701
  def render_web_chat_html(cfg: dict[str, Any], provider: str, pcfg: dict[str, Any]) -> str: return web_ui_controller().render_web_chat(cfg, provider, pcfg)
1701
1702
  def handle_web_get(handler: BaseHTTPRequestHandler, path: str) -> bool: return web_ui_controller().handle_get(handler, path)
1702
1703
 
1704
+ def speech_http_controller() -> SpeechHttpController: return SpeechHttpController(SpeechHttpPorts(load_config, save_config, write_json, router_log))
1703
1705
  def parse_json_body(raw: bytes) -> dict[str, Any]:
1704
1706
  try:
1705
1707
  value = json.loads(raw.decode("utf-8") if raw else "{}")
@@ -2817,8 +2819,8 @@ def _router_server_context() -> RouterServerContext:
2817
2819
  http_services = RouterHttpServices(
2818
2820
  core=RouterHttpCore(load_config, reject_external_router_request, get_current_provider, parse_json_body, is_client_disconnect_error, router_log),
2819
2821
  get=RouterHttpGetEndpoints(handle_codex_mcp_split_proxy_get, handle_events_get, handle_llm_config_get, handle_channel_mcp_get, handle_web_get,
2820
- handle_chat_get, handle_plan_get, route_runtime_get),
2821
- post=RouterHttpPostEndpoints(handle_codex_mcp_split_proxy_request, handle_llm_config_post, handle_channel_mcp_post, handle_chat_post,
2822
+ lambda handler, path: speech_http_controller().get(handler, path), handle_chat_get, handle_plan_get, route_runtime_get),
2823
+ post=RouterHttpPostEndpoints(handle_codex_mcp_split_proxy_request, lambda handler, path, raw, content_type: speech_http_controller().post(handler, path, raw, content_type), handle_llm_config_post, handle_channel_mcp_post, handle_chat_post,
2822
2824
  handle_plan_post, route_runtime_post),
2823
2825
  presentation=RouterHttpPresentation(render_router_home_html, router_health_payload, write_text_response, write_json, list_model_objects_for_request,
2824
2826
  resolve_requested_model, model_object),
@@ -26,6 +26,38 @@ def build_default_config(provider_defaults: dict[str, Any]) -> dict[str, Any]:
26
26
  "port": 0,
27
27
  "tailscale_https": False,
28
28
  },
29
+ "speech": {
30
+ "asr": {
31
+ "enabled": False,
32
+ "base_url": "http://ciel-asr:8000",
33
+ "endpoint": "/v1/audio/transcriptions",
34
+ "model": "Qwen/Qwen3-ASR-0.6B",
35
+ "language": "auto",
36
+ "api_key": "",
37
+ "timeout_seconds": 300,
38
+ },
39
+ "tts": {
40
+ "enabled": False,
41
+ "base_url": "http://ciel-tts:8091",
42
+ "endpoint": "/v1/audio/speech",
43
+ "voices_endpoint": "/v1/audio/voices",
44
+ "model": "OpenMOSS-Team/MOSS-TTS-Nano",
45
+ "voice": "default",
46
+ "language": "ko",
47
+ "ref_audio": "",
48
+ "ref_text": "",
49
+ "response_format": "wav",
50
+ "speed": 1.0,
51
+ "auto_speak": False,
52
+ "api_key": "",
53
+ "timeout_seconds": 300,
54
+ },
55
+ "tailscale": {
56
+ "enabled": True,
57
+ "asr_hostname": "ciel-asr",
58
+ "tts_hostname": "ciel-tts",
59
+ },
60
+ },
29
61
  "claude_code": {
30
62
  "compat_prompt_for_non_anthropic": True,
31
63
  "channels": [],
@@ -90,6 +90,7 @@ class RouterHttpGetEndpoints:
90
90
  llm_config: Callable[[Any, str], bool]
91
91
  channel_mcp: Callable[[Any, str], bool]
92
92
  web: Callable[[Any, str], bool]
93
+ speech: Callable[[Any, str], bool]
93
94
  chat: Callable[[Any, str], bool]
94
95
  plan: Callable[[Any, str], bool]
95
96
  runtime: Callable[..., bool]
@@ -98,6 +99,7 @@ class RouterHttpGetEndpoints:
98
99
  @dataclass(frozen=True, slots=True)
99
100
  class RouterHttpPostEndpoints:
100
101
  codex_mcp_split: Callable[[Any, str, bytes, str], bool]
102
+ speech: Callable[[Any, str, bytes, str], bool]
101
103
  llm_config: Callable[[Any, str, dict[str, Any]], bool]
102
104
  channel_mcp: Callable[[Any, str, dict[str, Any]], bool]
103
105
  chat: Callable[[Any, str, dict[str, Any]], bool]
@@ -636,6 +638,8 @@ class RouterHttpHandler(BaseHTTPRequestHandler):
636
638
  return
637
639
  if endpoints.web(self, path):
638
640
  return
641
+ if endpoints.speech(self, path):
642
+ return
639
643
  if endpoints.chat(self, path) or endpoints.plan(self, path):
640
644
  return
641
645
  provider, pcfg = services.core.get_current_provider(cfg)
@@ -676,6 +680,8 @@ class RouterHttpHandler(BaseHTTPRequestHandler):
676
680
  endpoints = services.post
677
681
  if endpoints.codex_mcp_split(self, path, raw, "POST"):
678
682
  return
683
+ if endpoints.speech(self, path, raw, str(self.headers.get("content-type") or "application/json")):
684
+ return
679
685
  body = services.core.parse_json_body(raw)
680
686
  if endpoints.llm_config(self, path, body):
681
687
  return
@@ -49,6 +49,8 @@ class RouterServerContext:
49
49
  "provider": provider,
50
50
  "model": self.health.current_alias(cfg),
51
51
  "web_chat": "/ca/web/chat",
52
+ "web_chat_api": "/ca/web/chat/api",
53
+ "speech": "/ca/speech/health",
52
54
  "chat": "/ca/chat/health",
53
55
  "plan": "/ca/plan/artifacts",
54
56
  "events": "/ca/events",
@@ -93,7 +93,7 @@ OFFICIAL_CHANNEL_PLUGINS = {
93
93
  }
94
94
 
95
95
  APP_NAME = "Ciel Runtime"
96
- VERSION = "0.2.3"
96
+ VERSION = "0.2.5"
97
97
  CREDITS = "Credits: One Ciel LLC"
98
98
  PRELAUNCH_CANCEL = 10
99
99
  PRELAUNCH_LAUNCH_CODEX = 11
@@ -0,0 +1,358 @@
1
+ """Speech configuration and OpenAI-compatible ASR/TTS proxy endpoints."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import json
7
+ import secrets
8
+ import urllib.error
9
+ import urllib.parse
10
+ import urllib.request
11
+ from dataclasses import dataclass
12
+ from http.server import BaseHTTPRequestHandler
13
+ from typing import Any, Callable
14
+
15
+
16
+ SpeechConfig = dict[str, Any]
17
+
18
+
19
+ @dataclass(frozen=True, slots=True)
20
+ class SpeechHttpPorts:
21
+ load_config: Callable[[], dict[str, Any]]
22
+ save_config: Callable[[dict[str, Any]], None]
23
+ write_json: Callable[..., None]
24
+ log: Callable[[str, str], None]
25
+ urlopen: Callable[..., Any] = urllib.request.urlopen
26
+
27
+
28
+ @dataclass(frozen=True, slots=True)
29
+ class SpeechHttpController:
30
+ ports: SpeechHttpPorts
31
+
32
+ def get(self, handler: BaseHTTPRequestHandler, path: str) -> bool:
33
+ if path == "/ca/speech/config":
34
+ self.ports.write_json(handler, self.public_config())
35
+ return True
36
+ if path == "/ca/speech/health":
37
+ self.ports.write_json(handler, self.health_payload())
38
+ return True
39
+ if path == "/ca/web/chat/api":
40
+ self.ports.write_json(handler, self.discovery_payload())
41
+ return True
42
+ if path == "/v1/audio/voices":
43
+ return self._proxy_get(handler, "tts", "voices_endpoint")
44
+ return False
45
+
46
+ def post(
47
+ self,
48
+ handler: BaseHTTPRequestHandler,
49
+ path: str,
50
+ raw: bytes,
51
+ content_type: str,
52
+ ) -> bool:
53
+ if path == "/ca/speech/config":
54
+ return self._save_public_config(handler, raw)
55
+ if path in {"/v1/audio/transcriptions", "/v1/audio/translations"}:
56
+ return self._proxy_asr(handler, raw, content_type)
57
+ if path in {"/v1/audio/speech", "/v1/audio/speech/batch"}:
58
+ return self._proxy_tts(handler, raw, content_type, batch=path.endswith("/batch"))
59
+ if path == "/v1/audio/voices":
60
+ return self._proxy_raw(handler, "tts", "voices_endpoint", raw, content_type)
61
+ return False
62
+
63
+ def public_config(self) -> dict[str, Any]:
64
+ speech = self._speech_config()
65
+ public: dict[str, Any] = {"ok": True}
66
+ for name in ("asr", "tts"):
67
+ source = speech.get(name) if isinstance(speech.get(name), dict) else {}
68
+ item = {key: value for key, value in source.items() if key not in {"api_key", "ref_audio"}}
69
+ item["api_key_set"] = bool(str(source.get("api_key") or "").strip())
70
+ if name == "tts":
71
+ item["ref_audio_set"] = bool(str(source.get("ref_audio") or "").strip())
72
+ public[name] = item
73
+ tailscale = speech.get("tailscale")
74
+ public["tailscale"] = dict(tailscale) if isinstance(tailscale, dict) else {}
75
+ public["endpoints"] = self.discovery_payload()["endpoints"]
76
+ return public
77
+
78
+ def discovery_payload(self) -> dict[str, Any]:
79
+ return {
80
+ "ok": True,
81
+ "web_chat": "/ca/web/chat",
82
+ "endpoints": {
83
+ "chat_health": "GET /ca/channel/health",
84
+ "chat_messages": "GET|POST /ca/channel/messages",
85
+ "chat_wait": "GET /ca/channel/wait",
86
+ "chat_stream": "GET /ca/channel/stream",
87
+ "chat_files": "POST /ca/channel/files",
88
+ "speech_config": "GET|POST /ca/speech/config",
89
+ "speech_health": "GET /ca/speech/health",
90
+ "asr": "POST /v1/audio/transcriptions",
91
+ "asr_translate": "POST /v1/audio/translations",
92
+ "tts": "POST /v1/audio/speech",
93
+ "tts_batch": "POST /v1/audio/speech/batch",
94
+ "tts_voices": "GET|POST /v1/audio/voices",
95
+ "models": "GET /v1/models",
96
+ "responses": "POST /v1/responses",
97
+ "messages": "POST /v1/messages",
98
+ },
99
+ }
100
+
101
+ def health_payload(self) -> dict[str, Any]:
102
+ services = {name: self._probe(name) for name in ("asr", "tts")}
103
+ return {"ok": all(not item["enabled"] or item["reachable"] for item in services.values()), "services": services}
104
+
105
+ def _speech_config(self) -> SpeechConfig:
106
+ speech = self.ports.load_config().get("speech")
107
+ return speech if isinstance(speech, dict) else {}
108
+
109
+ def _service_config(self, name: str) -> SpeechConfig:
110
+ service = self._speech_config().get(name)
111
+ return service if isinstance(service, dict) else {}
112
+
113
+ def _save_public_config(self, handler: BaseHTTPRequestHandler, raw: bytes) -> bool:
114
+ try:
115
+ update = json.loads(raw.decode("utf-8") if raw else "{}")
116
+ if not isinstance(update, dict):
117
+ raise ValueError("configuration body must be a JSON object")
118
+ config = self.ports.load_config()
119
+ speech = config.setdefault("speech", {})
120
+ if not isinstance(speech, dict):
121
+ speech = {}
122
+ config["speech"] = speech
123
+ for name in ("asr", "tts"):
124
+ incoming = update.get(name)
125
+ if not isinstance(incoming, dict):
126
+ continue
127
+ current = speech.setdefault(name, {})
128
+ if not isinstance(current, dict):
129
+ current = {}
130
+ speech[name] = current
131
+ for key, value in incoming.items():
132
+ if key in {"api_key_set", "clear_api_key", "ref_audio_set", "clear_ref_audio"}:
133
+ continue
134
+ if key in {"api_key", "ref_audio"} and not str(value or "").strip():
135
+ continue
136
+ current[key] = self._validated_value(name, key, value)
137
+ if incoming.get("clear_api_key") is True:
138
+ current["api_key"] = ""
139
+ if name == "tts" and incoming.get("clear_ref_audio") is True:
140
+ current["ref_audio"] = ""
141
+ tailscale = update.get("tailscale")
142
+ if isinstance(tailscale, dict):
143
+ current_tailscale = speech.setdefault("tailscale", {})
144
+ if not isinstance(current_tailscale, dict):
145
+ current_tailscale = {}
146
+ speech["tailscale"] = current_tailscale
147
+ for key in ("enabled", "asr_hostname", "tts_hostname"):
148
+ if key in tailscale:
149
+ current_tailscale[key] = tailscale[key]
150
+ self.ports.save_config(config)
151
+ self.ports.write_json(handler, self.public_config())
152
+ except (UnicodeError, ValueError, TypeError) as exc:
153
+ self.ports.write_json(handler, {"ok": False, "error": str(exc)}, 400)
154
+ return True
155
+
156
+ @staticmethod
157
+ def _validated_value(service: str, key: str, value: Any) -> Any:
158
+ allowed = {
159
+ "asr": {"enabled", "base_url", "endpoint", "model", "language", "api_key", "timeout_seconds"},
160
+ "tts": {"enabled", "base_url", "endpoint", "voices_endpoint", "model", "voice", "language", "ref_audio", "ref_text", "response_format", "speed", "auto_speak", "api_key", "timeout_seconds"},
161
+ }
162
+ if key not in allowed[service]:
163
+ raise ValueError(f"unsupported {service} setting: {key}")
164
+ if key in {"enabled", "auto_speak"}:
165
+ return bool(value)
166
+ if key == "timeout_seconds":
167
+ return max(1, min(3600, int(value)))
168
+ if key == "speed":
169
+ return max(0.25, min(4.0, float(value)))
170
+ text = str(value or "").strip()
171
+ if key == "ref_audio":
172
+ if len(text) > 14_000_000:
173
+ raise ValueError("TTS reference audio must be 10 MB or smaller")
174
+ if text.startswith("data:audio/") and ";base64," in text:
175
+ try:
176
+ audio = base64.b64decode(text.split(",", 1)[1], validate=True)
177
+ except (ValueError, TypeError) as exc:
178
+ raise ValueError("invalid base64 TTS reference audio") from exc
179
+ if not audio or len(audio) > 10 * 1024 * 1024:
180
+ raise ValueError("TTS reference audio must be between 1 byte and 10 MB")
181
+ return text
182
+ parsed_ref = urllib.parse.urlparse(text)
183
+ if parsed_ref.scheme not in {"http", "https"} or not parsed_ref.netloc:
184
+ raise ValueError("TTS ref_audio must be an audio data URL or HTTP(S) URL")
185
+ return text
186
+ if key == "base_url":
187
+ parsed = urllib.parse.urlparse(text)
188
+ if parsed.scheme not in {"http", "https"} or not parsed.netloc:
189
+ raise ValueError(f"invalid {service} base_url")
190
+ return text.rstrip("/")
191
+ if key in {"endpoint", "voices_endpoint"} and not text.startswith("/"):
192
+ raise ValueError(f"{service} {key} must begin with /")
193
+ return text
194
+
195
+ def _probe(self, name: str) -> dict[str, Any]:
196
+ config = self._service_config(name)
197
+ enabled = bool(config.get("enabled"))
198
+ result: dict[str, Any] = {
199
+ "enabled": enabled,
200
+ "configured": bool(str(config.get("base_url") or "").strip()),
201
+ "reachable": False,
202
+ }
203
+ if not enabled:
204
+ return result
205
+ try:
206
+ request = urllib.request.Request(self._url(config, "/health"), headers=self._headers(config, "application/json"))
207
+ with self.ports.urlopen(request, timeout=min(10.0, self._timeout(config))) as response:
208
+ result["status"] = int(getattr(response, "status", 200))
209
+ result["reachable"] = result["status"] < 500
210
+ except Exception as exc:
211
+ result["error"] = f"{type(exc).__name__}: {exc}"
212
+ return result
213
+
214
+ def _proxy_get(self, handler: BaseHTTPRequestHandler, service: str, endpoint_key: str) -> bool:
215
+ config = self._service_config(service)
216
+ if not self._require_enabled(handler, service, config):
217
+ return True
218
+ request = urllib.request.Request(self._url(config, str(config.get(endpoint_key) or "")), headers=self._headers(config, "application/json"))
219
+ return self._open_and_write(handler, service, config, request)
220
+
221
+ def _proxy_asr(self, handler: BaseHTTPRequestHandler, raw: bytes, content_type: str) -> bool:
222
+ config = self._service_config("asr")
223
+ if not self._require_enabled(handler, "asr", config):
224
+ return True
225
+ if "application/json" in content_type.lower():
226
+ try:
227
+ body = json.loads(raw.decode("utf-8"))
228
+ if not isinstance(body, dict):
229
+ raise ValueError("request must be a JSON object")
230
+ audio = base64.b64decode(str(body.get("audio_base64") or ""), validate=True)
231
+ if not audio:
232
+ raise ValueError("audio_base64 is required")
233
+ fields = {
234
+ "model": str(body.get("model") or config.get("model") or ""),
235
+ "language": str(body.get("language") or config.get("language") or ""),
236
+ "response_format": str(body.get("response_format") or "json"),
237
+ }
238
+ if fields["language"].lower() == "auto":
239
+ fields.pop("language")
240
+ raw, content_type = self._multipart(fields, "file", str(body.get("filename") or "recording.wav"), str(body.get("content_type") or "audio/wav"), audio)
241
+ except (ValueError, TypeError, UnicodeError) as exc:
242
+ self.ports.write_json(handler, {"error": {"type": "invalid_request_error", "message": str(exc)}}, 400)
243
+ return True
244
+ return self._proxy_raw(handler, "asr", "endpoint", raw, content_type)
245
+
246
+ def _proxy_tts(self, handler: BaseHTTPRequestHandler, raw: bytes, content_type: str, *, batch: bool) -> bool:
247
+ config = self._service_config("tts")
248
+ if not self._require_enabled(handler, "tts", config):
249
+ return True
250
+ if "application/json" in content_type.lower():
251
+ try:
252
+ body = json.loads(raw.decode("utf-8") if raw else "{}")
253
+ if not isinstance(body, dict):
254
+ raise ValueError("request must be a JSON object")
255
+ if not batch:
256
+ body.setdefault("model", str(config.get("model") or ""))
257
+ body.setdefault("voice", str(config.get("voice") or "default"))
258
+ body.setdefault("language", str(config.get("language") or "Auto"))
259
+ if str(config.get("ref_audio") or "").strip():
260
+ body.setdefault("ref_audio", str(config["ref_audio"]))
261
+ if str(config.get("ref_text") or "").strip():
262
+ body.setdefault("ref_text", str(config["ref_text"]))
263
+ body.setdefault("response_format", str(config.get("response_format") or "wav"))
264
+ body.setdefault("speed", float(config.get("speed") or 1.0))
265
+ raw = json.dumps(body, ensure_ascii=False).encode("utf-8")
266
+ except (ValueError, TypeError, UnicodeError) as exc:
267
+ self.ports.write_json(handler, {"error": {"type": "invalid_request_error", "message": str(exc)}}, 400)
268
+ return True
269
+ endpoint = str(config.get("endpoint") or "/v1/audio/speech") + ("/batch" if batch else "")
270
+ return self._proxy_bytes(handler, "tts", config, endpoint, raw, content_type)
271
+
272
+ def _proxy_raw(self, handler: BaseHTTPRequestHandler, service: str, endpoint_key: str, raw: bytes, content_type: str) -> bool:
273
+ config = self._service_config(service)
274
+ if not self._require_enabled(handler, service, config):
275
+ return True
276
+ return self._proxy_bytes(handler, service, config, str(config.get(endpoint_key) or ""), raw, content_type)
277
+
278
+ def _proxy_bytes(self, handler: BaseHTTPRequestHandler, service: str, config: SpeechConfig, endpoint: str, raw: bytes, content_type: str) -> bool:
279
+ request = urllib.request.Request(
280
+ self._url(config, endpoint),
281
+ data=raw,
282
+ headers=self._headers(config, content_type or "application/octet-stream"),
283
+ method="POST",
284
+ )
285
+ return self._open_and_write(handler, service, config, request)
286
+
287
+ def _open_and_write(self, handler: BaseHTTPRequestHandler, service: str, config: SpeechConfig, request: urllib.request.Request) -> bool:
288
+ try:
289
+ with self.ports.urlopen(request, timeout=self._timeout(config)) as response:
290
+ self._write_bytes(handler, response.read(), int(getattr(response, "status", 200)), str(response.headers.get("content-type") or "application/octet-stream"))
291
+ except urllib.error.HTTPError as exc:
292
+ self._write_bytes(handler, exc.read(), int(exc.code), str(exc.headers.get("content-type") or "application/json"))
293
+ except Exception as exc:
294
+ self.ports.log("ERROR", f"speech_proxy_failed service={service} error={type(exc).__name__}: {exc}")
295
+ self.ports.write_json(handler, {"error": {"type": "upstream_error", "message": f"{service.upper()} upstream unavailable: {exc}"}}, 502)
296
+ return True
297
+
298
+ @staticmethod
299
+ def _require_enabled(handler: BaseHTTPRequestHandler, service: str, config: SpeechConfig) -> bool:
300
+ if bool(config.get("enabled")) and str(config.get("base_url") or "").strip():
301
+ return True
302
+ body = json.dumps({"error": {"type": "service_disabled", "message": f"{service.upper()} is not configured or enabled"}}).encode()
303
+ handler.send_response(503)
304
+ handler.send_header("content-type", "application/json")
305
+ handler.send_header("content-length", str(len(body)))
306
+ handler.end_headers()
307
+ handler.wfile.write(body)
308
+ return False
309
+
310
+ @staticmethod
311
+ def _write_bytes(handler: BaseHTTPRequestHandler, data: bytes, status: int, content_type: str) -> None:
312
+ handler.send_response(status)
313
+ handler.send_header("content-type", content_type)
314
+ handler.send_header("content-length", str(len(data)))
315
+ handler.send_header("cache-control", "no-store")
316
+ handler.end_headers()
317
+ handler.wfile.write(data)
318
+
319
+ @staticmethod
320
+ def _headers(config: SpeechConfig, content_type: str) -> dict[str, str]:
321
+ headers = {"accept": "*/*", "content-type": content_type}
322
+ api_key = str(config.get("api_key") or "").strip()
323
+ if api_key:
324
+ headers["authorization"] = f"Bearer {api_key}"
325
+ return headers
326
+
327
+ @staticmethod
328
+ def _url(config: SpeechConfig, endpoint: str) -> str:
329
+ return str(config.get("base_url") or "").rstrip("/") + "/" + endpoint.lstrip("/")
330
+
331
+ @staticmethod
332
+ def _timeout(config: SpeechConfig) -> float:
333
+ try:
334
+ return max(1.0, min(3600.0, float(config.get("timeout_seconds") or 300)))
335
+ except (TypeError, ValueError):
336
+ return 300.0
337
+
338
+ @staticmethod
339
+ def _multipart(fields: dict[str, str], file_field: str, filename: str, mime: str, data: bytes) -> tuple[bytes, str]:
340
+ boundary = "ciel-" + secrets.token_hex(16)
341
+ chunks: list[bytes] = []
342
+ for name, value in fields.items():
343
+ chunks.extend([
344
+ f"--{boundary}\r\n".encode(),
345
+ f'Content-Disposition: form-data; name="{name}"\r\n\r\n'.encode(),
346
+ str(value).encode("utf-8"), b"\r\n",
347
+ ])
348
+ safe_filename = filename.replace('"', "_").replace("\r", "_").replace("\n", "_")
349
+ chunks.extend([
350
+ f"--{boundary}\r\n".encode(),
351
+ f'Content-Disposition: form-data; name="{file_field}"; filename="{safe_filename}"\r\n'.encode(),
352
+ f"Content-Type: {mime}\r\n\r\n".encode(), data, b"\r\n",
353
+ f"--{boundary}--\r\n".encode(),
354
+ ])
355
+ return b"".join(chunks), f"multipart/form-data; boundary={boundary}"
356
+
357
+
358
+ __all__ = ["SpeechHttpController", "SpeechHttpPorts"]
@@ -112,6 +112,22 @@ def render_web_chat_page(
112
112
  }}
113
113
  .attach-button:hover {{ border-color: var(--accent); }}
114
114
  .attach-button:disabled {{ opacity: .55; cursor: not-allowed; }}
115
+ .recording {{ border-color: #ef4444 !important; color: #fecaca !important; }}
116
+ .message-actions {{ display: flex; align-items: flex-start; padding: 4px; }}
117
+ .message-actions button {{ border: 1px solid var(--line); border-radius: 999px; background: #0b111b; color: var(--muted); cursor: pointer; padding: 4px 8px; }}
118
+ dialog {{ width: min(720px, calc(100vw - 28px)); max-height: calc(100vh - 28px); overflow: auto; border: 1px solid var(--line); border-radius: 10px; background: var(--panel); color: var(--text); padding: 0; }}
119
+ dialog::backdrop {{ background: rgba(0,0,0,.72); }}
120
+ .settings-head {{ position: sticky; top: 0; display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 14px 16px; border-bottom: 1px solid var(--line); background: var(--panel); }}
121
+ .settings-body {{ padding: 16px; display: grid; gap: 16px; }}
122
+ .settings-section {{ border: 1px solid var(--line); border-radius: 8px; padding: 12px; display: grid; gap: 10px; }}
123
+ .settings-section h3 {{ margin: 0; font-size: 14px; }}
124
+ .settings-grid {{ display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }}
125
+ .settings-grid label {{ display: grid; gap: 5px; color: var(--muted); font-size: 12px; }}
126
+ .settings-grid label.wide {{ grid-column: 1 / -1; }}
127
+ .settings-grid input, .settings-grid select {{ width: 100%; border: 1px solid var(--line); border-radius: 6px; background: #080d14; color: var(--text); padding: 8px; }}
128
+ .check {{ display: flex !important; grid-auto-flow: column; justify-content: start; align-items: center; gap: 7px !important; }}
129
+ .check input {{ width: auto; }}
130
+ .settings-actions {{ display: flex; justify-content: flex-end; gap: 8px; }}
115
131
  #fileInput {{ display: none; }}
116
132
  .attachment-tray {{ display: flex; gap: 7px; flex-wrap: wrap; min-height: 0; }}
117
133
  .attachment-chip {{
@@ -135,6 +151,7 @@ def render_web_chat_page(
135
151
  .bubble {{ max-width: 94%; }}
136
152
  header {{ align-items: flex-start; flex-direction: column; }}
137
153
  .pill {{ white-space: normal; }}
154
+ .settings-grid {{ grid-template-columns: 1fr; }}
138
155
  }}
139
156
  </style>
140
157
  </head>
@@ -154,6 +171,8 @@ def render_web_chat_page(
154
171
  <a href="/">Router Home</a>
155
172
  <a href="/ca/events">Events</a>
156
173
  <a href="/health">Health JSON</a>
174
+ <a href="/ca/web/chat/api">Chat API JSON</a>
175
+ <button class="ghost" id="speechSettingsButton" type="button">Speech Settings</button>
157
176
  <button class="ghost" id="shareButton" type="button">Copy Chat Link</button>
158
177
  <button class="ghost" id="clearButton" type="button">Clear Chat</button>
159
178
  </div>
@@ -173,6 +192,7 @@ def render_web_chat_page(
173
192
  <button class="primary" id="sendButton" type="submit">Send</button>
174
193
  </div>
175
194
  <div class="composer-actions">
195
+ <button class="attach-button" id="micButton" type="button">Start voice input</button>
176
196
  <button class="attach-button" id="attachButton" type="button">Attach files</button>
177
197
  <input id="fileInput" type="file" multiple>
178
198
  <div class="attachment-tray" id="attachmentTray" aria-live="polite"></div>
@@ -181,6 +201,48 @@ def render_web_chat_page(
181
201
  </form>
182
202
  </main>
183
203
  </div>
204
+ <dialog id="speechSettingsDialog">
205
+ <form id="speechSettingsForm">
206
+ <div class="settings-head"><strong>Speech Settings</strong><button class="ghost" id="speechSettingsClose" type="button">Close</button></div>
207
+ <div class="settings-body">
208
+ <section class="settings-section">
209
+ <h3>STT / Qwen ASR</h3>
210
+ <div class="settings-grid">
211
+ <label class="check"><input id="asrEnabled" type="checkbox"> Enable STT</label>
212
+ <label>Language<input id="asrLanguage" placeholder="auto"></label>
213
+ <label class="wide">Tailscale base URL<input id="asrBaseUrl" placeholder="http://ciel-asr:8000"></label>
214
+ <label class="wide">Model<input id="asrModel" placeholder="Qwen/Qwen3-ASR-0.6B"></label>
215
+ <label class="wide">Remote bearer token<input id="asrApiKey" type="password" autocomplete="new-password" placeholder="Leave blank to keep current token"></label>
216
+ </div>
217
+ </section>
218
+ <section class="settings-section">
219
+ <h3>TTS / MOSS-TTS-Nano</h3>
220
+ <div class="settings-grid">
221
+ <label class="check"><input id="ttsEnabled" type="checkbox"> Enable TTS</label>
222
+ <label class="check"><input id="ttsAutoSpeak" type="checkbox"> Speak replies automatically</label>
223
+ <label class="wide">Tailscale base URL<input id="ttsBaseUrl" placeholder="http://ciel-tts:8091"></label>
224
+ <label>Voice<input id="ttsVoice" placeholder="default"></label>
225
+ <label>Language<input id="ttsLanguage" placeholder="ko"></label>
226
+ <label class="wide">Model<input id="ttsModel" placeholder="OpenMOSS-Team/MOSS-TTS-Nano"></label>
227
+ <label class="wide">Reference voice (required by MOSS-TTS-Nano)<input id="ttsReferenceAudio" type="file" accept="audio/*"><span class="hint" id="ttsReferenceAudioStatus">No reference voice configured</span></label>
228
+ <label class="wide">Reference transcript (optional)<input id="ttsReferenceText" placeholder="Transcript of the reference clip"></label>
229
+ <label class="check wide"><input id="ttsClearReferenceAudio" type="checkbox"> Remove the saved reference voice</label>
230
+ <label class="wide">Remote bearer token<input id="ttsApiKey" type="password" autocomplete="new-password" placeholder="Leave blank to keep current token"></label>
231
+ </div>
232
+ </section>
233
+ <section class="settings-section">
234
+ <h3>Tailscale tunnel</h3>
235
+ <div class="settings-grid">
236
+ <label class="check"><input id="tailscaleEnabled" type="checkbox"> Use tailnet-only addresses</label>
237
+ <label>ASR hostname<input id="tailscaleAsrHostname" placeholder="ciel-asr"></label>
238
+ <label>TTS hostname<input id="tailscaleTtsHostname" placeholder="ciel-tts"></label>
239
+ </div>
240
+ <div class="hint">The browser calls Ciel locally. Only the Ciel router connects to these Tailscale services.</div>
241
+ </section>
242
+ <div class="settings-actions"><button class="ghost" id="speechHealthButton" type="button">Test connections</button><button class="primary" type="submit">Save</button></div>
243
+ </div>
244
+ </form>
245
+ </dialog>
184
246
  <script>
185
247
  const MODEL = {json.dumps(model)};
186
248
  const transcript = document.getElementById('transcript');
@@ -188,10 +250,16 @@ def render_web_chat_page(
188
250
  const prompt = document.getElementById('prompt');
189
251
  const sendButton = document.getElementById('sendButton');
190
252
  const attachButton = document.getElementById('attachButton');
253
+ const micButton = document.getElementById('micButton');
191
254
  const fileInput = document.getElementById('fileInput');
192
255
  const attachmentTray = document.getElementById('attachmentTray');
193
256
  const shareButton = document.getElementById('shareButton');
194
257
  const clearButton = document.getElementById('clearButton');
258
+ const speechSettingsButton = document.getElementById('speechSettingsButton');
259
+ const speechSettingsDialog = document.getElementById('speechSettingsDialog');
260
+ const speechSettingsForm = document.getElementById('speechSettingsForm');
261
+ const speechSettingsClose = document.getElementById('speechSettingsClose');
262
+ const speechHealthButton = document.getElementById('speechHealthButton');
195
263
  const statePill = document.getElementById('statePill');
196
264
  const SESSION_KEY = 'ciel-runtime-web-chat-session';
197
265
  const LAST_ID_KEY = 'ciel-runtime-web-chat-last-id';
@@ -218,6 +286,11 @@ def render_web_chat_page(
218
286
  let lastId = Number(localStorage.getItem(scopedLastIdKey) || '0') || 0;
219
287
  let eventSource = null;
220
288
  let selectedFiles = [];
289
+ let speechConfig = {{asr: {{enabled: false}}, tts: {{enabled: false, auto_speak: false}}}};
290
+ let mediaRecorder = null;
291
+ let mediaStream = null;
292
+ let recordingChunks = [];
293
+ let pendingTtsReferenceAudio = '';
221
294
  function setState(text, cls = '') {{
222
295
  statePill.textContent = text;
223
296
  statePill.className = 'pill ' + cls;
@@ -401,6 +474,16 @@ def render_web_chat_page(
401
474
  bubble.innerHTML = renderMarkdown(text);
402
475
  }}
403
476
  row.appendChild(bubble);
477
+ if (role === 'assistant') {{
478
+ const actions = document.createElement('div');
479
+ actions.className = 'message-actions';
480
+ const speak = document.createElement('button');
481
+ speak.type = 'button';
482
+ speak.textContent = 'Speak';
483
+ speak.addEventListener('click', () => speakText(text));
484
+ actions.appendChild(speak);
485
+ row.appendChild(actions);
486
+ }}
404
487
  if (mode === 'prepend') {{
405
488
  transcript.insertBefore(row, transcript.firstChild);
406
489
  }} else {{
@@ -424,7 +507,10 @@ def render_web_chat_page(
424
507
  const text = message.message || '';
425
508
  if (!text.trim()) return;
426
509
  addBubble(roleForMessage(message), text, mode, message.id);
427
- if (mode !== 'prepend' && message.sender_id !== 'web-user') setState('reply received', 'ok');
510
+ if (mode !== 'prepend' && message.sender_id !== 'web-user') {{
511
+ setState('reply received', 'ok');
512
+ if (speechConfig.tts && speechConfig.tts.enabled && speechConfig.tts.auto_speak) speakText(text);
513
+ }}
428
514
  }}
429
515
  function formatBytes(bytes) {{
430
516
  const value = Number(bytes || 0);
@@ -471,6 +557,150 @@ def render_web_chat_page(
471
557
  reader.readAsDataURL(file);
472
558
  }});
473
559
  }}
560
+ function fileToDataUrl(file) {{
561
+ return new Promise((resolve, reject) => {{
562
+ const reader = new FileReader();
563
+ reader.onload = () => resolve(String(reader.result || ''));
564
+ reader.onerror = () => reject(reader.error || new Error('Could not read file'));
565
+ reader.readAsDataURL(file);
566
+ }});
567
+ }}
568
+ function setSpeechForm(config) {{
569
+ const asr = config.asr || {{}};
570
+ const tts = config.tts || {{}};
571
+ const tailscale = config.tailscale || {{}};
572
+ document.getElementById('asrEnabled').checked = Boolean(asr.enabled);
573
+ document.getElementById('asrBaseUrl').value = asr.base_url || '';
574
+ document.getElementById('asrModel').value = asr.model || '';
575
+ document.getElementById('asrLanguage').value = asr.language || 'auto';
576
+ document.getElementById('asrApiKey').value = '';
577
+ document.getElementById('asrApiKey').placeholder = asr.api_key_set ? 'Token is set; leave blank to keep it' : 'Optional remote bearer token';
578
+ document.getElementById('ttsEnabled').checked = Boolean(tts.enabled);
579
+ document.getElementById('ttsAutoSpeak').checked = Boolean(tts.auto_speak);
580
+ document.getElementById('ttsBaseUrl').value = tts.base_url || '';
581
+ document.getElementById('ttsModel').value = tts.model || '';
582
+ document.getElementById('ttsVoice').value = tts.voice || 'default';
583
+ document.getElementById('ttsLanguage').value = tts.language || 'ko';
584
+ document.getElementById('ttsReferenceText').value = tts.ref_text || '';
585
+ document.getElementById('ttsReferenceAudioStatus').textContent = tts.ref_audio_set ? 'Reference voice saved securely on this Ciel router' : 'No reference voice configured';
586
+ document.getElementById('ttsClearReferenceAudio').checked = false;
587
+ document.getElementById('ttsReferenceAudio').value = '';
588
+ pendingTtsReferenceAudio = '';
589
+ document.getElementById('ttsApiKey').value = '';
590
+ document.getElementById('ttsApiKey').placeholder = tts.api_key_set ? 'Token is set; leave blank to keep it' : 'Optional remote bearer token';
591
+ document.getElementById('tailscaleEnabled').checked = tailscale.enabled !== false;
592
+ document.getElementById('tailscaleAsrHostname').value = tailscale.asr_hostname || 'ciel-asr';
593
+ document.getElementById('tailscaleTtsHostname').value = tailscale.tts_hostname || 'ciel-tts';
594
+ micButton.disabled = !asr.enabled;
595
+ micButton.title = asr.enabled ? 'Record speech and transcribe it' : 'Enable STT in Speech Settings first';
596
+ }}
597
+ async function loadSpeechConfig() {{
598
+ const response = await fetch('/ca/speech/config', {{headers: {{'accept': 'application/json'}}}});
599
+ const data = await response.json();
600
+ if (!response.ok || !data.ok) throw new Error(data.error || `HTTP ${{response.status}}`);
601
+ speechConfig = data;
602
+ setSpeechForm(data);
603
+ return data;
604
+ }}
605
+ async function saveSpeechConfig() {{
606
+ const payload = {{
607
+ asr: {{
608
+ enabled: document.getElementById('asrEnabled').checked,
609
+ base_url: document.getElementById('asrBaseUrl').value,
610
+ model: document.getElementById('asrModel').value,
611
+ language: document.getElementById('asrLanguage').value,
612
+ api_key: document.getElementById('asrApiKey').value,
613
+ }},
614
+ tts: {{
615
+ enabled: document.getElementById('ttsEnabled').checked,
616
+ auto_speak: document.getElementById('ttsAutoSpeak').checked,
617
+ base_url: document.getElementById('ttsBaseUrl').value,
618
+ model: document.getElementById('ttsModel').value,
619
+ voice: document.getElementById('ttsVoice').value,
620
+ language: document.getElementById('ttsLanguage').value,
621
+ ref_audio: pendingTtsReferenceAudio,
622
+ ref_text: document.getElementById('ttsReferenceText').value,
623
+ clear_ref_audio: document.getElementById('ttsClearReferenceAudio').checked,
624
+ api_key: document.getElementById('ttsApiKey').value,
625
+ }},
626
+ tailscale: {{
627
+ enabled: document.getElementById('tailscaleEnabled').checked,
628
+ asr_hostname: document.getElementById('tailscaleAsrHostname').value,
629
+ tts_hostname: document.getElementById('tailscaleTtsHostname').value,
630
+ }},
631
+ }};
632
+ const response = await fetch('/ca/speech/config', {{method: 'POST', headers: {{'content-type': 'application/json', 'accept': 'application/json'}}, body: JSON.stringify(payload)}});
633
+ const data = await response.json();
634
+ if (!response.ok || !data.ok) throw new Error(data.error || `HTTP ${{response.status}}`);
635
+ speechConfig = data;
636
+ setSpeechForm(data);
637
+ return data;
638
+ }}
639
+ async function speakText(text) {{
640
+ if (!speechConfig.tts || !speechConfig.tts.enabled) {{
641
+ setState('TTS disabled', 'error');
642
+ return;
643
+ }}
644
+ try {{
645
+ setState('generating speech');
646
+ const response = await fetch('/v1/audio/speech', {{
647
+ method: 'POST',
648
+ headers: {{'content-type': 'application/json'}},
649
+ body: JSON.stringify({{input: String(text || ''), model: speechConfig.tts.model, voice: speechConfig.tts.voice, language: speechConfig.tts.language, response_format: speechConfig.tts.response_format || 'wav'}})
650
+ }});
651
+ if (!response.ok) throw new Error(await response.text() || `HTTP ${{response.status}}`);
652
+ const blob = await response.blob();
653
+ const url = URL.createObjectURL(blob);
654
+ const audio = new Audio(url);
655
+ audio.addEventListener('ended', () => URL.revokeObjectURL(url), {{once: true}});
656
+ audio.addEventListener('error', () => URL.revokeObjectURL(url), {{once: true}});
657
+ await audio.play();
658
+ setState('speaking', 'ok');
659
+ }} catch (err) {{
660
+ setState('TTS error', 'error');
661
+ addBubble('system', 'TTS failed: ' + String(err && err.message ? err.message : err));
662
+ }}
663
+ }}
664
+ async function transcribeRecording(blob) {{
665
+ setState('transcribing');
666
+ const audio_base64 = await fileToBase64(blob);
667
+ const response = await fetch('/v1/audio/transcriptions', {{
668
+ method: 'POST',
669
+ headers: {{'content-type': 'application/json', 'accept': 'application/json'}},
670
+ body: JSON.stringify({{audio_base64, filename: 'web-chat-recording.webm', content_type: blob.type || 'audio/webm', model: speechConfig.asr.model, language: speechConfig.asr.language}})
671
+ }});
672
+ const text = await response.text();
673
+ let data = {{}};
674
+ try {{ data = text ? JSON.parse(text) : {{}}; }} catch {{}}
675
+ if (!response.ok) throw new Error((data.error && (data.error.message || data.error)) || text || `HTTP ${{response.status}}`);
676
+ const transcriptText = String(data.text || data.transcript || '').trim();
677
+ if (!transcriptText) throw new Error('ASR returned no transcript');
678
+ prompt.value = prompt.value ? prompt.value + ' ' + transcriptText : transcriptText;
679
+ prompt.focus();
680
+ setState('transcribed', 'ok');
681
+ }}
682
+ async function startVoiceInput() {{
683
+ if (!navigator.mediaDevices || !window.MediaRecorder) throw new Error('This browser does not support microphone recording');
684
+ mediaStream = await navigator.mediaDevices.getUserMedia({{audio: true}});
685
+ recordingChunks = [];
686
+ mediaRecorder = new MediaRecorder(mediaStream);
687
+ mediaRecorder.addEventListener('dataavailable', event => {{ if (event.data && event.data.size) recordingChunks.push(event.data); }});
688
+ mediaRecorder.addEventListener('stop', async () => {{
689
+ const blob = new Blob(recordingChunks, {{type: mediaRecorder.mimeType || 'audio/webm'}});
690
+ if (mediaStream) mediaStream.getTracks().forEach(track => track.stop());
691
+ mediaStream = null;
692
+ micButton.textContent = 'Start voice input';
693
+ micButton.classList.remove('recording');
694
+ try {{ await transcribeRecording(blob); }} catch (err) {{ setState('STT error', 'error'); addBubble('system', 'STT failed: ' + String(err && err.message ? err.message : err)); }}
695
+ }}, {{once: true}});
696
+ mediaRecorder.start();
697
+ micButton.textContent = 'Stop and transcribe';
698
+ micButton.classList.add('recording');
699
+ setState('recording', 'error');
700
+ }}
701
+ function stopVoiceInput() {{
702
+ if (mediaRecorder && mediaRecorder.state !== 'inactive') mediaRecorder.stop();
703
+ }}
474
704
  async function uploadAttachment(file) {{
475
705
  const content = await fileToBase64(file);
476
706
  const response = await fetch('/ca/channel/files', {{
@@ -663,6 +893,54 @@ def render_web_chat_page(
663
893
  }}
664
894
  }});
665
895
  attachButton.addEventListener('click', () => fileInput.click());
896
+ micButton.addEventListener('click', async () => {{
897
+ if (mediaRecorder && mediaRecorder.state !== 'inactive') {{
898
+ stopVoiceInput();
899
+ return;
900
+ }}
901
+ try {{ await startVoiceInput(); }} catch (err) {{
902
+ setState('microphone error', 'error');
903
+ addBubble('system', 'Microphone failed: ' + String(err && err.message ? err.message : err));
904
+ }}
905
+ }});
906
+ speechSettingsButton.addEventListener('click', async () => {{
907
+ try {{ await loadSpeechConfig(); }} catch (err) {{ addBubble('system', 'Could not load speech settings: ' + String(err && err.message ? err.message : err)); }}
908
+ speechSettingsDialog.showModal();
909
+ }});
910
+ speechSettingsClose.addEventListener('click', () => speechSettingsDialog.close());
911
+ document.getElementById('ttsReferenceAudio').addEventListener('change', async event => {{
912
+ const file = event.target.files && event.target.files[0];
913
+ if (!file) return;
914
+ if (file.size > 10 * 1024 * 1024) {{
915
+ event.target.value = '';
916
+ addBubble('system', 'Reference voice must be 10 MB or smaller.');
917
+ return;
918
+ }}
919
+ pendingTtsReferenceAudio = await fileToDataUrl(file);
920
+ document.getElementById('ttsClearReferenceAudio').checked = false;
921
+ document.getElementById('ttsReferenceAudioStatus').textContent = file.name + ' (' + formatBytes(file.size) + ') ready to save';
922
+ }});
923
+ speechSettingsForm.addEventListener('submit', async event => {{
924
+ event.preventDefault();
925
+ try {{
926
+ await saveSpeechConfig();
927
+ speechSettingsDialog.close();
928
+ setState('speech settings saved', 'ok');
929
+ }} catch (err) {{
930
+ setState('settings error', 'error');
931
+ addBubble('system', 'Speech settings failed: ' + String(err && err.message ? err.message : err));
932
+ }}
933
+ }});
934
+ speechHealthButton.addEventListener('click', async () => {{
935
+ try {{
936
+ await saveSpeechConfig();
937
+ const response = await fetch('/ca/speech/health', {{headers: {{'accept': 'application/json'}}}});
938
+ const data = await response.json();
939
+ const asr = data.services && data.services.asr;
940
+ const tts = data.services && data.services.tts;
941
+ addBubble('system', `Speech health — ASR: ${{asr && asr.reachable ? 'reachable' : asr && asr.enabled ? 'unreachable' : 'disabled'}}, TTS: ${{tts && tts.reachable ? 'reachable' : tts && tts.enabled ? 'unreachable' : 'disabled'}}.`);
942
+ }} catch (err) {{ addBubble('system', 'Speech health check failed: ' + String(err && err.message ? err.message : err)); }}
943
+ }});
666
944
  fileInput.addEventListener('change', () => {{
667
945
  addSelectedFiles(fileInput.files);
668
946
  fileInput.value = '';
@@ -706,6 +984,7 @@ def render_web_chat_page(
706
984
  if (transcript.scrollTop < 48) loadOlderHistory();
707
985
  }});
708
986
  addBubble('system', `Connected to active session bridge for ${{MODEL}}. Messages are queued on channel ${{channel}} and replies stream back from /ca/channel/stream.`);
987
+ loadSpeechConfig().catch(() => {{ micButton.disabled = true; }});
709
988
  loadInitialHistory().finally(startChannelStream);
710
989
  prompt.focus();
711
990
  </script>
@@ -0,0 +1,34 @@
1
+ # Colab speech workers
2
+
3
+ Ciel Runtime can proxy its web chat and OpenAI-compatible audio API to two Colab workers over a tailnet-only Tailscale tunnel.
4
+
5
+ ## One-time prerequisites
6
+
7
+ 1. Authenticate the Colab CLI inside WSL. The installed CLI currently uses Google Application Default Credentials, so run `~/google-cloud-sdk/bin/gcloud auth application-default login` in `Ubuntu-26.04`.
8
+ 2. Create a reusable Tailscale auth key (two workers must register) and set it only for the current PowerShell process with `$env:TAILSCALE_AUTHKEY = Read-Host`. Alternatively, use a separate fresh key for each worker. The CLI passes the key without writing it to the repository. A Colab `TAILSCALE_AUTHKEY` Secret is also supported as a fallback.
9
+ 3. Optionally set `$env:CIEL_SPEECH_API_KEY = Read-Host` before deployment. Enter that value once in Web Chat > Speech Settings; Ciel stores it server-side and never returns it to the browser.
10
+
11
+ ## Deploy
12
+
13
+ From PowerShell at the repository root:
14
+
15
+ ```powershell
16
+ .\scripts\deploy_colab_speech.ps1
17
+ ```
18
+
19
+ The script creates `ciel-asr` and `ciel-tts` T4 sessions, installs Qwen3-ASR-0.6B and MOSS-TTS-Nano, starts Tailscale in userspace networking mode, publishes each localhost model server with Tailscale Serve, and saves both returned `base_url` values into Web Chat > Speech Settings automatically.
20
+
21
+ MOSS-TTS-Nano is a voice-cloning model without built-in speakers. Deployment configures the project's official `zh_1.wav` sample so the first request works immediately. In Web Chat > Speech Settings, upload a reference voice clip (10 MB maximum) to replace it. Ciel stores uploaded audio only in the local protected runtime configuration, omits it from configuration responses, and adds it to TTS requests automatically. API clients can instead pass `ref_audio` as an HTTP(S) URL or base64 audio data URL to `POST /v1/audio/speech`.
22
+
23
+ Colab sessions are ephemeral. Re-run the bootstrap after a runtime reset. The workers are reachable only by devices in the same tailnet unless an administrator separately enables Tailscale Funnel.
24
+
25
+ ## API surface
26
+
27
+ - `GET|POST /ca/speech/config`
28
+ - `GET /ca/speech/health`
29
+ - `POST /v1/audio/transcriptions`
30
+ - `POST /v1/audio/translations`
31
+ - `POST /v1/audio/speech`
32
+ - `POST /v1/audio/speech/batch`
33
+ - `GET|POST /v1/audio/voices`
34
+ - `GET /ca/web/chat/api` lists chat, model, message, response, file, and speech endpoints.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oneciel-ai/ciel-runtime",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
4
4
  "description": "Universal AI coding-agent runtime and model-routing layer for Claude, Codex, AGY, and compatible runtimes.",
5
5
  "license": "MIT",
6
6
  "author": "One Ciel LLC",
@@ -36,6 +36,9 @@
36
36
  "ciel-runtime-stop.cmd",
37
37
  "ciel-runtime-stop.ps1",
38
38
  "npm-bin/",
39
+ "scripts/colab/",
40
+ "scripts/configure_speech_workers.py",
41
+ "scripts/deploy_colab_speech.ps1",
39
42
  "install.sh",
40
43
  "install.ps1",
41
44
  "README.md",
@@ -0,0 +1,150 @@
1
+ """Bootstrap MOSS-TTS-Nano with vLLM-Omni on Colab and Tailscale Serve.
2
+
3
+ Required Colab Secret: TAILSCALE_AUTHKEY
4
+ Optional Colab Secret: CIEL_SPEECH_API_KEY
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ from pathlib import Path
12
+ import site
13
+ import shutil
14
+ import subprocess
15
+ import sys
16
+ import time
17
+ import urllib.request
18
+
19
+
20
+ HOSTNAME = os.environ.get("CIEL_TTS_HOSTNAME", "ciel-tts")
21
+ PORT = 8091
22
+ SOCKET = "/tmp/ciel-tts-tailscaled.sock"
23
+ STATE = "/tmp/ciel-tts-tailscaled.state"
24
+ LOG_DIR = Path("/content/ciel-speech-logs")
25
+
26
+
27
+ def secret(name: str, *, required: bool = False) -> str:
28
+ value = str(os.environ.get(name) or "").strip()
29
+ if not value:
30
+ try:
31
+ from google.colab import userdata # type: ignore
32
+
33
+ value = str(userdata.get(name) or "").strip()
34
+ except Exception:
35
+ value = ""
36
+ if required and not value:
37
+ raise RuntimeError(f"Add {name} to Colab Secrets and allow notebook access, then rerun this script.")
38
+ return value
39
+
40
+
41
+ def run(*args: str, check: bool = True) -> subprocess.CompletedProcess[str]:
42
+ visible: list[str] = []
43
+ redact_next = False
44
+ for arg in args:
45
+ if redact_next:
46
+ visible.append("<redacted>")
47
+ redact_next = False
48
+ elif arg.startswith("--auth-key="):
49
+ visible.append("--auth-key=<redacted>")
50
+ else:
51
+ visible.append(arg)
52
+ redact_next = arg == "--api-key"
53
+ print("+", " ".join(visible))
54
+ return subprocess.run(args, check=check, text=True, capture_output=False)
55
+
56
+
57
+ def install_tailscale() -> None:
58
+ if shutil.which("tailscale"):
59
+ return
60
+ run("bash", "-lc", "curl -fsSL https://tailscale.com/install.sh | sh")
61
+
62
+
63
+ def start_tailscale(auth_key: str) -> tuple[str, str]:
64
+ install_tailscale()
65
+ LOG_DIR.mkdir(parents=True, exist_ok=True)
66
+ tail_log = (LOG_DIR / "tailscale-tts.log").open("ab")
67
+ if not Path(SOCKET).exists():
68
+ subprocess.Popen(
69
+ ["tailscaled", f"--socket={SOCKET}", f"--state={STATE}", "--tun=userspace-networking"],
70
+ stdout=tail_log,
71
+ stderr=subprocess.STDOUT,
72
+ start_new_session=True,
73
+ )
74
+ for _ in range(60):
75
+ if Path(SOCKET).exists():
76
+ break
77
+ time.sleep(1)
78
+ login = run("tailscale", f"--socket={SOCKET}", "up", f"--auth-key={auth_key}", f"--hostname={HOSTNAME}", "--accept-dns=true", "--reset", check=False)
79
+ if login.returncode:
80
+ raise RuntimeError("Tailscale authentication failed; use a valid reusable key or a fresh key for this second worker")
81
+ status = subprocess.check_output(["tailscale", f"--socket={SOCKET}", "status", "--json"], text=True)
82
+ dns_name = str(json.loads(status).get("Self", {}).get("DNSName") or HOSTNAME).rstrip(".")
83
+ run("tailscale", f"--socket={SOCKET}", "serve", "--bg", "--http=80", f"http://127.0.0.1:{PORT}")
84
+ return dns_name, f"http://{dns_name}"
85
+
86
+
87
+ def wait_for_server(api_key: str, process: subprocess.Popen[bytes]) -> None:
88
+ headers = {"authorization": f"Bearer {api_key}"} if api_key else {}
89
+ for _ in range(240):
90
+ if process.poll() is not None:
91
+ log_path = LOG_DIR / "moss-tts.log"
92
+ log_tail = log_path.read_text(encoding="utf-8", errors="replace")[-12000:] if log_path.exists() else "log unavailable"
93
+ raise RuntimeError(f"MOSS TTS exited with status {process.returncode}:\n{log_tail}")
94
+ try:
95
+ with urllib.request.urlopen(urllib.request.Request(f"http://127.0.0.1:{PORT}/health", headers=headers), timeout=3) as response:
96
+ if response.status < 500:
97
+ return
98
+ except Exception:
99
+ time.sleep(2)
100
+ raise RuntimeError("MOSS TTS did not become healthy; inspect /content/ciel-speech-logs/moss-tts.log")
101
+
102
+
103
+ def server_is_healthy(api_key: str) -> bool:
104
+ headers = {"authorization": f"Bearer {api_key}"} if api_key else {}
105
+ try:
106
+ with urllib.request.urlopen(urllib.request.Request(f"http://127.0.0.1:{PORT}/health", headers=headers), timeout=3) as response:
107
+ return response.status < 500
108
+ except Exception:
109
+ return False
110
+
111
+
112
+ def main() -> None:
113
+ auth_key = secret("TAILSCALE_AUTHKEY", required=True)
114
+ api_key = secret("CIEL_SPEECH_API_KEY")
115
+ run(
116
+ sys.executable,
117
+ "-m",
118
+ "pip",
119
+ "install",
120
+ "-U",
121
+ "nvidia-cuda-runtime==13.0.96",
122
+ "vllm==0.24.0",
123
+ "vllm-omni==0.24.0",
124
+ )
125
+ LOG_DIR.mkdir(parents=True, exist_ok=True)
126
+ if not server_is_healthy(api_key):
127
+ command = [
128
+ "vllm-omni", "serve", "OpenMOSS-Team/MOSS-TTS-Nano", "--omni", "--host", "127.0.0.1", "--port", str(PORT),
129
+ "--gpu-memory-utilization", "0.72",
130
+ ]
131
+ if api_key:
132
+ command.extend(["--api-key", api_key])
133
+ server_env = os.environ.copy()
134
+ cuda_runtime_libraries = [
135
+ library
136
+ for package_dir in site.getsitepackages()
137
+ for library in Path(package_dir).glob("**/libcudart.so.13")
138
+ ]
139
+ if cuda_runtime_libraries:
140
+ existing_library_path = server_env.get("LD_LIBRARY_PATH", "")
141
+ server_env["LD_LIBRARY_PATH"] = str(cuda_runtime_libraries[0].parent) + (f":{existing_library_path}" if existing_library_path else "")
142
+ server_log = (LOG_DIR / "moss-tts.log").open("ab")
143
+ process = subprocess.Popen(command, stdout=server_log, stderr=subprocess.STDOUT, start_new_session=True, env=server_env)
144
+ wait_for_server(api_key, process)
145
+ dns_name, base_url = start_tailscale(auth_key)
146
+ print(json.dumps({"ok": True, "role": "tts", "hostname": dns_name, "base_url": base_url, "model": "OpenMOSS-Team/MOSS-TTS-Nano", "api_key_set": bool(api_key)}, indent=2))
147
+
148
+
149
+ if __name__ == "__main__":
150
+ main()
@@ -0,0 +1,116 @@
1
+ """Bootstrap Qwen3-ASR on a Colab T4 and publish it only to the tailnet.
2
+
3
+ Required Colab Secret: TAILSCALE_AUTHKEY
4
+ Optional Colab Secret: CIEL_SPEECH_API_KEY
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ from pathlib import Path
12
+ import shutil
13
+ import subprocess
14
+ import sys
15
+ import time
16
+ import urllib.request
17
+
18
+
19
+ HOSTNAME = os.environ.get("CIEL_ASR_HOSTNAME", "ciel-asr")
20
+ PORT = 8000
21
+ SOCKET = "/tmp/ciel-asr-tailscaled.sock"
22
+ STATE = "/tmp/ciel-asr-tailscaled.state"
23
+ LOG_DIR = Path("/content/ciel-speech-logs")
24
+
25
+
26
+ def secret(name: str, *, required: bool = False) -> str:
27
+ value = str(os.environ.get(name) or "").strip()
28
+ if not value:
29
+ try:
30
+ from google.colab import userdata # type: ignore
31
+
32
+ value = str(userdata.get(name) or "").strip()
33
+ except Exception:
34
+ value = ""
35
+ if required and not value:
36
+ raise RuntimeError(f"Add {name} to Colab Secrets and allow notebook access, then rerun this script.")
37
+ return value
38
+
39
+
40
+ def run(*args: str, check: bool = True) -> subprocess.CompletedProcess[str]:
41
+ visible: list[str] = []
42
+ redact_next = False
43
+ for arg in args:
44
+ if redact_next:
45
+ visible.append("<redacted>")
46
+ redact_next = False
47
+ elif arg.startswith("--auth-key="):
48
+ visible.append("--auth-key=<redacted>")
49
+ else:
50
+ visible.append(arg)
51
+ redact_next = arg == "--api-key"
52
+ print("+", " ".join(visible))
53
+ return subprocess.run(args, check=check, text=True, capture_output=False)
54
+
55
+
56
+ def install_tailscale() -> None:
57
+ if shutil.which("tailscale"):
58
+ return
59
+ run("bash", "-lc", "curl -fsSL https://tailscale.com/install.sh | sh")
60
+
61
+
62
+ def start_tailscale(auth_key: str) -> tuple[str, str]:
63
+ install_tailscale()
64
+ LOG_DIR.mkdir(parents=True, exist_ok=True)
65
+ tail_log = (LOG_DIR / "tailscale-asr.log").open("ab")
66
+ subprocess.Popen(
67
+ ["tailscaled", f"--socket={SOCKET}", f"--state={STATE}", "--tun=userspace-networking"],
68
+ stdout=tail_log,
69
+ stderr=subprocess.STDOUT,
70
+ start_new_session=True,
71
+ )
72
+ for _ in range(60):
73
+ if Path(SOCKET).exists():
74
+ break
75
+ time.sleep(1)
76
+ login = run("tailscale", f"--socket={SOCKET}", "up", f"--auth-key={auth_key}", f"--hostname={HOSTNAME}", "--accept-dns=true", "--reset", check=False)
77
+ if login.returncode:
78
+ raise RuntimeError("Tailscale authentication failed; use a valid reusable key or a fresh key for this worker")
79
+ status = subprocess.check_output(["tailscale", f"--socket={SOCKET}", "status", "--json"], text=True)
80
+ dns_name = str(json.loads(status).get("Self", {}).get("DNSName") or HOSTNAME).rstrip(".")
81
+ run("tailscale", f"--socket={SOCKET}", "serve", "--bg", "--http=80", f"http://127.0.0.1:{PORT}")
82
+ return dns_name, f"http://{dns_name}"
83
+
84
+
85
+ def wait_for_server(api_key: str) -> None:
86
+ headers = {"authorization": f"Bearer {api_key}"} if api_key else {}
87
+ for _ in range(180):
88
+ try:
89
+ with urllib.request.urlopen(urllib.request.Request(f"http://127.0.0.1:{PORT}/health", headers=headers), timeout=3) as response:
90
+ if response.status < 500:
91
+ return
92
+ except Exception:
93
+ time.sleep(2)
94
+ raise RuntimeError("Qwen ASR did not become healthy; inspect /content/ciel-speech-logs/qwen-asr.log")
95
+
96
+
97
+ def main() -> None:
98
+ auth_key = secret("TAILSCALE_AUTHKEY", required=True)
99
+ api_key = secret("CIEL_SPEECH_API_KEY")
100
+ run(sys.executable, "-m", "pip", "install", "-U", "qwen-asr[vllm]", "vllm[audio]")
101
+ LOG_DIR.mkdir(parents=True, exist_ok=True)
102
+ command = [
103
+ "qwen-asr-serve", "Qwen/Qwen3-ASR-0.6B", "--host", "127.0.0.1", "--port", str(PORT),
104
+ "--gpu-memory-utilization", "0.78", "--max-model-len", "8192",
105
+ ]
106
+ if api_key:
107
+ command.extend(["--api-key", api_key])
108
+ server_log = (LOG_DIR / "qwen-asr.log").open("ab")
109
+ subprocess.Popen(command, stdout=server_log, stderr=subprocess.STDOUT, start_new_session=True)
110
+ wait_for_server(api_key)
111
+ dns_name, base_url = start_tailscale(auth_key)
112
+ print(json.dumps({"ok": True, "role": "asr", "hostname": dns_name, "base_url": base_url, "model": "Qwen/Qwen3-ASR-0.6B", "api_key_set": bool(api_key)}, indent=2))
113
+
114
+
115
+ if __name__ == "__main__":
116
+ main()
@@ -0,0 +1,43 @@
1
+ """Persist deployed speech worker URLs in the active Ciel Runtime config."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ from pathlib import Path
7
+ import sys
8
+ from typing import Any
9
+
10
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
11
+
12
+
13
+ DEFAULT_TTS_REFERENCE_AUDIO = "https://raw.githubusercontent.com/OpenMOSS/MOSS-TTS-Nano/main/assets/audio/zh_1.wav"
14
+
15
+
16
+ def configure(asr_base_url: str, tts_base_url: str, tts_reference_audio: str = DEFAULT_TTS_REFERENCE_AUDIO) -> dict[str, Any]:
17
+ import ciel_runtime
18
+
19
+ config = ciel_runtime.load_config()
20
+ speech = config.setdefault("speech", {})
21
+ asr = speech.setdefault("asr", {})
22
+ tts = speech.setdefault("tts", {})
23
+ asr.update({"enabled": True, "base_url": asr_base_url.rstrip("/"), "model": "Qwen/Qwen3-ASR-0.6B"})
24
+ tts.update({"enabled": True, "base_url": tts_base_url.rstrip("/"), "model": "OpenMOSS-Team/MOSS-TTS-Nano"})
25
+ if tts_reference_audio and not str(tts.get("ref_audio") or "").strip():
26
+ tts["ref_audio"] = tts_reference_audio
27
+ ciel_runtime.save_config(config)
28
+ return {"asr": asr["base_url"], "tts": tts["base_url"]}
29
+
30
+
31
+ def main() -> int:
32
+ parser = argparse.ArgumentParser()
33
+ parser.add_argument("--asr-base-url", required=True)
34
+ parser.add_argument("--tts-base-url", required=True)
35
+ parser.add_argument("--tts-reference-audio", default=DEFAULT_TTS_REFERENCE_AUDIO)
36
+ args = parser.parse_args()
37
+ result = configure(args.asr_base_url, args.tts_base_url, args.tts_reference_audio)
38
+ print(f"Configured Ciel speech workers: ASR={result['asr']} TTS={result['tts']}")
39
+ return 0
40
+
41
+
42
+ if __name__ == "__main__":
43
+ raise SystemExit(main())
@@ -0,0 +1,56 @@
1
+ param(
2
+ [string]$Distribution = "Ubuntu-26.04",
3
+ [string]$AsrSession = "ciel-asr",
4
+ [string]$TtsSession = "ciel-tts"
5
+ )
6
+
7
+ $ErrorActionPreference = "Stop"
8
+ $repo = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
9
+ $wslRepo = (& wsl -d $Distribution -- wslpath -a ($repo -replace '\\', '/')).Trim()
10
+ if (-not $wslRepo) { throw "Could not resolve the repository path in WSL." }
11
+ $bootstrapEnv = ""
12
+ if ($env:TAILSCALE_AUTHKEY) {
13
+ if ($env:TAILSCALE_AUTHKEY -notmatch '^tskey-[A-Za-z0-9_-]+$') { throw "TAILSCALE_AUTHKEY has an unexpected format." }
14
+ $bootstrapEnv += " --env TAILSCALE_AUTHKEY=$($env:TAILSCALE_AUTHKEY)"
15
+ }
16
+ if ($env:CIEL_SPEECH_API_KEY) {
17
+ if ($env:CIEL_SPEECH_API_KEY -match '[\s''"]') { throw "CIEL_SPEECH_API_KEY cannot contain whitespace or quotes for CLI deployment." }
18
+ $bootstrapEnv += " --env CIEL_SPEECH_API_KEY=$($env:CIEL_SPEECH_API_KEY)"
19
+ }
20
+
21
+ Write-Host "Checking Colab CLI authentication..."
22
+ & wsl -d $Distribution -- bash -lc "colab --auth adc status >/dev/null"
23
+ if ($LASTEXITCODE -ne 0) {
24
+ throw "Colab CLI is not authenticated. Configure ADC in WSL (gcloud auth application-default login), then rerun."
25
+ }
26
+
27
+ Write-Host "Creating ASR T4 session: $AsrSession"
28
+ & wsl -d $Distribution -- bash -lc "colab --auth adc new --gpu T4 --session '$AsrSession'"
29
+ if ($LASTEXITCODE -ne 0) { throw "Could not create ASR Colab session." }
30
+
31
+ Write-Host "Creating TTS T4 session: $TtsSession"
32
+ & wsl -d $Distribution -- bash -lc "colab --auth adc new --gpu T4 --session '$TtsSession'"
33
+ if ($LASTEXITCODE -ne 0) { throw "Could not create TTS Colab session." }
34
+
35
+ Write-Host "Installing Qwen3-ASR and its Tailscale service..."
36
+ $asrOutput = (& wsl -d $Distribution -- bash -lc "colab --auth adc exec --session '$AsrSession'$bootstrapEnv --file '$wslRepo/scripts/colab/bootstrap_qwen_asr.py'" 2>&1 | Tee-Object -Variable asrDisplay) -join "`n"
37
+ if ($LASTEXITCODE -ne 0) { throw "ASR bootstrap failed." }
38
+
39
+ Write-Host "Installing MOSS-TTS-Nano and its Tailscale service..."
40
+ $ttsOutput = (& wsl -d $Distribution -- bash -lc "colab --auth adc exec --session '$TtsSession'$bootstrapEnv --file '$wslRepo/scripts/colab/bootstrap_moss_tts.py'" 2>&1 | Tee-Object -Variable ttsDisplay) -join "`n"
41
+ if ($LASTEXITCODE -ne 0) { throw "TTS bootstrap failed." }
42
+
43
+ function Read-BootstrapResult([string]$Text, [string]$Role) {
44
+ $matches = [regex]::Matches($Text, '(?s)\{\s*"ok"\s*:\s*true.*?\}')
45
+ if ($matches.Count -eq 0) { throw "Could not find the $Role bootstrap result in Colab output." }
46
+ $result = $matches[$matches.Count - 1].Value | ConvertFrom-Json
47
+ if ($result.role -ne $Role -or -not $result.base_url) { throw "Invalid $Role bootstrap result." }
48
+ return $result
49
+ }
50
+
51
+ $asr = Read-BootstrapResult $asrOutput "asr"
52
+ $tts = Read-BootstrapResult $ttsOutput "tts"
53
+ & python (Join-Path $PSScriptRoot "configure_speech_workers.py") --asr-base-url $asr.base_url --tts-base-url $tts.base_url
54
+ if ($LASTEXITCODE -ne 0) { throw "Workers started, but Ciel speech configuration failed." }
55
+
56
+ Write-Host "Both services are running and connected to Web Chat > Speech Settings."