unsloth/studio/backend/core/inference/llama_cpp.py
Daniel Han 7be10852cb
install: support STUDIO_HOME / UNSLOTH_STUDIO_HOME for custom install paths (#5190)
* install: support STUDIO_HOME / UNSLOTH_STUDIO_HOME for custom install paths

Currently install.sh and install.ps1 hardcode all install paths off
$HOME / $env:USERPROFILE with no env-var fallback. This blocks
workspace-isolated installs (CI sandboxes, per-PR test environments,
multi-tenant boxes) unless the entire HOME / USERPROFILE is faked,
which also relocates ~/.gitconfig, ~/.ssh, and other unrelated state.

Add an opt-in env-var override that does only what is needed.

Resolution priority (highest first):
1. HOME / USERPROFILE explicitly redirected vs the password-database
   default. Detected via getent (Linux), dscl (macOS), or
   [Environment]::GetFolderPath (Windows). Best-effort: when the
   detection mechanism is unavailable the check is skipped and we
   fall through to step 2.
2. UNSLOTH_STUDIO_HOME, if set.
3. STUDIO_HOME, if set (alias for convenience; the variable name
   already matches the internal var install.sh sets).
4. Default: legacy $HOME/.unsloth/studio (or
   $USERPROFILE\.unsloth\studio on Windows). Identical to today's
   behavior when no env var is set.

When an env var override fires:
* DATA_DIR is nested inside ($STUDIO_HOME/share, or $StudioHome\share
  on Windows) so the runtime launcher and shortcuts find studio.conf
  in the same place install-time wrote it.
* The unsloth CLI shim lands at $STUDIO_HOME/bin/unsloth (Unix) or
  $StudioHome\bin\unsloth.exe (Windows). On Windows the shim already
  lives under $StudioHome; the change only redirects DATA_DIR and
  skips the persistent registry PATH update.
* Persistent shell PATH modifications are skipped (no .bashrc /
  .zshrc / .profile append on Unix; no Add-ToUserPath on Windows).
  Caller is expected to invoke via absolute path or add the bin dir
  to PATH explicitly. Avoids polluting the user's profile with a
  workspace-scoped path that may be deleted.

The Unix launcher script is the only piece that must read DATA_DIR
at runtime (it sources studio.conf from there). The hardcoded
DATA_DIR inside the LAUNCHER_EOF heredoc is replaced with an
@@DATA_DIR@@ placeholder substituted via sed at install time, using
the same approach the script already uses for other install-time
substitutions.

Default path behavior is unchanged: when no env var is set and HOME
is not redirected, install.sh / install.ps1 produce exactly the same
file layout as today.

Test scenarios verified locally on install.sh:
* Default (no env vars)             -> $HOME/.unsloth/studio (legacy)
* HOME=/tmp/x                       -> /tmp/x/.unsloth/studio
* UNSLOTH_STUDIO_HOME=/tmp/y        -> /tmp/y as STUDIO_HOME root
* STUDIO_HOME=/tmp/z (alias)        -> /tmp/z as STUDIO_HOME root
* HOME redirect + env var (HOME wins) -> install follows HOME
* Unwritable override               -> exits with clear ERROR message

* install: priority change -- env vars now win over HOME redirect

Flip the resolution order so explicit env vars take precedence over
HOME / USERPROFILE redirection.

New priority (highest first):
1. UNSLOTH_STUDIO_HOME, if set.
2. STUDIO_HOME, if set.
3. HOME / USERPROFILE explicitly redirected.
4. Default.

Rationale: the env vars are explicit single-purpose signals (the user
typed UNSLOTH_STUDIO_HOME=... specifically to redirect Studio). HOME
redirection is broader and incidental -- the user may have redirected
HOME for unrelated reasons (workspace tools, container builds) without
wanting Studio to follow it. When both are set, the more specific
signal should win.

When only HOME is redirected (no env var), behavior is unchanged from
the previous commit: install follows $HOME.

* install: address review feedback (sed escape, downstream propagation, edge cases)

Fixes from gemini-code-assist + chatgpt-codex-connector + reviewer.py
20-parallel run on the open PR.

install.sh:
* Escape sed replacement metacharacters before substituting @@DATA_DIR@@.
  Two-stage escape: ' -> '\'' for safe single-quote shell embedding,
  then \, &, | for sed replacement string + chosen delimiter. Heredoc
  switched to single-quoted DATA_DIR='@@DATA_DIR@@' so we only need
  single-quote escaping at runtime. Verified end-to-end with paths
  containing & and | (the sed delimiter).
* Pass UNSLOTH_STUDIO_HOME into both setup.sh invocations
  (--local and PyPI paths) so the downstream install resolves the
  same Studio root install.sh picked.
* macOS .app stub: replace hardcoded
  exec "$HOME/.local/share/unsloth/launch-studio.sh" with
  exec "$_css_data_dir/launch-studio.sh" so the .app launches the
  resolved launcher even in env-override mode.
* Use mkdir -p -- and cd -- when validating the env override so
  paths starting with - cannot be misread as flags.

install.ps1:
* Drop .Guid from [guid]::NewGuid().Guid: the property does not
  exist; the probe filename was always identical and not unique.
  Default ToString() on System.Guid produces the canonical UUID
  string we want.
* Guard LOCALAPPDATA before Join-Path to avoid aborting the
  installer in service / CI contexts where LOCALAPPDATA is unset
  (Join-Path under $ErrorActionPreference='Stop' would otherwise
  throw). Computed once into $defaultDataDir; both 'profile' and
  'default' branches reuse it.
* Set $env:UNSLOTH_STUDIO_HOME for the duration of the
  'unsloth studio setup' subprocess so studio/setup.ps1 and
  unsloth_cli see the same install root install.ps1 picked.
  Restored in a finally block.

studio/setup.sh:
* Honor UNSLOTH_STUDIO_HOME / STUDIO_HOME (alias) when resolving
  STUDIO_HOME, VENV_DIR, VENV_T5_*_DIR. Falls back to the legacy
  $HOME/.unsloth/studio when no override is set.

studio/setup.ps1:
* Same change in PowerShell: honor $env:UNSLOTH_STUDIO_HOME /
  $env:STUDIO_HOME for $StudioHome / $VenvDir resolution.

unsloth_cli/commands/studio.py:
* Replace the module-level constant
  STUDIO_HOME = Path.home() / ".unsloth" / "studio"
  with a resolver that honors UNSLOTH_STUDIO_HOME / STUDIO_HOME
  before falling through to the legacy default. Same precedence
  the installers use.

Verified locally: 6 install.sh scenarios still produce correct paths
(default, HOME redirect, env var, alias, both, bad override). New
sed-escape unit tests pass for paths containing & and |. Python
resolver matches priority: UNSLOTH_STUDIO_HOME > STUDIO_HOME > default.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* install.sh: portable sed (no -i.bak) per gemini review feedback

GNU sed -i.bak vs BSD/macOS sed -i.bak vs BusyBox sed have subtly
different semantics. Use the POSIX-portable redirect-then-mv pattern
instead. Functionally identical, runs everywhere.

* studio: persist UNSLOTH_STUDIO_HOME so fresh shells find custom installs

Without this, a custom-root install (UNSLOTH_STUDIO_HOME=/work/studio
bash install.sh --local) only worked in the same shell that ran the
installer. Closing the terminal and reopening lost the env var, the
PATH was deliberately not persisted, and the Python CLI fell back to
~/.unsloth/studio. Result: 'Studio not set up' or quietly operating on
a stale legacy install.

Three persistence layers, all backwards-compatible (default installs
emit zero changes):

1. Unix studio.conf
   install.sh now writes 'export UNSLOTH_STUDIO_HOME=...' next to
   UNSLOTH_EXE in studio.conf when in env-override mode. The launcher
   sources studio.conf at startup so the exec'd binary gets the var.
   Default installs do not write this line; studio.conf stays
   byte-identical to before.

2. Windows launch-studio.ps1
   install.ps1 prepends '$env:UNSLOTH_STUDIO_HOME = ...' to the
   generated launcher when in env-override mode. Default installs
   produce the same launcher content as before.

3. Python sys.prefix inference
   storage_roots.studio_root() and unsloth_cli/commands/studio.py
   now infer the install root from sys.prefix when no env var is
   set (Path(sys.prefix).parent for unsloth_studio venvs). Catches
   direct invocations of <STUDIO_HOME>/bin/unsloth that bypass the
   launcher entirely.

unsloth_cli/commands/studio.py also re-exports the resolved
UNSLOTH_STUDIO_HOME via os.environ.setdefault so child processes
(setup script, backend run.py) inherit it.

Backend storage roots (storage_roots.studio_root, cache_root) now
respect the env var via the shared resolver. run.py PID file,
transformers_version.py T5 venvs, and model_config.py vision-check
venv all switch to studio_root() so custom installs are
self-contained.

studio/setup.ps1: T5 sidecar venvs now resolve under $StudioHome
(was $env:USERPROFILE\.unsloth\studio\.venv_t5_*).

studio/setup.sh + studio/setup.ps1: llama.cpp build dir nests under
$STUDIO_HOME / $StudioHome when env-override is active, otherwise
keeps the legacy ~/.unsloth/llama.cpp.

Verified locally:
* studio.conf write block: env-override mode emits the export line;
  default mode does not (byte-identical to today).
* PowerShell heredoc interpolation: correct output for both modes.
* studio_root() resolver: default, UNSLOTH_STUDIO_HOME, STUDIO_HOME
  alias, and sys.prefix-based inference all return correct paths.
* cache_root() now derives from studio_root().

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* install: tilde expansion + macOS .app stub safe-quoting

Two fixes from running a 25-scenario simulation sweep against install.sh
across path edge cases (spaces, apostrophes, ampersands, pipes,
backslashes, dollar signs, Unicode, trailing slash, relative paths).

1. UNSLOTH_STUDIO_HOME=~/foo was landing as literal '~/foo' (env vars
   are not subject to tilde expansion). Added a POSIX-portable case
   block in install.sh, install.ps1, studio/setup.sh, studio/setup.ps1
   that expands a leading ~ or ~/ to $HOME / $env:USERPROFILE.
   The prefix-removal pattern is single-quoted ('${var#'~/'}') so the
   shell does not tilde-expand the pattern back to $HOME/ before
   matching -- a subtle dash/bash gotcha.

2. macOS .app stub used an unquoted heredoc ('<< STUB_EOF'), so any
   $VAR / backtick / etc in the path would expand at .app launch time.
   Switched to single-quoted heredoc ('<< 'STUB_EOF'') with a
   placeholder + sed substitution + single-quoted shell embedding,
   matching the @@DATA_DIR@@ pattern already used for launch-studio.sh.

Verified: 25/25 simulation scenarios pass on Linux dash + bash,
including paths with $VAR, &, |, \\, ', spaces, and Unicode. End-to-end
install in env-mode + fresh-shell launcher invocation confirmed: studio
binds to /api/health from a clean env, and sys.prefix-based inference
correctly returns the workspace root.

* install: stop accidentally treating default installs as env-override

Reviewer.py 20-runs cycle 1 found a unanimous P1 regression: a default
'unsloth studio update' relocates llama.cpp from ~/.unsloth/llama.cpp
to ~/.unsloth/studio/llama.cpp, because the CLI was re-exporting
UNSLOTH_STUDIO_HOME unconditionally and install.sh / install.ps1 were
passing it into setup.{sh,ps1} unconditionally. The setup scripts
treated the var's mere presence as "env-override mode" and relocated
the llama.cpp build dir away from the legacy path, breaking the
runtime backend's _find_llama_server_binary lookup on default installs.

Fixes:

* unsloth_cli/commands/studio.py: _resolve_studio_home now returns
  (path, is_custom). Re-export only when is_custom -- a real env
  override or a sys.prefix inference that resolves to a non-legacy
  path. Default installs leave UNSLOTH_STUDIO_HOME unset.

* install.sh: gate UNSLOTH_STUDIO_HOME on $_STUDIO_HOME_REDIRECT == env
  before calling setup.sh. Use 'env $VARS bash setup.sh' so the var
  is set only for the subprocess, never leaked.

* install.ps1: gate $env:UNSLOTH_STUDIO_HOME on $StudioRedirectMode
  -eq 'env' before invoking 'unsloth studio setup'. Restore prior
  value in finally block (unset if it wasn't set).

* studio/setup.sh + setup.ps1: decide llama.cpp install root from
  the resolved $STUDIO_HOME (not from env-var presence). If the
  resolved path equals the legacy default ($HOME/.unsloth/studio),
  fall back to ~/.unsloth/llama.cpp. This makes setup robust against
  a stale UNSLOTH_STUDIO_HOME inherited from a parent process that
  happens to point at the legacy default.

* studio/backend/core/inference/llama_cpp.py:
  - _find_llama_server_binary() now searches studio_root() / llama.cpp
    AND the legacy ~/.unsloth/llama.cpp (de-duped). Custom-root
    installs become discoverable; default installs unaffected.
  - kill_orphaned_servers ownership allowlist also includes
    studio_root() / llama.cpp so custom-root processes are cleanable.

Verified locally:
* 25/25 sim scenarios still pass (path edge cases unchanged).
* setup.sh unit test: default-mode lands UNSLOTH_HOME at $HOME/.unsloth;
  env-mode lands at $STUDIO_HOME.
* Python CLI unit test: default-mode returns is_custom=False and does
  NOT setdefault UNSLOTH_STUDIO_HOME; env-mode sets is_custom=True.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* install: || exit 1 on STUDIO_HOME subshell (dash set -e gap)

Gemini review feedback: in dash, set -e does not trigger on subshell
failures inside variable assignments. If 'cd -- "$_override" && pwd'
fails, STUDIO_HOME stays empty and DATA_DIR collapses to /share. Add
explicit '|| exit 1' on both install.sh:187 and setup.sh:413.

* install.sh: argv-safe setup invocation for paths with spaces

Cycle 2 reviewer.py 20-runs found a unanimous P1: passing the env-var
through 'env $_STUDIO_ENV_FOR_SETUP' word-splits on whitespace, so a
custom root like '/tmp/Unsloth Studio' becomes 'UNSLOTH_STUDIO_HOME=
/tmp/Unsloth' followed by env trying to exec 'Studio'.

Replaced with a tiny helper that prepends the env-var directly to the
argv (no string-form intermediary), so spaces are preserved as a
single argument. Default-mode invocation skips the env-var entirely.

Verified: 'UNSLOTH_STUDIO_HOME=/tmp/test space/studio' now reaches
setup.sh as a single value.

* studio: tighten sys.prefix inference + Tauri env handling + llama.cpp env

Cycle 3 reviewer.py findings (3 P1s converging):

* sys.prefix inference too broad: a developer venv named 'unsloth_studio'
  was being treated as a custom Studio root. Narrow with an installer-
  sentinel check (presence of share/studio.conf or bin/unsloth shim
  inside the parent dir) in both unsloth_cli/commands/studio.py and
  studio/backend/utils/paths/storage_roots.py.

* Tauri studio/src-tauri/src/process.rs::find_unsloth_binary() hardcoded
  ~/.unsloth/studio. Honor UNSLOTH_STUDIO_HOME / STUDIO_HOME (in that
  priority order) before falling back to legacy.

* unsloth-zoo's GGUF export binds LLAMA_CPP_DEFAULT_DIR at import time
  from UNSLOTH_LLAMA_CPP_PATH. For env-override installs, persist
  UNSLOTH_LLAMA_CPP_PATH alongside UNSLOTH_STUDIO_HOME in studio.conf
  (Unix), in the generated PowerShell launcher (Windows), and via
  os.environ.setdefault in the Python CLI when running on a custom
  root, so GGUF export uses the custom-root llama.cpp build instead
  of the legacy ~/.unsloth/llama.cpp.

Default behaviour unchanged: no env vars are written to studio.conf
in default mode, no LLAMA_CPP_PATH is set, and the dev-venv inference
falls through to legacy when no installer sentinels are present.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* studio: desktop_auth env-aware + legacy-root llama.cpp consistency

- desktop_auth.rs: honor UNSLOTH_STUDIO_HOME / STUDIO_HOME for the
  .desktop_secret path so Tauri desktop login works against custom-root
  installs instead of always reading ~/.unsloth/studio/auth/.

- install.sh / install.ps1 / unsloth_cli/commands/studio.py: when an env
  override resolves to the legacy default ($HOME/.unsloth/studio), set
  UNSLOTH_LLAMA_CPP_PATH to ~/.unsloth/llama.cpp (matching setup.sh /
  setup.ps1's legacy-equality branch). Previously the persisted value
  pointed at $STUDIO_HOME/llama.cpp, which was a non-existent location
  and broke unsloth-zoo's import-time GGUF binding for that edge case.

* studio: tauri studio_root helper + marker-file persistence + ~ expansion

Address cycle-5 reviewer findings:

- Add studio/src-tauri/src/studio_root.rs: shared resolver with
  UNSLOTH_STUDIO_HOME / STUDIO_HOME (priority order), tilde expansion
  (~, ~/..., ~\...), installer-written marker fallback, then
  ~/.unsloth/studio. 5 unit tests cover the expansion paths.

- Tauri lookups now go through the shared resolver:
  - process.rs::find_unsloth_binary
  - desktop_auth.rs::desktop_secret_path
  - main.rs::setup_logging (tauri.log under custom root)
  - commands.rs::open_logs_dir (opens custom root dir)
  - install.rs work_dir uses parent of resolved root (avoids creating
    a stray ~/.unsloth on a custom-root install)

- install.sh / install.ps1 (env-mode only): write
  ~/.unsloth/studio-home marker so the desktop app launched from
  Finder/Start Menu (no shell env inheritance) still resolves the
  custom root.

- install.sh / install.ps1 non-interactive completion: when
  StudioRedirectMode=env, print the absolute custom-root shim path
  since the persistent rc/registry PATH update is intentionally
  skipped in env-override mode.

- unsloth_cli/commands/studio.py: replace setdefault() with
  truthy-check so a blank UNSLOTH_STUDIO_HOME / UNSLOTH_LLAMA_CPP_PATH
  in the parent env doesn't suppress the inferred custom root.

40/40 cargo test --bins pass.

* studio: validate marker file + write in --tauri mode + propagate to subprocess

Cycle-6 reviewer follow-ups:

- studio_root.rs marker resolver now validates the persisted path before
  using it. A stale ~/.unsloth/studio-home pointing at a deleted/moved
  workspace is ignored (resolution falls back to the legacy default
  rather than hijacking it). Validation accepts share/studio.conf
  sentinel or bin/unsloth shim. Trailing newline strip uses
  trim_end_matches(['\n','\r']) so paths whose content legitimately has
  leading/trailing spaces survive.

- install.sh / install.ps1: marker write moved out of the launcher
  generation path so it runs before the Tauri-mode early exit. Both
  shell-launcher and Tauri-installed env-mode roots now persist the
  marker. Removed the duplicate marker write that was previously inside
  install.ps1's $studioHomeExport block.

- studio/src-tauri/src/install.rs: pass UNSLOTH_STUDIO_HOME to the
  installer subprocess (when not already in scope) so app-initiated
  repair / update flows reach the same root the running app uses.

cargo test --bins -- --test-threads=1: 44/44 pass (4 new tests for
marker validation: sentinel accepted, bin shim accepted, empty dir
rejected, missing path rejected).

* studio: fix Tauri legacy-fallback regression + stale marker cleanup

Cycle-7 reviewer follow-ups (regression I introduced in cycle 6):

- studio_root.rs: add StudioRootSource enum + resolve_studio_root_with_source().
  Lets callers distinguish a real custom override (Env / Marker) from the
  legacy fallback (Default).

- studio/src-tauri/src/install.rs: only forward UNSLOTH_STUDIO_HOME to the
  installer subprocess when the resolution source is Env or Marker. The
  Default fallback must NOT be passed -- install.sh / install.ps1 treat
  any non-empty UNSLOTH_STUDIO_HOME as env-override mode and would
  relocate DATA_DIR to $STUDIO_HOME/share and _LOCAL_BIN to $STUDIO_HOME/bin
  (regressing default Tauri repair / update flows from the legacy
  ~/.local/share/unsloth and ~/.local/bin).

- install.sh / install.ps1: clear stale marker on default / HOME-redirect
  installs. A user who first installed with UNSLOTH_STUDIO_HOME=/work/studio
  then later reinstalls without env vars no longer has the desktop app
  hijacked by ~/.unsloth/studio-home pointing at the old custom root.

- install.sh / install.ps1: when env mode wins over a redirected
  HOME / USERPROFILE, write the marker into the OS-reported real profile
  home (getent / dscl on Unix; [Environment]::GetFolderPath on Windows)
  so a later desktop launch from the user's normal session still finds
  it. Falls back to the current HOME / USERPROFILE.

cargo test --bins -- --test-threads=1: 45/45 pass (1 new for the source
enum invariants).

* install: scrub stale marker from real-home on HOME-redirect cleanup

Cycle-8 reviewer follow-up: the previous cleanup branch only removed
\$HOME/.unsloth/studio-home, leaving a stale marker in the real
password-database home after a prior env-mode install. A later default
install with redirected HOME / USERPROFILE would still see the desktop
app resolving the old custom root.

- install.sh: compute the real password-database home (via getent /
  dscl) unconditionally, and scrub markers from BOTH \$HOME and the
  real-home in the default / HOME-redirect cleanup branch.

- install.ps1: build a profile-candidate list (current USERPROFILE
  + OS-reported real profile) and remove markers from EVERY candidate
  in the default / profile-redirect cleanup branch.

bash -n + cleanup smoke verified.

* revert: drop Tauri env-var support + marker file mechanism

Keep this PR scoped to shell installer + Python backend env-var support.
Tauri desktop integration with custom Studio roots is deferred to a
separate, focused PR.

Reverts to pre-PR state:
- studio/src-tauri/src/process.rs (find_unsloth_binary)
- studio/src-tauri/src/desktop_auth.rs (auth_secret_path)
- studio/src-tauri/src/main.rs (setup_logging tauri.log path)
- studio/src-tauri/src/commands.rs (open_logs_dir)
- studio/src-tauri/src/install.rs (work_dir + subprocess env)
- studio/src-tauri/src/studio_root.rs DELETED

Removes from install.sh / install.ps1:
- ~/.unsloth/studio-home marker write/read/cleanup
- HOME-redirect-aware marker location logic

What this PR keeps (the original scope):
- install.sh / install.ps1: UNSLOTH_STUDIO_HOME / STUDIO_HOME env-var
  resolver with HOME-redirect detection, tilde expansion, legacy
  fallback. Default installs are byte-identical to pre-PR.
- studio/setup.sh / studio/setup.ps1: legacy-equality llama.cpp path.
- studio.conf / launcher persists UNSLOTH_STUDIO_HOME +
  UNSLOTH_LLAMA_CPP_PATH for fresh shells (env-mode only).
- unsloth_cli/commands/studio.py: env > sys.prefix sentinel > legacy
  resolver, conditional re-export.
- studio/backend/utils/paths/storage_roots.py: same resolver.
- Backend modules use storage_roots (run.py, model_config.py,
  transformers_version.py, llama_cpp.py).

cargo test --bins -- --test-threads=1: 34/34 pass (pre-PR baseline).
bash -n install.sh: clean.

* install: cycle-10 fixes (default launcher, --tauri guard, env-mode shortcuts, win PATH)

- install.sh launcher: default and HOME-redirect installs keep the
  legacy DATA_DIR=\"\$HOME/.local/share/unsloth\" runtime form so a
  later shell with a different \$HOME still resolves DATA_DIR. Only
  env-mode bakes the resolved absolute path. Restores byte-identical
  default behavior.

- install.sh / install.ps1: fail fast when --tauri is combined with
  UNSLOTH_STUDIO_HOME / STUDIO_HOME. The desktop app still resolves
  the legacy ~/.unsloth/studio root, so a custom-root --tauri install
  would yield a desktop app that cannot find its binary or auth
  secret. Print the right alternative.

- install.sh / install.ps1: skip persistent desktop / Start-Menu
  shortcuts in env-override mode. Workspace-scoped installs would
  otherwise leave launchers pointing at a path the user may delete.
  Default and HOME/profile-redirect installs keep the shortcut.

- install.ps1: re-prepend env-override \$ShimDir AFTER
  Refresh-SessionPath. Refresh rebuilds PATH as Machine > User >
  current \$env:Path, so a previously-installed legacy User PATH
  entry would otherwise win precedence over the current-session
  env-override shim.

bash -n install.sh, pwsh parser install.ps1 + setup.ps1: clean.
cargo test --bins -- --test-threads=1: 34/34 (Tauri unchanged).

* install: cycle-11 fixes (env-mode launcher writes, --tauri legacy passthrough, run.py llama path)

- install.sh / install.ps1: env-mode no longer skips the entire
  create_studio_shortcuts / New-StudioShortcuts function. Move the
  early-return INSIDE those functions, just before the persistent
  desktop / Start-Menu shortcut creation. The runtime launcher
  (launch-studio.sh / launch-studio.ps1), studio.conf with
  UNSLOTH_STUDIO_HOME / UNSLOTH_LLAMA_CPP_PATH exports, and the icon
  ARE always written so env-mode shims can resolve via fresh shells.

- install.sh / install.ps1: --tauri guard passes through when the
  override resolves to the legacy default ($HOME/.unsloth/studio /
  %USERPROFILE%\.unsloth\studio). The desktop app already uses that
  path, so explicit-equality is a supported edge case (matches the
  llama.cpp legacy-equality branch).

- studio/backend/run.py: when launched directly (bypassing the
  unsloth CLI), set UNSLOTH_STUDIO_HOME and UNSLOTH_LLAMA_CPP_PATH
  before the rest of import chain runs so unsloth-zoo's import-time
  LLAMA_CPP_DEFAULT_DIR binding picks up the custom-root build. Only
  set when STUDIO_ROOT is a real custom override; legacy default
  installs leave them unset.

bash -n install.sh, pwsh parser install.ps1: clean.
python ast parse studio/backend/run.py: clean.
cargo test --bins -- --test-threads=1: 34/34 pass (Tauri unchanged).

* install: cycle-12 fixes (--tauri trailing slash + main.py uvicorn env)

- install.sh / install.ps1 --tauri legacy passthrough: strip trailing
  separators before comparing the override to the legacy default.
  Previously UNSLOTH_STUDIO_HOME=\"\$HOME/.unsloth/studio/\" (with
  trailing slash) was rejected even though it resolves to the
  supported legacy root.

- studio/backend/main.py: when launched directly via
  \`uvicorn main:app\` from a custom-root venv (bypassing both
  unsloth_cli and run.py), export UNSLOTH_STUDIO_HOME and
  UNSLOTH_LLAMA_CPP_PATH before any unsloth-zoo import so its
  import-time LLAMA_CPP_DEFAULT_DIR binding picks up the custom-root
  build. Only sets when STUDIO_ROOT is a real custom override.

bash -n install.sh, pwsh parser install.ps1, python ast main.py: clean.
Smoke probe: UNSLOTH_STUDIO_HOME=\$HOME/.unsloth/studio/ install.sh --tauri
no longer exits with the unsupported-custom-root error.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* install.ps1: skip CWD-relative venv migration in env-override mode

The legacy ~/unsloth_studio venv migration path on Windows reads
%USERPROFILE%\unsloth_studio\Scripts\python.exe (a fixed home-relative
path). Under env-override mode this would Move-Item the user's
pre-existing default-install venv into $StudioHome\unsloth_studio,
breaking the default install and contaminating the workspace root.

Gate the migration on $StudioRedirectMode -ne 'env' so workspace-scoped
installs leave the user's default-install venv untouched.

No Linux equivalent: install.sh migrates from \$STUDIO_HOME/.venv which
is already env-mode-aware (points at the workspace root, not \$HOME).

* install: cycle-14 fixes (Tauri env scrub + setup.ps1 missing-root error)

Tauri does not honor UNSLOTH_STUDIO_HOME / STUDIO_HOME / UNSLOTH_LLAMA_CPP_PATH
yet -- the desktop app's Rust paths use the legacy ~/.unsloth/studio root.
If the user's shell has these env vars set, spawned Python subprocesses would
diverge from the Rust paths (custom-root Python <-> legacy-root Rust).

Scrub the three env vars at all Tauri subprocess spawn sites:
- process.rs: backend launch
- desktop_auth.rs: provision-desktop-auth subprocess
- install.rs: install.sh / install.ps1 invoked from the desktop app
  (also prevents the --tauri guard from rejecting an inherited override).

setup.ps1: when UNSLOTH_STUDIO_HOME points at a non-existent directory,
'Resolve-Path -LiteralPath' threw a confusing PSObject error under
$ErrorActionPreference = "Stop". Test-Path the override first and emit a
friendly "run install.ps1 to create the install root" message instead.

* install: cycle-15 fixes (preserve UNSLOTH_LLAMA_CPP_PATH + add update.rs scrub)

UNSLOTH_LLAMA_CPP_PATH is a pre-existing custom-llama.cpp-directory override
the Python backend (studio/backend/core/inference/llama_cpp.py) and unsloth-zoo
intentionally support. It is unrelated to the Studio install root. Cycle 14
over-scrubbed it from the Tauri spawn sites, regressing desktop GGUF/llama.cpp
workflows for users who set it in their shell.

- process.rs / desktop_auth.rs / install.rs: stop scrubbing
  UNSLOTH_LLAMA_CPP_PATH; only scrub UNSLOTH_STUDIO_HOME and STUDIO_HOME.
- update.rs: missed Tauri spawn site -- add the same UNSLOTH_STUDIO_HOME /
  STUDIO_HOME scrub so 'unsloth studio update' from the desktop app updates
  the legacy-root install Tauri actually manages.

Verified: cargo test --bins -- --test-threads=1 -> 34/34 pass.

* install.sh: document apostrophe-escape derivation inline

The shell quoting at install.sh:642 / 659 / 679 / 680 / 823 has been
flagged as broken across multiple review cycles, but every end-to-end
verification (DATA_DIR=\"a b's&c|d\$e\" -> generated launcher -> source ->
recovered exact input) passes. The proposed "8 backslash" fix would
double the escape and actually break what currently works.

Strengthen the inline comments to spell out the derivation:
- shell pattern \"s/'/'\\\\''/g\" passes \"s/'/'\\''/g\" to sed (\\\\ -> \\)
- sed replacement '\\'' yields close-quote / escaped-quote / open-quote
- stage 2 (\\, &, |) only needed where the value is then sed-replaced
  into a launcher template via s|@@DATA_DIR@@|VALUE|g

studio.conf is written via printf, not sed, so it only needs stage 1.

No behavior change, only inline doc to head off future false positives.

* install/setup .ps1: use -LiteralPath for $StudioHome-derived paths

Pre-PR, $StudioHome was hardcoded to %USERPROFILE%\.unsloth\studio --
no wildcard characters possible. The PR introduces UNSLOTH_STUDIO_HOME /
STUDIO_HOME, so $StudioHome (and every path derived from it: $VenvDir,
$VenvPyExe, $UnslothExe, $UnslothHome, $LlamaCppDir, $VenvT5_*, etc.)
can now contain bracket characters that PowerShell would interpret as
wildcards.

Reproducer (from cycle 17 review 20):
    pwsh> Test-Path 'studio[abc]/Scripts/python.exe'
    False
    pwsh> Test-Path -LiteralPath 'studio[abc]/Scripts/python.exe'
    True

Switch the relevant Test-Path / Remove-Item / New-Item / Move-Item calls
in install.ps1 and studio/setup.ps1 to -LiteralPath. Sites where the
path is fixed (the shim under %LOCALAPPDATA%\Microsoft\WindowsApps,
$RepoRoot from -PSCommandPath) keep the wildcard-aware form.

* install/setup .ps1: fix New-Item -LiteralPath regression from cycle 17

Cycle 17 added -LiteralPath to all $StudioHome-derived path operations,
but New-Item has no -LiteralPath parameter (verified pwsh 7.6 syntax:
"New-Item [-Path] <string[]> [-ItemType <string>] ..."). Every directory-
creation site would throw "A parameter cannot be found that matches
parameter name 'LiteralPath'" at runtime, blocking T5 sidecar setup,
llama.cpp parent creation, and StudioHome creation.

Likewise, "Split-Path -LiteralPath $X -Parent" cannot mix LiteralPath
with -Parent (separate parameter sets). The default LiteralPath mode
already returns the parent.

Switch to [System.IO.Directory]::CreateDirectory($X), which natively
takes a literal path, and drop the trailing -Parent on Split-Path.

Verified end-to-end on a bracketed path "/tmp/...[abc]":
- CreateDirectory: created
- Test-Path -LiteralPath: detects
- nested CreateDirectory(Split-Path -LiteralPath ...): works

* install/setup .ps1: extend -LiteralPath sweep to remaining \$StudioHome paths

Cycle 17/18 missed several wildcard-aware operations on user-controlled
\$StudioHome-derived paths. Reviewers identified remaining sites:

install.ps1:
- \$UnslothExePath (Test-Path / Resolve-Path) at the shortcut creator
- \$VenvDir (Get-ChildItem) at the no-torch-runtime resolver
- \$ShimDir (New-Item Directory -- replaced with .NET CreateDirectory)
- \$ShimExe (Test-Path / Remove-Item / re-prepend guards) -- the shim
  lives at \$StudioHome\\bin\\unsloth.exe in env-override mode, so it
  inherits bracket sensitivity from \$StudioHome.
- \$UnslothExe (Copy-Item fallback) when HardLink fails.

studio/setup.ps1:
- \$LlamaServerBin (Test-Path) at the prebuilt-bundle / source-build
  validation gates (3 sites). \$LlamaServerBin lives under \$BuildDir
  under \$LlamaCppDir under \$UnslothHome under \$StudioHome.

New-Item HardLink keeps -Path because creating a non-existent target
with brackets succeeds (verified via direct pwsh smoke test).

* install: cycle-20 fixes (more setup.ps1 -LiteralPath + shell-quote launch hints)

setup.ps1: extend -LiteralPath sweep to remaining \$BuildDir-derived paths
that the cycle-19 commit missed:
- \$CmakeCacheFile (Test-Path + Select-String -Path)
- \$buildTmp (10 Test-Path / Remove-Item sites in source-build cleanup)
- \$QuantizeBin (Test-Path)
- \$altBin (Test-Path)

These all live under \$BuildDir -> \$LlamaCppDir -> \$UnslothHome ->
\$StudioHome, which is now user-controlled via UNSLOTH_STUDIO_HOME.
Bracket characters in the override would silently skip rebuild
detection or leave stale build artifacts.

install.sh: shell-quote the launch-instruction substep lines for env-
override mode. UNSLOTH_STUDIO_HOME values containing spaces or
apostrophes (e.g. "/tmp/O'Brien Studio") would print copy-paste-
unsafe commands -- the install succeeded but the printed launch
instructions split at the space. Now wraps with the canonical
'\\''-style escape so the printed lines parse with bash -n.

Verified end-to-end:
- printed shim line: '/tmp/O'\''Brien Studio/bin/unsloth' studio ...
- bash -n on the printed line passes.

* install.ps1: -LiteralPath for macOS-stub-launcher \$appDir-derived paths

The shortcut/launcher generator at install.ps1:418-693 writes the
stub launcher, .vbs, and icon under \$appDir = \$StudioDataDir, which in
env-override mode is \$StudioHome\share. Cycle 17/19/20 missed the
following wildcard-aware ops on these paths:

- Test-Path \$appDir (with New-Item Directory swap to .NET CreateDirectory)
- Set-Content -Path \$launcherVbs (for the WSH .vbs stub)
- Test-Path / Copy-Item \$bundledIcon (bundled icon copy)
- Test-Path / Remove-Item \$iconPath (icon header validation)

In env-override mode \$StudioHome can contain bracket characters;
without -LiteralPath the .vbs write fails outright and the icon
validation can either skip a present icon or fail to delete a
malformed one. (The COM shortcut creation downstream returns early
in env-override mode, so its path values don't need this treatment.)

* install: don't override pre-existing UNSLOTH_LLAMA_CPP_PATH in launchers

Cycle 14/15 established UNSLOTH_LLAMA_CPP_PATH as a pre-existing
custom-llama.cpp-directory override the Python backend and unsloth-zoo
intentionally support, independent of the Studio install root.

The launchers (studio.conf sourced by Unix launch-studio.sh, and the
PowerShell launch-studio.ps1) were unconditionally re-exporting it,
which silently overrides a user's pre-existing value when they invoke
the launcher from a shell where UNSLOTH_LLAMA_CPP_PATH is already set.

Make the assignment conditional in both launchers:

install.sh studio.conf:
  if [ -z "\${UNSLOTH_LLAMA_CPP_PATH:-}" ]; then
      export UNSLOTH_LLAMA_CPP_PATH='...'
  fi

install.ps1 launch-studio.ps1:
  if (-not \$env:UNSLOTH_LLAMA_CPP_PATH) {
      \$env:UNSLOTH_LLAMA_CPP_PATH = '...'
  }

UNSLOTH_STUDIO_HOME stays unconditional: the launcher is bound to a
specific install, so its STUDIO_HOME must always match that install.

* install.sh: harden --tauri legacy resolver against CDPATH and symlinks

Reviewer cycle 23 (inst 19) noted that the bare \`cd -- ... && pwd\` form
in the --tauri legacy comparison can echo a CDPATH-prefixed path when the
user has CDPATH set in their environment, contaminating the resolved
absolute path used in the legacy-equality check.

Switch to \`CDPATH= cd -P -- ... && pwd -P\` so:
- CDPATH= clears the cd-prefix-echo behavior
- -P / pwd -P resolves any symlinks to a canonical path

No behavior change for users without CDPATH set; correctness fix for
users who have it set in their shell.

* install + llama_cpp backend: cycle-24 hardening

Three real findings from cycle 24 reviewers:

1. install.sh:231 + studio/setup.sh:413 -- main \$STUDIO_HOME
   resolvers used the same bare \`cd -- ... && pwd\` form that cycle 23
   only fixed for the --tauri guard. Switch both to:
       \$(CDPATH= cd -P -- "\$override" && pwd -P)
   so relative custom-root values don't get CDPATH-prefixed or have
   the cd-on-CDPATH stdout newline contaminate the captured value.

2. install.sh --tauri legacy root used logical \$HOME/.unsloth/studio
   while the override side was canonicalized via pwd -P. A symlinked
   \$HOME (e.g. /home/alice -> /u/alice) made the comparison fail even
   when both sides pointed at the same directory. Canonicalize the
   legacy side too when the dir exists.

3. studio/backend/core/inference/llama_cpp.py:_find_llama_server_binary
   searched \$STUDIO_HOME/llama.cpp first then ~/.unsloth/llama.cpp
   in default-mode installs. setup.sh / setup.ps1 only install llama.cpp
   under \$STUDIO_HOME/llama.cpp in env-override mode; in default mode
   it always lives at ~/.unsloth/llama.cpp. The post-PR search would
   pick up a stale partial install at ~/.unsloth/studio/llama.cpp over
   the real legacy binary.

   Mirror setup's legacy-equality check: when studio_root() resolves
   equal to ~/.unsloth/studio, search ONLY the legacy ~/.unsloth/llama.cpp.
   Otherwise (env-override custom root), search custom first, legacy
   fallback.

* install + setup: canonicalize legacy-equality comparison sites

Cycle 24 made \$STUDIO_HOME canonical via 'CDPATH= cd -P -- ... && pwd -P',
but the legacy-equality comparison sites still used the bare logical
"\$HOME/.unsloth/studio" string. With a symlinked \$HOME (e.g.
/home/alice -> /u/alice), the comparison fails even when both sides
point at the same dir, and llama.cpp ends up under a custom-root path
the Python backend's legacy comparison cannot find.

Reviewer cycle 25 inst 2 reproduced this with HOME=/tmp/link -> /tmp/real
and UNSLOTH_STUDIO_HOME=\$HOME/.unsloth/studio: setup.sh resolves
UNSLOTH_HOME to /tmp/real/.unsloth/studio while the backend search
resolves both physically equal and looks at /tmp/link/.unsloth/llama.cpp.

Canonicalize the legacy side at all four sites:
- install.sh:695 (create_studio_shortcuts llama.cpp path)
- studio/setup.sh:577 (UNSLOTH_HOME selection)
- install.ps1:462 (launcher UNSLOTH_LLAMA_CPP_PATH path)
- studio/setup.ps1:1829 (UnslothHome selection)

Apply CDPATH= cd -P -- ... && pwd -P (Unix) or Resolve-Path -LiteralPath
(Windows) when the legacy dir exists. unsloth_cli/commands/studio.py
already does this via Path.resolve().

* llama_cpp: gate _kill_orphaned_servers studio-root allowlist on env-override

Cycle 24 fixed _find_llama_server_binary to only search
\$STUDIO_HOME/llama.cpp when STUDIO_HOME is a real env override (not
the legacy default), but the symmetric _kill_orphaned_servers
allowlist still appended _sr() / "llama.cpp" unconditionally.

In default mode _sr() resolves to ~/.unsloth/studio, so
~/.unsloth/studio/llama.cpp would be treated as a Studio-owned install
root for the orphan-kill scan even though the default installer does
not own that path. A llama-server process running there from a
different tool or a stale partial install would be killed.

Apply the same legacy-equality check used in _find_llama_server_binary
and the install/setup scripts: only add _sr()/"llama.cpp" to the
allowlist when STUDIO_HOME != legacy default.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* setup.sh + setup.ps1: canonicalize both sides of legacy-equality check

Proactive audit pass found one real asymmetry the cycle-by-cycle
review process had not yet flagged:

- install.sh:704 / install.ps1:469 are gated on env-mode and only
  run when STUDIO_HOME has already been canonicalized (cycle 24).
  Symmetric.
- studio/setup.sh:577 / studio/setup.ps1:1829 run UNCONDITIONALLY,
  including in default mode. In default mode STUDIO_HOME is set to
  the bare logical \$HOME/.unsloth/studio (setup.sh:416) or
  Join-Path \$env:USERPROFILE ".unsloth\\studio" (setup.ps1:1480).
  Cycle 25 canonicalized only the legacy side, creating an
  asymmetry under symlinked \$HOME / junctioned %USERPROFILE%.

Result of the asymmetry: a default-mode install on a host with
\$HOME=/tmp/link -> /tmp/real treats the legacy default as a custom
root, putting llama.cpp at \$STUDIO_HOME/llama.cpp instead of
~/.unsloth/llama.cpp -- and the Python backend's _find_llama_server_binary
(which uses .resolve() on both sides) then can't find the install.

Fix: canonicalize STUDIO_HOME on the fly at the comparison site, in
both setup.sh and setup.ps1. Symmetric with the now-canonicalized
legacy side from cycle 25, regardless of which mode set STUDIO_HOME.

The other two comparison sites (install.sh:704, install.ps1:469) are
already symmetric because they only run when STUDIO_HOME comes from
the env-override resolution path that already does pwd -P / Resolve-Path.

unsloth_cli/commands/studio.py + studio/backend/run.py + main.py +
llama_cpp.py already use .resolve() on both sides -- symmetric.

* install.ps1: env-override resolution uses .NET API for literal paths

Gemini code-review (review 4177641398, commit 2ea2c91) caught two
remaining New-Item -Path sites in the env-override resolution block
that the cycle 18 sweep missed:

- Line 123: New-Item -ItemType Directory -Path \$envOverride
- Line 132: New-Item -ItemType File -Path \$probe (writability test)

Both use -Path which interprets square brackets as wildcards. For a
user with UNSLOTH_STUDIO_HOME=C:\\workspaces\\studio[abc], both calls
would fail before the install starts. New-Item also has no
-LiteralPath in PowerShell 5.1.

Replace both with the .NET API:
- [System.IO.Directory]::CreateDirectory(\$envOverride)
- [System.IO.File]::WriteAllText(\$probe, "") -- closes the file
  handle before the Remove-Item below.

End-to-end verified with /tmp/test-envoverride-[abc]-* path:
CreateDirectory + WriteAllText + Test-Path -LiteralPath all work.

* comments: condense multiline blocks added by this PR

Across the 27-cycle review process, comments accumulated as multiline
blocks explaining each fix's history (cycle numbers, prior bugs,
reviewer rationale). Compress every block to 1-2 lines that capture
just the WHY, dropping cycle references and history that belongs in
the PR description / commit log instead.

Net: 268 deletions / 124 insertions (-144 lines) of comments only.
Behavior unchanged. Verified: bash -n, pwsh parser, python ast.parse,
cargo check all pass.

* install.ps1: use 'return' over 'exit 1' for Install-UnslothStudio bail-outs

Per Gemini review #4177659001: when users run install.ps1 via
'irm ... | iex', 'exit 1' inside the function terminates the entire
PowerShell process and closes the user's terminal. 'return' bails out
of the function while keeping the shell open, matching existing error
sites at lines 34, 50, 57.

Three sites fixed: --tauri+env-override guard, env-override mkdir/access
failure, and write-probe failure. The 'exit' calls at lines 591/611
are inside a generated launcher here-string (a separate top-level .ps1
that runs as its own process), so they correctly stay as 'exit'.

* install.{sh,ps1}: address Gemini review #4177680451

Three medium fixes:

1. install.sh redirection detection: canonicalize both sides of the
   $HOME vs passwd-DB comparison via 'CDPATH= cd -P -- ... && pwd -P'
   so a trailing slash on $HOME (or symlink-vs-realpath mismatch with
   getent/dscl output) doesn't misfire the redirection branch.

2. install.sh shim symlink: 'ln -sf' into an existing directory creates
   the link INSIDE it ($_LOCAL_BIN/unsloth/unsloth instead of the
   intended file). Pre-strip a real (non-symlink) directory at
   $_LOCAL_BIN/unsloth before linking.

3. install.ps1 ShimExe: add -Recurse to Remove-Item so the launcher
   refresh recovers if $ShimExe somehow exists as a directory rather
   than a file (would otherwise drop into the catch and skip the
   shim update).

* install.ps1: use 'throw' over 'return' for fatal validation failures

Cycle 28 reviewer.py (12/8 RC/APPROVE) caught a regression introduced
by the previous Gemini-review fix (#4177659001 -> commit 393e676b).
'return' inside Install-UnslothStudio kept iex'd terminals alive but
made 'pwsh -File install.ps1' exit with code 0 on fatal validation
failures (--tauri+custom-root rejected, STUDIO_HOME unwritable, etc.),
so CI / wrapper scripts treated failed installs as successful.

'throw' satisfies both constraints:
- pwsh -File install.ps1: exits with code 1 (CI sees failure)
- irm | iex: shows error to user, does NOT close the host terminal

Three sites: --tauri+env-override guard, mkdir/access failure,
write-probe failure. Verified throw -> exit code 1 under pwsh -File.

* install.ps1 launcher: single-quote child -Command path

Cycle 28 P2 finding: the generated launch-studio.ps1 builds the child
PowerShell -Command string with the executable path inside double
quotes, so a custom Studio root containing PowerShell metacharacters
(\$, backtick) re-expands in the child shell. Example:
D:\work\\\$job\studio -> child reparses \$job and runs the wrong path.

Fix: single-quote the path inside the child command and double any
apostrophes (PowerShell's literal-quote-escape form) so paths like
"O'Brien Studio & x|y" or "C:\work\\\$bad\studio" survive verbatim.

* install: harden custom Studio root handling

- install.sh shim refresh: refuse to recursively delete a real directory
  at $_LOCAL_BIN/unsloth before creating the symlink. The previous rm -rf
  could destroy unrelated user data living at that path.
- install.ps1 shim refresh: drop -Recurse from Remove-Item on $ShimExe and
  refuse early when the shim path is a directory; mirrors the install.sh
  guard so a directory at $StudioHome\bin\unsloth.exe is not blown away.
- install.ps1 PATH wiring: remove the redundant first $ShimDir prepend in
  env-override mode; the post-Refresh-SessionPath prepend is the one that
  takes effect, and the duplicate left $ShimDir in $env:Path twice.
- install.ps1 manual launch instructions: single-quote the printed shim
  and Activate.ps1 paths so '$' / backtick metacharacters in custom roots
  do not reparse when the user copies and pastes the command.
- studio/setup.sh: validate writability of UNSLOTH_STUDIO_HOME with the
  same [ -w ] check install.sh already has, so a read-only override fails
  with a clear message instead of an obscure uv pip permission error.
- Drop the STUDIO_HOME alias everywhere (storage_roots.py, studio.py,
  install.sh, studio/setup.sh, install.ps1, studio/setup.ps1). The name
  is too generic and an ambient STUDIO_HOME from unrelated tooling could
  silently redirect the install. Only UNSLOTH_STUDIO_HOME is honored.
- unsloth_cli/commands/studio.py: defer UNSLOTH_STUDIO_HOME / UNSLOTH_LLAMA_CPP_PATH
  re-export from import time into a helper invoked by the studio app
  callback. Importing the module no longer mutates os.environ as a side
  effect, so test runners and CLI introspection stop leaking those vars
  into unrelated subprocesses.
- studio/backend/core/inference/llama_cpp.py: replace set-mutation inside
  list comprehension with an explicit dedup loop for readability.

* install: harden custom Studio root edge cases

- install.ps1 shim refresh: move the directory-collision preflight outside
  the lock-handling try/catch. The previous throw inside the try block was
  swallowed by the surrounding catch and downgraded to a "Continuing with
  the existing launcher" warning, leaving the install in a broken state
  with no usable shim on disk.
- storage_roots.py / unsloth_cli/commands/studio.py: tighten the bin-shim
  sentinel from .exists() to .is_file(). A directory at the candidate
  bin/unsloth (or bin/unsloth.exe) path would otherwise false-positive
  the venv inference and pick the wrong Studio root.
- storage_roots.py / unsloth_cli/commands/studio.py: wrap the env-var
  override Path(...).expanduser().resolve() in try/except (OSError, ValueError),
  matching the defensive pattern already used in studio/backend/main.py
  and studio/backend/run.py. An invalid override (unresolvable network
  drive, bad characters) now falls back to the un-resolved path instead
  of crashing at import time.

* install: fail fast on missing custom root, allow brackets in shim path

- install.ps1 shim hardlink: switch the New-Item -ItemType HardLink call
  from -Path to -LiteralPath so a custom Studio root containing bracket
  characters does not fail under PowerShell's wildcard-aware -Path
  parameter. Matches the -LiteralPath usage on every other Test-Path /
  Remove-Item / Copy-Item call against the same shim path.
- studio/setup.sh override branch: replace the silent mkdir -p of the
  override directory with an existence check that exits 1 with a clear
  message. setup.sh runs against an existing install (via 'unsloth
  studio update'), so a typo in UNSLOTH_STUDIO_HOME must not materialize
  an empty workspace dir. Brings the Unix flow in line with setup.ps1,
  which already errors on a missing override root.

* llama_cpp: scope orphan-server kill to the active install root

_kill_orphaned_servers used to unconditionally include the legacy
~/.unsloth/llama.cpp tree in install_roots, even when the running
Studio is in env-override mode and operates out of a custom root.
On a single OS user running both a default-install Studio and a
custom-root Studio concurrently, the custom Studio would kill the
default Studio's llama-server during startup orphan cleanup.

Hoist _is_custom_root out of the import try/catch so the legacy-
append decision sees it (default to False on ImportError so default
mode behaviour is unchanged), and gate the legacy ~/.unsloth/llama.cpp
append on `not _is_custom_root`.

* install: harden custom-root .venv migration and shim hardlink

- install.sh / install.ps1 OLD-layout .venv migration: gate on
  default-mode only. Without the guard, pointing UNSLOTH_STUDIO_HOME at a
  workspace that already has .venv (e.g. an unrelated Python project)
  caused the torch validation to fail and the installer to recursively
  remove the user's project venv. Mirrors the existing env-mode skip on
  the CWD-relative venv migration immediately below.
- install.ps1 shim hardlink: revert to New-Item -ItemType HardLink -Path.
  -LiteralPath is not accepted on the HardLink ItemType in any PowerShell
  version, so the previous form always threw and silently fell back to
  Copy-Item, breaking hardlink-update propagation. Bracket characters in
  $ShimExe are still defended by the directory-collision preflight added
  earlier.
- storage_roots.py / unsloth_cli/commands/studio.py: strip whitespace
  from the UNSLOTH_STUDIO_HOME env var before the truthy check so a
  blank "   " override does not become a real path with trailing spaces
  (which would silently break every downstream Studio path operation).

* Studio paths: tolerate stat / resolve failures during root inference

- storage_roots._infer_studio_home_from_venv: wrap the share/studio.conf
  and bin/shim is_file() sentinel checks in try/except OSError. A
  PermissionError on a restricted candidate dir would otherwise propagate
  out of studio_root() and crash module import in run.py / main.py /
  transformers_version.py / model_config.py at server startup.
- llama_cpp._kill_orphaned_servers: broaden the studio_root() guard from
  ImportError-only to (ImportError, OSError, ValueError) so transient
  resolve / sentinel failures do not crash the orphan-killer at server
  startup. Matches _find_llama_server_binary's existing pattern.
- llama_cpp._find_llama_server_binary: nest the inner resolve() in its
  own try/except and fall back to unresolved-path comparison instead of
  dropping the custom search root entirely. A transient resolve() error
  on the legacy path no longer loses the custom-root llama.cpp lookup.

* Add Studio install-root resilience tests

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: isolate custom-root installs from default-install state

- llama.cpp discovery in env-override mode no longer falls back to the
  legacy ~/.unsloth/llama.cpp tree. The orphan-cleanup path already
  excludes that root in custom mode; aligning discovery prevents a
  custom-root Studio from launching a sibling install's binary it then
  refuses to manage. Users who want a shared build set
  UNSLOTH_LLAMA_CPP_PATH explicitly.
- Generated POSIX launcher (install.sh heredoc) namespaces LOCK_DIR with
  a hash of DATA_DIR and persists the launched port to
  $DATA_DIR/studio.port; in env-override mode the fast-path attaches only
  to a port we ourselves wrote, never to a sibling Studio that happens
  to be healthy on 8888..8908.
- Generated Windows launcher (install.ps1 heredoc) bakes a per-install
  $portFile and SHA-256-suffixed mutex name, mirroring the POSIX side;
  Find-HealthyStudioPort uses the port file in env-override mode.
- studio/setup.sh and studio/setup.ps1 require an .unsloth-studio-owned
  marker before deleting $STUDIO_HOME/.venv_t5*, $STUDIO_HOME/llama.cpp,
  and the sidecar T5 venvs in env-override mode. The marker is dropped
  after fresh creation so subsequent runs of 'unsloth studio update'
  proceed cleanly. Mirrors the existing .venv guard in install.sh.
- Wrap bare Path.resolve() calls on the legacy STUDIO_HOME constant in
  studio/backend/main.py, studio/backend/run.py, and
  unsloth_cli/commands/studio.py in the same try/except (OSError,
  ValueError) used adjacently, so a restricted parent or recursive
  symlink on $HOME does not crash module import / CLI startup.

* Studio: guard env-mode workspace against destructive cleanup

- install.sh and install.ps1 unconditionally rm -rf / Remove-Item the
  new-layout $STUDIO_HOME/unsloth_studio when it has a python; in
  env-override mode that path is a user-chosen workspace, mirroring
  the .venv migration concern the .venv branch already guards. Refuse
  to remove an existing $STUDIO_HOME/unsloth_studio that lacks Studio
  sentinels (share/studio.conf or bin/unsloth).
- studio/setup.ps1 only checked Test-Path -PathType Container on the
  custom root; setup.sh and install.ps1 both also write-probe via
  WriteAllText / Remove-Item. Add the matching probe so 'unsloth
  studio update' against an ACL-restricted root fails fast with a
  clear message instead of erroring later while creating sidecar
  venvs.

* Add Studio install/setup workspace-isolation tests

* Studio: tighten installer rationale comments

- install.sh: collapse a 5-line restatement into 3 lines, naming
  env-mode behavior up front and the byte-identical pre-override
  fallback after.
- install.ps1: correct misleading hardlink comment that claimed the
  directory-collision preflight guards against wildcard expansion;
  bracket characters in $ShimExe still glob-expand here, with the
  Copy-Item -LiteralPath fallback handling them.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Split: keep only 2 file(s)

* Studio: harden env-mode workspace guards across installers and update path

Tightens the UNSLOTH_STUDIO_HOME custom-root protections so destructive
installer paths cannot displace unrelated user data when the override
points at a workspace.

install.sh / install.ps1: env-mode sentinel that gates rm -rf $VENV_DIR /
Remove-Item $VenvDir now requires share/studio.conf or the bin/unsloth(.exe)
shim to be a real file or symlink. Previously a directory at bin/unsloth or
bin\unsloth.exe satisfied the check (-e and bare Test-Path accept any path
type), so a workspace with unrelated content under unsloth_studio plus a
sibling directory at bin/unsloth could be wiped.

studio/setup.ps1: stale-venv rebuild branch now mirrors install.ps1's
env-mode guard before Remove-Item -LiteralPath $VenvDir -Recurse -Force.
Without this, "unsloth studio update" pointed at a custom workspace whose
unsloth_studio venv fails torch validation deletes the venv even when the
root carries no Studio sentinels.

studio/setup.sh / studio/setup.ps1: prebuilt llama.cpp install path now
calls _assert_studio_owned_or_absent / Assert-StudioOwnedOrAbsent before
invoking install_llama_prebuilt.py, and writes the .unsloth-studio-owned
marker on success. install_llama_prebuilt.py uses os.replace() to move
any existing install_dir aside before staging, so an unrelated
$STUDIO_HOME/llama.cpp could otherwise be displaced before the existing
source-build ownership guard ever ran.

* Studio: gate ownership guards on canonical custom-root and add venv marker

Tightens UNSLOTH_STUDIO_HOME ownership semantics so they fire only for a
genuinely custom root, never for an explicit override that resolves to the
legacy default. Adds an in-VENV marker that lets a partial install be
repaired and provides a strong primary sentinel for the deletion guard.

studio/setup.sh + studio/setup.ps1: hoist the canonical $STUDIO_HOME vs
legacy-default comparison so it sits next to the marker definition, derive
_STUDIO_HOME_IS_CUSTOM / $StudioHomeIsCustom once, and gate the
_assert_studio_owned_or_absent / Assert-StudioOwnedOrAbsent helpers and the
prebuilt llama.cpp marker writes on that flag instead of raw env-var
presence. UNSLOTH_STUDIO_HOME=$HOME/.unsloth/studio (legacy override) no
longer trips the guard for pre-PR T5 sidecar venvs or llama.cpp dirs that
predate the .unsloth-studio-owned marker. The duplicate canonical block
inside the llama.cpp section is removed; the new flag is reused.

studio/setup.ps1: Assert-StudioOwnedOrAbsent's marker check now requires
-PathType Leaf so a directory at .unsloth-studio-owned cannot satisfy it.
The in-place git-sync branch in the source-build path now calls
Mark-StudioOwned after a successful sync so a later prebuilt-update path
does not fail Assert-StudioOwnedOrAbsent on the same root.

install.sh + install.ps1: write $VENV_DIR/.unsloth-studio-owned right after
uv venv succeeds and accept it as the primary sentinel in the env-mode
deletion guard. This recovers from a partial install that was previously
unrepairable, and is a stronger sentinel than sibling shim files (the
marker is inside the venv that is about to be wiped, so an unrelated
workspace cannot accidentally satisfy it).

install.sh: drop the standalone -L test on $STUDIO_HOME/bin/unsloth in the
deletion guard. -L returns true for any symlink including symlinks to
directories and broken symlinks; -f already accepts the legitimate
file-targeted symlink shape created by ln -s at install.sh:1864.

* Studio: close residual workspace-isolation gaps for custom roots

Four follow-on hardenings that close the remaining cross-root leaks the
custom-root install plumbing still left open.

studio/setup.ps1 in-place git-sync: when the source-build path finds an
existing $LlamaCppDir/.git, it ran git remote set-url, checkout -B, and
clean -fdx in place before any ownership check. The previous fix marked
the tree as Studio-owned AFTER the sync but did not guard the BEFORE
case, so an unrelated workspace .git could be silently rewritten on the
first source-build under a custom UNSLOTH_STUDIO_HOME. Add the same
Assert-StudioOwnedOrAbsent guard already used by the prebuilt path and
the temp-dir swap path (gated on $StudioHomeIsCustom for parity).

Launcher port-file workspace isolation: the env-mode launchers' fast
path attached to any backend listening on the cached port that returned
a healthy /api/health, even when that backend belonged to a different
install root. studio/backend/main.py /api/health now returns the
resolved studio_root; install.sh _check_health and install.ps1
Test-StudioHealth verify it against UNSLOTH_STUDIO_HOME when set, so a
stale studio.port pointing at a sibling Studio is rejected instead of
opening the wrong UI.

studio/src-tauri preflight + commands: the Tauri desktop app stays on
the legacy root by design. process.rs / install.rs / desktop_auth.rs /
update.rs already strip UNSLOTH_STUDIO_HOME and STUDIO_HOME from their
CLI subprocesses, but preflight.rs run_cli_probe / probe_cli_capability
and commands.rs check_install_status did not, so a desktop launch from
a shell carrying those env vars produced status reflecting a different
root than the desktop manages. Mirror the existing scrub.

install.sh shim install: the previous `rm -f -- $_shim_path; ln -s ...`
pair leaves a window with no shim if interrupted. Use ln -sfn for an
atomic replace; the -n flag prevents descent into a symlink-to-directory
target (the existing directory guard above already rejects a real dir).

* Studio: replace launcher root verify with hex digest baked at install time

The previous launcher identity check returned the absolute resolved Studio
install root from /api/health and matched it against $UNSLOTH_STUDIO_HOME
in the launcher. Three problems that this commit closes:

- POSIX launcher used a raw bash `case` against the JSON-encoded value, so
  paths containing characters that JSON escapes (e.g. /tmp/back\slash,
  /tmp/O"Brien) caused the launcher to reject its own healthy backend.
- /api/health is unauthenticated and Studio supports `-H 0.0.0.0`, so any
  reachable client could read the absolute install path (username, home
  dir, workspace name, CI checkout path).
- The verification was gated on $UNSLOTH_STUDIO_HOME being set at runtime,
  so a default-mode launcher would attach to a sibling env-mode Studio
  listening on the same port instead of starting its own.

The fix replaces the raw path with a SHA-256 hex digest computed at install
time and baked into the generated launcher (mirroring how @@DATA_DIR@@ is
substituted today):

studio/backend/main.py: /api/health now returns `studio_root_id =
sha256(str(_studio_root()))` instead of the raw `studio_root` path.

install.sh: computes `_css_studio_root_id` once from $STUDIO_HOME using
python3, bakes `_EXPECTED_STUDIO_ROOT_ID='@@STUDIO_ROOT_ID@@'` into the
launcher heredoc, and adds `s|@@STUDIO_ROOT_ID@@|...|g` to the existing
sed pipeline for ALL modes (env / home / default). _check_health verifies
the baked id substring-matches the JSON response. Hex-only so no shell or
sed escape corner cases.

install.ps1: same shape on Windows. SHA256 the $StudioHome bytes, lower
hex, bake `$_ExpectedStudioRootId = '...'` into the launcher heredoc.
Test-StudioHealth now compares `$resp.studio_root_id -eq
$_ExpectedStudioRootId` unconditionally (no special-case for env-mode).

Default-mode launchers also bake their expected id, so two coexisting
Studio installs on the same machine can no longer cross-attach.

* Studio: harden launcher root-id and split install-time mode from runtime env

- install.sh launcher: compute studio_root_id with the venv Python (uv-managed
  systems may not have system python3) and canonicalize STUDIO_HOME with
  cd -P/pwd -P so default and home-redirect modes match the backend's
  Path(sys.prefix).resolve() canonicalization. Fail fast instead of silently
  baking an empty discriminator.
- install.sh launcher heredoc: gate PORT_FILE / namespaced LOCK_DIR on a baked
  install-time mode flag (@@INSTALLED_IS_ENV_MODE@@) instead of the runtime
  UNSLOTH_STUDIO_HOME variable so a sourced custom-root studio.conf cannot flip
  a default-mode launcher into env-mode behavior with stale state.
- studio/backend/main.py: cache the studio_root_id digest at module load so
  /api/health does not recompute hashlib + filesystem probes on every poll.
- studio/backend/core/inference/llama_cpp.py: widen the studio_root() probe
  except clause from ImportError to (ImportError, OSError, ValueError) so it
  matches the sibling _kill_orphaned_servers handler and tolerates Path.resolve
  failures from broken symlinks or odd codecs.

* Studio: align launcher root-id digest with backend canonicalization

- studio/backend/main.py: hash the already-resolved _STUDIO_ROOT_RESOLVED
  instead of recomputing str(_studio_root()); the default fallback in
  storage_roots returns Path.home()/.unsloth/studio without .resolve(), so
  on systems where $HOME is a symlink (NFS / AFS / Docker) the cached
  digest now matches install.sh's cd -P/pwd -P canonicalization and the
  launcher no longer rejects its own healthy backend.
- install.ps1: canonicalize $StudioHome via Resolve-Path before the SHA256
  compute (env-mode already resolves at line 121, only default and profile
  branches were raw); a junctioned USERPROFILE now produces the same digest
  the backend computes via Path.resolve() for the same install.
- install.sh launcher template: substitute the non-user-controlled
  @@STUDIO_ROOT_ID@@ and @@INSTALLED_IS_ENV_MODE@@ placeholders before the
  user-controlled @@DATA_DIR@@ pass so a $DATA_DIR that contains the
  literal placeholder text cannot be mutated by the second sed.

* Studio: tighten installer rationale comments

* Studio install: extend workspace-guard test coverage

Add behavioral coverage for env-mode workspace guards across install.sh,
install.ps1, studio/setup.sh, studio/setup.ps1, the launcher root-id
discriminator, and the backend's /api/health response. Also refresh the
custom-mode llama.cpp resilience assertion so it matches the implementation
that intentionally excludes the legacy tree from search_roots.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Honor STUDIO_HOME alias, fix workspace-guard test harness, harden rollback

The PR title and description promise STUDIO_HOME as a priority-2 alias
to UNSLOTH_STUDIO_HOME, but the implementation only read the longer name
in all six resolution sites. Wire the alias through install.sh,
install.ps1, studio/setup.sh, studio/setup.ps1, the Python storage_roots
resolver, and the unsloth_cli studio resolver. UNSLOTH_STUDIO_HOME wins
when both are set (more specific signal beats the generic alias).

Whitespace-only values are now treated as unset to match the Python
resolvers' .strip() semantics, preventing install/runtime layout drift
where the installer would create a literal " " directory while the
backend fell through to the legacy default.

Error messages and the substep status line report the env-var name the
user actually set ("UNSLOTH_STUDIO_HOME=..." vs "STUDIO_HOME=...") so
diagnostics stay accurate under either spelling.

Test harness fix: tests/test_studio_install_workspace_guard.py extracted
the install.sh venv-replacement block, but after the merge that block
delegates to _start_studio_venv_replacement (defined further up in
install.sh, not in the extracted snippet). Five sentinel-positive tests
echoed RESULT=ok but never moved $VENV_DIR. Add a single
_INSTALL_GUARD_STUBS constant that stands in a minimal mv-based stub
plus a no-op substep, and route every inline test script through a new
_build_install_guard_script() helper. All 50 tests now pass (was 45/50).

Rollback hardening: Start-StudioVenvRollback / Restore-StudioVenvRollback
/ Complete-StudioVenvRollback in install.ps1 used plain Test-Path,
Move-Item, Remove-Item against paths derived from $StudioHome. With a
custom UNSLOTH_STUDIO_HOME containing brackets (the very motivation for
the broader -LiteralPath sweep this PR set out to do), rollback would
silently misbehave under wildcard interpretation, turning a recoverable
install error into a destroyed env. Same fix for the --local Tauri
overlay block (Test-Path / Copy-Item / Get-FileHash on $VenvDir-derived
paths).

* Replace studio_root_id path-hash with per-install opaque id

The previous design computed studio_root_id as sha256 of the resolved
$STUDIO_HOME path, both at install time (baked into the launcher) and
at backend startup (returned via /api/health). This worked but had
three weaknesses:

1. Information disclosure on -H 0.0.0.0: anyone reaching /api/health
   could confirm a guessed install path (username, workspace name,
   etc.) by replaying the same hash.
2. Canonicalization brittleness: launcher (cd -P/pwd -P) and backend
   (Path.resolve()) had to produce identical strings, which required
   careful symlink/junction handling on every site (cycles 17-27 of
   the PR review history were entirely about closing this drift).
3. Stale-launcher attach: an uninstall + reinstall at the same path
   produced the same hash, so a launcher from the previous install
   would silently attach to the new (incompatible) backend.

Replace the path-hash with a per-install opaque id:

- install.sh and install.ps1 generate 32 bytes from the platform CSPRNG
  (/dev/urandom on POSIX with a python3 secrets fallback;
  RandomNumberGenerator.Create().GetBytes on Windows) and persist it to
  $STUDIO_HOME/share/studio_install_id with mode 0600. Atomic
  temp-file-rename so a crash mid-install can't leave a half-written id.
  The check 'if [ ! -s "$_css_id_file" ]' / Test-Path makes generation
  idempotent across re-runs (so re-running install.sh doesn't invalidate
  previously-baked launchers in the same install root).

- studio/backend/main.py replaces hashlib.sha256 with
  _read_studio_install_id(), which reads $STUDIO_HOME/share/studio_install_id
  once at module load. Validates the content against ^[0-9a-f]{64}$ so
  malformed/truncated/uppercase/wrong-length content returns "" and
  triggers the launcher's existing "no baked id, accept any healthy
  Unsloth backend" fallback path.

- /api/health field name (studio_root_id) and wire format (64 hex chars)
  preserved for compatibility with launchers already shipped via earlier
  PR iterations.

Tests:

- Drop test_install_sh_root_id_matches_backend_resolved_under_symlinked_home
  and test_install_ps1_canonicalizes_studio_home_before_root_id_hash --
  the entire reason these existed (cd -P/Resolve-Path/Path.resolve()
  digest agreement under symlinks/junctions) is moot when the id comes
  from a file rather than from the path.

- Drop test_main_py_studio_root_id_hashes_resolved_root_not_unresolved
  (no more hashing).

- Rewrite test_main_py_studio_root_id_caches_at_module_load to assert
  the file-read pattern; add test_main_py_read_studio_install_id_validates_hex_and_handles_missing
  to pin the exact rejection rules (empty / non-hex / wrong case /
  wrong length all -> "").

- Rewrite test_install_sh_create_shortcuts_uses_venv_python_first as
  test_install_sh_create_shortcuts_seeds_id_from_csprng_with_python_fallback
  with a behavioral subprocess check that re-invocation is idempotent.

- Rename test_check_health_handles_path_with_backslash_via_hash to
  test_check_health_handles_arbitrary_id_token (the JSON-escape concern
  it pinned is preserved -- ids are hex-only by construction -- but the
  test no longer derives the id from a path).

- Add test_install_sh_install_id_survives_symlinked_studio_home as a
  regression test pinning that the new design has zero canonicalization
  drift across symlinked parents.

- Update test_install_sh_bakes_studio_root_id_into_launcher and
  test_install_ps1_bakes_studio_root_id_into_launcher to assert the
  CSPRNG seed and the file location.

49/49 tests pass. Behavioral verification: install.sh-style generation
is idempotent across runs, three parallel installs at different roots
get distinct ids, reinstall at the same path produces a new id (so
stale launchers correctly fail to attach to the new backend), and
symlinked-\$HOME no longer causes launcher/backend disagreement.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Daniel Han <unslothai@gmail.com>
2026-05-05 23:17:40 -07:00

4332 lines
188 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""
llama-server inference backend for GGUF models.
Manages a llama-server subprocess and proxies chat completions
through its OpenAI-compatible /v1/chat/completions endpoint.
"""
import atexit
import contextlib
import json
import os
import re
import struct
import structlog
from loggers import get_logger
import shutil
import socket
import subprocess
import sys
import threading
import time
from pathlib import Path
from typing import Generator, List, Optional
from urllib.parse import urlparse
import httpx
from utils.native_path_leases import child_env_without_native_path_secret
from utils.subprocess_compat import (
windows_hidden_subprocess_kwargs as _windows_hidden_subprocess_kwargs,
)
logger = get_logger(__name__)
# ── Pre-compiled patterns for plan-without-action re-prompt ──
# Forward-looking intent signals that indicate the model is
# describing what it *will* do rather than giving a final answer.
_INTENT_SIGNAL = re.compile(
r"(?i)("
# Direct intent: "I'll ...", "I will ...", "Let me ...", "I am going to ..."
# Handles both straight and curly apostrophes.
# Excludes "I can", "I should", "I want to", "let's" which
# appear frequently in direct answers / explanations.
r"\b(i['\u2019](ll|m going to|m gonna)|i am (going to|gonna)|i will|i shall|let me|allow me)\b"
r"|"
# Step/plan framing: "First ...", "Step 1:", "Here's my plan"
r"\b(?:first\b|step \d+:?|here['\u2019]?s (?:my |the |a )?(?:plan|approach))"
r"|"
# "Now I" / "Next I" patterns
r"\b(?:now i|next i)\b"
r")"
)
_MAX_REPROMPTS = 3
# Without max_tokens, llama-server defaults to n_predict = n_ctx (up to
# 262144 for Qwen3.5), producing many-minute zombie decodes when cancel
# fails. t_max_predict_ms is a wall-clock backstop applied unconditionally,
# but the llama.cpp README notes it ONLY fires after a newline has been
# generated -- a model stuck in a long unbroken non-newline sequence is
# unbounded by it. So we still want a token cap as the front-line limiter.
#
# The cap is the model's effective context length when we know it,
# falling back to a generous floor when metadata is unavailable. 4096 was
# too low: Qwen3 / gpt-oss reasoning traces routinely exceed it, and any
# OpenAI-API caller that omits max_tokens (langchain, llama-index, raw
# curl) sees responses silently truncated mid-sentence.
_DEFAULT_MAX_TOKENS_FLOOR = 32768
_DEFAULT_T_MAX_PREDICT_MS = 600_000 # 10 min
_REPROMPT_MAX_CHARS = 2000
# ── Pre-compiled patterns for GGUF shard detection ───────────
_SHARD_FULL_RE = re.compile(r"^(.*)-(\d{5})-of-(\d{5})\.gguf$")
_SHARD_RE = re.compile(r"^(.*)-\d{5}-of-\d{5}\.gguf$")
# ── Sliding-window-pattern resolver ───────────────────────────
# Resolves the per-layer SWA mask when a GGUF reports a sliding window
# but no `sliding_window_pattern` field. Tier order in
# `_resolve_swa_pattern`: GGUF metadata, on-disk cache, bootstrap dict
# below, transformers introspection, HF Hub config.json, legacy 1/4
# fallback. Period N means layer i is SWA iff `(i + 1) % N != 0`,
# matching transformers. Skipped on purpose: phi3 (no key/val length
# in GGUF, window >= ctx anyway), qwen2 family (converter strips
# sliding_window when use_sliding_window=False), mistral v0.1/v0.2
# (all-SWA can't be expressed as a period).
_BOOTSTRAP_SWA_DEFAULTS: dict[str, int] = {
"gemma2": 2, # Gemma2Config.sliding_window_pattern
"gemma3": 6, # Gemma3TextConfig.sliding_window_pattern
"gemma3n": 5, # text_config.layer_types: SWA*4 + FULL
"gpt_oss": 2, # text_config.layer_types: alternating
"cohere2": 4, # Cohere2Config.sliding_window_pattern
}
# Process-wide cache backed by JSON on disk. Values are int period or
# list[bool] mask. Lazy-loaded.
_SWA_CACHE: Optional[dict] = None
_SWA_CACHE_LOCK = threading.Lock()
def _swa_cache_path() -> Path:
home = os.environ.get("UNSLOTH_STUDIO_HOME") or os.environ.get("STUDIO_HOME")
base = Path(home) if home else Path.home() / ".unsloth" / "studio"
return base / "swa_cache.json"
def _load_swa_cache() -> dict:
global _SWA_CACHE
with _SWA_CACHE_LOCK:
if _SWA_CACHE is not None:
return _SWA_CACHE
try:
with open(_swa_cache_path()) as f:
_SWA_CACHE = json.load(f)
if not isinstance(_SWA_CACHE, dict):
_SWA_CACHE = {}
except (FileNotFoundError, json.JSONDecodeError, OSError):
_SWA_CACHE = {}
return _SWA_CACHE
def _save_swa_cache(cache: dict) -> None:
try:
path = _swa_cache_path()
path.parent.mkdir(parents = True, exist_ok = True)
tmp = path.with_suffix(".json.tmp")
with open(tmp, "w") as f:
json.dump(cache, f, indent = 2, sort_keys = True)
tmp.replace(path)
except OSError:
pass
def _period_from_layer_types(layer_types: list) -> Optional[int]:
"""Smallest period N where `(i+1) % N != 0` matches the SWA mask,
or None if no fixed period fits."""
if not layer_types:
return None
is_swa = ["full" not in str(t).lower() for t in layer_types]
n = len(is_swa)
for N in range(1, n + 1):
if all(((i + 1) % N != 0) == is_swa[i] for i in range(n)):
return N
return None
def _fetch_swa_entry_from_hf(repo_id: str) -> Optional[object]:
try:
from huggingface_hub import hf_hub_download
cfg_path = hf_hub_download(repo_id, "config.json", repo_type = "model")
with open(cfg_path) as f:
cfg = json.load(f)
except Exception:
return None
src = cfg.get("text_config") if isinstance(cfg.get("text_config"), dict) else cfg
period = src.get("sliding_window_pattern")
if isinstance(period, int) and period > 0:
return period
lt = src.get("layer_types")
if isinstance(lt, list) and lt:
return _period_from_layer_types(lt) or [
"full" not in str(t).lower() for t in lt
]
return None
def _arch_aliases(arch: str) -> tuple:
# GGUF emits `falcon-h1`; HF model_type is `falcon_h1`. Normalise both ways.
seen = []
for a in (arch, arch.replace("-", "_"), arch.replace("_", "-")):
if a and a not in seen:
seen.append(a)
return tuple(seen)
def _swa_entry_from_config_obj(cfg) -> Optional[object]:
src = getattr(cfg, "text_config", None) or cfg
period = getattr(src, "sliding_window_pattern", None)
if isinstance(period, int) and period > 0:
return period
lt = getattr(src, "layer_types", None)
if isinstance(lt, list) and lt:
return _period_from_layer_types(lt) or [
"full" not in str(t).lower() for t in lt
]
return None
_SWA_PATTERN_SOURCE_RE = re.compile(
r"sliding_window_pattern\s*(?::\s*[\w\[\], ]*)?\s*=\s*(\d+)"
)
def _resolve_swa_entry_from_transformers(arch: str) -> Optional[object]:
"""Default-instantiate the matching Config; on failure, regex-parse
its source for `sliding_window_pattern = N`."""
try:
from transformers.models.auto.configuration_auto import (
CONFIG_MAPPING,
CONFIG_MAPPING_NAMES,
)
except Exception:
return None
cfg_class = None
for alias in _arch_aliases(arch):
if alias in CONFIG_MAPPING_NAMES:
try:
cfg_class = CONFIG_MAPPING[alias]
break
except Exception:
cfg_class = None
if cfg_class is None:
return None
try:
if (entry := _swa_entry_from_config_obj(cfg_class())) is not None:
return entry
except Exception:
pass
import inspect
candidates = [cfg_class]
text_cfg_class = getattr(cfg_class, "sub_configs", {}).get("text_config")
if text_cfg_class is not None:
candidates.append(text_cfg_class)
for cls in candidates:
try:
src = inspect.getsource(cls)
except (OSError, TypeError):
continue
if m := _SWA_PATTERN_SOURCE_RE.search(src):
period = int(m.group(1))
if period > 0:
return period
return None
def _resolve_swa_pattern(
arch: Optional[str],
n_layers: Optional[int],
source_repo_candidates: tuple = (),
*,
allow_network: Optional[bool] = None,
) -> Optional[list]:
if not arch or not n_layers:
return None
if allow_network is None:
allow_network = os.environ.get("UNSLOTH_STUDIO_OFFLINE", "0") not in (
"1",
"true",
"True",
"yes",
)
cache = _load_swa_cache()
def _entry_to_mask(entry):
if isinstance(entry, int) and entry > 0:
return [(i + 1) % entry != 0 for i in range(n_layers)]
if isinstance(entry, list) and entry:
return [bool(entry[i % len(entry)]) for i in range(n_layers)]
return None
def _persist(entry):
with _SWA_CACHE_LOCK:
cache[arch] = entry
_save_swa_cache(cache)
if (entry := cache.get(arch)) is not None:
if (mask := _entry_to_mask(entry)) is not None:
return mask
if (entry := _BOOTSTRAP_SWA_DEFAULTS.get(arch)) is not None:
return _entry_to_mask(entry)
entry = _resolve_swa_entry_from_transformers(arch)
if entry is not None:
_persist(entry)
return _entry_to_mask(entry)
# Tier 3: live HF fetch (with persistent caching of the result)
if allow_network:
for repo_id in source_repo_candidates:
if not repo_id:
continue
entry = _fetch_swa_entry_from_hf(repo_id)
if entry is not None:
_persist(entry)
return _entry_to_mask(entry)
return None
def _hf_repo_from_url(url: Optional[str]) -> Optional[str]:
"""Strip `https://huggingface.co/owner/name(/...)` to `owner/name`."""
if not url or "huggingface.co/" not in url:
return None
tail = url.split("huggingface.co/", 1)[1].rstrip("/")
parts = tail.split("/")
if len(parts) < 2:
return None
return f"{parts[0]}/{parts[1]}"
# Model size extraction — lazy import to avoid pulling in transformers
# at module level. See PR description for the full explanation.
def _extract_model_size_b(model_id: str):
from utils.models import extract_model_size_b
return extract_model_size_b(model_id)
# ── Pre-compiled patterns for tool XML stripping ─────────────
_TOOL_CLOSED_PATS = [
re.compile(r"<tool_call>.*?</tool_call>", re.DOTALL),
re.compile(r"<function=\w+>.*?</function>", re.DOTALL),
]
_TOOL_ALL_PATS = _TOOL_CLOSED_PATS + [
re.compile(r"<tool_call>.*$", re.DOTALL),
re.compile(r"<function=\w+>.*$", re.DOTALL),
]
# ── Pre-compiled patterns for tool-call XML parsing ──────────
_TC_JSON_START_RE = re.compile(r"<tool_call>\s*\{")
_TC_FUNC_START_RE = re.compile(r"<function=(\w+)>\s*")
_TC_END_TAG_RE = re.compile(r"</tool_call>")
_TC_FUNC_CLOSE_RE = re.compile(r"\s*</function>\s*$")
_TC_PARAM_START_RE = re.compile(r"<parameter=(\w+)>\s*")
_TC_PARAM_CLOSE_RE = re.compile(r"\s*</parameter>\s*$")
_TOOL_TEMPLATE_MARKERS = (
"{%- if tools %}",
"{%- if tools -%}",
"{% if tools %}",
"{% if tools -%}",
'"role" == "tool"',
"'role' == 'tool'",
'message.role == "tool"',
"message.role == 'tool'",
)
def detect_reasoning_flags(
chat_template: Optional[str],
model_identifier: Optional[str] = None,
*,
log_source: Optional[str] = None,
) -> dict:
"""Classify a chat template's reasoning and tool-calling capabilities.
Returns a dict with the same five keys populated by the GGUF sniffer:
``supports_reasoning``, ``reasoning_style``
(``"enable_thinking"`` | ``"reasoning_effort"``),
``reasoning_always_on``, ``supports_preserve_thinking``, and
``supports_tools``. Used by both the llama-server backend at load
time and the safetensors/transformers paths in ``routes/inference``
so the two agree on what the frontend will see.
"""
flags = {
"supports_reasoning": False,
"reasoning_style": "enable_thinking",
"reasoning_always_on": False,
"supports_preserve_thinking": False,
"supports_tools": False,
}
if not chat_template:
return flags
tpl = chat_template
prefix = f"{log_source}: " if log_source else ""
if "enable_thinking" in tpl:
flags["supports_reasoning"] = True
flags["reasoning_style"] = "enable_thinking"
logger.info(f"{prefix}model supports reasoning (enable_thinking)")
elif "reasoning_effort" in tpl:
# gpt-oss / Harmony templates use reasoning_effort
# ("low" | "medium" | "high") instead of a boolean.
flags["supports_reasoning"] = True
flags["reasoning_style"] = "reasoning_effort"
logger.info(f"{prefix}model supports reasoning (reasoning_effort)")
elif "thinking" in tpl:
# DeepSeek uses 'thinking' instead of 'enable_thinking'
normalized_id = (model_identifier or "").lower()
if "deepseek" in normalized_id:
flags["supports_reasoning"] = True
logger.info(f"{prefix}model supports reasoning (DeepSeek thinking)")
# Hardcoded <think> tags or reasoning_content in the template mean
# thinking is always on (no toggle to disable it).
if not flags["supports_reasoning"]:
if ("<think>" in tpl and "</think>" in tpl) or "reasoning_content" in tpl:
flags["supports_reasoning"] = True
flags["reasoning_always_on"] = True
logger.info(f"{prefix}model always reasons (<think> tags in template)")
# preserve_thinking is an independent kwarg on some Qwen templates
# that keeps historical <think> blocks in prior assistant turns.
if "preserve_thinking" in tpl:
flags["supports_preserve_thinking"] = True
logger.info(f"{prefix}model supports preserve_thinking")
if any(marker in tpl for marker in _TOOL_TEMPLATE_MARKERS):
flags["supports_tools"] = True
logger.info(f"{prefix}model supports tool calling")
return flags
class LlamaCppBackend:
"""
Manages a llama-server subprocess for GGUF model inference.
Lifecycle:
1. load_model() — starts llama-server with the GGUF file
2. generate_chat_completion() — proxies to /v1/chat/completions, streams back
3. unload_model() — terminates llama-server subprocess
"""
def __init__(self):
self._process: Optional[subprocess.Popen] = None
self._port: Optional[int] = None
self._model_identifier: Optional[str] = None
self._gguf_path: Optional[str] = None
self._hf_repo: Optional[str] = None
self._hf_variant: Optional[str] = None
self._is_vision: bool = False
self._healthy = False
self._context_length: Optional[int] = None
self._effective_context_length: Optional[int] = None
self._max_context_length: Optional[int] = None
self._chat_template: Optional[str] = None
self._supports_reasoning: bool = False
self._reasoning_always_on: bool = False
self._reasoning_style: str = "enable_thinking"
self._supports_preserve_thinking: bool = False
self._supports_tools: bool = False
self._cache_type_kv: Optional[str] = None
self._reasoning_default: bool = True
self._speculative_type: Optional[str] = None
# KV-cache estimation fields (populated by _read_gguf_metadata)
self._n_layers: Optional[int] = None
self._n_kv_heads: Optional[int] = None
self._n_kv_heads_by_layer: Optional[list[int]] = None
self._n_heads: Optional[int] = None
self._embedding_length: Optional[int] = None
# Architecture-aware KV fields for 5-path estimation
self._kv_key_length: Optional[int] = None
self._kv_value_length: Optional[int] = None
self._sliding_window: Optional[int] = None
self._sliding_window_pattern: Optional[list[bool]] = None
self._full_attention_interval: Optional[int] = None
self._kv_lora_rank: Optional[int] = None
self._key_length_mla: Optional[int] = None
self._kv_key_length_swa: Optional[int] = None
self._kv_value_length_swa: Optional[int] = None
self._ssm_inner_size: Optional[int] = None
self._ssm_state_size: Optional[int] = None
# Last N layers reuse KV from earlier layers and don't allocate
# their own cache (Gemma 3n / Gemma 4: <arch>.attention.shared_kv_layers).
self._shared_kv_layers: Optional[int] = None
self._lock = threading.Lock()
self._stdout_lines: list[str] = []
self._stdout_thread: Optional[threading.Thread] = None
self._cancel_event = threading.Event()
self._api_key: Optional[str] = None
self._kill_orphaned_servers()
atexit.register(self._cleanup)
# ── Properties ────────────────────────────────────────────────
@property
def is_loaded(self) -> bool:
return self._process is not None and self._healthy
@property
def is_active(self) -> bool:
"""True if a llama-server process exists (loading or loaded)."""
return self._process is not None
@property
def base_url(self) -> str:
return f"http://127.0.0.1:{self._port}"
@property
def model_identifier(self) -> Optional[str]:
return self._model_identifier
@property
def is_vision(self) -> bool:
return self._is_vision
@property
def hf_variant(self) -> Optional[str]:
return self._hf_variant
@property
def context_length(self) -> Optional[int]:
"""Return the effective context length the server is running at."""
return self._effective_context_length or self._context_length
@property
def max_context_length(self) -> Optional[int]:
"""Return the largest context that fits on this hardware at load time.
This is the "safe zone" threshold the UI renders warnings
against. For a model whose weights fit on some GPU subset, it
is the binary-search cap from ``_fit_context_to_vram`` for that
subset. For a model whose weights exceed 90% of every GPU
subset, it is the 4096 fallback -- the spec's default when the
model will not fit. The UI slider ceiling is
``native_context_length``; dragging above ``max_context_length``
triggers the "might be slower" warning.
"""
return self._max_context_length or self._context_length
@property
def native_context_length(self) -> Optional[int]:
"""Return the model's native context length from GGUF metadata."""
return self._context_length
def load_progress(self) -> Optional[dict]:
"""Return live model-load progress, or None if not loading.
While llama-server is warming up, its process is typically in
kernel state D (disk sleep) mmap'ing the weight shards into
page cache before pushing layers to VRAM. During that window
``/api/inference/status`` only reports ``loading``, which gives
the UI nothing to display besides a spinner that looks stuck
for minutes on large MoE models.
This method samples ``/proc/<pid>/status VmRSS`` against the
sum of the GGUF shard sizes so the UI can render a real bar
and compute rate / ETA. Returns ``None`` when no load is in
flight (no process, or process already healthy).
Shape::
{
"phase": "mmap" | "ready",
"bytes_loaded": int, # VmRSS of the llama-server
"bytes_total": int, # sum of shard file sizes
"fraction": float, # bytes_loaded / bytes_total, 0..1
}
Linux-only in the current implementation. On macOS/Windows the
equivalent would be a different API; this returns ``None`` on
platforms where ``/proc/<pid>/status`` is unavailable.
"""
proc = self._process
if proc is None:
return None
pid = proc.pid
if pid is None:
return None
# Sum up shard sizes (primary + any extras sitting alongside).
bytes_total = 0
gguf_path = self._gguf_path
if gguf_path:
primary = Path(gguf_path)
try:
if primary.is_file():
bytes_total += primary.stat().st_size
except OSError:
pass
# Extra shards live alongside the primary with the same prefix
# before the shard index (e.g. ``-00001-of-00004.gguf``).
try:
parent = primary.parent
stem = primary.name
m = _SHARD_RE.match(stem)
prefix = m.group(1) if m else None
if prefix and parent.is_dir():
for sibling in parent.iterdir():
if (
sibling.is_file()
and sibling.name.startswith(prefix)
and sibling.name != stem
and sibling.suffix == ".gguf"
):
try:
bytes_total += sibling.stat().st_size
except OSError:
pass
except OSError:
pass
# Read VmRSS from /proc/<pid>/status. Kilobytes on Linux.
bytes_loaded = 0
try:
with open(f"/proc/{pid}/status", "r", encoding = "utf-8") as f:
for line in f:
if line.startswith("VmRSS:"):
kb = int(line.split()[1])
bytes_loaded = kb * 1024
break
except (FileNotFoundError, PermissionError, ValueError, OSError):
return None
phase = "ready" if self._healthy else "mmap"
fraction = 0.0
if bytes_total > 0:
fraction = min(1.0, bytes_loaded / bytes_total)
return {
"phase": phase,
"bytes_loaded": bytes_loaded,
"bytes_total": bytes_total,
"fraction": round(fraction, 4),
}
@property
def chat_template(self) -> Optional[str]:
return self._chat_template
@property
def supports_reasoning(self) -> bool:
return self._supports_reasoning
@property
def reasoning_always_on(self) -> bool:
return self._reasoning_always_on
@property
def reasoning_style(self) -> str:
return self._reasoning_style
@property
def supports_preserve_thinking(self) -> bool:
return self._supports_preserve_thinking
@property
def reasoning_default(self) -> bool:
return self._reasoning_default
def _reasoning_kwargs(self, enable_thinking: bool) -> dict:
if self._reasoning_style == "reasoning_effort":
return {"reasoning_effort": "high" if enable_thinking else "low"}
return {"enable_thinking": enable_thinking}
def _request_reasoning_kwargs(
self,
enable_thinking: Optional[bool],
reasoning_effort: Optional[str] = None,
preserve_thinking: Optional[bool] = None,
) -> Optional[dict]:
"""Build chat_template_kwargs from per-request reasoning fields.
Produces a merged dict covering the active model's reasoning style
(``enable_thinking`` or ``reasoning_effort``) plus the independent
``preserve_thinking`` kwarg when the template supports it.
"""
kwargs: dict = {}
# Always-on reasoning models hardcode <think> tags in their template
# and do not consume enable_thinking / reasoning_effort -- skip.
if self._supports_reasoning and not self._reasoning_always_on:
if self._reasoning_style == "reasoning_effort":
if reasoning_effort in ("low", "medium", "high"):
kwargs["reasoning_effort"] = reasoning_effort
elif enable_thinking is not None:
kwargs["reasoning_effort"] = "high" if enable_thinking else "low"
else:
if enable_thinking is not None:
kwargs["enable_thinking"] = enable_thinking
if self._supports_preserve_thinking and preserve_thinking is not None:
kwargs["preserve_thinking"] = preserve_thinking
return kwargs or None
@property
def supports_tools(self) -> bool:
return self._supports_tools
@property
def cache_type_kv(self) -> Optional[str]:
return self._cache_type_kv
@property
def speculative_type(self) -> Optional[str]:
return self._speculative_type
# ── Binary discovery ──────────────────────────────────────────
@staticmethod
def _find_llama_server_binary() -> Optional[str]:
"""
Locate the llama-server binary.
Search order:
1. LLAMA_SERVER_PATH environment variable (direct path to binary)
1b. UNSLOTH_LLAMA_CPP_PATH env var (custom llama.cpp install dir)
2. ~/.unsloth/llama.cpp/llama-server (make build, root dir)
3. ~/.unsloth/llama.cpp/build/bin/llama-server (cmake build, Linux)
4. ~/.unsloth/llama.cpp/build/bin/Release/llama-server.exe (cmake build, Windows)
5. ./llama.cpp/llama-server (legacy: make build, root dir)
6. ./llama.cpp/build/bin/llama-server (legacy: cmake in-tree build)
7. llama-server on PATH (system install)
8. ./bin/llama-server (legacy: extracted binary)
"""
import os
import sys
binary_name = "llama-server.exe" if sys.platform == "win32" else "llama-server"
# 1. Env var — direct path to binary
env_path = os.environ.get("LLAMA_SERVER_PATH")
if env_path and Path(env_path).is_file():
return env_path
# 1b. UNSLOTH_LLAMA_CPP_PATH — custom llama.cpp install directory
custom_llama_cpp = os.environ.get("UNSLOTH_LLAMA_CPP_PATH")
if custom_llama_cpp:
custom_dir = Path(custom_llama_cpp)
# Root dir (make builds)
root_bin = custom_dir / binary_name
if root_bin.is_file():
return str(root_bin)
# build/bin/ (cmake builds on Linux)
cmake_bin = custom_dir / "build" / "bin" / binary_name
if cmake_bin.is_file():
return str(cmake_bin)
# build/bin/Release/ (cmake builds on Windows)
if sys.platform == "win32":
win_bin = custom_dir / "build" / "bin" / "Release" / binary_name
if win_bin.is_file():
return str(win_bin)
# 2-4. Match installer layout: env-mode -> $STUDIO_HOME/llama.cpp;
# default/HOME-redirect -> ~/.unsloth/llama.cpp (sibling of studio).
legacy_llama = Path.home() / ".unsloth" / "llama.cpp"
try:
from utils.paths.storage_roots import studio_root as _sr # noqa: WPS433
_resolved_sr = _sr()
_legacy_studio = Path.home() / ".unsloth" / "studio"
try:
_is_legacy = _resolved_sr.resolve() == _legacy_studio.resolve()
except (OSError, ValueError):
_is_legacy = _resolved_sr == _legacy_studio
if _is_legacy:
search_roots = [legacy_llama]
else:
# why: _kill_orphaned_servers excludes the legacy root in custom
# mode; discovery must match so we never spawn a server we then
# refuse to clean up. UNSLOTH_LLAMA_CPP_PATH (handled earlier)
# is the explicit way to share a build across roots.
search_roots = [_resolved_sr / "llama.cpp"]
except (ImportError, OSError, ValueError):
search_roots = [legacy_llama]
_seen_roots: set[str] = set()
_unique_roots: list[Path] = []
for r in search_roots:
k = str(r)
if k not in _seen_roots:
_seen_roots.add(k)
_unique_roots.append(r)
for unsloth_home in _unique_roots:
home_root = unsloth_home / binary_name
if home_root.is_file():
return str(home_root)
home_linux = unsloth_home / "build" / "bin" / binary_name
if home_linux.is_file():
return str(home_linux)
if sys.platform == "win32":
home_win = unsloth_home / "build" / "bin" / "Release" / binary_name
if home_win.is_file():
return str(home_win)
# 56. Legacy: in-tree build (older setup.sh / setup.ps1 versions)
project_root = Path(__file__).resolve().parents[4]
# Root dir (make builds)
root_path = project_root / "llama.cpp" / binary_name
if root_path.is_file():
return str(root_path)
# build/bin/ (cmake builds)
build_path = project_root / "llama.cpp" / "build" / "bin" / binary_name
if build_path.is_file():
return str(build_path)
if sys.platform == "win32":
win_path = (
project_root / "llama.cpp" / "build" / "bin" / "Release" / binary_name
)
if win_path.is_file():
return str(win_path)
# 7. System PATH
system_path = shutil.which("llama-server")
if system_path:
return system_path
# 8. Legacy: extracted to bin/
bin_path = project_root / "bin" / binary_name
if bin_path.is_file():
return str(bin_path)
return None
# ── GPU allocation ────────────────────────────────────────────
@staticmethod
def _get_gguf_size_bytes(model_path: str) -> int:
"""Get total GGUF size in bytes, including split shards."""
main = Path(model_path)
total = main.stat().st_size
# Check for split shards (e.g., model-00001-of-00003.gguf)
m = _SHARD_FULL_RE.match(main.name)
if m:
prefix, _, num_total = m.group(1), m.group(2), m.group(3)
sibling_pat = re.compile(
r"^"
+ re.escape(prefix)
+ r"-\d{5}-of-"
+ re.escape(num_total)
+ r"\.gguf$"
)
for sibling in main.parent.iterdir():
if sibling != main and sibling_pat.match(sibling.name):
total += sibling.stat().st_size
return total
@staticmethod
def _get_gpu_free_memory() -> list[tuple[int, int]]:
"""Query free memory per GPU.
Order:
1. ``nvidia-smi`` (NVIDIA CUDA hosts) -- respects
``CUDA_VISIBLE_DEVICES``.
2. ``torch.cuda.mem_get_info`` -- universal fallback that
works on AMD ROCm too because the HIP runtime
reuses the entire ``torch.cuda.*`` namespace. Covers the
AMD case for issue #5106 (nvidia-smi-only probe silently
returned [] on AMD hosts) and also rescues NVIDIA hosts
where ``nvidia-smi`` is missing from PATH.
Returns list of (gpu_index, free_mib) sorted by index. Empty
list if no supported GPU is reachable.
"""
import os
# ── NVIDIA via nvidia-smi ────────────────────────────────────
try:
result = subprocess.run(
[
"nvidia-smi",
"--query-gpu=index,memory.free",
"--format=csv,noheader,nounits",
],
capture_output = True,
text = True,
timeout = 10,
env = child_env_without_native_path_secret(),
**_windows_hidden_subprocess_kwargs(),
)
if result.returncode == 0:
allowed: Optional[set[int]] = None
cvd = os.environ.get("CUDA_VISIBLE_DEVICES")
if cvd is not None:
try:
# `if x.strip()` filters trailing-comma masks like
# "0,1," which would otherwise raise ValueError on
# an empty token. An explicitly empty mask (CVD="")
# yields an empty `allowed` set so all GPUs are
# filtered out, matching the codebase convention.
allowed = set(
int(x.strip()) for x in cvd.split(",") if x.strip()
)
except ValueError:
pass
gpus: list[tuple[int, int]] = []
for line in result.stdout.strip().splitlines():
parts = line.split(",")
if len(parts) == 2:
idx = int(parts[0].strip())
free_mib = int(parts[1].strip())
if allowed is not None and idx not in allowed:
continue
gpus.append((idx, free_mib))
# Match the docstring's sort-by-id guarantee. nvidia-smi
# almost always returns sorted output, but driver order
# is not formally guaranteed.
gpus.sort(key = lambda g: g[0])
if gpus:
return gpus
except Exception as e:
logger.debug(f"nvidia-smi probe failed: {e}")
# ── Torch fallback (covers AMD ROCm and missing nvidia-smi) ──
try:
import torch
if not hasattr(torch, "cuda") or not torch.cuda.is_available():
return []
if not hasattr(torch.cuda, "mem_get_info"):
return []
# torch.cuda enumerates GPUs RELATIVE to the visibility mask.
# On NVIDIA builds the mask is CUDA_VISIBLE_DEVICES; on AMD
# ROCm builds it is HIP_VISIBLE_DEVICES (or ROCR_VISIBLE_DEVICES
# if HIP is unset). Downstream we feed these IDs back into the
# llama-server subprocess as CVD, so we must translate visible
# ordinals back to physical indices first; otherwise launching
# with ``CUDA_VISIBLE_DEVICES=2,3`` would get rewritten to
# ``CUDA_VISIBLE_DEVICES=0,1`` and target the wrong GPUs.
physical_ids: Optional[list[int]] = None
# Match the codebase convention in
# ``utils/hardware/hardware.py::_get_parent_visible_gpu_spec``:
# treat an explicitly empty mask (``HIP_VISIBLE_DEVICES=""``)
# as "set to no GPUs" rather than falling through to the next
# var. ``or`` would coerce empty string to falsy and silently
# promote the wrong source.
if getattr(torch.version, "hip", None) is not None:
hip_v = os.environ.get("HIP_VISIBLE_DEVICES")
rocr_v = os.environ.get("ROCR_VISIBLE_DEVICES")
cvd = (
hip_v
if hip_v is not None
else rocr_v
if rocr_v is not None
else os.environ.get("CUDA_VISIBLE_DEVICES")
)
else:
cvd = os.environ.get("CUDA_VISIBLE_DEVICES")
if cvd is not None:
try:
# Empty mask (CVD="") yields an empty list so the
# below loop produces no GPUs, consistent with the
# nvidia-smi path and utils/hardware/hardware.py.
physical_ids = [int(x.strip()) for x in cvd.split(",") if x.strip()]
except ValueError:
physical_ids = None
gpus = []
for ordinal in range(torch.cuda.device_count()):
free_bytes, _total_bytes = torch.cuda.mem_get_info(ordinal)
idx = (
physical_ids[ordinal]
if physical_ids is not None and ordinal < len(physical_ids)
else ordinal
)
gpus.append((idx, free_bytes // (1024 * 1024)))
# Match the nvidia-smi path's docstring guarantee of sorted-by-id.
return sorted(gpus, key = lambda g: g[0])
except Exception as e:
logger.debug(f"torch GPU probe failed: {e}")
return []
@staticmethod
def _select_gpus(
model_size_bytes: int,
gpus: list[tuple[int, int]],
) -> tuple[Optional[list[int]], bool]:
"""Pick GPU(s) for a model based on estimated VRAM and free memory.
``model_size_bytes`` should include both model weights and estimated
KV cache. The 90% threshold provides headroom for compute buffers,
CUDA context, and other runtime overhead.
Returns (gpu_indices, use_fit):
- ([1], False) model fits on 1 GPU at 90% of free
- ([1, 2], False) model needs 2 GPUs
- (None, True) model too large, let --fit handle it
"""
if not gpus:
return None, True
model_size_mib = model_size_bytes / (1024 * 1024)
# Sort GPUs by free memory descending
ranked = sorted(gpus, key = lambda g: g[1], reverse = True)
# Try fitting on 1 GPU (90% of free memory threshold)
if ranked[0][1] * 0.90 >= model_size_mib:
return [ranked[0][0]], False
# Try fitting on N GPUs (accumulate free memory from most-free)
cumulative = 0
selected = []
for idx, free_mib in ranked:
selected.append(idx)
cumulative += free_mib * 0.90
if cumulative >= model_size_mib:
return sorted(selected), False
# Model is too large even for all GPUs, let --fit handle it
logger.debug(
"Model does not fit in available GPU memory, falling back to --fit",
model_size_mib = round(model_size_mib, 2),
ranked_gpus = ranked,
)
return None, True
# ── KV cache VRAM estimation ─────────────────────────────────────
def _can_estimate_kv(self) -> bool:
"""True if we have enough GGUF metadata to estimate KV cache size."""
if self._n_layers is None:
return False
# MLA: kv_lora_rank is sufficient (K-only cache)
if self._kv_lora_rank is not None:
return True
# New-style: need both explicit key AND value dimensions
if self._kv_key_length is not None and self._kv_value_length is not None:
return True
# Legacy: need embedding_length + a head count (scalar or per-layer).
return self._embedding_length is not None and (
self._n_kv_heads is not None
or self._n_heads is not None
or self._n_kv_heads_by_layer is not None
)
def _kv_heads_for_layer(self, layer_idx: int, fallback: int) -> int:
if self._n_kv_heads_by_layer is not None and layer_idx < len(
self._n_kv_heads_by_layer
):
return self._n_kv_heads_by_layer[layer_idx]
return fallback
def _estimate_kv_cache_bytes(
self,
n_ctx: int,
cache_type_kv: Optional[str] = None,
*,
swa_full: bool = False,
n_parallel: int = 1,
kv_unified: bool = True,
ctx_checkpoints: int = 0,
) -> int:
"""Estimate KV cache VRAM for a given context length.
Uses 5-path architecture-aware estimation:
1. MLA -- compressed KV latent + RoPE, K-only (no separate V)
2. Hybrid -- only attention layers need KV (Mamba layers don't)
3. SWA -- sliding-window layers cache min(ctx, window) tokens
4. GQA -- standard full KV with explicit key/value dimensions
5. Legacy -- fallback using embed // n_heads
Server-flag knobs (mirror llama-server's CLI):
swa_full -- ``--swa-full``: force SWA layers to cache the
full ``n_ctx`` (collapses path 3 to path 4
sizing for the SWA layers).
n_parallel -- ``--parallel``: number of server slots.
Verified empirically against llama-server:
non-SWA layers stay constant (cells split
across slots), SWA layers scale linearly
(per-slot window).
kv_unified -- ``--kv-unified`` (default on): retained for
API forward-compat. Currently a no-op for
memory math because the unified buffer total
matches per-slot buffers in measured cases.
ctx_checkpoints -- ``--ctx-checkpoints``: SWA snapshot count per
slot (PR #15293). Each snapshot stores one
sliding-window of state per SWA layer.
Returns 0 if metadata is insufficient for estimation.
"""
if not self._can_estimate_kv() or n_ctx <= 0:
return 0
n_layers = self._n_layers # type: ignore[assignment]
# Gemma 3n / Gemma 4 reuse KV from earlier layers in the last
# ``shared_kv_layers`` blocks -- those don't allocate their own
# cache. Floor at 1 so a misconfigured GGUF can't zero out KV.
shared = self._shared_kv_layers or 0
n_layers_kv = max(1, n_layers - shared)
n_kv = self._n_kv_heads or self._n_heads or 1 # type: ignore[assignment]
# Bytes per element depends on KV cache quantization
bpe = {
"f32": 4.0,
"f16": 2.0,
"bf16": 2.0,
"q8_0": 34 / 32,
"q5_1": 0.75,
"q5_0": 0.6875,
"q4_1": 0.625,
"q4_0": 0.5625,
"iq4_nl": 0.5625,
}.get(cache_type_kv or "f16", 2.0)
slots = max(1, n_parallel)
# Path 1: MLA (DeepSeek-V2/V3, GLM-4.7, GLM-5, Kimi-K2.5)
# MLA stores one compressed KV latent per token/layer (shared across heads).
# V is reconstructed from the latent on the fly -- no separate V cache.
# key_length = kv_lora_rank + rope_dim (the full compressed representation).
# MLA GGUFs set head_count_kv=1; default to 1 if absent to avoid
# falling back to n_heads (e.g., 128 for DeepSeek-V3) which would 128x.
if self._kv_lora_rank is not None:
n_kv_mla = self._n_kv_heads or 1
rope_dim = self._key_length_mla or 64
key_len = self._kv_key_length or (self._kv_lora_rank + rope_dim)
return int(n_layers_kv * n_ctx * n_kv_mla * key_len * bpe)
key_len = self._kv_key_length
val_len = self._kv_value_length
# Path 2: Hybrid Mamba/Attention (Qwen3.5-27B, Qwen3.5-35B-A3B)
# Only 1 in N layers is attention; the rest are Mamba (no KV cache).
if (
self._ssm_inner_size is not None
and self._full_attention_interval is not None
):
fai = self._full_attention_interval
n_attn = -(-n_layers // fai) if fai > 0 else n_layers # ceiling division
if key_len is not None and val_len is not None:
return int(n_attn * n_ctx * n_kv * (key_len + val_len) * bpe)
head_dim = self._embedding_length // self._n_heads if self._n_heads else 128 # type: ignore[operator]
return int(n_attn * n_ctx * n_kv * 2 * head_dim * bpe)
# Path 3: Sliding window (Gemma 2/3/3n/4, gpt-oss, Cohere2 ...).
# Pattern is filled in by the resolver at parse time; if absent,
# falls through to the legacy 1/4-global heuristic below.
# Per-layer-type ``--parallel N`` accounting (verified empirically
# against ``llama-server``):
# * non-SWA layers: total cells = n_ctx, partitioned across
# slots -> total memory CONSTANT in slots.
# * SWA layers: per-slot cells = 2 * sliding_window
# (capped at n_ctx and at per_slot_ctx
# when ctx is split among many slots) ->
# total memory grows LINEARLY in slots.
# ``--swa-full`` forces full n_ctx for SWA layers instead.
# ``--ctx-checkpoints N`` adds N snapshots per SWA layer per slot.
if (
self._sliding_window is not None
and self._sliding_window > 0
and key_len is not None
and val_len is not None
):
swa = self._sliding_window
per_slot_ctx = max(1, n_ctx // slots)
# ``--swa-full`` makes SWA layers cache the full context just
# like non-SWA: cells get partitioned across slots, so per-slot
# cells = per_slot_ctx and the slots*per-slot product collapses
# back to the constant ``n_ctx`` total. Otherwise SWA caches
# 2*sliding_window per slot, clamped at the per-slot ctx.
swa_cells_per_slot = (
per_slot_ctx if swa_full else min(n_ctx, 2 * swa, per_slot_ctx)
)
key_len_swa = self._kv_key_length_swa or key_len
val_len_swa = self._kv_value_length_swa or val_len
if self._sliding_window_pattern is not None:
global_bytes = 0.0 # constant across slots
swa_bytes_per_slot = 0.0 # multiplied by slots
checkpoint_extra_per_slot = 0.0
# Iterate only over layers that allocate their own KV;
# the trailing ``shared`` layers reuse earlier caches.
for layer_idx in range(n_layers_kv):
layer_n_kv = self._kv_heads_for_layer(layer_idx, n_kv)
is_swa = (
layer_idx < len(self._sliding_window_pattern)
and self._sliding_window_pattern[layer_idx]
)
if is_swa:
swa_bytes_per_slot += (
swa_cells_per_slot
* layer_n_kv
* (key_len_swa + val_len_swa)
* bpe
)
if ctx_checkpoints > 0 and not swa_full:
checkpoint_extra_per_slot += (
ctx_checkpoints
* swa
* layer_n_kv
* (key_len_swa + val_len_swa)
* bpe
)
else:
global_bytes += n_ctx * layer_n_kv * (key_len + val_len) * bpe
return int(
global_bytes
+ slots * (swa_bytes_per_slot + checkpoint_extra_per_slot)
)
n_global = max(1, n_layers_kv // 4)
n_swa = n_layers_kv - n_global
kv_per_token = n_kv * (key_len + val_len) * bpe
kv_per_token_swa = n_kv * (key_len_swa + val_len_swa) * bpe
global_bytes = n_global * n_ctx * kv_per_token
swa_bytes_per_slot = n_swa * swa_cells_per_slot * kv_per_token_swa
checkpoint_extra_per_slot = (
ctx_checkpoints * n_swa * swa * kv_per_token_swa
if ctx_checkpoints > 0 and not swa_full
else 0.0
)
return int(
global_bytes + slots * (swa_bytes_per_slot + checkpoint_extra_per_slot)
)
# Path 4: Standard GQA with explicit key/value dimensions
if key_len is not None and val_len is not None:
return int(n_layers_kv * n_ctx * n_kv * (key_len + val_len) * bpe)
# Path 5: Legacy fallback (old GGUFs without explicit dimensions)
head_dim = self._embedding_length // self._n_heads if self._n_heads else 128 # type: ignore[operator]
return int(2 * n_kv * head_dim * n_layers_kv * n_ctx * bpe)
def _fit_context_to_vram(
self,
requested_ctx: int,
available_mib: int,
model_size_bytes: int,
cache_type_kv: Optional[str] = None,
min_ctx: int = 4096,
*,
swa_full: bool = False,
n_parallel: int = 1,
kv_unified: bool = True,
ctx_checkpoints: int = 0,
kv_on_gpu: bool = True,
) -> int:
"""Return the largest context length that fits in GPU VRAM.
Uses 90% of available VRAM as the budget (matching _select_gpus
threshold -- 10% reserved for compute buffers, CUDA context,
scratch space, flash-attn workspace, etc.).
If the model weights alone don't fit, returns min_ctx unchanged.
``kv_on_gpu`` mirrors ``--kv-offload`` (default on). When False
the KV cache lives in CPU RAM and doesn't compete with weights
for VRAM; the requested context is honored verbatim. The other
keyword args mirror ``_estimate_kv_cache_bytes``.
"""
if not self._can_estimate_kv():
logger.debug(
"Skipping context fit because KV cache metadata is unavailable",
requested_ctx = requested_ctx,
available_mib = available_mib,
)
return requested_ctx
# KV lives off-GPU: no VRAM accounting needed for the cache itself.
if not kv_on_gpu:
return requested_ctx
kv_kwargs = dict(
swa_full = swa_full,
n_parallel = n_parallel,
kv_unified = kv_unified,
ctx_checkpoints = ctx_checkpoints,
)
budget_bytes = available_mib * 1024 * 1024 * 0.90
model_footprint = model_size_bytes
# Check if requested context already fits
kv = self._estimate_kv_cache_bytes(requested_ctx, cache_type_kv, **kv_kwargs)
if model_footprint + kv <= budget_bytes:
return requested_ctx
# Model weights alone exceed budget -- can't help by reducing ctx.
# Return requested_ctx unchanged; --fit will handle VRAM management.
if model_footprint >= budget_bytes:
logger.debug(
"Model footprint exceeds GPU budget before KV cache",
requested_ctx = requested_ctx,
available_mib = available_mib,
model_size_gb = round(model_footprint / (1024**3), 2),
)
return requested_ctx
# Binary search for max context that fits
remaining = budget_bytes - model_footprint
effective_min = min(min_ctx, requested_ctx)
lo, hi = effective_min, requested_ctx
best = effective_min
while lo <= hi:
mid = (lo + hi) // 2
kv = self._estimate_kv_cache_bytes(mid, cache_type_kv, **kv_kwargs)
if kv <= remaining:
best = mid
lo = mid + 1
else:
hi = mid - 1
# Round down to nearest 256 for alignment, but never exceed requested_ctx
best = (best // 256) * 256
best = max(effective_min, best)
best = min(best, requested_ctx)
return best
# ── Variant fallback ────────────────────────────────────────────
@staticmethod
def _find_smallest_fitting_variant(
hf_repo: str,
free_bytes: int,
hf_token: Optional[str] = None,
) -> Optional[tuple[str, int]]:
"""Find the smallest GGUF variant (including all shards) that fits.
Groups split shards by variant prefix and sums their sizes.
For example, UD-Q4_K_XL with 9 shards of 50 GB each = 450 GB total.
Returns (first_shard_filename, total_size_bytes) or None if nothing fits.
"""
try:
from huggingface_hub import get_paths_info, list_repo_files
files = list_repo_files(hf_repo, token = hf_token)
gguf_files = [
f for f in files if f.endswith(".gguf") and "mmproj" not in f.lower()
]
if not gguf_files:
return None
# Get sizes for all GGUF files
path_infos = list(get_paths_info(hf_repo, gguf_files, token = hf_token))
size_map = {p.path: (p.size or 0) for p in path_infos}
# Group files by variant: shards share a prefix before -NNNNN-of-NNNNN
variants: dict[str, list[str]] = {}
for f in gguf_files:
m = _SHARD_RE.match(f)
key = m.group(1) if m else f
variants.setdefault(key, []).append(f)
# Sum shard sizes per variant, track the first shard (for download)
variant_sizes: list[tuple[str, int, list[str]]] = []
for key, shard_files in variants.items():
total = sum(size_map.get(f, 0) for f in shard_files)
first = sorted(shard_files)[0]
variant_sizes.append((first, total, shard_files))
# Sort by total size ascending and pick the smallest that fits
variant_sizes.sort(key = lambda x: x[1])
for first_file, total_size, _ in variant_sizes:
if total_size > 0 and total_size <= free_bytes:
return first_file, total_size
return None
except Exception:
return None
# ── Port allocation ───────────────────────────────────────────
@staticmethod
def _find_free_port() -> int:
"""Find an available TCP port."""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
# ── Stdout drain (prevents pipe deadlock on Windows) ─────────
def _drain_stdout(self):
"""
Read lines from the subprocess stdout in a background thread.
This prevents a pipe-buffer deadlock on Windows where the default
pipe buffer is only ~4 KB. Without draining, llama-server blocks
on writes and never becomes healthy.
"""
try:
for line in self._process.stdout:
line = line.rstrip()
if line:
self._stdout_lines.append(line)
logger.debug(f"[llama-server] {line}")
except (ValueError, OSError):
# Pipe closed — process is terminating
pass
# GGUF KV type sizes for fast skipping
_GGUF_TYPE_SIZE = {
0: 1,
1: 1,
2: 2,
3: 2,
4: 4,
5: 4,
6: 4,
7: 1,
10: 8,
11: 8,
12: 8,
}
@staticmethod
def _gguf_skip_value(f, vtype: int) -> None:
"""Skip a GGUF KV value without reading it."""
sz = LlamaCppBackend._GGUF_TYPE_SIZE.get(vtype)
if sz is not None:
f.seek(sz, 1)
elif vtype == 8: # STRING
slen = struct.unpack("<Q", f.read(8))[0]
f.seek(slen, 1)
elif vtype == 9: # ARRAY
atype = struct.unpack("<I", f.read(4))[0]
alen = struct.unpack("<Q", f.read(8))[0]
elem_sz = LlamaCppBackend._GGUF_TYPE_SIZE.get(atype)
if elem_sz is not None:
f.seek(elem_sz * alen, 1)
elif atype == 8:
for _ in range(alen):
slen = struct.unpack("<Q", f.read(8))[0]
f.seek(slen, 1)
else:
for _ in range(alen):
LlamaCppBackend._gguf_skip_value(f, atype)
@staticmethod
def _gguf_read_array_value(f, atype: int, alen: int) -> Optional[list]:
if atype == 4: # UINT32
return [struct.unpack("<I", f.read(4))[0] for _ in range(alen)]
if atype == 5: # INT32
return [struct.unpack("<i", f.read(4))[0] for _ in range(alen)]
if atype == 7: # BOOL
return [struct.unpack("<?", f.read(1))[0] for _ in range(alen)]
for _ in range(alen):
LlamaCppBackend._gguf_skip_value(f, atype)
return None
def _read_gguf_metadata(self, gguf_path: str) -> None:
"""Read context_length, architecture params, and chat_template from a GGUF header.
Parses only the KV pairs we need (~30ms even for multi-GB files).
For split GGUFs, metadata is always in shard 1.
"""
# Reset metadata from any previously loaded model so stale flags
# (eg _supports_reasoning) do not carry over when switching models.
self._context_length = None
self._chat_template = None
self._supports_reasoning = False
self._reasoning_always_on = False
self._reasoning_style = "enable_thinking"
self._reasoning_default = True
self._supports_preserve_thinking = False
self._supports_tools = False
self._n_layers = None
self._n_kv_heads = None
self._n_kv_heads_by_layer = None
self._n_heads = None
self._embedding_length = None
self._kv_key_length = None
self._kv_value_length = None
self._sliding_window = None
self._sliding_window_pattern = None
self._full_attention_interval = None
self._kv_lora_rank = None
self._key_length_mla = None
self._kv_key_length_swa = None
self._kv_value_length_swa = None
self._ssm_inner_size = None
self._ssm_state_size = None
self._shared_kv_layers = None
try:
WANTED = {
"general.architecture",
"tokenizer.chat_template",
# Source-repo hints for the SWA resolver's HF fallback.
"general.source.huggingface.repository",
"general.source.url",
"general.source.repo_url",
"general.base_model.0.repo_url",
"general.base_model.0.organization",
"general.base_model.0.name",
"general.basename",
"general.organization",
"general.size_label",
"general.finetune",
}
# Additional arch-specific keys are added dynamically once
# we know the architecture name.
arch_keys: dict[str, str] = {} # gguf_key -> attribute name
arch = None
sliding_window_pattern_period: Optional[int] = None
general: dict[str, str] = {}
with open(gguf_path, "rb") as f:
magic = struct.unpack("<I", f.read(4))[0]
if magic != 0x46554747: # b"GGUF" as little-endian u32
return
_version = struct.unpack("<I", f.read(4))[0]
_tensor_count, kv_count = struct.unpack("<QQ", f.read(16))
for _ in range(kv_count):
# Tolerate truncated input (e.g., a partial header
# fetched via HTTP byte-range): bail out gracefully
# so the resolver fallback still runs on whatever
# we did manage to parse.
try:
key_len_bytes = f.read(8)
if len(key_len_bytes) < 8:
break
key_len = struct.unpack("<Q", key_len_bytes)[0]
key_bytes = f.read(key_len)
if len(key_bytes) < key_len:
break
key = key_bytes.decode("utf-8")
vtype_bytes = f.read(4)
if len(vtype_bytes) < 4:
break
vtype = struct.unpack("<I", vtype_bytes)[0]
except (struct.error, UnicodeDecodeError):
break
try:
if key in WANTED or key in arch_keys:
if vtype == 8: # STRING
slen = struct.unpack("<Q", f.read(8))[0]
val_s = f.read(slen).decode("utf-8")
if (
key.startswith("general.")
and key != "general.architecture"
):
general[key] = val_s
if key == "general.architecture":
arch = val_s
arch_keys = {
f"{arch}.context_length": "context_length",
f"{arch}.block_count": "n_layers",
f"{arch}.attention.head_count_kv": "n_kv_heads",
f"{arch}.attention.head_count": "n_heads",
f"{arch}.embedding_length": "embedding_length",
f"{arch}.attention.key_length": "kv_key_length",
f"{arch}.attention.value_length": "kv_value_length",
f"{arch}.attention.sliding_window": "sliding_window",
f"{arch}.attention.sliding_window_pattern": "sliding_window_pattern",
f"{arch}.full_attention_interval": "full_attention_interval",
f"{arch}.attention.kv_lora_rank": "kv_lora_rank",
f"{arch}.attention.key_length_mla": "key_length_mla",
f"{arch}.attention.key_length_swa": "kv_key_length_swa",
f"{arch}.attention.value_length_swa": "kv_value_length_swa",
f"{arch}.attention.shared_kv_layers": "shared_kv_layers",
f"{arch}.ssm.inner_size": "ssm_inner_size",
f"{arch}.ssm.state_size": "ssm_state_size",
}
elif key == "tokenizer.chat_template":
self._chat_template = val_s
elif vtype in (4, 10): # UINT32 or UINT64
val_i = (
struct.unpack("<I", f.read(4))[0]
if vtype == 4
else struct.unpack("<Q", f.read(8))[0]
)
attr = arch_keys.get(key)
if attr:
if attr == "sliding_window_pattern":
sliding_window_pattern_period = val_i
else:
setattr(self, f"_{attr}", val_i)
elif vtype == 9: # ARRAY
atype = struct.unpack("<I", f.read(4))[0]
alen = struct.unpack("<Q", f.read(8))[0]
val_a = self._gguf_read_array_value(f, atype, alen)
attr = arch_keys.get(key)
if attr == "n_kv_heads" and val_a is not None:
self._n_kv_heads_by_layer = [int(x) for x in val_a]
if self._n_kv_heads is None and val_a:
self._n_kv_heads = max(int(x) for x in val_a)
elif (
attr == "sliding_window_pattern"
and val_a is not None
):
self._sliding_window_pattern = [
bool(x) for x in val_a
]
sliding_window_pattern_period = None
else:
self._gguf_skip_value(f, vtype)
else:
self._gguf_skip_value(f, vtype)
except (struct.error, UnicodeDecodeError):
# Truncated input (e.g., HTTP byte-range fetch
# of just the GGUF header); break so the
# resolver fallback still runs on what we have.
break
# Expand a scalar period straight from the GGUF first.
if (
self._sliding_window_pattern is None
and sliding_window_pattern_period
and self._n_layers
):
self._sliding_window_pattern = [
(i + 1) % sliding_window_pattern_period != 0
for i in range(self._n_layers)
]
# Otherwise hand off to the resolver (cache / bootstrap /
# transformers / HF). See `_resolve_swa_pattern`.
if (
self._sliding_window_pattern is None
and self._sliding_window
and self._n_layers
):
hf_repo_candidates = (
general.get("general.source.huggingface.repository"),
_hf_repo_from_url(general.get("general.source.url")),
_hf_repo_from_url(general.get("general.source.repo_url")),
_hf_repo_from_url(general.get("general.base_model.0.repo_url")),
(
f"{general['general.base_model.0.organization']}/"
f"{general['general.base_model.0.name']}".replace(" ", "-")
if general.get("general.base_model.0.organization")
and general.get("general.base_model.0.name")
else None
),
(
f"{general['general.organization']}/"
f"{general['general.basename']}".replace(" ", "-")
if general.get("general.organization")
and general.get("general.basename")
else None
),
)
self._sliding_window_pattern = _resolve_swa_pattern(
arch,
self._n_layers,
hf_repo_candidates,
)
if self._context_length:
logger.info(f"GGUF metadata: context_length={self._context_length}")
if self._chat_template:
logger.info(
f"GGUF metadata: chat_template={len(self._chat_template)} chars"
)
# Detect thinking/reasoning support from chat template
flags = detect_reasoning_flags(
self._chat_template,
self._model_identifier,
log_source = "GGUF metadata",
)
self._supports_reasoning = flags["supports_reasoning"]
self._reasoning_style = flags["reasoning_style"]
self._reasoning_always_on = flags["reasoning_always_on"]
self._supports_preserve_thinking = flags["supports_preserve_thinking"]
self._supports_tools = flags["supports_tools"]
except Exception as e:
logger.warning(f"Failed to read GGUF metadata: {e}")
# ── HF download (no lock held) ───────────────────────────────
def _download_gguf(
self,
*,
hf_repo: str,
hf_variant: Optional[str] = None,
hf_token: Optional[str] = None,
) -> str:
"""Download GGUF file(s) from HuggingFace. Returns local path.
Runs WITHOUT self._lock so that unload_model() can set
_cancel_event at any time. Checks _cancel_event between
each shard download.
"""
try:
from huggingface_hub import hf_hub_download
except ImportError:
raise RuntimeError(
"huggingface_hub is required for HF model loading. "
"Install it with: pip install huggingface_hub"
)
# Determine the filename from the variant
gguf_filename = None
gguf_extra_shards: list[str] = []
if hf_variant:
try:
from huggingface_hub import list_repo_files
files = list_repo_files(hf_repo, token = hf_token)
variant_lower = hf_variant.lower()
boundary = re.compile(
r"(?<![a-zA-Z0-9])" + re.escape(variant_lower) + r"(?![a-zA-Z0-9])"
)
gguf_files = sorted(
f
for f in files
if f.endswith(".gguf") and boundary.search(f.lower())
)
if gguf_files:
gguf_filename = gguf_files[0]
m = _SHARD_FULL_RE.match(gguf_filename)
if m:
prefix = m.group(1)
total = m.group(3)
sibling_pat = re.compile(
r"^"
+ re.escape(prefix)
+ r"-\d{5}-of-"
+ re.escape(total)
+ r"\.gguf$"
)
gguf_extra_shards = [
f for f in gguf_files[1:] if sibling_pat.match(f)
]
except Exception as e:
logger.warning(f"Could not list repo files: {e}")
if not gguf_filename:
repo_name = hf_repo.split("/")[-1].replace("-GGUF", "")
gguf_filename = f"{repo_name}-{hf_variant}.gguf"
# Check disk space and fall back to a smaller variant if needed
all_gguf_files = [gguf_filename] + gguf_extra_shards
try:
import os
from huggingface_hub import get_paths_info, try_to_load_from_cache
path_infos = list(get_paths_info(hf_repo, all_gguf_files, token = hf_token))
total_bytes = sum((p.size or 0) for p in path_infos)
# Subtract bytes already present in the HF cache so we only
# preflight against what we actually have to download. Without
# this, re-loading a cached large model (e.g. MiniMax-M2.7-GGUF
# at 131 GB) fails cold whenever free disk is below the full
# weight footprint, even though nothing needs downloading.
already_cached_bytes = 0
for p in path_infos:
if not p.size:
continue
try:
cached_path = try_to_load_from_cache(hf_repo, p.path)
except Exception:
cached_path = None
if isinstance(cached_path, str) and os.path.exists(cached_path):
try:
on_disk = os.path.getsize(cached_path)
except OSError:
on_disk = 0
# Count as satisfied only when the full blob is present.
if on_disk >= p.size:
already_cached_bytes += p.size
total_download_bytes = max(0, total_bytes - already_cached_bytes)
if total_download_bytes > 0:
cache_dir = os.environ.get(
"HF_HUB_CACHE",
str(Path.home() / ".cache" / "huggingface" / "hub"),
)
Path(cache_dir).mkdir(parents = True, exist_ok = True)
free_bytes = shutil.disk_usage(cache_dir).free
total_gb = total_download_bytes / (1024**3)
free_gb = free_bytes / (1024**3)
cached_gb = already_cached_bytes / (1024**3)
logger.info(
f"GGUF download: {total_gb:.1f} GB needed "
f"({cached_gb:.1f} GB already cached), "
f"{free_gb:.1f} GB free on disk"
)
if total_download_bytes > free_bytes:
smaller = self._find_smallest_fitting_variant(
hf_repo,
free_bytes,
hf_token,
)
if smaller:
fallback_file, fallback_size = smaller
logger.info(
f"Selected variant too large ({total_gb:.1f} GB), "
f"falling back to {fallback_file} ({fallback_size / (1024**3):.1f} GB)"
)
gguf_filename = fallback_file
_m = _SHARD_RE.match(gguf_filename)
_prefix = _m.group(1) if _m else None
if _prefix:
gguf_extra_shards = sorted(
f
for f in all_gguf_files
if f.startswith(_prefix)
and f != gguf_filename
and "mmproj" not in f.lower()
)
else:
gguf_extra_shards = []
else:
raise RuntimeError(
f"Not enough disk space to download any variant. "
f"Only {free_gb:.1f} GB free in {cache_dir}"
)
except RuntimeError:
raise
except Exception as e:
logger.warning(f"Could not check disk space: {e}")
gguf_label = f"{hf_repo}/{gguf_filename}" + (
f" (+{len(gguf_extra_shards)} shards)" if gguf_extra_shards else ""
)
logger.info(f"Resolving GGUF: {gguf_label}")
try:
if self._cancel_event.is_set():
raise RuntimeError("Cancelled")
dl_start = time.monotonic()
local_path = hf_hub_download(
repo_id = hf_repo,
filename = gguf_filename,
token = hf_token,
)
for shard in gguf_extra_shards:
if self._cancel_event.is_set():
raise RuntimeError("Cancelled")
logger.info(f"Resolving GGUF shard: {shard}")
hf_hub_download(
repo_id = hf_repo,
filename = shard,
token = hf_token,
)
except RuntimeError as e:
if "Cancelled" in str(e):
raise
raise RuntimeError(
f"Failed to download GGUF file '{gguf_filename}' from {hf_repo}: {e}"
)
except Exception as e:
raise RuntimeError(
f"Failed to download GGUF file '{gguf_filename}' from {hf_repo}: {e}"
)
dl_elapsed = time.monotonic() - dl_start
if dl_elapsed < 2.0:
logger.info(f"GGUF resolved from cache: {local_path}")
else:
logger.info(f"GGUF downloaded in {dl_elapsed:.1f}s: {local_path}")
return local_path
def _download_mmproj(
self,
*,
hf_repo: str,
hf_token: Optional[str] = None,
) -> Optional[str]:
"""Download the mmproj (vision projection) file from a GGUF repo.
Prefers mmproj-F16.gguf, falls back to any mmproj*.gguf file.
Returns the local path, or None if no mmproj file exists.
"""
try:
from huggingface_hub import hf_hub_download, list_repo_files
files = list_repo_files(hf_repo, token = hf_token)
mmproj_files = sorted(
f for f in files if f.endswith(".gguf") and "mmproj" in f.lower()
)
if not mmproj_files:
return None
# Prefer F16 variant
target = None
for f in mmproj_files:
if f.lower().endswith("-f16.gguf"):
target = f
break
if target is None:
target = mmproj_files[0]
logger.info(f"Downloading mmproj: {hf_repo}/{target}")
local_path = hf_hub_download(
repo_id = hf_repo,
filename = target,
token = hf_token,
)
return local_path
except Exception as e:
logger.warning(f"Could not download mmproj: {e}")
return None
# ── Lifecycle ─────────────────────────────────────────────────
def load_model(
self,
*,
# Local mode: pass a path to a .gguf file
gguf_path: Optional[str] = None,
# Vision projection (mmproj) for local vision models
mmproj_path: Optional[str] = None,
# HF mode: let llama-server download via -hf "repo:quant"
hf_repo: Optional[str] = None,
hf_variant: Optional[str] = None,
hf_token: Optional[str] = None,
# Common
model_identifier: str,
is_vision: bool = False,
n_ctx: int = 4096,
chat_template_override: Optional[str] = None,
cache_type_kv: Optional[str] = None,
speculative_type: Optional[str] = None,
n_threads: Optional[int] = None,
n_gpu_layers: Optional[int] = None, # Accepted for caller compat, unused
n_parallel: int = 1,
extra_args: Optional[List[str]] = None,
) -> bool:
"""
Start llama-server with a GGUF model.
Two modes:
- Local: ``gguf_path="/path/to/model.gguf"`` → uses ``-m``
- HF: ``hf_repo="unsloth/gemma-3-4b-it-GGUF", hf_variant="Q4_K_M"`` → uses ``-hf``
In HF mode, llama-server handles downloading, caching, and
auto-loading mmproj files for vision models.
Returns True if server started and health check passed.
"""
self._cancel_event.clear()
# ── Phase 1: kill old process (under lock, fast) ──────────
with self._lock:
self._kill_process()
binary = self._find_llama_server_binary()
if not binary:
raise RuntimeError(
"llama-server binary not found. "
"Run setup.sh to build it, install llama.cpp, "
"or set LLAMA_SERVER_PATH environment variable."
)
# ── Phase 2: download (NO lock held, so cancel can proceed) ──
if hf_repo:
model_path = self._download_gguf(
hf_repo = hf_repo,
hf_variant = hf_variant,
hf_token = hf_token,
)
# Auto-download mmproj for vision models
if is_vision and not mmproj_path:
mmproj_path = self._download_mmproj(
hf_repo = hf_repo,
hf_token = hf_token,
)
elif gguf_path:
if not Path(gguf_path).is_file():
raise FileNotFoundError(f"GGUF file not found: {gguf_path}")
model_path = gguf_path
else:
raise ValueError("Either gguf_path or hf_repo must be provided")
# Set identifier early so _read_gguf_metadata can use it for DeepSeek detection
self._model_identifier = model_identifier
# Read GGUF metadata (context_length, chat_template) -- fast, header only
self._read_gguf_metadata(model_path)
# Check cancel after download
if self._cancel_event.is_set():
logger.info("Load cancelled after download phase")
return False
# ── Phase 3: start llama-server (under lock) ──────────────
with self._lock:
# Re-check cancel inside lock
if self._cancel_event.is_set():
logger.info("Load cancelled before server start")
return False
self._port = self._find_free_port()
# Select GPU(s) based on model size + estimated KV cache.
# Seed safe defaults before GPU probing so the except path
# still has valid state to publish.
effective_ctx = n_ctx if n_ctx > 0 else (self._context_length or 0)
max_available_ctx = self._context_length or effective_ctx
try:
model_size = self._get_gguf_size_bytes(model_path)
gpus = self._get_gpu_free_memory()
# Resolve effective context: 0 means let llama-server use the
# model's native length. Only expand to a known native length
# if metadata is available; otherwise preserve 0 as a sentinel.
if n_ctx > 0:
effective_ctx = n_ctx
elif self._context_length is not None:
effective_ctx = self._context_length
else:
effective_ctx = 0
original_ctx = effective_ctx
# Default UI ceiling to the model's native context length.
# GPU/VRAM-fit logic below may shrink this if hardware is limited.
max_available_ctx = self._context_length or effective_ctx
# Auto-cap context to fit in GPU VRAM and select GPUs.
#
# Two policies depending on whether the user set n_ctx:
#
# Explicit n_ctx (user chose a context length):
# Honor it. Try the full requested context with _select_gpus
# (which uses as many GPUs as needed). Only cap if it doesn't
# fit on any GPU combination.
#
# Auto n_ctx=0 (model's native context):
# Prefer fewer GPUs with reduced context over more GPUs,
# since multi-GPU is slower and the user didn't ask for a
# specific context length.
gpu_indices, use_fit = None, True
explicit_ctx = n_ctx > 0
if gpus and self._can_estimate_kv() and effective_ctx > 0:
# Compute the largest hardware-aware cap from the model's
# native context across all usable GPU subsets (for UI
# bounds), independent of the currently requested context.
native_ctx_for_cap = self._context_length or effective_ctx
if native_ctx_for_cap > 0:
ranked_for_cap = sorted(gpus, key = lambda g: g[1], reverse = True)
best_cap = 0
for n_gpus in range(1, len(ranked_for_cap) + 1):
subset = ranked_for_cap[:n_gpus]
pool_mib = sum(free for _, free in subset)
capped = self._fit_context_to_vram(
native_ctx_for_cap,
pool_mib,
model_size,
cache_type_kv,
n_parallel = n_parallel,
)
kv = self._estimate_kv_cache_bytes(
capped, cache_type_kv, n_parallel = n_parallel
)
total_mib = (model_size + kv) / (1024 * 1024)
if total_mib <= pool_mib * 0.90:
best_cap = max(best_cap, capped)
if best_cap > 0:
max_available_ctx = best_cap
else:
# Weights exceed 90% of every GPU subset's free
# memory, so there is no fitting context. Anchor
# the UI's "safe zone" threshold at 4096 (the
# spec's default when the model cannot fit) so
# the ctx slider shows the "might be slower"
# warning as soon as the user drags above the
# fallback default instead of never.
max_available_ctx = min(4096, native_ctx_for_cap)
if explicit_ctx:
# Honor the user's requested context verbatim. If it
# fits, pin GPUs and skip --fit; if it doesn't, ship
# -c <user_ctx> --fit on and let llama-server flex
# -ngl (CPU layer offload). The UI is expected to
# have surfaced the "might be slower" warning before
# the user submitted a ctx above the fit ceiling.
requested_total = model_size + self._estimate_kv_cache_bytes(
effective_ctx, cache_type_kv, n_parallel = n_parallel
)
gpu_indices, use_fit = self._select_gpus(requested_total, gpus)
# No silent shrink: effective_ctx stays == n_ctx.
else:
# Auto context: prefer fewer GPUs, cap context to fit.
ranked = sorted(gpus, key = lambda g: g[1], reverse = True)
for n_gpus in range(1, len(ranked) + 1):
subset = ranked[:n_gpus]
pool_mib = sum(free for _, free in subset)
capped = self._fit_context_to_vram(
effective_ctx,
pool_mib,
model_size,
cache_type_kv,
n_parallel = n_parallel,
)
kv = self._estimate_kv_cache_bytes(
capped, cache_type_kv, n_parallel = n_parallel
)
total_mib = (model_size + kv) / (1024 * 1024)
if total_mib <= pool_mib * 0.90:
effective_ctx = capped
gpu_indices = sorted(idx for idx, _ in subset)
use_fit = False
break
else:
# No subset can host the weights (weights alone
# exceed 90% of every pool). Per spec, default
# the UI-visible context to 4096 and let
# --fit on flex -ngl so llama-server offloads
# layers to CPU RAM.
effective_ctx = min(4096, effective_ctx)
elif gpus:
# Can't estimate KV -- fall back to file-size-only check.
# Without KV estimation we cannot prove a hardware cap, so
# keep the ceiling at the native context (already the default).
logger.debug(
"Falling back to file-size-only GPU selection",
model_size_gb = round(model_size / (1024**3), 2),
)
gpu_indices, use_fit = self._select_gpus(model_size, gpus)
if use_fit and not explicit_ctx:
# Weights don't fit on any subset. Default the UI to
# 4096 so the slider doesn't land on an unusable native
# context. --fit on will flex -ngl at runtime.
effective_ctx = (
min(4096, effective_ctx) if effective_ctx > 0 else 4096
)
if effective_ctx < original_ctx:
kv_est = self._estimate_kv_cache_bytes(
effective_ctx, cache_type_kv, n_parallel = n_parallel
)
logger.info(
f"Context auto-reduced: {original_ctx} -> {effective_ctx} "
f"(model: {model_size / (1024**3):.1f} GB, "
f"est. KV cache: {kv_est / (1024**3):.1f} GB)"
)
kv_cache_bytes = self._estimate_kv_cache_bytes(
effective_ctx, cache_type_kv, n_parallel = n_parallel
)
logger.info(
f"GGUF size: {model_size / (1024**3):.1f} GB, "
f"est. KV cache: {kv_cache_bytes / (1024**3):.1f} GB, "
f"context: {effective_ctx}, "
f"GPUs free: {gpus}, selected: {gpu_indices}, fit: {use_fit}"
)
except Exception as e:
logger.warning(f"GPU selection failed ({e}), using --fit on")
gpu_indices, use_fit = None, True
effective_ctx = n_ctx # fall back to original
cmd = [
binary,
"-m",
model_path,
"--port",
str(self._port),
"-c",
str(effective_ctx) if effective_ctx > 0 else "0",
"--parallel",
str(n_parallel),
"--flash-attn",
"on", # Force flash attention for speed
# Error out at n_ctx instead of silently rotating the KV cache; frontend catches it and points the user at "Context Length".
"--no-context-shift",
]
if use_fit:
cmd.extend(["--fit", "on"])
elif gpu_indices is not None:
# Model fits on selected GPU(s) -- offload all layers
cmd.extend(["-ngl", "-1"])
# -1 = llama.cpp auto-detect (physical cores). Pass explicitly so we
# do not inherit llama-server's internal default, which has historically
# varied (hardware concurrency incl. hyperthreads on some builds).
cmd.extend(["--threads", str(n_threads if n_threads is not None else -1)])
# Always enable Jinja chat template rendering for proper template support
cmd.extend(["--jinja"])
# KV cache data type
_valid_cache_types = {
"f16",
"bf16",
"q8_0",
"q4_0",
"q4_1",
"q5_0",
"q5_1",
"iq4_nl",
"f32",
}
if cache_type_kv and cache_type_kv in _valid_cache_types:
cmd.extend(
["--cache-type-k", cache_type_kv, "--cache-type-v", cache_type_kv]
)
self._cache_type_kv = cache_type_kv
logger.info(f"KV cache type: {cache_type_kv}")
else:
self._cache_type_kv = None
# Speculative decoding (n-gram self-speculation, zero VRAM cost)
# ngram-mod: ~16 MB shared hash pool, constant memory/complexity,
# variable draft lengths. Helps most when the model repeats
# existing text (code refactoring, summarization, reasoning).
# For general chat with low repetition, overhead is ~5 ms.
#
# Benchmarks from upstream llama.cpp speculative-decoding PRs:
# Scenario | Without | With | Speedup
# gpt-oss-120b code refactor | 181 t/s | 446 t/s | 2.5x
# Qwen3-235B offloaded | 12 t/s | 21 t/s | 1.8x
# gpt-oss-120b repeat (92% accept)| 181 t/s | 814 t/s | 4.5x
#
# Params from llama.cpp docs (docs/speculative.md):
# --spec-ngram-size-n 24 (small n not recommended)
# --draft-min 48 --draft-max 64 (MoEs need long drafts;
# dense models can reduce these)
# ref: https://github.com/ggml-org/llama.cpp/blob/master/docs/speculative.md
# ref: https://github.com/ggml-org/llama.cpp/pull/19164
# ref: https://github.com/ggml-org/llama.cpp/pull/18471
# ``"default"`` -> let llama-server pick a sensible spec
# config via ``--spec-default``. Explicit type names are
# passed through with the manual draft tuning we've shipped
# historically so power users keep their overrides.
_valid_spec_types = {"ngram-simple", "ngram-mod"}
normalized_spec = (
speculative_type.lower().strip() if speculative_type else None
)
if normalized_spec and normalized_spec != "off" and not is_vision:
if normalized_spec == "default":
cmd.append("--spec-default")
self._speculative_type = "default"
elif normalized_spec in _valid_spec_types:
cmd.extend(["--spec-type", normalized_spec])
if normalized_spec == "ngram-mod":
cmd.extend(
[
"--spec-ngram-size-n",
"24",
"--draft-min",
"48",
"--draft-max",
"64",
]
)
self._speculative_type = normalized_spec
else:
self._speculative_type = None
else:
self._speculative_type = None
# Apply custom chat template override if provided
if chat_template_override:
import tempfile
self._chat_template = chat_template_override
flags = detect_reasoning_flags(
self._chat_template,
self._model_identifier,
log_source = "GGUF chat template override",
)
self._supports_reasoning = flags["supports_reasoning"]
self._reasoning_style = flags["reasoning_style"]
self._reasoning_always_on = flags["reasoning_always_on"]
self._supports_preserve_thinking = flags["supports_preserve_thinking"]
self._supports_tools = flags["supports_tools"]
self._chat_template_file = tempfile.NamedTemporaryFile(
mode = "w",
suffix = ".jinja",
delete = False,
prefix = "unsloth_chat_template_",
)
self._chat_template_file.write(chat_template_override)
self._chat_template_file.close()
cmd.extend(["--chat-template-file", self._chat_template_file.name])
logger.info(
f"Using custom chat template file: {self._chat_template_file.name}"
)
# For reasoning models, set default thinking mode.
# Qwen3.5/3.6 models below 9B (0.8B, 2B, 4B) disable thinking by default.
# Only 9B and larger enable thinking.
# Always-on templates ignore the kwarg entirely, so skip.
if self._supports_reasoning and not self._reasoning_always_on:
thinking_default = True
mid = (model_identifier or "").lower()
if "qwen3.5" in mid or "qwen3.6" in mid:
size_val = _extract_model_size_b(mid)
if size_val is not None and size_val < 9:
thinking_default = False
self._reasoning_default = thinking_default
reasoning_kw = self._reasoning_kwargs(thinking_default)
cmd.extend(
[
"--chat-template-kwargs",
json.dumps(reasoning_kw),
]
)
logger.info(f"Reasoning model: {reasoning_kw} by default")
if mmproj_path:
if not Path(mmproj_path).is_file():
logger.warning(f"mmproj file not found: {mmproj_path}")
else:
cmd.extend(["--mmproj", mmproj_path])
logger.info(f"Using mmproj for vision: {mmproj_path}")
# Option C: add --api-key for direct client access when enabled
import os as _os
import secrets as _secrets
if _os.getenv("UNSLOTH_DIRECT_STREAM", "0") == "1":
self._api_key = _secrets.token_urlsafe(32)
cmd.extend(["--api-key", self._api_key])
logger.info("llama-server started with --api-key for direct streaming")
else:
self._api_key = None
# User-supplied pass-through args go last so llama.cpp's
# last-wins flag parsing lets the user override Studio's
# auto-set tier-2 flags (e.g. --cache-type-k, --spec-type).
# The route layer has already validated this list against
# the managed-flag denylist via validate_extra_args().
if extra_args:
cmd.extend(str(a) for a in extra_args)
logger.info(
f"Appending user extra args to llama-server: {list(extra_args)}"
)
_log_cmd = list(cmd)
if "--api-key" in _log_cmd:
_ki = _log_cmd.index("--api-key") + 1
if _ki < len(_log_cmd):
_log_cmd[_ki] = "<redacted>"
logger.info(f"Starting llama-server: {' '.join(_log_cmd)}")
# Set library paths so llama-server can find its shared libs and CUDA DLLs
import os
import sys
env = child_env_without_native_path_secret()
binary_dir = str(Path(binary).parent)
if sys.platform == "win32":
# On Windows, CUDA DLLs (cublas64_12.dll, cudart64_12.dll, etc.)
# must be on PATH. Add CUDA_PATH\bin if available.
path_dirs = [binary_dir]
cuda_path = os.environ.get("CUDA_PATH", "")
if cuda_path:
cuda_bin = os.path.join(cuda_path, "bin")
if os.path.isdir(cuda_bin):
path_dirs.append(cuda_bin)
# Some CUDA installs put DLLs in bin\x64
cuda_bin_x64 = os.path.join(cuda_path, "bin", "x64")
if os.path.isdir(cuda_bin_x64):
path_dirs.append(cuda_bin_x64)
existing_path = env.get("PATH", "")
env["PATH"] = ";".join(path_dirs) + ";" + existing_path
else:
# Linux: set LD_LIBRARY_PATH for shared libs next to the binary
# and CUDA runtime libs (libcudart, libcublas, etc.)
import platform
lib_dirs = [binary_dir]
_arch = platform.machine() # x86_64, aarch64, etc.
# Pip-installed nvidia CUDA runtime libs (e.g. torch's
# bundled cuda-bindings). The prebuilt llama.cpp binary
# links against libcudart.so.13 / libcublas.so.13 which
# live here, not in /usr/local/cuda.
import glob as _glob
for _nv_pattern in [
os.path.join(
sys.prefix,
"lib",
"python*",
"site-packages",
"nvidia",
"cu*",
"lib",
),
os.path.join(
sys.prefix,
"lib",
"python*",
"site-packages",
"nvidia",
"cudnn",
"lib",
),
os.path.join(
sys.prefix,
"lib",
"python*",
"site-packages",
"nvidia",
"nvjitlink",
"lib",
),
]:
for _nv_dir in _glob.glob(_nv_pattern):
if os.path.isdir(_nv_dir):
lib_dirs.append(_nv_dir)
for cuda_lib in [
"/usr/local/cuda/lib64",
f"/usr/local/cuda/targets/{_arch}-linux/lib",
# Fallback CUDA compat paths (e.g. binary built with
# CUDA 12 on a system where default /usr/local/cuda
# points to CUDA 13+).
"/usr/local/cuda-12/lib64",
"/usr/local/cuda-12.8/lib64",
f"/usr/local/cuda-12/targets/{_arch}-linux/lib",
f"/usr/local/cuda-12.8/targets/{_arch}-linux/lib",
]:
if os.path.isdir(cuda_lib):
lib_dirs.append(cuda_lib)
existing_ld = env.get("LD_LIBRARY_PATH", "")
new_ld = ":".join(lib_dirs)
env["LD_LIBRARY_PATH"] = (
f"{new_ld}:{existing_ld}" if existing_ld else new_ld
)
# Pin to selected GPU(s). On ROCm, llama-server (and any torch
# in the subprocess) honors HIP_VISIBLE_DEVICES / ROCR_VISIBLE_DEVICES;
# narrowing only CUDA_VISIBLE_DEVICES leaves an AMD child seeing
# the full HIP/ROCR set the parent inherited.
if gpu_indices is not None:
pinned = ",".join(str(i) for i in gpu_indices)
env["CUDA_VISIBLE_DEVICES"] = pinned
try:
import torch as _torch
if getattr(_torch.version, "hip", None) is not None:
env["HIP_VISIBLE_DEVICES"] = pinned
env["ROCR_VISIBLE_DEVICES"] = pinned
except Exception as e:
logger.debug(
"Failed to set ROCm visibility env vars for child: %s", e
)
# Defensive kill: if a concurrent load slipped past Phase 1
# (because its `self._process` was None at the time) and
# already stored a Popen handle here, drop that orphan
# before we overwrite the reference. See issue #5161.
self._kill_process()
self._stdout_lines = []
self._process = subprocess.Popen(
cmd,
stdout = subprocess.PIPE,
stderr = subprocess.STDOUT,
text = True,
env = env,
**_windows_hidden_subprocess_kwargs(),
)
# Start background thread to drain stdout and prevent pipe deadlock
self._stdout_thread = threading.Thread(
target = self._drain_stdout, daemon = True, name = "llama-stdout"
)
self._stdout_thread.start()
# Store the resolved on-disk path, not the caller's kwarg. In
# HF mode the caller passes gguf_path=None and the real path
# (``model_path``) is what llama-server is actually mmap'ing.
# Downstream consumers (load_progress, log lines, etc.) need
# the path that exists on disk.
self._gguf_path = model_path
self._hf_repo = hf_repo
# For local GGUF files, extract variant from filename if not provided
if hf_variant:
self._hf_variant = hf_variant
elif gguf_path:
try:
from utils.models.model_config import _extract_quant_label
self._hf_variant = _extract_quant_label(gguf_path)
except Exception:
self._hf_variant = None
else:
self._hf_variant = None
self._is_vision = is_vision
self._model_identifier = model_identifier
# Store the effective (possibly capped) context separately.
# Do NOT overwrite _context_length -- it holds the model's native
# context length from GGUF metadata and is used for display/info.
self._effective_context_length = (
effective_ctx if effective_ctx > 0 else self._context_length
)
self._max_context_length = (
max_available_ctx
if max_available_ctx > 0
else self._effective_context_length
)
# Wait for llama-server to become healthy
if not self._wait_for_health(timeout = 600.0):
self._kill_process()
_gguf = gguf_path or ""
_is_ollama = (
".studio_links" in _gguf
or os.sep + "ollama_links" + os.sep in _gguf
or os.sep + ".cache" + os.sep + "ollama" + os.sep in _gguf
or (self._model_identifier or "").startswith("ollama/")
)
# Only show the Ollama-specific message when the server
# output indicates a GGUF compatibility issue, not for
# unrelated failures like OOM or missing binaries.
if _is_ollama:
_output = "\n".join(self._stdout_lines[-50:]).lower()
_gguf_compat_hints = (
"key not found",
"unknown model architecture",
"failed to load model",
)
if any(h in _output for h in _gguf_compat_hints):
raise RuntimeError(
"Some Ollama models do not work with llama.cpp. "
"Try a different model, or use this model directly through Ollama instead."
)
raise RuntimeError(
"llama-server failed to start. "
"Check that the GGUF file is valid and you have enough memory."
)
self._healthy = True
logger.info(
f"llama-server ready on port {self._port} "
f"for model '{model_identifier}'"
)
return True
def unload_model(self) -> bool:
"""Terminate the llama-server subprocess and cancel any in-flight download."""
self._cancel_event.set()
with self._lock:
self._kill_process()
logger.info(f"Unloaded GGUF model: {self._model_identifier}")
self._model_identifier = None
self._gguf_path = None
self._hf_repo = None
self._hf_variant = None
self._is_vision = False
self._is_audio = False
self._audio_type = None
self._port = None
self._healthy = False
self._context_length = None
self._effective_context_length = None
self._max_context_length = None
self._chat_template = None
self._supports_reasoning = False
self._reasoning_always_on = False
self._reasoning_style = "enable_thinking"
self._reasoning_default = True
self._supports_preserve_thinking = False
self._supports_tools = False
self._cache_type_kv = None
self._speculative_type = None
self._n_layers = None
self._n_kv_heads = None
self._n_kv_heads_by_layer = None
self._n_heads = None
self._embedding_length = None
self._kv_key_length = None
self._kv_value_length = None
self._sliding_window = None
self._sliding_window_pattern = None
self._full_attention_interval = None
self._kv_lora_rank = None
self._key_length_mla = None
self._kv_key_length_swa = None
self._kv_value_length_swa = None
self._ssm_inner_size = None
self._ssm_state_size = None
self._shared_kv_layers = None
# Clean up temp chat template file
if hasattr(self, "_chat_template_file") and self._chat_template_file:
try:
import os
os.unlink(self._chat_template_file.name)
except Exception:
pass
self._chat_template_file = None
# Free audio codec GPU memory
if LlamaCppBackend._codec_mgr is not None:
LlamaCppBackend._codec_mgr.unload()
LlamaCppBackend._codec_mgr = None
import torch
if torch.cuda.is_available():
torch.cuda.empty_cache()
return True
def _kill_process(self):
"""Terminate the subprocess if running."""
if self._process is None:
return
try:
self._process.terminate()
self._process.wait(timeout = 5)
except subprocess.TimeoutExpired:
logger.warning("llama-server did not exit on SIGTERM, sending SIGKILL")
self._process.kill()
self._process.wait(timeout = 5)
except Exception as e:
logger.warning(f"Error killing llama-server process: {e}")
finally:
self._process = None
if self._stdout_thread is not None:
self._stdout_thread.join(timeout = 2)
self._stdout_thread = None
@staticmethod
def _kill_orphaned_servers():
"""Kill orphaned llama-server processes started by studio.
Only kills processes whose resolved binary lives under a known
Studio install directory (or matches an exact env-var override)
to avoid terminating unrelated llama-server instances.
Mirrors every location that _find_llama_server_binary() can
return from so that orphans from any supported install path
are still cleaned up.
Uses psutil for cross-platform support (Linux, macOS, Windows).
Falls back to pgrep + /proc/<pid>/exe on Linux when psutil is
not installed.
"""
import os
import signal
import sys
try:
# -- Build the ownership allowlist --------------------------------
# Two kinds of matches:
# exact_binaries -- env var overrides (exact path match only)
# install_roots -- directory trees that are Studio-owned
# (binary must be *under* one of these)
install_roots: list[Path] = []
# Env-mode custom root (mirrors _find_llama_server_binary).
_is_custom_root = False
try:
from utils.paths.storage_roots import studio_root as _sr # noqa: WPS433
_resolved_sr = _sr()
_legacy_studio = Path.home() / ".unsloth" / "studio"
try:
_is_custom_root = _resolved_sr.resolve() != _legacy_studio.resolve()
except (OSError, ValueError):
_is_custom_root = _resolved_sr != _legacy_studio
if _is_custom_root:
install_roots.append(_resolved_sr / "llama.cpp")
except (ImportError, OSError, ValueError):
pass
# Primary install dir (default mode only). Env-mode skips this so
# a custom-root Studio cannot kill a concurrent default-install
# Studio's llama-server (same OS user, different install).
if not _is_custom_root:
install_roots.append(Path.home() / ".unsloth" / "llama.cpp")
# Legacy in-tree build dirs (older setup.sh versions)
project_root = Path(__file__).resolve().parents[4]
install_roots.append(project_root / "llama.cpp")
# Legacy: extracted binary
install_roots.append(project_root / "bin")
# UNSLOTH_LLAMA_CPP_PATH env var (custom install dir)
custom_dir = os.environ.get("UNSLOTH_LLAMA_CPP_PATH")
if custom_dir:
install_roots.append(Path(custom_dir))
# LLAMA_SERVER_PATH env var (exact binary path)
exact_binaries: list[Path] = []
env_binary = os.environ.get("LLAMA_SERVER_PATH")
if env_binary:
try:
exact_binaries.append(Path(env_binary).resolve())
except OSError:
pass
# Resolve all roots so is_relative_to works reliably
resolved_roots: list[Path] = []
for root in install_roots:
try:
resolved_roots.append(root.resolve())
except OSError:
pass
my_pid = os.getpid()
# -- Enumerate processes -------------------------------------------
# Prefer psutil (cross-platform). Fall back to pgrep + /proc on
# Linux when psutil is not installed.
try:
import psutil
has_psutil = True
except ImportError:
has_psutil = False
if has_psutil:
for proc in psutil.process_iter(["pid", "name", "exe"]):
try:
if proc.info["pid"] == my_pid:
continue
name = proc.info.get("name") or ""
if not name.lower().startswith("llama-server"):
continue
exe = proc.info.get("exe")
if not exe:
continue
exe_path = Path(exe).resolve()
# Check ownership: exact binary match OR binary is
# under a known install root (proper ancestry, not
# substring).
is_ours = exe_path in exact_binaries or any(
exe_path.is_relative_to(root) for root in resolved_roots
)
if not is_ours:
continue
proc.kill()
logger.info(
f"Killed orphaned llama-server process "
f"(pid={proc.info['pid']})"
)
except (
psutil.NoSuchProcess,
psutil.AccessDenied,
psutil.ZombieProcess,
):
pass
else:
# -- Fallback: pgrep + /proc/<pid>/exe (Linux only) -----------
if sys.platform != "linux":
return
result = subprocess.run(
["pgrep", "-a", "-f", "llama-server"],
capture_output = True,
text = True,
timeout = 5,
env = child_env_without_native_path_secret(),
)
if result.returncode != 0:
return
for line in result.stdout.strip().splitlines():
parts = line.strip().split(None, 1)
if len(parts) < 2:
continue
pid = int(parts[0])
if pid == my_pid:
continue
# Resolve the actual executable. /proc/<pid>/exe is a
# symlink to the real binary and avoids all cmdline-
# parsing ambiguities (spaces in paths, argv rewriting).
# Fall back to the first cmdline token when /proc is
# unavailable.
proc_exe = Path(f"/proc/{pid}/exe")
try:
binary = proc_exe.resolve(strict = True)
except (OSError, ValueError):
cmdline = parts[1]
token = cmdline.split()[0] if cmdline.strip() else ""
if not token:
continue
binary = Path(token).resolve(strict = False)
owned = binary in exact_binaries or any(
binary.is_relative_to(root) for root in resolved_roots
)
if not owned:
continue
try:
os.kill(pid, signal.SIGKILL)
logger.info(f"Killed orphaned llama-server process (pid={pid})")
except ProcessLookupError:
pass
except PermissionError:
pass
except Exception:
logger.warning("Error during orphan server cleanup", exc_info = True)
def _cleanup(self):
"""atexit handler to ensure llama-server is terminated."""
self._kill_process()
def _wait_for_health(self, timeout: float = 120.0, interval: float = 0.5) -> bool:
"""
Poll llama-server's /health endpoint until it responds 200.
Also monitors subprocess for early exit/crash.
"""
deadline = time.monotonic() + timeout
url = f"http://127.0.0.1:{self._port}/health"
while time.monotonic() < deadline:
# Check if process crashed
if self._process.poll() is not None:
# Give the drain thread a moment to collect final output
if self._stdout_thread is not None:
self._stdout_thread.join(timeout = 2)
output = "\n".join(self._stdout_lines[-50:])
logger.error(
f"llama-server exited with code {self._process.returncode}. "
f"Output: {output[:2000]}"
)
return False
try:
resp = httpx.get(url, timeout = 2.0)
if resp.status_code == 200:
return True
except (httpx.ConnectError, httpx.TimeoutException):
pass
time.sleep(interval)
logger.error(f"llama-server health check timed out after {timeout}s")
return False
# ── Message building (OpenAI format) ──────────────────────────
@staticmethod
def _parse_tool_calls_from_text(content: str) -> list[dict]:
"""
Parse tool calls from XML markup in content text.
Handles formats like:
<tool_call>{"name":"web_search","arguments":{"query":"..."}}</tool_call>
<tool_call><function=web_search><parameter=query>...</parameter></function></tool_call>
Closing tags (</tool_call>, </function>, </parameter>) are all optional
since models frequently omit them.
"""
tool_calls = []
# Pattern 1: JSON inside <tool_call> tags.
# Use balanced-brace extraction that skips braces inside JSON strings.
for m in _TC_JSON_START_RE.finditer(content):
brace_start = m.end() - 1 # position of the opening {
depth, i = 0, brace_start
in_string = False
while i < len(content):
ch = content[i]
if in_string:
if ch == "\\" and i + 1 < len(content):
i += 2 # skip escaped character
continue
if ch == '"':
in_string = False
elif ch == '"':
in_string = True
elif ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
break
i += 1
if depth == 0:
json_str = content[brace_start : i + 1]
try:
obj = json.loads(json_str)
tc = {
"id": f"call_{len(tool_calls)}",
"type": "function",
"function": {
"name": obj.get("name", ""),
"arguments": obj.get("arguments", {}),
},
}
if isinstance(tc["function"]["arguments"], dict):
tc["function"]["arguments"] = json.dumps(
tc["function"]["arguments"]
)
tool_calls.append(tc)
except (json.JSONDecodeError, ValueError):
pass
# Pattern 2: XML-style <function=name><parameter=key>value</parameter></function>
# All closing tags optional -- models frequently omit </parameter>,
# </function>, and/or </tool_call>.
if not tool_calls:
# Step 1: Find all <function=name> positions and extract their bodies.
# Body boundary: use only </tool_call> or next <function= as hard
# boundaries. We avoid using </function> as a boundary because
# code parameter values can contain that literal string.
# After extracting, we trim a trailing </function> if present.
func_starts = list(_TC_FUNC_START_RE.finditer(content))
for idx, fm in enumerate(func_starts):
func_name = fm.group(1)
body_start = fm.end()
# Hard boundaries: next <function= tag or </tool_call>
next_func = (
func_starts[idx + 1].start()
if idx + 1 < len(func_starts)
else len(content)
)
end_tag = _TC_END_TAG_RE.search(content[body_start:])
if end_tag:
body_end = body_start + end_tag.start()
else:
body_end = len(content)
body_end = min(body_end, next_func)
body = content[body_start:body_end]
# Trim trailing </function> if present (it's the real closing tag)
body = _TC_FUNC_CLOSE_RE.sub("", body)
# Step 2: Extract parameters from body.
# For single-parameter functions (the common case: code, command,
# query), use body end as the only boundary to avoid false matches
# on </parameter> inside code strings.
arguments = {}
param_starts = list(_TC_PARAM_START_RE.finditer(body))
if len(param_starts) == 1:
# Single parameter: value is everything from after the tag
# to end of body, trimming any trailing </parameter>.
pm = param_starts[0]
val = body[pm.end() :]
val = _TC_PARAM_CLOSE_RE.sub("", val)
arguments[pm.group(1)] = val.strip()
else:
for pidx, pm in enumerate(param_starts):
param_name = pm.group(1)
val_start = pm.end()
# Value ends at next <parameter= or end of body
next_param = (
param_starts[pidx + 1].start()
if pidx + 1 < len(param_starts)
else len(body)
)
val = body[val_start:next_param]
# Trim trailing </parameter> if present
val = _TC_PARAM_CLOSE_RE.sub("", val)
arguments[param_name] = val.strip()
tc = {
"id": f"call_{len(tool_calls)}",
"type": "function",
"function": {
"name": func_name,
"arguments": json.dumps(arguments),
},
}
tool_calls.append(tc)
return tool_calls
@staticmethod
def _build_openai_messages(
messages: list[dict],
image_b64: Optional[str] = None,
) -> list[dict]:
"""
Build OpenAI-format messages, optionally injecting an image_url
content part into the last user message for vision models.
If no image is provided, returns messages as-is.
"""
if not image_b64:
return messages
# Find the last user message and convert to multimodal content parts
result = [msg.copy() for msg in messages]
last_user_idx = None
for i, msg in enumerate(result):
if msg["role"] == "user":
last_user_idx = i
if last_user_idx is not None:
text_content = result[last_user_idx].get("content", "")
result[last_user_idx]["content"] = [
{"type": "text", "text": text_content},
{
"type": "image_url",
"image_url": {
"url": f"data:image/png;base64,{image_b64}",
},
},
]
return result
# ── Generation (proxy to llama-server) ────────────────────────
@staticmethod
def _iter_text_cancellable(
response: "httpx.Response",
cancel_event: Optional[threading.Event] = None,
) -> Generator[str, None, None]:
"""Iterate over an httpx streaming response with cancel support.
Checks cancel_event between chunks and on ReadTimeout. The
cancel watcher in _stream_with_retry also calls response.close()
on cancel, which unblocks iter_text() once the response exists.
During normal streaming llama-server sends tokens frequently,
so the cancel check between chunks is the primary mechanism.
"""
text_iter = response.iter_text()
while True:
if cancel_event is not None and cancel_event.is_set():
response.close()
return
try:
chunk = next(text_iter)
yield chunk
except StopIteration:
return
except httpx.ReadTimeout:
# No data within the timeout window -- just loop back
# and re-check cancel_event.
continue
@staticmethod
@contextlib.contextmanager
def _stream_with_retry(
client: "httpx.Client",
url: str,
payload: dict,
cancel_event: Optional[threading.Event] = None,
headers: Optional[dict] = None,
):
"""Open an httpx streaming POST with cancel support.
Sends the request once with a long read timeout (120 s) so
prompt processing (prefill) can finish without triggering a
retry storm. The previous 0.5 s timeout caused duplicate POST
requests every half second, forcing llama-server to restart
processing each time.
A background watcher thread provides cancel by closing the
response when cancel_event is set. Limitation: httpx does not
allow interrupting a blocked read from another thread before
the response object exists, so cancel during the initial
header wait (prefill phase) only takes effect once headers
arrive. After that, response.close() unblocks reads promptly.
In practice llama-server prefill is 1-5 s for typical prompts,
during which cancel is deferred -- still much better than the
old retry storm which made prefill slower.
"""
if cancel_event is not None and cancel_event.is_set():
raise GeneratorExit
# Background watcher: close the response if cancel is requested.
# Only effective after response headers arrive (httpx limitation).
_cancel_closed = threading.Event()
_response_ref: list = [None]
def _cancel_watcher():
while not _cancel_closed.is_set():
if cancel_event.wait(timeout = 0.3):
# Cancel requested. Keep polling until the response object
# exists so we can close it, or until the main thread
# finishes on its own (_cancel_closed is set in finally).
while not _cancel_closed.is_set():
r = _response_ref[0]
if r is not None:
try:
r.close()
return
except Exception as e:
logger.debug(
f"Error closing response in cancel watcher: {e}"
)
# Response not created yet -- wait briefly and retry
_cancel_closed.wait(timeout = 0.1)
return
watcher = None
if cancel_event is not None:
watcher = threading.Thread(
target = _cancel_watcher, daemon = True, name = "prefill-cancel"
)
watcher.start()
try:
# Long read timeout so prefill (prompt processing) can finish
# without triggering a retry storm. Cancel during both
# prefill and streaming is handled by the watcher thread
# which closes the response, unblocking any httpx read.
prefill_timeout = httpx.Timeout(
connect = 30,
read = 120.0,
write = 10,
pool = 10,
)
with client.stream(
"POST",
url,
json = payload,
timeout = prefill_timeout,
headers = headers,
) as response:
_response_ref[0] = response
if cancel_event is not None and cancel_event.is_set():
raise GeneratorExit
yield response
return
except (httpx.ReadError, httpx.RemoteProtocolError, httpx.CloseError):
# Response was closed by the cancel watcher
if cancel_event is not None and cancel_event.is_set():
raise GeneratorExit
raise
finally:
_cancel_closed.set()
def generate_chat_completion(
self,
messages: list[dict],
image_b64: Optional[str] = None,
temperature: float = 0.6,
top_p: float = 0.95,
top_k: int = 20,
min_p: float = 0.01,
max_tokens: Optional[int] = None,
repetition_penalty: float = 1.0,
presence_penalty: float = 0.0,
stop: Optional[list[str]] = None,
cancel_event: Optional[threading.Event] = None,
enable_thinking: Optional[bool] = None,
reasoning_effort: Optional[str] = None,
preserve_thinking: Optional[bool] = None,
) -> Generator[str | dict, None, None]:
"""
Send a chat completion request to llama-server and stream tokens back.
Uses /v1/chat/completions — llama-server handles chat template
application and vision (multimodal image_url parts) natively.
Yields cumulative text (matching InferenceBackend's convention).
"""
if not self.is_loaded:
raise RuntimeError("llama-server is not loaded")
openai_messages = self._build_openai_messages(messages, image_b64)
payload = {
"messages": openai_messages,
"stream": True,
"temperature": temperature,
"top_p": top_p,
"top_k": top_k if top_k >= 0 else 0,
"min_p": min_p,
"repeat_penalty": repetition_penalty,
"presence_penalty": presence_penalty,
}
# Pass enable_thinking / reasoning_effort / preserve_thinking per-request
_reasoning_kw = self._request_reasoning_kwargs(
enable_thinking, reasoning_effort, preserve_thinking
)
if _reasoning_kw is not None:
payload["chat_template_kwargs"] = _reasoning_kw
# Default cap to the model's effective context length when known,
# otherwise the conservative floor. The wall-clock backstop below
# keeps a stuck model from running indefinitely either way.
payload["max_tokens"] = (
max_tokens
if max_tokens is not None
else (self._effective_context_length or _DEFAULT_MAX_TOKENS_FLOOR)
)
payload["t_max_predict_ms"] = _DEFAULT_T_MAX_PREDICT_MS
if stop:
payload["stop"] = stop
payload["stream_options"] = {"include_usage": True}
url = f"{self.base_url}/v1/chat/completions"
cumulative = ""
in_thinking = False
_stream_done = False
_metadata_usage = None
_metadata_timings = None
try:
# _stream_with_retry uses a 120 s read timeout so prefill
# can finish. Cancel during streaming is handled by the
# watcher thread (closes the response on cancel_event).
stream_timeout = httpx.Timeout(connect = 10, read = 0.5, write = 10, pool = 10)
_auth_headers = (
{"Authorization": f"Bearer {self._api_key}"} if self._api_key else None
)
with httpx.Client(
timeout = stream_timeout, limits = httpx.Limits(max_keepalive_connections = 0)
) as client:
with self._stream_with_retry(
client,
url,
payload,
cancel_event,
headers = _auth_headers,
) as response:
if response.status_code != 200:
error_body = response.read().decode()
raise RuntimeError(
f"llama-server returned {response.status_code}: {error_body}"
)
buffer = ""
has_content_tokens = False
reasoning_text = ""
for raw_chunk in self._iter_text_cancellable(
response, cancel_event
):
buffer += raw_chunk
while "\n" in buffer:
line, buffer = buffer.split("\n", 1)
line = line.strip()
if not line:
continue
if line == "data: [DONE]":
if in_thinking:
if has_content_tokens:
# Real thinking + content: close the tag
cumulative += "</think>"
yield cumulative
else:
# Only reasoning_content, no content tokens:
# the model put its entire reply in reasoning
# (e.g. Qwen3 always-think mode). Show it
# as the main response, not as a thinking block.
cumulative = reasoning_text
yield cumulative
_stream_done = True
break # exit inner while
if not line.startswith("data: "):
continue
try:
data = json.loads(line[6:])
# Capture server timings/usage from final chunks
_chunk_timings = data.get("timings")
if _chunk_timings:
_metadata_timings = _chunk_timings
_chunk_usage = data.get("usage")
if _chunk_usage:
_metadata_usage = _chunk_usage
choices = data.get("choices", [])
if choices:
delta = choices[0].get("delta", {})
# Handle reasoning/thinking tokens
# llama-server sends these as "reasoning_content"
# Wrap in <think> tags for the frontend parser
reasoning = delta.get("reasoning_content", "")
if reasoning:
reasoning_text += reasoning
if not in_thinking:
cumulative += "<think>"
in_thinking = True
cumulative += reasoning
yield cumulative
token = delta.get("content", "")
if token:
has_content_tokens = True
if in_thinking:
cumulative += "</think>"
in_thinking = False
cumulative += token
yield cumulative
except json.JSONDecodeError:
logger.debug(
f"Skipping malformed SSE line: {line[:100]}"
)
if _stream_done:
break # exit outer for
if _metadata_usage or _metadata_timings:
yield {
"type": "metadata",
"usage": _metadata_usage,
"timings": _metadata_timings,
}
except httpx.ConnectError:
raise RuntimeError("Lost connection to llama-server")
except Exception as e:
if cancel_event is not None and cancel_event.is_set():
return
raise
# ── Tool-calling agentic loop ──────────────────────────────
def generate_chat_completion_with_tools(
self,
messages: list[dict],
tools: list[dict],
temperature: float = 0.6,
top_p: float = 0.95,
top_k: int = 20,
min_p: float = 0.01,
max_tokens: Optional[int] = None,
repetition_penalty: float = 1.0,
presence_penalty: float = 0.0,
stop: Optional[list[str]] = None,
cancel_event: Optional[threading.Event] = None,
enable_thinking: Optional[bool] = None,
reasoning_effort: Optional[str] = None,
preserve_thinking: Optional[bool] = None,
max_tool_iterations: int = 25,
auto_heal_tool_calls: bool = True,
tool_call_timeout: int = 300,
session_id: Optional[str] = None,
) -> Generator[dict, None, None]:
"""
Agentic loop: let the model call tools, execute them, and continue.
Yields dicts with:
{"type": "status", "text": "Searching: ..."/"Reading: ..."} -- tool status updates
{"type": "content", "text": "token"} -- streamed content tokens (cumulative)
{"type": "reasoning", "text": "token"} -- streamed reasoning tokens (cumulative)
"""
from core.inference.tools import execute_tool
if not self.is_loaded:
raise RuntimeError("llama-server is not loaded")
conversation = list(messages)
url = f"{self.base_url}/v1/chat/completions"
_accumulated_completion_tokens = 0
_accumulated_predicted_ms = 0.0
_accumulated_predicted_n = 0
def _strip_tool_markup(text: str, *, final: bool = False) -> str:
if not auto_heal_tool_calls:
return text
patterns = _TOOL_ALL_PATS if final else _TOOL_CLOSED_PATS
for pat in patterns:
text = pat.sub("", text)
return text.strip() if final else text
# XML prefixes that signal a tool call in content.
# Empty when auto_heal is disabled so the buffer never
# speculatively holds content for XML detection.
_TOOL_XML_SIGNALS = (
("<tool_call>", "<function=") if auto_heal_tool_calls else ()
)
_MAX_BUFFER_CHARS = 32
# ── Duplicate tool-call detection ────────────────────────
# Track recent (tool_name, arguments) hashes to detect loops
# where the model repeats the exact same call. Retries after
# a transient failure are allowed (only block when the previous
# identical call succeeded).
_tool_call_history: list[tuple[str, bool]] = [] # (key, failed)
# ── Re-prompt on plan-without-action ─────────────────
# When the model describes what it intends to do (forward-looking
# language) without actually calling a tool, re-prompt once.
# Only triggers on responses that signal intent/planning -- a
# direct answer like "4" or "Hello!" will not match.
# Pattern is compiled once at module level (_INTENT_SIGNAL).
_reprompt_count = 0
# Reserve extra iterations for re-prompts so they don't
# consume the caller's tool-call budget. Only add the
# extra slot when tool iterations are actually allowed.
_extra = _MAX_REPROMPTS if max_tool_iterations > 0 else 0
for iteration in range(max_tool_iterations + _extra):
if cancel_event is not None and cancel_event.is_set():
return
# Build payload -- stream: True so we detect tool signals
# in the first 1-2 chunks without a non-streaming penalty.
payload = {
"messages": conversation,
"stream": True,
"stream_options": {"include_usage": True},
"temperature": temperature,
"top_p": top_p,
"top_k": top_k if top_k >= 0 else 0,
"min_p": min_p,
"repeat_penalty": repetition_penalty,
"presence_penalty": presence_penalty,
"tools": tools,
"tool_choice": "auto",
}
_reasoning_kw = self._request_reasoning_kwargs(
enable_thinking, reasoning_effort, preserve_thinking
)
if _reasoning_kw is not None:
payload["chat_template_kwargs"] = _reasoning_kw
payload["max_tokens"] = (
max_tokens
if max_tokens is not None
else (self._effective_context_length or _DEFAULT_MAX_TOKENS_FLOOR)
)
payload["t_max_predict_ms"] = _DEFAULT_T_MAX_PREDICT_MS
if stop:
payload["stop"] = stop
try:
_auth_headers = (
{"Authorization": f"Bearer {self._api_key}"}
if self._api_key
else None
)
# ── Speculative buffer state machine ──────────────────
# BUFFERING: accumulating content, checking for tool signals
# STREAMING: no tool detected, yielding tokens to caller
# DRAINING: tool signal found, silently consuming rest
_S_BUFFERING = 0
_S_STREAMING = 1
_S_DRAINING = 2
detect_state = _S_BUFFERING
content_buffer = "" # Raw content held during BUFFERING
content_accum = "" # All content tokens (for tool parsing)
reasoning_accum = ""
cumulative_display = "" # Cumulative text yielded (with <think>)
in_thinking = False
has_content_tokens = False
tool_calls_acc = {} # Structured delta.tool_calls fragments
has_structured_tc = False
_iter_usage = None
_iter_timings = None
_stream_done = False
_last_emitted = ""
stream_timeout = httpx.Timeout(
connect = 10,
read = 0.5,
write = 10,
pool = 10,
)
with httpx.Client(
timeout = stream_timeout,
limits = httpx.Limits(max_keepalive_connections = 0),
) as client:
with self._stream_with_retry(
client,
url,
payload,
cancel_event,
headers = _auth_headers,
) as response:
if response.status_code != 200:
error_body = response.read().decode()
raise RuntimeError(
f"llama-server returned {response.status_code}: "
f"{error_body}"
)
raw_buf = ""
for raw_chunk in self._iter_text_cancellable(
response,
cancel_event,
):
raw_buf += raw_chunk
while "\n" in raw_buf:
line, raw_buf = raw_buf.split("\n", 1)
line = line.strip()
if not line:
continue
if line == "data: [DONE]":
# Flush thinking state for STREAMING
if detect_state == _S_STREAMING and in_thinking:
if has_content_tokens:
cumulative_display += "</think>"
yield {
"type": "content",
"text": _strip_tool_markup(
cumulative_display,
final = True,
),
}
else:
cumulative_display = reasoning_accum
yield {
"type": "content",
"text": cumulative_display,
}
_stream_done = True
break # exit inner while
if not line.startswith("data: "):
continue
try:
chunk_data = json.loads(line[6:])
_ct = chunk_data.get("timings")
if _ct:
_iter_timings = _ct
_cu = chunk_data.get("usage")
if _cu:
_iter_usage = _cu
choices = chunk_data.get("choices", [])
if not choices:
continue
delta = choices[0].get("delta", {})
# ── Structured tool_calls ──
tc_deltas = delta.get("tool_calls")
if tc_deltas:
# Once visible content has been
# emitted, do not reclassify this
# turn as a tool call.
if _last_emitted:
continue
has_structured_tc = True
detect_state = _S_DRAINING
for tc_d in tc_deltas:
idx = tc_d.get("index", 0)
if idx not in tool_calls_acc:
tool_calls_acc[idx] = {
"id": tc_d.get("id", f"call_{idx}"),
"type": "function",
"function": {
"name": "",
"arguments": "",
},
}
elif tc_d.get("id"):
# Update ID if real one
# arrives on a later delta
tool_calls_acc[idx]["id"] = tc_d["id"]
func = tc_d.get("function", {})
if func.get("name"):
tool_calls_acc[idx]["function"][
"name"
] += func["name"]
if func.get("arguments"):
tool_calls_acc[idx]["function"][
"arguments"
] += func["arguments"]
continue
# ── Reasoning tokens ──
# Only yield in STREAMING state. In BUFFERING
# and DRAINING, accumulate silently so we don't
# corrupt the consumer's prev_text tracker
# (routes/inference.py never resets prev_text
# between tool iterations).
reasoning = delta.get("reasoning_content", "")
if reasoning:
reasoning_accum += reasoning
if detect_state == _S_STREAMING:
if not in_thinking:
cumulative_display += "<think>"
in_thinking = True
cumulative_display += reasoning
yield {
"type": "content",
"text": cumulative_display,
}
# ── Content tokens ──
token = delta.get("content", "")
if token:
has_content_tokens = True
content_accum += token
if detect_state == _S_DRAINING:
pass # accumulate silently
elif detect_state == _S_STREAMING:
if in_thinking:
cumulative_display += "</think>"
in_thinking = False
cumulative_display += token
cleaned = _strip_tool_markup(
cumulative_display,
)
if len(cleaned) > len(_last_emitted):
_last_emitted = cleaned
yield {
"type": "content",
"text": cleaned,
}
elif detect_state == _S_BUFFERING:
content_buffer += token
stripped_buf = content_buffer.lstrip()
if not stripped_buf:
continue
# Check tool signal prefixes
is_prefix = False
is_match = False
for sig in _TOOL_XML_SIGNALS:
if stripped_buf.startswith(sig):
is_match = True
break
if sig.startswith(stripped_buf):
is_prefix = True
break
if is_match:
detect_state = _S_DRAINING
elif (
is_prefix
and len(stripped_buf)
< _MAX_BUFFER_CHARS
):
pass # keep buffering
else:
# Not a tool -- flush buffer
detect_state = _S_STREAMING
# Flush any reasoning accumulated
# during BUFFERING phase
if reasoning_accum:
cumulative_display += "<think>"
cumulative_display += (
reasoning_accum
)
cumulative_display += "</think>"
cumulative_display += content_buffer
cleaned = _strip_tool_markup(
cumulative_display,
)
if len(cleaned) > len(_last_emitted):
_last_emitted = cleaned
yield {
"type": "content",
"text": cleaned,
}
except json.JSONDecodeError:
logger.debug(
f"Skipping malformed SSE line: " f"{line[:100]}"
)
if _stream_done:
break # exit outer for
# ── Resolve BUFFERING at stream end ──
if detect_state == _S_BUFFERING:
stripped_buf = content_buffer.lstrip()
if (
stripped_buf
and auto_heal_tool_calls
and any(s in stripped_buf for s in _TOOL_XML_SIGNALS)
):
detect_state = _S_DRAINING
elif content_accum or reasoning_accum:
detect_state = _S_STREAMING
if content_buffer:
# Flush any reasoning accumulated first
if reasoning_accum:
cumulative_display += "<think>"
cumulative_display += reasoning_accum
cumulative_display += "</think>"
cumulative_display += content_buffer
yield {
"type": "content",
"text": _strip_tool_markup(
cumulative_display,
final = True,
),
}
elif reasoning_accum and not has_content_tokens:
# Reasoning-only response (no content tokens):
# show reasoning as plain text, matching
# the final streaming pass behavior for
# models that put everything in reasoning.
cumulative_display = reasoning_accum
yield {
"type": "content",
"text": cumulative_display,
}
else:
return
# ── STREAMING path: no tool call ──
if detect_state == _S_STREAMING:
# Safety net: check for XML tool signals in content.
# The route layer resets prev_text on tool_start, so
# post-tool synthesis streams correctly even if
# content was already emitted before the tool XML.
_safety_tc = None
if auto_heal_tool_calls and any(
s in content_accum for s in _TOOL_XML_SIGNALS
):
_safety_tc = self._parse_tool_calls_from_text(
content_accum,
)
if not _safety_tc:
# ── Re-prompt on plan-without-action ──
# If the model described what it intends to do
# (forward-looking language) without calling any
# tool, nudge it to act. Only fires once per
# request and only on short responses that
# contain intent signals -- a direct answer
# like "4" or "Hello!" won't trigger this.
# Use content if available, otherwise fall back
# to reasoning text (reasoning-only stalls).
_stripped = content_accum.strip()
if not _stripped:
_stripped = reasoning_accum.strip()
if (
tools
and _reprompt_count < _MAX_REPROMPTS
and 0 < len(_stripped) < _REPROMPT_MAX_CHARS
and _INTENT_SIGNAL.search(_stripped)
):
_reprompt_count += 1
logger.info(
f"Re-prompt {_reprompt_count}/{_MAX_REPROMPTS}: "
f"model responded without calling tools "
f"({len(_stripped)} chars)"
)
conversation.append(
{
"role": "assistant",
"content": _stripped,
}
)
conversation.append(
{
"role": "user",
"content": (
"STOP. Do NOT write code or explain. "
"You MUST call a tool NOW. "
"Call web_search or python immediately."
),
}
)
# Accumulate tokens and timing from this iteration
_fu_r = _iter_usage or {}
_accumulated_completion_tokens += _fu_r.get(
"completion_tokens", 0
)
_it_r = _iter_timings or {}
_accumulated_predicted_ms += _it_r.get("predicted_ms", 0)
_accumulated_predicted_n += _it_r.get("predicted_n", 0)
yield {"type": "status", "text": ""}
continue
# Content was already streamed. Yield metadata.
yield {"type": "status", "text": ""}
_fu = _iter_usage or {}
_fc = _fu.get("completion_tokens", 0)
_fp = _fu.get("prompt_tokens", 0)
_tc = _fc + _accumulated_completion_tokens
if (
_iter_usage
or _iter_timings
or _accumulated_completion_tokens
):
_mt = dict(_iter_timings) if _iter_timings else {}
if _accumulated_predicted_ms or _accumulated_predicted_n:
_mt["predicted_ms"] = (
_mt.get("predicted_ms", 0)
+ _accumulated_predicted_ms
)
_tn = (
_mt.get("predicted_n", 0) + _accumulated_predicted_n
)
_mt["predicted_n"] = _tn
_tms = _mt["predicted_ms"]
if _tms > 0:
_mt["predicted_per_second"] = _tn / (_tms / 1000.0)
yield {
"type": "metadata",
"usage": {
"prompt_tokens": _fp,
"completion_tokens": _tc,
"total_tokens": _fp + _tc,
},
"timings": _mt,
}
return
# Safety net caught tool XML -- treat as tool call
tool_calls = _safety_tc
content_text = _strip_tool_markup(
content_accum,
final = True,
)
logger.info(
f"Safety net: parsed {len(tool_calls)} tool call(s) "
f"from streamed content"
)
else:
# ── DRAINING path: assemble tool_calls ──
tool_calls = None
content_text = content_accum
if has_structured_tc:
# Filter out incomplete fragments (e.g. from
# truncation by max_tokens or disconnect).
tool_calls = [
tool_calls_acc[i]
for i in sorted(tool_calls_acc)
if (
tool_calls_acc[i]
.get("function", {})
.get("name", "")
.strip()
)
] or None
if (
not tool_calls
and auto_heal_tool_calls
and any(s in content_accum for s in _TOOL_XML_SIGNALS)
):
tool_calls = self._parse_tool_calls_from_text(
content_accum,
)
if tool_calls and not has_structured_tc:
content_text = _strip_tool_markup(
content_text,
final = True,
)
if tool_calls:
logger.info(
f"Parsed {len(tool_calls)} tool call(s) from "
f"{'structured delta' if has_structured_tc else 'content text'}"
)
if not tool_calls:
# DRAINING but no tool calls (false positive).
# Merge accumulated metrics from prior tool
# iterations so they are not silently dropped.
yield {"type": "status", "text": ""}
if content_accum:
# Strip leaked tool-call XML before yielding
content_accum = _strip_tool_markup(
content_accum, final = True
)
if content_accum:
yield {"type": "content", "text": content_accum}
_fu = _iter_usage or {}
_fc = _fu.get("completion_tokens", 0)
_fp = _fu.get("prompt_tokens", 0)
_tc = _fc + _accumulated_completion_tokens
if (
_iter_usage
or _iter_timings
or _accumulated_completion_tokens
):
_mt = dict(_iter_timings) if _iter_timings else {}
if _accumulated_predicted_ms or _accumulated_predicted_n:
_mt["predicted_ms"] = (
_mt.get("predicted_ms", 0)
+ _accumulated_predicted_ms
)
_tn = (
_mt.get("predicted_n", 0) + _accumulated_predicted_n
)
_mt["predicted_n"] = _tn
_tms = _mt["predicted_ms"]
if _tms > 0:
_mt["predicted_per_second"] = _tn / (_tms / 1000.0)
yield {
"type": "metadata",
"usage": {
"prompt_tokens": _fp,
"completion_tokens": _tc,
"total_tokens": _fp + _tc,
},
"timings": _mt,
}
return
# ── Execute tool calls ──
_accumulated_completion_tokens += (_iter_usage or {}).get(
"completion_tokens", 0
)
_it = _iter_timings or {}
_accumulated_predicted_ms += _it.get("predicted_ms", 0)
_accumulated_predicted_n += _it.get("predicted_n", 0)
assistant_msg = {"role": "assistant", "content": content_text}
if tool_calls:
assistant_msg["tool_calls"] = tool_calls
conversation.append(assistant_msg)
for tc in tool_calls or []:
func = tc.get("function", {})
tool_name = func.get("name", "")
raw_args = func.get("arguments", {})
if isinstance(raw_args, str):
try:
arguments = json.loads(raw_args)
except (json.JSONDecodeError, ValueError):
if auto_heal_tool_calls:
arguments = {"query": raw_args}
else:
arguments = {"raw": raw_args}
else:
arguments = raw_args
if tool_name == "web_search":
_ws_url = (arguments.get("url") or "").strip()
if _ws_url:
_parsed = urlparse(_ws_url)
if _parsed.scheme in ("http", "https") and _parsed.hostname:
_ws_host = _parsed.hostname
if _ws_host.startswith("www."):
_ws_host = _ws_host[4:]
status_text = f"Reading: {_ws_host}"
else:
status_text = "Reading page..."
else:
status_text = f"Searching: {arguments.get('query', '')}"
elif tool_name == "python":
preview = (
(arguments.get("code") or "").strip().split("\n")[0][:60]
)
status_text = (
f"Running Python: {preview}"
if preview
else "Running Python..."
)
elif tool_name == "terminal":
cmd_preview = (arguments.get("command") or "")[:60]
status_text = (
f"Running: {cmd_preview}"
if cmd_preview
else "Running command..."
)
else:
status_text = f"Calling: {tool_name}"
yield {"type": "status", "text": status_text}
yield {
"type": "tool_start",
"tool_name": tool_name,
"tool_call_id": tc.get("id", ""),
"arguments": arguments,
}
# ── Duplicate call detection ──────────────
# str(dict) is stable here: arguments always comes from
# json.loads on the same model output within one request,
# so insertion order is deterministic (Python 3.7+).
_tc_key = tool_name + str(arguments)
_prev = _tool_call_history[-1] if _tool_call_history else None
if _prev and _prev[0] == _tc_key and not _prev[1]:
result = (
"You already made this exact call. "
"Do not repeat the same tool call. "
"Try a different approach: fetch a URL "
"from previous results, use Python to "
"process data you already have, or "
"provide your final answer now."
)
else:
_effective_timeout = (
None if tool_call_timeout >= 9999 else tool_call_timeout
)
result = execute_tool(
tool_name,
arguments,
cancel_event = cancel_event,
timeout = _effective_timeout,
session_id = session_id,
)
yield {
"type": "tool_end",
"tool_name": tool_name,
"tool_call_id": tc.get("id", ""),
"result": result,
}
# Nudge model to try a different approach on errors
_error_prefixes = (
"Error",
"Search failed",
"Execution error",
"Blocked:",
"Exit code",
"Failed to fetch",
"Failed to resolve",
"No query provided",
)
_is_error = isinstance(result, str) and result.lstrip().startswith(
_error_prefixes
)
_tool_call_history.append((_tc_key, _is_error))
# Strip image sentinel before feeding result to the LLM
# (the full result with sentinel is still yielded via
# tool_end so the frontend can extract image paths).
_result_content = result
if "\n__IMAGES__:" in _result_content:
_result_content = _result_content.rsplit("\n__IMAGES__:", 1)[0]
if _is_error:
_result_content = (
_result_content + "\n\nThe tool call encountered an issue. "
"Please try a different approach or rephrase your request."
)
tool_msg = {
"role": "tool",
"name": tool_name,
"content": _result_content,
}
tool_call_id = tc.get("id")
if tool_call_id:
tool_msg["tool_call_id"] = tool_call_id
conversation.append(tool_msg)
# Clear tool status badge before next generation iteration
yield {"type": "status", "text": ""}
# Continue the loop to let model respond with context
continue
except httpx.ConnectError:
raise RuntimeError("Lost connection to llama-server")
except Exception as e:
if cancel_event is not None and cancel_event.is_set():
return
raise
# ── Tool iteration cap reached -- synthesize final answer ──
# The model used all iterations without producing a final text
# response. Inject a nudge so the final streaming pass produces
# a useful answer instead of continuing to request tools.
if max_tool_iterations > 0:
conversation.append(
{
"role": "user",
"content": (
"You have used all available tool calls. Based on "
"everything you have found so far, provide your final "
"answer now. Do not call any more tools."
),
}
)
# Clear status
yield {"type": "status", "text": ""}
# Final streaming pass with the full conversation context
stream_payload = {
"messages": conversation,
"stream": True,
"temperature": temperature,
"top_p": top_p,
"top_k": top_k if top_k >= 0 else 0,
"min_p": min_p,
"repeat_penalty": repetition_penalty,
"presence_penalty": presence_penalty,
}
_reasoning_kw = self._request_reasoning_kwargs(
enable_thinking, reasoning_effort, preserve_thinking
)
if _reasoning_kw is not None:
stream_payload["chat_template_kwargs"] = _reasoning_kw
stream_payload["max_tokens"] = (
max_tokens
if max_tokens is not None
else (self._effective_context_length or _DEFAULT_MAX_TOKENS_FLOOR)
)
stream_payload["t_max_predict_ms"] = _DEFAULT_T_MAX_PREDICT_MS
if stop:
stream_payload["stop"] = stop
stream_payload["stream_options"] = {"include_usage": True}
cumulative = ""
_last_emitted = ""
in_thinking = False
has_content_tokens = False
reasoning_text = ""
_metadata_usage = None
_metadata_timings = None
_stream_done = False
try:
stream_timeout = httpx.Timeout(connect = 10, read = 0.5, write = 10, pool = 10)
_auth_headers = (
{"Authorization": f"Bearer {self._api_key}"} if self._api_key else None
)
with httpx.Client(
timeout = stream_timeout, limits = httpx.Limits(max_keepalive_connections = 0)
) as client:
with self._stream_with_retry(
client,
url,
stream_payload,
cancel_event,
headers = _auth_headers,
) as response:
if response.status_code != 200:
error_body = response.read().decode()
raise RuntimeError(
f"llama-server returned {response.status_code}: {error_body}"
)
buffer = ""
for raw_chunk in self._iter_text_cancellable(
response, cancel_event
):
buffer += raw_chunk
while "\n" in buffer:
line, buffer = buffer.split("\n", 1)
line = line.strip()
if not line:
continue
if line == "data: [DONE]":
if in_thinking:
if has_content_tokens:
cumulative += "</think>"
yield {
"type": "content",
"text": _strip_tool_markup(
cumulative, final = True
),
}
else:
cumulative = reasoning_text
yield {"type": "content", "text": cumulative}
_stream_done = True
break # exit inner while
if not line.startswith("data: "):
continue
try:
chunk_data = json.loads(line[6:])
# Capture server timings/usage from final chunks
_chunk_timings = chunk_data.get("timings")
if _chunk_timings:
_metadata_timings = _chunk_timings
_chunk_usage = chunk_data.get("usage")
if _chunk_usage:
_metadata_usage = _chunk_usage
choices = chunk_data.get("choices", [])
if choices:
delta = choices[0].get("delta", {})
reasoning = delta.get("reasoning_content", "")
if reasoning:
reasoning_text += reasoning
if not in_thinking:
cumulative += "<think>"
in_thinking = True
cumulative += reasoning
yield {"type": "content", "text": cumulative}
token = delta.get("content", "")
if token:
has_content_tokens = True
if in_thinking:
cumulative += "</think>"
in_thinking = False
cumulative += token
cleaned = _strip_tool_markup(cumulative)
# Only emit when cleaned text grows (monotonic).
if len(cleaned) > len(_last_emitted):
_last_emitted = cleaned
yield {"type": "content", "text": cleaned}
except json.JSONDecodeError:
logger.debug(
f"Skipping malformed SSE line: {line[:100]}"
)
if _stream_done:
break # exit outer for
_final_usage = _metadata_usage or {}
_final_completion = _final_usage.get("completion_tokens", 0)
_final_prompt = _final_usage.get("prompt_tokens", 0)
_total_completion = (
_final_completion + _accumulated_completion_tokens
)
if _metadata_usage or _metadata_timings:
_merged_timings = (
dict(_metadata_timings) if _metadata_timings else {}
)
if _accumulated_predicted_ms or _accumulated_predicted_n:
_merged_timings["predicted_ms"] = (
_merged_timings.get("predicted_ms", 0)
+ _accumulated_predicted_ms
)
_total_predicted_n = (
_merged_timings.get("predicted_n", 0)
+ _accumulated_predicted_n
)
_merged_timings["predicted_n"] = _total_predicted_n
_total_predicted_ms = _merged_timings["predicted_ms"]
if _total_predicted_ms > 0:
_merged_timings["predicted_per_second"] = (
_total_predicted_n / (_total_predicted_ms / 1000.0)
)
yield {
"type": "metadata",
"usage": {
"prompt_tokens": _final_prompt,
"completion_tokens": _total_completion,
"total_tokens": _final_prompt + _total_completion,
},
"timings": _merged_timings,
}
except httpx.ConnectError:
raise RuntimeError("Lost connection to llama-server")
except Exception as e:
if cancel_event is not None and cancel_event.is_set():
return
raise
# ── TTS support ────────────────────────────────────────────
def detect_audio_type(self) -> Optional[str]:
"""Detect audio/TTS codec by probing the loaded model's vocabulary."""
if not self.is_loaded:
return None
try:
_auth_headers = (
{"Authorization": f"Bearer {self._api_key}"} if self._api_key else None
)
with httpx.Client(timeout = 10, headers = _auth_headers) as client:
def _detok(tid: int) -> str:
r = client.post(
f"{self.base_url}/detokenize", json = {"tokens": [tid]}
)
return r.json().get("content", "") if r.status_code == 200 else ""
def _tok(text: str) -> list[int]:
r = client.post(
f"{self.base_url}/tokenize",
json = {"content": text, "add_special": False},
)
return r.json().get("tokens", []) if r.status_code == 200 else []
# Check codec-specific tokens (not generic ones that may exist in non-audio models)
if "<custom_token_" in _detok(128258) and "<custom_token_" in _detok(
128259
):
return "snac"
if len(_tok("<|AUDIO|>")) == 1 and len(_tok("<|audio_eos|>")) == 1:
return "csm"
if len(_tok("<|startoftranscript|>")) == 1:
return "whisper"
if (
len(_tok("<|bicodec_semantic_0|>")) == 1
and len(_tok("<|bicodec_global_0|>")) == 1
):
return "bicodec"
if len(_tok("<|c1_0|>")) == 1 and len(_tok("<|c2_0|>")) == 1:
return "dac"
except Exception as e:
logger.debug(f"Audio type detection failed: {e}")
return None
# Prompt format per codec: (template, stop_tokens, needs_token_ids)
# Matches prompts in InferenceBackend._generate_snac/bicodec/dac
_TTS_PROMPTS = {
"snac": (
"<custom_token_3>{text}<|eot_id|><custom_token_4>",
["<custom_token_2>"],
True,
),
"bicodec": (
"<|task_tts|><|start_content|>{text}<|end_content|><|start_global_token|>",
["<|im_end|>", "</s>"],
False,
),
"dac": (
"<|im_start|>\n<|text_start|>{text}<|text_end|>\n<|audio_start|><|global_features_start|>\n",
["<|im_end|>", "<|audio_end|>"],
False,
),
}
_codec_mgr = None # Shared AudioCodecManager instance
def init_audio_codec(self, audio_type: str) -> None:
"""Load the audio codec at model load time (mirrors non-GGUF path)."""
import torch
from core.inference.audio_codecs import AudioCodecManager
if LlamaCppBackend._codec_mgr is None:
LlamaCppBackend._codec_mgr = AudioCodecManager()
device = "cuda" if torch.cuda.is_available() else "cpu"
model_repo_path = None
# BiCodec needs a repo with BiCodec/ weights — download canonical SparkTTS
if audio_type == "bicodec":
from huggingface_hub import snapshot_download
import os
repo_path = snapshot_download(
"unsloth/Spark-TTS-0.5B", local_dir = "Spark-TTS-0.5B"
)
model_repo_path = os.path.abspath(repo_path)
LlamaCppBackend._codec_mgr.load_codec(
audio_type, device, model_repo_path = model_repo_path
)
logger.info(f"Loaded audio codec for GGUF TTS: {audio_type}")
def generate_audio_response(
self,
text: str,
audio_type: str,
temperature: float = 0.6,
top_p: float = 0.95,
top_k: int = 50,
min_p: float = 0.0,
max_new_tokens: int = 2048,
repetition_penalty: float = 1.1,
) -> tuple:
"""
Generate TTS audio via llama-server /completion + codec decoding.
Returns (wav_bytes, sample_rate).
"""
if audio_type not in self._TTS_PROMPTS:
raise RuntimeError(f"GGUF TTS does not support '{audio_type}' codec.")
tpl, stop, need_ids = self._TTS_PROMPTS[audio_type]
payload: dict = {
"prompt": tpl.format(text = text),
"stream": False,
"n_predict": max_new_tokens,
"temperature": temperature,
"top_p": top_p,
"top_k": top_k if top_k >= 0 else 0,
"min_p": min_p,
"repeat_penalty": repetition_penalty,
}
if stop:
payload["stop"] = stop
if need_ids:
payload["n_probs"] = 1
_auth_headers = (
{"Authorization": f"Bearer {self._api_key}"} if self._api_key else None
)
with httpx.Client(
timeout = httpx.Timeout(300, connect = 10), headers = _auth_headers
) as client:
resp = client.post(f"{self.base_url}/completion", json = payload)
if resp.status_code != 200:
raise RuntimeError(
f"llama-server returned {resp.status_code}: {resp.text}"
)
data = resp.json()
token_ids = (
[p["id"] for p in data.get("completion_probabilities", []) if "id" in p]
if need_ids
else None
)
import torch
device = "cuda" if torch.cuda.is_available() else "cpu"
return LlamaCppBackend._codec_mgr.decode(
audio_type, device, token_ids = token_ids, text = data.get("content", "")
)