@oneciel-ai/ciel-runtime 0.2.3 → 0.2.4

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,36 @@ 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
+ "response_format": "wav",
48
+ "speed": 1.0,
49
+ "auto_speak": False,
50
+ "api_key": "",
51
+ "timeout_seconds": 300,
52
+ },
53
+ "tailscale": {
54
+ "enabled": True,
55
+ "asr_hostname": "ciel-asr",
56
+ "tts_hostname": "ciel-tts",
57
+ },
58
+ },
29
59
  "claude_code": {
30
60
  "compat_prompt_for_non_anthropic": True,
31
61
  "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.4"
97
97
  CREDITS = "Credits: One Ciel LLC"
98
98
  PRELAUNCH_CANCEL = 10
99
99
  PRELAUNCH_LAUNCH_CODEX = 11
@@ -0,0 +1,335 @@
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 != "api_key"}
69
+ item["api_key_set"] = bool(str(source.get("api_key") or "").strip())
70
+ public[name] = item
71
+ tailscale = speech.get("tailscale")
72
+ public["tailscale"] = dict(tailscale) if isinstance(tailscale, dict) else {}
73
+ public["endpoints"] = self.discovery_payload()["endpoints"]
74
+ return public
75
+
76
+ def discovery_payload(self) -> dict[str, Any]:
77
+ return {
78
+ "ok": True,
79
+ "web_chat": "/ca/web/chat",
80
+ "endpoints": {
81
+ "chat_health": "GET /ca/channel/health",
82
+ "chat_messages": "GET|POST /ca/channel/messages",
83
+ "chat_wait": "GET /ca/channel/wait",
84
+ "chat_stream": "GET /ca/channel/stream",
85
+ "chat_files": "POST /ca/channel/files",
86
+ "speech_config": "GET|POST /ca/speech/config",
87
+ "speech_health": "GET /ca/speech/health",
88
+ "asr": "POST /v1/audio/transcriptions",
89
+ "asr_translate": "POST /v1/audio/translations",
90
+ "tts": "POST /v1/audio/speech",
91
+ "tts_batch": "POST /v1/audio/speech/batch",
92
+ "tts_voices": "GET|POST /v1/audio/voices",
93
+ "models": "GET /v1/models",
94
+ "responses": "POST /v1/responses",
95
+ "messages": "POST /v1/messages",
96
+ },
97
+ }
98
+
99
+ def health_payload(self) -> dict[str, Any]:
100
+ services = {name: self._probe(name) for name in ("asr", "tts")}
101
+ return {"ok": all(not item["enabled"] or item["reachable"] for item in services.values()), "services": services}
102
+
103
+ def _speech_config(self) -> SpeechConfig:
104
+ speech = self.ports.load_config().get("speech")
105
+ return speech if isinstance(speech, dict) else {}
106
+
107
+ def _service_config(self, name: str) -> SpeechConfig:
108
+ service = self._speech_config().get(name)
109
+ return service if isinstance(service, dict) else {}
110
+
111
+ def _save_public_config(self, handler: BaseHTTPRequestHandler, raw: bytes) -> bool:
112
+ try:
113
+ update = json.loads(raw.decode("utf-8") if raw else "{}")
114
+ if not isinstance(update, dict):
115
+ raise ValueError("configuration body must be a JSON object")
116
+ config = self.ports.load_config()
117
+ speech = config.setdefault("speech", {})
118
+ if not isinstance(speech, dict):
119
+ speech = {}
120
+ config["speech"] = speech
121
+ for name in ("asr", "tts"):
122
+ incoming = update.get(name)
123
+ if not isinstance(incoming, dict):
124
+ continue
125
+ current = speech.setdefault(name, {})
126
+ if not isinstance(current, dict):
127
+ current = {}
128
+ speech[name] = current
129
+ for key, value in incoming.items():
130
+ if key in {"api_key_set", "clear_api_key"}:
131
+ continue
132
+ if key == "api_key" and not str(value or "").strip():
133
+ continue
134
+ current[key] = self._validated_value(name, key, value)
135
+ if incoming.get("clear_api_key") is True:
136
+ current["api_key"] = ""
137
+ tailscale = update.get("tailscale")
138
+ if isinstance(tailscale, dict):
139
+ current_tailscale = speech.setdefault("tailscale", {})
140
+ if not isinstance(current_tailscale, dict):
141
+ current_tailscale = {}
142
+ speech["tailscale"] = current_tailscale
143
+ for key in ("enabled", "asr_hostname", "tts_hostname"):
144
+ if key in tailscale:
145
+ current_tailscale[key] = tailscale[key]
146
+ self.ports.save_config(config)
147
+ self.ports.write_json(handler, self.public_config())
148
+ except (UnicodeError, ValueError, TypeError) as exc:
149
+ self.ports.write_json(handler, {"ok": False, "error": str(exc)}, 400)
150
+ return True
151
+
152
+ @staticmethod
153
+ def _validated_value(service: str, key: str, value: Any) -> Any:
154
+ allowed = {
155
+ "asr": {"enabled", "base_url", "endpoint", "model", "language", "api_key", "timeout_seconds"},
156
+ "tts": {"enabled", "base_url", "endpoint", "voices_endpoint", "model", "voice", "language", "response_format", "speed", "auto_speak", "api_key", "timeout_seconds"},
157
+ }
158
+ if key not in allowed[service]:
159
+ raise ValueError(f"unsupported {service} setting: {key}")
160
+ if key in {"enabled", "auto_speak"}:
161
+ return bool(value)
162
+ if key == "timeout_seconds":
163
+ return max(1, min(3600, int(value)))
164
+ if key == "speed":
165
+ return max(0.25, min(4.0, float(value)))
166
+ text = str(value or "").strip()
167
+ if key == "base_url":
168
+ parsed = urllib.parse.urlparse(text)
169
+ if parsed.scheme not in {"http", "https"} or not parsed.netloc:
170
+ raise ValueError(f"invalid {service} base_url")
171
+ return text.rstrip("/")
172
+ if key in {"endpoint", "voices_endpoint"} and not text.startswith("/"):
173
+ raise ValueError(f"{service} {key} must begin with /")
174
+ return text
175
+
176
+ def _probe(self, name: str) -> dict[str, Any]:
177
+ config = self._service_config(name)
178
+ enabled = bool(config.get("enabled"))
179
+ result: dict[str, Any] = {
180
+ "enabled": enabled,
181
+ "configured": bool(str(config.get("base_url") or "").strip()),
182
+ "reachable": False,
183
+ }
184
+ if not enabled:
185
+ return result
186
+ try:
187
+ request = urllib.request.Request(self._url(config, "/health"), headers=self._headers(config, "application/json"))
188
+ with self.ports.urlopen(request, timeout=min(10.0, self._timeout(config))) as response:
189
+ result["status"] = int(getattr(response, "status", 200))
190
+ result["reachable"] = result["status"] < 500
191
+ except Exception as exc:
192
+ result["error"] = f"{type(exc).__name__}: {exc}"
193
+ return result
194
+
195
+ def _proxy_get(self, handler: BaseHTTPRequestHandler, service: str, endpoint_key: str) -> bool:
196
+ config = self._service_config(service)
197
+ if not self._require_enabled(handler, service, config):
198
+ return True
199
+ request = urllib.request.Request(self._url(config, str(config.get(endpoint_key) or "")), headers=self._headers(config, "application/json"))
200
+ return self._open_and_write(handler, service, config, request)
201
+
202
+ def _proxy_asr(self, handler: BaseHTTPRequestHandler, raw: bytes, content_type: str) -> bool:
203
+ config = self._service_config("asr")
204
+ if not self._require_enabled(handler, "asr", config):
205
+ return True
206
+ if "application/json" in content_type.lower():
207
+ try:
208
+ body = json.loads(raw.decode("utf-8"))
209
+ if not isinstance(body, dict):
210
+ raise ValueError("request must be a JSON object")
211
+ audio = base64.b64decode(str(body.get("audio_base64") or ""), validate=True)
212
+ if not audio:
213
+ raise ValueError("audio_base64 is required")
214
+ fields = {
215
+ "model": str(body.get("model") or config.get("model") or ""),
216
+ "language": str(body.get("language") or config.get("language") or ""),
217
+ "response_format": str(body.get("response_format") or "json"),
218
+ }
219
+ if fields["language"].lower() == "auto":
220
+ fields.pop("language")
221
+ raw, content_type = self._multipart(fields, "file", str(body.get("filename") or "recording.wav"), str(body.get("content_type") or "audio/wav"), audio)
222
+ except (ValueError, TypeError, UnicodeError) as exc:
223
+ self.ports.write_json(handler, {"error": {"type": "invalid_request_error", "message": str(exc)}}, 400)
224
+ return True
225
+ return self._proxy_raw(handler, "asr", "endpoint", raw, content_type)
226
+
227
+ def _proxy_tts(self, handler: BaseHTTPRequestHandler, raw: bytes, content_type: str, *, batch: bool) -> bool:
228
+ config = self._service_config("tts")
229
+ if not self._require_enabled(handler, "tts", config):
230
+ return True
231
+ if "application/json" in content_type.lower():
232
+ try:
233
+ body = json.loads(raw.decode("utf-8") if raw else "{}")
234
+ if not isinstance(body, dict):
235
+ raise ValueError("request must be a JSON object")
236
+ if not batch:
237
+ body.setdefault("model", str(config.get("model") or ""))
238
+ body.setdefault("voice", str(config.get("voice") or "default"))
239
+ body.setdefault("language", str(config.get("language") or "Auto"))
240
+ body.setdefault("response_format", str(config.get("response_format") or "wav"))
241
+ body.setdefault("speed", float(config.get("speed") or 1.0))
242
+ raw = json.dumps(body, ensure_ascii=False).encode("utf-8")
243
+ except (ValueError, TypeError, UnicodeError) as exc:
244
+ self.ports.write_json(handler, {"error": {"type": "invalid_request_error", "message": str(exc)}}, 400)
245
+ return True
246
+ endpoint = str(config.get("endpoint") or "/v1/audio/speech") + ("/batch" if batch else "")
247
+ return self._proxy_bytes(handler, "tts", config, endpoint, raw, content_type)
248
+
249
+ def _proxy_raw(self, handler: BaseHTTPRequestHandler, service: str, endpoint_key: str, raw: bytes, content_type: str) -> bool:
250
+ config = self._service_config(service)
251
+ if not self._require_enabled(handler, service, config):
252
+ return True
253
+ return self._proxy_bytes(handler, service, config, str(config.get(endpoint_key) or ""), raw, content_type)
254
+
255
+ def _proxy_bytes(self, handler: BaseHTTPRequestHandler, service: str, config: SpeechConfig, endpoint: str, raw: bytes, content_type: str) -> bool:
256
+ request = urllib.request.Request(
257
+ self._url(config, endpoint),
258
+ data=raw,
259
+ headers=self._headers(config, content_type or "application/octet-stream"),
260
+ method="POST",
261
+ )
262
+ return self._open_and_write(handler, service, config, request)
263
+
264
+ def _open_and_write(self, handler: BaseHTTPRequestHandler, service: str, config: SpeechConfig, request: urllib.request.Request) -> bool:
265
+ try:
266
+ with self.ports.urlopen(request, timeout=self._timeout(config)) as response:
267
+ self._write_bytes(handler, response.read(), int(getattr(response, "status", 200)), str(response.headers.get("content-type") or "application/octet-stream"))
268
+ except urllib.error.HTTPError as exc:
269
+ self._write_bytes(handler, exc.read(), int(exc.code), str(exc.headers.get("content-type") or "application/json"))
270
+ except Exception as exc:
271
+ self.ports.log("ERROR", f"speech_proxy_failed service={service} error={type(exc).__name__}: {exc}")
272
+ self.ports.write_json(handler, {"error": {"type": "upstream_error", "message": f"{service.upper()} upstream unavailable: {exc}"}}, 502)
273
+ return True
274
+
275
+ @staticmethod
276
+ def _require_enabled(handler: BaseHTTPRequestHandler, service: str, config: SpeechConfig) -> bool:
277
+ if bool(config.get("enabled")) and str(config.get("base_url") or "").strip():
278
+ return True
279
+ body = json.dumps({"error": {"type": "service_disabled", "message": f"{service.upper()} is not configured or enabled"}}).encode()
280
+ handler.send_response(503)
281
+ handler.send_header("content-type", "application/json")
282
+ handler.send_header("content-length", str(len(body)))
283
+ handler.end_headers()
284
+ handler.wfile.write(body)
285
+ return False
286
+
287
+ @staticmethod
288
+ def _write_bytes(handler: BaseHTTPRequestHandler, data: bytes, status: int, content_type: str) -> None:
289
+ handler.send_response(status)
290
+ handler.send_header("content-type", content_type)
291
+ handler.send_header("content-length", str(len(data)))
292
+ handler.send_header("cache-control", "no-store")
293
+ handler.end_headers()
294
+ handler.wfile.write(data)
295
+
296
+ @staticmethod
297
+ def _headers(config: SpeechConfig, content_type: str) -> dict[str, str]:
298
+ headers = {"accept": "*/*", "content-type": content_type}
299
+ api_key = str(config.get("api_key") or "").strip()
300
+ if api_key:
301
+ headers["authorization"] = f"Bearer {api_key}"
302
+ return headers
303
+
304
+ @staticmethod
305
+ def _url(config: SpeechConfig, endpoint: str) -> str:
306
+ return str(config.get("base_url") or "").rstrip("/") + "/" + endpoint.lstrip("/")
307
+
308
+ @staticmethod
309
+ def _timeout(config: SpeechConfig) -> float:
310
+ try:
311
+ return max(1.0, min(3600.0, float(config.get("timeout_seconds") or 300)))
312
+ except (TypeError, ValueError):
313
+ return 300.0
314
+
315
+ @staticmethod
316
+ def _multipart(fields: dict[str, str], file_field: str, filename: str, mime: str, data: bytes) -> tuple[bytes, str]:
317
+ boundary = "ciel-" + secrets.token_hex(16)
318
+ chunks: list[bytes] = []
319
+ for name, value in fields.items():
320
+ chunks.extend([
321
+ f"--{boundary}\r\n".encode(),
322
+ f'Content-Disposition: form-data; name="{name}"\r\n\r\n'.encode(),
323
+ str(value).encode("utf-8"), b"\r\n",
324
+ ])
325
+ safe_filename = filename.replace('"', "_").replace("\r", "_").replace("\n", "_")
326
+ chunks.extend([
327
+ f"--{boundary}\r\n".encode(),
328
+ f'Content-Disposition: form-data; name="{file_field}"; filename="{safe_filename}"\r\n'.encode(),
329
+ f"Content-Type: {mime}\r\n\r\n".encode(), data, b"\r\n",
330
+ f"--{boundary}--\r\n".encode(),
331
+ ])
332
+ return b"".join(chunks), f"multipart/form-data; boundary={boundary}"
333
+
334
+
335
+ __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,45 @@ 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">Remote bearer token<input id="ttsApiKey" type="password" autocomplete="new-password" placeholder="Leave blank to keep current token"></label>
228
+ </div>
229
+ </section>
230
+ <section class="settings-section">
231
+ <h3>Tailscale tunnel</h3>
232
+ <div class="settings-grid">
233
+ <label class="check"><input id="tailscaleEnabled" type="checkbox"> Use tailnet-only addresses</label>
234
+ <label>ASR hostname<input id="tailscaleAsrHostname" placeholder="ciel-asr"></label>
235
+ <label>TTS hostname<input id="tailscaleTtsHostname" placeholder="ciel-tts"></label>
236
+ </div>
237
+ <div class="hint">The browser calls Ciel locally. Only the Ciel router connects to these Tailscale services.</div>
238
+ </section>
239
+ <div class="settings-actions"><button class="ghost" id="speechHealthButton" type="button">Test connections</button><button class="primary" type="submit">Save</button></div>
240
+ </div>
241
+ </form>
242
+ </dialog>
184
243
  <script>
185
244
  const MODEL = {json.dumps(model)};
186
245
  const transcript = document.getElementById('transcript');
@@ -188,10 +247,16 @@ def render_web_chat_page(
188
247
  const prompt = document.getElementById('prompt');
189
248
  const sendButton = document.getElementById('sendButton');
190
249
  const attachButton = document.getElementById('attachButton');
250
+ const micButton = document.getElementById('micButton');
191
251
  const fileInput = document.getElementById('fileInput');
192
252
  const attachmentTray = document.getElementById('attachmentTray');
193
253
  const shareButton = document.getElementById('shareButton');
194
254
  const clearButton = document.getElementById('clearButton');
255
+ const speechSettingsButton = document.getElementById('speechSettingsButton');
256
+ const speechSettingsDialog = document.getElementById('speechSettingsDialog');
257
+ const speechSettingsForm = document.getElementById('speechSettingsForm');
258
+ const speechSettingsClose = document.getElementById('speechSettingsClose');
259
+ const speechHealthButton = document.getElementById('speechHealthButton');
195
260
  const statePill = document.getElementById('statePill');
196
261
  const SESSION_KEY = 'ciel-runtime-web-chat-session';
197
262
  const LAST_ID_KEY = 'ciel-runtime-web-chat-last-id';
@@ -218,6 +283,10 @@ def render_web_chat_page(
218
283
  let lastId = Number(localStorage.getItem(scopedLastIdKey) || '0') || 0;
219
284
  let eventSource = null;
220
285
  let selectedFiles = [];
286
+ let speechConfig = {{asr: {{enabled: false}}, tts: {{enabled: false, auto_speak: false}}}};
287
+ let mediaRecorder = null;
288
+ let mediaStream = null;
289
+ let recordingChunks = [];
221
290
  function setState(text, cls = '') {{
222
291
  statePill.textContent = text;
223
292
  statePill.className = 'pill ' + cls;
@@ -401,6 +470,16 @@ def render_web_chat_page(
401
470
  bubble.innerHTML = renderMarkdown(text);
402
471
  }}
403
472
  row.appendChild(bubble);
473
+ if (role === 'assistant') {{
474
+ const actions = document.createElement('div');
475
+ actions.className = 'message-actions';
476
+ const speak = document.createElement('button');
477
+ speak.type = 'button';
478
+ speak.textContent = 'Speak';
479
+ speak.addEventListener('click', () => speakText(text));
480
+ actions.appendChild(speak);
481
+ row.appendChild(actions);
482
+ }}
404
483
  if (mode === 'prepend') {{
405
484
  transcript.insertBefore(row, transcript.firstChild);
406
485
  }} else {{
@@ -424,7 +503,10 @@ def render_web_chat_page(
424
503
  const text = message.message || '';
425
504
  if (!text.trim()) return;
426
505
  addBubble(roleForMessage(message), text, mode, message.id);
427
- if (mode !== 'prepend' && message.sender_id !== 'web-user') setState('reply received', 'ok');
506
+ if (mode !== 'prepend' && message.sender_id !== 'web-user') {{
507
+ setState('reply received', 'ok');
508
+ if (speechConfig.tts && speechConfig.tts.enabled && speechConfig.tts.auto_speak) speakText(text);
509
+ }}
428
510
  }}
429
511
  function formatBytes(bytes) {{
430
512
  const value = Number(bytes || 0);
@@ -471,6 +553,134 @@ def render_web_chat_page(
471
553
  reader.readAsDataURL(file);
472
554
  }});
473
555
  }}
556
+ function setSpeechForm(config) {{
557
+ const asr = config.asr || {{}};
558
+ const tts = config.tts || {{}};
559
+ const tailscale = config.tailscale || {{}};
560
+ document.getElementById('asrEnabled').checked = Boolean(asr.enabled);
561
+ document.getElementById('asrBaseUrl').value = asr.base_url || '';
562
+ document.getElementById('asrModel').value = asr.model || '';
563
+ document.getElementById('asrLanguage').value = asr.language || 'auto';
564
+ document.getElementById('asrApiKey').value = '';
565
+ document.getElementById('asrApiKey').placeholder = asr.api_key_set ? 'Token is set; leave blank to keep it' : 'Optional remote bearer token';
566
+ document.getElementById('ttsEnabled').checked = Boolean(tts.enabled);
567
+ document.getElementById('ttsAutoSpeak').checked = Boolean(tts.auto_speak);
568
+ document.getElementById('ttsBaseUrl').value = tts.base_url || '';
569
+ document.getElementById('ttsModel').value = tts.model || '';
570
+ document.getElementById('ttsVoice').value = tts.voice || 'default';
571
+ document.getElementById('ttsLanguage').value = tts.language || 'ko';
572
+ document.getElementById('ttsApiKey').value = '';
573
+ document.getElementById('ttsApiKey').placeholder = tts.api_key_set ? 'Token is set; leave blank to keep it' : 'Optional remote bearer token';
574
+ document.getElementById('tailscaleEnabled').checked = tailscale.enabled !== false;
575
+ document.getElementById('tailscaleAsrHostname').value = tailscale.asr_hostname || 'ciel-asr';
576
+ document.getElementById('tailscaleTtsHostname').value = tailscale.tts_hostname || 'ciel-tts';
577
+ micButton.disabled = !asr.enabled;
578
+ micButton.title = asr.enabled ? 'Record speech and transcribe it' : 'Enable STT in Speech Settings first';
579
+ }}
580
+ async function loadSpeechConfig() {{
581
+ const response = await fetch('/ca/speech/config', {{headers: {{'accept': 'application/json'}}}});
582
+ const data = await response.json();
583
+ if (!response.ok || !data.ok) throw new Error(data.error || `HTTP ${{response.status}}`);
584
+ speechConfig = data;
585
+ setSpeechForm(data);
586
+ return data;
587
+ }}
588
+ async function saveSpeechConfig() {{
589
+ const payload = {{
590
+ asr: {{
591
+ enabled: document.getElementById('asrEnabled').checked,
592
+ base_url: document.getElementById('asrBaseUrl').value,
593
+ model: document.getElementById('asrModel').value,
594
+ language: document.getElementById('asrLanguage').value,
595
+ api_key: document.getElementById('asrApiKey').value,
596
+ }},
597
+ tts: {{
598
+ enabled: document.getElementById('ttsEnabled').checked,
599
+ auto_speak: document.getElementById('ttsAutoSpeak').checked,
600
+ base_url: document.getElementById('ttsBaseUrl').value,
601
+ model: document.getElementById('ttsModel').value,
602
+ voice: document.getElementById('ttsVoice').value,
603
+ language: document.getElementById('ttsLanguage').value,
604
+ api_key: document.getElementById('ttsApiKey').value,
605
+ }},
606
+ tailscale: {{
607
+ enabled: document.getElementById('tailscaleEnabled').checked,
608
+ asr_hostname: document.getElementById('tailscaleAsrHostname').value,
609
+ tts_hostname: document.getElementById('tailscaleTtsHostname').value,
610
+ }},
611
+ }};
612
+ const response = await fetch('/ca/speech/config', {{method: 'POST', headers: {{'content-type': 'application/json', 'accept': 'application/json'}}, body: JSON.stringify(payload)}});
613
+ const data = await response.json();
614
+ if (!response.ok || !data.ok) throw new Error(data.error || `HTTP ${{response.status}}`);
615
+ speechConfig = data;
616
+ setSpeechForm(data);
617
+ return data;
618
+ }}
619
+ async function speakText(text) {{
620
+ if (!speechConfig.tts || !speechConfig.tts.enabled) {{
621
+ setState('TTS disabled', 'error');
622
+ return;
623
+ }}
624
+ try {{
625
+ setState('generating speech');
626
+ const response = await fetch('/v1/audio/speech', {{
627
+ method: 'POST',
628
+ headers: {{'content-type': 'application/json'}},
629
+ 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'}})
630
+ }});
631
+ if (!response.ok) throw new Error(await response.text() || `HTTP ${{response.status}}`);
632
+ const blob = await response.blob();
633
+ const url = URL.createObjectURL(blob);
634
+ const audio = new Audio(url);
635
+ audio.addEventListener('ended', () => URL.revokeObjectURL(url), {{once: true}});
636
+ audio.addEventListener('error', () => URL.revokeObjectURL(url), {{once: true}});
637
+ await audio.play();
638
+ setState('speaking', 'ok');
639
+ }} catch (err) {{
640
+ setState('TTS error', 'error');
641
+ addBubble('system', 'TTS failed: ' + String(err && err.message ? err.message : err));
642
+ }}
643
+ }}
644
+ async function transcribeRecording(blob) {{
645
+ setState('transcribing');
646
+ const audio_base64 = await fileToBase64(blob);
647
+ const response = await fetch('/v1/audio/transcriptions', {{
648
+ method: 'POST',
649
+ headers: {{'content-type': 'application/json', 'accept': 'application/json'}},
650
+ body: JSON.stringify({{audio_base64, filename: 'web-chat-recording.webm', content_type: blob.type || 'audio/webm', model: speechConfig.asr.model, language: speechConfig.asr.language}})
651
+ }});
652
+ const text = await response.text();
653
+ let data = {{}};
654
+ try {{ data = text ? JSON.parse(text) : {{}}; }} catch {{}}
655
+ if (!response.ok) throw new Error((data.error && (data.error.message || data.error)) || text || `HTTP ${{response.status}}`);
656
+ const transcriptText = String(data.text || data.transcript || '').trim();
657
+ if (!transcriptText) throw new Error('ASR returned no transcript');
658
+ prompt.value = prompt.value ? prompt.value + ' ' + transcriptText : transcriptText;
659
+ prompt.focus();
660
+ setState('transcribed', 'ok');
661
+ }}
662
+ async function startVoiceInput() {{
663
+ if (!navigator.mediaDevices || !window.MediaRecorder) throw new Error('This browser does not support microphone recording');
664
+ mediaStream = await navigator.mediaDevices.getUserMedia({{audio: true}});
665
+ recordingChunks = [];
666
+ mediaRecorder = new MediaRecorder(mediaStream);
667
+ mediaRecorder.addEventListener('dataavailable', event => {{ if (event.data && event.data.size) recordingChunks.push(event.data); }});
668
+ mediaRecorder.addEventListener('stop', async () => {{
669
+ const blob = new Blob(recordingChunks, {{type: mediaRecorder.mimeType || 'audio/webm'}});
670
+ if (mediaStream) mediaStream.getTracks().forEach(track => track.stop());
671
+ mediaStream = null;
672
+ micButton.textContent = 'Start voice input';
673
+ micButton.classList.remove('recording');
674
+ try {{ await transcribeRecording(blob); }} catch (err) {{ setState('STT error', 'error'); addBubble('system', 'STT failed: ' + String(err && err.message ? err.message : err)); }}
675
+ }}, {{once: true}});
676
+ mediaRecorder.start();
677
+ micButton.textContent = 'Stop and transcribe';
678
+ micButton.classList.add('recording');
679
+ setState('recording', 'error');
680
+ }}
681
+ function stopVoiceInput() {{
682
+ if (mediaRecorder && mediaRecorder.state !== 'inactive') mediaRecorder.stop();
683
+ }}
474
684
  async function uploadAttachment(file) {{
475
685
  const content = await fileToBase64(file);
476
686
  const response = await fetch('/ca/channel/files', {{
@@ -663,6 +873,42 @@ def render_web_chat_page(
663
873
  }}
664
874
  }});
665
875
  attachButton.addEventListener('click', () => fileInput.click());
876
+ micButton.addEventListener('click', async () => {{
877
+ if (mediaRecorder && mediaRecorder.state !== 'inactive') {{
878
+ stopVoiceInput();
879
+ return;
880
+ }}
881
+ try {{ await startVoiceInput(); }} catch (err) {{
882
+ setState('microphone error', 'error');
883
+ addBubble('system', 'Microphone failed: ' + String(err && err.message ? err.message : err));
884
+ }}
885
+ }});
886
+ speechSettingsButton.addEventListener('click', async () => {{
887
+ try {{ await loadSpeechConfig(); }} catch (err) {{ addBubble('system', 'Could not load speech settings: ' + String(err && err.message ? err.message : err)); }}
888
+ speechSettingsDialog.showModal();
889
+ }});
890
+ speechSettingsClose.addEventListener('click', () => speechSettingsDialog.close());
891
+ speechSettingsForm.addEventListener('submit', async event => {{
892
+ event.preventDefault();
893
+ try {{
894
+ await saveSpeechConfig();
895
+ speechSettingsDialog.close();
896
+ setState('speech settings saved', 'ok');
897
+ }} catch (err) {{
898
+ setState('settings error', 'error');
899
+ addBubble('system', 'Speech settings failed: ' + String(err && err.message ? err.message : err));
900
+ }}
901
+ }});
902
+ speechHealthButton.addEventListener('click', async () => {{
903
+ try {{
904
+ await saveSpeechConfig();
905
+ const response = await fetch('/ca/speech/health', {{headers: {{'accept': 'application/json'}}}});
906
+ const data = await response.json();
907
+ const asr = data.services && data.services.asr;
908
+ const tts = data.services && data.services.tts;
909
+ addBubble('system', `Speech health — ASR: ${{asr && asr.reachable ? 'reachable' : asr && asr.enabled ? 'unreachable' : 'disabled'}}, TTS: ${{tts && tts.reachable ? 'reachable' : tts && tts.enabled ? 'unreachable' : 'disabled'}}.`);
910
+ }} catch (err) {{ addBubble('system', 'Speech health check failed: ' + String(err && err.message ? err.message : err)); }}
911
+ }});
666
912
  fileInput.addEventListener('change', () => {{
667
913
  addSelectedFiles(fileInput.files);
668
914
  fileInput.value = '';
@@ -706,6 +952,7 @@ def render_web_chat_page(
706
952
  if (transcript.scrollTop < 48) loadOlderHistory();
707
953
  }});
708
954
  addBubble('system', `Connected to active session bridge for ${{MODEL}}. Messages are queued on channel ${{channel}} and replies stream back from /ca/channel/stream.`);
955
+ loadSpeechConfig().catch(() => {{ micButton.disabled = true; }});
709
956
  loadInitialHistory().finally(startChannelStream);
710
957
  prompt.focus();
711
958
  </script>
@@ -0,0 +1,32 @@
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 or ephemeral Tailscale auth key. In each Colab account/notebook Secret store, add `TAILSCALE_AUTHKEY` and grant notebook access.
9
+ 3. Optionally add the same `CIEL_SPEECH_API_KEY` secret to both workers. 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
+ 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.
22
+
23
+ ## API surface
24
+
25
+ - `GET|POST /ca/speech/config`
26
+ - `GET /ca/speech/health`
27
+ - `POST /v1/audio/transcriptions`
28
+ - `POST /v1/audio/translations`
29
+ - `POST /v1/audio/speech`
30
+ - `POST /v1/audio/speech/batch`
31
+ - `GET|POST /v1/audio/voices`
32
+ - `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.4",
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,106 @@
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 shutil
13
+ import subprocess
14
+ import sys
15
+ import time
16
+ import urllib.request
17
+
18
+
19
+ HOSTNAME = os.environ.get("CIEL_TTS_HOSTNAME", "ciel-tts")
20
+ PORT = 8091
21
+ SOCKET = "/tmp/ciel-tts-tailscaled.sock"
22
+ STATE = "/tmp/ciel-tts-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
+ print("+", " ".join(args))
42
+ return subprocess.run(args, check=check, text=True, capture_output=False)
43
+
44
+
45
+ def install_tailscale() -> None:
46
+ if shutil.which("tailscale"):
47
+ return
48
+ run("bash", "-lc", "curl -fsSL https://tailscale.com/install.sh | sh")
49
+
50
+
51
+ def start_tailscale(auth_key: str) -> tuple[str, str]:
52
+ install_tailscale()
53
+ LOG_DIR.mkdir(parents=True, exist_ok=True)
54
+ tail_log = (LOG_DIR / "tailscale-tts.log").open("ab")
55
+ subprocess.Popen(
56
+ ["tailscaled", f"--socket={SOCKET}", f"--state={STATE}", "--tun=userspace-networking"],
57
+ stdout=tail_log,
58
+ stderr=subprocess.STDOUT,
59
+ start_new_session=True,
60
+ )
61
+ for _ in range(60):
62
+ if Path(SOCKET).exists():
63
+ break
64
+ time.sleep(1)
65
+ run("tailscale", f"--socket={SOCKET}", "up", f"--auth-key={auth_key}", f"--hostname={HOSTNAME}", "--accept-dns=true", "--reset")
66
+ status = subprocess.check_output(["tailscale", f"--socket={SOCKET}", "status", "--json"], text=True)
67
+ dns_name = str(json.loads(status).get("Self", {}).get("DNSName") or HOSTNAME).rstrip(".")
68
+ serve = run("tailscale", f"--socket={SOCKET}", "serve", "--bg", "--https=443", f"http://127.0.0.1:{PORT}", check=False)
69
+ if serve.returncode == 0:
70
+ return dns_name, f"https://{dns_name}"
71
+ run("tailscale", f"--socket={SOCKET}", "serve", "--bg", "--http=80", f"http://127.0.0.1:{PORT}")
72
+ return dns_name, f"http://{dns_name}"
73
+
74
+
75
+ def wait_for_server(api_key: str) -> None:
76
+ headers = {"authorization": f"Bearer {api_key}"} if api_key else {}
77
+ for _ in range(240):
78
+ try:
79
+ with urllib.request.urlopen(urllib.request.Request(f"http://127.0.0.1:{PORT}/health", headers=headers), timeout=3) as response:
80
+ if response.status < 500:
81
+ return
82
+ except Exception:
83
+ time.sleep(2)
84
+ raise RuntimeError("MOSS TTS did not become healthy; inspect /content/ciel-speech-logs/moss-tts.log")
85
+
86
+
87
+ def main() -> None:
88
+ auth_key = secret("TAILSCALE_AUTHKEY", required=True)
89
+ api_key = secret("CIEL_SPEECH_API_KEY")
90
+ run(sys.executable, "-m", "pip", "install", "-U", "vllm-omni==0.24.0")
91
+ LOG_DIR.mkdir(parents=True, exist_ok=True)
92
+ command = [
93
+ "vllm", "serve", "OpenMOSS-Team/MOSS-TTS-Nano", "--omni", "--host", "127.0.0.1", "--port", str(PORT),
94
+ "--gpu-memory-utilization", "0.72", "--trust-remote-code", "--enforce-eager",
95
+ ]
96
+ if api_key:
97
+ command.extend(["--api-key", api_key])
98
+ server_log = (LOG_DIR / "moss-tts.log").open("ab")
99
+ subprocess.Popen(command, stdout=server_log, stderr=subprocess.STDOUT, start_new_session=True)
100
+ wait_for_server(api_key)
101
+ dns_name, base_url = start_tailscale(auth_key)
102
+ 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))
103
+
104
+
105
+ if __name__ == "__main__":
106
+ main()
@@ -0,0 +1,106 @@
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
+ print("+", " ".join(args))
42
+ return subprocess.run(args, check=check, text=True, capture_output=False)
43
+
44
+
45
+ def install_tailscale() -> None:
46
+ if shutil.which("tailscale"):
47
+ return
48
+ run("bash", "-lc", "curl -fsSL https://tailscale.com/install.sh | sh")
49
+
50
+
51
+ def start_tailscale(auth_key: str) -> tuple[str, str]:
52
+ install_tailscale()
53
+ LOG_DIR.mkdir(parents=True, exist_ok=True)
54
+ tail_log = (LOG_DIR / "tailscale-asr.log").open("ab")
55
+ subprocess.Popen(
56
+ ["tailscaled", f"--socket={SOCKET}", f"--state={STATE}", "--tun=userspace-networking"],
57
+ stdout=tail_log,
58
+ stderr=subprocess.STDOUT,
59
+ start_new_session=True,
60
+ )
61
+ for _ in range(60):
62
+ if Path(SOCKET).exists():
63
+ break
64
+ time.sleep(1)
65
+ run("tailscale", f"--socket={SOCKET}", "up", f"--auth-key={auth_key}", f"--hostname={HOSTNAME}", "--accept-dns=true", "--reset")
66
+ status = subprocess.check_output(["tailscale", f"--socket={SOCKET}", "status", "--json"], text=True)
67
+ dns_name = str(json.loads(status).get("Self", {}).get("DNSName") or HOSTNAME).rstrip(".")
68
+ serve = run("tailscale", f"--socket={SOCKET}", "serve", "--bg", "--https=443", f"http://127.0.0.1:{PORT}", check=False)
69
+ if serve.returncode == 0:
70
+ return dns_name, f"https://{dns_name}"
71
+ run("tailscale", f"--socket={SOCKET}", "serve", "--bg", "--http=80", f"http://127.0.0.1:{PORT}")
72
+ return dns_name, f"http://{dns_name}"
73
+
74
+
75
+ def wait_for_server(api_key: str) -> None:
76
+ headers = {"authorization": f"Bearer {api_key}"} if api_key else {}
77
+ for _ in range(180):
78
+ try:
79
+ with urllib.request.urlopen(urllib.request.Request(f"http://127.0.0.1:{PORT}/health", headers=headers), timeout=3) as response:
80
+ if response.status < 500:
81
+ return
82
+ except Exception:
83
+ time.sleep(2)
84
+ raise RuntimeError("Qwen ASR did not become healthy; inspect /content/ciel-speech-logs/qwen-asr.log")
85
+
86
+
87
+ def main() -> None:
88
+ auth_key = secret("TAILSCALE_AUTHKEY", required=True)
89
+ api_key = secret("CIEL_SPEECH_API_KEY")
90
+ run(sys.executable, "-m", "pip", "install", "-U", "qwen-asr[vllm]", "vllm[audio]")
91
+ LOG_DIR.mkdir(parents=True, exist_ok=True)
92
+ command = [
93
+ "qwen-asr-serve", "Qwen/Qwen3-ASR-0.6B", "--host", "127.0.0.1", "--port", str(PORT),
94
+ "--gpu-memory-utilization", "0.78", "--max-model-len", "8192",
95
+ ]
96
+ if api_key:
97
+ command.extend(["--api-key", api_key])
98
+ server_log = (LOG_DIR / "qwen-asr.log").open("ab")
99
+ subprocess.Popen(command, stdout=server_log, stderr=subprocess.STDOUT, start_new_session=True)
100
+ wait_for_server(api_key)
101
+ dns_name, base_url = start_tailscale(auth_key)
102
+ 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))
103
+
104
+
105
+ if __name__ == "__main__":
106
+ main()
@@ -0,0 +1,37 @@
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
+ def configure(asr_base_url: str, tts_base_url: str) -> dict[str, Any]:
14
+ import ciel_runtime
15
+
16
+ config = ciel_runtime.load_config()
17
+ speech = config.setdefault("speech", {})
18
+ asr = speech.setdefault("asr", {})
19
+ tts = speech.setdefault("tts", {})
20
+ asr.update({"enabled": True, "base_url": asr_base_url.rstrip("/"), "model": "Qwen/Qwen3-ASR-0.6B"})
21
+ tts.update({"enabled": True, "base_url": tts_base_url.rstrip("/"), "model": "OpenMOSS-Team/MOSS-TTS-Nano"})
22
+ ciel_runtime.save_config(config)
23
+ return {"asr": asr["base_url"], "tts": tts["base_url"]}
24
+
25
+
26
+ def main() -> int:
27
+ parser = argparse.ArgumentParser()
28
+ parser.add_argument("--asr-base-url", required=True)
29
+ parser.add_argument("--tts-base-url", required=True)
30
+ args = parser.parse_args()
31
+ result = configure(args.asr_base_url, args.tts_base_url)
32
+ print(f"Configured Ciel speech workers: ASR={result['asr']} TTS={result['tts']}")
33
+ return 0
34
+
35
+
36
+ if __name__ == "__main__":
37
+ raise SystemExit(main())
@@ -0,0 +1,47 @@
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
+
12
+ Write-Host "Checking Colab CLI authentication..."
13
+ & wsl -d $Distribution -- bash -lc "colab status >/dev/null"
14
+ if ($LASTEXITCODE -ne 0) {
15
+ throw "Colab CLI is not authenticated. Configure ADC in WSL (gcloud auth application-default login), then rerun."
16
+ }
17
+
18
+ Write-Host "Creating ASR T4 session: $AsrSession"
19
+ & wsl -d $Distribution -- bash -lc "colab new --gpu T4 --session '$AsrSession'"
20
+ if ($LASTEXITCODE -ne 0) { throw "Could not create ASR Colab session." }
21
+
22
+ Write-Host "Creating TTS T4 session: $TtsSession"
23
+ & wsl -d $Distribution -- bash -lc "colab new --gpu T4 --session '$TtsSession'"
24
+ if ($LASTEXITCODE -ne 0) { throw "Could not create TTS Colab session." }
25
+
26
+ Write-Host "Installing Qwen3-ASR and its Tailscale service..."
27
+ $asrOutput = (& wsl -d $Distribution -- bash -lc "colab exec --session '$AsrSession' --file '$wslRepo/scripts/colab/bootstrap_qwen_asr.py'" 2>&1 | Tee-Object -Variable asrDisplay) -join "`n"
28
+ if ($LASTEXITCODE -ne 0) { throw "ASR bootstrap failed." }
29
+
30
+ Write-Host "Installing MOSS-TTS-Nano and its Tailscale service..."
31
+ $ttsOutput = (& wsl -d $Distribution -- bash -lc "colab exec --session '$TtsSession' --file '$wslRepo/scripts/colab/bootstrap_moss_tts.py'" 2>&1 | Tee-Object -Variable ttsDisplay) -join "`n"
32
+ if ($LASTEXITCODE -ne 0) { throw "TTS bootstrap failed." }
33
+
34
+ function Read-BootstrapResult([string]$Text, [string]$Role) {
35
+ $matches = [regex]::Matches($Text, '(?s)\{\s*"ok"\s*:\s*true.*?\}')
36
+ if ($matches.Count -eq 0) { throw "Could not find the $Role bootstrap result in Colab output." }
37
+ $result = $matches[$matches.Count - 1].Value | ConvertFrom-Json
38
+ if ($result.role -ne $Role -or -not $result.base_url) { throw "Invalid $Role bootstrap result." }
39
+ return $result
40
+ }
41
+
42
+ $asr = Read-BootstrapResult $asrOutput "asr"
43
+ $tts = Read-BootstrapResult $ttsOutput "tts"
44
+ & python (Join-Path $PSScriptRoot "configure_speech_workers.py") --asr-base-url $asr.base_url --tts-base-url $tts.base_url
45
+ if ($LASTEXITCODE -ne 0) { throw "Workers started, but Ciel speech configuration failed." }
46
+
47
+ Write-Host "Both services are running and connected to Web Chat > Speech Settings."