unsloth/unsloth_cli/commands/studio.py
Daniel Han 649b9f7808
Studio: expose --parallel / -np flag on unsloth studio run (#5737)
* Studio: expose --parallel / -np on `unsloth studio run`

The CLI was hardcoding `llama_parallel_slots=4` in `run_kwargs` at
`unsloth_cli/commands/studio.py`, leaving users unable to tune the
concurrent decode slot count even though the engine, KV-cache math,
and `studio.backend.run.run_server(llama_parallel_slots=...)`
plumbing all already accepted any N. This change adds a `--parallel`
/ `--n-parallel` / `-np` typer option (default 4 -- matches the
previous hardcoded value), forwards it into `run_kwargs`, and pins
the new surface with 4 unit tests.

Per-request state in `routes/inference.py` is already isolated
(`cancel_event` and `prev_text` are per-request locals in every
streaming handler; the `_lock` / `_serial_load_lock` only wrap
load/unload, not chat completions), so no concurrency refactor is
needed alongside this -- the engine layer already handles N
concurrent requests on one loaded model when llama-server is told
to.

Range guards: 1 <= N <= 64. With higher N each slot gets ctx/N KV
cache; users tuning this should be aware that per-call context
shrinks proportionally.

`unsloth studio` (the bare default command, no subcommand) still
defaults to llama_parallel_slots=1 via `run_server`'s own default;
this PR does not change that path -- it only exposes the knob on the
one-liner `studio run` command that already silently used 4.

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

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

* Forward --parallel through venv re-exec and drop colliding short aliases

`unsloth studio run` re-execs into the Studio venv when invoked from
outside it (the common path). The arg-builder forwards every typer
option but the new --parallel, so the child re-execs at the default 4
and any user value is silently dropped. Worse: pre-PR users who
already pass `-np N` as a pass-through extra (where llama.cpp's
last-wins parsing made it stick) silently lose N after this PR lands.
Forward --parallel explicitly in the re-exec arg list.

While auditing the re-exec path, also drop the colliding 1-char
short aliases -m (--model) and -f (--frontend) plus the redundant
-hfr. Click's short-option clustering had been silently mis-parsing
~11 llama-server short flags via the pass-through path: -fa as
`-f a`, -mg 0 as `-m g` + stray 0, -fitt 1024 as `-f itt` + stray
1024, -hff path as `-f f` + stray `-h path`, -cmoe / -cram / -sm /
-ncmoe etc. The docstring promise ("any flag this command does not
recognize is forwarded verbatim") was silently violated.

-hf (2-char) is kept because Click treats multi-char shorts atomically
(no clustering of -hff / -hfv / -hffv / -hft) and -hf is documented
in basics/api/README.md. --model / --hf-repo / --frontend long forms
all unchanged. studio_default keeps -f because it has no pass-through.

Tests:
- test_studio_run_parallel_flag.py: 8 new re-exec coverage cases
  (all 3 aliases, 3 platforms via sys.platform mock, pre-PR `-np`
  regression, mixed with pass-through extras).
- test_studio_run_short_alias_clashes.py (new): surface checks that
  the removed shorts cannot reappear, plus 11 parametrized cases
  proving each previously-broken llama-server short flag now passes
  through verbatim, plus a happy-path test that documented -hf still
  works for `org/repo:variant` syntax.

All 27 tests pass. Negative test (revert either fix) shows the new
tests catch the regression.

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

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

* Fix stale studio run docstring describing rejected llama-server flags

The pre-PR docstring listed --port, -c / --ctx-size, --api-key, -ngl,
--jinja, --flash-attn, --no-context-shift as "rejected with HTTP 400",
but only --port and --api-key (plus other networking / auth / model
identity / single-model UI flags) are actually in
studio/backend/core/inference/llama_server_args.py's denylist. -c /
-ngl / --jinja / --flash-attn / --no-context-shift are pass-through
and last-wins-override Studio's auto-set value.

Rewrite the docstring to match the real denylist groups and point at
the canonical source. Also add --parallel to one of the examples now
that it is a first-class flag.

* ci: broaden Linux + narrow Windows llama.cpp runtime patterns + trim #5741 comments (#5746)

* ci: broaden Linux llama.cpp runtime pattern to lib*.so*

#5741 patched the explicit Linux pattern list to add
``libllama-*-impl.so*`` after ggml-org/llama.cpp#23462 (between
b9279 and b9283) split each binary's entry code into a paired
``lib<binary>-impl.so`` shared library. Same class of upstream
repackaging will hit us again whenever a new shared lib is added.

Mirror what macOS already does and replace the per-lib list with a
single ``lib*.so*`` glob. ``copy_globs`` (line 3614) unions
patterns, so the per-variant ``libggml-cuda.so*`` / ``libggml-hip.so*``
entries were never filtering anything; the spec lives in
``runtime_payload_health_groups`` (line 5209) which keeps the
explicit minimum-required list per variant.

Dry-run against b9296-bin-ubuntu-x64.tar.gz: 40 files copied (all
ggml, llama, mtmd, impl variants + the two binaries we ship), 22
skipped (other CLIs, rpc-server, LICENSE). Functionally equal to
the post-#5741 set.

* cleanup: trim #5741 comments on the pydantic split

Comments added in #5741 explained the original bug in full each
time. They are mostly redundant with the commit message and the PR.
Trim them to one short paragraph per site.

No behavior change.

* ci: narrow Windows runtime pattern to llama-server.exe + llama-quantize.exe

Studio only invokes llama-server and llama-quantize. Mac and Linux
already filter to those two binaries; Windows was the odd one out
with ``*.exe`` copying every CLI upstream ships (llama-cli,
llama-bench, llama-mtmd-cli, ...).

Dry-run on b9296 (win cpu-x64, cpu-arm64, cuda-13.1, hip-radeon):
20 unused EXEs skipped per variant, all DLLs (incl. the new
llama-*-impl.dll family) still copied via ``*.dll``.

``existing_install_matches_choice`` already checks llama-server.exe
exists explicitly (line 5297), so the health gate is unchanged.

* Lower default weight_decay in RL config from 0.01 to 0.001 (#5747)

In full FT, AdamW weight decay shrinks the parameter directly so the
implicit prior is W -> 0. In LoRA the trained parameters are A and B
while the effective weight is W = W_init + (alpha/r) * B @ A; decaying
A and B separately drives BA -> 0, hence W -> W_init rather than 0.
The previous default of 0.01 inherited from full-FT recipes adds a
measurable pull on the merged adapter back toward the base model over
a few thousand steps. 0.001 keeps a small Frobenius-norm prior on
||A||^2 + ||B||^2 for numerical stability without meaningfully biasing
the merged weight toward init, and aligns with the value used across
the unsloth notebook templates.

* Studio: strip orphan tool_call XML leaking into visible content (#5735)

* Studio: strip orphan tool_call XML from streamed visible content

The speculative-buffer state machine in
`studio/backend/core/inference/llama_cpp.py` can slice a tool_call XML
block between the silent DRAINING path and the user-visible
content_accum, depending on when in the model's emission the BUFFERING
-> STREAMING -> DRAINING transitions fire. Three leak shapes were
observed in a 2026-05-22 sweep of 900 Qwen3.5 / Qwen3.6 GGUF runs:

  Pre-fix XML leak rate: 20/900 (2.22%), concentrated 6.7% on the
  larger Q8 / MTP configs:

    Qwen3.6-35B-A3B Q8_0         4/60  (6.7%)
    Qwen3.6-35B-A3B-MTP Q4       4/60  (6.7%)
    Qwen3.5-35B-A3B Q8_0         3/60  (5.0%)
    Qwen3.6-27B Q8_0             3/60  (5.0%)

The existing `_TOOL_XML_RE` only matched well-formed
`<tool_call>...</tool_call>` and `<function=...></function>` pairs, so
unterminated openings (close was DRAINED) and orphan closes (opening
was DRAINED) survived the strip and reached the user.

Fix relaxes the regex to also strip:
  1. Orphan opening up to end-of-string: `(?:</tool_call>|\Z)`
  2. Orphan closing tag: bare `</tool_call>` / `</function>`

Verified on the full sweep: 20/900 -> 0/900 (100% of detected leaks
eliminated). 16 unit tests in `test_tool_xml_strip.py` pin all three
leak shapes plus the well-formed cases, plus parametrised checks on
the 5 actual real-world leak samples from the sweep data.

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

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

* Studio: strip tail-only </parameter> orphan + tighten regex

The 2026-05-22 gdpval sweep surfaced a 4th XML-leak shape not caught
by the earlier regex: a bare `</parameter>\n\n` at end-of-buffer (7
of 192 trials, all Qwen3.5-27B + a few Qwen3.6-27B). The model emits
the full `<tool_call><function=...><parameter=...>...content...
</parameter></function></tool_call>` envelope, the speculative buffer
DRAINS the opening tags as intended, but EOS (max_tokens cutoff)
truncates the outer `</function></tool_call>` close, leaving just
`</parameter>` as the visible tail.

We strip this ONLY when end-anchored (`\s*\Z`) so legitimate
mid-text uses (user code samples, documentation discussing the
Qwen tool-call XML shape) survive. Verified on the 192-trial
gdpval corpus: before=7, after=0.

While at it, fold the five top-level alternations into three by
sharing tag-name and prefix subgroups:

  <tool_call>...    + <function=\w+>...    +    -->  <(?:tool_call|function=\w+)>...
  </tool_call>      | </function>                  -->  </(?:tool_call|function)>

Semantically identical (verified by replay over the 192-trial
corpus + adversarial inputs, 0 diffs) and 1.34x faster on real
workloads. Backtracking-safety pinned by two new perf guards
(256KB '<' spam, 1000x orphan opens).

Tests: 16 -> 28 (6 new functional + 4 well-formed-vs-orphan +
2 perf guards).

* Tighten comments in XML-strip regex and tests

Code says what it does; comments were repeating it. Strip the verbose
explanations down to the WHY-only bits (engine quirk, tail-anchor
rationale, real-world source of each test sample). No code changes.

inference.py:  21 -> 12 lines around _TOOL_XML_RE
test_tool_xml_strip.py: 343 -> 259 lines (-84)
Tests: 28/28 still pass.

* [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>

* Address review: deny pass-through --parallel, preserve legacy short aliases, fix test harness

Round 1 review fixes for #5737:

1. Deny --parallel / --n-parallel / -np in the pass-through validator.
   Without this, `unsloth studio run --model X --parallel 8 -- --parallel
   999` would last-win-override the running llama-server slot count while
   Studio's app.state.llama_parallel_slots and KV-cache fitting stay at
   the typer value (8), so the resource plan and the running process
   disagree. Also bypasses the typer 1..64 range guard. Reject so the
   only path is the first-class typer flag.

2. Backwards-compat shim for -m / -hfr / -f. Dropping the short aliases
   from typer broke any script using `unsloth studio run -m X` or
   `-hfr Y` or `-f dist`. Add _consume_legacy_short_aliases which pops
   EXACT whole-token matches (or `-x=value` inline form) from ctx.args
   into the corresponding typer parameter. Clustered tokens (`-fa`,
   `-mg`, `-fitt`, ...) are left in the pass-through tail unchanged.
   --model becomes Optional with an explicit missing-required check
   after the preprocessor so legacy `-m X` still satisfies the
   "must specify a model" requirement.

3. Drop mix_stderr from CliRunner. Typer 0.25.1 / Click 8.4.1 removed
   the kwarg; the test harness raised TypeError before exercising the
   PR behaviour. Tests run cleanly on current and older Typer/Click.

4. Correct the -np regression test docstring. Pre-PR `-np 8` was
   clustered by Click as `-p 8` (port=8) + stray `-n`, silently
   breaking the port binding -- not "passed through as 8 slots". The
   post-PR assertion (child gets --parallel 8) is unchanged.

5. Update studio run docstring listing rejected flags so it now
   correctly includes --parallel / -np / --n-parallel.

New tests:
- test_llama_server_args.py: parametrized denylist coverage for
  --parallel / --n-parallel / -np including equals-form, including
  out-of-range bypass attempts (999, 0). is_managed_flag flips True.
- test_studio_run_short_alias_clashes.py: legacy -m / -hfr / -f
  promote to typer params; --model X + -m Y conflict errors; clustered
  -mg / -fa / -fitt still pass through (the original bug fix holds).

132 tests pass (98 backend + 34 cli).

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

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

* Extend legacy-alias shim tests for repo:variant, inline value form, and missing model

Three additional edge cases for the -m / -hfr / -f preprocessor:
- `-m unsloth/foo:UD-Q4_K_XL` round-trips through both the preprocessor
  and _split_repo_variant so the child sees --model + --gguf-variant.
- `-m=foo` inline value form is promoted just like `-m foo`.
- Missing --model after the preprocessor raises typer.Exit(2) cleanly
  (replacing typer's pre-PR required-flag enforcement now that --model
  is Optional to allow the legacy promotion path).

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

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

* Scrub .github/workflows for staging push (matches staging base)

* Fix studio CLI argv handling and pass-through docstring drift

- studio/backend/core/inference/llama_server_args.py: drop the stale
  ``-np``/``--parallel`` entry from the docstring's pass-through tunable
  list. These flags moved into _DENYLIST_GROUPS so the docstring now
  contradicts the validator and would mislead future maintainers
  debugging the ValueError from validate_extra_args(["--parallel","8"]).
  The deleted wording was introduced by dbea77e34 ("Studio: forward
  llama-server args from `unsloth studio run`, activate `unsloth run`,
  and allow passing model:quant to load models") when --parallel was
  still a documented pass-through; the same commit's "quant" reference
  is about the model:quant syntax, unrelated to the parallel slot
  wording being deleted here.

- unsloth_cli/commands/studio.py: add _expand_attached_np_short next to
  _consume_legacy_short_aliases. Both work around Click's short-option
  clustering for this command -- the legacy preprocessor for `-m` / `-f`
  / `-hfr` and this one for the attached `-np<N>` form. Click clusters
  `-np8` as `-n -p 8` because `-p` is the typer short for `--port`,
  silently setting port=8 and dropping the parallel value; rewriting the
  attached form into separated `-np <N>` in sys.argv before Click
  parses preserves the user's value. Space/equals forms (`-np 8`,
  `-np=8`) already work and are left alone.

- unsloth_cli/__init__.py: import _expand_attached_np_short from the
  studio command and run it only when argv[0] looks like the unsloth
  console-script or workspace cli.py, so importing this module from a
  notebook or pytest run does not mutate the caller's argv.

* Tighten the -np canonicaliser comments

Drop the helper's co-location sentence (location is self-evident from
grep) and shorten the entry-gate rationale to one short sentence
covering the why.

* Sync .github/workflows with upstream author branch

* Sync .github/workflows with upstream author branch

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

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

* Bump install.sh / install.ps1 pin to unsloth>=2026.5.7 (#5753)

PyPI release unsloth 2026.5.7 is now live. Bumps the pinned floor in
install.sh and install.ps1 from unsloth>=2026.5.6 to unsloth>=2026.5.7
so fresh installs resolve to the new wheel.

Tagged on main as v0.1.416-beta.

* Catch attached `-np<N>` form in backend pass-through validator

The CLI-side `_expand_attached_np_short` rewrites `-np8` to `-np 8`
before Click parses, but HTTP /load `llama_extra_args=["-np8"]` goes
straight to `validate_extra_args` which only matched the exact token.
Reproducer: `validate_extra_args(["-np8"])` previously returned
`["-np8"]` instead of raising; once forwarded to llama-server it
last-win-overrode Studio's slot count while
`app.state.llama_parallel_slots` stayed at the typer value.

Normalise `-np<digits>` to `-np` in `_flag_name` so the denylist
catches the attached form alongside `-np`, `-np=8`, `--parallel`,
`--parallel=8`, and `--n-parallel`. Tests parametrize the new form
including out-of-range values.

* Restore _consume_legacy_short_aliases unit tests + _expand_attached_np_short tests

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

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

* Restore .github/workflows from origin/main

Earlier merge from claude_review's staging-scrub commits accidentally
deleted production CI workflows. Restore them to main's state.

* Scrub .github/workflows for staging push (matches staging base)

* Sync .github/workflows with upstream author branch

* Round 5+6: broaden -np gate to exact basenames + runtime parallel test

Reviewer-flagged improvements squashed into one commit so the auto-push
review bot doesn't keep stomping the branch:

- unsloth_cli/__init__.py: exact-basename match instead of
  endswith('cli.py'). Covers unsloth, unsloth.exe, unsloth-cli,
  unsloth-cli.exe, cli.py, unsloth-cli.py. A third-party mycli.py that
  happens to import unsloth_cli no longer has its argv mutated.

- unsloth_cli/tests/test_studio_run_parallel_flag.py: parametrised
  runtime test (N in {1, 4, 8, 64}) that fakes the in-venv path and
  asserts run_server is invoked with llama_parallel_slots=N.
  Complements the existing source-text check so refactors that preserve
  runtime semantics don't trip a false failure.

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

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

* Round 7: respect '--' end-of-options and reject flag-as-value

Round 7 reviewer flagged three legitimate edge cases:

- _expand_attached_np_short rewrote post-'--' tokens. Convention: '--'
  ends option processing; payload after it is raw. Stop the loop there.

- _consume_legacy_short_aliases promoted post-'--' legacy aliases for
  the same reason. Treat post-'--' tail as raw.

- Legacy '-m -fa' silently consumed '-fa' as the model name, hiding
  the real CLI shape error. Reject any next-token that starts with '-'
  (except the lone '-' stdin/path sentinel) with a clear BadParameter.

Also expanded the missing-model error string to mention the still-
supported legacy '-m' / '-hfr' aliases so users hitting that diagnostic
on legacy scripts get the right migration hint.

Added four regression tests covering each new behaviour.

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

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

* Round 8: soften flag-as-value to long-form only + normalise is_managed_flag

Round 8 reviewer flagged two cleanups:

- _consume_legacy_short_aliases rejected any next token starting with
  '-' as a flag, which would break legitimate values like '-foo'
  (path or model name with leading dash). Narrow the rejection to
  '--long' tokens only; '-x' short forms still pass through.

- is_managed_flag did raw _DENYLIST membership while validate_extra_args
  goes through _flag_name first, so '-np8' / '--parallel=8' /
  '--port=9000' classified as not-managed by the helper but rejected
  by the validator. Route is_managed_flag through _flag_name so the
  two helpers agree on every form callers might use.

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

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

* Round 9: also catch -np-1 / -np+1 signed attached forms in denylist

Round 9 reviewer noticed _flag_name normalised -np<digits> but missed
signed variants -np-1 and -np+1, so validate_extra_args waved them
through while rejecting --parallel -1. llama.cpp would error out on
negative slot counts anyway, but the validator should classify every
form of the managed flag identically so the boundary is consistent.

* Round 10: signed -np in CLI canonicaliser + reject empty inline aliases

Round 10 reviewer flagged two real issues:

- _expand_attached_np_short rewrote only -np<digits>; signed forms
  -np-1 / -np+1 fell through. Backend _flag_name already classifies
  them as managed, so the CLI rewriter must too -- otherwise Click
  clusters -np-1 into -n -p -1 (port=-1) and never reaches the
  backend validator at all.

- -m= / -hfr= / -f= empty inline forms were accepted and produced
  --model '' / --frontend '' (then Path('') silently became '.') on
  re-exec. Reject empty inline values at the preprocessor with a
  clear BadParameter so the malformed input fails fast.

Both behaviours pinned with parametrised regression tests.

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

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

* Expose --parallel on plain `unsloth studio` for API-path parity

The PR added --parallel to `unsloth studio run` but the plain
`unsloth studio` callback (used for API-only / bare-server launches)
still hardcoded llama_parallel_slots to its run_server default. With
--parallel now denied as a llama_extra_args pass-through, that flow
had no first-class way to raise concurrency.

- unsloth_cli/commands/studio.py: add --parallel / --n-parallel typer
  Option (default 4, range 1..64) to studio_default, forward through
  the venv re-exec, and pass llama_parallel_slots= to run_server in
  the in-venv path.
- studio/backend/run.py: argparse --parallel / --n-parallel with the
  same range guard so the spawned child accepts the forwarded flag.
- unsloth_cli/tests/test_studio_run_parallel_flag.py: test pins the
  new option presence, aliases, default and range guards.

* Round 12: narrow entry-point gate, preserve pre-PR plain-studio default, drop brittle source-text test

Three Opus subagent reviewers (security / backcompat / code-quality)
flagged the same handful of real issues. Consensus fixes:

- unsloth_cli/__init__.py: narrow the -np canonicaliser gate to just
  {unsloth, unsloth.exe} (the only pyproject-declared console_script).
  The previous cli.py / unsloth-cli.py entries would silently rewrite
  sys.argv for any third-party myproj/cli.py that happens to import
  unsloth_cli. Dev users running python cli.py ... -np N still work
  via the space form, which parses without the rewrite.

- unsloth_cli/commands/studio.py + studio/backend/run.py: restore the
  pre-PR llama_parallel_slots default of 1 on plain unsloth studio and
  python studio/backend/run.py. unsloth studio run keeps its
  hardcoded-pre-PR default of 4. Without this, my earlier API-path
  parity commit silently dropped per-call context to ctx/4 for the
  plain-studio flow.

- unsloth_cli/tests/test_studio_run_parallel_flag.py: drop the brittle
  source-text grep test (test_run_kwargs_use_parallel_value). The
  parametrised runtime test test_in_venv_path_passes_parallel_to_run_server
  already pins the same intent against actual behaviour.

- unsloth_cli/tests/test_studio_run_short_alias_clashes.py: pin the
  narrow entry-point gate with a parametrised negative test covering
  seven third-party argv[0] basenames (cli.py, /path/myproj/cli.py,
  pytest, unsloth-cli, etc.). Re-broadening the gate now trips a
  test instead of silently mutating an unrelated CLI's argv.

* Round 13: shared parallel constants, denylist invariant test, defence-in-depth

Three Opus subagent reviewers (adversarial-user / maintenance /
cross-file consistency) flagged a consistent set of cleanups; folded
into one commit to avoid the pre-commit.ci force-push race.

unsloth_cli/commands/studio.py:
- Extract _PARALLEL_MIN / _PARALLEL_MAX / _PARALLEL_DEFAULT_RUN /
  _PARALLEL_DEFAULT_PLAIN module-level constants and use them in both
  typer Options (plain studio_default = 1, studio run = 4).
- _expand_attached_np_short now rewrites -np<junk> when the suffix
  starts with a digit (or signed digit) so '-np8x' surfaces as a
  clean '-np takes an int' typer error instead of a baffling
  '--port invalid' complaint after Click clusters '-n -p 8x'.
- Re-exec forwarding emits --load-in-4bit / --no-load-in-4bit
  explicitly in both directions; previously the True default relied
  on both layers sharing the same default forever.
- run() docstring now explicitly says --parallel / -np pass-through
  via llama_extra_args is denied (use the typer flag above).

studio/backend/run.py:
- Mirror the parallel constants and route the argparse default,
  range check, and error message through them. Help text mentions
  the asymmetry with 'unsloth studio run' so direct-launch dev users
  aren't confused by Default 1 in isolation.

studio/backend/core/inference/llama_server_args.py:
- _flag_name strips surrounding whitespace before denylist lookup so
  a caller can't slip a managed flag past the boundary with a
  trailing space (the trimmed form is what downstream parsers see).

Tests:
- New typer-aliases-subset-of-denylist invariant: every alias the
  typer Option claims as --parallel on run() MUST be in the backend
  parallel denylist group. Catches the failure mode where someone
  adds a new alias and forgets the boundary.
- Extended denylist parametrize to cover ~14 previously untested
  aliases (-mu, -dr, -hfv/-hfrv/-hffv family, -mmu, full --ui group,
  --models-preset / --models-autoload / --no-models-autoload).
- Whitespace-padded denylist rejection (' --parallel', '-np ', etc).
- --load-in-4bit re-exec test pinning both polarities + default.
- -np<junk> argv rewriter regression tests.
- Cross-reference headers between the two test files.

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

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

* fix: repair mlx studio base export save_method (#5727)

* Round 14: align backend -np recogniser with CLI rewriter + reject parent --parallel

Round 14 (reviewer.py --parallel 20 with gpt-5.3-codex-spark) flagged
two real P1s and a stale-rebase warning. All three addressed.

- studio/backend/core/inference/llama_server_args.py: widen
  _flag_name so -np<digit-prefix> with trailing junk (-np8x,
  -np-1foo, -np+1bar, -np9zzz) classifies as managed flag -np,
  matching the CLI _expand_attached_np_short rewriter. Without this,
  POST /api/inference/load with llama_extra_args=['-np8x'] slipped
  past the boundary while the CLI canonicalised the same form. The
  two sides now agree on every digit-prefix form.

- unsloth_cli/commands/studio.py: reject --parallel on the
  studio group when a subcommand is invoked. Pre-PR the studio
  callback had no --parallel; my Round 12 addition made
  'unsloth studio --parallel 8 run ...' silently drop the 8
  because typer doesn't propagate parent options into subcommand
  kwargs. Now errors with exit 2 and a message pointing the
  operator at the correct invocation
  ('unsloth studio run --parallel 8 ...').

- Picked up origin/main via merge (parent commit 0caf0526): the
  pre-flight stale-rebase detector found 2 lines on main in
  studio/backend/core/export/export.py missing from PR HEAD.
  Merged cleanly with no conflicts.

Tests:
- Parametrised denylist coverage for -np<digit-prefix>+junk forms.
- New runtime test confirms exit 2 + helpful error when the group
  --parallel is supplied alongside an invoked subcommand.
- Test that the default group --parallel value still lets a
  subcommand resolve (no false-positive rejection).

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

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

* Studio: tighten code comments across --parallel PR

Comment-only pass over the seven PR-touched files; trim verbose
docstrings, collapse multi-line section dividers, and drop
redundant prose that the code already conveys. No behaviour change.

* Studio: trim remaining verbose docstrings missed in last pass

Shorten the test_studio_run_parallel_flag.py module docstring and
the `Re-exec arg-builder coverage` block. No behaviour change.

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

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

* Studio: second comment-tightening pass across PR-touched code

Trim docstrings and inline comments in studio.py, run.py,
llama_server_args.py, and unsloth_cli/__init__.py. No behaviour change;
all 215 tests still pass.

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

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

* Studio: deny --embedding / --rerank / --tools pass-through

`--embedding` and `--rerank` flip llama-server into single-endpoint
mode, which breaks Studio's /v1/chat/completions hop. llama-server's
own `--tools` flag silently stacks on top of Studio's tool policy
resolved by `--enable-tools` / `--disable-tools`.

Add all three (plus the `--embeddings` / `--reranking` plural aliases)
to the boundary denylist so HTTP /load and pass-through extras both
reject them cleanly instead of silently desyncing the server surface.

Test added to the existing `test_denylist_rejects_all_aliases`
parametrize. 220 tests pass.

* Studio: make PR-touched tests robust to minimal envs + Windows

Two cross-OS CI findings:

1. `test_typer_parallel_aliases_are_subset_of_backend_denylist` was
   doing `from core.inference.llama_server_args import _DENYLIST_GROUPS`
   which triggers `core/inference/__init__.py` and pulls in the full
   backend chain (fastapi / structlog / loggers / utils.hardware).
   The invariant only needs the constants tuple, so load the module
   directly via `importlib.util.spec_from_file_location` -- the test
   now runs with just typer + pytest installed.

2. `test_legacy_frontend_alias_still_promotes_to_frontend` asserted
   the literal string `"/tmp/dist"` after the value round-trips through
   `Path()`. On Windows `str(Path("/tmp/dist"))` is `"\tmp\dist"`, so
   the assertion tripped on the same logical path. Compare via
   `Path(x) == Path("/tmp/dist")` so the test passes on every OS.

Both surfaced by the staging-4 cross-OS CI; no production-code change.
220 tests still pass locally.

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

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

* Studio: load llama_server_args.py directly in its unit tests

Same fix as the previous CLI-test commit: import the module via
`importlib.util.spec_from_file_location` instead of
`from core.inference.llama_server_args import ...`, so the test no
longer needs the full backend chain (fastapi / structlog / loggers /
utils.hardware) installed via `core/inference/__init__.py`.

The boundary validator is intentionally dependency-free; its unit
tests should reflect that.

* Fix test_main_composer_has_dir_auto anchor after PR #5784

PR #5784 ("Improve image generation UI") rewrote the message-input
textarea's static `aria-label="Message input"` into a JSX conditional
`aria-label={overlay ? "Image edit instructions" : "Message input"}`
but did not update the RTL bidi-attribute regression test, leaving
the literal-string `find('aria-label="Message input"')` anchor with
no match. The `Repo tests (CPU)` job has been red on main since.

Anchor on the inner `"Message input"` string literal instead -- it
survives both spellings and still pins the same textarea element so
the `dir="auto"` assertion has the right block to inspect.

Verified by re-running the exact CI command:
  954 passed, 3 skipped, 23 deselected (was 948 passed, 1 failed).

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Long Yixing <longyixing331@gmail.com>
2026-05-26 23:13:45 -07:00

1641 lines
58 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import importlib.util
import hashlib
import json
import os
import platform
import re
import secrets
import sqlite3
import subprocess
import sys
import tempfile
import time
import types
import urllib.error
import urllib.request
from datetime import datetime, timezone
from pathlib import Path
from typing import List, Optional
import typer
studio_app = typer.Typer(help = "Unsloth Studio commands.")
# Resolve install root: UNSLOTH_STUDIO_HOME, then STUDIO_HOME alias, then
# sys.prefix inference (so a direct call to <root>/bin/unsloth resolves after
# the installer's env var has expired), then legacy ~/.unsloth/studio.
# UNSLOTH_STUDIO_HOME wins when both env vars are set.
def _looks_like_installer_managed_studio_home(candidate: Path) -> bool:
"""Sentinel check (studio.conf or bin shim) so a dev venv named
unsloth_studio is not misidentified as a custom Studio root.
"""
shim_name = "unsloth.exe" if platform.system() == "Windows" else "unsloth"
return (candidate / "share" / "studio.conf").is_file() or (
candidate / "bin" / shim_name
).is_file()
def _resolve_studio_home() -> tuple[Path, bool]:
override = (os.environ.get("UNSLOTH_STUDIO_HOME") or "").strip()
if not override:
override = (os.environ.get("STUDIO_HOME") or "").strip()
if override:
try:
return Path(override).expanduser().resolve(), True
except (OSError, ValueError):
return Path(override).expanduser(), True
try:
prefix = Path(sys.prefix).resolve()
if prefix.name == "unsloth_studio":
inferred = prefix.parent
legacy = (Path.home() / ".unsloth" / "studio").resolve()
if inferred != legacy and _looks_like_installer_managed_studio_home(
inferred
):
return inferred, True
except (OSError, ValueError):
pass
return Path.home() / ".unsloth" / "studio", False
STUDIO_HOME, _STUDIO_HOME_IS_CUSTOM = _resolve_studio_home()
def _ensure_studio_env_exported() -> None:
"""Re-export UNSLOTH_STUDIO_HOME / UNSLOTH_LLAMA_CPP_PATH only for real
custom roots so subprocesses inherit the right install. Called from each
studio subcommand entry rather than at import time, to avoid leaking env
state into unrelated importers (tests, --help, CLI introspection).
"""
if not _STUDIO_HOME_IS_CUSTOM:
return
# Truthy-check (not setdefault) so a blank UNSLOTH_STUDIO_HOME= does not
# suppress the inferred custom root.
if not os.environ.get("UNSLOTH_STUDIO_HOME"):
os.environ["UNSLOTH_STUDIO_HOME"] = str(STUDIO_HOME)
# When override == legacy default, llama.cpp stays at ~/.unsloth/llama.cpp.
try:
_legacy_studio = (Path.home() / ".unsloth" / "studio").resolve()
_is_legacy = STUDIO_HOME.resolve() == _legacy_studio
except (OSError, ValueError):
_is_legacy = STUDIO_HOME == (Path.home() / ".unsloth" / "studio")
if _is_legacy:
_llama_dir = Path.home() / ".unsloth" / "llama.cpp"
else:
_llama_dir = STUDIO_HOME / "llama.cpp"
if not os.environ.get("UNSLOTH_LLAMA_CPP_PATH"):
os.environ["UNSLOTH_LLAMA_CPP_PATH"] = str(_llama_dir)
BOOTSTRAP_PASSWORD_FILE = ".bootstrap_password"
DESKTOP_SECRET_FILE = ".desktop_secret"
DEFAULT_ADMIN_USERNAME = "unsloth"
DESKTOP_SECRET_PREFIX = "desktop-"
API_KEY_PBKDF2_SALT_KEY = "api_key_pbkdf2_salt"
DESKTOP_SECRET_HASH_KEY = "desktop_secret_hash"
DESKTOP_SECRET_CREATED_AT_KEY = "desktop_secret_created_at"
PBKDF2_ITERATIONS = 100_000
# __file__ is unsloth_cli/commands/studio.py -- two parents up is the package root
# (either site-packages or the repo root for editable installs).
_PACKAGE_ROOT = Path(__file__).resolve().parent.parent.parent
def _should_hide_windows_subprocesses() -> bool:
"""Hide child console windows only for non-interactive Windows launches."""
if platform.system() != "Windows":
return False
try:
return not sys.stdout.isatty()
except (AttributeError, OSError, ValueError):
return True
def _windows_hidden_subprocess_kwargs() -> dict[str, object]:
"""Return Windows-only Popen kwargs that suppress transient console windows."""
if not _should_hide_windows_subprocesses():
return {}
kwargs: dict[str, object] = {}
create_no_window = getattr(subprocess, "CREATE_NO_WINDOW", 0)
if create_no_window:
kwargs["creationflags"] = create_no_window
startupinfo_factory = getattr(subprocess, "STARTUPINFO", None)
startf_use_showwindow = getattr(subprocess, "STARTF_USESHOWWINDOW", 0)
sw_hide = getattr(subprocess, "SW_HIDE", 0)
if startupinfo_factory is not None and startf_use_showwindow:
startupinfo = startupinfo_factory()
startupinfo.dwFlags |= startf_use_showwindow
startupinfo.wShowWindow = sw_hide
kwargs["startupinfo"] = startupinfo
return kwargs
def _stream_for_subprocess(stream):
"""Return *stream* if it has a real OS file descriptor, else None.
subprocess.run on Windows refuses to inherit std handles unless
they're passed explicitly (otherwise close_fds=True forces
bInheritHandles=False, and a CREATE_NO_WINDOW child ends up with
no stdio at all). When sys.stdout / sys.stderr is a real fd-backed
stream we want to hand it through; when it's been captured by a
test harness (pytest's capsys, an in-memory wrapper, etc) we fall
back to None so subprocess uses its default.
"""
if stream is None:
return None
try:
stream.fileno()
except (AttributeError, OSError, ValueError):
return None
return stream
def _studio_venv_python() -> Optional[Path]:
"""Return the studio venv Python binary, or None if not set up."""
if platform.system() == "Windows":
p = STUDIO_HOME / "unsloth_studio" / "Scripts" / "python.exe"
else:
p = STUDIO_HOME / "unsloth_studio" / "bin" / "python"
return p if p.is_file() else None
def _find_run_py() -> Optional[Path]:
"""Find studio/backend/run.py.
No CWD dependency — works from any directory.
Since studio/ is now a proper package (has __init__.py), it lives in
site-packages after pip install, right next to unsloth_cli/.
"""
# 1. Relative to __file__ (site-packages or editable repo root)
run_py = _PACKAGE_ROOT / "studio" / "backend" / "run.py"
if run_py.is_file():
return run_py
# 2. Studio venv's site-packages (Linux + Windows layouts)
for pattern in (
"lib/python*/site-packages/studio/backend/run.py",
"Lib/site-packages/studio/backend/run.py",
):
for match in (STUDIO_HOME / "unsloth_studio").glob(pattern):
return match
return None
def _find_setup_script() -> Optional[Path]:
"""Find studio/setup.sh or studio/setup.ps1.
No CWD dependency — works from any directory.
"""
name = "setup.ps1" if platform.system() == "Windows" else "setup.sh"
# 1. Relative to __file__ (site-packages or editable repo root)
s = _PACKAGE_ROOT / "studio" / name
if s.is_file():
return s
# 2. Studio venv's site-packages
for pattern in (
f"lib/python*/site-packages/studio/{name}",
f"Lib/site-packages/studio/{name}",
):
for match in (STUDIO_HOME / "unsloth_studio").glob(pattern):
return match
return None
# Mirror in studio/backend/run.py argparse + backend denylist test;
# bumping the cap in one place only desyncs.
_PARALLEL_MIN = 1
_PARALLEL_MAX = 64
_PARALLEL_DEFAULT_RUN = 4 # pre-PR hardcoded for `unsloth studio run`
_PARALLEL_DEFAULT_PLAIN = 1 # pre-PR effective for plain `unsloth studio`
def _iter_editable_studio_source_roots(venv_dir: Path):
"""Yield repo roots from setuptools `__editable___*_finder.py` files in
*venv_dir*'s site-packages whose MAPPING includes a `studio` entry.
Returns the parent dir of the mapped `studio` package (i.e. the repo
root), so callers can append `/studio/...` to reach any subdir.
"""
import ast
import re
for sp_pattern in ("lib/python*/site-packages", "Lib/site-packages"):
for sp in venv_dir.glob(sp_pattern):
for finder in sp.glob("__editable___*_finder.py"):
try:
src = finder.read_text(encoding = "utf-8")
except OSError:
continue
# Tolerate single- or multi-line dict literals; [^}]* still
# rejects nested dicts, which the setuptools template never
# emits for editable installs.
m = re.search(
r"^MAPPING\s*(?::[^=]*)?=\s*(\{[^}]*\})", src, re.M | re.S
)
if not m:
continue
try:
mapping = ast.literal_eval(m.group(1))
except (SyntaxError, ValueError):
continue
# Defensive: literal_eval can return a set / list / None if the
# matched literal is not a dict (regex captures `{...}`).
if not isinstance(mapping, dict):
continue
studio_pkg = mapping.get("studio")
if studio_pkg:
yield Path(studio_pkg).parent
def _find_frontend_dist() -> Optional[Path]:
"""Locate a built `studio/frontend/dist` (containing index.html).
Probes (in order): package-local default, installer venv site-packages,
editable source roots referenced from the installer venv. Returns None
if nothing servable is found, so callers can decide to error or proceed
in `--api-only` mode.
Fixes the silent 404 when another `unsloth` on PATH shadows the
installer's binary and points `_PACKAGE_ROOT` at a site-packages copy
that never received a vite build.
"""
candidates: List[Path] = [_PACKAGE_ROOT / "studio" / "frontend" / "dist"]
venv_dir = STUDIO_HOME / "unsloth_studio"
for pattern in (
"lib/python*/site-packages/studio/frontend/dist",
"Lib/site-packages/studio/frontend/dist",
):
candidates.extend(venv_dir.glob(pattern))
for repo_root in _iter_editable_studio_source_roots(venv_dir):
candidates.append(repo_root / "studio" / "frontend" / "dist")
seen: set[Path] = set()
for c in candidates:
try:
resolved = c.resolve()
except OSError:
resolved = c
if resolved in seen:
continue
seen.add(resolved)
if (c / "index.html").is_file():
return c
return None
# ── helpers for `unsloth studio run` ────────────────────────────────
def _wait_for_server(port: int, timeout: int = 30) -> bool:
"""Poll ``GET /api/health`` until the server responds 200 or *timeout* expires."""
import urllib.request
import urllib.error
url = f"http://127.0.0.1:{port}/api/health"
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
try:
with urllib.request.urlopen(url, timeout = 2) as resp:
if resp.status == 200:
return True
except (urllib.error.URLError, OSError, ConnectionError):
pass
time.sleep(0.5)
return False
def _create_api_key_inprocess(name: str) -> str:
"""Create an API key via direct storage call (no HTTP needed).
Bypasses the ``must_change_password`` gate that blocks HTTP
``POST /api/auth/api-keys`` on fresh installs. Safe because the
CLI already has filesystem access to ``~/.unsloth/studio``.
"""
storage = _load_backend_auth_storage()
raw_key, _row = storage.create_api_key(
username = storage.DEFAULT_ADMIN_USERNAME,
name = name,
)
return raw_key
def _load_backend_auth_storage():
run_py = _find_run_py()
backend_dir = (
run_py.parent if run_py is not None else _PACKAGE_ROOT / "studio" / "backend"
)
if backend_dir.is_dir() and str(backend_dir) not in sys.path:
sys.path.insert(0, str(backend_dir))
auth_dir = backend_dir / "auth"
storage_py = auth_dir / "storage.py"
loaded = sys.modules.get("auth.storage")
loaded_path = Path(getattr(loaded, "__file__", "")).resolve()
if loaded is not None and loaded_path == storage_py:
return loaded
package = sys.modules.get("auth")
package_paths = [Path(path).resolve() for path in getattr(package, "__path__", [])]
if package is None or auth_dir.resolve() not in package_paths:
package = types.ModuleType("auth")
package.__path__ = [str(auth_dir)]
package.__package__ = "auth"
package.__file__ = str(auth_dir / "__init__.py")
sys.modules["auth"] = package
spec = importlib.util.spec_from_file_location("auth.storage", storage_py)
if spec is None or spec.loader is None:
raise ImportError(f"Could not load backend auth storage from {storage_py}")
storage = importlib.util.module_from_spec(spec)
sys.modules["auth.storage"] = storage
spec.loader.exec_module(storage)
return storage
def _write_auth_secret(path: Path, secret: str) -> None:
path.parent.mkdir(parents = True, exist_ok = True)
fd, tmp_name = tempfile.mkstemp(prefix = f".{path.name}.", dir = path.parent)
tmp_path = Path(tmp_name)
try:
try:
os.chmod(tmp_path, 0o600)
except OSError:
pass
with os.fdopen(fd, "w") as f:
fd = -1
f.write(secret)
os.replace(tmp_path, path)
except Exception:
if fd >= 0:
os.close(fd)
tmp_path.unlink(missing_ok = True)
raise
try:
os.chmod(path, 0o600)
except OSError:
pass
def _connect_auth_db() -> sqlite3.Connection:
auth_dir = STUDIO_HOME / "auth"
auth_dir.mkdir(parents = True, exist_ok = True)
conn = sqlite3.connect(auth_dir / "auth.db")
conn.execute(
"""
CREATE TABLE IF NOT EXISTS auth_user (
id INTEGER PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
password_salt TEXT NOT NULL,
password_hash TEXT NOT NULL,
jwt_secret TEXT NOT NULL,
must_change_password INTEGER NOT NULL DEFAULT 0
);
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS refresh_tokens (
id INTEGER PRIMARY KEY,
token_hash TEXT NOT NULL,
username TEXT NOT NULL,
expires_at TEXT NOT NULL,
is_desktop INTEGER NOT NULL DEFAULT 0
);
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS api_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL,
key_prefix TEXT NOT NULL,
key_hash TEXT NOT NULL UNIQUE,
name TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
last_used_at TEXT,
expires_at TEXT,
is_active INTEGER NOT NULL DEFAULT 1
);
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS app_secrets (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
"""
)
auth_columns = {row[1] for row in conn.execute("PRAGMA table_info(auth_user)")}
if "must_change_password" not in auth_columns:
conn.execute(
"ALTER TABLE auth_user ADD COLUMN must_change_password INTEGER NOT NULL DEFAULT 0"
)
refresh_columns = {
row[1] for row in conn.execute("PRAGMA table_info(refresh_tokens)")
}
if "is_desktop" not in refresh_columns:
conn.execute(
"ALTER TABLE refresh_tokens ADD COLUMN is_desktop INTEGER NOT NULL DEFAULT 0"
)
conn.commit()
return conn
def _pbkdf2_hex(value: str, salt: bytes) -> str:
return hashlib.pbkdf2_hmac(
"sha256",
value.encode("utf-8"),
salt,
PBKDF2_ITERATIONS,
).hex()
def _hash_password(password: str) -> tuple[str, str]:
salt = secrets.token_hex(16)
pwd_hash = _pbkdf2_hex(password, salt.encode("utf-8"))
return salt, pwd_hash
def _get_or_create_api_key_pbkdf2_salt(conn: sqlite3.Connection) -> bytes:
row = conn.execute(
"SELECT value FROM app_secrets WHERE key = ?",
(API_KEY_PBKDF2_SALT_KEY,),
).fetchone()
if row is None:
salt_hex = secrets.token_hex(32)
conn.execute(
"INSERT OR IGNORE INTO app_secrets (key, value) VALUES (?, ?)",
(API_KEY_PBKDF2_SALT_KEY, salt_hex),
)
row = conn.execute(
"SELECT value FROM app_secrets WHERE key = ?",
(API_KEY_PBKDF2_SALT_KEY,),
).fetchone()
return bytes.fromhex(row[0])
def _ensure_cli_default_admin(conn: sqlite3.Connection) -> None:
row = conn.execute(
"SELECT 1 FROM auth_user WHERE username = ?",
(DEFAULT_ADMIN_USERNAME,),
).fetchone()
if row is not None:
return
bootstrap_password = secrets.token_urlsafe(32)
password_salt, password_hash = _hash_password(bootstrap_password)
conn.execute(
"""
INSERT INTO auth_user (
username,
password_salt,
password_hash,
jwt_secret,
must_change_password
)
VALUES (?, ?, ?, ?, ?)
""",
(
DEFAULT_ADMIN_USERNAME,
password_salt,
password_hash,
secrets.token_urlsafe(64),
1,
),
)
_write_auth_secret(
STUDIO_HOME / "auth" / BOOTSTRAP_PASSWORD_FILE,
bootstrap_password,
)
def _create_desktop_secret_in_cli() -> str:
raw_secret = DESKTOP_SECRET_PREFIX + secrets.token_urlsafe(48)
now = datetime.now(timezone.utc).isoformat()
conn = _connect_auth_db()
try:
_ensure_cli_default_admin(conn)
secret_hash = _pbkdf2_hex(raw_secret, _get_or_create_api_key_pbkdf2_salt(conn))
conn.execute(
"INSERT OR REPLACE INTO app_secrets (key, value) VALUES (?, ?)",
(DESKTOP_SECRET_HASH_KEY, secret_hash),
)
conn.execute(
"INSERT OR REPLACE INTO app_secrets (key, value) VALUES (?, ?)",
(DESKTOP_SECRET_CREATED_AT_KEY, now),
)
conn.commit()
return raw_secret
finally:
conn.close()
def _load_model_via_http(
port: int,
api_key: str,
model: str,
gguf_variant: Optional[str],
max_seq_length: int,
load_in_4bit: bool,
llama_extra_args: Optional[List[str]] = None,
timeout: int = 600,
) -> dict:
"""POST to ``/api/inference/load`` using the API key for auth."""
import json
import urllib.request
import urllib.error
payload: dict = {
"model_path": model,
"max_seq_length": max_seq_length,
"load_in_4bit": load_in_4bit,
}
if gguf_variant:
payload["gguf_variant"] = gguf_variant
if llama_extra_args:
payload["llama_extra_args"] = list(llama_extra_args)
data = json.dumps(payload).encode()
req = urllib.request.Request(
f"http://127.0.0.1:{port}/api/inference/load",
data = data,
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {api_key}",
},
method = "POST",
)
try:
with urllib.request.urlopen(req, timeout = timeout) as resp:
return json.loads(resp.read())
except urllib.error.HTTPError as exc:
body = exc.read().decode(errors = "replace")
raise RuntimeError(f"Model load failed (HTTP {exc.code}): {body}") from exc
# ── unsloth studio (server) ──────────────────────────────────────────
@studio_app.callback(invoke_without_command = True)
def studio_default(
ctx: typer.Context,
port: int = typer.Option(8888, "--port", "-p"),
host: str = typer.Option("127.0.0.1", "--host", "-H"),
frontend: Optional[Path] = typer.Option(None, "--frontend", "-f"),
silent: bool = typer.Option(False, "--silent", "-q"),
api_only: bool = typer.Option(
False,
"--api-only",
help = "Run API server only, no frontend serving (for Tauri desktop app)",
),
parallel: int = typer.Option(
_PARALLEL_DEFAULT_PLAIN,
"--parallel",
"--n-parallel",
min = _PARALLEL_MIN,
max = _PARALLEL_MAX,
help = (
f"llama-server parallel decode slots ({_PARALLEL_MIN}..{_PARALLEL_MAX}). "
f"Default {_PARALLEL_DEFAULT_PLAIN}; `unsloth studio run` "
f"defaults to {_PARALLEL_DEFAULT_RUN}."
),
),
):
"""Launch the Unsloth Studio server."""
# Runs before every subcommand (run/setup/update/...).
_ensure_studio_env_exported()
if ctx.invoked_subcommand is not None:
# Typer doesn't forward parent options to subcommands, so
# `unsloth studio --parallel N run ...` would silently drop N.
if parallel != _PARALLEL_DEFAULT_PLAIN:
typer.echo(
f"Error: --parallel on `unsloth studio` applies to the "
f"plain-server path only. For `unsloth studio "
f"{ctx.invoked_subcommand}`, put the flag after the "
f"subcommand: `unsloth studio {ctx.invoked_subcommand} "
f"--parallel {parallel} ...`",
err = True,
)
raise typer.Exit(2)
return
# Use the studio venv if it exists and we aren't already in it.
studio_venv_dir = STUDIO_HOME / "unsloth_studio"
in_studio_venv = sys.prefix.startswith(str(studio_venv_dir))
if not in_studio_venv:
studio_python = _studio_venv_python()
run_py = _find_run_py()
if studio_python and run_py:
if not silent:
typer.echo("Launching Unsloth Studio... Please wait...")
args = [
str(studio_python),
str(run_py),
"--host",
host,
"--port",
str(port),
"--parallel",
str(parallel),
]
# Resolve frontend explicitly so the spawned run.py uses a real
# built dist regardless of where its __file__ lands. Skip in
# --api-only (no UI served).
resolved_frontend = frontend
if resolved_frontend is None and not api_only:
resolved_frontend = _find_frontend_dist()
if resolved_frontend is not None:
args.extend(["--frontend", str(resolved_frontend)])
if silent:
args.append("--silent")
if api_only:
args.append("--api-only")
# On Windows os.execvp keeps the parent alive, so Ctrl+C
# would orphan the child; use Popen+wait instead.
if sys.platform == "win32":
import subprocess as _sp
proc = _sp.Popen(args, **_windows_hidden_subprocess_kwargs())
try:
rc = proc.wait()
except KeyboardInterrupt:
# Child handles its own signal; let it finish.
rc = proc.wait()
if rc != 0:
typer.echo(
f"\nError: Studio server exited unexpectedly (code {rc}).",
err = True,
)
typer.echo(
"Check the error above. If a package is missing, "
"re-run: unsloth studio setup",
err = True,
)
raise typer.Exit(rc)
else:
os.execvp(str(studio_python), args)
else:
typer.echo("Studio not set up. Run install.sh first.")
raise typer.Exit(1)
from studio.backend.run import run_server
if not silent:
from studio.backend.run import _resolve_external_ip
display_host = _resolve_external_ip() if host == "0.0.0.0" else host
typer.echo(f"Starting Unsloth Studio on http://{display_host}:{port}")
run_kwargs = dict(
host = host,
port = port,
silent = silent,
api_only = api_only,
llama_parallel_slots = parallel,
)
if frontend is not None:
run_kwargs["frontend_path"] = frontend
run_server(**run_kwargs)
from studio.backend.run import _shutdown_event
try:
if _shutdown_event is not None:
# Event.wait() with no timeout blocks at C-level on Linux
# and swallows SIGINT; loop with a 1s timeout instead.
while not _shutdown_event.is_set():
_shutdown_event.wait(timeout = 1)
else:
while True:
time.sleep(1)
except KeyboardInterrupt:
from studio.backend.run import _graceful_shutdown, _server
_graceful_shutdown(_server)
typer.echo("\nShutting down...")
# ── unsloth studio run ───────────────────────────────────────────────
def _split_repo_variant(model_arg: str) -> tuple[str, Optional[str]]:
"""Split ``org/name:variant`` into ``(repo, variant)``; mirrors
llama.cpp's ``-hf <repo>:<quant>``. Local paths, Windows drives,
and ids without ``:`` pass through verbatim."""
s = model_arg.strip()
if not s:
return s, None
if s.startswith(("/", "./", "../", "~")) or s == ".":
return s, None
# Windows drive letter (e.g. "C:\path"): colon is a path separator.
if len(s) >= 2 and s[1] == ":" and s[0].isalpha():
return s, None
if ":" not in s:
return s, None
repo, _, variant = s.rpartition(":")
if not repo or not variant:
return s, None
# Quant labels never contain a slash; `foo:bar/baz` isn't repo:variant.
if "/" in variant:
return s, None
return repo, variant
def _expand_attached_np_short() -> None:
# Click clusters `-np8` as `-n -p 8` (-p = --port), dropping the
# parallel value. Split to `-np <N>` so typer's alias matches.
# Stops at `--`; accepts signed and digit-prefix-junk forms so
# typer can report a clean error against `-np`. Kept in lockstep
# with the backend `_flag_name` recogniser.
i = 0
while i < len(sys.argv):
tok = sys.argv[i]
if tok == "--":
break
if len(tok) > 3 and tok.startswith("-np") and tok[3] != "=":
suffix = tok[3:]
first_numeric = suffix[0].isdigit() or (
len(suffix) > 1 and suffix[0] in {"-", "+"} and suffix[1].isdigit()
)
if first_numeric:
sys.argv[i : i + 1] = ["-np", suffix]
i += 2
continue
i += 1
def _consume_legacy_short_aliases(
args: List[str],
aliases: tuple[str, ...],
current: Optional[str],
canonical: str,
) -> tuple[Optional[str], List[str]]:
"""Pop exact-match legacy shorts (`-m`/`-hfr`/`-f`) from args;
leave clusters (`-mg`/`-fa`/...) for the llama-server tail. Inline
`-x=value` form also accepted."""
out: List[str] = []
value = current
i, n = 0, len(args)
while i < n:
tok = args[i]
if tok == "--": # end of options; tail is raw payload.
out.extend(args[i:])
break
name, sep, inline = tok.partition("=")
if name not in aliases:
out.append(tok)
i += 1
continue
if value is not None:
raise typer.BadParameter(
f"{name} conflicts with {canonical} already provided"
)
if sep:
if inline == "": # `-m=` would become --model '' (Path('')='.').
raise typer.BadParameter(f"{name} requires a non-empty value")
value = inline
i += 1
elif i + 1 < n:
nxt = args[i + 1]
# `--long` is unambiguously a flag; single-dash `-x` may be a path.
if nxt.startswith("--") and nxt != "--":
raise typer.BadParameter(
f"{name} expects a value but got the flag {nxt}"
)
value = nxt
i += 2
else:
raise typer.BadParameter(f"{name} requires a value")
return value, out
@studio_app.command(
context_settings = {
"allow_extra_args": True,
"ignore_unknown_options": True,
},
)
def run(
ctx: typer.Context,
model: Optional[str] = typer.Option(
None,
"--model",
"-hf",
"--hf-repo",
# `-m` / `-hfr` removed (Click would cluster `-mg`/`-md`/...).
# Exact-match `-m`/`-hfr` still work via the legacy shim below.
# `-hf` stays (multi-char shorts don't cluster).
help = (
"Model path or HF repo. Accepts llama.cpp-style "
"`org/repo:variant` syntax. `-hf` / `--hf-repo` match "
"llama-server's spelling."
),
),
gguf_variant: Optional[str] = typer.Option(
None, "--gguf-variant", help = "GGUF quant variant (e.g. UD-Q4_K_XL)"
),
max_seq_length: int = typer.Option(
0, "--max-seq-length", help = "Max sequence length (0 = model default)"
),
load_in_4bit: bool = typer.Option(True, "--load-in-4bit/--no-load-in-4bit"),
api_key_name: str = typer.Option(
"cli", "--api-key-name", help = "Label for the auto-generated API key"
),
port: int = typer.Option(8888, "--port", "-p"),
host: str = typer.Option("127.0.0.1", "--host", "-H"),
# `-f` removed (clustered `-fa`/`-fit*`); studio_default keeps it.
frontend: Optional[Path] = typer.Option(None, "--frontend"),
silent: bool = typer.Option(False, "--silent", "-q"),
enable_tools: Optional[bool] = typer.Option(
None,
"--enable-tools/--disable-tools",
help = (
"Force server-side tools on/off for all requests. "
"Default: on for 127.0.0.1, off for 0.0.0.0."
),
),
yes: bool = typer.Option(
False,
"--yes",
"-y",
help = "Skip the 0.0.0.0 + --enable-tools confirmation prompt.",
),
parallel: int = typer.Option(
_PARALLEL_DEFAULT_RUN,
"--parallel",
"--n-parallel",
"-np",
min = _PARALLEL_MIN,
max = _PARALLEL_MAX,
help = (
"llama-server parallel decode slots. N requests share one "
"loaded model; each slot gets ctx/N KV cache. Default "
f"{_PARALLEL_DEFAULT_RUN} (pre-PR hardcoded value)."
),
),
):
"""Start Studio, load a model, print an API key -- one-liner server.
Unknown flags pass through to llama-server (GGUF only). Studio
rejects managed flags with HTTP 400: model identity, network
(--host/--port/--path/--api-prefix/--reuse-port), auth/TLS
(--api-key/--ssl-*), single-model UI (--ui/--models-*/--webui),
and parallel slots (use --parallel above). Full denylist in
studio/backend/core/inference/llama_server_args.py. Other knobs
(-c, -ngl, --jinja, --flash-attn, -t, ...) pass through and
last-wins-override Studio's auto-set value.
Example:
unsloth studio run --model unsloth/Qwen3-1.7B-GGUF --gguf-variant UD-Q4_K_XL
unsloth studio run --model unsloth/Qwen3-1.7B-GGUF --top-k 20 --seed 42 --parallel 8
unsloth studio run --model some-model --chat-template-file /path/to/tpl.jinja
"""
extra_llama_args: List[str] = list(ctx.args) if ctx.args else []
# Promote legacy exact `-m`/`-hfr`/`-f` back into typer params;
# clusters stay in extras.
model, extra_llama_args = _consume_legacy_short_aliases(
extra_llama_args,
("-m", "-hfr"),
model,
"--model",
)
legacy_frontend, extra_llama_args = _consume_legacy_short_aliases(
extra_llama_args,
("-f",),
str(frontend) if frontend is not None else None,
"--frontend",
)
if legacy_frontend is not None and frontend is None:
frontend = Path(legacy_frontend)
if model is None:
typer.echo(
"Error: Missing option '--model' / '-hf' / '--hf-repo' "
"(legacy aliases '-m' / '-hfr' are still accepted).",
err = True,
)
raise typer.Exit(2)
# 0. Parse llama.cpp `repo:variant` in --model; error if also paired
# with --gguf-variant and they disagree.
parsed_repo, embedded_variant = _split_repo_variant(model)
if embedded_variant:
if gguf_variant and gguf_variant != embedded_variant:
typer.echo(
f"Error: --model embeds variant '{embedded_variant}' but "
f"--gguf-variant '{gguf_variant}' was also provided.",
err = True,
)
raise typer.Exit(1)
model = parsed_repo
gguf_variant = gguf_variant or embedded_variant
# Resolve tool policy here so the re-exec'd child inherits a
# concrete decision and never re-prompts.
from unsloth_cli._tool_policy import is_external_host, resolve_tool_policy
enable_tools = resolve_tool_policy(
host = host,
flag = enable_tools,
yes = yes,
silent = silent,
)
# 1. Re-exec into the studio venv (same pattern as studio_default).
studio_venv_dir = STUDIO_HOME / "unsloth_studio"
in_studio_venv = sys.prefix.startswith(str(studio_venv_dir))
if not in_studio_venv:
studio_python = _studio_venv_python()
if not studio_python:
typer.echo("Studio not set up. Run install.sh first.")
raise typer.Exit(1)
# Re-exec via the studio venv's `unsloth` console-script.
studio_bin = studio_python.parent / "unsloth"
if not studio_bin.is_file():
typer.echo(
"Studio venv missing 'unsloth' entry point. Re-run: unsloth studio setup"
)
raise typer.Exit(1)
args = [
str(studio_bin),
"studio",
"run",
"--model",
model,
"--max-seq-length",
str(max_seq_length),
"--api-key-name",
api_key_name,
"--port",
str(port),
"--host",
host,
]
if gguf_variant:
args.extend(["--gguf-variant", gguf_variant])
# Forward the explicit polarity; a future default flip on one
# layer must not silently invert behaviour for the other.
args.append("--load-in-4bit" if load_in_4bit else "--no-load-in-4bit")
if frontend:
args.extend(["--frontend", str(frontend)])
if silent:
args.append("--silent")
# Forward the resolved tool policy so the child doesn't re-resolve.
if enable_tools:
args.append("--enable-tools")
else:
args.append("--disable-tools")
# Forward --yes if the parent already cleared the network-bind
# prompt, else the child re-prompts.
if yes or (enable_tools and is_external_host(host)):
args.append("--yes")
# Typer claims --parallel outside ctx.args; without this the
# child reverts to its default and silently drops the value.
args.extend(["--parallel", str(parallel)])
# llama-server pass-through extras → child ctx.args → load payload.
if extra_llama_args:
args.extend(extra_llama_args)
if sys.platform == "win32":
proc = subprocess.Popen(args)
try:
rc = proc.wait()
except KeyboardInterrupt:
rc = proc.wait()
raise typer.Exit(rc)
else:
os.execvp(str(studio_bin), args)
# ── 2. Start server (always suppress built-in banner) ─────────────
from studio.backend.run import run_server, _resolve_external_ip
run_kwargs = dict(host = host, port = port, silent = True, llama_parallel_slots = parallel)
if frontend is not None:
run_kwargs["frontend_path"] = frontend
app = run_server(**run_kwargs)
actual_port = getattr(app.state, "server_port", port) or port
# Match the route handlers' import path: run.py adds
# studio/backend/ to sys.path, so they import as `state.tool_policy`.
# Importing via `studio.backend.state.tool_policy` would cache a
# second module object whose flag the gates can't see.
from state.tool_policy import set_tool_policy
set_tool_policy(enable_tools)
# 3. Wait for server health.
if not silent:
typer.echo("Starting Unsloth Studio...")
if not _wait_for_server(actual_port):
typer.echo("Error: server did not become healthy within 30 seconds.", err = True)
raise typer.Exit(1)
# 4. Create API key in-process.
api_key = _create_api_key_inprocess(api_key_name)
# 5. Load model via HTTP.
if not silent:
typer.echo(f"Loading model: {model}...")
try:
result = _load_model_via_http(
port = actual_port,
api_key = api_key,
model = model,
gguf_variant = gguf_variant,
max_seq_length = max_seq_length,
load_in_4bit = load_in_4bit,
llama_extra_args = extra_llama_args,
)
except RuntimeError as exc:
typer.echo(f"Error: {exc}", err = True)
raise typer.Exit(1)
loaded_model = result.get("model", model)
display_variant = f" ({gguf_variant})" if gguf_variant else ""
# 6. Print banner.
display_host = _resolve_external_ip() if host == "0.0.0.0" else host
base_url = f"http://{display_host}:{actual_port}"
sdk_base_url = f"{base_url}/v1"
# Orange so the tool-policy notice stands out; printed under
# --silent / --yes too so the policy is never invisible.
_tool_notice_fg = (217, 119, 87)
_is_external = is_external_host(host)
if _is_external and enable_tools:
_tool_notice = (
f"Server-side tools are ENABLED on {host} (network-reachable). "
f"Anyone with the API key can run code on this machine. "
f"Do not share the API key."
)
elif _is_external:
_tool_notice = (
f"Server-side tools are disabled by default on {host} "
f"(network-reachable). Pass --enable-tools to turn on "
f"(you will be warned about API-key risk)."
)
elif enable_tools:
_tool_notice = (
"Server-side tools are enabled by default for loopback. "
"Pass --disable-tools to turn off."
)
else:
_tool_notice = "Server-side tools are disabled."
if not silent:
typer.echo("")
typer.echo("=" * 56)
typer.echo(f" Unsloth Studio running at {base_url}")
typer.echo(f" Model loaded: {loaded_model}{display_variant}")
typer.echo(f" API Key: {api_key}")
typer.echo("")
typer.echo(" OpenAI / Anthropic SDK base URL:")
typer.echo(f" {sdk_base_url}")
typer.echo("=" * 56)
typer.secho(_tool_notice, fg = _tool_notice_fg, bold = True)
typer.echo("")
typer.echo("OpenAI Chat Completions:")
typer.echo(f" curl {sdk_base_url}/chat/completions \\")
typer.echo(f' -H "Authorization: Bearer {api_key}" \\')
typer.echo(' -H "Content-Type: application/json" \\')
typer.echo(
""" -d '{"messages": [{"role": "user", "content": "Hello"}], "stream": true}'"""
)
typer.echo("")
typer.echo("Anthropic Messages:")
typer.echo(f" curl {sdk_base_url}/messages \\")
typer.echo(f' -H "Authorization: Bearer {api_key}" \\')
typer.echo(' -H "Content-Type: application/json" \\')
typer.echo(
""" -d '{"max_tokens": 256, "messages": [{"role": "user", "content": "Hello"}], "stream": true}'"""
)
typer.echo("")
typer.echo("OpenAI Responses:")
typer.echo(f" curl {sdk_base_url}/responses \\")
typer.echo(f' -H "Authorization: Bearer {api_key}" \\')
typer.echo(' -H "Content-Type: application/json" \\')
typer.echo(""" -d '{"input": "Hello", "stream": true}'""")
typer.echo("")
else:
# Silent still prints URL + API key + tool-status policy.
typer.echo(f"URL: {base_url}")
typer.echo(f"API Key: {api_key}")
typer.secho(_tool_notice, fg = _tool_notice_fg, bold = True)
# 7. Wait for Ctrl+C.
from studio.backend.run import _shutdown_event, _graceful_shutdown, _server
try:
if _shutdown_event is not None:
while not _shutdown_event.is_set():
_shutdown_event.wait(timeout = 1)
else:
while True:
time.sleep(1)
except KeyboardInterrupt:
_graceful_shutdown(_server)
typer.echo("\nShutting down...")
# ── unsloth studio stop ───────────────────────────────────────────────
_PID_FILE = STUDIO_HOME / "studio.pid"
@studio_app.command()
def stop():
"""Stop a running Unsloth Studio server.
Reads the PID from ~/.unsloth/studio/studio.pid and sends SIGTERM
(or TerminateProcess on Windows) to shut it down gracefully.
"""
import signal as _signal
if not _PID_FILE.is_file():
typer.echo("No running Studio server found (no PID file).")
raise typer.Exit(0)
pid_text = _PID_FILE.read_text().strip()
if not pid_text.isdigit():
typer.echo(f"Invalid PID file contents: {pid_text}")
_PID_FILE.unlink(missing_ok = True)
raise typer.Exit(1)
pid = int(pid_text)
# Check if the process is still alive
try:
os.kill(pid, 0)
except ProcessLookupError:
typer.echo(
f"Studio server (PID {pid}) is not running. Cleaning up stale PID file."
)
_PID_FILE.unlink(missing_ok = True)
raise typer.Exit(0)
except PermissionError:
pass # process exists but we may not own it; try to signal anyway
# Send SIGTERM (graceful shutdown) or TerminateProcess on Windows
try:
if sys.platform == "win32":
subprocess.run(["taskkill", "/PID", str(pid), "/F"], check = True)
else:
os.kill(pid, _signal.SIGTERM)
typer.echo(f"Sent shutdown signal to Studio server (PID {pid}).")
except ProcessLookupError:
typer.echo(f"Studio server (PID {pid}) already exited.")
_PID_FILE.unlink(missing_ok = True)
raise typer.Exit(0)
except Exception as e:
typer.echo(f"Failed to stop Studio server (PID {pid}): {e}", err = True)
raise typer.Exit(1)
# Wait briefly for the process to exit and clean up
for _ in range(10):
time.sleep(0.5)
try:
os.kill(pid, 0)
except ProcessLookupError:
_PID_FILE.unlink(missing_ok = True)
typer.echo("Studio server stopped.")
raise typer.Exit(0)
except PermissionError:
break
typer.echo("Studio server is shutting down (may take a few seconds).")
# ── unsloth studio setup / update ─────────────────────────────────────
def _run_setup_script(*, verbose: bool = False) -> None:
"""Find and run the studio setup/update script."""
script = _find_setup_script()
if not script:
typer.echo("Error: Could not find setup script (setup.sh / setup.ps1).")
raise typer.Exit(1)
env = {**os.environ, "UNSLOTH_VERBOSE": "1"} if verbose else None
if platform.system() == "Windows":
powershell_args = ["powershell.exe"]
if _should_hide_windows_subprocesses():
powershell_args.extend(
["-NoLogo", "-NoProfile", "-NonInteractive", "-WindowStyle", "Hidden"]
)
# Use -Command + `*>&1` instead of -File so setup.ps1's
# Write-Host output (PowerShell Information stream / #6) is
# merged into the success stream and reaches the parent's
# stdout. With -File, Information stream output is dropped
# whenever stdout is a pipe, which is exactly the situation
# CI hits with `unsloth studio update --local 2>&1 | tee
# logs/update.log`. Single-quote escaping handles paths that
# contain apostrophes.
script_pwsh_literal = str(script).replace("'", "''")
powershell_args.extend(
[
"-ExecutionPolicy",
"Bypass",
"-Command",
f"& '{script_pwsh_literal}' *>&1",
]
)
# Explicitly hand stdin/stdout/stderr to the child so the
# CI tee actually sees setup.ps1's output. Without this,
# subprocess.run on Windows uses close_fds=True (default,
# since Python 3.7) which sets bInheritHandles=False on
# CreateProcess. With CREATE_NO_WINDOW also set (via
# _windows_hidden_subprocess_kwargs in non-TTY runs), the
# child has neither a console nor any inherited std
# handles, so PowerShell's Write-Host -- and even
# [Console]::Out.WriteLine -- writes to nothing. Passing
# stdout=sys.stdout / stderr=sys.stderr makes Python set up
# PROC_THREAD_ATTRIBUTE_HANDLE_LIST with the std handles
# explicitly inheritable, which works alongside
# CREATE_NO_WINDOW. Empty update.log on the windows-latest
# CI was the smoking gun (run 25533694490 and 25534292239).
result = subprocess.run(
powershell_args,
env = env,
stdin = _stream_for_subprocess(sys.stdin),
stdout = _stream_for_subprocess(sys.stdout),
stderr = _stream_for_subprocess(sys.stderr),
**_windows_hidden_subprocess_kwargs(),
)
else:
result = subprocess.run(["bash", str(script)], env = env)
if result.returncode != 0:
raise typer.Exit(result.returncode)
_INSTALLER_URL_BASH = "https://unsloth.ai/install.sh"
_INSTALLER_URL_PWSH = "https://unsloth.ai/install.ps1"
def _refresh_desktop_shortcuts(*, verbose: bool = False) -> None:
"""Re-run installer with --shortcuts-only to refresh launchers post-update."""
env = {**os.environ}
if verbose:
env["UNSLOTH_VERBOSE"] = "1"
is_windows = platform.system() == "Windows"
installer_name = "install.ps1" if is_windows else "install.sh"
installer_url = _INSTALLER_URL_PWSH if is_windows else _INSTALLER_URL_BASH
# Prefer local checkout, fall back to package dir, then network fetch.
local_repo = (os.environ.get("STUDIO_LOCAL_REPO") or "").strip()
candidates: list[Path] = []
if local_repo:
candidates.append(Path(local_repo) / installer_name)
candidates.append(_PACKAGE_ROOT / installer_name)
args = ["--shortcuts-only"]
if verbose:
args.append("--verbose")
if is_windows:
ps_argv: list[str] = ["powershell.exe"]
if _should_hide_windows_subprocesses():
ps_argv.extend(
["-NoLogo", "-NoProfile", "-NonInteractive", "-WindowStyle", "Hidden"]
)
for script in candidates:
try:
if script.is_file():
quoted = str(script).replace("'", "''")
argv = list(ps_argv)
argv.extend(
[
"-ExecutionPolicy",
"Bypass",
"-Command",
f"& '{quoted}' {' '.join(args)} *>&1",
]
)
result = subprocess.run(
argv,
env = env,
check = False,
**_windows_hidden_subprocess_kwargs(),
)
if result.returncode != 0:
typer.echo(
f" refresh-launcher install.ps1 exited {result.returncode}"
)
return
except OSError:
continue
# PyPI installs lack install.ps1: fetch + pipe to powershell stdin.
try:
request = urllib.request.Request(
installer_url, headers = {"User-Agent": "unsloth-studio-update"}
)
with urllib.request.urlopen(request, timeout = 30) as response:
installer = response.read().decode("utf-8", errors = "replace")
except (urllib.error.URLError, TimeoutError, OSError) as exc:
typer.echo(
f" refresh-launcher skipped: could not fetch {installer_url} ({exc})"
)
return
# install.ps1 auto-invokes `Install-UnslothStudio @args` at EOF; over
# stdin `$args` is empty so that triggers the full installer flow
# (deps, venv, prompts) before our shortcuts-only call. Strip it.
installer = re.sub(
r"(?m)^[ \t]*Install-UnslothStudio[ \t]+@args[ \t]*\r?\n?",
"",
installer,
)
# stdin-piped scripts have empty $args, so call Install-UnslothStudio explicitly.
marker_args = " ".join(args)
wrapper = installer + f"\nInstall-UnslothStudio {marker_args}\n"
# Write to a UTF-8 BOM tempfile and use -File rather than -Command -.
# `powershell.exe -Command -` reads stdin via [Console]::InputEncoding
# (CP1252/OEM on most Windows boxes), which mangles box-drawing chars
# in install.ps1. -File reads the BOM and decodes correctly. The
# prefix gives AV/EDR engines (and grep'ing users) a clear identity.
ps1_fd, ps1_path = tempfile.mkstemp(
prefix = "unsloth-studio-refresh-",
suffix = ".ps1",
)
try:
with os.fdopen(ps1_fd, "wb") as fh:
fh.write(b"\xef\xbb\xbf" + wrapper.encode("utf-8"))
argv = list(ps_argv)
argv.extend(["-ExecutionPolicy", "Bypass", "-File", ps1_path])
try:
result = subprocess.run(
argv,
env = env,
check = False,
**_windows_hidden_subprocess_kwargs(),
)
if result.returncode != 0:
typer.echo(
f" refresh-launcher fetched install.ps1 exited {result.returncode}"
)
except OSError as exc:
typer.echo(
f" refresh-launcher skipped: powershell exec failed ({exc})"
)
finally:
try:
os.unlink(ps1_path)
except OSError:
pass
return
for script in candidates:
try:
if script.is_file():
result = subprocess.run(
["bash", str(script), *args],
env = env,
check = False,
)
if result.returncode != 0:
typer.echo(
f" refresh-launcher install.sh exited {result.returncode}"
)
return
except OSError:
continue
# PyPI installs lack install.sh: fetch upstream.
try:
request = urllib.request.Request(
installer_url, headers = {"User-Agent": "unsloth-studio-update"}
)
with urllib.request.urlopen(request, timeout = 30) as response:
installer = response.read()
except (urllib.error.URLError, TimeoutError, OSError) as exc:
typer.echo(
f" refresh-launcher skipped: could not fetch {installer_url} ({exc})"
)
return
try:
result = subprocess.run(
["bash", "-s", "--", *args],
input = installer,
env = env,
check = False,
)
if result.returncode != 0:
typer.echo(
f" refresh-launcher fetched install.sh exited {result.returncode}"
)
except OSError as exc:
typer.echo(f" refresh-launcher skipped: bash exec failed ({exc})")
@studio_app.command(hidden = True)
def setup(
verbose: bool = typer.Option(
False,
"--verbose",
"-v",
help = "Full pip/build output during setup for troubleshooting.",
),
):
"""Run Studio setup (called by install.ps1 / install.sh)."""
_run_setup_script(verbose = verbose)
@studio_app.command()
def update(
local: bool = typer.Option(
False, "--local", help = "Install from local repo instead of PyPI"
),
package: str = typer.Option(
"unsloth", "--package", help = "Package name to install/update (for testing)"
),
verbose: bool = typer.Option(
False,
"--verbose",
"-v",
help = "Full pip/build output during update for troubleshooting.",
),
):
"""Update Unsloth Studio dependencies and rebuild."""
# Re-export UNSLOTH_STUDIO_HOME for env-mode installs so the refresh
# subprocess resolves the same install root the user originally chose.
_ensure_studio_env_exported()
# Ensure SKIP_STUDIO_BASE is not inherited from a parent install.ps1 session
os.environ.pop("SKIP_STUDIO_BASE", None)
os.environ["STUDIO_PACKAGE_NAME"] = package
if local:
os.environ["STUDIO_LOCAL_INSTALL"] = "1"
# Pass the repo root explicitly so install_python_stack.py doesn't
# have to guess from SCRIPT_DIR (which may be inside site-packages).
repo_root = Path(__file__).resolve().parents[2]
os.environ["STUDIO_LOCAL_REPO"] = str(repo_root)
else:
os.environ["STUDIO_LOCAL_INSTALL"] = "0"
os.environ.pop("STUDIO_LOCAL_REPO", None)
_release_self_exe_lock_windows()
try:
_run_setup_script(verbose = verbose)
except BaseException:
# Restore unsloth.exe from .deleteme if setup failed before pip
# produced a replacement; otherwise the user has no CLI for recovery.
_restore_self_exe_lock_windows()
raise
# On Windows clear the .deleteme orphan now that pip wrote a fresh
# unsloth.exe; on next update os.replace would overwrite it anyway,
# but leaving a stale binary around invites cross-version restore
# confusion from _restore_self_exe_lock_windows.
_cleanup_self_exe_lock_windows()
# Tauri desktop owns its own bundle entries; skip CLI launcher refresh
# so a Tauri-initiated update doesn't create duplicate shortcuts.
if os.environ.get("UNSLOTH_TAURI_UPDATE") == "1":
if verbose:
typer.echo(" refresh-launcher skipped (Tauri update)")
return
_refresh_desktop_shortcuts(verbose = verbose)
def _release_self_exe_lock_windows() -> None:
"""Rename running unsloth.exe so pip can replace it. setup.ps1 also retries."""
if platform.system() != "Windows":
return
try:
venv_scripts = Path(sys.executable).resolve().parent
except OSError:
return
exe = venv_scripts / "unsloth.exe"
if not exe.exists():
return
stale = exe.with_suffix(".exe.deleteme")
try:
# os.replace is atomic-overwrite on Windows; os.rename would raise
# FileExistsError if a prior aborted update left a .deleteme behind.
os.replace(exe, stale)
except OSError as e:
# Not fatal; setup.ps1 retries from a sibling process.
print(f"[update] could not rename {exe.name} -> {stale.name}: {e}")
def _restore_self_exe_lock_windows() -> None:
"""If setup failed before pip wrote a working unsloth.exe, restore .deleteme."""
if platform.system() != "Windows":
return
try:
venv_scripts = Path(sys.executable).resolve().parent
except OSError:
return
exe = venv_scripts / "unsloth.exe"
stale = exe.with_suffix(".exe.deleteme")
if not stale.exists():
return
# Treat a missing or zero-byte exe as "pip didn't produce a usable
# replacement"; otherwise leave the new binary alone.
if exe.exists():
try:
if exe.stat().st_size > 0:
return
except OSError:
return
try:
os.replace(stale, exe)
except OSError as e:
print(f"[update] could not restore {stale.name} -> {exe.name}: {e}")
def _cleanup_self_exe_lock_windows() -> None:
"""Remove the .deleteme orphan after a successful update on Windows."""
if platform.system() != "Windows":
return
try:
venv_scripts = Path(sys.executable).resolve().parent
except OSError:
return
stale = (venv_scripts / "unsloth.exe").with_suffix(".exe.deleteme")
try:
stale.unlink(missing_ok = True)
except OSError:
pass
# ── unsloth studio reset-password ────────────────────────────────────
@studio_app.command("desktop-capabilities", hidden = True)
def desktop_capabilities(
json_output: bool = typer.Option(
False,
"--json",
help = "Emit machine-readable JSON.",
),
):
payload = {
"desktop_protocol_version": 1,
"desktop_manageability_version": 1,
"supports_provision_desktop_auth": True,
"supports_api_only": True,
"supports_desktop_backend_ownership": True,
"version": "unknown",
}
try:
from importlib.metadata import version as package_version
payload["version"] = package_version("unsloth")
except Exception:
pass
if json_output:
typer.echo(json.dumps(payload, sort_keys = True))
return
for key, value in payload.items():
typer.echo(f"{key}: {value}")
@studio_app.command("provision-desktop-auth", hidden = True)
def provision_desktop_auth():
"""Create/repair desktop auth state for the local machine."""
auth_dir = STUDIO_HOME / "auth"
secret = _create_desktop_secret_in_cli()
_write_auth_secret(auth_dir / DESKTOP_SECRET_FILE, secret)
typer.echo("Desktop auth ready.")
@studio_app.command("reset-password")
def reset_password():
"""Reset the Studio admin password.
Deletes the auth database so that a fresh admin account with a new
random password is created on the next server start. The Studio
server must be restarted after running this command.
"""
auth_dir = STUDIO_HOME / "auth"
db_file = auth_dir / "auth.db"
stale_files = [
auth_dir / BOOTSTRAP_PASSWORD_FILE,
auth_dir / DESKTOP_SECRET_FILE,
]
had_db = db_file.exists()
db_file.unlink(missing_ok = True)
for path in stale_files:
path.unlink(missing_ok = True)
if not had_db:
typer.echo("No auth database found -- nothing to reset.")
raise typer.Exit(0)
typer.echo("Auth database deleted. Restart Unsloth Studio to get a new password.")