unsloth/unsloth_cli/_inference.py
Daniel Han a867496a28
Studio: launch a DFlash speculative drafter automatically (#8338)
* Studio: launch a DFlash drafter automatically

Studio has recognised dflash-*.gguf since #7811, but only to hide it from
the quant picker. Nothing ever launched it, so a model that ships a DFlash
sidecar fell through to no speculative decoding at all.

Add DFlash as the third launchable drafter kind beside MTP and DSpark:
a _is_dflash_drafter_path predicate, local and Hub discovery, a
supports_dflash capability parsed from llama-server --help, and the
--model-draft / --spec-type draft-dflash emission. Unlike DSpark it is on
under Auto, since the published sidecar is 1.52 GiB and ships in the
model's own GGUF repo rather than being an ~11 GB opt-in fetch. DSpark
keeps first refusal when a repo somehow ships both, matching llama.cpp's
own downloader.

Discovery confirms general.architecture = dflash in the header rather than
pairing on the filename: the published sidecar is dflash-kquant.gguf, which
names no model family, so the DSpark pairing rule would reject the one file
this exists to find. The dflash/ directory is still not a drafter marker,
and DFlash is still excluded from companion reclaim, both because the name
doubles as a family a publisher puts on real weights.

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

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

* Harden the DFlash drafter fallback, pairing and dedupe

Four fixes from review of the auto-launch path.

Strip user-supplied DFlash args on the drafterless retry. The gate that
enters the retry counts a DFlash request, but the cleanup only recognised
MTP and DSpark. llama.cpp accumulates speculative types, so prepending
--spec-default while the DFlash group survived relaunched the drafter
that had just failed, and a main model that loads fine without it was
lost instead of recovered.

Skip a DFlash sidecar that names another weight in the same folder.
_drafter_matches_weight is False both for a sidecar naming no family and
for one naming a different family, so ranking put them in one bucket and
precision could float the foreign one to the top: loading model B beside
dflash-model-A-Q8_0.gguf and dflash-kquant.gguf launched model A's
drafter. Both files carry a real dflash header, so the architecture check
behind the ranking cannot catch it. The decision is made against the
weights actually present in the folder rather than by guessing which
stems are precision tokens, which keeps the published unpaired sidecar
eligible.

Stand the Auto DFlash fetch down once DSpark has resolved. DSpark takes
first refusal in the promotion, so for a repo shipping both kinds the
DFlash sidecar could never launch and the fetch spent bandwidth and cache
on a file that would not be used. An explicit dflash request still
fetches.

Keep Auto deduplicated after a failed DFlash drafter. _speculative_type
is reset to "default" by a successful drafterless retry while the launch
still records the resolved sidecar, so the next Apply compared the
intent's empty MTP path against it and reloaded a healthy server.
_spec_drafter_kind survives the fallback and now decides the comparison.

test_mtp_drafter_companion.py, test_native_gguf_companion.py,
test_llama_cpp_mtp_detection.py and test_resolve_quant_gguf.py: 489
passed, including two new tests for the foreign-sidecar case and for the
paired sidecar still winning.

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

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

* Keep DFlash discovery, the training guard and the hints in step

Discovery now accepts the dflash- prefix only. The shared companion
predicates recognise DFlash by that prefix, so a <model>-dflash.gguf
accepted by discovery was also a selectable Q8_0 main model in the quant
picker, and choosing that variant handed llama-server the drafter as the
target. Teaching the predicate the suffix instead would hide a real model
whose name merely ends in DFlash, which is the case #7811 exists to
protect, so detection gives the form up rather than the picker giving up
a model. No published sidecar uses it; the shipped one is
dflash-kquant.gguf. The same mismatch exists for MTP on main and is left
alone here.

The training VRAM guard now sizes a drafter named through
llama_extra_args. Discovery never fills gguf_dflash_file for a file
outside the model directory, but load_model still passes that path to
llama-server, so a load could be admitted beside a training run while
nothing was charged for the sidecar it makes resident.

The Speculative Decoding hint said Auto picks DSpark or else MTP / ngram
and that everything but DSpark leaves output unchanged. Auto now picks
DFlash too, and like DSpark it is not bit-identical on quantized targets.
The Draft Tokens hint gained the DFlash default, which shares the MTP
branch at 2 on GPU and 3 on CPU/Mac.

514 passed across the drafter, companion, detection, quant-resolution and
picker suites, including two new tests pinning the suffix form out of
discovery and the prefix form still in.

* Pair the remote DFlash sidecars with the selected weight

detect_dflash_file already refuses a sidecar named after a NEIGHBOURING
weight, so a folder holding two families cannot attach a foreign drafter
locally. The download picker and the offline cache reuse still ranked
every dflash-*.gguf by precision and name alone, never comparing a
candidate against the weight being loaded, so in a repo hosting more
than one family dflash-model-A-Q8_0.gguf outranked the generic
dflash-kquant.gguf and model B downloaded and launched model A's
drafter.

The pairing rule now lives in one place, dflash_repo_preference_key,
built on the same _drafter_names_other_weight predicate the local scan
uses: a sidecar naming this weight's family first (most specific stem
first, as detect_mtp_file does), then one naming no weight present here,
then one naming a neighbour. The last is demoted rather than dropped, so
a repo whose only sidecar looks foreign still has a fallback.

Deciding against the weights actually present is what keeps the
published unpaired sidecar eligible: dflash-kquant.gguf has a precision
token for a stem, not a family name, so "the stem is non-empty" cannot
stand in for "this names another model". Nothing changes for a repo with
one sidecar, and with no weight in hand the order is precision only, as
before.

Tests cover a multi-family repo picking the generic sidecar, the same
repo picking the specific one for its own weight, the shipped
Muse-Glimmer layout still resolving, and the cached path agreeing with
the download path.

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

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

* Validate DFlash candidates and size the extras drafter once

Three fixes found in review of the DFlash drafter work.

detect_dflash_file read a candidate's GGUF header before asking the
caller's accept callback about it, so a dflash-*.gguf symlink in a
directory reached through a native grant had its out-of-lease target
opened before the grant check ran, and no later rejection takes a read
back. The loop now resolves the launch path, runs accept, and only then
parses the header and applies the architecture check. accept still
receives the resolved launch path, and callers that pass no accept see
the same candidates in the same order as before.

The training admission guard charged the llama_extra_args --model-draft
sidecar on top of the local one discovery had already found, so a 1.5
GiB drafter was billed as 3 GiB and the guard could refuse an inference
load that fits. The effective draft path is now sized exactly once, with
identity taken from the resolved path so a symlink or another spelling
of the same file dedupes too.

That same charge also satisfied the local-weights early return on its
own. Loading a remote GGUF repo has no local main weight, so a local
--model-draft made the guard return the drafter alone and skip the
listing that prices the target model, which could admit a load that then
exhausts VRAM next to a running training job. The local branch now fires
only when a local weight is actually present, and the drafter is added
to whichever branch produces the estimate, including the remote one.

Regression tests for all three.

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

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

* Validate remote DFlash files by header and stop charging unused DFlash bytes

Two fixes to the DFlash sidecar paths.

Remote and cached DFlash candidates are now confirmed by their GGUF header,
not by their filename. _pick_dflash and _cached_repo_dflash_drafter selected
with _is_dflash_drafter_path, a dflash- prefix test, while the local scan in
detect_dflash_file also required general.architecture == dflash. A remote repo
holding an ordinary weight whose basename starts with dflash- therefore had
that full weight downloaded and handed to llama-server as --model-draft, which
falls back at startup after the bytes are already spent. The architecture rule
moves into is_dflash_architecture in model_config, beside the naming rules and
shared by every path, the way dflash_repo_preference_key already is. The header
is only readable once the file is on disk, so the download validates after the
fetch and falls through to the next candidate instead of returning None; the
prefix-only naming rule is unchanged.

The training coexistence guard no longer charges DFlash bytes a load under Auto
will never fetch. _remote_gguf_companion_bytes added the preferred DSpark and
the preferred DFlash sidecar whenever the repo listed both, but the loader
stands down on the DFlash fetch once DSpark resolves under Auto, so those bytes
are never resident and the guard could 409 a load that fits. The new
dspark_first flag mirrors that selection. Where the choice is genuinely unknown
the deliberate over-estimate stands, and an explicitly forced DFlash still pays
for its sidecar.

Regression tests cover the fetch falling through an impostor to the real
sidecar, an all-impostor repo recording a permanent absence, the snapshot reuse
and offline cache lookups applying the same rule, and the Auto guard charging
DSpark only when a repo publishes both kinds.

* Gate the DFlash stand-down and the guard's sizing on what the load actually does

The Auto DFlash fetch stood down whenever _download_dspark answered with a
path, but that call deliberately reports an already-cached DSpark sidecar even
on a binary with no usable --spec-type draft-dspark (so the route's reuse check
does not reload the same server on every Apply). The promotion refuses such a
path, so on a DFlash-capable binary a repo shipping both companions suppressed
the DFlash fetch for a sidecar that can never launch and the load ended up with
no drafter at all. The capability gate now lives in _dspark_wins_auto, shared by
the fetch and the promotion so the two cannot disagree.

_remote_gguf_companion_bytes still ranked DFlash candidates with the name-only
dflash_preference_key while the loader moved to the family-aware
dflash_repo_preference_key, so in a multi-family repo the guard could price a
different, smaller sidecar than the one that lands. The selected weight name is
threaded down and the guard now sorts with the downloader's key over the
neighbouring weights from the same listing.

* Apply the load's boundaries to drafter discovery, and size Auto's one drafter

ModelConfig.from_identifier ran the local companion scan with no way for the
caller to say what was in bounds, so a native-grant load read the header of a
dflash-*.gguf symlinked out of the granted directory. The validated rescan on
the load route rejected it afterwards, which does not take a read back. The
boundary now travels into the scan, for all three drafter kinds, so the two
passes cannot disagree about what is in bounds.

Remote DFlash discovery matched the basename in any nested directory, but the
local contract is root level only: a quants/dflash-*.gguf is an ordinary weight
detect_dflash_file would never offer, and the header can only be read once the
bytes are here, so the whole weight downloaded before the rejection. Checked
through a separate predicate so the prefix-only naming rule the other callers
share stays exactly as it is.

A split companion is only usable as a whole set, since llama-server resolves the
sibling shards from the first one's directory. Fetching just the picked shard
left a drafter whose header reads fine and which the server cannot open, so the
load fell back to no speculation with nothing to show for the download. The
companion download now resolves its shards with the same helper the main-model
download uses, and neither reuse path reports a half set as a cache hit.

The remote sizing charged the first-ranked DFlash candidate, but a rejected
candidate falls through to the next name in the ranking, which can be a larger
file; headers are unreadable from a listing, so the bound now covers every
candidate the fallback can reach. And under Auto the guard charged the MTP
drafter on top of the DFlash sidecar that replaces it. Auto launches exactly one
drafter, in a fixed order, so dspark_first now expresses the whole promotion:
DSpark alone when the repo publishes one, otherwise the larger of the DFlash
bound and the MTP drafter, since every DFlash candidate can still be turned away
on its header and the load then keeps the MTP one.

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

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

* Budget a split DFlash sidecar as a set, and reject half a cached one

The remote sizing bounded the DFlash fetch with the largest candidate the
post-fetch fallback could land on, but each entry is one shard, while
_download_companion_gguf fetches the whole shard set the picked file belongs to
and llama-server keeps every shard resident. A sidecar published as two 1 GiB
shards was budgeted at 1 GiB, and under-charging is the direction that waves a
load through and then exhausts VRAM beside a running training job. The
candidates are grouped into their sets with _gguf_extra_shards, the same helper
the download resolves shards with, and the bound is the largest set total.

_cached_repo_dflash_drafter's offline fallback accepted a candidate on is_file
plus its header, so a snapshot holding shard 1 alone was handed back as the
drafter with no fetch left to complete the set. The header reads fine, then
llama-server cannot open the siblings it resolves from that directory and the
load falls back to no speculation. Same _drafter_split_is_complete rule the
snapshot reuse already applies, and skipped rather than fatal like the header
check, since another snapshot may hold the complete copy.

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

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

* Move drafter naming, ranking and DFlash discovery into a drafters package

Pure structural move, no behaviour change. The shared primitives, the
ranking keys and the DFlash detector were spread through model_config
alongside unrelated model handling, and the same rules are reached from
four different paths, so they now live in one package.

model_config re-exports every moved name, so callers and tests that
import them from there keep working.

The package deliberately does not import model_config at module import
time. The GGUF split and quant naming helpers stay where they are, since
non-drafter code shares them, and are imported per call instead.

* Give the guard's DFlash bound a name and a home

The bound was fifteen lines of generator plus the comment explaining why
it is a max over shard sets rather than the best-ranked candidate, inline
in the middle of a function that also sizes mmproj, MTP and DSpark. It is
pure arithmetic over a listing, so it moves to drafters.budget with the
reasoning attached to it.

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

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

* Studio: fix the DFlash lint gate, and carry over what #6747 got right

Five changes on top of the DFlash drafter work.

Lint gate. The compatibility shim re-exporting the moved drafter helpers from
model_config tripped scripts/verify_import_hoist.py, whose __all__ exemption is
scoped to package __init__.py and which ships a
reexport_in_ordinary_module_is_still_blocked self-test. The shim is gone: the
module imports only what it still calls, and every other call site imports from
utils.models.drafters directly. dspark_preference_key stays reachable from
model_config as a delegating def, because repointing routes/inference.py's
pre-existing function-local import is the verifier's TARGET-CHANGED case and its
relocation exemption only covers module-level imports.

Download plan. preferred_dflash_sibling in hub/utils/gguf_plan.py, and the
sidecar as an expected file on every GgufVariantPlan. The sidecar was fetched
but the hub manifest never knew about it, so download progress under-counted by
~1.5 GiB. Ranked with dflash_repo_preference_key, so the plan and the loader
cannot disagree, and per variant, so a multi-family repo does not hand variant B
the drafter named after variant A.

Capability-regained retry. A load that stood down because llama-server could not
run the drafter told the user to update, then deduped the reload the update was
meant to repair. spec_binary_fallback_can_retry re-reads the binary, asking
about the capability the drafter kind actually needs rather than the reason
code, since every kind records the same binary_no_mtp.

Transient fetch retry. _download_companion_gguf gained on_transient_failure, so
a listing that never answered or a download that dropped is worth one more
Apply. Permanent Hub errors, a full or unwritable cache, offline mode and
cancellation are unaffected, and a header rejection still falls through to the
next candidate rather than counting as transient. The probe cache key moved from
(path, int(mtime)) to (path, st_mtime_ns, st_size), so an update landing in the
same second as the probe is not answered with the old build's capabilities.

CLI. unsloth_cli/_inference.py passes gguf_dflash_file into the GGUF load, so
the managed CLI path engages DFlash instead of silently running without it.

No vision gate, now measured rather than argued. Muse-Glimmer-30B UD-Q4_K_XL
with mmproj-kquant and dflash-kquant, llama.cpp b10342, one B200, n_max=2,
greedy, on a prompt carrying ~545 image tokens: 92.1 to 114.2 tok/s at 0.646
acceptance, greedy output byte-identical to the drafter-free run, no load
failure. The comment at the Auto promotion site cited llama.cpp #22673, which is
an MTP result, for a DFlash decision; it now cites the measurement.

Also fixes test_from_identifier_never_reads_a_sidecar_outside_the_boundary,
which patched is_dflash_architecture on the re-exporting module rather than the
one detect_dflash_file resolves it in, so its reads == [] assertion held whether
or not the lease boundary worked.

* Tighten the DFlash comments for PR #8338

* Apply ruff-format kwarg spacing for PR #8338

* Fix the DFlash download plan and two stale-state reloads for PR #8338

Five review items, all reproduced first.

The download plan promised the wrong files. A split sidecar contributed only its
first shard, so the variant read complete while the loader's completeness check
then refused the companion; it now carries the whole shard family. The pairing
weight came from the listing's first sibling while plan_from_expected_files keeps
the lexicographically first family, so a two-family variant key planned the
discarded family's sidecar; both now use the kept family. And a root-level
dflash- prefix is one real weights carry, which a listing cannot tell apart from
a drafter, so a 54 GB model was planned as a companion to a 15 GB variant; a
candidate is now bounded by the weights it would draft for, since a drafter is a
few layers of its target and cannot outweigh it.

The training coexistence guard charged the Auto DFlash sidecar even when extra
args owned --spec-type, which stops the loader's promotion, so a chat load could
be refused with 409 for bytes nothing would open. Extra args asking for
draft-dflash keep the charge.

The diffusion early-return cleared the speculative fallback state but not the
DFlash retry flag, and discovery runs before the metadata read that classifies
the model, so a transient sidecar failure tore down a healthy diffusion server on
every Apply.

Each fix has a regression test that fails without it.

* Carry the DFlash plan bounds into the runtime paths for PR #8338

Four review items from the second round, each reproduced first.

The budget still charged a forced dflash mode when extra args owned --spec-type.
_build_speculative_flags returns before any mode branch in that case, so neither
the forced mode nor the Auto promotion reaches the sidecar; only extra args
asking for draft-dflash themselves still pay.

The runtime picker had no size bound, so a root-level ordinary weight carrying
the dflash- prefix downloaded in full before its header could be read, which is
exactly what the download plan now refuses. It applies the same bound, sized from
the repo listing, and an unavailable size leaves the candidate eligible as before.

A permanent listing error records no answer at all, so _dflash_sidecar_absent
stayed False and the drafter_not_found arm relaunched a healthy drafter-free
server on every Apply. DFlash asks through _dflash_retry_needed instead, which is
set only for the failures worth another attempt.

A listing holding part of a split companion returned its first shard as usable,
contradicting the complete-set checks on snapshot and cache reuse and handing
llama-server a set it cannot open. The filename carries the set size, so the
listing is now checked before the download.

Each fix has a regression test that fails without it.

* Make the DFlash size and split rules agree across plan, fetch and guard for PR #8338

Six review items from the third round, each reproduced first. Five are places
the previous round's rules had not reached.

dflash_plan_files now filters candidate families before ranking rather than
after, so a half-published split set or an oversized ordinary weight at the top
of the order steps aside for a usable sidecar behind it instead of taking the
plan down with it. It also applies the split-completeness rule the runtime got
last round, since planning a set the listing only half carries reports the
download complete and then loses DFlash.

The runtime size bound compared the picked shard rather than its whole set, so a
split ordinary weight whose halves each sit under the target still downloaded in
full. It sums the family now, through a shared helper.

The training coexistence guard took the maximum over every root candidate with
no size bound at all, charging gigabytes for files the fetch itself refuses.
dflash_budget_bytes takes the target size and drops them.

The incomplete-split rejection added last round lands after outcome["listed"]
is set, so DSpark read a settled answer as retryable and relaunched a healthy
server on every Apply. It records absence explicitly.

SpeculativeType omitted dflash, so Typer rejected --speculative-type dflash
before any of the new loading code ran and the mode was reachable only through
Auto.

Each fix has a regression test that fails without it.

* Price DSpark by shard set and share one split-listing rule for PR #8338

Four review items from the fourth round, two of them under-charges that could
admit a load beside a running training job and then exhaust VRAM.

The guard priced a remote DSpark sidecar as the single file the ranking picked,
while llama-server maps every shard of a split set, so a two-shard sidecar was
budgeted at roughly half its resident weight. DSpark candidates are grouped into
shard families now and the selected family's total is charged, matching what
DFlash already did.

Auto granted DSpark first refusal on the strength of the listing alone. Since the
fetch now refuses an incomplete split set, the load falls through to DFlash,
which can be the larger of the two, and the guard had already returned the DSpark
figure. Only a complete set settles it.

The runtime DFlash picker filtered incomplete families after ranking rather than
before, so a half-published set at the top of the order returned a shard,
_download_companion_gguf refused it, and the loop ended instead of reaching the
complete sidecar behind it.

Extras owning --spec-type with their own --model-draft charged the discovered
sidecar as well, though _build_speculative_flags returns before that one is
emitted. Only the drafter that launches is charged now. Extras without
--spec-type still charge both, since Studio emits its own and which lands is
genuinely unknown.

The listing completeness rule was about to have three copies, so it moved into
utils.models.drafters as split_listing_is_complete and the plan, the fetch and
the guard all call it.

Each fix has a regression test that fails without it.

* Tighten the DFlash review-round comments for PR #8338

Comments and docstrings only, no code change: verified with comment_tools.py
check and the prepush gate's comment-only mode.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-08-11 06:17:38 -07:00

925 lines
33 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
"""Model loading and streaming shared by `inference` and `chat`."""
import asyncio
import json
import os
import re
import sys
from contextlib import contextmanager, redirect_stderr, redirect_stdout
from pathlib import Path
from typing import List, Literal, Optional
import typer
# Canonical speculative-decoding modes, mirroring the backend's
# _CANONICAL_SPEC_MODES. Named once so the CLI's option annotations, the HTTP
# payload builders and the in-process loader cannot drift apart when a mode is
# added; typer reads it at runtime to validate --speculative-type.
SpeculativeType = Literal[
"auto", "mtp", "dspark", "dflash", "ngram", "mtp+ngram", "off", "ngram-simple"
]
_THINK_OPEN = "<think>"
_THINK_BLOCK = re.compile(rf"{re.escape(_THINK_OPEN)}.*?</think>", re.DOTALL)
_STREAMED_ERROR_PREFIX = "Error: "
# Cloudflare (in front of remote Unsloth proxies like RunPod) 403s the default
# "Python-urllib/X.Y" User-Agent as a bot; send a real one on every request.
_USER_AGENT = "unsloth-cli"
_MPI_ENV_PAIRS = (
("OMPI_COMM_WORLD_RANK", "OMPI_COMM_WORLD_SIZE"),
("PMI_RANK", "PMI_SIZE"),
("PMIX_RANK", "PMIX_SIZE"),
("MPI_RANK", "MPI_WORLD_SIZE"),
("MV2_COMM_WORLD_RANK", "MV2_COMM_WORLD_SIZE"),
)
# Built lazily; urllib stays function-local to match this module.
_no_redirect_opener = None
def urlopen_no_redirect(request, timeout):
"""urlopen that errors on any redirect: following a 3xx would send a bearer
token (or accept an identity proof) to a base we never vetted, letting a port
squatter relay a real Unsloth's response."""
global _no_redirect_opener
if _no_redirect_opener is None:
import urllib.error
import urllib.request
class _NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
raise urllib.error.HTTPError(
req.full_url, code, f"refusing redirect to {newurl}", headers, fp
)
_no_redirect_opener = urllib.request.build_opener(_NoRedirect)
return _no_redirect_opener.open(request, timeout = timeout)
# /api/inference/load and /unload pad their body so a proxy cannot time a slow load
# out, committing the 200 before the work finishes. A failure found after that travels
# only in-band under this key (studio/backend/routes/inference.py), so a client that
# treats any 200 as success reports a failed load as a successful one.
_DEFERRED_ERROR_KEY = "_deferred_error"
def raise_for_deferred_error(url: str, body):
"""Raise the late failure a padded 200 body carries; else return ``body``.
``urllib.error.HTTPError`` specifically: it is the class every CLI caller already
handles for a plain HTTP failure, so existing ``except`` blocks, messages and exit
codes keep working, and ``.read()`` yields the same ``{"detail": ...}`` shape.
"""
if not isinstance(body, dict):
return body
deferred = body.get(_DEFERRED_ERROR_KEY)
if not isinstance(deferred, dict):
return body
import email.message
import io
import urllib.error
status = deferred.get("status_code")
if not isinstance(status, int) or isinstance(status, bool):
status = 500
detail = deferred.get("detail")
if not isinstance(detail, str) or not detail:
detail = "unknown error" if detail is None else json.dumps(detail)
headers = email.message.Message()
headers["Content-Type"] = "application/json"
raise urllib.error.HTTPError(
url, status, detail, headers, io.BytesIO(json.dumps({"detail": detail}).encode())
)
def require_completed_padded_body(url: str, body):
"""Return ``body``, or raise if it is not the payload a padded route promised.
A proxy that gives up mid-pad leaves a 200 with an empty or truncated body, so
accepting it reports an unfinished load or unload as completed. Only the two padded
routes commit their status that early, so only they require a payload; ``{}`` is
rejected too, since that is what a blank body decodes to here. Mirrored by
``assertCompletedPaddedBody`` in studio/frontend/src/features/chat/api/padded-response.ts.
"""
if isinstance(body, dict) and body:
return body
raise RuntimeError(
f"{url} did not report completion: the connection closed before the "
"server's reply arrived. Check the model's status before retrying."
)
def read_json_checking_deferred_error(url: str, response):
"""Drain ``response``, then raise any deferred error its body carries.
Draining matters on its own: stopping at the headers of a padded /load leaves the
load running, so the caller resumes too early. An incomplete JSON payload is a
truncated padded reply, not a success (see ``require_completed_padded_body``).
"""
try:
raw = response.read()
finally:
response.close()
try:
body = json.loads(raw.decode(errors = "replace") or "{}")
except ValueError:
body = None
return require_completed_padded_body(url, raise_for_deferred_error(url, body))
def ensure_studio_backend_path() -> None:
backend_dir = str(Path(__file__).resolve().parents[1] / "studio" / "backend")
if backend_dir not in sys.path:
sys.path.insert(0, backend_dir)
def configure_quiet_logging() -> None:
import logging
# The CLI never configures structlog, so without this every backend INFO
# line prints. LOG_LEVEL is exported so the worker subprocess inherits it.
level_name = os.environ.setdefault("LOG_LEVEL", "WARNING").upper()
level = getattr(logging, level_name, logging.WARNING)
os.environ.setdefault("HF_HUB_DISABLE_PROGRESS_BARS", "1")
# Quieting logs must not fail a command before the import that really needs
# structlog gets to report itself.
try:
import structlog
except ModuleNotFoundError:
return
structlog.configure(wrapper_class = structlog.make_filtering_bound_logger(level))
def _parse_nonnegative_int(value: Optional[str]) -> Optional[int]:
if value is None:
return None
try:
parsed = int(value)
except (TypeError, ValueError):
return None
return parsed if parsed >= 0 else None
def _first_mpi_env_pair() -> tuple[Optional[int], Optional[int]]:
for rank_name, size_name in _MPI_ENV_PAIRS:
rank = _parse_nonnegative_int(os.environ.get(rank_name))
world_size = _parse_nonnegative_int(os.environ.get(size_name))
if rank is not None and world_size is not None and world_size > 1 and rank < world_size:
return rank, world_size
return None, None
def _json_rank_count_from_env(name: str) -> Optional[int]:
value = os.environ.get(name)
if not value:
return None
try:
if value.lstrip().startswith(("[", "{")):
data = json.loads(value)
else:
with open(value, "r", encoding = "utf-8") as f:
data = json.load(f)
except (json.JSONDecodeError, OSError, UnicodeDecodeError):
return None
if isinstance(data, list):
return len(data)
if isinstance(data, dict) and isinstance(data.get("hosts"), list):
return len(data["hosts"])
return None
def mlx_distributed_info() -> tuple[bool, int, Optional[int]]:
"""Return launch-context metadata without initializing MLX distributed."""
rank = _parse_nonnegative_int(os.environ.get("MLX_RANK"))
world_size = _parse_nonnegative_int(os.environ.get("MLX_WORLD_SIZE"))
if rank is not None:
if (
world_size is not None
and world_size > 1
and rank < world_size
and os.environ.get("NCCL_HOST_IP")
and os.environ.get("NCCL_PORT")
):
return True, rank, world_size
inferred_size = _json_rank_count_from_env("MLX_HOSTFILE")
if inferred_size is not None and inferred_size > 1 and rank < inferred_size:
return True, rank, inferred_size
inferred_size = _json_rank_count_from_env("MLX_IBV_DEVICES")
if (
inferred_size is not None
and inferred_size > 1
and rank < inferred_size
and os.environ.get("MLX_JACCL_COORDINATOR")
):
return True, rank, inferred_size
return False, 0, None
mpi_rank, mpi_world_size = _first_mpi_env_pair()
return mpi_rank is not None, mpi_rank or 0, mpi_world_size
def mlx_distributed_uses_mpi() -> bool:
"""Whether the current distributed context was launched through MPI."""
return (
_parse_nonnegative_int(os.environ.get("MLX_RANK")) is None
and _first_mpi_env_pair()[0] is not None
)
@contextmanager
def quiet_if_nonzero_mlx_rank():
"""Silence parent and child-process stdout/stderr on nonzero ranks."""
if mlx_distributed_info()[1] == 0:
yield
return
sys.stdout.flush()
sys.stderr.flush()
saved_stdout_fd = os.dup(1)
saved_stderr_fd = os.dup(2)
with open(os.devnull, "w", encoding = "utf-8") as devnull:
try:
os.dup2(devnull.fileno(), 1)
os.dup2(devnull.fileno(), 2)
with redirect_stdout(devnull), redirect_stderr(devnull):
yield
finally:
sys.stdout.flush()
sys.stderr.flush()
os.dup2(saved_stdout_fd, 1)
os.dup2(saved_stderr_fd, 2)
os.close(saved_stdout_fd)
os.close(saved_stderr_fd)
def visible_text(text: str, show_thinking: bool) -> str:
if show_thinking:
return text
text = _THINK_BLOCK.sub("", text)
# Hold back an unclosed trailing <think> so reasoning never leaks mid-stream.
open_idx = text.find(_THINK_OPEN)
if open_idx != -1:
text = text[:open_idx]
max_prefix = min(len(text), len(_THINK_OPEN) - 1)
for size in range(max_prefix, 0, -1):
if _THINK_OPEN.startswith(text[-size:]):
return text[:-size]
return text
def stream_to_stdout(stream, show_thinking: bool) -> str:
# Backends yield the full text-so-far on each step (llama.cpp ends with a
# metadata dict, skipped); print the growing tail, return the raw text.
raw = ""
shown = ""
for chunk in stream:
if not isinstance(chunk, str):
continue
raw = chunk
rendered = visible_text(chunk, show_thinking)
delta = rendered[len(shown) :]
if delta:
sys.stdout.write(delta)
sys.stdout.flush()
shown = rendered
sys.stdout.write("\n")
sys.stdout.flush()
return raw
def stream_markdown(stream, show_thinking: bool, *, console) -> str:
from rich.live import Live
from rich.markdown import Markdown
from rich.text import Text
raw = ""
with Live(console = console, refresh_per_second = 12, vertical_overflow = "visible") as live:
for chunk in stream:
if not isinstance(chunk, str):
continue
raw = chunk
visible = visible_text(chunk, show_thinking)
live.update(Markdown(visible) if visible.strip() else Text(""))
return raw
def collect_stream(stream, show_thinking: bool) -> str:
raw = ""
for chunk in stream:
if isinstance(chunk, str):
raw = chunk
return visible_text(raw, show_thinking)
def raise_on_streamed_error(stream):
# Match real backend errors by type (GenStreamError), not the "Error:" text
# prefix, so a completion whose text opens with "Error:" is not misread as a
# backend failure.
try:
ensure_studio_backend_path()
from core.inference.orchestrator import GenStreamError
except Exception:
GenStreamError = None
for chunk in stream:
if GenStreamError is not None and isinstance(chunk, GenStreamError):
raise RuntimeError(str(chunk)[len(_STREAMED_ERROR_PREFIX) :].strip() or "Unknown error")
yield chunk
def render_columns(
left_label: str,
left_text: str,
right_label: str,
right_text: str,
*,
console = None,
) -> None:
from rich import box
from rich.console import Console
from rich.table import Table
table = Table(box = box.MINIMAL, expand = True, padding = (0, 1), pad_edge = False)
table.add_column(left_label, header_style = "bold yellow", ratio = 1, overflow = "fold")
table.add_column(right_label, header_style = "bold magenta", ratio = 1, overflow = "fold")
table.add_row(left_text or "", right_text or "")
(console or Console()).print(table)
class ChatBackend:
"""Uniform stream()/close() over the llama-server and Unsloth backends."""
def __init__(self, kind: str, backend) -> None:
self._kind = kind # "gguf" | "unsloth"
self._backend = backend
def stream(
self,
messages: list,
*,
system_prompt: str,
temperature: float,
top_p: float,
top_k: int,
max_new_tokens: int,
repetition_penalty: float,
enable_thinking: bool,
use_adapter: Optional[bool] = None,
):
if self._kind == "gguf":
# llama-server takes the system prompt as the first message.
msgs = list(messages)
if system_prompt:
msgs = [{"role": "system", "content": system_prompt}, *msgs]
return self._backend.generate_chat_completion(
messages = msgs,
temperature = temperature,
top_p = top_p,
top_k = top_k,
max_tokens = max_new_tokens,
repetition_penalty = repetition_penalty,
enable_thinking = enable_thinking,
)
gen_kwargs = dict(
messages = messages,
system_prompt = system_prompt,
temperature = temperature,
top_p = top_p,
top_k = top_k,
max_new_tokens = max_new_tokens,
repetition_penalty = repetition_penalty,
enable_thinking = enable_thinking,
)
if use_adapter is not None:
return self._backend.generate_with_adapter_control(
use_adapter = use_adapter, **gen_kwargs
)
return self._backend.generate_chat_response(**gen_kwargs)
def close(self) -> None:
# Shut the worker down directly: the graceful unload_model waits for
# an ack that compare mode can swallow, hanging exit for minutes.
try:
if self._kind == "gguf":
self._backend.unload_model()
else:
self._backend._shutdown_subprocess(timeout = 2.0)
except Exception:
pass
def share_distributed_object(
self,
obj,
*,
timeout = 300.0,
):
if self._kind != "unsloth" or not hasattr(self._backend, "share_distributed_object"):
raise RuntimeError(
"Distributed MLX chat requires the Unsloth MLX backend; "
f"backend '{self._kind}' cannot broadcast chat turns."
)
return self._backend.share_distributed_object(obj, timeout = timeout)
def resolve_model_config(model: str, *, hf_token: Optional[str]):
ensure_studio_backend_path()
from utils.models import ModelConfig
model_config = ModelConfig.from_identifier(model_id = model, hf_token = hf_token)
if not model_config:
typer.echo("Could not resolve model config", err = True)
raise typer.Exit(code = 1)
return model_config
def _validate_llama_extra_args_or_exit(llama_extra_args: Optional[List[str]]) -> list[str]:
from core.inference.llama_server_args import validate_extra_args
try:
return validate_extra_args(llama_extra_args)
except ValueError as exc:
typer.echo(f"Error: {exc}", err = True)
raise typer.Exit(code = 1)
def _load_gguf_backend(
model_config,
*,
hf_token,
max_seq_length,
tensor_parallel: bool = False,
speculative_type: Optional[SpeculativeType] = None,
spec_draft_n_max: Optional[int] = None,
llama_extra_args: Optional[List[str]] = None,
):
ensure_studio_backend_path()
from core.inference.llama_cpp import GgufLoadIntent, LlamaCppBackend
from core.inference.tensor_fallback import load_with_tensor_fallback
llama_backend = LlamaCppBackend()
extra_args = _validate_llama_extra_args_or_exit(llama_extra_args)
intent_fields = dict(
hf_variant = model_config.gguf_variant,
model_identifier = model_config.identifier,
is_vision = model_config.is_vision,
n_ctx = max_seq_length,
)
if model_config.gguf_hf_repo:
intent_fields.update(hf_repo = model_config.gguf_hf_repo, hf_token = hf_token)
else:
intent_fields.update(
gguf_path = model_config.gguf_file,
mmproj_path = model_config.gguf_mmproj_file,
mtp_draft_path = model_config.gguf_mtp_file,
dspark_draft_path = model_config.gguf_dspark_file,
dflash_draft_path = model_config.gguf_dflash_file,
)
if speculative_type is not None:
intent_fields["speculative_type"] = speculative_type
if spec_draft_n_max is not None:
intent_fields["spec_draft_n_max"] = spec_draft_n_max
async def _attempt_gguf_load(
requested_tensor_parallel: bool, attempt_extra_args: Optional[List[str]]
) -> bool:
return llama_backend.load_model(
GgufLoadIntent(
**intent_fields,
tensor_parallel = requested_tensor_parallel,
extra_args = attempt_extra_args,
)
)
loaded = asyncio.run(
load_with_tensor_fallback(
_attempt_gguf_load,
requested_tensor = tensor_parallel,
extra_args = extra_args,
label = model_config.identifier,
)
)
if not loaded:
typer.echo("Model load failed", err = True)
raise typer.Exit(code = 1)
return ChatBackend("gguf", llama_backend)
def load_chat_backend(
model: str,
*,
hf_token: Optional[str],
max_seq_length: int,
load_in_4bit: bool,
tensor_parallel: bool = False,
speculative_type: Optional[SpeculativeType] = None,
spec_draft_n_max: Optional[int] = None,
llama_extra_args: Optional[List[str]] = None,
model_config = None,
fresh_backend: bool = False,
):
"""Load `model` in-process: GGUF via llama-server, else the orchestrator.
fresh_backend uses a private orchestrator so a second model (compare's
base column) can run alongside the main one.
"""
from unsloth_cli._studio_deps import studio_backend_imports
with studio_backend_imports("unsloth inference", studio_only = True), quiet_if_nonzero_mlx_rank():
is_mlx_distributed, rank, _world_size = mlx_distributed_info()
if model_config is None:
model_config = resolve_model_config(model, hf_token = hf_token)
if is_mlx_distributed and model_config.is_gguf:
if rank == 0:
typer.echo(
"Distributed MLX inference does not support GGUF/llama.cpp models. "
"Use a non-GGUF MLX model under mlx.launch, or run GGUF without "
"mlx.launch.",
err = True,
)
raise typer.Exit(code = 1)
if rank == 0:
typer.echo(f"Loading {model}", err = True)
if model_config.is_gguf:
return _load_gguf_backend(
model_config,
hf_token = hf_token,
max_seq_length = max_seq_length,
tensor_parallel = tensor_parallel,
speculative_type = speculative_type,
spec_draft_n_max = spec_draft_n_max,
llama_extra_args = llama_extra_args,
)
if fresh_backend:
ensure_studio_backend_path()
from core.inference import InferenceOrchestrator
backend = InferenceOrchestrator()
else:
ensure_studio_backend_path()
from core.inference import get_inference_backend
backend = get_inference_backend()
try:
loaded = backend.load_model(
config = model_config,
max_seq_length = max_seq_length,
load_in_4bit = load_in_4bit,
hf_token = hf_token,
tensor_parallel = tensor_parallel,
mlx_distributed = is_mlx_distributed,
)
except Exception as exc:
if not is_mlx_distributed:
raise
if rank == 0:
typer.echo(str(exc) or "Model load failed", err = True)
raise typer.Exit(code = 1)
if not loaded:
typer.echo("Model load failed", err = True)
raise typer.Exit(code = 1)
return ChatBackend("unsloth", backend)
def _loopback_candidate_bases(base: str) -> list:
"""For a bare ``localhost`` base, the concrete IP bases to try, IPv4
127.0.0.1 first (where ``unsloth studio`` binds by default). Pinning to one
address up front means discovery, the identity check, and the credential we
then send all target the same endpoint instead of racing IPv4/IPv6
resolution -- which would otherwise let the health probe land on one address
and the identity check on another. A literal IP or remote name is unchanged.
"""
from urllib.parse import urlparse
parsed = urlparse(base)
if (parsed.hostname or "").lower() != "localhost":
return [base]
import socket
port = parsed.port or (443 if parsed.scheme == "https" else 80)
try:
ips = {
ai[4][0] for ai in socket.getaddrinfo(parsed.hostname, port, type = socket.SOCK_STREAM)
}
except Exception:
return [base]
ordered = sorted(ips, key = lambda ip: (ip != "127.0.0.1", ip))
bases = [
f"{parsed.scheme}://" + (f"[{ip}]:{port}" if ":" in ip else f"{ip}:{port}")
for ip in ordered
]
return bases or [base]
def find_studio_server(timeout: float = 3.0) -> Optional[str]:
import urllib.request
base = os.environ.get("UNSLOTH_STUDIO_URL", "http://127.0.0.1:8888").rstrip("/")
# Try the concrete loopback addresses in order and return the first that
# answers, so the rest of the flow talks to that exact address.
for candidate in _loopback_candidate_bases(base):
request = urllib.request.Request(
f"{candidate}/api/health", headers = {"User-Agent": _USER_AGENT}
)
try:
with urllib.request.urlopen(request, timeout = timeout):
return candidate
except Exception:
continue
return None
def is_loopback_url(base: str) -> bool:
"""True only when *base* resolves to loopback. find_studio_server() trusts a
base after only a health probe, so credentials are auto-sent only to loopback
(a local Unsloth or an SSH tunnel on 127.0.0.1), the targets the auto flows mean."""
from urllib.parse import urlparse
host = (urlparse(base).hostname or "").lower()
if host in ("localhost", "127.0.0.1", "::1"):
return True
try:
import ipaddress
return ipaddress.ip_address(host).is_loopback
except ValueError:
return False
def verify_studio_identity(base: str, timeout: float = 3.0) -> bool:
"""Confirm `base` is really this machine's Unsloth before sending a secret.
Send a random nonce to /api/auth/identity and check the returned HMAC against
the one computed from the local same-user secret; an endpoint without that
secret (port squatter, remote/fake) can't match. Fails closed on any error."""
import base64
import hmac as _hmac
import json
import secrets as _secrets
import socket
import urllib.request
from urllib.parse import urlparse
try:
import studio.backend.core # noqa: F401 puts studio/backend on sys.path
from studio.backend.auth import storage
except Exception:
return False
parsed = urlparse(base)
host = parsed.hostname or ""
port = parsed.port or (443 if parsed.scheme == "https" else 80)
# Resolve to one concrete address and talk to *that* address, then bind the
# proof to (address, port). A name like localhost can resolve to a squatter on
# ::1 while the real Unsloth is on 127.0.0.1; connecting to the resolved IP and
# binding to it means a proof relayed from a different address/port won't match.
try:
ip = socket.getaddrinfo(host, port, type = socket.SOCK_STREAM)[0][4][0]
except Exception:
return False
netloc = f"[{ip}]:{port}" if ":" in ip else f"{ip}:{port}"
nonce = _secrets.token_bytes(32)
query = base64.urlsafe_b64encode(nonce).decode()
request = urllib.request.Request(
f"{parsed.scheme}://{netloc}/api/auth/identity?nonce={query}",
headers = {"User-Agent": _USER_AGENT, "Host": parsed.netloc},
)
try:
# No redirects: a 302 could relay a real Unsloth's proof (see urlopen_no_redirect).
# Cap the read: the server is still unverified, so don't trust its length.
with urlopen_no_redirect(request, timeout = timeout) as response:
proof = json.loads(response.read(65536).decode() or "{}").get("proof")
except Exception:
return False
if not isinstance(proof, str):
return False
try:
expected = storage.compute_identity_proof(nonce, ip, port)
except Exception:
return False
return _hmac.compare_digest(proof, expected)
def _studio_token() -> Optional[str]:
"""Self-issue a JWT: the CLI runs as the same OS user as the server, so it
signs with the same stored secret the server validates against."""
try:
import studio.backend.core # noqa: F401 puts studio/backend on sys.path
from studio.backend.auth import storage
from studio.backend.auth.authentication import create_access_token
row = storage.get_connection().execute("SELECT username FROM auth_user LIMIT 1").fetchone()
return create_access_token(row[0], desktop = True) if row else None
except Exception:
return None
class HttpChatBackend:
"""Chat against a running Unsloth server over its OpenAI-compatible API.
close() leaves the model loaded on purpose — the next session (or the
UI) starts instantly.
"""
def __init__(self, base_url: str, token: str) -> None:
self._base = base_url
self._token = token
def _request(
self,
method: str,
path: str,
payload = None,
timeout = None,
):
import json
import urllib.request
request = urllib.request.Request(
self._base + path,
data = None if payload is None else json.dumps(payload).encode(),
headers = {
"Authorization": f"Bearer {self._token}",
"Content-Type": "application/json",
"User-Agent": _USER_AGENT,
},
method = method,
)
# No redirects: this carries a bearer token (see urlopen_no_redirect).
return urlopen_no_redirect(request, timeout = timeout)
def ensure_loaded(
self,
model: str,
*,
hf_token,
max_seq_length,
load_in_4bit,
tensor_parallel: bool = False,
speculative_type: Optional[SpeculativeType] = None,
spec_draft_n_max: Optional[int] = None,
llama_extra_args: Optional[List[str]] = None,
) -> None:
typer.echo(f"Loading {model} on the Unsloth server", err = True)
payload = {
"model_path": model,
"hf_token": hf_token,
"max_seq_length": max_seq_length,
"load_in_4bit": load_in_4bit,
"tensor_parallel": tensor_parallel,
}
if llama_extra_args:
payload["llama_extra_args"] = llama_extra_args
if speculative_type is not None:
payload["speculative_type"] = speculative_type
if spec_draft_n_max is not None:
payload["spec_draft_n_max"] = spec_draft_n_max
try:
# Read the body, don't close at the headers: a slow load commits its 200
# early and pads until done, so closing here would generate mid-load and
# discard the only report of a late failure.
read_json_checking_deferred_error(
self._base + "/api/inference/load",
self._request("POST", "/api/inference/load", payload),
)
except Exception as exc:
typer.echo(f"Model load failed: {exc}", err = True)
raise typer.Exit(code = 1)
def stream(
self,
messages: list,
*,
system_prompt: str,
temperature: float,
top_p: float,
top_k: int,
max_new_tokens: int,
repetition_penalty: float,
enable_thinking: bool,
use_adapter: Optional[bool] = None,
):
import json
msgs = list(messages)
if system_prompt:
msgs = [{"role": "system", "content": system_prompt}, *msgs]
resp = self._request(
"POST",
"/v1/chat/completions",
{
"model": "default",
"messages": msgs,
"stream": True,
"temperature": temperature,
"top_p": top_p,
"top_k": top_k,
"max_tokens": max_new_tokens,
"repetition_penalty": repetition_penalty,
"enable_thinking": enable_thinking,
},
)
def cumulative():
# Accumulate SSE deltas into the full-text-so-far convention the
# stream helpers expect.
text = ""
with resp:
for raw_line in resp:
line = raw_line.decode("utf-8", "replace").strip()
if not line.startswith("data:"):
continue
data = line[len("data:") :].strip()
if data == "[DONE]":
break
try:
parsed = json.loads(data)
except ValueError:
continue
if "error" in parsed:
raise RuntimeError(
f"Server error: {parsed['error'].get('message', 'Unknown server error')}"
)
try:
delta = parsed["choices"][0]["delta"].get("content")
except (KeyError, IndexError):
continue
if not delta:
continue
text += delta
# An emoji can arrive split across two deltas as lone
# surrogate halves: hold back a trailing half, merge pairs.
visible = text
if "\ud800" <= visible[-1] <= "\udbff":
visible = visible[:-1]
yield visible.encode("utf-16", "surrogatepass").decode("utf-16", "replace")
return cumulative()
def close(self) -> None:
pass
def connect_studio_server(
model: str,
*,
hf_token,
max_seq_length,
load_in_4bit,
tensor_parallel: bool = False,
speculative_type: Optional[SpeculativeType] = None,
spec_draft_n_max: Optional[int] = None,
llama_extra_args: Optional[List[str]] = None,
):
"""Backend on a running Unsloth server, or None (caller loads locally)."""
base_url = find_studio_server()
if not base_url:
return None
# Explicit server (UNSLOTH_STUDIO_URL) we can't safely attach to -> fail loudly;
# opportunistic local discovery just falls back to a local load.
explicit = bool(os.environ.get("UNSLOTH_STUDIO_URL"))
def _refuse(reason: str):
if not explicit:
return None
typer.echo(
f"Can't attach to the Unsloth server at {base_url}: {reason} Run Unsloth "
"on this machine, or unset UNSLOTH_STUDIO_URL to load the model locally.",
err = True,
)
raise typer.Exit(code = 1)
# Only hand the self-issued JWT (signed with the local secret) to loopback: a
# remote URL is unverified and a real remote Unsloth would reject it anyway.
if not is_loopback_url(base_url):
return _refuse(
"it isn't a local Unsloth, so a self-issued token can't "
"authenticate to it and must not be sent to it."
)
# Confirm the loopback responder is really our Unsloth (not a port squatter).
if not verify_studio_identity(base_url):
return _refuse(
"its identity couldn't be verified (it may be running as a "
"different OS user, or another process took the port)."
)
token = _studio_token()
if not token:
return _refuse("couldn't self-issue an Unsloth token (is Unsloth set up here?).")
backend = HttpChatBackend(base_url, token)
backend.ensure_loaded(
model,
hf_token = hf_token,
max_seq_length = max_seq_length,
load_in_4bit = load_in_4bit,
tensor_parallel = tensor_parallel,
speculative_type = speculative_type,
spec_draft_n_max = spec_draft_n_max,
llama_extra_args = llama_extra_args,
)
return backend