@oneciel-ai/ciel-runtime 0.2.2 → 0.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ciel_runtime.py +2555 -9635
- package/ciel_runtime_support/advisor_request_builder.py +8 -21
- package/ciel_runtime_support/anthropic_tool_turns.py +13 -8
- package/ciel_runtime_support/architecture.py +68 -0
- package/ciel_runtime_support/architecture_budget.py +1 -1
- package/ciel_runtime_support/channel_connection_context.py +233 -0
- package/ciel_runtime_support/channel_delivery_context.py +332 -0
- package/ciel_runtime_support/channel_mcp_context.py +313 -0
- package/ciel_runtime_support/channel_mcp_discovery.py +47 -0
- package/ciel_runtime_support/channel_mcp_transport.py +5 -1
- package/ciel_runtime_support/channel_message_context.py +212 -0
- package/ciel_runtime_support/channel_message_repository.py +14 -3
- package/ciel_runtime_support/channel_pending_injection.py +9 -0
- package/ciel_runtime_support/channel_probe_launch_context.py +213 -0
- package/ciel_runtime_support/channel_replay_policy.py +38 -0
- package/ciel_runtime_support/channel_runtime_environment.py +8 -0
- package/ciel_runtime_support/channel_session_context.py +236 -0
- package/ciel_runtime_support/channel_terminal_context.py +350 -0
- package/ciel_runtime_support/channel_wake_context.py +532 -0
- package/ciel_runtime_support/claude_environment.py +60 -0
- package/ciel_runtime_support/claude_launch_assembly.py +249 -0
- package/ciel_runtime_support/claude_router.py +62 -12
- package/ciel_runtime_support/cli_application_context.py +132 -0
- package/ciel_runtime_support/cli_assembly.py +50 -0
- package/ciel_runtime_support/codex_backend_context.py +363 -0
- package/ciel_runtime_support/codex_config.py +13 -1
- package/ciel_runtime_support/codex_launch_assembly.py +213 -0
- package/ciel_runtime_support/codex_launch_configuration.py +30 -1
- package/ciel_runtime_support/codex_mcp_integration.py +90 -8
- package/ciel_runtime_support/codex_model_catalog.py +4 -1
- package/ciel_runtime_support/codex_reasoning_rejects.py +225 -0
- package/ciel_runtime_support/codex_router.py +38 -8
- package/ciel_runtime_support/codex_turn_recovery.py +154 -0
- package/ciel_runtime_support/config_migrations.py +103 -0
- package/ciel_runtime_support/config_repository.py +30 -0
- package/ciel_runtime_support/configuration_cli.py +38 -0
- package/ciel_runtime_support/context_compaction.py +9 -4
- package/ciel_runtime_support/credential_management.py +12 -0
- package/ciel_runtime_support/credentials.py +12 -0
- package/ciel_runtime_support/github_copilot_oauth.py +2 -2
- package/ciel_runtime_support/hosted_formula_tools.py +216 -0
- package/ciel_runtime_support/kimi_runtime_context.py +208 -0
- package/ciel_runtime_support/llm_preset_context.py +338 -0
- package/ciel_runtime_support/managed_mcp_config.py +8 -4
- package/ciel_runtime_support/mcp_configuration_context.py +291 -0
- package/ciel_runtime_support/mcp_http_proxy.py +14 -8
- package/ciel_runtime_support/mcp_probe_transport.py +47 -15
- package/ciel_runtime_support/mcp_transport.py +14 -1
- package/ciel_runtime_support/native_context_recovery.py +72 -0
- package/ciel_runtime_support/ollama_catalog_context.py +213 -0
- package/ciel_runtime_support/ollama_stream_collection.py +103 -0
- package/ciel_runtime_support/ollama_thinking.py +6 -1
- package/ciel_runtime_support/ollama_wire_projection.py +157 -0
- package/ciel_runtime_support/openai_forwarding.py +32 -10
- package/ciel_runtime_support/openai_responses_router.py +12 -0
- package/ciel_runtime_support/package_lifecycle.py +39 -0
- package/ciel_runtime_support/prelaunch_assembly.py +37 -0
- package/ciel_runtime_support/prelaunch_panel_context.py +418 -0
- package/ciel_runtime_support/prelaunch_shell_context.py +394 -0
- package/ciel_runtime_support/prompt_compaction.py +144 -0
- package/ciel_runtime_support/prompt_injection.py +45 -0
- package/ciel_runtime_support/protocols/anthropic_thinking_policy.py +1 -1
- package/ciel_runtime_support/protocols/chat_projection.py +85 -5
- package/ciel_runtime_support/protocols/conversation_turn_policy.py +43 -0
- package/ciel_runtime_support/protocols/ollama_chat.py +31 -0
- package/ciel_runtime_support/protocols/ollama_response.py +57 -5
- package/ciel_runtime_support/protocols/openai_reasoning.py +5 -2
- package/ciel_runtime_support/protocols/openai_responses.py +61 -15
- package/ciel_runtime_support/provider_adapters.py +26 -0
- package/ciel_runtime_support/provider_administration_context.py +207 -0
- package/ciel_runtime_support/provider_config_mutations.py +3 -0
- package/ciel_runtime_support/provider_model_catalog_context.py +137 -0
- package/ciel_runtime_support/provider_model_context.py +107 -0
- package/ciel_runtime_support/provider_model_metadata_context.py +197 -0
- package/ciel_runtime_support/provider_model_selection.py +10 -3
- package/ciel_runtime_support/provider_models.py +45 -2
- package/ciel_runtime_support/provider_option_cli.py +19 -0
- package/ciel_runtime_support/provider_policy.py +1 -1
- package/ciel_runtime_support/provider_readiness_context.py +189 -0
- package/ciel_runtime_support/provider_request_builder.py +64 -28
- package/ciel_runtime_support/provider_responses_passthrough.py +21 -2
- package/ciel_runtime_support/provider_timeout_policy.py +54 -0
- package/ciel_runtime_support/provider_tool_policy.py +9 -1
- package/ciel_runtime_support/providers/__init__.py +6 -0
- package/ciel_runtime_support/providers/alibaba.py +634 -0
- package/ciel_runtime_support/providers/catalog.py +24 -16
- package/ciel_runtime_support/providers/deepseek.py +73 -0
- package/ciel_runtime_support/providers/github_copilot_oauth.py +22 -1
- package/ciel_runtime_support/providers/kimi.py +69 -9
- package/ciel_runtime_support/providers/ollama.py +8 -0
- package/ciel_runtime_support/providers/ollama_context.py +21 -2
- package/ciel_runtime_support/providers/vllm.py +7 -1
- package/ciel_runtime_support/response_collection.py +68 -18
- package/ciel_runtime_support/response_collection_context.py +391 -0
- package/ciel_runtime_support/response_stream_context.py +555 -0
- package/ciel_runtime_support/responses_input_compatibility.py +121 -0
- package/ciel_runtime_support/responses_usage_observer.py +83 -0
- package/ciel_runtime_support/router_client_lifecycle.py +1 -0
- package/ciel_runtime_support/router_http.py +245 -3
- package/ciel_runtime_support/router_observability_context.py +251 -0
- package/ciel_runtime_support/router_process_context.py +200 -0
- package/ciel_runtime_support/router_process_lifecycle.py +2 -0
- package/ciel_runtime_support/router_request_assembly.py +399 -0
- package/ciel_runtime_support/router_request_context.py +215 -0
- package/ciel_runtime_support/router_server_context.py +84 -0
- package/ciel_runtime_support/runaway_output_guard.py +488 -0
- package/ciel_runtime_support/runtime_asset_assembly.py +147 -0
- package/ciel_runtime_support/runtime_asset_context.py +297 -0
- package/ciel_runtime_support/runtime_constants.py +16 -1
- package/ciel_runtime_support/runtime_launch.py +9 -5
- package/ciel_runtime_support/runtime_launch_context.py +130 -0
- package/ciel_runtime_support/runtime_maintenance_assembly.py +60 -0
- package/ciel_runtime_support/runtime_maintenance_context.py +309 -0
- package/ciel_runtime_support/runtime_maintenance_services.py +265 -0
- package/ciel_runtime_support/runtime_paths.py +60 -40
- package/ciel_runtime_support/runtime_primitives.py +78 -0
- package/ciel_runtime_support/speech_http_controller.py +335 -0
- package/ciel_runtime_support/sse_stream_collection.py +236 -0
- package/ciel_runtime_support/statusline_script.py +57 -8
- package/ciel_runtime_support/streaming_anthropic.py +361 -24
- package/ciel_runtime_support/tool_schema.py +40 -2
- package/ciel_runtime_support/tool_side_effect_dedupe.py +117 -12
- package/ciel_runtime_support/upstream_dump.py +68 -0
- package/ciel_runtime_support/upstream_retry_context.py +259 -0
- package/ciel_runtime_support/web_ui.py +248 -1
- package/ciel_runtime_support/workspace_router_selection.py +86 -0
- package/docs/COLAB_SPEECH.md +32 -0
- package/docs/Configuration.md +50 -0
- package/docs/Test-Suite.md +1 -0
- package/package.json +4 -1
- package/scripts/colab/__pycache__/bootstrap_moss_tts.cpython-311.pyc +0 -0
- package/scripts/colab/__pycache__/bootstrap_qwen_asr.cpython-311.pyc +0 -0
- package/scripts/colab/bootstrap_moss_tts.py +106 -0
- package/scripts/colab/bootstrap_qwen_asr.py +106 -0
- package/scripts/configure_speech_workers.py +37 -0
- package/scripts/deploy_colab_speech.ps1 +47 -0
|
@@ -112,6 +112,22 @@ def render_web_chat_page(
|
|
|
112
112
|
}}
|
|
113
113
|
.attach-button:hover {{ border-color: var(--accent); }}
|
|
114
114
|
.attach-button:disabled {{ opacity: .55; cursor: not-allowed; }}
|
|
115
|
+
.recording {{ border-color: #ef4444 !important; color: #fecaca !important; }}
|
|
116
|
+
.message-actions {{ display: flex; align-items: flex-start; padding: 4px; }}
|
|
117
|
+
.message-actions button {{ border: 1px solid var(--line); border-radius: 999px; background: #0b111b; color: var(--muted); cursor: pointer; padding: 4px 8px; }}
|
|
118
|
+
dialog {{ width: min(720px, calc(100vw - 28px)); max-height: calc(100vh - 28px); overflow: auto; border: 1px solid var(--line); border-radius: 10px; background: var(--panel); color: var(--text); padding: 0; }}
|
|
119
|
+
dialog::backdrop {{ background: rgba(0,0,0,.72); }}
|
|
120
|
+
.settings-head {{ position: sticky; top: 0; display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 14px 16px; border-bottom: 1px solid var(--line); background: var(--panel); }}
|
|
121
|
+
.settings-body {{ padding: 16px; display: grid; gap: 16px; }}
|
|
122
|
+
.settings-section {{ border: 1px solid var(--line); border-radius: 8px; padding: 12px; display: grid; gap: 10px; }}
|
|
123
|
+
.settings-section h3 {{ margin: 0; font-size: 14px; }}
|
|
124
|
+
.settings-grid {{ display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }}
|
|
125
|
+
.settings-grid label {{ display: grid; gap: 5px; color: var(--muted); font-size: 12px; }}
|
|
126
|
+
.settings-grid label.wide {{ grid-column: 1 / -1; }}
|
|
127
|
+
.settings-grid input, .settings-grid select {{ width: 100%; border: 1px solid var(--line); border-radius: 6px; background: #080d14; color: var(--text); padding: 8px; }}
|
|
128
|
+
.check {{ display: flex !important; grid-auto-flow: column; justify-content: start; align-items: center; gap: 7px !important; }}
|
|
129
|
+
.check input {{ width: auto; }}
|
|
130
|
+
.settings-actions {{ display: flex; justify-content: flex-end; gap: 8px; }}
|
|
115
131
|
#fileInput {{ display: none; }}
|
|
116
132
|
.attachment-tray {{ display: flex; gap: 7px; flex-wrap: wrap; min-height: 0; }}
|
|
117
133
|
.attachment-chip {{
|
|
@@ -135,6 +151,7 @@ def render_web_chat_page(
|
|
|
135
151
|
.bubble {{ max-width: 94%; }}
|
|
136
152
|
header {{ align-items: flex-start; flex-direction: column; }}
|
|
137
153
|
.pill {{ white-space: normal; }}
|
|
154
|
+
.settings-grid {{ grid-template-columns: 1fr; }}
|
|
138
155
|
}}
|
|
139
156
|
</style>
|
|
140
157
|
</head>
|
|
@@ -154,6 +171,8 @@ def render_web_chat_page(
|
|
|
154
171
|
<a href="/">Router Home</a>
|
|
155
172
|
<a href="/ca/events">Events</a>
|
|
156
173
|
<a href="/health">Health JSON</a>
|
|
174
|
+
<a href="/ca/web/chat/api">Chat API JSON</a>
|
|
175
|
+
<button class="ghost" id="speechSettingsButton" type="button">Speech Settings</button>
|
|
157
176
|
<button class="ghost" id="shareButton" type="button">Copy Chat Link</button>
|
|
158
177
|
<button class="ghost" id="clearButton" type="button">Clear Chat</button>
|
|
159
178
|
</div>
|
|
@@ -173,6 +192,7 @@ def render_web_chat_page(
|
|
|
173
192
|
<button class="primary" id="sendButton" type="submit">Send</button>
|
|
174
193
|
</div>
|
|
175
194
|
<div class="composer-actions">
|
|
195
|
+
<button class="attach-button" id="micButton" type="button">Start voice input</button>
|
|
176
196
|
<button class="attach-button" id="attachButton" type="button">Attach files</button>
|
|
177
197
|
<input id="fileInput" type="file" multiple>
|
|
178
198
|
<div class="attachment-tray" id="attachmentTray" aria-live="polite"></div>
|
|
@@ -181,6 +201,45 @@ def render_web_chat_page(
|
|
|
181
201
|
</form>
|
|
182
202
|
</main>
|
|
183
203
|
</div>
|
|
204
|
+
<dialog id="speechSettingsDialog">
|
|
205
|
+
<form id="speechSettingsForm">
|
|
206
|
+
<div class="settings-head"><strong>Speech Settings</strong><button class="ghost" id="speechSettingsClose" type="button">Close</button></div>
|
|
207
|
+
<div class="settings-body">
|
|
208
|
+
<section class="settings-section">
|
|
209
|
+
<h3>STT / Qwen ASR</h3>
|
|
210
|
+
<div class="settings-grid">
|
|
211
|
+
<label class="check"><input id="asrEnabled" type="checkbox"> Enable STT</label>
|
|
212
|
+
<label>Language<input id="asrLanguage" placeholder="auto"></label>
|
|
213
|
+
<label class="wide">Tailscale base URL<input id="asrBaseUrl" placeholder="http://ciel-asr:8000"></label>
|
|
214
|
+
<label class="wide">Model<input id="asrModel" placeholder="Qwen/Qwen3-ASR-0.6B"></label>
|
|
215
|
+
<label class="wide">Remote bearer token<input id="asrApiKey" type="password" autocomplete="new-password" placeholder="Leave blank to keep current token"></label>
|
|
216
|
+
</div>
|
|
217
|
+
</section>
|
|
218
|
+
<section class="settings-section">
|
|
219
|
+
<h3>TTS / MOSS-TTS-Nano</h3>
|
|
220
|
+
<div class="settings-grid">
|
|
221
|
+
<label class="check"><input id="ttsEnabled" type="checkbox"> Enable TTS</label>
|
|
222
|
+
<label class="check"><input id="ttsAutoSpeak" type="checkbox"> Speak replies automatically</label>
|
|
223
|
+
<label class="wide">Tailscale base URL<input id="ttsBaseUrl" placeholder="http://ciel-tts:8091"></label>
|
|
224
|
+
<label>Voice<input id="ttsVoice" placeholder="default"></label>
|
|
225
|
+
<label>Language<input id="ttsLanguage" placeholder="ko"></label>
|
|
226
|
+
<label class="wide">Model<input id="ttsModel" placeholder="OpenMOSS-Team/MOSS-TTS-Nano"></label>
|
|
227
|
+
<label class="wide">Remote bearer token<input id="ttsApiKey" type="password" autocomplete="new-password" placeholder="Leave blank to keep current token"></label>
|
|
228
|
+
</div>
|
|
229
|
+
</section>
|
|
230
|
+
<section class="settings-section">
|
|
231
|
+
<h3>Tailscale tunnel</h3>
|
|
232
|
+
<div class="settings-grid">
|
|
233
|
+
<label class="check"><input id="tailscaleEnabled" type="checkbox"> Use tailnet-only addresses</label>
|
|
234
|
+
<label>ASR hostname<input id="tailscaleAsrHostname" placeholder="ciel-asr"></label>
|
|
235
|
+
<label>TTS hostname<input id="tailscaleTtsHostname" placeholder="ciel-tts"></label>
|
|
236
|
+
</div>
|
|
237
|
+
<div class="hint">The browser calls Ciel locally. Only the Ciel router connects to these Tailscale services.</div>
|
|
238
|
+
</section>
|
|
239
|
+
<div class="settings-actions"><button class="ghost" id="speechHealthButton" type="button">Test connections</button><button class="primary" type="submit">Save</button></div>
|
|
240
|
+
</div>
|
|
241
|
+
</form>
|
|
242
|
+
</dialog>
|
|
184
243
|
<script>
|
|
185
244
|
const MODEL = {json.dumps(model)};
|
|
186
245
|
const transcript = document.getElementById('transcript');
|
|
@@ -188,10 +247,16 @@ def render_web_chat_page(
|
|
|
188
247
|
const prompt = document.getElementById('prompt');
|
|
189
248
|
const sendButton = document.getElementById('sendButton');
|
|
190
249
|
const attachButton = document.getElementById('attachButton');
|
|
250
|
+
const micButton = document.getElementById('micButton');
|
|
191
251
|
const fileInput = document.getElementById('fileInput');
|
|
192
252
|
const attachmentTray = document.getElementById('attachmentTray');
|
|
193
253
|
const shareButton = document.getElementById('shareButton');
|
|
194
254
|
const clearButton = document.getElementById('clearButton');
|
|
255
|
+
const speechSettingsButton = document.getElementById('speechSettingsButton');
|
|
256
|
+
const speechSettingsDialog = document.getElementById('speechSettingsDialog');
|
|
257
|
+
const speechSettingsForm = document.getElementById('speechSettingsForm');
|
|
258
|
+
const speechSettingsClose = document.getElementById('speechSettingsClose');
|
|
259
|
+
const speechHealthButton = document.getElementById('speechHealthButton');
|
|
195
260
|
const statePill = document.getElementById('statePill');
|
|
196
261
|
const SESSION_KEY = 'ciel-runtime-web-chat-session';
|
|
197
262
|
const LAST_ID_KEY = 'ciel-runtime-web-chat-last-id';
|
|
@@ -218,6 +283,10 @@ def render_web_chat_page(
|
|
|
218
283
|
let lastId = Number(localStorage.getItem(scopedLastIdKey) || '0') || 0;
|
|
219
284
|
let eventSource = null;
|
|
220
285
|
let selectedFiles = [];
|
|
286
|
+
let speechConfig = {{asr: {{enabled: false}}, tts: {{enabled: false, auto_speak: false}}}};
|
|
287
|
+
let mediaRecorder = null;
|
|
288
|
+
let mediaStream = null;
|
|
289
|
+
let recordingChunks = [];
|
|
221
290
|
function setState(text, cls = '') {{
|
|
222
291
|
statePill.textContent = text;
|
|
223
292
|
statePill.className = 'pill ' + cls;
|
|
@@ -401,6 +470,16 @@ def render_web_chat_page(
|
|
|
401
470
|
bubble.innerHTML = renderMarkdown(text);
|
|
402
471
|
}}
|
|
403
472
|
row.appendChild(bubble);
|
|
473
|
+
if (role === 'assistant') {{
|
|
474
|
+
const actions = document.createElement('div');
|
|
475
|
+
actions.className = 'message-actions';
|
|
476
|
+
const speak = document.createElement('button');
|
|
477
|
+
speak.type = 'button';
|
|
478
|
+
speak.textContent = 'Speak';
|
|
479
|
+
speak.addEventListener('click', () => speakText(text));
|
|
480
|
+
actions.appendChild(speak);
|
|
481
|
+
row.appendChild(actions);
|
|
482
|
+
}}
|
|
404
483
|
if (mode === 'prepend') {{
|
|
405
484
|
transcript.insertBefore(row, transcript.firstChild);
|
|
406
485
|
}} else {{
|
|
@@ -424,7 +503,10 @@ def render_web_chat_page(
|
|
|
424
503
|
const text = message.message || '';
|
|
425
504
|
if (!text.trim()) return;
|
|
426
505
|
addBubble(roleForMessage(message), text, mode, message.id);
|
|
427
|
-
if (mode !== 'prepend' && message.sender_id !== 'web-user')
|
|
506
|
+
if (mode !== 'prepend' && message.sender_id !== 'web-user') {{
|
|
507
|
+
setState('reply received', 'ok');
|
|
508
|
+
if (speechConfig.tts && speechConfig.tts.enabled && speechConfig.tts.auto_speak) speakText(text);
|
|
509
|
+
}}
|
|
428
510
|
}}
|
|
429
511
|
function formatBytes(bytes) {{
|
|
430
512
|
const value = Number(bytes || 0);
|
|
@@ -471,6 +553,134 @@ def render_web_chat_page(
|
|
|
471
553
|
reader.readAsDataURL(file);
|
|
472
554
|
}});
|
|
473
555
|
}}
|
|
556
|
+
function setSpeechForm(config) {{
|
|
557
|
+
const asr = config.asr || {{}};
|
|
558
|
+
const tts = config.tts || {{}};
|
|
559
|
+
const tailscale = config.tailscale || {{}};
|
|
560
|
+
document.getElementById('asrEnabled').checked = Boolean(asr.enabled);
|
|
561
|
+
document.getElementById('asrBaseUrl').value = asr.base_url || '';
|
|
562
|
+
document.getElementById('asrModel').value = asr.model || '';
|
|
563
|
+
document.getElementById('asrLanguage').value = asr.language || 'auto';
|
|
564
|
+
document.getElementById('asrApiKey').value = '';
|
|
565
|
+
document.getElementById('asrApiKey').placeholder = asr.api_key_set ? 'Token is set; leave blank to keep it' : 'Optional remote bearer token';
|
|
566
|
+
document.getElementById('ttsEnabled').checked = Boolean(tts.enabled);
|
|
567
|
+
document.getElementById('ttsAutoSpeak').checked = Boolean(tts.auto_speak);
|
|
568
|
+
document.getElementById('ttsBaseUrl').value = tts.base_url || '';
|
|
569
|
+
document.getElementById('ttsModel').value = tts.model || '';
|
|
570
|
+
document.getElementById('ttsVoice').value = tts.voice || 'default';
|
|
571
|
+
document.getElementById('ttsLanguage').value = tts.language || 'ko';
|
|
572
|
+
document.getElementById('ttsApiKey').value = '';
|
|
573
|
+
document.getElementById('ttsApiKey').placeholder = tts.api_key_set ? 'Token is set; leave blank to keep it' : 'Optional remote bearer token';
|
|
574
|
+
document.getElementById('tailscaleEnabled').checked = tailscale.enabled !== false;
|
|
575
|
+
document.getElementById('tailscaleAsrHostname').value = tailscale.asr_hostname || 'ciel-asr';
|
|
576
|
+
document.getElementById('tailscaleTtsHostname').value = tailscale.tts_hostname || 'ciel-tts';
|
|
577
|
+
micButton.disabled = !asr.enabled;
|
|
578
|
+
micButton.title = asr.enabled ? 'Record speech and transcribe it' : 'Enable STT in Speech Settings first';
|
|
579
|
+
}}
|
|
580
|
+
async function loadSpeechConfig() {{
|
|
581
|
+
const response = await fetch('/ca/speech/config', {{headers: {{'accept': 'application/json'}}}});
|
|
582
|
+
const data = await response.json();
|
|
583
|
+
if (!response.ok || !data.ok) throw new Error(data.error || `HTTP ${{response.status}}`);
|
|
584
|
+
speechConfig = data;
|
|
585
|
+
setSpeechForm(data);
|
|
586
|
+
return data;
|
|
587
|
+
}}
|
|
588
|
+
async function saveSpeechConfig() {{
|
|
589
|
+
const payload = {{
|
|
590
|
+
asr: {{
|
|
591
|
+
enabled: document.getElementById('asrEnabled').checked,
|
|
592
|
+
base_url: document.getElementById('asrBaseUrl').value,
|
|
593
|
+
model: document.getElementById('asrModel').value,
|
|
594
|
+
language: document.getElementById('asrLanguage').value,
|
|
595
|
+
api_key: document.getElementById('asrApiKey').value,
|
|
596
|
+
}},
|
|
597
|
+
tts: {{
|
|
598
|
+
enabled: document.getElementById('ttsEnabled').checked,
|
|
599
|
+
auto_speak: document.getElementById('ttsAutoSpeak').checked,
|
|
600
|
+
base_url: document.getElementById('ttsBaseUrl').value,
|
|
601
|
+
model: document.getElementById('ttsModel').value,
|
|
602
|
+
voice: document.getElementById('ttsVoice').value,
|
|
603
|
+
language: document.getElementById('ttsLanguage').value,
|
|
604
|
+
api_key: document.getElementById('ttsApiKey').value,
|
|
605
|
+
}},
|
|
606
|
+
tailscale: {{
|
|
607
|
+
enabled: document.getElementById('tailscaleEnabled').checked,
|
|
608
|
+
asr_hostname: document.getElementById('tailscaleAsrHostname').value,
|
|
609
|
+
tts_hostname: document.getElementById('tailscaleTtsHostname').value,
|
|
610
|
+
}},
|
|
611
|
+
}};
|
|
612
|
+
const response = await fetch('/ca/speech/config', {{method: 'POST', headers: {{'content-type': 'application/json', 'accept': 'application/json'}}, body: JSON.stringify(payload)}});
|
|
613
|
+
const data = await response.json();
|
|
614
|
+
if (!response.ok || !data.ok) throw new Error(data.error || `HTTP ${{response.status}}`);
|
|
615
|
+
speechConfig = data;
|
|
616
|
+
setSpeechForm(data);
|
|
617
|
+
return data;
|
|
618
|
+
}}
|
|
619
|
+
async function speakText(text) {{
|
|
620
|
+
if (!speechConfig.tts || !speechConfig.tts.enabled) {{
|
|
621
|
+
setState('TTS disabled', 'error');
|
|
622
|
+
return;
|
|
623
|
+
}}
|
|
624
|
+
try {{
|
|
625
|
+
setState('generating speech');
|
|
626
|
+
const response = await fetch('/v1/audio/speech', {{
|
|
627
|
+
method: 'POST',
|
|
628
|
+
headers: {{'content-type': 'application/json'}},
|
|
629
|
+
body: JSON.stringify({{input: String(text || ''), model: speechConfig.tts.model, voice: speechConfig.tts.voice, language: speechConfig.tts.language, response_format: speechConfig.tts.response_format || 'wav'}})
|
|
630
|
+
}});
|
|
631
|
+
if (!response.ok) throw new Error(await response.text() || `HTTP ${{response.status}}`);
|
|
632
|
+
const blob = await response.blob();
|
|
633
|
+
const url = URL.createObjectURL(blob);
|
|
634
|
+
const audio = new Audio(url);
|
|
635
|
+
audio.addEventListener('ended', () => URL.revokeObjectURL(url), {{once: true}});
|
|
636
|
+
audio.addEventListener('error', () => URL.revokeObjectURL(url), {{once: true}});
|
|
637
|
+
await audio.play();
|
|
638
|
+
setState('speaking', 'ok');
|
|
639
|
+
}} catch (err) {{
|
|
640
|
+
setState('TTS error', 'error');
|
|
641
|
+
addBubble('system', 'TTS failed: ' + String(err && err.message ? err.message : err));
|
|
642
|
+
}}
|
|
643
|
+
}}
|
|
644
|
+
async function transcribeRecording(blob) {{
|
|
645
|
+
setState('transcribing');
|
|
646
|
+
const audio_base64 = await fileToBase64(blob);
|
|
647
|
+
const response = await fetch('/v1/audio/transcriptions', {{
|
|
648
|
+
method: 'POST',
|
|
649
|
+
headers: {{'content-type': 'application/json', 'accept': 'application/json'}},
|
|
650
|
+
body: JSON.stringify({{audio_base64, filename: 'web-chat-recording.webm', content_type: blob.type || 'audio/webm', model: speechConfig.asr.model, language: speechConfig.asr.language}})
|
|
651
|
+
}});
|
|
652
|
+
const text = await response.text();
|
|
653
|
+
let data = {{}};
|
|
654
|
+
try {{ data = text ? JSON.parse(text) : {{}}; }} catch {{}}
|
|
655
|
+
if (!response.ok) throw new Error((data.error && (data.error.message || data.error)) || text || `HTTP ${{response.status}}`);
|
|
656
|
+
const transcriptText = String(data.text || data.transcript || '').trim();
|
|
657
|
+
if (!transcriptText) throw new Error('ASR returned no transcript');
|
|
658
|
+
prompt.value = prompt.value ? prompt.value + ' ' + transcriptText : transcriptText;
|
|
659
|
+
prompt.focus();
|
|
660
|
+
setState('transcribed', 'ok');
|
|
661
|
+
}}
|
|
662
|
+
async function startVoiceInput() {{
|
|
663
|
+
if (!navigator.mediaDevices || !window.MediaRecorder) throw new Error('This browser does not support microphone recording');
|
|
664
|
+
mediaStream = await navigator.mediaDevices.getUserMedia({{audio: true}});
|
|
665
|
+
recordingChunks = [];
|
|
666
|
+
mediaRecorder = new MediaRecorder(mediaStream);
|
|
667
|
+
mediaRecorder.addEventListener('dataavailable', event => {{ if (event.data && event.data.size) recordingChunks.push(event.data); }});
|
|
668
|
+
mediaRecorder.addEventListener('stop', async () => {{
|
|
669
|
+
const blob = new Blob(recordingChunks, {{type: mediaRecorder.mimeType || 'audio/webm'}});
|
|
670
|
+
if (mediaStream) mediaStream.getTracks().forEach(track => track.stop());
|
|
671
|
+
mediaStream = null;
|
|
672
|
+
micButton.textContent = 'Start voice input';
|
|
673
|
+
micButton.classList.remove('recording');
|
|
674
|
+
try {{ await transcribeRecording(blob); }} catch (err) {{ setState('STT error', 'error'); addBubble('system', 'STT failed: ' + String(err && err.message ? err.message : err)); }}
|
|
675
|
+
}}, {{once: true}});
|
|
676
|
+
mediaRecorder.start();
|
|
677
|
+
micButton.textContent = 'Stop and transcribe';
|
|
678
|
+
micButton.classList.add('recording');
|
|
679
|
+
setState('recording', 'error');
|
|
680
|
+
}}
|
|
681
|
+
function stopVoiceInput() {{
|
|
682
|
+
if (mediaRecorder && mediaRecorder.state !== 'inactive') mediaRecorder.stop();
|
|
683
|
+
}}
|
|
474
684
|
async function uploadAttachment(file) {{
|
|
475
685
|
const content = await fileToBase64(file);
|
|
476
686
|
const response = await fetch('/ca/channel/files', {{
|
|
@@ -663,6 +873,42 @@ def render_web_chat_page(
|
|
|
663
873
|
}}
|
|
664
874
|
}});
|
|
665
875
|
attachButton.addEventListener('click', () => fileInput.click());
|
|
876
|
+
micButton.addEventListener('click', async () => {{
|
|
877
|
+
if (mediaRecorder && mediaRecorder.state !== 'inactive') {{
|
|
878
|
+
stopVoiceInput();
|
|
879
|
+
return;
|
|
880
|
+
}}
|
|
881
|
+
try {{ await startVoiceInput(); }} catch (err) {{
|
|
882
|
+
setState('microphone error', 'error');
|
|
883
|
+
addBubble('system', 'Microphone failed: ' + String(err && err.message ? err.message : err));
|
|
884
|
+
}}
|
|
885
|
+
}});
|
|
886
|
+
speechSettingsButton.addEventListener('click', async () => {{
|
|
887
|
+
try {{ await loadSpeechConfig(); }} catch (err) {{ addBubble('system', 'Could not load speech settings: ' + String(err && err.message ? err.message : err)); }}
|
|
888
|
+
speechSettingsDialog.showModal();
|
|
889
|
+
}});
|
|
890
|
+
speechSettingsClose.addEventListener('click', () => speechSettingsDialog.close());
|
|
891
|
+
speechSettingsForm.addEventListener('submit', async event => {{
|
|
892
|
+
event.preventDefault();
|
|
893
|
+
try {{
|
|
894
|
+
await saveSpeechConfig();
|
|
895
|
+
speechSettingsDialog.close();
|
|
896
|
+
setState('speech settings saved', 'ok');
|
|
897
|
+
}} catch (err) {{
|
|
898
|
+
setState('settings error', 'error');
|
|
899
|
+
addBubble('system', 'Speech settings failed: ' + String(err && err.message ? err.message : err));
|
|
900
|
+
}}
|
|
901
|
+
}});
|
|
902
|
+
speechHealthButton.addEventListener('click', async () => {{
|
|
903
|
+
try {{
|
|
904
|
+
await saveSpeechConfig();
|
|
905
|
+
const response = await fetch('/ca/speech/health', {{headers: {{'accept': 'application/json'}}}});
|
|
906
|
+
const data = await response.json();
|
|
907
|
+
const asr = data.services && data.services.asr;
|
|
908
|
+
const tts = data.services && data.services.tts;
|
|
909
|
+
addBubble('system', `Speech health — ASR: ${{asr && asr.reachable ? 'reachable' : asr && asr.enabled ? 'unreachable' : 'disabled'}}, TTS: ${{tts && tts.reachable ? 'reachable' : tts && tts.enabled ? 'unreachable' : 'disabled'}}.`);
|
|
910
|
+
}} catch (err) {{ addBubble('system', 'Speech health check failed: ' + String(err && err.message ? err.message : err)); }}
|
|
911
|
+
}});
|
|
666
912
|
fileInput.addEventListener('change', () => {{
|
|
667
913
|
addSelectedFiles(fileInput.files);
|
|
668
914
|
fileInput.value = '';
|
|
@@ -706,6 +952,7 @@ def render_web_chat_page(
|
|
|
706
952
|
if (transcript.scrollTop < 48) loadOlderHistory();
|
|
707
953
|
}});
|
|
708
954
|
addBubble('system', `Connected to active session bridge for ${{MODEL}}. Messages are queued on channel ${{channel}} and replies stream back from /ca/channel/stream.`);
|
|
955
|
+
loadSpeechConfig().catch(() => {{ micButton.disabled = true; }});
|
|
709
956
|
loadInitialHistory().finally(startChannelStream);
|
|
710
957
|
prompt.focus();
|
|
711
958
|
</script>
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Select an isolated router port for the current working directory."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import socket
|
|
8
|
+
import urllib.request
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Callable, Mapping
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def workspace_identity(value: Any) -> str:
|
|
14
|
+
text = str(value or "").strip()
|
|
15
|
+
if not text:
|
|
16
|
+
return ""
|
|
17
|
+
try:
|
|
18
|
+
return os.path.normcase(str(Path(text).resolve(strict=False)))
|
|
19
|
+
except Exception:
|
|
20
|
+
return os.path.normcase(text)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def probe_router_health(port: int, timeout: float = 0.15) -> dict[str, Any] | None:
|
|
24
|
+
try:
|
|
25
|
+
with urllib.request.urlopen(f"http://127.0.0.1:{port}/health", timeout=timeout) as response:
|
|
26
|
+
payload = json.loads(response.read().decode("utf-8", errors="replace"))
|
|
27
|
+
return payload if isinstance(payload, dict) and payload.get("ok") is True else None
|
|
28
|
+
except Exception:
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def port_is_free(port: int) -> bool:
|
|
33
|
+
probe = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
34
|
+
try:
|
|
35
|
+
probe.bind(("127.0.0.1", port))
|
|
36
|
+
return True
|
|
37
|
+
except OSError:
|
|
38
|
+
return False
|
|
39
|
+
finally:
|
|
40
|
+
probe.close()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def select_workspace_router_port(
|
|
44
|
+
base_port: int,
|
|
45
|
+
workspace: Path,
|
|
46
|
+
environ: Mapping[str, str],
|
|
47
|
+
*,
|
|
48
|
+
health: Callable[[int], dict[str, Any] | None] = probe_router_health,
|
|
49
|
+
available: Callable[[int], bool] = port_is_free,
|
|
50
|
+
scan_size: int = 32,
|
|
51
|
+
) -> int:
|
|
52
|
+
"""Reuse the same workspace port, otherwise select the first free port."""
|
|
53
|
+
|
|
54
|
+
if str(environ.get("CIEL_RUNTIME_ROUTER_PORT") or "").strip():
|
|
55
|
+
return base_port
|
|
56
|
+
target = workspace_identity(
|
|
57
|
+
environ.get("CIEL_RUNTIME_LAUNCH_CWD") or workspace
|
|
58
|
+
)
|
|
59
|
+
unknown_base_health: dict[str, Any] | None = None
|
|
60
|
+
for offset in range(max(1, scan_size)):
|
|
61
|
+
port = base_port + offset
|
|
62
|
+
if port > 65535:
|
|
63
|
+
break
|
|
64
|
+
observed = health(port)
|
|
65
|
+
if observed is not None:
|
|
66
|
+
running_workspace = workspace_identity(observed.get("workspace"))
|
|
67
|
+
if running_workspace and running_workspace == target:
|
|
68
|
+
return port
|
|
69
|
+
if offset == 0 and not running_workspace:
|
|
70
|
+
unknown_base_health = observed
|
|
71
|
+
continue
|
|
72
|
+
if available(port):
|
|
73
|
+
if offset == 0 and unknown_base_health is not None:
|
|
74
|
+
continue
|
|
75
|
+
return port
|
|
76
|
+
raise RuntimeError(
|
|
77
|
+
f"no free ciel-runtime router port found in {base_port}-{min(65535, base_port + scan_size - 1)}"
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
__all__ = [
|
|
82
|
+
"port_is_free",
|
|
83
|
+
"probe_router_health",
|
|
84
|
+
"select_workspace_router_port",
|
|
85
|
+
"workspace_identity",
|
|
86
|
+
]
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Colab speech workers
|
|
2
|
+
|
|
3
|
+
Ciel Runtime can proxy its web chat and OpenAI-compatible audio API to two Colab workers over a tailnet-only Tailscale tunnel.
|
|
4
|
+
|
|
5
|
+
## One-time prerequisites
|
|
6
|
+
|
|
7
|
+
1. Authenticate the Colab CLI inside WSL. The installed CLI currently uses Google Application Default Credentials, so run `~/google-cloud-sdk/bin/gcloud auth application-default login` in `Ubuntu-26.04`.
|
|
8
|
+
2. Create a reusable or ephemeral Tailscale auth key. In each Colab account/notebook Secret store, add `TAILSCALE_AUTHKEY` and grant notebook access.
|
|
9
|
+
3. Optionally add the same `CIEL_SPEECH_API_KEY` secret to both workers. Enter that value once in Web Chat > Speech Settings; Ciel stores it server-side and never returns it to the browser.
|
|
10
|
+
|
|
11
|
+
## Deploy
|
|
12
|
+
|
|
13
|
+
From PowerShell at the repository root:
|
|
14
|
+
|
|
15
|
+
```powershell
|
|
16
|
+
.\scripts\deploy_colab_speech.ps1
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
The script creates `ciel-asr` and `ciel-tts` T4 sessions, installs Qwen3-ASR-0.6B and MOSS-TTS-Nano, starts Tailscale in userspace networking mode, publishes each localhost model server with Tailscale Serve, and saves both returned `base_url` values into Web Chat > Speech Settings automatically.
|
|
20
|
+
|
|
21
|
+
Colab sessions are ephemeral. Re-run the bootstrap after a runtime reset. The workers are reachable only by devices in the same tailnet unless an administrator separately enables Tailscale Funnel.
|
|
22
|
+
|
|
23
|
+
## API surface
|
|
24
|
+
|
|
25
|
+
- `GET|POST /ca/speech/config`
|
|
26
|
+
- `GET /ca/speech/health`
|
|
27
|
+
- `POST /v1/audio/transcriptions`
|
|
28
|
+
- `POST /v1/audio/translations`
|
|
29
|
+
- `POST /v1/audio/speech`
|
|
30
|
+
- `POST /v1/audio/speech/batch`
|
|
31
|
+
- `GET|POST /v1/audio/voices`
|
|
32
|
+
- `GET /ca/web/chat/api` lists chat, model, message, response, file, and speech endpoints.
|
package/docs/Configuration.md
CHANGED
|
@@ -89,6 +89,56 @@
|
|
|
89
89
|
| `CIEL_RUNTIME_THINKING_PASSBACK_MAX` | Thinking 패스백 최대 토큰 (기본: `4096`) |
|
|
90
90
|
| `CIEL_RUNTIME_PYTHON` | 사용할 Python 실행 파일 경로 |
|
|
91
91
|
| `CIEL_RUNTIME_SKIP_POSTINSTALL_STOP` | npm 설치 후 stop 건너뜀 |
|
|
92
|
+
| `CIEL_RUNTIME_RUNAWAY_GUARD` | 반복 폭주 가드 (기본: 켜짐, `off`로 비활성화) |
|
|
93
|
+
| `CIEL_RUNTIME_RUNAWAY_CONTINUE` | 루프 감지 후 턴 이어가기 (기본: 켜짐, `off`면 감지만 하고 종료) |
|
|
94
|
+
| `CIEL_RUNTIME_RUNAWAY_RETRIES` | 수집 경로 재시도 횟수 (기본: `2`, 최대 `4`) |
|
|
95
|
+
| `CIEL_RUNTIME_COLLECT_STREAM` | 수집 경로 스트림 읽기 (기본: 켜짐, `off`면 단일 POST로 회귀) |
|
|
96
|
+
| `CIEL_RUNTIME_RUNAWAY_MIN_REPEATS` | 연속 반복 최소 횟수 (기본: `10`) |
|
|
97
|
+
| `CIEL_RUNTIME_RUNAWAY_MIN_CHARS` | 반복 구간 최소 길이 (기본: `2000`) |
|
|
98
|
+
| `CIEL_RUNTIME_RUNAWAY_MAX_PERIOD` | 반복 블록 최대 길이 (기본: `4096`) |
|
|
99
|
+
| `CIEL_RUNTIME_RUNAWAY_MIN_DENSITY` | 비연속 반복 최소 밀도 % (기본: `70`) |
|
|
100
|
+
|
|
101
|
+
---
|
|
102
|
+
|
|
103
|
+
## 반복 폭주 가드
|
|
104
|
+
|
|
105
|
+
모델이 같은 문장을 끝없이 되풀이하는 생성 루프에 빠지면 라우터가 이를 끊는다.
|
|
106
|
+
업스트림 연결을 즉시 닫으므로 남은 루프는 생성되지도, 과금되지도 않는다.
|
|
107
|
+
|
|
108
|
+
### 판정
|
|
109
|
+
|
|
110
|
+
두 가지 정확 규칙으로만 판단한다. 의미 판단이나 유사도 점수는 쓰지 않는다.
|
|
111
|
+
|
|
112
|
+
1. **연속 반복** — 같은 블록이 바로 뒤에 붙어 `MIN_REPEATS`회 이상,
|
|
113
|
+
`MIN_CHARS`자 이상 반복될 때.
|
|
114
|
+
2. **비연속 반복** — 같은 블록이 사이사이 다른 문구를 끼고 되풀이될 때.
|
|
115
|
+
횟수는 2배, 그리고 해당 구간의 `MIN_DENSITY`% 이상이 그 블록 자체여야 한다.
|
|
116
|
+
|
|
117
|
+
2번은 임계값이지 증명이 아니다. 실제로 대부분이 한 블록의 반복인 정상 출력
|
|
118
|
+
(데이터가 거의 없는 표, 거의 동일한 로그 줄 묶음)은 같은 밀도에 근접할 수 있다.
|
|
119
|
+
그런 워크로드에서는 `CIEL_RUNTIME_RUNAWAY_MIN_DENSITY`를 올리거나
|
|
120
|
+
`CIEL_RUNTIME_RUNAWAY_GUARD=off`로 끄면 된다. 1번 규칙은 그런 판단이 필요 없다.
|
|
121
|
+
|
|
122
|
+
### 감지 후 동작
|
|
123
|
+
|
|
124
|
+
턴을 죽이지 않고 이어가는 것이 기본이다. 무엇을 할 수 있는지는 경로마다 다르다.
|
|
125
|
+
|
|
126
|
+
- **수집 경로** (Codex): 클라이언트로 나간 바이트도, 실행된 툴도 없으므로 응답을
|
|
127
|
+
버리고 **다시 요청한다.** 사용자에게는 아무 메시지도 보이지 않는다. 재시도는
|
|
128
|
+
같은 조건 → `effort=high` → `effort=low`(사고 끄기) 순으로 올라간다.
|
|
129
|
+
DeepSeek이 문서에서 권하는 대응(*"Retry or lower reasoning effort"*)과 같고,
|
|
130
|
+
샘플링이 확률적(`do_sample: true, temperature: 1.0`)이라 재시도는 실제로 다른 결과다.
|
|
131
|
+
세 프로토콜(Ollama NDJSON, OpenAI chat SSE, Anthropic Messages SSE) 모두
|
|
132
|
+
스트림으로 읽어 조립하므로 루프가 다 만들어지기 전에 끊긴다. 전송 방식만
|
|
133
|
+
되돌리려면 `CIEL_RUNTIME_COLLECT_STREAM=off`.
|
|
134
|
+
- **스트리밍 경로** (Claude Code): 이미 나간 바이트는 되돌릴 수 없으므로 재시도가
|
|
135
|
+
불가능하다. 대신 짧은 알림과 함께 `TaskList` 툴 호출을 합성해 CLI가 다음 턴을
|
|
136
|
+
가져가게 한다. 직전 어시스턴트 턴에 같은 알림이 이미 있으면 합성하지 않고
|
|
137
|
+
종료한다 — 복구 자체가 루프가 되는 것을 막는다.
|
|
138
|
+
|
|
139
|
+
알림 문구에는 측정값을 넣지 않는다. 그 텍스트는 어시스턴트 메시지에 남아 다음 턴에
|
|
140
|
+
모델이 다시 읽기 때문이다. 반복 블록 길이·횟수·원문은 라우터 로그
|
|
141
|
+
(`collect_runaway_repetition`, `ollama_stream_runaway_repetition` 등)에만 기록된다.
|
|
92
142
|
|
|
93
143
|
---
|
|
94
144
|
|
package/docs/Test-Suite.md
CHANGED
|
@@ -43,6 +43,7 @@
|
|
|
43
43
|
|------|-----------|
|
|
44
44
|
| `test_router_debug.py` | 라우터 디버그 기능 |
|
|
45
45
|
| `test_upstream_filter.py` | 업스트림 필터링 |
|
|
46
|
+
| `test_mid_conversation_system_projection.py` | 대화를 닫는 시스템 메시지의 chat wire 투영 |
|
|
46
47
|
| `test_upstream_cancel.py` | 업스트림 요청 취소 |
|
|
47
48
|
| `test_provider_wire_normalization.py` | 제공자 Wire 형식 정규화 |
|
|
48
49
|
| `test_channel_bridge.py` | 채널 브릿지 |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oneciel-ai/ciel-runtime",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.4",
|
|
4
4
|
"description": "Universal AI coding-agent runtime and model-routing layer for Claude, Codex, AGY, and compatible runtimes.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "One Ciel LLC",
|
|
@@ -36,6 +36,9 @@
|
|
|
36
36
|
"ciel-runtime-stop.cmd",
|
|
37
37
|
"ciel-runtime-stop.ps1",
|
|
38
38
|
"npm-bin/",
|
|
39
|
+
"scripts/colab/",
|
|
40
|
+
"scripts/configure_speech_workers.py",
|
|
41
|
+
"scripts/deploy_colab_speech.ps1",
|
|
39
42
|
"install.sh",
|
|
40
43
|
"install.ps1",
|
|
41
44
|
"README.md",
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Bootstrap MOSS-TTS-Nano with vLLM-Omni on Colab and Tailscale Serve.
|
|
2
|
+
|
|
3
|
+
Required Colab Secret: TAILSCALE_AUTHKEY
|
|
4
|
+
Optional Colab Secret: CIEL_SPEECH_API_KEY
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
import shutil
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
import time
|
|
16
|
+
import urllib.request
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
HOSTNAME = os.environ.get("CIEL_TTS_HOSTNAME", "ciel-tts")
|
|
20
|
+
PORT = 8091
|
|
21
|
+
SOCKET = "/tmp/ciel-tts-tailscaled.sock"
|
|
22
|
+
STATE = "/tmp/ciel-tts-tailscaled.state"
|
|
23
|
+
LOG_DIR = Path("/content/ciel-speech-logs")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def secret(name: str, *, required: bool = False) -> str:
|
|
27
|
+
value = str(os.environ.get(name) or "").strip()
|
|
28
|
+
if not value:
|
|
29
|
+
try:
|
|
30
|
+
from google.colab import userdata # type: ignore
|
|
31
|
+
|
|
32
|
+
value = str(userdata.get(name) or "").strip()
|
|
33
|
+
except Exception:
|
|
34
|
+
value = ""
|
|
35
|
+
if required and not value:
|
|
36
|
+
raise RuntimeError(f"Add {name} to Colab Secrets and allow notebook access, then rerun this script.")
|
|
37
|
+
return value
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def run(*args: str, check: bool = True) -> subprocess.CompletedProcess[str]:
|
|
41
|
+
print("+", " ".join(args))
|
|
42
|
+
return subprocess.run(args, check=check, text=True, capture_output=False)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def install_tailscale() -> None:
|
|
46
|
+
if shutil.which("tailscale"):
|
|
47
|
+
return
|
|
48
|
+
run("bash", "-lc", "curl -fsSL https://tailscale.com/install.sh | sh")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def start_tailscale(auth_key: str) -> tuple[str, str]:
|
|
52
|
+
install_tailscale()
|
|
53
|
+
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
|
54
|
+
tail_log = (LOG_DIR / "tailscale-tts.log").open("ab")
|
|
55
|
+
subprocess.Popen(
|
|
56
|
+
["tailscaled", f"--socket={SOCKET}", f"--state={STATE}", "--tun=userspace-networking"],
|
|
57
|
+
stdout=tail_log,
|
|
58
|
+
stderr=subprocess.STDOUT,
|
|
59
|
+
start_new_session=True,
|
|
60
|
+
)
|
|
61
|
+
for _ in range(60):
|
|
62
|
+
if Path(SOCKET).exists():
|
|
63
|
+
break
|
|
64
|
+
time.sleep(1)
|
|
65
|
+
run("tailscale", f"--socket={SOCKET}", "up", f"--auth-key={auth_key}", f"--hostname={HOSTNAME}", "--accept-dns=true", "--reset")
|
|
66
|
+
status = subprocess.check_output(["tailscale", f"--socket={SOCKET}", "status", "--json"], text=True)
|
|
67
|
+
dns_name = str(json.loads(status).get("Self", {}).get("DNSName") or HOSTNAME).rstrip(".")
|
|
68
|
+
serve = run("tailscale", f"--socket={SOCKET}", "serve", "--bg", "--https=443", f"http://127.0.0.1:{PORT}", check=False)
|
|
69
|
+
if serve.returncode == 0:
|
|
70
|
+
return dns_name, f"https://{dns_name}"
|
|
71
|
+
run("tailscale", f"--socket={SOCKET}", "serve", "--bg", "--http=80", f"http://127.0.0.1:{PORT}")
|
|
72
|
+
return dns_name, f"http://{dns_name}"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def wait_for_server(api_key: str) -> None:
|
|
76
|
+
headers = {"authorization": f"Bearer {api_key}"} if api_key else {}
|
|
77
|
+
for _ in range(240):
|
|
78
|
+
try:
|
|
79
|
+
with urllib.request.urlopen(urllib.request.Request(f"http://127.0.0.1:{PORT}/health", headers=headers), timeout=3) as response:
|
|
80
|
+
if response.status < 500:
|
|
81
|
+
return
|
|
82
|
+
except Exception:
|
|
83
|
+
time.sleep(2)
|
|
84
|
+
raise RuntimeError("MOSS TTS did not become healthy; inspect /content/ciel-speech-logs/moss-tts.log")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def main() -> None:
|
|
88
|
+
auth_key = secret("TAILSCALE_AUTHKEY", required=True)
|
|
89
|
+
api_key = secret("CIEL_SPEECH_API_KEY")
|
|
90
|
+
run(sys.executable, "-m", "pip", "install", "-U", "vllm-omni==0.24.0")
|
|
91
|
+
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
|
92
|
+
command = [
|
|
93
|
+
"vllm", "serve", "OpenMOSS-Team/MOSS-TTS-Nano", "--omni", "--host", "127.0.0.1", "--port", str(PORT),
|
|
94
|
+
"--gpu-memory-utilization", "0.72", "--trust-remote-code", "--enforce-eager",
|
|
95
|
+
]
|
|
96
|
+
if api_key:
|
|
97
|
+
command.extend(["--api-key", api_key])
|
|
98
|
+
server_log = (LOG_DIR / "moss-tts.log").open("ab")
|
|
99
|
+
subprocess.Popen(command, stdout=server_log, stderr=subprocess.STDOUT, start_new_session=True)
|
|
100
|
+
wait_for_server(api_key)
|
|
101
|
+
dns_name, base_url = start_tailscale(auth_key)
|
|
102
|
+
print(json.dumps({"ok": True, "role": "tts", "hostname": dns_name, "base_url": base_url, "model": "OpenMOSS-Team/MOSS-TTS-Nano", "api_key_set": bool(api_key)}, indent=2))
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
if __name__ == "__main__":
|
|
106
|
+
main()
|