unsloth start/run: tool-call flags, positional model, and grouped help (#7328)

* unsloth start/run: tool-call flags, positional model, grouped help

Expose the existing tool-call controls as first-class CLI flags on both
unsloth run and unsloth start, add positional model detection with a GGUF
quant default, and group --help into rich panels.

Flags (unsloth run): --enable-tool-call-healing/--disable-tool-call-healing
(default on), --enable-tool-call-nudging/--disable-tool-call-nudging
(default on). Resolved before any re-exec and written to the existing env
controls (UNSLOTH_DISABLE_TOOL_CALL_HEALING, UNSLOTH_TOOL_CALL_NUDGE) so the
in-venv server reads them at import; an omitted flag respects a value the
parent already set.

Flags (unsloth start): --enable-tools/--disable-tools (default off, passthrough),
plus the same healing/nudging flags (default on). start conveys them to the
auto-started run via the child env and the tools flag, so it stays correct even
if run re-execs into an older Studio venv.

Positional model: a leading org/name(:variant) token routes to --model when
--model is absent, without stealing an option value or an agent passthrough arg.
A bare GGUF repo with no variant defaults to UD-Q4_K_XL for the unsloth namespace
and Q4_K_M elsewhere, applied only on the fresh auto-serve path so attaching to a
loaded model never reloads.

Help is grouped into rich panels (Model / Server / Session for start; Model /
Server and network / Tool calls / Advanced for run) so --help reads cleanly.

Adds unit coverage for the helpers, the start command-and-env forwarding, the
positional/quant defaulting, and the run env resolution.

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

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

* Positional model: reuse _is_hub_model_id so local dirs and paths are not stolen

Route a bare org/name positional to --model only when it resolves as a hub id
(via the existing _is_hub_model_id, which rejects local paths and existing
dirs), so an OpenCode project dir like owner/repo is left for the agent. Apply
the same guard to the auto-serve GGUF quant default so a local -GGUF path is
not forced to a quant it may not contain.

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

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

* unsloth start: typer floor, drop redundant GGUF quant default, respect inherited tool-call env

- Require typer>=0.12.0. The rich_help_panel options added here crash at import
  on typer<0.6, and the dependency was previously unbounded.
- Stop forcing a default GGUF quant for a bare org/name-GGUF on auto-serve. The
  server's own quant preference already picks UD-Q4_K_XL for Unsloth uploads and
  Q4_K_M otherwise, and falls back when that exact quant is missing, so forcing a
  fixed variant broke external repos that only publish Q5_K_M/Q8_0.
- Make the healing/nudging start flags tri-state so an omitted flag keeps an
  operator's inherited UNSLOTH_DISABLE_TOOL_CALL_HEALING / UNSLOTH_TOOL_CALL_NUDGE
  instead of overwriting it with the start defaults.

* Fix start passthrough and inherited tool settings

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: oobabooga <112222186+oobabooga@users.noreply.github.com>
This commit is contained in:
Daniel Han 2026-07-23 01:44:57 -07:00 committed by GitHub
commit 4fedb51b73
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 535 additions and 56 deletions

View file

@ -25,7 +25,7 @@ classifiers = [
"Topic :: Scientific/Engineering :: Artificial Intelligence",
]
dependencies = [
"typer",
"typer>=0.12.0",
"rich",
"pydantic",
"pyyaml",

View file

@ -7,7 +7,7 @@
# (current PyPI metadata still declares torch as a hard dep).
# unsloth direct deps (from pyproject.toml [project].dependencies)
typer
typer>=0.12.0
# typer's full runtime dep tree. Required explicitly because this
# file is installed with --no-deps. On Linux/Mac CI runners these
# are often cached transitively; on a fresh windows-latest venv they

View file

@ -24,6 +24,7 @@ from urllib.parse import urlencode, urlparse
import click
import typer
from typer.core import TyperCommand
from unsloth_cli._inference import (
_USER_AGENT,
@ -91,55 +92,117 @@ _PI_SUBAGENT_EXTENSION = Path(__file__).parent.parent / "pi_subagent.ts"
_OPENCODE_PROVIDER = "unsloth-studio"
_PROVIDER_HEADER = f"[model_providers.{_CODEX_PROFILE}]"
_PASSTHROUGH = {"allow_extra_args": True, "ignore_unknown_options": True}
class _PassthroughCommand(TyperCommand):
"""Preserve the option separator when forwarding arguments to an agent."""
def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]:
raw_args = list(args)
try:
separator = raw_args.index("--")
except ValueError:
return super().parse_args(ctx, args)
trailing_count = len(raw_args) - separator - 1
remaining = super().parse_args(ctx, args)
insert_at = max(0, len(remaining) - trailing_count)
if insert_at >= len(remaining) or remaining[insert_at] != "--":
remaining.insert(insert_at, "--")
ctx.args = remaining
return remaining
_CLAUDE_ENV_UNSET = ("ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN")
# Shared by every agent command; only the config/env/command differ.
# Help is grouped into rich panels so `--help` reads as Model / Server / Session
# instead of one long unaligned list.
_PANEL_MODEL = "Model"
_PANEL_SERVER = "Server"
_PANEL_SESSION = "Agent session"
_MODEL_OPTION = typer.Option(
None, "--model", "-m", help = "Model for the agent; defaults to the one loaded in Unsloth."
)
_KEY_OPTION = typer.Option(
None,
"--api-key",
envvar = "UNSLOTH_API_KEY",
help = (
"Unsloth API key. For a local Unsloth it is minted automatically and "
"remembered per server. For a remote server, pass one with --api-key "
"(or UNSLOTH_API_KEY); it is remembered for next time."
),
"--model",
"-m",
rich_help_panel = _PANEL_MODEL,
help = "Model for the agent, or a bare `org/name(:variant)` positional. "
"Defaults to the one loaded in Unsloth.",
)
_LAUNCH_OPTION = typer.Option(
True,
"--launch/--no-launch",
help = "--no-launch prints the env and command instead (remote shells, WSL).",
)
_SERVE_OPTION = typer.Option(
True,
"--serve/--no-serve",
help = (
"If no Unsloth server is running, auto-start one for --model and keep it available "
"after the agent exits. --no-serve keeps the old behavior of erroring out."
),
)
# Model-load knobs mirrored from `unsloth run`; only used when --model triggers a
# load on the server. Server-startup flags (--host/--port/--cloudflare/...) do not
# apply here because `unsloth start` attaches to an already-running server.
_GGUF_VARIANT_OPTION = typer.Option(
None, "--gguf-variant", help = "GGUF quant variant to load (e.g. UD-Q4_K_XL)."
None,
"--gguf-variant",
rich_help_panel = _PANEL_MODEL,
help = "GGUF quant variant to load (e.g. UD-Q4_K_XL). Defaults to UD-Q4_K_XL for "
"unsloth/* GGUF repos, else Q4_K_M.",
)
_CONTEXT_OPTION = typer.Option(
0,
"--max-seq-length",
"--context-length",
rich_help_panel = _PANEL_MODEL,
help = "Context length in tokens for the load (0 = model default).",
)
_LOAD_4BIT_OPTION = typer.Option(
True, "--load-in-4bit/--no-load-in-4bit", help = "Load hub models in 4-bit (ignored for GGUF)."
True,
"--load-in-4bit/--no-load-in-4bit",
rich_help_panel = _PANEL_MODEL,
help = "Load hub models in 4-bit (ignored for GGUF).",
)
_TENSOR_PARALLEL_OPTION = typer.Option(
False,
"--tensor-parallel/--no-tensor-parallel",
rich_help_panel = _PANEL_MODEL,
help = "Split a GGUF across GPUs by tensor instead of by layer (multi-GPU only).",
)
# Server knobs. Only used when `unsloth start` auto-starts the server (--serve);
# they have no effect when attaching to a server someone else already started.
_SERVE_OPTION = typer.Option(
True,
"--serve/--no-serve",
rich_help_panel = _PANEL_SERVER,
help = "If no Unsloth server is running, auto-start one for --model and keep it "
"available after the agent exits. --no-serve errors out instead.",
)
_ENABLE_TOOLS_OPTION = typer.Option(
False,
"--enable-tools/--disable-tools",
rich_help_panel = _PANEL_SERVER,
help = "Server-side tools (web search, code execution) for the auto-started server. "
"Default off so the agent's own tools are relayed unchanged.",
)
_TOOL_CALL_HEALING_OPTION = typer.Option(
None,
"--enable-tool-call-healing/--disable-tool-call-healing",
rich_help_panel = _PANEL_SERVER,
help = "Promote text-form tool calls from small GGUFs back into structured calls. On by "
"default; when the flag is omitted an inherited UNSLOTH_DISABLE_TOOL_CALL_HEALING is kept.",
)
_TOOL_CALL_NUDGING_OPTION = typer.Option(
None,
"--enable-tool-call-nudging/--disable-tool-call-nudging",
rich_help_panel = _PANEL_SERVER,
help = "Retry once with a nudge when a non-streaming passthrough tool call can't be healed. "
"On by default; when the flag is omitted an inherited UNSLOTH_TOOL_CALL_NUDGE is kept.",
)
# Agent-session knobs.
_KEY_OPTION = typer.Option(
None,
"--api-key",
envvar = "UNSLOTH_API_KEY",
rich_help_panel = _PANEL_SESSION,
help = "Unsloth API key. For a local Unsloth it is minted automatically and "
"remembered per server. For a remote server, pass one with --api-key "
"(or UNSLOTH_API_KEY); it is remembered for next time.",
)
_LAUNCH_OPTION = typer.Option(
True,
"--launch/--no-launch",
rich_help_panel = _PANEL_SESSION,
help = "--no-launch prints the env and command instead (remote shells, WSL).",
)
# One normalized "run tools without prompting" switch. Each agent spells this
# differently and it's easy to forget which is which, so accept every spelling and
# route to the agent's own mechanism in _yolo_command_flags / the config writers.
@ -148,14 +211,14 @@ _YOLO_OPTION = typer.Option(
"--yolo",
"--dangerously-skip-permissions",
"--dangerously-bypass-approvals-and-sandbox",
help = (
"Auto-approve all tool actions for this session; routed to the agent's own "
"flag/config. Any of the three spellings works for any agent."
),
rich_help_panel = _PANEL_SESSION,
help = "Auto-approve all tool actions for this session; routed to the agent's own "
"flag/config. Any of the three spellings works for any agent.",
)
_PERSIST_OPTION = typer.Option(
False,
"--persist/--no-persist",
rich_help_panel = _PANEL_SESSION,
help = (
"Keep this agent's Unsloth-managed session dir so you can resume it later. "
"codex/openclaw/hermes/pi have their whole home relocated into an Unsloth dir "
@ -171,6 +234,7 @@ _PERSIST_OPTION = typer.Option(
_AS_SUBAGENT_OPTION = typer.Option(
False,
"--as-subagent",
rich_help_panel = _PANEL_SESSION,
help = "Keep the coding agent's current model and add Unsloth as a local subagent.",
)
@ -319,6 +383,14 @@ class LoadOptions(NamedTuple):
tensor_parallel: bool = False
class ServerOptions(NamedTuple):
"""Tool-call knobs forwarded to an auto-started `unsloth run` server."""
enable_tools: bool = False
tool_call_healing: Optional[bool] = None
tool_call_nudging: Optional[bool] = None
def _split_repo_variant(model: str) -> tuple:
"""Split ``org/name:QUANT`` into ``(repo, variant)`` -> ``("org/name", "QUANT")``.
@ -342,6 +414,32 @@ def _split_repo_variant(model: str) -> tuple:
return repo, variant
def _looks_like_model(token: str) -> bool:
"""True for a bare `org/name(:variant)` hub id that is not a flag or a local path.
Reuses `_is_hub_model_id`, so a relative dir like `owner/repo` that actually exists
is left for the agent (e.g. OpenCode opens it as a project) instead of being taken
as a model; a non-existent `org/name` is treated as a hub id.
"""
if not token or token.startswith("-") or " " in token:
return False
repo, _ = _split_repo_variant(token)
return _is_hub_model_id(repo)
def _consume_positional_model(model: Optional[str], args: list) -> tuple:
"""Route a leading `org/name` positional to --model when --model was not given.
Only the FIRST token is considered so an option value like `--profile owner/repo`
is never stolen, and only when --model is absent so an explicit --model always wins.
Returns (model, remaining_args) with the consumed token removed from the passthrough.
"""
args = list(args)
if model or not args or not _looks_like_model(args[0]):
return model, args
return args[0], args[1:]
def _display_model_spec(model: str, variant: Optional[str]) -> str:
"""Return a user-facing model name that includes the selected GGUF variant."""
repo, inline_variant = _split_repo_variant(model)
@ -771,13 +869,20 @@ def _keep_auto_served() -> bool:
return server is not None and server.poll() is None
def _start_studio_server(base: str, model: str, load: LoadOptions) -> subprocess.Popen:
def _start_studio_server(
base: str,
model: str,
load: LoadOptions,
server: ServerOptions = ServerOptions(),
) -> subprocess.Popen:
"""Spawn `unsloth run` for `model`, wait until it is fully ready, and return it."""
global _auto_served_server
unsloth = shutil.which("unsloth") or "unsloth"
parsed = urlparse(base)
# --disable-tools = passthrough mode (relay the agent's own tools); --no-cloudflare =
# loopback only, no tunnel. Mirrors .github/scripts/serve-unsloth-run.sh.
# Tools default off = passthrough mode (relay the agent's own tools); --no-cloudflare =
# loopback only, no tunnel. Mirrors .github/scripts/serve-unsloth-run.sh. Healing/nudging
# travel via the child env below (version-agnostic) rather than new run flags that an
# older re-exec'd run could mistake for llama-server args.
command = [
unsloth,
"run",
@ -785,7 +890,7 @@ def _start_studio_server(base: str, model: str, load: LoadOptions) -> subprocess
parsed.hostname or "127.0.0.1",
"-p",
str(parsed.port or 8888),
"--disable-tools",
"--enable-tools" if server.enable_tools else "--disable-tools",
"--no-cloudflare",
"--model",
model,
@ -814,6 +919,19 @@ def _start_studio_server(base: str, model: str, load: LoadOptions) -> subprocess
# Pass the marker via env so an older launcher ignores it instead of treating an
# unknown CLI flag as a llama-server arg; new launchers preserve it across re-exec.
child_env[_START_API_KEY_MARKER_ENV] = "1"
# Convey healing/nudging through the env; `unsloth run` reads these when its own
# flags are omitted, so this works even if run re-execs into an older Studio venv.
# Only write when the operator set the flag explicitly; otherwise keep whatever they
# already exported (child_env is a copy of os.environ), falling back to the start
# defaults (healing on, nudging on) when nothing was inherited.
if server.tool_call_healing is not None:
child_env["UNSLOTH_DISABLE_TOOL_CALL_HEALING"] = "0" if server.tool_call_healing else "1"
elif "UNSLOTH_DISABLE_TOOL_CALL_HEALING" not in child_env:
child_env["UNSLOTH_DISABLE_TOOL_CALL_HEALING"] = "0"
if server.tool_call_nudging is not None:
child_env["UNSLOTH_TOOL_CALL_NUDGE"] = "1" if server.tool_call_nudging else "0"
elif "UNSLOTH_TOOL_CALL_NUDGE" not in child_env:
child_env["UNSLOTH_TOOL_CALL_NUDGE"] = "1"
kwargs: dict = {
"stdout": log,
"stderr": subprocess.STDOUT,
@ -896,6 +1014,7 @@ def _require_studio(
*,
serve: bool = False,
launch: bool = True,
server_options: ServerOptions = ServerOptions(),
) -> tuple:
"""Return (base, server). server is a Popen only when WE auto-started it."""
base = find_studio_server()
@ -916,7 +1035,11 @@ def _require_studio(
# Normalize to the port unsloth run actually binds, so the health poll and the
# returned base hit the same server we launch (not a portless :80).
expected = _effective_base(expected)
return expected, _start_studio_server(expected, model, load or LoadOptions())
load = load or LoadOptions()
# Leave a bare GGUF repo's variant unset: the server's own quant preference already
# picks the best available (UD-Q4_K_XL for Unsloth uploads, else Q4_K_M) and falls back
# when that exact quant is missing, which forcing a fixed variant here would break.
return expected, _start_studio_server(expected, model, load, server_options)
model_hint = "" if model else " Pass --model to have it start one for you, or"
_fail(
f"No running Unsloth server found at {expected}.{model_hint} start one with "
@ -2004,6 +2127,7 @@ def _connect(
*,
serve: bool = False,
launch: bool = True,
server_options: ServerOptions = ServerOptions(),
) -> tuple:
# `--model org/name:QUANT` is shorthand for `--model org/name --gguf-variant QUANT`.
# Split it before we match/serve so the attach path resolves against the already-loaded
@ -2015,7 +2139,9 @@ def _connect(
model = repo
if not load.gguf_variant:
load = load._replace(gguf_variant = variant)
base, server = _require_studio(model, load, serve = serve, launch = launch)
base, server = _require_studio(
model, load, serve = serve, launch = launch, server_options = server_options
)
try:
key = _agent_api_key(base, api_key, auto_started = server is not None)
# A server we just started has exactly the requested model loaded, so resolve to
@ -2453,7 +2579,7 @@ def write_pi_subagent_config(base: str, key: str, model: dict, path: Path) -> No
)
@start_app.command("claude", context_settings = _PASSTHROUGH)
@start_app.command("claude", cls = _PassthroughCommand, context_settings = _PASSTHROUGH)
def claude(
ctx: typer.Context,
model: Optional[str] = _MODEL_OPTION,
@ -2463,18 +2589,24 @@ def claude(
max_seq_length: int = _CONTEXT_OPTION,
load_in_4bit: bool = _LOAD_4BIT_OPTION,
tensor_parallel: bool = _TENSOR_PARALLEL_OPTION,
enable_tools: bool = _ENABLE_TOOLS_OPTION,
tool_call_healing: Optional[bool] = _TOOL_CALL_HEALING_OPTION,
tool_call_nudging: Optional[bool] = _TOOL_CALL_NUDGING_OPTION,
serve: bool = _SERVE_OPTION,
yolo: bool = _YOLO_OPTION,
persist: bool = _PERSIST_OPTION,
as_subagent: bool = _AS_SUBAGENT_OPTION,
):
"""Point Claude Code at the running Unsloth server and start it."""
# Route a leading `org/name` positional to --model; forward the rest to the agent.
model, ctx.args[:] = _consume_positional_model(model, ctx.args)
base, key, entry = _connect(
api_key,
model,
LoadOptions(gguf_variant, max_seq_length, load_in_4bit, tensor_parallel),
serve = serve,
launch = launch,
server_options = ServerOptions(enable_tools, tool_call_healing, tool_call_nudging),
)
model_id = entry["id"]
install_hint = (
@ -2548,7 +2680,7 @@ def claude(
)
@start_app.command("codex", context_settings = _PASSTHROUGH)
@start_app.command("codex", cls = _PassthroughCommand, context_settings = _PASSTHROUGH)
def codex(
ctx: typer.Context,
model: Optional[str] = _MODEL_OPTION,
@ -2558,18 +2690,24 @@ def codex(
max_seq_length: int = _CONTEXT_OPTION,
load_in_4bit: bool = _LOAD_4BIT_OPTION,
tensor_parallel: bool = _TENSOR_PARALLEL_OPTION,
enable_tools: bool = _ENABLE_TOOLS_OPTION,
tool_call_healing: Optional[bool] = _TOOL_CALL_HEALING_OPTION,
tool_call_nudging: Optional[bool] = _TOOL_CALL_NUDGING_OPTION,
serve: bool = _SERVE_OPTION,
yolo: bool = _YOLO_OPTION,
persist: bool = _PERSIST_OPTION,
as_subagent: bool = _AS_SUBAGENT_OPTION,
):
"""Point OpenAI Codex at the running Unsloth server and start it."""
# Route a leading `org/name` positional to --model; forward the rest to the agent.
model, ctx.args[:] = _consume_positional_model(model, ctx.args)
base, key, entry = _connect(
api_key,
model,
LoadOptions(gguf_variant, max_seq_length, load_in_4bit, tensor_parallel),
serve = serve,
launch = launch,
server_options = ServerOptions(enable_tools, tool_call_healing, tool_call_nudging),
)
# This preflight runs after _connect may have auto-started a server but before _run
# takes over its lifecycle, so tear the server down here if it rejects the model
@ -2617,7 +2755,7 @@ def codex(
_run(base, entry, env, command, launch = launch, install_hint = "npm install -g @openai/codex")
@start_app.command("openclaw", context_settings = _PASSTHROUGH)
@start_app.command("openclaw", cls = _PassthroughCommand, context_settings = _PASSTHROUGH)
def openclaw(
ctx: typer.Context,
model: Optional[str] = _MODEL_OPTION,
@ -2627,11 +2765,16 @@ def openclaw(
max_seq_length: int = _CONTEXT_OPTION,
load_in_4bit: bool = _LOAD_4BIT_OPTION,
tensor_parallel: bool = _TENSOR_PARALLEL_OPTION,
enable_tools: bool = _ENABLE_TOOLS_OPTION,
tool_call_healing: Optional[bool] = _TOOL_CALL_HEALING_OPTION,
tool_call_nudging: Optional[bool] = _TOOL_CALL_NUDGING_OPTION,
serve: bool = _SERVE_OPTION,
yolo: bool = _YOLO_OPTION,
persist: bool = _PERSIST_OPTION,
):
"""Point OpenClaw at the running Unsloth server and start it."""
# Route a leading `org/name` positional to --model; forward the rest to the agent.
model, ctx.args[:] = _consume_positional_model(model, ctx.args)
_reject_as_subagent("openclaw", ctx.args)
base, key, entry = _connect(
api_key,
@ -2639,6 +2782,7 @@ def openclaw(
LoadOptions(gguf_variant, max_seq_length, load_in_4bit, tensor_parallel),
serve = serve,
launch = launch,
server_options = ServerOptions(enable_tools, tool_call_healing, tool_call_nudging),
)
openclaw_args = list(ctx.args)
# Default a bare `unsloth start openclaw` to the local TUI. Anything the caller
@ -2675,7 +2819,7 @@ def openclaw(
_run(base, entry, env, command, launch = launch, install_hint = install_hint)
@start_app.command("opencode", context_settings = _PASSTHROUGH)
@start_app.command("opencode", cls = _PassthroughCommand, context_settings = _PASSTHROUGH)
def opencode(
ctx: typer.Context,
model: Optional[str] = _MODEL_OPTION,
@ -2685,18 +2829,24 @@ def opencode(
max_seq_length: int = _CONTEXT_OPTION,
load_in_4bit: bool = _LOAD_4BIT_OPTION,
tensor_parallel: bool = _TENSOR_PARALLEL_OPTION,
enable_tools: bool = _ENABLE_TOOLS_OPTION,
tool_call_healing: Optional[bool] = _TOOL_CALL_HEALING_OPTION,
tool_call_nudging: Optional[bool] = _TOOL_CALL_NUDGING_OPTION,
serve: bool = _SERVE_OPTION,
yolo: bool = _YOLO_OPTION,
persist: bool = _PERSIST_OPTION,
as_subagent: bool = _AS_SUBAGENT_OPTION,
):
"""Point OpenCode at the running Unsloth server and start it."""
# Route a leading `org/name` positional to --model; forward the rest to the agent.
model, ctx.args[:] = _consume_positional_model(model, ctx.args)
base, key, entry = _connect(
api_key,
model,
LoadOptions(gguf_variant, max_seq_length, load_in_4bit, tensor_parallel),
serve = serve,
launch = launch,
server_options = ServerOptions(enable_tools, tool_call_healing, tool_call_nudging),
)
if as_subagent:
subagent_id = _subagent_model_id(base, key, entry, model, gguf_variant)
@ -2813,7 +2963,7 @@ def opencode(
_run(base, entry, env, command, launch = launch, install_hint = "npm install -g opencode-ai")
@start_app.command("hermes", context_settings = _PASSTHROUGH)
@start_app.command("hermes", cls = _PassthroughCommand, context_settings = _PASSTHROUGH)
def hermes(
ctx: typer.Context,
model: Optional[str] = _MODEL_OPTION,
@ -2823,11 +2973,16 @@ def hermes(
max_seq_length: int = _CONTEXT_OPTION,
load_in_4bit: bool = _LOAD_4BIT_OPTION,
tensor_parallel: bool = _TENSOR_PARALLEL_OPTION,
enable_tools: bool = _ENABLE_TOOLS_OPTION,
tool_call_healing: Optional[bool] = _TOOL_CALL_HEALING_OPTION,
tool_call_nudging: Optional[bool] = _TOOL_CALL_NUDGING_OPTION,
serve: bool = _SERVE_OPTION,
yolo: bool = _YOLO_OPTION,
persist: bool = _PERSIST_OPTION,
):
"""Point Hermes (Nous Research) at the running Unsloth server and start it."""
# Route a leading `org/name` positional to --model; forward the rest to the agent.
model, ctx.args[:] = _consume_positional_model(model, ctx.args)
_reject_as_subagent("hermes", ctx.args)
native_args = [*_yolo_command_flags("hermes", yolo), *ctx.args]
command = ["hermes", *_hermes_resume_oneshot_args(native_args)]
@ -2837,6 +2992,7 @@ def hermes(
LoadOptions(gguf_variant, max_seq_length, load_in_4bit, tensor_parallel),
serve = serve,
launch = launch,
server_options = ServerOptions(enable_tools, tool_call_healing, tool_call_nudging),
)
install_hint = _hermes_install_hint()
with _session_config("hermes", launch, persist = persist) as home:
@ -2847,7 +3003,7 @@ def hermes(
_run(base, entry, env, command, launch = launch, install_hint = install_hint)
@start_app.command("pi", context_settings = _PASSTHROUGH)
@start_app.command("pi", cls = _PassthroughCommand, context_settings = _PASSTHROUGH)
def pi(
ctx: typer.Context,
model: Optional[str] = _MODEL_OPTION,
@ -2857,18 +3013,24 @@ def pi(
max_seq_length: int = _CONTEXT_OPTION,
load_in_4bit: bool = _LOAD_4BIT_OPTION,
tensor_parallel: bool = _TENSOR_PARALLEL_OPTION,
enable_tools: bool = _ENABLE_TOOLS_OPTION,
tool_call_healing: Optional[bool] = _TOOL_CALL_HEALING_OPTION,
tool_call_nudging: Optional[bool] = _TOOL_CALL_NUDGING_OPTION,
serve: bool = _SERVE_OPTION,
yolo: bool = _YOLO_OPTION,
persist: bool = _PERSIST_OPTION,
as_subagent: bool = _AS_SUBAGENT_OPTION,
):
"""Point Pi (coding agent) at the running Unsloth server and start it."""
# Route a leading `org/name` positional to --model; forward the rest to the agent.
model, ctx.args[:] = _consume_positional_model(model, ctx.args)
base, key, entry = _connect(
api_key,
model,
LoadOptions(gguf_variant, max_seq_length, load_in_4bit, tensor_parallel),
serve = serve,
launch = launch,
server_options = ServerOptions(enable_tools, tool_call_healing, tool_call_nudging),
)
install_hint = "npm install -g --ignore-scripts @earendil-works/pi-coding-agent"
if as_subagent:

View file

@ -1657,6 +1657,13 @@ def _consume_legacy_short_aliases(
return value, out
# Help panels so `unsloth run --help` groups options instead of one long list.
_RUN_PANEL_MODEL = "Model"
_RUN_PANEL_SERVER = "Server & network"
_RUN_PANEL_TOOLS = "Tool calls"
_RUN_PANEL_ADVANCED = "Advanced"
@studio_app.command(
context_settings = {
"allow_extra_args": True,
@ -1673,6 +1680,7 @@ def run(
# `-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).
rich_help_panel = _RUN_PANEL_MODEL,
help = (
"Model path or HF repo. Accepts llama.cpp-style "
"`org/repo:variant` syntax. `-hf` / `--hf-repo` match "
@ -1680,12 +1688,16 @@ def run(
),
),
gguf_variant: Optional[str] = typer.Option(
None, "--gguf-variant", help = "GGUF quant variant (e.g. UD-Q4_K_XL)"
None,
"--gguf-variant",
rich_help_panel = _RUN_PANEL_MODEL,
help = "GGUF quant variant (e.g. UD-Q4_K_XL)",
),
verbose: bool = typer.Option(
False,
"--verbose",
"-v",
rich_help_panel = _RUN_PANEL_ADVANCED,
help = "Log every API request, including the high-frequency polling that is "
"deduplicated by default.",
),
@ -1693,35 +1705,64 @@ def run(
0,
"--max-seq-length",
"--context-length",
rich_help_panel = _RUN_PANEL_MODEL,
help = "Runtime context length in tokens (0 = model default for GGUF; 2048 for hub models)",
),
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"
load_in_4bit: bool = typer.Option(
True, "--load-in-4bit/--no-load-in-4bit", rich_help_panel = _RUN_PANEL_MODEL
),
port: int = typer.Option(8888, "--port", "-p"),
host: str = typer.Option("127.0.0.1", "--host", "-H"),
api_key_name: str = typer.Option(
"cli",
"--api-key-name",
rich_help_panel = _RUN_PANEL_ADVANCED,
help = "Label for the auto-generated API key",
),
port: int = typer.Option(8888, "--port", "-p", rich_help_panel = _RUN_PANEL_SERVER),
host: str = typer.Option("127.0.0.1", "--host", "-H", rich_help_panel = _RUN_PANEL_SERVER),
# `-f` removed (clustered `-fa`/`-fit*`); studio_default keeps it.
frontend: Optional[Path] = typer.Option(None, "--frontend"),
frontend: Optional[Path] = typer.Option(None, "--frontend", rich_help_panel = _RUN_PANEL_SERVER),
api_only: bool = typer.Option(
False,
"--api-only",
rich_help_panel = _RUN_PANEL_SERVER,
help = "Serve only the API (no web UI), for a headless model server. "
"Pairs with --secure to expose the API over the Cloudflare link alone.",
),
silent: bool = typer.Option(False, "--silent", "-q"),
silent: bool = typer.Option(False, "--silent", "-q", rich_help_panel = _RUN_PANEL_ADVANCED),
enable_tools: Optional[bool] = typer.Option(
None,
"--enable-tools/--disable-tools",
rich_help_panel = _RUN_PANEL_TOOLS,
help = (
"Force server-side tools (web search, code execution) on or off for "
"every request. Default: on for every bind."
),
),
tool_call_healing: Optional[bool] = typer.Option(
None,
"--enable-tool-call-healing/--disable-tool-call-healing",
rich_help_panel = _RUN_PANEL_TOOLS,
help = (
"Promote text-form tool calls (small GGUFs often emit <tool_call>...) "
"back into structured calls on the client-tool passthrough. Default: on. "
"An explicit --disable-tool-call-healing is an absolute server kill-switch."
),
),
tool_call_nudging: Optional[bool] = typer.Option(
None,
"--enable-tool-call-nudging/--disable-tool-call-nudging",
rich_help_panel = _RUN_PANEL_TOOLS,
help = (
"On the non-streaming client-tool passthrough, retry once with a short "
"nudge when the model emitted a tool signal that healing could not repair. "
"Default: on. No effect on streaming requests or the server-side agentic loop."
),
),
yes: bool = typer.Option(
False,
"--yes",
"-y",
rich_help_panel = _RUN_PANEL_ADVANCED,
help = "Accepted for backward compatibility; the tool policy no longer prompts.",
),
parallel: int = typer.Option(
@ -1731,6 +1772,7 @@ def run(
"-np",
min = _PARALLEL_MIN,
max = _PARALLEL_MAX,
rich_help_panel = _RUN_PANEL_SERVER,
help = (
"llama-server parallel decode slots. N requests share one "
"loaded model; each slot gets ctx/N KV cache. Default "
@ -1740,6 +1782,7 @@ def run(
cloudflare: Optional[bool] = typer.Option(
None,
"--cloudflare/--no-cloudflare",
rich_help_panel = _RUN_PANEL_SERVER,
help = "Expose Unsloth on a PUBLIC internet URL via a free Cloudflare HTTPS "
"tunnel, for non-api-only wildcard binds (0.0.0.0 or ::). Off by default; "
"pass --cloudflare to enable it (--secure implies it). --no-cloudflare forces "
@ -1748,6 +1791,7 @@ def run(
secure: bool = typer.Option(
False,
"--secure/--no-secure",
rich_help_panel = _RUN_PANEL_SERVER,
help = "Expose ONLY a Cloudflare HTTPS link: bind localhost and fail closed "
"if the tunnel can't start. Without it, --no-secure also serves the raw "
"0.0.0.0 port, which is reachable from anywhere on the network.",
@ -1761,6 +1805,7 @@ def run(
tensor_parallel: bool = typer.Option(
False,
"--tensor-parallel/--no-tensor-parallel",
rich_help_panel = _RUN_PANEL_MODEL,
help = (
"Split a GGUF across GPUs by tensor (--split-mode tensor) instead of "
"by layer. Multi-GPU only (no effect on one GPU); dense models gain "
@ -1776,6 +1821,7 @@ def run(
password: str = typer.Option(
"",
"--password",
rich_help_panel = _RUN_PANEL_ADVANCED,
help = "Set the INITIAL admin password non-interactively (headless setups), "
"only when none is set yet. Also reads the UNSLOTH_STUDIO_PASSWORD env var, or "
"`--password -` to read one line from stdin. A literal value is visible in the "
@ -1808,6 +1854,22 @@ def run(
secure = _resolve_secure(secure, not_secure)
extra_llama_args: List[str] = list(ctx.args) if ctx.args else []
# Tool-call healing/nudging are read from the env at backend import. Resolve here
# (before any re-exec/import) so the in-venv child inherits the decision. When the
# flag is omitted, respect a value the parent already set (e.g. `unsloth start`
# forwards its choice via the env) and otherwise apply the default: healing on,
# nudging on for a CLI-launched server.
_healing_disabled = (
os.environ.get("UNSLOTH_DISABLE_TOOL_CALL_HEALING") == "1"
if tool_call_healing is None
else not tool_call_healing
)
os.environ["UNSLOTH_DISABLE_TOOL_CALL_HEALING"] = "1" if _healing_disabled else "0"
if tool_call_nudging is not None:
os.environ["UNSLOTH_TOOL_CALL_NUDGE"] = "1" if tool_call_nudging else "0"
elif "UNSLOTH_TOOL_CALL_NUDGE" not in os.environ:
os.environ["UNSLOTH_TOOL_CALL_NUDGE"] = "1"
# Set before any re-exec so the in-venv server inherits it via the env.
# `run --verbose` used to pass through to llama-server (its own -v); keep
# that by forwarding --log-verbose so we add Unsloth logs without dropping it.

View file

@ -1561,6 +1561,211 @@ def test_split_repo_variant(model, expected):
assert start._split_repo_variant(model) == expected
@pytest.mark.parametrize(
"token, expected",
[
("unsloth/gemma-4-E2B-it-GGUF", True),
("unsloth/gemma-4-E2B-it-GGUF:UD-Q4_K_XL", True),
("some-org/model.name_1", True),
("--continue", False), # flag
("resume", False), # single word, no slash
("/models/local.gguf", False), # absolute path
("./rel.gguf", False), # relative path
("C:\\models\\x.gguf", False), # Windows drive
("my models/foo", False), # has a space
("owner/repo/extra", False), # too many segments
],
)
def test_looks_like_model(token, expected):
assert start._looks_like_model(token) is expected
def test_consume_positional_model_leading_token():
# A leading org/name positional routes to --model and is dropped from the passthrough.
model, rest = start._consume_positional_model(None, ["unsloth/Model-GGUF", "--continue"])
assert model == "unsloth/Model-GGUF"
assert rest == ["--continue"]
def test_looks_like_model_leaves_existing_local_dir_for_agent(tmp_path, monkeypatch):
# A relative `owner/repo` that actually exists (e.g. an OpenCode project dir) must
# stay an agent argument, not be consumed as a model.
monkeypatch.chdir(tmp_path)
(tmp_path / "owner" / "repo").mkdir(parents = True)
assert start._looks_like_model("owner/repo") is False
model, rest = start._consume_positional_model(None, ["owner/repo"])
assert model is None and rest == ["owner/repo"]
# The same shape, when it does not exist locally, is still treated as a model.
assert start._looks_like_model("owner/absent-repo") is True
def test_consume_positional_model_ignores_non_leading_and_explicit_model():
# An org/name that is an option value (not leading) is never stolen.
model, rest = start._consume_positional_model(None, ["--profile", "owner/repo"])
assert model is None and rest == ["--profile", "owner/repo"]
# An explicit --model always wins; the positional is left untouched.
model, rest = start._consume_positional_model("explicit/model", ["owner/repo"])
assert model == "explicit/model" and rest == ["owner/repo"]
def test_start_separator_preserves_model_shaped_agent_argument(fake_studio):
result = CliRunner().invoke(
start.start_app,
["codex", "--no-launch", "--", "owner/repo"],
)
assert result.exit_code == 0, result.output
command = _launch_command(result.output)
assert command[-2:] == ["--", "owner/repo"]
result = CliRunner().invoke(
start.start_app,
["codex", "--no-launch", MODEL["id"], "--", "--continue"],
)
assert result.exit_code == 0, result.output
command = _launch_command(result.output)
assert command[-2:] == ["--", "--continue"]
def test_start_positional_model_routes_to_model_on_auto_serve(fake_studio, monkeypatch):
# `unsloth start claude unsloth/Model-GGUF` (no --model): the positional becomes the
# model; the GGUF variant is left unset so the server's own quant preference selects it.
monkeypatch.setenv("UNSLOTH_STUDIO_URL", "http://127.0.0.1:8888")
monkeypatch.setattr(start, "find_studio_server", lambda: None)
captured = {}
fake = SimpleNamespace(pid = 1, poll = lambda: None)
def fake_start(
base,
model,
load,
server_options = None,
):
captured["model"] = model
captured["load"] = load
captured["server_options"] = server_options
start._auto_served_server = fake
return fake
monkeypatch.setattr(start, "_start_studio_server", fake_start)
monkeypatch.setattr(start, "_shutdown_server", lambda server: None)
monkeypatch.setattr(start.shutil, "which", lambda _: "/usr/local/bin/claude")
monkeypatch.setattr(start.subprocess, "run", lambda command, env: SimpleNamespace(returncode = 0))
result = CliRunner().invoke(start.start_app, ["claude", "unsloth/gemma-4-E2B-it-GGUF"])
assert result.exit_code == 0, result.output
assert captured["model"] == "unsloth/gemma-4-E2B-it-GGUF"
assert captured["load"].gguf_variant is None
def test_start_local_gguf_path_keeps_no_default_variant(fake_studio, monkeypatch, tmp_path):
# A local GGUF dir/path ending in -GGUF must NOT get a forced default quant: the dir
# may only hold a different quant, and pre-PR the server picked whatever was available.
monkeypatch.setenv("UNSLOTH_STUDIO_URL", "http://127.0.0.1:8888")
monkeypatch.setattr(start, "find_studio_server", lambda: None)
local = tmp_path / "Qwen3-1.7B-GGUF"
local.mkdir()
captured = {}
fake = SimpleNamespace(pid = 1, poll = lambda: None)
def fake_start(
base,
model,
load,
server_options = None,
):
captured["load"] = load
start._auto_served_server = fake
return fake
monkeypatch.setattr(start, "_start_studio_server", fake_start)
monkeypatch.setattr(start, "_shutdown_server", lambda server: None)
monkeypatch.setattr(start.shutil, "which", lambda _: "/usr/local/bin/claude")
monkeypatch.setattr(start.subprocess, "run", lambda command, env: SimpleNamespace(returncode = 0))
result = CliRunner().invoke(start.start_app, ["claude", "--model", str(local)])
assert result.exit_code == 0, result.output
assert captured["load"].gguf_variant is None
def test_start_studio_server_forwards_tool_flags_via_command_and_env(monkeypatch):
captured = {}
class FakePopen:
def __init__(self, command, **kwargs):
captured["command"] = command
captured["kwargs"] = kwargs
self.pid = 1
def poll(self):
return None
monkeypatch.setattr(start.subprocess, "Popen", FakePopen)
monkeypatch.setattr(start, "_studio_healthy", lambda base, timeout = 3.0: True)
monkeypatch.setattr(start, "_log_tail", lambda path, lines = 20: "API Key: sk-unsloth-x")
monkeypatch.setattr(start.time, "sleep", lambda _s: None)
# No inherited kill switches, so the omitted-flag default applies.
monkeypatch.delenv("UNSLOTH_DISABLE_TOOL_CALL_HEALING", raising = False)
monkeypatch.delenv("UNSLOTH_TOOL_CALL_NUDGE", raising = False)
# Default start: tools off (passthrough), healing + nudging on.
start._start_studio_server("http://127.0.0.1:8888", "unsloth/M-GGUF", start.LoadOptions())
cmd, env = captured["command"], captured["kwargs"]["env"]
assert "--disable-tools" in cmd and "--enable-tools" not in cmd
assert env["UNSLOTH_DISABLE_TOOL_CALL_HEALING"] == "0"
assert env["UNSLOTH_TOOL_CALL_NUDGE"] == "1"
# Flipped: tools on, healing off, nudging off.
start._start_studio_server(
"http://127.0.0.1:8888",
"unsloth/M-GGUF",
start.LoadOptions(),
start.ServerOptions(enable_tools = True, tool_call_healing = False, tool_call_nudging = False),
)
cmd, env = captured["command"], captured["kwargs"]["env"]
assert "--enable-tools" in cmd and "--disable-tools" not in cmd
assert env["UNSLOTH_DISABLE_TOOL_CALL_HEALING"] == "1"
assert env["UNSLOTH_TOOL_CALL_NUDGE"] == "0"
def test_start_studio_server_respects_inherited_tool_call_env(monkeypatch):
# With the flags omitted, an operator's pre-exported kill switch must survive into the
# child server instead of being overwritten with the start defaults.
captured = {}
class FakePopen:
def __init__(self, command, **kwargs):
captured["kwargs"] = kwargs
self.pid = 1
def poll(self):
return None
monkeypatch.setattr(start.subprocess, "Popen", FakePopen)
monkeypatch.setattr(start, "_studio_healthy", lambda base, timeout = 3.0: True)
monkeypatch.setattr(start, "_log_tail", lambda path, lines = 20: "API Key: sk-unsloth-x")
monkeypatch.setattr(start.time, "sleep", lambda _s: None)
monkeypatch.setenv("UNSLOTH_DISABLE_TOOL_CALL_HEALING", "1")
monkeypatch.setenv("UNSLOTH_TOOL_CALL_NUDGE", "0")
# Flags omitted -> inherited values are preserved.
start._start_studio_server("http://127.0.0.1:8888", "unsloth/M-GGUF", start.LoadOptions())
env = captured["kwargs"]["env"]
assert env["UNSLOTH_DISABLE_TOOL_CALL_HEALING"] == "1"
assert env["UNSLOTH_TOOL_CALL_NUDGE"] == "0"
# An explicit flag still overrides the inherited env.
start._start_studio_server(
"http://127.0.0.1:8888",
"unsloth/M-GGUF",
start.LoadOptions(),
start.ServerOptions(tool_call_healing = True, tool_call_nudging = True),
)
env = captured["kwargs"]["env"]
assert env["UNSLOTH_DISABLE_TOOL_CALL_HEALING"] == "0"
assert env["UNSLOTH_TOOL_CALL_NUDGE"] == "1"
def test_connect_model_bare_id_matches_loaded_without_reload(fake_studio):
# A bare `--model <loaded repo>` (no load knobs) attaches to the already-loaded model
# without touching /api/inference/load, so it can never evict another session.
@ -2227,7 +2432,12 @@ def test_auto_serves_when_no_server_then_keeps_server(fake_studio, monkeypatch):
started = {}
fake = SimpleNamespace(pid = 999, poll = lambda: None)
def fake_start(base, model, load):
def fake_start(
base,
model,
load,
server_options = None,
):
started.update(base = base, model = model, load = load)
start._auto_served_server = fake
return fake
@ -2386,7 +2596,12 @@ def test_codex_preflight_failure_tears_down_auto_served(fake_studio, monkeypatch
started = {}
fake = SimpleNamespace(pid = 999, poll = lambda: None)
def fake_start(base, model, load):
def fake_start(
base,
model,
load,
server_options = None,
):
started.update(base = base, model = model)
start._auto_served_server = fake
return fake
@ -2481,7 +2696,12 @@ def test_auto_serve_normalizes_portless_url(fake_studio, monkeypatch):
started = {}
fake = SimpleNamespace(pid = 999, poll = lambda: None)
def fake_start(base, model, load):
def fake_start(
base,
model,
load,
server_options = None,
):
started["base"] = base
start._auto_served_server = fake
return fake

View file

@ -270,6 +270,41 @@ def test_reexeced_child_consumes_start_api_key_marker_env(monkeypatch):
assert studio_mod._START_API_KEY_MARKER_ENV not in studio_mod.os.environ
def test_run_default_sets_tool_call_env(monkeypatch):
"""Plain `unsloth run` enables healing and nudging via the inherited env
(written before the re-exec so the child server picks them up at import)."""
studio_mod = _load_run_command()
monkeypatch.delenv("UNSLOTH_DISABLE_TOOL_CALL_HEALING", raising = False)
monkeypatch.delenv("UNSLOTH_TOOL_CALL_NUDGE", raising = False)
_invoke_run(monkeypatch, _BASE)
assert studio_mod.os.environ["UNSLOTH_DISABLE_TOOL_CALL_HEALING"] == "0"
assert studio_mod.os.environ["UNSLOTH_TOOL_CALL_NUDGE"] == "1"
def test_run_disable_flags_set_tool_call_env(monkeypatch):
"""`--disable-tool-call-healing --disable-tool-call-nudging` flips both env vars."""
studio_mod = _load_run_command()
monkeypatch.delenv("UNSLOTH_DISABLE_TOOL_CALL_HEALING", raising = False)
monkeypatch.delenv("UNSLOTH_TOOL_CALL_NUDGE", raising = False)
_invoke_run(
monkeypatch,
_BASE + ["--disable-tool-call-healing", "--disable-tool-call-nudging"],
)
assert studio_mod.os.environ["UNSLOTH_DISABLE_TOOL_CALL_HEALING"] == "1"
assert studio_mod.os.environ["UNSLOTH_TOOL_CALL_NUDGE"] == "0"
@pytest.mark.parametrize("inherited", ["0", "false", "False", "no", ""])
def test_run_omitted_flag_respects_inherited_env(monkeypatch, inherited):
"""When the flag is omitted, a value the parent set (e.g. `unsloth start`) wins
instead of being reset to the default."""
studio_mod = _load_run_command()
monkeypatch.setenv("UNSLOTH_TOOL_CALL_NUDGE", inherited)
monkeypatch.delenv("UNSLOTH_DISABLE_TOOL_CALL_HEALING", raising = False)
_invoke_run(monkeypatch, _BASE)
assert studio_mod.os.environ["UNSLOTH_TOOL_CALL_NUDGE"] == inherited
@pytest.mark.parametrize("platform", ["linux", "darwin", "win32"])
def test_reexec_argv_is_consistent_across_platforms(monkeypatch, platform):
"""Linux/Darwin (execvp) and Windows (Popen) must build the same argv."""