@oneciel-ai/ciel-runtime 0.2.4 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -44,6 +44,8 @@ def build_default_config(provider_defaults: dict[str, Any]) -> dict[str, Any]:
44
44
  "model": "OpenMOSS-Team/MOSS-TTS-Nano",
45
45
  "voice": "default",
46
46
  "language": "ko",
47
+ "ref_audio": "",
48
+ "ref_text": "",
47
49
  "response_format": "wav",
48
50
  "speed": 1.0,
49
51
  "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.5"
97
97
  CREDITS = "Credits: One Ciel LLC"
98
98
  PRELAUNCH_CANCEL = 10
99
99
  PRELAUNCH_LAUNCH_CODEX = 11
@@ -65,8 +65,10 @@ class SpeechHttpController:
65
65
  public: dict[str, Any] = {"ok": True}
66
66
  for name in ("asr", "tts"):
67
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"}
68
+ item = {key: value for key, value in source.items() if key not in {"api_key", "ref_audio"}}
69
69
  item["api_key_set"] = bool(str(source.get("api_key") or "").strip())
70
+ if name == "tts":
71
+ item["ref_audio_set"] = bool(str(source.get("ref_audio") or "").strip())
70
72
  public[name] = item
71
73
  tailscale = speech.get("tailscale")
72
74
  public["tailscale"] = dict(tailscale) if isinstance(tailscale, dict) else {}
@@ -127,13 +129,15 @@ class SpeechHttpController:
127
129
  current = {}
128
130
  speech[name] = current
129
131
  for key, value in incoming.items():
130
- if key in {"api_key_set", "clear_api_key"}:
132
+ if key in {"api_key_set", "clear_api_key", "ref_audio_set", "clear_ref_audio"}:
131
133
  continue
132
- if key == "api_key" and not str(value or "").strip():
134
+ if key in {"api_key", "ref_audio"} and not str(value or "").strip():
133
135
  continue
134
136
  current[key] = self._validated_value(name, key, value)
135
137
  if incoming.get("clear_api_key") is True:
136
138
  current["api_key"] = ""
139
+ if name == "tts" and incoming.get("clear_ref_audio") is True:
140
+ current["ref_audio"] = ""
137
141
  tailscale = update.get("tailscale")
138
142
  if isinstance(tailscale, dict):
139
143
  current_tailscale = speech.setdefault("tailscale", {})
@@ -153,7 +157,7 @@ class SpeechHttpController:
153
157
  def _validated_value(service: str, key: str, value: Any) -> Any:
154
158
  allowed = {
155
159
  "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"},
160
+ "tts": {"enabled", "base_url", "endpoint", "voices_endpoint", "model", "voice", "language", "ref_audio", "ref_text", "response_format", "speed", "auto_speak", "api_key", "timeout_seconds"},
157
161
  }
158
162
  if key not in allowed[service]:
159
163
  raise ValueError(f"unsupported {service} setting: {key}")
@@ -164,6 +168,21 @@ class SpeechHttpController:
164
168
  if key == "speed":
165
169
  return max(0.25, min(4.0, float(value)))
166
170
  text = str(value or "").strip()
171
+ if key == "ref_audio":
172
+ if len(text) > 14_000_000:
173
+ raise ValueError("TTS reference audio must be 10 MB or smaller")
174
+ if text.startswith("data:audio/") and ";base64," in text:
175
+ try:
176
+ audio = base64.b64decode(text.split(",", 1)[1], validate=True)
177
+ except (ValueError, TypeError) as exc:
178
+ raise ValueError("invalid base64 TTS reference audio") from exc
179
+ if not audio or len(audio) > 10 * 1024 * 1024:
180
+ raise ValueError("TTS reference audio must be between 1 byte and 10 MB")
181
+ return text
182
+ parsed_ref = urllib.parse.urlparse(text)
183
+ if parsed_ref.scheme not in {"http", "https"} or not parsed_ref.netloc:
184
+ raise ValueError("TTS ref_audio must be an audio data URL or HTTP(S) URL")
185
+ return text
167
186
  if key == "base_url":
168
187
  parsed = urllib.parse.urlparse(text)
169
188
  if parsed.scheme not in {"http", "https"} or not parsed.netloc:
@@ -237,6 +256,10 @@ class SpeechHttpController:
237
256
  body.setdefault("model", str(config.get("model") or ""))
238
257
  body.setdefault("voice", str(config.get("voice") or "default"))
239
258
  body.setdefault("language", str(config.get("language") or "Auto"))
259
+ if str(config.get("ref_audio") or "").strip():
260
+ body.setdefault("ref_audio", str(config["ref_audio"]))
261
+ if str(config.get("ref_text") or "").strip():
262
+ body.setdefault("ref_text", str(config["ref_text"]))
240
263
  body.setdefault("response_format", str(config.get("response_format") or "wav"))
241
264
  body.setdefault("speed", float(config.get("speed") or 1.0))
242
265
  raw = json.dumps(body, ensure_ascii=False).encode("utf-8")
@@ -224,6 +224,9 @@ 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>
@@ -287,6 +290,7 @@ def render_web_chat_page(
287
290
  let mediaRecorder = null;
288
291
  let mediaStream = null;
289
292
  let recordingChunks = [];
293
+ let pendingTtsReferenceAudio = '';
290
294
  function setState(text, cls = '') {{
291
295
  statePill.textContent = text;
292
296
  statePill.className = 'pill ' + cls;
@@ -553,6 +557,14 @@ def render_web_chat_page(
553
557
  reader.readAsDataURL(file);
554
558
  }});
555
559
  }}
560
+ function fileToDataUrl(file) {{
561
+ return new Promise((resolve, reject) => {{
562
+ const reader = new FileReader();
563
+ reader.onload = () => resolve(String(reader.result || ''));
564
+ reader.onerror = () => reject(reader.error || new Error('Could not read file'));
565
+ reader.readAsDataURL(file);
566
+ }});
567
+ }}
556
568
  function setSpeechForm(config) {{
557
569
  const asr = config.asr || {{}};
558
570
  const tts = config.tts || {{}};
@@ -569,6 +581,11 @@ def render_web_chat_page(
569
581
  document.getElementById('ttsModel').value = tts.model || '';
570
582
  document.getElementById('ttsVoice').value = tts.voice || 'default';
571
583
  document.getElementById('ttsLanguage').value = tts.language || 'ko';
584
+ document.getElementById('ttsReferenceText').value = tts.ref_text || '';
585
+ document.getElementById('ttsReferenceAudioStatus').textContent = tts.ref_audio_set ? 'Reference voice saved securely on this Ciel router' : 'No reference voice configured';
586
+ document.getElementById('ttsClearReferenceAudio').checked = false;
587
+ document.getElementById('ttsReferenceAudio').value = '';
588
+ pendingTtsReferenceAudio = '';
572
589
  document.getElementById('ttsApiKey').value = '';
573
590
  document.getElementById('ttsApiKey').placeholder = tts.api_key_set ? 'Token is set; leave blank to keep it' : 'Optional remote bearer token';
574
591
  document.getElementById('tailscaleEnabled').checked = tailscale.enabled !== false;
@@ -601,6 +618,9 @@ def render_web_chat_page(
601
618
  model: document.getElementById('ttsModel').value,
602
619
  voice: document.getElementById('ttsVoice').value,
603
620
  language: document.getElementById('ttsLanguage').value,
621
+ ref_audio: pendingTtsReferenceAudio,
622
+ ref_text: document.getElementById('ttsReferenceText').value,
623
+ clear_ref_audio: document.getElementById('ttsClearReferenceAudio').checked,
604
624
  api_key: document.getElementById('ttsApiKey').value,
605
625
  }},
606
626
  tailscale: {{
@@ -888,6 +908,18 @@ def render_web_chat_page(
888
908
  speechSettingsDialog.showModal();
889
909
  }});
890
910
  speechSettingsClose.addEventListener('click', () => speechSettingsDialog.close());
911
+ document.getElementById('ttsReferenceAudio').addEventListener('change', async event => {{
912
+ const file = event.target.files && event.target.files[0];
913
+ if (!file) return;
914
+ if (file.size > 10 * 1024 * 1024) {{
915
+ event.target.value = '';
916
+ addBubble('system', 'Reference voice must be 10 MB or smaller.');
917
+ return;
918
+ }}
919
+ pendingTtsReferenceAudio = await fileToDataUrl(file);
920
+ document.getElementById('ttsClearReferenceAudio').checked = false;
921
+ document.getElementById('ttsReferenceAudioStatus').textContent = file.name + ' (' + formatBytes(file.size) + ') ready to save';
922
+ }});
891
923
  speechSettingsForm.addEventListener('submit', async event => {{
892
924
  event.preventDefault();
893
925
  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
 
@@ -18,6 +18,8 @@ From PowerShell at the repository root:
18
18
 
19
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
20
 
21
+ MOSS-TTS-Nano is a voice-cloning model without built-in speakers. Deployment configures the project's official `zh_1.wav` sample so the first request works immediately. In Web Chat > Speech Settings, upload a reference voice clip (10 MB maximum) to replace it. Ciel stores uploaded audio only in the local protected runtime configuration, omits it from configuration responses, and adds it to TTS requests automatically. API clients can instead pass `ref_audio` as an HTTP(S) URL or base64 audio data URL to `POST /v1/audio/speech`.
22
+
21
23
  Colab sessions are ephemeral. Re-run the bootstrap after a runtime reset. The workers are reachable only by devices in the same tailnet unless an administrator separately enables Tailscale Funnel.
22
24
 
23
25
  ## API surface
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.5",
4
4
  "description": "Universal AI coding-agent runtime and model-routing layer for Claude, Codex, AGY, and compatible runtimes.",
5
5
  "license": "MIT",
6
6
  "author": "One Ciel LLC",
@@ -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
 
@@ -10,7 +10,10 @@ from typing import Any
10
10
  sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
11
11
 
12
12
 
13
- def configure(asr_base_url: str, tts_base_url: str) -> dict[str, Any]:
13
+ DEFAULT_TTS_REFERENCE_AUDIO = "https://raw.githubusercontent.com/OpenMOSS/MOSS-TTS-Nano/main/assets/audio/zh_1.wav"
14
+
15
+
16
+ def configure(asr_base_url: str, tts_base_url: str, tts_reference_audio: str = DEFAULT_TTS_REFERENCE_AUDIO) -> dict[str, Any]:
14
17
  import ciel_runtime
15
18
 
16
19
  config = ciel_runtime.load_config()
@@ -19,6 +22,8 @@ def configure(asr_base_url: str, tts_base_url: str) -> dict[str, Any]:
19
22
  tts = speech.setdefault("tts", {})
20
23
  asr.update({"enabled": True, "base_url": asr_base_url.rstrip("/"), "model": "Qwen/Qwen3-ASR-0.6B"})
21
24
  tts.update({"enabled": True, "base_url": tts_base_url.rstrip("/"), "model": "OpenMOSS-Team/MOSS-TTS-Nano"})
25
+ if tts_reference_audio and not str(tts.get("ref_audio") or "").strip():
26
+ tts["ref_audio"] = tts_reference_audio
22
27
  ciel_runtime.save_config(config)
23
28
  return {"asr": asr["base_url"], "tts": tts["base_url"]}
24
29
 
@@ -27,8 +32,9 @@ def main() -> int:
27
32
  parser = argparse.ArgumentParser()
28
33
  parser.add_argument("--asr-base-url", required=True)
29
34
  parser.add_argument("--tts-base-url", required=True)
35
+ parser.add_argument("--tts-reference-audio", default=DEFAULT_TTS_REFERENCE_AUDIO)
30
36
  args = parser.parse_args()
31
- result = configure(args.asr_base_url, args.tts_base_url)
37
+ result = configure(args.asr_base_url, args.tts_base_url, args.tts_reference_audio)
32
38
  print(f"Configured Ciel speech workers: ASR={result['asr']} TTS={result['tts']}")
33
39
  return 0
34
40
 
@@ -8,27 +8,36 @@ $ErrorActionPreference = "Stop"
8
8
  $repo = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
9
9
  $wslRepo = (& wsl -d $Distribution -- wslpath -a ($repo -replace '\\', '/')).Trim()
10
10
  if (-not $wslRepo) { throw "Could not resolve the repository path in WSL." }
11
+ $bootstrapEnv = ""
12
+ if ($env:TAILSCALE_AUTHKEY) {
13
+ if ($env:TAILSCALE_AUTHKEY -notmatch '^tskey-[A-Za-z0-9_-]+$') { throw "TAILSCALE_AUTHKEY has an unexpected format." }
14
+ $bootstrapEnv += " --env TAILSCALE_AUTHKEY=$($env:TAILSCALE_AUTHKEY)"
15
+ }
16
+ if ($env:CIEL_SPEECH_API_KEY) {
17
+ if ($env:CIEL_SPEECH_API_KEY -match '[\s''"]') { throw "CIEL_SPEECH_API_KEY cannot contain whitespace or quotes for CLI deployment." }
18
+ $bootstrapEnv += " --env CIEL_SPEECH_API_KEY=$($env:CIEL_SPEECH_API_KEY)"
19
+ }
11
20
 
12
21
  Write-Host "Checking Colab CLI authentication..."
13
- & wsl -d $Distribution -- bash -lc "colab status >/dev/null"
22
+ & wsl -d $Distribution -- bash -lc "colab --auth adc status >/dev/null"
14
23
  if ($LASTEXITCODE -ne 0) {
15
24
  throw "Colab CLI is not authenticated. Configure ADC in WSL (gcloud auth application-default login), then rerun."
16
25
  }
17
26
 
18
27
  Write-Host "Creating ASR T4 session: $AsrSession"
19
- & wsl -d $Distribution -- bash -lc "colab new --gpu T4 --session '$AsrSession'"
28
+ & wsl -d $Distribution -- bash -lc "colab --auth adc new --gpu T4 --session '$AsrSession'"
20
29
  if ($LASTEXITCODE -ne 0) { throw "Could not create ASR Colab session." }
21
30
 
22
31
  Write-Host "Creating TTS T4 session: $TtsSession"
23
- & wsl -d $Distribution -- bash -lc "colab new --gpu T4 --session '$TtsSession'"
32
+ & wsl -d $Distribution -- bash -lc "colab --auth adc new --gpu T4 --session '$TtsSession'"
24
33
  if ($LASTEXITCODE -ne 0) { throw "Could not create TTS Colab session." }
25
34
 
26
35
  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"
36
+ $asrOutput = (& wsl -d $Distribution -- bash -lc "colab --auth adc exec --session '$AsrSession'$bootstrapEnv --file '$wslRepo/scripts/colab/bootstrap_qwen_asr.py'" 2>&1 | Tee-Object -Variable asrDisplay) -join "`n"
28
37
  if ($LASTEXITCODE -ne 0) { throw "ASR bootstrap failed." }
29
38
 
30
39
  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"
40
+ $ttsOutput = (& wsl -d $Distribution -- bash -lc "colab --auth adc exec --session '$TtsSession'$bootstrapEnv --file '$wslRepo/scripts/colab/bootstrap_moss_tts.py'" 2>&1 | Tee-Object -Variable ttsDisplay) -join "`n"
32
41
  if ($LASTEXITCODE -ne 0) { throw "TTS bootstrap failed." }
33
42
 
34
43
  function Read-BootstrapResult([string]$Text, [string]$Role) {