@oneciel-ai/ciel-runtime 0.2.4 → 0.2.6

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.
@@ -27,6 +27,15 @@ def build_default_config(provider_defaults: dict[str, Any]) -> dict[str, Any]:
27
27
  "tailscale_https": False,
28
28
  },
29
29
  "speech": {
30
+ "colab": {
31
+ "enabled": True,
32
+ "distribution": "Ubuntu-26.04",
33
+ "auth": "adc",
34
+ "asr_session": "ciel-asr",
35
+ "tts_session": "ciel-tts",
36
+ "asr_accelerator": "T4",
37
+ "tts_accelerator": "T4",
38
+ },
30
39
  "asr": {
31
40
  "enabled": False,
32
41
  "base_url": "http://ciel-asr:8000",
@@ -44,6 +53,8 @@ def build_default_config(provider_defaults: dict[str, Any]) -> dict[str, Any]:
44
53
  "model": "OpenMOSS-Team/MOSS-TTS-Nano",
45
54
  "voice": "default",
46
55
  "language": "ko",
56
+ "ref_audio": "",
57
+ "ref_text": "",
47
58
  "response_format": "wav",
48
59
  "speed": 1.0,
49
60
  "auto_speak": False,
@@ -93,7 +93,7 @@ OFFICIAL_CHANNEL_PLUGINS = {
93
93
  }
94
94
 
95
95
  APP_NAME = "Ciel Runtime"
96
- VERSION = "0.2.4"
96
+ VERSION = "0.2.6"
97
97
  CREDITS = "Credits: One Ciel LLC"
98
98
  PRELAUNCH_CANCEL = 10
99
99
  PRELAUNCH_LAUNCH_CODEX = 11
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import base64
6
6
  import json
7
+ import re
7
8
  import secrets
8
9
  import urllib.error
9
10
  import urllib.parse
@@ -65,11 +66,15 @@ class SpeechHttpController:
65
66
  public: dict[str, Any] = {"ok": True}
66
67
  for name in ("asr", "tts"):
67
68
  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 = {key: value for key, value in source.items() if key not in {"api_key", "ref_audio"}}
69
70
  item["api_key_set"] = bool(str(source.get("api_key") or "").strip())
71
+ if name == "tts":
72
+ item["ref_audio_set"] = bool(str(source.get("ref_audio") or "").strip())
70
73
  public[name] = item
71
74
  tailscale = speech.get("tailscale")
72
75
  public["tailscale"] = dict(tailscale) if isinstance(tailscale, dict) else {}
76
+ colab = speech.get("colab")
77
+ public["colab"] = dict(colab) if isinstance(colab, dict) else {}
73
78
  public["endpoints"] = self.discovery_payload()["endpoints"]
74
79
  return public
75
80
 
@@ -127,13 +132,15 @@ class SpeechHttpController:
127
132
  current = {}
128
133
  speech[name] = current
129
134
  for key, value in incoming.items():
130
- if key in {"api_key_set", "clear_api_key"}:
135
+ if key in {"api_key_set", "clear_api_key", "ref_audio_set", "clear_ref_audio"}:
131
136
  continue
132
- if key == "api_key" and not str(value or "").strip():
137
+ if key in {"api_key", "ref_audio"} and not str(value or "").strip():
133
138
  continue
134
139
  current[key] = self._validated_value(name, key, value)
135
140
  if incoming.get("clear_api_key") is True:
136
141
  current["api_key"] = ""
142
+ if name == "tts" and incoming.get("clear_ref_audio") is True:
143
+ current["ref_audio"] = ""
137
144
  tailscale = update.get("tailscale")
138
145
  if isinstance(tailscale, dict):
139
146
  current_tailscale = speech.setdefault("tailscale", {})
@@ -143,6 +150,14 @@ class SpeechHttpController:
143
150
  for key in ("enabled", "asr_hostname", "tts_hostname"):
144
151
  if key in tailscale:
145
152
  current_tailscale[key] = tailscale[key]
153
+ colab = update.get("colab")
154
+ if isinstance(colab, dict):
155
+ current_colab = speech.setdefault("colab", {})
156
+ if not isinstance(current_colab, dict):
157
+ current_colab = {}
158
+ speech["colab"] = current_colab
159
+ for key, value in colab.items():
160
+ current_colab[key] = self._validated_colab_value(key, value)
146
161
  self.ports.save_config(config)
147
162
  self.ports.write_json(handler, self.public_config())
148
163
  except (UnicodeError, ValueError, TypeError) as exc:
@@ -153,7 +168,7 @@ class SpeechHttpController:
153
168
  def _validated_value(service: str, key: str, value: Any) -> Any:
154
169
  allowed = {
155
170
  "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"},
171
+ "tts": {"enabled", "base_url", "endpoint", "voices_endpoint", "model", "voice", "language", "ref_audio", "ref_text", "response_format", "speed", "auto_speak", "api_key", "timeout_seconds"},
157
172
  }
158
173
  if key not in allowed[service]:
159
174
  raise ValueError(f"unsupported {service} setting: {key}")
@@ -164,6 +179,21 @@ class SpeechHttpController:
164
179
  if key == "speed":
165
180
  return max(0.25, min(4.0, float(value)))
166
181
  text = str(value or "").strip()
182
+ if key == "ref_audio":
183
+ if len(text) > 14_000_000:
184
+ raise ValueError("TTS reference audio must be 10 MB or smaller")
185
+ if text.startswith("data:audio/") and ";base64," in text:
186
+ try:
187
+ audio = base64.b64decode(text.split(",", 1)[1], validate=True)
188
+ except (ValueError, TypeError) as exc:
189
+ raise ValueError("invalid base64 TTS reference audio") from exc
190
+ if not audio or len(audio) > 10 * 1024 * 1024:
191
+ raise ValueError("TTS reference audio must be between 1 byte and 10 MB")
192
+ return text
193
+ parsed_ref = urllib.parse.urlparse(text)
194
+ if parsed_ref.scheme not in {"http", "https"} or not parsed_ref.netloc:
195
+ raise ValueError("TTS ref_audio must be an audio data URL or HTTP(S) URL")
196
+ return text
167
197
  if key == "base_url":
168
198
  parsed = urllib.parse.urlparse(text)
169
199
  if parsed.scheme not in {"http", "https"} or not parsed.netloc:
@@ -173,6 +203,36 @@ class SpeechHttpController:
173
203
  raise ValueError(f"{service} {key} must begin with /")
174
204
  return text
175
205
 
206
+ @staticmethod
207
+ def _validated_colab_value(key: str, value: Any) -> Any:
208
+ allowed = {
209
+ "enabled",
210
+ "distribution",
211
+ "auth",
212
+ "asr_session",
213
+ "tts_session",
214
+ "asr_accelerator",
215
+ "tts_accelerator",
216
+ }
217
+ if key not in allowed:
218
+ raise ValueError(f"unsupported colab setting: {key}")
219
+ if key == "enabled":
220
+ return bool(value)
221
+ text = str(value or "").strip()
222
+ if key == "auth":
223
+ auth = text.lower()
224
+ if auth not in {"adc", "oauth2"}:
225
+ raise ValueError("Colab auth must be adc or oauth2")
226
+ return auth
227
+ if key.endswith("_accelerator"):
228
+ accelerator = text.upper()
229
+ if accelerator not in {"T4", "L4", "G4", "A100", "H100"}:
230
+ raise ValueError("unsupported Colab accelerator")
231
+ return accelerator
232
+ if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}", text):
233
+ raise ValueError(f"invalid Colab {key}")
234
+ return text
235
+
176
236
  def _probe(self, name: str) -> dict[str, Any]:
177
237
  config = self._service_config(name)
178
238
  enabled = bool(config.get("enabled"))
@@ -237,6 +297,10 @@ class SpeechHttpController:
237
297
  body.setdefault("model", str(config.get("model") or ""))
238
298
  body.setdefault("voice", str(config.get("voice") or "default"))
239
299
  body.setdefault("language", str(config.get("language") or "Auto"))
300
+ if str(config.get("ref_audio") or "").strip():
301
+ body.setdefault("ref_audio", str(config["ref_audio"]))
302
+ if str(config.get("ref_text") or "").strip():
303
+ body.setdefault("ref_text", str(config["ref_text"]))
240
304
  body.setdefault("response_format", str(config.get("response_format") or "wav"))
241
305
  body.setdefault("speed", float(config.get("speed") or 1.0))
242
306
  raw = json.dumps(body, ensure_ascii=False).encode("utf-8")
@@ -224,9 +224,25 @@ def render_web_chat_page(
224
224
  <label>Voice<input id="ttsVoice" placeholder="default"></label>
225
225
  <label>Language<input id="ttsLanguage" placeholder="ko"></label>
226
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>
227
230
  <label class="wide">Remote bearer token<input id="ttsApiKey" type="password" autocomplete="new-password" placeholder="Leave blank to keep current token"></label>
228
231
  </div>
229
232
  </section>
233
+ <section class="settings-section">
234
+ <h3>Colab CLI connection</h3>
235
+ <div class="settings-grid">
236
+ <label class="check"><input id="colabEnabled" type="checkbox"> Manage workers with Colab CLI</label>
237
+ <label>WSL distribution<input id="colabDistribution" placeholder="Ubuntu-26.04"></label>
238
+ <label>Authentication<select id="colabAuth"><option value="adc">ADC</option><option value="oauth2">OAuth2</option></select></label>
239
+ <label>ASR session<input id="colabAsrSession" placeholder="ciel-asr"></label>
240
+ <label>TTS session<input id="colabTtsSession" placeholder="ciel-tts"></label>
241
+ <label>ASR GPU<select id="colabAsrAccelerator"><option>T4</option><option>L4</option><option>G4</option><option>A100</option><option>H100</option></select></label>
242
+ <label>TTS GPU<select id="colabTtsAccelerator"><option>T4</option><option>L4</option><option>G4</option><option>A100</option><option>H100</option></select></label>
243
+ </div>
244
+ <div class="hint">Saved here for scripts/deploy_colab_speech.ps1. Credentials remain in the Colab CLI profile and are never stored by Ciel.</div>
245
+ </section>
230
246
  <section class="settings-section">
231
247
  <h3>Tailscale tunnel</h3>
232
248
  <div class="settings-grid">
@@ -287,6 +303,7 @@ def render_web_chat_page(
287
303
  let mediaRecorder = null;
288
304
  let mediaStream = null;
289
305
  let recordingChunks = [];
306
+ let pendingTtsReferenceAudio = '';
290
307
  function setState(text, cls = '') {{
291
308
  statePill.textContent = text;
292
309
  statePill.className = 'pill ' + cls;
@@ -553,9 +570,18 @@ def render_web_chat_page(
553
570
  reader.readAsDataURL(file);
554
571
  }});
555
572
  }}
573
+ function fileToDataUrl(file) {{
574
+ return new Promise((resolve, reject) => {{
575
+ const reader = new FileReader();
576
+ reader.onload = () => resolve(String(reader.result || ''));
577
+ reader.onerror = () => reject(reader.error || new Error('Could not read file'));
578
+ reader.readAsDataURL(file);
579
+ }});
580
+ }}
556
581
  function setSpeechForm(config) {{
557
582
  const asr = config.asr || {{}};
558
583
  const tts = config.tts || {{}};
584
+ const colab = config.colab || {{}};
559
585
  const tailscale = config.tailscale || {{}};
560
586
  document.getElementById('asrEnabled').checked = Boolean(asr.enabled);
561
587
  document.getElementById('asrBaseUrl').value = asr.base_url || '';
@@ -569,8 +595,20 @@ def render_web_chat_page(
569
595
  document.getElementById('ttsModel').value = tts.model || '';
570
596
  document.getElementById('ttsVoice').value = tts.voice || 'default';
571
597
  document.getElementById('ttsLanguage').value = tts.language || 'ko';
598
+ document.getElementById('ttsReferenceText').value = tts.ref_text || '';
599
+ document.getElementById('ttsReferenceAudioStatus').textContent = tts.ref_audio_set ? 'Reference voice saved securely on this Ciel router' : 'No reference voice configured';
600
+ document.getElementById('ttsClearReferenceAudio').checked = false;
601
+ document.getElementById('ttsReferenceAudio').value = '';
602
+ pendingTtsReferenceAudio = '';
572
603
  document.getElementById('ttsApiKey').value = '';
573
604
  document.getElementById('ttsApiKey').placeholder = tts.api_key_set ? 'Token is set; leave blank to keep it' : 'Optional remote bearer token';
605
+ document.getElementById('colabEnabled').checked = colab.enabled !== false;
606
+ document.getElementById('colabDistribution').value = colab.distribution || 'Ubuntu-26.04';
607
+ document.getElementById('colabAuth').value = colab.auth || 'adc';
608
+ document.getElementById('colabAsrSession').value = colab.asr_session || 'ciel-asr';
609
+ document.getElementById('colabTtsSession').value = colab.tts_session || 'ciel-tts';
610
+ document.getElementById('colabAsrAccelerator').value = colab.asr_accelerator || 'T4';
611
+ document.getElementById('colabTtsAccelerator').value = colab.tts_accelerator || 'T4';
574
612
  document.getElementById('tailscaleEnabled').checked = tailscale.enabled !== false;
575
613
  document.getElementById('tailscaleAsrHostname').value = tailscale.asr_hostname || 'ciel-asr';
576
614
  document.getElementById('tailscaleTtsHostname').value = tailscale.tts_hostname || 'ciel-tts';
@@ -601,8 +639,20 @@ def render_web_chat_page(
601
639
  model: document.getElementById('ttsModel').value,
602
640
  voice: document.getElementById('ttsVoice').value,
603
641
  language: document.getElementById('ttsLanguage').value,
642
+ ref_audio: pendingTtsReferenceAudio,
643
+ ref_text: document.getElementById('ttsReferenceText').value,
644
+ clear_ref_audio: document.getElementById('ttsClearReferenceAudio').checked,
604
645
  api_key: document.getElementById('ttsApiKey').value,
605
646
  }},
647
+ colab: {{
648
+ enabled: document.getElementById('colabEnabled').checked,
649
+ distribution: document.getElementById('colabDistribution').value,
650
+ auth: document.getElementById('colabAuth').value,
651
+ asr_session: document.getElementById('colabAsrSession').value,
652
+ tts_session: document.getElementById('colabTtsSession').value,
653
+ asr_accelerator: document.getElementById('colabAsrAccelerator').value,
654
+ tts_accelerator: document.getElementById('colabTtsAccelerator').value,
655
+ }},
606
656
  tailscale: {{
607
657
  enabled: document.getElementById('tailscaleEnabled').checked,
608
658
  asr_hostname: document.getElementById('tailscaleAsrHostname').value,
@@ -888,6 +938,18 @@ def render_web_chat_page(
888
938
  speechSettingsDialog.showModal();
889
939
  }});
890
940
  speechSettingsClose.addEventListener('click', () => speechSettingsDialog.close());
941
+ document.getElementById('ttsReferenceAudio').addEventListener('change', async event => {{
942
+ const file = event.target.files && event.target.files[0];
943
+ if (!file) return;
944
+ if (file.size > 10 * 1024 * 1024) {{
945
+ event.target.value = '';
946
+ addBubble('system', 'Reference voice must be 10 MB or smaller.');
947
+ return;
948
+ }}
949
+ pendingTtsReferenceAudio = await fileToDataUrl(file);
950
+ document.getElementById('ttsClearReferenceAudio').checked = false;
951
+ document.getElementById('ttsReferenceAudioStatus').textContent = file.name + ' (' + formatBytes(file.size) + ') ready to save';
952
+ }});
891
953
  speechSettingsForm.addEventListener('submit', async event => {{
892
954
  event.preventDefault();
893
955
  try {{
@@ -5,8 +5,8 @@ Ciel Runtime can proxy its web chat and OpenAI-compatible audio API to two Colab
5
5
  ## One-time prerequisites
6
6
 
7
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.
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
10
 
11
11
  ## Deploy
12
12
 
@@ -16,7 +16,11 @@ From PowerShell at the repository root:
16
16
  .\scripts\deploy_colab_speech.ps1
17
17
  ```
18
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.
19
+ Set the WSL distribution, authentication mode, ASR/TTS session names, and accelerators in **Web Chat > Speech Settings > Colab CLI connection**. These values are available through `GET|POST /ca/speech/config`; Ciel does not store Colab credentials. The deployment script reads the saved values automatically. Command-line parameters such as `-Distribution`, `-ColabAuth`, `-AsrSession`, and `-AsrAccelerator` override them for one run.
20
+
21
+ The script reuses matching active sessions when possible, otherwise creates them, 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.
22
+
23
+ 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`.
20
24
 
21
25
  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
26
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oneciel-ai/ciel-runtime",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
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",
@@ -9,6 +9,7 @@ from __future__ import annotations
9
9
  import json
10
10
  import os
11
11
  from pathlib import Path
12
+ import site
12
13
  import shutil
13
14
  import subprocess
14
15
  import sys
@@ -38,7 +39,18 @@ def secret(name: str, *, required: bool = False) -> str:
38
39
 
39
40
 
40
41
  def run(*args: str, check: bool = True) -> subprocess.CompletedProcess[str]:
41
- print("+", " ".join(args))
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))
42
54
  return subprocess.run(args, check=check, text=True, capture_output=False)
43
55
 
44
56
 
@@ -52,29 +64,33 @@ def start_tailscale(auth_key: str) -> tuple[str, str]:
52
64
  install_tailscale()
53
65
  LOG_DIR.mkdir(parents=True, exist_ok=True)
54
66
  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
- )
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
+ )
61
74
  for _ in range(60):
62
75
  if Path(SOCKET).exists():
63
76
  break
64
77
  time.sleep(1)
65
- run("tailscale", f"--socket={SOCKET}", "up", f"--auth-key={auth_key}", f"--hostname={HOSTNAME}", "--accept-dns=true", "--reset")
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")
66
81
  status = subprocess.check_output(["tailscale", f"--socket={SOCKET}", "status", "--json"], text=True)
67
82
  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
83
  run("tailscale", f"--socket={SOCKET}", "serve", "--bg", "--http=80", f"http://127.0.0.1:{PORT}")
72
84
  return dns_name, f"http://{dns_name}"
73
85
 
74
86
 
75
- def wait_for_server(api_key: str) -> None:
87
+ def wait_for_server(api_key: str, process: subprocess.Popen[bytes]) -> None:
76
88
  headers = {"authorization": f"Bearer {api_key}"} if api_key else {}
77
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}")
78
94
  try:
79
95
  with urllib.request.urlopen(urllib.request.Request(f"http://127.0.0.1:{PORT}/health", headers=headers), timeout=3) as response:
80
96
  if response.status < 500:
@@ -84,20 +100,48 @@ def wait_for_server(api_key: str) -> None:
84
100
  raise RuntimeError("MOSS TTS did not become healthy; inspect /content/ciel-speech-logs/moss-tts.log")
85
101
 
86
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
+
87
112
  def main() -> None:
88
113
  auth_key = secret("TAILSCALE_AUTHKEY", required=True)
89
114
  api_key = secret("CIEL_SPEECH_API_KEY")
90
- run(sys.executable, "-m", "pip", "install", "-U", "vllm-omni==0.24.0")
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
+ )
91
125
  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)
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)
101
145
  dns_name, base_url = start_tailscale(auth_key)
102
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))
103
147
 
@@ -38,7 +38,18 @@ def secret(name: str, *, required: bool = False) -> str:
38
38
 
39
39
 
40
40
  def run(*args: str, check: bool = True) -> subprocess.CompletedProcess[str]:
41
- print("+", " ".join(args))
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))
42
53
  return subprocess.run(args, check=check, text=True, capture_output=False)
43
54
 
44
55
 
@@ -62,12 +73,11 @@ def start_tailscale(auth_key: str) -> tuple[str, str]:
62
73
  if Path(SOCKET).exists():
63
74
  break
64
75
  time.sleep(1)
65
- run("tailscale", f"--socket={SOCKET}", "up", f"--auth-key={auth_key}", f"--hostname={HOSTNAME}", "--accept-dns=true", "--reset")
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")
66
79
  status = subprocess.check_output(["tailscale", f"--socket={SOCKET}", "status", "--json"], text=True)
67
80
  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
81
  run("tailscale", f"--socket={SOCKET}", "serve", "--bg", "--http=80", f"http://127.0.0.1:{PORT}")
72
82
  return dns_name, f"http://{dns_name}"
73
83
 
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import argparse
6
+ import json
6
7
  from pathlib import Path
7
8
  import sys
8
9
  from typing import Any
@@ -10,25 +11,94 @@ from typing import Any
10
11
  sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
11
12
 
12
13
 
13
- def configure(asr_base_url: str, tts_base_url: str) -> dict[str, Any]:
14
+ DEFAULT_TTS_REFERENCE_AUDIO = "https://raw.githubusercontent.com/OpenMOSS/MOSS-TTS-Nano/main/assets/audio/zh_1.wav"
15
+ DEFAULT_COLAB_SETTINGS: dict[str, Any] = {
16
+ "enabled": True,
17
+ "distribution": "Ubuntu-26.04",
18
+ "auth": "adc",
19
+ "asr_session": "ciel-asr",
20
+ "tts_session": "ciel-tts",
21
+ "asr_accelerator": "T4",
22
+ "tts_accelerator": "T4",
23
+ }
24
+
25
+
26
+ def colab_settings(config: dict[str, Any] | None = None) -> dict[str, Any]:
27
+ import ciel_runtime
28
+
29
+ active = config if config is not None else ciel_runtime.load_config()
30
+ speech = active.get("speech") if isinstance(active.get("speech"), dict) else {}
31
+ saved = speech.get("colab") if isinstance(speech.get("colab"), dict) else {}
32
+ return {**DEFAULT_COLAB_SETTINGS, **saved}
33
+
34
+
35
+ def configure(
36
+ asr_base_url: str,
37
+ tts_base_url: str,
38
+ tts_reference_audio: str = DEFAULT_TTS_REFERENCE_AUDIO,
39
+ *,
40
+ distribution: str | None = None,
41
+ auth: str | None = None,
42
+ asr_session: str | None = None,
43
+ tts_session: str | None = None,
44
+ asr_accelerator: str | None = None,
45
+ tts_accelerator: str | None = None,
46
+ ) -> dict[str, Any]:
14
47
  import ciel_runtime
15
48
 
16
49
  config = ciel_runtime.load_config()
17
50
  speech = config.setdefault("speech", {})
18
51
  asr = speech.setdefault("asr", {})
19
52
  tts = speech.setdefault("tts", {})
53
+ colab = colab_settings(config)
54
+ overrides = {
55
+ "distribution": distribution,
56
+ "auth": auth,
57
+ "asr_session": asr_session,
58
+ "tts_session": tts_session,
59
+ "asr_accelerator": asr_accelerator,
60
+ "tts_accelerator": tts_accelerator,
61
+ }
62
+ colab.update({key: value for key, value in overrides.items() if value is not None})
63
+ colab["enabled"] = True
64
+ speech["colab"] = colab
20
65
  asr.update({"enabled": True, "base_url": asr_base_url.rstrip("/"), "model": "Qwen/Qwen3-ASR-0.6B"})
21
66
  tts.update({"enabled": True, "base_url": tts_base_url.rstrip("/"), "model": "OpenMOSS-Team/MOSS-TTS-Nano"})
67
+ if tts_reference_audio and not str(tts.get("ref_audio") or "").strip():
68
+ tts["ref_audio"] = tts_reference_audio
22
69
  ciel_runtime.save_config(config)
23
- return {"asr": asr["base_url"], "tts": tts["base_url"]}
70
+ return {"asr": asr["base_url"], "tts": tts["base_url"], "colab": colab}
24
71
 
25
72
 
26
73
  def main() -> int:
27
74
  parser = argparse.ArgumentParser()
28
- parser.add_argument("--asr-base-url", required=True)
29
- parser.add_argument("--tts-base-url", required=True)
75
+ parser.add_argument("--asr-base-url")
76
+ parser.add_argument("--tts-base-url")
77
+ parser.add_argument("--tts-reference-audio", default=DEFAULT_TTS_REFERENCE_AUDIO)
78
+ parser.add_argument("--distribution")
79
+ parser.add_argument("--auth", choices=("adc", "oauth2"))
80
+ parser.add_argument("--asr-session")
81
+ parser.add_argument("--tts-session")
82
+ parser.add_argument("--asr-accelerator")
83
+ parser.add_argument("--tts-accelerator")
84
+ parser.add_argument("--print-colab-settings", action="store_true")
30
85
  args = parser.parse_args()
31
- result = configure(args.asr_base_url, args.tts_base_url)
86
+ if args.print_colab_settings:
87
+ print(json.dumps(colab_settings(), separators=(",", ":")))
88
+ return 0
89
+ if not args.asr_base_url or not args.tts_base_url:
90
+ parser.error("--asr-base-url and --tts-base-url are required unless --print-colab-settings is used")
91
+ result = configure(
92
+ args.asr_base_url,
93
+ args.tts_base_url,
94
+ args.tts_reference_audio,
95
+ distribution=args.distribution,
96
+ auth=args.auth,
97
+ asr_session=args.asr_session,
98
+ tts_session=args.tts_session,
99
+ asr_accelerator=args.asr_accelerator,
100
+ tts_accelerator=args.tts_accelerator,
101
+ )
32
102
  print(f"Configured Ciel speech workers: ASR={result['asr']} TTS={result['tts']}")
33
103
  return 0
34
104
 
@@ -1,34 +1,70 @@
1
1
  param(
2
- [string]$Distribution = "Ubuntu-26.04",
3
- [string]$AsrSession = "ciel-asr",
4
- [string]$TtsSession = "ciel-tts"
2
+ [string]$Distribution,
3
+ [string]$ColabAuth,
4
+ [string]$AsrSession,
5
+ [string]$TtsSession,
6
+ [string]$AsrAccelerator,
7
+ [string]$TtsAccelerator
5
8
  )
6
9
 
7
10
  $ErrorActionPreference = "Stop"
8
11
  $repo = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
12
+ $settingsJson = (& python (Join-Path $PSScriptRoot "configure_speech_workers.py") --print-colab-settings) -join "`n"
13
+ if ($LASTEXITCODE -ne 0) { throw "Could not read Ciel Colab settings." }
14
+ $settings = $settingsJson | ConvertFrom-Json
15
+ if ($settings.enabled -eq $false) { throw "Colab worker management is disabled in Web Chat > Speech Settings." }
16
+ if ([string]::IsNullOrWhiteSpace($Distribution)) { $Distribution = [string]$settings.distribution }
17
+ if ([string]::IsNullOrWhiteSpace($ColabAuth)) { $ColabAuth = [string]$settings.auth }
18
+ if ([string]::IsNullOrWhiteSpace($AsrSession)) { $AsrSession = [string]$settings.asr_session }
19
+ if ([string]::IsNullOrWhiteSpace($TtsSession)) { $TtsSession = [string]$settings.tts_session }
20
+ if ([string]::IsNullOrWhiteSpace($AsrAccelerator)) { $AsrAccelerator = [string]$settings.asr_accelerator }
21
+ if ([string]::IsNullOrWhiteSpace($TtsAccelerator)) { $TtsAccelerator = [string]$settings.tts_accelerator }
22
+ if ($Distribution -notmatch '^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$') { throw "Invalid WSL distribution name." }
23
+ if ($ColabAuth -notin @('adc', 'oauth2')) { throw "ColabAuth must be adc or oauth2." }
24
+ foreach ($session in @($AsrSession, $TtsSession)) {
25
+ if ($session -notmatch '^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$') { throw "Invalid Colab session name: $session" }
26
+ }
27
+ foreach ($accelerator in @($AsrAccelerator, $TtsAccelerator)) {
28
+ if ($accelerator -notin @('T4', 'L4', 'G4', 'A100', 'H100')) { throw "Unsupported Colab accelerator: $accelerator" }
29
+ }
9
30
  $wslRepo = (& wsl -d $Distribution -- wslpath -a ($repo -replace '\\', '/')).Trim()
10
31
  if (-not $wslRepo) { throw "Could not resolve the repository path in WSL." }
32
+ $bootstrapEnv = ""
33
+ if ($env:TAILSCALE_AUTHKEY) {
34
+ if ($env:TAILSCALE_AUTHKEY -notmatch '^tskey-[A-Za-z0-9_-]+$') { throw "TAILSCALE_AUTHKEY has an unexpected format." }
35
+ $bootstrapEnv += " --env TAILSCALE_AUTHKEY=$($env:TAILSCALE_AUTHKEY)"
36
+ }
37
+ if ($env:CIEL_SPEECH_API_KEY) {
38
+ if ($env:CIEL_SPEECH_API_KEY -match '[\s''"]') { throw "CIEL_SPEECH_API_KEY cannot contain whitespace or quotes for CLI deployment." }
39
+ $bootstrapEnv += " --env CIEL_SPEECH_API_KEY=$($env:CIEL_SPEECH_API_KEY)"
40
+ }
11
41
 
12
42
  Write-Host "Checking Colab CLI authentication..."
13
- & wsl -d $Distribution -- bash -lc "colab status >/dev/null"
43
+ & wsl -d $Distribution -- bash -lc "colab --auth $ColabAuth status >/dev/null"
14
44
  if ($LASTEXITCODE -ne 0) {
15
- throw "Colab CLI is not authenticated. Configure ADC in WSL (gcloud auth application-default login), then rerun."
45
+ throw "Colab CLI is not authenticated with '$ColabAuth' in WSL '$Distribution'."
16
46
  }
17
47
 
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." }
48
+ function Ensure-ColabSession([string]$Session, [string]$Accelerator, [string]$Role) {
49
+ & wsl -d $Distribution -- bash -lc "colab --auth $ColabAuth status --session '$Session' >/dev/null 2>&1"
50
+ if ($LASTEXITCODE -eq 0) {
51
+ Write-Host "Reusing $Role $Accelerator session: $Session"
52
+ return
53
+ }
54
+ Write-Host "Creating $Role $Accelerator session: $Session"
55
+ & wsl -d $Distribution -- bash -lc "colab --auth $ColabAuth new --gpu $Accelerator --session '$Session'"
56
+ if ($LASTEXITCODE -ne 0) { throw "Could not create $Role Colab session." }
57
+ }
21
58
 
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." }
59
+ Ensure-ColabSession $AsrSession $AsrAccelerator "ASR"
60
+ Ensure-ColabSession $TtsSession $TtsAccelerator "TTS"
25
61
 
26
62
  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"
63
+ $asrOutput = (& wsl -d $Distribution -- bash -lc "colab --auth $ColabAuth exec --session '$AsrSession'$bootstrapEnv --file '$wslRepo/scripts/colab/bootstrap_qwen_asr.py'" 2>&1 | Tee-Object -Variable asrDisplay) -join "`n"
28
64
  if ($LASTEXITCODE -ne 0) { throw "ASR bootstrap failed." }
29
65
 
30
66
  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"
67
+ $ttsOutput = (& wsl -d $Distribution -- bash -lc "colab --auth $ColabAuth exec --session '$TtsSession'$bootstrapEnv --file '$wslRepo/scripts/colab/bootstrap_moss_tts.py'" 2>&1 | Tee-Object -Variable ttsDisplay) -join "`n"
32
68
  if ($LASTEXITCODE -ne 0) { throw "TTS bootstrap failed." }
33
69
 
34
70
  function Read-BootstrapResult([string]$Text, [string]$Role) {
@@ -41,7 +77,7 @@ function Read-BootstrapResult([string]$Text, [string]$Role) {
41
77
 
42
78
  $asr = Read-BootstrapResult $asrOutput "asr"
43
79
  $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
80
+ & python (Join-Path $PSScriptRoot "configure_speech_workers.py") --asr-base-url $asr.base_url --tts-base-url $tts.base_url --distribution $Distribution --auth $ColabAuth --asr-session $AsrSession --tts-session $TtsSession --asr-accelerator $AsrAccelerator --tts-accelerator $TtsAccelerator
45
81
  if ($LASTEXITCODE -ne 0) { throw "Workers started, but Ciel speech configuration failed." }
46
82
 
47
83
  Write-Host "Both services are running and connected to Web Chat > Speech Settings."