personal memory agent
0

Configure Feed

Select the types of activity you want to include in your feed.

feat(transcribe): add opt-in parakeet-cpp Linux STT backend

Add a selectable `parakeet-cpp` speech-to-text backend that transcribes each
16 kHz mono segment by POSTing a WAV to a supervisor-owned, long-lived
parakeet-server (mudler/parakeet.cpp v0.4.0, OpenAI-compatible
/v1/audio/transcriptions). Structurally the STT-side twin of the bundled
local-LLM provider: an install module (both cpu+vulkan binaries plus a pinned
GGUF, sha256-verified), a connect-only server client, supervisor ownership with
fail-closed-to-CPU device policy and physical-core threads, a backend-gated
doctor readiness check, settings + install-CLI surfaces, and third-party
notices.

Opt-in only: `parakeet` (onnx) stays the default backend and auto-select is
untouched. A retryable "server not ready" signal leaves a segment for a later
pass with no terminal FAILED record and without aborting an --all batch. Linux
only; macOS untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

+2596 -23
+34 -3
solstone/THIRD_PARTY_NOTICES.md
··· 1 1 # third-party notices 2 2 3 - This file records third-party model weights bundled with solstone. The solstone 4 - source code remains AGPL-3.0-only; the notices below apply only to the listed 5 - model files. 3 + This file records third-party model weights bundled with solstone and 4 + provider artifacts downloaded at runtime into the journal provider cache. The 5 + solstone source code remains AGPL-3.0-only; the notices below apply only to the 6 + listed model files and runtime provider artifacts. 6 7 7 8 ## bundled model weights 8 9 ··· 10 11 |---|---|---|---|---| 11 12 | `solstone/observe/transcribe/assets/wespeaker-resnet34-256.onnx` | WeSpeaker ResNet34 speaker embedding model trained on VoxCeleb | `wespeaker_en_voxceleb_resnet34.onnx` from the k2-fsa/sherpa-onnx `speaker-recongition-models` release | CC-BY-4.0 | `5ef208a9da1453335308a6b6f4e6dfbd7e183a38b604de0a57664f45d257fe94` | 12 13 | `solstone/observe/transcribe/assets/pyannote-segmentation-3.0.onnx` | `pyannote/segmentation-3.0` speaker segmentation model | `onnx/model.onnx` from `onnx-community/pyannote-segmentation-3.0` | MIT | `057ee564753071c0b09b5b611648b50ac188d50846bff5f01e9f7bbf1591ea25` | 14 + 15 + ## runtime-downloaded provider artifacts (parakeet-cpp) 16 + 17 + These artifacts are fetched on demand into the journal provider cache when an 18 + owner opts into the `parakeet-cpp` transcription backend; they are not bundled 19 + in this repository. 20 + 21 + ### parakeet.cpp server binary 22 + 23 + Attribution: parakeet.cpp project (mudler). 24 + 25 + Source: 26 + 27 + - Release binaries: https://github.com/mudler/parakeet.cpp/releases/tag/v0.4.0 28 + - Project: https://github.com/mudler/parakeet.cpp 29 + 30 + License notice: MIT. 31 + 32 + ### parakeet TDT 0.6B v3 GGUF model 33 + 34 + Attribution: parakeet-cpp-gguf (mudler), NVIDIA NeMo Parakeet TDT 0.6B v3. 35 + 36 + Source: 37 + 38 + - Model repository: https://huggingface.co/mudler/parakeet-cpp-gguf 39 + - Pinned revision: bf0af9f425fa01809cadec671b3cb672709d13e9 40 + - Downloaded file: tdt-0.6b-v3-q8_0.gguf 41 + 42 + License notice: Creative Commons Attribution 4.0 International (CC-BY-4.0). 43 + License text: https://creativecommons.org/licenses/by/4.0/legalcode.txt 13 44 14 45 ## WeSpeaker ResNet34 / VoxCeleb 15 46
+103
solstone/apps/settings/tests/test_parakeet_cpp_settings.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + from __future__ import annotations 5 + 6 + import json 7 + import re 8 + from pathlib import Path 9 + 10 + from solstone.convey import create_app 11 + 12 + WORKSPACE = Path(__file__).resolve().parents[1] / "workspace.html" 13 + NOTICES = Path(__file__).resolve().parents[3] / "THIRD_PARTY_NOTICES.md" 14 + BANNED_OWNER_WORDS = re.compile( 15 + r"\b(capture|watch|record|monitor|track|collect)\b", re.IGNORECASE 16 + ) 17 + 18 + 19 + def _workspace_text() -> str: 20 + return WORKSPACE.read_text(encoding="utf-8") 21 + 22 + 23 + def _parakeet_cpp_fieldset() -> str: 24 + text = _workspace_text() 25 + match = re.search( 26 + r'<fieldset id="parakeet-cpp-settings".*?</fieldset>', 27 + text, 28 + re.DOTALL, 29 + ) 30 + assert match is not None 31 + return match.group(0) 32 + 33 + 34 + def _client(journal_path: Path): 35 + app = create_app(str(journal_path)) 36 + app.config["TESTING"] = True 37 + return app.test_client() 38 + 39 + 40 + def test_workspace_contains_parakeet_cpp_device_fieldset() -> None: 41 + fieldset = _parakeet_cpp_fieldset() 42 + 43 + assert 'id="parakeet-cpp-settings"' in fieldset 44 + assert 'id="field-parakeet-cpp-device"' in fieldset 45 + options = re.findall(r'<option value="([^"]+)"', fieldset) 46 + assert options == ["auto", "cpu"] 47 + assert "cuda" not in options 48 + assert "vulkan" not in options 49 + 50 + 51 + def test_workspace_wires_parakeet_cpp_population_switch_and_save() -> None: 52 + text = _workspace_text() 53 + 54 + assert "const parakeetCpp = transcribe['parakeet-cpp'] || {};" in text 55 + assert "setValue('field-parakeet-cpp-device', parakeetCpp.device || 'auto')" in text 56 + assert "document.getElementById('parakeet-cpp-settings').style.display" in text 57 + assert "backend === 'parakeet-cpp' ? 'block' : 'none'" in text 58 + assert "data: { 'parakeet-cpp': { device: value } }" in text 59 + 60 + 61 + def test_parakeet_cpp_device_round_trips_through_settings_config(settings_env) -> None: 62 + journal_path, config = settings_env() 63 + config["setup"] = {"completed_at": "2026-05-23T00:00:00Z"} 64 + (journal_path / "config" / "journal.json").write_text( 65 + json.dumps(config, indent=2) + "\n", 66 + encoding="utf-8", 67 + ) 68 + client = _client(journal_path) 69 + 70 + response = client.put( 71 + "/app/settings/api/config", 72 + json={"section": "transcribe", "data": {"parakeet-cpp": {"device": "cpu"}}}, 73 + ) 74 + 75 + assert response.status_code == 200 76 + payload = client.get("/app/settings/api/config").get_json() 77 + assert payload["transcribe"]["parakeet-cpp"]["device"] == "cpu" 78 + 79 + unknown = client.put( 80 + "/app/settings/api/config", 81 + json={"section": "transcribe", "data": {"parakeet-cpp": {"threads": 8}}}, 82 + ) 83 + 84 + assert unknown.status_code == 200 85 + payload = client.get("/app/settings/api/config").get_json() 86 + assert payload["transcribe"]["parakeet-cpp"] == {"device": "cpu"} 87 + 88 + 89 + def test_parakeet_cpp_notices_and_owner_copy() -> None: 90 + fieldset = _parakeet_cpp_fieldset() 91 + notices = NOTICES.read_text(encoding="utf-8") 92 + section = notices.split("## runtime-downloaded provider artifacts (parakeet-cpp)")[ 93 + 1 94 + ].split("## WeSpeaker ResNet34 / VoxCeleb")[0] 95 + 96 + assert "mudler/parakeet.cpp" in section 97 + assert "mudler/parakeet-cpp-gguf" in section 98 + assert "MIT" in section 99 + assert "CC-BY-4.0" in section 100 + assert "Downloaded file: tdt-0.6b-v3-q8_0.gguf" in section 101 + assert "Bundled file: tdt-0.6b-v3-q8_0.gguf" not in section 102 + assert BANNED_OWNER_WORDS.search(fieldset) is None 103 + assert BANNED_OWNER_WORDS.search(section) is None
+43
solstone/apps/settings/workspace.html
··· 2557 2557 2558 2558 <div class="form-help">Platform runtime: <span id="parakeet-runtime-label">unsupported</span></div> 2559 2559 </fieldset> 2560 + 2561 + <fieldset id="parakeet-cpp-settings" class="backend-settings" style="display:none"> 2562 + <div class="settings-field"> 2563 + <label for="field-parakeet-cpp-device">device</label> 2564 + <select id="field-parakeet-cpp-device"> 2565 + <option value="auto" selected>Auto - Use GPU if available, else CPU</option> 2566 + <option value="cpu">CPU - Force processor runtime</option> 2567 + </select> 2568 + <small>Runtime preference for the parakeet.cpp server on this host</small> 2569 + </div> 2570 + <p class="settings-field-note"> 2571 + Selecting this backend fetches two external artifacts into this journal's 2572 + provider cache: the parakeet.cpp server from github.com and the speech model 2573 + from huggingface.co. Install with <code>journal install-provider parakeet</code>. 2574 + </p> 2575 + </fieldset> 2560 2576 <div class="form-help" id="transcribeResourceInfo"></div> 2561 2577 2562 2578 <!-- Shared settings --> ··· 3763 3779 setValue('field-parakeet-model-version', parakeet.model_version || 'v3'); 3764 3780 setValue('field-parakeet-device', parakeet.device || 'auto'); 3765 3781 setValue('field-parakeet-timeout', parakeet.timeout_sec ?? 120); 3782 + 3783 + // Parakeet.cpp settings (nested) 3784 + const parakeetCpp = transcribe['parakeet-cpp'] || {}; 3785 + setValue('field-parakeet-cpp-device', parakeetCpp.device || 'auto'); 3766 3786 3767 3787 // Whisper settings (nested) 3768 3788 const whisper = transcribe.whisper || {}; ··· 5178 5198 function switchTranscribeBackend(backend, config) { 5179 5199 // Show/hide backend-specific fieldsets 5180 5200 document.getElementById('parakeet-settings').style.display = backend === 'parakeet' ? 'block' : 'none'; 5201 + document.getElementById('parakeet-cpp-settings').style.display = backend === 'parakeet-cpp' ? 'block' : 'none'; 5181 5202 document.getElementById('whisper-settings').style.display = backend === 'whisper' ? 'block' : 'none'; 5182 5203 document.getElementById('revai-settings').style.display = backend === 'revai' ? 'block' : 'none'; 5183 5204 document.getElementById('gemini-settings').style.display = backend === 'gemini' ? 'block' : 'none'; ··· 5332 5353 showFieldStatus(e.target, 'error', err.message); 5333 5354 } 5334 5355 }); 5356 + }); 5357 + 5358 + document.getElementById('field-parakeet-cpp-device')?.addEventListener('change', async (e) => { 5359 + const value = e.target.value; 5360 + 5361 + try { 5362 + const response = await fetch('api/config', { 5363 + method: 'PUT', 5364 + headers: { 'Content-Type': 'application/json' }, 5365 + body: JSON.stringify({ section: 'transcribe', data: { 'parakeet-cpp': { device: value } } }) 5366 + }); 5367 + const result = await response.json(); 5368 + if (result.success) { 5369 + configData = result.config; 5370 + showFieldStatus(e.target, 'saved'); 5371 + } else { 5372 + throw new Error(result.error); 5373 + } 5374 + } catch (err) { 5375 + console.error('Error saving parakeet-cpp setting:', err); 5376 + showFieldStatus(e.target, 'error', err.message); 5377 + } 5335 5378 }); 5336 5379 5337 5380 // ========== OBSERVER ==========
+7
solstone/observe/transcribe/__init__.py
··· 65 65 "revai": "solstone.observe.transcribe.revai", 66 66 "gemini": "solstone.observe.transcribe.gemini", 67 67 "parakeet": "solstone.observe.transcribe.parakeet", 68 + "parakeet-cpp": "solstone.observe.transcribe._parakeet_cpp", 68 69 } 69 70 70 71 # --------------------------------------------------------------------------- ··· 98 99 "description": "On-device speech recognition via Parakeet TDT; macOS uses a FluidAudio/CoreML helper, Linux uses onnx-asr + onnxruntime. Requires `make install`.", 99 100 "env_key": None, 100 101 "settings": ["model_version", "device", "timeout_sec"], 102 + }, 103 + "parakeet-cpp": { 104 + "label": "Parakeet.cpp - Local processing (Linux, selectable)", 105 + "description": "On-device speech recognition via a supervised parakeet.cpp server (mudler/parakeet.cpp). Linux only; opt-in alongside the default Parakeet backend. Install with `journal install-provider parakeet`.", 106 + "env_key": None, 107 + "settings": ["device"], 101 108 }, 102 109 } 103 110
+187
solstone/observe/transcribe/_parakeet_cpp.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Linux Parakeet backend via supervised parakeet.cpp HTTP server.""" 5 + 6 + from __future__ import annotations 7 + 8 + import io 9 + import logging 10 + import sys 11 + import time 12 + 13 + import numpy as np 14 + 15 + from solstone.think import parakeet_readiness 16 + from solstone.think.providers import parakeet_server 17 + from solstone.think.providers.parakeet_install import ParakeetProviderError 18 + from solstone.think.providers.parakeet_server import ParakeetServerNotReady 19 + 20 + from .utils import build_statements_from_acoustic 21 + 22 + logger = logging.getLogger(__name__) 23 + 24 + _DEFAULT_TIMEOUT_SEC = 300.0 25 + _DEFAULT_DEVICE = "auto" 26 + _COMPUTE_TYPE = "q8_0" 27 + 28 + 29 + def _require_linux() -> None: 30 + if not sys.platform.startswith("linux"): 31 + raise ParakeetProviderError( 32 + "unsupported_platform", "parakeet-cpp is only supported on Linux" 33 + ) 34 + 35 + 36 + def _validate_config(config: dict) -> str: 37 + device = config.get("device", _DEFAULT_DEVICE) 38 + if device not in {"auto", "cpu"}: 39 + raise ValueError("device must be one of: auto, cpu") 40 + return device 41 + 42 + 43 + def _audio_to_wav_bytes(audio_array: np.ndarray, sample_rate: int) -> bytes: 44 + import soundfile as sf 45 + 46 + buf = io.BytesIO() 47 + sf.write(buf, audio_array, sample_rate, format="WAV", subtype="PCM_16") 48 + return buf.getvalue() 49 + 50 + 51 + def _invalid_contract(message: str) -> ParakeetProviderError: 52 + return ParakeetProviderError("contract_violation", message) 53 + 54 + 55 + def _parse_words(payload: dict) -> tuple[list[dict], str]: 56 + if "words" not in payload: 57 + raise _invalid_contract( 58 + "response missing top-level words[]; server did not honor " 59 + "timestamp_granularities" 60 + ) 61 + raw_words = payload["words"] 62 + if not isinstance(raw_words, list): 63 + raise _invalid_contract("response words must be a list") 64 + 65 + text = str(payload.get("text", "")).strip() 66 + if not raw_words: 67 + if text: 68 + raise _invalid_contract("response has text but no word timings") 69 + return [], text 70 + 71 + words: list[dict] = [] 72 + for item in raw_words: 73 + if not isinstance(item, dict): 74 + raise _invalid_contract("word timing item must be an object") 75 + try: 76 + token = str(item["word"]).strip() 77 + start = float(item["start"]) 78 + end = float(item["end"]) 79 + conf = item.get("conf") 80 + probability = float(conf) if conf is not None else 1.0 81 + except KeyError as exc: 82 + raise _invalid_contract(f"word timing missing key: {exc.args[0]}") from exc 83 + except (TypeError, ValueError) as exc: 84 + raise _invalid_contract( 85 + "word timing contains invalid numeric value" 86 + ) from exc 87 + words.append( 88 + { 89 + "word": f" {token}", 90 + "start": start, 91 + "end": end, 92 + "probability": probability, 93 + } 94 + ) 95 + return words, text 96 + 97 + 98 + def transcribe(audio: np.ndarray, sample_rate: int, config: dict) -> list[dict]: 99 + """Transcribe audio using a supervised parakeet.cpp server.""" 100 + _require_linux() 101 + audio_array = np.asarray(audio, dtype=np.float32) 102 + if audio_array.ndim != 1: 103 + raise ValueError("audio must be a 1-D mono ndarray") 104 + 105 + device = _validate_config(config) 106 + wav_bytes = _audio_to_wav_bytes(audio_array, sample_rate) 107 + server = parakeet_server.connect() 108 + 109 + import httpx 110 + 111 + started = time.perf_counter() 112 + files = {"file": ("audio.wav", wav_bytes, "audio/wav")} 113 + data = [ 114 + ("response_format", "verbose_json"), 115 + ("timestamp_granularities[]", "word"), 116 + ] 117 + try: 118 + response = httpx.post( 119 + f"{server.base_url}/v1/audio/transcriptions", 120 + files=files, 121 + data=data, 122 + timeout=_DEFAULT_TIMEOUT_SEC, 123 + ) 124 + except ( 125 + httpx.ConnectError, 126 + httpx.ConnectTimeout, 127 + httpx.ReadTimeout, 128 + httpx.PoolTimeout, 129 + httpx.TimeoutException, 130 + httpx.NetworkError, 131 + ) as exc: 132 + raise ParakeetServerNotReady( 133 + f"parakeet-server unreachable during transcription: {exc}" 134 + ) from exc 135 + 136 + if response.status_code != 200: 137 + raise ParakeetProviderError( 138 + "transcription_http_error", 139 + f"HTTP {response.status_code}: {response.text[:200]}", 140 + ) 141 + 142 + try: 143 + payload = response.json() 144 + except Exception as exc: 145 + raise ParakeetProviderError("invalid_json", str(exc)) from exc 146 + if not isinstance(payload, dict): 147 + raise ParakeetProviderError("invalid_json", "response JSON was not an object") 148 + 149 + words, _text = _parse_words(payload) 150 + if not words: 151 + return [] 152 + 153 + audio_sec = len(audio_array) / sample_rate 154 + acoustic_segments = [ 155 + { 156 + "id": 1, 157 + "start": 0.0, 158 + "end": audio_sec, 159 + "text": "".join(word["word"] for word in words).strip(), 160 + "words": words, 161 + } 162 + ] 163 + statements = build_statements_from_acoustic(acoustic_segments) 164 + for statement in statements: 165 + statement["speaker"] = None 166 + 167 + elapsed = time.perf_counter() - started 168 + rtfx = audio_sec / max(elapsed, 0.001) 169 + logger.info( 170 + f" Transcribed {len(statements)} statements, {audio_sec:.2f}s speech " 171 + f"in {elapsed:.2f}s (RTFx: {rtfx:.2f}) " 172 + f"[model={parakeet_readiness.PARAKEET_CPP_MODEL_FILENAME} " 173 + f"device={device} quant={_COMPUTE_TYPE}]" 174 + ) 175 + return statements 176 + 177 + 178 + def get_model_info(config: dict) -> dict: 179 + """Return parakeet.cpp model metadata for transcript JSONL headers.""" 180 + _require_linux() 181 + device = _validate_config(config) 182 + return { 183 + "model": parakeet_readiness.PARAKEET_CPP_MODEL_FILENAME, 184 + "device": device, 185 + "compute_type": _COMPUTE_TYPE, 186 + "per_word_confidence": True, 187 + }
+13 -1
solstone/observe/transcribe/main.py
··· 16 16 - <stem>.npz: Sentence-level voice embeddings indexed by statement id 17 17 18 18 Configuration (journal config transcribe section): 19 - - transcribe.backend: STT backend ("parakeet", "whisper", "revai", "gemini"). If unset, auto-selected by free memory. 19 + - transcribe.backend: STT backend ("parakeet", "parakeet-cpp", "whisper", "revai", "gemini"). If unset, auto-selected by free memory. 20 20 - transcribe.enrich: Enable/disable LLM enrichment (default: true) 21 21 - transcribe.preserve_all: Keep audio files even when no speech detected (default: false) 22 22 - transcribe.min_speech_seconds: Minimum speech duration to proceed. Default: 1.0 ··· 110 110 from solstone.think.journal_io.npz import write_npz 111 111 from solstone.think.media import AUDIO_EXTENSIONS as SUPPORTED_AUDIO_FORMATS 112 112 from solstone.think.providers.memory import gb, read_available_bytes 113 + from solstone.think.providers.parakeet_server import ParakeetServerNotReady 113 114 from solstone.think.utils import ( 114 115 day_dirs, 115 116 day_from_path, ··· 925 926 926 927 callosum_send("observe", "transcribed", **event) 927 928 929 + except ParakeetServerNotReady as e: 930 + logging.info( 931 + "Parakeet server not ready for %s; leaving audio for retry: %s", 932 + raw_path, 933 + e, 934 + ) 935 + return 936 + 928 937 except Exception as e: 929 938 logging.error(f"Failed to transcribe {raw_path}: {e}", exc_info=True) 930 939 try: ··· 1130 1139 "quantization", 1131 1140 ) 1132 1141 } 1142 + elif backend == "parakeet-cpp": 1143 + parakeet_cpp_config = transcribe_config.get("parakeet-cpp", {}) 1144 + backend_config = {k: v for k, v in parakeet_cpp_config.items() if k == "device"} 1133 1145 elif backend == "gemini": 1134 1146 # Gemini backend - model resolved by think.models based on context 1135 1147 # Entity names handled by enrich step, not passed to transcription
+4 -1
solstone/talent/journal/references/config.md
··· 160 160 ``` 161 161 162 162 **Top-level fields:** 163 - - `backend` (string) – STT backend to use: `"parakeet"` (default local processing), `"whisper"` (local rollback path), `"revai"` (cloud with speaker diarization), or `"gemini"` (cloud with speaker diarization). Default: `"parakeet"`. 163 + - `backend` (string) – STT backend to use: `"parakeet"` (default local processing), `"parakeet-cpp"` (Linux-only, opt-in local processing via a supervised parakeet.cpp server), `"whisper"` (local rollback path), `"revai"` (cloud with speaker diarization), or `"gemini"` (cloud with speaker diarization). Default: `"parakeet"`. 164 164 - `enrich` (boolean) – Enable LLM enrichment for topic extraction and transcript correction. Default: `true`. 165 165 - `preserve_all` (boolean) – Keep audio files even when no speech is detected. When `false`, silent recordings are deleted to save disk space. Default: `false`. 166 166 - `noise_upgrade_min_speech_ratio` (number) – Min speech/loud ratio required for noisy upgrade (default: `0.3`). Filters out music and other non-speech noise. ··· 169 169 - `model_version` (string) – Parakeet model version: `"v3"`. Default: `"v3"`. 170 170 - `device` (string) – Runtime preference for Parakeet: `"auto"`, `"cpu"`, or `"cuda"`. Default: `"auto"`. 171 171 - `timeout_sec` (number) – Helper/runtime timeout in seconds. Default: `120.0`. 172 + 173 + **Parakeet.cpp backend settings** (`transcribe.parakeet-cpp`): 174 + - `device` (string) – Runtime preference for the parakeet.cpp server: `"auto"` (use GPU if available, else CPU) or `"cpu"`. Default: `"auto"`. 172 175 173 176 **Whisper backend settings** (`transcribe.whisper`): 174 177 - `device` (string) – Device for inference: `"auto"` (detect GPU, fall back to CPU), `"cpu"`, or `"cuda"`. Default: `"auto"`.
+49
solstone/think/doctor.py
··· 115 115 LAUNCHD_STALE_PLIST_CHECK = Check("launchd_stale_plist", "advisory", ("darwin",)) 116 116 JOURNAL_SYNC_CHECK = Check("journal_sync", "blocker", ("linux", "darwin")) 117 117 DEFAULT_STT_READY_CHECK = Check("default_stt_ready", "advisory", ("linux", "darwin")) 118 + PARAKEET_CPP_STT_READY_CHECK = Check( 119 + "parakeet_cpp_stt_ready", "advisory", ("linux", "darwin") 120 + ) 118 121 _DEFAULT_STT_RUNTIME_FIX = ( 119 122 "parakeet runtime (onnx-asr) is not installed — reinstall to add it: " 120 123 "uv tool install --reinstall solstone" 121 124 ) 122 125 _DEFAULT_STT_MODEL_FIX = ( 123 126 "parakeet model is not downloaded — fetch it with: journal install-models" 127 + ) 128 + _PARAKEET_CPP_INSTALL_FIX = ( 129 + "parakeet-cpp artifacts are not installed — fetch them with: " 130 + "journal install-provider parakeet" 131 + ) 132 + _PARAKEET_CPP_START_FIX = ( 133 + "parakeet-server is not reachable — start the journal service: journal start" 124 134 ) 125 135 JOURNAL_CAUGHT_UP_CHECK = Check("journal_caught_up", "advisory", ("linux", "darwin")) 126 136 TASK_PACE_CHECK = Check("task_pace", "advisory", ("linux", "darwin")) ··· 779 789 return make_result(check, "ok", f"parakeet model ready at {ready_cache}") 780 790 781 791 792 + def parakeet_cpp_stt_ready_check(args: Args) -> CheckResult: 793 + del args 794 + check = PARAKEET_CPP_STT_READY_CHECK 795 + if _resolve_configured_backend() != "parakeet-cpp": 796 + return make_result( 797 + check, 798 + "skip", 799 + "configured backend is not parakeet-cpp; check not applicable", 800 + ) 801 + os_name, arch = parakeet_readiness._platform_info() 802 + if os_name != "linux": 803 + return make_result(check, "skip", "parakeet-cpp is only supported on Linux") 804 + path_text, _source = get_journal_info() 805 + cache_root = parakeet_readiness.parakeet_cpp_cache_root(Path(path_text)) 806 + try: 807 + artifact_key = parakeet_readiness.parakeet_cpp_artifact_key(os_name, arch) 808 + except RuntimeError as exc: 809 + return make_result(check, "warn", str(exc), _PARAKEET_CPP_INSTALL_FIX) 810 + try: 811 + parakeet_readiness.check_parakeet_cpp_files(cache_root, artifact_key) 812 + except RuntimeError as exc: 813 + return make_result(check, "warn", str(exc), _PARAKEET_CPP_INSTALL_FIX) 814 + from solstone.think.providers import parakeet_server 815 + 816 + state, error = parakeet_server.probe_state() 817 + if state != parakeet_server.STATE_READY: 818 + return make_result( 819 + check, 820 + "warn", 821 + f"parakeet-server not reachable: {error}", 822 + _PARAKEET_CPP_START_FIX, 823 + ) 824 + return make_result( 825 + check, "ok", "parakeet-cpp ready (binaries + model installed, server reachable)" 826 + ) 827 + 828 + 782 829 def _make_feature_check( 783 830 feat_name: str, 784 831 ) -> tuple[Check, Runner]: ··· 825 872 (STALE_ALIAS_CHECK, partial(stale_alias_symlink_check, binary="journal")), 826 873 (LAUNCHD_STALE_PLIST_CHECK, launchd_stale_plist_check), 827 874 (DEFAULT_STT_READY_CHECK, default_stt_ready_check), 875 + (PARAKEET_CPP_STT_READY_CHECK, parakeet_cpp_stt_ready_check), 828 876 (SKILL_STATE_CHECK, skill_state_check), 829 877 *FEATURE_CHECKS.values(), 830 878 ] ··· 842 890 (HOST_DEPENDENCIES_CHECK, host_dependencies_check), 843 891 *READINESS_CHECKS, 844 892 (DEFAULT_STT_READY_CHECK, default_stt_ready_check), 893 + (PARAKEET_CPP_STT_READY_CHECK, parakeet_cpp_stt_ready_check), 845 894 *FEATURE_CHECKS.values(), 846 895 ] 847 896
+17 -7
solstone/think/install_provider.py
··· 1 1 # SPDX-License-Identifier: AGPL-3.0-only 2 2 # Copyright (c) 2026 sol pbc 3 3 4 - """Top-level `journal install-provider <name>` — install a local provider runtime. 4 + """Top-level `journal install-provider <name>` — install a provider runtime. 5 5 6 6 Local-system-only: only meaningful on the host that stores the journal. Moved 7 7 here from the old journal-access provider-install surface. ··· 13 13 import json 14 14 import sys 15 15 16 - from solstone.think.providers import local_install 16 + from solstone.think.providers import local_install, parakeet_install 17 17 from solstone.think.utils import require_solstone 18 18 19 + PARAKEET_DOWNLOAD_DISCLOSURE = ( 20 + "parakeet-cpp fetches two external artifacts into this journal's provider " 21 + "cache before it can run: the parakeet.cpp server binary from github.com " 22 + "(MIT) and the speech model from huggingface.co (CC-BY-4.0)." 23 + ) 24 + 19 25 20 26 def main() -> int: 21 27 parser = argparse.ArgumentParser( 22 28 prog="journal install-provider", 23 - description="Install or retry the local provider runtime.", 29 + description="Install or retry a provider runtime.", 24 30 ) 25 - parser.add_argument("name", help="Provider name (only 'local' is supported).") 31 + parser.add_argument("name", help="Provider to install: 'local' or 'parakeet'.") 26 32 args = parser.parse_args() 27 33 28 34 require_solstone() 29 35 30 - if args.name != "local": 36 + if args.name not in {"local", "parakeet"}: 31 37 print( 32 - f"unsupported provider {args.name!r}; only 'local' is supported; " 33 - "cogitate runs baseline for hosted providers", 38 + f"unsupported provider {args.name!r}; supported: local, parakeet", 34 39 file=sys.stderr, 35 40 ) 36 41 return 2 42 + 43 + if args.name == "parakeet": 44 + print(PARAKEET_DOWNLOAD_DISCLOSURE, file=sys.stderr) 45 + print(json.dumps(parakeet_install.install_parakeet(), indent=2)) 46 + return 0 37 47 38 48 print(json.dumps(local_install.install_local(), indent=2)) 39 49 return 0
+79 -3
solstone/think/parakeet_readiness.py
··· 1 1 # SPDX-License-Identifier: AGPL-3.0-only 2 2 # Copyright (c) 2026 sol pbc 3 3 4 - """Examine-only parakeet readiness checks shared by doctor and install_models. 4 + """Examine-only parakeet readiness checks shared by doctor and provider setup. 5 5 6 6 Stdlib-only by contract. Must NOT import solstone.observe.* (inference), 7 - solstone.think.install_models (installer), any third-party package, or anything 8 - that downloads, installs, or loads inference code. doctor depends on this 7 + installer modules, any third-party package, or anything that downloads, 8 + installs, or loads inference code. doctor depends on this 9 9 boundary to stay fast and diagnostic-only — do not add heavy imports here. 10 10 """ 11 11 12 12 from __future__ import annotations 13 13 14 14 import json 15 + import os 15 16 import platform 16 17 import sys 17 18 from pathlib import Path ··· 39 40 "JointDecision.mlmodelc/weights/weight.bin", 40 41 "Preprocessor.mlmodelc/weights/weight.bin", 41 42 ) 43 + PARAKEET_CPP_RELEASE_TAG = "v0.4.0" 44 + PARAKEET_CPP_BINARY_NAME = "parakeet-server" 45 + PARAKEET_CPP_BINARY_BACKENDS = ("cpu", "vulkan") 46 + PARAKEET_CPP_MODEL_REPO = "mudler/parakeet-cpp-gguf" 47 + PARAKEET_CPP_MODEL_FILENAME = "tdt-0.6b-v3-q8_0.gguf" 48 + PARAKEET_CPP_MODEL_REVISION = "bf0af9f425fa01809cadec671b3cb672709d13e9" 42 49 43 50 44 51 def _platform_info() -> tuple[str, str]: 45 52 os_name = "linux" if sys.platform.startswith("linux") else sys.platform 46 53 return os_name, platform.machine().lower() 54 + 55 + 56 + def parakeet_cpp_artifact_key( 57 + os_name: str | None = None, arch: str | None = None 58 + ) -> str: 59 + if os_name is None or arch is None: 60 + detected_os, detected_arch = _platform_info() 61 + os_name = os_name or detected_os 62 + arch = arch or detected_arch 63 + 64 + if os_name != "linux": 65 + raise RuntimeError(f"parakeet-cpp is unsupported on {os_name}/{arch}") 66 + 67 + normalized_arch = arch.lower() 68 + if normalized_arch in {"amd64", "x64", "x86_64"}: 69 + return "x86_64-unknown-linux-gnu" 70 + if normalized_arch in {"arm64", "aarch64"}: 71 + return "aarch64-unknown-linux-gnu" 72 + raise RuntimeError(f"parakeet-cpp is unsupported on {os_name}/{arch}") 73 + 74 + 75 + def parakeet_cpp_cache_root(journal_path: Path) -> Path: 76 + return journal_path / "cache" / "providers" / "parakeet" 77 + 78 + 79 + def parakeet_cpp_binary_install_dir( 80 + cache_root: Path, artifact_key: str, backend: str 81 + ) -> Path: 82 + return cache_root / "bin" / artifact_key / backend / PARAKEET_CPP_RELEASE_TAG 83 + 84 + 85 + def parakeet_cpp_binary_path(cache_root: Path, artifact_key: str, backend: str) -> Path: 86 + return ( 87 + parakeet_cpp_binary_install_dir(cache_root, artifact_key, backend) 88 + / PARAKEET_CPP_BINARY_NAME 89 + ) 90 + 91 + 92 + def parakeet_cpp_model_dir(cache_root: Path) -> Path: 93 + return ( 94 + cache_root 95 + / "models" 96 + / PARAKEET_CPP_MODEL_REPO.replace("/", "__") 97 + / PARAKEET_CPP_MODEL_REVISION 98 + ) 99 + 100 + 101 + def parakeet_cpp_model_path(cache_root: Path) -> Path: 102 + return parakeet_cpp_model_dir(cache_root) / PARAKEET_CPP_MODEL_FILENAME 103 + 104 + 105 + def check_parakeet_cpp_files(cache_root: Path, artifact_key: str) -> dict[str, Path]: 106 + paths = { 107 + "binary_cpu": parakeet_cpp_binary_path(cache_root, artifact_key, "cpu"), 108 + "binary_vulkan": parakeet_cpp_binary_path(cache_root, artifact_key, "vulkan"), 109 + "model": parakeet_cpp_model_path(cache_root), 110 + } 111 + for key in ("binary_cpu", "binary_vulkan"): 112 + path = paths[key] 113 + if not path.is_file(): 114 + raise RuntimeError(f"parakeet-cpp check failed: {key} missing at {path}") 115 + if not os.access(path, os.X_OK): 116 + raise RuntimeError( 117 + f"parakeet-cpp check failed: {key} not executable at {path}" 118 + ) 119 + model_path = paths["model"] 120 + if not model_path.is_file(): 121 + raise RuntimeError(f"parakeet-cpp check failed: model missing at {model_path}") 122 + return paths 47 123 48 124 49 125 def _sentinel_path(variant: str) -> Path:
+447
solstone/think/providers/parakeet_install.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Install and inspect bundled parakeet.cpp provider artifacts. 5 + 6 + This module is the sole writer for ``providers.bundled.parakeet`` install state. 7 + It performs no network access at import time. 8 + """ 9 + 10 + from __future__ import annotations 11 + 12 + import hashlib 13 + import os 14 + import shutil 15 + import stat 16 + from dataclasses import dataclass 17 + from pathlib import Path 18 + from typing import Any, Callable 19 + 20 + from solstone.think import parakeet_readiness 21 + from solstone.think.journal_config import read_journal_config, write_journal_config 22 + from solstone.think.providers.install_state import ( 23 + IN_FLIGHT_STATES, 24 + InstallStatus, 25 + bump_progress, 26 + read_install_status, 27 + transition_state, 28 + write_install_status, 29 + ) 30 + from solstone.think.utils import get_journal 31 + 32 + PARAKEET_PROVIDER_NAME = "parakeet" 33 + _PROBE_TIMEOUT_SECONDS = 10 34 + _PARAKEET_METADATA_KEYS = frozenset( 35 + { 36 + "binary_artifact_cpu", 37 + "binary_sha256_cpu", 38 + "binary_path_cpu", 39 + "binary_artifact_vulkan", 40 + "binary_sha256_vulkan", 41 + "binary_path_vulkan", 42 + "model_repo", 43 + "model_filename", 44 + "model_revision", 45 + "model_path", 46 + "model_sha256", 47 + } 48 + ) 49 + 50 + PARAKEET_SERVER_PINS: dict[tuple[str, str], dict[str, str]] = { 51 + ("x86_64-unknown-linux-gnu", "vulkan"): { 52 + "filename": "parakeet-v0.4.0-bin-linux-vulkan-x64.tar.gz", 53 + "sha256": "12ee636ccb4a8b3c8f316f1f40c63f5aa4da178bf11563795b39385480ede87e", 54 + }, 55 + ("x86_64-unknown-linux-gnu", "cpu"): { 56 + "filename": "parakeet-v0.4.0-bin-linux-cpu-x64.tar.gz", 57 + "sha256": "0846509eeb64fcb40e0ad28cd16b5bec5387e4799e08c85fb600b428bb306240", 58 + }, 59 + ("aarch64-unknown-linux-gnu", "vulkan"): { 60 + "filename": "parakeet-v0.4.0-bin-linux-vulkan-arm64.tar.gz", 61 + "sha256": "b1e9251c9d247dffffc5e2db44bb993fb5ec40faab208ec83f7b89b8cc24efd0", 62 + }, 63 + ("aarch64-unknown-linux-gnu", "cpu"): { 64 + "filename": "parakeet-v0.4.0-bin-linux-cpu-arm64.tar.gz", 65 + "sha256": "6634487a4cdbd3185e7a127aa4f22fbc49ec56421f7bfb14f450400260597773", 66 + }, 67 + } 68 + 69 + 70 + @dataclass(frozen=True) 71 + class ParakeetModelSpec: 72 + repo: str 73 + filename: str 74 + revision: str 75 + sha256: str 76 + size_bytes: int 77 + 78 + 79 + PARAKEET_MODEL_SPEC = ParakeetModelSpec( 80 + repo=parakeet_readiness.PARAKEET_CPP_MODEL_REPO, 81 + filename=parakeet_readiness.PARAKEET_CPP_MODEL_FILENAME, 82 + revision=parakeet_readiness.PARAKEET_CPP_MODEL_REVISION, 83 + sha256="4d69a4a6683f4f2d952bad794c1357ca6eb628027695b4699c5a9ad4cd07d757", 84 + size_bytes=940663680, 85 + ) 86 + 87 + 88 + class ParakeetProviderError(RuntimeError): 89 + """Parakeet provider failure with a recovery reason code.""" 90 + 91 + def __init__(self, reason_code: str, message: str) -> None: 92 + super().__init__(message) 93 + self.reason_code = reason_code 94 + 95 + 96 + def _validate_backend(backend: str) -> str: 97 + if backend not in parakeet_readiness.PARAKEET_CPP_BINARY_BACKENDS: 98 + valid = ", ".join(parakeet_readiness.PARAKEET_CPP_BINARY_BACKENDS) 99 + raise ParakeetProviderError( 100 + "unsupported_backend", f"parakeet backend must be one of: {valid}" 101 + ) 102 + return backend 103 + 104 + 105 + def _pin_for_backend(artifact_key: str, backend: str) -> dict[str, str]: 106 + backend = _validate_backend(backend) 107 + pin = PARAKEET_SERVER_PINS.get((artifact_key, backend)) 108 + if pin is None: 109 + raise ParakeetProviderError( 110 + "unsupported_platform", 111 + f"No pinned parakeet artifact for {artifact_key}/{backend}", 112 + ) 113 + return pin 114 + 115 + 116 + def parakeet_server_artifact_key() -> str: 117 + try: 118 + return parakeet_readiness.parakeet_cpp_artifact_key() 119 + except RuntimeError as exc: 120 + raise ParakeetProviderError("unsupported_platform", str(exc)) from exc 121 + 122 + 123 + def cache_root() -> Path: 124 + return parakeet_readiness.parakeet_cpp_cache_root(Path(get_journal())) 125 + 126 + 127 + def binary_install_dir(backend: str) -> Path: 128 + return parakeet_readiness.parakeet_cpp_binary_install_dir( 129 + cache_root(), parakeet_server_artifact_key(), _validate_backend(backend) 130 + ) 131 + 132 + 133 + def binary_path(backend: str) -> Path: 134 + return parakeet_readiness.parakeet_cpp_binary_path( 135 + cache_root(), parakeet_server_artifact_key(), _validate_backend(backend) 136 + ) 137 + 138 + 139 + def model_dir() -> Path: 140 + return parakeet_readiness.parakeet_cpp_model_dir(cache_root()) 141 + 142 + 143 + def model_path() -> Path: 144 + return parakeet_readiness.parakeet_cpp_model_path(cache_root()) 145 + 146 + 147 + def install_hint() -> str: 148 + return "journal install-provider parakeet" 149 + 150 + 151 + def _read_parakeet_status() -> InstallStatus: 152 + return read_install_status(scope="bundled", name=PARAKEET_PROVIDER_NAME) 153 + 154 + 155 + def _write_parakeet_status(status: InstallStatus) -> InstallStatus: 156 + write_install_status(status, scope="bundled") 157 + return status 158 + 159 + 160 + def _write_parakeet_metadata(updates: dict[str, str]) -> None: 161 + unknown_keys = sorted(set(updates) - _PARAKEET_METADATA_KEYS) 162 + if unknown_keys: 163 + raise ValueError(f"unknown parakeet install metadata key: {unknown_keys[0]}") 164 + 165 + config = read_journal_config() 166 + slot = ( 167 + config.setdefault("providers", {}) 168 + .setdefault("bundled", {}) 169 + .setdefault(PARAKEET_PROVIDER_NAME, {}) 170 + ) 171 + for key, value in updates.items(): 172 + slot[key] = value 173 + write_journal_config(config) 174 + 175 + 176 + def _record_parakeet_progress(received: int, total: int | None) -> None: 177 + status = _read_parakeet_status() 178 + if status["install_state"] not in IN_FLIGHT_STATES: 179 + return 180 + _write_parakeet_status(bump_progress(status, received=received, total=total)) 181 + 182 + 183 + def _sha256_file(path: Path) -> str: 184 + digest = hashlib.sha256() 185 + with path.open("rb") as handle: 186 + for chunk in iter(lambda: handle.read(1024 * 1024), b""): 187 + digest.update(chunk) 188 + return digest.hexdigest() 189 + 190 + 191 + def _verify_sha256(path: Path, expected: str) -> None: 192 + actual = _sha256_file(path) 193 + if actual != expected: 194 + raise ParakeetProviderError( 195 + "sha256_mismatch", 196 + f"sha256 mismatch for {path.name}: expected {expected}, got {actual}", 197 + ) 198 + 199 + 200 + def _download_file( 201 + url: str, 202 + dest: Path, 203 + *, 204 + timeout_s: float = 600.0, 205 + on_progress: Callable[[int, int | None], None] | None = None, 206 + ) -> None: 207 + import httpx 208 + 209 + dest.parent.mkdir(parents=True, exist_ok=True) 210 + tmp = dest.with_suffix(dest.suffix + ".tmp") 211 + with httpx.stream("GET", url, timeout=timeout_s, follow_redirects=True) as response: 212 + response.raise_for_status() 213 + total_header = response.headers.get("content-length") 214 + total = int(total_header) if total_header and total_header.isdigit() else None 215 + received = 0 216 + with tmp.open("wb") as handle: 217 + for chunk in response.iter_bytes(): 218 + if chunk: 219 + handle.write(chunk) 220 + received += len(chunk) 221 + if on_progress is not None: 222 + on_progress(received, total) 223 + tmp.rename(dest) 224 + 225 + 226 + def _safe_extract_tarball(tarball: Path, dest: Path) -> None: 227 + import tarfile 228 + 229 + dest.mkdir(parents=True, exist_ok=True) 230 + dest_resolved = dest.resolve() 231 + with tarfile.open(tarball, "r:*") as archive: 232 + for member in archive.getmembers(): 233 + target = (dest / member.name).resolve() 234 + if target != dest_resolved and dest_resolved not in target.parents: 235 + raise ParakeetProviderError( 236 + "archive_path_traversal", 237 + f"Unsafe tar member path: {member.name}", 238 + ) 239 + archive.extractall(dest) 240 + 241 + 242 + def _find_extracted_binary(dest: Path, binary_name: str) -> Path: 243 + direct = dest / binary_name 244 + if direct.exists(): 245 + return direct 246 + matches = [path for path in dest.rglob(binary_name) if path.is_file()] 247 + if not matches: 248 + raise ParakeetProviderError( 249 + "binary_missing", 250 + f"Extracted archive did not contain {binary_name}", 251 + ) 252 + if len(matches) > 1: 253 + matches.sort(key=lambda path: len(path.parts)) 254 + return matches[0] 255 + 256 + 257 + def _chmod_executable(path: Path) -> None: 258 + mode = path.stat().st_mode 259 + path.chmod(mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) 260 + 261 + 262 + def probe_binary_runnable(binary_path: str | Path) -> tuple[bool, str | None]: 263 + import subprocess 264 + 265 + try: 266 + completed = subprocess.run( 267 + [str(binary_path), "--version"], 268 + capture_output=True, 269 + text=True, 270 + timeout=_PROBE_TIMEOUT_SECONDS, 271 + check=False, 272 + ) 273 + except subprocess.TimeoutExpired: 274 + return False, f"timed out after {_PROBE_TIMEOUT_SECONDS}s" 275 + except Exception as exc: 276 + return False, str(exc) 277 + 278 + if completed.returncode == 0: 279 + return True, None 280 + 281 + detail = ( 282 + (completed.stderr or "").strip() 283 + or (completed.stdout or "").strip() 284 + or f"exited with status {completed.returncode}" 285 + ) 286 + return False, detail 287 + 288 + 289 + def install_parakeet_server(backend: str) -> dict[str, Any]: 290 + backend = _validate_backend(backend) 291 + artifact_key = parakeet_server_artifact_key() 292 + pin = _pin_for_backend(artifact_key, backend) 293 + url = ( 294 + "https://github.com/mudler/parakeet.cpp/releases/download/" 295 + f"{parakeet_readiness.PARAKEET_CPP_RELEASE_TAG}/{pin['filename']}" 296 + ) 297 + install_dir = binary_install_dir(backend) 298 + tarball = install_dir / pin["filename"] 299 + 300 + try: 301 + _write_parakeet_status( 302 + transition_state(_read_parakeet_status(), new_state="downloading") 303 + ) 304 + _write_parakeet_metadata({f"binary_artifact_{backend}": pin["filename"]}) 305 + _download_file(url, tarball, on_progress=_record_parakeet_progress) 306 + _write_parakeet_status( 307 + transition_state(_read_parakeet_status(), new_state="verifying") 308 + ) 309 + _verify_sha256(tarball, pin["sha256"]) 310 + if install_dir.exists(): 311 + for child in install_dir.iterdir(): 312 + if child != tarball: 313 + if child.is_dir(): 314 + shutil.rmtree(child) 315 + else: 316 + child.unlink() 317 + _safe_extract_tarball(tarball, install_dir) 318 + extracted = _find_extracted_binary( 319 + install_dir, parakeet_readiness.PARAKEET_CPP_BINARY_NAME 320 + ) 321 + final_path = binary_path(backend) 322 + inner_dir = extracted.parent 323 + if inner_dir != install_dir: 324 + for item in inner_dir.iterdir(): 325 + shutil.move(str(item), str(install_dir / item.name)) 326 + inner_dir.rmdir() 327 + _chmod_executable(final_path) 328 + _write_parakeet_metadata( 329 + { 330 + f"binary_artifact_{backend}": pin["filename"], 331 + f"binary_sha256_{backend}": pin["sha256"], 332 + f"binary_path_{backend}": str(final_path), 333 + } 334 + ) 335 + return _write_parakeet_status( 336 + transition_state(_read_parakeet_status(), new_state="installed") 337 + ) 338 + except Exception as exc: 339 + _write_parakeet_status( 340 + transition_state( 341 + _read_parakeet_status(), new_state="failed", error=str(exc) 342 + ) 343 + ) 344 + raise 345 + 346 + 347 + def install_model() -> dict[str, Any]: 348 + spec = PARAKEET_MODEL_SPEC 349 + url = f"https://huggingface.co/{spec.repo}/resolve/{spec.revision}/{spec.filename}" 350 + dest = model_path() 351 + 352 + try: 353 + _write_parakeet_status( 354 + transition_state(_read_parakeet_status(), new_state="downloading") 355 + ) 356 + _write_parakeet_metadata( 357 + { 358 + "model_repo": spec.repo, 359 + "model_filename": spec.filename, 360 + "model_revision": spec.revision, 361 + } 362 + ) 363 + _download_file(url, dest, on_progress=_record_parakeet_progress) 364 + _write_parakeet_status( 365 + transition_state(_read_parakeet_status(), new_state="verifying") 366 + ) 367 + _verify_sha256(dest, spec.sha256) 368 + _write_parakeet_metadata( 369 + { 370 + "model_repo": spec.repo, 371 + "model_filename": spec.filename, 372 + "model_revision": spec.revision, 373 + "model_path": str(dest), 374 + "model_sha256": spec.sha256, 375 + } 376 + ) 377 + return _write_parakeet_status( 378 + transition_state(_read_parakeet_status(), new_state="installed") 379 + ) 380 + except Exception as exc: 381 + _write_parakeet_status( 382 + transition_state( 383 + _read_parakeet_status(), new_state="failed", error=str(exc) 384 + ) 385 + ) 386 + raise 387 + 388 + 389 + def install_parakeet() -> dict[str, Any]: 390 + for backend in parakeet_readiness.PARAKEET_CPP_BINARY_BACKENDS: 391 + install_parakeet_server(backend) 392 + return install_model() 393 + 394 + 395 + def inspect_readiness() -> dict[str, Any]: 396 + status = _read_parakeet_status() 397 + cpu_path = binary_path("cpu") 398 + vulkan_path = binary_path("vulkan") 399 + gguf_path = model_path() 400 + cpu_installed = cpu_path.exists() and os.access(cpu_path, os.X_OK) 401 + vulkan_installed = vulkan_path.exists() and os.access(vulkan_path, os.X_OK) 402 + model_installed = gguf_path.exists() 403 + return { 404 + "install_state": status["install_state"], 405 + "binary_installed": cpu_installed and vulkan_installed, 406 + "binary_cpu_installed": cpu_installed, 407 + "binary_vulkan_installed": vulkan_installed, 408 + "model_installed": model_installed, 409 + "binary_path_cpu": str(cpu_path), 410 + "binary_path_vulkan": str(vulkan_path), 411 + "model_path": str(gguf_path), 412 + "install_error": status["install_error"], 413 + } 414 + 415 + 416 + def ensure_artifacts_installed(backend: str) -> tuple[Path, Path]: 417 + backend = _validate_backend(backend) 418 + readiness = inspect_readiness() 419 + if not readiness["binary_installed"]: 420 + raise ParakeetProviderError( 421 + "binary_missing", "Parakeet server binaries are not installed." 422 + ) 423 + if not readiness["model_installed"]: 424 + raise ParakeetProviderError("model_missing", "Parakeet model is not installed.") 425 + return Path(readiness[f"binary_path_{backend}"]), Path(readiness["model_path"]) 426 + 427 + 428 + __all__ = [ 429 + "PARAKEET_MODEL_SPEC", 430 + "PARAKEET_PROVIDER_NAME", 431 + "PARAKEET_SERVER_PINS", 432 + "ParakeetModelSpec", 433 + "ParakeetProviderError", 434 + "binary_install_dir", 435 + "binary_path", 436 + "cache_root", 437 + "ensure_artifacts_installed", 438 + "inspect_readiness", 439 + "install_hint", 440 + "install_model", 441 + "install_parakeet", 442 + "install_parakeet_server", 443 + "model_dir", 444 + "model_path", 445 + "parakeet_server_artifact_key", 446 + "probe_binary_runnable", 447 + ]
+82
solstone/think/providers/parakeet_server.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Connect-only client for the supervisor-owned Parakeet STT service.""" 5 + 6 + from __future__ import annotations 7 + 8 + from dataclasses import dataclass 9 + 10 + from solstone.think import parakeet_readiness 11 + from solstone.think.providers.parakeet_install import ParakeetProviderError 12 + from solstone.think.utils import read_service_port 13 + 14 + STATE_READY = "ready" 15 + STATE_FAILED = "failed" 16 + 17 + _HOST = "127.0.0.1" 18 + _SERVICE_NAME = "parakeet-cpp" 19 + 20 + 21 + class ParakeetServerNotReady(ParakeetProviderError): 22 + """The supervised Parakeet STT service is not ready for retryable work.""" 23 + 24 + def __init__(self, message: str) -> None: 25 + super().__init__("parakeet_server_not_ready", message) 26 + 27 + 28 + @dataclass(frozen=True) 29 + class ParakeetServerInfo: 30 + model_id: str 31 + port: int 32 + base_url: str 33 + state: str 34 + 35 + 36 + def _base_url(port: int) -> str: 37 + return f"http://{_HOST}:{port}" 38 + 39 + 40 + def _probe_health(port: int, timeout_s: float = 1.0) -> tuple[str, str | None]: 41 + import httpx 42 + 43 + try: 44 + response = httpx.get(f"{_base_url(port)}/health", timeout=timeout_s) 45 + except Exception as exc: 46 + return STATE_FAILED, str(exc) 47 + if response.status_code == 200: 48 + return STATE_READY, None 49 + return STATE_FAILED, f"HTTP {response.status_code}: {response.text[:200]}" 50 + 51 + 52 + def probe_state() -> tuple[str, str | None]: 53 + port = read_service_port(_SERVICE_NAME) 54 + if port is None: 55 + return STATE_FAILED, "no port" 56 + return _probe_health(port) 57 + 58 + 59 + def connect() -> ParakeetServerInfo: 60 + port = read_service_port(_SERVICE_NAME) 61 + if port is None: 62 + raise ParakeetServerNotReady("Parakeet server is not ready yet.") 63 + state, error = _probe_health(port) 64 + if state != STATE_READY: 65 + detail = f": {error}" if error else "" 66 + raise ParakeetServerNotReady(f"Parakeet server is not ready yet{detail}") 67 + return ParakeetServerInfo( 68 + model_id=parakeet_readiness.PARAKEET_CPP_MODEL_FILENAME, 69 + port=port, 70 + base_url=_base_url(port), 71 + state=STATE_READY, 72 + ) 73 + 74 + 75 + __all__ = [ 76 + "STATE_FAILED", 77 + "STATE_READY", 78 + "ParakeetServerInfo", 79 + "ParakeetServerNotReady", 80 + "connect", 81 + "probe_state", 82 + ]
+236 -2
solstone/think/supervisor.py
··· 20 20 import threading 21 21 import time 22 22 from collections import OrderedDict, deque 23 + from dataclasses import dataclass 23 24 from datetime import datetime, timezone 24 25 from logging.handlers import RotatingFileHandler 25 26 from pathlib import Path ··· 44 45 poll_display_powersave, 45 46 reset_display_powersave_monitor, 46 47 ) 48 + from solstone.think.journal_config import read_journal_config 47 49 from solstone.think.maint import run_pending_tasks 48 50 from solstone.think.models import LOCAL_MODEL, is_local_provider_needed 49 51 from solstone.think.processing import ( ··· 107 109 LOCAL_WEDGE_PROVIDER_MAP_CAP = 512 108 110 LOCAL_SERVER_READY_TIMEOUT_S = 300.0 109 111 LOCAL_SERVER_HEALTH_POLL_INTERVAL_S = 1.0 112 + PARAKEET_SERVER_PROCESS_NAME = "parakeet-server" 113 + PARAKEET_SERVER_READY_TIMEOUT_S = 300.0 114 + PARAKEET_SERVER_HEALTH_POLL_INTERVAL_S = 1.0 110 115 LOCAL_MODEL_WARMING_UP_COPY = "Local model is warming up..." 111 116 # supervisor.log is size-rotated with a bounded on-disk footprint. 112 117 # Hard ceiling = SUPERVISOR_LOG_MAX_BYTES * (SUPERVISOR_LOG_BACKUP_COUNT + 1) ··· 114 119 SUPERVISOR_LOG_MAX_BYTES = 16 * 1024 * 1024 115 120 SUPERVISOR_LOG_BACKUP_COUNT = 5 116 121 logger = logging.getLogger(__name__) 122 + 123 + 124 + def configured_stt_backend() -> str | None: 125 + config = read_journal_config() 126 + transcribe = config.get("transcribe", {}) 127 + backend = transcribe.get("backend") if isinstance(transcribe, dict) else None 128 + return backend if isinstance(backend, str) else None 129 + 130 + 131 + def _configured_parakeet_device() -> str: 132 + config = read_journal_config() 133 + transcribe = config.get("transcribe", {}) 134 + nested = transcribe.get("parakeet-cpp", {}) if isinstance(transcribe, dict) else {} 135 + device = nested.get("device") if isinstance(nested, dict) else None 136 + return device if device in {"auto", "cpu"} else "auto" 117 137 118 138 119 139 def _compact_log_if_oversized(log_path: Path, max_bytes: int) -> None: ··· 399 419 # (f"{binary}:{cmd}"). setproctitle is in-process and persists until the 400 420 # process exits, so an orphaned service or task child still reports its title 401 421 # via proc.name() after the supervisor dies, which is what lets the sweep find 402 - # it. The supervisor-owned `llama-server` reports its own bare binary name (no 403 - # colon prefix) and is included here so the sweep reaps it too. 422 + # it. Supervisor-owned local provider servers report their own bare binary names 423 + # (no colon prefix) and are included here so the sweep reaps them too. 404 424 # The mlx-vlm server is a Python process, but our launcher sets the same 405 425 # managed proctitle so proc.name() is stable for orphan sweeping. 406 426 _LOCAL_SERVER_PROCTITLES = frozenset( 407 427 { 408 428 LOCAL_SERVER_PROCESS_NAME, 429 + PARAKEET_SERVER_PROCESS_NAME, 409 430 MLX_SERVER_PROCESS_NAME, 410 431 } 411 432 ) ··· 1814 1835 logging.info("llama-server slot count not reported; skipped") 1815 1836 1816 1837 1838 + def parakeet_physical_thread_count() -> int: 1839 + physical = psutil.cpu_count(logical=False) 1840 + if isinstance(physical, int) and physical > 0: 1841 + return physical 1842 + return max(1, (os.cpu_count() or 2) // 2) 1843 + 1844 + 1845 + @dataclass(frozen=True) 1846 + class ParakeetServerLaunchPlan: 1847 + binary_backend: str 1848 + env_updates: dict[str, str] 1849 + gpu_index: int | None 1850 + 1851 + 1852 + def resolve_parakeet_server_launch_plan( 1853 + config_device: str, selected_gpu: Any 1854 + ) -> ParakeetServerLaunchPlan: 1855 + if config_device not in {"auto", "cpu"}: 1856 + raise ValueError( 1857 + f"parakeet device must be 'auto' or 'cpu', got {config_device!r}" 1858 + ) 1859 + if config_device == "cpu": 1860 + return ParakeetServerLaunchPlan("cpu", {}, None) 1861 + if selected_gpu is not None: 1862 + return ParakeetServerLaunchPlan( 1863 + "vulkan", 1864 + {"GGML_VK_VISIBLE_DEVICES": str(selected_gpu.index)}, 1865 + selected_gpu.index, 1866 + ) 1867 + return ParakeetServerLaunchPlan("cpu", {}, None) 1868 + 1869 + 1870 + def _build_parakeet_cmd( 1871 + binary_path: Path, gguf_path: Path, port: int, threads: int 1872 + ) -> list[str]: 1873 + # Re-verify exact parakeet.cpp v0.4.0 CLI flag spellings at live bring-up. 1874 + cmd = [ 1875 + str(binary_path), 1876 + "--model", 1877 + str(gguf_path), 1878 + "--host", 1879 + "127.0.0.1", 1880 + "--port", 1881 + str(port), 1882 + "--threads", 1883 + str(threads), 1884 + ] 1885 + if "0.0.0.0" in cmd: 1886 + raise RuntimeError("parakeet server may not bind 0.0.0.0.") 1887 + return cmd 1888 + 1889 + 1890 + def _launch_and_warm_parakeet( 1891 + backend: str, 1892 + binary_path: Path, 1893 + gguf_path: Path, 1894 + port: int, 1895 + threads: int, 1896 + env: dict[str, str], 1897 + ) -> tuple[str, RunnerManagedProcess]: 1898 + """Launch one parakeet-server backend and warm it. 1899 + 1900 + Returns (status, managed), where status is "ready", "crashed", or 1901 + "timeout". Does not clean up on failure; the caller owns termination. 1902 + """ 1903 + from solstone.think.providers import parakeet_server 1904 + 1905 + cmd = _build_parakeet_cmd(binary_path, gguf_path, port, threads) 1906 + managed = _launch_process(PARAKEET_SERVER_PROCESS_NAME, cmd, restart=True, env=env) 1907 + deadline = time.monotonic() + PARAKEET_SERVER_READY_TIMEOUT_S 1908 + while time.monotonic() < deadline: 1909 + if managed.process.poll() is not None: 1910 + logging.warning( 1911 + "parakeet-server %s exited during warmup with code %s", 1912 + backend, 1913 + managed.process.returncode, 1914 + ) 1915 + return "crashed", managed 1916 + state, error = parakeet_server.probe_state() 1917 + if state == parakeet_server.STATE_READY: 1918 + logging.info("parakeet-server ready on port %s", port) 1919 + return "ready", managed 1920 + if state == parakeet_server.STATE_FAILED and error: 1921 + logging.debug( 1922 + "parakeet-server health probe failed during warmup: %s", error 1923 + ) 1924 + time.sleep(PARAKEET_SERVER_HEALTH_POLL_INTERVAL_S) 1925 + logging.warning( 1926 + "parakeet-server %s did not become ready within %.0fs", 1927 + backend, 1928 + PARAKEET_SERVER_READY_TIMEOUT_S, 1929 + ) 1930 + return "timeout", managed 1931 + 1932 + 1933 + def _cleanup_parakeet_launch(managed: RunnerManagedProcess, reason: str) -> None: 1934 + timeout = _SERVICE_STATE.get(managed.name, {}).get("shutdown_timeout", 15) 1935 + _SERVICE_STATE.pop(managed.name, None) 1936 + _terminate_managed(managed, timeout, reason=reason) 1937 + managed.cleanup() 1938 + 1939 + 1817 1940 def start_local_server() -> RunnerManagedProcess | None: 1818 1941 """Launch the supervisor-owned local llama-server when artifacts are present.""" 1819 1942 from solstone.think.providers.local_endpoint import resolve_local_endpoint ··· 1961 2084 "llama-server did not become ready within %.0fs; continuing startup", 1962 2085 LOCAL_SERVER_READY_TIMEOUT_S, 1963 2086 ) 2087 + return managed 2088 + 2089 + 2090 + def start_parakeet_server() -> RunnerManagedProcess | None: 2091 + """Launch the supervisor-owned parakeet-server when STT opts into it.""" 2092 + if not sys.platform.startswith("linux"): 2093 + return None 2094 + if configured_stt_backend() != "parakeet-cpp": 2095 + return None 2096 + 2097 + from solstone.think.providers import local_vulkan, parakeet_install 2098 + 2099 + config_device = _configured_parakeet_device() 2100 + selected = None 2101 + if config_device == "auto": 2102 + devices = local_vulkan.detect_gpus() 2103 + selected = local_vulkan.select_device(devices) 2104 + logging.info( 2105 + "Vulkan GPU probe: devices=%s; selected=%s", 2106 + _format_vulkan_devices(devices, local_vulkan), 2107 + ( 2108 + f"raw_index={selected.index} name={selected.name!r} " 2109 + f"type={local_vulkan.classify(selected)}" 2110 + if selected is not None 2111 + else "none" 2112 + ), 2113 + ) 2114 + 2115 + plan = resolve_parakeet_server_launch_plan(config_device, selected) 2116 + try: 2117 + binary_path, gguf_path = parakeet_install.ensure_artifacts_installed( 2118 + plan.binary_backend 2119 + ) 2120 + except Exception as exc: 2121 + logging.info( 2122 + "Parakeet artifacts not ready; skipping parakeet-server startup: %s", exc 2123 + ) 2124 + return None 2125 + 2126 + port = find_available_port() 2127 + write_service_port("parakeet-cpp", port) 2128 + threads = parakeet_physical_thread_count() 2129 + logging.info("parakeet-server threads=%d", threads) 2130 + env = os.environ | plan.env_updates 2131 + status, managed = _launch_and_warm_parakeet( 2132 + plan.binary_backend, 2133 + binary_path, 2134 + gguf_path, 2135 + port, 2136 + threads, 2137 + env, 2138 + ) 2139 + if status == "ready": 2140 + return managed 2141 + 2142 + if plan.binary_backend == "vulkan" and status in {"crashed", "timeout"}: 2143 + logging.warning("parakeet-server vulkan warmup %s; falling back to CPU", status) 2144 + _cleanup_parakeet_launch( 2145 + managed, reason=f"parakeet-server vulkan warmup {status}" 2146 + ) 2147 + try: 2148 + cpu_binary, _ = parakeet_install.ensure_artifacts_installed("cpu") 2149 + except Exception as exc: 2150 + logging.info( 2151 + "Parakeet CPU artifacts not ready; skipping fallback startup: %s", exc 2152 + ) 2153 + return None 2154 + cpu_status, cpu_managed = _launch_and_warm_parakeet( 2155 + "cpu", 2156 + cpu_binary, 2157 + gguf_path, 2158 + port, 2159 + threads, 2160 + os.environ | {}, 2161 + ) 2162 + if cpu_status == "crashed": 2163 + logging.warning("parakeet-server cpu exited during warmup") 2164 + _cleanup_parakeet_launch( 2165 + cpu_managed, reason="parakeet-server cpu warmup crashed" 2166 + ) 2167 + return None 2168 + if cpu_status == "timeout": 2169 + logging.warning( 2170 + "parakeet-server cpu did not become ready within %.0fs; " 2171 + "continuing startup", 2172 + PARAKEET_SERVER_READY_TIMEOUT_S, 2173 + ) 2174 + return cpu_managed 2175 + 2176 + if plan.binary_backend == "cpu": 2177 + if status == "crashed": 2178 + logging.warning("parakeet-server cpu exited during warmup") 2179 + _cleanup_parakeet_launch( 2180 + managed, reason="parakeet-server cpu warmup crashed" 2181 + ) 2182 + return None 2183 + if status == "timeout": 2184 + logging.warning( 2185 + "parakeet-server cpu did not become ready within %.0fs; " 2186 + "continuing startup", 2187 + PARAKEET_SERVER_READY_TIMEOUT_S, 2188 + ) 2189 + return managed 2190 + 1964 2191 return managed 1965 2192 1966 2193 ··· 2997 3224 proc = start_local_server() 2998 3225 if proc is not None: 2999 3226 procs.append(proc) 3227 + if ( 3228 + sys.platform.startswith("linux") 3229 + and configured_stt_backend() == "parakeet-cpp" 3230 + ): 3231 + parakeet_proc = start_parakeet_server() 3232 + if parakeet_proc is not None: 3233 + procs.append(parakeet_proc) 3000 3234 # Sense handles file processing 3001 3235 print(" Starting sense...", flush=True) 3002 3236 procs.append(start_sense())
+10
tests/baselines/api/settings/transcribe.json
··· 2 2 "api_keys": { 3 3 "gemini": false, 4 4 "parakeet": false, 5 + "parakeet-cpp": false, 5 6 "revai": false, 6 7 "whisper": false 7 8 }, ··· 42 43 "model_version", 43 44 "device", 44 45 "timeout_sec" 46 + ] 47 + }, 48 + { 49 + "description": "On-device speech recognition via a supervised parakeet.cpp server (mudler/parakeet.cpp). Linux only; opt-in alongside the default Parakeet backend. Install with `journal install-provider parakeet`.", 50 + "env_key": null, 51 + "label": "Parakeet.cpp - Local processing (Linux, selectable)", 52 + "name": "parakeet-cpp", 53 + "settings": [ 54 + "device" 45 55 ] 46 56 } 47 57 ],
+145
tests/test_doctor.py
··· 357 357 assert doctor.CHECK_MAP["default_stt_ready"].severity == "advisory" 358 358 359 359 360 + class TestParakeetCppSttReady: 361 + @staticmethod 362 + def make_ready_files(doctor, cache_root: Path, artifact_key: str) -> None: 363 + for backend in doctor.parakeet_readiness.PARAKEET_CPP_BINARY_BACKENDS: 364 + path = doctor.parakeet_readiness.parakeet_cpp_binary_path( 365 + cache_root, artifact_key, backend 366 + ) 367 + path.parent.mkdir(parents=True, exist_ok=True) 368 + path.write_text("#!/bin/sh\n", encoding="utf-8") 369 + path.chmod(0o755) 370 + model_path = doctor.parakeet_readiness.parakeet_cpp_model_path(cache_root) 371 + model_path.parent.mkdir(parents=True, exist_ok=True) 372 + model_path.write_text("model", encoding="utf-8") 373 + 374 + def test_registered_for_journal_and_journal_readiness_only(self, doctor): 375 + pair = ( 376 + doctor.PARAKEET_CPP_STT_READY_CHECK, 377 + doctor.parakeet_cpp_stt_ready_check, 378 + ) 379 + 380 + assert doctor.PARAKEET_CPP_STT_READY_CHECK.name in doctor.CHECK_MAP 381 + assert pair in doctor.JOURNAL_CHECKS 382 + assert pair in doctor.JOURNAL_READINESS_CHECKS 383 + assert pair not in doctor.UNIVERSAL_CHECKS 384 + assert pair not in doctor.READINESS_CHECKS 385 + 386 + def test_skips_when_backend_is_not_parakeet_cpp(self, doctor, monkeypatch): 387 + monkeypatch.setattr(doctor, "_resolve_configured_backend", lambda: "parakeet") 388 + 389 + result = doctor.parakeet_cpp_stt_ready_check(args(doctor)) 390 + 391 + assert result.status == "skip" 392 + assert ( 393 + result.detail 394 + == "configured backend is not parakeet-cpp; check not applicable" 395 + ) 396 + 397 + def test_skips_off_linux(self, doctor, monkeypatch): 398 + monkeypatch.setattr( 399 + doctor, "_resolve_configured_backend", lambda: "parakeet-cpp" 400 + ) 401 + monkeypatch.setattr( 402 + doctor.parakeet_readiness, 403 + "_platform_info", 404 + lambda: ("darwin", "arm64"), 405 + ) 406 + 407 + result = doctor.parakeet_cpp_stt_ready_check(args(doctor)) 408 + 409 + assert result.status == "skip" 410 + assert result.detail == "parakeet-cpp is only supported on Linux" 411 + 412 + def test_warns_with_install_fix_when_files_missing( 413 + self, doctor, monkeypatch, tmp_path 414 + ): 415 + journal = tmp_path / "journal" 416 + monkeypatch.setattr( 417 + doctor, "_resolve_configured_backend", lambda: "parakeet-cpp" 418 + ) 419 + monkeypatch.setattr( 420 + doctor.parakeet_readiness, 421 + "_platform_info", 422 + lambda: ("linux", "x86_64"), 423 + ) 424 + monkeypatch.setattr(doctor, "get_journal_info", lambda: (str(journal), "env")) 425 + 426 + result = doctor.parakeet_cpp_stt_ready_check(args(doctor)) 427 + 428 + assert result.status == "warn" 429 + assert "missing" in result.detail 430 + assert result.fix == doctor._PARAKEET_CPP_INSTALL_FIX 431 + 432 + def test_warns_with_start_fix_when_server_not_ready( 433 + self, doctor, monkeypatch, tmp_path 434 + ): 435 + from solstone.think.providers import parakeet_server 436 + 437 + journal = tmp_path / "journal" 438 + artifact_key = "x86_64-unknown-linux-gnu" 439 + cache_root = doctor.parakeet_readiness.parakeet_cpp_cache_root(journal) 440 + self.make_ready_files(doctor, cache_root, artifact_key) 441 + monkeypatch.setattr( 442 + doctor, "_resolve_configured_backend", lambda: "parakeet-cpp" 443 + ) 444 + monkeypatch.setattr( 445 + doctor.parakeet_readiness, 446 + "_platform_info", 447 + lambda: ("linux", "x86_64"), 448 + ) 449 + monkeypatch.setattr(doctor, "get_journal_info", lambda: (str(journal), "env")) 450 + monkeypatch.setattr( 451 + parakeet_server, 452 + "probe_state", 453 + lambda: (parakeet_server.STATE_FAILED, "no port"), 454 + ) 455 + 456 + result = doctor.parakeet_cpp_stt_ready_check(args(doctor)) 457 + 458 + assert result.status == "warn" 459 + assert result.detail == "parakeet-server not reachable: no port" 460 + assert result.fix == doctor._PARAKEET_CPP_START_FIX 461 + 462 + def test_ok_when_files_present_and_server_ready( 463 + self, doctor, monkeypatch, tmp_path 464 + ): 465 + from solstone.think.providers import parakeet_server 466 + 467 + journal = tmp_path / "journal" 468 + artifact_key = "x86_64-unknown-linux-gnu" 469 + cache_root = doctor.parakeet_readiness.parakeet_cpp_cache_root(journal) 470 + self.make_ready_files(doctor, cache_root, artifact_key) 471 + monkeypatch.setattr( 472 + doctor, "_resolve_configured_backend", lambda: "parakeet-cpp" 473 + ) 474 + monkeypatch.setattr( 475 + doctor.parakeet_readiness, 476 + "_platform_info", 477 + lambda: ("linux", "x86_64"), 478 + ) 479 + monkeypatch.setattr(doctor, "get_journal_info", lambda: (str(journal), "env")) 480 + monkeypatch.setattr( 481 + parakeet_server, 482 + "probe_state", 483 + lambda: (parakeet_server.STATE_READY, None), 484 + ) 485 + 486 + result = doctor.parakeet_cpp_stt_ready_check(args(doctor)) 487 + 488 + assert result.status == "ok" 489 + assert result.detail == ( 490 + "parakeet-cpp ready (binaries + model installed, server reachable)" 491 + ) 492 + 493 + def test_does_not_create_absent_journal_when_not_configured( 494 + self, doctor, monkeypatch, tmp_path 495 + ): 496 + journal = tmp_path / "missing-journal" 497 + monkeypatch.setattr(doctor, "get_journal_info", lambda: (str(journal), "env")) 498 + 499 + result = doctor.parakeet_cpp_stt_ready_check(args(doctor)) 500 + 501 + assert result.status == "skip" 502 + assert not journal.exists() 503 + 504 + 360 505 class TestHostDependencies: 361 506 def test_client_readiness_battery_is_host_free(self, doctor, monkeypatch): 362 507 expected = [
+58 -5
tests/test_install_provider.py
··· 11 11 12 12 def test_install_provider_local_prints_install_status(monkeypatch, capsys): 13 13 calls = [] 14 + parakeet_calls = [] 14 15 15 16 def install_local(): 16 17 calls.append(True) 17 18 return {"name": "local", "install_state": "installed"} 18 19 20 + def install_parakeet(): 21 + parakeet_calls.append(True) 22 + return {"name": "parakeet", "install_state": "installed"} 23 + 19 24 monkeypatch.setattr(sys, "argv", ["journal install-provider", "local"]) 20 25 monkeypatch.setattr(install_provider.local_install, "install_local", install_local) 26 + monkeypatch.setattr( 27 + install_provider.parakeet_install, 28 + "install_parakeet", 29 + install_parakeet, 30 + ) 21 31 22 32 assert install_provider.main() == 0 23 33 24 34 assert calls == [True] 35 + assert parakeet_calls == [] 25 36 assert json.loads(capsys.readouterr().out) == { 26 37 "name": "local", 27 38 "install_state": "installed", 28 39 } 29 40 30 41 31 - def test_install_provider_non_local_rejects_without_install(monkeypatch, capsys): 42 + def test_install_provider_parakeet_prints_disclosure_and_status(monkeypatch, capsys): 32 43 calls = [] 33 44 45 + def install_parakeet(): 46 + calls.append(True) 47 + return {"name": "parakeet", "install_state": "installed"} 48 + 49 + monkeypatch.setattr(sys, "argv", ["journal install-provider", "parakeet"]) 50 + monkeypatch.setattr( 51 + install_provider.parakeet_install, 52 + "install_parakeet", 53 + install_parakeet, 54 + ) 55 + 56 + assert install_provider.main() == 0 57 + 58 + captured = capsys.readouterr() 59 + assert calls == [True] 60 + assert json.loads(captured.out) == { 61 + "name": "parakeet", 62 + "install_state": "installed", 63 + } 64 + assert install_provider.PARAKEET_DOWNLOAD_DISCLOSURE in captured.err 65 + assert "github.com" in captured.err 66 + assert "huggingface.co" in captured.err 67 + banned = {"capture", "watch", "record", "monitor", "track", "collect"} 68 + assert not (banned & set(captured.err.lower().split())) 69 + 70 + 71 + def test_install_provider_unsupported_rejects_without_install(monkeypatch, capsys): 72 + local_calls = [] 73 + parakeet_calls = [] 74 + 34 75 def install_local(): 35 - calls.append(True) 76 + local_calls.append(True) 36 77 return {"name": "local", "install_state": "installed"} 37 78 38 - monkeypatch.setattr(sys, "argv", ["journal install-provider", "anthropic"]) 79 + def install_parakeet(): 80 + parakeet_calls.append(True) 81 + return {"name": "parakeet", "install_state": "installed"} 82 + 83 + monkeypatch.setattr(sys, "argv", ["journal install-provider", "foo"]) 39 84 monkeypatch.setattr(install_provider.local_install, "install_local", install_local) 85 + monkeypatch.setattr( 86 + install_provider.parakeet_install, 87 + "install_parakeet", 88 + install_parakeet, 89 + ) 40 90 41 91 assert install_provider.main() == 2 42 92 43 93 captured = capsys.readouterr() 44 - assert calls == [] 45 - assert "only 'local' is supported" in captured.err 94 + assert local_calls == [] 95 + assert parakeet_calls == [] 96 + assert "unsupported provider 'foo'" in captured.err 97 + assert "local" in captured.err 98 + assert "parakeet" in captured.err
+264
tests/test_parakeet_install.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + from __future__ import annotations 5 + 6 + import io 7 + import os 8 + import shutil 9 + import tarfile 10 + from pathlib import Path 11 + 12 + import pytest 13 + 14 + from solstone.think import parakeet_readiness 15 + from solstone.think.journal_config import read_journal_config 16 + from solstone.think.providers import parakeet_install 17 + from solstone.think.providers.install_state import read_install_status 18 + 19 + 20 + def _init_journal(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: 21 + config_dir = tmp_path / "config" 22 + config_dir.mkdir(parents=True) 23 + (config_dir / "journal.json").write_text('{"providers": {}}\n', encoding="utf-8") 24 + monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) 25 + import solstone.think.utils as think_utils 26 + 27 + think_utils._journal_path_cache = None 28 + 29 + 30 + def _parakeet_status() -> dict: 31 + return read_install_status(scope="bundled", name="parakeet") 32 + 33 + 34 + def _parakeet_slot() -> dict: 35 + return read_journal_config()["providers"]["bundled"]["parakeet"] 36 + 37 + 38 + def _server_tarball(tmp_path: Path, backend: str) -> Path: 39 + inner_name = ( 40 + f"parakeet-{parakeet_readiness.PARAKEET_CPP_RELEASE_TAG}-bin-linux-" 41 + f"{backend}-x64" 42 + ) 43 + fixture_root = tmp_path / f"fixture-{backend}" / inner_name 44 + fixture_root.mkdir(parents=True) 45 + (fixture_root / parakeet_readiness.PARAKEET_CPP_BINARY_NAME).write_bytes( 46 + f"fake {parakeet_readiness.PARAKEET_CPP_BINARY_NAME} {backend}".encode() 47 + ) 48 + (fixture_root / "LICENSE").write_text("license\n", encoding="utf-8") 49 + (fixture_root / "README.md").write_text("readme\n", encoding="utf-8") 50 + (fixture_root / "parakeet-cli").write_text("cli\n", encoding="utf-8") 51 + tarball = tmp_path / f"{backend}.tar.gz" 52 + with tarfile.open(tarball, "w:gz") as archive: 53 + archive.add(fixture_root, arcname=inner_name) 54 + return tarball 55 + 56 + 57 + def _stage_ready_files() -> tuple[Path, Path, Path]: 58 + cpu = parakeet_install.binary_path("cpu") 59 + vulkan = parakeet_install.binary_path("vulkan") 60 + model = parakeet_install.model_path() 61 + for path in (cpu, vulkan): 62 + path.parent.mkdir(parents=True, exist_ok=True) 63 + path.write_text("server\n", encoding="utf-8") 64 + path.chmod(0o755) 65 + model.parent.mkdir(parents=True, exist_ok=True) 66 + model.write_text("model\n", encoding="utf-8") 67 + return cpu, vulkan, model 68 + 69 + 70 + def test_install_hint_literal() -> None: 71 + assert parakeet_install.install_hint() == "journal install-provider parakeet" 72 + 73 + 74 + def test_parakeet_server_pins_cover_expected_platforms_and_backends() -> None: 75 + assert parakeet_install.PARAKEET_SERVER_PINS == { 76 + ("x86_64-unknown-linux-gnu", "vulkan"): { 77 + "filename": "parakeet-v0.4.0-bin-linux-vulkan-x64.tar.gz", 78 + "sha256": "12ee636ccb4a8b3c8f316f1f40c63f5aa4da178bf11563795b39385480ede87e", 79 + }, 80 + ("x86_64-unknown-linux-gnu", "cpu"): { 81 + "filename": "parakeet-v0.4.0-bin-linux-cpu-x64.tar.gz", 82 + "sha256": "0846509eeb64fcb40e0ad28cd16b5bec5387e4799e08c85fb600b428bb306240", 83 + }, 84 + ("aarch64-unknown-linux-gnu", "vulkan"): { 85 + "filename": "parakeet-v0.4.0-bin-linux-vulkan-arm64.tar.gz", 86 + "sha256": "b1e9251c9d247dffffc5e2db44bb993fb5ec40faab208ec83f7b89b8cc24efd0", 87 + }, 88 + ("aarch64-unknown-linux-gnu", "cpu"): { 89 + "filename": "parakeet-v0.4.0-bin-linux-cpu-arm64.tar.gz", 90 + "sha256": "6634487a4cdbd3185e7a127aa4f22fbc49ec56421f7bfb14f450400260597773", 91 + }, 92 + } 93 + 94 + 95 + def test_non_linux_artifact_key_raises_provider_error( 96 + monkeypatch: pytest.MonkeyPatch, 97 + ) -> None: 98 + monkeypatch.setattr(parakeet_readiness.sys, "platform", "darwin") 99 + monkeypatch.setattr(parakeet_readiness.platform, "machine", lambda: "arm64") 100 + 101 + with pytest.raises(parakeet_install.ParakeetProviderError) as exc_info: 102 + parakeet_install.parakeet_server_artifact_key() 103 + 104 + assert exc_info.value.reason_code == "unsupported_platform" 105 + 106 + 107 + def test_cpu_and_vulkan_install_dirs_are_distinct(tmp_path, monkeypatch) -> None: 108 + _init_journal(tmp_path, monkeypatch) 109 + 110 + assert parakeet_install.binary_install_dir( 111 + "cpu" 112 + ) != parakeet_install.binary_install_dir("vulkan") 113 + 114 + 115 + def test_install_parakeet_server_relocates_and_chmods_binary( 116 + tmp_path, monkeypatch 117 + ) -> None: 118 + _init_journal(tmp_path, monkeypatch) 119 + fixture_tarball = _server_tarball(tmp_path, "cpu") 120 + 121 + def fake_download(_url, dest, **_kwargs): 122 + dest.parent.mkdir(parents=True, exist_ok=True) 123 + shutil.copy2(fixture_tarball, dest) 124 + 125 + monkeypatch.setattr(parakeet_install, "_download_file", fake_download) 126 + monkeypatch.setattr( 127 + parakeet_install, "_verify_sha256", lambda _path, _expected: None 128 + ) 129 + 130 + result = parakeet_install.install_parakeet_server("cpu") 131 + 132 + install_dir = parakeet_install.binary_install_dir("cpu") 133 + final_path = parakeet_install.binary_path("cpu") 134 + assert result["install_state"] == "installed" 135 + assert final_path.exists() 136 + assert ( 137 + final_path.read_bytes() 138 + == f"fake {parakeet_readiness.PARAKEET_CPP_BINARY_NAME} cpu".encode() 139 + ) 140 + assert os.access(final_path, os.X_OK) 141 + assert (install_dir / "LICENSE").is_file() 142 + assert (install_dir / "README.md").is_file() 143 + assert not ( 144 + install_dir 145 + / f"parakeet-{parakeet_readiness.PARAKEET_CPP_RELEASE_TAG}-bin-linux-cpu-x64" 146 + ).exists() 147 + 148 + 149 + def test_sha256_mismatch_fails_closed_and_records_failed_state( 150 + tmp_path, monkeypatch 151 + ) -> None: 152 + _init_journal(tmp_path, monkeypatch) 153 + 154 + def fake_download(_url, dest, **_kwargs): 155 + dest.parent.mkdir(parents=True, exist_ok=True) 156 + dest.write_bytes(b"not the pinned archive") 157 + 158 + monkeypatch.setattr(parakeet_install, "_download_file", fake_download) 159 + 160 + with pytest.raises(parakeet_install.ParakeetProviderError) as exc_info: 161 + parakeet_install.install_parakeet_server("cpu") 162 + 163 + assert exc_info.value.reason_code == "sha256_mismatch" 164 + status = _parakeet_status() 165 + assert status["install_state"] == "failed" 166 + assert status["install_error"] is not None 167 + assert "sha256 mismatch" in status["install_error"] 168 + slot = _parakeet_slot() 169 + assert slot["install_state"] == "failed" 170 + assert "sha256 mismatch" in slot["install_error"] 171 + 172 + 173 + def test_install_parakeet_writes_distinct_binary_and_model_metadata( 174 + tmp_path, monkeypatch 175 + ) -> None: 176 + _init_journal(tmp_path, monkeypatch) 177 + tarballs = { 178 + "cpu": _server_tarball(tmp_path, "cpu"), 179 + "vulkan": _server_tarball(tmp_path, "vulkan"), 180 + } 181 + 182 + def fake_download(_url, dest, **_kwargs): 183 + dest.parent.mkdir(parents=True, exist_ok=True) 184 + if dest.name.endswith(".gguf"): 185 + dest.write_bytes(b"fake gguf") 186 + return 187 + backend = "vulkan" if "vulkan" in dest.name else "cpu" 188 + shutil.copy2(tarballs[backend], dest) 189 + 190 + monkeypatch.setattr(parakeet_install, "_download_file", fake_download) 191 + monkeypatch.setattr( 192 + parakeet_install, "_verify_sha256", lambda _path, _expected: None 193 + ) 194 + 195 + result = parakeet_install.install_parakeet() 196 + 197 + assert result["install_state"] == "installed" 198 + slot = _parakeet_slot() 199 + assert ( 200 + slot["binary_artifact_cpu"] 201 + == parakeet_install.PARAKEET_SERVER_PINS[ 202 + (parakeet_install.parakeet_server_artifact_key(), "cpu") 203 + ]["filename"] 204 + ) 205 + assert ( 206 + slot["binary_artifact_vulkan"] 207 + == parakeet_install.PARAKEET_SERVER_PINS[ 208 + (parakeet_install.parakeet_server_artifact_key(), "vulkan") 209 + ]["filename"] 210 + ) 211 + assert slot["binary_path_cpu"] != slot["binary_path_vulkan"] 212 + assert Path(slot["binary_path_cpu"]).is_file() 213 + assert Path(slot["binary_path_vulkan"]).is_file() 214 + assert slot["model_repo"] == parakeet_readiness.PARAKEET_CPP_MODEL_REPO 215 + assert slot["model_filename"] == parakeet_readiness.PARAKEET_CPP_MODEL_FILENAME 216 + assert slot["model_revision"] == parakeet_readiness.PARAKEET_CPP_MODEL_REVISION 217 + assert slot["model_path"] == str(parakeet_install.model_path()) 218 + assert Path(slot["model_path"]).is_file() 219 + 220 + 221 + def test_ensure_artifacts_installed_resolves_requested_backend( 222 + tmp_path, monkeypatch 223 + ) -> None: 224 + _init_journal(tmp_path, monkeypatch) 225 + cpu, vulkan, model = _stage_ready_files() 226 + 227 + assert parakeet_install.ensure_artifacts_installed("cpu") == (cpu, model) 228 + assert parakeet_install.ensure_artifacts_installed("vulkan") == (vulkan, model) 229 + 230 + 231 + def test_ensure_artifacts_installed_reports_missing_binary_and_model( 232 + tmp_path, monkeypatch 233 + ) -> None: 234 + _init_journal(tmp_path, monkeypatch) 235 + 236 + with pytest.raises(parakeet_install.ParakeetProviderError) as binary_exc: 237 + parakeet_install.ensure_artifacts_installed("cpu") 238 + 239 + assert binary_exc.value.reason_code == "binary_missing" 240 + 241 + for backend in parakeet_readiness.PARAKEET_CPP_BINARY_BACKENDS: 242 + path = parakeet_install.binary_path(backend) 243 + path.parent.mkdir(parents=True, exist_ok=True) 244 + path.write_text("server\n", encoding="utf-8") 245 + path.chmod(0o755) 246 + 247 + with pytest.raises(parakeet_install.ParakeetProviderError) as model_exc: 248 + parakeet_install.ensure_artifacts_installed("cpu") 249 + 250 + assert model_exc.value.reason_code == "model_missing" 251 + 252 + 253 + def test_safe_extract_tarball_rejects_path_traversal(tmp_path) -> None: 254 + tarball = tmp_path / "bad.tar.gz" 255 + data = b"bad" 256 + with tarfile.open(tarball, "w:gz") as archive: 257 + member = tarfile.TarInfo("../escape") 258 + member.size = len(data) 259 + archive.addfile(member, io.BytesIO(data)) 260 + 261 + with pytest.raises(parakeet_install.ParakeetProviderError) as exc_info: 262 + parakeet_install._safe_extract_tarball(tarball, tmp_path / "dest") 263 + 264 + assert exc_info.value.reason_code == "archive_path_traversal"
+93
tests/test_parakeet_readiness_stdlib.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + from __future__ import annotations 5 + 6 + import ast 7 + import sys 8 + from pathlib import Path 9 + 10 + import pytest 11 + 12 + from solstone.think import parakeet_readiness 13 + 14 + ROOT = Path(__file__).resolve().parent.parent 15 + 16 + 17 + def test_parakeet_readiness_imports_stdlib_only() -> None: 18 + source = ROOT / "solstone" / "think" / "parakeet_readiness.py" 19 + tree = ast.parse(source.read_text(encoding="utf-8")) 20 + allowed = set(sys.stdlib_module_names) | {"__future__"} 21 + unexpected = [] 22 + for node in ast.walk(tree): 23 + if isinstance(node, ast.Import): 24 + modules = [alias.name for alias in node.names] 25 + elif isinstance(node, ast.ImportFrom): 26 + modules = [node.module] if node.module else [] 27 + else: 28 + continue 29 + for module in modules: 30 + root = module.split(".", 1)[0] 31 + if root not in allowed: 32 + unexpected.append(module) 33 + 34 + assert unexpected == [] 35 + 36 + 37 + @pytest.mark.parametrize( 38 + ("arch", "expected"), 39 + [ 40 + ("amd64", "x86_64-unknown-linux-gnu"), 41 + ("x64", "x86_64-unknown-linux-gnu"), 42 + ("x86_64", "x86_64-unknown-linux-gnu"), 43 + ("arm64", "aarch64-unknown-linux-gnu"), 44 + ("aarch64", "aarch64-unknown-linux-gnu"), 45 + ], 46 + ) 47 + def test_parakeet_cpp_artifact_key_linux_arches(arch: str, expected: str) -> None: 48 + assert parakeet_readiness.parakeet_cpp_artifact_key("linux", arch) == expected 49 + 50 + 51 + def test_parakeet_cpp_artifact_key_rejects_non_linux() -> None: 52 + with pytest.raises(RuntimeError, match="unsupported"): 53 + parakeet_readiness.parakeet_cpp_artifact_key("darwin", "arm64") 54 + 55 + 56 + def test_check_parakeet_cpp_files_reports_missing_and_ready(tmp_path: Path) -> None: 57 + cache_root = tmp_path / "cache" 58 + artifact_key = "x86_64-unknown-linux-gnu" 59 + 60 + with pytest.raises(RuntimeError, match="binary_cpu missing"): 61 + parakeet_readiness.check_parakeet_cpp_files(cache_root, artifact_key) 62 + 63 + cpu = parakeet_readiness.parakeet_cpp_binary_path(cache_root, artifact_key, "cpu") 64 + cpu.parent.mkdir(parents=True) 65 + cpu.write_text("cpu\n", encoding="utf-8") 66 + cpu.chmod(0o755) 67 + 68 + with pytest.raises(RuntimeError, match="binary_vulkan missing"): 69 + parakeet_readiness.check_parakeet_cpp_files(cache_root, artifact_key) 70 + 71 + vulkan = parakeet_readiness.parakeet_cpp_binary_path( 72 + cache_root, artifact_key, "vulkan" 73 + ) 74 + vulkan.parent.mkdir(parents=True) 75 + vulkan.write_text("vulkan\n", encoding="utf-8") 76 + 77 + with pytest.raises(RuntimeError, match="binary_vulkan not executable"): 78 + parakeet_readiness.check_parakeet_cpp_files(cache_root, artifact_key) 79 + 80 + vulkan.chmod(0o755) 81 + 82 + with pytest.raises(RuntimeError, match="model missing"): 83 + parakeet_readiness.check_parakeet_cpp_files(cache_root, artifact_key) 84 + 85 + model = parakeet_readiness.parakeet_cpp_model_path(cache_root) 86 + model.parent.mkdir(parents=True) 87 + model.write_text("model\n", encoding="utf-8") 88 + 89 + assert parakeet_readiness.check_parakeet_cpp_files(cache_root, artifact_key) == { 90 + "binary_cpu": cpu, 91 + "binary_vulkan": vulkan, 92 + "model": model, 93 + }
+95
tests/test_parakeet_server.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + from __future__ import annotations 5 + 6 + import sys 7 + import types 8 + 9 + import pytest 10 + 11 + from solstone.think import parakeet_readiness 12 + from solstone.think.providers import parakeet_server 13 + 14 + 15 + class _Response: 16 + def __init__(self, status_code: int, text: str = "") -> None: 17 + self.status_code = status_code 18 + self.text = text 19 + 20 + 21 + def _install_fake_httpx(monkeypatch: pytest.MonkeyPatch, get): 22 + fake_httpx = types.SimpleNamespace(get=get) 23 + monkeypatch.setitem(sys.modules, "httpx", fake_httpx) 24 + return fake_httpx 25 + 26 + 27 + def test_no_port_probe_failed_and_connect_not_ready(monkeypatch: pytest.MonkeyPatch): 28 + monkeypatch.setattr(parakeet_server, "read_service_port", lambda _service: None) 29 + 30 + assert parakeet_server.probe_state() == (parakeet_server.STATE_FAILED, "no port") 31 + with pytest.raises(parakeet_server.ParakeetServerNotReady) as exc_info: 32 + parakeet_server.connect() 33 + 34 + assert exc_info.value.reason_code == "parakeet_server_not_ready" 35 + 36 + 37 + def test_connect_returns_info_when_health_ready(monkeypatch: pytest.MonkeyPatch): 38 + observed_urls = [] 39 + 40 + def fake_get(url, timeout): 41 + observed_urls.append((url, timeout)) 42 + return _Response(200, "{}") 43 + 44 + monkeypatch.setattr(parakeet_server, "read_service_port", lambda _service: 4567) 45 + _install_fake_httpx(monkeypatch, fake_get) 46 + 47 + info = parakeet_server.connect() 48 + 49 + assert observed_urls == [("http://127.0.0.1:4567/health", 1.0)] 50 + assert info == parakeet_server.ParakeetServerInfo( 51 + model_id=parakeet_readiness.PARAKEET_CPP_MODEL_FILENAME, 52 + port=4567, 53 + base_url="http://127.0.0.1:4567", 54 + state=parakeet_server.STATE_READY, 55 + ) 56 + 57 + 58 + @pytest.mark.parametrize( 59 + ("response", "expected_detail"), 60 + [ 61 + (_Response(500, "broken"), "HTTP 500: broken"), 62 + (_Response(503, "loading model"), "HTTP 503: loading model"), 63 + ], 64 + ) 65 + def test_non_200_health_is_failed_not_loading( 66 + monkeypatch: pytest.MonkeyPatch, response: _Response, expected_detail: str 67 + ): 68 + monkeypatch.setattr(parakeet_server, "read_service_port", lambda _service: 4567) 69 + _install_fake_httpx(monkeypatch, lambda _url, timeout: response) 70 + 71 + assert parakeet_server.probe_state() == ( 72 + parakeet_server.STATE_FAILED, 73 + expected_detail, 74 + ) 75 + with pytest.raises(parakeet_server.ParakeetServerNotReady) as exc_info: 76 + parakeet_server.connect() 77 + 78 + assert exc_info.value.reason_code == "parakeet_server_not_ready" 79 + 80 + 81 + @pytest.mark.parametrize("error", [ConnectionError("refused"), TimeoutError("slow")]) 82 + def test_connection_errors_are_not_ready( 83 + monkeypatch: pytest.MonkeyPatch, error: Exception 84 + ): 85 + def fake_get(_url, timeout): 86 + raise error 87 + 88 + monkeypatch.setattr(parakeet_server, "read_service_port", lambda _service: 4567) 89 + _install_fake_httpx(monkeypatch, fake_get) 90 + 91 + state, detail = parakeet_server.probe_state() 92 + assert state == parakeet_server.STATE_FAILED 93 + assert detail == str(error) 94 + with pytest.raises(parakeet_server.ParakeetServerNotReady): 95 + parakeet_server.connect()
+1 -1
tests/test_settings_call_parity.py
··· 258 258 ) 259 259 assert transcribe_bad.exit_code == 1 260 260 assert transcribe_bad.stderr == ( 261 - "Invalid backend: invalid. Must be one of: gemini, parakeet, revai, whisper\n" 261 + "Invalid backend: invalid. Must be one of: gemini, parakeet, parakeet-cpp, revai, whisper\n" 262 262 ) 263 263 264 264 transcribe_set = runner.invoke(
+222
tests/test_supervisor_parakeet.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + from __future__ import annotations 5 + 6 + from pathlib import Path 7 + from types import SimpleNamespace 8 + 9 + import pytest 10 + 11 + from solstone.think import supervisor 12 + from solstone.think.providers import local_vulkan, parakeet_install, parakeet_server 13 + 14 + 15 + class _FakeProcess: 16 + def __init__(self, poll_value: int | None, returncode: int | None = None): 17 + self.pid = 12345 18 + self.returncode = returncode 19 + self._poll_value = poll_value 20 + 21 + def poll(self) -> int | None: 22 + return self._poll_value 23 + 24 + 25 + class _FakeManaged: 26 + def __init__(self, poll_value: int | None, returncode: int | None = None): 27 + self.name = supervisor.PARAKEET_SERVER_PROCESS_NAME 28 + self.process = _FakeProcess(poll_value, returncode) 29 + self.cleanup_called = False 30 + 31 + def cleanup(self) -> None: 32 + self.cleanup_called = True 33 + 34 + 35 + def test_parakeet_server_is_sweepable_orphan_name() -> None: 36 + assert ( 37 + supervisor.PARAKEET_SERVER_PROCESS_NAME in supervisor._LOCAL_SERVER_PROCTITLES 38 + ) 39 + assert supervisor._is_sweepable_orphan_name("parakeet-server") is True 40 + 41 + 42 + def test_resolve_launch_plan_cpu_ignores_gpu() -> None: 43 + gpu = SimpleNamespace(index=2) 44 + 45 + plan = supervisor.resolve_parakeet_server_launch_plan("cpu", gpu) 46 + 47 + assert plan == supervisor.ParakeetServerLaunchPlan("cpu", {}, None) 48 + 49 + 50 + def test_resolve_launch_plan_auto_uses_selected_gpu() -> None: 51 + gpu = SimpleNamespace(index=2) 52 + 53 + plan = supervisor.resolve_parakeet_server_launch_plan("auto", gpu) 54 + 55 + assert plan == supervisor.ParakeetServerLaunchPlan( 56 + "vulkan", {"GGML_VK_VISIBLE_DEVICES": "2"}, 2 57 + ) 58 + 59 + 60 + def test_resolve_launch_plan_auto_without_gpu_uses_cpu() -> None: 61 + plan = supervisor.resolve_parakeet_server_launch_plan("auto", None) 62 + 63 + assert plan == supervisor.ParakeetServerLaunchPlan("cpu", {}, None) 64 + 65 + 66 + def test_resolve_launch_plan_rejects_invalid_device() -> None: 67 + with pytest.raises(ValueError, match="auto"): 68 + supervisor.resolve_parakeet_server_launch_plan("bogus", None) 69 + 70 + 71 + def test_parakeet_physical_thread_count_uses_physical(monkeypatch) -> None: 72 + monkeypatch.setattr(supervisor.psutil, "cpu_count", lambda logical=False: 6) 73 + monkeypatch.setattr( 74 + supervisor.os, 75 + "cpu_count", 76 + lambda: pytest.fail("logical count should not be primary"), 77 + ) 78 + 79 + assert supervisor.parakeet_physical_thread_count() == 6 80 + 81 + 82 + @pytest.mark.parametrize( 83 + ("logical_count", "expected"), 84 + [ 85 + (8, 4), 86 + (None, 1), 87 + ], 88 + ) 89 + def test_parakeet_physical_thread_count_fallback_halves_logical( 90 + monkeypatch, logical_count, expected 91 + ) -> None: 92 + monkeypatch.setattr(supervisor.psutil, "cpu_count", lambda logical=False: None) 93 + monkeypatch.setattr(supervisor.os, "cpu_count", lambda: logical_count) 94 + 95 + assert supervisor.parakeet_physical_thread_count() == expected 96 + 97 + 98 + def test_build_parakeet_cmd_load_bearing_invariants() -> None: 99 + cmd = supervisor._build_parakeet_cmd( 100 + Path("/tmp/parakeet-server"), 101 + Path("/tmp/model.gguf"), 102 + 45123, 103 + 6, 104 + ) 105 + 106 + assert "127.0.0.1" in cmd 107 + assert "0.0.0.0" not in cmd 108 + assert cmd[cmd.index("--model") + 1] == "/tmp/model.gguf" 109 + assert cmd[cmd.index("--threads") + 1] == "6" 110 + 111 + 112 + def test_start_parakeet_server_vulkan_crash_falls_back_to_cpu( 113 + monkeypatch, 114 + ) -> None: 115 + monkeypatch.delenv("GGML_VK_VISIBLE_DEVICES", raising=False) 116 + monkeypatch.setattr(supervisor.sys, "platform", "linux") 117 + monkeypatch.setattr(supervisor, "configured_stt_backend", lambda: "parakeet-cpp") 118 + monkeypatch.setattr(supervisor, "_configured_parakeet_device", lambda: "auto") 119 + gpu = local_vulkan.VulkanDevice( 120 + 2, 121 + "NVIDIA Test GPU", 122 + local_vulkan.VK_TYPE_DISCRETE, 123 + 8192, 124 + ) 125 + monkeypatch.setattr(local_vulkan, "detect_gpus", lambda: [gpu]) 126 + monkeypatch.setattr(local_vulkan, "select_device", lambda devices: devices[0]) 127 + monkeypatch.setattr(local_vulkan, "classify", lambda _device: "discrete") 128 + 129 + def fake_ensure(backend: str): 130 + return Path(f"/tmp/{backend}/parakeet-server"), Path("/tmp/model.gguf") 131 + 132 + monkeypatch.setattr(parakeet_install, "ensure_artifacts_installed", fake_ensure) 133 + monkeypatch.setattr(supervisor, "find_available_port", lambda: 45123) 134 + ports: list[tuple[str, int]] = [] 135 + monkeypatch.setattr( 136 + supervisor, 137 + "write_service_port", 138 + lambda service, port: ports.append((service, port)), 139 + ) 140 + monkeypatch.setattr(supervisor, "parakeet_physical_thread_count", lambda: 6) 141 + monkeypatch.setattr( 142 + parakeet_server, "probe_state", lambda: (parakeet_server.STATE_READY, None) 143 + ) 144 + terminated = [] 145 + monkeypatch.setattr( 146 + supervisor, 147 + "_terminate_managed", 148 + lambda managed, timeout, *, reason: terminated.append( 149 + (managed, timeout, reason) 150 + ), 151 + ) 152 + 153 + launches = [] 154 + 155 + def fake_launch_process( 156 + name, cmd, *, restart=False, shutdown_timeout=15, ref=None, env=None 157 + ): 158 + managed = _FakeManaged(9, 9) if not launches else _FakeManaged(None, None) 159 + launches.append( 160 + { 161 + "name": name, 162 + "cmd": cmd, 163 + "restart": restart, 164 + "env": env, 165 + "managed": managed, 166 + } 167 + ) 168 + return managed 169 + 170 + monkeypatch.setattr(supervisor, "_launch_process", fake_launch_process) 171 + 172 + result = supervisor.start_parakeet_server() 173 + 174 + assert result is launches[1]["managed"] 175 + assert len(launches) == 2 176 + assert launches[0]["name"] == supervisor.PARAKEET_SERVER_PROCESS_NAME 177 + assert launches[0]["restart"] is True 178 + assert launches[0]["cmd"][0] == "/tmp/vulkan/parakeet-server" 179 + assert launches[0]["env"]["GGML_VK_VISIBLE_DEVICES"] == "2" 180 + assert launches[1]["cmd"][0] == "/tmp/cpu/parakeet-server" 181 + assert "GGML_VK_VISIBLE_DEVICES" not in launches[1]["env"] 182 + assert launches[0]["managed"].cleanup_called is True 183 + assert terminated[0][0] is launches[0]["managed"] 184 + assert ports == [("parakeet-cpp", 45123)] 185 + 186 + 187 + @pytest.mark.parametrize( 188 + ("platform", "backend", "expected"), 189 + [ 190 + ("linux", "parakeet-cpp", True), 191 + ("linux", "parakeet", False), 192 + ("darwin", "parakeet-cpp", False), 193 + ], 194 + ) 195 + def test_boot_gate_predicate(monkeypatch, platform: str, backend: str, expected: bool): 196 + monkeypatch.setattr(supervisor.sys, "platform", platform) 197 + monkeypatch.setattr(supervisor, "configured_stt_backend", lambda: backend) 198 + 199 + should_start = ( 200 + supervisor.sys.platform.startswith("linux") 201 + and supervisor.configured_stt_backend() == "parakeet-cpp" 202 + ) 203 + 204 + assert should_start is expected 205 + 206 + 207 + def test_start_parakeet_server_early_returns_for_non_linux(monkeypatch) -> None: 208 + monkeypatch.setattr(supervisor.sys, "platform", "darwin") 209 + monkeypatch.setattr( 210 + supervisor, 211 + "configured_stt_backend", 212 + lambda: pytest.fail("backend should not be read off-linux"), 213 + ) 214 + 215 + assert supervisor.start_parakeet_server() is None 216 + 217 + 218 + def test_start_parakeet_server_early_returns_for_other_backend(monkeypatch) -> None: 219 + monkeypatch.setattr(supervisor.sys, "platform", "linux") 220 + monkeypatch.setattr(supervisor, "configured_stt_backend", lambda: "parakeet") 221 + 222 + assert supervisor.start_parakeet_server() is None
+256
tests/test_transcribe_parakeet_cpp.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + from __future__ import annotations 5 + 6 + import io 7 + 8 + import httpx 9 + import numpy as np 10 + import pytest 11 + import soundfile as sf 12 + 13 + from solstone.observe.transcribe import _parakeet_cpp as parakeet_cpp 14 + from solstone.observe.transcribe import get_backend, get_backend_list 15 + from solstone.think import parakeet_readiness 16 + from solstone.think.providers.parakeet_install import ParakeetProviderError 17 + from solstone.think.providers.parakeet_server import ( 18 + STATE_READY, 19 + ParakeetServerInfo, 20 + ParakeetServerNotReady, 21 + ) 22 + 23 + 24 + class _Response: 25 + def __init__(self, status_code: int = 200, payload=None, text: str = "") -> None: 26 + self.status_code = status_code 27 + self._payload = payload if payload is not None else {} 28 + self.text = text 29 + 30 + def json(self): 31 + if isinstance(self._payload, Exception): 32 + raise self._payload 33 + return self._payload 34 + 35 + 36 + def _server() -> ParakeetServerInfo: 37 + return ParakeetServerInfo( 38 + model_id=parakeet_readiness.PARAKEET_CPP_MODEL_FILENAME, 39 + port=4567, 40 + base_url="http://127.0.0.1:4567", 41 + state=STATE_READY, 42 + ) 43 + 44 + 45 + def _payload(words=None, text: str = "") -> dict: 46 + return { 47 + "text": text, 48 + "words": words 49 + if words is not None 50 + else [{"word": "hello.", "start": 0.1, "end": 0.5, "conf": 0.75}], 51 + } 52 + 53 + 54 + def _run_transcribe(monkeypatch: pytest.MonkeyPatch, response: _Response): 55 + monkeypatch.setattr(parakeet_cpp.parakeet_server, "connect", lambda: _server()) 56 + monkeypatch.setattr(httpx, "post", lambda *_args, **_kwargs: response) 57 + return parakeet_cpp.transcribe(np.zeros(1600, dtype=np.float32), 16000, {}) 58 + 59 + 60 + def test_registry_exposes_parakeet_cpp_backend() -> None: 61 + assert get_backend("parakeet-cpp") is parakeet_cpp 62 + backend = next( 63 + item for item in get_backend_list() if item["name"] == "parakeet-cpp" 64 + ) 65 + assert backend["settings"] == ["device"] 66 + 67 + 68 + @pytest.mark.parametrize("device", ["auto", "cpu"]) 69 + def test_validate_config_accepts_supported_devices(device: str) -> None: 70 + assert parakeet_cpp._validate_config({"device": device}) == device 71 + 72 + 73 + @pytest.mark.parametrize("device", ["cuda", "tpu"]) 74 + def test_validate_config_rejects_unsupported_devices(device: str) -> None: 75 + with pytest.raises(ValueError, match="auto, cpu"): 76 + parakeet_cpp._validate_config({"device": device}) 77 + 78 + 79 + def test_non_linux_transcribe_and_model_info_raise_provider_error(monkeypatch) -> None: 80 + monkeypatch.setattr(parakeet_cpp.sys, "platform", "darwin") 81 + 82 + with pytest.raises(ParakeetProviderError) as transcribe_exc: 83 + parakeet_cpp.transcribe(np.zeros(100, dtype=np.float32), 16000, {}) 84 + with pytest.raises(ParakeetProviderError) as info_exc: 85 + parakeet_cpp.get_model_info({}) 86 + 87 + assert transcribe_exc.value.reason_code == "unsupported_platform" 88 + assert info_exc.value.reason_code == "unsupported_platform" 89 + 90 + 91 + def test_request_shape_and_wav_encoding(monkeypatch: pytest.MonkeyPatch) -> None: 92 + observed = {} 93 + 94 + def fake_post(url, **kwargs): 95 + observed["url"] = url 96 + observed.update(kwargs) 97 + return _Response(payload=_payload(text="hello.")) 98 + 99 + monkeypatch.setattr(parakeet_cpp.parakeet_server, "connect", lambda: _server()) 100 + monkeypatch.setattr(httpx, "post", fake_post) 101 + 102 + statements = parakeet_cpp.transcribe( 103 + np.linspace(-0.5, 0.5, 3200, dtype=np.float32), 104 + 16000, 105 + {"device": "cpu"}, 106 + ) 107 + 108 + assert statements[0]["text"] == "hello." 109 + assert observed["url"] == "http://127.0.0.1:4567/v1/audio/transcriptions" 110 + assert observed["timeout"] == parakeet_cpp._DEFAULT_TIMEOUT_SEC 111 + assert observed["data"] == [ 112 + ("response_format", "verbose_json"), 113 + ("timestamp_granularities[]", "word"), 114 + ] 115 + filename, wav_bytes, content_type = observed["files"]["file"] 116 + assert filename == "audio.wav" 117 + assert content_type == "audio/wav" 118 + assert wav_bytes.startswith(b"RIFF") 119 + with sf.SoundFile(io.BytesIO(wav_bytes)) as wav_file: 120 + assert wav_file.samplerate == 16000 121 + assert wav_file.channels == 1 122 + assert wav_file.subtype == "PCM_16" 123 + 124 + 125 + def test_connect_not_ready_propagates(monkeypatch: pytest.MonkeyPatch) -> None: 126 + error = ParakeetServerNotReady("warming") 127 + monkeypatch.setattr( 128 + parakeet_cpp.parakeet_server, 129 + "connect", 130 + lambda: (_ for _ in ()).throw(error), 131 + ) 132 + 133 + with pytest.raises(ParakeetServerNotReady) as exc_info: 134 + parakeet_cpp.transcribe(np.zeros(100, dtype=np.float32), 16000, {}) 135 + 136 + assert exc_info.value is error 137 + 138 + 139 + @pytest.mark.parametrize( 140 + "error", 141 + [httpx.ConnectError("refused"), httpx.TimeoutException("slow")], 142 + ) 143 + def test_post_connection_errors_map_to_not_ready( 144 + monkeypatch: pytest.MonkeyPatch, error: Exception 145 + ) -> None: 146 + monkeypatch.setattr(parakeet_cpp.parakeet_server, "connect", lambda: _server()) 147 + monkeypatch.setattr( 148 + httpx, "post", lambda *_args, **_kwargs: (_ for _ in ()).throw(error) 149 + ) 150 + 151 + with pytest.raises(ParakeetServerNotReady) as exc_info: 152 + parakeet_cpp.transcribe(np.zeros(100, dtype=np.float32), 16000, {}) 153 + 154 + assert exc_info.value.reason_code == "parakeet_server_not_ready" 155 + 156 + 157 + def test_http_non_200_is_visible_provider_error( 158 + monkeypatch: pytest.MonkeyPatch, 159 + ) -> None: 160 + with pytest.raises(ParakeetProviderError) as exc_info: 161 + _run_transcribe(monkeypatch, _Response(status_code=500, text="broken")) 162 + 163 + assert exc_info.value.reason_code == "transcription_http_error" 164 + 165 + 166 + @pytest.mark.parametrize( 167 + "payload", 168 + [ValueError("not json"), ["not", "object"]], 169 + ) 170 + def test_invalid_json_is_visible_provider_error( 171 + monkeypatch: pytest.MonkeyPatch, payload 172 + ) -> None: 173 + with pytest.raises(ParakeetProviderError) as exc_info: 174 + _run_transcribe(monkeypatch, _Response(payload=payload)) 175 + 176 + assert exc_info.value.reason_code == "invalid_json" 177 + 178 + 179 + def test_missing_words_key_is_contract_violation( 180 + monkeypatch: pytest.MonkeyPatch, 181 + ) -> None: 182 + with pytest.raises(ParakeetProviderError) as exc_info: 183 + _run_transcribe(monkeypatch, _Response(payload={"text": "hello"})) 184 + 185 + assert exc_info.value.reason_code == "contract_violation" 186 + 187 + 188 + def test_empty_words_and_empty_text_is_silence(monkeypatch: pytest.MonkeyPatch) -> None: 189 + assert _run_transcribe(monkeypatch, _Response(payload=_payload([], ""))) == [] 190 + 191 + 192 + def test_empty_words_and_text_is_contract_violation( 193 + monkeypatch: pytest.MonkeyPatch, 194 + ) -> None: 195 + with pytest.raises(ParakeetProviderError) as exc_info: 196 + _run_transcribe(monkeypatch, _Response(payload=_payload([], "hello"))) 197 + 198 + assert exc_info.value.reason_code == "contract_violation" 199 + 200 + 201 + def test_non_empty_words_build_statements_with_speaker_none( 202 + monkeypatch: pytest.MonkeyPatch, 203 + ) -> None: 204 + statements = _run_transcribe( 205 + monkeypatch, 206 + _Response( 207 + payload=_payload( 208 + [ 209 + {"word": "Hello", "start": 0.0, "end": 0.3, "conf": 0.5}, 210 + {"word": "world.", "start": 0.4, "end": 0.8}, 211 + ], 212 + "Hello world.", 213 + ) 214 + ), 215 + ) 216 + 217 + assert len(statements) == 1 218 + statement = statements[0] 219 + assert statement["speaker"] is None 220 + assert statement["text"] == "Hello world." 221 + assert statement["words"] == [ 222 + {"word": " Hello", "start": 0.0, "end": 0.3, "probability": 0.5}, 223 + {"word": " world.", "start": 0.4, "end": 0.8, "probability": 1.0}, 224 + ] 225 + 226 + 227 + @pytest.mark.parametrize( 228 + "word_item", 229 + [ 230 + "not a dict", 231 + {"start": 0.0, "end": 0.1}, 232 + {"word": "bad", "start": "nope", "end": 0.1}, 233 + {"word": "bad", "start": 0.0, "end": "nope"}, 234 + ], 235 + ) 236 + def test_bad_word_items_are_contract_violations( 237 + monkeypatch: pytest.MonkeyPatch, word_item 238 + ) -> None: 239 + with pytest.raises(ParakeetProviderError) as exc_info: 240 + _run_transcribe(monkeypatch, _Response(payload=_payload([word_item], "bad"))) 241 + 242 + assert exc_info.value.reason_code == "contract_violation" 243 + 244 + 245 + def test_get_model_info_does_not_connect(monkeypatch: pytest.MonkeyPatch) -> None: 246 + def fail_connect(): 247 + raise AssertionError("connect should not be called") 248 + 249 + monkeypatch.setattr(parakeet_cpp.parakeet_server, "connect", fail_connect) 250 + 251 + assert parakeet_cpp.get_model_info({"device": "cpu"}) == { 252 + "model": parakeet_readiness.PARAKEET_CPP_MODEL_FILENAME, 253 + "device": "cpu", 254 + "compute_type": "q8_0", 255 + "per_word_confidence": True, 256 + }
+151
tests/test_transcribe_parakeet_cpp_retry.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + from __future__ import annotations 5 + 6 + import argparse 7 + from pathlib import Path 8 + from unittest.mock import patch 9 + 10 + import numpy as np 11 + import pytest 12 + 13 + from solstone.observe.utils import SAMPLE_RATE 14 + from solstone.observe.vad import VadResult 15 + from solstone.think.providers.parakeet_install import ParakeetProviderError 16 + from solstone.think.providers.parakeet_server import ParakeetServerNotReady 17 + 18 + 19 + @pytest.fixture 20 + def raw_path(tmp_path: Path) -> Path: 21 + path = tmp_path / "chronicle" / "20260416" / "default" / "120000_300" / "audio.m4a" 22 + path.parent.mkdir(parents=True) 23 + path.write_bytes(b"audio") 24 + return path 25 + 26 + 27 + @pytest.fixture 28 + def audio_buffer() -> np.ndarray: 29 + return np.zeros(10 * SAMPLE_RATE, dtype=np.float32) 30 + 31 + 32 + @pytest.fixture 33 + def vad_result() -> VadResult: 34 + return VadResult( 35 + duration=10.0, 36 + speech_duration=5.0, 37 + has_speech=True, 38 + speech_segments=[(1.0, 6.0)], 39 + ) 40 + 41 + 42 + def test_process_audio_parakeet_server_not_ready_is_clean_retry( 43 + raw_path: Path, audio_buffer: np.ndarray, vad_result: VadResult 44 + ) -> None: 45 + from solstone.observe.transcribe.main import process_audio 46 + 47 + with ( 48 + patch( 49 + "solstone.observe.transcribe.main.stt_transcribe", 50 + side_effect=ParakeetServerNotReady("no port"), 51 + ), 52 + patch("solstone.observe.transcribe.main.callosum_send") as mock_send, 53 + ): 54 + process_audio(raw_path, audio_buffer, vad_result, {}, backend="parakeet-cpp") 55 + 56 + assert raw_path.exists() 57 + assert not raw_path.with_suffix(".jsonl").exists() 58 + mock_send.assert_not_called() 59 + 60 + 61 + def test_process_audio_parakeet_provider_error_uses_existing_failure_path( 62 + raw_path: Path, audio_buffer: np.ndarray, vad_result: VadResult 63 + ) -> None: 64 + from solstone.observe.transcribe.main import process_audio 65 + 66 + with ( 67 + patch( 68 + "solstone.observe.transcribe.main.stt_transcribe", 69 + side_effect=ParakeetProviderError( 70 + "transcription_http_error", "HTTP 500: broken" 71 + ), 72 + ), 73 + patch( 74 + "solstone.observe.transcribe.main.get_journal", 75 + return_value=str(raw_path.parents[4]), 76 + ), 77 + patch("solstone.observe.transcribe.main.callosum_send") as mock_send, 78 + ): 79 + with pytest.raises(SystemExit) as exc_info: 80 + process_audio( 81 + raw_path, audio_buffer, vad_result, {}, backend="parakeet-cpp" 82 + ) 83 + 84 + assert exc_info.value.code == 1 85 + assert raw_path.exists() 86 + assert not raw_path.with_suffix(".jsonl").exists() 87 + assert mock_send.call_args.args[:2] == ("observe", "transcribed") 88 + assert mock_send.call_args.kwargs["outcome"] == "failed" 89 + assert mock_send.call_args.kwargs["backend"] == "parakeet-cpp" 90 + assert "ParakeetProviderError" in mock_send.call_args.kwargs["error"] 91 + 92 + 93 + @pytest.mark.parametrize( 94 + ("transcribe_config", "expected_backend_config"), 95 + [ 96 + ( 97 + {"backend": "parakeet-cpp", "parakeet-cpp": {"device": "cpu"}}, 98 + {"device": "cpu"}, 99 + ), 100 + ({"backend": "parakeet-cpp"}, {}), 101 + ], 102 + ) 103 + def test_process_one_builds_parakeet_cpp_backend_config( 104 + tmp_path: Path, 105 + transcribe_config: dict, 106 + expected_backend_config: dict, 107 + ) -> None: 108 + from solstone.observe.transcribe.main import _process_one 109 + 110 + audio_path = ( 111 + tmp_path / "chronicle" / "20260416" / "default" / "120000_300" / "audio.m4a" 112 + ) 113 + audio_path.parent.mkdir(parents=True) 114 + audio_path.write_bytes(b"audio") 115 + args = argparse.Namespace(backend=None, cpu=False, model=None, redo=False) 116 + vad_result = VadResult( 117 + duration=10.0, 118 + speech_duration=5.0, 119 + has_speech=True, 120 + speech_segments=[(0.0, 5.0)], 121 + ) 122 + captured = {} 123 + 124 + def fake_process_audio( 125 + _audio_path, 126 + _audio_buffer, 127 + _vad_result, 128 + backend_config, 129 + **kwargs, 130 + ): 131 + captured["backend_config"] = backend_config 132 + captured["backend"] = kwargs["backend"] 133 + 134 + with ( 135 + patch( 136 + "solstone.observe.transcribe.main.load_audio", 137 + return_value=np.zeros(10 * SAMPLE_RATE, dtype=np.float32), 138 + ), 139 + patch("solstone.observe.vad.run_vad", return_value=vad_result), 140 + patch("solstone.observe.vad.reduce_audio", return_value=(None, None)), 141 + patch( 142 + "solstone.observe.transcribe.main.process_audio", 143 + side_effect=fake_process_audio, 144 + ), 145 + ): 146 + _process_one(audio_path, args, transcribe_config, "parakeet-cpp", []) 147 + 148 + assert captured == { 149 + "backend": "parakeet-cpp", 150 + "backend_config": expected_backend_config, 151 + }