@oneciel-ai/ciel-runtime 0.2.11 → 0.2.12

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.colab_speech_jobs import colab_speech_job_status, launch_colab_speech_job
503
504
  from ciel_runtime_support.speech_http_controller import SpeechHttpController, SpeechHttpPorts
504
505
  from ciel_runtime_support.session_import import ImportSessionHttpController, ImportSessionHttpPorts, ImportSessionLimits, ImportSessionRepository, ImportSessionService, import_record_line, import_tool_text, normalize_import_source
505
506
  from ciel_runtime_support.slash_command_assets import ADVISOR_NATIVE_DISABLED_SLASH_COMMAND # noqa: F401 - compatibility export
@@ -1700,8 +1701,7 @@ def web_ui_controller() -> WebUiController:
1700
1701
  def render_router_home_html(cfg: dict[str, Any], provider: str, pcfg: dict[str, Any]) -> str: return web_ui_controller().render_router_home(cfg, provider, pcfg)
1701
1702
  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)
1702
1703
  def handle_web_get(handler: BaseHTTPRequestHandler, path: str) -> bool: return web_ui_controller().handle_get(handler, path)
1703
-
1704
- def speech_http_controller() -> SpeechHttpController: return SpeechHttpController(SpeechHttpPorts(load_config, save_config, write_json, router_log))
1704
+ def speech_http_controller() -> SpeechHttpController: return SpeechHttpController(SpeechHttpPorts(load_config, save_config, write_json, router_log, colab_action=launch_colab_speech_job, colab_status=colab_speech_job_status))
1705
1705
  def parse_json_body(raw: bytes) -> dict[str, Any]:
1706
1706
  try:
1707
1707
  value = json.loads(raw.decode("utf-8") if raw else "{}")
@@ -0,0 +1,176 @@
1
+ """Background Colab speech deployment jobs launched by Web Chat."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ import subprocess
8
+ import threading
9
+ import uuid
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from .runtime_paths import CONFIG_DIR
15
+
16
+
17
+ _ACTIONS = {"start", "deploy", "recreate", "status"}
18
+ _SAFE_NAME = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}")
19
+ _SECRET_PATTERN = re.compile(r"(?:tskey-[A-Za-z0-9_-]+|Bearer\s+\S+)", re.IGNORECASE)
20
+
21
+
22
+ @dataclass(slots=True)
23
+ class _Job:
24
+ job_id: str
25
+ action: str
26
+ profile: str
27
+ process: subprocess.Popen[bytes]
28
+ log_path: Path
29
+ redactions: tuple[str, ...]
30
+
31
+
32
+ class ColabSpeechJobManager:
33
+ def __init__(self, script_path: Path, state_dir: Path) -> None:
34
+ self.script_path = script_path
35
+ self.state_dir = state_dir
36
+ self._jobs: dict[str, _Job] = {}
37
+ self._latest = ""
38
+ self._lock = threading.Lock()
39
+
40
+ @staticmethod
41
+ def _safe_name(value: Any, label: str) -> str:
42
+ text = str(value or "").strip()
43
+ if not _SAFE_NAME.fullmatch(text):
44
+ raise ValueError(f"invalid Colab {label}")
45
+ return text
46
+
47
+ def login_command(self, settings: dict[str, Any], *, reset: bool = False) -> str:
48
+ profile = self._safe_name(settings.get("profile") or "default", "profile")
49
+ distribution = self._safe_name(settings.get("distribution") or "Ubuntu-26.04", "distribution")
50
+ auth = str(settings.get("auth") or "oauth2").strip().lower()
51
+ if auth not in {"adc", "oauth2"}:
52
+ raise ValueError("Colab auth must be adc or oauth2")
53
+ suffix = " -ResetAuthentication" if reset else ""
54
+ return (
55
+ "powershell -ExecutionPolicy Bypass -File "
56
+ f'"{self.script_path}" -Action Login -Distribution "{distribution}" '
57
+ f'-ColabAuth "{auth}" -Profile "{profile}"{suffix}'
58
+ )
59
+
60
+ @staticmethod
61
+ def _redact(text: str, redactions: tuple[str, ...]) -> str:
62
+ result = _SECRET_PATTERN.sub("<redacted>", text)
63
+ for secret in redactions:
64
+ result = result.replace(secret, "<redacted>")
65
+ return result
66
+
67
+ def _scrub_completed_log(self, job: _Job) -> None:
68
+ job.process.wait()
69
+ try:
70
+ original = job.log_path.read_text(encoding="utf-8", errors="replace")
71
+ redacted = self._redact(original, job.redactions)
72
+ if redacted != original:
73
+ job.log_path.write_text(redacted, encoding="utf-8")
74
+ except OSError:
75
+ pass
76
+
77
+ def launch(
78
+ self,
79
+ action: str,
80
+ settings: dict[str, Any],
81
+ secrets: dict[str, str] | None = None,
82
+ ) -> dict[str, Any]:
83
+ normalized = str(action or "").strip().lower()
84
+ if normalized == "login":
85
+ return {
86
+ "ok": True,
87
+ "requires_terminal": True,
88
+ "command": self.login_command(settings, reset=bool((secrets or {}).get("reset_authentication"))),
89
+ }
90
+ if normalized not in _ACTIONS:
91
+ raise ValueError("Colab action must be start, deploy, recreate, status, or login")
92
+ if os.name != "nt":
93
+ raise RuntimeError("Web-managed Colab deployment currently requires Windows with WSL")
94
+ if not self.script_path.is_file():
95
+ raise RuntimeError(f"Colab deployment script is missing: {self.script_path}")
96
+ profile = self._safe_name(settings.get("profile") or "default", "profile")
97
+ with self._lock:
98
+ for job in self._jobs.values():
99
+ if job.profile == profile and job.process.poll() is None:
100
+ raise RuntimeError(f"Colab profile '{profile}' already has a running job")
101
+ self.state_dir.mkdir(parents=True, exist_ok=True)
102
+ job_id = uuid.uuid4().hex[:16]
103
+ log_path = self.state_dir / f"{job_id}.log"
104
+ command = [
105
+ "powershell.exe",
106
+ "-NoProfile",
107
+ "-ExecutionPolicy",
108
+ "Bypass",
109
+ "-File",
110
+ str(self.script_path),
111
+ "-Action",
112
+ normalized.capitalize(),
113
+ "-Profile",
114
+ profile,
115
+ ]
116
+ environment = os.environ.copy()
117
+ supplied = secrets or {}
118
+ for source, target in (("tailscale_auth_key", "TAILSCALE_AUTHKEY"), ("speech_api_key", "CIEL_SPEECH_API_KEY")):
119
+ value = str(supplied.get(source) or "").strip()
120
+ if value:
121
+ environment[target] = value
122
+ with log_path.open("wb") as output:
123
+ process = subprocess.Popen(
124
+ command,
125
+ cwd=str(self.script_path.parent.parent),
126
+ env=environment,
127
+ stdin=subprocess.DEVNULL,
128
+ stdout=output,
129
+ stderr=subprocess.STDOUT,
130
+ creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
131
+ )
132
+ redactions = tuple(str(supplied.get(key) or "").strip() for key in ("tailscale_auth_key", "speech_api_key") if str(supplied.get(key) or "").strip())
133
+ job = _Job(job_id, normalized, profile, process, log_path, redactions)
134
+ self._jobs[job_id] = job
135
+ self._latest = job_id
136
+ threading.Thread(target=self._scrub_completed_log, args=(job,), daemon=True).start()
137
+ return self.status(job_id)
138
+
139
+ def status(self, job_id: str = "") -> dict[str, Any]:
140
+ selected = str(job_id or "").strip() or self._latest
141
+ with self._lock:
142
+ job = self._jobs.get(selected)
143
+ if job is None:
144
+ return {"ok": True, "job": None}
145
+ return_code = job.process.poll()
146
+ try:
147
+ output = job.log_path.read_text(encoding="utf-8", errors="replace")[-16000:]
148
+ except OSError:
149
+ output = ""
150
+ redacted_output = self._redact(output, job.redactions)
151
+ return {
152
+ "ok": return_code in {None, 0},
153
+ "job": {
154
+ "id": job.job_id,
155
+ "action": job.action,
156
+ "profile": job.profile,
157
+ "running": return_code is None,
158
+ "return_code": return_code,
159
+ "output": redacted_output,
160
+ },
161
+ }
162
+
163
+
164
+ _SCRIPT_PATH = Path(__file__).resolve().parents[1] / "scripts" / "deploy_colab_speech.ps1"
165
+ _MANAGER = ColabSpeechJobManager(_SCRIPT_PATH, CONFIG_DIR / "colab-jobs")
166
+
167
+
168
+ def launch_colab_speech_job(action: str, settings: dict[str, Any], secrets: dict[str, str] | None = None) -> dict[str, Any]:
169
+ return _MANAGER.launch(action, settings, secrets)
170
+
171
+
172
+ def colab_speech_job_status(job_id: str = "") -> dict[str, Any]:
173
+ return _MANAGER.status(job_id)
174
+
175
+
176
+ __all__ = ["ColabSpeechJobManager", "colab_speech_job_status", "launch_colab_speech_job"]
@@ -32,6 +32,7 @@ def build_default_config(provider_defaults: dict[str, Any]) -> dict[str, Any]:
32
32
  "enabled": True,
33
33
  "distribution": "Ubuntu-26.04",
34
34
  "auth": "adc",
35
+ "profile": "default",
35
36
  "asr_session": "ciel-asr",
36
37
  "tts_session": "ciel-tts",
37
38
  "asr_accelerator": "T4",
@@ -93,7 +93,7 @@ OFFICIAL_CHANNEL_PLUGINS = {
93
93
  }
94
94
 
95
95
  APP_NAME = "Ciel Runtime"
96
- VERSION = "0.2.11"
96
+ VERSION = "0.2.12"
97
97
  CREDITS = "Credits: One Ciel LLC"
98
98
  PRELAUNCH_CANCEL = 10
99
99
  PRELAUNCH_LAUNCH_CODEX = 11
@@ -24,6 +24,8 @@ class SpeechHttpPorts:
24
24
  write_json: Callable[..., None]
25
25
  log: Callable[[str, str], None]
26
26
  urlopen: Callable[..., Any] = urllib.request.urlopen
27
+ colab_action: Callable[[str, dict[str, Any], dict[str, str]], dict[str, Any]] | None = None
28
+ colab_status: Callable[[str], dict[str, Any]] | None = None
27
29
 
28
30
 
29
31
  @dataclass(frozen=True, slots=True)
@@ -37,6 +39,10 @@ class SpeechHttpController:
37
39
  if path == "/ca/speech/health":
38
40
  self.ports.write_json(handler, self.health_payload())
39
41
  return True
42
+ if path == "/ca/speech/colab/job":
43
+ payload = self.ports.colab_status("") if self.ports.colab_status else {"ok": True, "job": None}
44
+ self.ports.write_json(handler, payload)
45
+ return True
40
46
  if path == "/ca/web/chat/api":
41
47
  self.ports.write_json(handler, self.discovery_payload())
42
48
  return True
@@ -53,6 +59,8 @@ class SpeechHttpController:
53
59
  ) -> bool:
54
60
  if path == "/ca/speech/config":
55
61
  return self._save_public_config(handler, raw)
62
+ if path == "/ca/speech/colab/action":
63
+ return self._start_colab_action(handler, raw)
56
64
  if path in {"/v1/audio/transcriptions", "/v1/audio/translations"}:
57
65
  return self._proxy_asr(handler, raw, content_type)
58
66
  if path in {"/v1/audio/speech", "/v1/audio/speech/batch"}:
@@ -90,6 +98,8 @@ class SpeechHttpController:
90
98
  "chat_files": "POST /ca/channel/files",
91
99
  "speech_config": "GET|POST /ca/speech/config",
92
100
  "speech_health": "GET /ca/speech/health",
101
+ "colab_action": "POST /ca/speech/colab/action",
102
+ "colab_job": "GET /ca/speech/colab/job",
93
103
  "asr": "POST /v1/audio/transcriptions",
94
104
  "asr_translate": "POST /v1/audio/translations",
95
105
  "tts": "POST /v1/audio/speech",
@@ -164,6 +174,32 @@ class SpeechHttpController:
164
174
  self.ports.write_json(handler, {"ok": False, "error": str(exc)}, 400)
165
175
  return True
166
176
 
177
+ def _start_colab_action(self, handler: BaseHTTPRequestHandler, raw: bytes) -> bool:
178
+ try:
179
+ if self.ports.colab_action is None:
180
+ raise RuntimeError("Colab deployment jobs are unavailable")
181
+ body = json.loads(raw.decode("utf-8") if raw else "{}")
182
+ if not isinstance(body, dict):
183
+ raise ValueError("request must be a JSON object")
184
+ action = str(body.get("action") or "").strip().lower()
185
+ config = self._speech_config()
186
+ colab = config.get("colab") if isinstance(config.get("colab"), dict) else {}
187
+ if colab.get("enabled") is False:
188
+ raise ValueError("Colab worker management is disabled")
189
+ secrets_payload = body.get("secrets") if isinstance(body.get("secrets"), dict) else {}
190
+ secrets = {
191
+ "tailscale_auth_key": str(secrets_payload.get("tailscale_auth_key") or ""),
192
+ "speech_api_key": str(secrets_payload.get("speech_api_key") or ""),
193
+ "reset_authentication": "1" if body.get("reset_authentication") is True else "",
194
+ }
195
+ result = self.ports.colab_action(action, dict(colab), secrets)
196
+ self.ports.write_json(handler, result)
197
+ except (UnicodeError, json.JSONDecodeError, ValueError, TypeError) as exc:
198
+ self.ports.write_json(handler, {"ok": False, "error": str(exc)}, 400)
199
+ except RuntimeError as exc:
200
+ self.ports.write_json(handler, {"ok": False, "error": str(exc)}, 409)
201
+ return True
202
+
167
203
  @staticmethod
168
204
  def _validated_value(service: str, key: str, value: Any) -> Any:
169
205
  allowed = {
@@ -215,6 +251,7 @@ class SpeechHttpController:
215
251
  "enabled",
216
252
  "distribution",
217
253
  "auth",
254
+ "profile",
218
255
  "asr_session",
219
256
  "tts_session",
220
257
  "asr_accelerator",
@@ -254,12 +254,18 @@ def render_web_chat_page(
254
254
  <label class="check"><input id="colabEnabled" type="checkbox"> Manage workers with Colab CLI</label>
255
255
  <label>WSL distribution<input id="colabDistribution" placeholder="Ubuntu-26.04"></label>
256
256
  <label>Authentication<select id="colabAuth"><option value="adc">ADC</option><option value="oauth2">OAuth2</option></select></label>
257
+ <label>Account profile<input id="colabProfile" placeholder="default"></label>
257
258
  <label>ASR session<input id="colabAsrSession" placeholder="ciel-asr"></label>
258
259
  <label>TTS session<input id="colabTtsSession" placeholder="ciel-tts"></label>
259
260
  <label>ASR GPU<select id="colabAsrAccelerator"><option>T4</option><option>L4</option><option>G4</option><option>A100</option><option>H100</option></select></label>
260
261
  <label>TTS GPU<select id="colabTtsAccelerator"><option>T4</option><option>L4</option><option>G4</option><option>A100</option><option>H100</option></select></label>
262
+ <label class="wide">Tailscale auth key for this run<input id="colabTailscaleAuthKey" type="password" autocomplete="new-password" placeholder="Not saved; Colab Secret may be used instead"></label>
263
+ <label class="wide">Speech API key for this run<input id="colabSpeechApiKey" type="password" autocomplete="new-password" placeholder="Not saved; optional"></label>
264
+ <label class="check wide"><input id="colabResetAuthentication" type="checkbox"> Forget this profile's current login before generating a login command</label>
261
265
  </div>
262
- <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>
266
+ <div class="settings-actions"><button class="ghost" id="colabLoginButton" type="button">Copy login command</button><button class="ghost" id="colabStatusButton" type="button">Check sessions</button><button class="ghost" id="colabStartButton" type="button">Start missing</button><button class="primary" id="colabDeployButton" type="button">Recover &amp; deploy</button><button class="ghost" id="colabRecreateButton" type="button">Recreate all</button></div>
267
+ <pre class="hint" id="colabJobStatus">No Colab deployment job has been started.</pre>
268
+ <div class="hint">Named account profiles get an isolated WSL HOME, OAuth token, session state, and history; <code>default</code> keeps the existing Colab CLI login for compatibility. Login remains an interactive CLI step; deployment jobs run in the background. Ephemeral keys above are passed only to that job and are never saved by Ciel.</div>
263
269
  </section>
264
270
  <section class="settings-section">
265
271
  <h3>Tailscale tunnel</h3>
@@ -747,10 +753,14 @@ def render_web_chat_page(
747
753
  document.getElementById('colabEnabled').checked = colab.enabled !== false;
748
754
  document.getElementById('colabDistribution').value = colab.distribution || 'Ubuntu-26.04';
749
755
  document.getElementById('colabAuth').value = colab.auth || 'adc';
756
+ document.getElementById('colabProfile').value = colab.profile || 'default';
750
757
  document.getElementById('colabAsrSession').value = colab.asr_session || 'ciel-asr';
751
758
  document.getElementById('colabTtsSession').value = colab.tts_session || 'ciel-tts';
752
759
  document.getElementById('colabAsrAccelerator').value = colab.asr_accelerator || 'T4';
753
760
  document.getElementById('colabTtsAccelerator').value = colab.tts_accelerator || 'T4';
761
+ document.getElementById('colabTailscaleAuthKey').value = '';
762
+ document.getElementById('colabSpeechApiKey').value = '';
763
+ document.getElementById('colabResetAuthentication').checked = false;
754
764
  document.getElementById('tailscaleEnabled').checked = tailscale.enabled !== false;
755
765
  document.getElementById('tailscaleAsrHostname').value = tailscale.asr_hostname || 'ciel-asr';
756
766
  document.getElementById('tailscaleTtsHostname').value = tailscale.tts_hostname || 'ciel-tts';
@@ -793,6 +803,7 @@ def render_web_chat_page(
793
803
  enabled: document.getElementById('colabEnabled').checked,
794
804
  distribution: document.getElementById('colabDistribution').value,
795
805
  auth: document.getElementById('colabAuth').value,
806
+ profile: document.getElementById('colabProfile').value,
796
807
  asr_session: document.getElementById('colabAsrSession').value,
797
808
  tts_session: document.getElementById('colabTtsSession').value,
798
809
  asr_accelerator: document.getElementById('colabAsrAccelerator').value,
@@ -811,6 +822,48 @@ def render_web_chat_page(
811
822
  setSpeechForm(data);
812
823
  return data;
813
824
  }}
825
+ function renderColabJob(payload) {{
826
+ const output = document.getElementById('colabJobStatus');
827
+ const job = payload && payload.job;
828
+ if (!job) {{ output.textContent = 'No Colab deployment job has been started.'; return; }}
829
+ const state = job.running ? 'running' : job.return_code === 0 ? 'completed' : `failed (${{job.return_code}})`;
830
+ output.textContent = `${{job.action}} · profile ${{job.profile}} · ${{state}}\n${{job.output || ''}}`.trim();
831
+ output.scrollTop = output.scrollHeight;
832
+ }}
833
+ async function pollColabJob() {{
834
+ const response = await fetch('/ca/speech/colab/job', {{headers: {{'accept': 'application/json'}}, cache: 'no-store'}});
835
+ const data = await response.json();
836
+ renderColabJob(data);
837
+ if (data.job && data.job.running) setTimeout(() => pollColabJob().catch(() => {{}}), 2000);
838
+ else if (data.job) setState(data.job.return_code === 0 ? 'Colab job complete' : 'Colab job failed', data.job.return_code === 0 ? 'ok' : 'error');
839
+ return data;
840
+ }}
841
+ async function runColabAction(action) {{
842
+ await saveSpeechConfig();
843
+ const payload = {{
844
+ action,
845
+ reset_authentication: document.getElementById('colabResetAuthentication').checked,
846
+ secrets: {{
847
+ tailscale_auth_key: document.getElementById('colabTailscaleAuthKey').value,
848
+ speech_api_key: document.getElementById('colabSpeechApiKey').value,
849
+ }},
850
+ }};
851
+ const response = await fetch('/ca/speech/colab/action', {{method: 'POST', headers: {{'content-type': 'application/json', 'accept': 'application/json'}}, body: JSON.stringify(payload)}});
852
+ const data = await response.json();
853
+ document.getElementById('colabTailscaleAuthKey').value = '';
854
+ document.getElementById('colabSpeechApiKey').value = '';
855
+ if (!response.ok || !data.ok) throw new Error(data.error || `HTTP ${{response.status}}`);
856
+ if (data.requires_terminal) {{
857
+ await navigator.clipboard.writeText(data.command);
858
+ document.getElementById('colabJobStatus').textContent = 'Login command copied. Run it in a local terminal and complete authentication for the selected Google account.\\n\\n' + data.command;
859
+ setState('login command copied', 'ok');
860
+ return data;
861
+ }}
862
+ renderColabJob(data);
863
+ setState(`Colab ${{action}} started`, 'ok');
864
+ setTimeout(() => pollColabJob().catch(() => {{}}), 1000);
865
+ return data;
866
+ }}
814
867
  function stopActiveSpeech() {{
815
868
  if (speechGenerationController) speechGenerationController.abort();
816
869
  speechGenerationController = null;
@@ -1276,6 +1329,14 @@ def render_web_chat_page(
1276
1329
  speechSettingsDialog.showModal();
1277
1330
  }});
1278
1331
  speechSettingsClose.addEventListener('click', () => speechSettingsDialog.close());
1332
+ document.getElementById('colabLoginButton').addEventListener('click', () => runColabAction('login').catch(err => addBubble('system', 'Colab login command failed: ' + String(err && err.message ? err.message : err))));
1333
+ document.getElementById('colabStatusButton').addEventListener('click', () => runColabAction('status').catch(err => addBubble('system', 'Colab status failed: ' + String(err && err.message ? err.message : err))));
1334
+ document.getElementById('colabStartButton').addEventListener('click', () => runColabAction('start').catch(err => addBubble('system', 'Colab start failed: ' + String(err && err.message ? err.message : err))));
1335
+ document.getElementById('colabDeployButton').addEventListener('click', () => runColabAction('deploy').catch(err => addBubble('system', 'Colab deployment failed: ' + String(err && err.message ? err.message : err))));
1336
+ document.getElementById('colabRecreateButton').addEventListener('click', () => {{
1337
+ if (!confirm('Release both sessions in this account profile, create new instances, and redeploy ASR/TTS?')) return;
1338
+ runColabAction('recreate').catch(err => addBubble('system', 'Colab recreation failed: ' + String(err && err.message ? err.message : err)));
1339
+ }});
1279
1340
  document.getElementById('ttsReferenceAudio').addEventListener('change', async event => {{
1280
1341
  const file = event.target.files && event.target.files[0];
1281
1342
  if (!file) return;
@@ -20,6 +20,24 @@ Set the WSL distribution, authentication mode, ASR/TTS session names, and accele
20
20
 
21
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
22
 
23
+ ### Session recovery and account profiles
24
+
25
+ Web Chat > Speech Settings exposes **Check sessions**, **Start missing**, **Recover & deploy**, and **Recreate all**. Start creates only missing or expired ASR/TTS sessions. Recover creates missing sessions and then runs both bootstrap scripts. Recreate explicitly releases both sessions before creating and deploying replacements. These actions are also available through `POST /ca/speech/colab/action`, while `GET /ca/speech/colab/job` returns the latest background-job state and redacted output.
26
+
27
+ The `default` account profile reuses the existing WSL Colab CLI login. Every other profile name receives an isolated WSL `HOME`, so its OAuth token, ADC credentials, session state, and history cannot mix with another Google account. Choose OAuth2 for the simplest multi-account flow, click **Copy login command**, run that command in a local terminal, and complete the copy/paste authorization prompt with the intended Google account. The optional reset checkbox removes credentials only inside the selected profile before login.
28
+
29
+ Equivalent CLI actions are:
30
+
31
+ ```powershell
32
+ .\scripts\deploy_colab_speech.ps1 -Action Login -Profile second-account -ColabAuth oauth2
33
+ .\scripts\deploy_colab_speech.ps1 -Action Status -Profile second-account
34
+ .\scripts\deploy_colab_speech.ps1 -Action Start -Profile second-account
35
+ .\scripts\deploy_colab_speech.ps1 -Action Deploy -Profile second-account
36
+ .\scripts\deploy_colab_speech.ps1 -Action Recreate -Profile second-account
37
+ ```
38
+
39
+ Tailscale and speech API keys entered in Web Chat are passed only to the selected background deployment process and are not persisted. They can instead be stored as authorized Colab Secrets for the selected Google account.
40
+
23
41
  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`.
24
42
 
25
43
  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.
@@ -40,6 +58,8 @@ Web backend ownership is scoped to the normalized workspace and router port. A s
40
58
 
41
59
  - `GET|POST /ca/speech/config`
42
60
  - `GET /ca/speech/health`
61
+ - `POST /ca/speech/colab/action`
62
+ - `GET /ca/speech/colab/job`
43
63
  - `POST /v1/audio/transcriptions`
44
64
  - `POST /v1/audio/translations`
45
65
  - `POST /v1/audio/speech`
package/install.ps1 CHANGED
@@ -12,6 +12,11 @@ if (Test-Path $supportDir) {
12
12
  Remove-Item -Recurse -Force $supportDir
13
13
  }
14
14
  Copy-Item -Recurse -Force "ciel_runtime_support" $supportDir
15
+ $scriptsDir = Join-Path $shareDir "scripts"
16
+ if (Test-Path $scriptsDir) {
17
+ Remove-Item -Recurse -Force $scriptsDir
18
+ }
19
+ Copy-Item -Recurse -Force "scripts" $scriptsDir
15
20
  Copy-Item -Force "ciel-runtime-menu.py" (Join-Path $binDir "ciel-runtime-menu.py")
16
21
  Copy-Item -Force "ciel-runtime-tool-guard.py" (Join-Path $binDir "ciel-runtime-tool-guard.py")
17
22
  Copy-Item -Force "ciel-runtime" (Join-Path $binDir "ciel-runtime")
package/install.sh CHANGED
@@ -11,6 +11,9 @@ install -m 755 ciel_runtime.py "$SHARE_DIR/ciel_runtime.py"
11
11
  rm -rf "$SHARE_DIR/ciel_runtime_support"
12
12
  mkdir -p "$SHARE_DIR/ciel_runtime_support"
13
13
  cp -R ciel_runtime_support/. "$SHARE_DIR/ciel_runtime_support/"
14
+ rm -rf "$SHARE_DIR/scripts"
15
+ mkdir -p "$SHARE_DIR/scripts"
16
+ cp -R scripts/. "$SHARE_DIR/scripts/"
14
17
  install -m 755 ciel-runtime-menu.py "$BIN_DIR/ciel-runtime-menu"
15
18
  install -m 755 ciel-runtime-tool-guard.py "$BIN_DIR/ciel-runtime-tool-guard"
16
19
  install -m 755 ciel-runtime "$BIN_DIR/ciel-runtime"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oneciel-ai/ciel-runtime",
3
- "version": "0.2.11",
3
+ "version": "0.2.12",
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",
@@ -16,6 +16,7 @@ DEFAULT_COLAB_SETTINGS: dict[str, Any] = {
16
16
  "enabled": True,
17
17
  "distribution": "Ubuntu-26.04",
18
18
  "auth": "adc",
19
+ "profile": "default",
19
20
  "asr_session": "ciel-asr",
20
21
  "tts_session": "ciel-tts",
21
22
  "asr_accelerator": "T4",
@@ -39,6 +40,7 @@ def configure(
39
40
  *,
40
41
  distribution: str | None = None,
41
42
  auth: str | None = None,
43
+ profile: str | None = None,
42
44
  asr_session: str | None = None,
43
45
  tts_session: str | None = None,
44
46
  asr_accelerator: str | None = None,
@@ -54,6 +56,7 @@ def configure(
54
56
  overrides = {
55
57
  "distribution": distribution,
56
58
  "auth": auth,
59
+ "profile": profile,
57
60
  "asr_session": asr_session,
58
61
  "tts_session": tts_session,
59
62
  "asr_accelerator": asr_accelerator,
@@ -77,6 +80,7 @@ def main() -> int:
77
80
  parser.add_argument("--tts-reference-audio", default=DEFAULT_TTS_REFERENCE_AUDIO)
78
81
  parser.add_argument("--distribution")
79
82
  parser.add_argument("--auth", choices=("adc", "oauth2"))
83
+ parser.add_argument("--profile")
80
84
  parser.add_argument("--asr-session")
81
85
  parser.add_argument("--tts-session")
82
86
  parser.add_argument("--asr-accelerator")
@@ -94,6 +98,7 @@ def main() -> int:
94
98
  args.tts_reference_audio,
95
99
  distribution=args.distribution,
96
100
  auth=args.auth,
101
+ profile=args.profile,
97
102
  asr_session=args.asr_session,
98
103
  tts_session=args.tts_session,
99
104
  asr_accelerator=args.asr_accelerator,
@@ -1,4 +1,8 @@
1
1
  param(
2
+ [ValidateSet('Login', 'Status', 'Start', 'Deploy', 'Recreate')]
3
+ [string]$Action = 'Deploy',
4
+ [string]$Profile,
5
+ [switch]$ResetAuthentication,
2
6
  [string]$Distribution,
3
7
  [string]$ColabAuth,
4
8
  [string]$AsrSession,
@@ -15,12 +19,15 @@ $settings = $settingsJson | ConvertFrom-Json
15
19
  if ($settings.enabled -eq $false) { throw "Colab worker management is disabled in Web Chat > Speech Settings." }
16
20
  if ([string]::IsNullOrWhiteSpace($Distribution)) { $Distribution = [string]$settings.distribution }
17
21
  if ([string]::IsNullOrWhiteSpace($ColabAuth)) { $ColabAuth = [string]$settings.auth }
22
+ if ([string]::IsNullOrWhiteSpace($Profile)) { $Profile = [string]$settings.profile }
23
+ if ([string]::IsNullOrWhiteSpace($Profile)) { $Profile = "default" }
18
24
  if ([string]::IsNullOrWhiteSpace($AsrSession)) { $AsrSession = [string]$settings.asr_session }
19
25
  if ([string]::IsNullOrWhiteSpace($TtsSession)) { $TtsSession = [string]$settings.tts_session }
20
26
  if ([string]::IsNullOrWhiteSpace($AsrAccelerator)) { $AsrAccelerator = [string]$settings.asr_accelerator }
21
27
  if ([string]::IsNullOrWhiteSpace($TtsAccelerator)) { $TtsAccelerator = [string]$settings.tts_accelerator }
22
28
  if ($Distribution -notmatch '^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$') { throw "Invalid WSL distribution name." }
23
29
  if ($ColabAuth -notin @('adc', 'oauth2')) { throw "ColabAuth must be adc or oauth2." }
30
+ if ($Profile -notmatch '^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$') { throw "Invalid Colab account profile name." }
24
31
  foreach ($session in @($AsrSession, $TtsSession)) {
25
32
  if ($session -notmatch '^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$') { throw "Invalid Colab session name: $session" }
26
33
  }
@@ -29,42 +36,98 @@ foreach ($accelerator in @($AsrAccelerator, $TtsAccelerator)) {
29
36
  }
30
37
  $wslRepo = (& wsl -d $Distribution -- wslpath -a ($repo -replace '\\', '/')).Trim()
31
38
  if (-not $wslRepo) { throw "Could not resolve the repository path in WSL." }
32
- $bootstrapEnv = ""
39
+ $wslHome = (& wsl -d $Distribution -- bash -lc 'printf %s "$HOME"').Trim()
40
+ $colabExecutable = (& wsl -d $Distribution -- bash -lc 'command -v colab').Trim()
41
+ if (-not $wslHome -or -not $colabExecutable) { throw "Could not locate the WSL home directory or Colab CLI." }
42
+ $profileHome = if ($Profile -eq 'default') { $wslHome } else { "$wslHome/.config/ciel-runtime/colab-profiles/$Profile" }
43
+ & wsl -d $Distribution -- mkdir -p $profileHome
44
+ if ($LASTEXITCODE -ne 0) { throw "Could not create the Colab account profile directory." }
45
+
46
+ function Invoke-Colab([string[]]$Arguments) {
47
+ & wsl -d $Distribution -- env "HOME=$profileHome" $colabExecutable --auth $ColabAuth @Arguments
48
+ }
49
+
50
+ if ($Action -eq 'Login') {
51
+ if ($ResetAuthentication) {
52
+ $tokenPath = "$profileHome/.config/colab-cli/token.json"
53
+ $adcPath = "$profileHome/.config/gcloud/application_default_credentials.json"
54
+ & wsl -d $Distribution -- rm -f $tokenPath $adcPath
55
+ if ($LASTEXITCODE -ne 0) { throw "Could not reset authentication for profile '$Profile'." }
56
+ }
57
+ Write-Host "Authenticating isolated Colab account profile: $Profile ($ColabAuth)"
58
+ if ($ColabAuth -eq 'adc') {
59
+ $scopes = 'openid,https://www.googleapis.com/auth/cloud-platform,https://www.googleapis.com/auth/userinfo.email,https://www.googleapis.com/auth/colaboratory'
60
+ & wsl -d $Distribution -- env "HOME=$profileHome" gcloud auth application-default login --scopes=$scopes
61
+ } else {
62
+ Invoke-Colab @('sessions')
63
+ }
64
+ if ($LASTEXITCODE -ne 0) { throw "Colab authentication failed for profile '$Profile'." }
65
+ Write-Host "Colab profile '$Profile' is authenticated."
66
+ exit 0
67
+ }
68
+
69
+ if ($Action -eq 'Status') {
70
+ Write-Host "Colab sessions for isolated account profile: $Profile"
71
+ Invoke-Colab @('sessions')
72
+ if ($LASTEXITCODE -ne 0) { throw "Could not read Colab sessions for profile '$Profile'. Run the Login action first." }
73
+ exit 0
74
+ }
33
75
  if ($env:TAILSCALE_AUTHKEY) {
34
76
  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
77
  }
37
78
  if ($env:CIEL_SPEECH_API_KEY) {
38
79
  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
80
  }
41
81
 
42
82
  Write-Host "Checking Colab CLI authentication..."
43
- & wsl -d $Distribution -- bash -lc "colab --auth $ColabAuth status >/dev/null"
83
+ Invoke-Colab @('sessions') | Out-Null
44
84
  if ($LASTEXITCODE -ne 0) {
45
- throw "Colab CLI is not authenticated with '$ColabAuth' in WSL '$Distribution'."
85
+ throw "Colab profile '$Profile' is not authenticated with '$ColabAuth' in WSL '$Distribution'. Run -Action Login -Profile '$Profile' first."
46
86
  }
47
87
 
48
88
  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"
89
+ Invoke-Colab @('status', '--session', $Session) *> $null
50
90
  if ($LASTEXITCODE -eq 0) {
51
91
  Write-Host "Reusing $Role $Accelerator session: $Session"
52
92
  return
53
93
  }
54
94
  Write-Host "Creating $Role $Accelerator session: $Session"
55
- & wsl -d $Distribution -- bash -lc "colab --auth $ColabAuth new --gpu $Accelerator --session '$Session'"
95
+ Invoke-Colab @('new', '--gpu', $Accelerator, '--session', $Session)
56
96
  if ($LASTEXITCODE -ne 0) { throw "Could not create $Role Colab session." }
57
97
  }
58
98
 
99
+ function Stop-ColabSession([string]$Session, [string]$Role) {
100
+ Write-Host "Releasing existing $Role session if present: $Session"
101
+ Invoke-Colab @('stop', '--session', $Session) *> $null
102
+ }
103
+
104
+ if ($Action -eq 'Recreate') {
105
+ Stop-ColabSession $AsrSession "ASR"
106
+ Stop-ColabSession $TtsSession "TTS"
107
+ }
108
+
59
109
  Ensure-ColabSession $AsrSession $AsrAccelerator "ASR"
60
110
  Ensure-ColabSession $TtsSession $TtsAccelerator "TTS"
61
111
 
112
+ if ($Action -eq 'Start') {
113
+ Write-Host "Colab sessions are allocated for profile '$Profile'. Run -Action Deploy to install and connect the workers."
114
+ exit 0
115
+ }
116
+
62
117
  Write-Host "Installing Qwen3-ASR and its Tailscale service..."
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"
118
+ $asrArguments = @('exec', '--session', $AsrSession)
119
+ if ($env:TAILSCALE_AUTHKEY) { $asrArguments += @('--env', "TAILSCALE_AUTHKEY=$($env:TAILSCALE_AUTHKEY)") }
120
+ if ($env:CIEL_SPEECH_API_KEY) { $asrArguments += @('--env', "CIEL_SPEECH_API_KEY=$($env:CIEL_SPEECH_API_KEY)") }
121
+ $asrArguments += @('--file', "$wslRepo/scripts/colab/bootstrap_qwen_asr.py")
122
+ $asrOutput = (Invoke-Colab $asrArguments 2>&1 | Tee-Object -Variable asrDisplay) -join "`n"
64
123
  if ($LASTEXITCODE -ne 0) { throw "ASR bootstrap failed." }
65
124
 
66
125
  Write-Host "Installing MOSS-TTS-Nano and its Tailscale service..."
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"
126
+ $ttsArguments = @('exec', '--session', $TtsSession)
127
+ if ($env:TAILSCALE_AUTHKEY) { $ttsArguments += @('--env', "TAILSCALE_AUTHKEY=$($env:TAILSCALE_AUTHKEY)") }
128
+ if ($env:CIEL_SPEECH_API_KEY) { $ttsArguments += @('--env', "CIEL_SPEECH_API_KEY=$($env:CIEL_SPEECH_API_KEY)") }
129
+ $ttsArguments += @('--file', "$wslRepo/scripts/colab/bootstrap_moss_tts.py")
130
+ $ttsOutput = (Invoke-Colab $ttsArguments 2>&1 | Tee-Object -Variable ttsDisplay) -join "`n"
68
131
  if ($LASTEXITCODE -ne 0) { throw "TTS bootstrap failed." }
69
132
 
70
133
  function Read-BootstrapResult([string]$Text, [string]$Role) {
@@ -77,7 +140,7 @@ function Read-BootstrapResult([string]$Text, [string]$Role) {
77
140
 
78
141
  $asr = Read-BootstrapResult $asrOutput "asr"
79
142
  $tts = Read-BootstrapResult $ttsOutput "tts"
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
143
+ & python (Join-Path $PSScriptRoot "configure_speech_workers.py") --asr-base-url $asr.base_url --tts-base-url $tts.base_url --distribution $Distribution --auth $ColabAuth --profile $Profile --asr-session $AsrSession --tts-session $TtsSession --asr-accelerator $AsrAccelerator --tts-accelerator $TtsAccelerator
81
144
  if ($LASTEXITCODE -ne 0) { throw "Workers started, but Ciel speech configuration failed." }
82
145
 
83
146
  Write-Host "Both services are running and connected to Web Chat > Speech Settings."