* Studio: add Voice settings tab (dictation, dictionary, read aloud) New Voice tab in Settings, placed just before About: - Dictation: microphone picker, browser STT engine, recognition language, and an inline mic test with a live transcript - Dictation dictionary: entries rewrite matching speech to their exact spelling and casing, applied in both dictation paths - Recent dictations: last 20 final transcripts with copy and clear, so text can be recovered if it lands in the wrong place - Read aloud: optional button on assistant responses with two engines, curated system voices (novelty and legacy voices filtered, quality ranked, capped at 20) or the TTS audio model loaded in Unsloth via /audio/generate (e.g. Orpheus), plus speed, pitch, volume and preview Settings persist in localStorage (unsloth_voice_settings) and are read at call time so changes apply without reloading the runtime. Adds en keys plus the tab label for ja, zh-CN and pt-BR. * Studio: drop the single option STT engine select, rename TTS option The STT engine dropdown only had one entry, so it added noise without giving a real choice. The engine row can come back once local STT models land. Also renames the TTS engine option Unsloth TTS model to Load TTS model to make the action clearer. * Studio: harden Voice settings against edge cases found in simulation Simulated the feature across Chromium, Firefox and WebKit plus node level unit runs and backend contract checks. Fixes from the findings: - Dictionary rewrite used a replacement string, so entries containing dollar patterns corrupted transcripts (A$$AP became A$AP, $& injected the match). Switched to the callback form of String.replace - Persisted voice settings now validate types on hydration: non string micDeviceId, dictationLanguage and ttsVoiceURI, and non boolean ttsEnabled fall back to defaults instead of flowing into the UI - Dictionary entries are trimmed, capped at 120 chars and re-sanitized on hydration - The Test dictation panel now falls back to the default microphone when the saved device is unplugged, matching the composer adapter Test coverage: 46 unit assertions (dictionary regex edge cases across unicode, word boundaries and injection, voice curation for simulated macOS, Windows and Linux voice inventories, corrupt storage merge), 13 backend contract checks against /audio/generate on an isolated instance, and 60 browser assertions across the three engines covering rendering, degradation without SpeechRecognition, curation in a real DOM, dictionary persistence with unicode and dollar entries, the no-model preview error path and corrupt localStorage recovery. * Studio: address Voice settings review feedback Verified each review comment before acting. Confirmed and fixed: - Editing a dictionary entry was broken in two ways: the store trimmed on every keystroke so spaces could not be typed, and clearing the field deleted the entry and unmounted the input mid edit. Updates now keep the raw value and a blur commit trims or removes the entry - The unplugged mic fallback checked instanceof DOMException, but a cross browser probe showed Firefox and WebKit throw OverconstrainedError objects that are not DOMExceptions, so the fallback never fired there. Matching on the error name now - When the browser ended a dictation test on its own (silence timeout), the mic stream stayed open. All recognition end paths now stop the tracks and save the transcript through a single finalize path - The studio TTS audio element now releases its WAV data URL as soon as playback ends, fails or is cancelled - Allow microphone now reports insecure contexts (no mediaDevices) accurately instead of claiming access was blocked - Voice tab copy moved into i18n keys per src/i18n/AGENTS.md, so locale overlays can translate it; en is the baseline and parity passes - unsloth_voice_settings added to the Reset all local preferences key list so voice preferences obey the reset - Non default microphones note that the system default is used when the browser speech engine cannot bind a specific device, since browsers without the start(track) overload ignore the argument silently Re-ran the full simulation set after the changes: 46 unit assertions, 13 backend contract checks and 60 browser assertions across Chromium, Firefox and WebKit all pass, plus a dedicated browser probe for the dictionary editing behavior. * Studio: use the chat mic icon in Voice settings for consistency The Voice tab and its buttons used the hugeicons Mic02 glyph while the chat composer uses a custom filled mic. Extract that composer icon into a shared lib/mic-icon component, drop the duplicate inline copies in thread.tsx and shared-composer.tsx, and use it for the Voice tab icon and the tab's mic buttons so the microphone looks the same everywhere. * Studio: address second round of Voice settings review feedback Verified each new comment against the current code first. One item was already fixed in the previous round (recording transcripts when the browser ends a dictation test on its own). Confirmed and fixed: - The microphone row showed a picker with generic names when browsers enumerate unlabeled devices before permission, leaving no way to grant access from the row. It now branches on whether labels are visible and shows Allow microphone otherwise - Compare chat dictation ignored the selected microphone. It now opens the chosen device with the same fallback rules as the main adapter, passes the track to recognition where supported and releases the stream when recognition ends - Closing the Voice tab cancelled the shared speechSynthesis even when read aloud was playing a chat message. Cleanup now only cancels when the tab owns an active preview - Double clicking Start test could race two recognizers and leak the first stream. A starting flag set before the getUserMedia await makes start reentrancy safe - Turning off the read aloud setting mid playback removed the only stop control. The stop button now renders whenever a message is speaking - When an engine lacks the start(track) overload, both dictation paths now release the selected device stream before retrying with the default microphone instead of holding it open - Read aloud support no longer requires Web Speech synthesis: the Unsloth TTS engine only needs audio playback, so it stays available in WebViews without speechSynthesis, with a clear error if the system engine is chosen there Not addressed here: cancelling in flight backend TTS generation on stop. The route runs generation in a worker thread without a cancellation path, which is shared pre existing behavior with audio chat generation and belongs in a backend change. All suites re-run green: 46 unit, 13 backend contract and 60 browser matrix assertions across Chromium, Firefox and WebKit, plus probes for the unlabeled device branch and the double click race. * Studio: drop empty and duplicate voiceURIs so the Voice tab never renders a crashing Select item * Studio: guard dictation mic lifecycle in Voice test and Compare composer Release a microphone opened after the component unmounts, and stop Compare dictation on a permission or security failure instead of silently recording from the default device, matching the main chat adapter. * Studio: fix dictation and read-aloud lifecycle edge cases in Voice settings - Join final dictation chunks with a space so recorded transcripts do not merge words - Ignore a stale recognizer onend so a quick stop then restart is not torn down - Use previewingRef so a double click on TTS preview does not orphan the first request - Keep the read-aloud stop control visible when a new run starts while a message is spoken - Stop the dictionary remove button from deleting an adjacent entry on a blur then click race * Studio: trim redundant Voice settings comments * Studio: fix Voice preview and Compare dictation edge cases - Only cancel the shared speechSynthesis for a system-voice preview, so stopping a Studio preview no longer stops an unrelated chat read-aloud - Release the Studio preview audio and its WAV data URL on normal completion - Iterate every finalized result in Compare dictation so batched phrases are kept - Cap persisted recent dictations to the last 20 on hydration * Studio: use clipboard fallback for recents and release failed preview audio - Copy recent dictations via the copyToClipboard helper so the execCommand fallback works in Safari and insecure http LAN contexts - Release the Studio preview audio when play() rejects, not just on ended/error * Studio: add local speech-to-text dictation engine Add an offline dictation engine that transcribes with a local faster-whisper model, alongside the existing browser (Web Speech) engine. The browser engine streams audio to Apple or Google speech services and needs internet; the new engine runs on the server, works offline, and drives any chat model without evicting it (it loads in the backend process, separate from the model subprocess). It also gives Firefox dictation, which has no Web Speech support. Backend: a lazily-loaded, kept-warm faster-whisper sidecar and three routes under /api/inference/audio (stt/status, stt/load, transcribe). faster-whisper is torch-free, so this does not disturb the existing model stack. Frontend: a Dictation engine setting (browser or local model), a curated model picker with sizes, and MediaRecorder capture posted to the transcribe route. The model warms automatically when the engine is selected, with live status. * Studio: stream local STT transcription as you speak Local dictation showed nothing until you stopped, because the whole clip was transcribed once on stop. Now the growing recording is re-transcribed on a fast pass every second and emitted as live interim text, with an accurate final pass on stop. Partial recordings decode fine, and the model refines earlier words as more audio arrives. Adds an interim flag to the transcribe route (beam 1, no VAD) for the fast preview pass; the final stop uses the accurate path. * Studio: make local dictation stop instant and reliable Stopping local dictation waited for a final network transcription before the session ended, so the stop button did not flip and a second click ended the session early and dropped the text. Now stop commits the live transcript immediately, releases the mic at once, and ignores a second stop while finalizing. Previews run more often so the committed text is current. * Studio: record local dictation in short clips for reliable streaming Re-transcribing a growing buffer every second got slower as it grew, flooded the backend, showed stale words, and could leave the stop button stuck waiting on a backlog. Record short independent clips instead and transcribe each once, appending the text as you speak. Work per clip is bounded, so stopping is prompt (with a hard timeout as a safety net) and long dictations stay smooth. * Studio: dictate then transcribe once on stop, ChatGPT style Local STT dictation streamed by re-transcribing the growing clip, which was quadratic and saturated the backend (multi-second lag), and stop only halted the recorder without releasing the mic, so it kept recording. Record the microphone continuously, release it the instant the user stops, and transcribe the whole clip once. Stopping is immediate and the transcript lands in about a second. Also add the tiny model for the fastest option. * Studio: surface dictation and read-aloud failures instead of failing silently - Compare dictation reports microphone and speech-recognition errors via toast, reusing the main chat adapter's describeMediaError and describeSpeechError - Read-aloud toasts genuine model or synthesis failures while ignoring cancellations * Studio: ChatGPT-style recording bar for dictation Clicking the mic now drops the composer into a dedicated recording bar with a live waveform, a discard (X) and a confirm (tick), instead of a plain stop button. The tick stops recording and transcribes the clip; the X throws the recording away and keeps whatever text was already in the composer. The model adapter taps the mic with an analyser to drive the waveform, and the router tracks the live session so the X can cancel it without transcribing. * Studio: transcribe dictation while speaking, ChatGPT layout Match ChatGPT's recording layout: the bar now renders in place of the input with the left plus button kept, the waveform in the middle, and the discard and confirm buttons together on the right. Cut the post-confirm delay by transcribing in the background as the user talks. The audio is split at natural pauses (voice-activity detection off the same analyser that drives the waveform) and each clip is transcribed as it is cut, so confirming only has to finish the short final tail. The model is also warmed when recording starts so the first run never pays a cold load. * Studio: ChatGPT waveform, hide tools while dictating, faster STT Make the recording UI read like ChatGPT: the waveform is now a dense row of round dots that rise into thin centered bars, and while dictating only the plus button shows, with the mode badge and tool toggles hidden so the bar is just the waveform and controls. Speed up transcription: decode greedily (beam_size=1), which is several times faster on CPU with negligible accuracy loss on short dictation clips, and cap background segments at 6s so the final tail after confirm stays short. * Studio: finish ChatGPT voice bar and low-latency STT * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: full-width waveform with a timer that freezes on stop Use the full-width waveform for the recording bar: brighter, bigger bars that advance on a fixed cadence (keeping peaks between advances) so they glide instead of racing by, inset from the composer edges. Keep a visible timer and the green confirm button, matching the ChatGPT reference, and freeze the timer and waveform the moment the user confirms. * Studio: fix multilingual local dictation * Studio: speed up dictation and release local STT * Studio: harden dictation finalization and STT decoding * Studio: restore Firefox dictation fallback * Studio: add dictation history manager * Studio: manage speech model downloads * Studio: remove em dash from voice model label * Studio: move dictation history into Voice * Studio: source local STT from Unsloth Whisper models Point the dictation STT sidecar and its Model Hub download entries at Unsloth's Hugging Face Whisper repos (small, large-v3-turbo, large-v3) and run them through Transformers, so Studio only ever downloads Unsloth-uploaded weights. Drop faster-whisper and the Systran/mobiuslabs repos; keep the Model Hub as the only download path via local_files_only, and keep PyAV for audio decoding. Device selection uses float16 on CUDA and float32 on MPS and CPU, since Whisper's decoder is unstable in float16 on MPS and repeats tokens. Shorten the model picker labels to name plus download size and update the STT tests for the new backend. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: smooth dictation waveform and keep pill height * Studio: align STT model dropdown width and tidy voice copy * Studio: guide to local engine when browser dictation is offline * Studio: clarify voice section and STT model copy * Studio: keep STT warm with training-aware eviction * Harden STT lifecycle and browser compatibility * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Fix model discovery test lint * Harden cross-browser microphone errors * Harden cross-browser microphone errors * Surface voice test recognition errors and fall back to Studio TTS - Voice test now toasts non-abort speech-recognition failures instead of ending silently, matching the main and Compare dictation paths. - Read-aloud routes to the backend model when the runtime lacks Web Speech synthesis (audio-only WebView), so it no longer errors immediately. * Fix reviewed STT lifecycle races * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Fix read-aloud fallback controls * Guard read-aloud stop when deleting a non-speaking message aui.message().stopSpeaking() throws unless this message is the one being read aloud, so calling it unconditionally rejected the delete handler before the message was removed. Only stop speech when this message is speaking. * Cap recent dictation transcript length before persisting Recent dictations only limited entry count, so a long transcript stored the full text in the persisted voice settings and a few could exceed the localStorage quota, throwing synchronously from the uncaught dictation cleanup path. Truncate each entry on save and on hydration, matching the dictionary cap. * Studio: keep dictation mic clickable and guide to local model Register the dictation adapter unconditionally so the mic stays enabled for any engine and starts working right after switching to the local model on an already-open thread. When the browser engine cannot run (Firefox, Brave, non-secure origins), clicking the mic shows a toast that points to the local speech-to-text model instead of leaving a disabled button. The toast stacks its action below the text with a fully rounded button. * Studio: add bottom padding below the dictation guidance toast button * Studio: increase bottom padding under the dictation toast button * Studio: add bottom padding inside the dictation toast button * Studio: add five Whisper defaults and custom model search Add private UnslothAI Tiny and Base mirrors to the curated local STT choices while keeping Small as the default. Let users search or paste a Transformers-compatible Whisper repository and validate it end to end. Keep short dictations in one clip to avoid repeated padded encoder work, then split longer recordings near Whisper's 30-second boundary. Update hidden model filters and tests, including the CPU-only CI runtime stub for PyAV. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: use public Unsloth Whisper repositories Point the Tiny and Base dictation defaults to the public unsloth repositories and remove the private mirror references from model filtering and tests. * Studio: update Whisper download sizes Reflect the cleaned public Tiny and Base repositories in the curated model labels. * Studio: right-align STT model size, fix dropdown wheel scroll, refresh sizes - Show the download size on the right of each model row so long names like Whisper Large v3 Turbo no longer hide it - Update curated Whisper sizes to the safetensors weights actually downloaded: Tiny 151 MB, Base 290 MB, Small 967 MB - Drive the model list scroll from a wheel handler so the mouse wheel scrolls it inside the Settings dialog, not just the scrollbar - Add a search icon and shorten the placeholder to Search model * Studio: do not search when a dictation model is picked, shrink repo label - Treat the filled-in model text as a selection, not a query, so choosing a model no longer kicks off a Hugging Face search - Make the repository line under each model name smaller * Studio: tighten dictation model and local engine descriptions * Studio: keep model display on pick instead of the query, shrink row text - Guard the combobox input so selecting a model shows its name and does not echo the typed query back or start a search - Map the item label to the friendly display so picks fill the field - Reduce the model name and size text in each row * Studio: show only the model name in the dictation field, shrink size label - Drop the download size from the search field; the name alone is shown once a model is selected, with sizes kept in the dropdown list - Reduce the size label text in each row * Studio: clarify the dictation model description * Studio: drop Hugging Face from the dictation model description * Studio: move the dictation dictionary to its own Manage subpage - Replace the inline entry list with a Manage row, matching Dictation history, so a long dictionary no longer crowds Voice settings - Add a DictationDictionaryView subpage that holds the entry editor * Studio: match STT field font, use best voice for System default - Bump the dictation model field text to text-sm so it matches the engine dropdown next to it - Resolve the System default read-aloud voice to the top curated voice instead of the browser default, which is a robotic legacy voice on macOS * Studio: rerank read-aloud voices and drop duplicate voice entries - Rank by vendor quality, then the user's locale, then a preferred list of natural voices, so the best voice leads instead of the first alphabetically - Collapse voices that macOS reports twice under one name and language * Studio: fold dictionary and recents into the dictation section - Drop the separate Dictation dictionary and Recent dictations headings; their Manage rows now sit under Dictation, split by the row divider - Shorten the custom spellings description * Studio: add search and sort to dictation history - Filter saved dictations by text with a search field - Sort by newest, oldest, or A to Z; show a no-matches message - Keep Clear all available regardless of the current filter * Studio: settle cancelled STT loads before training and fix dictation review items Wait for a cancelled STT load to exit and release its memory before reporting it freed for training, so the loader cannot still be inside from_pretrained()/.to(device) holding VRAM when the training subprocess starts. A load that finishes before observing the cancel now gets unloaded so the memory is actually reclaimed. Clear the accelerator cache before the CPU fallback in load() so a failed CUDA/MPS load does not strand reserved VRAM once the sidecar is marked CPU-resident. Send the saved Hugging Face token when polling STT download progress so a gated or private repo resolves and shows the correct Load/Downloaded state instead of reporting missing. Mark the composer Dictate button as type="button" so clicking it does not also submit the draft when the composer already has text or attachments. * Studio: pin dictation settings per session and close STT startup races Capture the STT model and language when a dictation session starts and pass them to every queued segment and the warm-up load, so changing the model or language mid-recording no longer transcribes the same clip with the wrong model or a model that is not downloaded. Check the local runtime at the top of transcribe(), before the model cache lookup and the bounded audio decode, so a server missing PyTorch or Transformers returns 501 up front instead of decoding a long clip first. Treat the training startup window as active for STT device selection. start_training frees VRAM in before_spawn but only assigns _proc later, so a concurrent STT load could take the GPU that was just cleared. A startup flag now reports training active from the free until the process is live, forcing those loads to CPU; a finally clears it on every exit. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: stub the STT runtime check in transcribe orchestration tests transcribe() now verifies the local runtime up front, so the unit tests that exercise transcription orchestration must treat the runtime as present to keep passing where PyTorch, Transformers, and PyAV are not installed. Stub ensure_stt_available in the shared fixture and restore the real check in the availability and load-rejection tests. * Harden custom Whisper dictation models * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Add whisper.cpp dictation engine with per-engine downloads and history rework Engines - New GGML STT sidecar that runs a managed whisper-server subprocess with idle unload, plus a pinned static build script (scripts/build_whisper_cpp.sh) - Dictation engine picker now offers Browser, Local transcription (whisper.cpp), and Local transcription (Transformers) - Both local engines serve the same five curated Whisper models and download them directly with byte-level progress reported by /audio/stt/status - Models auto load on selection and when their download finishes - Unload and training admission account for both engines Benchmarks (Apple Silicon, greedy, warm, same checkpoints) - whisper.cpp transcribes 2.4x to 5x faster than Transformers and loads in about 0.45s vs 0.86s for Whisper Small - whisper.cpp GGUF path is unchanged by the Transformers addition (load 0.445s -> 0.444s, short clip 0.391s -> 0.347s, long 1.197s -> 1.129s) Voice settings UI - Plain curated model select replaces the searchable combobox - Single download progress bar with transfer rate for both engines - Dictation history now stores every dictation with Show more pagination, a top Clear history action, and links back to the chat it was spoken into - Archived chats dialog gets the same pagination - Delete dialog offers deleting a dictation together with its chat Tests: 88 backend STT tests pass, including new snapshot download coverage. Frontend typecheck, lint, i18n parity, and production build pass. * Merge local engines into one option and source GGML models from unslothai Engine selection - The dictation engine dropdown is back to two choices: Browser and Local transcription. The selected model decides the backend: curated ids run GGML checkpoints through whisper.cpp, searched Hugging Face repositories run safetensors through Transformers - Model picker lists the curated models and searches Hugging Face for other Whisper repositories, validating them before selection. The trigger is a plain button so the selection never renders inside a text input - /audio/stt/status accepts a model query param so downloaded state works for custom repositories; the engine param on load, transcribe, and download routes is derived from the model everywhere Model source - Curated GGML checkpoints now download from the Unsloth-hosted unslothai/whisper-*-GGUF repositories (one repo per model) instead of ggerganov/whisper.cpp; cache lookups, progress totals, and in-flight blob tracking are per-model Fixes - Voice settings and dictation history were not persisting: the quota-safe localStorage wrapper was declared after the store that uses it, so the persist storage factory failed silently. Every settings write also threw mid-click, which kept the model picker popover from closing on selection - is_model_downloaded now verifies config, preprocessor config, and real weight files instead of trusting an offline snapshot lookup, so a partial download left by an aborted fetch shows the Download button instead of failing to load - Removed whisper.cpp mentions from user-facing text: the ready status shows Loaded instead of the runtime name, picker rows show the source repository, and runtime error messages say local transcription runtime Verified with automated browser sessions and live API checks: selection closes the picker with no page errors, persisted settings hydrate on reload, a stale partial snapshot triggers download then loads on MPS and transcribes, and curated models download from the unslothai repos. 88 backend STT tests, typecheck, lint, i18n parity, and build pass. * Skip the duplicate source line for custom models in the STT picker A custom repository's display name is its id, so search results and the appended current selection rendered the same string twice. The source line now only renders when it differs from the name; curated rows keep their name, unslothai source repository, and download size. * Verify every shard of a sharded checkpoint in the downloaded check A snapshot holding one of N shards (or a corrupt shard index) passed the downloaded check and then failed at load. When model.safetensors.index.json exists, every shard in its weight map must now be present. Found by simulation; covered by a regression test. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Rename stale _starting references in the pump resilience tests The startup flag on TrainingBackend was renamed to _spawn_in_progress but two tests added alongside it still asserted on the old name, failing the Python 3.11 to 3.13 CI jobs. * Make the selected model row clearly highlighted in the STT picker The current selection was a faint background tint. It now uses the accent background with a medium weight name. Two line rows use a small corner radius; single line custom repo rows keep the pill shape. * Address review feedback on STT snapshot checks, VRAM release, and dictation UX Verify snapshot completeness in the load preflight so a partial download fails before the audio is decoded, for curated and custom repos alike. Drop the failed accelerator traceback before the CPU retry so the cache clear can actually release that memory. Keep unloading the GGUF sidecar after cancelling an in-flight Transformers load; both engines can hold memory at once. Allow Auto language with English-only .en checkpoints, matching the backend which sends no forced language. Keep the discard button usable while a transcription is pending so a slow or hung request cannot trap the composer in dictation mode. Stop linking Compare and settings test dictations to the unrelated active single chat thread. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Move the CPU retry out of the exception handler On Python 3.10 the interpreter exception state keeps its own reference to the traceback, so dropping it from the caught exception was not enough to release the failed accelerator load during the retry. Leaving the handler before clearing the cache works on every supported version. * Address review feedback on session handoff, chat pinning, and server lifetime Starting a dictation from a second entry point now cancels the session it replaces, so the old recording cannot keep the microphone open or save a transcript with no discard button pointing at it. The linked chat is pinned when recording starts, so switching threads while a transcription finalizes cannot relink the transcript to the newly opened chat. whisper-server is now bound to Studio's lifetime like the other long-lived children: PDEATHSIG on Linux, the parent job object on Windows, and pid adoption so the shutdown sweep reaps it; before this it survived a Ctrl+C exit as an orphan still holding the model. * Remove the dictation mic test from Voice settings The composer dictate button covers the same check, so the test row, its transcript panel, the unsupported fallback row, and their strings and search entry are gone. * Studio STT: gate GGUF whisper-server on training and fix dictation retry and dictionary edits GGUF (whisper.cpp) sidecar: - Launch whisper-server with --no-gpu while training is active, mirroring the Transformers sidecar's CPU device choice, so a mid-training dictation cannot reclaim the VRAM training just freed. - Report is_loading() during whisper-server startup so training VRAM admission accounts for the accelerator memory it is about to bind. - Require PyAV in is_available() so /audio/stt/status reports the engine unavailable when uploads cannot be decoded, instead of loading fine and then 501ing at transcription. - Reject a missing model before decoding audio, matching the Transformers download preflight. Voice settings: - The download Retry button now restarts the download; the sidecar error is sticky until a new start(), so re-polling alone never cleared it. Dictation dictionary: - Tabbing from an emptied entry to its remove button no longer commit-splices the row first, which shifted indices and deleted the wrong entry. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio STT: fix curated GGUF whisper filenames to match hosted repos The unslothai/whisper-*-GGUF repos host the checkpoint as whisper-<id>.bin, not ggml-<id>.bin, so every curated dictation download and cached-path lookup 404'd and the whisper.cpp engine could never load a model. Point GGML_STT_MODELS at the real filenames and guard the naming with a test. * Studio STT: validate a custom dictation repo before downloading it The Transformers STT engine accepts an arbitrary owner/model repo, but the download route handed it straight to snapshot_download, pulling a possibly large non-Whisper repository into the shared HF cache. Confirm the repo is a Whisper checkpoint first with the existing metadata-only validate_remote_model (no weights); curated ids short-circuit and the GGUF engine (curated-only) is unaffected. A non-Whisper repo now 422s before any download. * Studio STT: preempt a still-loading GGUF server for training admission A whisper-server still in its startup window binds accelerator memory but has no loaded_model yet, so training admission could miss it and launch into an OOM. Make the GGUF startup cancellable (cancel_pending_load signals an abort event and terminates the starting process without the load lock; _wait_for_server observes it and raises SttLoadCancelledError; wait_for_load_to_settle blocks on the lock until the killed server is reaped), and always fold the GGUF sidecar into the resident-STT summary so a resident Transformers model cannot mask a loading GGUF server. free_stt_model_for_training now cancels an in-flight load and waits for it to settle before training claims the memory. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio STT: fall back to Transformers when whisper-server is absent A curated dictation model (including the default small) hard-pinned the GGUF engine, but standard installs do not ship whisper-server, so every recording 501'd instead of using the Transformers engine that serves the same checkpoint -- the GGUF sidecar's own documented contract. Add _resolve_serving_stt_engine: a GGUF request for a curated id (the only ids GGUF accepts, all Transformers- servable) downgrades to Transformers when whisper-server is unavailable, applied consistently to download, load and transcribe (not unload, which targets a specific engine). The Voice tab likewise falls back to the Transformers status so the model is not shown unavailable and download is not blocked. * Studio STT: hide custom Whisper caches from the legacy model pickers The legacy /cached-models (and /cached-gguf) routes called is_hidden_model with only the owner/model id, which cannot reach the config-based Whisper check, so a downloaded custom (non-curated) Whisper checkpoint was still offered as a chat model. Pass the cached snapshot path so _path_is_whisper_model inspects the repo config and hides it, matching the discovery route. * Studio STT: hide GGUF dictation repos, lock-free status, unload fallback, split training eviction - Hide the curated GGUF dictation repos (unslothai/whisper-*-GGUF) from the chat model inventory and pickers, backend and frontend. Only their Transformers safetensors companions were hidden; the GGUF repos use a different org and a -GGUF suffix and carry a raw .bin with no whisper config.json, so they leaked into chat pickers. - Make the GGUF sidecar loaded_model/device accessors lock-free, mirroring the Transformers sidecar. transcribe() holds self._lock across the whole inference call, so /audio/stt status polls and training admission previously blocked behind an in-flight transcription. - stt_unload resolves through the serving resolver: a "gguf" pick on a host without whisper-server is served by the Transformers fallback, so unload must target that engine or the resident model is never freed. Unload also attempts every engine even if one raises, so a failure freeing one backend no longer skips the other. - free_stt_model_for_training frees the Transformers and GGUF sidecars under independent exception boundaries so a failure unloading one no longer skips the other before training claims the memory. Adds tests/test_stt_review_fixes.py covering all four. * Studio STT: resolve Auto dictation language for the model engine + snapshot process liveness - The model dictation adapter sent the raw setting (the literal "auto") to the backend, while the browser engine resolves Auto via resolveDictationLanguage. A batch of non-English voice notes came back mostly English on Auto. Add resolveModelDictationLanguage: only the literal "auto" is resolved to a concrete locale, gated so it becomes a language the model AND Whisper can honor (mirroring the backend's known-whisper-languages set); an explicit language, or a locale Whisper cannot honor, stays unchanged/auto-detect. Wire it into both adapter call sites. - GgmlSttSidecar._process_alive() read self._process twice; a concurrent unload() nulls it under the lock while loaded_model/device read lock-free, so a null between the two reads called None.poll(). Snapshot once. Adds a deterministic regression test. * studio: tighten comments and docstrings in the dictation modules * studio: harden dictation model downloads, GGML readiness, and recording paths Address review findings on the STT dictation feature: - build_whisper_cpp.sh refuses to delete a whisper.cpp tree under a custom Studio home unless it carries the Studio ownership marker, matching the setup.sh policy, and marks trees it creates - _snapshot_is_complete validates every shard of a sharded PyTorch (pytorch_model.bin.index.json) checkpoint like the safetensors path, and requires tokenizer assets (tokenizer.json or vocab.json + merges.txt) - custom-repo downloads pin the revision resolved at validation time and restrict snapshot_download to the model/tokenizer/config/preprocessor file classes Studio loads - the GGML sidecar holds its port reservation until just before spawning whisper-server and only accepts readiness from a responder that both looks like whisper.cpp's server and belongs to the still-running managed child, probing twice, so mic audio cannot be posted to a foreign local process - the recording adapter transcribes every non-empty segment; the RMS meter only shapes segment boundaries and can no longer discard quiet speech - Compare-pane dictation can cancel a pending transcription on second click, with the button relabeled while finalizing - localStorage quota recovery halves the dictation history until the save fits, so small histories shrink too - the System default TTS voice resolves to the platform default voice - new dictation UI imports go through the chat and hub feature barrels Regression tests cover the build-script gate, sharded PyTorch and tokenizer completeness, revision pinning and allow patterns, and the whisper-server readiness probe. * Fix STT download and voice picker follow-ups * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Add dictation button regression coverage * Studio: prebuilt whisper.cpp via the shared llama.cpp install core, slim bundles paired to the llama prebuilt (#7294) * Studio STT: add prebuilt whisper.cpp (whisper-server) installer New install_whisper_prebuilt.py downloads a per-platform whisper-server bundle published by the unslothai/whisper.cpp prebuilt CI into the managed whisper.cpp dir (build/bin/whisper-server) so local dictation needs no compiler. Mirrors install_node_prebuilt.py / install_llama_prebuilt.py: host + backend detection, sha256 pins (whisper_prebuilt_pins.json) as the trust anchor, staging + install lock + atomic swap, traversal-safe extract, co-located shared libs (RUNPATH=$ORIGIN), an UNSLOTH_WHISPER_PREBUILT_INFO.json marker with idempotent "already matches", and exit codes 0/1/2/3. Not wired into setup yet; the pins ship empty so every asset fails closed until the first fork release is published and its digests are reviewed in. * Studio STT: install prebuilt whisper.cpp during setup and update Add a fail-open whisper.cpp block to setup.sh after the llama.cpp section so `unsloth studio update` (and a fresh install) fetch the prebuilt whisper-server into the managed whisper.cpp dir the sidecar discovers. It skips a user-set WHISPER_SERVER_PATH/UNSLOTH_WHISPER_CPP_PATH, honors UNSLOTH_SKIP_WHISPER_INSTALL, forwards the resolved ROCm gfx, and never aborts setup: a busy install keeps the existing runtime, and an unavailable prebuilt stays quiet (source build is opt-in via UNSLOTH_WHISPER_FORCE_COMPILE) since Transformers STT and browser dictation remain. Register UNSLOTH_WHISPER_PREBUILT_INFO.json as Studio-owned evidence. * Studio STT: harden whisper-server child env + WSL ROCm detection - Sidecar spawns whisper-server with a scrubbed child env that prepends the binary dir (co-located GPU libs) to the loader path, and on WSL2 ROCm loads the system HIP first (HSA_ENABLE_DXG_DETECTION=1) so a bundle's bare-metal HIP does not segfault on /dev/dxg. Secret-bearing vars are dropped from the child. - find_whisper_server_binary now requires an executable, not just a file. - Installer rocm probe passes HSA_ENABLE_DXG_DETECTION and falls back to /opt/rocm/bin/rocminfo so a WSL ROCm host is not misdetected as CPU-only; gfx parsing skips the gfx000 CPU agent and generic ISA lines. - Tests for the child env (secret scrub, lib dir, WSL HIP precedence), the executable check, and the WSL rocm detection. * Studio STT: in-app whisper.cpp prebuilt update stack + ship pins in the wheel Mirror the llama.cpp update stack for the whisper.cpp prebuilt so Studio can detect and install a newer whisper-server release from inside the app: - backend/utils/whisper_cpp_freshness.py: read UNSLOTH_WHISPER_PREBUILT_INFO.json and compare the installed release against the newest unslothai/whisper.cpp release. Whisper tags are v<upstream>-unsloth.<N>, so is_behind compares a (major, minor, patch, serial) key with a strict downgrade guard; 24h cache; fail-open. - backend/utils/whisper_cpp_update.py: run install_whisper_prebuilt.py to fetch and atomically swap the newest bundle, unloading the warm GGUF sidecar first. - backend/routes/whisper.py mounted at /api/whisper (update-status + update). - pyproject: add whisper_prebuilt_pins.json to studio package-data so the installer's trust anchor ships in the wheel (it is a data file, not a .py module, so package discovery alone does not include it; node_prebuilt_pins.json is listed for the same reason). Without this a pip-installed wheel had no pins and the prebuilt install aborted to Transformers STT. Adds test_whisper_cpp_freshness.py (version parser, is_behind matrix + downgrade guard, marker layouts, stale decision, fail-open). * Studio STT: verify whisper prebuilts via the release checksum index, like llama.cpp Re-align the whisper.cpp prebuilt installer to install_llama_prebuilt.py's trust model: instead of a committed whisper_prebuilt_pins.json, verify every download against the release's own whisper-prebuilt-sha256.json checksum index, fetched from the same GitHub release. - parse_release_checksums / fetch_release_checksums / expected_sha256_for replace the pins layer. The index is validated for schema/component and that its release_tag matches the resolved release; an asset absent from it, a release that does not publish it, or a manifest sha256 that disagrees with it all fail closed to a source build. - resolve_release_tag now resolves the newest published release at runtime (or an explicit --published-release-tag), matching llama and the freshness check; removed the pinned-default and the UNSLOTH_WHISPER_ALLOW_UNVERIFIED opt-in. - Delete studio/whisper_prebuilt_pins.json and drop its pyproject package-data entry (nothing to ship now, same as llama which has no committed pins). - Adds test_install_whisper_prebuilt_checksums.py (index parser, fail-closed on uncovered asset, tampered-manifest guard, newest-release resolution). This is a same-origin checksum (integrity, not authenticity), identical to the llama.cpp installer; pair releases with GitHub artifact attestations for provenance. * Resolve whisper prebuilt release via the download host (no GitHub API) Mirror install_llama_prebuilt.py's fast path: resolve the release tag from the releases/latest redirect and fetch the manifest + checksum index from constructed releases/download URLs, so the common install path makes zero api.github.com calls (unauthenticated api.github.com is capped at 60 req/hour per IP; the download host is not). Fall back to the GitHub API only on a 404, malformed asset, or tag mismatch. * Studio STT: coverage-aware whisper prebuilt selection via a shared core whisper's select_artifact returned the first os/arch/backend manifest match and ignored the SM-coverage fields the release manifest already carries, so a Blackwell B200 (sm_100) was served cuda12-legacy (sms 50-61) -- runnable only via forward PTX JIT. install_llama_prebuilt.py on the same host correctly picks cuda13-newer. Extract the coverage-aware selection into a shared, component-agnostic core under studio/backend/utils/prebuilt/ (selection + GPU host-capability detection), lifted from llama's linux_cuda_choice_from_release / _artifact_covers_sms / _sm_range and generalised over a normalised artifact. whisper's HostInfo now records the GPU compute caps + driver CUDA version (honoring CUDA_VISIBLE_DEVICES), and select_artifact routes CUDA/ROCm through the shared selector: every visible SM must be covered, the tightest-covering profile wins (Blackwell-aware runtime-line ordering), ROCm matches the gfx target exactly, and an uncovered GPU falls back to the CPU bundle. CPU/Metal/Vulkan keep first-match. The resolver JSON, exit codes, and "already matches" contract are unchanged. On the B200 the installer now resolves cuda13-newer, matching llama. * Studio STT: gate whisper CUDA selection on the on-disk runtime, like llama The prebuilt CUDA bundles are dynamically linked and intentionally do NOT ship libcudart/libcublas -- they load the same runtime the host already has. So the driver's advertised CUDA version is only an upper bound: a cuda13 bundle still needs cuda13 runtime libraries present on disk. Port llama's on-disk runtime scan (detected_linux_runtime_lines / detected_windows_runtime_lines) into the shared core and intersect it with the driver-compatible lines in select_cuda_attempts. A host with a cuda13 driver but only cuda12 runtime (e.g. torch-cuda12) now correctly gets a cuda12 bundle instead of an unloadable cuda13 one; a host with no CUDA runtime at all falls back to CPU. Fixes a glob bug in the port (any(Path(d).glob(p) for d in dirs) tests generator truthiness, not a match) that made every major report present; add a real filesystem test that exercises the scan. * studio: harden shared prebuilt core to full llama parity Apply the review findings on the shared coverage-aware prebuilt-consumer core so whisper.cpp selection is exactly equivalent to the llama.cpp path. hosts.py: port llama's CUDA_VISIBLE_DEVICES handling. A GPU hidden by an index/UUID selector now reports has_usable_nvidia False instead of staying usable, via supports_explicit_visible_device_matching plus the physical / explicit-match branches, and _select_visible_rows now matches rows the way llama does (index or UUID, gpu- prefix optional) and skips unmatched tokens rather than keeping all rows. Adds the Linux /proc/driver/nvidia/gpus fallback and has_physical_nvidia. Adds parse_macos_version. runtime_libs.py: the Linux on-disk scan now requires the exact libcudart / libcublas SONAME (libcudart.so.13), not a libcudart.so.13* glob, so a bare versioned file without the SONAME symlink no longer counts as loadable. Hardens the ldconfig parse against an empty left-hand side. selection.py: fix the Blackwell/torch reordering so it keys on the covering runtime lines (falls through to the torch preference when the covering lines were filtered out), matching linux_cuda_choice_from_release. Corrects the compatible_runtime_lines_for_driver docstring: the bundles do not ship the CUDA runtime, so the driver version is only an upper bound and the caller must intersect with the on-disk scan. install_whisper_prebuilt.py: enforce a macOS artifact's min_os (new HostInfo.macos_version) so a bundle that cannot load on the host OS version is dropped. Keep resolver stdout to only the JSON line by leaving logs on stderr in --resolve-prebuilt mode, and map an unexpected probe failure to prebuilt_available False instead of a traceback. Tests: new host-probe suite for the visible-device logic, exact-SONAME runtime-scan cases, macOS min_os filtering, resolver stdout-only-JSON, exit-code mapping, and the repo key. * studio: fix whisper prebuilt selection + launch parity gaps from review A parallel review surfaced integration defects where the whisper path could select or launch a bundle that cannot run on a concrete host. Each is fixed to match install_llama_prebuilt.py. macOS min_os: the manifest labels macOS requirements as macos-<version> (e.g. macos-14.0), which the version parser could not read, so the guard was a no-op and a macOS-13 host would install the macos-14 Metal bundle. Strip the platform prefix before parsing. ROCm gfx detection: _detect_rocm_gfx returned the first gfx token and ignored HIP_VISIBLE_DEVICES / ROCR_VISIBLE_DEVICES / CUDA_VISIBLE_DEVICES. Since exact ROCm matching treats that token as the active GPU, a mixed APU + dGPU host (gfx1151 + gfx1100) with HIP_VISIBLE_DEVICES=1 installed the wrong archive. Route through a shared pick_rocm_gfx_target (lifted from llama) that parses per-GPU sections and honors the visibility vars (empty / -1 -> no AMD GPU). --rocm-gfx override: recording the arch without setting has_rocm left the host on its CUDA/CPU path so the ROCm bundle was never picked. --rocm-gfx now implies has_rocm and clears NVIDIA state, like llama's _apply_host_overrides. CUDA launch env: a CUDA bundle ships the ggml CUDA backend but not libcudart/libcublas, and the sidecar launch env exposed only the bundle dir, so on a host whose CUDA runtime lives only in the PyTorch wheels the selection would gate cuda usable but the server could not load it. Add the CUDA-from-PyTorch runtime dirs to the child loader path for CUDA bundles (bundle dir still first), mirroring binary_env. Also normalize a manifest artifact's supported_sms defensively (parity with llama's parser) and document that blackwell_min_toolkit_for_caps is retained for the Phase B llama Windows path. Not changed (verified parity, not defects): Linux/Windows min_os is enforced nowhere in llama (macOS only); the resolver is optimistic about the checksum index and the install path verifies. * studio: tighten prebuilt-core code comments * studio: lift shared prebuilt installer core out of the whisper installer * studio: reuse the llama.cpp prebuilt installer machinery for whisper * studio: unify llama and whisper prebuilt installers on a shared descriptor core * studio: consolidate prebuilt installer tests into the shared core suite Grow tests/studio/install/test_prebuilt_core.py from 62 to 164 tests so every component-agnostic behavior runs against both descriptors: the full seven profile CUDA release matrix (multi-GPU, on-disk runtime gating, shuffle stability, missing SM metadata, dotted SM normalization, no-driver fallback policy), the ROCm gfx family matrix, macOS min_os gating and its helper, backend resolution incl. cpu-fallback precedence and Intel-mac auto detect, checksum-index non-object and plain-lookup cases, the tar symlink/hardlink extraction guards moved from the llama suite, and the compute-cap, visible device, runtime-line and Blackwell helper value tables moved verbatim from the llama characterization suites. Delete only tests whose exact behavior the master now asserts for the same component: 40 pure-alias helper cases in test_selection_logic.py (replaced by value-identical master tables plus an alias-identity pin), 6 extraction moves and the master-absorbed zip-symlink case in the llama logic suite, 3 routing twins in test_rocm_support.py already pinned byte-for-byte in test_selection_logic.py, the 2 Blackwell helper tables in the backend resolve suite, 28 whisper logic tests and 10 whisper checksum tests re-asserted by the master whisper parameterization. Wrapper wiring pins, the llama release plan dialect, fingerprints and every llama-only behavior stay untouched. * studio: dedupe sidecar and update helpers into the backend prebuilt package * studio: chain whisper.cpp prebuilt updates onto the llama.cpp update flow * studio: consume paired slim whisper prebuilts via the llama ggml runtime * studio: serve every whisper backend from slim prebuilts * studio: drop the whisper fat per-accelerator selection chain unslothai/whisper.cpp releases are slim-only from v1.9.1-unsloth.2: one ggml-less bundle per os/arch, paired to the llama.cpp prebuilt that provides every ggml backend. Delete the whisper-side fat CUDA/ROCm/metal/vulkan selection glue; keep slim selection + pairing, link_ggml_runtime, and one legacy shape, the published fat CPU bundle of an explicitly pinned pre-slim release. Exit 2 now reads as prebuilt unavailable (whisper never source builds); setup already treats it that way. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Wire libomp runtime DLL alongside ggml in slim whisper installs llama's clang-built windows-arm64 ggml-base.dll imports libomp140.aarch64.dll, shipped in the llama bundle but not a system DLL. Without it next to whisper-server.exe the loader fails with STATUS_DLL_NOT_FOUND before main. MSVC x64 links vcomp140.dll from System32 and Linux ggml uses system libgomp.so.1, so only windows-arm64 was affected. The empty-runtime guard still requires a real ggml library; libomp alone is not a pairing. * studio: drop whisper-side fat-selection support structure Slim whisper bundles are selected per os/arch only; all accelerator capability comes from the installed llama.cpp prebuilt, whose installer already did the coverage-aware selection. Remove the machinery that only existed to pick among fat per-accelerator whisper bundles: - prebuilt_core: delete the generic CUDA/ROCm coverage selection (select_cuda_artifact, select_rocm_artifact, ArtifactView adapters, detected_cuda_runtime_lines, the exact-SONAME linux probe) that no shipped component routes through; llama keeps its own selection chain and whisper shadows select_artifact with the slim-only version. select_artifact is now a plain os/arch/backend first-match. - install_whisper_prebuilt: drop the HostInfo CUDA fields (compute_caps, driver_cuda_version, torch_runtime_line) and the torch runtime probe that populated them; nothing reachable reads them, and the resolver payload sources runtime_line from the artifact. - whisper_cpp_update: delete the standalone start_update job worker; whisper applies only run as the chained phase of the combined llama+whisper update. The status payload keeps its job field (idle). - routes/whisper: drop the progress logger that could never fire. - tests: remove tests of the deleted paths and tests duplicating the descriptor-parameterized core suite or the llama freshness suite. Contracts unchanged: resolver JSON keys, exit codes, marker fields, pairing logs, and the pinned pre-slim fat CPU escape hatch. * Address review feedback on the whisper prebuilt update and install paths - Pin the chained whisper phase to the release the freshness check offered, so the download-host latest pointer cannot reinstall an older build in a loop - Wire the whisper prebuilt install into setup.ps1 (Windows setup previously skipped it entirely) - Treat a non-executable server or missing wired ggml libraries as a broken install instead of reporting already matches - Keep whisper sidecar reloads out of the job-level reload flag and resync chat state after a partial chained update that unloaded llama - Repoint home and profile vars for the whisper-server subprocess at a managed scratch dir and drop credential-store pointers - Clear the prebuilt marker before the opt-in source build overwrite - Write the prebuilt marker with explicit utf-8 encoding * Tighten comments in the whisper prebuilt consumer * Harden the Windows whisper setup phase and the chained update edges - setup.ps1: honor WHISPER_SERVER_PATH / UNSLOTH_WHISPER_CPP_PATH / UNSLOTH_SKIP_WHISPER_INSTALL, run the custom-home ownership guard before the atomic install, and forward the release-tag pin and ROCm hints like setup.sh - sidecar: a cpu-selected install launches whisper-server with --no-gpu (slim wiring links every llama backend, so the flag is what keeps a deliberate CPU choice off the GPU) - chained update: leave whisper unpinned on macOS (the llama phase can walk back there, and a newest-tag pin could be an impossible pairing on every retry) and treat installer exit 2 as kept-existing-runtime instead of failing the combined job - job.to_tag now comes only from the llama phase, so a whisper-only round cannot report a llama update that never ran * Fix slim whisper runtime follow-ups * Address remaining whisper update reviews * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Address remaining prebuilt update reviews * Fix remaining chained update reviews * Fix remaining whisper runtime review edges * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: danielhanchen <unslothai@gmail.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: oobabooga <112222186+oobabooga@users.noreply.github.com> --------- Co-authored-by: danielhanchen <danielhanchen@gmail.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Unsloth <michaelhan@Michaels-MacBook-Pro.local> Co-authored-by: oobabooga <112222186+oobabooga@users.noreply.github.com>
2427 lines
92 KiB
Python
2427 lines
92 KiB
Python
#!/usr/bin/env python3
|
|
# SPDX-License-Identifier: AGPL-3.0-only
|
|
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
|
|
|
"""Component-agnostic machinery shared by the ggml-family prebuilt installers.
|
|
|
|
``install_llama_prebuilt.py`` and ``install_whisper_prebuilt.py`` both run on
|
|
this module, which owns two layers:
|
|
|
|
1. Primitives: HTTP fetches with retries and token-safe redirects, verified
|
|
downloads, safe archive extraction, the install lock, sha256 helpers, and
|
|
CUDA runtime-line / compute-capability selection. Moved verbatim out of
|
|
``install_llama_prebuilt.py``, which re-exports them under their old names.
|
|
|
|
2. The generic descriptor-driven install flow: release resolution (download
|
|
host fast path, GitHub API fallback), checksum-index parsing, coverage-aware
|
|
artifact selection, verified download + extraction + staged atomic
|
|
activation, marker/fingerprint handling, and the resolve probe. Whisper runs
|
|
entirely on this flow; a third component plugs in with a ``ComponentDescriptor``.
|
|
|
|
Seam rule: every function calling a collaborator tests monkeypatch takes an
|
|
``ops`` handle first. ``ops`` resolves names in the calling installer module's
|
|
globals first (so a ``monkeypatch.setattr`` still wins) and falls back to this
|
|
module's defaults, letting a descriptor-only component work with no installer
|
|
module behind it.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import errno
|
|
import functools
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import random
|
|
import re
|
|
import shutil
|
|
import socket
|
|
import subprocess
|
|
import sys
|
|
import tarfile
|
|
import tempfile
|
|
import time
|
|
import urllib.error
|
|
import urllib.parse
|
|
import urllib.request
|
|
import zipfile
|
|
from contextlib import contextmanager
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Any, Callable, Iterable, Iterator
|
|
|
|
try:
|
|
from filelock import FileLock, Timeout as FileLockTimeout
|
|
except ImportError:
|
|
FileLock = None
|
|
FileLockTimeout = None
|
|
|
|
|
|
class PrebuiltFallback(RuntimeError):
|
|
pass
|
|
|
|
|
|
class BusyInstallConflict(RuntimeError):
|
|
pass
|
|
|
|
|
|
# ── Defaults a component module may override via its own globals ──
|
|
USER_AGENT = "unsloth-studio-prebuilt"
|
|
GITHUB_AUTH_HOSTS = {"api.github.com", "github.com"}
|
|
HF_AUTH_HOSTS = {"huggingface.co", "www.huggingface.co"}
|
|
RETRYABLE_HTTP_STATUS = {408, 429, 500, 502, 503, 504}
|
|
HTTP_FETCH_ATTEMPTS = 4
|
|
HTTP_FETCH_BASE_DELAY_SECONDS = 0.75
|
|
JSON_FETCH_ATTEMPTS = 3
|
|
TTY_PROGRESS_START_DELAY_SECONDS = 0.5
|
|
INSTALL_LOCK_TIMEOUT_SECONDS = 300
|
|
INSTALL_STAGING_ROOT_NAME = ".staging"
|
|
SCHEMA_VERSION = 1
|
|
# Backend to retry when the preferred one has no covering asset; None disables it.
|
|
FALLBACK_BACKEND: str | None = "cpu"
|
|
_RATE_LIMIT_WAIT_CAP_SECONDS = 60.0
|
|
|
|
# Lowest CUDA major we ship prebuilts for, and highest we probe for installed
|
|
# runtime libraries. Detection and runtime-line derivation are generated per
|
|
# major, so a new toolkit (cuda14, ...) needs no code change while ggml keeps
|
|
# the cudart64_<major>.dll / libcudart.so.<major> naming.
|
|
_MIN_CUDA_MAJOR = 12
|
|
_MAX_PROBE_CUDA_MAJOR = 19
|
|
|
|
# Blackwell floor is sm_100 (B100/B200 sm_100, B300/GB300 sm_103 below consumer
|
|
# RTX 50 sm_120); the family needs toolkit >= 12.8, sm_103/sm_121 need 12.9.
|
|
_BLACKWELL_MIN_SM = 100
|
|
_BLACKWELL_MIN_TOOLKIT = (12, 8)
|
|
_BLACKWELL_SM_MIN_TOOLKIT = {103: (12, 9), 121: (12, 9)}
|
|
|
|
|
|
def log(message: str) -> None:
|
|
print(f"[prebuilt-core] {message}", file = sys.stderr)
|
|
|
|
|
|
def _cuda_runtime_lines_for_major(major: int) -> list[str]:
|
|
"""Runtime lines a driver of this CUDA major can use (its own major and any
|
|
older one, newest first down to the minimum we ship)."""
|
|
return [f"cuda{m}" for m in range(major, _MIN_CUDA_MAJOR - 1, -1)]
|
|
|
|
|
|
# ── The ops seam ──
|
|
_MISSING = object()
|
|
|
|
# Core functions expecting an ``ops`` first argument. ModuleOps binds itself when
|
|
# a lookup falls back to the core default, so a descriptor-only component gets
|
|
# working defaults while an installer wrapper (or a test monkeypatch) always wins.
|
|
_OPS_FIRST_NAMES = {
|
|
"auth_headers",
|
|
"github_api_headers",
|
|
"download_bytes",
|
|
"fetch_json",
|
|
"download_file",
|
|
"download_file_verified",
|
|
"download_file_verified_strict",
|
|
"github_release",
|
|
"github_release_assets",
|
|
"download_host_latest_release_tag",
|
|
"fetch_download_host_json",
|
|
"linux_runtime_dirs_for_required_libraries",
|
|
"detected_linux_runtime_lines",
|
|
"detected_windows_runtime_lines",
|
|
"parse_manifest",
|
|
"parse_release_checksums",
|
|
"fetch_release_checksums",
|
|
"expected_sha256_for",
|
|
"macos_min_os_ok",
|
|
"artifacts_for_host",
|
|
"select_artifact",
|
|
"select_artifact_with_fallback",
|
|
"auto_detect_backend",
|
|
"resolve_backend",
|
|
"fetch_release_bundle",
|
|
"asset_download_url",
|
|
"resolve_newest_release_tag",
|
|
"resolve_release_tag",
|
|
"resolve_release_via_download_host",
|
|
"fetch_release_for_install",
|
|
"write_prebuilt_metadata",
|
|
"load_prebuilt_metadata",
|
|
"existing_install_matches",
|
|
"metadata_path",
|
|
"selection_from_artifact",
|
|
"plan_selection",
|
|
"install_from_bundle",
|
|
"install_prebuilt",
|
|
"resolve_prebuilt",
|
|
"installed_server_path",
|
|
"assemble_install_tree",
|
|
"validate_staged_server",
|
|
"locate_server_in_tree",
|
|
# Underscored aliases the installer modules expose for their tests.
|
|
"_download_host_latest_release_tag",
|
|
"_download_host_json",
|
|
"_resolve_release_via_download_host",
|
|
"_install_from_bundle",
|
|
}
|
|
|
|
|
|
class ModuleOps:
|
|
"""Late-binding name resolver over an installer module's globals.
|
|
|
|
Lookup hits the wrapped globals first (so a ``monkeypatch.setattr`` is always
|
|
observed), then this module's defaults (ops-first defaults come back bound).
|
|
"""
|
|
|
|
def __init__(self, module_globals: dict[str, Any]) -> None:
|
|
self._globals = module_globals
|
|
|
|
def __getattr__(self, name: str) -> Any:
|
|
value = self._globals.get(name, _MISSING)
|
|
if value is _MISSING:
|
|
value = globals().get(name, _MISSING)
|
|
if value is _MISSING:
|
|
raise AttributeError(f"prebuilt component namespace has no attribute {name!r}")
|
|
if name in _OPS_FIRST_NAMES:
|
|
return functools.partial(value, self)
|
|
return value
|
|
|
|
|
|
@dataclass(frozen = True)
|
|
class ComponentDescriptor:
|
|
"""Everything the generic flow needs about one ggml-family component.
|
|
|
|
Hooks mirror the module-level names an installer defines; a descriptor-only
|
|
component supplies them here and ``component_ops`` builds a namespace with
|
|
core defaults behind them.
|
|
"""
|
|
|
|
component: str # manifest "component" value, e.g. "whisper.cpp"
|
|
log_prefix: str # stderr prefix, e.g. "whisper-prebuilt"
|
|
published_repo: str # fork publishing the prebuilt releases
|
|
manifest_asset_name: str
|
|
sha256_asset_name: str
|
|
metadata_filename: str # marker written into the install dir
|
|
user_agent: str
|
|
supported_backends: tuple[str, ...] = ("cpu", "cuda", "metal", "vulkan", "rocm")
|
|
schema_version: int = SCHEMA_VERSION
|
|
# Backend to retry when the preferred one has no covering asset: "cpu"
|
|
# (whisper) ships a CPU bundle in every release; None (llama) reports "no
|
|
# prebuilt" so the caller falls back to a source build.
|
|
fallback_backend: str | None = "cpu"
|
|
staging_root_name: str = INSTALL_STAGING_ROOT_NAME
|
|
run_staged_validation: bool = False
|
|
# Hooks. Each mirrors the module-level function it replaces; None keeps the
|
|
# core default (which raises if truly required).
|
|
detect_host: Callable[[], Any] | None = None
|
|
host_platform_tokens: Callable[[Any], tuple[str, str]] | None = None
|
|
server_binary_name: Callable[[Any], str] | None = None
|
|
runtime_bin_dir: Callable[[Path, Any], Path] | None = None
|
|
auto_detect_backend: Callable[[Any], str] | None = None
|
|
|
|
|
|
def component_namespace(descriptor: ComponentDescriptor) -> dict[str, Any]:
|
|
"""Materialize a descriptor into the module-like namespace ``ModuleOps``
|
|
resolves against; core defaults cover anything not listed here."""
|
|
prefix = descriptor.log_prefix
|
|
|
|
def component_log(message: str) -> None:
|
|
print(f"[{prefix}] {message}", file = sys.stderr)
|
|
|
|
namespace: dict[str, Any] = {
|
|
"COMPONENT": descriptor.component,
|
|
"SCHEMA_VERSION": descriptor.schema_version,
|
|
"DEFAULT_PUBLISHED_REPO": descriptor.published_repo,
|
|
"MANIFEST_ASSET_NAME": descriptor.manifest_asset_name,
|
|
"SHA256_ASSET_NAME": descriptor.sha256_asset_name,
|
|
"METADATA_FILENAME": descriptor.metadata_filename,
|
|
"SUPPORTED_BACKENDS": descriptor.supported_backends,
|
|
"USER_AGENT": descriptor.user_agent,
|
|
"FALLBACK_BACKEND": descriptor.fallback_backend,
|
|
"INSTALL_STAGING_ROOT_NAME": descriptor.staging_root_name,
|
|
"_RUN_STAGED_PREBUILT_VALIDATION": descriptor.run_staged_validation,
|
|
"log": component_log,
|
|
}
|
|
for hook in (
|
|
"detect_host",
|
|
"host_platform_tokens",
|
|
"server_binary_name",
|
|
"runtime_bin_dir",
|
|
"auto_detect_backend",
|
|
):
|
|
value = getattr(descriptor, hook)
|
|
if value is not None:
|
|
namespace[hook] = value
|
|
return namespace
|
|
|
|
|
|
def component_ops(descriptor: ComponentDescriptor) -> ModuleOps:
|
|
return ModuleOps(component_namespace(descriptor))
|
|
|
|
|
|
# ── Lock/busy classification ──
|
|
def _os_error_messages(exc: BaseException) -> list[str]:
|
|
messages: list[str] = []
|
|
if isinstance(exc, OSError):
|
|
for value in (
|
|
getattr(exc, "strerror", None),
|
|
getattr(exc, "filename", None),
|
|
getattr(exc, "filename2", None),
|
|
):
|
|
if isinstance(value, str) and value:
|
|
messages.append(value)
|
|
text = str(exc)
|
|
if text:
|
|
messages.append(text)
|
|
return [message.lower() for message in messages if message]
|
|
|
|
|
|
def is_busy_lock_error(exc: BaseException) -> bool:
|
|
if isinstance(exc, BusyInstallConflict):
|
|
return True
|
|
if isinstance(exc, OSError):
|
|
if exc.errno in {
|
|
errno.EACCES,
|
|
errno.EBUSY,
|
|
errno.EPERM,
|
|
errno.ETXTBSY,
|
|
}:
|
|
return True
|
|
if getattr(exc, "winerror", None) in {5, 32, 145}:
|
|
return True
|
|
for message in _os_error_messages(exc):
|
|
if any(
|
|
needle in message
|
|
for needle in (
|
|
"access is denied",
|
|
"being used by another process",
|
|
"device or resource busy",
|
|
"permission denied",
|
|
"text file busy",
|
|
"file is in use",
|
|
"process cannot access the file",
|
|
"cannot create a file when that file already exists",
|
|
)
|
|
):
|
|
return True
|
|
return False
|
|
|
|
|
|
# ── HTTP: auth, redirects, retries ──
|
|
def parsed_hostname(url: str | None) -> str | None:
|
|
if not url:
|
|
return None
|
|
try:
|
|
hostname = urllib.parse.urlparse(url).hostname
|
|
except Exception:
|
|
return None
|
|
if not hostname:
|
|
return None
|
|
return hostname.lower()
|
|
|
|
|
|
def should_send_github_auth(url: str | None) -> bool:
|
|
return parsed_hostname(url) in GITHUB_AUTH_HOSTS
|
|
|
|
|
|
def should_send_hf_auth(url: str | None) -> bool:
|
|
return parsed_hostname(url) in HF_AUTH_HOSTS
|
|
|
|
|
|
def auth_headers(ops: ModuleOps, url: str | None = None) -> dict[str, str]:
|
|
headers = {
|
|
"User-Agent": ops.USER_AGENT,
|
|
}
|
|
token = os.environ.get("GH_TOKEN") or os.environ.get("GITHUB_TOKEN")
|
|
if token and should_send_github_auth(url):
|
|
headers["Authorization"] = f"Bearer {token}"
|
|
return headers
|
|
# Anonymous huggingface.co fetches share a per-IP rate limit CI fleets
|
|
# exhaust (HTTP 429); authenticate when a token is available.
|
|
hf_token = os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN")
|
|
if hf_token and should_send_hf_auth(url):
|
|
headers["Authorization"] = f"Bearer {hf_token}"
|
|
return headers
|
|
|
|
|
|
class _CrossHostAuthStrippingRedirectHandler(urllib.request.HTTPRedirectHandler):
|
|
"""Drop Authorization when a redirect leaves the original host.
|
|
|
|
huggingface.co redirects downloads to CDN hosts whose signed URLs can reject
|
|
a foreign Authorization header; urllib forwards headers across redirects by
|
|
default (requests/huggingface_hub strip them).
|
|
"""
|
|
|
|
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
|
new_request = super().redirect_request(req, fp, code, msg, headers, newurl)
|
|
if new_request is not None and parsed_hostname(newurl) != parsed_hostname(req.full_url):
|
|
new_request.headers.pop("Authorization", None)
|
|
new_request.unredirected_hdrs.pop("Authorization", None)
|
|
return new_request
|
|
|
|
|
|
_URL_OPENER = urllib.request.build_opener(_CrossHostAuthStrippingRedirectHandler())
|
|
|
|
|
|
def github_api_headers(ops: ModuleOps, url: str | None = None) -> dict[str, str]:
|
|
return {
|
|
"Accept": "application/vnd.github+json",
|
|
**ops.auth_headers(url),
|
|
}
|
|
|
|
|
|
def is_github_api_url(url: str | None) -> bool:
|
|
return parsed_hostname(url) == "api.github.com"
|
|
|
|
|
|
def is_retryable_url_error(exc: Exception) -> bool:
|
|
if isinstance(exc, urllib.error.HTTPError):
|
|
# GitHub returns 403 (not 429) on API rate-limit; anonymous calls share a
|
|
# 60-req/hour bucket per runner IP that CI fleets exhaust. Treat 403
|
|
# against api.github.com as retryable so we get a backoff cycle or two
|
|
# (honouring Retry-After / X-RateLimit-Reset) before the source-build
|
|
# fallback fires. 403s on other hosts (private downloads, auth) stay non-retryable.
|
|
if exc.code == 403:
|
|
return is_github_api_url(getattr(exc, "url", None))
|
|
return exc.code in RETRYABLE_HTTP_STATUS
|
|
if isinstance(exc, urllib.error.URLError):
|
|
return True
|
|
if isinstance(exc, TimeoutError):
|
|
return True
|
|
if isinstance(exc, socket.timeout):
|
|
return True
|
|
return False
|
|
|
|
|
|
def _http_error_retry_delay(exc: Exception) -> float | None:
|
|
"""Recommended wait from rate-limit headers on a 403/429.
|
|
|
|
None when no header is present or the wait exceeds
|
|
_RATE_LIMIT_WAIT_CAP_SECONDS (the source-build fallback is faster).
|
|
"""
|
|
if not isinstance(exc, urllib.error.HTTPError):
|
|
return None
|
|
headers = getattr(exc, "headers", None)
|
|
if headers is None:
|
|
return None
|
|
retry_after = headers.get("Retry-After")
|
|
if retry_after and retry_after.strip().isdigit():
|
|
wait = float(retry_after.strip())
|
|
return wait if wait <= _RATE_LIMIT_WAIT_CAP_SECONDS else None
|
|
rate_reset = headers.get("X-RateLimit-Reset")
|
|
if rate_reset and rate_reset.strip().isdigit():
|
|
wait = float(rate_reset.strip()) - time.time()
|
|
if 0.0 < wait <= _RATE_LIMIT_WAIT_CAP_SECONDS:
|
|
return wait + 1.0 # +1s of slack so the bucket is fresh
|
|
return None
|
|
|
|
|
|
def sleep_backoff(
|
|
attempt: int,
|
|
*,
|
|
base_delay: float = HTTP_FETCH_BASE_DELAY_SECONDS,
|
|
exc: Exception | None = None,
|
|
) -> None:
|
|
delay = base_delay * (2 ** max(attempt - 1, 0))
|
|
header_delay = _http_error_retry_delay(exc) if exc is not None else None
|
|
if header_delay is not None:
|
|
delay = max(delay, header_delay)
|
|
delay += random.uniform(0.0, 0.2)
|
|
time.sleep(delay)
|
|
|
|
|
|
# ── Atomic writes and hashing ──
|
|
def atomic_write_bytes(destination: Path, data: bytes) -> None:
|
|
destination.parent.mkdir(parents = True, exist_ok = True)
|
|
with tempfile.NamedTemporaryFile(
|
|
prefix = destination.name + ".tmp-",
|
|
dir = destination.parent,
|
|
delete = False,
|
|
) as handle:
|
|
tmp_path = Path(handle.name)
|
|
handle.write(data)
|
|
handle.flush()
|
|
os.fsync(handle.fileno())
|
|
os.replace(tmp_path, destination)
|
|
|
|
|
|
def atomic_replace_from_tempfile(tmp_path: Path, destination: Path) -> None:
|
|
destination.parent.mkdir(parents = True, exist_ok = True)
|
|
os.replace(tmp_path, destination)
|
|
|
|
|
|
def sha256_file(path: Path) -> str:
|
|
digest = hashlib.sha256()
|
|
with path.open("rb") as handle:
|
|
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
|
digest.update(chunk)
|
|
return digest.hexdigest()
|
|
|
|
|
|
def sha256_bytes(data: bytes) -> str:
|
|
return hashlib.sha256(data).hexdigest()
|
|
|
|
|
|
def normalize_sha256_digest(value: str | None) -> str | None:
|
|
if not isinstance(value, str) or not value:
|
|
return None
|
|
lowered = value.lower()
|
|
if lowered.startswith("sha256:"):
|
|
lowered = lowered.split(":", 1)[1]
|
|
if len(lowered) != 64 or any(ch not in "0123456789abcdef" for ch in lowered):
|
|
return None
|
|
return lowered
|
|
|
|
|
|
def validate_schema_version(
|
|
payload: dict[str, Any],
|
|
*,
|
|
label: str,
|
|
schema_version: int = SCHEMA_VERSION,
|
|
error: type[Exception] = RuntimeError,
|
|
) -> None:
|
|
value = payload.get("schema_version")
|
|
if value is None:
|
|
return
|
|
try:
|
|
normalized = int(value)
|
|
except (TypeError, ValueError) as exc:
|
|
raise error(f"{label} schema_version was not an integer") from exc
|
|
if normalized != schema_version:
|
|
raise error(f"{label} schema_version={normalized} is unsupported")
|
|
|
|
|
|
# ── Download progress ──
|
|
def format_byte_count(num_bytes: float) -> str:
|
|
units = ["B", "KiB", "MiB", "GiB", "TiB"]
|
|
value = float(num_bytes)
|
|
for unit in units:
|
|
if abs(value) < 1024.0 or unit == units[-1]:
|
|
if unit == "B":
|
|
return f"{int(value)} {unit}"
|
|
return f"{value:.1f} {unit}"
|
|
value /= 1024.0
|
|
return f"{num_bytes:.1f} B"
|
|
|
|
|
|
def _progress_percent_step() -> int:
|
|
"""Non-tty milestone granularity. The in-app updater sets
|
|
UNSLOTH_PROGRESS_PERCENT_STEP=5 for finer progress lines."""
|
|
try:
|
|
step = int(os.environ.get("UNSLOTH_PROGRESS_PERCENT_STEP", "25"))
|
|
except ValueError:
|
|
return 25
|
|
return min(max(step, 1), 50)
|
|
|
|
|
|
class DownloadProgress:
|
|
def __init__(self, label: str, total_bytes: int | None) -> None:
|
|
self.label = label
|
|
self.total_bytes = total_bytes if total_bytes and total_bytes > 0 else None
|
|
self.start_time = time.monotonic()
|
|
self.last_emit = 0.0
|
|
term_ok = os.environ.get("TERM", "").lower() != "dumb"
|
|
self.stream = (
|
|
sys.stderr if sys.stderr.isatty() else sys.stdout if sys.stdout.isatty() else sys.stderr
|
|
)
|
|
self.is_tty = term_ok and self.stream.isatty()
|
|
self.completed = False
|
|
self.milestone_step = _progress_percent_step()
|
|
self.last_milestone_percent = -1
|
|
self.last_milestone_bytes = 0
|
|
self.has_rendered_tty_progress = False
|
|
|
|
def _render(
|
|
self,
|
|
downloaded_bytes: int,
|
|
*,
|
|
final: bool = False,
|
|
) -> str:
|
|
elapsed = max(time.monotonic() - self.start_time, 1e-6)
|
|
speed = downloaded_bytes / elapsed
|
|
speed_text = f"{format_byte_count(speed)}/s"
|
|
if self.total_bytes is not None:
|
|
percent = min(100.0, (downloaded_bytes / self.total_bytes) * 100.0)
|
|
return (
|
|
f"{self.label}: {percent:5.1f}% "
|
|
f"({format_byte_count(downloaded_bytes)}/{format_byte_count(self.total_bytes)}) "
|
|
f"at {speed_text}"
|
|
)
|
|
if final:
|
|
return f"{self.label}: {format_byte_count(downloaded_bytes)} downloaded at {speed_text}"
|
|
return f"{self.label}: {format_byte_count(downloaded_bytes)} downloaded at {speed_text}"
|
|
|
|
def update(self, downloaded_bytes: int) -> None:
|
|
now = time.monotonic()
|
|
if self.is_tty:
|
|
elapsed = now - self.start_time
|
|
if not self.has_rendered_tty_progress:
|
|
if self.total_bytes is not None and downloaded_bytes >= self.total_bytes:
|
|
return
|
|
if elapsed < TTY_PROGRESS_START_DELAY_SECONDS:
|
|
return
|
|
min_interval = 0.2
|
|
if (
|
|
self.has_rendered_tty_progress
|
|
and not self.completed
|
|
and (now - self.last_emit) < min_interval
|
|
):
|
|
return
|
|
self.last_emit = now
|
|
line = self._render(downloaded_bytes)
|
|
self.stream.write("\r\033[K" + line)
|
|
self.stream.flush()
|
|
self.has_rendered_tty_progress = True
|
|
return
|
|
|
|
should_emit = False
|
|
if self.total_bytes is not None:
|
|
percent = int((downloaded_bytes * 100) / max(self.total_bytes, 1))
|
|
step = self.milestone_step
|
|
milestone_percent = min((percent // step) * step, 100)
|
|
if milestone_percent > self.last_milestone_percent and milestone_percent < 100:
|
|
self.last_milestone_percent = milestone_percent
|
|
should_emit = True
|
|
else:
|
|
byte_step = 25 * 1024 * 1024
|
|
if (
|
|
downloaded_bytes - self.last_milestone_bytes >= byte_step
|
|
and (now - self.last_emit) >= 5.0
|
|
):
|
|
self.last_milestone_bytes = downloaded_bytes
|
|
should_emit = True
|
|
|
|
if not should_emit:
|
|
return
|
|
|
|
self.last_emit = now
|
|
self.stream.write(self._render(downloaded_bytes) + "\n")
|
|
self.stream.flush()
|
|
|
|
def finish(self, downloaded_bytes: int) -> None:
|
|
self.completed = True
|
|
line = self._render(downloaded_bytes, final = True)
|
|
if self.is_tty:
|
|
if not self.has_rendered_tty_progress:
|
|
return
|
|
self.stream.write("\r\033[K")
|
|
else:
|
|
self.stream.write(line + "\n")
|
|
self.stream.flush()
|
|
|
|
|
|
def download_label_from_url(url: str) -> str:
|
|
name = Path(urllib.parse.urlparse(url).path).name
|
|
return name or url
|
|
|
|
|
|
# ── Downloads ──
|
|
def download_bytes(
|
|
ops: ModuleOps,
|
|
url: str,
|
|
*,
|
|
timeout: int = 120,
|
|
attempts: int | None = None,
|
|
headers: dict[str, str] | None = None,
|
|
progress_label: str | None = None,
|
|
) -> bytes:
|
|
if attempts is None:
|
|
attempts = ops.HTTP_FETCH_ATTEMPTS
|
|
last_exc: Exception | None = None
|
|
for attempt in range(1, attempts + 1):
|
|
try:
|
|
request = urllib.request.Request(url, headers = headers or ops.auth_headers(url))
|
|
with ops._URL_OPENER.open(request, timeout = timeout) as response:
|
|
total_bytes: int | None = None
|
|
content_length = response.headers.get("Content-Length")
|
|
if content_length and content_length.isdigit():
|
|
total_bytes = int(content_length)
|
|
progress = DownloadProgress(progress_label, total_bytes) if progress_label else None
|
|
data = bytearray()
|
|
while True:
|
|
chunk = response.read(1024 * 1024)
|
|
if not chunk:
|
|
break
|
|
data.extend(chunk)
|
|
if progress is not None:
|
|
progress.update(len(data))
|
|
if progress is not None:
|
|
progress.finish(len(data))
|
|
return bytes(data)
|
|
except Exception as exc:
|
|
last_exc = exc
|
|
if attempt >= attempts or not is_retryable_url_error(exc):
|
|
raise
|
|
ops.log(f"fetch failed ({attempt}/{attempts}) for {url}: {exc}; retrying")
|
|
sleep_backoff(attempt, exc = exc)
|
|
assert last_exc is not None
|
|
raise last_exc
|
|
|
|
|
|
def fetch_json(ops: ModuleOps, url: str) -> Any:
|
|
attempts = ops.JSON_FETCH_ATTEMPTS if is_github_api_url(url) else 1
|
|
last_decode_exc: Exception | None = None
|
|
for attempt in range(1, attempts + 1):
|
|
try:
|
|
data = ops.download_bytes(
|
|
url,
|
|
timeout = 30,
|
|
headers = ops.github_api_headers(url)
|
|
if is_github_api_url(url)
|
|
else ops.auth_headers(url),
|
|
)
|
|
except urllib.error.HTTPError as exc:
|
|
if exc.code == 403 and is_github_api_url(url):
|
|
hint = ""
|
|
if not (os.environ.get("GH_TOKEN") or os.environ.get("GITHUB_TOKEN")):
|
|
hint = "; set GH_TOKEN or GITHUB_TOKEN to avoid GitHub API rate limits"
|
|
raise RuntimeError(f"GitHub API returned 403 for {url}{hint}") from exc
|
|
raise
|
|
if not data:
|
|
last_decode_exc = RuntimeError(f"downloaded empty JSON payload from {url}")
|
|
else:
|
|
try:
|
|
payload = json.loads(data.decode("utf-8"))
|
|
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
last_decode_exc = RuntimeError(f"downloaded invalid JSON from {url}: {exc}")
|
|
else:
|
|
if not isinstance(payload, dict) and not isinstance(payload, list):
|
|
raise RuntimeError(
|
|
f"downloaded unexpected JSON type from {url}: {type(payload).__name__}"
|
|
)
|
|
return payload
|
|
if attempt >= attempts:
|
|
assert last_decode_exc is not None
|
|
raise last_decode_exc
|
|
ops.log(f"json fetch failed ({attempt}/{attempts}) for {url}; retrying")
|
|
sleep_backoff(attempt)
|
|
assert last_decode_exc is not None
|
|
raise last_decode_exc
|
|
|
|
|
|
def download_file(ops: ModuleOps, url: str, destination: Path) -> None:
|
|
destination.parent.mkdir(parents = True, exist_ok = True)
|
|
attempts = ops.HTTP_FETCH_ATTEMPTS
|
|
last_exc: Exception | None = None
|
|
for attempt in range(1, attempts + 1):
|
|
tmp_path: Path | None = None
|
|
try:
|
|
request = urllib.request.Request(url, headers = ops.auth_headers(url))
|
|
with tempfile.NamedTemporaryFile(
|
|
prefix = destination.name + ".tmp-",
|
|
dir = destination.parent,
|
|
delete = False,
|
|
) as handle:
|
|
tmp_path = Path(handle.name)
|
|
with ops._URL_OPENER.open(request, timeout = 120) as response:
|
|
total_bytes: int | None = None
|
|
content_length = response.headers.get("Content-Length")
|
|
if content_length and content_length.isdigit():
|
|
total_bytes = int(content_length)
|
|
progress = DownloadProgress(f"Downloading {destination.name}", total_bytes)
|
|
downloaded_bytes = 0
|
|
while True:
|
|
chunk = response.read(1024 * 1024)
|
|
if not chunk:
|
|
break
|
|
handle.write(chunk)
|
|
downloaded_bytes += len(chunk)
|
|
progress.update(downloaded_bytes)
|
|
progress.finish(downloaded_bytes)
|
|
handle.flush()
|
|
os.fsync(handle.fileno())
|
|
if not tmp_path.exists() or tmp_path.stat().st_size == 0:
|
|
raise RuntimeError(f"downloaded empty file from {url}")
|
|
atomic_replace_from_tempfile(tmp_path, destination)
|
|
return
|
|
except Exception as exc:
|
|
last_exc = exc
|
|
if tmp_path is not None:
|
|
try:
|
|
tmp_path.unlink(missing_ok = True)
|
|
except Exception:
|
|
pass
|
|
if attempt >= attempts or not is_retryable_url_error(exc):
|
|
raise
|
|
ops.log(f"download failed ({attempt}/{attempts}) for {url}: {exc}; retrying")
|
|
sleep_backoff(attempt, exc = exc)
|
|
assert last_exc is not None
|
|
raise last_exc
|
|
|
|
|
|
def download_file_verified(
|
|
ops: ModuleOps, url: str, destination: Path, *, expected_sha256: str | None, label: str
|
|
) -> None:
|
|
normalized_expected = normalize_sha256_digest(expected_sha256)
|
|
if not normalized_expected:
|
|
ops.download_file(url, destination)
|
|
ops.log(f"downloaded {label} without a published sha256; relying on install validation")
|
|
return
|
|
|
|
for attempt in range(1, 3):
|
|
ops.download_file(url, destination)
|
|
actual_sha256 = ops.sha256_file(destination)
|
|
if actual_sha256 == normalized_expected:
|
|
ops.log(f"verified {label} sha256={actual_sha256}")
|
|
return
|
|
|
|
ops.log(
|
|
f"{label} checksum mismatch on attempt {attempt}/2: "
|
|
f"expected={normalized_expected} actual={actual_sha256}"
|
|
)
|
|
destination.unlink(missing_ok = True)
|
|
if attempt == 2:
|
|
raise PrebuiltFallback(
|
|
f"{label} checksum mismatch after retry: expected={normalized_expected} actual={actual_sha256}"
|
|
)
|
|
ops.log(f"retrying {label} download after checksum mismatch")
|
|
|
|
|
|
def download_file_verified_strict(
|
|
ops: ModuleOps, url: str, destination: Path, *, expected_sha256: str, label: str
|
|
) -> None:
|
|
"""Verified download with a required digest (the generic flow fails closed on
|
|
a missing checksum, so None never reaches here)."""
|
|
for attempt in range(1, 3):
|
|
ops.download_file(url, destination)
|
|
actual = ops.sha256_file(destination)
|
|
if actual == expected_sha256:
|
|
ops.log(f"verified {label} sha256={actual}")
|
|
return
|
|
ops.log(
|
|
f"{label} checksum mismatch {attempt}/2: expected={expected_sha256} actual={actual}"
|
|
)
|
|
destination.unlink(missing_ok = True)
|
|
if attempt == 2:
|
|
raise PrebuiltFallback(f"{label} checksum mismatch after retry")
|
|
|
|
|
|
# ── GitHub release primitives ──
|
|
def release_asset_map(release: dict[str, Any]) -> dict[str, str]:
|
|
assets = release.get("assets")
|
|
if not isinstance(assets, list):
|
|
return {}
|
|
return {
|
|
asset["name"]: asset.get("browser_download_url", "")
|
|
for asset in assets
|
|
if isinstance(asset, dict)
|
|
and isinstance(asset.get("name"), str)
|
|
and isinstance(asset.get("browser_download_url"), str)
|
|
}
|
|
|
|
|
|
def github_release(
|
|
ops: ModuleOps,
|
|
repo: str,
|
|
tag: str,
|
|
*,
|
|
error: type[Exception] = RuntimeError,
|
|
) -> dict[str, Any]:
|
|
payload = ops.fetch_json(
|
|
f"https://api.github.com/repos/{repo}/releases/tags/{urllib.parse.quote(tag, safe = '')}"
|
|
)
|
|
if not isinstance(payload, dict):
|
|
raise error(f"unexpected release payload for {repo}@{tag}")
|
|
return payload
|
|
|
|
|
|
def github_release_assets(ops: ModuleOps, repo: str, tag: str) -> dict[str, str]:
|
|
payload = ops.fetch_json(
|
|
f"https://api.github.com/repos/{repo}/releases/tags/{urllib.parse.quote(tag, safe = '')}"
|
|
)
|
|
if not isinstance(payload, dict):
|
|
raise RuntimeError(f"unexpected release payload for {repo}@{tag}")
|
|
return release_asset_map(payload)
|
|
|
|
|
|
def release_asset_download_url(repo: str, release_tag: str, asset_name: str) -> str:
|
|
"""Tag-pinned asset URL on the release-assets CDN (github.com redirect, NOT
|
|
api.github.com, so no 60-req/hour unauthenticated limit)."""
|
|
return (
|
|
f"https://github.com/{urllib.parse.quote(repo, safe = '/')}/releases/download/"
|
|
f"{urllib.parse.quote(release_tag, safe = '')}/"
|
|
f"{urllib.parse.quote(asset_name, safe = '')}"
|
|
)
|
|
|
|
|
|
def download_host_latest_release_tag(ops: ModuleOps, repo: str) -> str | None:
|
|
"""Latest release tag from github.com/<repo>/releases/latest via its redirect
|
|
target (no api.github.com call). None on 404 so the caller falls back to the
|
|
API. /releases/latest resolves by created_at/make_latest, which can lag the
|
|
published_at newest the freshness check uses -- acceptable for install."""
|
|
url = f"https://github.com/{urllib.parse.quote(repo, safe = '/')}/releases/latest"
|
|
request = urllib.request.Request(
|
|
url,
|
|
method = "HEAD",
|
|
headers = {"User-Agent": ops.USER_AGENT},
|
|
)
|
|
try:
|
|
with ops._URL_OPENER.open(request, timeout = 30) as response:
|
|
final_url = response.geturl()
|
|
except urllib.error.HTTPError as exc:
|
|
if exc.code == 404:
|
|
return None
|
|
raise
|
|
marker = "/releases/tag/"
|
|
index = final_url.find(marker)
|
|
if index == -1:
|
|
return None
|
|
tag = urllib.parse.unquote(final_url[index + len(marker) :]).strip("/")
|
|
return tag or None
|
|
|
|
|
|
def fetch_download_host_json(ops: ModuleOps, url: str) -> Any:
|
|
# Public CDN asset: plain unauthenticated GET, not the rate-limited API.
|
|
data = ops.download_bytes(
|
|
url,
|
|
timeout = 30,
|
|
headers = {"User-Agent": ops.USER_AGENT},
|
|
)
|
|
return json.loads(data.decode("utf-8"))
|
|
|
|
|
|
# ── Archive extraction (traversal/symlink guarded) ──
|
|
def extract_archive(archive_path: Path, destination: Path) -> None:
|
|
def safe_extract_path(base: Path, member_name: str) -> Path:
|
|
normalized = member_name.replace("\\", "/")
|
|
member_path = Path(normalized)
|
|
if member_path.is_absolute():
|
|
raise PrebuiltFallback(f"archive member used an absolute path: {member_name}")
|
|
|
|
target = (base / member_path).resolve()
|
|
base_resolved = base.resolve()
|
|
try:
|
|
target.relative_to(base_resolved)
|
|
except ValueError as exc:
|
|
raise PrebuiltFallback(f"archive member escaped destination: {member_name}") from exc
|
|
return target
|
|
|
|
def _try_repair_missing_slash(
|
|
member_name: str, link_name: str, archive_names: set[str]
|
|
) -> str | None:
|
|
"""Repair a mangled symlink from some upstream llama.cpp Mac releases
|
|
(e.g. b9165, b9169) whose linkname drops the separator AND the file
|
|
basename's leading char between the top-level dir and the rest:
|
|
|
|
llama-b9165/libggml-rpc.0.dylib -> llama-b9165ibggml-rpc.0.11.1.dylib
|
|
|
|
Detect the pattern (linkname starts with the top-level dir but no
|
|
following slash), then find the archive entry under that dir whose
|
|
basename ends with the mangled suffix; only accept a unique match.
|
|
Returns the corrected linkname relative to the member's parent dir --
|
|
callers join it with `target.parent`, so a full `top/file` path would
|
|
double the prefix into `top/top/file`."""
|
|
if "/" not in member_name or "/" in link_name:
|
|
return None
|
|
top, _, _ = member_name.partition("/")
|
|
if not link_name.startswith(top) or len(link_name) <= len(top):
|
|
return None
|
|
bad_suffix = link_name[len(top) :]
|
|
if not bad_suffix or bad_suffix.startswith("/"):
|
|
return None
|
|
prefix = f"{top}/"
|
|
candidates = [
|
|
name
|
|
for name in archive_names
|
|
if name.startswith(prefix)
|
|
and "/" not in name[len(prefix) :]
|
|
and name[len(prefix) :].endswith(bad_suffix)
|
|
]
|
|
if len(candidates) != 1:
|
|
return None
|
|
# Strip the top-level dir so the caller's `target.parent / Path(...)`
|
|
# resolves inside the staging dir, not a duplicate `top/top/...` path.
|
|
return candidates[0][len(prefix) :]
|
|
|
|
def safe_link_target(
|
|
base: Path, member_name: str, link_name: str, target: Path, archive_names: set[str]
|
|
) -> tuple[str, Path]:
|
|
normalized = link_name.replace("\\", "/")
|
|
repaired = _try_repair_missing_slash(member_name, normalized, archive_names)
|
|
if repaired is not None:
|
|
normalized = repaired
|
|
link_path = Path(normalized)
|
|
if link_path.is_absolute():
|
|
raise PrebuiltFallback(
|
|
f"archive link used an absolute target: {member_name} -> {link_name}"
|
|
)
|
|
if not normalized:
|
|
raise PrebuiltFallback(f"archive link used an empty target: {member_name}")
|
|
|
|
resolved = (target.parent / link_path).resolve()
|
|
base_resolved = base.resolve()
|
|
try:
|
|
resolved.relative_to(base_resolved)
|
|
except ValueError as exc:
|
|
raise PrebuiltFallback(
|
|
f"archive link escaped destination: {member_name} -> {link_name}"
|
|
) from exc
|
|
return normalized, resolved
|
|
|
|
def extract_zip_safely(source: Path, base: Path) -> None:
|
|
with zipfile.ZipFile(source) as archive:
|
|
for member in archive.infolist():
|
|
target = safe_extract_path(base, member.filename)
|
|
mode = (member.external_attr >> 16) & 0o170000
|
|
if mode == 0o120000:
|
|
raise PrebuiltFallback(
|
|
f"zip archive contained a symlink entry: {member.filename}"
|
|
)
|
|
if member.is_dir():
|
|
target.mkdir(parents = True, exist_ok = True)
|
|
continue
|
|
target.parent.mkdir(parents = True, exist_ok = True)
|
|
with archive.open(member, "r") as src, target.open("wb") as dst:
|
|
shutil.copyfileobj(src, dst)
|
|
|
|
def extract_tar_safely(source: Path, base: Path) -> None:
|
|
pending_links: list[tuple[tarfile.TarInfo, Path]] = []
|
|
archive_names: set[str] = set()
|
|
with tarfile.open(source, "r:gz") as archive:
|
|
for member in archive.getmembers():
|
|
archive_names.add(member.name)
|
|
target = safe_extract_path(base, member.name)
|
|
if member.isdir():
|
|
target.mkdir(parents = True, exist_ok = True)
|
|
continue
|
|
if member.islnk() or member.issym():
|
|
pending_links.append((member, target))
|
|
continue
|
|
if not member.isfile():
|
|
raise PrebuiltFallback(
|
|
f"tar archive contained an unsupported entry: {member.name}"
|
|
)
|
|
target.parent.mkdir(parents = True, exist_ok = True)
|
|
extracted = archive.extractfile(member)
|
|
if extracted is None:
|
|
raise PrebuiltFallback(f"tar archive entry could not be read: {member.name}")
|
|
with extracted, target.open("wb") as dst:
|
|
shutil.copyfileobj(extracted, dst)
|
|
|
|
unresolved = list(pending_links)
|
|
while unresolved:
|
|
next_round: list[tuple[tarfile.TarInfo, Path]] = []
|
|
progressed = False
|
|
for member, target in unresolved:
|
|
normalized_link, resolved_target = safe_link_target(
|
|
base, member.name, member.linkname, target, archive_names
|
|
)
|
|
if not resolved_target.exists() and not resolved_target.is_symlink():
|
|
next_round.append((member, target))
|
|
continue
|
|
if resolved_target.is_dir():
|
|
raise PrebuiltFallback(
|
|
f"archive link targeted a directory: {member.name} -> {member.linkname}"
|
|
)
|
|
|
|
target.parent.mkdir(parents = True, exist_ok = True)
|
|
if target.exists() or target.is_symlink():
|
|
target.unlink()
|
|
|
|
if member.issym():
|
|
target.symlink_to(normalized_link)
|
|
else:
|
|
shutil.copy2(resolved_target, target)
|
|
progressed = True
|
|
|
|
if not progressed:
|
|
details = ", ".join(
|
|
f"{member.name} -> {member.linkname}" for member, _ in next_round
|
|
)
|
|
raise PrebuiltFallback(f"tar archive contained unresolved link entries: {details}")
|
|
unresolved = next_round
|
|
|
|
destination.mkdir(parents = True, exist_ok = True)
|
|
if archive_path.name.endswith(".zip"):
|
|
extract_zip_safely(archive_path, destination)
|
|
return
|
|
if archive_path.name.endswith(".tar.gz"):
|
|
extract_tar_safely(archive_path, destination)
|
|
return
|
|
raise PrebuiltFallback(f"unsupported archive format: {archive_path.name}")
|
|
|
|
|
|
def restore_tar_exec_bits(archive_path: Path, destination: Path) -> None:
|
|
"""Re-apply tar exec bits after the guarded extraction writes plain files;
|
|
server binaries must stay executable on Unix."""
|
|
if os.name == "nt" or not archive_path.name.endswith(".tar.gz"):
|
|
return
|
|
with tarfile.open(archive_path, "r:gz") as archive:
|
|
for member in archive.getmembers():
|
|
if not (member.isfile() and member.mode & 0o111):
|
|
continue
|
|
# Paths were already traversal-validated by the extractor above.
|
|
target = destination / Path(member.name.replace("\\", "/"))
|
|
if target.is_file():
|
|
os.chmod(target, target.stat().st_mode | 0o111)
|
|
|
|
|
|
# ── Install lock ──
|
|
@contextmanager
|
|
def install_lock(lock_path: Path) -> Iterator[None]:
|
|
lock_path.parent.mkdir(parents = True, exist_ok = True)
|
|
|
|
if FileLock is None:
|
|
# Fallback lock: exclusive file creation, writing our PID so stale locks
|
|
# from crashed processes can be detected.
|
|
fd: int | None = None
|
|
deadline = time.monotonic() + INSTALL_LOCK_TIMEOUT_SECONDS
|
|
while True:
|
|
try:
|
|
fd = os.open(str(lock_path), os.O_CREAT | os.O_EXCL | os.O_RDWR)
|
|
try:
|
|
os.write(fd, f"{os.getpid()}\n".encode())
|
|
os.fsync(fd)
|
|
except Exception:
|
|
os.close(fd)
|
|
fd = None
|
|
lock_path.unlink(missing_ok = True)
|
|
raise
|
|
break
|
|
except FileExistsError:
|
|
stale = False
|
|
try:
|
|
raw = lock_path.read_text().strip()
|
|
except FileNotFoundError:
|
|
# Lock vanished between our open and read -- retry
|
|
continue
|
|
if not raw:
|
|
# Exists but PID not yet written; wait for the write to land.
|
|
if time.monotonic() >= deadline:
|
|
raise BusyInstallConflict(
|
|
f"timed out after {INSTALL_LOCK_TIMEOUT_SECONDS}s waiting for concurrent install lock: {lock_path}"
|
|
)
|
|
time.sleep(0.1)
|
|
continue
|
|
try:
|
|
holder_pid = int(raw)
|
|
os.kill(holder_pid, 0) # signal 0 = existence check
|
|
except ValueError:
|
|
stale = True # PID unreadable (corrupted file)
|
|
except ProcessLookupError:
|
|
stale = True # holder is dead
|
|
except PermissionError:
|
|
pass # alive but owned by another user -- not stale
|
|
if stale:
|
|
lock_path.unlink(missing_ok = True)
|
|
continue
|
|
if time.monotonic() >= deadline:
|
|
raise BusyInstallConflict(
|
|
f"timed out after {INSTALL_LOCK_TIMEOUT_SECONDS}s waiting for concurrent install lock: {lock_path}"
|
|
)
|
|
time.sleep(0.5)
|
|
try:
|
|
yield
|
|
finally:
|
|
if fd is not None:
|
|
os.close(fd)
|
|
lock_path.unlink(missing_ok = True)
|
|
return
|
|
|
|
try:
|
|
with FileLock(lock_path, timeout = INSTALL_LOCK_TIMEOUT_SECONDS):
|
|
yield
|
|
except FileLockTimeout as exc:
|
|
raise BusyInstallConflict(
|
|
f"timed out after {INSTALL_LOCK_TIMEOUT_SECONDS}s waiting for concurrent install lock: {lock_path}"
|
|
) from exc
|
|
|
|
|
|
def install_lock_path(install_dir: Path) -> Path:
|
|
return install_dir.parent / f".{install_dir.name}.install.lock"
|
|
|
|
|
|
# ── macOS version parsing ──
|
|
def parse_macos_version(value: str | None) -> tuple[int, int] | None:
|
|
"""Parse a macOS product version into (major, minor).
|
|
|
|
Handles "14.7.1", "15.5", "26.0", bare "26". None when empty/unparseable
|
|
(callers then defer to runtime validation rather than reject every prebuilt)."""
|
|
if not value:
|
|
return None
|
|
match = re.match(r"\s*(\d+)(?:\.(\d+))?", str(value))
|
|
if not match:
|
|
return None
|
|
return int(match.group(1)), int(match.group(2) or 0)
|
|
|
|
|
|
# ── GPU and CUDA runtime-line selection primitives ──
|
|
def normalize_compute_cap(value: Any) -> str | None:
|
|
raw = str(value).strip()
|
|
if not raw:
|
|
return None
|
|
if "." in raw:
|
|
parts = raw.split(".", 1)
|
|
if len(parts) != 2:
|
|
return None
|
|
major, minor = parts
|
|
if not major.isdigit() or not minor.isdigit():
|
|
return None
|
|
return f"{int(major)}{int(minor)}"
|
|
if raw.isdigit():
|
|
return str(int(raw))
|
|
return None
|
|
|
|
|
|
def normalize_compute_caps(compute_caps: Iterable[str]) -> list[str]:
|
|
normalized: list[str] = []
|
|
seen: set[str] = set()
|
|
for raw in compute_caps:
|
|
normalized_value = normalize_compute_cap(raw)
|
|
if normalized_value is None:
|
|
continue
|
|
if normalized_value in seen:
|
|
continue
|
|
seen.add(normalized_value)
|
|
normalized.append(normalized_value)
|
|
normalized.sort(key = int)
|
|
return normalized
|
|
|
|
|
|
def parse_cuda_visible_devices(value: str | None) -> list[str] | None:
|
|
if value is None:
|
|
return None
|
|
raw = value.strip()
|
|
if not raw or raw == "-1":
|
|
return []
|
|
return [token.strip() for token in raw.split(",") if token.strip()]
|
|
|
|
|
|
def supports_explicit_visible_device_matching(visible_devices: list[str] | None) -> bool:
|
|
if not visible_devices:
|
|
return False
|
|
for token in visible_devices:
|
|
lowered = token.lower()
|
|
if token.isdigit() or lowered.startswith("gpu-"):
|
|
continue
|
|
return False
|
|
return True
|
|
|
|
|
|
def select_visible_gpu_rows(
|
|
gpu_rows: Iterable[tuple[str, str, str]], visible_devices: list[str] | None
|
|
) -> list[tuple[str, str, str]]:
|
|
rows = list(gpu_rows)
|
|
if visible_devices is None:
|
|
return rows
|
|
if not visible_devices:
|
|
return []
|
|
|
|
by_index = {index: (index, uuid, cap) for index, uuid, cap in rows}
|
|
by_uuid = {uuid.lower(): (index, uuid, cap) for index, uuid, cap in rows}
|
|
selected: list[tuple[str, str, str]] = []
|
|
seen_indices: set[str] = set()
|
|
for token in visible_devices:
|
|
row = by_index.get(token)
|
|
if row is None:
|
|
normalized_token = token.lower()
|
|
row = by_uuid.get(normalized_token)
|
|
if row is None and normalized_token.startswith("gpu-"):
|
|
row = by_uuid.get(normalized_token)
|
|
if row is None and not normalized_token.startswith("gpu-"):
|
|
row = by_uuid.get("gpu-" + normalized_token)
|
|
if row is None:
|
|
continue
|
|
index = row[0]
|
|
if index in seen_indices:
|
|
continue
|
|
seen_indices.add(index)
|
|
selected.append(row)
|
|
return selected
|
|
|
|
|
|
def dir_provides_exact_library(directory: str | Path, library: str) -> bool:
|
|
if not library:
|
|
return False
|
|
candidate = Path(directory) / library
|
|
return candidate.exists() and (candidate.is_file() or candidate.is_symlink())
|
|
|
|
|
|
def linux_runtime_dirs_for_required_libraries(
|
|
ops: ModuleOps, required_libraries: Iterable[str]
|
|
) -> list[str]:
|
|
required = [library for library in required_libraries if library]
|
|
candidates: list[str | Path] = []
|
|
|
|
env_dirs = os.environ.get("CUDA_RUNTIME_LIB_DIR", "")
|
|
if env_dirs:
|
|
candidates.extend(part for part in env_dirs.split(os.pathsep) if part)
|
|
ld_library_path = os.environ.get("LD_LIBRARY_PATH", "")
|
|
if ld_library_path:
|
|
candidates.extend(part for part in ld_library_path.split(os.pathsep) if part)
|
|
|
|
cuda_roots: list[Path] = []
|
|
for name in ("CUDA_HOME", "CUDA_PATH", "CUDA_ROOT"):
|
|
value = os.environ.get(name)
|
|
if value:
|
|
cuda_roots.append(Path(value))
|
|
cuda_roots.extend(Path(path) for path in ops.glob_paths("/usr/local/cuda", "/usr/local/cuda-*"))
|
|
|
|
for root in cuda_roots:
|
|
candidates.extend(
|
|
[
|
|
root / "lib",
|
|
root / "lib64",
|
|
root / "targets" / "x86_64-linux" / "lib",
|
|
]
|
|
)
|
|
|
|
candidates.extend(
|
|
Path(path)
|
|
for path in ops.glob_paths(
|
|
"/lib",
|
|
"/lib64",
|
|
"/usr/lib",
|
|
"/usr/lib64",
|
|
"/usr/local/lib",
|
|
"/usr/local/lib64",
|
|
"/lib/x86_64-linux-gnu",
|
|
"/usr/lib/x86_64-linux-gnu",
|
|
)
|
|
)
|
|
candidates.extend(
|
|
Path(path) for path in ops.glob_paths("/usr/local/lib/ollama/cuda_v*", "/usr/lib/wsl/lib")
|
|
)
|
|
candidates.extend(Path(path) for path in ops.python_runtime_dirs())
|
|
candidates.extend(Path(path) for path in ops.ldconfig_runtime_dirs(required))
|
|
|
|
resolved = ops.dedupe_existing_dirs(candidates)
|
|
if not required:
|
|
return resolved
|
|
|
|
matched: list[tuple[int, str]] = []
|
|
for directory in resolved:
|
|
provided = sum(1 for library in required if dir_provides_exact_library(directory, library))
|
|
if provided:
|
|
matched.append((provided, directory))
|
|
|
|
matched.sort(key = lambda item: item[0], reverse = True)
|
|
return [directory for _, directory in matched]
|
|
|
|
|
|
def detected_linux_runtime_lines(ops: ModuleOps) -> tuple[list[str], dict[str, list[str]]]:
|
|
"""`cuda<major>` lines with a matching libcudart/libcublas file on disk (glob
|
|
match, so a versioned-only file counts), plus the dirs that matched."""
|
|
line_requirements = {
|
|
f"cuda{m}": [f"libcudart.so.{m}", f"libcublas.so.{m}"]
|
|
for m in range(_MAX_PROBE_CUDA_MAJOR, _MIN_CUDA_MAJOR - 1, -1)
|
|
}
|
|
detected: list[str] = []
|
|
runtime_dirs: dict[str, list[str]] = {}
|
|
for line, required in line_requirements.items():
|
|
dirs = ops.linux_runtime_dirs_for_required_libraries(required)
|
|
library_matches: dict[str, list[str]] = {}
|
|
matching_dirs: list[str] = []
|
|
for library in required:
|
|
matched_dirs = [
|
|
directory for directory in dirs if any(Path(directory).glob(f"{library}*"))
|
|
]
|
|
if not matched_dirs:
|
|
library_matches = {}
|
|
matching_dirs = []
|
|
break
|
|
library_matches[library] = matched_dirs
|
|
for directory in matched_dirs:
|
|
if directory not in matching_dirs:
|
|
matching_dirs.append(directory)
|
|
if library_matches:
|
|
detected.append(line)
|
|
runtime_dirs[line] = matching_dirs
|
|
return detected, runtime_dirs
|
|
|
|
|
|
def windows_runtime_line_info() -> dict[str, tuple[str, ...]]:
|
|
# Generated per CUDA major (newest first) so a new toolkit is detected without
|
|
# a code change while the cudart64_<major>.dll naming holds.
|
|
return {
|
|
f"cuda{m}": (
|
|
f"cudart64_{m}*.dll",
|
|
f"cublas64_{m}*.dll",
|
|
f"cublasLt64_{m}*.dll",
|
|
)
|
|
for m in range(_MAX_PROBE_CUDA_MAJOR, _MIN_CUDA_MAJOR - 1, -1)
|
|
}
|
|
|
|
|
|
def detected_windows_runtime_lines(ops: ModuleOps) -> tuple[list[str], dict[str, list[str]]]:
|
|
dirs = ops.windows_runtime_dirs()
|
|
detected: list[str] = []
|
|
runtime_dirs: dict[str, list[str]] = {}
|
|
for runtime_line, required_patterns in windows_runtime_line_info().items():
|
|
matching_dirs = ops.windows_runtime_dirs_for_patterns(required_patterns, dirs)
|
|
if matching_dirs:
|
|
detected.append(runtime_line)
|
|
runtime_dirs[runtime_line] = matching_dirs
|
|
return detected, runtime_dirs
|
|
|
|
|
|
def compatible_linux_runtime_lines(host: Any) -> list[str]:
|
|
if not host.driver_cuda_version:
|
|
return []
|
|
major, _minor = host.driver_cuda_version
|
|
if major < _MIN_CUDA_MAJOR:
|
|
return []
|
|
return _cuda_runtime_lines_for_major(major)
|
|
|
|
|
|
def runtime_line_from_cuda_version(cuda_version: str | None) -> str | None:
|
|
if not cuda_version:
|
|
return None
|
|
raw = str(cuda_version).strip()
|
|
if not raw:
|
|
return None
|
|
major, _, _ = raw.partition(".")
|
|
if major == "12":
|
|
return "cuda12"
|
|
if major == "13":
|
|
return "cuda13"
|
|
return None
|
|
|
|
|
|
@dataclass
|
|
class CudaRuntimePreference:
|
|
runtime_line: str | None
|
|
selection_log: list[str]
|
|
|
|
|
|
def detect_torch_cuda_runtime_preference(host: Any) -> CudaRuntimePreference:
|
|
selection_log: list[str] = []
|
|
if host.is_macos:
|
|
selection_log.append("torch_cuda_preference: skipped on macOS")
|
|
return CudaRuntimePreference(runtime_line = None, selection_log = selection_log)
|
|
if not (host.has_usable_nvidia and (host.is_linux or host.is_windows)):
|
|
selection_log.append(
|
|
"torch_cuda_preference: skipped because CUDA host prerequisites were not met"
|
|
)
|
|
return CudaRuntimePreference(runtime_line = None, selection_log = selection_log)
|
|
|
|
try:
|
|
import torch
|
|
except Exception as exc:
|
|
selection_log.append(f"torch_cuda_preference: import failed: {exc}")
|
|
return CudaRuntimePreference(runtime_line = None, selection_log = selection_log)
|
|
|
|
cuda_version = getattr(getattr(torch, "version", None), "cuda", None)
|
|
if not isinstance(cuda_version, str) or not cuda_version.strip():
|
|
selection_log.append(
|
|
"torch_cuda_preference: torch.version.cuda missing; skipping Torch shortcut"
|
|
)
|
|
return CudaRuntimePreference(runtime_line = None, selection_log = selection_log)
|
|
|
|
try:
|
|
cuda_available = bool(torch.cuda.is_available())
|
|
except Exception as exc:
|
|
selection_log.append(f"torch_cuda_preference: torch.cuda.is_available() failed: {exc}")
|
|
return CudaRuntimePreference(runtime_line = None, selection_log = selection_log)
|
|
|
|
if not cuda_available:
|
|
selection_log.append(
|
|
"torch_cuda_preference: torch.cuda.is_available() returned False; falling back to normal selection"
|
|
)
|
|
return CudaRuntimePreference(runtime_line = None, selection_log = selection_log)
|
|
|
|
runtime_line = runtime_line_from_cuda_version(cuda_version)
|
|
if runtime_line is None:
|
|
selection_log.append(
|
|
f"torch_cuda_preference: unsupported torch.version.cuda={cuda_version}; falling back to normal selection"
|
|
)
|
|
return CudaRuntimePreference(runtime_line = None, selection_log = selection_log)
|
|
|
|
selection_log.append(
|
|
"torch_cuda_preference: selected runtime_line="
|
|
f"{runtime_line} from torch.version.cuda={cuda_version}"
|
|
)
|
|
return CudaRuntimePreference(runtime_line = runtime_line, selection_log = selection_log)
|
|
|
|
|
|
def artifact_covers_sms(artifact: Any, host_sms: Iterable[str]) -> bool:
|
|
"""True when every host SM is in the artifact's supported_sms and within its
|
|
[min_sm, max_sm] range."""
|
|
if not artifact.supported_sms or artifact.min_sm is None or artifact.max_sm is None:
|
|
return False
|
|
supported = {str(value) for value in artifact.supported_sms}
|
|
return all(sm in supported and artifact.min_sm <= int(sm) <= artifact.max_sm for sm in host_sms)
|
|
|
|
|
|
def sm_range(artifact: Any) -> int:
|
|
"""SM-coverage span as a sort key (tighter range wins). A bundle with no SM
|
|
metadata gets a max range so it sorts last, never outranking a targeted bundle."""
|
|
if artifact.min_sm is not None and artifact.max_sm is not None:
|
|
return artifact.max_sm - artifact.min_sm
|
|
return 9999
|
|
|
|
|
|
def blackwell_capable_linux_runtime_lines(host_sms: list[str], artifacts: list[Any]) -> list[str]:
|
|
"""CUDA runtime lines (highest major first) shipping a bundle covering every
|
|
visible host SM. Lets a Blackwell host prefer a native sm_120 line over torch's
|
|
reported line, mirroring the Windows Blackwell preference."""
|
|
lines: set[str] = set()
|
|
for artifact in artifacts:
|
|
line = artifact.runtime_line
|
|
# Only rank "cuda<major>" lines; skip malformed/future-format values
|
|
# (e.g. "cuda13.1") rather than crash the major sort.
|
|
if not (line and line.startswith("cuda") and line[len("cuda") :].isdigit()):
|
|
continue
|
|
if not artifact.supported_sms or artifact.min_sm is None or artifact.max_sm is None:
|
|
continue
|
|
supported = {str(value) for value in artifact.supported_sms}
|
|
if all(
|
|
sm in supported and artifact.min_sm <= int(sm) <= artifact.max_sm for sm in host_sms
|
|
):
|
|
lines.add(line)
|
|
return sorted(lines, key = lambda line: int(line[len("cuda") :]), reverse = True)
|
|
|
|
|
|
def host_is_blackwell(host: Any) -> bool:
|
|
caps = normalize_compute_caps(host.compute_caps)
|
|
return bool(caps) and int(caps[-1]) >= _BLACKWELL_MIN_SM
|
|
|
|
|
|
def blackwell_min_toolkit_for_host(host: Any) -> tuple[int, int]:
|
|
"""Minimum CUDA toolkit this Blackwell host needs: 12.8 for the family, 12.9
|
|
if any SM is sm_103/sm_121 (no native target before 12.9)."""
|
|
req = _BLACKWELL_MIN_TOOLKIT
|
|
for sm in normalize_compute_caps(host.compute_caps):
|
|
req = max(req, _BLACKWELL_SM_MIN_TOOLKIT.get(int(sm), _BLACKWELL_MIN_TOOLKIT))
|
|
return req
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════════════════════
|
|
# Generic descriptor-driven install flow (whisper dialect: one release carries
|
|
# a manifest of os/arch/backend artifacts plus a same-origin checksum index).
|
|
# ════════════════════════════════════════════════════════════════════════════
|
|
def host_platform_tokens(host: Any) -> tuple[str, str]:
|
|
"""Default (os, arch) asset tokens; components with their own HostInfo field
|
|
names override this hook."""
|
|
return host.os_token, host.arch_token
|
|
|
|
|
|
# ── Manifest parsing ──
|
|
def parse_manifest(ops: ModuleOps, payload: Any, *, label: str) -> dict[str, Any]:
|
|
"""Validate a component prebuilt manifest and return it normalized.
|
|
|
|
Rejects an unknown schema_version or wrong component. Returns keys:
|
|
schema_version, component, studio_protocol, upstream_tag, source_commit,
|
|
artifacts (list of dicts).
|
|
"""
|
|
component = ops.COMPONENT
|
|
if not isinstance(payload, dict):
|
|
raise PrebuiltFallback(f"{label} was not a JSON object")
|
|
validate_schema_version(
|
|
payload, label = label, schema_version = ops.SCHEMA_VERSION, error = PrebuiltFallback
|
|
)
|
|
manifest_component = payload.get("component")
|
|
if manifest_component != component:
|
|
raise PrebuiltFallback(
|
|
f"{label} describes component {manifest_component!r}, expected {component!r}"
|
|
)
|
|
|
|
artifacts_raw = payload.get("artifacts")
|
|
if not isinstance(artifacts_raw, list):
|
|
raise PrebuiltFallback(f"{label} omitted an 'artifacts' list")
|
|
artifacts: list[dict[str, Any]] = []
|
|
for index, raw in enumerate(artifacts_raw):
|
|
if not isinstance(raw, dict):
|
|
ops.log(f"{label} artifact[{index}] ignored: not an object")
|
|
continue
|
|
asset = raw.get("asset")
|
|
if not isinstance(asset, str) or not asset:
|
|
ops.log(f"{label} artifact[{index}] ignored: missing asset name")
|
|
continue
|
|
artifacts.append(raw)
|
|
|
|
studio_protocol = payload.get("studio_protocol")
|
|
return {
|
|
"schema_version": ops.SCHEMA_VERSION,
|
|
"component": component,
|
|
"studio_protocol": studio_protocol if isinstance(studio_protocol, str) else None,
|
|
"upstream_tag": payload.get("upstream_tag")
|
|
if isinstance(payload.get("upstream_tag"), str)
|
|
else None,
|
|
"source_commit": payload.get("source_commit")
|
|
if isinstance(payload.get("source_commit"), str)
|
|
else None,
|
|
"artifacts": artifacts,
|
|
}
|
|
|
|
|
|
def macos_min_os_ok(ops: ModuleOps, host: Any, min_os: Any) -> bool:
|
|
"""True if a macOS artifact requiring `min_os` can load here. The manifest
|
|
labels it `macos-<version>` (e.g. `macos-14.0`); strip that prefix before
|
|
parsing or every entry parses as None and the guard no-ops. Unknown host or
|
|
min_os -> True (defer to runtime validation)."""
|
|
if not isinstance(min_os, str) or not min_os.strip():
|
|
return True
|
|
raw = min_os.strip()
|
|
if raw.lower().startswith("macos-"):
|
|
raw = raw[len("macos-") :]
|
|
required = ops.parse_macos_version(raw)
|
|
if required is None or host.macos_version is None:
|
|
return True
|
|
return host.macos_version >= required
|
|
|
|
|
|
def artifacts_for_host(
|
|
ops: ModuleOps, manifest: dict[str, Any], host: Any, backend: str
|
|
) -> list[dict[str, Any]]:
|
|
"""Manifest artifacts matching this host os/arch/backend. On macOS, drop any
|
|
whose `min_os` exceeds the host version."""
|
|
os_token, arch_token = ops.host_platform_tokens(host)
|
|
return [
|
|
artifact
|
|
for artifact in manifest.get("artifacts", [])
|
|
if artifact.get("os") == os_token
|
|
and artifact.get("arch") == arch_token
|
|
and artifact.get("backend") == backend
|
|
and (not host.is_macos or ops.macos_min_os_ok(host, artifact.get("min_os")))
|
|
]
|
|
|
|
|
|
def select_artifact(
|
|
ops: ModuleOps, manifest: dict[str, Any], host: Any, backend: str
|
|
) -> dict[str, Any] | None:
|
|
"""First manifest artifact matching this host os/arch and backend, or None
|
|
(caller then applies the component's fallback policy). No accelerator
|
|
capability matching here: whisper bundles are slim per os/arch (the paired
|
|
llama.cpp installer already picked SM/gfx-appropriate ggml backends), and
|
|
llama keeps its own selection chain in install_llama_prebuilt.py."""
|
|
candidates = ops.artifacts_for_host(manifest, host, backend)
|
|
return candidates[0] if candidates else None
|
|
|
|
|
|
def select_artifact_with_fallback(
|
|
ops: ModuleOps, manifest: dict[str, Any], host: Any, backend: str
|
|
) -> tuple[dict[str, Any], str, bool]:
|
|
"""Select the backend artifact, else the descriptor's fallback-backend
|
|
artifact of the same release (whisper: CPU; llama: none, so a GPU miss
|
|
surfaces as "no prebuilt" and the caller does a source build).
|
|
|
|
Returns (artifact, effective_backend, used_fallback). Raises PrebuiltFallback
|
|
when neither the requested backend nor the fallback has an asset."""
|
|
os_token, arch_token = ops.host_platform_tokens(host)
|
|
artifact = ops.select_artifact(manifest, host, backend)
|
|
if artifact is not None:
|
|
return artifact, backend, False
|
|
fallback_backend = ops.FALLBACK_BACKEND
|
|
if fallback_backend and backend != fallback_backend:
|
|
fallback_artifact = ops.select_artifact(manifest, host, fallback_backend)
|
|
if fallback_artifact is not None:
|
|
ops.log(
|
|
f"no '{backend}' asset for {os_token}-{arch_token}; "
|
|
f"falling back to the {fallback_backend.upper()} asset of the same release"
|
|
)
|
|
return fallback_artifact, fallback_backend, True
|
|
raise PrebuiltFallback(
|
|
f"no {ops.COMPONENT} prebuilt asset for {os_token}-{arch_token} (backend '{backend}')"
|
|
)
|
|
|
|
|
|
def artifact_coverage(artifact: dict[str, Any]) -> dict[str, Any]:
|
|
"""The sm/gfx/min_os coverage recorded for an artifact (marker/fingerprint)."""
|
|
coverage: dict[str, Any] = {}
|
|
for key in ("sm_coverage", "gfx_coverage", "min_os", "sm", "gfx"):
|
|
if key in artifact and artifact.get(key) is not None:
|
|
coverage[key] = artifact.get(key)
|
|
return coverage
|
|
|
|
|
|
# ── Backend resolution ──
|
|
def auto_detect_backend(ops: ModuleOps, host: Any) -> str:
|
|
"""Host-preferred backend: Apple Silicon -> metal, usable NVIDIA -> cuda,
|
|
AMD gfx -> rocm, otherwise cpu."""
|
|
if host.is_apple_silicon:
|
|
return "metal"
|
|
if host.has_usable_nvidia:
|
|
return "cuda"
|
|
if host.has_rocm:
|
|
return "rocm"
|
|
return "cpu"
|
|
|
|
|
|
def resolve_backend(ops: ModuleOps, host: Any, requested: str | None, *, cpu_fallback: bool) -> str:
|
|
"""Resolve the effective backend from --backend / --cpu-fallback and detection."""
|
|
if cpu_fallback:
|
|
return "cpu"
|
|
value = (requested or "auto").strip().lower()
|
|
if value in {"", "auto"}:
|
|
return ops.auto_detect_backend(host)
|
|
if value in ops.SUPPORTED_BACKENDS:
|
|
return value
|
|
raise PrebuiltFallback(
|
|
f"unsupported --backend '{requested}'; choose from auto,{','.join(ops.SUPPORTED_BACKENDS)}"
|
|
)
|
|
|
|
|
|
# ── Release checksum index (trust anchor: the release's own sha256 asset) ──
|
|
def valid_sha256(value: Any) -> str | None:
|
|
if not isinstance(value, str):
|
|
return None
|
|
digest = value.strip().lower()
|
|
if digest.startswith("sha256:"):
|
|
digest = digest[len("sha256:") :]
|
|
if len(digest) == 64 and all(c in "0123456789abcdef" for c in digest):
|
|
return digest
|
|
return None
|
|
|
|
|
|
def parse_release_checksums(
|
|
ops: ModuleOps, repo: str, release_tag: str, payload: Any
|
|
) -> dict[str, str]:
|
|
"""Asset name -> sha256 from a release's checksum-index asset.
|
|
|
|
That index is the authority for each asset's sha256. It is validated for
|
|
schema/component and that its ``release_tag`` matches the resolved release,
|
|
so a redirected or mismatched index is rejected; malformed fails closed."""
|
|
label = f"{ops.SHA256_ASSET_NAME} in {repo}@{release_tag}"
|
|
if not isinstance(payload, dict):
|
|
raise PrebuiltFallback(f"{label} was not a JSON object")
|
|
if payload.get("schema_version") != ops.SCHEMA_VERSION:
|
|
raise PrebuiltFallback(f"{label} has an unexpected schema_version")
|
|
if payload.get("component") != ops.COMPONENT:
|
|
raise PrebuiltFallback(f"{label} did not describe {ops.COMPONENT}")
|
|
payload_tag = payload.get("release_tag")
|
|
if not isinstance(payload_tag, str) or not payload_tag:
|
|
raise PrebuiltFallback(f"{label} omitted release_tag")
|
|
if payload_tag != release_tag:
|
|
raise PrebuiltFallback(
|
|
f"{label} release_tag={payload_tag} did not match the resolved release {release_tag}"
|
|
)
|
|
artifacts = payload.get("artifacts")
|
|
if not isinstance(artifacts, dict):
|
|
raise PrebuiltFallback(f"{label} omitted an 'artifacts' map")
|
|
checksums: dict[str, str] = {}
|
|
for asset_name, entry in artifacts.items():
|
|
if not isinstance(asset_name, str) or not asset_name or not isinstance(entry, dict):
|
|
continue
|
|
digest = valid_sha256(entry.get("sha256"))
|
|
if digest is not None:
|
|
checksums[asset_name] = digest
|
|
if not checksums:
|
|
raise PrebuiltFallback(f"{label} carried no usable sha256 entries")
|
|
return checksums
|
|
|
|
|
|
def fetch_release_checksums(ops: ModuleOps, bundle: "ReleaseBundle") -> dict[str, str]:
|
|
"""Download + parse the release's checksum-index asset; fails closed if the
|
|
release does not publish it."""
|
|
sha_asset = ops.SHA256_ASSET_NAME
|
|
url = bundle.asset_urls.get(sha_asset)
|
|
if not url:
|
|
raise PrebuiltFallback(
|
|
f"release {bundle.repo}@{bundle.release_tag} has no {sha_asset}; "
|
|
f"cannot verify a download"
|
|
)
|
|
try:
|
|
raw = ops.download_bytes(url, timeout = 30, headers = ops.auth_headers(url))
|
|
payload = json.loads(raw.decode("utf-8"))
|
|
except (urllib.error.URLError, OSError, socket.timeout) as exc:
|
|
raise PrebuiltFallback(
|
|
f"could not fetch {sha_asset} from {bundle.repo}@{bundle.release_tag}: {exc}"
|
|
) from exc
|
|
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
raise PrebuiltFallback(
|
|
f"{sha_asset} in {bundle.repo}@{bundle.release_tag} was not valid JSON"
|
|
) from exc
|
|
return ops.parse_release_checksums(bundle.repo, bundle.release_tag, payload)
|
|
|
|
|
|
def expected_sha256_for(
|
|
ops: ModuleOps,
|
|
checksums: dict[str, str],
|
|
asset_name: str,
|
|
*,
|
|
manifest_sha256: str | None = None,
|
|
) -> str:
|
|
"""The sha256 the archive must match, from the release checksum-index entry.
|
|
An asset absent from the index fails closed. Any sha256 the manifest embeds
|
|
for the asset must agree with the index (a mismatch means a tampered manifest)."""
|
|
digest = checksums.get(asset_name)
|
|
if digest is None:
|
|
raise PrebuiltFallback(
|
|
f"{asset_name} is not covered by {ops.SHA256_ASSET_NAME}; "
|
|
f"refusing an unverifiable download"
|
|
)
|
|
embedded = valid_sha256(manifest_sha256)
|
|
if embedded is not None and embedded != digest:
|
|
raise PrebuiltFallback(
|
|
f"manifest sha256 for {asset_name} disagrees with {ops.SHA256_ASSET_NAME}; "
|
|
f"refusing a possibly tampered release"
|
|
)
|
|
ops.log(f"verifying {asset_name} against {ops.SHA256_ASSET_NAME} sha256={digest}")
|
|
return digest
|
|
|
|
|
|
# ── Release resolution ──
|
|
@dataclass
|
|
class ReleaseBundle:
|
|
repo: str
|
|
release_tag: str
|
|
manifest: dict[str, Any]
|
|
asset_urls: dict[str, str]
|
|
|
|
|
|
def fetch_release_bundle(ops: ModuleOps, repo: str, release_tag: str) -> ReleaseBundle:
|
|
"""Fetch a fork release, download+validate its manifest, return the bundle.
|
|
|
|
The single network seam for API-path release resolution; tests inject a fake
|
|
to exercise selection/install offline. A missing or unreachable release (404,
|
|
rate limit, no network) surfaces as PrebuiltFallback so the caller does a
|
|
source build rather than error out.
|
|
"""
|
|
manifest_asset = ops.MANIFEST_ASSET_NAME
|
|
try:
|
|
release = ops.github_release(repo, release_tag)
|
|
except PrebuiltFallback:
|
|
raise
|
|
except (urllib.error.URLError, OSError, socket.timeout) as exc:
|
|
raise PrebuiltFallback(f"could not fetch release {repo}@{release_tag}: {exc}") from exc
|
|
resolved_tag = release.get("tag_name")
|
|
resolved_tag = resolved_tag if isinstance(resolved_tag, str) and resolved_tag else release_tag
|
|
asset_urls = ops.release_asset_map(release)
|
|
manifest_url = asset_urls.get(manifest_asset)
|
|
if not manifest_url:
|
|
raise PrebuiltFallback(
|
|
f"release {repo}@{resolved_tag} has no {manifest_asset}; cannot select a prebuilt"
|
|
)
|
|
try:
|
|
manifest_bytes = ops.download_bytes(
|
|
manifest_url, timeout = 30, headers = ops.auth_headers(manifest_url)
|
|
)
|
|
except (urllib.error.URLError, OSError, socket.timeout) as exc:
|
|
raise PrebuiltFallback(
|
|
f"could not fetch {manifest_asset} from {repo}@{resolved_tag}: {exc}"
|
|
) from exc
|
|
try:
|
|
manifest_payload = json.loads(manifest_bytes.decode("utf-8"))
|
|
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
raise PrebuiltFallback(
|
|
f"{manifest_asset} in {repo}@{resolved_tag} was not valid JSON"
|
|
) from exc
|
|
manifest = ops.parse_manifest(
|
|
manifest_payload, label = f"{manifest_asset} in {repo}@{resolved_tag}"
|
|
)
|
|
return ReleaseBundle(
|
|
repo = repo, release_tag = resolved_tag, manifest = manifest, asset_urls = asset_urls
|
|
)
|
|
|
|
|
|
def asset_download_url(ops: ModuleOps, bundle: ReleaseBundle, asset_name: str) -> str:
|
|
url = bundle.asset_urls.get(asset_name)
|
|
if url:
|
|
return url
|
|
# A manifest can list an asset the release JSON omitted; fall back to the
|
|
# deterministic release-download URL.
|
|
return ops.release_asset_download_url(bundle.repo, bundle.release_tag, asset_name)
|
|
|
|
|
|
def resolve_newest_release_tag(ops: ModuleOps, repo: str) -> str:
|
|
"""Newest published (non-draft/non-prerelease) release tag for `repo` by
|
|
``published_at`` -- what the freshness checks use, NOT GitHub's
|
|
``/releases/latest`` pointer (sorts by commit date, can lag the newest build)."""
|
|
payload = ops.fetch_json(f"https://api.github.com/repos/{repo}/releases?per_page=30")
|
|
if not isinstance(payload, list):
|
|
raise PrebuiltFallback(f"unexpected releases payload for {repo}")
|
|
published = [
|
|
r
|
|
for r in payload
|
|
if isinstance(r, dict)
|
|
and not r.get("draft")
|
|
and not r.get("prerelease")
|
|
and isinstance(r.get("tag_name"), str)
|
|
and r.get("tag_name")
|
|
]
|
|
if not published:
|
|
raise PrebuiltFallback(f"{repo} has no published prebuilt release yet")
|
|
newest = max(published, key = lambda r: r.get("published_at") or "")
|
|
return newest["tag_name"]
|
|
|
|
|
|
def resolve_release_tag(
|
|
ops: ModuleOps, published_repo: str, *, published_release_tag: str | None
|
|
) -> str:
|
|
"""The release tag to install: an explicit override, else the newest
|
|
published release resolved at runtime."""
|
|
override = (published_release_tag or "").strip()
|
|
if override:
|
|
return override
|
|
return ops.resolve_newest_release_tag(published_repo)
|
|
|
|
|
|
def resolve_release_via_download_host(
|
|
ops: ModuleOps, repo: str, published_release_tag: str | None
|
|
) -> tuple[ReleaseBundle, dict[str, str]] | None:
|
|
"""Resolve the release + manifest + checksum index entirely from the download
|
|
host, with zero api.github.com calls. None (caller falls back to the API) on a
|
|
missing/renamed asset, a 404, or a tag mismatch. Fetches the checksum index
|
|
first (an in-progress release can publish it before the manifest)."""
|
|
manifest_asset = ops.MANIFEST_ASSET_NAME
|
|
sha_asset = ops.SHA256_ASSET_NAME
|
|
release_tag = (published_release_tag or "").strip() or ops._download_host_latest_release_tag(
|
|
repo
|
|
)
|
|
if not release_tag:
|
|
return None
|
|
sha_url = ops.release_asset_download_url(repo, release_tag, sha_asset)
|
|
try:
|
|
sha_payload = ops._download_host_json(sha_url)
|
|
except urllib.error.HTTPError as exc:
|
|
if exc.code == 404:
|
|
return None
|
|
raise
|
|
except (
|
|
urllib.error.URLError,
|
|
OSError,
|
|
socket.timeout,
|
|
UnicodeDecodeError,
|
|
json.JSONDecodeError,
|
|
):
|
|
return None
|
|
try:
|
|
checksums = ops.parse_release_checksums(repo, release_tag, sha_payload)
|
|
except PrebuiltFallback:
|
|
return None # schema/component/tag mismatch -> let the API path decide
|
|
manifest_url = ops.release_asset_download_url(repo, release_tag, manifest_asset)
|
|
try:
|
|
manifest_payload = ops._download_host_json(manifest_url)
|
|
except urllib.error.HTTPError as exc:
|
|
if exc.code == 404:
|
|
return None
|
|
raise
|
|
except (
|
|
urllib.error.URLError,
|
|
OSError,
|
|
socket.timeout,
|
|
UnicodeDecodeError,
|
|
json.JSONDecodeError,
|
|
):
|
|
return None
|
|
try:
|
|
manifest = ops.parse_manifest(
|
|
manifest_payload, label = f"{manifest_asset} in {repo}@{release_tag}"
|
|
)
|
|
except PrebuiltFallback:
|
|
return None
|
|
# Tag-pinned CDN URLs for every asset the install may fetch (asset_download_url
|
|
# reconstructs any missing one), keeping this a pure download-host path.
|
|
names = {
|
|
str(a.get("asset"))
|
|
for a in manifest.get("artifacts", [])
|
|
if isinstance(a, dict) and a.get("asset")
|
|
}
|
|
names |= {manifest_asset, sha_asset}
|
|
asset_urls = {name: ops.release_asset_download_url(repo, release_tag, name) for name in names}
|
|
bundle = ReleaseBundle(
|
|
repo = repo, release_tag = release_tag, manifest = manifest, asset_urls = asset_urls
|
|
)
|
|
return bundle, checksums
|
|
|
|
|
|
def fetch_release_for_install(
|
|
ops: ModuleOps, repo: str, *, published_release_tag: str | None
|
|
) -> tuple[ReleaseBundle, dict[str, str]]:
|
|
"""Resolve the release + manifest + checksum index, preferring the download
|
|
host (no api.github.com rate limit) and falling back to the GitHub API. The
|
|
single network seam the install/probe paths use."""
|
|
fast = ops._resolve_release_via_download_host(repo, published_release_tag)
|
|
if fast is not None:
|
|
bundle, checksums = fast
|
|
ops.log(f"resolved {repo}@{bundle.release_tag} via the download host (no GitHub API)")
|
|
return bundle, checksums
|
|
release_tag = ops.resolve_release_tag(repo, published_release_tag = published_release_tag)
|
|
bundle = ops.fetch_release_bundle(repo, release_tag)
|
|
checksums = ops.fetch_release_checksums(bundle)
|
|
return bundle, checksums
|
|
|
|
|
|
# ── Marker and fingerprint ──
|
|
def compute_install_fingerprint(
|
|
*,
|
|
published_repo: str,
|
|
release_tag: str,
|
|
upstream_tag: str | None,
|
|
source_commit: str | None,
|
|
asset: str,
|
|
asset_sha256: str,
|
|
backend: str,
|
|
runtime_line: str | None,
|
|
coverage: dict[str, Any],
|
|
) -> str:
|
|
payload = {
|
|
"published_repo": published_repo,
|
|
"release_tag": release_tag,
|
|
"upstream_tag": upstream_tag,
|
|
"source_commit": source_commit,
|
|
"asset": asset,
|
|
"asset_sha256": asset_sha256,
|
|
"backend": backend,
|
|
"runtime_line": runtime_line,
|
|
"coverage": coverage,
|
|
}
|
|
return hashlib.sha256(
|
|
json.dumps(payload, sort_keys = True, separators = (",", ":")).encode("utf-8")
|
|
).hexdigest()
|
|
|
|
|
|
@dataclass(frozen = True)
|
|
class InstallSelection:
|
|
"""The identity of a chosen prebuilt: everything the marker/fingerprint record."""
|
|
|
|
published_repo: str
|
|
release_tag: str
|
|
upstream_tag: str | None
|
|
source_commit: str | None
|
|
asset: str
|
|
asset_sha256: str
|
|
backend: str
|
|
runtime_line: str | None
|
|
coverage: dict[str, Any]
|
|
studio_protocol: str | None
|
|
# Slim pairing identity (whisper slim bundles ride the llama ggml runtime);
|
|
# all None for fat installs, never part of the fingerprint.
|
|
install_kind: str | None = None
|
|
paired_llama_tag: str | None = None
|
|
linked_from: str | None = None
|
|
# Filenames the slim wiring hardlinked beside the server; the sidecar launch
|
|
# guard verifies exactly these instead of hardcoded per-OS names.
|
|
linked_libraries: tuple[str, ...] | None = None
|
|
runtime_wiring_version: int | None = None
|
|
linked_runtime_directories: tuple[str, ...] | None = None
|
|
|
|
def fingerprint(self) -> str:
|
|
return compute_install_fingerprint(
|
|
published_repo = self.published_repo,
|
|
release_tag = self.release_tag,
|
|
upstream_tag = self.upstream_tag,
|
|
source_commit = self.source_commit,
|
|
asset = self.asset,
|
|
asset_sha256 = self.asset_sha256,
|
|
backend = self.backend,
|
|
runtime_line = self.runtime_line,
|
|
coverage = self.coverage,
|
|
)
|
|
|
|
|
|
def selection_from_artifact(
|
|
ops: ModuleOps,
|
|
*,
|
|
published_repo: str,
|
|
release_tag: str,
|
|
manifest: dict[str, Any],
|
|
artifact: dict[str, Any],
|
|
backend: str,
|
|
asset_sha256: str,
|
|
) -> InstallSelection:
|
|
return InstallSelection(
|
|
published_repo = published_repo,
|
|
release_tag = release_tag,
|
|
upstream_tag = manifest.get("upstream_tag"),
|
|
source_commit = manifest.get("source_commit"),
|
|
asset = str(artifact.get("asset")),
|
|
asset_sha256 = asset_sha256,
|
|
backend = backend,
|
|
runtime_line = artifact.get("runtime_line")
|
|
if isinstance(artifact.get("runtime_line"), str)
|
|
else None,
|
|
coverage = ops.artifact_coverage(artifact),
|
|
studio_protocol = manifest.get("studio_protocol"),
|
|
)
|
|
|
|
|
|
def metadata_path(ops: ModuleOps, install_dir: Path) -> Path:
|
|
return install_dir / ops.METADATA_FILENAME
|
|
|
|
|
|
def write_prebuilt_metadata(ops: ModuleOps, install_dir: Path, selection: InstallSelection) -> None:
|
|
coverage = selection.coverage
|
|
payload = {
|
|
"schema_version": ops.SCHEMA_VERSION,
|
|
"component": ops.COMPONENT,
|
|
"published_repo": selection.published_repo,
|
|
"release_tag": selection.release_tag,
|
|
"upstream_tag": selection.upstream_tag,
|
|
"source_commit": selection.source_commit,
|
|
"asset": selection.asset,
|
|
"asset_sha256": selection.asset_sha256,
|
|
"backend": selection.backend,
|
|
"runtime_line": selection.runtime_line,
|
|
"sm_coverage": coverage.get("sm_coverage") or coverage.get("sm"),
|
|
"gfx_coverage": coverage.get("gfx_coverage") or coverage.get("gfx"),
|
|
"min_os": coverage.get("min_os"),
|
|
"studio_protocol": selection.studio_protocol,
|
|
"install_fingerprint": selection.fingerprint(),
|
|
"installed_at_utc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
}
|
|
if selection.install_kind == "slim":
|
|
# Additive slim fields; fat markers keep the legacy payload exactly.
|
|
payload["install_kind"] = "slim"
|
|
payload["paired_llama_tag"] = selection.paired_llama_tag
|
|
payload["linked_from"] = selection.linked_from
|
|
if selection.linked_libraries is not None:
|
|
payload["linked_libraries"] = list(selection.linked_libraries)
|
|
if selection.runtime_wiring_version is not None:
|
|
payload["runtime_wiring_version"] = selection.runtime_wiring_version
|
|
if selection.linked_runtime_directories is not None:
|
|
payload["linked_runtime_directories"] = list(selection.linked_runtime_directories)
|
|
ops.metadata_path(install_dir).write_text(
|
|
json.dumps(payload, indent = 2) + "\n", encoding = "utf-8"
|
|
)
|
|
|
|
|
|
def load_prebuilt_metadata(ops: ModuleOps, install_dir: Path) -> dict[str, Any] | None:
|
|
path = ops.metadata_path(install_dir)
|
|
if not path.is_file():
|
|
return None
|
|
try:
|
|
payload = json.loads(path.read_text(encoding = "utf-8"))
|
|
except (json.JSONDecodeError, OSError):
|
|
return None
|
|
return payload if isinstance(payload, dict) else None
|
|
|
|
|
|
def existing_install_matches(
|
|
ops: ModuleOps, install_dir: Path, host: Any, selection: InstallSelection
|
|
) -> bool:
|
|
"""True iff the marker records this exact selection and the server binary is on disk."""
|
|
metadata = ops.load_prebuilt_metadata(install_dir)
|
|
if metadata is None:
|
|
return False
|
|
if not ops.installed_server_path(install_dir, host).is_file():
|
|
return False
|
|
recorded = metadata.get("install_fingerprint")
|
|
if not isinstance(recorded, str) or recorded != selection.fingerprint():
|
|
return False
|
|
ops.log(
|
|
f"existing {ops.COMPONENT} install already matches {selection.release_tag} "
|
|
f"({selection.backend}); nothing to do"
|
|
)
|
|
return True
|
|
|
|
|
|
# ── Install tree assembly and staged activation ──
|
|
def locate_server_in_tree(ops: ModuleOps, root: Path, host: Any) -> Path:
|
|
name = ops.server_binary_name(host)
|
|
matches = sorted(root.rglob(name))
|
|
if not matches:
|
|
raise PrebuiltFallback(f"archive did not contain a {name} binary")
|
|
return matches[0]
|
|
|
|
|
|
def assemble_install_tree(ops: ModuleOps, bundle_root: Path, staged_root: Path, host: Any) -> Path:
|
|
"""Lay out staged_root as a full install: <runtime bin dir>/<server + libs>.
|
|
|
|
Everything beside the server in the archive (shared libs, backend kernel
|
|
subdirs, license/build-info) is co-located into the canonical bin dir so the
|
|
server's RUNPATH=$ORIGIN resolves its libs.
|
|
"""
|
|
bin_dir = ops.runtime_bin_dir(staged_root, host)
|
|
bin_dir.mkdir(parents = True, exist_ok = True)
|
|
for entry in sorted(bundle_root.iterdir()):
|
|
dest = bin_dir / entry.name
|
|
if entry.is_dir() and not entry.is_symlink():
|
|
shutil.copytree(entry, dest, symlinks = True)
|
|
else:
|
|
shutil.copy2(entry, dest, follow_symlinks = False)
|
|
server = bin_dir / ops.server_binary_name(host)
|
|
if not server.exists():
|
|
raise PrebuiltFallback(f"staged install is missing the {ops.COMPONENT} server binary")
|
|
if not host.is_windows:
|
|
os.chmod(server, server.stat().st_mode | 0o111)
|
|
return server
|
|
|
|
|
|
def swap_into_place(staged_root: Path, install_dir: Path) -> None:
|
|
"""Atomically replace install_dir with staged_root (same filesystem), with rollback."""
|
|
install_dir.parent.mkdir(parents = True, exist_ok = True)
|
|
backup: Path | None = None
|
|
try:
|
|
if install_dir.exists():
|
|
backup = install_dir.parent / f".{install_dir.name}.old-{os.getpid()}"
|
|
os.replace(install_dir, backup)
|
|
os.replace(staged_root, install_dir)
|
|
except OSError as exc:
|
|
restored = False
|
|
if backup is not None and backup.exists() and not install_dir.exists():
|
|
try:
|
|
os.replace(backup, install_dir)
|
|
restored = True
|
|
except OSError as rollback_exc:
|
|
raise PrebuiltFallback(
|
|
"prebuilt activation failed and the previous install could not be restored "
|
|
f"({exc}; rollback: {rollback_exc})"
|
|
) from rollback_exc
|
|
if is_busy_lock_error(exc):
|
|
detail = "; restored the previous install" if restored else ""
|
|
raise BusyInstallConflict(
|
|
f"the existing install appears to still be in use{detail} ({exc})"
|
|
) from exc
|
|
raise
|
|
if backup is not None:
|
|
shutil.rmtree(backup, ignore_errors = True)
|
|
|
|
|
|
def validate_staged_server(ops: ModuleOps, staged_root: Path, host: Any) -> None:
|
|
"""Optional pre-activate smoke test, gated off by default (the component's
|
|
_RUN_STAGED_PREBUILT_VALIDATION switch)."""
|
|
if not ops._RUN_STAGED_PREBUILT_VALIDATION:
|
|
return
|
|
server = ops.installed_server_path(staged_root, host)
|
|
env = os.environ.copy()
|
|
bin_dir = str(ops.runtime_bin_dir(staged_root, host))
|
|
for var in ("LD_LIBRARY_PATH", "DYLD_LIBRARY_PATH"):
|
|
env[var] = bin_dir + (os.pathsep + env[var] if env.get(var) else "")
|
|
try:
|
|
result = subprocess.run(
|
|
[str(server), "--help"],
|
|
capture_output = True,
|
|
text = True,
|
|
timeout = 60,
|
|
env = env,
|
|
**ops.windows_hidden_subprocess_kwargs(),
|
|
)
|
|
except (OSError, subprocess.SubprocessError) as exc:
|
|
raise PrebuiltFallback(f"staged {ops.COMPONENT} server failed to launch: {exc}") from exc
|
|
if result.returncode != 0:
|
|
raise PrebuiltFallback(
|
|
f"staged {ops.COMPONENT} server --help exited {result.returncode}: "
|
|
f"{result.stderr.strip()}"
|
|
)
|
|
|
|
|
|
def windows_hidden_subprocess_kwargs() -> dict[str, object]:
|
|
"""Return Windows-only subprocess kwargs that suppress console windows."""
|
|
if sys.platform != "win32":
|
|
return {}
|
|
|
|
kwargs: dict[str, object] = {}
|
|
create_no_window = getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
|
if create_no_window:
|
|
kwargs["creationflags"] = create_no_window
|
|
|
|
startupinfo_factory = getattr(subprocess, "STARTUPINFO", None)
|
|
startf_use_showwindow = getattr(subprocess, "STARTF_USESHOWWINDOW", 0)
|
|
sw_hide = getattr(subprocess, "SW_HIDE", 0)
|
|
if startupinfo_factory is not None and startf_use_showwindow:
|
|
startupinfo = startupinfo_factory()
|
|
startupinfo.dwFlags |= startf_use_showwindow
|
|
startupinfo.wShowWindow = sw_hide
|
|
kwargs["startupinfo"] = startupinfo
|
|
|
|
return kwargs
|
|
|
|
|
|
def install_from_bundle(
|
|
ops: ModuleOps, install_dir: Path, host: Any, bundle: ReleaseBundle, selection: InstallSelection
|
|
) -> None:
|
|
staging_root = install_dir.parent / ops.INSTALL_STAGING_ROOT_NAME
|
|
staging_root.mkdir(parents = True, exist_ok = True)
|
|
staging = Path(tempfile.mkdtemp(prefix = f"{install_dir.name}.staging-", dir = staging_root))
|
|
try:
|
|
archive_path = staging / selection.asset
|
|
url = ops.asset_download_url(bundle, selection.asset)
|
|
ops.log(f"downloading {url}")
|
|
ops.download_file_verified(
|
|
url, archive_path, expected_sha256 = selection.asset_sha256, label = selection.asset
|
|
)
|
|
extract_dir = staging / "extracted"
|
|
ops.extract_archive(archive_path, extract_dir)
|
|
|
|
server = ops.locate_server_in_tree(extract_dir, host)
|
|
bundle_root = server.parent
|
|
|
|
staged_root = staging / "staged"
|
|
ops.assemble_install_tree(bundle_root, staged_root, host)
|
|
# Component hook (default no-op): slim whisper wires the llama ggml
|
|
# runtime here so staged validation sees the final linked tree; a returned
|
|
# selection (with the wired filenames) supersedes the input so the marker
|
|
# records what was actually linked.
|
|
updated = ops.prepare_runtime_payload(staged_root, host, selection)
|
|
if updated is not None:
|
|
selection = updated
|
|
ops.validate_staged_server(staged_root, host)
|
|
ops.write_prebuilt_metadata(staged_root, selection)
|
|
ops._swap_into_place(staged_root, install_dir)
|
|
finally:
|
|
shutil.rmtree(staging, ignore_errors = True)
|
|
try:
|
|
staging_root.rmdir()
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def plan_selection(
|
|
ops: ModuleOps,
|
|
host: Any,
|
|
bundle: ReleaseBundle,
|
|
*,
|
|
published_repo: str,
|
|
backend: str,
|
|
checksums: dict[str, str],
|
|
) -> InstallSelection:
|
|
"""Choose an artifact (with the component's fallback policy) and resolve its
|
|
trusted sha256 from the release checksum index."""
|
|
artifact, effective_backend, _used_fallback = ops.select_artifact_with_fallback(
|
|
bundle.manifest, host, backend
|
|
)
|
|
asset = str(artifact.get("asset"))
|
|
manifest_sha256 = artifact.get("sha256") if isinstance(artifact.get("sha256"), str) else None
|
|
expected_sha = ops.expected_sha256_for(
|
|
checksums,
|
|
asset,
|
|
manifest_sha256 = manifest_sha256,
|
|
)
|
|
return ops.selection_from_artifact(
|
|
published_repo = published_repo,
|
|
release_tag = bundle.release_tag,
|
|
manifest = bundle.manifest,
|
|
artifact = artifact,
|
|
backend = effective_backend,
|
|
asset_sha256 = expected_sha,
|
|
)
|
|
|
|
|
|
def install_prebuilt(
|
|
ops: ModuleOps,
|
|
install_dir: Path,
|
|
*,
|
|
published_repo: str,
|
|
published_release_tag: str | None = None,
|
|
backend: str | None = "auto",
|
|
cpu_fallback: bool = False,
|
|
force: bool = False,
|
|
host: Any = None,
|
|
) -> int:
|
|
if host is None:
|
|
host = ops.detect_host()
|
|
effective_backend = ops.resolve_backend(host, backend, cpu_fallback = cpu_fallback)
|
|
os_token, arch_token = ops.host_platform_tokens(host)
|
|
ops.log(
|
|
f"target {ops.COMPONENT} from {published_repo} "
|
|
f"({os_token}-{arch_token}, backend {effective_backend})"
|
|
)
|
|
|
|
bundle, checksums = ops.fetch_release_for_install(
|
|
published_repo, published_release_tag = published_release_tag
|
|
)
|
|
selection = ops.plan_selection(
|
|
host,
|
|
bundle,
|
|
published_repo = published_repo,
|
|
backend = effective_backend,
|
|
checksums = checksums,
|
|
)
|
|
|
|
return install_selected_prebuilt(
|
|
ops,
|
|
install_dir,
|
|
host = host,
|
|
bundle = bundle,
|
|
selection = selection,
|
|
force = force,
|
|
)
|
|
|
|
|
|
def install_selected_prebuilt(
|
|
ops: ModuleOps,
|
|
install_dir: Path,
|
|
*,
|
|
host: Any,
|
|
bundle: ReleaseBundle,
|
|
selection: InstallSelection,
|
|
force: bool,
|
|
) -> int:
|
|
"""Validate and activate an already selected release plan.
|
|
|
|
Components that need to examine more than one published release can keep
|
|
release selection component-specific while sharing the lock, idempotency,
|
|
atomic activation, and post-install verification path.
|
|
"""
|
|
|
|
if not force and ops.existing_install_matches(install_dir, host, selection):
|
|
return 0
|
|
|
|
with ops.install_lock(ops.install_lock_path(install_dir)):
|
|
# Re-check under the lock: a concurrent run may have just finished.
|
|
if not force and ops.existing_install_matches(install_dir, host, selection):
|
|
return 0
|
|
ops._install_from_bundle(install_dir, host, bundle, selection)
|
|
|
|
server = ops.installed_server_path(install_dir, host)
|
|
if not server.is_file():
|
|
raise PrebuiltFallback(f"post-install verification failed: {server} is missing")
|
|
ops.log(
|
|
f"installed {ops.COMPONENT} {bundle.release_tag} " f"({selection.backend}) at {install_dir}"
|
|
)
|
|
return 0
|
|
|
|
|
|
def resolve_prebuilt(
|
|
ops: ModuleOps,
|
|
host: Any,
|
|
*,
|
|
published_repo: str,
|
|
published_release_tag: str | None,
|
|
backend: str | None,
|
|
cpu_fallback: bool,
|
|
) -> dict[str, Any]:
|
|
"""Host-aware "is a prebuilt available" probe. No archive download."""
|
|
effective_backend = ops.resolve_backend(host, backend, cpu_fallback = cpu_fallback)
|
|
try:
|
|
bundle, _checksums = ops.fetch_release_for_install(
|
|
published_repo, published_release_tag = published_release_tag
|
|
)
|
|
artifact, resolved_backend, used_fallback = ops.select_artifact_with_fallback(
|
|
bundle.manifest, host, effective_backend
|
|
)
|
|
except PrebuiltFallback:
|
|
return {"prebuilt_available": False, "repo": published_repo}
|
|
os_token, arch_token = ops.host_platform_tokens(host)
|
|
payload = {
|
|
"prebuilt_available": True,
|
|
"repo": published_repo,
|
|
"release_tag": bundle.release_tag,
|
|
"upstream_tag": bundle.manifest.get("upstream_tag"),
|
|
"backend": resolved_backend,
|
|
"requested_backend": effective_backend,
|
|
"cpu_fallback": used_fallback,
|
|
"asset": str(artifact.get("asset")),
|
|
"os": os_token,
|
|
"arch": arch_token,
|
|
"runtime_line": artifact.get("runtime_line"),
|
|
}
|
|
# Component hook (default none): whisper adds install_kind slim|fat. The JSON
|
|
# emitter sorts keys, so an appended field can't perturb the legacy key order.
|
|
payload.update(ops.resolver_payload_extra(artifact))
|
|
return payload
|
|
|
|
|
|
def prepare_runtime_payload(staged_root: Path, host: Any, selection: InstallSelection) -> Any:
|
|
"""Post-assemble hook; the core stages nothing extra. Components override it
|
|
to wire external runtime files into the staged bin dir (whisper slim hardlinks
|
|
the llama install's ggml libraries) before validation, and may return an
|
|
updated InstallSelection for the marker to record instead."""
|
|
return None
|
|
|
|
|
|
def resolver_payload_extra(artifact: dict[str, Any]) -> dict[str, Any]:
|
|
"""Additive --resolve-prebuilt payload fields; the core adds none."""
|
|
return {}
|
|
|
|
|
|
def installed_server_path(ops: ModuleOps, install_dir: Path, host: Any) -> Path:
|
|
return ops.runtime_bin_dir(install_dir, host) / ops.server_binary_name(host)
|
|
|
|
|
|
# Underscored aliases so ops lookups that mirror the installer modules' private
|
|
# names still resolve for a descriptor-only component.
|
|
_download_host_latest_release_tag = download_host_latest_release_tag
|
|
_download_host_json = fetch_download_host_json
|
|
_resolve_release_via_download_host = resolve_release_via_download_host
|
|
_install_from_bundle = install_from_bundle
|
|
_swap_into_place = swap_into_place
|
|
|
|
|
|
def emit_resolver_output(payload: dict[str, Any], *, output_format: str) -> None:
|
|
if output_format == "json":
|
|
print(json.dumps(payload, sort_keys = True))
|
|
return
|
|
if "asset" in payload and payload.get("prebuilt_available"):
|
|
print(payload["asset"])
|
|
return
|
|
print(json.dumps(payload, sort_keys = True))
|