Skip to content
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
65 commits
Select commit Hold shift + click to select a range
f33e994
Add Agents settings tab for unsloth start
shimmyshimmer Jul 21, 2026
6f5700d
Use official brand logos for agents, invert Ollama and OpenRouter in …
shimmyshimmer Jul 21, 2026
20bf103
Title Agents tab "Agents (unsloth start)" and move it below Connections
shimmyshimmer Jul 21, 2026
853c66c
Address review: guard PATH detection, fix copy timeout, OS-aware remo…
shimmyshimmer Jul 21, 2026
c924775
Add interactive Agents command builder
Imagineer99 Jul 21, 2026
c682508
Add local subagent command guidance
Imagineer99 Jul 22, 2026
0a939be
Add official coding agent icons
Imagineer99 Jul 22, 2026
be0ddad
Use client OS for remote commands, fix copy a11y and model wording (#…
danielhanchen Jul 22, 2026
7712355
Shell-quote the model, forward the HF token, and fix the quant placeh…
danielhanchen Jul 22, 2026
1063051
Merge updated Agents settings base
Imagineer99 Jul 22, 2026
c324238
Fix Agents command discovery and routing
Imagineer99 Jul 22, 2026
5d92bff
Unsloth start improvements: download progress, server reuse, and safe…
oobabooga Jul 22, 2026
7827fc8
Unsloth start: add local subagents for Claude Code, Codex, OpenCode a…
danielhanchen Jul 22, 2026
e2f412b
Fix Agents builder defaults and flag validation
Imagineer99 Jul 22, 2026
1412cbc
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jul 22, 2026
a2424c5
Fix Agents variant and provider fallbacks
Imagineer99 Jul 22, 2026
78c5795
Fix local model and Pi subagent edge cases
Imagineer99 Jul 22, 2026
b51b56b
Merge branch 'main' into studio-settings-agents-tab
Imagineer99 Jul 24, 2026
fa208af
Merge remote-tracking branch 'upstream/studio-settings-agents-tab' in…
Imagineer99 Jul 24, 2026
6a2ede9
Agents tab: flag the Codex row when the loaded model is not GGUF
danielhanchen Jul 25, 2026
2e44cd6
Merge studio-settings-agents-tab into feat/agents-command-builder
danielhanchen Jul 25, 2026
648ffc9
Agents tab: target the active Studio server, wrap narrow rows, index …
danielhanchen Jul 25, 2026
9e9d1a8
Fix merge conflicts with the Agents tab base branch for PR #7312
danielhanchen Jul 25, 2026
5075a69
Agents tab: build copied commands from the browser-reachable Studio a…
danielhanchen Jul 25, 2026
c3ec5fc
Preserve cache load ids and path variants in built commands for PR #7312
danielhanchen Jul 25, 2026
1e64e80
Agents tab: index the intro for agent-name searches and keep long com…
danielhanchen Jul 25, 2026
81727fe
List GGUF variants from the cache the command loads from for PR #7312
danielhanchen Jul 25, 2026
8b4c1f7
Merge studio-settings-agents-tab into feat/agents-command-builder
danielhanchen Jul 25, 2026
29e5856
Agents tab: omit --api-key so the CLI can replay a saved key for the …
danielhanchen Jul 25, 2026
c33a1fe
Merge studio-settings-agents-tab and drop the API key placeholder for…
danielhanchen Jul 25, 2026
b5ac032
Agents tab: label the indexed heading rows and fall back to the activ…
danielhanchen Jul 25, 2026
7bd7553
Agents tab: name every supported agent in the indexed intro for PR #7303
danielhanchen Jul 25, 2026
85ac6d6
Merge studio-settings-agents-tab and carry the API base fallback for …
danielhanchen Jul 25, 2026
a493027
Send the cached GGUF load path and fix the agents tab search targets …
danielhanchen Jul 25, 2026
00fb522
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jul 25, 2026
853ed1a
Tighten the agents tab comments for PR #7303
danielhanchen Jul 25, 2026
f2a3af4
Merge studio-settings-agents-tab and tighten the agents tab comments …
danielhanchen Jul 25, 2026
afbcd9c
Build the agents tab example commands from the active Studio base for…
danielhanchen Jul 25, 2026
729c980
Keep the resident model on its active cache load for PR #7312
danielhanchen Jul 25, 2026
e72c79d
Merge studio-settings-agents-tab and point the fixed examples at the …
danielhanchen Jul 25, 2026
82e8bb3
Tighten the agents tab and cached GGUF comments for PR #7312
danielhanchen Jul 25, 2026
dba51f3
Take the agent command shell from the Studio host for PR #7303
danielhanchen Jul 25, 2026
5484e75
Stop emitting snapshot paths as --model and keep unsloth start search…
danielhanchen Jul 25, 2026
07ebda4
Track the resident model, list scanned GGUFs and use the host shell f…
danielhanchen Jul 25, 2026
ed93b67
Pick the command shell from where the CLI runs for PR #7303
danielhanchen Jul 25, 2026
8d848bc
Match a path load by its advertised id and follow the resident model …
danielhanchen Jul 25, 2026
d3402f7
Merge studio-settings-agents-tab and pick the shell from where the CL…
danielhanchen Jul 25, 2026
96016c7
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jul 25, 2026
d4922ad
Merge remote-tracking branch 'origin/main' into feat/agents-command-b…
danielhanchen Jul 26, 2026
c3d9a41
Keep an explicit quantization and retire superseded native-grant labe…
danielhanchen Jul 26, 2026
1d622c2
Scope the remembered quant, stop following unloaded models and keep l…
danielhanchen Jul 26, 2026
39ce928
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jul 26, 2026
f2ed2d0
Stop shadowing the path classifier, match snapshot ordering and seque…
danielhanchen Jul 26, 2026
3117fd6
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jul 26, 2026
4762ab5
Release stale native-grant picks, keep local GGUF identities and inde…
danielhanchen Jul 26, 2026
54f9f2e
Index inactive-cache snapshots, widen local GGUF detection and clear …
danielhanchen Jul 26, 2026
61cc015
Classify cached repos by snapshot, merge repo ids case-insensitively …
danielhanchen Jul 26, 2026
3f2b1f2
Fix snapshot alias, partial split and mmproj-only handling for PR #7312
danielhanchen Jul 26, 2026
b17fdf9
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jul 26, 2026
9fd4a01
Trust scanned model_format and drop incomplete snapshot ids for PR #7312
danielhanchen Jul 26, 2026
9b2b424
Exclude mmproj and partial downloads, keep path case and drop duplica…
danielhanchen Jul 26, 2026
4a070df
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jul 26, 2026
3a17f7b
Merge main into feat/agents-command-builder for PR #7312
danielhanchen Jul 26, 2026
e967dc9
Restrict revision aliases and require complete snapshot variants for …
danielhanchen Jul 26, 2026
f79c63e
Index revisions individually and hide partial variants for PR #7312
danielhanchen Jul 26, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,13 @@ Replace `claude` with any supported agent:
| OpenCode | `unsloth start opencode` |
| Pi Coding Agent | `unsloth start pi` |

Claude Code, Codex, OpenCode and Pi can keep their current model and use Unsloth as a local
subagent:

```bash
unsloth start claude --as-subagent --model unsloth/model-GGUF:quant
```

## 📥 Install
Unsloth can be used in two ways: through **[Unsloth Studio](https://unsloth.ai/docs/new/studio/)**, the web UI, or through **Unsloth Core**, the code-based version. Each has different requirements.

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ version = {attr = "unsloth.models._utils.__version__"}
include-package-data = true

[tool.setuptools.package-data]
unsloth_cli = ["codex_fallback_prompt.md"]
unsloth_cli = ["codex_fallback_prompt.md", "pi_subagent.ts"]
studio = [
"*.sh",
"*.ps1",
Expand Down
136 changes: 62 additions & 74 deletions studio/backend/routes/inference.py
Original file line number Diff line number Diff line change
Expand Up @@ -3406,9 +3406,8 @@ async def _acquire_swap_gate() -> None:
await asyncio.sleep(0.02)


# Counts in-flight auto-switch requests per (target, variant). The busy guard
# subtracts same-target waiters so concurrent requests for one model load once
# instead of each 409-ing the other.
# Counts auto-switch requests queued to load each (target, variant). They are not
# generating, so the drain wait below excludes them from the active inference count.
_auto_switch_waiters: dict[tuple[str, str], int] = {}
_auto_switch_waiters_guard = threading.Lock()

Expand All @@ -3426,35 +3425,31 @@ def _note_switch_waiter(key: tuple[str, str], delta: int) -> None:
_auto_switch_waiters.pop(key, None)


def _same_target_waiters(key: tuple[str, str]) -> int:
def _switch_waiter_count() -> int:
with _auto_switch_waiters_guard:
return _auto_switch_waiters.get(key, 0)
return sum(max(0, count) for count in _auto_switch_waiters.values())


# A second waiter map keyed by the raw requested model, registered before the
# (slow) resolve. The middleware counts a concurrent same-model request as
# in-flight before it resolves and joins _auto_switch_waiters, so without this
# the first request would see it as an unrelated request and 409.
_auto_switch_request_waiters: dict[str, int] = {}
_auto_switch_request_waiters_guard = threading.Lock()
async def _wait_for_model_switch_idle(*, current_request_counted: bool) -> None:
"""Wait until a model replacement cannot interrupt active inference.


def _request_waiter_key(requested_model: str) -> str:
return requested_model.strip().lower()


def _note_request_waiter(key: str, delta: int) -> None:
with _auto_switch_request_waiters_guard:
n = _auto_switch_request_waiters.get(key, 0) + delta
if n > 0:
_auto_switch_request_waiters[key] = n
else:
_auto_switch_request_waiters.pop(key, None)


def _same_request_waiters(key: str) -> int:
with _auto_switch_request_waiters_guard:
return _auto_switch_request_waiters.get(key, 0)
The caller holds ``inference_lifecycle_gate``, which prevents new inference
from starting while existing requests drain. Auto-switch requests that have
resolved their targets are scheduler waiters, not active generations, so
exclude them to avoid a queue deadlock.
"""
from core.inference.llama_keepwarm import other_inference_request_count
while True:
queued_switches = _switch_waiter_count()
if current_request_counted and queued_switches > 0:
queued_switches -= 1
active_others = other_inference_request_count(
current_request_counted = current_request_counted,
include_pending = False,
)
if active_others <= queued_switches:
return
await asyncio.sleep(0.02)


def _llama_public_model_id(llama_backend, fallback: Optional[str] = None) -> Optional[str]:
Expand Down Expand Up @@ -3582,7 +3577,6 @@ async def _maybe_auto_switch_model(
from core.inference.local_model_resolver import resolve_local_gguf
from core.inference.llama_keepwarm import (
get_last_unloaded_model,
other_inference_request_count,
inference_lifecycle_gate,
)

Expand All @@ -3603,12 +3597,7 @@ async def _maybe_auto_switch_model(
if not auto_switch_on and get_auto_unload_idle_seconds() <= 0:
return

# Register by the raw requested model before resolving (which can be slow):
# the middleware already counts a concurrent same-model request as in-flight,
# so the busy guard must know it shares this target even while it resolves.
request_key = _request_waiter_key(requested_model)
_note_request_waiter(request_key, 1)
try:
async def _resolve_and_switch() -> None:
# Off the loop: a cold-cache rebuild walks several model dirs + HF caches.
# With auto-switch off (or an omitted-model reload-only request), skip the
# resolve so only the reload-stash path runs and no name is ever matched.
Expand Down Expand Up @@ -3706,6 +3695,7 @@ def _record_serving_alias() -> None:
)
key = _switch_key(override_id, variant)
_note_switch_waiter(key, 1)
waiter_noted = True
try:
async with _auto_switch_lock():
# The asyncio lock is per loop; add a process-wide gate so a swap on
Expand All @@ -3718,31 +3708,6 @@ def _record_serving_alias() -> None:
if _already_serving():
_record_serving_alias()
return
# Single slot: refuse a cross-model swap while another inference
# request is active rather than killing its response. Requests
# heading to this same target (by resolved id or raw name) are
# excluded, so concurrent requests for one model load once. A
# pending request is still in the middleware, not generating, so
# it is not counted here.
same_others = max(
_same_target_waiters(key) - 1, _same_request_waiters(request_key) - 1, 0
)
others = other_inference_request_count(
current_request_counted = True, include_pending = False
)
# Not gated on the GGUF being loaded: _load_model_impl also
# tears down an active Unsloth backend before loading a GGUF,
# so refuse whenever any other inference request is in flight.
if others > same_others:
raise HTTPException(
status_code = 409,
detail = openai_error_body(
"Cannot switch models while another inference request is in progress.",
status = 409,
code = "model_switch_busy",
param = "model",
),
)
# Apply this model's saved launch flags so the swap honors the config.
override = get_model_override(override_id)
load_kwargs = {"model_path": target_id, "gguf_variant": variant}
Expand All @@ -3757,16 +3722,22 @@ def _record_serving_alias() -> None:
LoadRequest(**load_kwargs),
fastapi_request,
current_subject,
current_request_counted = True,
)
# Advertise the repo id (not the concrete load path) as the loaded
# model's public id and override key for /v1/models and idle stash.
get_llama_cpp_backend()._openai_advertised_id = override_id
finally:
# Deregister before releasing the gate: otherwise a swap on another
# loop counts this finished request as queued and unloads its model.
_note_switch_waiter(key, -1)
waiter_noted = False
_auto_switch_process_lock.release()
finally:
_note_switch_waiter(key, -1)
finally:
_note_request_waiter(request_key, -1)
if waiter_noted:
_note_switch_waiter(key, -1)

await _resolve_and_switch()


async def _auto_switch_from_request_body(request: Request, current_subject: str):
Expand Down Expand Up @@ -4186,6 +4157,15 @@ def _maybe_unsupported_message(msg: str) -> str:
return msg


def _raise_if_sidecar_swap_in_progress() -> None:
from utils.transformers_version import sidecar_swap_in_progress
if sidecar_swap_in_progress():
raise HTTPException(
status_code = 409,
detail = "A transformers installation is in progress. Retry when it completes.",
)


@router.post("/load", response_model = LoadResponse)
async def load_model(
request: LoadRequest,
Expand All @@ -4206,24 +4186,23 @@ async def load_model(
# install can reserve while this request queues on the gate, so the pre-gate
# check alone is only a fast path.
from core.inference.llama_keepwarm import inference_lifecycle_gate
from utils.transformers_version import sidecar_swap_in_progress

_swap_409 = HTTPException(
status_code = 409,
detail = "A transformers installation is in progress. Retry when it completes.",
)
if sidecar_swap_in_progress():
raise _swap_409
_raise_if_sidecar_swap_in_progress()
# Hold the lifecycle gate across the load so idle auto-unload can't unload the
# model mid-load. Auto-switch calls _load_model_impl directly since it already
# holds this gate.
async with inference_lifecycle_gate():
if sidecar_swap_in_progress():
raise _swap_409
_raise_if_sidecar_swap_in_progress()
return await _load_model_impl(request, fastapi_request, current_subject)


async def _load_model_impl(request: LoadRequest, fastapi_request: Request, current_subject: str):
async def _load_model_impl(
request: LoadRequest,
fastapi_request: Request,
current_subject: str,
*,
current_request_counted: bool = False,
):
from core.inference.llama_cpp import LlamaServerNotFoundError

# A new load starts here; arm the progress throttle so this load's first
Expand Down Expand Up @@ -4557,6 +4536,13 @@ async def _load_model_impl(request: LoadRequest, fastapi_request: Request, curre
),
)

# Keep the resident model alive until every active generation finishes;
# the caller's lifecycle gate blocks new starts.
await _wait_for_model_switch_idle(current_request_counted = current_request_counted)
# A sidecar install can reserve the gate while inference drains, after the
# route-level checks above, so recheck before replacing either backend.
_raise_if_sidecar_swap_in_progress()

# Unload any active Unsloth model only after every hub conflict check.
if unsloth_backend.active_model_name:
logger.info(
Expand Down Expand Up @@ -4767,6 +4753,8 @@ async def _attempt_gguf_load(

# Unload any active GGUF model first
llama_backend = get_llama_cpp_backend()
await _wait_for_model_switch_idle(current_request_counted = current_request_counted)
_raise_if_sidecar_swap_in_progress()
if llama_backend.is_loaded:
logger.info("Unloading GGUF model before loading Unsloth model")
llama_backend.unload_model()
Expand Down Expand Up @@ -7096,7 +7084,7 @@ async def openai_chat_completions(
if payload.provider_id or payload.provider_type:
# External provider: this request won't touch the local GGUF, so drop it
# from the keep-warm count or its in-flight stream would falsely block a
# concurrent local auto-switch with model_switch_busy.
# concurrent local model switch from proceeding.
from core.inference.llama_keepwarm import untrack_current_request

untrack_current_request(request.scope)
Expand Down
Loading
Loading